Skip to content

Commit 9fa3624

Browse files
committed
fix(sankey): thread measureText through sankey pipeline for accurate text wrapping
Sankey chrome and node labels always fell back to the ±20% character-width heuristic for wrapText, even in the browser where canvas measurement is free. The chart pipeline already had this plumbing; sankey just never got it. - Add measureText to SankeyLayout type - Pass options.measureText through compile-sankey return - Create measureText in sankey-mount and pass to CompileOptions - Thread measureText to all wrapText calls in sankey-renderer - Extract createMeasureText to shared measure-text.ts
1 parent 4a41c07 commit 9fa3624

7 files changed

Lines changed: 63 additions & 41 deletions

File tree

packages/core/src/layout/text-wrap.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
* accurate wrapping. Otherwise falls back to a character-width
77
* heuristic driven by `estimateCharWidth` from text-measure.ts.
88
*
9-
* Callers that want heuristic-only behavior (e.g. sankey) should omit
10-
* the measureText argument. Do not change the signature without
9+
* Non-browser callers (SSR, tests) omit the measureText argument to
10+
* use the heuristic fallback. Do not change the signature without
1111
* re-verifying visual baselines for every caller.
1212
*/
1313

packages/core/src/types/layout.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1030,6 +1030,8 @@ export interface SankeyLayout {
10301030
animation?: ResolvedAnimation;
10311031
/** Whether the tryOpenData.ai watermark is enabled. */
10321032
watermark: boolean;
1033+
/** Real text measurement function from the adapter (for accurate SVG text wrapping). */
1034+
measureText?: MeasureTextFn;
10331035
}
10341036

10351037
// ---------------------------------------------------------------------------

packages/engine/src/sankey/compile-sankey.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,7 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
507507
},
508508
animation: resolvedAnimation,
509509
watermark,
510+
measureText: options.measureText,
510511
};
511512
}
512513

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Canvas-backed text measurement factory.
3+
*
4+
* Shared by mount.ts (charts) and sankey-mount.ts (sankey diagrams) so both
5+
* pipelines get accurate browser-measured text widths instead of the heuristic
6+
* fallback. Falls back to the heuristic when canvas isn't available (e.g. SSR).
7+
*/
8+
9+
import type { MeasureTextFn } from '@opendata-ai/openchart-core';
10+
11+
export function createMeasureText(): MeasureTextFn {
12+
let canvas: HTMLCanvasElement | null = null;
13+
let ctx: CanvasRenderingContext2D | null = null;
14+
15+
return (
16+
text: string,
17+
fontSize: number,
18+
fontWeight?: number,
19+
): { width: number; height: number } => {
20+
if (!canvas) {
21+
canvas = document.createElement('canvas');
22+
ctx = canvas.getContext('2d');
23+
}
24+
if (!ctx) {
25+
// Fallback: heuristic estimation
26+
return { width: text.length * fontSize * 0.6, height: fontSize * 1.2 };
27+
}
28+
29+
const weight = fontWeight ?? 400;
30+
ctx.font = `${weight} ${fontSize}px Inter, sans-serif`;
31+
const metrics = ctx.measureText(text);
32+
return {
33+
width: metrics.width,
34+
height: fontSize * 1.2,
35+
};
36+
};
37+
}

packages/vanilla/src/mount.ts

