fluxplot 0.1.0__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.
- fluxplot/__init__.py +115 -0
- fluxplot/_fieldmap.py +97 -0
- fluxplot/_mesh_reduce.py +54 -0
- fluxplot/_scene3d_size.py +95 -0
- fluxplot/_viewer/THIRD-PARTY.txt +23 -0
- fluxplot/_viewer/flux-model3d-viewer.min.js +4221 -0
- fluxplot/_viewer/stamp.json +4 -0
- fluxplot/api.py +1196 -0
- fluxplot/autotag.py +164 -0
- fluxplot/base.mplstyle +0 -0
- fluxplot/brackets.py +242 -0
- fluxplot/canonical_json.py +23 -0
- fluxplot/capture.py +150 -0
- fluxplot/colorcheck.py +285 -0
- fluxplot/colors.py +727 -0
- fluxplot/colorscale.py +477 -0
- fluxplot/data.py +178 -0
- fluxplot/definitions/colormaps.json +1639 -0
- fluxplot/definitions/flexoki.tokens.json +2571 -0
- fluxplot/definitions/palettes.json +2547 -0
- fluxplot/descriptors.py +87 -0
- fluxplot/fields.py +611 -0
- fluxplot/fits.py +240 -0
- fluxplot/glb.py +84 -0
- fluxplot/ids.py +173 -0
- fluxplot/images.py +362 -0
- fluxplot/integrity.py +27 -0
- fluxplot/manifest.py +788 -0
- fluxplot/mesh3d.py +376 -0
- fluxplot/panels.py +284 -0
- fluxplot/postprocess.py +638 -0
- fluxplot/presets.py +66 -0
- fluxplot/provenance.py +177 -0
- fluxplot/raster.py +295 -0
- fluxplot/recipe.py +178 -0
- fluxplot/render.py +66 -0
- fluxplot/roles.py +147 -0
- fluxplot/scene3d.py +386 -0
- fluxplot/scene3d_manifest.py +112 -0
- fluxplot/scene3d_viewer.py +633 -0
- fluxplot/schemas/.gitkeep +0 -0
- fluxplot/schemas/manifest.schema.json +2479 -0
- fluxplot/schemas/recipe.schema.json +179 -0
- fluxplot/schemas/scene3d.schema.json +461 -0
- fluxplot/seaborn_adapters.py +323 -0
- fluxplot/signature_fluxplots/__init__.py +18 -0
- fluxplot/signature_fluxplots/_colour.py +412 -0
- fluxplot/signature_fluxplots/fluxbox.py +433 -0
- fluxplot/signature_fluxplots/glowbar.py +769 -0
- fluxplot/signature_fluxplots/hexmatrix.py +927 -0
- fluxplot/stats/__init__.py +63 -0
- fluxplot/stats/_common.py +196 -0
- fluxplot/stats/multi_group.py +443 -0
- fluxplot/stats/paired.py +209 -0
- fluxplot/stats/two_group.py +149 -0
- fluxplot/style.py +469 -0
- fluxplot/surface.py +487 -0
- fluxplot/surface3d.py +197 -0
- fluxplot/tagger.py +561 -0
- fluxplot/version.py +19 -0
- fluxplot-0.1.0.dist-info/METADATA +1199 -0
- fluxplot-0.1.0.dist-info/RECORD +65 -0
- fluxplot-0.1.0.dist-info/WHEEL +4 -0
- fluxplot-0.1.0.dist-info/licenses/LICENSE +21 -0
- fluxplot-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +472 -0
fluxplot/recipe.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Assemble the ``*.recipe.json`` provenance sidecar (spec §11.3, v0 "enough to re-run here").
|
|
2
|
+
|
|
3
|
+
The recipe is the **provenance surface**, deliberately separate from the deterministic manifest:
|
|
4
|
+
timestamps and content hashes (which vary run-to-run) live here, so the SVG + manifest stay
|
|
5
|
+
byte-stable for morph/diff while provenance stays honest.
|
|
6
|
+
|
|
7
|
+
When the producing ``script`` is known, the recipe also carries a small **re-run block**
|
|
8
|
+
(``command``/``args``/``cwd``/``output``) so Flux's ``rerun-plot`` (and the in-app *Regenerate*
|
|
9
|
+
button) can reproduce the plot. Those paths are written **relative** so the recipe survives the
|
|
10
|
+
project being moved or copied as a whole; only the interpreter (``command``) is absolute, and it is
|
|
11
|
+
overridable via ``recipe["command"]``.
|
|
12
|
+
|
|
13
|
+
The script no longer has to be recorded by hand: when the caller does not pass one,
|
|
14
|
+
:mod:`fluxplot.provenance` discovers it from the running interpreter (safe deterministic rules
|
|
15
|
+
only — see plan §1). Explicit fields always win; ``recipe=False`` suppresses discovery entirely
|
|
16
|
+
for notebooks, generated figures and privacy-sensitive callers. The recipe then says *how* the
|
|
17
|
+
script was determined (``provenance.scriptDiscovery``: automatic / explicit / unavailable) and
|
|
18
|
+
never claims to know inputs or parameters it cannot know — there is no automatic input discovery.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
|
|
27
|
+
from . import provenance as _provenance
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _now_iso() -> str:
|
|
31
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _script_block(script) -> dict | None:
|
|
35
|
+
if script is None:
|
|
36
|
+
return None
|
|
37
|
+
if isinstance(script, dict):
|
|
38
|
+
return script
|
|
39
|
+
return {"path": str(script)}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _hash_input(inp, base_dir):
|
|
43
|
+
path = inp if isinstance(inp, str) else inp.get("path")
|
|
44
|
+
role = "data" if isinstance(inp, str) else inp.get("role", "data")
|
|
45
|
+
entry = {"role": role, "path": path}
|
|
46
|
+
full = path if base_dir is None else os.path.join(base_dir, path)
|
|
47
|
+
try:
|
|
48
|
+
with open(full, "rb") as f:
|
|
49
|
+
digest, size = hashlib.sha256(), 0
|
|
50
|
+
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
|
51
|
+
digest.update(chunk)
|
|
52
|
+
size += len(chunk)
|
|
53
|
+
entry["sha256"] = digest.hexdigest()
|
|
54
|
+
entry["bytes"] = size
|
|
55
|
+
except OSError:
|
|
56
|
+
entry["sha256"] = None
|
|
57
|
+
return entry
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_recipe(
|
|
61
|
+
recipe: dict | bool | None,
|
|
62
|
+
*,
|
|
63
|
+
plot_name: str,
|
|
64
|
+
svg_filename: str | None = None,
|
|
65
|
+
glb_filename: str | None = None,
|
|
66
|
+
manifest_filename: str,
|
|
67
|
+
spec_version: str,
|
|
68
|
+
base_dir: str | None = None,
|
|
69
|
+
recipe_dir: str | None = None,
|
|
70
|
+
now: str | None = None,
|
|
71
|
+
) -> dict:
|
|
72
|
+
if (svg_filename is None) == (glb_filename is None):
|
|
73
|
+
raise ValueError("provide exactly one svg_filename or glb_filename")
|
|
74
|
+
output_name = glb_filename if glb_filename is not None else svg_filename
|
|
75
|
+
# recipe semantics (plan §1): None → automatic provenance; False → explicitly suppress it
|
|
76
|
+
# (still writes a valid, non-rerunnable recipe); a dict → explicit fields win, an inferred
|
|
77
|
+
# script only fills a *missing* script. Inputs are never discovered automatically.
|
|
78
|
+
suppress = recipe is False
|
|
79
|
+
recipe = {} if recipe in (None, False) else dict(recipe)
|
|
80
|
+
|
|
81
|
+
script = _script_block(recipe.get("script"))
|
|
82
|
+
notebook = None
|
|
83
|
+
if script is not None:
|
|
84
|
+
discovery = "explicit"
|
|
85
|
+
elif recipe.get("notebook"):
|
|
86
|
+
# a notebook cell made the plot: recorded (path, cell, hash), not rerunnable by Flux yet
|
|
87
|
+
notebook = _provenance.notebook_block(recipe["notebook"], recipe.get("cell"))
|
|
88
|
+
discovery = "notebook"
|
|
89
|
+
elif not suppress:
|
|
90
|
+
found = _provenance.discover_script()
|
|
91
|
+
if found:
|
|
92
|
+
script = {"path": found}
|
|
93
|
+
discovery = "automatic"
|
|
94
|
+
else:
|
|
95
|
+
nb = _provenance.discover_notebook()
|
|
96
|
+
if nb:
|
|
97
|
+
notebook = _provenance.notebook_block(nb, recipe.get("cell"))
|
|
98
|
+
discovery = "notebook"
|
|
99
|
+
else:
|
|
100
|
+
discovery = "unavailable"
|
|
101
|
+
else:
|
|
102
|
+
discovery = "suppressed"
|
|
103
|
+
|
|
104
|
+
inputs = recipe.get("inputs", []) or []
|
|
105
|
+
out = {
|
|
106
|
+
"spec": "fluxplot/recipe",
|
|
107
|
+
"schemaVersion": spec_version,
|
|
108
|
+
"plot": plot_name,
|
|
109
|
+
"outputs": {("glb" if glb_filename is not None else "svg"): output_name, "manifest": manifest_filename},
|
|
110
|
+
"generatedAt": now or _now_iso(),
|
|
111
|
+
"script": script,
|
|
112
|
+
"params": recipe.get("params", {}),
|
|
113
|
+
"inputs": [_hash_input(i, base_dir) for i in inputs],
|
|
114
|
+
"env": None, # v0: full environment capture deferred (spec §11.3)
|
|
115
|
+
}
|
|
116
|
+
if notebook is not None:
|
|
117
|
+
out["notebook"] = notebook
|
|
118
|
+
if not suppress:
|
|
119
|
+
out["provenance"] = _provenance.build_provenance(
|
|
120
|
+
script.get("path") if script else (notebook["path"] if notebook else None), discovery
|
|
121
|
+
)
|
|
122
|
+
if notebook is not None and notebook["sha256"] is None:
|
|
123
|
+
out["provenance"].pop("scriptSha256", None)
|
|
124
|
+
|
|
125
|
+
# Re-run block — what flux-core's runRecipe needs to reproduce the plot. It resolves
|
|
126
|
+
# `cwd` and `output` against the recipe's OWN directory, runs `command args` (appending
|
|
127
|
+
# params as `--key value` and exporting them as $FLUX_PARAMS), so the script re-emits the
|
|
128
|
+
# SVG in place. Emitted only when we know which script produced the plot. Paths are written
|
|
129
|
+
# relative (to the recipe dir / the run cwd) for portability; the interpreter is absolute
|
|
130
|
+
# and overridable via recipe["command"].
|
|
131
|
+
script = out["script"]
|
|
132
|
+
if script and script.get("path") and recipe_dir is not None:
|
|
133
|
+
cwd_now = os.path.abspath(os.path.join(recipe_dir, recipe["cwd"])) if recipe.get("cwd") else os.getcwd()
|
|
134
|
+
script_abs = os.path.abspath(script["path"])
|
|
135
|
+
svg_abs = os.path.join(recipe_dir, output_name)
|
|
136
|
+
out["command"] = recipe.get("command") or sys.executable or "python"
|
|
137
|
+
out["args"] = list(recipe["args"]) if "args" in recipe else [os.path.relpath(script_abs, cwd_now)]
|
|
138
|
+
out["cwd"] = os.path.relpath(cwd_now, recipe_dir)
|
|
139
|
+
out["output"] = recipe.get("output", os.path.relpath(svg_abs, recipe_dir))
|
|
140
|
+
elif recipe.get("command"): # explicit command without a script — pass through (back-compat)
|
|
141
|
+
out["command"] = recipe["command"]
|
|
142
|
+
for key in ("args", "cwd", "output"):
|
|
143
|
+
if key in recipe:
|
|
144
|
+
out[key] = recipe[key]
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def params(defaults: dict | None = None) -> dict:
|
|
149
|
+
"""Merge ``defaults`` with any ``FLUX_PARAMS`` (JSON) provided in the environment.
|
|
150
|
+
|
|
151
|
+
Read tunables through this so ``flux rerun-plot <recipe> --key value`` (and the in-app
|
|
152
|
+
*Regenerate* button, which set ``$FLUX_PARAMS``) can re-run the script with overrides::
|
|
153
|
+
|
|
154
|
+
import fluxplot as fp
|
|
155
|
+
p = fp.params({"test": "t-test", "smooth": False})
|
|
156
|
+
if p["test"] == "mann-whitney":
|
|
157
|
+
...
|
|
158
|
+
"""
|
|
159
|
+
import json as _json
|
|
160
|
+
|
|
161
|
+
out = dict(defaults or {})
|
|
162
|
+
raw = os.environ.get("FLUX_PARAMS")
|
|
163
|
+
if raw:
|
|
164
|
+
try:
|
|
165
|
+
out.update(_json.loads(raw))
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
168
|
+
# `flux rerun-plot --__fluxplot__ '{…}'` hands the colour controls over as one JSON string;
|
|
169
|
+
# the block is always a mapping to the helpers.
|
|
170
|
+
controls = out.get("__fluxplot__")
|
|
171
|
+
if isinstance(controls, str):
|
|
172
|
+
try:
|
|
173
|
+
out["__fluxplot__"] = _json.loads(controls)
|
|
174
|
+
except ValueError:
|
|
175
|
+
raise ValueError("FLUX_PARAMS __fluxplot__ is not valid JSON") from None
|
|
176
|
+
if not isinstance(out["__fluxplot__"], dict):
|
|
177
|
+
raise ValueError("FLUX_PARAMS __fluxplot__ must be a JSON object keyed by colour-control key")
|
|
178
|
+
return out
|
fluxplot/render.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Deterministic SVG rendering (P5). Where byte-stability is won or lost.
|
|
2
|
+
|
|
3
|
+
See ``NOTES_matplotlib_svg.md`` for the verified determinism knobs.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import io
|
|
8
|
+
|
|
9
|
+
import matplotlib.pyplot as plt
|
|
10
|
+
|
|
11
|
+
# rcParams that make matplotlib's SVG output deterministic + editable/addressable.
|
|
12
|
+
DETERMINISTIC_RCPARAMS = {
|
|
13
|
+
# keep text as real <text> referencing fonts by name → editable, restyleable, addressable,
|
|
14
|
+
# and font-version-independent (the 'path' default outlines glyphs into nondeterministic d's).
|
|
15
|
+
"svg.fonttype": "none",
|
|
16
|
+
"savefig.bbox": None,
|
|
17
|
+
# path simplification is deterministic given a pinned threshold; pin both explicitly.
|
|
18
|
+
"path.simplify": True,
|
|
19
|
+
"path.simplify_threshold": 0.111111,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def render_svg(fig, hashsalt: str, dpi=None) -> bytes:
|
|
24
|
+
"""Render ``fig`` to SVG bytes deterministically.
|
|
25
|
+
|
|
26
|
+
``hashsalt`` fixes matplotlib's hashed ids (clip-paths, marker glyphs, ``<defs>``); without it
|
|
27
|
+
they are salted with a fresh uuid4 each run. ``metadata={'Date': None}`` drops the timestamp.
|
|
28
|
+
We never pass ``bbox_inches='tight'`` — it would crop/shift the viewBox *after* coordinate
|
|
29
|
+
anchors were captured, invalidating the data↔pixel mapping.
|
|
30
|
+
|
|
31
|
+
``dpi`` sets the resolution of **rasterized artists only** (see ``raster.py``). SVG user
|
|
32
|
+
space is points, so vector geometry is dpi-invariant — verified in
|
|
33
|
+
``NOTES_matplotlib_svg.md`` §6 and pinned by ``tests/test_rasterize.py``. Pass it only
|
|
34
|
+
when something is actually rasterized, so ordinary saves keep matplotlib's default.
|
|
35
|
+
"""
|
|
36
|
+
rc = dict(DETERMINISTIC_RCPARAMS)
|
|
37
|
+
rc["svg.hashsalt"] = hashsalt
|
|
38
|
+
buf = io.BytesIO()
|
|
39
|
+
extra = {"dpi": dpi} if dpi else {}
|
|
40
|
+
with plt.rc_context(rc):
|
|
41
|
+
fig.savefig(buf, format="svg", metadata={"Date": None}, bbox_inches=None, **extra)
|
|
42
|
+
return buf.getvalue()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
from contextlib import contextmanager
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@contextmanager
|
|
49
|
+
def final_layout(fig):
|
|
50
|
+
"""Lay out with the SVG renderer, then freeze it through capture and export.
|
|
51
|
+
|
|
52
|
+
Agg and SVG have different font metrics. Public draw_without_rendering avoids
|
|
53
|
+
rasterizing data just to obtain the correct text/layout metrics.
|
|
54
|
+
"""
|
|
55
|
+
from matplotlib.backends.backend_svg import FigureCanvasSVG
|
|
56
|
+
canvas, dpi, engine = fig.canvas, fig.dpi, fig.get_layout_engine()
|
|
57
|
+
try:
|
|
58
|
+
FigureCanvasSVG(fig)
|
|
59
|
+
fig.set_dpi(72)
|
|
60
|
+
fig.draw_without_rendering()
|
|
61
|
+
fig.set_layout_engine('none')
|
|
62
|
+
yield
|
|
63
|
+
finally:
|
|
64
|
+
fig.set_layout_engine(engine)
|
|
65
|
+
fig.set_dpi(dpi)
|
|
66
|
+
fig.set_canvas(canvas)
|
fluxplot/roles.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Role vocabulary revision 1 (spec §5, §10). A versioned core + a namespaced ``x-`` extension mechanism.
|
|
2
|
+
|
|
3
|
+
Unknown roles are NOT rejected — they degrade gracefully (P4): they still get a stable id and a
|
|
4
|
+
``data-role`` and are listed in the manifest; consumers that don't recognize them treat them as
|
|
5
|
+
opaque addressable groups.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
ROLE_VERSION = 1 # Local vocabulary revision; not a serialized schema version.
|
|
10
|
+
|
|
11
|
+
CORE_ROLES = frozenset(
|
|
12
|
+
{
|
|
13
|
+
# containers
|
|
14
|
+
"figure",
|
|
15
|
+
"panel",
|
|
16
|
+
"plot-area",
|
|
17
|
+
"legend",
|
|
18
|
+
"colorbar",
|
|
19
|
+
"title",
|
|
20
|
+
"subtitle",
|
|
21
|
+
# scaffold / guides
|
|
22
|
+
"axis",
|
|
23
|
+
"spine",
|
|
24
|
+
"tick",
|
|
25
|
+
"tick-label",
|
|
26
|
+
"axis-title",
|
|
27
|
+
"gridline",
|
|
28
|
+
"background",
|
|
29
|
+
# data marks (geoms)
|
|
30
|
+
"series",
|
|
31
|
+
"mesh", "pane", "scalebar",
|
|
32
|
+
"image",
|
|
33
|
+
"line",
|
|
34
|
+
"point",
|
|
35
|
+
"bar",
|
|
36
|
+
"area",
|
|
37
|
+
"errorbar",
|
|
38
|
+
"box",
|
|
39
|
+
"violin",
|
|
40
|
+
"contour",
|
|
41
|
+
# composite sub-parts (box / violin / errorbar internals)
|
|
42
|
+
"whisker",
|
|
43
|
+
"cap",
|
|
44
|
+
"flier",
|
|
45
|
+
"median",
|
|
46
|
+
"mean",
|
|
47
|
+
"segment",
|
|
48
|
+
# overlays
|
|
49
|
+
"annotation",
|
|
50
|
+
"reference-line",
|
|
51
|
+
"highlight-region",
|
|
52
|
+
"significance-bracket",
|
|
53
|
+
"label",
|
|
54
|
+
"caption",
|
|
55
|
+
# untagged user-drawn artists swept into addressable "extra" content
|
|
56
|
+
"extra",
|
|
57
|
+
# legend internals
|
|
58
|
+
"legend-entry",
|
|
59
|
+
"legend-swatch",
|
|
60
|
+
"legend-label",
|
|
61
|
+
# a manifest-only container that groups sibling parts (e.g. "all x tick labels")
|
|
62
|
+
"group",
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Data-kind hints (text | line | shape | container) per core role — authored truth for
|
|
68
|
+
# consumers (Flux's part editors pick the property set by kind: a tick-label edits like a
|
|
69
|
+
# text object, a gridline like a line, …). Deliberate omission: "extra" is heterogeneous
|
|
70
|
+
# (a swept artist can be a line, a collection or a patch), so its kind is inferred from
|
|
71
|
+
# the concrete artist at sweep time (see ``descriptors.artist_kind``). Unknown / ``x-``
|
|
72
|
+
# roles are likewise inferred per-artist where possible, else the hint is omitted.
|
|
73
|
+
KIND_BY_ROLE = {
|
|
74
|
+
"mesh": "shape", "pane": "shape", "scalebar": "line",
|
|
75
|
+
# containers
|
|
76
|
+
"figure": "container",
|
|
77
|
+
"panel": "container",
|
|
78
|
+
"plot-area": "container",
|
|
79
|
+
"legend": "container",
|
|
80
|
+
"colorbar": "container",
|
|
81
|
+
"axis": "container",
|
|
82
|
+
"series": "container",
|
|
83
|
+
"group": "container",
|
|
84
|
+
"legend-entry": "container",
|
|
85
|
+
# text
|
|
86
|
+
"title": "text",
|
|
87
|
+
"subtitle": "text",
|
|
88
|
+
"tick-label": "text",
|
|
89
|
+
"axis-title": "text",
|
|
90
|
+
"legend-label": "text",
|
|
91
|
+
"annotation": "text",
|
|
92
|
+
"label": "text",
|
|
93
|
+
"caption": "text",
|
|
94
|
+
# line
|
|
95
|
+
"line": "line",
|
|
96
|
+
"spine": "line",
|
|
97
|
+
"tick": "line",
|
|
98
|
+
"gridline": "line",
|
|
99
|
+
"reference-line": "line",
|
|
100
|
+
"errorbar": "line",
|
|
101
|
+
"whisker": "line",
|
|
102
|
+
"cap": "line",
|
|
103
|
+
"median": "line",
|
|
104
|
+
"mean": "line",
|
|
105
|
+
"segment": "line",
|
|
106
|
+
"significance-bracket": "line",
|
|
107
|
+
# shape
|
|
108
|
+
"image": "shape",
|
|
109
|
+
"area": "shape",
|
|
110
|
+
"bar": "shape",
|
|
111
|
+
"point": "shape",
|
|
112
|
+
"box": "shape",
|
|
113
|
+
"violin": "shape",
|
|
114
|
+
"contour": "shape",
|
|
115
|
+
"flier": "shape",
|
|
116
|
+
"background": "shape",
|
|
117
|
+
"highlight-region": "shape",
|
|
118
|
+
"legend-swatch": "shape",
|
|
119
|
+
# members of colour-mapped fields: a heatmap cell, a contour band / level path, a hexagon
|
|
120
|
+
"cell": "shape",
|
|
121
|
+
"contour-level": "shape",
|
|
122
|
+
"x-hex": "shape",
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def kind_for_role(role: str):
|
|
127
|
+
"""The data-kind hint for a role, or ``None`` when the role carries no static kind."""
|
|
128
|
+
return KIND_BY_ROLE.get(role)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def is_core(role: str) -> bool:
|
|
132
|
+
return role in CORE_ROLES
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def is_extension(role: str) -> bool:
|
|
136
|
+
return role.startswith("x-")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def validate(role: str) -> str:
|
|
140
|
+
"""Return the role unchanged if it is a recognized core role or a well-formed ``x-`` extension.
|
|
141
|
+
|
|
142
|
+
Unknown bare roles are allowed (graceful degradation) but normalized into the ``x-`` namespace
|
|
143
|
+
so the core vocabulary stays closed and self-describing.
|
|
144
|
+
"""
|
|
145
|
+
if is_core(role) or is_extension(role):
|
|
146
|
+
return role
|
|
147
|
+
return f"x-{role}"
|