@opendata-ai/openchart-vanilla 7.3.0 → 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.
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, () => {
@@ -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
 
@@ -3,7 +3,7 @@
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';
6
+ import { BRAND_FONT_SIZE, BRAND_MIN_WIDTH, textAscent } from '@opendata-ai/openchart-core';
7
7
  import { createSVGElement, setAttrs, XLINK_NS } from './svg-dom';
8
8
 
9
9
  const BRAND_URL = 'https://tryopendata.ai';
@@ -45,13 +45,15 @@ export function renderBrand(parent: SVGElement, layout: ChartLayout): void {
45
45
 
46
46
  // "try" in normal weight, "OpenData" in semibold, ".ai" in normal weight,
47
47
  // rendered as a single right-aligned text element with three tspans.
48
- // 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.)
49
52
  const BRAND_LARGE = 16;
50
53
  const text = createSVGElement('text');
51
54
  setAttrs(text, {
52
55
  x: rightEdge,
53
- y: chromeY,
54
- 'dominant-baseline': 'hanging',
56
+ y: chromeY + textAscent(BRAND_LARGE),
55
57
  'font-family': layout.theme.fonts.family,
56
58
  'font-size': BRAND_FONT_SIZE,
57
59
  'text-anchor': 'end',
@@ -7,7 +7,7 @@ import type {
7
7
  MeasureTextFn,
8
8
  ResolvedChromeElement,
9
9
  } from '@opendata-ai/openchart-core';
10
- import { estimateTextWidth, wrapText } from '@opendata-ai/openchart-core';
10
+ import { estimateTextWidth, textAscent, wrapText } from '@opendata-ai/openchart-core';
11
11
  import { applyTextStyle, createSVGElement, setAttrs } from './svg-dom';
12
12
 
13
13
  function renderChromeElement(
@@ -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;
@@ -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, () => {
@@ -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));