From 78201cf7c7a38be3b9ccad489ae35e7edf6ad164 Mon Sep 17 00:00:00 2001 From: "douboer@gmail.com" Date: Sat, 14 Feb 2026 11:43:49 +0800 Subject: [PATCH] update at 2026-02-14 11:43:49 --- miniapp/pages/index/index.js | 413 ++++++++++++++++++++++------------- 1 file changed, 262 insertions(+), 151 deletions(-) diff --git a/miniapp/pages/index/index.js b/miniapp/pages/index/index.js index fc30e3a..8289ea6 100644 --- a/miniapp/pages/index/index.js +++ b/miniapp/pages/index/index.js @@ -322,37 +322,48 @@ function applyDirection(links, direction) { /** * 根据标签位置模式返回 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; +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: sourceOuterX, textAlign: 'right' } - : { x: targetOuterX, textAlign: 'left' }; + ? { x: leftOuterX, textAlign: 'right' } + : { x: rightOuterX, textAlign: 'left' }; } if (labelPositionMode === 'left') { return isSource - ? { x: sourceOuterX, textAlign: 'right' } - : { x: targetInnerX, textAlign: 'right' }; + ? { x: leftOuterX, textAlign: 'right' } + : { x: leftOuterX, textAlign: 'right' }; } if (labelPositionMode === 'right') { return isSource - ? { x: sourceInnerX, textAlign: 'left' } - : { x: targetOuterX, textAlign: 'left' }; + ? { x: rightOuterX, textAlign: 'left' } + : { x: rightOuterX, textAlign: 'left' }; } return isSource - ? { x: sourceInnerX, textAlign: 'left' } - : { x: targetInnerX, textAlign: 'right' }; + ? { x: rightOuterX, textAlign: 'left' } + : { x: leftOuterX, textAlign: 'right' }; } /** * 根据标签位置模式返回 SVG 文本锚点参数。 */ -function getSvgLabelPlacement(layout, isSource, labelPositionMode) { - const canvasPlacement = getCanvasLabelPlacement(layout, isSource, labelPositionMode); +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' @@ -383,16 +394,29 @@ function buildSankeyLayout(links, width, height, renderOptions) { 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 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 sourceValueMap = {}; - const targetValueMap = {}; const nodeColorIndexMap = {}; + const nodeOrderMap = {}; 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); + 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; @@ -401,107 +425,226 @@ function buildSankeyLayout(links, width, height, renderOptions) { 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 sourceNames = Object.keys(sourceValueMap); - const targetNames = Object.keys(targetValueMap); - const totalValue = sourceNames.reduce((sum, name) => sum + sourceValueMap[name], 0); - if (totalValue <= 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 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 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 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 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 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 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); + } + } - const targetPos = {}; - let targetCursorY = targetStartY; - targetNames.forEach((name) => { - const nodeHeight = targetValueMap[name] * targetUnitHeight; - targetPos[name] = { y: targetCursorY, h: nodeHeight }; - targetCursorY += nodeHeight + targetGap; + 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 sourceOffset = {}; - const targetOffset = {}; - sourceNames.forEach((name) => { - sourceOffset[name] = 0; - }); - targetNames.forEach((name) => { - targetOffset[name] = 0; + const sourceOffsetMap = {}; + const targetOffsetMap = {}; + nodeNames.forEach((name) => { + sourceOffsetMap[name] = 0; + targetOffsetMap[name] = 0; }); const linkSegments = []; - links.forEach((link) => { - const sourceNode = sourcePos[link.source]; - const targetNode = targetPos[link.target]; + validLinks.forEach((link) => { + const sourceNode = nodePositionMap[link.source]; + const targetNode = nodePositionMap[link.target]; if (!sourceNode || !targetNode) { return; } - const linkHeight = Number(link.value || 0) * sourceUnitHeight; + const linkHeight = link.value * unitHeight; 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; + 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, - controlX, + controlX1: startX + controlOffset, + controlX2: endX - controlOffset, sy, ty, linkHeight, - sourceIndex: Number.isFinite(nodeColorIndexMap[link.source]) ? nodeColorIndexMap[link.source] : 0 + sourceIndex: sourceNode.colorIndex }); }); return { - leftX, - rightX, nodeWidth, - sourceNames, - targetNames, - sourcePos, - targetPos, + maxDepth, + nodes, nodeColorIndexMap, linkSegments }; @@ -535,8 +678,8 @@ function buildSankeySvgText(links, width, height, renderOptions) { 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.controlX1 + )} ${formatSvgNumber(segment.sy)} ${formatSvgNumber(segment.controlX2)} ${formatSvgNumber(segment.ty)} ${formatSvgNumber( segment.endX )} ${formatSvgNumber(segment.ty)}`; segments.push( @@ -549,51 +692,31 @@ function buildSankeySvgText(links, width, height, renderOptions) { ); }); - layout.sourceNames.forEach((name, index) => { - const node = layout.sourcePos[name]; - const colorIndex = Number.isFinite(layout.nodeColorIndexMap[name]) - ? layout.nodeColorIndexMap[name] - : index; + 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(layout, true, labelPositionMode); + const textPlacement = getSvgLabelPlacement( + node.x, + layout.nodeWidth, + isSource, + isTarget, + labelPositionMode + ); segments.push( - `` + )}" height="${formatSvgNumber(node.h)}" fill="${getNodeColor(node.colorIndex, themeColors)}" />` ); segments.push( `${escapeSvgText(name)}` - ); - }); - - 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( - `` - ); - segments.push( - `${escapeSvgText(name)}` + }" dominant-baseline="middle">${escapeSvgText(node.name)}` ); }); @@ -1270,9 +1393,9 @@ Page({ ctx.beginPath(); ctx.moveTo(segment.startX, segment.sy); ctx.bezierCurveTo( - segment.controlX, + segment.controlX1, segment.sy, - segment.controlX, + segment.controlX2, segment.ty, segment.endX, segment.ty @@ -1280,36 +1403,24 @@ Page({ 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; + 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(colorIndex, themeColors)); - ctx.fillRect(layout.leftX, node.y, layout.nodeWidth, node.h); + 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(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.fillText(node.name, labelPlacement.x, textY); }); ctx.draw();