fjkit-charts 0.1.0__tar.gz

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,37 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ *.egg-info
5
+
6
+ # Packaging output — anchored to the repo root on purpose, so the pattern does
7
+ # not swallow app/static/dist/, which is a build artefact we DO commit.
8
+ /build/
9
+ /dist/
10
+ /wheels/
11
+
12
+ # Virtual environments
13
+ .venv
14
+
15
+ # Project
16
+ .env
17
+ # The admin demo's database — created and seeded on first start.
18
+ examples/fjkit-admin-demo/admin-demo.sqlite
19
+ .jinja-cache/
20
+ .pytest_cache/
21
+
22
+ # fjkit build output — regenerated by `uv run fjkit build-css`
23
+ packages/fjkit/src/fjkit/static/dist/
24
+
25
+ # Docs-site intermediate — regenerated by `docs/workbench/build.py`. The
26
+ # templates and the renderers are the source; this is the rendered previews on
27
+ # their way into docs/assets/data.js, which IS committed.
28
+ packages/fjkit/docs/workbench/data.json
29
+
30
+ # 私人目標文件 — 刻意不進版控
31
+ /goal/
32
+
33
+ # Agent worktrees — embedded checkouts, never part of a commit.
34
+ .claude/worktrees/
35
+
36
+ # The built editor extension; `tools/fjkit-vscode/build.py` recreates it.
37
+ tools/fjkit-vscode/dist/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 liweicheng00
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.5
2
+ Name: fjkit-charts
3
+ Version: 0.1.0
4
+ Summary: Server-rendered Plotly charts for fjkit — a plugin, no front-end build
5
+ Project-URL: Homepage, https://liweicheng00.github.io/fjkit/
6
+ Project-URL: Documentation, https://liweicheng00.github.io/fjkit/
7
+ Project-URL: Repository, https://github.com/liweicheng00/fjkit
8
+ Project-URL: Issues, https://github.com/liweicheng00/fjkit/issues
9
+ Author: liweicheng00
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: charts,dashboard,fastapi,fjkit,jinja2,no-build,plotly,server-rendered
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Environment :: Web Environment
15
+ Classifier: Framework :: FastAPI
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
19
+ Classifier: Topic :: Scientific/Engineering :: Visualization
20
+ Classifier: Topic :: Software Development :: User Interfaces
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.13
23
+ Requires-Dist: fjkit>=0.1.0.dev0
24
+ Requires-Dist: pydantic>=2.9
25
+ Description-Content-Type: text/markdown
26
+
27
+ # fjkit-charts
28
+
29
+ Server-rendered Plotly charts for [fjkit](https://liweicheng00.github.io/fjkit/).
30
+ A plugin, one line to add, no front-end build.
31
+
32
+ ```bash
33
+ uv add fjkit-charts
34
+ ```
35
+
36
+ ```python
37
+ from fjkit import FjkitConfig
38
+ from fjkit_charts import ChartsPlugin
39
+
40
+ config = FjkitConfig(template_dir=APP_DIR / "templates", plugins=(ChartsPlugin(),))
41
+ ```
42
+
43
+ ```jinja
44
+ {% from "charts/macros.html" import chart, chart_scripts %}
45
+ {% for item in charts %}{{ chart(item) }}{% endfor %}
46
+
47
+ {% block scripts %}{{ chart_scripts() }}{% endblock %}
48
+ ```
49
+
50
+ Plotly's basic bundle ships inside this wheel and is served from the plugin's
51
+ own static mount. Nothing is fetched at runtime, and a page loads the 1.1 MB
52
+ only if it calls `chart_scripts()`.
53
+
54
+ Chart chrome — axis text, grid lines, tick colour, the hover card — is resolved
55
+ from the live theme tokens on every draw, so a theme toggle is a redraw.
56
+
57
+ ## License
58
+
59
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,33 @@
1
+ # fjkit-charts
2
+
3
+ Server-rendered Plotly charts for [fjkit](https://liweicheng00.github.io/fjkit/).
4
+ A plugin, one line to add, no front-end build.
5
+
6
+ ```bash
7
+ uv add fjkit-charts
8
+ ```
9
+
10
+ ```python
11
+ from fjkit import FjkitConfig
12
+ from fjkit_charts import ChartsPlugin
13
+
14
+ config = FjkitConfig(template_dir=APP_DIR / "templates", plugins=(ChartsPlugin(),))
15
+ ```
16
+
17
+ ```jinja
18
+ {% from "charts/macros.html" import chart, chart_scripts %}
19
+ {% for item in charts %}{{ chart(item) }}{% endfor %}
20
+
21
+ {% block scripts %}{{ chart_scripts() }}{% endblock %}
22
+ ```
23
+
24
+ Plotly's basic bundle ships inside this wheel and is served from the plugin's
25
+ own static mount. Nothing is fetched at runtime, and a page loads the 1.1 MB
26
+ only if it calls `chart_scripts()`.
27
+
28
+ Chart chrome — axis text, grid lines, tick colour, the hover card — is resolved
29
+ from the live theme tokens on every draw, so a theme toggle is a redraw.
30
+
31
+ ## License
32
+
33
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,64 @@
1
+ # Charts as their own distribution, so an app that draws none does not install
2
+ # Plotly. The bundle is 1.1 MB — 365 KB over the wire — and was 37% of what
3
+ # `pip install fjkit` downloaded while it lived in the kit's wheel.
4
+ #
5
+ # The import package is `fjkit_charts`, not `fjkit_charts`: `fjkit/__init__.py`
6
+ # exists, so `fjkit` is a regular package and a second distribution cannot
7
+ # legally write into its directory. Making `fjkit` a PEP 420 namespace package
8
+ # would cost that `__init__.py`. The underscore is the cheaper answer, and it
9
+ # was free to take before 0.1.0 reached PyPI.
10
+ [project]
11
+ name = "fjkit-charts"
12
+ version = "0.1.0"
13
+ description = "Server-rendered Plotly charts for fjkit — a plugin, no front-end build"
14
+ readme = "README.md"
15
+ requires-python = ">=3.13"
16
+ authors = [{ name = "liweicheng00" }]
17
+ license = "MIT"
18
+ license-files = ["LICENSE"]
19
+
20
+ keywords = ["fastapi", "jinja2", "fjkit", "plotly", "charts", "dashboard", "server-rendered", "no-build"]
21
+
22
+ classifiers = [
23
+ "Development Status :: 3 - Alpha",
24
+ "Environment :: Web Environment",
25
+ "Framework :: FastAPI",
26
+ "Intended Audience :: Developers",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
29
+ "Topic :: Scientific/Engineering :: Visualization",
30
+ "Topic :: Software Development :: User Interfaces",
31
+ "Typing :: Typed",
32
+ ]
33
+
34
+ # `pydantic` came across with `figures.py`, which was its only importer in the
35
+ # kit: `PlotlyFigure` is a model an app embeds in its own `response_model`.
36
+ #
37
+ # `plotly` the Python package is deliberately absent. `figures.py` duck-types on
38
+ # `to_plotly_json()`, so an app that builds figures by hand needs nothing extra
39
+ # and an app that uses `plotly` declares it itself.
40
+ dependencies = [
41
+ "fjkit>=0.1.0.dev0",
42
+ "pydantic>=2.9",
43
+ ]
44
+
45
+ [project.urls]
46
+ Homepage = "https://liweicheng00.github.io/fjkit/"
47
+ Documentation = "https://liweicheng00.github.io/fjkit/"
48
+ Repository = "https://github.com/liweicheng00/fjkit"
49
+ Issues = "https://github.com/liweicheng00/fjkit/issues"
50
+
51
+ [build-system]
52
+ requires = ["hatchling"]
53
+ build-backend = "hatchling.build"
54
+
55
+ [tool.hatch.build.targets.wheel]
56
+ packages = ["src/fjkit_charts"]
57
+
58
+ [tool.hatch.build]
59
+ # The Plotly bundle and the theming bridge ship inside the wheel — that is what
60
+ # keeps "nothing to download" true for a page that draws charts.
61
+ artifacts = [
62
+ "src/fjkit_charts/templates/**/*",
63
+ "src/fjkit_charts/static/**/*",
64
+ ]
@@ -0,0 +1,42 @@
1
+ """Vendor Plotly's basic bundle into the fjkit-charts package.
2
+
3
+ Split out of the kit's `scripts/vendor_ui.py` when charts became their own
4
+ distribution: the bytes live here now, so the script that fetches them does too.
5
+
6
+ uv run python packages/fjkit-charts/scripts/vendor_plotly.py
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import urllib.request
12
+ from pathlib import Path
13
+
14
+ # Single source of truth lives in the package, so the URL the plugin advertises
15
+ # cannot drift from the bytes on disk.
16
+ from fjkit_charts.plugin import PLOTLY_FILENAME, PLOTLY_VERSION
17
+
18
+ PACKAGE = Path(__file__).resolve().parent.parent / "src" / "fjkit_charts"
19
+ VENDOR = PACKAGE / "static" / "vendor" / "plotly"
20
+
21
+ JSDELIVR = "https://cdn.jsdelivr.net/npm"
22
+
23
+
24
+ def fetch(url: str) -> bytes:
25
+ with urllib.request.urlopen(url, timeout=30) as response:
26
+ return response.read()
27
+
28
+
29
+ def vendor_plotly() -> None:
30
+ # The basic bundle, not the full one — `PLOTLY_VERSION` says why. Served
31
+ # from this plugin's own static mount, and loaded by nothing but the page
32
+ # that calls `chart_scripts()`.
33
+ print(f"plotly.js-basic-dist-min@{PLOTLY_VERSION}")
34
+ target = VENDOR / PLOTLY_FILENAME
35
+ target.parent.mkdir(parents=True, exist_ok=True)
36
+ payload = fetch(f"{JSDELIVR}/plotly.js-basic-dist-min@{PLOTLY_VERSION}/{PLOTLY_FILENAME}")
37
+ target.write_bytes(payload)
38
+ print(f" {target.relative_to(PACKAGE.parent.parent)} {len(payload) / 1024:.0f} KB")
39
+
40
+
41
+ if __name__ == "__main__":
42
+ vendor_plotly()
@@ -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
+ )
@@ -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)
File without changes