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);
|
return bindings.find((b) => b.axis === axis);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wordcloud-specific config stored in style.wordcloud (custom extension)
|
|
||||||
interface WordcloudConfig {
|
interface WordcloudConfig {
|
||||||
shape: string; // circle, cardioid, diamond, triangle, star, pentagon
|
shape: string;
|
||||||
sizeMin: number; // min font size
|
sizeMin: number;
|
||||||
sizeMax: number; // max font size
|
sizeMax: number;
|
||||||
rotationMin: number; // rotation range min (degrees)
|
rotationMin: number;
|
||||||
rotationMax: number; // rotation range max (degrees)
|
rotationMax: number;
|
||||||
rotationStep: number;
|
rotationStep: number;
|
||||||
gridSize: number; // gap between words
|
gridSize: number;
|
||||||
fontFamily: string;
|
fontFamily: string;
|
||||||
fontWeight: string;
|
fontWeight: string;
|
||||||
colorMode: 'palette' | 'random' | 'gradient';
|
colorMode: 'palette' | 'random' | 'gradient';
|
||||||
|
customImageUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_WORDCLOUD: WordcloudConfig = {
|
const DEFAULT_WORDCLOUD: WordcloudConfig = {
|
||||||
|
|
@ -37,17 +37,131 @@ function getWordcloudConfig(style: any): WordcloudConfig {
|
||||||
return { ...DEFAULT_WORDCLOUD, ...wc };
|
return { ...DEFAULT_WORDCLOUD, ...wc };
|
||||||
}
|
}
|
||||||
|
|
||||||
function randomColor(): string {
|
function randomHSL(): string {
|
||||||
const hue = Math.floor(Math.random() * 360);
|
const h = Math.floor(Math.random() * 360);
|
||||||
const sat = 50 + Math.floor(Math.random() * 40);
|
const s = 50 + Math.floor(Math.random() * 40);
|
||||||
const light = 35 + Math.floor(Math.random() * 30);
|
const l = 35 + Math.floor(Math.random() * 30);
|
||||||
return `hsl(${hue}, ${sat}%, ${light}%)`;
|
return `hsl(${h},${s}%,${l}%)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function gradientColor(ratio: number, colors: string[]): string {
|
function gradientColor(ratio: number, colors: string[]): string {
|
||||||
if (colors.length < 2) return colors[0] || '#5470c6';
|
if (colors.length < 2) return colors[0] || '#5470c6';
|
||||||
const idx = ratio * (colors.length - 1);
|
return colors[Math.round(ratio * (colors.length - 1))] || colors[0];
|
||||||
return colors[Math.round(idx)] || 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(
|
export function buildWordcloudOption(
|
||||||
|
|
@ -66,10 +180,8 @@ export function buildWordcloudOption(
|
||||||
}
|
}
|
||||||
|
|
||||||
const wc = getWordcloudConfig(style);
|
const wc = getWordcloudConfig(style);
|
||||||
|
|
||||||
const option: Record<string, any> = {};
|
const option: Record<string, any> = {};
|
||||||
|
|
||||||
// Title
|
|
||||||
if (style.title?.visible) {
|
if (style.title?.visible) {
|
||||||
option.title = {
|
option.title = {
|
||||||
text: style.title.text,
|
text: style.title.text,
|
||||||
|
|
@ -88,7 +200,6 @@ export function buildWordcloudOption(
|
||||||
formatter: (params: any) => `${params.name}: ${Number(params.value).toLocaleString()}`,
|
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 sorted = [...data].sort((a, b) => Number(b[valueField]) - Number(a[valueField]));
|
||||||
const maxVal = Number(sorted[0]?.[valueField]) || 1;
|
const maxVal = Number(sorted[0]?.[valueField]) || 1;
|
||||||
|
|
||||||
|
|
@ -96,7 +207,7 @@ export function buildWordcloudOption(
|
||||||
const ratio = Number(row[valueField]) / maxVal;
|
const ratio = Number(row[valueField]) / maxVal;
|
||||||
let color: string;
|
let color: string;
|
||||||
if (wc.colorMode === 'random') {
|
if (wc.colorMode === 'random') {
|
||||||
color = randomColor();
|
color = randomHSL();
|
||||||
} else if (wc.colorMode === 'gradient') {
|
} else if (wc.colorMode === 'gradient') {
|
||||||
color = gradientColor(ratio, style.colors || ['#5470c6', '#91cc75', '#fac858', '#ee6666']);
|
color = gradientColor(ratio, style.colors || ['#5470c6', '#91cc75', '#fac858', '#ee6666']);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -106,41 +217,48 @@ export function buildWordcloudOption(
|
||||||
return {
|
return {
|
||||||
name: String(row[labelField]),
|
name: String(row[labelField]),
|
||||||
value: Number(row[valueField]),
|
value: Number(row[valueField]),
|
||||||
textStyle: {
|
textStyle: { color, fontFamily: wc.fontFamily },
|
||||||
color,
|
|
||||||
fontFamily: wc.fontFamily,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
option.series = [
|
// Build mask image for shape
|
||||||
{
|
const maskImage = getMask(wc.shape);
|
||||||
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,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
|
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;
|
return option;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { useChartConfig } from '@/frameworks/hooks/useChartConfig';
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
const SHAPES = [
|
const SHAPES = [
|
||||||
|
{ label: '矩形', value: 'rectangle' },
|
||||||
{ label: '圆形', value: 'circle' },
|
{ label: '圆形', value: 'circle' },
|
||||||
{ label: '心形', value: 'cardioid' },
|
{ label: '心形', value: 'cardioid' },
|
||||||
{ label: '菱形', value: 'diamond' },
|
{ label: '菱形', value: 'diamond' },
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue