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.
- fjkit_charts/__init__.py +55 -0
- fjkit_charts/figures.py +189 -0
- fjkit_charts/plugin.py +108 -0
- fjkit_charts/py.typed +0 -0
- fjkit_charts/static/charts.js +199 -0
- fjkit_charts/static/vendor/plotly/plotly-basic.min.js +47 -0
- fjkit_charts/templates/charts/macros.html +77 -0
- fjkit_charts-0.1.0.dev0.dist-info/METADATA +59 -0
- fjkit_charts-0.1.0.dev0.dist-info/RECORD +11 -0
- fjkit_charts-0.1.0.dev0.dist-info/WHEEL +4 -0
- fjkit_charts-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
fjkit_charts/__init__.py
ADDED
|
@@ -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
|
+
]
|
fjkit_charts/figures.py
ADDED
|
@@ -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
|
+
})();
|