@opendata-ai/openchart-vanilla 7.3.0 → 7.4.1
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/dist/index.js +208 -28
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +3 -3
- package/src/__tests__/font-race.test.ts +199 -0
- package/src/__tests__/svg-renderer.test.ts +6 -4
- package/src/barlist-mount.ts +58 -3
- package/src/barlist-renderer.ts +4 -1
- package/src/measure-text.ts +93 -6
- package/src/mount.ts +65 -2
- package/src/renderers/axes.ts +7 -7
- package/src/renderers/brand.ts +6 -4
- package/src/renderers/chrome.ts +7 -4
- package/src/sankey-mount.ts +62 -2
- package/src/sankey-renderer.ts +4 -1
- package/src/svg-renderer.ts +6 -4
- package/src/tilemap-mount.ts +60 -4
- package/src/tilemap-renderer.ts +23 -5
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
|
-
|
|
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, () => {
|
package/src/renderers/axes.ts
CHANGED
|
@@ -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
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
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
|
|
package/src/renderers/brand.ts
CHANGED
|
@@ -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
|
-
//
|
|
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',
|
package/src/renderers/chrome.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
65
|
-
//
|
|
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;
|
package/src/sankey-mount.ts
CHANGED
|
@@ -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
|
-
|
|
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, () => {
|
package/src/sankey-renderer.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/src/svg-renderer.ts
CHANGED
|
@@ -52,10 +52,12 @@ export function renderChartSVG(
|
|
|
52
52
|
setAttrs(svg, {
|
|
53
53
|
viewBox: `0 0 ${width} ${height}`,
|
|
54
54
|
xmlns: SVG_NS,
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
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.
|
package/src/tilemap-mount.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
369
|
-
|
|
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, () => {
|
package/src/tilemap-renderer.ts
CHANGED
|
@@ -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, {
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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));
|