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) <noreply@anthropic.com>
This commit is contained in:
hailin 2026-04-05 21:19:37 -07:00
parent e515aed960
commit 1a0abaa453
2 changed files with 169 additions and 50 deletions

View File

@ -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<string, HTMLCanvasElement>();
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<string, any> = {};
// 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<string, any> = {
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;
}

View File

@ -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' },