From 784b03a91535dbd8292c9b5808927b32a09bb2b5 Mon Sep 17 00:00:00 2001 From: Roy Kid Date: Mon, 10 Aug 2026 14:19:54 +0200 Subject: [PATCH 1/2] feat: canvas-only render and Vega 6 stack (v0.1.6) Pin host layout against vega-embed shrink-wrap so ResizeObserver keeps working in responsive hosts; render with Canvas only; bump VL schema and deps to v6 / vega-embed 7. --- core/README.md | 7 +- core/package.json | 8 +- core/src/chart_base.ts | 30 +- core/src/element.ts | 2 +- core/src/raw_chart.ts | 3 +- core/src/specs.ts | 2 +- core/tests/_fake_vega.ts | 15 +- core/tests/element.test.ts | 5 + core/tests/line_chart.test.ts | 11 + core/tests/specs.test.ts | 2 +- example/package.json | 6 +- package-lock.json | 754 ++++++++++++++++------------------ package.json | 2 +- python/src/molplot/specs.py | 2 +- python/tests/test_specs.py | 2 +- 15 files changed, 418 insertions(+), 433 deletions(-) diff --git a/core/README.md b/core/README.md index 2742b41..e49a76d 100644 --- a/core/README.md +++ b/core/README.md @@ -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 diff --git a/core/package.json b/core/package.json index fc9e2f2..e8d3f01 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "@molcrafts/molplot", - "version": "0.1.5", + "version": "0.1.6", "type": "module", "exports": { ".": { @@ -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" diff --git a/core/src/chart_base.ts b/core/src/chart_base.ts index 2167b63..d289f78 100644 --- a/core/src/chart_base.ts +++ b/core/src/chart_base.ts @@ -122,12 +122,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 { return this.mountPromise; @@ -189,6 +210,9 @@ export abstract class VegaChart { private async renderImpl(): Promise { 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(); @@ -200,9 +224,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; @@ -216,7 +242,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); } diff --git a/core/src/element.ts b/core/src/element.ts index 0320326..b7792b7 100644 --- a/core/src/element.ts +++ b/core/src/element.ts @@ -151,7 +151,7 @@ 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