Skip to content

Commit 4f2b47c

Browse files
feat(sankey): Add inline label layout and fix auto-assigned node colors in tooltips (#526)
1 parent 0701050 commit 4f2b47c

5 files changed

Lines changed: 234 additions & 11 deletions

File tree

.changeset/six-bags-train.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@cloudflare/kumo-docs-astro": patch
3+
"@cloudflare/kumo": patch
4+
---
5+
6+
Add inline label layout and fix auto-assigned node colors in tooltips

packages/kumo-docs-astro/src/components/demos/Chart/SankeyChartDemo.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,3 +473,39 @@ export function SankeyChartDrillDownDemo() {
473473
</div>
474474
);
475475
}
476+
477+
/** Demo showing inline label layout for small nodes */
478+
export function SankeyChartInlineLabelDemo() {
479+
const isDarkMode = useIsDarkMode();
480+
481+
// Data with many small nodes where stacked labels would overlap
482+
const nodes = [
483+
{ name: "Workloads", value: 109870 },
484+
{ name: "Users", value: 45 },
485+
{ name: "API Management", value: 42 },
486+
{ name: "Device Enrollment", value: 38 },
487+
{ name: "Service Token", value: 109870 },
488+
{ name: "Self-hosted", value: 109826 },
489+
{ name: "Tunnels", value: 87 },
490+
{ name: "Mesh", value: 87 },
491+
];
492+
493+
const links = [
494+
{ source: 0, target: 4, value: 80000 },
495+
{ source: 0, target: 5, value: 29870 },
496+
{ source: 1, target: 5, value: 45 },
497+
{ source: 2, target: 6, value: 42 },
498+
{ source: 3, target: 7, value: 38 },
499+
];
500+
501+
return (
502+
<SankeyChart
503+
echarts={echarts}
504+
nodes={nodes}
505+
links={links}
506+
height={300}
507+
nodeLabelLayout="inline"
508+
isDarkMode={isDarkMode}
509+
/>
510+
);
511+
}

packages/kumo-docs-astro/src/pages/charts/sankey.astro

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
SankeyChartRichTooltipDemo,
1414
SankeyChartInteractiveDemo,
1515
SankeyChartDrillDownDemo,
16+
SankeyChartInlineLabelDemo,
1617
} from "~/components/demos/Chart/SankeyChartDemo";
1718
---
1819

