feat: add frontend source code (Next.js + React + TypeScript)

Previously excluded due to nested .git from create-next-app.
Includes all Clean Architecture layers, API client integration,
and full UI component suite.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hailin 2026-04-04 23:33:37 -07:00
parent 6e3127e7d6
commit 6079ec8b97
163 changed files with 17535 additions and 0 deletions

5
frontend/AGENTS.md Normal file
View File

@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

1
frontend/CLAUDE.md Normal file
View File

@ -0,0 +1 @@
@AGENTS.md

11
frontend/next.config.ts Normal file
View File

@ -0,0 +1,11 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'export',
transpilePackages: ['echarts', 'echarts-wordcloud'],
images: {
unoptimized: true,
},
};
export default nextConfig;

8307
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
frontend/package.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@ant-design/icons": "^6.1.1",
"@reduxjs/toolkit": "^2.11.2",
"antd": "^6.3.5",
"echarts": "^5.6.0",
"echarts-for-react": "^3.0.6",
"echarts-wordcloud": "^2.1.0",
"html2canvas": "^1.4.1",
"jspdf": "^4.2.1",
"next": "16.2.2",
"pptxgenjs": "^4.0.1",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-grid-layout": "^2.2.3",
"react-redux": "^9.2.0",
"uuid": "^13.0.0",
"xlsx": "^0.18.5",
"zustand": "^5.0.12"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-grid-layout": "^1.3.6",
"@types/uuid": "^10.0.0",
"eslint": "^9",
"eslint-config-next": "16.2.2",
"tailwindcss": "^4",
"typescript": "^5"
}
}

1
frontend/public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
frontend/public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@ -0,0 +1,213 @@
import { type ChartInstance } from '@/domain/entities/ChartInstance';
import { type StyleConfig } from '@/domain/entities/StyleConfig';
import { type FieldBinding } from '@/domain/entities/FieldBinding';
function getBinding(bindings: FieldBinding[], axis: string): FieldBinding | undefined {
return bindings.find((b) => b.axis === axis);
}
function applyCommonStyle(option: Record<string, any>, style: StyleConfig): void {
if (style.title.visible) {
option.title = {
text: style.title.text,
left: style.title.align,
textStyle: {
fontSize: style.title.fontSize,
fontWeight: style.title.fontWeight,
color: style.title.color,
},
};
}
if (style.legend.visible) {
option.legend = {
show: true,
orient: style.legend.orient,
top: style.legend.position === 'top' ? 'top' : style.legend.position === 'bottom' ? 'bottom' : 'middle',
left: style.legend.position === 'left' ? 'left' : style.legend.position === 'right' ? 'right' : 'center',
textStyle: {
fontSize: style.legend.fontSize,
color: style.legend.color,
},
};
} else {
option.legend = { show: false };
}
option.backgroundColor = style.background.color !== '' ? style.background.color : 'transparent';
if (style.animation.enabled) {
option.animation = true;
option.animationDuration = style.animation.duration;
option.animationEasing = style.animation.easing === 'ease-in'
? 'cubicIn'
: style.animation.easing === 'ease-out'
? 'cubicOut'
: style.animation.easing === 'ease-in-out'
? 'cubicInOut'
: 'linear';
} else {
option.animation = false;
}
option.grid = {
containLabel: true,
top: 60,
right: 30,
bottom: 30,
left: 30,
};
}
function buildAxisConfig(style: StyleConfig, isHorizontal: boolean) {
const xAxisStyle = isHorizontal ? style.yAxis : style.xAxis;
const yAxisStyle = isHorizontal ? style.xAxis : style.yAxis;
const xAxis: Record<string, any> = {
show: xAxisStyle.visible,
axisLabel: {
show: xAxisStyle.labelVisible,
fontSize: xAxisStyle.labelFontSize,
color: xAxisStyle.labelColor,
rotate: xAxisStyle.labelRotation,
},
splitLine: {
show: xAxisStyle.gridVisible,
lineStyle: { color: xAxisStyle.gridColor },
},
};
if (xAxisStyle.titleVisible && xAxisStyle.titleText) {
xAxis.name = xAxisStyle.titleText;
}
const yAxis: Record<string, any> = {
show: yAxisStyle.visible,
axisLabel: {
show: yAxisStyle.labelVisible,
fontSize: yAxisStyle.labelFontSize,
color: yAxisStyle.labelColor,
rotate: yAxisStyle.labelRotation,
},
splitLine: {
show: yAxisStyle.gridVisible,
lineStyle: { color: yAxisStyle.gridColor },
},
};
if (yAxisStyle.titleVisible && yAxisStyle.titleText) {
yAxis.name = yAxisStyle.titleText;
}
return { xAxis, yAxis };
}
function buildLabelConfig(style: StyleConfig) {
if (!style.dataLabel.visible) return { show: false };
let formatter: string | undefined;
switch (style.dataLabel.format) {
case 'percent':
formatter = '{b}: {d}%';
break;
case 'custom':
formatter = style.dataLabel.template;
break;
case 'value':
default:
formatter = '{c}';
break;
}
return {
show: true,
fontSize: style.dataLabel.fontSize,
color: style.dataLabel.color,
position: style.dataLabel.position,
formatter,
};
}
export function buildBarOption(
chart: ChartInstance,
data: Record<string, any>[],
): Record<string, any> {
const { bindings, style, type } = chart;
const xBinding = getBinding(bindings, 'x');
const yBinding = getBinding(bindings, 'y');
const seriesBinding = getBinding(bindings, 'series');
const isHorizontal = type === 'horizontal-bar';
const isStacked = type === 'stacked-bar';
const isGrouped = type === 'grouped-bar';
const xField = xBinding?.columnName ?? '';
const yField = yBinding?.columnName ?? '';
const option: Record<string, any> = {};
applyCommonStyle(option, style);
const categories = [...new Set(data.map((row) => String(row[xField])))];
const { xAxis, yAxis } = buildAxisConfig(style, isHorizontal);
const label = buildLabelConfig(style);
if ((isGrouped || isStacked) && seriesBinding) {
const seriesField = seriesBinding.columnName;
const seriesNames = [...new Set(data.map((row) => String(row[seriesField])))];
const seriesList = seriesNames.map((name, idx) => {
const seriesData = categories.map((cat) => {
const row = data.find(
(r) => String(r[xField]) === cat && String(r[seriesField]) === name,
);
return row ? Number(row[yField]) : 0;
});
return {
name,
type: 'bar',
stack: isStacked ? 'total' : undefined,
data: seriesData,
itemStyle: {
color: style.colors[idx % style.colors.length],
},
label,
};
});
if (isHorizontal) {
option.yAxis = { ...xAxis, type: 'category', data: categories };
option.xAxis = { ...yAxis, type: 'value' };
} else {
option.xAxis = { ...xAxis, type: 'category', data: categories };
option.yAxis = { ...yAxis, type: 'value' };
}
option.series = seriesList;
option.tooltip = { trigger: 'axis' };
} else {
const seriesData = categories.map((cat) => {
const row = data.find((r) => String(r[xField]) === cat);
return row ? Number(row[yField]) : 0;
});
if (isHorizontal) {
option.yAxis = { ...xAxis, type: 'category', data: categories };
option.xAxis = { ...yAxis, type: 'value' };
} else {
option.xAxis = { ...xAxis, type: 'category', data: categories };
option.yAxis = { ...yAxis, type: 'value' };
}
option.series = [
{
type: 'bar',
data: seriesData,
itemStyle: {
color: style.colors[0],
},
label,
},
];
option.tooltip = { trigger: 'axis' };
}
return option;
}

View File

@ -0,0 +1,146 @@
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);
}
function getAllBindings(bindings: FieldBinding[], axis: string): FieldBinding[] {
return bindings.filter((b) => b.axis === axis);
}
export function buildComboOption(
chart: ChartInstance,
data: Record<string, any>[],
): Record<string, any> {
const { bindings, style } = chart;
const xBinding = getBinding(bindings, 'x');
const yBindings = getAllBindings(bindings, 'y');
const xField = xBinding?.columnName ?? '';
const option: Record<string, any> = {};
if (style.title.visible) {
option.title = {
text: style.title.text,
left: style.title.align,
textStyle: {
fontSize: style.title.fontSize,
fontWeight: style.title.fontWeight,
color: style.title.color,
},
};
}
if (style.legend.visible) {
option.legend = {
show: true,
orient: style.legend.orient,
top: style.legend.position === 'top' ? 'top' : style.legend.position === 'bottom' ? 'bottom' : 'middle',
left: style.legend.position === 'left' ? 'left' : style.legend.position === 'right' ? 'right' : 'center',
textStyle: {
fontSize: style.legend.fontSize,
color: style.legend.color,
},
};
} else {
option.legend = { show: false };
}
option.backgroundColor = style.background.color || 'transparent';
option.tooltip = { trigger: 'axis' };
option.grid = { containLabel: true, top: 60, right: 60, bottom: 30, left: 30 };
if (style.animation.enabled) {
option.animation = true;
option.animationDuration = style.animation.duration;
} else {
option.animation = false;
}
const categories = [...new Set(data.map((row) => String(row[xField])))];
option.xAxis = {
type: 'category',
data: categories,
show: style.xAxis.visible,
axisLabel: {
show: style.xAxis.labelVisible,
fontSize: style.xAxis.labelFontSize,
color: style.xAxis.labelColor,
rotate: style.xAxis.labelRotation,
},
};
option.yAxis = [
{
type: 'value',
show: style.yAxis.visible,
axisLabel: {
show: style.yAxis.labelVisible,
fontSize: style.yAxis.labelFontSize,
color: style.yAxis.labelColor,
},
splitLine: { show: style.yAxis.gridVisible, lineStyle: { color: style.yAxis.gridColor } },
name: style.yAxis.titleVisible ? style.yAxis.titleText : undefined,
},
{
type: 'value',
show: style.yAxis.visible,
axisLabel: {
show: style.yAxis.labelVisible,
fontSize: style.yAxis.labelFontSize,
color: style.yAxis.labelColor,
},
splitLine: { show: false },
},
];
let label: Record<string, any>;
if (style.dataLabel.visible) {
let formatter: string | undefined;
switch (style.dataLabel.format) {
case 'percent':
formatter = '{b}: {d}%';
break;
case 'custom':
formatter = style.dataLabel.template;
break;
case 'value':
default:
formatter = '{c}';
break;
}
label = {
show: true,
fontSize: style.dataLabel.fontSize,
color: style.dataLabel.color,
position: style.dataLabel.position,
formatter,
};
} else {
label = { show: false };
}
// First y binding(s) as bars on yAxisIndex 0, last y binding as line on yAxisIndex 1
option.series = yBindings.map((yBind, idx) => {
const isLast = idx === yBindings.length - 1 && yBindings.length > 1;
const seriesData = categories.map((cat) => {
const row = data.find((r) => String(r[xField]) === cat);
return row ? Number(row[yBind.columnName]) : 0;
});
return {
name: yBind.columnName,
type: isLast ? 'line' : 'bar',
yAxisIndex: isLast ? 1 : 0,
data: seriesData,
smooth: isLast,
itemStyle: { color: style.colors[idx % style.colors.length] },
label,
};
});
return option;
}

View File

@ -0,0 +1,150 @@
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);
}
export function buildHeatmapOption(
chart: ChartInstance,
data: Record<string, any>[],
): Record<string, any> {
const { bindings, style } = chart;
const xBinding = getBinding(bindings, 'x');
const yBinding = getBinding(bindings, 'y');
const valueBinding = getBinding(bindings, 'value');
const xField = xBinding?.columnName ?? '';
const yField = yBinding?.columnName ?? '';
const valueField = valueBinding?.columnName ?? '';
const option: Record<string, any> = {};
if (style.title.visible) {
option.title = {
text: style.title.text,
left: style.title.align,
textStyle: {
fontSize: style.title.fontSize,
fontWeight: style.title.fontWeight,
color: style.title.color,
},
};
}
if (style.legend.visible) {
option.legend = {
show: true,
orient: style.legend.orient,
top: style.legend.position === 'top' ? 'top' : style.legend.position === 'bottom' ? 'bottom' : 'middle',
left: style.legend.position === 'left' ? 'left' : style.legend.position === 'right' ? 'right' : 'center',
textStyle: {
fontSize: style.legend.fontSize,
color: style.legend.color,
},
};
} else {
option.legend = { show: false };
}
option.backgroundColor = style.background.color || 'transparent';
option.tooltip = { position: 'top' };
option.grid = { containLabel: true, top: 60, right: 80, bottom: 30, left: 30 };
if (style.animation.enabled) {
option.animation = true;
option.animationDuration = style.animation.duration;
} else {
option.animation = false;
}
const xCategories = [...new Set(data.map((row) => String(row[xField])))];
const yCategories = [...new Set(data.map((row) => String(row[yField])))];
option.xAxis = {
type: 'category',
data: xCategories,
show: style.xAxis.visible,
axisLabel: {
show: style.xAxis.labelVisible,
fontSize: style.xAxis.labelFontSize,
color: style.xAxis.labelColor,
rotate: style.xAxis.labelRotation,
},
splitArea: { show: true },
};
option.yAxis = {
type: 'category',
data: yCategories,
show: style.yAxis.visible,
axisLabel: {
show: style.yAxis.labelVisible,
fontSize: style.yAxis.labelFontSize,
color: style.yAxis.labelColor,
},
splitArea: { show: true },
};
const allValues = data.map((row) => Number(row[valueField]));
const minVal = Math.min(...allValues);
const maxVal = Math.max(...allValues);
const heatmapData = data.map((row) => [
xCategories.indexOf(String(row[xField])),
yCategories.indexOf(String(row[yField])),
Number(row[valueField]),
]);
option.visualMap = {
min: minVal,
max: maxVal,
calculable: true,
orient: 'horizontal',
left: 'center',
bottom: 0,
inRange: {
color: style.colors.length >= 2
? style.colors.slice(0, 2)
: ['#313695', '#d73027'],
},
};
let label: Record<string, any>;
if (style.dataLabel.visible) {
let formatter: string | undefined;
switch (style.dataLabel.format) {
case 'percent':
formatter = '{b}: {d}%';
break;
case 'custom':
formatter = style.dataLabel.template;
break;
case 'value':
default:
formatter = '{c}';
break;
}
label = {
show: true,
fontSize: style.dataLabel.fontSize,
color: style.dataLabel.color,
formatter,
};
} else {
label = { show: false };
}
option.series = [
{
type: 'heatmap',
data: heatmapData,
label,
emphasis: {
itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0, 0, 0, 0.5)' },
},
},
];
return option;
}

View File

@ -0,0 +1,53 @@
import { type IChartRenderer } from '@/application/ports/output/IChartRenderer';
import { type ChartInstance } from '@/domain/entities/ChartInstance';
import { buildBarOption } from './barOptionBuilder';
import { buildLineOption } from './lineOptionBuilder';
import { buildPieOption } from './pieOptionBuilder';
import { buildScatterOption } from './scatterOptionBuilder';
import { buildRadarOption } from './radarOptionBuilder';
import { buildHeatmapOption } from './heatmapOptionBuilder';
import { buildMapOption } from './mapOptionBuilder';
import { buildWordcloudOption } from './wordcloudOptionBuilder';
import { buildComboOption } from './comboOptionBuilder';
export class EChartsOptionBuilder implements IChartRenderer {
build(chart: ChartInstance, data: Record<string, any>[]): Record<string, any> {
switch (chart.type) {
case 'bar':
case 'grouped-bar':
case 'stacked-bar':
case 'horizontal-bar':
return buildBarOption(chart, data);
case 'line':
case 'area':
return buildLineOption(chart, data);
case 'pie':
case 'donut':
return buildPieOption(chart, data);
case 'scatter':
case 'boston-matrix':
return buildScatterOption(chart, data);
case 'radar':
return buildRadarOption(chart, data);
case 'heatmap':
return buildHeatmapOption(chart, data);
case 'map':
return buildMapOption(chart, data);
case 'wordcloud':
return buildWordcloudOption(chart, data);
case 'combo':
return buildComboOption(chart, data);
default:
throw new Error(`Unsupported chart type: ${chart.type}`);
}
}
}

View File

