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();