@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.
@@ -21,6 +21,7 @@ import {
21
21
  BRAND_FONT_SIZE,
22
22
  BRAND_MIN_WIDTH,
23
23
  estimateTextWidth,
24
+ textAscent,
24
25
  wrapText,
25
26
  } from '@opendata-ai/openchart-core';
26
27
  import { clampStaggerDelay } from '@opendata-ai/openchart-engine';
@@ -94,7 +95,9 @@ function renderChromeElement(
94
95
  measureText?: MeasureTextFn,
95
96
  ): void {
96
97
  const text = createSVGElement('text');
97
- setAttrs(text, { x: element.x, y: element.y });
98
+ // element.y is the TOP of the text box; convert to the alphabetic baseline
99
+ // (see renderers/chrome.ts — WebKit mishandles dominant-baseline:hanging).
100
+ setAttrs(text, { x: element.x, y: element.y + textAscent(element.style.fontSize) });
98
101
  applyTextStyle(text, element.style);
99
102
  text.setAttribute('class', className);
100
103
  text.setAttribute('data-chrome-key', chromeKey);
@@ -52,10 +52,12 @@ export function renderChartSVG(
52
52
  setAttrs(svg, {
53
53
  viewBox: `0 0 ${width} ${height}`,
54
54
  xmlns: SVG_NS,
55
- // WebKit/iOS Safari getBBox() bug: text with dominant-baseline:hanging
56
- // reports bounding boxes extending above y=0. The SVG spec default
57
- // overflow is "hidden", which clips this phantom extent. Setting
58
- // overflow:visible prevents the clipping. Chart marks are already
55
+ // The SVG spec default is overflow:"hidden", which clips anything a hair
56
+ // outside the viewBox. We now position all text on the alphabetic/central
57
+ // baseline (dominant-baseline:hanging was dropped because WebKit computed
58
+ // it from different metrics), but WebKit/iOS still reports getBBox extents
59
+ // with a few pixels of slack around tspans, so text touching an edge can
60
+ // still get clipped. overflow:visible avoids that. Chart marks are already
59
61
  // constrained by a clipPath, so nothing bleeds out.
60
62
  overflow: 'visible',
61
63
  // Hint browsers to enable sub-pixel font hinting and kerning for chart text.
@@ -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 { renderTileMapSVG } from './tilemap-renderer';
29
29
  import { createTooltipManager, type TooltipManager } from './tooltip';
@@ -118,7 +118,30 @@ export function createTileMap(
118
118
  let animationCleanup: (() => void) | null = null;
119
119
  let pendingResize = false;
120
120
 
121
- const measureText = createMeasureText();
121
+ // Set when webfonts have loaded and a recompile is owed. The next render()
122
+ // that recompiles flips data-oc-fonts-state to 'ready' and clears this, so
123
+ // the attribute stays honest when resize() defers to pendingResize during
124
+ // the entrance animation.
125
+ let fontsReloadPending = false;
126
+
127
+ // Apply the root class up front so getComputedStyle sees --oc-font-family
128
+ // before the text measurer is built.
129
+ container.classList.add('oc-tilemap-root');
130
+
131
+ // Resolve the effective font the way compile() will: compile merges
132
+ // { ...spec.theme, ...options.theme }, so options.theme wins over the
133
+ // spec-level theme; fall back to the container's computed font. Measuring a
134
+ // different font than gets rendered desyncs layout metrics and the reload watcher.
135
+ function resolveEffectiveFont(): string {
136
+ return (
137
+ options?.theme?.fonts?.family ??
138
+ currentSpec.theme?.fonts?.family ??
139
+ resolveFontFamily(container)
140
+ );
141
+ }
142
+ let fontFamily = resolveEffectiveFont();
143
+ let measureText = createMeasureText(fontFamily);
144
+ let renderGen = 0;
122
145
 
123
146
  // ---------------------------------------------------------------------------
124
147
  // Helpers
@@ -273,10 +296,28 @@ export function createTileMap(
273
296
  }
274
297
  });
275
298
  }
299
+
300
+ renderGen += 1;
301
+ container.dataset.ocRenderGen = String(renderGen);
302
+
303
+ // This render recompiled with the loaded webfonts; publish 'ready' now
304
+ // rather than right after the fonts-ready resize() (which may have deferred
305
+ // to pendingResize during the entrance animation).
306
+ if (fontsReloadPending) {
307
+ fontsReloadPending = false;
308
+ container.dataset.ocFontsState = 'ready';
309
+ }
276
310
  }
277
311
 
278
312
  function update(newSpec: TileMapSpec): void {
279
313
  currentSpec = newSpec;
314
+ // A new spec can change theme.fonts.family; rebuild the measurer so layout
315
+ // measures the font compile will actually render with.
316
+ const nextFont = resolveEffectiveFont();
317
+ if (nextFont !== fontFamily) {
318
+ fontFamily = nextFont;
319
+ measureText = createMeasureText(fontFamily);
320
+ }
280
321
  currentLayout = compile();
281
322
  render();
282
323
  }
@@ -365,8 +406,8 @@ export function createTileMap(
365
406
  // Initialize
366
407
  // ---------------------------------------------------------------------------
367
408
 
368
- // Add root class for CSS custom properties (tokens, tooltip styles)
369
- container.classList.add('oc-tilemap-root');
409
+ // Root class was applied before the measurer was built (see above); dark
410
+ // mode class still applies here.
370
411
  if (resolveDarkMode(options?.darkMode)) {
371
412
  container.classList.add('oc-dark');
372
413
  }
@@ -375,6 +416,21 @@ export function createTileMap(
375
416
  currentLayout = compile();
376
417
  render(true);
377
418
 
419
+ // Recompile once after webfonts load so labels aren't stuck measured
420
+ // against fallback metrics on real devices.
421
+ const fontsPending = scheduleFontReload(
422
+ fontFamily,
423
+ () => !destroyed,
424
+ () => {
425
+ // Mark the reload owed, then recompile. render() flips the attribute to
426
+ // 'ready' once it actually recompiles, including the pendingResize replay
427
+ // after the entrance animation finishes.
428
+ fontsReloadPending = true;
429
+ resize();
430
+ },
431
+ );
432
+ container.dataset.ocFontsState = fontsPending ? 'pending' : 'ready';
433
+
378
434
  // Setup responsive resizing
379
435
  if (options?.responsive !== false) {
380
436
  disconnectResize = observeResize(container, () => {
@@ -11,6 +11,7 @@ import type {
11
11
  TileMapLayout,
12
12
  TileMapTileMark,
13
13
  } from '@opendata-ai/openchart-core';
14
+ import { textAscent } from '@opendata-ai/openchart-core';
14
15
 
15
16
  const SVG_NS = 'http://www.w3.org/2000/svg';
16
17
  const XLINK_NS = 'http://www.w3.org/1999/xlink';
@@ -41,6 +42,8 @@ function setAttrs(el: SVGElement, attrs: Record<string, string | number>): void
41
42
  // Chrome rendering
42
43
  // ---------------------------------------------------------------------------
43
44
 
45
+ // Chrome y positions are top-edge coordinates; convert to the alphabetic
46
+ // baseline via textAscent() (WebKit mishandles dominant-baseline:hanging).
44
47
  function renderChrome(parent: SVGElement, layout: TileMapLayout): void {
45
48
  const g = createSVGElement('g');
46
49
  g.setAttribute('class', 'oc-chrome');
@@ -50,7 +53,10 @@ function renderChrome(parent: SVGElement, layout: TileMapLayout): void {
50
53
 
51
54
  if (chrome.title) {
52
55
  const text = createSVGElement('text');
53
- setAttrs(text, { x: chrome.title.x, y: chrome.title.y });
56
+ setAttrs(text, {
57
+ x: chrome.title.x,
58
+ y: chrome.title.y + textAscent(chrome.title.style.fontSize),
59
+ });
54
60
  text.setAttribute('class', 'oc-title');
55
61
  text.setAttribute('font-family', chrome.title.style.fontFamily);
56
62
  text.setAttribute('font-size', String(chrome.title.style.fontSize));
@@ -62,7 +68,10 @@ function renderChrome(parent: SVGElement, layout: TileMapLayout): void {
62
68
 
63
69
  if (chrome.subtitle) {
64
70
  const text = createSVGElement('text');
65
- setAttrs(text, { x: chrome.subtitle.x, y: chrome.subtitle.y });
71
+ setAttrs(text, {
72
+ x: chrome.subtitle.x,
73
+ y: chrome.subtitle.y + textAscent(chrome.subtitle.style.fontSize),
74
+ });
66
75
  text.setAttribute('class', 'oc-subtitle');
67
76
  text.setAttribute('font-family', chrome.subtitle.style.fontFamily);
68
77
  text.setAttribute('font-size', String(chrome.subtitle.style.fontSize));
@@ -77,7 +86,10 @@ function renderChrome(parent: SVGElement, layout: TileMapLayout): void {
77
86
 
78
87
  if (chrome.source) {
79
88
  const text = createSVGElement('text');
80
- setAttrs(text, { x: chrome.source.x, y: bottomOffset + chrome.source.y });
89
+ setAttrs(text, {
90
+ x: chrome.source.x,
91
+ y: bottomOffset + chrome.source.y + textAscent(chrome.source.style.fontSize),
92
+ });
81
93
  text.setAttribute('class', 'oc-source');
82
94
  text.setAttribute('font-family', chrome.source.style.fontFamily);
83
95
  text.setAttribute('font-size', String(chrome.source.style.fontSize));
@@ -92,7 +104,10 @@ function renderChrome(parent: SVGElement, layout: TileMapLayout): void {
92
104
 
93
105
  if (chrome.byline) {
94
106
  const text = createSVGElement('text');
95
- setAttrs(text, { x: chrome.byline.x, y: bottomOffset + chrome.byline.y });
107
+ setAttrs(text, {
108
+ x: chrome.byline.x,
109
+ y: bottomOffset + chrome.byline.y + textAscent(chrome.byline.style.fontSize),
110
+ });
96
111
  text.setAttribute('class', 'oc-byline');
97
112
  text.setAttribute('font-family', chrome.byline.style.fontFamily);
98
113
  text.setAttribute('font-size', String(chrome.byline.style.fontSize));
@@ -107,7 +122,10 @@ function renderChrome(parent: SVGElement, layout: TileMapLayout): void {
107
122
 
108
123
  if (chrome.footer) {
109
124
  const text = createSVGElement('text');
110
- setAttrs(text, { x: chrome.footer.x, y: bottomOffset + chrome.footer.y });
125
+ setAttrs(text, {
126
+ x: chrome.footer.x,
127
+ y: bottomOffset + chrome.footer.y + textAscent(chrome.footer.style.fontSize),
128
+ });
111
129
  text.setAttribute('class', 'oc-footer');
112
130
  text.setAttribute('font-family', chrome.footer.style.fontFamily);
113
131
  text.setAttribute('font-size', String(chrome.footer.style.fontSize));