@ -0,0 +1,169 @@
import { type ChartInstance } from '@/domain/entities/ChartInstance';
import { type FieldBinding } from '@/domain/entities/FieldBinding';
import { type StyleConfig } from '@/domain/entities/StyleConfig';
function getBinding(bindings: FieldBinding[], axis: string): FieldBinding | undefined {
return bindings.find((b) => b.axis === axis);
}
function applyBaseOption(option: Record<string, any>, style: StyleConfig): void {
if (style.title.visible) {
option.title = {
text: style.title.text,
left: style.title.align,
textStyle: {
fontSize: style.title.fontSize,
fontWeight: style.title.fontWeight,
color: style.title.color,
},
};
}
if (style.legend.visible) {
option.legend = {
show: true,
orient: style.legend.orient,
top: style.legend.position === 'top' ? 'top' : style.legend.position === 'bottom' ? 'bottom' : 'middle',
left: style.legend.position === 'left' ? 'left' : style.legend.position === 'right' ? 'right' : 'center',
textStyle: {
fontSize: style.legend.fontSize,
color: style.legend.color,
},
};
} else {
option.legend = { show: false };
}
option.backgroundColor = style.background.color || 'transparent';
option.grid = { containLabel: true, top: 60, right: 30, bottom: 30, left: 30 };
if (style.animation.enabled) {
option.animation = true;
option.animationDuration = style.animation.duration;
option.animationEasing = style.animation.easing === 'ease-in'
? 'cubicIn'
: style.animation.easing === 'ease-out'
? 'cubicOut'
: style.animation.easing === 'ease-in-out'
? 'cubicInOut'
: 'linear';
} else {
option.animation = false;
}
}
export function buildLineOption(
chart: ChartInstance,
data: Record<string, any>[],
): Record<string, any> {
const { bindings, style, type } = chart;
const isArea = type === 'area';
const xBinding = getBinding(bindings, 'x');
const yBinding = getBinding(bindings, 'y');
const seriesBinding = getBinding(bindings, 'series');
const xField = xBinding?.columnName ?? '';
const yField = yBinding?.columnName ?? '';
const option: Record<string, any> = {};
applyBaseOption(option, style);
const categories = [...new Set(data.map((row) => String(row[xField])))];
option.xAxis = {
type: 'category',
data: categories,
show: style.xAxis.visible,
axisLabel: {
show: style.xAxis.labelVisible,
fontSize: style.xAxis.labelFontSize,
color: style.xAxis.labelColor,
rotate: style.xAxis.labelRotation,
},
splitLine: { show: style.xAxis.gridVisible, lineStyle: { color: style.xAxis.gridColor } },
name: style.xAxis.titleVisible ? style.xAxis.titleText : undefined,
};
option.yAxis = {
type: 'value',
show: style.yAxis.visible,
axisLabel: {
show: style.yAxis.labelVisible,
fontSize: style.yAxis.labelFontSize,
color: style.yAxis.labelColor,
rotate: style.yAxis.labelRotation,
},
splitLine: { show: style.yAxis.gridVisible, lineStyle: { color: style.yAxis.gridColor } },
name: style.yAxis.titleVisible ? style.yAxis.titleText : undefined,
};
option.tooltip = { trigger: 'axis' };
let label: Record<string, any>;
if (style.dataLabel.visible) {
let formatter: string | undefined;
switch (style.dataLabel.format) {
case 'percent':
formatter = '{b}: {d}%';
break;
case 'custom':
formatter = style.dataLabel.template;
break;
case 'value':
default:
formatter = '{c}';
break;
}
label = {
show: true,
fontSize: style.dataLabel.fontSize,
color: style.dataLabel.color,
position: style.dataLabel.position,
formatter,
};
} else {
label = { show: false };
}
if (seriesBinding) {
const seriesField = seriesBinding.columnName;
const seriesNames = [...new Set(data.map((row) => String(row[seriesField])))];
option.series = seriesNames.map((name, idx) => {
const seriesData = categories.map((cat) => {
const row = data.find(
(r) => String(r[xField]) === cat && String(r[seriesField]) === name,
);
return row ? Number(row[yField]) : 0;
});
return {
name,
type: 'line',
data: seriesData,
smooth: true,
areaStyle: isArea ? {} : undefined,
itemStyle: { color: style.colors[idx % style.colors.length] },
label,
};
});
} else {
const seriesData = categories.map((cat) => {
const row = data.find((r) => String(r[xField]) === cat);
return row ? Number(row[yField]) : 0;
});
option.series = [
{
type: 'line',
data: seriesData,
smooth: true,
areaStyle: isArea ? {} : undefined,
itemStyle: { color: style.colors[0] },
label,
},
];
}
return option;
}

View File

@ -0,0 +1,121 @@
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);
}
export function buildMapOption(
chart: ChartInstance,
data: Record<string, any>[],
): Record<string, any> {
const { bindings, style } = chart;
const geoBinding = getBinding(bindings, 'geo');
const valueBinding = getBinding(bindings, 'value');
const geoField = geoBinding?.columnName ?? '';
const valueField = valueBinding?.columnName ?? '';
const option: Record<string, any> = {};
if (style.title.visible) {
option.title = {
text: style.title.text,
left: style.title.align,
textStyle: {
fontSize: style.title.fontSize,
fontWeight: style.title.fontWeight,
color: style.title.color,
},
};
}
if (style.legend.visible) {
option.legend = {
show: true,
orient: style.legend.orient,
top: style.legend.position === 'top' ? 'top' : style.legend.position === 'bottom' ? 'bottom' : 'middle',
left: style.legend.position === 'left' ? 'left' : style.legend.position === 'right' ? 'right' : 'center',
textStyle: {
fontSize: style.legend.fontSize,
color: style.legend.color,
},
};
} else {
option.legend = { show: false };
}
option.backgroundColor = style.background.color || 'transparent';
option.tooltip = { trigger: 'item', formatter: '{b}: {c}' };
if (style.animation.enabled) {
option.animation = true;
option.animationDuration = style.animation.duration;
} else {
option.animation = false;
}
const allValues = data.map((row) => Number(row[valueField]));
const minVal = Math.min(...allValues);
const maxVal = Math.max(...allValues);
option.visualMap = {
min: minVal,
max: maxVal,
left: 'left',
top: 'bottom',
text: ['High', 'Low'],
calculable: true,
inRange: {
color: style.colors.length >= 2
? style.colors.slice(0, 2)
: ['#e0f3f8', '#d73027'],
},
};
const mapData = data.map((row) => ({
name: String(row[geoField]),
value: Number(row[valueField]),
}));
let label: Record<string, any>;
if (style.dataLabel.visible) {
let formatter: string | undefined;
switch (style.dataLabel.format) {
case 'percent':
formatter = '{b}: {d}%';
break;
case 'custom':
formatter = style.dataLabel.template;
break;
case 'value':
default:
formatter = '{c}';
break;
}
label = {
show: true,
fontSize: style.dataLabel.fontSize,
color: style.dataLabel.color,
formatter,
};
} else {
label = { show: false };
}
option.series = [
{
type: 'map',
map: 'china',
roam: true,
data: mapData,
label,
emphasis: {
label: { show: true },
itemStyle: { areaColor: '#ffd700' },
},
},
];
return option;
}

View File

@ -0,0 +1,111 @@
import { type ChartInstance } from '@/domain/entities/ChartInstance';
import { type FieldBinding } from '@/domain/entities/FieldBinding';
import { type StyleConfig } from '@/domain/entities/StyleConfig';
function getBinding(bindings: FieldBinding[], axis: string): FieldBinding | undefined {
return bindings.find((b) => b.axis === axis);
}
export function buildPieOption(
chart: ChartInstance,
data: Record<string, any>[],
): Record<string, any> {
const { bindings, style, type } = chart;
const isDonut = type === 'donut';
const labelBinding = getBinding(bindings, 'label');
const valueBinding = getBinding(bindings, 'value');
const labelField = labelBinding?.columnName ?? '';
const valueField = valueBinding?.columnName ?? '';
const option: Record<string, any> = {};
if (style.title.visible) {
option.title = {
text: style.title.text,
left: style.title.align,
textStyle: {
fontSize: style.title.fontSize,
fontWeight: style.title.fontWeight,
color: style.title.color,
},
};
}
if (style.legend.visible) {
option.legend = {
show: true,
orient: style.legend.orient,
top: style.legend.position === 'top' ? 'top' : style.legend.position === 'bottom' ? 'bottom' : 'middle',
left: style.legend.position === 'left' ? 'left' : style.legend.position === 'right' ? 'right' : 'center',
textStyle: {
fontSize: style.legend.fontSize,
color: style.legend.color,
},
};
} else {
option.legend = { show: false };
}
option.backgroundColor = style.background.color || 'transparent';
option.tooltip = { trigger: 'item', formatter: '{b}: {c} ({d}%)' };
if (style.animation.enabled) {
option.animation = true;
option.animationDuration = style.animation.duration;
} else {
option.animation = false;
}
const pieData = data.map((row, idx) => ({
name: String(row[labelField]),
value: Number(row[valueField]),
itemStyle: {
color: style.colors[idx % style.colors.length],
},
}));
let label: Record<string, any>;
if (style.dataLabel.visible) {
let formatter: string | undefined;
switch (style.dataLabel.format) {
case 'percent':
formatter = '{b}: {d}%';
break;
case 'custom':
formatter = style.dataLabel.template;
break;
case 'value':
default:
formatter = '{c}';
break;
}
label = {
show: true,
fontSize: style.dataLabel.fontSize,
color: style.dataLabel.color,
position: style.dataLabel.position === 'inside' ? 'inside' : 'outside',
formatter,
};
} else {
label = { show: false };
}
option.series = [
{
type: 'pie',
radius: isDonut ? ['40%', '70%'] : '70%',
data: pieData,
label,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
},
},
},
];
return option;
}

View File

@ -0,0 +1,116 @@
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);
}
export function buildRadarOption(
chart: ChartInstance,
data: Record<string, any>[],
): Record<string, any> {
const { bindings, style } = chart;
const labelBinding = getBinding(bindings, 'label');
const valueBinding = getBinding(bindings, 'value');
const seriesBinding = getBinding(bindings, 'series');
const labelField = labelBinding?.columnName ?? '';
const valueField = valueBinding?.columnName ?? '';
const option: Record<string, any> = {};
if (style.title.visible) {
option.title = {
text: style.title.text,
left: style.title.align,
textStyle: {
fontSize: style.title.fontSize,
fontWeight: style.title.fontWeight,
color: style.title.color,
},
};
}
if (style.legend.visible) {
option.legend = {
show: true,
orient: style.legend.orient,
top: style.legend.position === 'top' ? 'top' : style.legend.position === 'bottom' ? 'bottom' : 'middle',
left: style.legend.position === 'left' ? 'left' : style.legend.position === 'right' ? 'right' : 'center',
textStyle: {
fontSize: style.legend.fontSize,
color: style.legend.color,
},
};
} else {
option.legend = { show: false };
}
option.backgroundColor = style.background.color || 'transparent';
option.tooltip = { trigger: 'item' };
if (style.animation.enabled) {
option.animation = true;
option.animationDuration = style.animation.duration;
} else {
option.animation = false;
}
const dimensions = [...new Set(data.map((row) => String(row[labelField])))];
const maxValues = dimensions.map((dim) => {
const vals = data
.filter((row) => String(row[labelField]) === dim)
.map((row) => Number(row[valueField]));
return Math.max(...vals, 0);
});
option.radar = {
indicator: dimensions.map((name, idx) => ({
name,
max: maxValues[idx] * 1.2 || 100,
})),
};
if (seriesBinding) {
const seriesField = seriesBinding.columnName;
const seriesNames = [...new Set(data.map((row) => String(row[seriesField])))];
option.series = [
{
type: 'radar',
data: seriesNames.map((sName, idx) => ({
name: sName,
value: dimensions.map((dim) => {
const row = data.find(
(r) => String(r[labelField]) === dim && String(r[seriesField]) === sName,
);
return row ? Number(row[valueField]) : 0;
}),
itemStyle: { color: style.colors[idx % style.colors.length] },
areaStyle: { opacity: 0.2 },
})),
},
];
} else {
const values = dimensions.map((dim) => {
const row = data.find((r) => String(r[labelField]) === dim);
return row ? Number(row[valueField]) : 0;
});
option.series = [
{
type: 'radar',
data: [
{
value: values,
itemStyle: { color: style.colors[0] },
areaStyle: { opacity: 0.2 },
},
],
},
];
}
return option;
}

View File

@ -0,0 +1,167 @@
import { type ChartInstance } from '@/domain/entities/ChartInstance';
import { type FieldBinding } from '@/domain/entities/FieldBinding';
import { type StyleConfig } from '@/domain/entities/StyleConfig';
function getBinding(bindings: FieldBinding[], axis: string): FieldBinding | undefined {
return bindings.find((b) => b.axis === axis);
}
function applyBaseOption(option: Record<string, any>, style: StyleConfig): void {
if (style.title.visible) {
option.title = {
text: style.title.text,
left: style.title.align,
textStyle: {
fontSize: style.title.fontSize,
fontWeight: style.title.fontWeight,
color: style.title.color,
},
};
}
if (style.legend.visible) {
option.legend = {
show: true,
orient: style.legend.orient,
top: style.legend.position === 'top' ? 'top' : style.legend.position === 'bottom' ? 'bottom' : 'middle',
left: style.legend.position === 'left' ? 'left' : style.legend.position === 'right' ? 'right' : 'center',
textStyle: {
fontSize: style.legend.fontSize,
color: style.legend.color,
},
};
} else {
option.legend = { show: false };
}
option.backgroundColor = style.background.color || 'transparent';
option.grid = { containLabel: true, top: 60, right: 30, bottom: 30, left: 30 };
if (style.animation.enabled) {
option.animation = true;
option.animationDuration = style.animation.duration;
} else {
option.animation = false;
}
}
export function buildScatterOption(
chart: ChartInstance,
data: Record<string, any>[],
): Record<string, any> {
const { bindings, style, type } = chart;
const isBostonMatrix = type === 'boston-matrix';
const xBinding = getBinding(bindings, 'x');
const yBinding = getBinding(bindings, 'y');
const sizeBinding = getBinding(bindings, 'size');
const labelBinding = getBinding(bindings, 'label');
const xField = xBinding?.columnName ?? '';
const yField = yBinding?.columnName ?? '';
const sizeField = sizeBinding?.columnName;
const labelField = labelBinding?.columnName;
const option: Record<string, any> = {};
applyBaseOption(option, style);
option.xAxis = {
type: 'value',
show: style.xAxis.visible,
axisLabel: {
show: style.xAxis.labelVisible,
fontSize: style.xAxis.labelFontSize,
color: style.xAxis.labelColor,
rotate: style.xAxis.labelRotation,
},
splitLine: { show: style.xAxis.gridVisible, lineStyle: { color: style.xAxis.gridColor } },
name: style.xAxis.titleVisible ? style.xAxis.titleText : undefined,
};
option.yAxis = {
type: 'value',
show: style.yAxis.visible,
axisLabel: {
show: style.yAxis.labelVisible,
fontSize: style.yAxis.labelFontSize,
color: style.yAxis.labelColor,
rotate: style.yAxis.labelRotation,
},
splitLine: { show: style.yAxis.gridVisible, lineStyle: { color: style.yAxis.gridColor } },
name: style.yAxis.titleVisible ? style.yAxis.titleText : undefined,
};
option.tooltip = {
trigger: 'item',
formatter: (params: any) => {
const d = params.data;
return `${d[2] ?? ''}<br/>X: ${d[0]}<br/>Y: ${d[1]}`;
},
};
const scatterData = data.map((row) => {
const point: any[] = [Number(row[xField]), Number(row[yField])];
if (labelField) point.push(row[labelField]);
return point;
});
const symbolSize = sizeField
? (dataItem: any[]) => {
const row = data[scatterData.indexOf(dataItem)];
return row && sizeField ? Math.max(5, Math.sqrt(Number(row[sizeField])) * 3) : 10;
}
: 10;
let label: Record<string, any>;
if (style.dataLabel.visible) {
let formatter: string | ((params: any) => string) | undefined;
switch (style.dataLabel.format) {
case 'percent':
formatter = '{b}: {d}%';
break;
case 'custom':
formatter = style.dataLabel.template;
break;
case 'value':
default:
formatter = (params: any) => params.data[2] ?? '';
break;
}
label = {
show: true,
fontSize: style.dataLabel.fontSize,
color: style.dataLabel.color,
position: style.dataLabel.position,
formatter,
};
} else {
label = { show: false };
}
option.series = [
{
type: 'scatter',
data: scatterData,
symbolSize,
itemStyle: { color: style.colors[0] },
label,
},
];
if (isBostonMatrix) {
const xValues = data.map((row) => Number(row[xField]));
const yValues = data.map((row) => Number(row[yField]));
const xAvg = xValues.reduce((a, b) => a + b, 0) / xValues.length;
const yAvg = yValues.reduce((a, b) => a + b, 0) / yValues.length;
option.series[0].markLine = {
silent: true,
lineStyle: { type: 'dashed', color: '#999' },
data: [
{ xAxis: xAvg },
{ yAxis: yAvg },
],
};
}
return option;
}

