diff --git a/.gitignore b/.gitignore index 62e28c8..cd6d5c1 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,12 @@ build/ # Docs site/ +# Staged from core/dist for local zensical; rebuild stages fresh. +docs/assets/molplot/ # OS / editors .DS_Store *.log + +.tox/ +.venv/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5dcfd9c..cea7e8d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,3 +1,6 @@ +# Managed by prek: `prek install` +default_install_hook_types: [pre-commit, pre-push] + # Local hooks mirror .github/workflows/ci.yml job-for-job so a green pre-push # means a green CI. See CLAUDE.md "Build & test". repos: diff --git a/CLAUDE.md b/CLAUDE.md index 843b797..fe42513 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ on Vega-Lite so a single spec is portable between the browser and matplotlib. | Preset compiler | `scripts/build-presets.mjs` | | TS chart engine | `core/src/` | | TS tests | `core/tests/` | -| Demo gallery | `page/src/` | +| Web Component example | `example/src/` | | Python package | `python/src/molplot/` | | Python tests | `python/tests/` | | Docs | `docs/` | @@ -57,9 +57,9 @@ on Vega-Lite so a single spec is portable between the browser and matplotlib. ```bash npm install npm run build:presets # regenerate generated preset artifacts from presets/*.json -npm run dev # demo gallery at localhost:3000 (alias of dev:page) +npm run dev # Web Component example at localhost:3000 npm run build:core # rslib → core/dist (npm publish artifact) -npm run typecheck # core + page +npm run typecheck # core + example npm test # core (rstest, node) + python (pytest) npm run lint # biome check --write cd python && pytest # python only @@ -67,14 +67,14 @@ cd python && pytest # python only ## Monorepo structure -npm workspaces: `["core", "page"]`. `python/` is a separate hatchling package -(not an npm workspace). `page/` bundles `@molcrafts/molplot` from **source** via +npm workspaces: `["core", "example"]`. `python/` is a separate hatchling package +(not an npm workspace). `example/` bundles `@molcrafts/molplot` from **source** via an rsbuild alias; each package's `dist/` is for publish only. | Package | Path | Purpose | |---------|------|---------| | `@molcrafts/molplot` | `core/` | Vega-Lite chart classes + spec builders + theme | -| `page` | `page/` | React 19 demo gallery | +| `example` | `example/` | React 19 Web Component playground | | `molcrafts-molplot` (PyPI) | `python/` | scienceplots wrapper + VL→matplotlib renderer | ## Critical invariants diff --git a/README.md b/README.md index bf0cf2b..e5a4c7c 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ molplot/ ├── presets/ # canonical design tokens (single source of truth) + JSON schema ├── scripts/ # build-presets.mjs — compiles presets → per-package artifacts ├── core/ # @molcrafts/molplot — Vega-Lite chart classes (TypeScript) -├── page/ # demo gallery (React 19 + rsbuild) +├── example/ # Web Component example (React 19 + rsbuild) ├── python/ # molcrafts-molplot — scienceplots wrapper + VL→matplotlib └── docs/ # zensical docs ``` @@ -62,8 +62,8 @@ fig, ax = molplot.render(spec) # same spec → matplotlib figure ```bash npm install npm run build:presets # regenerate preset artifacts from presets/*.json -npm run dev:page # demo gallery at localhost:3000 -npm run typecheck # core + page +npm run dev:example # Web Component example at localhost:3000 +npm run typecheck # core + example npm test # core (rstest) + python (pytest) npm run lint # biome ``` @@ -72,6 +72,25 @@ The generated preset files (`core/src/presets/generated.ts`, `python/src/molplot/presets/*`) are committed and guarded in CI — run `npm run build:presets` after editing any `presets/*.json` and commit the result. +## Documentation + +Full manual: [docs.molcrafts.org/molplot](https://docs.molcrafts.org/molplot/) +(sources in [`docs/`](docs/)): + +- [Getting started](https://docs.molcrafts.org/molplot/getting-started/) +- [Unified preset](https://docs.molcrafts.org/molplot/getting-started/preset/) +- [Web (Vega-Lite)](https://docs.molcrafts.org/molplot/getting-started/web/) +- [Python (scienceplots)](https://docs.molcrafts.org/molplot/getting-started/python/) +- [Charts in Markdown](https://docs.molcrafts.org/molplot/getting-started/markdown/) +- [API reference](https://docs.molcrafts.org/molplot/api/) + +```bash +# local preview +pip install -e ./python # or: pip install molcrafts-molplot +pip install "zensical>=0.0.53" "molcrafts-zensical-theme>=0.2.5" +zensical serve +``` + ## License BSD-3-Clause diff --git a/biome.json b/biome.json index 45a246f..7fe3d18 100644 --- a/biome.json +++ b/biome.json @@ -9,6 +9,8 @@ "!**/.rslib", "!**/out", "!**/site", + "!**/docs/assets", + "!**/.cache", "!**/*.css", "!**/presets/generated.ts" ] diff --git a/core/package.json b/core/package.json index 89962d1..4431f80 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "@molcrafts/molplot", - "version": "0.1.3", + "version": "0.1.4", "type": "module", "exports": { ".": { diff --git a/core/rslib.config.ts b/core/rslib.config.ts index f383ca9..392eebf 100644 --- a/core/rslib.config.ts +++ b/core/rslib.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ bundle: false, dts: true, source: { + // Sources only (`tests/` lives outside; do not put `*.test.ts` under src/). entry: { index: "./src/**" }, }, output: { diff --git a/core/src/annotations.ts b/core/src/annotations.ts new file mode 100644 index 0000000..f1def97 --- /dev/null +++ b/core/src/annotations.ts @@ -0,0 +1,385 @@ +/** + * Chart annotations as **Vega-Lite layers** (not a separate overlay). + * + * Vega-Lite has no `annotate` / `arrowstyle='|-|'` and no official plugin + * system for annotations (research prototypes only). We expand a small + * molplot extension (`annotations: [...]`) into ordinary `rule` / `text` + * layers so they live in the VL scenegraph and **pan/zoom with the chart**. + * + * Caps are axis-aligned ticks in data space (VL-native). Perfect screen-space + * right angles on log axes would need post-layout pixel math and an overlay + * that is hard to keep in sync with `bind: scales`. + */ + +import type { VegaLiteSpec } from "./specs"; + +export type ScaleBarAnnotation = { + kind: "scaleBar"; + /** Start (data coords). For `along`, a point on the curve. */ + x: number; + y: number; + x2?: number; + y2?: number; + length?: number; + label?: string; + color?: string; + strokeWidth?: number; + /** + * `along` — chord (x,y)→(x2,y2), optionally shifted in data space. + * `horizontal` / `vertical` — axis-aligned size bar. + */ + orientation?: "horizontal" | "vertical" | "along"; + /** + * Multiplicative y-shift for `along` bars (default 1.4) so the bar sits + * above the curve on log–y plots without leaving the VL scenegraph. + */ + yOffset?: number; + /** End-cap half-length as a fraction of local y (log-friendly). Default 0.25. */ + capFrac?: number; + fontSize?: number; +}; + +export type ArrowAnnotation = { + kind: "arrow"; + x: number; + y: number; + x2: number; + y2: number; + label?: string; + color?: string; + strokeWidth?: number; + fontSize?: number; +}; + +export type Annotation = ScaleBarAnnotation | ArrowAnnotation; + +export function scaleBar( + partial: Omit, +): ScaleBarAnnotation { + const hasEnds = partial.x2 != null && partial.y2 != null; + return { + color: "#18432b", + strokeWidth: 2, + orientation: hasEnds ? "along" : "horizontal", + yOffset: 1.4, + capFrac: 0.25, + fontSize: 14, + ...partial, + kind: "scaleBar", + }; +} + +export function arrow(partial: Omit): ArrowAnnotation { + return { + color: "#18432b", + strokeWidth: 1.6, + fontSize: 14, + ...partial, + kind: "arrow", + }; +} + +export function takeAnnotations(spec: Record): { + spec: Record; + annotations: Annotation[]; +} { + const raw = spec.annotations; + if (!Array.isArray(raw) || raw.length === 0) { + if ("annotations" in spec) { + const { annotations: _d, ...rest } = spec; + return { spec: rest, annotations: [] }; + } + return { spec, annotations: [] }; + } + const annotations = raw.filter( + (a): a is Annotation => + !!a && + typeof a === "object" && + ((a as Annotation).kind === "scaleBar" || + (a as Annotation).kind === "arrow"), + ); + const { annotations: _d, ...rest } = spec; + return { spec: rest, annotations }; +} + +function clean(layer: Record): VegaLiteSpec { + for (const k of Object.keys(layer)) { + if (layer[k] === undefined) delete layer[k]; + } + return layer as VegaLiteSpec; +} + +/** + * Expand annotations into VL layers (rule / text). These join the chart + * scenegraph so pan/zoom applies automatically. + */ +export function annotationLayers(annotations: Annotation[]): VegaLiteSpec[] { + const out: VegaLiteSpec[] = []; + for (const a of annotations) { + if (a.kind === "scaleBar") out.push(...scaleBarLayers(a)); + else if (a.kind === "arrow") out.push(...arrowLayers(a)); + } + return out; +} + +/** Merge annotation layers into a unit or layered VL spec. */ +export function withAnnotations( + spec: VegaLiteSpec, + annotations: Annotation[] | undefined | null, +): VegaLiteSpec { + if (!annotations?.length) return spec; + const extra = annotationLayers(annotations); + if (!extra.length) return spec; + + if (Array.isArray(spec.layer)) { + return { ...spec, layer: [...(spec.layer as VegaLiteSpec[]), ...extra] }; + } + if (spec.mark !== undefined || spec.encoding !== undefined) { + const { mark, encoding, data, transform, params, ...rest } = spec as Record< + string, + unknown + >; + const base: VegaLiteSpec = {}; + if (data !== undefined) base.data = data; + if (mark !== undefined) base.mark = mark; + if (encoding !== undefined) base.encoding = encoding; + if (transform !== undefined) base.transform = transform; + if (params !== undefined) base.params = params; + return { ...rest, layer: [base, ...extra] } as VegaLiteSpec; + } + return { ...spec, layer: extra }; +} + +function scaleBarLayers(a: ScaleBarAnnotation): VegaLiteSpec[] { + const color = a.color ?? "#18432b"; + const sw = a.strokeWidth ?? 2; + const fontSize = a.fontSize ?? 14; + const orient = + a.orientation ?? (a.x2 != null && a.y2 != null ? "along" : "horizontal"); + const layers: VegaLiteSpec[] = []; + const q = { type: "quantitative" as const }; + + if (orient === "along" && a.x2 != null && a.y2 != null) { + // Lift the whole bar above the curve in data y (works on linear & log-y). + const yMul = a.yOffset ?? 1.4; + const x0 = a.x; + const x1 = a.x2; + const y0 = a.y * yMul; + const y1 = a.y2 * yMul; + const capFrac = a.capFrac ?? 0.25; + // Vertical caps in data space (VL-native; pan/zoom with scales). + const c0lo = y0 / (1 + capFrac); + const c0hi = y0 * (1 + capFrac); + const c1lo = y1 / (1 + capFrac); + const c1hi = y1 * (1 + capFrac); + const midX = Math.sqrt(x0 * x1); // geometric mid for log-x + const midY = Math.sqrt(y0 * y1) * (1 + capFrac * 0.5); + + layers.push( + clean({ + data: { values: [{ x: x0, y: y0, x2: x1, y2: y1 }] }, + mark: { type: "rule", strokeWidth: sw, color, strokeCap: "butt" }, + encoding: { + x: { field: "x", ...q }, + y: { field: "y", ...q }, + x2: { field: "x2" }, + y2: { field: "y2" }, + }, + }), + clean({ + data: { + values: [ + { x: x0, y: c0lo, y2: c0hi }, + { x: x1, y: c1lo, y2: c1hi }, + ], + }, + mark: { type: "rule", strokeWidth: sw, color }, + encoding: { + x: { field: "x", ...q }, + y: { field: "y", ...q }, + y2: { field: "y2" }, + }, + }), + ); + if (a.label) { + layers.push( + clean({ + data: { values: [{ x: midX, y: midY, label: a.label }] }, + mark: { + type: "text", + fontSize, + font: "Times New Roman, Times, STIX Two Text, serif", + fontStyle: "normal", + color, + align: "center", + baseline: "bottom", + }, + encoding: { + x: { field: "x", ...q }, + y: { field: "y", ...q }, + text: { field: "label", type: "nominal" }, + }, + }), + ); + } + return layers; + } + + if (orient === "vertical") { + const y1 = a.y2 ?? a.y + (a.length ?? 0); + const mid = (a.y + y1) / 2; + const tick = Math.abs(y1 - a.y) * 0.08 || a.x * 0.05; + layers.push( + clean({ + data: { values: [{ x: a.x, y: a.y, y2: y1 }] }, + mark: { type: "rule", strokeWidth: sw, color }, + encoding: { + x: { field: "x", ...q }, + y: { field: "y", ...q }, + y2: { field: "y2" }, + }, + }), + clean({ + data: { + values: [ + { x: a.x - tick, x2: a.x + tick, y: a.y }, + { x: a.x - tick, x2: a.x + tick, y: y1 }, + ], + }, + mark: { type: "rule", strokeWidth: sw, color }, + encoding: { + x: { field: "x", ...q }, + x2: { field: "x2" }, + y: { field: "y", ...q }, + }, + }), + ); + if (a.label) { + layers.push( + clean({ + data: { values: [{ x: a.x, y: mid, label: a.label }] }, + mark: { + type: "text", + dx: 10, + fontSize, + font: "Times New Roman, Times, serif", + color, + align: "left", + baseline: "middle", + }, + encoding: { + x: { field: "x", ...q }, + y: { field: "y", ...q }, + text: { field: "label", type: "nominal" }, + }, + }), + ); + } + return layers; + } + + // horizontal + const x1 = a.x2 ?? a.x + (a.length ?? 0); + const mid = (a.x + x1) / 2; + const capFrac = a.capFrac ?? 0.25; + const yLo = a.y / (1 + capFrac); + const yHi = a.y * (1 + capFrac); + layers.push( + clean({ + data: { values: [{ x: a.x, x2: x1, y: a.y }] }, + mark: { type: "rule", strokeWidth: sw, color, strokeCap: "butt" }, + encoding: { + x: { field: "x", ...q }, + x2: { field: "x2" }, + y: { field: "y", ...q }, + }, + }), + clean({ + data: { + values: [ + { x: a.x, y: yLo, y2: yHi }, + { x: x1, y: yLo, y2: yHi }, + ], + }, + mark: { type: "rule", strokeWidth: sw, color }, + encoding: { + x: { field: "x", ...q }, + y: { field: "y", ...q }, + y2: { field: "y2" }, + }, + }), + ); + if (a.label) { + layers.push( + clean({ + data: { values: [{ x: mid, y: yHi, label: a.label }] }, + mark: { + type: "text", + dy: -4, + fontSize, + font: "Times New Roman, Times, serif", + color, + align: "center", + baseline: "bottom", + }, + encoding: { + x: { field: "x", ...q }, + y: { field: "y", ...q }, + text: { field: "label", type: "nominal" }, + }, + }), + ); + } + return layers; +} + +function arrowLayers(a: ArrowAnnotation): VegaLiteSpec[] { + const color = a.color ?? "#18432b"; + const sw = a.strokeWidth ?? 1.6; + const fontSize = a.fontSize ?? 14; + const q = { type: "quantitative" as const }; + const layers: VegaLiteSpec[] = [ + clean({ + data: { + values: [{ x: a.x, y: a.y, x2: a.x2, y2: a.y2 }], + }, + mark: { type: "rule", strokeWidth: sw, color, strokeCap: "round" }, + encoding: { + x: { field: "x", ...q }, + y: { field: "y", ...q }, + x2: { field: "x2" }, + y2: { field: "y2" }, + }, + }), + ]; + if (a.label) { + layers.push( + clean({ + data: { + values: [ + { + x: (a.x + a.x2) / 2, + y: (a.y + a.y2) / 2, + label: a.label, + }, + ], + }, + mark: { + type: "text", + dy: -8, + fontSize, + font: "Times New Roman, Times, serif", + color, + align: "center", + baseline: "bottom", + }, + encoding: { + x: { field: "x", ...q }, + y: { field: "y", ...q }, + text: { field: "label", type: "nominal" }, + }, + }), + ); + } + return layers; +} diff --git a/core/src/chart_base.ts b/core/src/chart_base.ts index b9138bb..d53b8b3 100644 --- a/core/src/chart_base.ts +++ b/core/src/chart_base.ts @@ -23,7 +23,7 @@ interface SceneItem { } /** Minimal shape of the vega-embed result we depend on. */ -interface EmbedResult { +export interface EmbedResult { view: { data(name: string, values?: unknown[]): unknown; resize(): { run(): unknown }; @@ -31,10 +31,12 @@ interface EmbedResult { /** Top-left of the plot rectangle within the rendered element. */ origin(): number[]; signal(name: string): unknown; + /** Named scale: data value → plot-local pixel (undefined if missing). */ + scale(name: string): ((value: number) => number) | undefined; scenegraph(): { root: SceneItem }; addEventListener( type: string, - handler: (e: unknown, item: unknown) => void, + handler: (e: unknown, item?: unknown) => void, ): void; finalize(): void; }; @@ -215,20 +217,25 @@ export abstract class VegaChart { this.zoomable = zoomParamsOf(spec).length > 0; this.rendered = this.container.querySelector("svg"); this.result = result; + this.afterRender(result); } /** - * Wheel over an axis zooms that axis alone. The spec's zoom params filter on - * flags this stamps, because a Vega event filter cannot read the `width` / - * `height` signals it would need to locate the pointer itself — see - * `ZOOM_EVENT_FLAG` in `specs.ts`. - * - * Bound once for the chart's life: the listener sits on `container`, which - * vega-embed renders into but never replaces. + * Hook after a successful embed. Default no-op. + * Annotations are VL layers (see `withAnnotations`), not a post-render overlay. + */ + protected afterRender(_result: EmbedResult): void {} + + /** + * Wheel interaction for scale zoom: + * - Axis gutter → that axis alone (classic molplot behaviour) + * - Plot interior → both continuous axes (embed-friendly; chat ScrollAreas + * no longer need Shift+wheel for a first useful zoom) + * - Shift+wheel still works via the VL filter without flags * - * Passive, capture phase, and it never calls `preventDefault()`. Consuming - * the wheel is Vega's job and it only does so for a wheel its own selector - * matched, which is why an unmatched wheel still scrolls the enclosing panel. + * Bound once on `container` (vega-embed never replaces it). Non-passive so + * we can `preventDefault` and stop the host page/chat from scrolling when + * a zoom actually applies. */ private bindAxisHoverZoom(): () => void { const onWheel = (event: Event): void => { @@ -237,16 +244,33 @@ export abstract class VegaChart { const rect = this.rendered.getBoundingClientRect(); const [originX, originY] = view.origin(); const wheel = event as ZoomWheelEvent; + const width = view.signal("width") as number; + const height = view.signal("height") as number; + const localX = wheel.clientX - rect.left - originX; + const localY = wheel.clientY - rect.top - originY; const channel = axisChannelAt( axisBounds(view.scenegraph().root), - wheel.clientX - rect.left - originX, - wheel.clientY - rect.top - originY, - view.signal("width") as number, - view.signal("height") as number, + localX, + localY, + width, + height, ); - if (channel) wheel[ZOOM_EVENT_FLAG[channel]] = true; + if (channel) { + wheel[ZOOM_EVENT_FLAG[channel]] = true; + wheel.preventDefault(); + return; + } + // Plot interior: stamp both flags so ordinary wheel zooms the chart + // (otherwise only gutter hits or Shift+wheel did anything). + const inPlot = + localX >= 0 && localX <= width && localY >= 0 && localY <= height; + if (inPlot || wheel.shiftKey) { + wheel[ZOOM_EVENT_FLAG.x] = true; + wheel[ZOOM_EVENT_FLAG.y] = true; + wheel.preventDefault(); + } }; - const options = { capture: true, passive: true }; + const options: AddEventListenerOptions = { capture: true, passive: false }; this.container.addEventListener("wheel", onWheel, options); return () => this.container.removeEventListener("wheel", onWheel, options); } @@ -296,6 +320,14 @@ export abstract class VegaChart { return { width, height }; } + /** Whether a ResizeObserver measurement requires a full re-embed. */ + protected resizeChanged( + previous: { width: number; height: number }, + next: { width: number; height: number }, + ): boolean { + return previous.width !== next.width || previous.height !== next.height; + } + private setupResizeObserver(): void { if (typeof ResizeObserver === "undefined") return; this.resizeObserver = new ResizeObserver(() => { @@ -304,7 +336,13 @@ export abstract class VegaChart { this.resizeRaf = null; if (this.disposed) return; const { width, height } = this.dims(); - if (width === this.lastW && height === this.lastH) return; + if ( + !this.resizeChanged( + { width: this.lastW, height: this.lastH }, + { width, height }, + ) + ) + return; void this.render(); }); }); diff --git a/core/src/element.ts b/core/src/element.ts index ef27b31..0320326 100644 --- a/core/src/element.ts +++ b/core/src/element.ts @@ -26,8 +26,9 @@ import type { ThemeMode } from "./types"; * ``` * * Attributes: `preset` (unified preset name, default the molplot preset), - * `theme` (`auto` | `light` | `dark`, default `auto`), and `spec` (inline JSON, - * a one-line alternative to the script block). + * `theme` (`auto` | `light` | `dark`, default `auto`), `interactive` (`false` + * disables the default pan/zoom bindings), and `spec` (inline JSON, a one-line + * alternative to the script block). */ /** @@ -53,6 +54,52 @@ function parseTheme(value: string | null): ThemeMode { return value === "light" || value === "dark" ? value : "auto"; } +/** Interaction is on by default; accept common false-like HTML values. */ +export function parseInteractive(value: string | null): boolean { + if (value === null || value === "") return true; + return !["false", "0", "off", "none"].includes(value.toLowerCase()); +} + +/** Parse `4:3`, `4/3`, or a numeric width:height ratio. */ +export function parseAspect(value: string | null): number { + if (!value) return 4 / 3; + const parts = value.split(/[:/]/).map(Number); + const ratio = + parts.length === 2 && parts[0] && parts[1] + ? parts[0] / parts[1] + : Number(value); + return Number.isFinite(ratio) && ratio > 0 ? ratio : 4 / 3; +} + +const ELEMENT_STYLE_ID = "molplot-element-defaults"; + +function installElementStyles(): void { + if (typeof document === "undefined") return; + if (document.getElementById(ELEMENT_STYLE_ID)) return; + const style = document.createElement("style"); + style.id = ELEMENT_STYLE_ID; + style.textContent = ` +:where([data-molplot-chart]) { + display: block; + position: relative; + box-sizing: border-box; + width: min(100%, var(--molplot-width, 28rem)); + aspect-ratio: var(--molplot-aspect, 16 / 10); + /* Inner air so axis titles clear the host edge without eating the plot. */ + padding: 0.5rem; + overflow: hidden; +} +:where([data-molplot-chart]) > :where(.molplot-chart__surface) { + position: absolute; + inset: 0.5rem; + min-width: 0; + min-height: 0; + overflow: hidden; +} +`; + document.head.appendChild(style); +} + /** * Register the `` custom element. Idempotent and browser-only: * a no-op when there is no `customElements` registry (SSR/Node) or the tag is @@ -67,14 +114,24 @@ export function defineMolplotChart(tag = "molplot-chart"): void { ) return; if (customElements.get(tag)) return; + installElementStyles(); class MolplotChartElement extends HTMLElement { - static readonly observedAttributes = ["spec", "preset", "theme"]; + static readonly observedAttributes = [ + "spec", + "preset", + "theme", + "interactive", + "width", + "aspect", + ]; private chart: RawChart | null = null; private surface: HTMLElement | null = null; connectedCallback(): void { + this.setAttribute("data-molplot-chart", ""); + this.applySizing(); this.mount(); } @@ -82,7 +139,8 @@ export function defineMolplotChart(tag = "molplot-chart"): void { this.teardown(); } - attributeChangedCallback(): void { + attributeChangedCallback(name: string): void { + if (name === "width" || name === "aspect") this.applySizing(); // Attributes are also set before the first connect; only react once live. if (this.isConnected && this.chart) { this.teardown(); @@ -96,6 +154,7 @@ export function defineMolplotChart(tag = "molplot-chart"): void { // Render into a dedicated child so the base class's `querySelector("svg")` // and ResizeObserver have a stable host — never the sibling