update at 2026-02-14 11:43:49

This commit is contained in:
douboer@gmail.com
2026-02-14 11:43:49 +08:00
parent 44d4afd037
commit 78201cf7c7

View File

@@ -322,37 +322,48 @@ function applyDirection(links, direction) {
/** /**
* 根据标签位置模式返回 Canvas 文本绘制参数。 * 根据标签位置模式返回 Canvas 文本绘制参数。
*/ */
function getCanvasLabelPlacement(layout, isSource, labelPositionMode) { function getCanvasLabelPlacement(nodeX, nodeWidth, isSource, isTarget, labelPositionMode) {
const sourceInnerX = layout.leftX + layout.nodeWidth + 4; const leftOuterX = Math.max(2, nodeX - 4);
const sourceOuterX = Math.max(2, layout.leftX - 4); const rightOuterX = nodeX + nodeWidth + 4;
const targetInnerX = Math.max(2, layout.rightX - 4);
const targetOuterX = layout.rightX + layout.nodeWidth + 4; if (!isSource && !isTarget) {
if (labelPositionMode === 'left') {
return { x: leftOuterX, textAlign: 'right' };
}
return { x: rightOuterX, textAlign: 'left' };
}
if (labelPositionMode === 'outer') { if (labelPositionMode === 'outer') {
return isSource return isSource
? { x: sourceOuterX, textAlign: 'right' } ? { x: leftOuterX, textAlign: 'right' }
: { x: targetOuterX, textAlign: 'left' }; : { x: rightOuterX, textAlign: 'left' };
} }
if (labelPositionMode === 'left') { if (labelPositionMode === 'left') {
return isSource return isSource
? { x: sourceOuterX, textAlign: 'right' } ? { x: leftOuterX, textAlign: 'right' }
: { x: targetInnerX, textAlign: 'right' }; : { x: leftOuterX, textAlign: 'right' };
} }
if (labelPositionMode === 'right') { if (labelPositionMode === 'right') {
return isSource return isSource
? { x: sourceInnerX, textAlign: 'left' } ? { x: rightOuterX, textAlign: 'left' }
: { x: targetOuterX, textAlign: 'left' }; : { x: rightOuterX, textAlign: 'left' };
} }
return isSource return isSource
? { x: sourceInnerX, textAlign: 'left' } ? { x: rightOuterX, textAlign: 'left' }
: { x: targetInnerX, textAlign: 'right' }; : { x: leftOuterX, textAlign: 'right' };
} }
/** /**
* 根据标签位置模式返回 SVG 文本锚点参数。 * 根据标签位置模式返回 SVG 文本锚点参数。
*/ */
function getSvgLabelPlacement(layout, isSource, labelPositionMode) { function getSvgLabelPlacement(nodeX, nodeWidth, isSource, isTarget, labelPositionMode) {
const canvasPlacement = getCanvasLabelPlacement(layout, isSource, labelPositionMode); const canvasPlacement = getCanvasLabelPlacement(
nodeX,
nodeWidth,
isSource,
isTarget,
labelPositionMode
);
return { return {
x: canvasPlacement.x, x: canvasPlacement.x,
textAnchor: canvasPlacement.textAlign === 'right' ? 'end' : 'start' 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 padding = Math.min(rawPadding, Math.max(0, height / 2 - 4));
const targetAlignMode = (renderOptions && renderOptions.targetAlignMode) || 'between'; const targetAlignMode = (renderOptions && renderOptions.targetAlignMode) || 'between';
const nodeWidth = 10; const nodeWidth = 10;
const leftX = padding; const validLinks = links
const rightX = Math.max(padding + nodeWidth + 80, width - padding - nodeWidth); .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 nodeColorIndexMap = {};
const nodeOrderMap = {};
let nodeColorIndexCursor = 0; let nodeColorIndexCursor = 0;
links.forEach((link) => { let nodeOrderCursor = 0;
sourceValueMap[link.source] = (sourceValueMap[link.source] || 0) + Number(link.value || 0); const incomingCountMap = {};
targetValueMap[link.target] = (targetValueMap[link.target] || 0) + Number(link.value || 0); const outgoingCountMap = {};
const inValueMap = {};
const outValueMap = {};
const adjacencyMap = {};
const indegreeMap = {};
validLinks.forEach((link) => {
if (!Object.prototype.hasOwnProperty.call(nodeColorIndexMap, link.source)) { if (!Object.prototype.hasOwnProperty.call(nodeColorIndexMap, link.source)) {
nodeColorIndexMap[link.source] = nodeColorIndexCursor; nodeColorIndexMap[link.source] = nodeColorIndexCursor;
nodeColorIndexCursor += 1; nodeColorIndexCursor += 1;
@@ -401,107 +425,226 @@ function buildSankeyLayout(links, width, height, renderOptions) {
nodeColorIndexMap[link.target] = nodeColorIndexCursor; nodeColorIndexMap[link.target] = nodeColorIndexCursor;
nodeColorIndexCursor += 1; 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 nodeNames = Object.keys(nodeOrderMap).sort((a, b) => nodeOrderMap[a] - nodeOrderMap[b]);
const targetNames = Object.keys(targetValueMap); const nodeCount = nodeNames.length;
const totalValue = sourceNames.reduce((sum, name) => sum + sourceValueMap[name], 0); const depthMap = {};
if (totalValue <= 0) { 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; return null;
} }
const sourceGapCount = Math.max(0, sourceNames.length - 1); const columnNodeHeightMap = {};
const sourceContentHeight = Math.max(0, height - padding * 2 - sourceGapCount * nodeGap); const columnGapMap = {};
const sourceUnitHeight = sourceContentHeight / totalValue; const columnBlockHeightMap = {};
const sourceTotalNodeHeight = totalValue * sourceUnitHeight; columns.forEach((names, depth) => {
const sourceSpanHeight = sourceTotalNodeHeight + sourceGapCount * nodeGap; const totalNodeHeight = names.reduce((sum, name) => sum + (nodeValueMap[name] || 0) * unitHeight, 0);
let columnGap = effectiveGap;
const sourcePos = {}; if (depth === maxDepth && targetAlignMode === 'between' && names.length > 1) {
let sourceCursorY = padding; const sourceNames = columns[0] || [];
sourceNames.forEach((name) => { const sourceTotalNodeHeight = sourceNames.reduce(
const nodeHeight = sourceValueMap[name] * sourceUnitHeight; (sum, name) => sum + (nodeValueMap[name] || 0) * unitHeight,
sourcePos[name] = { y: sourceCursorY, h: nodeHeight }; 0
sourceCursorY += nodeHeight + nodeGap; );
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 sourceSpanHeight = columnBlockHeightMap[0] || 0;
const targetTotalValue = targetNames.reduce((sum, name) => sum + targetValueMap[name], 0); const leftX = padding;
const targetUnitHeight = targetTotalValue > 0 ? sourceUnitHeight : 0; const rightX = Math.max(padding + nodeWidth + 80, width - padding - nodeWidth);
const targetTotalNodeHeight = targetTotalValue * targetUnitHeight; const columnStep = maxDepth > 0 ? (rightX - leftX) / maxDepth : 0;
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; const nodePositionMap = {};
let targetStartY = padding; const nodes = [];
if (targetAlignMode === 'middle') { columns.forEach((names, depth) => {
targetStartY = padding + Math.max(0, (sourceSpanHeight - targetBlockHeight) / 2); const blockHeight = columnBlockHeightMap[depth] || 0;
} else if (targetAlignMode === 'bottom') { const columnGap = columnGapMap[depth] || 0;
targetStartY = padding + Math.max(0, sourceSpanHeight - targetBlockHeight); let startY = padding;
} else if (targetAlignMode === 'top') { if (depth === maxDepth) {
targetStartY = padding; 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 cursorY = startY;
let targetCursorY = targetStartY; const nodeX = leftX + columnStep * depth;
targetNames.forEach((name) => { names.forEach((name) => {
const nodeHeight = targetValueMap[name] * targetUnitHeight; const nodeHeight = (nodeValueMap[name] || 0) * unitHeight;
targetPos[name] = { y: targetCursorY, h: nodeHeight }; const node = {
targetCursorY += nodeHeight + targetGap; 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 sourceOffsetMap = {};
const targetOffset = {}; const targetOffsetMap = {};
sourceNames.forEach((name) => { nodeNames.forEach((name) => {
sourceOffset[name] = 0; sourceOffsetMap[name] = 0;
}); targetOffsetMap[name] = 0;
targetNames.forEach((name) => {
targetOffset[name] = 0;
}); });
const linkSegments = []; const linkSegments = [];
links.forEach((link) => { validLinks.forEach((link) => {
const sourceNode = sourcePos[link.source]; const sourceNode = nodePositionMap[link.source];
const targetNode = targetPos[link.target]; const targetNode = nodePositionMap[link.target];
if (!sourceNode || !targetNode) { if (!sourceNode || !targetNode) {
return; return;
} }
const linkHeight = Number(link.value || 0) * sourceUnitHeight; const linkHeight = link.value * unitHeight;
if (linkHeight <= 0) { if (linkHeight <= 0) {
return; 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 sy = sourceNode.y + sourceOffsetMap[link.source] + linkHeight / 2;
const endX = rightX; const ty = targetNode.y + targetOffsetMap[link.target] + linkHeight / 2;
const controlX = (startX + endX) / 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({ linkSegments.push({
startX, startX,
endX, endX,
controlX, controlX1: startX + controlOffset,
controlX2: endX - controlOffset,
sy, sy,
ty, ty,
linkHeight, linkHeight,
sourceIndex: Number.isFinite(nodeColorIndexMap[link.source]) ? nodeColorIndexMap[link.source] : 0 sourceIndex: sourceNode.colorIndex
}); });
}); });
return { return {
leftX,
rightX,
nodeWidth, nodeWidth,
sourceNames, maxDepth,
targetNames, nodes,
sourcePos,
targetPos,
nodeColorIndexMap, nodeColorIndexMap,
linkSegments linkSegments
}; };
@@ -535,8 +678,8 @@ function buildSankeySvgText(links, width, height, renderOptions) {
layout.linkSegments.forEach((segment) => { layout.linkSegments.forEach((segment) => {
const pathData = `M ${formatSvgNumber(segment.startX)} ${formatSvgNumber(segment.sy)} C ${formatSvgNumber( const pathData = `M ${formatSvgNumber(segment.startX)} ${formatSvgNumber(segment.sy)} C ${formatSvgNumber(
segment.controlX segment.controlX1
)} ${formatSvgNumber(segment.sy)} ${formatSvgNumber(segment.controlX)} ${formatSvgNumber(segment.ty)} ${formatSvgNumber( )} ${formatSvgNumber(segment.sy)} ${formatSvgNumber(segment.controlX2)} ${formatSvgNumber(segment.ty)} ${formatSvgNumber(
segment.endX segment.endX
)} ${formatSvgNumber(segment.ty)}`; )} ${formatSvgNumber(segment.ty)}`;
segments.push( segments.push(
@@ -549,51 +692,31 @@ function buildSankeySvgText(links, width, height, renderOptions) {
); );
}); });
layout.sourceNames.forEach((name, index) => { layout.nodes.forEach((node) => {
const node = layout.sourcePos[name]; const isSource = node.depth === 0;
const colorIndex = Number.isFinite(layout.nodeColorIndexMap[name]) const isTarget = node.depth === layout.maxDepth;
? layout.nodeColorIndexMap[name]
: index;
const textY = getClampedLabelY( const textY = getClampedLabelY(
node.y, node.y,
node.h, node.h,
height, height,
clampNumber(renderOptions && renderOptions.chartPadding, 0, 120, 16) 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( segments.push(
`<rect x="${formatSvgNumber(layout.leftX)}" y="${formatSvgNumber(node.y)}" width="${formatSvgNumber( `<rect x="${formatSvgNumber(node.x)}" y="${formatSvgNumber(node.y)}" width="${formatSvgNumber(
layout.nodeWidth layout.nodeWidth
)}" height="${formatSvgNumber(node.h)}" fill="${getNodeColor(colorIndex, themeColors)}" />` )}" height="${formatSvgNumber(node.h)}" fill="${getNodeColor(node.colorIndex, themeColors)}" />`
); );
segments.push( segments.push(
`<text x="${formatSvgNumber(textPlacement.x)}" y="${formatSvgNumber(textY)}" fill="#4e5969" font-size="10" text-anchor="${ `<text x="${formatSvgNumber(textPlacement.x)}" y="${formatSvgNumber(textY)}" fill="#4e5969" font-size="10" text-anchor="${
textPlacement.textAnchor textPlacement.textAnchor
}" dominant-baseline="middle">${escapeSvgText(name)}</text>` }" dominant-baseline="middle">${escapeSvgText(node.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>`
); );
}); });
@@ -1270,9 +1393,9 @@ Page({
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(segment.startX, segment.sy); ctx.moveTo(segment.startX, segment.sy);
ctx.bezierCurveTo( ctx.bezierCurveTo(
segment.controlX, segment.controlX1,
segment.sy, segment.sy,
segment.controlX, segment.controlX2,
segment.ty, segment.ty,
segment.endX, segment.endX,
segment.ty segment.ty
@@ -1280,36 +1403,24 @@ Page({
ctx.stroke(); ctx.stroke();
}); });
layout.sourceNames.forEach((name, index) => { layout.nodes.forEach((node) => {
const node = layout.sourcePos[name]; const isSource = node.depth === 0;
const labelPlacement = getCanvasLabelPlacement(layout, true, this.data.labelPositionMode); const isTarget = node.depth === layout.maxDepth;
const colorIndex = Number.isFinite(layout.nodeColorIndexMap[name]) const labelPlacement = getCanvasLabelPlacement(
? layout.nodeColorIndexMap[name] node.x,
: index; layout.nodeWidth,
isSource,
isTarget,
this.data.labelPositionMode
);
const textY = getClampedLabelY(node.y, node.h, height, this.data.chartPadding); const textY = getClampedLabelY(node.y, node.h, height, this.data.chartPadding);
ctx.setFillStyle(getNodeColor(colorIndex, themeColors)); ctx.setFillStyle(getNodeColor(node.colorIndex, themeColors));
ctx.fillRect(layout.leftX, node.y, layout.nodeWidth, node.h); ctx.fillRect(node.x, node.y, layout.nodeWidth, node.h);
ctx.setFillStyle('#4e5969'); ctx.setFillStyle('#4e5969');
ctx.setFontSize(10); ctx.setFontSize(10);
ctx.setTextAlign(labelPlacement.textAlign); ctx.setTextAlign(labelPlacement.textAlign);
ctx.setTextBaseline('middle'); ctx.setTextBaseline('middle');
ctx.fillText(name, labelPlacement.x, textY); ctx.fillText(node.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(); ctx.draw();