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, '''); } /** * 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 = {}; const baseColumnBlockHeightMap = {}; columns.forEach((names, depth) => { const totalNodeHeight = names.reduce((sum, name) => sum + (nodeValueMap[name] || 0) * unitHeight, 0); columnNodeHeightMap[depth] = totalNodeHeight; baseColumnBlockHeightMap[depth] = totalNodeHeight + Math.max(0, names.length - 1) * effectiveGap; }); const globalSpanHeight = columns.reduce((max, _names, depth) => { return Math.max(max, baseColumnBlockHeightMap[depth] || 0); }, 0); columns.forEach((names, depth) => { const totalNodeHeight = columnNodeHeightMap[depth] || 0; let columnGap = effectiveGap; if (targetAlignMode === 'between' && names.length > 1) { // 全局两端对齐:每一列都按同一纵向跨度拉伸。 columnGap = (globalSpanHeight - totalNodeHeight) / (names.length - 1); } columnGap = Math.max(0, columnGap); const blockHeight = totalNodeHeight + Math.max(0, names.length - 1) * columnGap; columnGapMap[depth] = columnGap; columnBlockHeightMap[depth] = blockHeight; }); 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 (targetAlignMode === 'middle') { startY = padding + Math.max(0, (globalSpanHeight - blockHeight) / 2); } else if (targetAlignMode === 'bottom') { startY = padding + Math.max(0, globalSpanHeight - 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( `` ); segments.push( `` ); 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( `` ); }); 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( `` ); segments.push( `${escapeSvgText(node.name)}` ); }); segments.push(''); 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' }); } }); }); } });