mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Chart fonts: self-hosted roster + document.fonts.load render gate
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
import vegaEmbed, { type Result as EmbedResult } from 'vega-embed';
|
||||
import type { VisualizationSpec } from 'vega-embed';
|
||||
import type { Config } from 'vega-lite';
|
||||
import { collectFontFamilies } from '@core/custom-theme';
|
||||
|
||||
/** Options for `RenderHandle.toImageURL` (spec §08 → Per-chart export). */
|
||||
interface ImageExportOptions {
|
||||
@@ -145,6 +146,44 @@ function canvasObjectUrl(canvas: HTMLCanvasElement): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Weights to preload per family — the roster ships 400/600 (700 for the mono). */
|
||||
const FONT_LOAD_WEIGHTS = ['400', '600', '700'];
|
||||
/**
|
||||
* Cap on waiting for fonts before rendering anyway. A precached/cached face
|
||||
* resolves near-instantly; this only bounds the first fetch of an uncached
|
||||
* subset so a slow network never freezes the preview — it renders with fallback
|
||||
* metrics and sharpens on a later re-render once the face is cached.
|
||||
*/
|
||||
const FONT_LOAD_TIMEOUT_MS = 3000;
|
||||
|
||||
/**
|
||||
* Load the font faces a spec/config will use before rendering. Vega measures
|
||||
* every text label via canvas `measureText` regardless of renderer (even the
|
||||
* 'none' probe runs layout), so a face that finishes loading *after* embed
|
||||
* leaves the whole chart laid out with fallback metrics.
|
||||
*
|
||||
* Font loading is a non-critical enhancement, and an individual face failing
|
||||
* (offline, a 404, or a system family with no `@font-face`) is *expected* — the
|
||||
* correct response is to render with fallback metrics, not to fail the chart. So
|
||||
* we wait on `allSettled` (which never rejects; a rejected load is ignored by
|
||||
* design) and bound a slow first fetch with the timeout. This is the sanctioned
|
||||
* "safe to swallow" case from arch 02's fail-loud rule — the failure mode is
|
||||
* graceful fallback, not a buried bug or lost data — kept explicit rather than
|
||||
* hidden in a catch-all. No-op where the Font Loading API is absent (tests).
|
||||
*/
|
||||
async function ensureFontsLoaded(spec: VisualizationSpec, config: Config): Promise<void> {
|
||||
if (typeof document === 'undefined' || !document.fonts?.load) return;
|
||||
const families = new Set<string>([...collectFontFamilies(config), ...collectFontFamilies(spec)]);
|
||||
if (families.size === 0) return;
|
||||
const loads = [...families].flatMap((family) =>
|
||||
FONT_LOAD_WEIGHTS.map((weight) => document.fonts.load(`${weight} 16px ${family}`)),
|
||||
);
|
||||
await Promise.race([
|
||||
Promise.allSettled(loads),
|
||||
new Promise((resolve) => setTimeout(resolve, FONT_LOAD_TIMEOUT_MS)),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Embed a prepared spec into `node`. Always: no actions menu. Renderer per `options`. */
|
||||
export async function renderSpec(
|
||||
node: HTMLElement,
|
||||
@@ -154,6 +193,9 @@ export async function renderSpec(
|
||||
): Promise<RenderHandle> {
|
||||
const renderer = options.renderer ?? 'svg';
|
||||
|
||||
// Load the chart's fonts before any layout pass measures text (see the helper).
|
||||
await ensureFontsLoaded(spec, config);
|
||||
|
||||
// Canvas can't allocate past the browser's max dimension, and an oversized canvas
|
||||
// fails *silently* (a blank/broken surface, sometimes a null 2d context). So for
|
||||
// canvas we first run a headless ('none') layout pass — no canvas allocated — read
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CURRENT_THEME_VERSION,
|
||||
THEME_FONT_OPTIONS,
|
||||
applyFontToConfig,
|
||||
collectFontFamilies,
|
||||
createCustomTheme,
|
||||
} from './custom-theme';
|
||||
import { THEME_PREVIEW_SPECS } from './theme-preview-specs';
|
||||
@@ -62,13 +63,63 @@ describe('applyFontToConfig', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectFontFamilies', () => {
|
||||
it('collects the top-level font and every *Font slot', () => {
|
||||
const config = {
|
||||
font: 'Inter',
|
||||
axis: { labelFont: 'Spectral', titleFont: 'Spectral' },
|
||||
legend: { labelFont: 'Caveat' },
|
||||
};
|
||||
expect(collectFontFamilies(config)).toEqual(new Set(['Inter', 'Spectral', 'Caveat']));
|
||||
});
|
||||
|
||||
it('walks arrays (layered specs)', () => {
|
||||
const spec = { layer: [{ mark: { font: 'A' } }, { mark: { font: 'B' } }] };
|
||||
expect(collectFontFamilies(spec)).toEqual(new Set(['A', 'B']));
|
||||
});
|
||||
|
||||
it('skips data and datasets (rows are never fonts)', () => {
|
||||
const spec = {
|
||||
data: { values: [{ font: 'NotAFont', x: 1 }] },
|
||||
datasets: { d: [{ titleFont: 'AlsoNot' }] },
|
||||
config: { font: 'Real' },
|
||||
};
|
||||
expect(collectFontFamilies(spec)).toEqual(new Set(['Real']));
|
||||
});
|
||||
|
||||
it('ignores non-string font values', () => {
|
||||
expect(collectFontFamilies({ font: 42, fontSize: 11 })).toEqual(new Set());
|
||||
});
|
||||
|
||||
it('returns family stacks verbatim (the render gate loads them as-is)', () => {
|
||||
expect(collectFontFamilies({ font: '"Inter", system-ui, sans-serif' })).toEqual(
|
||||
new Set(['"Inter", system-ui, sans-serif']),
|
||||
);
|
||||
});
|
||||
|
||||
it('finds the font a theme applies (round-trip with applyFontToConfig)', () => {
|
||||
const applied = applyFontToConfig({ axis: { labelFont: 'Old' } }, 'New');
|
||||
expect(collectFontFamilies(applied)).toEqual(new Set(['New']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('THEME_FONT_OPTIONS', () => {
|
||||
it('offers distinct, non-empty CSS stacks', () => {
|
||||
expect(THEME_FONT_OPTIONS.length).toBeGreaterThanOrEqual(4);
|
||||
const values = THEME_FONT_OPTIONS.map((f) => f.value);
|
||||
const labels = THEME_FONT_OPTIONS.map((f) => f.label);
|
||||
expect(new Set(values).size).toBe(values.length);
|
||||
expect(new Set(labels).size).toBe(labels.length);
|
||||
expect(values.every((v) => v.trim().length > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('every self-hosted family (quoted primary) carries a category fallback', () => {
|
||||
// A quoted primary that hasn't loaded must degrade to a sensible system font,
|
||||
// so each roster stack lists at least one fallback after the primary.
|
||||
for (const { value } of THEME_FONT_OPTIONS.filter((o) => o.value.startsWith('"'))) {
|
||||
expect(value).toContain(',');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('THEME_PREVIEW_SPECS', () => {
|
||||
|
||||
@@ -84,18 +84,62 @@ export interface ThemeFontOption {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fonts the builder can apply today: the two self-hosted Plex faces the app
|
||||
* already loads, plus web-safe/system stacks that need no loading at all. Every
|
||||
* entry is render-safe without a `document.fonts.load` gate — Plex is loaded by
|
||||
* the UI before any chart renders, the rest resolve to locally installed faces.
|
||||
* The self-hosted roster (scope doc §4.5) extends this list and brings the
|
||||
* pre-render loading gate with it.
|
||||
* Fonts the builder can apply: the self-hosted roster (scope doc §3 — chart
|
||||
* fonts, registered in styles/chart-fonts.css) followed by web-safe/system
|
||||
* stacks that need no loading. The roster faces require their woff2 to be loaded
|
||||
* before a chart measures text, which the render path's font gate handles
|
||||
* (`collectFontFamilies` + `document.fonts.load`); the system stacks resolve to
|
||||
* locally installed faces. Order is by role so the in-face dropdown reads as a
|
||||
* specimen. Every primary family pairs with a category-appropriate fallback.
|
||||
*/
|
||||
export const THEME_FONT_OPTIONS: ReadonlyArray<ThemeFontOption> = [
|
||||
// Sans
|
||||
{ value: '"IBM Plex Sans", system-ui, -apple-system, sans-serif', label: 'IBM Plex Sans' },
|
||||
{ value: '"Inter", system-ui, sans-serif', label: 'Inter' },
|
||||
{ value: '"Libre Franklin", system-ui, sans-serif', label: 'Libre Franklin' },
|
||||
{ value: '"Roboto Condensed", system-ui, sans-serif', label: 'Roboto Condensed' },
|
||||
{ value: '"IBM Plex Sans Condensed", system-ui, sans-serif', label: 'IBM Plex Sans Condensed' },
|
||||
// Serif
|
||||
{ value: '"IBM Plex Serif", Georgia, serif', label: 'IBM Plex Serif' },
|
||||
{ value: '"Source Serif 4", Georgia, serif', label: 'Source Serif 4' },
|
||||
{ value: '"Spectral", Georgia, serif', label: 'Spectral' },
|
||||
// Mono
|
||||
{ value: '"IBM Plex Mono", ui-monospace, monospace', label: 'IBM Plex Mono' },
|
||||
{ value: '"Space Mono", ui-monospace, monospace', label: 'Space Mono' },
|
||||
// Display
|
||||
{ value: '"Space Grotesk", system-ui, sans-serif', label: 'Space Grotesk' },
|
||||
{ value: '"Playfair Display", Georgia, serif', label: 'Playfair Display' },
|
||||
// Handwriting
|
||||
{ value: '"Caveat", cursive', label: 'Caveat' },
|
||||
// System / web-safe (no load)
|
||||
{ value: 'system-ui, -apple-system, sans-serif', label: 'System UI' },
|
||||
{ value: 'Helvetica, Arial, sans-serif', label: 'Helvetica / Arial' },
|
||||
{ value: 'Georgia, "Times New Roman", serif', label: 'Georgia' },
|
||||
{ value: '"Courier New", Courier, monospace', label: 'Courier' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Collect every font family stack referenced under a `font` or `*Font` key
|
||||
* anywhere in `value` (a Vega-Lite spec or config) — the read-counterpart of
|
||||
* `applyFontToConfig`'s write. The render path uses it to know which faces to
|
||||
* `document.fonts.load` before measuring text. `data`/`datasets` are skipped:
|
||||
* they hold dataset rows (potentially huge, never fonts), so walking them is
|
||||
* wasted work. Returns the distinct stacks, in no particular order.
|
||||
*/
|
||||
export function collectFontFamilies(value: unknown): Set<string> {
|
||||
const out = new Set<string>();
|
||||
const walk = (node: unknown): void => {
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) walk(item);
|
||||
return;
|
||||
}
|
||||
if (!isJsonObject(node)) return;
|
||||
for (const [key, v] of Object.entries(node)) {
|
||||
if (key === 'data' || key === 'datasets') continue;
|
||||
if ((key === 'font' || key.endsWith('Font')) && typeof v === 'string') out.add(v);
|
||||
else walk(v);
|
||||
}
|
||||
};
|
||||
walk(value);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ import { initPersistentStorage, registerServiceWorker } from './app/orchestratio
|
||||
import { initApp } from './app/orchestration/startup';
|
||||
import { initTheme, wireTheme } from './app/orchestration/theme';
|
||||
import '../styles/base.css';
|
||||
// Self-hosted chart-font roster (Theme Builder font control). Separate from the
|
||||
// UI font in base.css; only its latin subsets are precached (see chart-fonts.css).
|
||||
import '../styles/chart-fonts.css';
|
||||
|
||||
// Hydrate the persisted theme onto <html data-theme> before first paint (no
|
||||
// FOUC), then keep store ↔ DOM ↔ localStorage in sync. Store stays DOM-free;
|
||||
|
||||
Reference in New Issue
Block a user