From 9246f346ccbc8f7db2fcb2284c417d8c19c62fb5 Mon Sep 17 00:00:00 2001 From: Jichen Li <42854324+Roy-Kid@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:58:31 +0800 Subject: [PATCH 01/17] =?UTF-8?q?feat(render):=20rewrite=20VL=E2=86=92matp?= =?UTF-8?q?lotlib=20as=20a=20staged=20tree-walking=20interpreter=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the ad-hoc per-mark render.py prototype with a small interpreter package (molplot.vlmpl) structured as four compiler passes: normalize → bind scales → dispatch marks → finalize axes. Mark encoders read typed channels instead of hardcoded field names, so marks and scales are extensible. Fixes two Vega-Lite → matplotlib parity gaps the prototype had: - positional scales are applied: log axes (scale.type) and explicit domains (scale.domain → axis limits) now reach the figure. - per-layer transform filters are honoured, so a plain line chart no longer draws the stray marker-layer points; the detail channel splits series that share a legend colour. render.py becomes a thin shim re-exporting molplot.vlmpl.render, preserving the public molplot.render API. Adds test_vlmpl.py for the fixed behaviour. --- python/src/molplot/render.py | 265 +-------------------------- python/src/molplot/vlmpl/__init__.py | 18 ++ python/src/molplot/vlmpl/axes.py | 47 +++++ python/src/molplot/vlmpl/data.py | 52 ++++++ python/src/molplot/vlmpl/interp.py | 49 +++++ python/src/molplot/vlmpl/marks.py | 200 ++++++++++++++++++++ python/src/molplot/vlmpl/model.py | 154 ++++++++++++++++ python/src/molplot/vlmpl/scales.py | 67 +++++++ python/tests/test_vlmpl.py | 61 ++++++ 9 files changed, 658 insertions(+), 255 deletions(-) create mode 100644 python/src/molplot/vlmpl/__init__.py create mode 100644 python/src/molplot/vlmpl/axes.py create mode 100644 python/src/molplot/vlmpl/data.py create mode 100644 python/src/molplot/vlmpl/interp.py create mode 100644 python/src/molplot/vlmpl/marks.py create mode 100644 python/src/molplot/vlmpl/model.py create mode 100644 python/src/molplot/vlmpl/scales.py create mode 100644 python/tests/test_vlmpl.py diff --git a/python/src/molplot/render.py b/python/src/molplot/render.py index 07edec3..41ee121 100644 --- a/python/src/molplot/render.py +++ b/python/src/molplot/render.py @@ -1,263 +1,18 @@ -"""Vega-Lite → matplotlib renderer. +"""Vega-Lite → matplotlib rendering — public entry point. -Reads a self-contained Vega-Lite spec (the kind :mod:`molplot.specs` emits) and -draws it with matplotlib under the unified preset style. This is the concrete -mechanism behind "convert between the web and matplotlib via the Vega-Lite -intermediate language": build one spec, render it in the browser *or* here for a -publication figure. +The implementation is the tree-walking interpreter in :mod:`molplot.vlmpl` +(normalize → bind scales → dispatch marks → finalize axes). This module keeps +the historical ``molplot.render`` import path stable. -The translator targets the mark/encoding shapes MolPlot emits (line / point / -bar / gantt). It is spec-driven (it reads fields and colour scales from the -encoding) but not a general Vega-Lite engine — for pixel-exact web parity of an -arbitrary spec, use ``vl-convert`` instead (see :func:`molplot.to_png`). +Build one Vega-Lite spec with :mod:`molplot.specs` and render it in the browser +(vega-embed / RawChart) *or* here to a publication figure — the equivalence is +the whole point of routing through the Vega-Lite intermediate language. For +pixel-exact web parity of an arbitrary spec, use ``vl-convert`` via +:func:`molplot.to_png`. """ from __future__ import annotations -from contextlib import nullcontext -from datetime import datetime -from typing import Any, Sequence - -from .preset import DEFAULT_PRESET, Mode -from .style import style as _style +from .vlmpl import render __all__ = ["render"] - - -def _ensure_ax(ax: Any) -> tuple[Any, Any]: - import matplotlib.pyplot as plt - - if ax is not None: - return ax.figure, ax - fig, ax = plt.subplots() - return fig, ax - - -def _field(enc: dict[str, Any], channel: str) -> str | None: - c = enc.get(channel) - return c.get("field") if isinstance(c, dict) else None - - -def _color_lookup(color_enc: dict[str, Any] | None) -> tuple[str, dict[Any, str] | str | None]: - """Return (kind, mapping). kind in {'nominal','quantitative','none'}.""" - if not isinstance(color_enc, dict): - return "none", None - if color_enc.get("type") == "quantitative": - scale = color_enc.get("scale") or {} - return "quantitative", scale.get("scheme") - scale = color_enc.get("scale") - if isinstance(scale, dict) and "domain" in scale and "range" in scale: - return "nominal", dict(zip(scale["domain"], scale["range"])) - return "literal", None # field value *is* the colour - - -def _scale_map(enc_channel: dict[str, Any] | None) -> dict[Any, float] | None: - if not isinstance(enc_channel, dict): - return None - scale = enc_channel.get("scale") - if isinstance(scale, dict) and "domain" in scale and "range" in scale: - return dict(zip(scale["domain"], scale["range"])) - return None - - -def _values(node: dict[str, Any], top: list[dict[str, Any]]) -> list[dict[str, Any]]: - data = node.get("data") - if isinstance(data, dict) and isinstance(data.get("values"), list): - return data["values"] - return top - - -def _as_num_time(v: Any) -> float: - import matplotlib.dates as mdates - - if isinstance(v, (int, float)): - return float(v) - return mdates.date2num(datetime.fromisoformat(str(v).replace("Z", "+00:00"))) - - -def _draw_line(ax: Any, rows: list[dict[str, Any]], enc: dict[str, Any]) -> bool: - xf, yf = _field(enc, "x"), _field(enc, "y") - kind, cmap = _color_lookup(enc.get("color")) - color_field = _field(enc, "color") - width_map = _scale_map(enc.get("strokeWidth")) - op_map = _scale_map(enc.get("opacity")) - groups: dict[Any, list[dict[str, Any]]] = {} - for r in rows: - groups.setdefault(r.get(color_field) if color_field else None, []).append(r) - labelled = False - for key, grp in groups.items(): - grp = sorted(grp, key=lambda r: r[xf]) - color = cmap.get(key) if isinstance(cmap, dict) else None - lw = width_map.get(key) if width_map else None - alpha = op_map.get(key) if op_map else None - ax.plot( - [r[xf] for r in grp], - [r[yf] for r in grp], - label=None if key is None else str(key), - color=color, - linewidth=lw, - alpha=alpha, - ) - labelled = labelled or key is not None - return labelled - - -def _draw_point(ax: Any, rows: list[dict[str, Any]], enc: dict[str, Any], mark: dict[str, Any]) -> bool: - xf, yf = _field(enc, "x"), _field(enc, "y") - kind, cmap = _color_lookup(enc.get("color")) - cf = _field(enc, "color") - xs = [r[xf] for r in rows] - ys = [r[yf] for r in rows] - size = mark.get("size", 36) - if kind == "quantitative" and cf: - sc = ax.scatter(xs, ys, c=[r[cf] for r in rows], cmap=cmap, s=size) - ax.figure.colorbar(sc, ax=ax) - elif kind == "nominal" and isinstance(cmap, dict) and cf: - ax.scatter(xs, ys, c=[cmap.get(r[cf]) for r in rows], s=size) - elif kind == "literal" and cf: - ax.scatter(xs, ys, c=[r[cf] for r in rows], s=size) - else: - ax.scatter(xs, ys, color=mark.get("color"), s=size) - return False - - -def _draw_bar(ax: Any, rows: list[dict[str, Any]], enc: dict[str, Any]) -> bool: - horizontal = _field(enc, "y") == "cat" or (enc.get("y", {}).get("field") == "cat") - cat_enc = enc["y"] if horizontal else enc["x"] - val_enc = enc["x"] if horizontal else enc["y"] - cat_f, val_f = cat_enc["field"], val_enc["field"] - _, cmap = _color_lookup(enc.get("color")) - color_field = _field(enc, "color") - stacked = bool(val_enc.get("stack")) - grouped = "xOffset" in enc or "yOffset" in enc - overlay = isinstance(enc.get("opacity"), dict) and "value" in enc["opacity"] - - cats = list(dict.fromkeys(r[cat_f] for r in rows)) - keys = list(dict.fromkeys(r[color_field] for r in rows)) if color_field else [None] - cat_idx = {c: i for i, c in enumerate(cats)} - import numpy as np - - pos = np.arange(len(cats), dtype=float) - bottoms = np.zeros(len(cats)) - nkeys = max(1, len(keys)) - band = 0.8 - bar_w = band / nkeys if grouped else band - for gi, key in enumerate(keys): - vals = np.zeros(len(cats)) - for r in rows: - if color_field and r[color_field] != key: - continue - vals[cat_idx[r[cat_f]]] = r[val_f] - color = cmap.get(key) if isinstance(cmap, dict) else None - label = None if key is None else str(key) - if grouped: - offs = pos - band / 2 + bar_w * (gi + 0.5) - else: - offs = pos - if horizontal: - ax.barh(offs, vals, height=bar_w, left=bottoms if stacked else None, color=color, label=label, alpha=0.65 if overlay else None) - else: - ax.bar(offs, vals, width=bar_w, bottom=bottoms if stacked else None, color=color, label=label, alpha=0.65 if overlay else None) - if stacked: - bottoms += vals - if horizontal: - ax.set_yticks(pos) - ax.set_yticklabels([str(c) for c in cats]) - else: - ax.set_xticks(pos) - ax.set_xticklabels([str(c) for c in cats]) - return bool(color_field) - - -def _draw_gantt(ax: Any, rows: list[dict[str, Any]], enc: dict[str, Any]) -> bool: - _, cmap = _color_lookup(enc.get("color")) - color_field = _field(enc, "color") - op_map = _scale_map(enc.get("opacity")) - op_field = _field(enc, "opacity") - y_sort = enc.get("y", {}).get("sort") - labels = list(y_sort) if y_sort else list(dict.fromkeys(r["label"] for r in rows)) - ypos = {lbl: i for i, lbl in enumerate(labels)} - seen: set[Any] = set() - for r in rows: - start, end = _as_num_time(r["start"]), _as_num_time(r["end"]) - color = cmap.get(r.get(color_field)) if isinstance(cmap, dict) else None - alpha = op_map.get(r.get(op_field)) if op_map else None - lbl = r.get(color_field) - ax.barh(ypos[r["label"]], end - start, left=start, height=0.6, color=color, alpha=alpha, label=None if lbl in seen else str(lbl)) - seen.add(lbl) - ax.set_yticks(range(len(labels))) - ax.set_yticklabels(labels) - is_time = rows and not isinstance(rows[0]["start"], (int, float)) - if is_time: - import matplotlib.dates as mdates - - ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M")) - return bool(color_field) - - -def _apply_axis_titles(ax: Any, enc: dict[str, Any]) -> None: - x = enc.get("x", {}) - y = enc.get("y", {}) - xt = (x.get("axis") or {}).get("title") if isinstance(x, dict) else None - yt = (y.get("axis") or {}).get("title") if isinstance(y, dict) else None - if xt: - ax.set_xlabel(xt) - if yt: - ax.set_ylabel(yt) - - -def _draw_layer(ax: Any, layer: dict[str, Any], top_rows: list[dict[str, Any]], top_enc: dict[str, Any]) -> bool: - rows = _values(layer, top_rows) - enc = {**top_enc, **(layer.get("encoding") or {})} - mark = layer.get("mark") - mtype = mark.get("type") if isinstance(mark, dict) else mark - if not rows: - return False - if mtype == "line": - return _draw_line(ax, rows, enc) - if mtype == "point": - return _draw_point(ax, rows, enc, mark if isinstance(mark, dict) else {}) - if mtype == "bar": - # temporal x + x2 → gantt; otherwise a categorical bar chart. - if "x2" in enc: - return _draw_gantt(ax, rows, enc) - return _draw_bar(ax, rows, enc) - return False - - -def render( - spec: dict[str, Any], - *, - preset: str = DEFAULT_PRESET, - mode: Mode = "light", - ax: Any = None, - apply_style: bool = True, -): - """Render a Vega-Lite spec to matplotlib. Returns ``(figure, axes)``. - - >>> spec = molplot.line_spec([{ "id": "a", "x": [0,1,2], "y": [1,3,2] }]) - >>> fig, ax = molplot.render(spec) - """ - ctx = _style(preset, mode) if apply_style else nullcontext() - with ctx: - fig, ax = _ensure_ax(ax) - top_rows = _values(spec, []) - top_enc = spec.get("encoding") or {} - any_legend = False - if "layer" in spec: - for layer in spec["layer"]: - any_legend = _draw_layer(ax, layer, top_rows, top_enc) or any_legend - else: - any_legend = _draw_layer(ax, spec, top_rows, top_enc) - _apply_axis_titles(ax, top_enc) - # Show a legend only when the spec asked for one. - color_enc = top_enc.get("color") if isinstance(top_enc.get("color"), dict) else None - if not color_enc and "layer" in spec: - for layer in spec["layer"]: - ce = (layer.get("encoding") or {}).get("color") - if isinstance(ce, dict): - color_enc = ce - break - if any_legend and isinstance(color_enc, dict) and color_enc.get("legend") is not None: - ax.legend() - return fig, ax diff --git a/python/src/molplot/vlmpl/__init__.py b/python/src/molplot/vlmpl/__init__.py new file mode 100644 index 0000000..1696706 --- /dev/null +++ b/python/src/molplot/vlmpl/__init__.py @@ -0,0 +1,18 @@ +"""Vega-Lite → matplotlib interpreter. + +A small tree-walking interpreter whose *source language* is Vega-Lite and whose +*target machine* is matplotlib. Four passes, each a classic compiler stage: + +1. :mod:`.model` — normalize the raw spec into a typed, immutable AST (frontend). +2. :mod:`.scales` — bind scales: colour maps, numeric maps, positional axes. +3. :mod:`.marks` — dispatch each mark to an encoder that emits artists (codegen). +4. :mod:`.axes` — finalize titles, scales, and legend (link). + +The public entry is :func:`molplot.render`. +""" + +from __future__ import annotations + +from .interp import render + +__all__ = ["render"] diff --git a/python/src/molplot/vlmpl/axes.py b/python/src/molplot/vlmpl/axes.py new file mode 100644 index 0000000..4700732 --- /dev/null +++ b/python/src/molplot/vlmpl/axes.py @@ -0,0 +1,47 @@ +"""Finalize an Axes after all marks are drawn: axis titles, positional scales +(log / explicit domain — the step the old prototype dropped), and the legend. +Runs once per render so cross-layer axis state is resolved in one place. +""" + +from __future__ import annotations + +from typing import Any + +from .model import Unit +from .scales import apply_positional + + +def apply_axes(ax: Any, units: list[Unit], labelled: bool) -> None: + enc = _merged_encoding(units) + x, y = enc.get("x"), enc.get("y") + if x is not None and x.axis_title: + ax.set_xlabel(x.axis_title) + if y is not None and y.axis_title: + ax.set_ylabel(y.axis_title) + apply_positional(ax, "x", x) + apply_positional(ax, "y", y) + _apply_legend(ax, units, labelled) + + +def _merged_encoding(units: list[Unit]) -> dict: + """First-seen channel across layers (shared x/y live at the spec root and + appear identically in every unit after normalization).""" + merged: dict = {} + for unit in units: + for name, channel in unit.encoding.items(): + merged.setdefault(name, channel) + return merged + + +def _apply_legend(ax: Any, units: list[Unit], labelled: bool) -> None: + """Show a categorical legend only when a colour channel asked for one and + labelled artists exist. Quantitative colour uses a colourbar, added when the + point mark is drawn, so it is skipped here.""" + color = next( + (u.encoding["color"] for u in units if u.encoding.get("color") and u.encoding["color"].wants_legend), + None, + ) + if color is None or color.type == "quantitative": + return + if labelled: + ax.legend() diff --git a/python/src/molplot/vlmpl/data.py b/python/src/molplot/vlmpl/data.py new file mode 100644 index 0000000..4eac285 --- /dev/null +++ b/python/src/molplot/vlmpl/data.py @@ -0,0 +1,52 @@ +"""Data-space helpers: apply a unit's transforms to its rows and coerce +temporal values to matplotlib date numbers. Kept separate so mark encoders +receive plain, already-filtered row dicts and never re-implement filtering. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + + +def apply_transforms(rows: tuple, transforms: tuple) -> list[dict]: + """Return the rows that survive a unit's ``transform`` list. + + Only ``filter`` transforms are meaningful for a static figure; this is what + gates the line spec's marker layer (``oneOf: []`` → draw nothing), so a + plain line chart shows no stray marker points. + """ + out = list(rows) + for t in transforms: + f = t.get("filter") if isinstance(t, dict) else None + if isinstance(f, dict): + out = _apply_filter(out, f) + return out + + +def _apply_filter(rows: list[dict], f: dict) -> list[dict]: + field = f.get("field") + if field is None: + return rows + if "oneOf" in f: + allowed = set(f["oneOf"]) + return [r for r in rows if r.get(field) in allowed] + if "equal" in f: + target = f["equal"] + return [r for r in rows if r.get(field) == target] + return rows + + +def is_temporal(v: Any) -> bool: + """A value that is not already numeric is treated as a timestamp.""" + return not isinstance(v, (int, float)) + + +def as_number(v: Any) -> float: + """Coerce a value that may be an ISO-8601 timestamp to a plottable number; + pass ints/floats through unchanged.""" + if isinstance(v, (int, float)): + return float(v) + import matplotlib.dates as mdates + + return float(mdates.date2num(datetime.fromisoformat(str(v).replace("Z", "+00:00")))) diff --git a/python/src/molplot/vlmpl/interp.py b/python/src/molplot/vlmpl/interp.py new file mode 100644 index 0000000..35bacab --- /dev/null +++ b/python/src/molplot/vlmpl/interp.py @@ -0,0 +1,49 @@ +"""The interpreter driver. + +Walks a Vega-Lite spec end to end: normalize into typed units, apply the unified +preset style, dispatch each unit's mark onto the axes, then finalize the axes +(titles, scales, legend). This is the implementation behind ``molplot.render``. +""" + +from __future__ import annotations + +from contextlib import nullcontext +from typing import Any + +from ..preset import DEFAULT_PRESET, Mode +from ..style import style as _style +from .axes import apply_axes +from .marks import encode +from .model import normalize + + +def _ensure_ax(ax: Any) -> tuple[Any, Any]: + import matplotlib.pyplot as plt + + if ax is not None: + return ax.figure, ax + return plt.subplots() + + +def render( + spec: dict[str, Any], + *, + preset: str = DEFAULT_PRESET, + mode: Mode = "light", + ax: Any = None, + apply_style: bool = True, +): + """Render a Vega-Lite spec to matplotlib. Returns ``(figure, axes)``. + + >>> spec = molplot.line_spec([{"id": "a", "x": [0, 1, 2], "y": [1, 3, 2]}]) + >>> fig, ax = molplot.render(spec) + """ + units = normalize(spec) + ctx = _style(preset, mode) if apply_style else nullcontext() + with ctx: + fig, ax = _ensure_ax(ax) + labelled = False + for unit in units: + labelled = encode(ax, unit) or labelled + apply_axes(ax, units, labelled) + return fig, ax diff --git a/python/src/molplot/vlmpl/marks.py b/python/src/molplot/vlmpl/marks.py new file mode 100644 index 0000000..d8f06d5 --- /dev/null +++ b/python/src/molplot/vlmpl/marks.py @@ -0,0 +1,200 @@ +"""Mark encoders — the interpreter's emit pass. + +One encoder per Vega-Lite mark type, registered in :data:`MARK_ENCODERS`. Each +reads *typed channels* off the unit (``enc["x"].field``) rather than hardcoded +field names, so the same encoder serves any spec that uses the mark. Every +encoder returns whether it drew legend-eligible (labelled) artists. + +Adding a mark = write an encoder and register it here. +""" + +from __future__ import annotations + +from typing import Any + +from .data import apply_transforms, as_number, is_temporal +from .model import Unit +from .scales import color_map, numeric_map + + +def _draw_line(ax: Any, rows: list[dict], enc: dict, mark: dict) -> bool: + xf = enc["x"].field + yf = enc["y"].field + color = enc.get("color") + cf = color.field if color else None + cmap = color_map(color) + detail = enc.get("detail") + df = detail.field if detail else None + width_map = numeric_map(enc.get("strokeWidth")) + op_map = numeric_map(enc.get("opacity")) + uniform_width = mark.get("strokeWidth") + uniform_opacity = mark.get("opacity") + + # A polyline per (colour, detail) pair: detail splits series that share a + # legend colour (e.g. repeated runs) without merging their points. + groups: dict[Any, list[dict]] = {} + order: list[Any] = [] + for r in rows: + key = (r.get(cf) if cf else None, r.get(df) if df else None) + if key not in groups: + groups[key] = [] + order.append(key) + groups[key].append(r) + + labelled = False + seen_labels: set = set() + for cval, _dval in order: + grp = sorted(groups[(cval, _dval)], key=lambda r: r[xf]) + color_v = cmap.mapping.get(cval) if cmap.kind == "nominal" else None + lw = width_map.get(cval) if width_map else uniform_width + alpha = op_map.get(cval) if op_map else uniform_opacity + # Same colour → one merged legend entry (mirrors Vega legend merging). + label = str(cval) if cval is not None and cval not in seen_labels else None + if label is not None: + seen_labels.add(cval) + ax.plot( + [r[xf] for r in grp], + [r[yf] for r in grp], + label=label, + color=color_v, + linewidth=lw, + alpha=alpha, + ) + labelled = labelled or cval is not None + return labelled + + +def _draw_point(ax: Any, rows: list[dict], enc: dict, mark: dict) -> bool: + if not rows: + return False + xf = enc["x"].field + yf = enc["y"].field + color = enc.get("color") + cf = color.field if color else None + cmap = color_map(color) + xs = [r[xf] for r in rows] + ys = [r[yf] for r in rows] + size = mark.get("size", 36) + alpha = mark.get("opacity") + if cmap.kind == "quantitative" and cf: + sc = ax.scatter(xs, ys, c=[r[cf] for r in rows], cmap=cmap.mapping, s=size, alpha=alpha) + if color.wants_legend: + ax.figure.colorbar(sc, ax=ax) + elif cmap.kind == "nominal" and cf: + ax.scatter(xs, ys, c=[cmap.mapping.get(r[cf]) for r in rows], s=size, alpha=alpha) + elif cmap.kind == "literal" and cf: + ax.scatter(xs, ys, c=[r[cf] for r in rows], s=size, alpha=alpha) + else: + ax.scatter(xs, ys, color=mark.get("color"), s=size, alpha=alpha) + return False + + +def _draw_bar(ax: Any, rows: list[dict], enc: dict, mark: dict) -> bool: + import numpy as np + + x, y = enc.get("x"), enc.get("y") + horizontal = y is not None and y.type == "nominal" + cat_ch = y if horizontal else x + val_ch = x if horizontal else y + cat_f, val_f = cat_ch.field, val_ch.field + color = enc.get("color") + color_field = color.field if color else None + cmap = color_map(color) + stacked = bool(val_ch.stack) + grouped = "xOffset" in enc or "yOffset" in enc + opacity = enc.get("opacity") + overlay_alpha = opacity.value if (opacity is not None and opacity.is_value) else None + + cats = list(dict.fromkeys(r[cat_f] for r in rows)) + keys = list(dict.fromkeys(r[color_field] for r in rows)) if color_field else [None] + cat_idx = {c: i for i, c in enumerate(cats)} + pos = np.arange(len(cats), dtype=float) + bottoms = np.zeros(len(cats)) + band = 0.8 + bar_w = band / max(1, len(keys)) if grouped else band + + for gi, key in enumerate(keys): + vals = np.zeros(len(cats)) + for r in rows: + if color_field and r[color_field] != key: + continue + vals[cat_idx[r[cat_f]]] = r[val_f] + color_v = cmap.mapping.get(key) if cmap.kind == "nominal" else None + label = None if key is None else str(key) + offs = pos - band / 2 + bar_w * (gi + 0.5) if grouped else pos + if horizontal: + ax.barh(offs, vals, height=bar_w, left=bottoms if stacked else None, color=color_v, label=label, alpha=overlay_alpha) + else: + ax.bar(offs, vals, width=bar_w, bottom=bottoms if stacked else None, color=color_v, label=label, alpha=overlay_alpha) + if stacked: + bottoms += vals + + if horizontal: + ax.set_yticks(pos) + ax.set_yticklabels([str(c) for c in cats]) + else: + ax.set_xticks(pos) + ax.set_xticklabels([str(c) for c in cats]) + return bool(color_field) + + +def _draw_interval(ax: Any, rows: list[dict], enc: dict, mark: dict) -> bool: + """A bar with an ``x2`` (or ``y2``) span — the Gantt case, generalized: a + bar whose start and end both come from data instead of a baseline.""" + if not rows: + return False + color = enc.get("color") + color_field = color.field if color else None + cmap = color_map(color) + opacity = enc.get("opacity") + op_field = opacity.field if opacity else None + op_map = numeric_map(opacity) + y = enc.get("y") + y_field = y.field if y and y.field else "label" + labels = list(y.sort) if (y and y.sort) else list(dict.fromkeys(r[y_field] for r in rows)) + ypos = {lbl: i for i, lbl in enumerate(labels)} + xf = enc["x"].field if enc.get("x") else "start" + x2f = enc["x2"].field if enc.get("x2") else "end" + + seen: set = set() + for r in rows: + start, end = as_number(r[xf]), as_number(r[x2f]) + color_v = cmap.mapping.get(r.get(color_field)) if cmap.kind == "nominal" else None + alpha = op_map.get(r.get(op_field)) if op_map else None + group = r.get(color_field) + label = None if group in seen or group is None else str(group) + ax.barh(ypos[r[y_field]], end - start, left=start, height=0.6, color=color_v, alpha=alpha, label=label) + seen.add(group) + + ax.set_yticks(range(len(labels))) + ax.set_yticklabels([str(lbl) for lbl in labels]) + if rows and is_temporal(rows[0][xf]): + import matplotlib.dates as mdates + + ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M")) + return bool(color_field) + + +def _draw_bar_or_interval(ax: Any, rows: list[dict], enc: dict, mark: dict) -> bool: + if "x2" in enc or "y2" in enc: + return _draw_interval(ax, rows, enc, mark) + return _draw_bar(ax, rows, enc, mark) + + +MARK_ENCODERS = { + "line": _draw_line, + "point": _draw_point, + "circle": _draw_point, + "square": _draw_point, + "bar": _draw_bar_or_interval, +} + + +def encode(ax: Any, unit: Unit) -> bool: + """Draw one unit's mark; returns whether it produced labelled artists. + Unknown marks are skipped (returns False).""" + encoder = MARK_ENCODERS.get(unit.mark) + if encoder is None: + return False + rows = apply_transforms(unit.rows, unit.transforms) + return encoder(ax, rows, unit.encoding, unit.mark_props) diff --git a/python/src/molplot/vlmpl/model.py b/python/src/molplot/vlmpl/model.py new file mode 100644 index 0000000..7ab3d01 --- /dev/null +++ b/python/src/molplot/vlmpl/model.py @@ -0,0 +1,154 @@ +"""Typed Vega-Lite AST + normalization — the interpreter frontend. + +Turns a raw Vega-Lite dict into an immutable list of :class:`Unit` (one per +layer), each carrying the encoding it should draw with (top-level channels +merged with the layer's own overrides), its inline data rows, its mark type and +properties, and any transforms. This is the *only* module that understands +Vega-Lite's structure — layering and channel inheritance; everything downstream +sees a flat, field-name-agnostic ``Unit``. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +# Sentinel: distinguishes "no value channel / no constant" from an explicit None. +_UNSET: Any = object() + + +@dataclass(frozen=True) +class Scale: + """A resolved Vega-Lite ``scale`` object (positional, colour, or size).""" + + type: str | None = None + domain: list | None = None + range: list | None = None + scheme: str | None = None + zero: bool | None = None + padding_inner: float | None = None + + @classmethod + def parse(cls, raw: Any) -> "Scale | None": + if not isinstance(raw, dict): + return None + dom = raw.get("domain") + rng = raw.get("range") + return cls( + type=raw.get("type"), + domain=list(dom) if isinstance(dom, (list, tuple)) else None, + range=list(rng) if isinstance(rng, (list, tuple)) else None, + scheme=raw.get("scheme"), + zero=raw.get("zero"), + padding_inner=raw.get("paddingInner"), + ) + + +@dataclass(frozen=True) +class Channel: + """A resolved encoding channel (``x``, ``color``, ``opacity``, …).""" + + name: str + field: str | None = None + type: str | None = None + scale: Scale | None = None + axis_title: str | None = None + legend: Any = None + sort: tuple | None = None + stack: bool | None = None + value: Any = _UNSET + + @property + def wants_legend(self) -> bool: + """Vega shows a legend/colourbar only when ``legend`` is non-null.""" + return self.legend is not None + + @property + def is_value(self) -> bool: + """True for a constant channel like ``{"value": 0.65}``.""" + return self.value is not _UNSET + + @classmethod + def parse(cls, name: str, raw: Any) -> "Channel": + if not isinstance(raw, dict): + return cls(name=name) + axis = raw.get("axis") + sort = raw.get("sort") + return cls( + name=name, + field=raw.get("field"), + type=raw.get("type"), + scale=Scale.parse(raw.get("scale")), + axis_title=axis.get("title") if isinstance(axis, dict) else None, + legend=raw.get("legend"), + sort=tuple(sort) if isinstance(sort, (list, tuple)) else None, + stack=raw.get("stack"), + value=raw.get("value", _UNSET), + ) + + +@dataclass(frozen=True) +class Unit: + """One drawable view: a mark plus the channels and rows that feed it.""" + + mark: str | None + mark_props: dict + encoding: dict # channel name -> Channel + rows: tuple # inline data rows (dicts) + transforms: tuple # raw Vega-Lite transform dicts + + +def _mark_of(node: dict) -> tuple[str | None, dict]: + mark = node.get("mark") + if isinstance(mark, dict): + return mark.get("type"), dict(mark) + return mark, {} + + +def _rows_of(node: dict, inherited: tuple) -> tuple: + data = node.get("data") + if isinstance(data, dict) and isinstance(data.get("values"), list): + return tuple(data["values"]) + return inherited + + +def _encoding_of(raw: dict | None) -> dict: + return {name: Channel.parse(name, ch) for name, ch in (raw or {}).items()} + + +def normalize(spec: dict) -> list[Unit]: + """Flatten a Vega-Lite spec into drawable units. + + A ``layer`` spec yields one unit per layer, each inheriting the spec's + top-level ``encoding`` and ``data`` (its own channels win). A single-view + spec yields one unit. Handles both shapes MolPlot emits: layered line specs + and top-level scatter / bar / gantt specs. + """ + top_rows = _rows_of(spec, ()) + top_enc_raw = spec.get("encoding") or {} + layers = spec.get("layer") + if isinstance(layers, list): + units: list[Unit] = [] + for layer in layers: + merged = {**top_enc_raw, **(layer.get("encoding") or {})} + mark, props = _mark_of(layer) + units.append( + Unit( + mark=mark, + mark_props=props, + encoding=_encoding_of(merged), + rows=_rows_of(layer, top_rows), + transforms=tuple(layer.get("transform") or ()), + ) + ) + return units + mark, props = _mark_of(spec) + return [ + Unit( + mark=mark, + mark_props=props, + encoding=_encoding_of(top_enc_raw), + rows=top_rows, + transforms=tuple(spec.get("transform") or ()), + ) + ] diff --git a/python/src/molplot/vlmpl/scales.py b/python/src/molplot/vlmpl/scales.py new file mode 100644 index 0000000..7dbf904 --- /dev/null +++ b/python/src/molplot/vlmpl/scales.py @@ -0,0 +1,67 @@ +"""Scale binding — the interpreter's semantic pass. + +Turns typed channels into the things matplotlib needs: colour lookups, numeric +maps for per-series stroke width / opacity, and positional-axis configuration +(log scaling and explicit domain limits). Applying positional scales is exactly +the step the ad-hoc renderer skipped, so log axes and fixed domains were lost. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from .model import Channel + + +@dataclass(frozen=True) +class ColorMap: + """Resolved colour encoding. + + ``kind`` is one of ``nominal`` (``mapping`` is a value→hex dict), + ``quantitative`` (``mapping`` is a colour-scheme name for a cmap), + ``literal`` (the field value *is* the colour), or ``none``. + """ + + kind: str + mapping: Any = None + + +def color_map(channel: Channel | None) -> ColorMap: + if channel is None: + return ColorMap("none") + if channel.type == "quantitative": + return ColorMap("quantitative", channel.scale.scheme if channel.scale else None) + scale = channel.scale + if scale and scale.domain is not None and scale.range is not None: + return ColorMap("nominal", dict(zip(scale.domain, scale.range))) + return ColorMap("literal") + + +def numeric_map(channel: Channel | None) -> dict | None: + """A value→number map for a per-field ``strokeWidth`` / ``opacity`` channel + (domain/range scale). Returns None when the channel carries no such scale. + """ + if channel is None or channel.scale is None: + return None + scale = channel.scale + if scale.domain is not None and scale.range is not None: + return dict(zip(scale.domain, scale.range)) + return None + + +def apply_positional(ax: Any, axis: str, channel: Channel | None) -> None: + """Apply a positional channel's scale to ``ax``: ``log`` scaling and an + explicit numeric ``domain`` → axis limits. ``axis`` is ``"x"`` or ``"y"``. + Categorical (nominal/ordinal) and temporal channels carry no numeric domain + and are left to the mark encoder / autoscaling. + """ + if channel is None or channel.scale is None: + return + scale = channel.scale + set_scale = ax.set_xscale if axis == "x" else ax.set_yscale + set_lim = ax.set_xlim if axis == "x" else ax.set_ylim + if scale.type == "log": + set_scale("log") + if scale.domain is not None and channel.type in (None, "quantitative"): + set_lim(scale.domain[0], scale.domain[-1]) diff --git a/python/tests/test_vlmpl.py b/python/tests/test_vlmpl.py new file mode 100644 index 0000000..cae5fb2 --- /dev/null +++ b/python/tests/test_vlmpl.py @@ -0,0 +1,61 @@ +"""Behavioural contract for the Vega-Lite → matplotlib interpreter that the +ad-hoc ``render.py`` prototype did not satisfy: positional scales (log / domain) +must reach the axis, and per-layer ``transform`` filters must be honoured so a +plain line chart draws no stray marker points. +""" + +import matplotlib.pyplot as plt + +import molplot + + +def teardown_function(): + plt.close("all") + + +def test_log_scale_reaches_the_axis(): + spec = molplot.line_spec([{"id": "a", "x": [1, 10, 100], "y": [1, 2, 3]}], x_log=True) + _, ax = molplot.render(spec) + assert ax.get_xscale() == "log" + + +def test_linear_scale_is_the_default(): + spec = molplot.line_spec([{"id": "a", "x": [1, 2, 3], "y": [1, 2, 3]}]) + _, ax = molplot.render(spec) + assert ax.get_xscale() == "linear" + + +def test_scale_domain_sets_axis_limits(): + spec = molplot.line_spec([{"id": "a", "x": [0, 1, 2], "y": [0, 1, 2]}], y_domain=[0, 10]) + _, ax = molplot.render(spec) + assert ax.get_ylim() == (0.0, 10.0) + + +def test_plain_line_draws_no_marker_points(): + # The line spec always carries a second point layer gated by a + # ``transform: [{filter: {field: 'key', oneOf: []}}]``. With no + # lines+markers series the filter matches nothing, so a faithful + # interpreter draws zero collections. + spec = molplot.line_spec([{"id": "a", "x": [0, 1, 2], "y": [0, 1, 2]}]) + _, ax = molplot.render(spec) + assert len(ax.collections) == 0 + + +def test_lines_plus_markers_draws_points(): + spec = molplot.line_spec([{"id": "a", "x": [0, 1, 2], "y": [0, 1, 2], "mode": "lines+markers"}]) + _, ax = molplot.render(spec) + assert len(ax.lines) == 1 + assert len(ax.collections) >= 1 + + +def test_detail_channel_splits_lines_without_extra_legend_series(): + # Two datapoints sharing a label but differing on the detail field ``s`` + # must not be merged into one polyline. + spec = molplot.line_spec( + [ + {"id": "run1", "label": "trial", "x": [0, 1], "y": [0, 1]}, + {"id": "run2", "label": "trial", "x": [0, 1], "y": [1, 2]}, + ] + ) + _, ax = molplot.render(spec) + assert len(ax.lines) == 2 From c8b32e3b00bc9b90647837fb9f23ef5f5a9dbd21 Mon Sep 17 00:00:00 2001 From: Roy Kid Date: Fri, 10 Jul 2026 11:33:49 +0800 Subject: [PATCH 02/17] feat(core): pan/zoom with per-axis wheel targeting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every chart now declares one scale-bound interval selection per continuous axis. Drag pans, a wheel over an axis gutter zooms that axis alone, shift+wheel zooms every bound axis, double click resets. A bare wheel over the plot is left untouched so an enclosing panel still scrolls. Band scales (bar's category axis, gantt's task axis) get no param at all — Vega-Lite refuses to bind a discrete domain — so they are inert by construction rather than by special case. Three Vega constraints drove the shape of this: - Selection params must sit on exactly one unit layer. At the top level of a layered spec Vega-Lite copies them into every layer and Vega then throws "Duplicate signal name" at parse time, while vl.compile() stays silent. specs.test.ts compiles each spec and asserts no duplicate signal names. - `bind: "scales"` defaults its wheel stream to `source: "scope"` — the plot group. Axes render with pointer-events: none, so an axis wheel falls through to the SVG root and never enters that group. Switching to `view:` catches it, and keeps sibling charts on the page from reacting. - A Vega event filter is compiled without the signal scope object: it may call x()/y() but throws `ReferenceError: _ is not defined` on any signal read. That rules out `y() > height`, so VegaChart resolves the pointer against the scenegraph's axis bounds and hands the spec a boolean flag. `|| event.shiftKey` keeps the spec self-sufficient for a raw vegaEmbed. The classification is a pure function (axisChannelAt) tested with plain rectangles: grid boxes coincide with the plot rect, legends never carry role "axis", and a wheel below a bottom legend must stay inert. Marks are now clipped, without which a zoomed mark paints over the axes. `params` is inert on paper: render.py reads only encoding/layer/mark, so the matplotlib translator and vl-convert draw the initial view. --- core/src/chart_base.ts | 116 ++++++++++++++++++++++- core/src/specs.ts | 100 +++++++++++++++++++- core/tests/_fake_vega.ts | 27 ++++++ core/tests/axis_hover_zoom.test.ts | 73 +++++++++++++++ core/tests/line_chart.test.ts | 21 ++++- core/tests/scatter_chart.test.ts | 5 +- core/tests/specs.test.ts | 146 ++++++++++++++++++++++++++++- python/src/molplot/specs.py | 74 +++++++++++++-- python/tests/test_render.py | 3 + python/tests/test_specs.py | 56 +++++++++++ 10 files changed, 605 insertions(+), 16 deletions(-) create mode 100644 core/tests/axis_hover_zoom.test.ts diff --git a/core/src/chart_base.ts b/core/src/chart_base.ts index dd01667..b9138bb 100644 --- a/core/src/chart_base.ts +++ b/core/src/chart_base.ts @@ -1,14 +1,37 @@ -import type { VegaLiteSpec } from "./specs"; +import { + type VegaLiteSpec, + ZOOM_EVENT_FLAG, + type ZoomChannel, + zoomParamsOf, +} from "./specs"; import { type ChartTheme, resolveTheme } from "./theme"; import type { ThemeMode } from "./types"; import { loadVegaEmbed, type VegaEmbed } from "./vega_loader"; +/** A scenegraph item's box, in the plot rectangle's coordinate frame. */ +export interface Bounds { + x1: number; + x2: number; + y1: number; + y2: number; +} + +interface SceneItem { + role?: string; + bounds?: Bounds; + items?: SceneItem[]; +} + /** Minimal shape of the vega-embed result we depend on. */ interface EmbedResult { view: { data(name: string, values?: unknown[]): unknown; resize(): { run(): unknown }; run(): unknown; + /** Top-left of the plot rectangle within the rendered element. */ + origin(): number[]; + signal(name: string): unknown; + scenegraph(): { root: SceneItem }; addEventListener( type: string, handler: (e: unknown, item: unknown) => void, @@ -17,6 +40,50 @@ interface EmbedResult { }; } +/** A wheel event carrying the axis-gutter flags the zoom params filter on. */ +type ZoomWheelEvent = WheelEvent & { + [ZOOM_EVENT_FLAG.x]?: boolean; + [ZOOM_EVENT_FLAG.y]?: boolean; +}; + +/** + * Which axis, if any, the pointer sits on. All coordinates share the plot + * rectangle's frame: its interior is `[0, width] × [0, height]`, so an axis + * governing x lies above or below it and one governing y lies beside it. + * + * `axes` must be the `role: "axis"` scenegraph items. Vega emits one per axis + * plus one per grid; a grid's box *is* the plot edge, so its centre lands on + * the boundary and it classifies as neither gutter. Legends carry + * `role: "legend"` and never reach here — a legend drawn under the x axis + * (gantt) must stay inert. + */ +export function axisChannelAt( + axes: Bounds[], + x: number, + y: number, + width: number, + height: number, +): ZoomChannel | null { + if (x >= 0 && x <= width && y >= 0 && y <= height) return null; + for (const box of axes) { + if (x < box.x1 || x > box.x2 || y < box.y1 || y > box.y2) continue; + const centreY = (box.y1 + box.y2) / 2; + if (centreY > height || centreY < 0) return "x"; + const centreX = (box.x1 + box.x2) / 2; + if (centreX < 0 || centreX > width) return "y"; + } + return null; +} + +/** Boxes of every axis Vega drew, in the plot rectangle's frame. */ +function axisBounds(root: SceneItem): Bounds[] { + const out: Bounds[] = []; + for (const frame of root.items ?? []) + for (const item of frame.items ?? []) + if (item.role === "axis" && item.bounds) out.push(item.bounds); + return out; +} + /** * Shared lifecycle for every chart: lazy vega-embed load, spec→embed render, * cheap streaming data updates (`view.data(name, rows)`), rAF-debounced @@ -36,6 +103,10 @@ export abstract class VegaChart { protected readonly mountPromise: Promise; private resizeObserver: ResizeObserver | null = null; private themeObserver: MutationObserver | null = null; + private detachAxisZoom: (() => void) | null = null; + /** The element Vega rendered into, and whether its spec declares zoom params. */ + private rendered: Element | null = null; + private zoomable = false; private resizeRaf: number | null = null; private lastW = 0; private lastH = 0; @@ -51,6 +122,7 @@ export abstract class VegaChart { this.presetName = presetName; this.mountPromise = this.mount(); this.setupResizeObserver(); + this.detachAxisZoom = this.bindAxisHoverZoom(); if (this.themeMode === "auto") this.setupThemeObserver(); } @@ -76,10 +148,13 @@ export abstract class VegaChart { } this.resizeObserver?.disconnect(); this.themeObserver?.disconnect(); + this.detachAxisZoom?.(); this.resizeObserver = null; this.themeObserver = null; + this.detachAxisZoom = null; this.result?.view.finalize(); this.result = null; + this.rendered = null; } /** Build the Vega-Lite spec for the current state + theme. */ @@ -119,6 +194,7 @@ export abstract class VegaChart { const spec = this.buildSpec(theme, { width, height }); this.result?.view.finalize(); this.result = null; + this.rendered = null; const result = (await this.embed(this.container, spec as never, { actions: false, renderer: "svg", @@ -134,9 +210,47 @@ export abstract class VegaChart { ?.datum; if (datum) this.onDatum(datum); }); + // 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.result = 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. + * + * 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. + */ + private bindAxisHoverZoom(): () => void { + const onWheel = (event: Event): void => { + if (!this.zoomable || !this.result || !this.rendered) return; + const { view } = this.result; + const rect = this.rendered.getBoundingClientRect(); + const [originX, originY] = view.origin(); + const wheel = event as ZoomWheelEvent; + 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, + ); + if (channel) wheel[ZOOM_EVENT_FLAG[channel]] = true; + }; + const options = { capture: true, passive: true }; + this.container.addEventListener("wheel", onWheel, options); + return () => this.container.removeEventListener("wheel", onWheel, options); + } + /** Push the current datasets into a freshly embedded view. */ private feed(result: EmbedResult): void { const data = this.datasets(); diff --git a/core/src/specs.ts b/core/src/specs.ts index b971f16..3589ed8 100644 --- a/core/src/specs.ts +++ b/core/src/specs.ts @@ -21,6 +21,80 @@ export interface SpecSize { const FALLBACK_STATUS_COLOR = "#a3a3a3"; +/** Continuous channels that can carry a scale binding. */ +export type ZoomChannel = "x" | "y"; + +/** One scale-bound interval selection per zoomable axis. */ +export const ZOOM_PARAM = { x: "zoomX", y: "zoomY" } as const; + +/** + * Flags `VegaChart` stamps on a wheel event to say which axis gutter the + * pointer is over. + * + * A Vega event filter is compiled without the signal scope object, so it can + * call the geometry functions (`x()`, `y()`) but throws `ReferenceError: _ is + * not defined` the moment it reads a signal. That rules out the expression the + * region test wants — `y() > height` — because `height` is a signal. Hence the + * test happens where the geometry is knowable and arrives here as a boolean. + */ +export const ZOOM_EVENT_FLAG = { + x: "molplotZoomX", + y: "molplotZoomY", +} as const; + +/** A scale-bound interval selection, one per zoomable axis. */ +export interface ZoomParam { + name: string; + bind: "scales"; + select: { type: "interval"; encodings: ZoomChannel[]; zoom: string }; +} + +/** + * The zoom params a builder attached, wherever it legally placed them: top + * level for a unit spec, `layer[0]` for a layered one. Empty for a spec that + * never went through a builder (`RawChart`). + */ +export function zoomParamsOf(spec: VegaLiteSpec): ZoomParam[] { + const layers = spec.layer as { params?: ZoomParam[] }[] | undefined; + return (spec.params as ZoomParam[]) ?? layers?.[0]?.params ?? []; +} + +/** + * Pan/zoom, one param per continuous axis: drag pans, wheel over an axis + * gutter zooms that axis alone, Shift+wheel zooms every bound axis, double + * click resets. + * + * The `view:` source matters. Vega-Lite's default for a scale binding is + * `scope` — the plot group — but axes render with `pointer-events: none`, so a + * wheel over an axis falls through to the SVG root and never enters that + * group. `view:` listens on the chart's own element instead, which both catches + * the axis wheel and keeps sibling charts on the page from reacting to it. + * + * `|| event.shiftKey` keeps the spec self-sufficient: Shift+wheel zooms even + * when nobody is stamping the event (a raw `vegaEmbed` of this spec, the docs). + * + * `bind: "scales"` attaches a `domainRaw` signal that outranks the computed + * domain, so `zero` / `nice` / an explicit `domain` still choose the *initial* + * view and the interaction owns the view from then on. + * + * Pass only continuous channels; binding a band scale warns "Scale bindings are + * currently only supported for scales with unbinned, continuous domains." + * + * Inert outside the browser: the matplotlib translator and vl-convert both + * ignore `params` and render the initial view. + */ +function interactionParams(channels: ZoomChannel[]): ZoomParam[] { + return channels.map((channel) => ({ + name: ZOOM_PARAM[channel], + select: { + type: "interval", + encodings: [channel], + zoom: `view:wheel![event.${ZOOM_EVENT_FLAG[channel]} || event.shiftKey]`, + }, + bind: "scales", + })); +} + /** Strip undefined so specs compare cleanly in tests and serialize small. */ function clean>(obj: T): T { for (const k of Object.keys(obj)) { @@ -145,6 +219,10 @@ export function lineSpec( encoding: xy, layer: [ { + // Params belong to exactly one unit layer. At the top level of a + // layered spec Vega-Lite copies them into every layer and Vega then + // throws "Duplicate signal name" while compiling stays silent. + params: interactionParams(["x", "y"]), mark: lineMark, encoding: lineEncoding, }, @@ -154,12 +232,13 @@ export function lineSpec( type: "point", filled: true, size: theme.geometry.markerSize ** 2, + clip: true, }, encoding: { color: colorEnc }, }, { // Invisible wide hit target → precise hover tooltip + click datum. - mark: { type: "point", opacity: 0, size: 160 }, + mark: { type: "point", opacity: 0, size: 160, clip: true }, encoding: { color: colorEnc, tooltip: [ @@ -199,11 +278,13 @@ export function scatterSpec( const layers: Record[] = [ { + params: interactionParams(["x", "y"]), data: { name: "table" }, mark: clean({ type: "point", filled: true, size: markSize, + clip: true, color: colorEnc ? undefined : ((marker.color as string) ?? theme.palette[0]), @@ -236,6 +317,7 @@ export function scatterSpec( size: markSize * 4, stroke: theme.highlightRing, strokeWidth: 2, + clip: true, }, encoding: { x: { field: "x", type: "quantitative" }, @@ -312,7 +394,14 @@ export function barSpec( }); const layers: Record[] = [ - { data: { name: "table" }, mark: { type: "bar" }, encoding: barEncoding }, + { + // Only the value axis is continuous; the category axis is a band scale + // and cannot take a scale binding. + params: interactionParams([valChannel]), + data: { name: "table" }, + mark: { type: "bar", clip: true }, + encoding: barEncoding, + }, ]; // Optional line-over-bars overlay (fed via the "line" dataset). @@ -320,7 +409,7 @@ export function barSpec( if (hasLine) { layers.push({ data: { name: "line" }, - mark: { type: "line", point: true, strokeWidth: 2 }, + mark: { type: "line", point: true, strokeWidth: 2, clip: true }, encoding: { [catChannel]: { field: "cat", type: "nominal" }, [valChannel]: { field: "val", type: "quantitative" }, @@ -373,7 +462,10 @@ export function ganttSpec( height, autosize: { type: "fit", contains: "padding" }, data: { name: "table" }, - mark: { type: "bar", cornerRadius: 2 }, + // A unit spec, so the params sit at the top level. Only the temporal axis + // is continuous; the task-label axis is a band scale. + params: interactionParams(["x"]), + mark: { type: "bar", cornerRadius: 2, clip: true }, encoding: { x: { field: "start", diff --git a/core/tests/_fake_vega.ts b/core/tests/_fake_vega.ts index 648a3aa..28f8f0e 100644 --- a/core/tests/_fake_vega.ts +++ b/core/tests/_fake_vega.ts @@ -1,5 +1,32 @@ import type { VegaEmbed } from "../src/vega_loader"; +/** A container element carrying a live count of its bound listeners. */ +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 ``. 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(); + return { + addEventListener: (_type: string, fn: EventListener) => { + listeners.add(fn); + }, + removeEventListener: (_type: string, fn: EventListener) => { + listeners.delete(fn); + }, + querySelector: () => null, + listenerCount: () => listeners.size, + } as unknown as FakeContainer; +} + /** * A stub vega-embed that records the spec it would render and the data pushed * into the view, and lets tests fire a synthetic click. Mirrors the old diff --git a/core/tests/axis_hover_zoom.test.ts b/core/tests/axis_hover_zoom.test.ts new file mode 100644 index 0000000..2b1a053 --- /dev/null +++ b/core/tests/axis_hover_zoom.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "@rstest/core"; +import { axisChannelAt, type Bounds } from "../src/chart_base"; + +/** + * `axisChannelAt` decides which axis a wheel is over. Everything else in the + * axis-hover path is a three-line adapter from the Vega scenegraph, so this is + * where the behaviour is pinned — with plain rectangles, no DOM, no browser. + * + * Boxes are in the plot rectangle's frame: its interior is [0, W] x [0, H]. + * The numbers below are the real ones Vega emits for a 343x133 plot. + */ +const W = 343; +const H = 133; + +const bottomAxis: Bounds = { x1: -1, x2: 344, y1: 132, y2: 165 }; +const leftAxis: Bounds = { x1: -46, x2: 1, y1: -5, y2: 138 }; +// Vega emits a grid group per axis whose box *is* the plot edge. +const bottomGrid: Bounds = { x1: 0, x2: W, y1: H, y2: H }; +const leftGrid: Bounds = { x1: 0, x2: 0, y1: 0, y2: H }; + +const ALL = [bottomGrid, leftGrid, bottomAxis, leftAxis]; +const at = (x: number, y: number, axes: Bounds[] = ALL) => + axisChannelAt(axes, x, y, W, H); + +describe("axisChannelAt", () => { + it("ignores the plot interior so the wheel scrolls the page", () => { + expect(at(W / 2, H / 2)).toBeNull(); + expect(at(0, 0)).toBeNull(); + expect(at(W, H)).toBeNull(); + }); + + it("claims x on the bottom axis and y on the left axis", () => { + expect(at(W / 2, H + 14)).toBe("x"); + expect(at(-14, H / 2)).toBe("y"); + }); + + it("never classifies a grid, whose box is the plot edge itself", () => { + // Grids overlap the interior, so they must lose to the early return; feed + // them alone to prove they never claim a channel on their own either. + expect(at(W / 2, H, [bottomGrid])).toBeNull(); + expect(at(0, H / 2, [leftGrid])).toBeNull(); + }); + + it("leaves a legend drawn under the x axis inert", () => { + // Vega tags legends role:"legend", so they never reach here. A wheel below + // the bottom axis therefore lands outside every axis box. + expect(at(W / 2, 200)).toBeNull(); + }); + + it("ignores the corner beside and below the plot", () => { + expect(at(-14, H + 14)).toBeNull(); + }); + + it("prefers x when an axis box spans both gutters", () => { + // leftAxis reaches y = -5..138, past the plot's bottom edge; the pointer at + // its lower-left corner is inside its box but the box is a y gutter. + expect(at(-20, H + 2)).toBe("y"); + }); + + it("classifies a top axis as x", () => { + const topAxis: Bounds = { x1: -1, x2: 344, y1: -34, y2: 1 }; + expect(at(W / 2, -14, [topAxis])).toBe("x"); + }); + + it("classifies a right axis as y", () => { + const rightAxis: Bounds = { x1: W - 1, x2: W + 46, y1: -5, y2: 138 }; + expect(at(W + 14, H / 2, [rightAxis])).toBe("y"); + }); + + it("claims nothing when the chart drew no axes", () => { + expect(at(-14, H / 2, [])).toBeNull(); + }); +}); diff --git a/core/tests/line_chart.test.ts b/core/tests/line_chart.test.ts index 97d260c..eae16c5 100644 --- a/core/tests/line_chart.test.ts +++ b/core/tests/line_chart.test.ts @@ -1,12 +1,18 @@ import { afterEach, beforeEach, describe, expect, it } from "@rstest/core"; import { LineChart } from "../src/line_chart"; import { __setVegaEmbedForTesting } from "../src/vega_loader"; -import { type FakeVega, makeFakeVega } from "./_fake_vega"; +import { + type FakeContainer, + type FakeVega, + makeFakeContainer, + makeFakeVega, +} from "./_fake_vega"; -const container = {} as unknown as HTMLElement; +let container: FakeContainer; let fake: FakeVega; beforeEach(() => { + container = makeFakeContainer(); fake = makeFakeVega(); __setVegaEmbedForTesting(fake.embed); }); @@ -79,4 +85,15 @@ describe("LineChart", () => { chart.dispose(); expect(fake.finalized).toBe(1); }); + + it("binds one axis-hover wheel listener and drops it on dispose", async () => { + // The listener lives on the container, which survives every re-embed, so + // setAxisRange (which re-embeds) must not add a second one. + const chart = new LineChart(container, { series: [{ id: "a" }] }); + await chart.ready(); + await chart.setAxisRange("x", [0, 1]); + expect(container.listenerCount()).toBe(1); + chart.dispose(); + expect(container.listenerCount()).toBe(0); + }); }); diff --git a/core/tests/scatter_chart.test.ts b/core/tests/scatter_chart.test.ts index 52ea089..86aea80 100644 --- a/core/tests/scatter_chart.test.ts +++ b/core/tests/scatter_chart.test.ts @@ -1,12 +1,13 @@ import { afterEach, beforeEach, describe, expect, it } from "@rstest/core"; import { ScatterChart } from "../src/scatter_chart"; import { __setVegaEmbedForTesting } from "../src/vega_loader"; -import { type FakeVega, makeFakeVega } from "./_fake_vega"; +import { type FakeVega, makeFakeContainer, makeFakeVega } from "./_fake_vega"; -const container = {} as unknown as HTMLElement; +let container: HTMLElement; let fake: FakeVega; beforeEach(() => { + container = makeFakeContainer(); fake = makeFakeVega(); __setVegaEmbedForTesting(fake.embed); }); diff --git a/core/tests/specs.test.ts b/core/tests/specs.test.ts index 06c46ce..5543346 100644 --- a/core/tests/specs.test.ts +++ b/core/tests/specs.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from "@rstest/core"; -import { barSpec, ganttSpec, lineSpec, scatterSpec } from "../src/specs"; +import { compile } from "vega-lite"; +import { + barSpec, + ganttSpec, + lineSpec, + scatterSpec, + ZOOM_EVENT_FLAG, + ZOOM_PARAM, + zoomParamsOf, +} from "../src/specs"; import { resolveTheme } from "../src/theme"; // biome-ignore lint/suspicious/noExplicitAny: spec is intentionally loosely typed JSON @@ -7,6 +16,40 @@ const S = (o: unknown) => o as any; const light = resolveTheme("light"); +/** Compile through the real Vega-Lite, collecting warnings and every signal + * name in the emitted Vega spec (top level plus nested group marks). */ +function compiled(spec: unknown) { + const warnings: string[] = []; + const logger = { + level: () => logger, + warn: (...a: unknown[]) => { + warnings.push(a.join(" ")); + return logger; + }, + info: () => logger, + debug: () => logger, + error: () => logger, + }; + // biome-ignore lint/suspicious/noExplicitAny: vega-lite's compile takes a TopLevelSpec + const out = compile(spec as any, { logger: logger as any }); + const names: string[] = []; + // biome-ignore lint/suspicious/noExplicitAny: walking untyped Vega output + const walk = (node: any) => { + for (const s of node.signals ?? []) names.push(s.name); + for (const m of node.marks ?? []) if (m.type === "group") walk(m); + }; + walk(out.spec); + const duplicates = names.filter((n, i) => names.indexOf(n) !== i); + return { vega: S(out.spec), warnings, duplicates }; +} + +type Mark = { clip?: boolean }; + +/** Every mark in the spec tree, whether the spec is layered or a unit spec. */ +// biome-ignore lint/suspicious/noExplicitAny: spec is loosely typed JSON +const marksOf = (spec: any): Mark[] => + spec.layer ? spec.layer.map((l: { mark: Mark }) => l.mark) : [spec.mark]; + describe("lineSpec", () => { it("carries the schema, preset config and named data", () => { const spec = S( @@ -137,3 +180,104 @@ describe("ganttSpec", () => { expect(spec.encoding.color.scale.range).toEqual(["#22c55e"]); }); }); + +describe("pan/zoom interaction", () => { + const specs = { + line: () => + lineSpec({ series: [{ id: "a", mode: "lines+markers" }] }, light), + scatter: () => + scatterSpec({ points: [{ x: 0, y: 0 }], xAxis: {}, yAxis: {} }, light), + bar: () => + barSpec({ series: [{ id: "a", points: [{ x: "c", y: 1 }] }] }, light), + gantt: () => + ganttSpec( + { + tasks: [{ id: "t", label: "L", start: 0, end: 1, statusGroup: "ok" }], + statusColors: { ok: "#22c55e" }, + }, + light, + ), + }; + + const cases = Object.entries(specs); + const channels = (spec: unknown) => + zoomParamsOf(S(spec)).map((p) => p.select.encodings[0]); + + it.each( + cases, + )("%s declares one scale binding per zoomable axis", (_k, make) => { + for (const param of zoomParamsOf(S(make()))) { + expect(param.bind).toBe("scales"); + expect(param.select.type).toBe("interval"); + // One param drives exactly one scale, which is what lets a wheel over a + // single axis gutter zoom that axis alone. + expect(param.select.encodings).toHaveLength(1); + const channel = param.select.encodings[0]; + expect(param.name).toBe(ZOOM_PARAM[channel]); + // `view:` (not the default `scope`) is what lets a wheel over an axis + // reach the selection at all — axes render with pointer-events: none, + // outside the plot group. `VegaChart` stamps the flag; `|| shiftKey` + // keeps the spec self-sufficient when nobody stamps it. + expect(param.select.zoom).toBe( + `view:wheel![event.${ZOOM_EVENT_FLAG[channel]} || event.shiftKey]`, + ); + } + }); + + it.each(cases)("%s clips every mark to the plot rect", (_k, make) => { + // Without clip a zoomed-in mark paints over the axes. + for (const mark of marksOf(S(make()))) expect(mark.clip).toBe(true); + }); + + it.each([ + ["line", ["x", "y"]], + ["scatter", ["x", "y"]], + // The remaining axis of each is a band scale, which cannot take a binding. + ["bar", ["y"]], + ["gantt", ["x"]], + ] as const)("%s binds only its continuous channels", (key, want) => { + expect(channels(specs[key]())).toEqual(want); + }); + + it("follows the value axis when a bar chart is horizontal", () => { + const horizontal = barSpec( + { series: [{ id: "a", points: [] }], orientation: "h" }, + light, + ); + expect(channels(horizontal)).toEqual(["x"]); + }); + + it.each(cases)("%s compiles without duplicate Vega signals", (_k, make) => { + // A selection param at the top level of a *layered* spec is copied into + // every layer; Vega then throws "Duplicate signal name" at parse time while + // vl.compile() stays silent. Only a browser (or this test) catches it. + expect(compiled(make()).duplicates).toEqual([]); + }); + + it.each(cases)("%s never binds a discrete scale", (_k, make) => { + // Listing a band channel in `encodings` logs "Scale bindings are currently + // only supported for scales with unbinned, continuous domains." (ganttSpec + // emits unrelated opacity/legend warnings, so match on this one only.) + const { warnings } = compiled(make()); + expect(warnings.filter((w) => w.includes("Scale binding"))).toEqual([]); + }); + + it("lets the interaction override zero/nice and an explicit domain", () => { + // `bind: "scales"` adds domainRaw, which wins over the computed domain at + // runtime — so rangemode/tozero still picks the *initial* view only. + const spec = lineSpec( + { + series: [{ id: "a" }], + xAxis: { rangemode: "tozero" }, + yAxis: { range: [0, 5] }, + }, + light, + ); + const { vega } = compiled(spec); + const scale = (n: string) => + vega.scales.find((s: { name: string }) => s.name === n); + expect(scale("x").domainRaw).toEqual({ signal: `${ZOOM_PARAM.x}["x"]` }); + expect(scale("y").domainRaw).toEqual({ signal: `${ZOOM_PARAM.y}["y"]` }); + expect(scale("y").domain).toEqual([0, 5]); + }); +}); diff --git a/python/src/molplot/specs.py b/python/src/molplot/specs.py index 2cfd4b3..914ed71 100644 --- a/python/src/molplot/specs.py +++ b/python/src/molplot/specs.py @@ -13,11 +13,59 @@ from .preset import DEFAULT_PRESET, Mode, resolve from .vega_config import vega_config -__all__ = ["line_spec", "scatter_spec", "bar_spec", "gantt_spec", "VL_SCHEMA"] +__all__ = [ + "line_spec", + "scatter_spec", + "bar_spec", + "gantt_spec", + "zoom_params_of", + "VL_SCHEMA", + "ZOOM_PARAM", + "ZOOM_EVENT_FLAG", +] VL_SCHEMA = "https://vega.github.io/schema/vega-lite/v5.json" _FALLBACK_STATUS = "#a3a3a3" +#: One scale-bound interval selection per zoomable axis. +ZOOM_PARAM = {"x": "zoomX", "y": "zoomY"} + +#: Flags the TS ``VegaChart`` stamps on a wheel event to name the axis gutter +#: the pointer is over. A Vega event filter is compiled without the signal scope +#: object, so it may call ``x()`` / ``y()`` but throws on any signal read — which +#: rules out the ``y() > height`` the region test would otherwise want. +ZOOM_EVENT_FLAG = {"x": "molplotZoomX", "y": "molplotZoomY"} + + +def zoom_params_of(spec: dict[str, Any]) -> list[dict[str, Any]]: + """The zoom params a builder attached, wherever it legally placed them: top + level for a unit spec, ``layer[0]`` for a layered one. Mirrors + ``zoomParamsOf`` in ``core/src/specs.ts``.""" + return spec.get("params") or spec["layer"][0]["params"] + + +def _interaction_params(channels: Sequence[str]) -> list[dict[str, Any]]: + """Pan/zoom, one param per continuous axis. Mirrors ``interactionParams`` + in ``core/src/specs.ts``: drag pans, a wheel over one axis gutter zooms that + axis, Shift+wheel zooms every bound axis, double click resets. + + Web-only: :func:`molplot.render.render` and ``vl-convert`` both ignore + ``params`` and draw the initial view. ``channels`` must name only continuous + channels — a band scale cannot take a scale binding. + """ + return [ + { + "name": ZOOM_PARAM[channel], + "select": { + "type": "interval", + "encodings": [channel], + "zoom": f"view:wheel![event.{ZOOM_EVENT_FLAG[channel]} || event.shiftKey]", + }, + "bind": "scales", + } + for channel in channels + ] + def _clean(d: dict[str, Any]) -> dict[str, Any]: return {k: v for k, v in d.items() if v is not None} @@ -118,10 +166,13 @@ def line_spec( "y": {"field": "y", "type": "quantitative", "axis": _axis(y_label), "scale": _scale(y_log, y_domain)}, }, "layer": [ - {"mark": line_mark, "encoding": line_encoding}, + # Params belong to exactly one unit layer: at the top level of a + # layered spec Vega-Lite copies them into every layer and Vega + # then throws "Duplicate signal name" at parse time. + {"params": _interaction_params(["x", "y"]), "mark": line_mark, "encoding": line_encoding}, { "transform": [{"filter": {"field": "key", "oneOf": marker_keys}}], - "mark": {"type": "point", "filled": True, "size": theme["geometry"]["markerSize"] ** 2}, + "mark": {"type": "point", "filled": True, "size": theme["geometry"]["markerSize"] ** 2, "clip": True}, "encoding": {"color": color_enc}, }, ], @@ -167,11 +218,12 @@ def scatter_spec( color_enc = {"field": "c", "type": "nominal", "scale": None, "legend": None} mark_size = (size or theme["geometry"]["markerSize"]) ** 2 - mark = _clean({"type": "point", "filled": True, "size": mark_size, "color": None if color_enc else (color if isinstance(color, str) else theme["palette"][0])}) + mark = _clean({"type": "point", "filled": True, "size": mark_size, "clip": True, "color": None if color_enc else (color if isinstance(color, str) else theme["palette"][0])}) spec = _base(preset, mode, width, height) spec.update( { "data": {"values": rows}, + "params": _interaction_params(["x", "y"]), "mark": mark, "encoding": _clean( { @@ -230,7 +282,15 @@ def bar_spec( } ) spec = _base(preset, mode, width, height) - spec.update({"data": {"values": rows}, "mark": {"type": "bar"}, "encoding": encoding}) + # Only the value axis is continuous; the category axis is a band scale. + spec.update( + { + "data": {"values": rows}, + "params": _interaction_params([val_channel]), + "mark": {"type": "bar", "clip": True}, + "encoding": encoding, + } + ) return spec @@ -279,7 +339,9 @@ def gantt_spec( spec.update( { "data": {"values": rows}, - "mark": {"type": "bar", "cornerRadius": 2}, + # Only the temporal axis is continuous; the task-label axis is a band scale. + "params": _interaction_params(["x"]), + "mark": {"type": "bar", "cornerRadius": 2, "clip": True}, "encoding": { "x": {"field": "start", "type": "temporal", "axis": _axis(x_label), "title": None}, "x2": {"field": "end"}, diff --git a/python/tests/test_render.py b/python/tests/test_render.py index 245011c..18bd63d 100644 --- a/python/tests/test_render.py +++ b/python/tests/test_render.py @@ -17,6 +17,9 @@ def test_render_line_draws_one_line_per_series(): {"id": "b", "x": [0, 1, 2], "y": [2, 1, 4]}, ] ) + # The builder attaches web-only pan/zoom params; the paper renderer must + # ignore them and draw the initial view. + assert molplot.specs.zoom_params_of(spec) fig, ax = molplot.render(spec) assert len(ax.lines) == 2 diff --git a/python/tests/test_specs.py b/python/tests/test_specs.py index 02ab189..bc8400b 100644 --- a/python/tests/test_specs.py +++ b/python/tests/test_specs.py @@ -54,3 +54,59 @@ def test_vega_config_matches_preset_palette(): cfg = molplot.vega_config("molplot") assert cfg["range"]["category"][0] == "#1f77b4" assert cfg["axis"]["grid"] is True + + +from molplot.specs import ZOOM_EVENT_FLAG, ZOOM_PARAM, zoom_params_of + + +def _marks_of(spec): + """Every mark in the spec tree, layered or unit. Mirrors `marksOf` in + core/tests/specs.test.ts.""" + return [layer["mark"] for layer in spec["layer"]] if "layer" in spec else [spec["mark"]] + + +def _channels(spec): + return [p["select"]["encodings"][0] for p in zoom_params_of(spec)] + + +def _all_specs(): + return { + "line": molplot.line_spec([{"id": "a", "x": [0, 1], "y": [1, 2]}]), + "scatter": molplot.scatter_spec([0, 1], [0, 1]), + "bar": molplot.bar_spec(["a"], [{"id": "s", "values": [1]}]), + "gantt": molplot.gantt_spec([{"id": "t", "label": "L", "start": 0, "end": 1, "status": "ok"}], {"ok": "#22c55e"}), + } + + +def test_every_chart_declares_one_scale_bound_param_per_zoomable_axis(): + """The interaction is part of the portable spec, so the TS and Python + builders must emit it identically (see core/tests/specs.test.ts).""" + for spec in _all_specs().values(): + for param in zoom_params_of(spec): + channel = param["select"]["encodings"][0] + assert param["name"] == ZOOM_PARAM[channel] + assert param["bind"] == "scales" + assert param["select"]["type"] == "interval" + # One param per scale is what lets an axis-gutter wheel zoom one axis. + assert len(param["select"]["encodings"]) == 1 + # `view:` reaches the axes (they render pointer-events: none, outside + # the plot group); the shift clause keeps the spec self-sufficient. + flag = ZOOM_EVENT_FLAG[channel] + assert param["select"]["zoom"] == f"view:wheel![event.{flag} || event.shiftKey]" + + +def test_only_continuous_channels_are_bound(): + specs = _all_specs() + assert _channels(specs["line"]) == ["x", "y"] + assert _channels(specs["scatter"]) == ["x", "y"] + # The remaining axis of each is a band scale, which cannot take a binding. + assert _channels(specs["bar"]) == ["y"] + assert _channels(specs["gantt"]) == ["x"] + horizontal = molplot.bar_spec(["a"], [{"id": "s", "values": [1]}], orientation="h") + assert _channels(horizontal) == ["x"] + + +def test_marks_are_clipped_to_the_plot_rect(): + """Without clip a zoomed-in mark paints over the axes.""" + for spec in _all_specs().values(): + assert all(mark["clip"] is True for mark in _marks_of(spec)) From 8c29b7db75875666a2f02e6434df5e85881a8ae7 Mon Sep 17 00:00:00 2001 From: Roy Kid Date: Fri, 10 Jul 2026 11:34:04 +0800 Subject: [PATCH 03/17] feat(page): npm run dev, and spell the gestures out in the gallery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npm run dev` now aliases dev:page, so the gallery starts from the repo root like every other MolCrafts package. Zoom is gated on hovering an axis or holding shift, neither of which is discoverable. The header names the four gestures, and each card says which of its axes can be zoomed — bar and gantt zoom only one, because the other is a band scale. --- CLAUDE.md | 2 +- package.json | 1 + page/src/App.tsx | 37 +++++++++++++++++++++++++++++++++---- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bcfb413..843b797 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,7 @@ 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:page # demo gallery at localhost:3000 +npm run dev # demo gallery at localhost:3000 (alias of dev:page) npm run build:core # rslib → core/dist (npm publish artifact) npm run typecheck # core + page npm test # core (rstest, node) + python (pytest) diff --git a/package.json b/package.json index 5fa1a73..4f5c808 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "build:core": "npm run build -w core", "build:page": "npm run build -w page", "build:all": "npm run build:core && npm run build:page", + "dev": "npm run dev:page", "dev:page": "npm run dev -w page", "typecheck:core": "npm run typecheck -w core", "typecheck:page": "npm run typecheck -w page", diff --git a/page/src/App.tsx b/page/src/App.tsx index dabc0fb..8289503 100644 --- a/page/src/App.tsx +++ b/page/src/App.tsx @@ -9,6 +9,15 @@ import { useEffect, useRef, useState } from "react"; type Kind = "line" | "scatter" | "bar" | "gantt"; type Disposable = { dispose(): void }; +/** Only continuous scales can be bound to the pan/zoom selection, so a chart + * with a category (band) axis zooms along one axis only. */ +const AXES_HINT: Record = { + line: "zooms x + y", + scatter: "zooms x + y", + bar: "zooms y — the category axis is a band scale", + gantt: "zooms time — the task axis is a band scale", +}; + function build(kind: Kind, el: HTMLElement, preset: string): Disposable { switch (kind) { case "line": { @@ -130,11 +139,19 @@ function ChartCard({ kind, preset }: { kind: Kind; preset: string }) { padding: 12, }} > -

- {kind} -

+

+ {kind} +

+ {AXES_HINT[kind]} +
); @@ -172,6 +189,18 @@ export function App() { Vega-Lite charts, unified preset — the same specs render to matplotlib in Python. + + wheel over an axis to zoom that axis · drag to pan ·{" "} + shift + wheel to zoom both · double click to reset. A + wheel over the plot itself is left alone so the page still scrolls. +
Date: Fri, 10 Jul 2026 11:34:20 +0800 Subject: [PATCH 04/17] docs: zensical landing page and GitHub Pages deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves zensical.toml onto the [project] schema and gives docs/index.md a hero front matter block (kicker, install command, npm/PyPI/licence badges). Adds a `doc` dependency group to python/pyproject.toml pinning zensical and the shared molcrafts-zensical-theme, and a Pages workflow that builds the site from that group alone — the docs are hand-written Markdown, so the job needs neither Node, the preset compiler, nor a wheel. --- .github/workflows/docs.yml | 66 ++++++++++++++++++++ docs/index.md | 122 +++++++++++++++++++++++++++++++++---- python/pyproject.toml | 11 ++++ zensical.toml | 71 ++++++++++++--------- 4 files changed, 227 insertions(+), 43 deletions(-) create mode 100644 .github/workflows/docs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..1be5d2e --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,66 @@ +name: Deploy docs to GitHub Pages + +# Builds the Zensical site (docs/ + the shared molcrafts theme) and publishes it +# to https://molcrafts.github.io/molplot/. The docs are hand-written Markdown — +# Zensical just reads the .md files under docs/ — so this job needs neither Node, +# the TS `core/` build, the preset compiler, nor a Python wheel. It installs the +# `doc` dependency group from python/pyproject.toml (zensical + the theme, both +# on PyPI) and nothing else. +# +# One-time repo setup: Settings -> Pages -> Build and deployment -> Source = +# "GitHub Actions" (this workflow provides the artifact + deployment). + +on: + push: + branches: [master] + paths: + - "docs/**" + - "zensical.toml" + - "python/pyproject.toml" + - ".github/workflows/docs.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# Serialize deployments; let an in-flight publish finish rather than cancelling. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + # Canonical repo only — forks have no Pages target, so skip there (matches + # the fork -> PR workflow: origin = Roy-Kid, upstream = MolCrafts). + if: github.repository == 'MolCrafts/molplot' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v6 + - name: Build site + # Install the doc group into a venv under python/, then build from the + # repo root where zensical.toml lives — it writes the site to ./site. + run: | + cd python + uv venv + source .venv/bin/activate + uv pip install --group doc + cd .. + zensical build + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: site + + deploy: + needs: build + if: github.repository == 'MolCrafts/molplot' + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/docs/index.md b/docs/index.md index 7cdce97..d1c24e9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,18 +1,56 @@ -# MolPlot +--- +title: MolPlot +description: Unified scientific charting — Vega-Lite on the web, scienceplots on paper, one preset. +hide: + - navigation + - toc +hero: + kicker: MolPlot Manual + title: MolPlot + description: One preset, one intermediate language, two renderers. Describe a chart once as a Vega-Lite spec; render it in the browser with vega-embed and to a matplotlib figure over scienceplots — sharing palette, type scale, and grid between a dashboard and a manuscript. + install: + label: Install + command: pip install molcrafts-molplot + badges: + - img: https://img.shields.io/npm/v/@molcrafts/molplot?color=4f46e5&label=npm + href: https://www.npmjs.com/package/@molcrafts/molplot + alt: npm version + - img: https://img.shields.io/pypi/v/molcrafts-molplot?color=4f46e5&label=pypi + href: https://pypi.org/project/molcrafts-molplot/ + alt: PyPI version + - img: https://img.shields.io/badge/license-BSD--3--Clause-blue.svg + href: https://github.com/MolCrafts/molplot/blob/master/LICENSE + alt: License BSD-3-Clause + actions: + - label: Get started + href: getting-started/ + style: primary + - label: Unified Preset + href: getting-started/preset/ + - label: API Reference + href: api/ +--- -Unified scientific charting for the MolCrafts stack. **One preset, one -intermediate language, two renderers.** +

MolPlot

-- **Web** — `@molcrafts/molplot` renders [Vega-Lite](https://vega.github.io/vega-lite/) - specs in the browser via `vega-embed`. -- **Paper** — `molcrafts-molplot` wraps [scienceplots](https://github.com/garrettj403/SciencePlots) - and renders the *same* Vega-Lite spec to matplotlib. +
-A chart is described once as a Vega-Lite JSON spec (the portable intermediate -language). The unified preset — defined once in `presets/*.json` — compiles to a -Vega-Lite `config` on the web and to matplotlib `rcParams` (over scienceplots) in -Python, so a dashboard chart and a manuscript figure share palette, type scale, -and grid. +
+ +
+ +At a glance + +## The same chart, in the browser and on paper + +
+ +A chart is described once as a [Vega-Lite](https://vega.github.io/vega-lite/) +spec — the portable intermediate language. The **web** package renders it live; +the **Python** package renders the *same* spec to a matplotlib figure over +[scienceplots](https://github.com/garrettj403/SciencePlots). Both read one +preset, so a dashboard and a manuscript figure share palette, type scale, and +grid. ``` presets/*.json ← single source of truth @@ -24,4 +62,62 @@ and grid. vega-embed (web) render() → matplotlib (paper) ``` -See [Getting Started](getting-started/index.md). +
+ +
+ +
+ +Two renderers + +## Pick your surface + +
+ +
+
Web — @molcrafts/molplot
+
Imperative TypeScript chart classes (`LineChart`, `ScatterChart`, `BarChart`, `GanttChart`, `RawChart`) that build a Vega-Lite spec and render it via `vega-embed`. The Vega runtime is lazy and externalized — consumers that never draw a chart never bundle it. `npm install @molcrafts/molplot`.
+
Paper — molcrafts-molplot
+
A scienceplots wrapper that injects the same preset into matplotlib `rcParams` and renders the *same* Vega-Lite spec to a figure. One-call `line` / `scatter` / `bar` / `gantt`, plus a `use()` / `style()` context manager. `pip install molcrafts-molplot`.
+
One preset
+
`presets/*.json` is the single source of truth, compiled to a typed TS const, a Python dict, and `.mplstyle` files. Edit the JSON once; both renderers pick up the change.
+
+ +
+ +
+ +
+ +Find your page + +## The manual in four chapters + +
+ + + +
+ +
diff --git a/python/pyproject.toml b/python/pyproject.toml index c04fa6a..40362af 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -36,6 +36,17 @@ dev = [ "pytest-cov", ] +# Docs are built with Zensical from the single site config at ../zensical.toml +# (the docs tree lives under ../docs/). That config sets `theme.name = +# "molcrafts"` — the shared MolCrafts brand extension, on PyPI — so the docs +# build is reproducible from this group alone. +[dependency-groups] +doc = [ + "zensical>=0.0.45", + # The molcrafts docs theme (zensical.toml sets `theme.name = "molcrafts"`). + "molcrafts-zensical-theme>=0.1.0", +] + [project.urls] Homepage = "https://github.com/MolCrafts/molplot" Repository = "https://github.com/MolCrafts/molplot" diff --git a/zensical.toml b/zensical.toml index b5c1730..8ee1d32 100644 --- a/zensical.toml +++ b/zensical.toml @@ -1,33 +1,44 @@ +[project] +site_name = "MolPlot" +site_description = "Unified scientific charting: Vega-Lite on the web, scienceplots on paper, one preset." +site_url = "https://molcrafts.github.io/molplot/" +repo_url = "https://github.com/MolCrafts/molplot" +repo_name = "MolCrafts/molplot" +copyright = "Copyright © 2026 MolCrafts" docs_dir = "docs" site_dir = "site" -[site] -name = "MolPlot" -description = "Unified scientific charting: Vega-Lite on the web, scienceplots on paper, one preset." - -[theme] -palette = "indigo" - -[[nav]] -title = "Home" -path = "index.md" - -[[nav]] -title = "Getting Started" -path = "getting-started/index.md" - -[[nav]] -title = "Unified Preset" -path = "getting-started/preset.md" - -[[nav.children]] -title = "Web (Vega-Lite)" -path = "getting-started/web.md" - -[[nav.children]] -title = "Python (scienceplots)" -path = "getting-started/python.md" - -[[nav]] -title = "API" -path = "api/index.md" +# Markdown extensions are intentionally not listed: Zensical's built-in default +# set (admonition, attr_list, def_list, footnotes, md_in_html, toc permalink, +# pymdownx.{arithmatex,details,emoji,highlight,inlinehilite,superfences,tabbed, +# tasklist}, and more) already covers everything these docs use — the tabbed +# web/Python blocks, admonitions, and fenced code. + +nav = [ + { "Home" = "index.md" }, + { "Getting Started" = "getting-started/index.md" }, + { "Unified Preset" = [ + "getting-started/preset.md", + { "Web (Vega-Lite)" = "getting-started/web.md" }, + { "Python (scienceplots)" = "getting-started/python.md" }, + ] }, + { "API Reference" = "api/index.md" }, +] + +# Shared MolCrafts theme (github.com/MolCrafts/molcrafts-zensical-theme). Provides +# the brand palette, light/dark schemes, navigation features, and the home-page +# hero/manual-home component system used by docs/index.md. The package must be +# installed to build (see the `doc` dependency group in python/pyproject.toml). +[project.theme] +name = "molcrafts" +language = "en" + +# Selects the molplot accent (indigo) for links, hovers, and hero eyebrows. +[project.extra.molcrafts] +product = "molplot" +accent = "#4f46e5" # primary (主) — molplot signature colour +accent_soft = "rgba(79, 70, 229, 0.14)" # secondary (副) — soft fill behind it + +[[project.extra.social]] +icon = "fontawesome/brands/github" +link = "https://github.com/MolCrafts/molplot" From b60708b8061d64d2330a2d42334a0ff0f1f5a28d Mon Sep 17 00:00:00 2001 From: Roy Kid Date: Fri, 10 Jul 2026 11:34:54 +0800 Subject: [PATCH 05/17] chore(release): v0.1.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lock the npm package, the Python package and the workspace root to one version — the v* tag fires release-core.yml and release-python.yml together, so a mismatch would publish two different versions under one tag. --- core/package.json | 2 +- package.json | 2 +- python/pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/core/package.json b/core/package.json index 93e7ef6..d4780e8 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "@molcrafts/molplot", - "version": "0.1.0", + "version": "0.1.1", "type": "module", "exports": { ".": { diff --git a/package.json b/package.json index 4f5c808..44a459c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "molplot", - "version": "0.1.0", + "version": "0.1.1", "description": "MolPlot — Vega-Lite scientific charting with a unified, matplotlib-portable preset", "author": "Roy Kid", "license": "BSD-3-Clause", diff --git a/python/pyproject.toml b/python/pyproject.toml index 40362af..44507fe 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "molcrafts-molplot" -version = "0.1.0" +version = "0.1.1" description = "Unified scientific charting: scienceplots + a matplotlib-portable Vega-Lite preset" readme = "README.md" license = "BSD-3-Clause" From 2d9bcecfe367268686c7287fd59cb11ff76ac2d9 Mon Sep 17 00:00:00 2001 From: Roy Kid Date: Fri, 10 Jul 2026 11:46:49 +0800 Subject: [PATCH 06/17] fix(docs): drop the GitHub Pages workflow, point the site at Cloudflare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workflow was copied from molpack, which still publishes to molcrafts.github.io. This repo has no Pages site, so `actions/configure-pages` failed on the first push to master with "Get Pages site failed". Docs here deploy the way molpy and molvis do: Cloudflare builds the Zensical site straight from the repo, and neither of those ships a docs workflow. So remove ours and move site_url onto the same .molcrafts.org convention. The `doc` dependency group stays — the Cloudflare build installs zensical and the shared molcrafts theme from it. --- .github/workflows/docs.yml | 66 -------------------------------------- zensical.toml | 2 +- 2 files changed, 1 insertion(+), 67 deletions(-) delete mode 100644 .github/workflows/docs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml deleted file mode 100644 index 1be5d2e..0000000 --- a/.github/workflows/docs.yml +++ /dev/null @@ -1,66 +0,0 @@ -name: Deploy docs to GitHub Pages - -# Builds the Zensical site (docs/ + the shared molcrafts theme) and publishes it -# to https://molcrafts.github.io/molplot/. The docs are hand-written Markdown — -# Zensical just reads the .md files under docs/ — so this job needs neither Node, -# the TS `core/` build, the preset compiler, nor a Python wheel. It installs the -# `doc` dependency group from python/pyproject.toml (zensical + the theme, both -# on PyPI) and nothing else. -# -# One-time repo setup: Settings -> Pages -> Build and deployment -> Source = -# "GitHub Actions" (this workflow provides the artifact + deployment). - -on: - push: - branches: [master] - paths: - - "docs/**" - - "zensical.toml" - - "python/pyproject.toml" - - ".github/workflows/docs.yml" - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -# Serialize deployments; let an in-flight publish finish rather than cancelling. -concurrency: - group: pages - cancel-in-progress: false - -jobs: - build: - # Canonical repo only — forks have no Pages target, so skip there (matches - # the fork -> PR workflow: origin = Roy-Kid, upstream = MolCrafts). - if: github.repository == 'MolCrafts/molplot' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: astral-sh/setup-uv@v6 - - name: Build site - # Install the doc group into a venv under python/, then build from the - # repo root where zensical.toml lives — it writes the site to ./site. - run: | - cd python - uv venv - source .venv/bin/activate - uv pip install --group doc - cd .. - zensical build - - uses: actions/configure-pages@v5 - - uses: actions/upload-pages-artifact@v3 - with: - path: site - - deploy: - needs: build - if: github.repository == 'MolCrafts/molplot' - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - id: deployment - uses: actions/deploy-pages@v4 diff --git a/zensical.toml b/zensical.toml index 8ee1d32..3d742a4 100644 --- a/zensical.toml +++ b/zensical.toml @@ -1,7 +1,7 @@ [project] site_name = "MolPlot" site_description = "Unified scientific charting: Vega-Lite on the web, scienceplots on paper, one preset." -site_url = "https://molcrafts.github.io/molplot/" +site_url = "https://molplot.molcrafts.org/" repo_url = "https://github.com/MolCrafts/molplot" repo_name = "MolCrafts/molplot" copyright = "Copyright © 2026 MolCrafts" From 8ff1b30ca6c3b4c105c65450af4e22bd1a5e03fb Mon Sep 17 00:00:00 2001 From: Roy Kid Date: Fri, 24 Jul 2026 10:46:33 +0200 Subject: [PATCH 07/17] refactor: rework core chart sizing/config; rename page demo to example --- CLAUDE.md | 12 +- README.md | 6 +- core/src/chart_base.ts | 16 +- core/src/element.ts | 89 +++++++++- core/src/index.ts | 2 + core/src/raw_chart.ts | 99 +++++++++++- core/src/specs.ts | 21 ++- core/src/theme.ts | 65 ++++++-- core/tests/element.test.ts | 166 ++++++++++++++++++- core/tests/preset.test.ts | 30 ++++ docs/getting-started/web.md | 9 ++ {page => example}/package.json | 2 +- {page => example}/rsbuild.config.ts | 6 +- example/src/App.tsx | 142 ++++++++++++++++ {page => example}/src/index.tsx | 0 {page => example}/tsconfig.json | 0 package-lock.json | 46 +++--- package.json | 8 +- page/src/App.tsx | 243 ---------------------------- 19 files changed, 650 insertions(+), 312 deletions(-) rename {page => example}/package.json (96%) rename {page => example}/rsbuild.config.ts (70%) create mode 100644 example/src/App.tsx rename {page => example}/src/index.tsx (100%) rename {page => example}/tsconfig.json (100%) delete mode 100644 page/src/App.tsx 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..7944edd 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 ``` diff --git a/core/src/chart_base.ts b/core/src/chart_base.ts index b9138bb..b7e28e8 100644 --- a/core/src/chart_base.ts +++ b/core/src/chart_base.ts @@ -296,6 +296,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 +312,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