269 lines
7.2 KiB
TypeScript
269 lines
7.2 KiB
TypeScript
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<string, HTMLCanvasElement>();
|
|
|
|
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<string, any>[],
|
|
): Record<string, any> {
|
|
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<string, any> = {};
|
|
|
|
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<string, any> = {
|
|
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;
|
|
}
|