dv/frontend/src/adapters/gateways/EChartsOptionBuilder/wordcloudOptionBuilder.ts

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;
}