View File

@ -0,0 +1,62 @@
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);
}
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 ?? '';
const option: Record<string, any> = {};
if (style.title.visible) {
option.title = {
text: style.title.text,
left: style.title.align,
textStyle: {
fontSize: style.title.fontSize,
fontWeight: style.title.fontWeight,
color: style.title.color,
},
};
}
option.backgroundColor = style.background.color || 'transparent';
option.tooltip = { show: true };
const wordData = data.map((row, idx) => ({
name: String(row[labelField]),
value: Number(row[valueField]),
textStyle: {
color: style.colors[idx % style.colors.length],
},
}));
option.series = [
{
type: 'wordCloud',
shape: 'circle',
left: 'center',
top: 'center',
width: '90%',
height: '90%',
sizeRange: [14, 60],
rotationRange: [-45, 45],
rotationStep: 15,
gridSize: 8,
drawOutOfBound: false,
data: wordData,
},
];
return option;
}

View File

@ -0,0 +1,19 @@
import { type IGeoDataProvider } from '@/application/ports/output/IGeoDataProvider';
export class GeoJsonProvider implements IGeoDataProvider {
async getProvinceGeoJSON(): Promise<any> {
const response = await fetch('/assets/geo/china.json');
if (!response.ok) {
throw new Error(`Failed to load province GeoJSON: ${response.statusText}`);
}
return response.json();
}
async getCityGeoJSON(province: string): Promise<any> {
const response = await fetch(`/assets/geo/province/${province}.json`);
if (!response.ok) {
throw new Error(`Failed to load city GeoJSON for ${province}: ${response.statusText}`);
}
return response.json();
}
}

View File

@ -0,0 +1,34 @@
import { type IStorageGateway } from '@/application/ports/output/IStorageGateway';
export class LocalStorageGateway implements IStorageGateway {
save<T>(key: string, value: T): void {
window.localStorage.setItem(key, JSON.stringify(value));
}
load<T>(key: string): T | null {
const raw = window.localStorage.getItem(key);
if (raw === null) {
return null;
}
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
remove(key: string): void {
window.localStorage.removeItem(key);
}
listKeys(prefix: string): string[] {
const keys: string[] = [];
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i);
if (key !== null && key.startsWith(prefix)) {
keys.push(key);
}
}
return keys;
}
}

View File

@ -0,0 +1,58 @@
import * as XLSX from 'xlsx';
import { type IFileParser, type ParsedSheet } from '@/application/ports/output/IFileParser';
export class SheetJSFileParser implements IFileParser {
async parse(file: File): Promise<ParsedSheet[]> {
const extension = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
if (extension === '.json') {
return this.parseJSON(file);
}
const buffer = await file.arrayBuffer();
const workbook = XLSX.read(buffer, { type: 'array' });
const sheets: ParsedSheet[] = [];
for (const sheetName of workbook.SheetNames) {
const worksheet = workbook.Sheets[sheetName];
const jsonData = XLSX.utils.sheet_to_json<Record<string, any>>(worksheet, {
defval: '',
});
const range = XLSX.utils.decode_range(worksheet['!ref'] ?? 'A1');
const columns: string[] = [];
for (let col = range.s.c; col <= range.e.c; col++) {
const cellAddress = XLSX.utils.encode_cell({ r: range.s.r, c: col });
const cell = worksheet[cellAddress];
columns.push(cell ? String(cell.v) : `Column${col + 1}`);
}
sheets.push({
sheetName,
columns,
rows: jsonData,
});
}
return sheets;
}
getSupportedFormats(): string[] {
return ['.xlsx', '.xls', '.csv', '.json'];
}
private async parseJSON(file: File): Promise<ParsedSheet[]> {
const text = await file.text();
const parsed = JSON.parse(text);
const dataArray: Record<string, any>[] = Array.isArray(parsed) ? parsed : [parsed];
const columns = dataArray.length > 0 ? Object.keys(dataArray[0]) : [];
return [
{
sheetName: file.name.replace(/\.json$/i, ''),
columns,
rows: dataArray,
},
];
}
}

View File

@ -0,0 +1,131 @@
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly statusText: string,
public readonly body: unknown,
) {
const message =
typeof body === 'object' && body !== null && 'message' in body
? String((body as Record<string, unknown>).message)
: `${status} ${statusText}`;
super(message);
this.name = 'ApiError';
}
}
export class ApiClient {
constructor(private baseUrl: string) {}
private buildUrl(path: string, params?: Record<string, string>): string {
const url = new URL(path, this.baseUrl);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.set(key, value);
}
});
}
return url.toString();
}
private async handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
let body: unknown;
try {
body = await response.json();
} catch {
body = await response.text().catch(() => null);
}
throw new ApiError(response.status, response.statusText, body);
}
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
return response.json() as Promise<T>;
}
// For 204 No Content or empty bodies
const text = await response.text();
if (text.length === 0) return undefined as T;
try {
return JSON.parse(text) as T;
} catch {
return text as unknown as T;
}
}
async get<T>(path: string, params?: Record<string, string>): Promise<T> {
const url = this.buildUrl(path, params);
const response = await fetch(url, {
method: 'GET',
headers: { Accept: 'application/json' },
});
return this.handleResponse<T>(response);
}
async post<T>(path: string, body?: unknown): Promise<T> {
const url = this.buildUrl(path);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
return this.handleResponse<T>(response);
}
async postForm<T>(path: string, formData: FormData): Promise<T> {
const url = this.buildUrl(path);
const response = await fetch(url, {
method: 'POST',
headers: { Accept: 'application/json' },
body: formData,
});
return this.handleResponse<T>(response);
}
async put<T>(path: string, body?: unknown): Promise<T> {
const url = this.buildUrl(path);
const response = await fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
return this.handleResponse<T>(response);
}
async delete(path: string): Promise<void> {
const url = this.buildUrl(path);
const response = await fetch(url, {
method: 'DELETE',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
let body: unknown;
try {
body = await response.json();
} catch {
body = await response.text().catch(() => null);
}
throw new ApiError(response.status, response.statusText, body);
}
}
async getBlob(path: string): Promise<Blob> {
const url = this.buildUrl(path);
const response = await fetch(url, { method: 'GET' });
if (!response.ok) {
let body: unknown;
try {
body = await response.json();
} catch {
body = await response.text().catch(() => null);
}
throw new ApiError(response.status, response.statusText, body);
}
return response.blob();
}
}

View File

@ -0,0 +1,64 @@
import { ApiClient } from './ApiClient';
import { API_BASE_URLS } from './config';
export class ChartServiceClient {
private client: ApiClient;
constructor() {
this.client = new ApiClient(API_BASE_URLS.chart);
}
async createChart(
datasetId: string,
chartType: string,
bindings?: any[],
style?: any,
): Promise<any> {
return this.client.post<any>('/api/v1/charts', {
dataset_id: datasetId,
chart_type: chartType,
bindings,
style,
});
}
async listCharts(): Promise<any[]> {
return this.client.get<any[]>('/charts');
}
async getChart(id: string): Promise<any> {
return this.client.get<any>(`/charts/${encodeURIComponent(id)}`);
}
async updateChart(
id: string,
updates: {
bindings?: any;
style?: any;
filters?: any;
sort_config?: any;
top_n?: number;
},
): Promise<any> {
return this.client.put<any>(
`/charts/${encodeURIComponent(id)}`,
updates,
);
}
async deleteChart(id: string): Promise<void> {
return this.client.delete(`/charts/${encodeURIComponent(id)}`);
}
async getChartOption(id: string): Promise<any> {
return this.client.get<any>(
`/charts/${encodeURIComponent(id)}/option`,
);
}
async recommendCharts(datasetId: string): Promise<any[]> {
return this.client.post<any[]>('/charts/recommend', {
dataset_id: datasetId,
});
}
}

View File

@ -0,0 +1,59 @@
import { ApiClient } from './ApiClient';
import { API_BASE_URLS } from './config';
export class DataServiceClient {
private client: ApiClient;
constructor() {
this.client = new ApiClient(API_BASE_URLS.data);
}
async importFile(file: File): Promise<any[]> {
const formData = new FormData();
formData.append('file', file);
return this.client.postForm<any[]>('/api/v1/datasets/import', formData);
}
async listDatasets(): Promise<any[]> {
return this.client.get<any[]>('/datasets');
}
async getDataset(id: string): Promise<any> {
return this.client.get<any>(`/datasets/${encodeURIComponent(id)}`);
}
async getRows(
id: string,
limit?: number,
offset?: number,
): Promise<any> {
const params: Record<string, string> = {};
if (limit !== undefined) params.limit = String(limit);
if (offset !== undefined) params.offset = String(offset);
return this.client.get<any>(
`/datasets/${encodeURIComponent(id)}/rows`,
Object.keys(params).length > 0 ? params : undefined,
);
}
async deleteDataset(id: string): Promise<void> {
return this.client.delete(`/datasets/${encodeURIComponent(id)}`);
}
async getStructure(id: string): Promise<any> {
return this.client.get<any>(
`/datasets/${encodeURIComponent(id)}/structure`,
);
}
async updateCell(
id: string,
rowIndex: number,
data: any,
): Promise<void> {
await this.client.put<void>(
`/datasets/${encodeURIComponent(id)}/rows/${rowIndex}`,
data,
);
}
}

View File

@ -0,0 +1,78 @@
import { ApiClient } from './ApiClient';
import { API_BASE_URLS } from './config';
const DEFAULT_POLL_INTERVAL_MS = 1000;
const DEFAULT_MAX_RETRIES = 60;
export class ExportServiceClient {
private client: ApiClient;
constructor() {
this.client = new ApiClient(API_BASE_URLS.export);
}
async createExport(data: {
format: string;
chart_ids: string[];
dataset_id: string;
file_name?: string;
}): Promise<{ id: string; status: string }> {
return this.client.post<{ id: string; status: string }>(
'/api/v1/exports',
data,
);
}
async getExportStatus(
id: string,
): Promise<{
id: string;
status: string;
file_path?: string;
error_message?: string;
}> {
return this.client.get<{
id: string;
status: string;
file_path?: string;
error_message?: string;
}>(`/exports/${encodeURIComponent(id)}`);
}
async downloadExport(id: string): Promise<Blob> {
return this.client.getBlob(
`/exports/${encodeURIComponent(id)}/download`,
);
}
/**
* Poll the export status until it completes, then download the result.
* Throws if the export fails or polling exceeds `maxRetries`.
*/
async waitForExport(
id: string,
intervalMs: number = DEFAULT_POLL_INTERVAL_MS,
maxRetries: number = DEFAULT_MAX_RETRIES,
): Promise<Blob> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const status = await this.getExportStatus(id);
if (status.status === 'completed' || status.status === 'done') {
return this.downloadExport(id);
}
if (status.status === 'failed' || status.status === 'error') {
throw new Error(
status.error_message || `Export ${id} failed with status: ${status.status}`,
);
}
// Wait before next poll
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error(
`Export ${id} did not complete within ${maxRetries} retries`,
);
}
}

View File

@ -0,0 +1,49 @@
import { ApiClient } from './ApiClient';
import { API_BASE_URLS } from './config';
export class TemplateServiceClient {
private client: ApiClient;
constructor() {
this.client = new ApiClient(API_BASE_URLS.template);
}
async saveTemplate(data: {
name: string;
description: string;
chart_configs: any[];
layout: any[];
theme: string;
}): Promise<any> {
return this.client.post<any>('/api/v1/templates', data);
}
async listTemplates(): Promise<any[]> {
return this.client.get<any[]>('/templates');
}
async getTemplate(id: string): Promise<any> {
return this.client.get<any>(`/templates/${encodeURIComponent(id)}`);
}
async updateTemplate(id: string, data: any): Promise<any> {
return this.client.put<any>(
`/templates/${encodeURIComponent(id)}`,
data,
);
}
async deleteTemplate(id: string): Promise<void> {
return this.client.delete(`/templates/${encodeURIComponent(id)}`);
}
async importTemplate(json: string): Promise<any> {
return this.client.post<any>('/templates/import', JSON.parse(json));
}
async exportTemplate(id: string): Promise<any> {
return this.client.get<any>(
`/templates/${encodeURIComponent(id)}/export`,
);
}
}

View File

@ -0,0 +1,6 @@
export const API_BASE_URLS = {
data: process.env.NEXT_PUBLIC_DATA_SERVICE_URL || 'http://localhost:8001',
chart: process.env.NEXT_PUBLIC_CHART_SERVICE_URL || 'http://localhost:8002',
template: process.env.NEXT_PUBLIC_TEMPLATE_SERVICE_URL || 'http://localhost:8003',
export: process.env.NEXT_PUBLIC_EXPORT_SERVICE_URL || 'http://localhost:8004',
} as const;

View File

@ -0,0 +1,6 @@
export { ApiClient, ApiError } from './ApiClient';
export { API_BASE_URLS } from './config';
export { DataServiceClient } from './DataServiceClient';
export { ChartServiceClient } from './ChartServiceClient';
export { TemplateServiceClient } from './TemplateServiceClient';
export { ExportServiceClient } from './ExportServiceClient';

View File

@ -0,0 +1,23 @@
import * as XLSX from 'xlsx';
export class ExcelExportGateway {
async exportExcel(data: Record<string, any>[], columns: string[]): Promise<Blob> {
const workbook = XLSX.utils.book_new();
const orderedData = data.map((row) => {
const ordered: Record<string, any> = {};
for (const col of columns) {
ordered[col] = row[col] ?? '';
}
return ordered;
});
const worksheet = XLSX.utils.json_to_sheet(orderedData, { header: columns });
XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1');
const buffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
return new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
}
}

View File

