feat: complete wordcloud chart with config panel

- WordcloudOptionBuilder: shape, font size range, rotation range,
  grid size, font family/weight, 3 color modes (palette/random/gradient)
- WordcloudConfig panel: full UI for all wordcloud settings
- StyleConfig: conditionally shows wordcloud/axis/legend panels
  based on chart type (wordcloud hides axes, KPI hides legend, etc.)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hailin 2026-04-05 19:57:11 -07:00
parent 278e53344f
commit 026fe11dff
3 changed files with 287 additions and 24 deletions

View File

@ -5,6 +5,51 @@ 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 {
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)
rotationStep: number;
gridSize: number; // gap between words
fontFamily: string;
fontWeight: string;
colorMode: 'palette' | 'random' | 'gradient';
}
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 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 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];
}
export function buildWordcloudOption( export function buildWordcloudOption(
chart: ChartInstance, chart: ChartInstance,
data: Record<string, any>[], data: Record<string, any>[],
@ -16,12 +61,19 @@ export function buildWordcloudOption(
const labelField = labelBinding?.columnName ?? ''; const labelField = labelBinding?.columnName ?? '';
const valueField = valueBinding?.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> = {}; const option: Record<string, any> = {};
if (style.title.visible) { // Title
if (style.title?.visible) {
option.title = { option.title = {
text: style.title.text, text: style.title.text,
left: style.title.align, left: style.title.align || 'center',
textStyle: { textStyle: {
fontSize: style.title.fontSize, fontSize: style.title.fontSize,
fontWeight: style.title.fontWeight, fontWeight: style.title.fontWeight,
@ -30,30 +82,62 @@ export function buildWordcloudOption(
}; };
} }
option.backgroundColor = style.background.color || 'transparent'; option.backgroundColor = style.background?.color || 'transparent';
option.tooltip = { show: true }; option.tooltip = {
show: true,
formatter: (params: any) => `${params.name}: ${Number(params.value).toLocaleString()}`,
};
const wordData = data.map((row, idx) => ({ // Sort by value descending for better layout
name: String(row[labelField]), const sorted = [...data].sort((a, b) => Number(b[valueField]) - Number(a[valueField]));
value: Number(row[valueField]), const maxVal = Number(sorted[0]?.[valueField]) || 1;
textStyle: {
color: style.colors[idx % style.colors.length], const wordData = sorted.map((row, idx) => {
}, const ratio = Number(row[valueField]) / maxVal;
})); let color: string;
if (wc.colorMode === 'random') {
color = randomColor();
} 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,
},
};
});
option.series = [ option.series = [
{ {
type: 'wordCloud', type: 'wordCloud',
shape: 'circle', shape: wc.shape,
left: 'center', left: 'center',
top: 'center', top: style.title?.visible ? 40 : 'center',
width: '90%', width: '85%',
height: '90%', height: style.title?.visible ? '80%' : '90%',
sizeRange: [14, 60], sizeRange: [wc.sizeMin, wc.sizeMax],
rotationRange: [-45, 45], rotationRange: [wc.rotationMin, wc.rotationMax],
rotationStep: 15, rotationStep: wc.rotationStep,
gridSize: 8, gridSize: wc.gridSize,
drawOutOfBound: false, 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, data: wordData,
}, },
]; ];

View File

@ -10,6 +10,7 @@ import AxisConfig from './AxisConfig';
import LabelConfig from './LabelConfig'; import LabelConfig from './LabelConfig';
import SizeConfig from './SizeConfig'; import SizeConfig from './SizeConfig';
import InteractionConfig from './InteractionConfig'; import InteractionConfig from './InteractionConfig';
import WordcloudConfig from './WordcloudConfig';
export default function StyleConfigPanel() { export default function StyleConfigPanel() {
const { chart } = useChartConfig(); const { chart } = useChartConfig();
@ -18,13 +19,25 @@ export default function StyleConfigPanel() {
return <Empty description="请先选择一个图表" />; return <Empty description="请先选择一个图表" />;
} }
const isWordcloud = chart.type === 'wordcloud';
const isKPI = chart.type === 'kpi';
const isPie = chart.type === 'pie' || chart.type === 'donut';
const isTable = chart.type === 'data-table';
const items = [ const items = [
{ key: 'title', label: '标题', children: <TitleConfig /> }, { key: 'title', label: '标题', children: <TitleConfig /> },
{ key: 'colors', label: '颜色', children: <ColorConfig /> }, { key: 'colors', label: '颜色', children: <ColorConfig /> },
{ key: 'legend', label: '图例', children: <LegendConfig /> }, // Wordcloud-specific settings
{ key: 'xAxis', label: 'X轴', children: <AxisConfig axis="x" /> }, ...(isWordcloud ? [{ key: 'wordcloud', label: '词云设置', children: <WordcloudConfig /> }] : []),
{ key: 'yAxis', label: 'Y轴', children: <AxisConfig axis="y" /> }, // Legend not for KPI/table
{ key: 'dataLabel', label: '数据标签', children: <LabelConfig /> }, ...(!isKPI && !isTable ? [{ key: 'legend', label: '图例', children: <LegendConfig /> }] : []),
// Axes only for cartesian charts
...(!isWordcloud && !isPie && !isKPI && !isTable ? [
{ key: 'xAxis', label: 'X轴', children: <AxisConfig axis="x" /> },
{ key: 'yAxis', label: 'Y轴', children: <AxisConfig axis="y" /> },
] : []),
// Data labels not for KPI/wordcloud
...(!isKPI && !isWordcloud ? [{ key: 'dataLabel', label: '数据标签', children: <LabelConfig /> }] : []),
{ key: 'background', label: '背景', children: <SizeConfig /> }, { key: 'background', label: '背景', children: <SizeConfig /> },
{ key: 'animation', label: '动画', children: <InteractionConfig /> }, { key: 'animation', label: '动画', children: <InteractionConfig /> },
]; ];
@ -34,7 +47,7 @@ export default function StyleConfigPanel() {
accordion accordion
size="small" size="small"
items={items} items={items}
defaultActiveKey={['title']} defaultActiveKey={isWordcloud ? ['wordcloud'] : ['title']}
style={{ background: 'transparent' }} style={{ background: 'transparent' }}
/> />
); );

View File

@ -0,0 +1,166 @@
'use client';
import React, { useCallback } from 'react';
import { Select, Slider, InputNumber, Space, Typography, Radio } from 'antd';
import { useChartConfig } from '@/frameworks/hooks/useChartConfig';
const { Text } = Typography;
const SHAPES = [
{ label: '圆形', value: 'circle' },
{ label: '心形', value: 'cardioid' },
{ label: '菱形', value: 'diamond' },
{ label: '三角形', value: 'triangle' },
{ label: '五角星', value: 'star' },
{ label: '五边形', value: 'pentagon' },
];
const FONTS = [
{ label: '默认 (sans-serif)', value: 'sans-serif' },
{ label: '宋体', value: 'SimSun, serif' },
{ label: '微软雅黑', value: 'Microsoft YaHei, sans-serif' },
{ label: '等宽', value: 'monospace' },
{ label: 'Impact', value: 'Impact, sans-serif' },
];
const WEIGHTS = [
{ label: '正常', value: 'normal' },
{ label: '粗体', value: 'bold' },
{ label: '更粗', value: '900' },
];
export default function WordcloudConfig() {
const { chart, setStyle } = useChartConfig();
const wc = (chart?.style as any)?.wordcloud ?? {};
const config = {
shape: wc.shape ?? 'circle',
sizeMin: wc.sizeMin ?? 14,
sizeMax: wc.sizeMax ?? 80,
rotationMin: wc.rotationMin ?? -45,
rotationMax: wc.rotationMax ?? 45,
rotationStep: wc.rotationStep ?? 15,
gridSize: wc.gridSize ?? 8,
fontFamily: wc.fontFamily ?? 'sans-serif',
fontWeight: wc.fontWeight ?? 'bold',
colorMode: wc.colorMode ?? 'palette',
};
const update = useCallback(
(partial: Record<string, any>) => {
setStyle({ wordcloud: { ...config, ...partial } } as any);
},
[config, setStyle],
);
if (chart?.type !== 'wordcloud') return null;
return (
<Space direction="vertical" style={{ width: '100%' }} size={12}>
{/* Shape */}
<div>
<Text strong style={{ fontSize: 12 }}></Text>
<Select
value={config.shape}
onChange={(val) => update({ shape: val })}
options={SHAPES}
size="small"
style={{ width: '100%', marginTop: 4 }}
/>
</div>
{/* Font Size Range */}
<div>
<Text strong style={{ fontSize: 12 }}></Text>
<div style={{ display: 'flex', gap: 8, marginTop: 4, alignItems: 'center' }}>
<InputNumber
value={config.sizeMin}
min={8}
max={config.sizeMax - 1}
onChange={(val) => val !== null && update({ sizeMin: val })}
size="small"
style={{ width: 70 }}
/>
<Text style={{ fontSize: 12 }}></Text>
<InputNumber
value={config.sizeMax}
min={config.sizeMin + 1}
max={200}
onChange={(val) => val !== null && update({ sizeMax: val })}
size="small"
style={{ width: 70 }}
/>
<Text style={{ fontSize: 12, color: '#999' }}>px</Text>
</div>
</div>
{/* Rotation Range */}
<div>
<Text strong style={{ fontSize: 12 }}>: [{config.rotationMin}°, {config.rotationMax}°]</Text>
<Slider
range
min={-90}
max={90}
value={[config.rotationMin, config.rotationMax]}
onChange={([min, max]) => update({ rotationMin: min, rotationMax: max })}
style={{ marginTop: 4 }}
/>
</div>
{/* Grid Size (word gap) */}
<div>
<Text strong style={{ fontSize: 12 }}>: {config.gridSize}px</Text>
<Slider
min={2}
max={30}
value={config.gridSize}
onChange={(val) => update({ gridSize: val })}
style={{ marginTop: 4 }}
/>
</div>
{/* Font Family */}
<div>
<Text strong style={{ fontSize: 12 }}></Text>
<Select
value={config.fontFamily}
onChange={(val) => update({ fontFamily: val })}
options={FONTS}
size="small"
style={{ width: '100%', marginTop: 4 }}
/>
</div>
{/* Font Weight */}
<div>
<Text strong style={{ fontSize: 12 }}></Text>
<Select
value={config.fontWeight}
onChange={(val) => update({ fontWeight: val })}
options={WEIGHTS}
size="small"
style={{ width: '100%', marginTop: 4 }}
/>
</div>
{/* Color Mode */}
<div>
<Text strong style={{ fontSize: 12 }}></Text>
<div style={{ marginTop: 4 }}>
<Radio.Group
value={config.colorMode}
onChange={(e) => update({ colorMode: e.target.value })}
size="small"
>
<Radio.Button value="palette"></Radio.Button>
<Radio.Button value="random"></Radio.Button>
<Radio.Button value="gradient"></Radio.Button>
</Radio.Group>
</div>
<Text style={{ fontSize: 11, color: '#999', marginTop: 2, display: 'block' }}>
{config.colorMode === 'palette' ? '使用颜色面板中的预设色板' : config.colorMode === 'random' ? '每个词随机颜色' : '按词频从高到低渐变'}
</Text>
</div>
</Space>
);
}