fjkit-charts 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,55 @@
1
+ """Charts for a fjkit app: a plugin, one line to add and nothing to skip.
2
+
3
+ from fjkit import FjkitConfig
4
+ from fjkit_charts import ChartsPlugin
5
+
6
+ config = FjkitConfig(
7
+ template_dir=APP_DIR / "templates",
8
+ plugins=(ChartsPlugin(),),
9
+ )
10
+
11
+ Then in a template:
12
+
13
+ {% from "charts/macros.html" import chart, chart_scripts %}
14
+ {% for item in charts %}{{ chart(item) }}{% endfor %}
15
+
16
+ {% block scripts %}{{ chart_scripts() }}{% endblock %}
17
+
18
+ and in the router, a `Chart` per figure:
19
+
20
+ from fjkit_charts import Chart, figure_of
21
+
22
+ Chart(id="by-owner", title="Workload", summary="Ana has 5 of 12 open tasks.",
23
+ figure=figure_of(go.Figure(...)))
24
+
25
+ That is the whole setup. Plotly's basic bundle ships in this wheel — one of the
26
+ scripts CHARTER §7 whitelists, pinned here rather than in `fjkit.vendored`
27
+ because the bytes are here too — and is served from this plugin's own static
28
+ mount. No download, no npm, no bundler, nothing fetched at runtime. A page
29
+ loads the 1.1 MB only if it calls `chart_scripts()`.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from fjkit_charts.figures import (
35
+ Chart,
36
+ PlotlyFigure,
37
+ PlotlyLayout,
38
+ PlotlyTrace,
39
+ assert_no_colour_in,
40
+ figure_of,
41
+ )
42
+ from fjkit_charts.plugin import PLOTLY_FILENAME, PLOTLY_URL, PLOTLY_VERSION, ChartsPlugin
43
+
44
+ __all__ = [
45
+ "PLOTLY_FILENAME",
46
+ "PLOTLY_URL",
47
+ "PLOTLY_VERSION",
48
+ "Chart",
49
+ "ChartsPlugin",
50
+ "PlotlyFigure",
51
+ "PlotlyLayout",
52
+ "PlotlyTrace",
53
+ "assert_no_colour_in",
54
+ "figure_of",
55
+ ]
@@ -0,0 +1,189 @@
1
+ """A Plotly figure, typed down to the parts this package reads.
2
+
3
+ The figure that reaches the browser is a Plotly figure: `data` and `layout`, the
4
+ names Plotly's own documentation uses. It is not `dict[str, Any]` — the fields
5
+ the drawing code branches on are typed, and the rest arrives through an explicit
6
+ tail. OpenAPI can then state which fields are promised and which are Plotly's.
7
+
8
+ A figure carries no colour.
9
+
10
+ Series run on Plotly's own palette, applied in the browser, so nothing on the
11
+ server picks a hue. A colour written into the figure is wrong in one of the two
12
+ themes, invisible to `fjkit check` (which reads class attributes, not JSON), and
13
+ unchangeable without a redeploy.
14
+
15
+ `extra="allow"` makes the Plotly tail reachable and is also the hole in that
16
+ rule: `#1F77B4` is a legal `str`, so no schema stops a colour arriving through
17
+ it. The guard is a test that scans the rendered figure JSON, which does not care
18
+ which field the bytes came from. `ChartsPlugin` ships that test as
19
+ `fjkit_charts.assert_no_colour_in`, so an app gets it in one line.
20
+
21
+ **plotly is not a dependency of fjkit.** `figure_of` takes anything with a
22
+ `to_plotly_json()`, which is its whole required surface. An app that builds
23
+ figures by hand as dicts never installs plotly.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import re
30
+ from typing import Any, Literal
31
+
32
+ from pydantic import BaseModel, ConfigDict
33
+
34
+ __all__ = [
35
+ "Chart",
36
+ "PlotlyFigure",
37
+ "PlotlyLayout",
38
+ "PlotlyTrace",
39
+ "assert_no_colour_in",
40
+ "figure_of",
41
+ ]
42
+
43
+
44
+ class PlotlyTrace(BaseModel):
45
+ """One series, in Plotly's vocabulary.
46
+
47
+ Typed down to what the drawing code reads and no further. `type` is a
48
+ `Literal` rather than `str` because the browser branches on it: a fourth
49
+ trace kind arriving unannounced renders a silent empty chart, which is how
50
+ `mpl_to_plotly` fails.
51
+
52
+ Everything else Plotly accepts — `mode`, `hole`, `textposition`,
53
+ `hovertemplate` — comes through `extra="allow"` untyped. That tail is what
54
+ makes the full library reachable, and why the colour check is not optional.
55
+ """
56
+
57
+ model_config = ConfigDict(extra="allow")
58
+
59
+ type: Literal["bar", "scatter", "pie"]
60
+ name: str | None = None
61
+ #: Cartesian traces. Not "category" and "value": Plotly's `x` and `y` are
62
+ #: axes, and a horizontal bar puts the numbers on `x` and the labels on
63
+ #: `y`. Typing them as `str` and `float` respectively looks right until the
64
+ #: first `orientation="h"` chart. That is this module's lesson in small: a
65
+ #: typed subset of somebody else's schema is a guess, and the tail is what
66
+ #: stops a wrong guess being fatal.
67
+ x: list[str | float] | None = None
68
+ y: list[str | float] | None = None
69
+ #: Pie traces use different names for the same two ideas.
70
+ labels: list[str] | None = None
71
+ values: list[float] | None = None
72
+
73
+
74
+ class PlotlyLayout(BaseModel):
75
+ """Layout, minus everything the theme owns.
76
+
77
+ No font, no grid colour, no background: those resolve from the live tokens
78
+ at draw time and would be overwritten anyway. What remains is what the route
79
+ decides — how bars combine, whether there is a legend, what the axes are
80
+ called, and the tick rules the data implies.
81
+ """
82
+
83
+ model_config = ConfigDict(extra="allow")
84
+
85
+ barmode: Literal["stack", "group"] | None = None
86
+ showlegend: bool | None = None
87
+
88
+
89
+ class PlotlyFigure(BaseModel):
90
+ model_config = ConfigDict(extra="allow")
91
+
92
+ data: list[PlotlyTrace]
93
+ layout: PlotlyLayout
94
+
95
+
96
+ class Chart(BaseModel):
97
+ """A figure, plus the three things a figure cannot supply itself: a stable
98
+ id, a sentence describing it, and what its series mean.
99
+
100
+ `summary` is required, not decoration. It is the text alternative, and the
101
+ only content a reader without JavaScript gets. A required field rather than
102
+ a keyword someone forgets: a chart with no text alternative is invisible to
103
+ a screen reader, and the macro cannot invent the sentence.
104
+ """
105
+
106
+ id: str
107
+ title: str
108
+ description: str = ""
109
+ #: Write it from the same numbers the traces are built from, so it cannot
110
+ #: describe a different chart.
111
+ summary: str
112
+ height: int = 288
113
+ figure: PlotlyFigure
114
+
115
+ # A plain property, not a `@computed_field`: a computed field joins the JSON
116
+ # representation, putting the same bytes on the wire twice — `figure` is
117
+ # already there, and this only renders it for an HTML attribute. Jinja reads
118
+ # plain properties.
119
+ @property
120
+ def figure_json(self) -> str:
121
+ """Render the figure for an HTML attribute.
122
+
123
+ `exclude_none` because the typed fields are a superset of any one trace:
124
+ a bar has no `labels`, a pie has no `x`. Emitting them as nulls sends
125
+ Plotly keys it has to ignore, on every trace."""
126
+ return json.dumps(self.figure.model_dump(exclude_none=True))
127
+
128
+
129
+ def figure_of(fig: Any) -> PlotlyFigure:
130
+ """Validate a `plotly.graph_objects.Figure` into the model above.
131
+
132
+ Two things happen on the way through. Both must happen exactly once, so
133
+ neither is left to callers:
134
+
135
+ * `template` is dropped. `plotly.py` writes one even when it is set to
136
+ `None`, and the default template is 7,621 bytes carrying 111 colour
137
+ literals, each of which violates this module's colour rule.
138
+ * the figure is validated, so a trace type the browser cannot draw fails
139
+ here rather than rendering an empty box.
140
+
141
+ Duck-typed on `to_plotly_json()` rather than imported from plotly, which
142
+ keeps plotly out of fjkit's dependencies. A plain dict also works.
143
+ """
144
+ payload = fig.to_plotly_json() if hasattr(fig, "to_plotly_json") else dict(fig)
145
+ layout = payload.get("layout")
146
+ if isinstance(layout, dict):
147
+ layout.pop("template", None)
148
+ return PlotlyFigure.model_validate(payload)
149
+
150
+
151
+ #: Hex literals and the CSS colour functions, in figure JSON. The same families
152
+ #: `fjkit check` looks for in markup: one rule, of which this is the half that
153
+ #: reads JSON instead of class attributes.
154
+ _COLOUR = re.compile(
155
+ r"#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b"
156
+ r"|\b(?:rgb|rgba|hsl|hsla|oklch|oklab|lab|lch)\("
157
+ r"|\bvar\(--"
158
+ )
159
+
160
+
161
+ def assert_no_colour_in(charts: Any) -> None:
162
+ """Raise if any figure carries a colour. For an app's own test suite.
163
+
164
+ from fjkit_charts import assert_no_colour_in
165
+
166
+ def test_the_figures_carry_no_colour(client):
167
+ assert_no_colour_in(build_my_charts())
168
+
169
+ Scans the rendered JSON rather than the model fields, so it catches a hue
170
+ that arrived through `extra="allow"` — the only route a hue can take.
171
+
172
+ `var(--…)` is in the pattern because that failure looks like it works:
173
+ `plotly.py` accepts the string, validates it and serialises it, and the
174
+ browser's parser then discards it silently. A token name on the server is
175
+ not a token; it is a typo with a plausible shape.
176
+
177
+ Accepts one `Chart`, an iterable of them, or anything JSON-serialisable.
178
+ """
179
+ items = charts if isinstance(charts, (list, tuple)) else [charts]
180
+ for item in items:
181
+ blob = item.figure_json if isinstance(item, Chart) else json.dumps(item, default=str)
182
+ found = _COLOUR.search(blob)
183
+ if found is not None:
184
+ where = getattr(item, "id", "<figure>")
185
+ raise AssertionError(
186
+ f"chart {where!r} carries the colour literal {found.group(0)!r}. "
187
+ "Series colours are Plotly's and the chrome is resolved from tokens in the "
188
+ "browser — a colour written on the server is wrong in one of the two themes."
189
+ )
fjkit_charts/plugin.py ADDED
@@ -0,0 +1,108 @@
1
+ """`ChartsPlugin` — charts as a registration, not a page built twice.
2
+
3
+ config = FjkitConfig(template_dir=…, plugins=(ChartsPlugin(),))
4
+
5
+ That gives an app the `chart` macro, the theming bridge, the Plotly bundle and
6
+ the two `<script>` lines that load them. Nothing to download, nothing to mount:
7
+ Plotly ships inside this wheel — one of the scripts CHARTER §7 whitelists — and
8
+ is served from this plugin's own static mount.
9
+
10
+ **The division of labour.** Python decides the shape of the figure; the browser
11
+ decides its chrome. Axis text, grid lines, tick colour, the gaps between pie
12
+ slices and the hover card belong to the card a chart sits in, not to the data,
13
+ and Plotly's defaults for them are `#444` on `#eee` — a chart drawn for a white
14
+ page, on a page that may be dark. So `charts.js` resolves those from the live
15
+ tokens on every draw, and a theme toggle is a redraw. The series colours are
16
+ Plotly's own and never change.
17
+
18
+ **Why the JavaScript is not in the shell.** A plugin cannot inject markup into
19
+ `ui/shell.html` (`fjkit.plugins`), and this is why that rule holds: Plotly's
20
+ basic bundle is 1.1 MB, so a hook letting this plugin put a `<script>` on every
21
+ page would put it on the login screen. Instead the page that draws charts calls
22
+ `chart_scripts()` in its own `{% block scripts %}` and no other page pays. Being
23
+ in the wheel changes what an install contains, not what a page downloads, and §7
24
+ budgets the download. Which distribution the bundle lives in decides the first
25
+ of those: an app that never draws a chart does not install `fjkit-charts` and
26
+ never carries the 1.1 MB at all.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from pathlib import Path
32
+
33
+ from fjkit.plugins import AppSetup, EnvSetup
34
+ from fjkit.templating import static_url
35
+
36
+ __all__ = ["PLOTLY_VERSION", "ChartsPlugin"]
37
+
38
+ #: This plugin's own templates. Named `templates/charts/` so template names are
39
+ #: `charts/…`, matching how `apidocs` does it.
40
+ TEMPLATE_DIR = Path(__file__).parent / "templates"
41
+
42
+ #: `charts.js` — the theming bridge. 3 KB, and the only JavaScript this plugin
43
+ #: ships of its own.
44
+ STATIC_DIR = Path(__file__).parent / "static"
45
+
46
+ #: Plotly's basic bundle — scatter, bar and pie. The full `plotly.js-dist-min`
47
+ #: is 4.85 MB of 3D, maps, financial and statistical traces most dashboards
48
+ #: never draw; the basic one is 1.1 MB, and picking the bundle is the only size
49
+ #: knob Plotly offers without a build step.
50
+ #:
51
+ #: The pin lives here rather than in `fjkit.vendored` because the bytes do:
52
+ #: `scripts/vendor_plotly.py` writes them under this package's own
53
+ #: `static/vendor/plotly/`, and the kit no longer knows Plotly exists.
54
+ PLOTLY_VERSION = "3.7.0"
55
+
56
+ PLOTLY_FILENAME = "plotly-basic.min.js"
57
+
58
+ #: Relative to this plugin's `assets` mount, not the kit's static mount.
59
+ PLOTLY_PATH = f"vendor/plotly/{PLOTLY_FILENAME}"
60
+ PLOTLY_URL = f"https://cdn.jsdelivr.net/npm/plotly.js-basic-dist-min@{PLOTLY_VERSION}/{PLOTLY_FILENAME}"
61
+
62
+
63
+ class ChartsPlugin:
64
+ """Register in `FjkitConfig.plugins`.
65
+
66
+ :param url: where this plugin's own assets (`charts.js`) are served. Keep it
67
+ outside `config.static_url` (`/_fjkit` by default): Starlette matches
68
+ the first mount whose prefix fits, so a path nested inside the kit's own
69
+ static mount is swallowed by it and 404s against a directory that does
70
+ not hold this plugin's files.
71
+ :param plotly_url: serve Plotly from somewhere other than the copy in the
72
+ wheel — a path your app already serves, or a URL. The default is the
73
+ vendored bundle under this plugin's own `url`, cache-stamped like every
74
+ other kit asset.
75
+ """
76
+
77
+ name = "charts"
78
+
79
+ def __init__(self, *, url: str = "/_fjkit-charts", plotly_url: str | None = None) -> None:
80
+ if not url.startswith("/") or url == "/":
81
+ raise ValueError(f"ChartsPlugin(url={url!r}) must be an absolute path such as '/_fjkit-charts'.")
82
+ self.url = url.rstrip("/")
83
+ self.plotly_url = plotly_url
84
+
85
+ # ---------------------------------------------------------------- plugin
86
+
87
+ def mount(self, setup: AppSetup) -> None:
88
+ # The bridge and the bundle both. `STATIC_DIR` holds `charts.js` beside
89
+ # `vendor/plotly/`, so one mount serves everything this plugin ships.
90
+ setup.mount_static(f"{self.url}/assets", STATIC_DIR)
91
+
92
+ def extend(self, setup: EnvSetup) -> None:
93
+ """Contribute the templates and the two URLs they need.
94
+
95
+ The globals are namespaced `fjkit_charts_*` for the reason `EnvSetup`
96
+ makes collisions a startup error: an app is entitled to want `charts`
97
+ for its own context.
98
+ """
99
+ setup.add_template_dir(TEMPLATE_DIR)
100
+
101
+ # Same `?v=<mtime>` stamp the kit's own assets get, for the same reason:
102
+ # StaticFiles sends no Cache-Control, so a browser may keep a script
103
+ # whose lifetime it was never told.
104
+ assets = static_url(f"{self.url}/assets", STATIC_DIR, auto_reload=setup.config.auto_reload)
105
+ setup.add_global("fjkit_charts_script", lambda: assets("charts.js"))
106
+
107
+ plotly = self.plotly_url if self.plotly_url is not None else assets(PLOTLY_PATH)
108
+ setup.add_global("fjkit_charts_plotly", lambda: plotly)
fjkit_charts/py.typed ADDED
File without changes
@@ -0,0 +1,199 @@
1
+ /*
2
+ Themes a Plotly chart to the fjkit page it sits on.
3
+
4
+ The series run on Plotly's own palette: the figure the server sends carries no
5
+ colour, and nothing here paints one on. This file does the other half —
6
+ everything around the data. Axis labels, grid lines, tick text, the gaps
7
+ between pie slices, the hover card. Those belong to the card the chart sits in,
8
+ not to the data, and Plotly's defaults for them are `#444` text on `#eee` grid:
9
+ a chart drawn for a white page, on a page that might be dark.
10
+
11
+ So the chrome is resolved from the live tokens on every draw, and a theme
12
+ toggle is a redraw. The data colours are not resolved and do not change.
13
+
14
+ The division of labour with the server: Python decides the shape, this file
15
+ decides the chrome.
16
+
17
+ Loaded only by charts/page.html. Plotly is 1.1 MB — no other page pays for it.
18
+ */
19
+ (() => {
20
+ "use strict";
21
+
22
+ /* How a token becomes something Plotly understands.
23
+
24
+ The tokens are `oklch()`. Plotly parses colours with tinycolor2, which
25
+ predates CSS Color 4: hand it `oklch(0.72 0.15 275)` and it reports no
26
+ error, it draws its own default palette instead. That failure is invisible
27
+ in a screenshot unless you know what fjkit's primary is meant to look like,
28
+ which is what makes it worth this much comment. `var(--success)` fails the
29
+ same way and is worse, because `plotly.py` accepts that string on the
30
+ server: it validates, it serialises, and it is discarded here.
31
+
32
+ So the conversion happens in the browser, using the browser's own parser.
33
+ Any table written here would be a second definition of the colour, and the
34
+ point is that there is one.
35
+
36
+ Not enough: assigning to `fillStyle` and reading it back. That round-trip
37
+ normalises the legacy formats to `#rrggbb` but preserves modern colour
38
+ functions verbatim — `oklch` in, `oklch` out — so it looks like it works
39
+ until you check what Plotly received.
40
+
41
+ Painting one pixel and reading it does work. `getImageData` answers in sRGB
42
+ bytes whatever the source notation was, which is also the right clamp: the
43
+ SVG Plotly emits is sRGB regardless. One 1x1 canvas, read a handful of times
44
+ per redraw.
45
+
46
+ Assigning the fallback first matters: an unparseable value leaves
47
+ `fillStyle` untouched, so without a known starting point a mistyped token
48
+ would inherit the previous colour instead of being visibly wrong. */
49
+ const canvas = document.createElement("canvas");
50
+ canvas.width = canvas.height = 1;
51
+ const probe = canvas.getContext("2d", { willReadFrequently: true });
52
+
53
+ function resolve(token, fallback) {
54
+ const raw = getComputedStyle(document.documentElement).getPropertyValue(token).trim();
55
+ probe.clearRect(0, 0, 1, 1);
56
+ probe.fillStyle = fallback;
57
+ probe.fillStyle = raw || fallback;
58
+ probe.fillRect(0, 0, 1, 1);
59
+ /* Alpha is carried through rather than flattened: `--border` is defined as
60
+ white at 11% in dark mode, and a grid line that ignores that is drawn on
61
+ top of the data. */
62
+ const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;
63
+ return `rgba(${r}, ${g}, ${b}, ${(a / 255).toFixed(3)})`;
64
+ }
65
+
66
+ /* Read once per draw, not once per load: the theme toggle changes all three,
67
+ and a cached palette leaves the chart in the previous theme's colours. */
68
+ function palette() {
69
+ return {
70
+ text: resolve("--foreground", "#333333"),
71
+ grid: resolve("--border", "#cccccc"),
72
+ surface: resolve("--popover", "#ffffff"),
73
+ };
74
+ }
75
+
76
+ /* The per-trace half of the chrome.
77
+
78
+ Only pies need it, and only because Plotly's pie ignores `layout.font`: its
79
+ `textfont` defaults to #444 whatever the rest of the figure says, so the
80
+ labels come out dark grey on whatever colour the card is. The slice gaps get
81
+ the card's colour too, which makes a donut read as cut rather than outlined.
82
+
83
+ Nothing here touches `marker.color` or `marker.colors`. The series palette
84
+ is Plotly's. */
85
+ function chrome(data, colors) {
86
+ return data.map((trace) =>
87
+ trace.type === "pie"
88
+ ? {
89
+ ...trace,
90
+ marker: { ...trace.marker, line: { color: colors.surface, width: 2 } },
91
+ textfont: { color: colors.text },
92
+ }
93
+ : trace,
94
+ );
95
+ }
96
+
97
+ /* The layout half the theme owns. The figure's own layout is merged over the
98
+ top, so anything the route decided wins. */
99
+ function themed(colors, height) {
100
+ /* Inherits the page's font rather than naming one. Plotly's default is
101
+ Open Sans, which nothing else on the card is set in. */
102
+ const font = { color: colors.text, family: getComputedStyle(document.body).fontFamily, size: 12 };
103
+ const axis = {
104
+ gridcolor: colors.grid,
105
+ zerolinecolor: colors.grid,
106
+ linecolor: colors.grid,
107
+ tickfont: font,
108
+ automargin: true,
109
+ };
110
+
111
+ return {
112
+ height: height,
113
+ /* Transparent, so the card underneath is the background in both themes and
114
+ there is no second surface colour to keep in agreement. */
115
+ paper_bgcolor: "rgba(0,0,0,0)",
116
+ plot_bgcolor: "rgba(0,0,0,0)",
117
+ font: font,
118
+ margin: { l: 8, r: 8, t: 8, b: 8 },
119
+ legend: { orientation: "h", y: -0.18, font: font },
120
+ xaxis: { ...axis },
121
+ yaxis: { ...axis, rangemode: "tozero" },
122
+ hoverlabel: { bgcolor: colors.surface, bordercolor: colors.grid, font: font },
123
+ };
124
+ }
125
+
126
+ /* One level deep, which is what the axes need: the figure carries
127
+ `yaxis: {title, dtick}` and the theme carries `yaxis: {gridcolor,
128
+ tickfont}`. A plain spread would drop one whole side of that. */
129
+ function merge(base, extra) {
130
+ const out = { ...base };
131
+ for (const [key, value] of Object.entries(extra || {})) {
132
+ const mergeable =
133
+ value && typeof value === "object" && !Array.isArray(value) && base[key] && typeof base[key] === "object";
134
+ out[key] = mergeable ? { ...base[key], ...value } : value;
135
+ }
136
+ return out;
137
+ }
138
+
139
+ /* `react` rather than `newPlot`: `react` is idempotent. First draw, theme
140
+ change and htmx swap all go through the same call, so there is no "has this
141
+ been drawn yet" flag to get wrong. */
142
+ const CONFIG = { displayModeBar: false, responsive: true, staticPlot: false };
143
+
144
+ function draw(element) {
145
+ const figure = JSON.parse(element.dataset.figure);
146
+ /* The height arrives as a number and is applied here rather than as a
147
+ `style=""` in the template. fjkit's vocabulary is closed and has no sizing
148
+ utility, so the alternative was an inline style in markup — and the height
149
+ is a property of the figure, which is already a model. */
150
+ const height = Number(element.dataset.height) || 288;
151
+ element.style.height = height + "px";
152
+
153
+ const colors = palette();
154
+ Plotly.react(element, chrome(figure.data, colors), merge(themed(colors, height), figure.layout), CONFIG);
155
+ }
156
+
157
+ function drawAll(root) {
158
+ const scope = root || document;
159
+ /* `matches` first, because an htmx swap can hand back the chart element
160
+ itself — `querySelectorAll` never returns its own root. */
161
+ if (scope.matches && scope.matches("[data-chart]")) draw(scope);
162
+ if (scope.querySelectorAll) scope.querySelectorAll("[data-chart]").forEach(draw);
163
+ }
164
+
165
+ if (!window.Plotly) {
166
+ console.warn("[charts] Plotly did not load; the figcaption under each chart is the fallback.");
167
+ return;
168
+ }
169
+
170
+ addEventListener("DOMContentLoaded", () => drawAll(document));
171
+
172
+ /* htmx swaps in charts that have never been drawn. `htmx:load` fires once per
173
+ new node, which is the hook needed here — `htmx:afterSwap` fires once per
174
+ request and hands back the target rather than the new content. */
175
+ document.body.addEventListener("htmx:load", (event) => drawAll(event.target));
176
+
177
+ /* htmx also swaps charts out. Plotly attaches resize listeners and, on some
178
+ trace types, a WebGL context; dropping the node without purging leaks both.
179
+ A chart inside a partial usually forgets this half. */
180
+ document.body.addEventListener("htmx:beforeCleanupElement", (event) => {
181
+ if (event.target.matches && event.target.matches("[data-chart]")) Plotly.purge(event.target);
182
+ });
183
+
184
+ /* The theme toggle flips `.dark` on <html> and nothing else fires. Watching
185
+ the attribute keeps this file independent of how the toggle is implemented,
186
+ and a `prefers-color-scheme` change lands here too, because the shell's
187
+ flash-guard writes the same class.
188
+
189
+ The redraw changes the chrome: text, grid, slice gaps, hover card. The data
190
+ keeps its colours, which were never ours.
191
+
192
+ Not watched: the demo's style-pack picker. The eight packs define no colour
193
+ tokens at all — they differ in geometry, radii and control heights — so a
194
+ pack switch cannot change anything read here. */
195
+ new MutationObserver(() => drawAll(document)).observe(document.documentElement, {
196
+ attributes: true,
197
+ attributeFilter: ["class"],
198
+ });
199
+ })();