update at 2026-02-13 14:20:39

This commit is contained in:
douboer@gmail.com
2026-02-13 14:20:39 +08:00
parent 92105bbcde
commit 36befbb63f
2 changed files with 165 additions and 95 deletions

View File

@@ -196,17 +196,6 @@
/> />
</button> </button>
</div> </div>
<div class="column-row total-row">
<img :src="iconDescription" alt="总和" class="column-icon" />
<span class="column-label">总和</span>
<button
class="select-btn"
type="button"
@click="targetShowTotal = !targetShowTotal"
>
<img :src="targetShowTotal ? iconCheckboxOn : iconCheckboxOff" alt="复选" />
</button>
</div>
</div> </div>
</template> </template>
</div> </div>
@@ -259,6 +248,18 @@
<span class="direction-switch-thumb" /> <span class="direction-switch-thumb" />
</span> </span>
</button> </button>
<label class="label-position-control">
<span class="label-position-label">标签位置</span>
<select v-model="labelPositionMode" class="label-position-select">
<option
v-for="option in labelPositionOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</label>
</div> </div>
</div> </div>
@@ -296,7 +297,7 @@ import * as echarts from 'echarts/core';
import type { EChartsOption } from 'echarts'; import type { EChartsOption } from 'echarts';
import { SankeyChart } from 'echarts/charts'; import { SankeyChart } from 'echarts/charts';
import { TooltipComponent } from 'echarts/components'; import { TooltipComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers'; import { CanvasRenderer, SVGRenderer } from 'echarts/renderers';
import { import {
applyDirection, applyDirection,
buildSankeyData, buildSankeyData,
@@ -325,7 +326,7 @@ import iconZhedie from '../assets/icons/zhedie.svg';
import iconGap from '../assets/icons/gap.svg'; import iconGap from '../assets/icons/gap.svg';
import iconPadding from '../assets/icons/padding.svg'; import iconPadding from '../assets/icons/padding.svg';
echarts.use([SankeyChart, TooltipComponent, CanvasRenderer]); echarts.use([SankeyChart, TooltipComponent, CanvasRenderer, SVGRenderer]);
const themes = THEME_PRESETS; const themes = THEME_PRESETS;
const selectedThemeId = ref<string>('figma-violet'); const selectedThemeId = ref<string>('figma-violet');
@@ -350,7 +351,7 @@ const buildResult = ref<SankeyBuildResult | null>(null);
const mapping = reactive<MappingConfig>({ const mapping = reactive<MappingConfig>({
sourceDataColumn: 2, sourceDataColumn: 2,
sourceDescriptionColumns: [0, 1], sourceDescriptionColumns: [0],
targetDescriptionColumns: [2], targetDescriptionColumns: [2],
delimiter: '-' delimiter: '-'
}); });
@@ -358,7 +359,14 @@ const mapping = reactive<MappingConfig>({
const direction = ref<'source-to-target' | 'target-to-source'>('source-to-target'); const direction = ref<'source-to-target' | 'target-to-source'>('source-to-target');
const nodeGap = ref(5); const nodeGap = ref(5);
const chartPadding = ref(24); const chartPadding = ref(24);
const targetShowTotal = ref(true); type LabelPositionMode = 'inner' | 'outer' | 'left' | 'right';
const labelPositionMode = ref<LabelPositionMode>('inner');
const labelPositionOptions: Array<{ value: LabelPositionMode; label: string }> = [
{ value: 'inner', label: '向内' },
{ value: 'outer', label: '向外' },
{ value: 'left', label: '向左' },
{ value: 'right', label: '向右' }
];
/** /**
* 左侧字段区块的展开/折叠状态。 * 左侧字段区块的展开/折叠状态。
* true 表示展开false 表示折叠。 * true 表示展开false 表示折叠。
@@ -385,65 +393,70 @@ const columnHeaders = computed(() => {
return ['列1', '列2', '列3', '列4']; 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<string, number>();
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<string, string>();
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) ?? []); 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 chartNodes = computed(() => {
const links = chartLinks.value; const links = chartLinks.value;
const names = new Set<string>(); const names = new Set<string>();
const incomingCountMap = new Map<string, number>();
const outgoingCountMap = new Map<string, number>();
links.forEach((link) => { links.forEach((link) => {
names.add(link.source); names.add(link.source);
names.add(link.target); 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; const palette = selectedTheme.value.colors;
return Array.from(names).map((name, index) => ({ 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, name,
itemStyle: { itemStyle: {
color: palette[index % palette.length] color: palette[index % palette.length]
},
label: position
? {
position
} }
})); : undefined
};
});
}); });
const chartLinks = computed(() => { const chartLinks = computed(() => {
@@ -451,12 +464,7 @@ const chartLinks = computed(() => {
return []; return [];
} }
const linksWithDisplayName = buildResult.value.links.map((link) => ({ return applyDirection(buildResult.value.links, direction.value);
...link,
target: targetDisplayNameMap.value.get(link.target) ?? link.target
}));
return applyDirection(linksWithDisplayName, direction.value);
}); });
const chartOption = computed<EChartsOption>(() => { const chartOption = computed<EChartsOption>(() => {
@@ -742,15 +750,40 @@ function openFileDialog(): void {
fileInputRef.value?.click(); 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 (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 (item, index, list) => list.indexOf(item) === index
); );
} }
@@ -764,7 +797,7 @@ async function loadDataFile(file: File): Promise<void> {
try { try {
const parsed = await parseDataFile(file); const parsed = await parseDataFile(file);
rawTable.value = parsed; rawTable.value = parsed;
setDefaultMappingByColumns(parsed.headers.length); setDefaultMappingByHeaders(parsed.headers);
uploadMessage.value = `已加载: ${file.name}${parsed.rows.length} 行)`; uploadMessage.value = `已加载: ${file.name}${parsed.rows.length} 行)`;
} catch (error) { } catch (error) {
parseError.value = error instanceof Error ? error.message : '文件解析失败'; parseError.value = error instanceof Error ? error.message : '文件解析失败';
@@ -808,7 +841,7 @@ async function loadDefaultExampleFile(): Promise<void> {
const buffer = await response.arrayBuffer(); const buffer = await response.arrayBuffer();
const parsed = parseXlsxBuffer(buffer); const parsed = parseXlsxBuffer(buffer);
rawTable.value = parsed; rawTable.value = parsed;
setDefaultMappingByColumns(parsed.headers.length); setDefaultMappingByHeaders(parsed.headers);
uploadMessage.value = `已加载: example0.xlsx${parsed.rows.length} 行)`; uploadMessage.value = `已加载: example0.xlsx${parsed.rows.length} 行)`;
parseError.value = ''; parseError.value = '';
} catch (error) { } catch (error) {
@@ -838,22 +871,33 @@ function exportSvg(): void {
return; return;
} }
const svgEl = chartRef.value?.querySelector('svg'); const width = chartInstance.getWidth();
if (svgEl) { const height = chartInstance.getHeight();
const serialized = new XMLSerializer().serializeToString(svgEl); const tempContainer = document.createElement('div');
const blob = new Blob([serialized], { type: 'image/svg+xml;charset=utf-8' }); tempContainer.style.position = 'fixed';
const url = URL.createObjectURL(blob); tempContainer.style.left = '-99999px';
downloadByDataUrl(url, `sankey_${formatFileTimestamp()}.svg`); tempContainer.style.top = '-99999px';
URL.revokeObjectURL(url); tempContainer.style.width = `${width}px`;
return; tempContainer.style.height = `${height}px`;
} tempContainer.style.opacity = '0';
tempContainer.style.pointerEvents = 'none';
document.body.append(tempContainer);
const dataUrl = chartInstance.getDataURL({ 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', type: 'svg',
backgroundColor: '#ffffff', backgroundColor: '#ffffff'
pixelRatio: 2
}); });
downloadByDataUrl(dataUrl, `sankey_${formatFileTimestamp()}.svg`); downloadByDataUrl(dataUrl, `sankey_${formatFileTimestamp()}.svg`);
} finally {
svgChart?.dispose();
tempContainer.remove();
}
} }
function exportPng(): void { function exportPng(): void {

View File

@@ -399,10 +399,6 @@ body {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.total-row .column-label {
color: var(--text-4);
}
.select-btn { .select-btn {
border: 0; border: 0;
background: transparent; background: transparent;
@@ -587,6 +583,32 @@ body {
flex-direction: row-reverse; 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 { .example-line {
margin: 4px 0 8px; margin: 4px 0 8px;
color: var(--text-4); color: var(--text-4);
@@ -668,6 +690,10 @@ body {
gap: 12px; gap: 12px;
} }
.label-position-control {
margin-left: 0;
}
.footer { .footer {
font-size: 14px; font-size: 14px;
} }