1444 lines
43 KiB
JavaScript
1444 lines
43 KiB
JavaScript
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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
/**
|
||
* 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(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'
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 标签纵坐标限制在画布安全区内,避免上下溢出。
|
||
*/
|
||
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 leftX = padding;
|
||
const rightX = Math.max(padding + nodeWidth + 80, width - padding - nodeWidth);
|
||
|
||
const sourceValueMap = {};
|
||
const targetValueMap = {};
|
||
const nodeColorIndexMap = {};
|
||
let nodeColorIndexCursor = 0;
|
||
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);
|
||
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;
|
||
}
|
||
});
|
||
|
||
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(0, height - padding * 2 - sourceGapCount * nodeGap);
|
||
const sourceUnitHeight = sourceContentHeight / totalValue;
|
||
const sourceTotalNodeHeight = totalValue * sourceUnitHeight;
|
||
const sourceSpanHeight = sourceTotalNodeHeight + sourceGapCount * nodeGap;
|
||
|
||
const sourcePos = {};
|
||
let sourceCursorY = padding;
|
||
sourceNames.forEach((name) => {
|
||
const nodeHeight = 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;
|
||
let targetGap = nodeGap;
|
||
if (targetAlignMode === 'between' && targetNames.length > 1) {
|
||
// 与 Web 端一致:两端对齐时,目标列整体高度与 source 列跨度一致。
|
||
targetGap = (sourceSpanHeight - targetTotalNodeHeight) / (targetNames.length - 1);
|
||
}
|
||
targetGap = Math.max(0, targetGap);
|
||
|
||
const targetBlockHeight = targetTotalNodeHeight + targetGapCount * targetGap;
|
||
let targetStartY = padding;
|
||
if (targetAlignMode === 'middle') {
|
||
targetStartY = padding + Math.max(0, (sourceSpanHeight - targetBlockHeight) / 2);
|
||
} else if (targetAlignMode === 'bottom') {
|
||
targetStartY = padding + Math.max(0, sourceSpanHeight - targetBlockHeight);
|
||
} else if (targetAlignMode === 'top') {
|
||
targetStartY = padding;
|
||
}
|
||
|
||
const targetPos = {};
|
||
let targetCursorY = targetStartY;
|
||
targetNames.forEach((name) => {
|
||
const nodeHeight = targetValueMap[name] * targetUnitHeight;
|
||
targetPos[name] = { y: targetCursorY, h: nodeHeight };
|
||
targetCursorY += nodeHeight + targetGap;
|
||
});
|
||
|
||
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 = Number(link.value || 0) * sourceUnitHeight;
|
||
if (linkHeight <= 0) {
|
||
return;
|
||
}
|
||
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,
|
||
sourceIndex: Number.isFinite(nodeColorIndexMap[link.source]) ? nodeColorIndexMap[link.source] : 0
|
||
});
|
||
});
|
||
|
||
return {
|
||
leftX,
|
||
rightX,
|
||
nodeWidth,
|
||
sourceNames,
|
||
targetNames,
|
||
sourcePos,
|
||
targetPos,
|
||
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.controlX
|
||
)} ${formatSvgNumber(segment.sy)} ${formatSvgNumber(segment.controlX)} ${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.sourceNames.forEach((name, index) => {
|
||
const node = layout.sourcePos[name];
|
||
const colorIndex = Number.isFinite(layout.nodeColorIndexMap[name])
|
||
? layout.nodeColorIndexMap[name]
|
||
: index;
|
||
const textY = getClampedLabelY(
|
||
node.y,
|
||
node.h,
|
||
height,
|
||
clampNumber(renderOptions && renderOptions.chartPadding, 0, 120, 16)
|
||
);
|
||
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(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(name)}</text>`
|
||
);
|
||
});
|
||
|
||
layout.targetNames.forEach((name, index) => {
|
||
const node = layout.targetPos[name];
|
||
const colorIndex = Number.isFinite(layout.nodeColorIndexMap[name])
|
||
? layout.nodeColorIndexMap[name]
|
||
: index;
|
||
const textY = getClampedLabelY(
|
||
node.y,
|
||
node.h,
|
||
height,
|
||
clampNumber(renderOptions && renderOptions.chartPadding, 0, 120, 16)
|
||
);
|
||
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(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(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.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);
|
||
const colorIndex = Number.isFinite(layout.nodeColorIndexMap[name])
|
||
? layout.nodeColorIndexMap[name]
|
||
: index;
|
||
const textY = getClampedLabelY(node.y, node.h, height, this.data.chartPadding);
|
||
ctx.setFillStyle(getNodeColor(colorIndex, themeColors));
|
||
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, textY);
|
||
});
|
||
|
||
layout.targetNames.forEach((name, index) => {
|
||
const node = layout.targetPos[name];
|
||
const labelPlacement = getCanvasLabelPlacement(layout, false, this.data.labelPositionMode);
|
||
const colorIndex = Number.isFinite(layout.nodeColorIndexMap[name])
|
||
? layout.nodeColorIndexMap[name]
|
||
: index;
|
||
const textY = getClampedLabelY(node.y, node.h, height, this.data.chartPadding);
|
||
ctx.setFillStyle(getNodeColor(colorIndex, themeColors));
|
||
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, 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'
|
||
});
|
||
}
|
||
});
|
||
});
|
||
}
|
||
});
|