轻松聘
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

/**
* 行业技能词云(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>
);
}