@ -0,0 +1,43 @@
export class HTMLExportGateway {
async exportHTML(
chartsOption: Record<string, any>[],
_data: Record<string, any>[],
): Promise<Blob> {
const chartsHTML = chartsOption
.map(
(option, idx) => `
<div id="chart-${idx}" style="width: 800px; height: 500px; margin: 20px auto;"></div>
<script>
(function() {
var chart = echarts.init(document.getElementById('chart-${idx}'));
chart.setOption(${JSON.stringify(option, null, 2)});
window.addEventListener('resize', function() { chart.resize(); });
})();
</script>`,
)
.join('\n');
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DataViz Pro - Chart Export</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
margin: 0;
padding: 20px;
}
</style>
</head>
<body>
${chartsHTML}
</body>
</html>`;
return new Blob([html], { type: 'text/html;charset=utf-8' });
}
}

View File

@ -0,0 +1,42 @@
import html2canvas from 'html2canvas';
export class ImageExportGateway {
async exportImage(element: HTMLElement, format: 'png' | 'jpg' | 'svg'): Promise<Blob> {
if (format === 'svg') {
return this.exportSVG(element);
}
const canvas = await html2canvas(element, {
backgroundColor: null,
useCORS: true,
scale: 2,
});
return new Promise<Blob>((resolve, reject) => {
const mimeType = format === 'jpg' ? 'image/jpeg' : 'image/png';
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error(`Failed to export image as ${format}`));
}
},
mimeType,
0.95,
);
});
}
private exportSVG(element: HTMLElement): Promise<Blob> {
const svgElement = element.querySelector('svg');
if (!svgElement) {
return Promise.reject(new Error('No SVG element found in the container'));
}
const serializer = new XMLSerializer();
const svgString = serializer.serializeToString(svgElement);
const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
return Promise.resolve(blob);
}
}

View File

@ -0,0 +1,28 @@
import { jsPDF } from 'jspdf';
import html2canvas from 'html2canvas';
export class PDFExportGateway {
async exportPDF(element: HTMLElement): Promise<Blob> {
const canvas = await html2canvas(element, {
backgroundColor: '#ffffff',
useCORS: true,
scale: 2,
});
const imgData = canvas.toDataURL('image/png');
const imgWidth = canvas.width;
const imgHeight = canvas.height;
const isLandscape = imgWidth > imgHeight;
const pdf = new jsPDF({
orientation: isLandscape ? 'landscape' : 'portrait',
unit: 'px',
format: [imgWidth, imgHeight],
});
pdf.addImage(imgData, 'PNG', 0, 0, imgWidth, imgHeight);
const pdfBlob = pdf.output('blob');
return pdfBlob;
}
}

View File

@ -0,0 +1,44 @@
import PptxGenJS from 'pptxgenjs';
export class PPTExportGateway {
async exportPPT(charts: { title: string; imageBlob: Blob }[]): Promise<Blob> {
const pptx = new PptxGenJS();
pptx.layout = 'LAYOUT_WIDE';
for (const chart of charts) {
const slide = pptx.addSlide();
slide.addText(chart.title, {
x: 0.5,
y: 0.3,
w: '90%',
fontSize: 20,
bold: true,
color: '333333',
});
const base64 = await this.blobToBase64(chart.imageBlob);
slide.addImage({
data: base64,
x: 0.5,
y: 1.0,
w: 12,
h: 6,
sizing: { type: 'contain', w: 12, h: 6 },
});
}
const output = await pptx.write({ outputType: 'blob' });
return output as Blob;
}
private blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
}

View File

@ -0,0 +1,34 @@
import { type IExportGateway } from '@/application/ports/output/IExportGateway';
import { ImageExportGateway } from './ImageExportGateway';
import { PDFExportGateway } from './PDFExportGateway';
import { ExcelExportGateway } from './ExcelExportGateway';
import { PPTExportGateway } from './PPTExportGateway';
import { HTMLExportGateway } from './HTMLExportGateway';
export class CompositeExportGateway implements IExportGateway {
private readonly imageExporter = new ImageExportGateway();
private readonly pdfExporter = new PDFExportGateway();
private readonly excelExporter = new ExcelExportGateway();
private readonly pptExporter = new PPTExportGateway();
private readonly htmlExporter = new HTMLExportGateway();
async exportImage(element: HTMLElement, format: 'png' | 'jpg' | 'svg'): Promise<Blob> {
return this.imageExporter.exportImage(element, format);
}
async exportPDF(element: HTMLElement): Promise<Blob> {
return this.pdfExporter.exportPDF(element);
}
async exportExcel(data: Record<string, any>[], columns: string[]): Promise<Blob> {
return this.excelExporter.exportExcel(data, columns);
}
async exportPPT(charts: { title: string; imageBlob: Blob }[]): Promise<Blob> {
return this.pptExporter.exportPPT(charts);
}
async exportHTML(chartsOption: Record<string, any>[], data: Record<string, any>[]): Promise<Blob> {
return this.htmlExporter.exportHTML(chartsOption, data);
}
}

View File

@ -0,0 +1,6 @@
export { SheetJSFileParser } from './SheetJSFileParser';
export { EChartsOptionBuilder } from './EChartsOptionBuilder';
export { CompositeExportGateway } from './exportGateway';
export { LocalStorageGateway } from './LocalStorageGateway';
export { GeoJsonProvider } from './GeoJsonProvider';
export * from './api';

View File

@ -0,0 +1,3 @@
export * from './gateways';
export * from './state';
export * from './presenters';

View File

@ -0,0 +1,29 @@
import { type ChartInstance } from '@/domain/entities/ChartInstance';
import { type LayoutItem } from '@/domain/entities/LayoutItem';
import { type ChartType } from '@/domain/valueObjects/ChartType';
export interface ChartVM {
id: string;
type: ChartType;
title: string;
option: Record<string, any>;
width: number;
height: number;
}
export class ChartPresenter {
static toViewModel(
chart: ChartInstance,
layout: LayoutItem | undefined,
echartsOption: Record<string, any>,
): ChartVM {
return {
id: chart.id,
type: chart.type,
title: chart.style.title.text,
option: echartsOption,
width: layout?.w ?? 400,
height: layout?.h ?? 300,
};
}
}

View File

@ -0,0 +1,26 @@
import { type DataSet } from '@/domain/entities/DataSet';
import { type FieldType } from '@/domain/valueObjects/FieldType';
export interface DataPreviewVM {
columns: { title: string; dataIndex: string; fieldType: FieldType }[];
rows: Record<string, any>[];
totalRows: number;
fileName: string;
sheetName?: string;
}
export class DataPreviewPresenter {
static toViewModel(dataSet: DataSet): DataPreviewVM {
return {
columns: dataSet.columns.map((col) => ({
title: col.name,
dataIndex: col.name,
fieldType: col.type,
})),
rows: dataSet.rows,
totalRows: dataSet.rowCount,
fileName: dataSet.fileName,
sheetName: dataSet.sheetName,
};
}
}

View File

@ -0,0 +1,53 @@
import { type ExportFormat } from '@/domain/valueObjects/ExportFormat';
export type ExportStatus = 'idle' | 'exporting' | 'success' | 'error';
export interface ExportVM {
status: ExportStatus;
format: ExportFormat | null;
progress: number;
errorMessage: string | null;
downloadUrl: string | null;
}
export class ExportPresenter {
static idle(): ExportVM {
return {
status: 'idle',
format: null,
progress: 0,
errorMessage: null,
downloadUrl: null,
};
}
static exporting(format: ExportFormat, progress: number): ExportVM {
return {
status: 'exporting',
format,
progress: Math.min(Math.max(progress, 0), 100),
errorMessage: null,
downloadUrl: null,
};
}
static success(format: ExportFormat, downloadUrl: string): ExportVM {
return {
status: 'success',
format,
progress: 100,
errorMessage: null,
downloadUrl,
};
}
static error(format: ExportFormat, errorMessage: string): ExportVM {
return {
status: 'error',
format,
progress: 0,
errorMessage,
downloadUrl: null,
};
}
}

View File

@ -0,0 +1,28 @@
import { type Column } from '@/domain/entities/DataSet';
import { type FieldType } from '@/domain/valueObjects/FieldType';
export interface FieldItemVM {
name: string;
type: FieldType;
icon: string;
draggable: boolean;
}
const FIELD_TYPE_ICON_MAP: Record<FieldType, string> = {
number: 'hashtag',
text: 'font',
date: 'calendar',
percentage: 'percent',
geo: 'globe',
};
export class FieldListPresenter {
static toViewModels(columns: Column[]): FieldItemVM[] {
return columns.map((col) => ({
name: col.name,
type: col.type,
icon: FIELD_TYPE_ICON_MAP[col.type] ?? 'question',
draggable: true,
}));
}
}

View File

@ -0,0 +1,11 @@
export { DataPreviewPresenter } from './DataPreviewPresenter';
export type { DataPreviewVM } from './DataPreviewPresenter';
export { ChartPresenter } from './ChartPresenter';
export type { ChartVM } from './ChartPresenter';
export { FieldListPresenter } from './FieldListPresenter';
export type { FieldItemVM } from './FieldListPresenter';
export { ExportPresenter } from './ExportPresenter';
export type { ExportVM, ExportStatus } from './ExportPresenter';

View File

@ -0,0 +1,2 @@
export * from './redux';
export * from './zustand';

View File

@ -0,0 +1,96 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import { type ChartInstance, type FilterRule } from '@/domain/entities/ChartInstance';
import { type FieldBinding } from '@/domain/entities/FieldBinding';
import { type StyleConfig } from '@/domain/entities/StyleConfig';
import { type SortConfig } from '@/domain/valueObjects/SortOrder';
interface ChartState {
charts: ChartInstance[];
activeChartId: string | null;
}
const initialState: ChartState = {
charts: [],
activeChartId: null,
};
const chartSlice = createSlice({
name: 'chart',
initialState,
reducers: {
addChart(state, action: PayloadAction<ChartInstance>) {
state.charts.push(action.payload);
},
removeChart(state, action: PayloadAction<string>) {
state.charts = state.charts.filter((c) => c.id !== action.payload);
if (state.activeChartId === action.payload) {
state.activeChartId = null;
}
},
setActiveChart(state, action: PayloadAction<string | null>) {
state.activeChartId = action.payload;
},
updateBindings(
state,
action: PayloadAction<{ chartId: string; bindings: FieldBinding[] }>,
) {
const chart = state.charts.find((c) => c.id === action.payload.chartId);
if (chart) {
chart.bindings = action.payload.bindings;
chart.updatedAt = new Date().toISOString();
}
},
updateStyle(
state,
action: PayloadAction<{ chartId: string; style: Partial<StyleConfig> }>,
) {
const chart = state.charts.find((c) => c.id === action.payload.chartId);
if (chart) {
chart.style = { ...chart.style, ...action.payload.style };
chart.updatedAt = new Date().toISOString();
}
},
updateFilters(
state,
action: PayloadAction<{ chartId: string; filters: FilterRule[] }>,
) {
const chart = state.charts.find((c) => c.id === action.payload.chartId);
if (chart) {
chart.filters = action.payload.filters;
chart.updatedAt = new Date().toISOString();
}
},
updateSort(
state,
action: PayloadAction<{ chartId: string; sort: SortConfig | undefined }>,
) {
const chart = state.charts.find((c) => c.id === action.payload.chartId);
if (chart) {
chart.sort = action.payload.sort;
chart.updatedAt = new Date().toISOString();
}
},
updateTopN(
state,
action: PayloadAction<{ chartId: string; topN: number | undefined }>,
) {
const chart = state.charts.find((c) => c.id === action.payload.chartId);
if (chart) {
chart.topN = action.payload.topN;
chart.updatedAt = new Date().toISOString();
}
},
},
});
export const {
addChart,
removeChart,
setActiveChart,
updateBindings,
updateStyle,
updateFilters,
updateSort,
updateTopN,
} = chartSlice.actions;
export default chartSlice.reducer;

View File

@ -0,0 +1,51 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import { type DataSet } from '@/domain/entities/DataSet';
interface DataState {
dataSets: DataSet[];
activeDataSetId: string | null;
}
const initialState: DataState = {
dataSets: [],
activeDataSetId: null,
};
const dataSlice = createSlice({
name: 'data',
initialState,
reducers: {
addDataSet(state, action: PayloadAction<DataSet>) {
state.dataSets.push(action.payload);
},
removeDataSet(state, action: PayloadAction<string>) {
state.dataSets = state.dataSets.filter((ds) => ds.id !== action.payload);
if (state.activeDataSetId === action.payload) {
state.activeDataSetId = null;
}
},
setActiveDataSet(state, action: PayloadAction<string | null>) {
state.activeDataSetId = action.payload;
},
updateCell(
state,
action: PayloadAction<{
dataSetId: string;
rowIndex: number;
columnName: string;
value: any;
}>,
) {
const { dataSetId, rowIndex, columnName, value } = action.payload;
const dataSet = state.dataSets.find((ds) => ds.id === dataSetId);
if (dataSet && dataSet.rows[rowIndex]) {
dataSet.rows[rowIndex][columnName] = value;
dataSet.updatedAt = new Date().toISOString();
}
},
},
});
export const { addDataSet, removeDataSet, setActiveDataSet, updateCell } =
dataSlice.actions;
export default dataSlice.reducer;

View File

@ -0,0 +1,36 @@
export { store, useAppDispatch, useAppSelector } from './store';
export type { RootState, AppDispatch } from './store';
export {
addDataSet,
removeDataSet,
setActiveDataSet,
updateCell,
} from './dataSlice';
export {
addChart,
removeChart,
setActiveChart,
updateBindings,
updateStyle,
updateFilters,
updateSort,
updateTopN,
} from './chartSlice';
export {
addLayoutItem,
removeLayoutItem,
updateLayoutItem,
updateLayouts,
setCanvasSize,
toggleSnapToGrid,
} from './layoutSlice';
export {
addTemplate,
removeTemplate,
updateTemplate,
setTemplates,
} from './templateSlice';

View File

@ -0,0 +1,59 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import { type LayoutItem } from '@/domain/entities/LayoutItem';
interface LayoutState {
layouts: LayoutItem[];
canvasSize: { width: number; height: number };
snapToGrid: boolean;
}
const initialState: LayoutState = {
layouts: [],
canvasSize: { width: 1920, height: 1080 },
snapToGrid: true,
};
const layoutSlice = createSlice({
name: 'layout',
initialState,
reducers: {
addLayoutItem(state, action: PayloadAction<LayoutItem>) {
state.layouts.push(action.payload);
},
removeLayoutItem(state, action: PayloadAction<string>) {
state.layouts = state.layouts.filter(
(item) => item.chartId !== action.payload,
);
},
updateLayoutItem(state, action: PayloadAction<LayoutItem>) {
const index = state.layouts.findIndex(
(item) => item.chartId === action.payload.chartId,
);
if (index !== -1) {
state.layouts[index] = action.payload;
}
},
updateLayouts(state, action: PayloadAction<LayoutItem[]>) {
state.layouts = action.payload;
},
setCanvasSize(
state,
action: PayloadAction<{ width: number; height: number }>,
) {
state.canvasSize = action.payload;
},
toggleSnapToGrid(state) {
state.snapToGrid = !state.snapToGrid;
},
},
});
export const {
addLayoutItem,
removeLayoutItem,
updateLayoutItem,
updateLayouts,
setCanvasSize,
toggleSnapToGrid,
} = layoutSlice.actions;
export default layoutSlice.reducer;

View File

@ -0,0 +1,24 @@
import { configureStore } from '@reduxjs/toolkit';
import {
useDispatch,
useSelector,
type TypedUseSelectorHook,
} from 'react-redux';
import dataReducer from './dataSlice';
import chartReducer from './chartSlice';
import layoutReducer from './layoutSlice';
import templateReducer from './templateSlice';
export const store = configureStore({
reducer: {
data: dataReducer,
chart: chartReducer,
layout: layoutReducer,
template: templateReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

View File

@ -0,0 +1,40 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import { type Template } from '@/domain/entities/Template';
interface TemplateState {
templates: Template[];
}
const initialState: TemplateState = {
templates: [],
};
const templateSlice = createSlice({
name: 'template',
initialState,
reducers: {
addTemplate(state, action: PayloadAction<Template>) {
state.templates.push(action.payload);
},
removeTemplate(state, action: PayloadAction<string>) {
state.templates = state.templates.filter(
(t) => t.id !== action.payload,
);
},
updateTemplate(state, action: PayloadAction<Template>) {
const index = state.templates.findIndex(
(t) => t.id === action.payload.id,
);
if (index !== -1) {
state.templates[index] = action.payload;
}
},
setTemplates(state, action: PayloadAction<Template[]>) {
state.templates = action.payload;
},
},
});
export const { addTemplate, removeTemplate, updateTemplate, setTemplates } =
templateSlice.actions;
export default templateSlice.reducer;

View File

@ -0,0 +1,8 @@
export { useUIStore } from './uiStore';
export type { UIState } from './uiStore';
export { useThemeStore } from './themeStore';
export type { ThemeState } from './themeStore';
export { useInteractionStore } from './interactionStore';
export type { InteractionState, DragState } from './interactionStore';

View File

@ -0,0 +1,37 @@
import { create } from 'zustand';
export interface DragState {
isDragging: boolean;
chartId: string | null;
}
export interface InteractionState {
hoveredChartId: string | null;
selectedChartIds: string[];
dragState: DragState;
zoomLevel: number;
setHoveredChart: (id: string | null) => void;
setSelectedCharts: (ids: string[]) => void;
addSelectedChart: (id: string) => void;
setDragState: (state: DragState) => void;
setZoomLevel: (level: number) => void;
}
export const useInteractionStore = create<InteractionState>((set) => ({
hoveredChartId: null,
selectedChartIds: [],
dragState: { isDragging: false, chartId: null },
zoomLevel: 1,
setHoveredChart: (id) => set({ hoveredChartId: id }),
setSelectedCharts: (ids) => set({ selectedChartIds: ids }),
addSelectedChart: (id) =>
set((state) => ({
selectedChartIds: state.selectedChartIds.includes(id)
? state.selectedChartIds
: [...state.selectedChartIds, id],
})),
setDragState: (dragState) => set({ dragState }),
setZoomLevel: (level) => set({ zoomLevel: level }),
}));

View File

@ -0,0 +1,30 @@
import { create } from 'zustand';
export interface ThemeState {
currentTheme: 'light' | 'dark';
currentPalette: string;
customPalettes: Record<string, string[]>;
setTheme: (theme: ThemeState['currentTheme']) => void;
setPalette: (name: string) => void;
addCustomPalette: (name: string, colors: string[]) => void;
removeCustomPalette: (name: string) => void;
}
export const useThemeStore = create<ThemeState>((set) => ({
currentTheme: 'light',
currentPalette: 'default',
customPalettes: {},
setTheme: (theme) => set({ currentTheme: theme }),
setPalette: (name) => set({ currentPalette: name }),
addCustomPalette: (name, colors) =>
set((state) => ({
customPalettes: { ...state.customPalettes, [name]: colors },
})),
removeCustomPalette: (name) =>
set((state) => {
const { [name]: _, ...rest } = state.customPalettes;
return { customPalettes: rest };
}),
}));

View File

@ -0,0 +1,39 @@
import { create } from 'zustand';
export interface UIState {
leftPanelCollapsed: boolean;
rightPanelCollapsed: boolean;
leftPanelTab: 'data' | 'charts' | 'fields';
rightPanelTab: 'bindData' | 'style' | 'interaction';
importModalVisible: boolean;
exportModalVisible: boolean;
templateModalVisible: boolean;
toggleLeftPanel: () => void;
toggleRightPanel: () => void;
setLeftPanelTab: (tab: UIState['leftPanelTab']) => void;
setRightPanelTab: (tab: UIState['rightPanelTab']) => void;
setImportModalVisible: (v: boolean) => void;
setExportModalVisible: (v: boolean) => void;
setTemplateModalVisible: (v: boolean) => void;
}
export const useUIStore = create<UIState>((set) => ({
leftPanelCollapsed: false,
rightPanelCollapsed: false,
leftPanelTab: 'data',
rightPanelTab: 'bindData',
importModalVisible: false,
exportModalVisible: false,
templateModalVisible: false,
toggleLeftPanel: () =>
set((state) => ({ leftPanelCollapsed: !state.leftPanelCollapsed })),
toggleRightPanel: () =>
set((state) => ({ rightPanelCollapsed: !state.rightPanelCollapsed })),
setLeftPanelTab: (tab) => set({ leftPanelTab: tab }),
setRightPanelTab: (tab) => set({ rightPanelTab: tab }),
setImportModalVisible: (v) => set({ importModalVisible: v }),
setExportModalVisible: (v) => set({ exportModalVisible: v }),
setTemplateModalVisible: (v) => set({ templateModalVisible: v }),
}));

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -0,0 +1,563 @@
@import "tailwindcss";
/* ============================================================
DataViz Pro - Global Styles
============================================================ */
/* ---------- CSS Custom Properties (Light Theme) ---------- */
:root {
--primary: #1677ff;
--primary-hover: #4096ff;
--primary-active: #0958d9;
--primary-bg: #e6f4ff;
--success: #52c41a;
--warning: #faad14;
--error: #ff4d4f;
--info: #1677ff;
--bg: #f5f5f5;
--bg-elevated: #ffffff;
--surface: #ffffff;
--surface-secondary: #fafafa;
--text: #141414;
--text-secondary: #595959;
--text-tertiary: #8c8c8c;
--text-disabled: #bfbfbf;
--border: #d9d9d9;
--border-light: #f0f0f0;
--divider: #f0f0f0;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.12);
--toolbar-height: 48px;
--statusbar-height: 32px;
--left-panel-width: 280px;
--right-panel-width: 320px;
--panel-collapsed-width: 0px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--transition-fast: 0.15s ease;
--transition-normal: 0.25s ease;
--transition-slow: 0.35s ease;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, "Noto Sans SC", sans-serif;
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
}
/* ---------- Dark Theme ---------- */
[data-theme="dark"] {
--primary: #3c89ff;
--primary-hover: #5a9eff;
--primary-active: #1d6be0;
--primary-bg: #111d2c;
--bg: #141414;
--bg-elevated: #1f1f1f;
--surface: #1f1f1f;
--surface-secondary: #262626;
--text: #e8e8e8;
--text-secondary: #a6a6a6;
--text-tertiary: #737373;
--text-disabled: #525252;
--border: #424242;
--border-light: #303030;
--divider: #303030;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.25);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.35);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.45);
}
/* ---------- Reset ---------- */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.5715;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html,
body {
height: 100%;
overflow: hidden;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-sans);
}
a {
color: var(--primary);
text-decoration: none;
}
a:hover {
color: var(--primary-hover);
}
img,
svg {
vertical-align: middle;
}
/* ---------- Scrollbar ---------- */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
/* ============================================================
Ant Design Overrides
============================================================ */
/* Make Ant Layout fill the viewport */
.ant-layout {
background: var(--bg) !important;
min-height: 100vh;
}
.ant-layout-header {
background: var(--surface) !important;
border-bottom: 1px solid var(--border-light);
height: var(--toolbar-height) !important;
line-height: var(--toolbar-height) !important;
padding: 0 16px !important;
}
.ant-layout-sider {
background: var(--surface) !important;
border-right: 1px solid var(--border-light);
transition: all var(--transition-normal);
}
.ant-layout-sider-right {
border-right: none;
border-left: 1px solid var(--border-light);
}
.ant-layout-content {
background: var(--bg) !important;
overflow: auto;
}
.ant-layout-footer {
background: var(--surface) !important;
border-top: 1px solid var(--border-light);
padding: 0 16px !important;
height: var(--statusbar-height);
line-height: var(--statusbar-height);
font-size: 12px;
color: var(--text-secondary);
}
/* Tabs inside panels */
.ant-tabs-nav {
margin-bottom: 0 !important;
}
.panel-tabs .ant-tabs-tab {
padding: 8px 12px !important;
font-size: 13px;
}
/* Buttons */
.ant-btn-text {
color: var(--text-secondary);
}
.ant-btn-text:hover {
color: var(--primary);
background: var(--primary-bg) !important;
}
/* Cards in panels */
.ant-card {
border-radius: var(--radius-md);
}
.ant-card-bordered {
border-color: var(--border-light);
}
/* ============================================================
Layout Structure
============================================================ */
/* Full-height app container */
.dvp-app {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
overflow: hidden;
}
/* Top toolbar */
.dvp-toolbar {
flex-shrink: 0;
height: var(--toolbar-height);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
background: var(--surface);
border-bottom: 1px solid var(--border-light);
box-shadow: var(--shadow-sm);
z-index: 100;
gap: 12px;
}
.dvp-toolbar-left,
.dvp-toolbar-center,
.dvp-toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.dvp-toolbar-center {
flex: 1;
justify-content: center;
}
/* Body: left panel + canvas + right panel */
.dvp-body {
flex: 1;
display: flex;
overflow: hidden;
min-height: 0;
}
/* Side panels */
.dvp-panel {
flex-shrink: 0;
background: var(--surface);
overflow-y: auto;
overflow-x: hidden;
transition: width var(--transition-normal), opacity var(--transition-fast);
}
.dvp-panel-left {
width: var(--left-panel-width);
border-right: 1px solid var(--border-light);
}
.dvp-panel-right {
width: var(--right-panel-width);
border-left: 1px solid var(--border-light);
}
.dvp-panel.collapsed {
width: 0;
opacity: 0;
overflow: hidden;
}
.dvp-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
font-weight: 500;
font-size: 13px;
color: var(--text);
border-bottom: 1px solid var(--border-light);
user-select: none;
}
.dvp-panel-content {
padding: 12px;
}
/* Center canvas */
.dvp-canvas {
flex: 1;
position: relative;
overflow: auto;
background: var(--bg);
min-width: 0;
}
.dvp-canvas-inner {
position: relative;
min-height: 100%;
padding: 16px;
}
/* Bottom status bar */
.dvp-statusbar {
flex-shrink: 0;
height: var(--statusbar-height);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
background: var(--surface);
border-top: 1px solid var(--border-light);
font-size: 12px;
color: var(--text-tertiary);
z-index: 100;
gap: 16px;
}
.dvp-statusbar-left,
.dvp-statusbar-right {
display: flex;
align-items: center;
gap: 12px;
}
/* ============================================================
React Grid Layout
============================================================ */
.react-grid-layout {
position: relative;
transition: height 200ms ease;
}
.react-grid-item {
transition: all 200ms ease;
transition-property: left, top, width, height;
background: var(--surface);
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.react-grid-item:hover {
box-shadow: var(--shadow-md);
border-color: var(--primary);
}
.react-grid-item.cssTransforms {
transition-property: transform, width, height;
}
.react-grid-item.resizing {
opacity: 0.9;
z-index: 1;
}
.react-grid-item.react-draggable-dragging {
transition: none;
z-index: 3;
box-shadow: var(--shadow-lg);
border-color: var(--primary);
}
.react-grid-item > .react-resizable-handle {
position: absolute;
width: 20px;
height: 20px;
bottom: 0;
right: 0;
cursor: se-resize;
}
.react-grid-item > .react-resizable-handle::after {
content: "";
position: absolute;
right: 3px;
bottom: 3px;
width: 6px;
height: 6px;
border-right: 2px solid var(--text-tertiary);
border-bottom: 2px solid var(--text-tertiary);
}
.react-grid-placeholder {
background: var(--primary);
opacity: 0.15;
border-radius: var(--radius-md);
transition-duration: 100ms;
z-index: 2;
user-select: none;
}
/* ============================================================
Chart Widget
============================================================ */
.dvp-chart-widget {
display: flex;
flex-direction: column;
height: 100%;
padding: 8px;
}
.dvp-chart-widget-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 4px;
font-size: 13px;
font-weight: 500;
color: var(--text);
cursor: move;
user-select: none;
}
.dvp-chart-widget-body {
flex: 1;
min-height: 0;
}
/* ============================================================
Data Table
============================================================ */
.dvp-data-table {
width: 100%;
font-size: 13px;
border-collapse: collapse;
}
.dvp-data-table thead th {
position: sticky;
top: 0;
background: var(--surface-secondary);
color: var(--text-secondary);
font-weight: 600;
text-align: left;
padding: 8px 12px;
border-bottom: 2px solid var(--border);
white-space: nowrap;
user-select: none;
}
.dvp-data-table thead th:hover {
background: var(--primary-bg);
color: var(--primary);
cursor: pointer;
}
.dvp-data-table tbody td {
padding: 6px 12px;
border-bottom: 1px solid var(--border-light);
color: var(--text);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dvp-data-table tbody tr:hover td {
background: var(--primary-bg);
}
.dvp-data-table tbody tr.selected td {
background: var(--primary-bg);
border-color: var(--primary);
}
/* Numeric columns */
.dvp-data-table .col-numeric {
text-align: right;
font-variant-numeric: tabular-nums;
font-family: var(--font-mono);
}
/* ============================================================
Responsive Breakpoints
============================================================ */
@media (max-width: 1200px) {
:root {
--left-panel-width: 240px;
--right-panel-width: 280px;
}
}
@media (max-width: 992px) {
:root {
--left-panel-width: 220px;
--right-panel-width: 260px;
}
}
@media (max-width: 768px) {
:root {
--left-panel-width: 200px;
--right-panel-width: 240px;
}
.dvp-panel-left,
.dvp-panel-right {
position: absolute;
z-index: 200;
height: calc(100vh - var(--toolbar-height) - var(--statusbar-height));
top: var(--toolbar-height);
box-shadow: var(--shadow-lg);
}
.dvp-panel-left {
left: 0;
}
.dvp-panel-right {
right: 0;
}
}
@media (max-width: 576px) {
.dvp-toolbar {
padding: 0 8px;
gap: 4px;
}
.dvp-toolbar-center {
display: none;
}
.dvp-panel-left,
.dvp-panel-right {
width: 100%;
}
}
/* ============================================================
Utility Classes
============================================================ */
.text-mono {
font-family: var(--font-mono);
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.no-select {
user-select: none;
}

View File

@ -0,0 +1,22 @@
import type { Metadata } from 'next';
import './globals.css';
import { Providers } from '@/frameworks/app/providers';
export const metadata: Metadata = {
title: 'DataViz Pro - 数据可视化平台',
description: '专业的数据可视化图表工具',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}

View File

@ -0,0 +1,5 @@
import { AppShell } from '@/frameworks/components/layout/AppShell';
export default function Home() {
return <AppShell />;
}

View File

@ -0,0 +1,8 @@
import { type ChartType, type FieldBinding } from '@/domain';
export interface ChartSuggestion {
chartType: ChartType;
label: string;
isPrimary: boolean;
defaultBindings: FieldBinding[];
}

View File

@ -0,0 +1,8 @@
import { type ExportFormat } from '@/domain';
export interface ExportOptions {
format: ExportFormat;
chartIds: string[];
element?: HTMLElement;
fileName?: string;
}

View File

@ -0,0 +1,7 @@
import { type DataSet } from '@/domain';
import { type ChartSuggestion } from './ChartSuggestion';
export interface ImportResult {
dataSet: DataSet;
suggestions: ChartSuggestion[];
}

View File

@ -0,0 +1,3 @@
export { type ImportResult } from './ImportResult';
export { type ChartSuggestion } from './ChartSuggestion';
export { type ExportOptions } from './ExportOptions';

View File

@ -0,0 +1,28 @@
// Ports
export {
type IImportDataUseCase,
type ICreateChartUseCase,
type IUpdateChartConfigUseCase,
type IExportUseCase,
type ITemplateUseCase,
type ILayoutUseCase,
type IFileParser,
type ParsedSheet,
type IChartRenderer,
type IExportGateway,
type IStorageGateway,
type IGeoDataProvider,
} from './ports';
// DTOs
export { type ImportResult } from './dto/ImportResult';
export { type ChartSuggestion } from './dto/ChartSuggestion';
export { type ExportOptions } from './dto/ExportOptions';
// Use Cases
export { ImportDataUseCase } from './usecases/ImportDataUseCase';
export { CreateChartUseCase } from './usecases/CreateChartUseCase';
export { UpdateChartConfigUseCase } from './usecases/UpdateChartConfigUseCase';
export { ExportUseCase } from './usecases/ExportUseCase';
export { TemplateUseCase } from './usecases/TemplateUseCase';
export { LayoutUseCase } from './usecases/LayoutUseCase';

View File

@ -0,0 +1,17 @@
export {
type IImportDataUseCase,
type ICreateChartUseCase,
type IUpdateChartConfigUseCase,
type IExportUseCase,
type ITemplateUseCase,
type ILayoutUseCase,
} from './input';
export {
type IFileParser,
type ParsedSheet,
type IChartRenderer,
type IExportGateway,
type IStorageGateway,
type IGeoDataProvider,
} from './output';

View File

@ -0,0 +1,6 @@
import { type ChartInstance } from '@/domain';
import { type ChartType } from '@/domain';
export interface ICreateChartUseCase {
execute(dataSetId: string, chartType?: ChartType): ChartInstance;
}

View File

@ -0,0 +1,5 @@
import { type ExportOptions } from '../../dto/ExportOptions';
export interface IExportUseCase {
execute(options: ExportOptions): Promise<Blob>;
}

View File

@ -0,0 +1,5 @@
import { type ImportResult } from '../../dto/ImportResult';
export interface IImportDataUseCase {
execute(file: File): Promise<ImportResult>;
}

View File

@ -0,0 +1,8 @@
import { type LayoutItem } from '@/domain';
export interface ILayoutUseCase {
addItem(chartId: string, x: number, y: number, w: number, h: number): LayoutItem;
updateItem(chartId: string, updates: Partial<LayoutItem>): void;
removeItem(chartId: string): void;
getLayout(): LayoutItem[];
}

View File

@ -0,0 +1,10 @@
import { type ChartInstance, type LayoutItem, type Template } from '@/domain';
export interface ITemplateUseCase {
save(name: string, description: string, charts: ChartInstance[], layout: LayoutItem[], theme: string): Template;
load(templateId: string): Template | null;
list(): Template[];
remove(templateId: string): void;
exportTemplate(templateId: string): string; // JSON string
importTemplate(json: string): Template;
}

View File

@ -0,0 +1,9 @@
import { type FieldBinding, type StyleConfig, type FilterRule, type SortConfig } from '@/domain';
export interface IUpdateChartConfigUseCase {
executeBindings(chartId: string, bindings: FieldBinding[]): void;
executeStyle(chartId: string, style: Partial<StyleConfig>): void;
executeFilters(chartId: string, filters: FilterRule[]): void;
executeSort(chartId: string, sort: SortConfig | undefined): void;
executeTopN(chartId: string, topN: number | undefined): void;
}

View File

@ -0,0 +1,6 @@
export { type IImportDataUseCase } from './IImportDataUseCase';
export { type ICreateChartUseCase } from './ICreateChartUseCase';
export { type IUpdateChartConfigUseCase } from './IUpdateChartConfigUseCase';
export { type IExportUseCase } from './IExportUseCase';
export { type ITemplateUseCase } from './ITemplateUseCase';
export { type ILayoutUseCase } from './ILayoutUseCase';

View File

@ -0,0 +1,5 @@
import { type ChartInstance } from '@/domain';
export interface IChartRenderer {
build(chart: ChartInstance, data: Record<string, any>[]): Record<string, any>; // ECharts option
}

View File

@ -0,0 +1,7 @@
export interface IExportGateway {
exportImage(element: HTMLElement, format: 'png' | 'jpg' | 'svg'): Promise<Blob>;
exportPDF(element: HTMLElement): Promise<Blob>;
exportExcel(data: Record<string, any>[], columns: string[]): Promise<Blob>;
exportPPT(charts: { title: string; imageBlob: Blob }[]): Promise<Blob>;
exportHTML(chartsOption: Record<string, any>[], data: Record<string, any>[]): Promise<Blob>;
}

View File

@ -0,0 +1,10 @@
export interface ParsedSheet {
sheetName: string;
columns: string[];
rows: Record<string, any>[];
}
export interface IFileParser {
parse(file: File): Promise<ParsedSheet[]>;
getSupportedFormats(): string[];
}

View File

@ -0,0 +1,4 @@
export interface IGeoDataProvider {
getProvinceGeoJSON(): Promise<any>;
getCityGeoJSON(province: string): Promise<any>;
}

View File

@ -0,0 +1,6 @@
export interface IStorageGateway {
save<T>(key: string, value: T): void;
load<T>(key: string): T | null;
remove(key: string): void;
listKeys(prefix: string): string[];
}

View File

@ -0,0 +1,5 @@
export { type IFileParser, type ParsedSheet } from './IFileParser';
export { type IChartRenderer } from './IChartRenderer';
export { type IExportGateway } from './IExportGateway';
export { type IStorageGateway } from './IStorageGateway';
export { type IGeoDataProvider } from './IGeoDataProvider';

View File

@ -0,0 +1,90 @@
import { v4 as uuidv4 } from 'uuid';
import { type ChartInstance, type ChartType, type StyleConfig } from '@/domain';
import { type ICreateChartUseCase } from '../ports/input/ICreateChartUseCase';
import { type IChartRenderer } from '../ports/output/IChartRenderer';
const DEFAULT_STYLE: StyleConfig = {
title: {
text: '',
visible: true,
fontSize: 16,
fontWeight: 'bold',
color: '#333333',
align: 'left',
},
colors: ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452', '#9a60b4', '#ea7ccc'],
legend: {
visible: true,
position: 'top',
orient: 'horizontal',
fontSize: 12,
color: '#666666',
},
xAxis: {
visible: true,
labelVisible: true,
labelFontSize: 12,
labelColor: '#666666',
labelRotation: 0,
titleVisible: false,
titleText: '',
gridVisible: true,
gridColor: '#eeeeee',
},
yAxis: {
visible: true,
labelVisible: true,
labelFontSize: 12,
labelColor: '#666666',
labelRotation: 0,
titleVisible: false,
titleText: '',
gridVisible: true,
gridColor: '#eeeeee',
},
dataLabel: {
visible: false,
fontSize: 12,
color: '#333333',
position: 'outside',
format: 'value',
template: undefined,
},
background: {
color: '#ffffff',
opacity: 1,
borderRadius: 4,
},
border: {
visible: false,
color: '#dddddd',
width: 1,
style: 'solid',
},
animation: {
enabled: true,
duration: 500,
easing: 'ease-out',
},
};
export class CreateChartUseCase implements ICreateChartUseCase {
constructor(private readonly chartRenderer: IChartRenderer) {}
execute(dataSetId: string, chartType?: ChartType): ChartInstance {
const now = new Date().toISOString();
return {
id: uuidv4(),
type: chartType ?? 'bar',
dataSetId,
bindings: [],
style: { ...DEFAULT_STYLE },
filters: [],
sort: undefined,
topN: undefined,
createdAt: now,
updatedAt: now,
};
}
}

View File

@ -0,0 +1,54 @@
import { type IExportUseCase } from '../ports/input/IExportUseCase';
import { type IExportGateway } from '../ports/output/IExportGateway';
import { type ExportOptions } from '../dto/ExportOptions';
export class ExportUseCase implements IExportUseCase {
constructor(private readonly exportGateway: IExportGateway) {}
async execute(options: ExportOptions): Promise<Blob> {
const { format, element } = options;
switch (format) {
case 'png':
case 'jpg':
case 'svg': {
if (!element) {
throw new Error(`An HTML element is required for ${format} export`);
}
return this.exportGateway.exportImage(element, format);
}
case 'pdf': {
if (!element) {
throw new Error('An HTML element is required for PDF export');
}
return this.exportGateway.exportPDF(element);
}
case 'excel': {
// Excel export delegates with empty data/columns; the gateway implementation
// is responsible for gathering the actual data from the chart IDs.
return this.exportGateway.exportExcel([], []);
}
case 'ppt': {
// PPT export delegates with empty charts array; the gateway implementation
// is responsible for gathering chart images.
return this.exportGateway.exportPPT([]);
}
case 'html': {
return this.exportGateway.exportHTML([], []);
}
case 'template': {
throw new Error('Template export should use ITemplateUseCase.exportTemplate() instead');
}
default: {
const _exhaustive: never = format;
throw new Error(`Unsupported export format: ${_exhaustive}`);
}
}
}
}

View File

@ -0,0 +1,137 @@
import { v4 as uuidv4 } from 'uuid';
import {
type Column,
type DataSet,
type ChartType,
type FieldBinding,
CHART_TYPE_META,
inferFieldType,
inferDataStructure,
recommendCharts,
} from '@/domain';
import { type IImportDataUseCase } from '../ports/input/IImportDataUseCase';
import { type IFileParser } from '../ports/output/IFileParser';
import { type ImportResult } from '../dto/ImportResult';
import { type ChartSuggestion } from '../dto/ChartSuggestion';
export class ImportDataUseCase implements IImportDataUseCase {
constructor(private readonly fileParser: IFileParser) {}
async execute(file: File): Promise<ImportResult> {
const sheets = await this.fileParser.parse(file);
// Use the first sheet by default
const sheet = sheets[0];
if (!sheet) {
throw new Error('No data found in the file');
}
// Build columns with inferred field types
const columns: Column[] = sheet.columns.map((colName) => {
const sampleValues = sheet.rows.slice(0, 100).map((row) => row[colName]);
const fieldType = inferFieldType(sampleValues);
return {
name: colName,
type: fieldType,
sampleValues: sampleValues.slice(0, 5),
};
});
// Infer data structure
const dataStructure = inferDataStructure(columns, sheet.rows.length);
// Build dataset entity
const now = new Date().toISOString();
const dataSet: DataSet = {
id: uuidv4(),
fileName: file.name,
sheetName: sheet.sheetName,
columns,
rows: sheet.rows,
rowCount: sheet.rows.length,
dataStructure,
createdAt: now,
updatedAt: now,
};
// Get chart recommendations
const recommendation = recommendCharts(dataStructure);
const suggestions: ChartSuggestion[] = [];
// Primary recommendation
suggestions.push({
chartType: recommendation.primary,
label: CHART_TYPE_META[recommendation.primary].label,
isPrimary: true,
defaultBindings: this.buildDefaultBindings(recommendation.primary, columns),
});
// Alternative recommendations
for (const altType of recommendation.alternatives) {
suggestions.push({
chartType: altType,
label: CHART_TYPE_META[altType].label,
isPrimary: false,
defaultBindings: this.buildDefaultBindings(altType, columns),
});
}
return { dataSet, suggestions };
}
private buildDefaultBindings(chartType: ChartType, columns: Column[]): FieldBinding[] {
const bindings: FieldBinding[] = [];
const meta = CHART_TYPE_META[chartType];
const textCols = columns.filter((c) => c.type === 'text');
const numberCols = columns.filter((c) => c.type === 'number' || c.type === 'percentage');
const dateCols = columns.filter((c) => c.type === 'date');
const geoCols = columns.filter((c) => c.type === 'geo');
for (const axis of meta.requiredBindings) {
switch (axis) {
case 'x': {
const col = dateCols[0] || textCols[0];
if (col) bindings.push({ axis: 'x', columnName: col.name });
break;
}
case 'y': {
const col = numberCols[0];
if (col) bindings.push({ axis: 'y', columnName: col.name, aggregation: 'sum' });
break;
}
case 'value': {
const col = numberCols[0];
if (col) bindings.push({ axis: 'value', columnName: col.name, aggregation: 'sum' });
break;
}
case 'label': {
const col = textCols[0];
if (col) bindings.push({ axis: 'label', columnName: col.name });
break;
}
case 'series': {
const col = textCols.length > 1 ? textCols[1] : textCols[0];
if (col) bindings.push({ axis: 'series', columnName: col.name });
break;
}
case 'geo': {
const col = geoCols[0];
if (col) bindings.push({ axis: 'geo', columnName: col.name });
break;
}
case 'size': {
const col = numberCols.length > 1 ? numberCols[1] : numberCols[0];
if (col) bindings.push({ axis: 'size', columnName: col.name });
break;
}
case 'color': {
const col = textCols[0];
if (col) bindings.push({ axis: 'color', columnName: col.name });
break;
}
}
}
return bindings;
}
}

View File

@ -0,0 +1,42 @@
import { type LayoutItem } from '@/domain';
import { type ILayoutUseCase } from '../ports/input/ILayoutUseCase';
export class LayoutUseCase implements ILayoutUseCase {
private items: LayoutItem[] = [];
addItem(chartId: string, x: number, y: number, w: number, h: number): LayoutItem {
const existing = this.items.find((item) => item.chartId === chartId);
if (existing) {
throw new Error(`Layout item already exists for chart: ${chartId}`);
}
const item: LayoutItem = { chartId, x, y, w, h };
this.items.push(item);
return item;
}
updateItem(chartId: string, updates: Partial<LayoutItem>): void {
const index = this.items.findIndex((item) => item.chartId === chartId);
if (index === -1) {
throw new Error(`Layout item not found for chart: ${chartId}`);
}
this.items[index] = {
...this.items[index],
...updates,
chartId, // ensure chartId cannot be overridden
};
}
removeItem(chartId: string): void {
const index = this.items.findIndex((item) => item.chartId === chartId);
if (index === -1) {
throw new Error(`Layout item not found for chart: ${chartId}`);
}
this.items.splice(index, 1);
}
getLayout(): LayoutItem[] {
return [...this.items];
}
}

View File

@ -0,0 +1,73 @@
import { v4 as uuidv4 } from 'uuid';
import { type ChartInstance, type LayoutItem, type Template } from '@/domain';
import { type ITemplateUseCase } from '../ports/input/ITemplateUseCase';
import { type IStorageGateway } from '../ports/output/IStorageGateway';
const TEMPLATE_KEY_PREFIX = 'template:';
export class TemplateUseCase implements ITemplateUseCase {
constructor(private readonly storage: IStorageGateway) {}
save(
name: string,
description: string,
charts: ChartInstance[],
layout: LayoutItem[],
theme: string,
): Template {
const template: Template = {
id: uuidv4(),
name,
description,
charts: charts.map(({ dataSetId: _, ...rest }) => rest),
layout,
theme,
createdAt: new Date().toISOString(),
};
this.storage.save(`${TEMPLATE_KEY_PREFIX}${template.id}`, template);
return template;
}
load(templateId: string): Template | null {
return this.storage.load<Template>(`${TEMPLATE_KEY_PREFIX}${templateId}`);
}
list(): Template[] {
const keys = this.storage.listKeys(TEMPLATE_KEY_PREFIX);
const templates: Template[] = [];
for (const key of keys) {
const template = this.storage.load<Template>(key);
if (template) {
templates.push(template);
}
}
return templates;
}
remove(templateId: string): void {
this.storage.remove(`${TEMPLATE_KEY_PREFIX}${templateId}`);
}
exportTemplate(templateId: string): string {
const template = this.load(templateId);
if (!template) {
throw new Error(`Template not found: ${templateId}`);
}
return JSON.stringify(template);
}
importTemplate(json: string): Template {
const parsed = JSON.parse(json) as Template;
// Assign a new ID to avoid collisions
const template: Template = {
...parsed,
id: uuidv4(),
createdAt: new Date().toISOString(),
};
this.storage.save(`${TEMPLATE_KEY_PREFIX}${template.id}`, template);
return template;
}
}

View File

@ -0,0 +1,73 @@
import {
type ChartInstance,
type FieldBinding,
type StyleConfig,
type FilterRule,
type SortConfig,
} from '@/domain';
import { type IUpdateChartConfigUseCase } from '../ports/input/IUpdateChartConfigUseCase';
/**
* UpdateChartConfigUseCase provides methods to update individual aspects
* of a chart's configuration. It operates on a mutable chart store
* (provided via constructor) that maps chart IDs to ChartInstance objects.
*/
export class UpdateChartConfigUseCase implements IUpdateChartConfigUseCase {
constructor(
private readonly getChart: (chartId: string) => ChartInstance | undefined,
private readonly setChart: (chart: ChartInstance) => void,
) {}
executeBindings(chartId: string, bindings: FieldBinding[]): void {
const chart = this.resolveChart(chartId);
this.setChart({
...chart,
bindings,
updatedAt: new Date().toISOString(),
});
}
executeStyle(chartId: string, style: Partial<StyleConfig>): void {
const chart = this.resolveChart(chartId);
this.setChart({
...chart,
style: { ...chart.style, ...style },
updatedAt: new Date().toISOString(),
});
}
executeFilters(chartId: string, filters: FilterRule[]): void {
const chart = this.resolveChart(chartId);
this.setChart({
...chart,
filters,
updatedAt: new Date().toISOString(),
});
}
executeSort(chartId: string, sort: SortConfig | undefined): void {
const chart = this.resolveChart(chartId);
this.setChart({
...chart,
sort,
updatedAt: new Date().toISOString(),
});
}
executeTopN(chartId: string, topN: number | undefined): void {
const chart = this.resolveChart(chartId);
this.setChart({
...chart,
topN,
updatedAt: new Date().toISOString(),
});
}
private resolveChart(chartId: string): ChartInstance {
const chart = this.getChart(chartId);
if (!chart) {
throw new Error(`Chart not found: ${chartId}`);
}
return chart;
}
}

View File

@ -0,0 +1,6 @@
export { ImportDataUseCase } from './ImportDataUseCase';
export { CreateChartUseCase } from './CreateChartUseCase';
export { UpdateChartConfigUseCase } from './UpdateChartConfigUseCase';
export { ExportUseCase } from './ExportUseCase';
export { TemplateUseCase } from './TemplateUseCase';
export { LayoutUseCase } from './LayoutUseCase';

View File

@ -0,0 +1,39 @@
{
"type": "FeatureCollection",
"features": [
{"type": "Feature", "properties": {"name": "北京市"}, "geometry": {"type": "Polygon", "coordinates": [[[116.0, 39.6], [116.8, 39.6], [116.8, 40.2], [116.0, 40.2], [116.0, 39.6]]]}},
{"type": "Feature", "properties": {"name": "天津市"}, "geometry": {"type": "Polygon", "coordinates": [[[116.8, 38.6], [117.8, 38.6], [117.8, 39.4], [116.8, 39.4], [116.8, 38.6]]]}},
{"type": "Feature", "properties": {"name": "河北省"}, "geometry": {"type": "Polygon", "coordinates": [[[114.0, 36.0], [119.8, 36.0], [119.8, 42.6], [114.0, 42.6], [114.0, 36.0]]]}},
{"type": "Feature", "properties": {"name": "山西省"}, "geometry": {"type": "Polygon", "coordinates": [[[110.2, 34.6], [114.6, 34.6], [114.6, 40.7], [110.2, 40.7], [110.2, 34.6]]]}},
{"type": "Feature", "properties": {"name": "内蒙古自治区"}, "geometry": {"type": "Polygon", "coordinates": [[[97.2, 37.4], [126.1, 37.4], [126.1, 53.4], [97.2, 53.4], [97.2, 37.4]]]}},
{"type": "Feature", "properties": {"name": "辽宁省"}, "geometry": {"type": "Polygon", "coordinates": [[[118.8, 38.7], [125.8, 38.7], [125.8, 43.5], [118.8, 43.5], [118.8, 38.7]]]}},
{"type": "Feature", "properties": {"name": "吉林省"}, "geometry": {"type": "Polygon", "coordinates": [[[121.6, 40.9], [131.3, 40.9], [131.3, 46.3], [121.6, 46.3], [121.6, 40.9]]]}},
{"type": "Feature", "properties": {"name": "黑龙江省"}, "geometry": {"type": "Polygon", "coordinates": [[[121.2, 43.4], [135.1, 43.4], [135.1, 53.6], [121.2, 53.6], [121.2, 43.4]]]}},
{"type": "Feature", "properties": {"name": "上海市"}, "geometry": {"type": "Polygon", "coordinates": [[[120.9, 30.7], [122.0, 30.7], [122.0, 31.9], [120.9, 31.9], [120.9, 30.7]]]}},
{"type": "Feature", "properties": {"name": "江苏省"}, "geometry": {"type": "Polygon", "coordinates": [[[116.4, 30.8], [121.9, 30.8], [121.9, 35.1], [116.4, 35.1], [116.4, 30.8]]]}},
{"type": "Feature", "properties": {"name": "浙江省"}, "geometry": {"type": "Polygon", "coordinates": [[[118.0, 27.1], [122.5, 27.1], [122.5, 31.2], [118.0, 31.2], [118.0, 27.1]]]}},
{"type": "Feature", "properties": {"name": "安徽省"}, "geometry": {"type": "Polygon", "coordinates": [[[114.9, 29.4], [119.6, 29.4], [119.6, 34.7], [114.9, 34.7], [114.9, 29.4]]]}},
{"type": "Feature", "properties": {"name": "福建省"}, "geometry": {"type": "Polygon", "coordinates": [[[115.8, 23.5], [120.7, 23.5], [120.7, 28.3], [115.8, 28.3], [115.8, 23.5]]]}},
{"type": "Feature", "properties": {"name": "江西省"}, "geometry": {"type": "Polygon", "coordinates": [[[113.6, 24.5], [118.5, 24.5], [118.5, 30.1], [113.6, 30.1], [113.6, 24.5]]]}},
{"type": "Feature", "properties": {"name": "山东省"}, "geometry": {"type": "Polygon", "coordinates": [[[114.8, 34.4], [122.7, 34.4], [122.7, 38.4], [114.8, 38.4], [114.8, 34.4]]]}},
{"type": "Feature", "properties": {"name": "河南省"}, "geometry": {"type": "Polygon", "coordinates": [[[110.4, 31.4], [116.7, 31.4], [116.7, 36.4], [110.4, 36.4], [110.4, 31.4]]]}},
{"type": "Feature", "properties": {"name": "湖北省"}, "geometry": {"type": "Polygon", "coordinates": [[[108.4, 29.0], [116.1, 29.0], [116.1, 33.3], [108.4, 33.3], [108.4, 29.0]]]}},
{"type": "Feature", "properties": {"name": "湖南省"}, "geometry": {"type": "Polygon", "coordinates": [[[108.8, 24.6], [114.3, 24.6], [114.3, 30.1], [108.8, 30.1], [108.8, 24.6]]]}},
{"type": "Feature", "properties": {"name": "广东省"}, "geometry": {"type": "Polygon", "coordinates": [[[109.7, 20.2], [117.2, 20.2], [117.2, 25.5], [109.7, 25.5], [109.7, 20.2]]]}},
{"type": "Feature", "properties": {"name": "广西壮族自治区"}, "geometry": {"type": "Polygon", "coordinates": [[[104.3, 20.9], [112.1, 20.9], [112.1, 26.4], [104.3, 26.4], [104.3, 20.9]]]}},
{"type": "Feature", "properties": {"name": "海南省"}, "geometry": {"type": "Polygon", "coordinates": [[[108.6, 18.2], [111.0, 18.2], [111.0, 20.2], [108.6, 20.2], [108.6, 18.2]]]}},
{"type": "Feature", "properties": {"name": "重庆市"}, "geometry": {"type": "Polygon", "coordinates": [[[105.3, 28.2], [110.2, 28.2], [110.2, 32.2], [105.3, 32.2], [105.3, 28.2]]]}},
{"type": "Feature", "properties": {"name": "四川省"}, "geometry": {"type": "Polygon", "coordinates": [[[97.4, 26.1], [108.5, 26.1], [108.5, 34.3], [97.4, 34.3], [97.4, 26.1]]]}},
{"type": "Feature", "properties": {"name": "贵州省"}, "geometry": {"type": "Polygon", "coordinates": [[[103.6, 24.6], [109.6, 24.6], [109.6, 29.2], [103.6, 29.2], [103.6, 24.6]]]}},
{"type": "Feature", "properties": {"name": "云南省"}, "geometry": {"type": "Polygon", "coordinates": [[[97.5, 21.1], [106.2, 21.1], [106.2, 29.3], [97.5, 29.3], [97.5, 21.1]]]}},
{"type": "Feature", "properties": {"name": "西藏自治区"}, "geometry": {"type": "Polygon", "coordinates": [[[78.4, 26.9], [99.1, 26.9], [99.1, 36.5], [78.4, 36.5], [78.4, 26.9]]]}},
{"type": "Feature", "properties": {"name": "陕西省"}, "geometry": {"type": "Polygon", "coordinates": [[[105.5, 31.7], [111.2, 31.7], [111.2, 39.6], [105.5, 39.6], [105.5, 31.7]]]}},
{"type": "Feature", "properties": {"name": "甘肃省"}, "geometry": {"type": "Polygon", "coordinates": [[[92.3, 32.6], [108.7, 32.6], [108.7, 42.8], [92.3, 42.8], [92.3, 32.6]]]}},
{"type": "Feature", "properties": {"name": "青海省"}, "geometry": {"type": "Polygon", "coordinates": [[[89.4, 31.6], [103.1, 31.6], [103.1, 39.2], [89.4, 39.2], [89.4, 31.6]]]}},
{"type": "Feature", "properties": {"name": "宁夏回族自治区"}, "geometry": {"type": "Polygon", "coordinates": [[[104.3, 35.3], [107.7, 35.3], [107.7, 39.4], [104.3, 39.4], [104.3, 35.3]]]}},
{"type": "Feature", "properties": {"name": "新疆维吾尔自治区"}, "geometry": {"type": "Polygon", "coordinates": [[[73.5, 34.4], [96.4, 34.4], [96.4, 49.2], [73.5, 49.2], [73.5, 34.4]]]}},
{"type": "Feature", "properties": {"name": "台湾省"}, "geometry": {"type": "Polygon", "coordinates": [[[120.0, 21.9], [122.0, 21.9], [122.0, 25.3], [120.0, 25.3], [120.0, 21.9]]]}},
{"type": "Feature", "properties": {"name": "香港特别行政区"}, "geometry": {"type": "Polygon", "coordinates": [[[113.8, 22.2], [114.4, 22.2], [114.4, 22.6], [113.8, 22.6], [113.8, 22.2]]]}},
{"type": "Feature", "properties": {"name": "澳门特别行政区"}, "geometry": {"type": "Polygon", "coordinates": [[[113.5, 22.1], [113.6, 22.1], [113.6, 22.2], [113.5, 22.2], [113.5, 22.1]]]}}
]
}

View File

@ -0,0 +1,41 @@
{
"sheets": [
{
"name": "投诉统计",
"data": [
{"地区": "华东", "商品名称": "手机", "问题类别": "质量问题", "投诉量": 1234, "同比": 12.5, "环比": -3.2},
{"地区": "华东", "商品名称": "电脑", "问题类别": "售后服务", "投诉量": 890, "同比": 8.3, "环比": 2.1},
{"地区": "华东", "商品名称": "家电", "问题类别": "物流问题", "投诉量": 567, "同比": -5.1, "环比": 1.8},
{"地区": "华东", "商品名称": "服装", "问题类别": "虚假宣传", "投诉量": 345, "同比": 15.2, "环比": -1.5},
{"地区": "华东", "商品名称": "食品", "问题类别": "质量问题", "投诉量": 789, "同比": 3.7, "环比": 4.2},
{"地区": "华南", "商品名称": "手机", "问题类别": "售后服务", "投诉量": 1102, "同比": 9.8, "环比": -2.4},
{"地区": "华南", "商品名称": "电脑", "问题类别": "质量问题", "投诉量": 756, "同比": -2.1, "环比": 3.6},
{"地区": "华南", "商品名称": "家电", "问题类别": "虚假宣传", "投诉量": 423, "同比": 7.4, "环比": -0.8},
{"地区": "华南", "商品名称": "服装", "问题类别": "物流问题", "投诉量": 298, "同比": 11.6, "环比": 5.3},
{"地区": "华南", "商品名称": "食品", "问题类别": "售后服务", "投诉量": 634, "同比": -1.3, "环比": 2.7},
{"地区": "华北", "商品名称": "手机", "问题类别": "物流问题", "投诉量": 987, "同比": 6.2, "环比": -4.1},
{"地区": "华北", "商品名称": "电脑", "问题类别": "虚假宣传", "投诉量": 654, "同比": 14.8, "环比": 1.2},
{"地区": "华北", "商品名称": "家电", "问题类别": "质量问题", "投诉量": 812, "同比": -3.5, "环比": -2.6},
{"地区": "华北", "商品名称": "服装", "问题类别": "售后服务", "投诉量": 276, "同比": 4.9, "环比": 3.8},
{"地区": "华北", "商品名称": "食品", "问题类别": "物流问题", "投诉量": 543, "同比": 10.1, "环比": -1.9},
{"地区": "西南", "商品名称": "手机", "问题类别": "虚假宣传", "投诉量": 876, "同比": -4.7, "环比": 2.3},
{"地区": "西南", "商品名称": "电脑", "问题类别": "物流问题", "投诉量": 432, "同比": 13.2, "环比": -3.7},
{"地区": "西南", "商品名称": "家电", "问题类别": "售后服务", "投诉量": 598, "同比": 2.8, "环比": 0.9},
{"地区": "西南", "商品名称": "服装", "问题类别": "质量问题", "投诉量": 367, "同比": 8.6, "环比": -0.4},
{"地区": "西南", "商品名称": "食品", "问题类别": "虚假宣传", "投诉量": 445, "同比": -6.3, "环比": 5.1},
{"地区": "西北", "商品名称": "手机", "问题类别": "质量问题", "投诉量": 534, "同比": 5.4, "环比": -2.8},
{"地区": "西北", "商品名称": "电脑", "问题类别": "售后服务", "投诉量": 312, "同比": -1.8, "环比": 4.5},
{"地区": "西北", "商品名称": "家电", "问题类别": "物流问题", "投诉量": 245, "同比": 16.7, "环比": 1.3},
{"地区": "西北", "商品名称": "服装", "问题类别": "虚假宣传", "投诉量": 189, "同比": 7.1, "环比": -5.2},
{"地区": "西北", "商品名称": "食品", "问题类别": "质量问题", "投诉量": 378, "同比": -0.9, "环比": 3.4},
{"地区": "东北", "商品名称": "手机", "问题类别": "售后服务", "投诉量": 723, "同比": 11.3, "环比": -1.6},
{"地区": "东北", "商品名称": "电脑", "问题类别": "质量问题", "投诉量": 489, "同比": -7.2, "环比": 2.9},
{"地区": "东北", "商品名称": "家电", "问题类别": "虚假宣传", "投诉量": 356, "同比": 3.4, "环比": -0.3},
{"地区": "东北", "商品名称": "服装", "问题类别": "物流问题", "投诉量": 267, "同比": 9.5, "环比": 6.1},
{"地区": "东北", "商品名称": "食品", "问题类别": "售后服务", "投诉量": 412, "同比": -2.6, "环比": -4.8},
{"地区": "华东", "商品名称": "手机", "问题类别": "售后服务", "投诉量": 1056, "同比": 7.9, "环比": 1.4},
{"地区": "华南", "商品名称": "家电", "问题类别": "质量问题", "投诉量": 678, "同比": -3.8, "环比": -2.1}
]
}
]
}

View File

@ -0,0 +1,23 @@
import { type ChartType } from '../valueObjects/ChartType';
import { type SortConfig } from '../valueObjects/SortOrder';
import { type FieldBinding } from './FieldBinding';
import { type StyleConfig } from './StyleConfig';
export interface FilterRule {
columnName: string;
operator: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'not-in' | 'contains' | 'between';
value: any;
}
export interface ChartInstance {
id: string;
type: ChartType;
dataSetId: string;
bindings: FieldBinding[];
style: StyleConfig;
filters: FilterRule[];
sort?: SortConfig;
topN?: number;
createdAt: string;
updatedAt: string;
}

View File

@ -0,0 +1,7 @@
import { type FieldType } from '../valueObjects/FieldType';
export interface Column {
name: string;
type: FieldType;
sampleValues: any[];
}

View File

@ -0,0 +1,16 @@
import { type DataStructureType } from '../valueObjects/DataStructureType';
import { type Column } from './Column';
export type { Column };
export interface DataSet {
id: string;
fileName: string;
sheetName?: string;
columns: Column[];
rows: Record<string, any>[];
rowCount: number;
dataStructure?: DataStructureType;
createdAt: string;
updatedAt: string;
}

View File

@ -0,0 +1,7 @@
import { type AggregationType } from '../valueObjects/Aggregation';
export interface FieldBinding {
axis: 'x' | 'y' | 'series' | 'color' | 'size' | 'label' | 'value' | 'geo';
columnName: string;
aggregation?: AggregationType;
}

View File

@ -0,0 +1,7 @@
export interface LayoutItem {
chartId: string;
x: number;
y: number;
w: number;
h: number;
}

View File

@ -0,0 +1,70 @@
export interface TitleStyle {
text: string;
visible: boolean;
fontSize: number;
fontWeight: 'normal' | 'bold' | 'lighter';
color: string;
align: 'left' | 'center' | 'right';
}
export interface LegendStyle {
visible: boolean;
position: 'top' | 'bottom' | 'left' | 'right';
orient: 'horizontal' | 'vertical';
fontSize: number;
color: string;
}
export interface AxisStyle {
visible: boolean;
labelVisible: boolean;
labelFontSize: number;
labelColor: string;
labelRotation: number;
titleVisible: boolean;
titleText: string;
gridVisible: boolean;
gridColor: string;
}
export type DataLabelFormat = 'value' | 'percent' | 'custom';
export interface DataLabelStyle {
visible: boolean;
fontSize: number;
color: string;
position: 'inside' | 'outside' | 'top' | 'bottom' | 'center';
format: DataLabelFormat;
template?: string;
}
export interface BackgroundStyle {
color: string;
opacity: number;
borderRadius: number;
}
export interface BorderStyle {
visible: boolean;
color: string;
width: number;
style: 'solid' | 'dashed' | 'dotted';
}
export interface AnimationStyle {
enabled: boolean;
duration: number;
easing: 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out';
}
export interface StyleConfig {
title: TitleStyle;
colors: string[];
legend: LegendStyle;
xAxis: AxisStyle;
yAxis: AxisStyle;
dataLabel: DataLabelStyle;
background: BackgroundStyle;
border: BorderStyle;
animation: AnimationStyle;
}

View File

@ -0,0 +1,12 @@
import { type ChartInstance } from './ChartInstance';
import { type LayoutItem } from './LayoutItem';
export interface Template {
id: string;
name: string;
description: string;
charts: Omit<ChartInstance, 'dataSetId'>[];
layout: LayoutItem[];
theme: string;
createdAt: string;
}

View File

@ -0,0 +1,17 @@
export { type Column } from './Column';
export { type DataSet } from './DataSet';
export { type ChartInstance, type FilterRule } from './ChartInstance';
export { type FieldBinding } from './FieldBinding';
export {
type StyleConfig,
type TitleStyle,
type LegendStyle,
type AxisStyle,
type DataLabelFormat,
type DataLabelStyle,
type BackgroundStyle,
type BorderStyle,
type AnimationStyle,
} from './StyleConfig';
export { type LayoutItem } from './LayoutItem';
export { type Template } from './Template';

View File

@ -0,0 +1,53 @@
// Entities
export {
type Column,
type DataSet,
type ChartInstance,
type FilterRule,
type FieldBinding,
type StyleConfig,
type TitleStyle,
type LegendStyle,
type AxisStyle,
type DataLabelStyle,
type BackgroundStyle,
type BorderStyle,
type AnimationStyle,
type LayoutItem,
type Template,
} from './entities';
// Value Objects
export {
type FieldType,
type ChartType,
type ChartTypeMeta,
CHART_TYPE_META,
type AggregationType,
type SortOrder,
type SortConfig,
type ExportFormat,
type DataStructureType,
} from './valueObjects';
// Services
export {
inferFieldType,
inferAllFieldTypes,
inferDataStructure,
recommendCharts,
recommendChartTypes,
type ChartRecommendation,
aggregate,
sortData,
topN,
crossTable,
validateChartBindings,
validateChartInstance,
type ValidationError,
type ValidationResult,
} from './services';
// Rules
export { CHART_BINDING_RULES, type BindingRule, type ChartBindingRule } from './rules/chartBindingRules';
export { FIELD_TYPE_CHART_COMPATIBILITY, getCompatibleChartTypes } from './rules/chartCompatibility';

View File

@ -0,0 +1,121 @@
import { type ChartType } from '../valueObjects/ChartType';
import { type FieldType } from '../valueObjects/FieldType';
export interface BindingRule {
axis: string;
acceptedTypes: FieldType[];
required: boolean;
multiple: boolean;
}
export interface ChartBindingRule {
chartType: ChartType;
bindings: BindingRule[];
}
export const CHART_BINDING_RULES: Record<ChartType, BindingRule[]> = {
kpi: [
{ axis: 'value', acceptedTypes: ['number', 'percentage'], required: true, multiple: false },
{ axis: 'label', acceptedTypes: ['text'], required: false, multiple: false },
],
bar: [
{ axis: 'x', acceptedTypes: ['text', 'date'], required: true, multiple: false },
{ axis: 'y', acceptedTypes: ['number', 'percentage'], required: true, multiple: true },
{ axis: 'color', acceptedTypes: ['text'], required: false, multiple: false },
{ axis: 'label', acceptedTypes: ['text', 'number'], required: false, multiple: false },
],
'grouped-bar': [
{ axis: 'x', acceptedTypes: ['text', 'date'], required: true, multiple: false },
{ axis: 'y', acceptedTypes: ['number', 'percentage'], required: true, multiple: true },
{ axis: 'series', acceptedTypes: ['text'], required: true, multiple: false },
{ axis: 'color', acceptedTypes: ['text'], required: false, multiple: false },
{ axis: 'label', acceptedTypes: ['text', 'number'], required: false, multiple: false },
],
'stacked-bar': [
{ axis: 'x', acceptedTypes: ['text', 'date'], required: true, multiple: false },
{ axis: 'y', acceptedTypes: ['number', 'percentage'], required: true, multiple: true },
{ axis: 'series', acceptedTypes: ['text'], required: true, multiple: false },
{ axis: 'color', acceptedTypes: ['text'], required: false, multiple: false },
{ axis: 'label', acceptedTypes: ['text', 'number'], required: false, multiple: false },
],
'horizontal-bar': [
{ axis: 'x', acceptedTypes: ['text', 'date'], required: true, multiple: false },
{ axis: 'y', acceptedTypes: ['number', 'percentage'], required: true, multiple: true },
{ axis: 'color', acceptedTypes: ['text'], required: false, multiple: false },
{ axis: 'label', acceptedTypes: ['text', 'number'], required: false, multiple: false },
],
line: [
{ axis: 'x', acceptedTypes: ['date', 'text'], required: true, multiple: false },
{ axis: 'y', acceptedTypes: ['number', 'percentage'], required: true, multiple: true },
{ axis: 'series', acceptedTypes: ['text'], required: false, multiple: false },
{ axis: 'color', acceptedTypes: ['text'], required: false, multiple: false },
{ axis: 'label', acceptedTypes: ['text', 'number'], required: false, multiple: false },
],
area: [
{ axis: 'x', acceptedTypes: ['date', 'text'], required: true, multiple: false },
{ axis: 'y', acceptedTypes: ['number', 'percentage'], required: true, multiple: true },
{ axis: 'series', acceptedTypes: ['text'], required: false, multiple: false },
{ axis: 'color', acceptedTypes: ['text'], required: false, multiple: false },
{ axis: 'label', acceptedTypes: ['text', 'number'], required: false, multiple: false },
],
pie: [
{ axis: 'label', acceptedTypes: ['text'], required: true, multiple: false },
{ axis: 'value', acceptedTypes: ['number', 'percentage'], required: true, multiple: false },
{ axis: 'color', acceptedTypes: ['text'], required: false, multiple: false },
],
donut: [
{ axis: 'label', acceptedTypes: ['text'], required: true, multiple: false },
{ axis: 'value', acceptedTypes: ['number', 'percentage'], required: true, multiple: false },
{ axis: 'color', acceptedTypes: ['text'], required: false, multiple: false },
],
scatter: [
{ axis: 'x', acceptedTypes: ['number'], required: true, multiple: false },
{ axis: 'y', acceptedTypes: ['number'], required: true, multiple: false },
{ axis: 'size', acceptedTypes: ['number'], required: false, multiple: false },
{ axis: 'color', acceptedTypes: ['text'], required: false, multiple: false },
{ axis: 'label', acceptedTypes: ['text'], required: false, multiple: false },
],
radar: [
{ axis: 'label', acceptedTypes: ['text'], required: true, multiple: false },
{ axis: 'value', acceptedTypes: ['number', 'percentage'], required: true, multiple: true },
{ axis: 'series', acceptedTypes: ['text'], required: false, multiple: false },
{ axis: 'color', acceptedTypes: ['text'], required: false, multiple: false },
],
wordcloud: [
{ axis: 'label', acceptedTypes: ['text'], required: true, multiple: false },
{ axis: 'value', acceptedTypes: ['number'], required: true, multiple: false },
{ axis: 'color', acceptedTypes: ['text'], required: false, multiple: false },
],
'boston-matrix': [
{ axis: 'x', acceptedTypes: ['number'], required: true, multiple: false },
{ axis: 'y', acceptedTypes: ['number'], required: true, multiple: false },
{ axis: 'size', acceptedTypes: ['number'], required: false, multiple: false },
{ axis: 'label', acceptedTypes: ['text'], required: false, multiple: false },
{ axis: 'color', acceptedTypes: ['text'], required: false, multiple: false },
],
heatmap: [
{ axis: 'x', acceptedTypes: ['text', 'date'], required: true, multiple: false },
{ axis: 'y', acceptedTypes: ['text'], required: true, multiple: false },
{ axis: 'value', acceptedTypes: ['number'], required: true, multiple: false },
{ axis: 'color', acceptedTypes: ['number'], required: false, multiple: false },
],
map: [
{ axis: 'geo', acceptedTypes: ['geo'], required: true, multiple: false },
{ axis: 'value', acceptedTypes: ['number', 'percentage'], required: true, multiple: false },
{ axis: 'color', acceptedTypes: ['number'], required: false, multiple: false },
{ axis: 'label', acceptedTypes: ['text'], required: false, multiple: false },
],
combo: [
{ axis: 'x', acceptedTypes: ['text', 'date'], required: true, multiple: false },
{ axis: 'y', acceptedTypes: ['number', 'percentage'], required: true, multiple: true },
{ axis: 'series', acceptedTypes: ['text'], required: false, multiple: false },
{ axis: 'color', acceptedTypes: ['text'], required: false, multiple: false },
{ axis: 'label', acceptedTypes: ['text', 'number'], required: false, multiple: false },
],
'data-table': [
{ axis: 'x', acceptedTypes: ['text', 'number', 'date', 'percentage', 'geo'], required: false, multiple: true },
{ axis: 'y', acceptedTypes: ['text', 'number', 'date', 'percentage', 'geo'], required: false, multiple: true },
{ axis: 'label', acceptedTypes: ['text'], required: false, multiple: true },
{ axis: 'value', acceptedTypes: ['number', 'percentage'], required: false, multiple: true },
],
};

View File

@ -0,0 +1,44 @@
import { type FieldType } from '../valueObjects/FieldType';
import { type ChartType } from '../valueObjects/ChartType';
/**
* Maps each FieldType to the ChartTypes that can meaningfully use it.
*/
export const FIELD_TYPE_CHART_COMPATIBILITY: Record<FieldType, ChartType[]> = {
number: [
'kpi', 'bar', 'grouped-bar', 'stacked-bar', 'horizontal-bar',
'line', 'area', 'pie', 'donut', 'scatter', 'radar',
'boston-matrix', 'heatmap', 'combo', 'data-table',
],
text: [
'bar', 'grouped-bar', 'stacked-bar', 'horizontal-bar',
'line', 'area', 'pie', 'donut', 'radar',
'wordcloud', 'heatmap', 'combo', 'data-table',
],
date: [
'bar', 'grouped-bar', 'stacked-bar', 'horizontal-bar',
'line', 'area', 'heatmap', 'combo', 'data-table',
],
percentage: [
'kpi', 'bar', 'grouped-bar', 'stacked-bar', 'horizontal-bar',
'line', 'area', 'pie', 'donut', 'radar', 'combo', 'data-table',
],
geo: [
'map', 'data-table',
],
};
/**
* Returns an array of ChartTypes compatible with the given set of FieldTypes.
* A chart type is compatible if every one of its required bindings can be
* satisfied by at least one of the provided field types.
*/
export function getCompatibleChartTypes(fieldTypes: FieldType[]): ChartType[] {
const allCharts = new Set<ChartType>();
for (const ft of fieldTypes) {
for (const ct of FIELD_TYPE_CHART_COMPATIBILITY[ft]) {
allCharts.add(ct);
}
}
return Array.from(allCharts);
}

View File

@ -0,0 +1,65 @@
import { type ChartType } from '../valueObjects/ChartType';
import { type DataStructureType } from '../valueObjects/DataStructureType';
export interface ChartRecommendation {
primary: ChartType;
alternatives: ChartType[];
}
const RECOMMENDATION_MAP: Record<DataStructureType, ChartRecommendation> = {
total: {
primary: 'kpi',
alternatives: ['bar', 'data-table'],
},
'yoy-mom': {
primary: 'kpi',
alternatives: ['bar', 'grouped-bar', 'data-table'],
},
'single-dim-single-metric': {
primary: 'bar',
alternatives: ['horizontal-bar', 'pie', 'donut', 'data-table'],
},
'single-dim-multi-metric': {
primary: 'grouped-bar',
alternatives: ['stacked-bar', 'radar', 'combo', 'data-table'],
},
'dual-dim-single-metric': {
primary: 'grouped-bar',
alternatives: ['stacked-bar', 'heatmap', 'data-table'],
},
'dual-dim-multi-metric': {
primary: 'stacked-bar',
alternatives: ['grouped-bar', 'heatmap', 'combo', 'data-table'],
},
'time-series': {
primary: 'line',
alternatives: ['area', 'bar', 'combo', 'data-table'],
},
geo: {
primary: 'map',
alternatives: ['bar', 'horizontal-bar', 'data-table'],
},
'text-frequency': {
primary: 'wordcloud',
alternatives: ['bar', 'horizontal-bar', 'pie', 'data-table'],
},
'two-dim-evaluation': {
primary: 'scatter',
alternatives: ['boston-matrix', 'data-table'],
},
};
/**
* Returns chart type recommendations based on the inferred data structure.
*/
export function recommendCharts(dataStructure: DataStructureType): ChartRecommendation {
return RECOMMENDATION_MAP[dataStructure];
}
/**
* Returns all recommended chart types (primary + alternatives) as a flat array.
*/
export function recommendChartTypes(dataStructure: DataStructureType): ChartType[] {
const rec = RECOMMENDATION_MAP[dataStructure];
return [rec.primary, ...rec.alternatives];
}

View File

@ -0,0 +1,79 @@
import { type FieldType } from '../valueObjects/FieldType';
import { type DataStructureType } from '../valueObjects/DataStructureType';
interface ColumnInfo {
name: string;
type: FieldType;
}
/**
* Infers the DataStructureType based on the column types and row count.
*/
export function inferDataStructure(
columns: ColumnInfo[],
rowCount: number
): DataStructureType {
const textCols = columns.filter(c => c.type === 'text');
const numberCols = columns.filter(c => c.type === 'number' || c.type === 'percentage');
const dateCols = columns.filter(c => c.type === 'date');
const geoCols = columns.filter(c => c.type === 'geo');
// If very few rows and all columns are numeric -> total or yoy-mom
if (rowCount <= 2 && numberCols.length > 0 && textCols.length === 0 && dateCols.length === 0) {
return rowCount === 1 ? 'total' : 'yoy-mom';
}
// If there's a yoy/mom pattern (2 rows with numeric data and maybe labels)
if (rowCount === 2 && numberCols.length > 0) {
return 'yoy-mom';
}
// Geo column present with numeric -> geo
if (geoCols.length >= 1 && numberCols.length >= 1) {
return 'geo';
}
// Date column present with numeric -> time-series
if (dateCols.length >= 1 && numberCols.length >= 1) {
return 'time-series';
}
// Two text columns + multiple numeric -> dual-dim-multi-metric
if (textCols.length >= 2 && numberCols.length > 1) {
return 'dual-dim-multi-metric';
}
// Two text columns + one numeric -> dual-dim-single-metric
if (textCols.length >= 2 && numberCols.length === 1) {
return 'dual-dim-single-metric';
}
// One text column + multiple numeric -> single-dim-multi-metric
if (textCols.length === 1 && numberCols.length > 1) {
return 'single-dim-multi-metric';
}
// One text column + one numeric -> could be single-dim-single-metric or text-frequency
if (textCols.length === 1 && numberCols.length === 1) {
// Heuristic: if the numeric column name suggests count/frequency
const numColName = numberCols[0].name.toLowerCase();
const frequencyKeywords = ['count', 'frequency', 'freq', '次数', '频次', '数量'];
if (frequencyKeywords.some(kw => numColName.includes(kw))) {
return 'text-frequency';
}
return 'single-dim-single-metric';
}
// Two numeric columns only -> two-dim-evaluation
if (textCols.length === 0 && numberCols.length === 2 && dateCols.length === 0) {
return 'two-dim-evaluation';
}
// Only one numeric column with no dimensions -> total
if (numberCols.length === 1 && textCols.length === 0 && dateCols.length === 0) {
return 'total';
}
// Fallback: single-dim-single-metric
return 'single-dim-single-metric';
}

View File

@ -0,0 +1,142 @@
import { type AggregationType } from '../valueObjects/Aggregation';
import { type SortConfig } from '../valueObjects/SortOrder';
type Row = Record<string, any>;
/**
* Groups rows by a column and applies an aggregation function to a metric column.
*/
export function aggregate(
rows: Row[],
groupByColumns: string[],
metricColumn: string,
aggregation: AggregationType
): Row[] {
const groups = new Map<string, Row[]>();
for (const row of rows) {
const key = groupByColumns.map(col => String(row[col] ?? '')).join('|||');
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key)!.push(row);
}
const result: Row[] = [];
for (const [, groupRows] of groups) {
const baseRow: Row = {};
for (const col of groupByColumns) {
baseRow[col] = groupRows[0][col];
}
const values = groupRows
.map(r => r[metricColumn])
.filter(v => v !== null && v !== undefined && v !== '')
.map(Number)
.filter(n => !isNaN(n));
switch (aggregation) {
case 'sum':
baseRow[metricColumn] = values.reduce((a, b) => a + b, 0);
break;
case 'count':
baseRow[metricColumn] = values.length;
break;
case 'avg':
baseRow[metricColumn] = values.length > 0
? values.reduce((a, b) => a + b, 0) / values.length
: 0;
break;
case 'max':
baseRow[metricColumn] = values.length > 0 ? Math.max(...values) : 0;
break;
case 'min':
baseRow[metricColumn] = values.length > 0 ? Math.min(...values) : 0;
break;
}
result.push(baseRow);
}
return result;
}
/**
* Sorts rows by a column in the given order.
*/
export function sortData(rows: Row[], sortConfig: SortConfig): Row[] {
const sorted = [...rows];
const { column, order } = sortConfig;
sorted.sort((a, b) => {
const valA = a[column];
const valB = b[column];
if (valA === valB) return 0;
if (valA === null || valA === undefined) return 1;
if (valB === null || valB === undefined) return -1;
let comparison: number;
if (typeof valA === 'number' && typeof valB === 'number') {
comparison = valA - valB;
} else {
comparison = String(valA).localeCompare(String(valB));
}
return order === 'asc' ? comparison : -comparison;
});
return sorted;
}
/**
* Returns the top N rows based on a metric column (descending).
*/
export function topN(rows: Row[], n: number, metricColumn: string): Row[] {
const sorted = sortData(rows, { column: metricColumn, order: 'desc' });
return sorted.slice(0, n);
}
/**
* Creates a cross-table (pivot) from rows.
* Rows are grouped by rowDim and colDim, with metric values aggregated by sum.
*/
export function crossTable(
rows: Row[],
rowDim: string,
colDim: string,
metricColumn: string
): { headers: string[]; rows: Row[] } {
// Collect unique column dimension values
const colValues = Array.from(new Set(rows.map(r => String(r[colDim] ?? ''))));
colValues.sort();
// Group by row dimension
const rowGroups = new Map<string, Map<string, number>>();
for (const row of rows) {
const rowKey = String(row[rowDim] ?? '');
const colKey = String(row[colDim] ?? '');
const value = Number(row[metricColumn]) || 0;
if (!rowGroups.has(rowKey)) {
rowGroups.set(rowKey, new Map());
}
const colMap = rowGroups.get(rowKey)!;
colMap.set(colKey, (colMap.get(colKey) || 0) + value);
}
const headers = [rowDim, ...colValues];
const resultRows: Row[] = [];
for (const [rowKey, colMap] of rowGroups) {
const resultRow: Row = { [rowDim]: rowKey };
for (const colVal of colValues) {
resultRow[colVal] = colMap.get(colVal) || 0;
}
resultRows.push(resultRow);
}
return { headers, rows: resultRows };
}

Some files were not shown because too many files have changed in this diff Show More