update at 2026-02-13 22:26:53

This commit is contained in:
douboer@gmail.com
2026-02-13 22:26:53 +08:00
parent 2fe45888ba
commit 43107afff1
54 changed files with 2183 additions and 311 deletions

View File

@@ -1,12 +1,419 @@
const { parseTableByFileName, buildSankeyData } = require('../../utils/sankey');
/**
* 将表头标准化,便于做中英文别名匹配。
*/
function normalizeHeaderName(header) {
return String(header || '')
.trim()
.toLowerCase()
.replace(/[\s_-]+/g, '');
}
/**
* 根据候选别名查找列索引。
*/
function findHeaderIndex(headers, aliases) {
const aliasSet = {};
aliases.forEach((item) => {
aliasSet[normalizeHeaderName(item)] = true;
});
for (let i = 0; i < headers.length; i += 1) {
if (aliasSet[normalizeHeaderName(headers[i])]) {
return i;
}
}
return -1;
}
/**
* 判断文本是否可作为数值。
*/
function isNumericCell(text) {
const normalized = String(text || '')
.replace(/,/g, '')
.trim();
if (!normalized) {
return false;
}
return !Number.isNaN(Number(normalized));
}
/**
* 取第二行中的首个数字列索引。
*/
function findNumericColumnFromSecondRow(rows) {
const secondRow = rows[0] || [];
for (let i = 0; i < secondRow.length; i += 1) {
if (isNumericCell(secondRow[i])) {
return i;
}
}
return -1;
}
/**
* 从文件名中提取后缀(不含点)。
*/
function getFileExtension(fileName) {
const lowerName = String(fileName || '').toLowerCase();
const lastDotIndex = lowerName.lastIndexOf('.');
if (lastDotIndex < 0) {
return '';
}
return lowerName.slice(lastDotIndex + 1);
}
/**
* 节点配色:与当前画布渲染保持一致,双色交替。
*/
function getNodeColor(index) {
return index % 2 === 0 ? '#9b6bc2' : '#7e95f7';
}
/**
* 标签纵向位置:小节点也能保持可读,不贴边。
*/
function getNodeLabelCenterY(nodeHeight) {
return Math.min(12, Math.max(8, nodeHeight / 2));
}
/**
* 构建导出文件时间戳,格式与 Web 保持一致。
*/
function formatFileTimestamp() {
const now = new Date();
const pad = (value) => String(value).padStart(2, '0');
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(
now.getMinutes()
)}${pad(now.getSeconds())}`;
}
/**
* SVG 文本转义,避免标签名包含特殊字符导致 XML 非法。
*/
function escapeSvgText(text) {
return String(text || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* SVG 数值输出做轻量格式化,避免太长小数。
*/
function formatSvgNumber(value) {
const normalized = Number(value || 0);
if (!Number.isFinite(normalized)) {
return '0';
}
return String(Number(normalized.toFixed(2)));
}
/**
* 下拉选项:与 Web 手机端保持一致。
*/
const GAP_OPTIONS = [0, 5, 10, 15, 20, 25, 30];
const PADDING_OPTIONS = [0, 10, 20, 30, 40, 50, 60, 70, 80];
const LABEL_POSITION_OPTIONS = [
{ value: 'inner', label: '内' },
{ value: 'outer', label: '外' },
{ value: 'left', label: '左' },
{ value: 'right', label: '右' }
];
const TARGET_ALIGN_OPTIONS = [
{ value: 'between', label: '两端' },
{ value: 'middle', label: '中间' },
{ value: 'top', label: '顶部' },
{ value: 'bottom', label: '底部' }
];
/**
* 数值限制,避免 UI 参数导致布局异常。
*/
function clampNumber(value, min, max, fallback) {
const normalized = Number(value);
if (!Number.isFinite(normalized)) {
return fallback;
}
return Math.min(max, Math.max(min, normalized));
}
/**
* 方向切换target->source 时对连线做镜像翻转。
*/
function applyDirection(links, direction) {
if (!Array.isArray(links)) {
return [];
}
if (direction !== 'target-to-source') {
return links;
}
return links.map((link) => ({
source: link.target,
target: link.source,
value: link.value
}));
}
/**
* 根据标签位置模式返回 Canvas 文本绘制参数。
*/
function getCanvasLabelPlacement(layout, isSource, labelPositionMode) {
const sourceInnerX = layout.leftX + layout.nodeWidth + 4;
const sourceOuterX = Math.max(2, layout.leftX - 4);
const targetInnerX = Math.max(2, layout.rightX - 4);
const targetOuterX = layout.rightX + layout.nodeWidth + 4;
if (labelPositionMode === 'outer') {
return isSource
? { x: sourceOuterX, textAlign: 'right' }
: { x: targetOuterX, textAlign: 'left' };
}
if (labelPositionMode === 'left') {
return isSource
? { x: sourceOuterX, textAlign: 'right' }
: { x: targetInnerX, textAlign: 'right' };
}
if (labelPositionMode === 'right') {
return isSource
? { x: sourceInnerX, textAlign: 'left' }
: { x: targetOuterX, textAlign: 'left' };
}
return isSource
? { x: sourceInnerX, textAlign: 'left' }
: { x: targetInnerX, textAlign: 'right' };
}
/**
* 根据标签位置模式返回 SVG 文本锚点参数。
*/
function getSvgLabelPlacement(layout, isSource, labelPositionMode) {
const canvasPlacement = getCanvasLabelPlacement(layout, isSource, labelPositionMode);
return {
x: canvasPlacement.x,
textAnchor: canvasPlacement.textAlign === 'right' ? 'end' : 'start'
};
}
/**
* 统一生成桑基图布局数据,供 canvas 渲染与 SVG 导出共用。
*/
function buildSankeyLayout(links, width, height, renderOptions) {
if (!Array.isArray(links) || links.length === 0) {
return null;
}
const nodeGap = clampNumber(renderOptions && renderOptions.nodeGap, 0, 60, 8);
const padding = clampNumber(renderOptions && renderOptions.chartPadding, 0, 120, 16);
const targetAlignMode = (renderOptions && renderOptions.targetAlignMode) || 'between';
const nodeWidth = 10;
const leftX = padding;
const rightX = Math.max(padding + nodeWidth + 80, width - padding - nodeWidth);
const sourceValueMap = {};
const targetValueMap = {};
links.forEach((link) => {
sourceValueMap[link.source] = (sourceValueMap[link.source] || 0) + Number(link.value || 0);
targetValueMap[link.target] = (targetValueMap[link.target] || 0) + Number(link.value || 0);
});
const sourceNames = Object.keys(sourceValueMap);
const targetNames = Object.keys(targetValueMap);
const totalValue = sourceNames.reduce((sum, name) => sum + sourceValueMap[name], 0);
if (totalValue <= 0) {
return null;
}
const sourceGapCount = Math.max(0, sourceNames.length - 1);
const sourceContentHeight = Math.max(10, height - padding * 2 - sourceGapCount * nodeGap);
const sourceUnitHeight = sourceContentHeight / totalValue;
const sourcePos = {};
let sourceCursorY = padding;
sourceNames.forEach((name) => {
const nodeHeight = Math.max(2, sourceValueMap[name] * sourceUnitHeight);
sourcePos[name] = { y: sourceCursorY, h: nodeHeight };
sourceCursorY += nodeHeight + nodeGap;
});
const targetGapCount = Math.max(0, targetNames.length - 1);
const targetTotalValue = targetNames.reduce((sum, name) => sum + targetValueMap[name], 0);
const targetUnitHeight = targetTotalValue > 0 ? sourceUnitHeight : 0;
const targetTotalNodeHeight = targetTotalValue * targetUnitHeight;
const targetBlockHeight = targetTotalNodeHeight + targetGapCount * nodeGap;
const layoutHeight = Math.max(10, height - padding * 2);
let targetStartY = padding;
if (targetAlignMode === 'middle') {
targetStartY = padding + Math.max(0, (layoutHeight - targetBlockHeight) / 2);
} else if (targetAlignMode === 'bottom') {
targetStartY = padding + Math.max(0, layoutHeight - targetBlockHeight);
} else if (targetAlignMode === 'top') {
targetStartY = padding;
}
const targetPos = {};
let targetCursorY = targetStartY;
targetNames.forEach((name) => {
const nodeHeight = Math.max(2, targetValueMap[name] * targetUnitHeight);
targetPos[name] = { y: targetCursorY, h: nodeHeight };
targetCursorY += nodeHeight + nodeGap;
});
const sourceOffset = {};
const targetOffset = {};
sourceNames.forEach((name) => {
sourceOffset[name] = 0;
});
targetNames.forEach((name) => {
targetOffset[name] = 0;
});
const linkSegments = [];
links.forEach((link) => {
const sourceNode = sourcePos[link.source];
const targetNode = targetPos[link.target];
if (!sourceNode || !targetNode) {
return;
}
const linkHeight = Math.max(1, Number(link.value || 0) * sourceUnitHeight);
const sy = sourceNode.y + sourceOffset[link.source] + linkHeight / 2;
const ty = targetNode.y + targetOffset[link.target] + linkHeight / 2;
sourceOffset[link.source] += linkHeight;
targetOffset[link.target] += linkHeight;
const startX = leftX + nodeWidth;
const endX = rightX;
const controlX = (startX + endX) / 2;
linkSegments.push({
startX,
endX,
controlX,
sy,
ty,
linkHeight
});
});
return {
leftX,
rightX,
nodeWidth,
sourceNames,
targetNames,
sourcePos,
targetPos,
linkSegments
};
}
/**
* 基于布局结果构建可下载的 SVG 字符串。
*/
function buildSankeySvgText(links, width, height, renderOptions) {
const layout = buildSankeyLayout(links, width, height, renderOptions);
if (!layout) {
return '';
}
const labelPositionMode = (renderOptions && renderOptions.labelPositionMode) || 'inner';
const segments = [];
segments.push(
`<svg xmlns="http://www.w3.org/2000/svg" width="${formatSvgNumber(width)}" height="${formatSvgNumber(
height
)}" viewBox="0 0 ${formatSvgNumber(width)} ${formatSvgNumber(height)}">`
);
segments.push(
`<rect x="0" y="0" width="${formatSvgNumber(width)}" height="${formatSvgNumber(height)}" fill="#f7f8fa" />`
);
layout.linkSegments.forEach((segment) => {
const pathData = `M ${formatSvgNumber(segment.startX)} ${formatSvgNumber(segment.sy)} C ${formatSvgNumber(
segment.controlX
)} ${formatSvgNumber(segment.sy)} ${formatSvgNumber(segment.controlX)} ${formatSvgNumber(segment.ty)} ${formatSvgNumber(
segment.endX
)} ${formatSvgNumber(segment.ty)}`;
segments.push(
`<path d="${pathData}" fill="none" stroke="#9b6bc2" stroke-opacity="0.35" stroke-width="${formatSvgNumber(
segment.linkHeight
)}" stroke-linecap="round" />`
);
});
layout.sourceNames.forEach((name, index) => {
const node = layout.sourcePos[name];
const textY = node.y + getNodeLabelCenterY(node.h);
const textPlacement = getSvgLabelPlacement(layout, true, labelPositionMode);
segments.push(
`<rect x="${formatSvgNumber(layout.leftX)}" y="${formatSvgNumber(node.y)}" width="${formatSvgNumber(
layout.nodeWidth
)}" height="${formatSvgNumber(node.h)}" fill="${getNodeColor(index)}" />`
);
segments.push(
`<text x="${formatSvgNumber(textPlacement.x)}" y="${formatSvgNumber(textY)}" fill="#4e5969" font-size="10" text-anchor="${
textPlacement.textAnchor
}" dominant-baseline="middle">${escapeSvgText(name)}</text>`
);
});
layout.targetNames.forEach((name, index) => {
const node = layout.targetPos[name];
const textY = node.y + getNodeLabelCenterY(node.h);
const textPlacement = getSvgLabelPlacement(layout, false, labelPositionMode);
segments.push(
`<rect x="${formatSvgNumber(layout.rightX)}" y="${formatSvgNumber(node.y)}" width="${formatSvgNumber(
layout.nodeWidth
)}" height="${formatSvgNumber(node.h)}" fill="${getNodeColor(index)}" />`
);
segments.push(
`<text x="${formatSvgNumber(textPlacement.x)}" y="${formatSvgNumber(textY)}" fill="#4e5969" font-size="10" text-anchor="${
textPlacement.textAnchor
}" dominant-baseline="middle">${escapeSvgText(name)}</text>`
);
});
segments.push('</svg>');
return segments.join('\n');
}
Page({
data: {
selectedThemeIndex: 1,
sourceColumns: ['列1', '列2'],
targetColumns: ['列1', '列2'],
sourceDataIndex: 1,
sourceDescChecked: [1],
targetDescChecked: [1],
showThemeSheet: false
showThemeSheet: false,
uploadMessage: '点击上传或将csv/xls文件拖到这里上传',
parseError: '',
buildError: '',
columnHeaders: ['列1', '列2', '列3'],
tableRows: [],
sourceDataColumn: null,
sourceDescriptionColumns: [],
targetDescriptionColumns: [],
nodesCount: 0,
linksCount: 0,
droppedRows: 0,
buildWarnings: [],
infoLogs: ['解析信息: 尚未加载数据文件'],
sankeyLinks: [],
sankeyNodes: [],
gapOptions: GAP_OPTIONS,
gapOptionIndex: 1,
nodeGap: GAP_OPTIONS[1],
paddingOptions: PADDING_OPTIONS,
paddingOptionIndex: 2,
chartPadding: PADDING_OPTIONS[2],
direction: 'source-to-target',
labelPositionOptionLabels: LABEL_POSITION_OPTIONS.map((item) => item.label),
labelPositionValues: LABEL_POSITION_OPTIONS.map((item) => item.value),
labelPositionIndex: 0,
labelPositionMode: LABEL_POSITION_OPTIONS[0].value,
targetAlignOptionLabels: TARGET_ALIGN_OPTIONS.map((item) => item.label),
targetAlignValues: TARGET_ALIGN_OPTIONS.map((item) => item.value),
targetAlignIndex: 0,
targetAlignMode: TARGET_ALIGN_OPTIONS[0].value
},
/**
@@ -21,5 +428,538 @@ Page({
*/
onCloseThemeSheet() {
this.setData({ showThemeSheet: false });
},
/**
* 更新“节点间距”下拉值并立即重绘。
*/
onChangeGap(e) {
const pickedIndex = Number(e.detail && e.detail.value);
const safeIndex = Number.isFinite(pickedIndex) ? pickedIndex : 0;
const nextValue = this.data.gapOptions[safeIndex];
this.setData(
{
gapOptionIndex: safeIndex,
nodeGap: Number.isFinite(nextValue) ? nextValue : this.data.nodeGap
},
() => {
this.drawSankey();
}
);
},
/**
* 更新“预览边距”下拉值并立即重绘。
*/
onChangePadding(e) {
const pickedIndex = Number(e.detail && e.detail.value);
const safeIndex = Number.isFinite(pickedIndex) ? pickedIndex : 0;
const nextValue = this.data.paddingOptions[safeIndex];
this.setData(
{
paddingOptionIndex: safeIndex,
chartPadding: Number.isFinite(nextValue) ? nextValue : this.data.chartPadding
},
() => {
this.drawSankey();
}
);
},
/**
* 切换连线方向source->target / target->source
*/
onToggleDirection() {
this.setData(
{
direction:
this.data.direction === 'source-to-target' ? 'target-to-source' : 'source-to-target'
},
() => {
this.drawSankey();
}
);
},
/**
* 更新标签位置模式并重绘。
*/
onChangeLabelPosition(e) {
const pickedIndex = Number(e.detail && e.detail.value);
const safeIndex = Number.isFinite(pickedIndex) ? pickedIndex : 0;
const nextMode = this.data.labelPositionValues[safeIndex];
this.setData(
{
labelPositionIndex: safeIndex,
labelPositionMode: nextMode || 'inner'
},
() => {
this.drawSankey();
}
);
},
/**
* 更新汇聚对齐模式并重绘。
*/
onChangeTargetAlign(e) {
const pickedIndex = Number(e.detail && e.detail.value);
const safeIndex = Number.isFinite(pickedIndex) ? pickedIndex : 0;
const nextMode = this.data.targetAlignValues[safeIndex];
this.setData(
{
targetAlignIndex: safeIndex,
targetAlignMode: nextMode || 'between'
},
() => {
this.drawSankey();
}
);
},
/**
* 页面 ready 后绘制一次空白画布,避免首次显示延迟。
*/
onReady() {
this.drawSankey();
},
/**
* 上传数据文件并解析为表头与数据行。
*/
onChooseFile() {
const that = this;
wx.chooseMessageFile({
count: 1,
type: 'file',
extension: ['csv', 'xls', 'xlsx'],
success(res) {
const picked = res.tempFiles && res.tempFiles[0];
if (!picked) {
return;
}
const filePath = picked.path;
const fileName = picked.name || 'unknown.csv';
const extension = getFileExtension(fileName);
const isCsvFile = extension === 'csv';
const readOptions = {
filePath,
success(readRes) {
try {
const filePayload = isCsvFile ? String(readRes.data || '') : readRes.data;
const table = parseTableByFileName(fileName, filePayload);
that.applyParsedTable(table, fileName);
} catch (error) {
that.setData({
parseError: error && error.message ? error.message : '文件解析失败'
});
that.refreshInfoLogs();
}
},
fail(err) {
that.setData({
parseError: `读取文件失败: ${err && err.errMsg ? err.errMsg : '未知错误'}`
});
that.refreshInfoLogs();
}
};
if (isCsvFile) {
readOptions.encoding = 'utf8';
}
wx.getFileSystemManager().readFile(readOptions);
},
fail(err) {
if (err && String(err.errMsg || '').indexOf('cancel') >= 0) {
return;
}
that.setData({
parseError: `选择文件失败: ${err && err.errMsg ? err.errMsg : '未知错误'}`
});
that.refreshInfoLogs();
}
});
},
/**
* 应用解析结果,并按 Web 规则设置默认映射后触发重建。
*/
applyParsedTable(table, fileName) {
const headers = table.headers || [];
const rows = table.rows || [];
const sourceByName = findHeaderIndex(headers, ['data', 'value', '数据', '值']);
const sourceBySecondRow = findNumericColumnFromSecondRow(rows);
const sourceDescByName = findHeaderIndex(headers, ['source', '源']);
const targetDescByName = findHeaderIndex(headers, ['target', '目标']);
const sourceDataColumn =
sourceByName >= 0 ? sourceByName : sourceBySecondRow >= 0 ? sourceBySecondRow : null;
const sourceDescriptionColumns = sourceDescByName >= 0 ? [sourceDescByName] : [];
const targetDescriptionColumns = targetDescByName >= 0 ? [targetDescByName] : [];
this.setData(
{
uploadMessage: `已加载: ${fileName}${rows.length} 行)`,
parseError: '',
buildError: '',
columnHeaders: headers.length > 0 ? headers : ['列1', '列2', '列3'],
tableRows: rows,
sourceDataColumn,
sourceDescriptionColumns,
targetDescriptionColumns
},
() => {
this.rebuildSankey();
}
);
},
onSelectSourceData(e) {
const index = Number(e.currentTarget.dataset.index);
this.setData(
{
sourceDataColumn: Number.isNaN(index) ? null : index
},
() => {
this.rebuildSankey();
}
);
},
onToggleSourceDesc(e) {
const index = Number(e.currentTarget.dataset.index);
if (Number.isNaN(index)) {
return;
}
const current = this.data.sourceDescriptionColumns || [];
const exists = current.indexOf(index) >= 0;
const next = exists
? current.filter((item) => item !== index)
: current.concat(index).sort((a, b) => a - b);
this.setData(
{
sourceDescriptionColumns: next
},
() => {
this.rebuildSankey();
}
);
},
onToggleTargetDesc(e) {
const index = Number(e.currentTarget.dataset.index);
if (Number.isNaN(index)) {
return;
}
const current = this.data.targetDescriptionColumns || [];
const exists = current.indexOf(index) >= 0;
const next = exists
? current.filter((item) => item !== index)
: current.concat(index).sort((a, b) => a - b);
this.setData(
{
targetDescriptionColumns: next
},
() => {
this.rebuildSankey();
}
);
},
/**
* 根据当前映射重建聚合结果,输出统计与告警。
*/
rebuildSankey() {
const headers = this.data.columnHeaders || [];
const rows = this.data.tableRows || [];
if (rows.length === 0) {
this.setData({
nodesCount: 0,
linksCount: 0,
droppedRows: 0,
buildWarnings: [],
buildError: '',
sankeyLinks: [],
sankeyNodes: []
});
this.refreshInfoLogs();
this.drawSankey();
return;
}
try {
const result = buildSankeyData(
{
headers,
rows
},
{
sourceDataColumn: this.data.sourceDataColumn,
sourceDescriptionColumns: this.data.sourceDescriptionColumns || [],
targetDescriptionColumns: this.data.targetDescriptionColumns || [],
delimiter: '-'
}
);
this.setData({
nodesCount: result.nodes.length,
linksCount: result.links.length,
droppedRows: result.meta.droppedRows,
buildWarnings: (result.meta.warnings || []).slice(0, 8),
buildError: '',
sankeyLinks: result.links || [],
sankeyNodes: result.nodes || []
});
} catch (error) {
this.setData({
nodesCount: 0,
linksCount: 0,
droppedRows: 0,
buildWarnings: [],
buildError: error && error.message ? error.message : '聚合失败',
sankeyLinks: [],
sankeyNodes: []
});
}
this.refreshInfoLogs();
this.drawSankey();
},
/**
* 汇总日志到“信息日志”区域。
*/
refreshInfoLogs() {
const logs = [];
const rows = this.data.tableRows || [];
const headers = this.data.columnHeaders || [];
if (rows.length > 0) {
logs.push(`解析信息: 已加载 ${rows.length} 行,${headers.length}`);
logs.push(`解析信息: 已生成 ${this.data.nodesCount} 个节点,${this.data.linksCount} 条连线`);
} else {
logs.push('解析信息: 尚未加载数据文件');
}
if (this.data.parseError) {
logs.push(`错误: ${this.data.parseError}`);
}
if (this.data.buildError) {
logs.push(`错误: ${this.data.buildError}`);
}
if (this.data.droppedRows > 0) {
logs.push(`告警: 已跳过 ${this.data.droppedRows} 行异常数据`);
}
(this.data.buildWarnings || []).forEach((warning) => {
logs.push(`告警: ${warning}`);
});
this.setData({
infoLogs: logs.length > 0 ? logs : ['暂无日志']
});
},
/**
* 在小程序 canvas 上绘制简化版桑基图。
* 说明:
* - 不依赖第三方图表库,保证小程序端零额外依赖可运行
* - 布局规则尽量对齐 Web左 source、右 target、按值缩放高度
*/
drawSankey() {
const rawLinks = this.data.sankeyLinks || [];
const links = applyDirection(rawLinks, this.data.direction);
const query = wx.createSelectorQuery().in(this);
query.select('#sankeyCanvas').boundingClientRect();
query.exec((res) => {
const rect = res && res[0];
if (!rect || !rect.width || !rect.height) {
return;
}
const width = rect.width;
const height = rect.height;
const ctx = wx.createCanvasContext('sankeyCanvas', this);
ctx.clearRect(0, 0, width, height);
ctx.setFillStyle('#f7f8fa');
ctx.fillRect(0, 0, width, height);
const layout = buildSankeyLayout(links, width, height, {
nodeGap: this.data.nodeGap,
chartPadding: this.data.chartPadding,
targetAlignMode: this.data.targetAlignMode
});
if (!layout) {
ctx.setFillStyle('#86909c');
ctx.setFontSize(12);
ctx.fillText('暂无可预览数据', 12, 24);
ctx.draw();
return;
}
layout.linkSegments.forEach((segment) => {
ctx.setStrokeStyle('rgba(155,107,194,0.35)');
ctx.setLineWidth(segment.linkHeight);
ctx.setLineCap('round');
ctx.beginPath();
ctx.moveTo(segment.startX, segment.sy);
ctx.bezierCurveTo(
segment.controlX,
segment.sy,
segment.controlX,
segment.ty,
segment.endX,
segment.ty
);
ctx.stroke();
});
layout.sourceNames.forEach((name, index) => {
const node = layout.sourcePos[name];
const labelPlacement = getCanvasLabelPlacement(layout, true, this.data.labelPositionMode);
ctx.setFillStyle(getNodeColor(index));
ctx.fillRect(layout.leftX, node.y, layout.nodeWidth, node.h);
ctx.setFillStyle('#4e5969');
ctx.setFontSize(10);
ctx.setTextAlign(labelPlacement.textAlign);
ctx.setTextBaseline('middle');
ctx.fillText(name, labelPlacement.x, node.y + getNodeLabelCenterY(node.h));
});
layout.targetNames.forEach((name, index) => {
const node = layout.targetPos[name];
const labelPlacement = getCanvasLabelPlacement(layout, false, this.data.labelPositionMode);
ctx.setFillStyle(getNodeColor(index));
ctx.fillRect(layout.rightX, node.y, layout.nodeWidth, node.h);
ctx.setFillStyle('#4e5969');
ctx.setFontSize(10);
ctx.setTextAlign(labelPlacement.textAlign);
ctx.setTextBaseline('middle');
ctx.fillText(name, labelPlacement.x, node.y + getNodeLabelCenterY(node.h));
});
ctx.draw();
});
},
/**
* 小程序端目前仅支持 PNG 导出(保存到相册)。
*/
onExportPng() {
const that = this;
wx.canvasToTempFilePath(
{
canvasId: 'sankeyCanvas',
fileType: 'png',
success(res) {
wx.saveImageToPhotosAlbum({
filePath: res.tempFilePath,
success() {
wx.showToast({
title: 'PNG 已保存到相册',
icon: 'success'
});
},
fail(err) {
wx.showToast({
title: err && err.errMsg ? '保存失败,请检查相册权限' : '保存失败',
icon: 'none'
});
}
});
},
fail() {
wx.showToast({
title: '导出 PNG 失败',
icon: 'none'
});
}
},
that
);
},
/**
* 小程序 SVG 导出:
* - 使用与画布一致的布局算法生成 SVG 字符串
* - 写入用户数据目录,支持后续转发/分享
*/
onExportSvg() {
const rawLinks = this.data.sankeyLinks || [];
const links = applyDirection(rawLinks, this.data.direction);
if (!Array.isArray(links) || links.length === 0) {
wx.showToast({
title: '暂无可导出的数据',
icon: 'none'
});
return;
}
const query = wx.createSelectorQuery().in(this);
query.select('#sankeyCanvas').boundingClientRect();
query.exec((res) => {
const rect = res && res[0];
if (!rect || !rect.width || !rect.height) {
wx.showToast({
title: '导出 SVG 失败:画布尺寸无效',
icon: 'none'
});
return;
}
const svgText = buildSankeySvgText(links, rect.width, rect.height, {
nodeGap: this.data.nodeGap,
chartPadding: this.data.chartPadding,
labelPositionMode: this.data.labelPositionMode,
targetAlignMode: this.data.targetAlignMode
});
if (!svgText) {
wx.showToast({
title: '暂无可导出的数据',
icon: 'none'
});
return;
}
const svgFilePath = `${wx.env.USER_DATA_PATH}/sankey_${formatFileTimestamp()}.svg`;
wx.getFileSystemManager().writeFile({
filePath: svgFilePath,
data: svgText,
encoding: 'utf8',
success() {
wx.openDocument({
filePath: svgFilePath,
showMenu: true,
success() {
wx.showToast({
title: 'SVG 已导出',
icon: 'success'
});
},
fail() {
wx.setClipboardData({
data: svgFilePath,
success() {
wx.showToast({
title: 'SVG 已导出,路径已复制',
icon: 'none'
});
},
fail() {
wx.showToast({
title: 'SVG 已导出',
icon: 'success'
});
}
});
}
});
},
fail(err) {
wx.showToast({
title: `导出 SVG 失败: ${err && err.errMsg ? err.errMsg : '未知错误'}`,
icon: 'none'
});
}
});
});
}
});