Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ Vega runtime.

Part of [MolPlot](https://github.com/MolCrafts/molplot): every chart builds a
**Vega-Lite spec** (the portable intermediate language) and renders it with
`vega-embed`. The same spec can be rendered to a matplotlib figure by the Python
package `molcrafts-molplot`, so web and paper figures share one description and
one preset.
`vega-embed` using its Canvas renderer. The web package does not emit SVG. The
same spec can be rendered to a matplotlib figure by the Python package
`molcrafts-molplot`, so web and paper figures share one description and one
preset.

## Install

Expand Down
8 changes: 4 additions & 4 deletions core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@molcrafts/molplot",
"version": "0.1.5",
"version": "0.1.6",
"type": "module",
"exports": {
".": {
Expand Down Expand Up @@ -43,9 +43,9 @@
"typescript": "^6.0.3"
},
"dependencies": {
"vega": "^5.30.0",
"vega-lite": "^5.21.0",
"vega-embed": "^6.29.0"
"vega": "^6.3.1",
"vega-lite": "^6.4.3",
"vega-embed": "^7.1.0"
},
"directories": {
"test": "tests"
Expand Down
45 changes: 38 additions & 7 deletions core/src/chart_base.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { trackRender } from "./report";
import {
type VegaLiteSpec,
ZOOM_EVENT_FLAG,
Expand Down Expand Up @@ -122,12 +123,33 @@ export abstract class VegaChart {
this.container = container;
this.themeMode = themeMode;
this.presetName = presetName;
// Pin layout before the first embed — vega-embed injects `.vega-embed
// { display:inline-block; position:relative }` which would otherwise
// shrink-wrap the host to the SVG and break ResizeObserver on parent
// resizes (sidebars, dialogs, responsive cards).
this.pinHostLayout();
this.mountPromise = this.mount();
this.setupResizeObserver();
this.detachAxisZoom = this.bindAxisHoverZoom();
if (this.themeMode === "auto") this.setupThemeObserver();
}

/**
* Keep the host on its allocated layout box. Inline styles beat the
* stylesheet vega-embed injects after page CSS (same specificity, later
* rule would win). We only force `display` / overflow / box-sizing —
* position and size stay under the host author's control (absolute fill,
* flex child, aspect-ratio card, …).
*/
private pinHostLayout(): void {
const { style } = this.container;
style.display = "block";
style.overflow = style.overflow || "hidden";
style.boxSizing = "border-box";
if (!style.minWidth) style.minWidth = "0";
if (!style.minHeight) style.minHeight = "0";
}

/** Resolves once the initial render completes. */
ready(): Promise<void> {
return this.mountPromise;
Expand Down Expand Up @@ -181,14 +203,21 @@ export abstract class VegaChart {
protected async render(): Promise<void> {
if (this.renderInFlight) await this.renderInFlight;
const run = this.renderImpl();
this.renderInFlight = run.finally(() => {
if (this.renderInFlight === run) this.renderInFlight = null;
// Await the bookkeeping promise, not `run` alone — otherwise
// `run.finally(...)` is an unhandled rejection when embed throws
// (empty chart, no console line).
const tracked = run.finally(() => {
if (this.renderInFlight === tracked) this.renderInFlight = null;
});
return run;
this.renderInFlight = tracked;
return tracked;
}

private async renderImpl(): Promise<void> {
if (!this.embed || this.disposed) return;
// Re-assert after every embed: finalize/classList churn must not restore
// shrink-wrap and freeze the chart at the previous pixel size.
this.pinHostLayout();
// Host element → page body type/color so docs charts match .md-typeset.
const theme = resolveTheme(this.themeMode, this.presetName, this.container);
const { width, height } = this.dims();
Expand All @@ -200,9 +229,11 @@ export abstract class VegaChart {
this.rendered = null;
const result = (await this.embed(this.container, spec as never, {
actions: false,
renderer: "svg",
// Canvas only — SVG is never used (perf + consistent hit-testing).
renderer: "canvas",
tooltip: true,
})) as unknown as EmbedResult;
this.pinHostLayout();
if (this.disposed) {
result.view.finalize();
return;
Expand All @@ -216,7 +247,7 @@ export abstract class VegaChart {
// A spec that never went through a builder (RawChart) has no zoom params,
// so its wheels can skip the hit test entirely.
this.zoomable = zoomParamsOf(spec).length > 0;
this.rendered = this.container.querySelector("svg");
this.rendered = this.container.querySelector("canvas");
this.result = result;
this.afterRender(result);
}
Expand Down Expand Up @@ -341,7 +372,7 @@ export abstract class VegaChart {
)
)
return;
void this.render();
trackRender("failed to resize", this.render());
});
});
this.resizeObserver.observe(this.container);
Expand All @@ -352,7 +383,7 @@ export abstract class VegaChart {
if (typeof document === "undefined") return;
this.themeObserver = new MutationObserver(() => {
if (this.disposed) return;
void this.render();
trackRender("failed to re-theme", this.render());
});
this.themeObserver.observe(document.documentElement, {
attributes: true,
Expand Down
90 changes: 71 additions & 19 deletions core/src/element.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { PresetName } from "./preset";
import { RawChart } from "./raw_chart";
import { reportMolplotError } from "./report";
import type { VegaLiteSpec } from "./specs";
import type { ThemeMode } from "./types";

Expand Down Expand Up @@ -31,24 +32,39 @@ import type { ThemeMode } from "./types";
* alternative to the script block).
*/

export { reportMolplotError } from "./report";

/** Outcome of reading the author-embedded Vega-Lite spec. */
export type SpecRead =
| { spec: VegaLiteSpec; reason?: undefined; cause?: undefined }
| { spec: null; reason: "empty" | "invalid-json"; cause?: unknown };

/**
* Parse the Vega-Lite spec an author embedded in a `<molplot-chart>`. Reads the
* first child `<script type="application/json">`; falls back to a `spec`
* attribute holding inline JSON. Returns null when no spec is present or the
* JSON is malformed — the element renders an inline error rather than throwing,
* so a typo in one doc block never breaks the page.
* Read the Vega-Lite spec an author embedded in a `<molplot-chart>`. Prefers
* the first child `<script type="application/json">`, then a `spec` attribute.
* Distinguishes empty from malformed JSON so the error boundary can log why.
*/
export function parseSpec(el: HTMLElement): VegaLiteSpec | null {
export function readSpec(el: HTMLElement): SpecRead {
const script = el.querySelector('script[type="application/json"]');
const raw = script?.textContent ?? el.getAttribute("spec");
if (!raw?.trim()) return null;
if (!raw?.trim()) return { spec: null, reason: "empty" };
try {
return JSON.parse(raw) as VegaLiteSpec;
} catch {
return null;
return { spec: JSON.parse(raw) as VegaLiteSpec };
} catch (cause) {
return { spec: null, reason: "invalid-json", cause };
}
}

/**
* Parse the Vega-Lite spec an author embedded in a `<molplot-chart>`. Returns
* null when no spec is present or the JSON is malformed — the element renders
* an inline error rather than throwing, so a typo in one doc block never
* breaks the page.
*/
export function parseSpec(el: HTMLElement): VegaLiteSpec | null {
return readSpec(el).spec;
}

/** Coerce a `theme` attribute to a valid mode, defaulting to `auto`. */
function parseTheme(value: string | null): ThemeMode {
return value === "light" || value === "dark" ? value : "auto";
Expand Down Expand Up @@ -131,6 +147,14 @@ export function defineMolplotChart(tag = "molplot-chart"): void {

connectedCallback(): void {
this.setAttribute("data-molplot-chart", "");
// Theme embeds-guard may have stamped a load-failure banner before
// this element upgraded. Drop it so a working chart is not covered.
this.removeAttribute("data-molcrafts-embed-error");
for (const box of Array.from(
this.querySelectorAll("[data-molcrafts-embed-error-msg]"),
)) {
box.remove();
}
this.applySizing();
this.mount();
}
Expand All @@ -151,34 +175,62 @@ export function defineMolplotChart(tag = "molplot-chart"): void {
private mount(): void {
if (this.chart) return; // already mounted (guard double-connect)
const surface = document.createElement("div");
// Render into a dedicated child so the base class's `querySelector("svg")`
// Render into a dedicated child so the base class's `querySelector("canvas")`
// and ResizeObserver have a stable host — never the sibling <script>.
surface.style.display = "block";
surface.className = "molplot-chart__surface";
this.appendChild(surface);
this.surface = surface;

const spec = parseSpec(this);
if (!spec) {
surface.textContent =
"molplot-chart: missing or invalid Vega-Lite spec";
const parsed = readSpec(this);
if (!parsed.spec) {
const message =
parsed.reason === "empty"
? "missing Vega-Lite spec (empty JSON script / spec attribute)"
: "invalid Vega-Lite JSON";
this.fail(parsed.cause ?? new Error(message), message);
return;
}
const preset =
(this.getAttribute("preset") as PresetName | null) ?? undefined;
const theme = parseTheme(this.getAttribute("theme"));
const interactive = parseInteractive(this.getAttribute("interactive"));
const aspectRatio = parseAspect(this.getAttribute("aspect"));
this.dataset.state = "loading";
this.chart = new RawChart(surface, {
spec,
spec: parsed.spec,
preset,
theme,
interactive,
aspectRatio,
});
void this.chart.ready().then(() => {
if (this.chart) this.dispatchEvent(new CustomEvent("molplot:ready"));
});
void this.chart.ready().then(
() => {
if (!this.chart) return;
this.dataset.state = "ready";
this.dispatchEvent(
new CustomEvent("molplot:ready", { bubbles: true, composed: true }),
);
},
(cause: unknown) => {
this.fail(cause, "failed to render");
},
);
}

private fail(cause: unknown, scope: string): void {
const error = reportMolplotError(scope, cause, this);
this.dataset.state = "error";
if (this.surface) {
this.surface.textContent = `molplot-chart: ${error.message}`;
}
this.dispatchEvent(
new CustomEvent("molplot:error", {
detail: { error },
bubbles: true,
composed: true,
}),
);
}

private teardown(): void {
Expand Down
3 changes: 2 additions & 1 deletion core/src/raw_chart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { PresetName } from "./preset";
import {
interactionParams,
type VegaLiteSpec,
VL_SCHEMA,
type ZoomChannel,
} from "./specs";
import { type ChartTheme, fontScaleForHost, vegaConfig } from "./theme";
Expand Down Expand Up @@ -81,7 +82,7 @@ export class RawChart extends VegaChart {
: baseConfig;

const base: VegaLiteSpec = {
$schema: "https://vega.github.io/schema/vega-lite/v5.json",
$schema: VL_SCHEMA,
width: sizeHint.width,
height,
autosize: { type: "fit", contains: "padding" },
Expand Down
24 changes: 24 additions & 0 deletions core/src/report.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Shared error-boundary helper. Docs `<molplot-chart>` and imperative chart
* classes both render asynchronously; a rejected promise with no listener is
* an empty host and no console line. Always go through here.
*/
export function reportMolplotError(
scope: string,
cause: unknown,
extra?: unknown,
): Error {
const error = cause instanceof Error ? cause : new Error(String(cause));
if (typeof console !== "undefined" && typeof console.error === "function") {
if (extra === undefined) console.error(`[molplot] ${scope}`, error);
else console.error(`[molplot] ${scope}`, error, extra);
}
return error;
}

/** Fire-and-forget a render so a rejection is logged instead of swallowed. */
export function trackRender(scope: string, run: Promise<void>): void {
void run.catch((cause: unknown) => {
reportMolplotError(scope, cause);
});
}
2 changes: 1 addition & 1 deletion core/src/specs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type { AxisConfig, LineChartConfig, ScatterChartConfig } from "./types";
*/
export type VegaLiteSpec = Record<string, unknown>;

export const VL_SCHEMA = "https://vega.github.io/schema/vega-lite/v5.json";
export const VL_SCHEMA = "https://vega.github.io/schema/vega-lite/v6.json";

export interface SpecSize {
width?: number | "container";
Expand Down
15 changes: 11 additions & 4 deletions core/tests/_fake_vega.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,20 @@ export type FakeContainer = HTMLElement & { listenerCount(): number };
/**
* A container stub honouring the slice of the element contract `VegaChart`
* relies on: it binds a wheel listener for axis-hover zoom and queries the
* rendered `<svg>`. Headless runs never render, so `querySelector` returns null
* and the handler bails. `dims()` supplies its own fallback size, so there is
* no `getBoundingClientRect` to stub.
* rendered `<canvas>`. Headless runs never render, so `querySelector` returns
* null and the handler bails. `dims()` supplies its own fallback size, so
* there is no `getBoundingClientRect` to stub.
*
* The pointer classification itself is pure — see `axisChannelAt` — and is
* tested directly rather than through this double.
*/
export function makeFakeContainer(): FakeContainer {
const listeners = new Set<EventListener>();
// Mutable style bag — pinHostLayout writes display/overflow/boxSizing so
// the host keeps a layout box under vega-embed's inline-block default.
const style: Record<string, string> = {};
return {
style,
addEventListener: (_type: string, fn: EventListener) => {
listeners.add(fn);
},
Expand All @@ -36,6 +40,7 @@ export function makeFakeContainer(): FakeContainer {
export interface FakeVega {
embed: VegaEmbed;
specs: Record<string, unknown>[];
options: Record<string, unknown>[];
data: Record<string, unknown[]>;
click(datum: Record<string, unknown>): void;
finalized: number;
Expand All @@ -44,11 +49,13 @@ export interface FakeVega {
export function makeFakeVega(): FakeVega {
const state: FakeVega = {
specs: [],
options: [],
data: {},
finalized: 0,
click: () => {},
embed: async (_el, spec) => {
embed: async (_el, spec, options) => {
state.specs.push(spec as Record<string, unknown>);
state.options.push((options ?? {}) as Record<string, unknown>);
const handlers: ((e: unknown, item: unknown) => void)[] = [];
const view = {
data(name: string, values?: unknown[]) {
Expand Down
Loading
Loading