@@ -182,6 +183,22 @@ const handleNodeClick = (node: { name: string }) => {
182183
<SankeyChartDrillDownDemo client:load />
183184
</ComponentExample>
184185
</div>
186+
187+
<div>
188+
<Heading level={3}>Inline Label Layout</Heading>
189+
<p class="mb-4 text-kumo-subtle">
190+
Use <code class="text-kumo-default">nodeLabelLayout="inline"</code> to display node values and names on a single line. This prevents label overlap on small nodes where the default stacked layout would cause text to collide.
191+
</p>
192+
<ComponentExample code={`<SankeyChart
193+
echarts={echarts}
194+
nodes={nodes}
195+
links={links}
196+
height={300}
197+
nodeLabelLayout="inline"
198+
/>`}>
199+
<SankeyChartInlineLabelDemo client:visible />
200+
</ComponentExample>
201+
</div>
185202
</div>
186203
</ComponentSection>
187204

packages/kumo/src/components/chart/SankeyChart.test.tsx

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,4 +674,142 @@ describe("security utilities", () => {
674674
expect(result).not.toContain("javascript:");
675675
});
676676
});
677+
678+
describe("nodeLabelLayout", () => {
679+
it("uses stacked layout by default (value above name)", () => {
680+
const mockChart = createMockChart();
681+
const mockEcharts = createMockEcharts(mockChart);
682+
683+
render(
684+
<SankeyChart
685+
echarts={mockEcharts as any}
686+
nodes={[{ name: "Node A", value: 100 }]}
687+
links={[]}
688+
/>,
689+
);
690+
691+
const options = mockChart.setOption.mock.calls[0][0];
692+
const formatter = options.series[0].label.formatter;
693+
694+
const result = formatter({ name: "Node A" });
695+
// Stacked layout: value\nname (newline between)
696+
expect(result).toContain("\n");
697+
expect(result).toMatch(/\{value\|.*\}\n\{name\|.*\}/);
698+
});
699+
700+
it("uses inline layout when nodeLabelLayout is 'inline'", () => {
701+
const mockChart = createMockChart();
702+
const mockEcharts = createMockEcharts(mockChart);
703+
704+
render(
705+
<SankeyChart
706+
echarts={mockEcharts as any}
707+
nodes={[{ name: "Node A", value: 100 }]}
708+
links={[]}
709+
nodeLabelLayout="inline"
710+
/>,
711+
);
712+
713+
const options = mockChart.setOption.mock.calls[0][0];
714+
const formatter = options.series[0].label.formatter;
715+
716+
const result = formatter({ name: "Node A" });
717+
// Inline layout: name value (space between, no newline)
718+
expect(result).not.toContain("\n");
719+
expect(result).toMatch(/\{name\|.*\} \{value\|.*\}/);
720+
});
721+
722+
it("sets lineHeight for stacked layout", () => {
723+
const mockChart = createMockChart();
724+
const mockEcharts = createMockEcharts(mockChart);
725+
726+
render(
727+
<SankeyChart
728+
echarts={mockEcharts as any}
729+
nodes={[{ name: "Node A", value: 100 }]}
730+
links={[]}
731+
nodeLabelLayout="stacked"
732+
/>,
733+
);
734+
735+
const options = mockChart.setOption.mock.calls[0][0];
736+
expect(options.series[0].label.rich.value.lineHeight).toBe(16);
737+
});
738+
739+
it("does not set lineHeight for inline layout", () => {
740+
const mockChart = createMockChart();
741+
const mockEcharts = createMockEcharts(mockChart);
742+
743+
render(
744+
<SankeyChart
745+
echarts={mockEcharts as any}
746+
nodes={[{ name: "Node A", value: 100 }]}
747+
links={[]}
748+
nodeLabelLayout="inline"
749+
/>,
750+
);
751+
752+
const options = mockChart.setOption.mock.calls[0][0];
753+
expect(options.series[0].label.rich.value.lineHeight).toBeUndefined();
754+
});
755+
});
756+
757+
describe("auto-assigned node colors", () => {
758+
it("uses auto-assigned colors in link tooltips when nodes have no explicit color", () => {
759+
const mockChart = createMockChart();
760+
const mockEcharts = createMockEcharts(mockChart);
761+
762+
render(
763+
<SankeyChart
764+
echarts={mockEcharts as any}
765+
nodes={[
766+
{ name: "Source" }, // No explicit color
767+
{ name: "Target" }, // No explicit color
768+
]}
769+
links={[{ source: 0, target: 1, value: 50 }]}
770+
/>,
771+
);
772+
773+
const options = mockChart.setOption.mock.calls[0][0];
774+
const formatter = options.tooltip.formatter;
775+
776+
// Simulate link tooltip
777+
const result = formatter({
778+
dataType: "edge",
779+
data: { source: "Source", target: "Target", value: 50 },
780+
});
781+
782+
// Should NOT contain the fallback gray color #666
783+
// Should contain auto-assigned categorical colors
784+
expect(result).not.toContain("#666");
785+
});
786+
787+
it("assigns consistent colors to nodes without explicit colors", () => {
788+
const mockChart = createMockChart();
789+
const mockEcharts = createMockEcharts(mockChart);
790+
791+
render(
792+
<SankeyChart
793+
echarts={mockEcharts as any}
794+
nodes={[
795+
{ name: "Node A" }, // No color - gets categorical(0)
796+
{ name: "Node B" }, // No color - gets categorical(1)
797+
{ name: "Node C", color: "#custom" }, // Explicit color
798+
]}
799+
links={[]}
800+
/>,
801+
);
802+
803+
const options = mockChart.setOption.mock.calls[0][0];
804+
const nodeData = options.series[0].data;
805+
806+
// First two nodes should have auto-assigned colors (not undefined)
807+
expect(nodeData[0].itemStyle.color).toBeDefined();
808+
expect(nodeData[1].itemStyle.color).toBeDefined();
809+
// Third node should have explicit color
810+
expect(nodeData[2].itemStyle.color).toBe("#custom");
811+
// Auto-assigned colors should be different
812+
expect(nodeData[0].itemStyle.color).not.toBe(nodeData[1].itemStyle.color);
813+
});
814+
});
677815
});

