update at 2026-02-14 11:37:46

This commit is contained in:
douboer@gmail.com
2026-02-14 11:37:46 +08:00
parent d9e89f080e
commit 44d4afd037
2 changed files with 37 additions and 10 deletions

View File

@@ -359,6 +359,16 @@ function getSvgLabelPlacement(layout, isSource, labelPositionMode) {
}; };
} }
/**
* 标签纵坐标限制在画布安全区内,避免上下溢出。
*/
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 导出共用。 * 统一生成桑基图布局数据,供 canvas 渲染与 SVG 导出共用。
*/ */
@@ -368,7 +378,9 @@ function buildSankeyLayout(links, width, height, renderOptions) {
} }
const nodeGap = clampNumber(renderOptions && renderOptions.nodeGap, 0, 60, 8); const nodeGap = clampNumber(renderOptions && renderOptions.nodeGap, 0, 60, 8);
const padding = clampNumber(renderOptions && renderOptions.chartPadding, 0, 120, 16); 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 targetAlignMode = (renderOptions && renderOptions.targetAlignMode) || 'between';
const nodeWidth = 10; const nodeWidth = 10;
const leftX = padding; const leftX = padding;
@@ -399,7 +411,7 @@ function buildSankeyLayout(links, width, height, renderOptions) {
} }
const sourceGapCount = Math.max(0, sourceNames.length - 1); const sourceGapCount = Math.max(0, sourceNames.length - 1);
const sourceContentHeight = Math.max(10, height - padding * 2 - sourceGapCount * nodeGap); const sourceContentHeight = Math.max(0, height - padding * 2 - sourceGapCount * nodeGap);
const sourceUnitHeight = sourceContentHeight / totalValue; const sourceUnitHeight = sourceContentHeight / totalValue;
const sourceTotalNodeHeight = totalValue * sourceUnitHeight; const sourceTotalNodeHeight = totalValue * sourceUnitHeight;
const sourceSpanHeight = sourceTotalNodeHeight + sourceGapCount * nodeGap; const sourceSpanHeight = sourceTotalNodeHeight + sourceGapCount * nodeGap;
@@ -407,7 +419,7 @@ function buildSankeyLayout(links, width, height, renderOptions) {
const sourcePos = {}; const sourcePos = {};
let sourceCursorY = padding; let sourceCursorY = padding;
sourceNames.forEach((name) => { sourceNames.forEach((name) => {
const nodeHeight = Math.max(2, sourceValueMap[name] * sourceUnitHeight); const nodeHeight = sourceValueMap[name] * sourceUnitHeight;
sourcePos[name] = { y: sourceCursorY, h: nodeHeight }; sourcePos[name] = { y: sourceCursorY, h: nodeHeight };
sourceCursorY += nodeHeight + nodeGap; sourceCursorY += nodeHeight + nodeGap;
}); });
@@ -436,7 +448,7 @@ function buildSankeyLayout(links, width, height, renderOptions) {
const targetPos = {}; const targetPos = {};
let targetCursorY = targetStartY; let targetCursorY = targetStartY;
targetNames.forEach((name) => { targetNames.forEach((name) => {
const nodeHeight = Math.max(2, targetValueMap[name] * targetUnitHeight); const nodeHeight = targetValueMap[name] * targetUnitHeight;
targetPos[name] = { y: targetCursorY, h: nodeHeight }; targetPos[name] = { y: targetCursorY, h: nodeHeight };
targetCursorY += nodeHeight + targetGap; targetCursorY += nodeHeight + targetGap;
}); });
@@ -458,7 +470,10 @@ function buildSankeyLayout(links, width, height, renderOptions) {
return; return;
} }
const linkHeight = Math.max(1, Number(link.value || 0) * sourceUnitHeight); const linkHeight = Number(link.value || 0) * sourceUnitHeight;
if (linkHeight <= 0) {
return;
}
const sy = sourceNode.y + sourceOffset[link.source] + linkHeight / 2; const sy = sourceNode.y + sourceOffset[link.source] + linkHeight / 2;
const ty = targetNode.y + targetOffset[link.target] + linkHeight / 2; const ty = targetNode.y + targetOffset[link.target] + linkHeight / 2;
sourceOffset[link.source] += linkHeight; sourceOffset[link.source] += linkHeight;
@@ -539,7 +554,12 @@ function buildSankeySvgText(links, width, height, renderOptions) {
const colorIndex = Number.isFinite(layout.nodeColorIndexMap[name]) const colorIndex = Number.isFinite(layout.nodeColorIndexMap[name])
? layout.nodeColorIndexMap[name] ? layout.nodeColorIndexMap[name]
: index; : index;
const textY = node.y + getNodeLabelCenterY(node.h); const textY = getClampedLabelY(
node.y,
node.h,
height,
clampNumber(renderOptions && renderOptions.chartPadding, 0, 120, 16)
);
const textPlacement = getSvgLabelPlacement(layout, true, labelPositionMode); const textPlacement = getSvgLabelPlacement(layout, true, labelPositionMode);
segments.push( segments.push(
`<rect x="${formatSvgNumber(layout.leftX)}" y="${formatSvgNumber(node.y)}" width="${formatSvgNumber( `<rect x="${formatSvgNumber(layout.leftX)}" y="${formatSvgNumber(node.y)}" width="${formatSvgNumber(
@@ -558,7 +578,12 @@ function buildSankeySvgText(links, width, height, renderOptions) {
const colorIndex = Number.isFinite(layout.nodeColorIndexMap[name]) const colorIndex = Number.isFinite(layout.nodeColorIndexMap[name])
? layout.nodeColorIndexMap[name] ? layout.nodeColorIndexMap[name]
: index; : index;
const textY = node.y + getNodeLabelCenterY(node.h); const textY = getClampedLabelY(
node.y,
node.h,
height,
clampNumber(renderOptions && renderOptions.chartPadding, 0, 120, 16)
);
const textPlacement = getSvgLabelPlacement(layout, false, labelPositionMode); const textPlacement = getSvgLabelPlacement(layout, false, labelPositionMode);
segments.push( segments.push(
`<rect x="${formatSvgNumber(layout.rightX)}" y="${formatSvgNumber(node.y)}" width="${formatSvgNumber( `<rect x="${formatSvgNumber(layout.rightX)}" y="${formatSvgNumber(node.y)}" width="${formatSvgNumber(
@@ -1261,13 +1286,14 @@ Page({
const colorIndex = Number.isFinite(layout.nodeColorIndexMap[name]) const colorIndex = Number.isFinite(layout.nodeColorIndexMap[name])
? layout.nodeColorIndexMap[name] ? layout.nodeColorIndexMap[name]
: index; : index;
const textY = getClampedLabelY(node.y, node.h, height, this.data.chartPadding);
ctx.setFillStyle(getNodeColor(colorIndex, themeColors)); ctx.setFillStyle(getNodeColor(colorIndex, themeColors));
ctx.fillRect(layout.leftX, node.y, layout.nodeWidth, node.h); ctx.fillRect(layout.leftX, 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, node.y + getNodeLabelCenterY(node.h)); ctx.fillText(name, labelPlacement.x, textY);
}); });
layout.targetNames.forEach((name, index) => { layout.targetNames.forEach((name, index) => {
@@ -1276,13 +1302,14 @@ Page({
const colorIndex = Number.isFinite(layout.nodeColorIndexMap[name]) const colorIndex = Number.isFinite(layout.nodeColorIndexMap[name])
? layout.nodeColorIndexMap[name] ? layout.nodeColorIndexMap[name]
: index; : index;
const textY = getClampedLabelY(node.y, node.h, height, this.data.chartPadding);
ctx.setFillStyle(getNodeColor(colorIndex, themeColors)); ctx.setFillStyle(getNodeColor(colorIndex, themeColors));
ctx.fillRect(layout.rightX, node.y, layout.nodeWidth, node.h); ctx.fillRect(layout.rightX, 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, node.y + getNodeLabelCenterY(node.h)); ctx.fillText(name, labelPlacement.x, textY);
}); });
ctx.draw(); ctx.draw();

View File

@@ -234,7 +234,7 @@
.preview-canvas { .preview-canvas {
margin-top: 3px; margin-top: 3px;
width: 100%; width: 100%;
height: auto; height: 0;
flex: 1; flex: 1;
min-height: 120px; min-height: 120px;
border-radius: 4px; border-radius: 4px;