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:
parent
278e53344f
commit
026fe11dff
|
|
@ -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
|
||||||
|
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 = 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]),
|
name: String(row[labelField]),
|
||||||
value: Number(row[valueField]),
|
value: Number(row[valueField]),
|
||||||
textStyle: {
|
textStyle: {
|
||||||
color: style.colors[idx % style.colors.length],
|
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,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
...(isWordcloud ? [{ key: 'wordcloud', label: '词云设置', children: <WordcloudConfig /> }] : []),
|
||||||
|
// Legend not for KPI/table
|
||||||
|
...(!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: 'xAxis', label: 'X轴', children: <AxisConfig axis="x" /> },
|
||||||
{ key: 'yAxis', label: 'Y轴', children: <AxisConfig axis="y" /> },
|
{ key: 'yAxis', label: 'Y轴', children: <AxisConfig axis="y" /> },
|
||||||
{ key: 'dataLabel', label: '数据标签', children: <LabelConfig /> },
|
] : []),
|
||||||
|
// 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' }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue