@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.
- package/dist/index.js +267 -74
- 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__/known-bugs-render.test.ts +183 -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 +100 -3
- package/src/mount.ts +65 -2
- package/src/renderers/annotations.ts +24 -16
- package/src/renderers/axes.ts +19 -16
- package/src/renderers/brand.ts +8 -7
- package/src/renderers/chrome.ts +9 -9
- package/src/renderers/legend.ts +21 -13
- package/src/renderers/svg-dom.ts +1 -27
- 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/measure-text.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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:
|
|
250
|
-
width:
|
|
251
|
-
height:
|
|
257
|
+
y: bgY,
|
|
258
|
+
width: bgW,
|
|
259
|
+
height: bgH,
|
|
252
260
|
fill: annotation.label.background,
|
|
253
261
|
rx: 2,
|
|
254
262
|
});
|
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
|
|
|
@@ -242,9 +242,18 @@ function renderAxis(
|
|
|
242
242
|
applyTextStyle(axisLabel, axis.labelStyle);
|
|
243
243
|
axisLabel.textContent = axis.label;
|
|
244
244
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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,
|
package/src/renderers/brand.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
//
|
|
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',
|
package/src/renderers/chrome.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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;
|
|
@@ -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
|
-
|
|
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,
|
package/src/renderers/legend.ts
CHANGED
|
@@ -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
|
-
|
|
29
|
-
if (
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
package/src/renderers/svg-dom.ts
CHANGED
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
* Pure, stateless utilities. No layout/theme knowledge.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type {
|
|
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
|
-
}
|
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, () => {
|