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:
parent
e515aed960
commit
1a0abaa453
|
|
@ -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,27 +217,26 @@ export function buildWordcloudOption(
|
|||
return {
|
||||
name: String(row[labelField]),
|
||||
value: Number(row[valueField]),
|
||||
textStyle: {
|
||||
color,
|
||||
fontFamily: wc.fontFamily,
|
||||
},
|
||||
textStyle: { color, fontFamily: wc.fontFamily },
|
||||
};
|
||||
});
|
||||
|
||||
option.series = [
|
||||
{
|
||||
// Build mask image for shape
|
||||
const maskImage = getMask(wc.shape);
|
||||
|
||||
const series: Record<string, any> = {
|
||||
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,
|
||||
rotationStep: wc.rotationStep || 1,
|
||||
gridSize: wc.gridSize,
|
||||
drawOutOfBound: false,
|
||||
layoutAnimation: true,
|
||||
keepAspect: true,
|
||||
textStyle: {
|
||||
fontFamily: wc.fontFamily,
|
||||
fontWeight: wc.fontWeight,
|
||||
|
|
@ -139,8 +249,16 @@ export function buildWordcloudOption(
|
|||
},
|
||||
},
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
Loading…
Reference in New Issue