You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
139 lines
4.3 KiB
139 lines
4.3 KiB
/**
|
|
* 行业技能词云(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<SkillWord['group'], string> = {
|
|
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 (
|
|
<Svg width="100%" height={height} viewBox={`0 0 ${CANVAS_W} ${height}`}>
|
|
{placed.map(({ word, cx, cy, fontSize, box }) => {
|
|
const isSelected = word.id === selectedId;
|
|
const fill = isSelected ? colors.successText : GROUP_COLOR[word.group];
|
|
return (
|
|
<G key={word.id} onPress={onSelect ? () => onSelect(word) : undefined}>
|
|
{isSelected && (
|
|
<Rect
|
|
x={box.x}
|
|
y={box.y}
|
|
width={box.w}
|
|
height={box.h}
|
|
rx={box.h / 2}
|
|
ry={box.h / 2}
|
|
fill={colors.successLight}
|
|
stroke={colors.successBorder}
|
|
strokeWidth={1}
|
|
/>
|
|
)}
|
|
<Text
|
|
x={cx}
|
|
y={cy + fontSize * 0.35}
|
|
fontSize={fontSize}
|
|
fontWeight={isSelected ? '700' : '500'}
|
|
fill={fill}
|
|
textAnchor="middle"
|
|
>
|
|
{word.text}
|
|
</Text>
|
|
</G>
|
|
);
|
|
})}
|
|
</Svg>
|
|
);
|
|
}
|
|
|