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:
parent
6e3127e7d6
commit
6079ec8b97
|
|
@ -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 -->
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
@AGENTS.md
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: 'export',
|
||||||
|
transpilePackages: ['echarts', 'echarts-wordcloud'],
|
||||||
|
images: {
|
||||||
|
unoptimized: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './gateways';
|
||||||
|
export * from './state';
|
||||||
|
export * from './presenters';
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './redux';
|
||||||
|
export * from './zustand';
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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 }),
|
||||||
|
}));
|
||||||
|
|
@ -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 };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
@ -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 |
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { AppShell } from '@/frameworks/components/layout/AppShell';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return <AppShell />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { type ChartType, type FieldBinding } from '@/domain';
|
||||||
|
|
||||||
|
export interface ChartSuggestion {
|
||||||
|
chartType: ChartType;
|
||||||
|
label: string;
|
||||||
|
isPrimary: boolean;
|
||||||
|
defaultBindings: FieldBinding[];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { type ExportFormat } from '@/domain';
|
||||||
|
|
||||||
|
export interface ExportOptions {
|
||||||
|
format: ExportFormat;
|
||||||
|
chartIds: string[];
|
||||||
|
element?: HTMLElement;
|
||||||
|
fileName?: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { type DataSet } from '@/domain';
|
||||||
|
import { type ChartSuggestion } from './ChartSuggestion';
|
||||||
|
|
||||||
|
export interface ImportResult {
|
||||||
|
dataSet: DataSet;
|
||||||
|
suggestions: ChartSuggestion[];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { type ImportResult } from './ImportResult';
|
||||||
|
export { type ChartSuggestion } from './ChartSuggestion';
|
||||||
|
export { type ExportOptions } from './ExportOptions';
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { type ChartInstance } from '@/domain';
|
||||||
|
import { type ChartType } from '@/domain';
|
||||||
|
|
||||||
|
export interface ICreateChartUseCase {
|
||||||
|
execute(dataSetId: string, chartType?: ChartType): ChartInstance;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { type ExportOptions } from '../../dto/ExportOptions';
|
||||||
|
|
||||||
|
export interface IExportUseCase {
|
||||||
|
execute(options: ExportOptions): Promise<Blob>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { type ImportResult } from '../../dto/ImportResult';
|
||||||
|
|
||||||
|
export interface IImportDataUseCase {
|
||||||
|
execute(file: File): Promise<ImportResult>;
|
||||||
|
}
|
||||||
|
|
@ -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[];
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { type ChartInstance } from '@/domain';
|
||||||
|
|
||||||
|
export interface IChartRenderer {
|
||||||
|
build(chart: ChartInstance, data: Record<string, any>[]): Record<string, any>; // ECharts option
|
||||||
|
}
|
||||||
|
|
@ -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>;
|
||||||
|
}
|
||||||
|
|
@ -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[];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export interface IGeoDataProvider {
|
||||||
|
getProvinceGeoJSON(): Promise<any>;
|
||||||
|
getCityGeoJSON(province: string): Promise<any>;
|
||||||
|
}
|
||||||
|
|
@ -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[];
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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]]]}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { type FieldType } from '../valueObjects/FieldType';
|
||||||
|
|
||||||
|
export interface Column {
|
||||||
|
name: string;
|
||||||
|
type: FieldType;
|
||||||
|
sampleValues: any[];
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
export interface LayoutItem {
|
||||||
|
chartId: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
|
@ -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
Loading…
Reference in New Issue