From 1a0abaa45319713d6ac3685e2eef3ce2fd8554e6 Mon Sep 17 00:00:00 2001 From: hailin Date: Sun, 5 Apr 2026 21:19:37 -0700 Subject: [PATCH] feat: wordcloud shapes via canvas maskImage Shape presets (circle/heart/diamond/triangle/star/pentagon) now render correctly using dynamically generated canvas mask images. Added rectangle option. Mask images are cached for performance. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../wordcloudOptionBuilder.ts | 218 ++++++++++++++---- .../configPanel/WordcloudConfig.tsx | 1 + 2 files changed, 169 insertions(+), 50 deletions(-) diff --git a/frontend/src/adapters/gateways/EChartsOptionBuilder/wordcloudOptionBuilder.ts b/frontend/src/adapters/gateways/EChartsOptionBuilder/wordcloudOptionBuilder.ts index adb3029..a951054 100644 --- a/frontend/src/adapters/gateways/EChartsOptionBuilder/wordcloudOptionBuilder.ts +++ b/frontend/src/adapters/gateways/EChartsOptionBuilder/wordcloudOptionBuilder.ts @@ -5,18 +5,18 @@ function getBinding(bindings: FieldBinding[], axis: string): FieldBinding | unde return bindings.find((b) => b.axis === axis); } -// Wordcloud-specific config stored in style.wordcloud (custom extension) interface WordcloudConfig { - shape: string; // circle, cardioid, diamond, triangle, star, pentagon - sizeMin: number; // min font size - sizeMax: number; // max font size - rotationMin: number; // rotation range min (degrees) - rotationMax: number; // rotation range max (degrees) + shape: string; + sizeMin: number; + sizeMax: number; + rotationMin: number; + rotationMax: number; rotationStep: number; - gridSize: number; // gap between words + gridSize: number; fontFamily: string; fontWeight: string; colorMode: 'palette' | 'random' | 'gradient'; + customImageUrl?: string; } const DEFAULT_WORDCLOUD: WordcloudConfig = { @@ -37,17 +37,131 @@ function getWordcloudConfig(style: any): WordcloudConfig { return { ...DEFAULT_WORDCLOUD, ...wc }; } -function randomColor(): string { - const hue = Math.floor(Math.random() * 360); - const sat = 50 + Math.floor(Math.random() * 40); - const light = 35 + Math.floor(Math.random() * 30); - return `hsl(${hue}, ${sat}%, ${light}%)`; +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'; - const idx = ratio * (colors.length - 1); - return colors[Math.round(idx)] || colors[0]; + 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, size: number): HTMLCanvasElement | null { + if (typeof document === 'undefined') return null; + + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + + // Fill white (excluded area) + ctx.fillStyle = '#fff'; + ctx.fillRect(0, 0, size, size); + + // Draw black shape (word area) + ctx.fillStyle = '#000'; + const cx = size / 2; + const cy = size / 2; + const r = size * 0.45; + + switch (shape) { + case 'circle': + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.fill(); + break; + + case 'cardioid': { + ctx.beginPath(); + for (let angle = 0; angle < Math.PI * 2; angle += 0.01) { + const rr = r * 0.5 * (1 - Math.sin(angle)); + const x = cx + rr * Math.cos(angle); + const y = cy - rr * Math.sin(angle) + r * 0.15; + 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 - r); + ctx.lineTo(cx + r, cy); + ctx.lineTo(cx, cy + r); + ctx.lineTo(cx - r, cy); + ctx.closePath(); + ctx.fill(); + break; + } + + case 'triangle': { + ctx.beginPath(); + ctx.moveTo(cx, cy - r); + ctx.lineTo(cx + r * 0.87, cy + r * 0.5); + ctx.lineTo(cx - r * 0.87, cy + r * 0.5); + 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 rr = i % 2 === 0 ? r : r * 0.4; + const x = cx + rr * Math.cos(angle); + const y = cy - rr * 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 + r * Math.cos(angle); + const y = cy - r * Math.sin(angle); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.closePath(); + ctx.fill(); + break; + } + + default: + // Fallback: fill entire canvas (rectangle) + ctx.fillRect(0, 0, size, size); + break; + } + + return canvas; +} + +// Cache mask images to avoid re-creating on every render +const maskCache = new Map(); + +function getMask(shape: string): HTMLCanvasElement | null { + if (shape === 'rectangle') return null; + const cached = maskCache.get(shape); + if (cached) return cached; + const mask = createShapeMask(shape, 512); + if (mask) maskCache.set(shape, mask); + return mask; } export function buildWordcloudOption( @@ -66,10 +180,8 @@ export function buildWordcloudOption( } const wc = getWordcloudConfig(style); - const option: Record = {}; - // Title if (style.title?.visible) { option.title = { text: style.title.text, @@ -88,7 +200,6 @@ export function buildWordcloudOption( formatter: (params: any) => `${params.name}: ${Number(params.value).toLocaleString()}`, }; - // Sort by value descending for better layout const sorted = [...data].sort((a, b) => Number(b[valueField]) - Number(a[valueField])); const maxVal = Number(sorted[0]?.[valueField]) || 1; @@ -96,7 +207,7 @@ export function buildWordcloudOption( const ratio = Number(row[valueField]) / maxVal; let color: string; if (wc.colorMode === 'random') { - color = randomColor(); + color = randomHSL(); } else if (wc.colorMode === 'gradient') { color = gradientColor(ratio, style.colors || ['#5470c6', '#91cc75', '#fac858', '#ee6666']); } else { @@ -106,41 +217,48 @@ export function buildWordcloudOption( return { name: String(row[labelField]), value: Number(row[valueField]), - textStyle: { - color, - fontFamily: wc.fontFamily, - }, + textStyle: { color, fontFamily: wc.fontFamily }, }; }); - option.series = [ - { - type: 'wordCloud', - shape: wc.shape, - left: 'center', - top: style.title?.visible ? 40 : 'center', - width: '85%', - height: style.title?.visible ? '80%' : '90%', - sizeRange: [wc.sizeMin, wc.sizeMax], - rotationRange: [wc.rotationMin, wc.rotationMax], - rotationStep: wc.rotationStep, - gridSize: wc.gridSize, - drawOutOfBound: false, - layoutAnimation: true, - textStyle: { - fontFamily: wc.fontFamily, - fontWeight: wc.fontWeight, - }, - emphasis: { - focus: 'self', - textStyle: { - textShadowBlur: 10, - textShadowColor: 'rgba(0,0,0,0.3)', - }, - }, - data: wordData, - }, - ]; + // Build mask image for shape + const maskImage = getMask(wc.shape); + const series: Record = { + type: 'wordCloud', + left: 'center', + top: style.title?.visible ? 40 : 'center', + width: '85%', + height: style.title?.visible ? '80%' : '90%', + sizeRange: [wc.sizeMin, wc.sizeMax], + rotationRange: [wc.rotationMin, wc.rotationMax], + rotationStep: wc.rotationStep || 1, + gridSize: wc.gridSize, + drawOutOfBound: false, + layoutAnimation: true, + keepAspect: true, + 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; } diff --git a/frontend/src/frameworks/components/configPanel/WordcloudConfig.tsx b/frontend/src/frameworks/components/configPanel/WordcloudConfig.tsx index 75e2c06..203d5c4 100644 --- a/frontend/src/frameworks/components/configPanel/WordcloudConfig.tsx +++ b/frontend/src/frameworks/components/configPanel/WordcloudConfig.tsx @@ -7,6 +7,7 @@ import { useChartConfig } from '@/frameworks/hooks/useChartConfig'; const { Text } = Typography; const SHAPES = [ + { label: '矩形', value: 'rectangle' }, { label: '圆形', value: 'circle' }, { label: '心形', value: 'cardioid' }, { label: '菱形', value: 'diamond' },