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.
- fjkit_charts-0.1.0/.gitignore +37 -0
- fjkit_charts-0.1.0/LICENSE +21 -0
- fjkit_charts-0.1.0/PKG-INFO +59 -0
- fjkit_charts-0.1.0/README.md +33 -0
- fjkit_charts-0.1.0/pyproject.toml +64 -0
- fjkit_charts-0.1.0/scripts/vendor_plotly.py +42 -0
- fjkit_charts-0.1.0/src/fjkit_charts/__init__.py +55 -0
- fjkit_charts-0.1.0/src/fjkit_charts/figures.py +189 -0
- fjkit_charts-0.1.0/src/fjkit_charts/plugin.py +108 -0
- fjkit_charts-0.1.0/src/fjkit_charts/py.typed +0 -0
- fjkit_charts-0.1.0/src/fjkit_charts/static/charts.js +199 -0
- fjkit_charts-0.1.0/src/fjkit_charts/static/vendor/plotly/plotly-basic.min.js +47 -0
- fjkit_charts-0.1.0/src/fjkit_charts/templates/charts/macros.html +77 -0
- fjkit_charts-0.1.0/tests/test_charts_plugin.py +246 -0
|
@@ -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
|