diff --git a/src/App.vue b/src/App.vue
index 031025a..552be6b 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -196,17 +196,6 @@
/>
-
-
![总和]()
-
总和
-
-
@@ -259,6 +248,18 @@
+
@@ -296,7 +297,7 @@ import * as echarts from 'echarts/core';
import type { EChartsOption } from 'echarts';
import { SankeyChart } from 'echarts/charts';
import { TooltipComponent } from 'echarts/components';
-import { CanvasRenderer } from 'echarts/renderers';
+import { CanvasRenderer, SVGRenderer } from 'echarts/renderers';
import {
applyDirection,
buildSankeyData,
@@ -325,7 +326,7 @@ import iconZhedie from '../assets/icons/zhedie.svg';
import iconGap from '../assets/icons/gap.svg';
import iconPadding from '../assets/icons/padding.svg';
-echarts.use([SankeyChart, TooltipComponent, CanvasRenderer]);
+echarts.use([SankeyChart, TooltipComponent, CanvasRenderer, SVGRenderer]);
const themes = THEME_PRESETS;
const selectedThemeId = ref('figma-violet');
@@ -350,7 +351,7 @@ const buildResult = ref(null);
const mapping = reactive({
sourceDataColumn: 2,
- sourceDescriptionColumns: [0, 1],
+ sourceDescriptionColumns: [0],
targetDescriptionColumns: [2],
delimiter: '-'
});
@@ -358,7 +359,14 @@ const mapping = reactive({
const direction = ref<'source-to-target' | 'target-to-source'>('source-to-target');
const nodeGap = ref(5);
const chartPadding = ref(24);
-const targetShowTotal = ref(true);
+type LabelPositionMode = 'inner' | 'outer' | 'left' | 'right';
+const labelPositionMode = ref('inner');
+const labelPositionOptions: Array<{ value: LabelPositionMode; label: string }> = [
+ { value: 'inner', label: '向内' },
+ { value: 'outer', label: '向外' },
+ { value: 'left', label: '向左' },
+ { value: 'right', label: '向右' }
+];
/**
* 左侧字段区块的展开/折叠状态。
* true 表示展开,false 表示折叠。
@@ -385,65 +393,70 @@ const columnHeaders = computed(() => {
return ['列1', '列2', '列3', '列4'];
});
-/**
- * 格式化总和数值,统一使用千分位显示。
- */
-function formatTotalValue(value: number): string {
- return value.toLocaleString('zh-CN', { maximumFractionDigits: 10 });
-}
-
-/**
- * 统计每个 target 的汇总值,用于展示“目标管道总和”。
- */
-const targetTotalValueMap = computed(() => {
- const result = buildResult.value;
- const map = new Map();
- if (!result) {
- return map;
- }
-
- result.links.forEach((link) => {
- map.set(link.target, (map.get(link.target) ?? 0) + link.value);
- });
-
- return map;
-});
-
-/**
- * 将原始 target 名称映射为“target + 总和”显示名。
- * 例如:男 -> 男 1,200
- */
-const targetDisplayNameMap = computed(() => {
- const map = new Map();
- if (!targetShowTotal.value) {
- return map;
- }
-
- targetTotalValueMap.value.forEach((total, targetName) => {
- map.set(targetName, `${targetName} ${formatTotalValue(total)}`);
- });
-
- return map;
-});
-
const buildWarnings = computed(() => buildResult.value?.meta.warnings.slice(0, 8) ?? []);
+/**
+ * 根据“标签位置”下拉框值,计算左右端节点的标签朝向。
+ * - 向内:左端标签朝右,右端标签朝左
+ * - 向外:左端标签朝左,右端标签朝右
+ * - 向左:两端都朝左
+ * - 向右:两端都朝右
+ */
+function resolveEdgeLabelPosition(
+ incomingCount: number,
+ outgoingCount: number,
+ mode: LabelPositionMode
+): 'left' | 'right' | undefined {
+ const isLeftEdge = incomingCount === 0 && outgoingCount > 0;
+ const isRightEdge = outgoingCount === 0 && incomingCount > 0;
+
+ if (!isLeftEdge && !isRightEdge) {
+ return undefined;
+ }
+
+ if (mode === 'left') {
+ return 'left';
+ }
+ if (mode === 'right') {
+ return 'right';
+ }
+ if (mode === 'inner') {
+ return isLeftEdge ? 'right' : 'left';
+ }
+ return isLeftEdge ? 'left' : 'right';
+}
+
const chartNodes = computed(() => {
const links = chartLinks.value;
const names = new Set();
+ const incomingCountMap = new Map();
+ const outgoingCountMap = new Map();
links.forEach((link) => {
names.add(link.source);
names.add(link.target);
+ outgoingCountMap.set(link.source, (outgoingCountMap.get(link.source) ?? 0) + 1);
+ incomingCountMap.set(link.target, (incomingCountMap.get(link.target) ?? 0) + 1);
});
const palette = selectedTheme.value.colors;
- return Array.from(names).map((name, index) => ({
- name,
- itemStyle: {
- color: palette[index % palette.length]
- }
- }));
+ return Array.from(names).map((name, index) => {
+ const incomingCount = incomingCountMap.get(name) ?? 0;
+ const outgoingCount = outgoingCountMap.get(name) ?? 0;
+ const position = resolveEdgeLabelPosition(incomingCount, outgoingCount, labelPositionMode.value);
+
+ return {
+ name,
+ itemStyle: {
+ color: palette[index % palette.length]
+ },
+ label: position
+ ? {
+ position
+ }
+ : undefined
+ };
+ });
});
const chartLinks = computed(() => {
@@ -451,12 +464,7 @@ const chartLinks = computed(() => {
return [];
}
- const linksWithDisplayName = buildResult.value.links.map((link) => ({
- ...link,
- target: targetDisplayNameMap.value.get(link.target) ?? link.target
- }));
-
- return applyDirection(linksWithDisplayName, direction.value);
+ return applyDirection(buildResult.value.links, direction.value);
});
const chartOption = computed(() => {
@@ -742,15 +750,40 @@ function openFileDialog(): void {
fileInputRef.value?.click();
}
-function setDefaultMappingByColumns(columnSize: number): void {
- const safeSize = Math.max(columnSize, 1);
- const safeColumn = (index: number): number => Math.min(index, safeSize - 1);
+/**
+ * 将表头统一为可匹配字符串:小写 + 去空格/下划线/中划线。
+ */
+function normalizeHeaderName(header: string): string {
+ return header.trim().toLowerCase().replace(/[\s_-]+/g, '');
+}
- mapping.sourceDataColumn = safeColumn(2);
- mapping.sourceDescriptionColumns = [safeColumn(0), safeColumn(1)].filter(
+/**
+ * 根据候选列名查找列索引;未命中返回 -1。
+ */
+function findHeaderIndex(headers: string[], aliases: string[]): number {
+ const aliasSet = new Set(aliases.map((item) => normalizeHeaderName(item)));
+ return headers.findIndex((header) => aliasSet.has(normalizeHeaderName(header)));
+}
+
+/**
+ * 上传文件后按列名设置默认映射:
+ * - 源数据-数据列:value
+ * - 源数据-描述列:source
+ * - 目标数据-描述列:target
+ * 若未找到对应列名,回退到安全列索引。
+ */
+function setDefaultMappingByHeaders(headers: string[]): void {
+ const safeSize = Math.max(headers.length, 1);
+ const safeColumn = (index: number): number => Math.min(index, safeSize - 1);
+ const sourceDataByName = findHeaderIndex(headers, ['value']);
+ const sourceDescByName = findHeaderIndex(headers, ['source']);
+ const targetDescByName = findHeaderIndex(headers, ['target']);
+
+ mapping.sourceDataColumn = sourceDataByName >= 0 ? sourceDataByName : safeColumn(2);
+ mapping.sourceDescriptionColumns = [sourceDescByName >= 0 ? sourceDescByName : safeColumn(0)].filter(
(item, index, list) => list.indexOf(item) === index
);
- mapping.targetDescriptionColumns = [safeColumn(2), safeColumn(3)].filter(
+ mapping.targetDescriptionColumns = [targetDescByName >= 0 ? targetDescByName : safeColumn(2)].filter(
(item, index, list) => list.indexOf(item) === index
);
}
@@ -764,7 +797,7 @@ async function loadDataFile(file: File): Promise {
try {
const parsed = await parseDataFile(file);
rawTable.value = parsed;
- setDefaultMappingByColumns(parsed.headers.length);
+ setDefaultMappingByHeaders(parsed.headers);
uploadMessage.value = `已加载: ${file.name}(${parsed.rows.length} 行)`;
} catch (error) {
parseError.value = error instanceof Error ? error.message : '文件解析失败';
@@ -808,7 +841,7 @@ async function loadDefaultExampleFile(): Promise {
const buffer = await response.arrayBuffer();
const parsed = parseXlsxBuffer(buffer);
rawTable.value = parsed;
- setDefaultMappingByColumns(parsed.headers.length);
+ setDefaultMappingByHeaders(parsed.headers);
uploadMessage.value = `已加载: example0.xlsx(${parsed.rows.length} 行)`;
parseError.value = '';
} catch (error) {
@@ -838,22 +871,33 @@ function exportSvg(): void {
return;
}
- const svgEl = chartRef.value?.querySelector('svg');
- if (svgEl) {
- const serialized = new XMLSerializer().serializeToString(svgEl);
- const blob = new Blob([serialized], { type: 'image/svg+xml;charset=utf-8' });
- const url = URL.createObjectURL(blob);
- downloadByDataUrl(url, `sankey_${formatFileTimestamp()}.svg`);
- URL.revokeObjectURL(url);
- return;
- }
+ const width = chartInstance.getWidth();
+ const height = chartInstance.getHeight();
+ const tempContainer = document.createElement('div');
+ tempContainer.style.position = 'fixed';
+ tempContainer.style.left = '-99999px';
+ tempContainer.style.top = '-99999px';
+ tempContainer.style.width = `${width}px`;
+ tempContainer.style.height = `${height}px`;
+ tempContainer.style.opacity = '0';
+ tempContainer.style.pointerEvents = 'none';
+ document.body.append(tempContainer);
- const dataUrl = chartInstance.getDataURL({
- type: 'svg',
- backgroundColor: '#ffffff',
- pixelRatio: 2
- });
- downloadByDataUrl(dataUrl, `sankey_${formatFileTimestamp()}.svg`);
+ let svgChart: echarts.EChartsType | null = null;
+ try {
+ // 主图继续走 canvas,导出时单独用 SVG 渲染器重建一份,保证导出格式正确。
+ svgChart = echarts.init(tempContainer, undefined, { renderer: 'svg', width, height });
+ svgChart.setOption(chartInstance.getOption() as EChartsOption, true);
+
+ const dataUrl = svgChart.getDataURL({
+ type: 'svg',
+ backgroundColor: '#ffffff'
+ });
+ downloadByDataUrl(dataUrl, `sankey_${formatFileTimestamp()}.svg`);
+ } finally {
+ svgChart?.dispose();
+ tempContainer.remove();
+ }
}
function exportPng(): void {
diff --git a/src/styles.css b/src/styles.css
index d638c19..00d6ea0 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -399,10 +399,6 @@ body {
text-overflow: ellipsis;
}
-.total-row .column-label {
- color: var(--text-4);
-}
-
.select-btn {
border: 0;
background: transparent;
@@ -587,6 +583,32 @@ body {
flex-direction: row-reverse;
}
+.label-position-control {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.label-position-label {
+ font-size: 14px;
+ font-weight: 600;
+ color: #000;
+ line-height: 1;
+ white-space: nowrap;
+}
+
+.label-position-select {
+ min-width: 64px;
+ height: 24px;
+ border: 1px solid #c9aee0;
+ border-radius: 2px;
+ background: #fff;
+ color: #1d2129;
+ font-size: 14px;
+ padding: 0 20px 0 6px;
+ line-height: 22px;
+}
+
.example-line {
margin: 4px 0 8px;
color: var(--text-4);
@@ -668,6 +690,10 @@ body {
gap: 12px;
}
+ .label-position-control {
+ margin-left: 0;
+ }
+
.footer {
font-size: 14px;
}