Files
sankey/miniapp/pages/index/index.js
2026-02-13 22:40:22 +08:00

1109 lines
32 KiB
JavaScript
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.

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: '底部' }
];
const DEFAULT_SANKEY_FILE_NAME = 'data/sankey.xlsx';
const DEFAULT_SANKEY_FILE_PATHS = ['/data/sankey.xlsx', 'data/sankey.xlsx'];
/**
* 数值限制,避免 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));
}
/**
* 统一错误文案:
* - xlsx 解析能力缺失时,固定提示用户去“构建 npm”
* - 其他错误返回原始 message便于定位
*/
function toFriendlyParseError(error, fallbackMessage) {
const message = error && error.message ? String(error.message) : '';
if (message.indexOf('xlsx 解析') >= 0) {
return '当前环境未启用 xlsx 解析,请先在开发者工具执行“构建 npm”';
}
return message || fallbackMessage;
}
/**
* 兼容 onWindowResize 不同回调结构,提取 windowHeight。
*/
function getWindowHeightFromResizePayload(payload) {
if (payload && payload.size && Number.isFinite(payload.size.windowHeight)) {
return payload.size.windowHeight;
}
if (payload && Number.isFinite(payload.windowHeight)) {
return payload.windowHeight;
}
return null;
}
/**
* 方向切换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,
showThemeSheet: false,
uploadMessage: '默认加载 data/sankey.xlsx 中...',
parseError: '',
buildError: '',
columnHeaders: [],
tableRows: [],
sourceDataColumn: null,
sourceDescriptionColumns: [],
targetDescriptionColumns: [],
nodesCount: 0,
linksCount: 0,
droppedRows: 0,
buildWarnings: [],
infoLogs: ['解析信息: 正在加载默认数据文件 data/sankey.xlsx'],
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,
bottomPanelsHeightPx: 300
},
/**
* 页面加载时:
* 1) 根据窗口高度计算底部双窗口的目标高度(默认 300低高度窗口自动压缩
* 2) 监听窗口变化,保持整体布局稳定
*/
onLoad() {
this.updateBottomPanelsHeight();
if (typeof wx.onWindowResize === 'function') {
this._handleWindowResize = (payload) => {
const nextWindowHeight = getWindowHeightFromResizePayload(payload);
this.updateBottomPanelsHeight(nextWindowHeight);
};
wx.onWindowResize(this._handleWindowResize);
}
},
/**
* 页面卸载时移除窗口变化监听,避免重复绑定。
*/
onUnload() {
if (this._handleWindowResize && typeof wx.offWindowResize === 'function') {
wx.offWindowResize(this._handleWindowResize);
}
},
/**
* 计算底部双窗口高度:
* - 优先保持 300px
* - 在小高度窗口中自动收缩到 180~300 区间,避免挤压主预览区
*/
updateBottomPanelsHeight(windowHeight) {
let resolvedWindowHeight = Number(windowHeight);
if (!Number.isFinite(resolvedWindowHeight)) {
try {
if (typeof wx.getWindowInfo === 'function') {
resolvedWindowHeight = wx.getWindowInfo().windowHeight;
} else {
resolvedWindowHeight = wx.getSystemInfoSync().windowHeight;
}
} catch (error) {
resolvedWindowHeight = 760;
}
}
const remainForBottomPanels = resolvedWindowHeight - 360;
const nextHeight = clampNumber(remainForBottomPanels, 180, 300, 300);
if (nextHeight === this.data.bottomPanelsHeightPx) {
return;
}
this.setData(
{
bottomPanelsHeightPx: nextHeight
},
() => {
this.drawSankey();
}
);
},
/**
* 主题选择按钮点击后,切换底部选择器。
*/
onToggleThemeSheet() {
this.setData({ showThemeSheet: !this.data.showThemeSheet });
},
/**
* 关闭主题底部选择器。
*/
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();
this.loadDefaultSankeyFile();
},
/**
* 统一读取并解析文件。
* - CSV 按 utf8 文本读取
* - XLS/XLSX 按二进制读取
*/
readAndApplyFile(filePath, fileName, onReadFailPrefix) {
const that = this;
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: toFriendlyParseError(error, '文件解析失败')
});
that.refreshInfoLogs();
}
},
fail(err) {
that.setData({
parseError: `${onReadFailPrefix}: ${err && err.errMsg ? err.errMsg : '未知错误'}`
});
that.refreshInfoLogs();
}
};
if (isCsvFile) {
readOptions.encoding = 'utf8';
}
wx.getFileSystemManager().readFile(readOptions);
},
/**
* 默认加载项目内置数据data/sankey.xlsx
* 说明:
* - 多路径兜底,兼容不同开发者工具路径解析差异
* - 加载失败不展示占位列,保持“未加载文件”状态
*/
loadDefaultSankeyFile() {
const that = this;
const tryReadByIndex = (index, lastErrorMessage) => {
if (index >= DEFAULT_SANKEY_FILE_PATHS.length) {
that.setData({
uploadMessage: '点击上传或将csv/xls文件拖到这里上传',
parseError: `默认文件加载失败: ${lastErrorMessage || `未找到 ${DEFAULT_SANKEY_FILE_NAME}`}`
});
that.refreshInfoLogs();
return;
}
const candidatePath = DEFAULT_SANKEY_FILE_PATHS[index];
wx.getFileSystemManager().readFile({
filePath: candidatePath,
success(readRes) {
try {
const table = parseTableByFileName(DEFAULT_SANKEY_FILE_NAME, readRes.data);
that.applyParsedTable(table, DEFAULT_SANKEY_FILE_NAME);
} catch (error) {
that.setData({
uploadMessage: '点击上传或将csv/xls文件拖到这里上传',
parseError: toFriendlyParseError(error, '默认文件解析失败')
});
that.refreshInfoLogs();
}
},
fail(err) {
const errorMessage = err && err.errMsg ? err.errMsg : '未知错误';
tryReadByIndex(index + 1, errorMessage);
}
});
};
tryReadByIndex(0, '');
},
/**
* 上传数据文件并解析为表头与数据行。
*/
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';
that.readAndApplyFile(filePath, fileName, '读取文件失败');
},
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 : [],
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'
});
}
});
});
}
});