Files
sankey/src/App.vue
2026-02-12 17:42:11 +08:00

575 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="page">
<header class="top-bar">
<div class="brand">
<img :src="iconWebLogo" alt="webicon" class="logo" />
<img :src="iconTitle" alt="星程桑基图" class="title-logo" />
</div>
<div class="toolbar">
<div class="tool-item theme-trigger">
<span class="tool-label">选择主题</span>
<button class="icon-btn" type="button" @click="toggleThemePicker">
<img :src="iconChooseColor" alt="choose-color" />
</button>
<div v-if="showThemePicker" class="theme-popover">
<div class="theme-header">选择配色主题</div>
<div class="theme-list">
<button
v-for="theme in themes"
:key="theme.id"
class="theme-row"
type="button"
@click="pickTheme(theme.id)"
>
<img :src="selectedThemeId === theme.id ? iconRadioOn : iconRadioOff" alt="主题选择" />
<div class="palette">
<span
v-for="color in theme.colors"
:key="`${theme.id}-${color}`"
class="palette-cell"
:style="{ backgroundColor: color }"
/>
</div>
</button>
</div>
</div>
</div>
<div class="tool-item">
<span class="tool-label">文件上传</span>
<button class="icon-btn" type="button" @click="openFileDialog">
<img :src="iconUpload" alt="upload" />
</button>
</div>
<label
class="upload-area"
@dragover.prevent
@drop.prevent="onDropFile"
@keydown.enter.prevent="openFileDialog"
@click="closeThemePicker"
>
<input
ref="fileInputRef"
class="hidden-input"
type="file"
accept=".csv,.xls,.xlsx"
@change="onFileChange"
/>
<span class="upload-text">{{ uploadMessage }}</span>
</label>
<div class="export-box">
<img :src="iconExport" alt="export" class="export-main" />
<button class="icon-btn export-item" type="button" @click="exportSvg">
<img :src="iconExportSvg" alt="export-svg" />
</button>
<button class="icon-btn export-item" type="button" @click="exportPng">
<img :src="iconExportPng" alt="export-png" />
</button>
</div>
</div>
</header>
<main class="content">
<section class="left-pane">
<article class="panel block-panel">
<h2>源数据</h2>
<div class="field-block">
<div class="field-title-wrap">
<img :src="iconExpand" alt="展开" class="expand-icon" />
<h3>数据列</h3>
</div>
<div
v-for="(header, index) in columnHeaders"
:key="`source-data-${index}`"
class="column-row"
>
<img :src="iconData" alt="数据列" class="column-icon" />
<span class="column-label">{{ header }}</span>
<button class="select-btn" type="button" @click="mapping.sourceDataColumn = index">
<img
:src="mapping.sourceDataColumn === index ? iconRadioOn : iconRadioOff"
alt="单选"
/>
</button>
</div>
</div>
<div class="field-block">
<div class="field-title-wrap">
<img :src="iconExpand" alt="展开" class="expand-icon" />
<h3>描述列</h3>
</div>
<div
v-for="(header, index) in columnHeaders"
:key="`source-desc-${index}`"
class="column-row"
>
<img :src="iconDescription" alt="描述列" class="column-icon" />
<span class="column-label">{{ header }}</span>
<button class="select-btn" type="button" @click="toggleSourceDescription(index)">
<img
:src="
mapping.sourceDescriptionColumns.includes(index) ? iconCheckboxOn : iconCheckboxOff
"
alt="复选"
/>
</button>
</div>
</div>
</article>
<article class="panel block-panel">
<h2>目标数据</h2>
<div class="field-block">
<div class="field-title-wrap">
<img :src="iconExpand" alt="展开" class="expand-icon" />
<h3>描述列</h3>
</div>
<div
v-for="(header, index) in columnHeaders"
:key="`target-desc-${index}`"
class="column-row"
>
<img :src="iconDescription" alt="描述列" class="column-icon" />
<span class="column-label">{{ header }}</span>
<button class="select-btn" type="button" @click="toggleTargetDescription(index)">
<img
:src="
mapping.targetDescriptionColumns.includes(index) ? iconCheckboxOn : iconCheckboxOff
"
alt="复选"
/>
</button>
</div>
</div>
</article>
</section>
<section class="panel preview-panel">
<div class="preview-head">
<h2>桑基图预览</h2>
<div class="preview-controls">
<label>
方向
<select v-model="direction">
<option value="source-to-target">source -&gt; target</option>
<option value="target-to-source">target -&gt; source</option>
</select>
</label>
<label>
间距
<input v-model.number="nodeGap" type="range" min="8" max="42" />
</label>
<label>
边距
<input v-model.number="chartPadding" type="range" min="10" max="70" />
</label>
</div>
</div>
<div v-if="buildError" class="error-text">{{ buildError }}</div>
<div v-if="parseError" class="error-text">{{ parseError }}</div>
<div class="example-line">示例{{ previewExample }}</div>
<div ref="chartRef" class="chart-area" />
<div v-if="buildWarnings.length > 0" class="warning-area">
<strong>解析告警 8 </strong>
<ul>
<li v-for="warning in buildWarnings" :key="warning">{{ warning }}</li>
</ul>
</div>
</section>
</main>
<footer class="footer">@版权说明星程社所有反馈邮箱douboer@gmail.com</footer>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch, watchEffect } from 'vue';
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 {
applyDirection,
buildSankeyData,
parseDataFile,
type MappingConfig,
type RawTable,
type SankeyBuildResult
} from './core';
import iconWebLogo from '../assets/icons/webicon.png';
import iconTitle from '../assets/icons/星程字体转换.svg';
import iconChooseColor from '../assets/icons/choose-color.svg';
import iconUpload from '../assets/icons/upload.svg';
import iconExport from '../assets/icons/export.svg';
import iconExportSvg from '../assets/icons/export-svg.svg';
import iconExportPng from '../assets/icons/export-png.svg';
import iconData from '../assets/icons/data.svg';
import iconDescription from '../assets/icons/description.svg';
import iconRadioOn from '../assets/icons/radiobutton.svg';
import iconRadioOff from '../assets/icons/radiobutton-no.svg';
import iconCheckboxOn from '../assets/icons/checkbox.svg';
import iconCheckboxOff from '../assets/icons/checkbox-no.svg';
import iconExpand from '../assets/icons/expand.svg';
echarts.use([SankeyChart, TooltipComponent, CanvasRenderer]);
/**
* 主题色板列表。
* 颜色来自 Figma 示例,保证与设计稿观感一致。
*/
const themes = [
{
id: 'morandi',
colors: ['#F4F1DE', '#EAB69F', '#E07A5F', '#8F5D5D', '#3D405B', '#5F797B', '#81B29A', '#9EB998']
},
{
id: 'purple',
colors: ['#F72585', '#B5179E', '#7209B7', '#560BAD', '#480CA8', '#3A0CA3', '#3F37C9', '#4895EF', '#4CC9F0']
},
{
id: 'fog',
colors: ['#E8EDDF', '#CFDBD5', '#B7B7A4', '#A5A58D', '#6B705C', '#4F5D75', '#5D576B', '#6D597A']
},
{
id: 'sunset',
colors: ['#355070', '#515575', '#6D597A', '#915F78', '#B56576', '#CD6873', '#E56B6F', '#E88C7D', '#EAAC8B']
}
] as const;
const selectedThemeId = ref<(typeof themes)[number]['id']>('purple');
const showThemePicker = ref(false);
const uploadMessage = ref('点击上传或将csv/xls文件拖到这里上传');
const parseError = ref('');
const buildError = ref('');
const chartRef = ref<HTMLDivElement | null>(null);
const fileInputRef = ref<HTMLInputElement | null>(null);
let chartInstance: echarts.EChartsType | null = null;
const rawTable = ref<RawTable | null>(null);
const buildResult = ref<SankeyBuildResult | null>(null);
const mapping = reactive<MappingConfig>({
sourceDataColumn: 1,
sourceDescriptionColumns: [0, 1],
targetDescriptionColumns: [2],
delimiter: '-'
});
const direction = ref<'source-to-target' | 'target-to-source'>('source-to-target');
const nodeGap = ref(24);
const chartPadding = ref(30);
const selectedTheme = computed(() => themes.find((item) => item.id === selectedThemeId.value) ?? themes[0]);
const columnHeaders = computed(() => {
const headers = rawTable.value?.headers ?? [];
if (headers.length > 0) {
return headers.map((item, index) => item || `${index + 1}`);
}
return ['列1', '列2', '列3', '列4'];
});
const buildWarnings = computed(() => buildResult.value?.meta.warnings.slice(0, 8) ?? []);
const previewExample = computed(() => {
const table = rawTable.value;
if (!table || table.rows.length === 0) {
return '宁波北欧10-2582 -> 嘉兴四级算力池-11623-小模型';
}
const firstRow = table.rows[0];
const sourceParts =
mapping.sourceDescriptionColumns.length > 0
? mapping.sourceDescriptionColumns.map((column) => firstRow[column] ?? '').filter(Boolean)
: [firstRow[mapping.sourceDataColumn ?? 0] ?? ''];
const targetParts = mapping.targetDescriptionColumns
.map((column) => firstRow[column] ?? '')
.filter(Boolean);
return `${sourceParts.join(mapping.delimiter)} -> ${targetParts.join(mapping.delimiter)}`;
});
const chartNodes = computed(() => {
const links = buildResult.value ? applyDirection(buildResult.value.links, direction.value) : [];
const names = new Set<string>();
links.forEach((link) => {
names.add(link.source);
names.add(link.target);
});
const palette = selectedTheme.value.colors;
return Array.from(names).map((name, index) => ({
name,
itemStyle: {
color: palette[index % palette.length]
}
}));
});
const chartLinks = computed(() => {
if (!buildResult.value) {
return [];
}
return applyDirection(buildResult.value.links, direction.value);
});
const chartOption = computed<EChartsOption>(() => {
return {
backgroundColor: '#f7f8fa',
tooltip: {
trigger: 'item'
},
series: [
{
type: 'sankey',
left: chartPadding.value,
top: chartPadding.value,
right: chartPadding.value,
bottom: chartPadding.value,
nodeAlign: 'justify',
nodeGap: nodeGap.value,
nodeWidth: 14,
roam: true,
label: {
color: '#4e5969',
fontSize: 12
},
lineStyle: {
color: 'source',
curveness: 0.45,
opacity: 0.45
},
data: chartNodes.value,
links: chartLinks.value
}
]
};
});
/**
* 每次映射配置变化都实时重新聚合,保持“输入即预览”的交互。
*/
watchEffect(() => {
const table = rawTable.value;
if (!table) {
buildResult.value = null;
buildError.value = '';
return;
}
if (mapping.sourceDataColumn === null) {
buildResult.value = null;
buildError.value = '请选择源数据列';
return;
}
if (mapping.targetDescriptionColumns.length === 0) {
buildResult.value = null;
buildError.value = '请至少选择一个目标描述列';
return;
}
try {
buildResult.value = buildSankeyData(table, {
sourceDataColumn: mapping.sourceDataColumn,
sourceDescriptionColumns: [...mapping.sourceDescriptionColumns],
targetDescriptionColumns: [...mapping.targetDescriptionColumns],
delimiter: mapping.delimiter
});
buildError.value = '';
} catch (error) {
buildResult.value = null;
buildError.value = error instanceof Error ? error.message : '构建桑基图失败';
}
});
watch(
chartOption,
() => {
if (!chartInstance) {
return;
}
chartInstance.setOption(chartOption.value, true);
},
{ deep: true }
);
function syncChartSize(): void {
chartInstance?.resize();
}
function closeThemePicker(): void {
showThemePicker.value = false;
}
function toggleThemePicker(): void {
showThemePicker.value = !showThemePicker.value;
}
function pickTheme(themeId: (typeof themes)[number]['id']): void {
selectedThemeId.value = themeId;
showThemePicker.value = false;
}
function toggleSourceDescription(column: number): void {
if (mapping.sourceDescriptionColumns.includes(column)) {
mapping.sourceDescriptionColumns = mapping.sourceDescriptionColumns.filter((item) => item !== column);
return;
}
mapping.sourceDescriptionColumns = [...mapping.sourceDescriptionColumns, column].sort((a, b) => a - b);
}
function toggleTargetDescription(column: number): void {
if (mapping.targetDescriptionColumns.includes(column)) {
mapping.targetDescriptionColumns = mapping.targetDescriptionColumns.filter((item) => item !== column);
return;
}
mapping.targetDescriptionColumns = [...mapping.targetDescriptionColumns, column].sort((a, b) => a - b);
}
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);
mapping.sourceDataColumn = safeColumn(1);
mapping.sourceDescriptionColumns = [safeColumn(0), safeColumn(1)].filter(
(item, index, list) => list.indexOf(item) === index
);
mapping.targetDescriptionColumns = [safeColumn(2), safeColumn(3)].filter(
(item, index, list) => list.indexOf(item) === index
);
}
/**
* 统一处理上传文件,支持点击上传和拖拽上传两种入口。
*/
async function loadDataFile(file: File): Promise<void> {
parseError.value = '';
try {
const parsed = await parseDataFile(file);
rawTable.value = parsed;
setDefaultMappingByColumns(parsed.headers.length);
uploadMessage.value = `已加载: ${file.name}${parsed.rows.length} 行)`;
} catch (error) {
parseError.value = error instanceof Error ? error.message : '文件解析失败';
}
}
async function onFileChange(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
await loadDataFile(file);
input.value = '';
}
async function onDropFile(event: DragEvent): Promise<void> {
const file = event.dataTransfer?.files?.[0];
if (!file) {
return;
}
await loadDataFile(file);
}
function formatFileTimestamp(): string {
const now = new Date();
const pad = (value: number) => String(value).padStart(2, '0');
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(
now.getHours()
)}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
}
function downloadByDataUrl(dataUrl: string, filename: string): void {
const anchor = document.createElement('a');
anchor.href = dataUrl;
anchor.download = filename;
document.body.append(anchor);
anchor.click();
anchor.remove();
}
function exportSvg(): void {
if (!chartInstance) {
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 dataUrl = chartInstance.getDataURL({
type: 'svg',
backgroundColor: '#ffffff',
pixelRatio: 2
});
downloadByDataUrl(dataUrl, `sankey_${formatFileTimestamp()}.svg`);
}
function exportPng(): void {
if (!chartInstance) {
return;
}
const dataUrl = chartInstance.getDataURL({
type: 'png',
backgroundColor: '#ffffff',
pixelRatio: 2
});
downloadByDataUrl(dataUrl, `sankey_${formatFileTimestamp()}.png`);
}
onMounted(() => {
const container = chartRef.value;
if (!container) {
return;
}
chartInstance = echarts.init(container, undefined, { renderer: 'canvas' });
chartInstance.setOption(chartOption.value);
window.addEventListener('resize', syncChartSize);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', syncChartSize);
chartInstance?.dispose();
chartInstance = null;
});
</script>