import { type ChartInstance } from '@/domain/entities/ChartInstance'; import { type FieldBinding } from '@/domain/entities/FieldBinding'; function getBinding(bindings: FieldBinding[], axis: string): FieldBinding | undefined { return bindings.find((b) => b.axis === axis); } interface WordcloudConfig { shape: string; sizeMin: number; sizeMax: number; rotationMin: number; rotationMax: number; rotationStep: number; gridSize: number; fontFamily: string; fontWeight: string; colorMode: 'palette' | 'random' | 'gradient'; customImageUrl?: string; } const DEFAULT_WORDCLOUD: WordcloudConfig = { shape: 'circle', sizeMin: 14, sizeMax: 80, rotationMin: -45, rotationMax: 45, rotationStep: 15, gridSize: 8, fontFamily: 'sans-serif', fontWeight: 'bold', colorMode: 'palette', }; function getWordcloudConfig(style: any): WordcloudConfig { const wc = style?.wordcloud ?? {}; return { ...DEFAULT_WORDCLOUD, ...wc }; } function randomHSL(): string { const h = Math.floor(Math.random() * 360); const s = 50 + Math.floor(Math.random() * 40); const l = 35 + Math.floor(Math.random() * 30); return `hsl(${h},${s}%,${l}%)`; } function gradientColor(ratio: number, colors: string[]): string { if (colors.length < 2) return colors[0] || '#5470c6'; return colors[Math.round(ratio * (colors.length - 1))] || colors[0]; } /** * Create a canvas maskImage for the given shape. * White = excluded, Black = where words can be placed. */ function createShapeMask(shape: string, w: number, h: number): HTMLCanvasElement | null { if (typeof document === 'undefined') return null; const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); if (!ctx) return null; // Fill white (excluded area) ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, w, h); // Draw black shape (word area) — stretch to fill container ctx.fillStyle = '#000'; const cx = w / 2; const cy = h / 2; const rx = w * 0.48; // horizontal radius const ry = h * 0.48; // vertical radius switch (shape) { case 'circle': { ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); ctx.fill(); break; } case 'cardioid': { ctx.beginPath(); for (let angle = 0; angle < Math.PI * 2; angle += 0.01) { const scale = 0.85 * (1 - Math.sin(angle)); const x = cx + rx * scale * Math.cos(angle); const y = cy - ry * scale * Math.sin(angle) + ry * 0.3; if (angle === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.closePath(); ctx.fill(); break; } case 'diamond': { ctx.beginPath(); ctx.moveTo(cx, cy - ry); ctx.lineTo(cx + rx, cy); ctx.lineTo(cx, cy + ry); ctx.lineTo(cx - rx, cy); ctx.closePath(); ctx.fill(); break; } case 'triangle': { ctx.beginPath(); ctx.moveTo(cx, cy - ry); ctx.lineTo(cx + rx, cy + ry); ctx.lineTo(cx - rx, cy + ry); ctx.closePath(); ctx.fill(); break; } case 'star': { ctx.beginPath(); for (let i = 0; i < 10; i++) { const angle = (Math.PI / 2) + (i * Math.PI / 5); const outerX = i % 2 === 0 ? rx : rx * 0.4; const outerY = i % 2 === 0 ? ry : ry * 0.4; const x = cx + outerX * Math.cos(angle); const y = cy - outerY * Math.sin(angle); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.closePath(); ctx.fill(); break; } case 'pentagon': { ctx.beginPath(); for (let i = 0; i < 5; i++) { const angle = (Math.PI / 2) + (i * 2 * Math.PI / 5); const x = cx + rx * Math.cos(angle); const y = cy - ry * Math.sin(angle); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.closePath(); ctx.fill(); break; } default: ctx.fillRect(0, 0, w, h); break; } return canvas; } // Cache mask images keyed by "shape-WxH" const maskCache = new Map(); function getMask(shape: string, w = 1024, h = 700): HTMLCanvasElement | null { if (shape === 'rectangle') return null; const key = `${shape}-${w}x${h}`; const cached = maskCache.get(key); if (cached) return cached; const mask = createShapeMask(shape, w, h); if (mask) maskCache.set(key, mask); return mask; } export function buildWordcloudOption( chart: ChartInstance, data: Record[], ): Record { const { bindings, style } = chart; const labelBinding = getBinding(bindings, 'label'); const valueBinding = getBinding(bindings, 'value'); const labelField = labelBinding?.columnName ?? ''; const valueField = valueBinding?.columnName ?? ''; if (!labelField || !valueField || !data.length) { return { title: { text: '请绑定 标签 和 值 字段', left: 'center', top: 'center', textStyle: { color: '#999', fontSize: 14 } } }; } const wc = getWordcloudConfig(style); const option: Record = {}; if (style.title?.visible) { option.title = { text: style.title.text, left: style.title.align || 'center', textStyle: { fontSize: style.title.fontSize, fontWeight: style.title.fontWeight, color: style.title.color, }, }; } option.backgroundColor = style.background?.color || 'transparent'; option.tooltip = { show: true, formatter: (params: any) => `${params.name}: ${Number(params.value).toLocaleString()}`, }; const sorted = [...data].sort((a, b) => Number(b[valueField]) - Number(a[valueField])); const maxVal = Number(sorted[0]?.[valueField]) || 1; const wordData = sorted.map((row, idx) => { const ratio = Number(row[valueField]) / maxVal; let color: string; if (wc.colorMode === 'random') { color = randomHSL(); } else if (wc.colorMode === 'gradient') { color = gradientColor(ratio, style.colors || ['#5470c6', '#91cc75', '#fac858', '#ee6666']); } else { color = (style.colors || [])[idx % (style.colors?.length || 8)] || '#5470c6'; } return { name: String(row[labelField]), value: Number(row[valueField]), textStyle: { color, fontFamily: wc.fontFamily }, }; }); // Build mask image for shape const maskImage = getMask(wc.shape); const series: Record = { type: 'wordCloud', left: 'center', top: style.title?.visible ? 35 : 'center', width: '95%', height: style.title?.visible ? '85%' : '95%', sizeRange: [wc.sizeMin, wc.sizeMax], rotationRange: [wc.rotationMin, wc.rotationMax], rotationStep: wc.rotationStep || 1, gridSize: wc.gridSize, shrinkToFit: true, drawOutOfBound: false, layoutAnimation: true, keepAspect: false, textStyle: { fontFamily: wc.fontFamily, fontWeight: wc.fontWeight, }, emphasis: { focus: 'self', textStyle: { textShadowBlur: 10, textShadowColor: 'rgba(0,0,0,0.3)', }, }, data: wordData, }; // Apply mask for non-default shapes if (maskImage) { series.maskImage = maskImage; series.shape = 'circle'; // base shape, mask overrides it } else { series.shape = 'circle'; } option.series = [series]; return option; }