Lines changed: 1 addition & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import type {
2020
ElementRef,
2121
GraphSpec,
2222
LayerSpec,
23-
MeasureTextFn,
2423
RangeAnnotation,
2524
RefLineAnnotation,
2625
TextAnnotation,
@@ -39,6 +38,7 @@ import {
3938
type JPGExportOptions,
4039
type SVGExportOptions,
4140
} from './export';
41+
import { createMeasureText } from './measure-text';
4242
import { observeResize } from './resize-observer';
4343
import { renderChartSVG } from './svg-renderer';
4444
import { createTextEditOverlay } from './text-edit-overlay';
@@ -115,38 +115,6 @@ function resolveDarkMode(mode?: DarkMode): boolean {
115115
return false;
116116
}
117117

118-
// ---------------------------------------------------------------------------
119-
// measureText via hidden canvas
120-
// ---------------------------------------------------------------------------
121-
122-
function createMeasureText(): MeasureTextFn {
123-
let canvas: HTMLCanvasElement | null = null;
124-
let ctx: CanvasRenderingContext2D | null = null;
125-
126-
return (
127-
text: string,
128-
fontSize: number,
129-
fontWeight?: number,
130-
): { width: number; height: number } => {
131-
if (!canvas) {
132-
canvas = document.createElement('canvas');
133-
ctx = canvas.getContext('2d');
134-
}
135-
if (!ctx) {
136-
// Fallback: heuristic estimation
137-
return { width: text.length * fontSize * 0.6, height: fontSize * 1.2 };
138-
}
139-
140-
const weight = fontWeight ?? 400;
141-
ctx.font = `${weight} ${fontSize}px Inter, sans-serif`;
142-
const metrics = ctx.measureText(text);
143-
return {
144-
width: metrics.width,
145-
height: fontSize * 1.2,
146-
};
147-
};
148-
}
149-
150118
// ---------------------------------------------------------------------------
151119
// Tooltip event wiring
152120
// ---------------------------------------------------------------------------

packages/vanilla/src/sankey-mount.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
type JPGExportOptions,
2424
type SVGExportOptions,
2525
} from './export';
26+
import { createMeasureText } from './measure-text';
2627
import { observeResize } from './resize-observer';
2728
import { renderSankeySVG } from './sankey-renderer';
2829
import { createTooltipManager, type TooltipManager } from './tooltip';
@@ -121,6 +122,8 @@ export function createSankey(
121122
let animationCleanup: (() => void) | null = null;
122123
let pendingResize = false;
123124

125+
const measureText = createMeasureText();
126+
124127
// ---------------------------------------------------------------------------
125128
// Helpers
126129
// ---------------------------------------------------------------------------
@@ -143,6 +146,7 @@ export function createSankey(
143146
theme: options?.theme,
144147
darkMode,
145148
watermark: options?.watermark,
149+
measureText,
146150
};
147151

148152
return compileSankey(currentSpec, compileOpts);

packages/vanilla/src/sankey-renderer.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import type {
1010
LegendLayout,
11+
MeasureTextFn,
1112
ResolvedAnimation,
1213
ResolvedChromeElement,
1314
SankeyLayout,
@@ -89,6 +90,7 @@ function renderChromeElement(
8990
element: ResolvedChromeElement,
9091
className: string,
9192
chromeKey: string,
93+
measureText?: MeasureTextFn,
9294
): void {
9395
const text = createSVGElement('text');
9496
setAttrs(text, { x: element.x, y: element.y });
@@ -101,6 +103,7 @@ function renderChromeElement(
101103
element.style.fontSize,
102104
element.style.fontWeight,
103105
element.maxWidth,
106+
measureText,
104107
);
105108

106109
if (lines.length === 1) {
@@ -122,13 +125,13 @@ function renderChrome(parent: SVGElement, layout: SankeyLayout): void {
122125
const g = createSVGElement('g');
123126
g.setAttribute('class', 'oc-chrome');
124127

125-
const { chrome } = layout;
128+
const { chrome, measureText } = layout;
126129

127130
if (chrome.title) {
128-
renderChromeElement(g, chrome.title, 'oc-title', 'title');
131+
renderChromeElement(g, chrome.title, 'oc-title', 'title', measureText);
129132
}
130133
if (chrome.subtitle) {
131-
renderChromeElement(g, chrome.subtitle, 'oc-subtitle', 'subtitle');
134+
renderChromeElement(g, chrome.subtitle, 'oc-subtitle', 'subtitle', measureText);
132135
}
133136

134137
// Bottom chrome: positioned below the sankey drawing area.
@@ -140,6 +143,7 @@ function renderChrome(parent: SVGElement, layout: SankeyLayout): void {
140143
{ ...chrome.source, y: bottomOffset + chrome.source.y },
141144
'oc-source',
142145
'source',
146+
measureText,
143147
);
144148
}
145149
if (chrome.byline) {
@@ -148,6 +152,7 @@ function renderChrome(parent: SVGElement, layout: SankeyLayout): void {
148152
{ ...chrome.byline, y: bottomOffset + chrome.byline.y },
149153
'oc-byline',
150154
'byline',
155+
measureText,
151156
);
152157
}
153158
if (chrome.footer) {
@@ -156,6 +161,7 @@ function renderChrome(parent: SVGElement, layout: SankeyLayout): void {
156161
{ ...chrome.footer, y: bottomOffset + chrome.footer.y },
157162
'oc-footer',
158163
'footer',
164+
measureText,
159165
);
160166
}
161167

@@ -476,7 +482,11 @@ function renderNodes(
476482
// Labels rendering
477483
// ---------------------------------------------------------------------------
478484

479-
function renderLabels(parent: SVGElement, nodes: SankeyNodeMark[]): void {
485+
function renderLabels(
486+
parent: SVGElement,
487+
nodes: SankeyNodeMark[],
488+
measureText?: MeasureTextFn,
489+
): void {
480490
const g = createSVGElement('g');
481491
g.setAttribute('class', 'oc-sankey-labels');
482492

@@ -492,7 +502,7 @@ function renderLabels(parent: SVGElement, nodes: SankeyNodeMark[]): void {
492502
if (label.maxWidth !== undefined && label.maxWidth > 0) {
493503
const fontSize = label.style.fontSize ?? 12;
494504
const fontWeight = label.style.fontWeight ?? 400;
495-
const lines = wrapText(label.text, fontSize, fontWeight, label.maxWidth);
505+
const lines = wrapText(label.text, fontSize, fontWeight, label.maxWidth, measureText);
496506
if (lines.length > 1) {
497507
const lineHeight = fontSize * (label.style.lineHeight ?? 1.3);
498508
// Center the multi-line block vertically around the label y position
@@ -585,7 +595,7 @@ export function renderSankeySVG(
585595
renderNodes(svg, layout.nodes, animation);
586596

587597
// Labels
588-
renderLabels(svg, layout.nodes);
598+
renderLabels(svg, layout.nodes, layout.measureText);
589599

590600
// Legend
591601
renderLegend(svg, layout.legend);

0 commit comments

Comments
 (0)