From 36befbb63f2158f330481b5a0bc04bd88dd59ac5 Mon Sep 17 00:00:00 2001 From: "douboer@gmail.com" Date: Fri, 13 Feb 2026 14:20:39 +0800 Subject: [PATCH] update at 2026-02-13 14:20:39 --- src/App.vue | 226 +++++++++++++++++++++++++++++-------------------- src/styles.css | 34 +++++++- 2 files changed, 165 insertions(+), 95 deletions(-) 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; }