packages/kumo/src/components/chart/SankeyChart.tsx

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ export interface SankeyChartProps {
6969
height?: number;
7070
/** Show node values above labels (default: true if any node has a value) */
7171
showNodeValues?: boolean;
72+
/** Layout for node labels when showNodeValues is true.
73+
* - 'stacked': value on top, name below (default)
74+
* - 'inline': "value name" on a single line (better for small nodes)
75+
*/
76+
nodeLabelLayout?: "stacked" | "inline";
7277
/** Format function for node values (default: toLocaleString) */
7378
formatValue?: (value: number) => string;
7479
/** Custom tooltip formatter. Return HTML string or empty string to hide tooltip. */
@@ -169,6 +174,7 @@ export function SankeyChart({
169174
nodePadding = 10,
170175
showTooltip: enableTooltip = true,
171176
showNodeValues,
177+
nodeLabelLayout = "stacked",
172178
formatValue = defaultFormatValue,
173179
tooltipFormatter,
174180
defaultNodeColor,
@@ -183,20 +189,29 @@ export function SankeyChart({
183189
}: SankeyChartProps) {
184190
const hasNodeValues = nodes.some((n) => n.value !== undefined);
185191
const shouldShowValues = showNodeValues ?? hasNodeValues;
192+
const isInlineLayout = nodeLabelLayout === "inline";
186193
const options = useMemo<EChartsOption>(() => {
187194
const labelColor = ChartPalette.text("primary", isDarkMode);
188195
const secondaryColor = ChartPalette.text("secondary", isDarkMode);
189-
// Build a map of node name to original node data for tooltip access
190-
const nodeDataMap = new Map(nodes.map((n) => [n.name, n]));
196+
197+
// Compute colors for each node (explicit color > default > categorical)
198+
const nodeColors = nodes.map(
199+
(node, index) =>
200+
node.color ??
201+
defaultNodeColor ??
202+
ChartPalette.categorical(index, isDarkMode),
203+
);
204+
205+
// Build a map of node name to original node data + computed color for tooltip access
206+
const nodeDataMap = new Map(
207+
nodes.map((n, i) => [n.name, { ...n, computedColor: nodeColors[i] }]),
208+
);
191209

192210
const echartsNodes = nodes.map((node, index) => ({
193211
name: node.name,
194212
value: node.value,
195213
itemStyle: {
196-
color:
197-
node.color ??
198-
defaultNodeColor ??
199-
ChartPalette.categorical(index, isDarkMode),
214+
color: nodeColors[index],
200215
},
201216
}));
202217

@@ -222,7 +237,7 @@ export function SankeyChart({
222237
if (params.dataType === "node" && params.name) {
223238
const nodeData = nodeDataMap.get(params.name);
224239
const color = sanitizeColor(
225-
nodeData?.color ?? params.color ?? "#666",
240+
nodeData?.computedColor ?? params.color ?? "#666",
226241
);
227242

228243
// Use custom formatter if provided
@@ -259,8 +274,12 @@ export function SankeyChart({
259274
// Get colors for source and target nodes
260275
const sourceNode = nodeDataMap.get(source ?? "");
261276
const targetNode = nodeDataMap.get(target ?? "");
262-
const sourceColor = sanitizeColor(sourceNode?.color ?? "#666");
263-
const targetColor = sanitizeColor(targetNode?.color ?? "#666");
277+
const sourceColor = sanitizeColor(
278+
sourceNode?.computedColor ?? "#666",
279+
);
280+
const targetColor = sanitizeColor(
281+
targetNode?.computedColor ?? "#666",
282+
);
264283

265284
// Default link tooltip with colored dots
266285
const safeSource = escapeHtml(source ?? "");
@@ -307,7 +326,13 @@ export function SankeyChart({
307326
const nodeData = nodeDataMap.get(name);
308327
const safeName = escapeRichText(name);
309328
if (nodeData?.value !== undefined) {
310-
return `{value|${escapeRichText(formatValue(nodeData.value))}}\n{name|${safeName}}`;
329+
const formattedValue = escapeRichText(
330+
formatValue(nodeData.value),
331+
);
332+
// Inline: "name value" on single line; Stacked: value above name
333+
return isInlineLayout
334+
? `{name|${safeName}} {value|${formattedValue}}`
335+
: `{value|${formattedValue}}\n{name|${safeName}}`;
311336
}
312337
return safeName;
313338
}
@@ -317,7 +342,7 @@ export function SankeyChart({
317342
value: {
318343
fontSize: 11,
319344
color: labelColor,
320-
lineHeight: 16,
345+
lineHeight: isInlineLayout ? undefined : 16,
321346
},
322347
name: {
323348
fontSize: 12,
@@ -343,6 +368,7 @@ export function SankeyChart({
343368
linkColor,
344369
linkOpacity,
345370
shouldShowValues,
371+
isInlineLayout,
346372
formatValue,
347373
tooltipFormatter,
348374
]);

0 commit comments

Comments
 (0)