@opendata-ai/openchart-vanilla 7.2.4 → 7.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,11 +4,20 @@
4
4
  * Shared by mount.ts (charts) and sankey-mount.ts (sankey diagrams) so both
5
5
  * pipelines get accurate browser-measured text widths instead of the heuristic
6
6
  * fallback. Falls back to the heuristic when canvas isn't available (e.g. SSR).
7
+ *
8
+ * The font family is passed in (resolved from the container's --oc-font-family
9
+ * or the spec theme) rather than hardcoded, so measurement matches what the SVG
10
+ * text actually renders with. This matters on real devices where the primary
11
+ * webfont (e.g. Inter) loads late: measuring against the wrong font produces
12
+ * wrong widths and mangles layout.
7
13
  */
8
14
 
9
15
  import type { MeasureTextFn } from '@opendata-ai/openchart-core';
16
+ import { estimateTextWidth } from '@opendata-ai/openchart-core';
17
+
18
+ const DEFAULT_FONT_FAMILY = 'Inter, sans-serif';
10
19
 
11
- export function createMeasureText(): MeasureTextFn {
20
+ export function createMeasureText(fontFamily: string = DEFAULT_FONT_FAMILY): MeasureTextFn {
12
21
  let canvas: HTMLCanvasElement | null = null;
13
22
  let ctx: CanvasRenderingContext2D | null = null;
14
23
 
@@ -23,11 +32,14 @@ export function createMeasureText(): MeasureTextFn {
23
32
  }
24
33
  if (!ctx) {
25
34
  // Fallback: heuristic estimation
26
- return { width: text.length * fontSize * 0.6, height: fontSize * 1.2 };
35
+ return {
36
+ width: estimateTextWidth(text, fontSize, fontWeight ?? 400),
37
+ height: fontSize * 1.2,
38
+ };
27
39
  }
28
40
 
29
41
  const weight = fontWeight ?? 400;
30
- ctx.font = `${weight} ${fontSize}px Inter, sans-serif`;
42
+ ctx.font = `${weight} ${fontSize}px ${fontFamily}`;
31
43
  const metrics = ctx.measureText(text);
32
44
  return {
33
45
  width: metrics.width,
@@ -35,3 +47,88 @@ export function createMeasureText(): MeasureTextFn {
35
47
  };
36
48
  };
37
49
  }
50
+
51
+ /**
52
+ * Resolve the font family a container's SVG text will actually render with.
53
+ *
54
+ * Reads the --oc-font-family custom property (set by tokens.css once .oc-root
55
+ * is applied, or overridden per-spec by the theme resolver), falling back to
56
+ * the container's computed fontFamily, then to the default Inter stack. Returns
57
+ * the previous hardcoded stack when there is no DOM (SSR) or no computed style.
58
+ */
59
+ export function resolveFontFamily(container: HTMLElement): string {
60
+ if (typeof window === 'undefined' || typeof getComputedStyle !== 'function') {
61
+ return DEFAULT_FONT_FAMILY;
62
+ }
63
+ try {
64
+ const style = getComputedStyle(container);
65
+ const custom = style.getPropertyValue('--oc-font-family').trim();
66
+ if (custom) return custom;
67
+ const computed = style.fontFamily?.trim();
68
+ if (computed) return computed;
69
+ } catch {
70
+ // getComputedStyle can throw on detached nodes in some engines; fall through.
71
+ }
72
+ return DEFAULT_FONT_FAMILY;
73
+ }
74
+
75
+ /**
76
+ * The first family in a CSS font stack, with surrounding quotes stripped, e.g.
77
+ * `'"Inter Variable", Inter, sans-serif'` -> `Inter Variable`. Used to build a
78
+ * representative check string for document.fonts.check().
79
+ */
80
+ export function primaryFontName(fontFamily: string): string {
81
+ const first = fontFamily.split(',')[0]?.trim() ?? '';
82
+ return first.replace(/^["']|["']$/g, '').trim();
83
+ }
84
+
85
+ /**
86
+ * True when the primary font of the resolved stack is not yet loaded and a
87
+ * later document.fonts.ready may change text metrics. False when fonts are
88
+ * ready, unavailable (SSR / no FontFaceSet), or the primary is a generic family.
89
+ */
90
+ export function fontsPending(fontFamily: string): boolean {
91
+ if (typeof document === 'undefined') return false;
92
+ const fonts = (document as Document & { fonts?: FontFaceSet }).fonts;
93
+ if (!fonts || typeof fonts.check !== 'function') return false;
94
+ const name = primaryFontName(fontFamily);
95
+ if (!name) return false;
96
+ const generics = new Set([
97
+ 'sans-serif',
98
+ 'serif',
99
+ 'monospace',
100
+ 'system-ui',
101
+ 'cursive',
102
+ 'fantasy',
103
+ 'ui-sans-serif',
104
+ 'ui-serif',
105
+ 'ui-monospace',
106
+ ]);
107
+ if (generics.has(name.toLowerCase())) return false;
108
+ try {
109
+ return !fonts.check(`12px "${name}"`);
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Run `onReady` exactly once after webfonts finish loading, if they were
117
+ * pending at call time. `isAlive` is checked before invoking so a destroyed
118
+ * chart is never re-rendered. Returns the initial pending state so callers can
119
+ * set their `ocFontsState` dataset flag. No-op (returns false) under SSR or
120
+ * when the primary font is already loaded.
121
+ */
122
+ export function scheduleFontReload(
123
+ fontFamily: string,
124
+ isAlive: () => boolean,
125
+ onReady: () => void,
126
+ ): boolean {
127
+ if (!fontsPending(fontFamily)) return false;
128
+ const fonts = (document as Document & { fonts?: FontFaceSet }).fonts;
129
+ if (!fonts?.ready) return false;
130
+ fonts.ready.then(() => {
131
+ if (isAlive()) onReady();
132
+ });
133
+ return true;
134
+ }
package/src/mount.ts CHANGED
@@ -53,7 +53,7 @@ import {
53
53
  wireTooltipEvents,
54
54
  wireVoronoiTooltipEvents,
55
55
  } from './interactions';
56
- import { createMeasureText } from './measure-text';
56
+ import { createMeasureText, resolveFontFamily, scheduleFontReload } from './measure-text';
57
57
  import { observeResize } from './resize-observer';
58
58
  import { renderChartSVG } from './svg-renderer';
59
59
  import { createTextEditOverlay } from './text-edit-overlay';
@@ -190,6 +190,13 @@ export function createChart<TData extends DataRow = DataRow>(
190
190
  let cleanupAnimations: (() => void) | null = null;
191
191
  let pendingResize = false;
192
192
 
193
+ // Set when webfonts have loaded and a recompile is owed to reflect final font
194
+ // metrics. The next render() that actually recompiles flips
195
+ // data-oc-fonts-state to 'ready' and clears this. Deferring the flip (rather
196
+ // than setting it right after resize()) keeps the attribute honest when the
197
+ // entrance animation makes resize() defer to pendingResize.
198
+ let fontsReloadPending = false;
199
+
193
200
  // Selection and text editing state
194
201
  let selectedElement: ElementRef | null = options?.selectedElement ?? null;
195
202
  let overlayElement: SVGGElement | null = null;
@@ -200,7 +207,25 @@ export function createChart<TData extends DataRow = DataRow>(
200
207
  const runtimeShownSeries = new Set<string>();
201
208
  let textEditCleanup: (() => void) | null = null;
202
209
 
203
- const measureText = createMeasureText();
210
+ // Apply the root class up front so getComputedStyle can read --oc-font-family
211
+ // before we build the text measurer.
212
+ container.classList.add('oc-root');
213
+
214
+ // Resolve the effective font family the way compile() will. compile merges
215
+ // { ...spec.theme, ...options.theme }, so options.theme wins over the
216
+ // spec-level theme; fall back to the container's computed font. Measuring
217
+ // against a different font than compile renders (e.g. a spec that sets
218
+ // theme.fonts.family) desyncs layout metrics and the font-reload watcher.
219
+ function resolveEffectiveFont(): string {
220
+ return (
221
+ options?.theme?.fonts?.family ??
222
+ currentSpec.theme?.fonts?.family ??
223
+ resolveFontFamily(container)
224
+ );
225
+ }
226
+ let fontFamily = resolveEffectiveFont();
227
+ let measureText = createMeasureText(fontFamily);
228
+ let renderGen = 0;
204
229
 
205
230
  // ---------------------------------------------------------------------------
206
231
  // Compilation
@@ -774,6 +799,20 @@ export function createChart<TData extends DataRow = DataRow>(
774
799
  }
775
800
  });
776
801
  }
802
+ // Bump the render generation so tests (and consumers) can observe every
803
+ // full recompile, including the post-font-load one.
804
+ renderGen += 1;
805
+ container.dataset.ocRenderGen = String(renderGen);
806
+
807
+ // This render recompiled with the loaded webfonts, so the layout now
808
+ // reflects final font metrics. Publish 'ready' only now (not right after
809
+ // the fonts-ready resize(), which may have deferred to pendingResize while
810
+ // the entrance animation was still running).
811
+ if (fontsReloadPending) {
812
+ fontsReloadPending = false;
813
+ container.dataset.ocFontsState = 'ready';
814
+ }
815
+
777
816
  if (isFirstRender) {
778
817
  isFirstRender = false;
779
818
  }
@@ -786,6 +825,13 @@ export function createChart<TData extends DataRow = DataRow>(
786
825
  function update(newSpec: ChartSpec | GraphSpec, updateOpts?: UpdateOptions): void {
787
826
  if (destroyed) return;
788
827
  currentSpec = newSpec;
828
+ // A new spec can change theme.fonts.family; rebuild the measurer so layout
829
+ // measures the font compile will actually render with.
830
+ const nextFont = resolveEffectiveFont();
831
+ if (nextFont !== fontFamily) {
832
+ fontFamily = nextFont;
833
+ measureText = createMeasureText(fontFamily);
834
+ }
789
835
  runtimeHiddenSeries.clear();
790
836
  runtimeShownSeries.clear();
791
837
  if (updateOpts && 'selectedElement' in updateOpts) {
@@ -893,6 +939,23 @@ export function createChart<TData extends DataRow = DataRow>(
893
939
  // Initial render
894
940
  render();
895
941
 
942
+ // Recompile once after webfonts load. On real devices the primary font
943
+ // (e.g. Inter via display=swap) often swaps in after first paint, changing
944
+ // text metrics; without a re-measure, titles wrap wrong and labels collide.
945
+ // Desktop Chrome has the font cached, so it renders 'ready' immediately.
946
+ const pending = scheduleFontReload(
947
+ fontFamily,
948
+ () => !destroyed,
949
+ () => {
950
+ // Mark the reload owed, then trigger a recompile. render() flips the
951
+ // attribute to 'ready' once it actually recompiles — including the
952
+ // pendingResize replay after the entrance animation finishes.
953
+ fontsReloadPending = true;
954
+ resize();
955
+ },
956
+ );
957
+ container.dataset.ocFontsState = pending ? 'pending' : 'ready';
958
+
896
959
  // Set up responsive resize
897
960
  if (options?.responsive !== false) {
898
961
  disconnectResize = observeResize(container, () => {
@@ -214,14 +214,7 @@ function renderAnnotation(
214
214
  const lineHeight = fontSize * (annotation.label.style.lineHeight ?? 1.3);
215
215
  const isMultiLine = lines.length > 1;
216
216
 
217
- // Multi-line text: drop-line connectors keep the resolved side anchor so
218
- // the label hugs the vertical line. Other connectors center the text for
219
- // a cleaner look.
220
217
  if (isMultiLine) {
221
- const isDropLine = annotation.label.connector?.style === 'drop-line';
222
- if (!isDropLine) {
223
- text.setAttribute('text-anchor', 'middle');
224
- }
225
218
  for (let i = 0; i < lines.length; i++) {
226
219
  const tspan = createSVGElement('tspan');
227
220
  setAttrs(tspan, { x: annotation.label.x, dy: i === 0 ? 0 : lineHeight });
@@ -235,20 +228,35 @@ function renderAnnotation(
235
228
  // Render background rect behind text if specified, otherwise use
236
229
  // paint-order stroke halo to knock out lines behind text
237
230
  if (annotation.label.background) {
238
- const charWidth = fontSize * 0.55;
239
- const maxLineWidth = Math.max(...lines.map((l) => l.length)) * charWidth;
240
- const totalHeight = lines.length * lineHeight;
241
231
  const pad = 3;
242
- const bgX = isMultiLine
243
- ? annotation.label.x - maxLineWidth / 2 - pad
244
- : annotation.label.x - pad;
232
+ let bgX: number;
233
+ let bgY: number;
234
+ let bgW: number;
235
+ let bgH: number;
236
+
237
+ if (annotation.label.bounds) {
238
+ const b = annotation.label.bounds;
239
+ bgX = b.x - pad;
240
+ bgY = b.y - pad;
241
+ bgW = b.width + pad * 2;
242
+ bgH = b.height + pad * 2;
243
+ } else {
244
+ const charWidth = fontSize * 0.55;
245
+ const maxLineWidth = Math.max(...lines.map((l) => l.length)) * charWidth;
246
+ const totalHeight = lines.length * lineHeight;
247
+ bgX = isMultiLine ? annotation.label.x - maxLineWidth / 2 - pad : annotation.label.x - pad;
248
+ bgY = annotation.label.y - fontSize + (lineHeight - fontSize) / 2 - pad;
249
+ bgW = maxLineWidth + pad * 2;
250
+ bgH = totalHeight + pad * 2;
251
+ }
252
+
245
253
  const bgRect = createSVGElement('rect');
246
254
  bgRect.setAttribute('class', 'oc-annotation-bg');
247
255
  setAttrs(bgRect, {
248
256
  x: bgX,
249
- y: annotation.label.y - fontSize + (lineHeight - fontSize) / 2 - pad,
250
- width: maxLineWidth + pad * 2,
251
- height: totalHeight + pad * 2,
257
+ y: bgY,
258
+ width: bgW,
259
+ height: bgH,
252
260
  fill: annotation.label.background,
253
261
  rx: 2,
254
262
  });
@@ -8,6 +8,7 @@ import {
8
8
  estimateTextWidth,
9
9
  getAxisTitleOffset,
10
10
  TICK_LABEL_OFFSET,
11
+ textAscent,
11
12
  } from '@opendata-ai/openchart-core';
12
13
  import { applyTextStyle, createSVGElement, setAttrs } from './svg-dom';
13
14
 
@@ -86,16 +87,15 @@ function renderAxis(
86
87
  });
87
88
  } else {
88
89
  const xLabelPad = axis.labelPadding ?? layout.theme.spacing.xAxisLabelPadding;
89
- // Anchor at the text's top edge (hanging baseline) so xLabelPad is the
90
- // literal gap between the axis line and the top of the label, regardless
91
- // of font size. With the default alphabetic baseline the offset lands at
92
- // the text baseline instead, so large fonts let the label top creep up
93
- // and hug (or overlap) the axis line.
90
+ // xLabelPad is the literal gap between the axis line and the TOP of
91
+ // the label regardless of font size, so shift down by the ascent to
92
+ // land on the alphabetic baseline. (dominant-baseline:hanging would
93
+ // express this directly, but WebKit positions hanging from different
94
+ // font metrics than Blink, drifting labels on iOS Safari.)
94
95
  setAttrs(label, {
95
96
  x: tick.position,
96
- y: area.y + area.height + xLabelPad,
97
+ y: area.y + area.height + xLabelPad + textAscent(axis.tickLabelStyle.fontSize),
97
98
  'text-anchor': 'middle',
98
- 'dominant-baseline': 'hanging',
99
99
  });
100
100
  }
101
101
 
@@ -242,9 +242,18 @@ function renderAxis(
242
242
  applyTextStyle(axisLabel, axis.labelStyle);
243
243
  axisLabel.textContent = axis.label;
244
244
 
245
- if (orientation === 'x') {
246
- // Position axis title below tick labels. For rotated labels, compute
247
- // the vertical extent of the rotated ticks and place the title below.
245
+ const tp = axis.titlePosition;
246
+ if (tp) {
247
+ const attrs: Record<string, string | number> = {
248
+ x: tp.x,
249
+ y: tp.y,
250
+ 'text-anchor': 'middle',
251
+ };
252
+ if (tp.angle) {
253
+ attrs.transform = `rotate(${tp.angle}, ${tp.x}, ${tp.y})`;
254
+ }
255
+ setAttrs(axisLabel, attrs);
256
+ } else if (orientation === 'x') {
248
257
  let titleY = area.y + area.height + 35;
249
258
  if (axis.tickAngle && Math.abs(axis.tickAngle) > 10) {
250
259
  const angleRad = Math.abs(axis.tickAngle) * (Math.PI / 180);
@@ -266,7 +275,6 @@ function renderAxis(
266
275
  'text-anchor': 'middle',
267
276
  });
268
277
  } else if (isRight) {
269
- // Rotated right y-axis label (tighter offset on compact viewports)
270
278
  const titleOffset = getAxisTitleOffset(layout.dimensions.width);
271
279
  const titleX = area.x + area.width + titleOffset;
272
280
  setAttrs(axisLabel, {
@@ -276,11 +284,6 @@ function renderAxis(
276
284
  transform: `rotate(90, ${titleX}, ${area.y + area.height / 2})`,
277
285
  });
278
286
  } else {
279
- // Rotated left y-axis label.
280
- // Compute a dynamic offset so the title clears the widest tick label.
281
- // The title is rotated and centered, so axisTitleOffset() adds its own
282
- // half-glyph height on top of the gap (otherwise large title fonts overlap
283
- // the tick labels — the gap is visible clearance, not center-to-edge).
284
287
  const maxTickLabelWidth = axis.ticks.reduce((max, t) => {
285
288
  const w = estimateTextWidth(
286
289
  t.label,
@@ -3,8 +3,8 @@
3
3
  */
4
4
 
5
5
  import type { ChartLayout } from '@opendata-ai/openchart-core';
6
- import { BRAND_FONT_SIZE, BRAND_MIN_WIDTH } from '@opendata-ai/openchart-core';
7
- import { computeXAxisExtent, createSVGElement, setAttrs, XLINK_NS } from './svg-dom';
6
+ import { BRAND_FONT_SIZE, BRAND_MIN_WIDTH, textAscent } from '@opendata-ai/openchart-core';
7
+ import { createSVGElement, setAttrs, XLINK_NS } from './svg-dom';
8
8
 
9
9
  const BRAND_URL = 'https://tryopendata.ai';
10
10
 
@@ -24,8 +24,7 @@ export function renderBrand(parent: SVGElement, layout: ChartLayout): void {
24
24
 
25
25
  // Vertically align with the first bottom chrome element.
26
26
  const { chrome } = layout;
27
- const xAxisExtent = computeXAxisExtent(layout);
28
- const bottomOffset = layout.area.y + layout.area.height + xAxisExtent;
27
+ const bottomOffset = chrome.bottomAnchorY ?? layout.area.y + layout.area.height;
29
28
  const firstBottom = chrome.source ?? chrome.byline ?? chrome.footer;
30
29
  // When no bottom chrome items exist, derive a fallback offset that still
31
30
  // clears any bottom-positioned legend so the brand watermark doesn't
@@ -46,13 +45,15 @@ export function renderBrand(parent: SVGElement, layout: ChartLayout): void {
46
45
 
47
46
  // "try" in normal weight, "OpenData" in semibold, ".ai" in normal weight,
48
47
  // rendered as a single right-aligned text element with three tspans.
49
- // Use hanging baseline to align top-edge with source/byline chrome text.
48
+ // chromeY is the top edge shared with source/byline chrome text; anchor the
49
+ // shared alphabetic baseline from the largest tspan's ascent. (Hanging
50
+ // baseline is avoided: WebKit positions it differently and never inherits
51
+ // it into tspans, which scattered the three spans on iOS Safari.)
50
52
  const BRAND_LARGE = 16;
51
53
  const text = createSVGElement('text');
52
54
  setAttrs(text, {
53
55
  x: rightEdge,
54
- y: chromeY,
55
- 'dominant-baseline': 'hanging',
56
+ y: chromeY + textAscent(BRAND_LARGE),
56
57
  'font-family': layout.theme.fonts.family,
57
58
  'font-size': BRAND_FONT_SIZE,
58
59
  'text-anchor': 'end',
@@ -7,8 +7,8 @@ import type {
7
7
  MeasureTextFn,
8
8
  ResolvedChromeElement,
9
9
  } from '@opendata-ai/openchart-core';
10
- import { estimateTextWidth, wrapText } from '@opendata-ai/openchart-core';
11
- import { applyTextStyle, computeXAxisExtent, createSVGElement, setAttrs } from './svg-dom';
10
+ import { estimateTextWidth, textAscent, wrapText } from '@opendata-ai/openchart-core';
11
+ import { applyTextStyle, createSVGElement, setAttrs } from './svg-dom';
12
12
 
13
13
  function renderChromeElement(
14
14
  parent: SVGElement,
@@ -19,7 +19,10 @@ function renderChromeElement(
19
19
  uppercase = false,
20
20
  ): void {
21
21
  const text = createSVGElement('text');
22
- setAttrs(text, { x: element.x, y: element.y });
22
+ // element.y is the TOP of the text box; convert to the alphabetic baseline
23
+ // here instead of relying on dominant-baseline:hanging, which WebKit
24
+ // positions from different font metrics and never inherits into tspans.
25
+ setAttrs(text, { x: element.x, y: element.y + textAscent(element.style.fontSize) });
23
26
  applyTextStyle(text, element.style);
24
27
  text.setAttribute('class', className);
25
28
  text.setAttribute('data-chrome-key', chromeKey);
@@ -61,8 +64,8 @@ export function renderChrome(parent: SVGElement, layout: ChartLayout): void {
61
64
  // Top chrome: render at their stored y positions (already absolute)
62
65
  if (chrome.eyebrow) {
63
66
  // Leading accent dot — matches the editorial design system mock.
64
- // Eyebrow text uses dominantBaseline: hanging, so eyebrow.y is the top of
65
- // the text. Visual center is roughly y + fontSize * 0.55 (cap height).
67
+ // eyebrow.y is the top of the text box. Visual center is roughly
68
+ // y + fontSize * 0.55 (cap height).
66
69
  const eyebrow = chrome.eyebrow;
67
70
  const dotR = 3;
68
71
  const dotGap = 8;
@@ -87,10 +90,7 @@ export function renderChrome(parent: SVGElement, layout: ChartLayout): void {
87
90
  renderChromeElement(g, chrome.subtitle, 'oc-subtitle', 'subtitle', measureText);
88
91
  }
89
92
 
90
- // Bottom chrome starts below x-axis labels/title, not at chart area bottom.
91
- // Accounts for rotated tick labels which need more vertical space.
92
- const xAxisExtent = computeXAxisExtent(layout);
93
- const bottomOffset = layout.area.y + layout.area.height + xAxisExtent;
93
+ const bottomOffset = layout.chrome.bottomAnchorY ?? layout.area.y + layout.area.height;
94
94
  if (chrome.source) {
95
95
  renderChromeElement(
96
96
  g,
@@ -19,14 +19,18 @@ export function renderLegend(parent: SVGElement, legend: LegendLayout): void {
19
19
  g.setAttribute('aria-label', 'Chart legend');
20
20
 
21
21
  const isHorizontal = legend.position === 'top' || legend.position === 'bottom';
22
+ const positions = 'entryPositions' in legend ? legend.entryPositions : undefined;
22
23
  let offsetX = legend.bounds.x;
23
24
  let offsetY = legend.bounds.y;
24
25
 
25
26
  for (let i = 0; i < legend.entries.length; i++) {
26
27
  const entry = legend.entries[i];
27
28
 
28
- // Pre-check: wrap to next line if this entry would overflow bounds
29
- if (isHorizontal && i > 0) {
29
+ const pos = positions?.[i];
30
+ if (pos) {
31
+ offsetX = pos.x;
32
+ offsetY = pos.y;
33
+ } else if (isHorizontal && i > 0) {
30
34
  const labelWidth = estimateTextWidth(
31
35
  entry.label,
32
36
  legend.labelStyle.fontSize,
@@ -116,17 +120,21 @@ export function renderLegend(parent: SVGElement, legend: LegendLayout): void {
116
120
 
117
121
  g.appendChild(entryG);
118
122
 
119
- // Advance position for next entry
120
- if (isHorizontal) {
121
- const labelWidth = estimateTextWidth(
122
- entry.label,
123
- legend.labelStyle.fontSize,
124
- legend.labelStyle.fontWeight,
125
- );
126
- const entryWidth = legend.swatchSize + legend.swatchGap + labelWidth + legend.entryGap;
127
- offsetX += entryWidth;
128
- } else {
129
- offsetY += legend.swatchSize + legend.entryGap;
123
+ if (!pos) {
124
+ if (isHorizontal) {
125
+ const labelWidth = estimateTextWidth(
126
+ entry.label,
127
+ legend.labelStyle.fontSize,
128
+ legend.labelStyle.fontWeight,
129
+ );
130
+ const entryWidth = legend.swatchSize + legend.swatchGap + labelWidth + legend.entryGap;
131
+ offsetX += entryWidth;
132
+ } else {
133
+ offsetY +=
134
+ 'rowHeight' in legend && legend.rowHeight
135
+ ? legend.rowHeight
136
+ : legend.swatchSize + legend.entryGap;
137
+ }
130
138
  }
131
139
  }
132
140
 
@@ -4,8 +4,7 @@
4
4
  * Pure, stateless utilities. No layout/theme knowledge.
5
5
  */
6
6
 
7
- import type { ChartLayout, TextStyle } from '@opendata-ai/openchart-core';
8
- import { estimateTextWidth } from '@opendata-ai/openchart-core';
7
+ import type { TextStyle } from '@opendata-ai/openchart-core';
9
8
 
10
9
  export const SVG_NS = 'http://www.w3.org/2000/svg';
11
10
  export const XLINK_NS = 'http://www.w3.org/1999/xlink';
@@ -39,28 +38,3 @@ export function applyTextStyle(el: SVGElement, style: TextStyle): void {
39
38
  el.setAttribute('font-variant', style.fontVariant);
40
39
  }
41
40
  }
42
-
43
- /**
44
- * Compute the vertical extent of x-axis labels below the chart area.
45
- * Accounts for rotated tick labels which need more vertical space.
46
- */
47
- export function computeXAxisExtent(layout: ChartLayout): number {
48
- const xAxis = layout.axes.x;
49
- if (!xAxis) return 0;
50
-
51
- if (xAxis.tickAngle && Math.abs(xAxis.tickAngle) > 10) {
52
- // Rotated labels: estimate height from the longest tick label.
53
- const fontSize = xAxis.tickLabelStyle.fontSize;
54
- const fontWeight = xAxis.tickLabelStyle.fontWeight;
55
- const angleRad = Math.abs(xAxis.tickAngle) * (Math.PI / 180);
56
- let maxLabelWidth = 40;
57
- for (const tick of xAxis.ticks) {
58
- const w = estimateTextWidth(tick.label, fontSize, fontWeight);
59
- if (w > maxLabelWidth) maxLabelWidth = w;
60
- }
61
- const rotatedHeight = Math.min(maxLabelWidth * Math.sin(angleRad) + 6, 120);
62
- return xAxis.label ? rotatedHeight + 20 : rotatedHeight;
63
- }
64
-
65
- return xAxis.label ? 48 : 26;
66
- }
@@ -23,7 +23,7 @@ import {
23
23
  type JPGExportOptions,
24
24
  type SVGExportOptions,
25
25
  } from './export';
26
- import { createMeasureText } from './measure-text';
26
+ import { createMeasureText, resolveFontFamily, scheduleFontReload } from './measure-text';
27
27
  import { observeResize } from './resize-observer';
28
28
  import { renderSankeySVG } from './sankey-renderer';
29
29
  import { createTooltipManager, type TooltipManager } from './tooltip';
@@ -122,7 +122,30 @@ export function createSankey(
122
122
  let animationCleanup: (() => void) | null = null;
123
123
  let pendingResize = false;
124
124
 
125
- const measureText = createMeasureText();
125
+ // Set when webfonts have loaded and a recompile is owed. The next render()
126
+ // that recompiles flips data-oc-fonts-state to 'ready' and clears this, so
127
+ // the attribute stays honest when resize() defers to pendingResize during
128
+ // the entrance animation.
129
+ let fontsReloadPending = false;
130
+
131
+ // Apply the root class up front so getComputedStyle sees --oc-font-family
132
+ // before the text measurer is built.
133
+ container.classList.add('oc-sankey-root');
134
+
135
+ // Resolve the effective font the way compile() will: compile merges
136
+ // { ...spec.theme, ...options.theme }, so options.theme wins over the
137
+ // spec-level theme; fall back to the container's computed font. Measuring a
138
+ // different font than gets rendered desyncs layout metrics and the reload watcher.
139
+ function resolveEffectiveFont(): string {
140
+ return (
141
+ options?.theme?.fonts?.family ??
142
+ currentSpec.theme?.fonts?.family ??
143
+ resolveFontFamily(container)
144
+ );
145
+ }
146
+ let fontFamily = resolveEffectiveFont();
147
+ let measureText = createMeasureText(fontFamily);
148
+ let renderGen = 0;
126
149
 
127
150
  // ---------------------------------------------------------------------------
128
151
  // Helpers
@@ -410,6 +433,17 @@ export function createSankey(
410
433
  }
411
434
  });
412
435
  }
436
+
437
+ renderGen += 1;
438
+ container.dataset.ocRenderGen = String(renderGen);
439
+
440
+ // This render recompiled with the loaded webfonts; publish 'ready' now
441
+ // rather than right after the fonts-ready resize() (which may have deferred
442
+ // to pendingResize during the entrance animation).
443
+ if (fontsReloadPending) {
444
+ fontsReloadPending = false;
445
+ container.dataset.ocFontsState = 'ready';
446
+ }
413
447
  }
414
448
 
415
449
  // ---------------------------------------------------------------------------
@@ -419,6 +453,13 @@ export function createSankey(
419
453
  function update(newSpec: SankeySpec): void {
420
454
  if (destroyed) return;
421
455
  currentSpec = newSpec;
456
+ // A new spec can change theme.fonts.family; rebuild the measurer so layout
457
+ // measures the font compile will actually render with.
458
+ const nextFont = resolveEffectiveFont();
459
+ if (nextFont !== fontFamily) {
460
+ fontFamily = nextFont;
461
+ measureText = createMeasureText(fontFamily);
462
+ }
422
463
  isFirstRender = true; // Allow animation on update
423
464
  render();
424
465
  }
@@ -495,6 +536,7 @@ export function createSankey(
495
536
  svgElement = null;
496
537
 
497
538
  container.classList.remove('oc-dark');
539
+ container.classList.remove('oc-sankey-root');
498
540
  }
499
541
 
500
542
  // ---------------------------------------------------------------------------
@@ -537,12 +579,30 @@ export function createSankey(
537
579
  }
538
580
  });
539
581
  }
582
+
583
+ renderGen += 1;
584
+ container.dataset.ocRenderGen = String(renderGen);
540
585
  } catch (err) {
541
586
  console.error('[viz] Sankey mount failed:', err);
542
587
  // Re-throw so callers can handle the error rather than silently returning a broken instance
543
588
  throw err;
544
589
  }
545
590
 
591
+ // Recompile once after webfonts load so late-swapping fonts don't leave
592
+ // node labels measured against fallback metrics.
593
+ const fontsPending = scheduleFontReload(
594
+ fontFamily,
595
+ () => !destroyed,
596
+ () => {
597
+ // Mark the reload owed, then recompile. render() flips the attribute to
598
+ // 'ready' once it actually recompiles, including the pendingResize replay
599
+ // after the entrance animation finishes.
600
+ fontsReloadPending = true;
601
+ resize();
602
+ },
603
+ );
604
+ container.dataset.ocFontsState = fontsPending ? 'pending' : 'ready';
605
+
546
606
  // Responsive resize
547
607
  if (options?.responsive !== false) {
548
608
  disconnectResize = observeResize(container, () => {