Files
sankey/miniapp/pages/index/index.js
2026-02-14 11:43:49 +08:00

1555 lines
46 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');
const defaultSankeyTable = require('../../data/sankey.default.js');
const themePresets = require('../../data/theme-presets.js');
/**
* 将表头标准化,便于做中英文别名匹配。
*/
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);
}
/**
* 从路径提取文件名,兼容 Unix/Windows 路径分隔符。
*/
function getBaseNameFromPath(filePath) {
const normalized = String(filePath || '').replace(/\\/g, '/');
const segments = normalized.split('/');
return segments[segments.length - 1] || '';
}
const FALLBACK_THEME_COLORS = ['#9b6bc2', '#7e95f7', '#4cc9f0', '#f4a261'];
/**
* 节点配色:使用当前主题色带循环取色。
*/
function getNodeColor(index, palette) {
const colors = Array.isArray(palette) && palette.length > 0 ? palette : FALLBACK_THEME_COLORS;
return colors[index % colors.length];
}
/**
* 将 16 进制颜色转换为 rgba便于控制连线透明度。
*/
function toRgbaColor(color, alpha) {
const text = String(color || '').trim();
const safeAlpha = Number.isFinite(Number(alpha)) ? Math.max(0, Math.min(1, Number(alpha))) : 1;
const shortHex = /^#([0-9a-fA-F]{3})$/;
const longHex = /^#([0-9a-fA-F]{6})$/;
let hex = '';
if (shortHex.test(text)) {
const raw = text.slice(1);
hex = `${raw[0]}${raw[0]}${raw[1]}${raw[1]}${raw[2]}${raw[2]}`;
} else if (longHex.test(text)) {
hex = text.slice(1);
} else {
return text || `rgba(155,107,194,${safeAlpha})`;
}
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return `rgba(${r},${g},${b},${safeAlpha})`;
}
/**
* 标签纵向位置:小节点也能保持可读,不贴边。
*/
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',
'/miniapp/data/sankey.xlsx',
'data/sankey.xlsx',
'miniapp/data/sankey.xlsx'
];
const DEFAULT_THEME_ID = 'figma-violet';
const DEFAULT_THEME_INDEX = (() => {
if (!Array.isArray(themePresets) || themePresets.length === 0) {
return 0;
}
const index = themePresets.findIndex((item) => item && item.id === DEFAULT_THEME_ID);
return index >= 0 ? index : 0;
})();
const THEME_ROW_HEIGHT_PX = 36;
const THEME_VISIBLE_ROW_COUNT = 6;
const THEME_LIST_HEIGHT_PX = THEME_ROW_HEIGHT_PX * THEME_VISIBLE_ROW_COUNT;
const THEME_LIST_EDGE_SPACER_PX = (THEME_LIST_HEIGHT_PX - THEME_ROW_HEIGHT_PX) / 2;
/**
* 数值限制,避免 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));
}
/**
* 主题弹层打开时的滚动定位:
* 通过上下占位留白,让任意索引都可滚动到视图中心。
*/
function getThemeListScrollTopByIndex(index, totalCount) {
const total = Number(totalCount);
if (!Number.isFinite(total) || total <= 0) {
return 0;
}
const safeIndex = clampNumber(index, 0, total - 1, 0);
return safeIndex * THEME_ROW_HEIGHT_PX;
}
/**
* 将列索引数组归一化为升序数字数组,避免数字/字符串混用导致 UI 判断失效。
*/
function normalizeColumnIndexArray(indices) {
const source = Array.isArray(indices) ? indices : [];
const uniqueMap = {};
source.forEach((item) => {
const numeric = Number(item);
if (!Number.isNaN(numeric) && Number.isFinite(numeric)) {
uniqueMap[numeric] = true;
}
});
return Object.keys(uniqueMap)
.map((item) => Number(item))
.sort((a, b) => a - b);
}
/**
* 将列索引数组转换为布尔映射,供 WXML 直接按下标判断选中态。
*/
function buildColumnSelectedMap(indices) {
const map = {};
normalizeColumnIndexArray(indices).forEach((index) => {
map[index] = true;
});
return map;
}
/**
* 为三组列选择 UI 构造可直接渲染的数据,避免模板层做复杂表达式计算。
*/
function buildColumnRenderRows(headers, sourceDataColumn, sourceDescMap, targetDescMap) {
const normalizedHeaders = Array.isArray(headers) ? headers : [];
const sourceRows = [];
const sourceDescRows = [];
const targetDescRows = [];
normalizedHeaders.forEach((header, index) => {
const label = String(header || '');
sourceRows.push({
index,
label,
selected: sourceDataColumn === index
});
sourceDescRows.push({
index,
label,
checked: !!(sourceDescMap && sourceDescMap[index])
});
targetDescRows.push({
index,
label,
checked: !!(targetDescMap && targetDescMap[index])
});
});
return {
sourceDataRows: sourceRows,
sourceDescRows,
targetDescRows
};
}
/**
* 统一错误文案:
* - xlsx 解析能力缺失时,固定提示用户去“构建 npm”
* - 其他错误返回原始 message便于定位
*/
function toFriendlyParseError(error, fallbackMessage) {
const message = error && error.message ? String(error.message) : '';
if (message.indexOf('xlsx 解析') >= 0) {
return message || '当前环境未启用 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(nodeX, nodeWidth, isSource, isTarget, labelPositionMode) {
const leftOuterX = Math.max(2, nodeX - 4);
const rightOuterX = nodeX + nodeWidth + 4;
if (!isSource && !isTarget) {
if (labelPositionMode === 'left') {
return { x: leftOuterX, textAlign: 'right' };
}
return { x: rightOuterX, textAlign: 'left' };
}
if (labelPositionMode === 'outer') {
return isSource
? { x: leftOuterX, textAlign: 'right' }
: { x: rightOuterX, textAlign: 'left' };
}
if (labelPositionMode === 'left') {
return isSource
? { x: leftOuterX, textAlign: 'right' }
: { x: leftOuterX, textAlign: 'right' };
}
if (labelPositionMode === 'right') {
return isSource
? { x: rightOuterX, textAlign: 'left' }
: { x: rightOuterX, textAlign: 'left' };
}
return isSource
? { x: rightOuterX, textAlign: 'left' }
: { x: leftOuterX, textAlign: 'right' };
}
/**
* 根据标签位置模式返回 SVG 文本锚点参数。
*/
function getSvgLabelPlacement(nodeX, nodeWidth, isSource, isTarget, labelPositionMode) {
const canvasPlacement = getCanvasLabelPlacement(
nodeX,
nodeWidth,
isSource,
isTarget,
labelPositionMode
);
return {
x: canvasPlacement.x,
textAnchor: canvasPlacement.textAlign === 'right' ? 'end' : 'start'
};
}
/**
* 标签纵坐标限制在画布安全区内,避免上下溢出。
*/
function getClampedLabelY(nodeY, nodeHeight, canvasHeight, chartPadding) {
const rawY = nodeY + getNodeLabelCenterY(nodeHeight);
const safeTop = Math.max(0, chartPadding + 6);
const safeBottom = Math.max(safeTop, canvasHeight - chartPadding - 6);
return Math.min(safeBottom, Math.max(safeTop, rawY));
}
/**
* 统一生成桑基图布局数据,供 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 rawPadding = clampNumber(renderOptions && renderOptions.chartPadding, 0, 120, 16);
// 纵向限制padding 不能超过可视高度的一半,避免节点整体被挤出画布。
const padding = Math.min(rawPadding, Math.max(0, height / 2 - 4));
const targetAlignMode = (renderOptions && renderOptions.targetAlignMode) || 'between';
const nodeWidth = 10;
const validLinks = links
.map((link) => ({
source: String(link.source || ''),
target: String(link.target || ''),
value: Number(link.value || 0)
}))
.filter((link) => link.source && link.target && Number.isFinite(link.value) && link.value > 0);
if (validLinks.length === 0) {
return null;
}
const nodeColorIndexMap = {};
const nodeOrderMap = {};
let nodeColorIndexCursor = 0;
let nodeOrderCursor = 0;
const incomingCountMap = {};
const outgoingCountMap = {};
const inValueMap = {};
const outValueMap = {};
const adjacencyMap = {};
const indegreeMap = {};
validLinks.forEach((link) => {
if (!Object.prototype.hasOwnProperty.call(nodeColorIndexMap, link.source)) {
nodeColorIndexMap[link.source] = nodeColorIndexCursor;
nodeColorIndexCursor += 1;
}
if (!Object.prototype.hasOwnProperty.call(nodeColorIndexMap, link.target)) {
nodeColorIndexMap[link.target] = nodeColorIndexCursor;
nodeColorIndexCursor += 1;
}
if (!Object.prototype.hasOwnProperty.call(nodeOrderMap, link.source)) {
nodeOrderMap[link.source] = nodeOrderCursor;
nodeOrderCursor += 1;
}
if (!Object.prototype.hasOwnProperty.call(nodeOrderMap, link.target)) {
nodeOrderMap[link.target] = nodeOrderCursor;
nodeOrderCursor += 1;
}
outgoingCountMap[link.source] = (outgoingCountMap[link.source] || 0) + 1;
incomingCountMap[link.target] = (incomingCountMap[link.target] || 0) + 1;
outValueMap[link.source] = (outValueMap[link.source] || 0) + link.value;
inValueMap[link.target] = (inValueMap[link.target] || 0) + link.value;
if (!adjacencyMap[link.source]) {
adjacencyMap[link.source] = [];
}
adjacencyMap[link.source].push(link.target);
indegreeMap[link.target] = (indegreeMap[link.target] || 0) + 1;
if (!Object.prototype.hasOwnProperty.call(indegreeMap, link.source)) {
indegreeMap[link.source] = indegreeMap[link.source] || 0;
}
});
const nodeNames = Object.keys(nodeOrderMap).sort((a, b) => nodeOrderMap[a] - nodeOrderMap[b]);
const nodeCount = nodeNames.length;
const depthMap = {};
nodeNames.forEach((name) => {
depthMap[name] = 0;
});
const indegreeWorkMap = {};
nodeNames.forEach((name) => {
indegreeWorkMap[name] = indegreeMap[name] || 0;
});
const queue = nodeNames.filter((name) => indegreeWorkMap[name] === 0);
const visitedMap = {};
while (queue.length > 0) {
const current = queue.shift();
visitedMap[current] = true;
const nextTargets = adjacencyMap[current] || [];
nextTargets.forEach((next) => {
const nextDepth = Math.min(nodeCount - 1, (depthMap[current] || 0) + 1);
if (nextDepth > (depthMap[next] || 0)) {
depthMap[next] = nextDepth;
}
indegreeWorkMap[next] = (indegreeWorkMap[next] || 0) - 1;
if (indegreeWorkMap[next] === 0) {
queue.push(next);
}
});
}
// 图中存在环时,继续做有限轮次松弛,尽量拉开层级。
if (Object.keys(visitedMap).length < nodeCount) {
for (let round = 0; round < nodeCount; round += 1) {
let changed = false;
validLinks.forEach((link) => {
const relaxedDepth = Math.min(nodeCount - 1, (depthMap[link.source] || 0) + 1);
if (relaxedDepth > (depthMap[link.target] || 0)) {
depthMap[link.target] = relaxedDepth;
changed = true;
}
});
if (!changed) {
break;
}
}
}
const maxDepth = nodeNames.reduce((max, name) => Math.max(max, depthMap[name] || 0), 0);
const columns = Array.from({ length: maxDepth + 1 }, () => []);
nodeNames.forEach((name) => {
const depth = depthMap[name] || 0;
columns[depth].push(name);
});
columns.forEach((names) => {
names.sort((a, b) => nodeOrderMap[a] - nodeOrderMap[b]);
});
const nodeValueMap = {};
nodeNames.forEach((name) => {
nodeValueMap[name] = Math.max(outValueMap[name] || 0, inValueMap[name] || 0);
});
const availableHeight = Math.max(0, height - padding * 2);
const maxColumnNodeCount = columns.reduce((max, names) => Math.max(max, names.length), 0);
const effectiveGap =
maxColumnNodeCount > 1 ? Math.min(nodeGap, availableHeight / (maxColumnNodeCount - 1)) : nodeGap;
let unitHeight = Infinity;
columns.forEach((names) => {
if (names.length === 0) {
return;
}
const totalValue = names.reduce((sum, name) => sum + (nodeValueMap[name] || 0), 0);
if (totalValue <= 0) {
return;
}
const gapCount = Math.max(0, names.length - 1);
const candidate = (availableHeight - gapCount * effectiveGap) / totalValue;
if (Number.isFinite(candidate) && candidate >= 0) {
unitHeight = Math.min(unitHeight, candidate);
}
});
if (!Number.isFinite(unitHeight) || unitHeight <= 0) {
return null;
}
const columnNodeHeightMap = {};
const columnGapMap = {};
const columnBlockHeightMap = {};
columns.forEach((names, depth) => {
const totalNodeHeight = names.reduce((sum, name) => sum + (nodeValueMap[name] || 0) * unitHeight, 0);
let columnGap = effectiveGap;
if (depth === maxDepth && targetAlignMode === 'between' && names.length > 1) {
const sourceNames = columns[0] || [];
const sourceTotalNodeHeight = sourceNames.reduce(
(sum, name) => sum + (nodeValueMap[name] || 0) * unitHeight,
0
);
const sourceSpanHeight =
sourceTotalNodeHeight + Math.max(0, sourceNames.length - 1) * effectiveGap;
columnGap = (sourceSpanHeight - totalNodeHeight) / (names.length - 1);
}
columnGap = Math.max(0, columnGap);
const blockHeight = totalNodeHeight + Math.max(0, names.length - 1) * columnGap;
columnNodeHeightMap[depth] = totalNodeHeight;
columnGapMap[depth] = columnGap;
columnBlockHeightMap[depth] = blockHeight;
});
const sourceSpanHeight = columnBlockHeightMap[0] || 0;
const leftX = padding;
const rightX = Math.max(padding + nodeWidth + 80, width - padding - nodeWidth);
const columnStep = maxDepth > 0 ? (rightX - leftX) / maxDepth : 0;
const nodePositionMap = {};
const nodes = [];
columns.forEach((names, depth) => {
const blockHeight = columnBlockHeightMap[depth] || 0;
const columnGap = columnGapMap[depth] || 0;
let startY = padding;
if (depth === maxDepth) {
if (targetAlignMode === 'middle') {
startY = padding + Math.max(0, (sourceSpanHeight - blockHeight) / 2);
} else if (targetAlignMode === 'bottom') {
startY = padding + Math.max(0, sourceSpanHeight - blockHeight);
}
}
let cursorY = startY;
const nodeX = leftX + columnStep * depth;
names.forEach((name) => {
const nodeHeight = (nodeValueMap[name] || 0) * unitHeight;
const node = {
name,
depth,
x: nodeX,
y: cursorY,
h: nodeHeight,
colorIndex: Number.isFinite(nodeColorIndexMap[name]) ? nodeColorIndexMap[name] : 0
};
nodes.push(node);
nodePositionMap[name] = node;
cursorY += nodeHeight + columnGap;
});
});
const sourceOffsetMap = {};
const targetOffsetMap = {};
nodeNames.forEach((name) => {
sourceOffsetMap[name] = 0;
targetOffsetMap[name] = 0;
});
const linkSegments = [];
validLinks.forEach((link) => {
const sourceNode = nodePositionMap[link.source];
const targetNode = nodePositionMap[link.target];
if (!sourceNode || !targetNode) {
return;
}
const linkHeight = link.value * unitHeight;
if (linkHeight <= 0) {
return;
}
const sy = sourceNode.y + sourceOffsetMap[link.source] + linkHeight / 2;
const ty = targetNode.y + targetOffsetMap[link.target] + linkHeight / 2;
sourceOffsetMap[link.source] += linkHeight;
targetOffsetMap[link.target] += linkHeight;
const startX = sourceNode.x + nodeWidth;
const endX = targetNode.x;
const distance = endX - startX;
if (distance <= 0) {
return;
}
const controlOffset = distance * 0.45;
linkSegments.push({
startX,
endX,
controlX1: startX + controlOffset,
controlX2: endX - controlOffset,
sy,
ty,
linkHeight,
sourceIndex: sourceNode.colorIndex
});
});
return {
nodeWidth,
maxDepth,
nodes,
nodeColorIndexMap,
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 themeColors =
renderOptions &&
Array.isArray(renderOptions.themeColors) &&
renderOptions.themeColors.length > 0
? renderOptions.themeColors
: FALLBACK_THEME_COLORS;
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.controlX1
)} ${formatSvgNumber(segment.sy)} ${formatSvgNumber(segment.controlX2)} ${formatSvgNumber(segment.ty)} ${formatSvgNumber(
segment.endX
)} ${formatSvgNumber(segment.ty)}`;
segments.push(
`<path d="${pathData}" fill="none" stroke="${toRgbaColor(
getNodeColor(segment.sourceIndex, themeColors),
0.35
)}" stroke-width="${formatSvgNumber(
segment.linkHeight
)}" stroke-linecap="round" />`
);
});
layout.nodes.forEach((node) => {
const isSource = node.depth === 0;
const isTarget = node.depth === layout.maxDepth;
const textY = getClampedLabelY(
node.y,
node.h,
height,
clampNumber(renderOptions && renderOptions.chartPadding, 0, 120, 16)
);
const textPlacement = getSvgLabelPlacement(
node.x,
layout.nodeWidth,
isSource,
isTarget,
labelPositionMode
);
segments.push(
`<rect x="${formatSvgNumber(node.x)}" y="${formatSvgNumber(node.y)}" width="${formatSvgNumber(
layout.nodeWidth
)}" height="${formatSvgNumber(node.h)}" fill="${getNodeColor(node.colorIndex, themeColors)}" />`
);
segments.push(
`<text x="${formatSvgNumber(textPlacement.x)}" y="${formatSvgNumber(textY)}" fill="#4e5969" font-size="10" text-anchor="${
textPlacement.textAnchor
}" dominant-baseline="middle">${escapeSvgText(node.name)}</text>`
);
});
segments.push('</svg>');
return segments.join('\n');
}
Page({
data: {
selectedThemeIndex: DEFAULT_THEME_INDEX,
themes: Array.isArray(themePresets) ? themePresets : [],
showThemeSheet: false,
themeListScrollTop: getThemeListScrollTopByIndex(
DEFAULT_THEME_INDEX,
Array.isArray(themePresets) ? themePresets.length : 0
),
uploadMessage: '默认加载 data/sankey.xlsx 中...',
parseError: '',
buildError: '',
columnHeaders: [],
tableRows: [],
sourceDataColumn: null,
sourceDescriptionColumns: [],
targetDescriptionColumns: [],
sourceDescSelectedMap: {},
targetDescSelectedMap: {},
sourceDataRows: [],
sourceDescRows: [],
targetDescRows: [],
sectionVisibleSourceData: true,
sectionVisibleSourceDesc: true,
sectionVisibleTargetDesc: true,
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() {
const nextVisible = !this.data.showThemeSheet;
if (!nextVisible) {
this.setData({ showThemeSheet: false });
return;
}
const targetScrollTop = getThemeListScrollTopByIndex(
this.data.selectedThemeIndex,
(this.data.themes || []).length
);
this.setData(
{
showThemeSheet: true,
themeListScrollTop: 0
},
() => {
const applyScrollTop = () => {
this.setData({ themeListScrollTop: targetScrollTop });
};
if (typeof wx.nextTick === 'function') {
wx.nextTick(applyScrollTop);
return;
}
setTimeout(applyScrollTop, 0);
}
);
},
/**
* 关闭主题底部选择器。
*/
onCloseThemeSheet() {
this.setData({ showThemeSheet: false });
},
/**
* 切换数据映射区块展开/收起状态,对齐 Web 的 expand/zhedie 行为。
*/
onToggleSection(e) {
const section = String((e.currentTarget.dataset && e.currentTarget.dataset.section) || '');
if (section === 'sourceData') {
this.setData({ sectionVisibleSourceData: !this.data.sectionVisibleSourceData });
return;
}
if (section === 'sourceDesc') {
this.setData({ sectionVisibleSourceDesc: !this.data.sectionVisibleSourceDesc });
return;
}
if (section === 'targetDesc') {
this.setData({ sectionVisibleTargetDesc: !this.data.sectionVisibleTargetDesc });
}
},
/**
* 返回当前选中主题色带。
*/
getCurrentThemeColors() {
const themes = this.data.themes || [];
const selectedTheme = themes[this.data.selectedThemeIndex] || themes[0];
if (!selectedTheme || !Array.isArray(selectedTheme.colors) || selectedTheme.colors.length === 0) {
return FALLBACK_THEME_COLORS;
}
return selectedTheme.colors;
},
/**
* 选择主题后立即生效,并关闭底部主题弹层。
*/
onSelectTheme(e) {
const index = Number(e.currentTarget.dataset.index);
if (Number.isNaN(index)) {
return;
}
this.setData(
{
selectedThemeIndex: index,
showThemeSheet: false
},
() => {
this.drawSankey();
}
);
},
/**
* 更新“节点间距”下拉值并立即重绘。
*/
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 读取(出现 PK... 头)
*/
readAndApplyFile(filePath, fileName, onReadFailPrefix) {
const that = this;
const readOptions = {
filePath,
success(readRes) {
try {
const table = parseTableByFileName(fileName, readRes.data);
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();
}
};
wx.getFileSystemManager().readFile(readOptions);
},
/**
* 默认加载项目内置数据data/sankey.xlsx
* 说明:
* - 多路径兜底,兼容不同开发者工具路径解析差异
* - 加载失败不展示占位列,保持“未加载文件”状态
*/
loadDefaultSankeyFile() {
const that = this;
const applyBuiltInTableFallback = (reasonText) => {
const hasRows =
defaultSankeyTable &&
Array.isArray(defaultSankeyTable.headers) &&
Array.isArray(defaultSankeyTable.rows) &&
defaultSankeyTable.rows.length > 0;
if (!hasRows) {
that.setData({
uploadMessage: '点击上传或将csv/xls文件拖到这里上传',
parseError: `默认文件加载失败: ${reasonText || `未找到 ${DEFAULT_SANKEY_FILE_NAME}`}`
});
that.refreshInfoLogs();
return;
}
that.applyParsedTable(defaultSankeyTable, `${DEFAULT_SANKEY_FILE_NAME}(内置回退)`);
};
const tryReadByIndex = (index, lastErrorMessage) => {
if (index >= DEFAULT_SANKEY_FILE_PATHS.length) {
applyBuiltInTableFallback(lastErrorMessage);
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 || picked.tempFilePath || '';
const fileName =
picked.name ||
getBaseNameFromPath(filePath) ||
// 无法识别文件名时,默认按二进制读取,交由解析器做内容识别。
'upload.bin';
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,
sourceDescSelectedMap: buildColumnSelectedMap(sourceDescriptionColumns),
targetDescSelectedMap: buildColumnSelectedMap(targetDescriptionColumns),
...buildColumnRenderRows(
headers.length > 0 ? headers : [],
sourceDataColumn,
buildColumnSelectedMap(sourceDescriptionColumns),
buildColumnSelectedMap(targetDescriptionColumns)
)
},
() => {
this.rebuildSankey();
}
);
},
onSelectSourceData(e) {
const index = Number(e.currentTarget.dataset.index);
const safeSourceDataColumn = Number.isNaN(index) ? null : index;
this.setData(
{
sourceDataColumn: safeSourceDataColumn,
...buildColumnRenderRows(
this.data.columnHeaders || [],
safeSourceDataColumn,
this.data.sourceDescSelectedMap || {},
this.data.targetDescSelectedMap || {}
)
},
() => {
this.rebuildSankey();
}
);
},
onToggleSourceDesc(e) {
const index = Number(e.currentTarget.dataset.index);
if (Number.isNaN(index)) {
return;
}
const current = normalizeColumnIndexArray(this.data.sourceDescriptionColumns);
const exists = current.indexOf(index) >= 0;
const next = exists
? current.filter((item) => item !== index)
: normalizeColumnIndexArray(current.concat(index));
const nextSourceDescSelectedMap = buildColumnSelectedMap(next);
this.setData(
{
sourceDescriptionColumns: next,
sourceDescSelectedMap: nextSourceDescSelectedMap,
...buildColumnRenderRows(
this.data.columnHeaders || [],
this.data.sourceDataColumn,
nextSourceDescSelectedMap,
this.data.targetDescSelectedMap || {}
)
},
() => {
this.rebuildSankey();
}
);
},
onToggleTargetDesc(e) {
const index = Number(e.currentTarget.dataset.index);
if (Number.isNaN(index)) {
return;
}
const current = normalizeColumnIndexArray(this.data.targetDescriptionColumns);
const exists = current.indexOf(index) >= 0;
const next = exists
? current.filter((item) => item !== index)
: normalizeColumnIndexArray(current.concat(index));
const nextTargetDescSelectedMap = buildColumnSelectedMap(next);
this.setData(
{
targetDescriptionColumns: next,
targetDescSelectedMap: nextTargetDescSelectedMap,
...buildColumnRenderRows(
this.data.columnHeaders || [],
this.data.sourceDataColumn,
this.data.sourceDescSelectedMap || {},
nextTargetDescSelectedMap
)
},
() => {
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: normalizeColumnIndexArray(this.data.sourceDescriptionColumns),
targetDescriptionColumns: normalizeColumnIndexArray(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 themeColors = this.getCurrentThemeColors();
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(toRgbaColor(getNodeColor(segment.sourceIndex, themeColors), 0.35));
ctx.setLineWidth(segment.linkHeight);
ctx.setLineCap('round');
ctx.beginPath();
ctx.moveTo(segment.startX, segment.sy);
ctx.bezierCurveTo(
segment.controlX1,
segment.sy,
segment.controlX2,
segment.ty,
segment.endX,
segment.ty
);
ctx.stroke();
});
layout.nodes.forEach((node) => {
const isSource = node.depth === 0;
const isTarget = node.depth === layout.maxDepth;
const labelPlacement = getCanvasLabelPlacement(
node.x,
layout.nodeWidth,
isSource,
isTarget,
this.data.labelPositionMode
);
const textY = getClampedLabelY(node.y, node.h, height, this.data.chartPadding);
ctx.setFillStyle(getNodeColor(node.colorIndex, themeColors));
ctx.fillRect(node.x, node.y, layout.nodeWidth, node.h);
ctx.setFillStyle('#4e5969');
ctx.setFontSize(10);
ctx.setTextAlign(labelPlacement.textAlign);
ctx.setTextBaseline('middle');
ctx.fillText(node.name, labelPlacement.x, textY);
});
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);
const themeColors = this.getCurrentThemeColors();
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,
themeColors
});
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'
});
}
});
});
}
});