/** * 行业技能词云(SVG):基于 react-native-svg 的螺旋布局词云。 * * - 字号按权重线性映射(12~34pt),权重越高越醒目 * - 颜色按技能分组区分:技术=主蓝 / 软技能=信息紫 / 领域=成功绿 / 工具=次要灰 * - 选中态:成功色浅底圆角胶囊 + 成功文字(来自 theme.success*) * - 布局为纯几何近似(按字符数估算宽度),不依赖原生文本测量,跨端一致 * * 性能:布局仅在 words 变化时通过 useMemo 计算一次;点击仅切换 selectedId,不重排。 */ import { useMemo } from 'react'; import Svg from 'react-native-svg'; import { G, Rect, Text } from 'react-native-svg'; import { colors } from '../src/theme'; import type { SkillWord } from '../src/types'; /** SVG 画布逻辑宽度(viewBox 宽度,配合 width="100%" 自适应) */ const CANVAS_W = 320; const PAD = 6; /** 技能分组 → 颜色(全部走主题令牌,禁止写死十六进制) */ const GROUP_COLOR: Record = { tech: colors.primary, soft: colors.info, domain: colors.success, tool: colors.textSecondary, }; interface PlacedWord { word: SkillWord; cx: number; cy: number; fontSize: number; box: { x: number; y: number; w: number; h: number }; } /** 矩形是否相交(用于布局去重叠) */ function intersects(a: PlacedWord['box'], b: PlacedWord['box']): boolean { return !( a.x + a.w <= b.x || b.x + b.w <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y ); } /** 螺旋布局:大词优先,沿阿基米德螺线寻找无重叠位置 */ function layoutWords(words: SkillWord[]): { placed: PlacedWord[]; height: number } { const sorted = [...words].sort((a, b) => b.weight - a.weight); const placed: PlacedWord[] = []; let maxY = 0; for (const word of sorted) { const fontSize = 12 + (word.weight / 100) * 22; const textW = word.text.length * fontSize * 0.62; const textH = fontSize; const boxW = textW + PAD * 2; const boxH = textH + PAD * 2; const cx0 = CANVAS_W / 2; const cy0 = 120; let chosen: PlacedWord['box'] | null = null; for (let t = 0; t < 600; t++) { const angle = 0.35 * t; const radius = 4 + 6 * angle; const x = cx0 + radius * Math.cos(angle) - boxW / 2; const y = cy0 + radius * Math.sin(angle) - boxH / 2; const box = { x, y, w: boxW, h: boxH }; const inBounds = x >= 0 && y >= 0 && x + boxW <= CANVAS_W; if (inBounds && !placed.some((p) => intersects(box, p.box))) { chosen = box; break; } } if (!chosen) { chosen = { x: cx0 - boxW / 2, y: cy0 - boxH / 2, w: boxW, h: boxH }; } const cx = chosen.x + boxW / 2; const cy = chosen.y + boxH / 2; placed.push({ word, cx, cy, fontSize, box: chosen }); maxY = Math.max(maxY, chosen.y + chosen.h); } return { placed, height: Math.max(200, Math.ceil(maxY + PAD)) }; } export interface IndustrySkillWordCloudProps { words: SkillWord[]; selectedId?: string | null; onSelect?: (word: SkillWord) => void; } export function IndustrySkillWordCloud({ words, selectedId, onSelect, }: IndustrySkillWordCloudProps) { const { placed, height } = useMemo(() => layoutWords(words), [words]); return ( {placed.map(({ word, cx, cy, fontSize, box }) => { const isSelected = word.id === selectedId; const fill = isSelected ? colors.successText : GROUP_COLOR[word.group]; return ( onSelect(word) : undefined}> {isSelected && ( )} {word.text} ); })} ); }