figurestead 0.9.0a1__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.
Files changed (42) hide show
  1. figurestead-0.9.0a1/LICENSE +21 -0
  2. figurestead-0.9.0a1/PKG-INFO +8 -0
  3. figurestead-0.9.0a1/README.md +10 -0
  4. figurestead-0.9.0a1/THIRD_PARTY_NOTICES.md +39 -0
  5. figurestead-0.9.0a1/TRADEMARKS.md +13 -0
  6. figurestead-0.9.0a1/pyproject.toml +32 -0
  7. figurestead-0.9.0a1/src/figurestead/__init__.py +78 -0
  8. figurestead-0.9.0a1/src/figurestead/adapters/__init__.py +1 -0
  9. figurestead-0.9.0a1/src/figurestead/adapters/tabular.py +399 -0
  10. figurestead-0.9.0a1/src/figurestead/application.py +63 -0
  11. figurestead-0.9.0a1/src/figurestead/core.py +167 -0
  12. figurestead-0.9.0a1/src/figurestead/extensions/__init__.py +1 -0
  13. figurestead-0.9.0a1/src/figurestead/extensions/categorical/__init__.py +13 -0
  14. figurestead-0.9.0a1/src/figurestead/extensions/categorical/contracts.py +88 -0
  15. figurestead-0.9.0a1/src/figurestead/extensions/categorical/renderers.py +125 -0
  16. figurestead-0.9.0a1/src/figurestead/extensions/categorical/validation.py +110 -0
  17. figurestead-0.9.0a1/src/figurestead/extensions/interval/__init__.py +23 -0
  18. figurestead-0.9.0a1/src/figurestead/extensions/interval/renderer.py +276 -0
  19. figurestead-0.9.0a1/src/figurestead/extensions/matrix/__init__.py +5 -0
  20. figurestead-0.9.0a1/src/figurestead/extensions/matrix/renderer.py +188 -0
  21. figurestead-0.9.0a1/src/figurestead/extensions/paired_reference/__init__.py +300 -0
  22. figurestead-0.9.0a1/src/figurestead/extensions/temporal/__init__.py +14 -0
  23. figurestead-0.9.0a1/src/figurestead/extensions/temporal/contracts.py +56 -0
  24. figurestead-0.9.0a1/src/figurestead/extensions/temporal/plots.py +95 -0
  25. figurestead-0.9.0a1/src/figurestead/extensions/temporal/validation.py +115 -0
  26. figurestead-0.9.0a1/src/figurestead/gallery.py +116 -0
  27. figurestead-0.9.0a1/src/figurestead/motion.py +426 -0
  28. figurestead-0.9.0a1/src/figurestead/motion_gallery.py +77 -0
  29. figurestead-0.9.0a1/src/figurestead/plots.py +175 -0
  30. figurestead-0.9.0a1/src/figurestead/portable.py +398 -0
  31. figurestead-0.9.0a1/src/figurestead/presentation.py +139 -0
  32. figurestead-0.9.0a1/src/figurestead/profiles.py +91 -0
  33. figurestead-0.9.0a1/src/figurestead/registry.py +39 -0
  34. figurestead-0.9.0a1/src/figurestead/scene.py +63 -0
  35. figurestead-0.9.0a1/src/figurestead/theme_io.py +490 -0
  36. figurestead-0.9.0a1/src/figurestead/themes/deep_observatory_sage_core.json +28 -0
  37. figurestead-0.9.0a1/src/figurestead/themes/lavender_fog_notebook.json +29 -0
  38. figurestead-0.9.0a1/src/figurestead/themes/midnight_transit_signal_slate.json +28 -0
  39. figurestead-0.9.0a1/src/figurestead/themes/registration_ink.json +28 -0
  40. figurestead-0.9.0a1/src/figurestead/themes/slipware.json +28 -0
  41. figurestead-0.9.0a1/src/figurestead/themes/ultraviolet_laboratory.json +29 -0
  42. figurestead-0.9.0a1/src/figurestead/themes.py +129 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Figurestead contributors
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,8 @@
1
+ Metadata-Version: 2.3
2
+ Name: figurestead
3
+ Version: 0.9.0a1
4
+ License: MIT
5
+ Requires-Python: >=3.10
6
+ Project-URL: Homepage, https://github.com/CharlesMish/figurestead
7
+ Project-URL: Repository, https://github.com/CharlesMish/figurestead
8
+ Project-URL: Issues, https://github.com/CharlesMish/figurestead/issues
@@ -0,0 +1,10 @@
1
+ # Figurestead experimental public alpha
2
+
3
+ Figurestead is a framework-free scientific figure system for deterministic,
4
+ inspectable Python and browser output. This experimental alpha contains six
5
+ curated themes and uses Slipware as the default. Presentation profiles are
6
+ independent from themes. The `monograph` profile is available through
7
+ `get_profile("monograph")` and remains unavailable through `get_theme`.
8
+
9
+ Python package: `figurestead` 0.9.0a1. Browser package: `@figurestead/web`
10
+ 0.9.0-alpha.1. Source: <https://github.com/CharlesMish/figurestead>.
@@ -0,0 +1,39 @@
1
+ # Third-party factual notice inventory
2
+
3
+ Status: `EXPERIMENTAL PUBLIC-ALPHA FACTUAL NOTICE`
4
+
5
+ This inventory records the dependency evidence used for the Figurestead
6
+ experimental alpha; it makes no legal conclusion. Runtime dependencies are not
7
+ vendored into the wheel, sdist, or npm tarball. The canonical Figurestead MIT
8
+ text is supplied separately as `LICENSE`.
9
+
10
+ ## Python dependency observations
11
+
12
+ An external Python 3.14.6 evidence environment resolved the current declared
13
+ constraints on 2026-08-05 to Matplotlib 3.11.1, NumPy 2.5.1, Pillow 12.3.0,
14
+ PyYAML 6.0.3, and setuptools 82.0.1, plus contourpy 1.3.3, cycler 0.12.1,
15
+ fonttools 4.63.0, kiwisolver 1.5.0, packaging 26.3, pyparsing 3.3.2,
16
+ python-dateutil 2.9.0.post0, and six 1.17.0. These versions are an evidence
17
+ snapshot, not an approved runtime lock.
18
+
19
+ The static-site PNGs are first-party deterministic synthetic Figurestead render
20
+ outputs produced through Matplotlib. No font file is embedded as a separate
21
+ site member. Matplotlib's evidence distribution includes its own license plus
22
+ DejaVu and STIX font notices; rendered glyph provenance remains a review
23
+ consideration. Site CSS names
24
+ only system font families and carries no downloaded font bytes or remote URL.
25
+
26
+ ## Browser tooling observations
27
+
28
+ The alpha verification environment used Playwright 1.61.1 and
29
+ playwright-core 1.61.1 as
30
+ Apache-2.0 development/test tooling and optional fsevents 2.3.2 as MIT. These
31
+ packages and downloaded browsers are not intended npm-tarball members or
32
+ top-level archive members. They remain test-tool provenance only.
33
+
34
+ ## Placement and maintenance
35
+
36
+ Each Figurestead wheel, sdist, npm package, static-site bundle, and composition
37
+ archive carries this file and `TRADEMARKS.md` beside its canonical `LICENSE`.
38
+ Any newly resolved dependency version, copied source, font, image, or other
39
+ byte requires a corresponding inventory update and review.
@@ -0,0 +1,13 @@
1
+ # Trademarks and names
2
+
3
+ Figurestead is an independent project.
4
+
5
+ Names of third-party products, services, organizations, and trademarks remain
6
+ the property of their respective owners. Their appearance in factual dependency
7
+ or compatibility notices does not imply affiliation, sponsorship, or
8
+ endorsement.
9
+
10
+ The experimental public-alpha distribution is intentionally limited to six
11
+ curated Figurestead themes.
12
+ Legacy or franchise-derived internal themes are not part of the public
13
+ distribution.
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "figurestead"
7
+ version = "0.9.0a1"
8
+ description = "A framework-free scientific figure system for deterministic, inspectable Python and browser output."
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.10"
12
+ dependencies = ["matplotlib>=3.7", "numpy>=1.24", "Pillow>=10"]
13
+
14
+ [project.urls]
15
+ Homepage = "https://github.com/CharlesMish/figurestead"
16
+ Repository = "https://github.com/CharlesMish/figurestead"
17
+ Issues = "https://github.com/CharlesMish/figurestead/issues"
18
+
19
+ [project.optional-dependencies]
20
+ themes = ["PyYAML>=6"]
21
+
22
+ [tool.setuptools.packages.find]
23
+ where = ["src"]
24
+
25
+ [tool.setuptools.package-data]
26
+ figurestead = ["themes/*.json"]
27
+
28
+ [project.scripts]
29
+ figurestead-gallery = "figurestead.gallery:main"
30
+ figurestead-motion-gallery = "figurestead.motion_gallery:main"
31
+ figurestead-portable = "figurestead.portable:main"
32
+ figurestead-theme = "figurestead.theme_io:main"
@@ -0,0 +1,78 @@
1
+ """figurestead public API."""
2
+
3
+ from .core import PlotSpec
4
+ from .plots import heatmap, histogram, line, scatter, strip_summary
5
+ from .motion import MotionStyle, MotionTimeline, animate_line, animate_scatter, animate_strip_summary
6
+ from .portable import PORTABLE_SCHEMA_VERSION, RENDERER_API_VERSION, export_contract, export_figure, portable_schema
7
+ from .profiles import PROFILES, Profile, get_profile
8
+ from .registry import PLOTS, available_plots, render
9
+ from .themes import THEMES, Theme, get_theme
10
+ from .theme_io import (
11
+ THEME_PACK_VERSION, PALETTE_PACK_VERSION, ThemePack, PalettePack, PaletteResolution, ThemePackError, apply_theme, compile_theme_pack,
12
+ contrast_audit, load_authored_theme_pack, load_compiled_theme_pack, load_theme, load_theme_pack,
13
+ normalize_theme_pack_lenient, preview_theme_pack, theme_to_contract,
14
+ load_palette_pack, resolve_palette,
15
+ )
16
+ from .presentation import EvidenceFocusAnnotation, FocusAnnotation, SCIENTIFIC_POSE, ScientificPose, apply_evidence_pose, apply_scientific_pose, monotone_curve
17
+ from .application import APPLICATION_PROFILE_VERSION, APPLICATION_PROFILES, ApplicationProfile, apply_application_profile, get_application_profile
18
+ from .scene import TERMINAL_SCENE_VERSION, compile_terminal_scene
19
+
20
+ __all__ = [
21
+ "PLOTS",
22
+ "PROFILES",
23
+ "THEMES",
24
+ "PlotSpec",
25
+ "MotionStyle",
26
+ "MotionTimeline",
27
+ "PORTABLE_SCHEMA_VERSION",
28
+ "RENDERER_API_VERSION",
29
+ "Profile",
30
+ "Theme",
31
+ "ThemePack",
32
+ "ThemePackError",
33
+ "FocusAnnotation",
34
+ "EvidenceFocusAnnotation",
35
+ "ScientificPose",
36
+ "SCIENTIFIC_POSE",
37
+ "APPLICATION_PROFILE_VERSION",
38
+ "APPLICATION_PROFILES",
39
+ "ApplicationProfile",
40
+ "TERMINAL_SCENE_VERSION",
41
+ "apply_application_profile",
42
+ "get_application_profile",
43
+ "compile_terminal_scene",
44
+ "apply_scientific_pose",
45
+ "apply_evidence_pose",
46
+ "THEME_PACK_VERSION",
47
+ "PALETTE_PACK_VERSION",
48
+ "PalettePack",
49
+ "PaletteResolution",
50
+ "available_plots",
51
+ "animate_line",
52
+ "animate_scatter",
53
+ "animate_strip_summary",
54
+ "export_contract",
55
+ "export_figure",
56
+ "get_profile",
57
+ "get_theme",
58
+ "load_theme",
59
+ "load_theme_pack",
60
+ "load_authored_theme_pack",
61
+ "load_compiled_theme_pack",
62
+ "normalize_theme_pack_lenient",
63
+ "load_palette_pack",
64
+ "resolve_palette",
65
+ "compile_theme_pack",
66
+ "apply_theme",
67
+ "contrast_audit",
68
+ "preview_theme_pack",
69
+ "theme_to_contract",
70
+ "heatmap",
71
+ "histogram",
72
+ "line",
73
+ "monotone_curve",
74
+ "portable_schema",
75
+ "render",
76
+ "scatter",
77
+ "strip_summary",
78
+ ]
@@ -0,0 +1 @@
1
+ """Strict, additive adapters for Figurestead portable contracts."""
@@ -0,0 +1,399 @@
1
+ """Strict records and CSV ingress for existing portable contracts.
2
+
3
+ This module deliberately performs no inference, coercion, aggregation, sorting,
4
+ interpolation, or imputation. Validation completes before ``export_contract``
5
+ is called so rejected input cannot produce partial Figurestead evidence.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping, Sequence
11
+ import csv
12
+ import math
13
+ import os
14
+ from pathlib import Path
15
+ import re
16
+ from typing import Any
17
+
18
+ from figurestead import portable
19
+
20
+
21
+ _CSV_NUMBER = re.compile(r"^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$")
22
+
23
+
24
+ class TabularIngressError(ValueError):
25
+ """Raised when tabular input cannot be converted without ambiguity."""
26
+
27
+
28
+ def _error(path: str, message: str) -> TabularIngressError:
29
+ return TabularIngressError(f"{path}: {message}")
30
+
31
+
32
+ def _column_name(value: Any, argument: str) -> str:
33
+ if not isinstance(value, str) or not value.strip():
34
+ raise _error(argument, "must be a nonblank column-name string")
35
+ return value
36
+
37
+
38
+ def _validate_dataframe_columns(source: Any) -> None:
39
+ try:
40
+ columns = source.columns
41
+ except AttributeError:
42
+ return
43
+ except Exception as exc:
44
+ raise _error("header", f"DataFrame-like column metadata could not be read: {exc}") from exc
45
+ if isinstance(columns, (str, bytes, bytearray)):
46
+ raise _error("header", "DataFrame-like columns must be an iterable of unique labels")
47
+ try:
48
+ labels = list(columns)
49
+ except TypeError as exc:
50
+ raise _error("header", "DataFrame-like columns must be an iterable of unique labels") from exc
51
+ seen: set[Any] = set()
52
+ for index, label in enumerate(labels):
53
+ try:
54
+ duplicate = label in seen
55
+ seen.add(label)
56
+ except TypeError as exc:
57
+ raise _error(f"header[{index}]", "DataFrame-like column labels must be hashable") from exc
58
+ if duplicate:
59
+ path = f"header.{label}" if isinstance(label, str) and label else f"header[{index}]"
60
+ raise _error(path, "duplicate column name")
61
+
62
+
63
+ def _record_rows(source: Any, required: set[str]) -> list[dict[str, Any]]:
64
+ if isinstance(source, (str, bytes, bytearray, Mapping)):
65
+ raise _error("source", "must be a non-string sequence of mappings or DataFrame-like object")
66
+ if hasattr(source, "to_dict"):
67
+ _validate_dataframe_columns(source)
68
+ try:
69
+ source = source.to_dict(orient="records")
70
+ except Exception as exc: # the foreign object owns the concrete failure
71
+ raise _error("source", f"to_dict(orient='records') failed: {exc}") from exc
72
+ if not isinstance(source, Sequence) or isinstance(source, (str, bytes, bytearray)):
73
+ raise _error("source", "must be a non-string sequence of mappings or DataFrame-like object")
74
+ rows: list[dict[str, Any]] = []
75
+ for index, row in enumerate(source, 1):
76
+ if not isinstance(row, Mapping):
77
+ raise _error(f"row {index}", "must be a mapping")
78
+ copied = dict(row)
79
+ missing = sorted(required - copied.keys())
80
+ if missing:
81
+ raise _error(f"row {index}.{missing[0]}", "required column is missing")
82
+ rows.append(copied)
83
+ if not rows:
84
+ raise _error("source", "must contain at least one data row")
85
+ return rows
86
+
87
+
88
+ def _reject_url(value: str) -> None:
89
+ if re.match(r"^[A-Za-z][A-Za-z0-9+.-]*://", value):
90
+ raise _error("source", "network URLs are not accepted")
91
+
92
+
93
+ def _csv_text(source: Any) -> str:
94
+ if isinstance(source, str):
95
+ _reject_url(source)
96
+ if "\n" in source or "\r" in source:
97
+ raise _error("source", "inline CSV strings are not accepted; use a path or text stream")
98
+ path = Path(source)
99
+ try:
100
+ return path.read_text(encoding="utf-8")
101
+ except (OSError, UnicodeError) as exc:
102
+ raise _error("source", f"CSV path could not be read: {exc}") from exc
103
+ if isinstance(source, os.PathLike):
104
+ path = Path(source)
105
+ try:
106
+ return path.read_text(encoding="utf-8")
107
+ except (OSError, UnicodeError) as exc:
108
+ raise _error("source", f"CSV path could not be read: {exc}") from exc
109
+ if isinstance(source, (bytes, bytearray)):
110
+ raise _error("source", "binary CSV input is not accepted")
111
+ reader = getattr(source, "read", None)
112
+ if not callable(reader):
113
+ raise _error("source", "must be a filesystem path or readable text stream")
114
+ try:
115
+ text = reader()
116
+ except Exception as exc:
117
+ raise _error("source", f"text stream could not be read: {exc}") from exc
118
+ if not isinstance(text, str):
119
+ raise _error("source", "binary streams are not accepted")
120
+ return text
121
+
122
+
123
+ def _csv_rows(source: Any, required: set[str]) -> list[dict[str, str]]:
124
+ text = _csv_text(source)
125
+ try:
126
+ parsed = list(csv.reader(text.splitlines(), strict=True))
127
+ except csv.Error as exc:
128
+ raise _error("source", f"malformed CSV: {exc}") from exc
129
+ if not parsed:
130
+ raise _error("header", "CSV header is required")
131
+ header = parsed[0]
132
+ if not header:
133
+ raise _error("header", "CSV header is required")
134
+ seen: set[str] = set()
135
+ for name in header:
136
+ if not name:
137
+ raise _error("header", "column names must be nonblank")
138
+ if name in seen:
139
+ raise _error(f"header.{name}", "duplicate column name")
140
+ seen.add(name)
141
+ missing = sorted(required - seen)
142
+ if missing:
143
+ raise _error(f"header.{missing[0]}", "required column is missing")
144
+ rows: list[dict[str, str]] = []
145
+ for index, values in enumerate(parsed[1:], 1):
146
+ if len(values) != len(header):
147
+ raise _error(f"row {index}", f"expected {len(header)} columns, found {len(values)}")
148
+ rows.append(dict(zip(header, values, strict=True)))
149
+ if not rows:
150
+ raise _error("source", "must contain at least one data row")
151
+ return rows
152
+
153
+
154
+ def _record_number(row: Mapping[str, Any], row_index: int, column: str) -> float:
155
+ value = row[column]
156
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
157
+ raise _error(f"row {row_index}.{column}", "must be a finite int or float (bool and numeric strings are rejected)")
158
+ return float(value)
159
+
160
+
161
+ def _csv_number(row: Mapping[str, str], row_index: int, column: str) -> float:
162
+ value = row[column]
163
+ if value != value.strip() or not _CSV_NUMBER.fullmatch(value):
164
+ raise _error(f"row {row_index}.{column}", "must exactly match the finite CSV numeric grammar")
165
+ result = float(value)
166
+ if not math.isfinite(result):
167
+ raise _error(f"row {row_index}.{column}", "must parse to a finite number")
168
+ return result
169
+
170
+
171
+ def _category(row: Mapping[str, Any], row_index: int, column: str) -> str:
172
+ value = row[column]
173
+ if not isinstance(value, str) or not value.strip():
174
+ raise _error(f"row {row_index}.{column}", "must be a nonblank string")
175
+ return value
176
+
177
+
178
+ def _numeric_order(value: Any, argument: str) -> list[float]:
179
+ if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)) or not value:
180
+ raise _error(argument, "must be a nonempty sequence of unique finite numbers")
181
+ result: list[float] = []
182
+ for index, item in enumerate(value):
183
+ if isinstance(item, bool) or not isinstance(item, (int, float)) or not math.isfinite(item):
184
+ raise _error(f"{argument}[{index}]", "must be a finite number")
185
+ number = float(item)
186
+ if number in result:
187
+ raise _error(f"{argument}[{index}]", "duplicate order member")
188
+ result.append(number)
189
+ return result
190
+
191
+
192
+ def _string_order(value: Any, argument: str) -> list[str]:
193
+ if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)) or not value:
194
+ raise _error(argument, "must be a nonempty sequence of unique nonblank strings")
195
+ result: list[str] = []
196
+ for index, item in enumerate(value):
197
+ if not isinstance(item, str) or not item.strip():
198
+ raise _error(f"{argument}[{index}]", "must be a nonblank string")
199
+ if item in result:
200
+ raise _error(f"{argument}[{index}]", "duplicate order member")
201
+ result.append(item)
202
+ return result
203
+
204
+
205
+ def _domain(value: Any, argument: str) -> list[float] | None:
206
+ if value is None:
207
+ return None
208
+ if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)) or len(value) != 2:
209
+ raise _error(argument, "must contain exactly two finite increasing numbers")
210
+ result = []
211
+ for index, item in enumerate(value):
212
+ if isinstance(item, bool) or not isinstance(item, (int, float)) or not math.isfinite(item):
213
+ raise _error(f"{argument}[{index}]", "must be a finite number")
214
+ result.append(float(item))
215
+ if result[0] >= result[1]:
216
+ raise _error(argument, "must be strictly increasing")
217
+ return result
218
+
219
+
220
+ def _require_members(observed: list[Any], order: list[Any], argument: str, column: str) -> None:
221
+ unknown = [item for item in observed if item not in order]
222
+ if unknown:
223
+ raise _error(column, f"observed member {unknown[0]!r} is absent from {argument}")
224
+ extras = [item for item in order if item not in observed]
225
+ if extras:
226
+ raise _error(argument, f"member {extras[0]!r} is not observed")
227
+
228
+
229
+ def _common_columns(*values: tuple[Any, str]) -> set[str]:
230
+ return {_column_name(value, argument) for value, argument in values}
231
+
232
+
233
+ def _line(rows: list[Mapping[str, Any]], *, numeric, spec, x: str, series: str,
234
+ value: str, x_order, series_order, x_domain, y_domain, reveal_order,
235
+ theme, profile, timeline, motion, application_profile):
236
+ x_values = _numeric_order(x_order, "x_order")
237
+ series_values = _string_order(series_order, "series_order")
238
+ if reveal_order not in {"random", "x"}:
239
+ raise _error("reveal_order", "must be 'random' or 'x'")
240
+ cells: dict[tuple[float, str], float] = {}
241
+ observed_x: list[float] = []
242
+ observed_series: list[str] = []
243
+ for index, row in enumerate(rows, 1):
244
+ row_x = numeric(row, index, x)
245
+ row_series = _category(row, index, series)
246
+ row_value = numeric(row, index, value)
247
+ if row_x not in observed_x:
248
+ observed_x.append(row_x)
249
+ if row_series not in observed_series:
250
+ observed_series.append(row_series)
251
+ cell = (row_x, row_series)
252
+ if cell in cells:
253
+ raise _error(f"row {index}.{value}", f"duplicate line cell for x={row_x!r}, series={row_series!r}")
254
+ cells[cell] = row_value
255
+ _require_members(observed_x, x_values, "x_order", x)
256
+ _require_members(observed_series, series_values, "series_order", series)
257
+ missing = [(row_x, row_series) for row_x in x_values for row_series in series_values if (row_x, row_series) not in cells]
258
+ if missing:
259
+ raise _error("data", f"incomplete line grid; missing x={missing[0][0]!r}, series={missing[0][1]!r}")
260
+ data: dict[str, Any] = {
261
+ "x": x_values,
262
+ "series": [{"key": key, "label": key, "y": [cells[(row_x, key)] for row_x in x_values]} for key in series_values],
263
+ "revealOrder": reveal_order,
264
+ }
265
+ if (domain := _domain(x_domain, "x_domain")) is not None:
266
+ data["xDomain"] = domain
267
+ if (domain := _domain(y_domain, "y_domain")) is not None:
268
+ data["yDomain"] = domain
269
+ return portable.export_contract(renderer="line", spec=spec, data=data, theme=theme,
270
+ profile=profile, timeline=timeline, motion=motion,
271
+ application_profile=application_profile)
272
+
273
+
274
+ def _scatter(rows: list[Mapping[str, Any]], *, numeric, spec, x: str, y: str,
275
+ series: str | None, x_domain, y_domain, summary, theme, profile,
276
+ timeline, motion, application_profile):
277
+ if series is not None:
278
+ _column_name(series, "series")
279
+ xs, ys, identities = [], [], []
280
+ for index, row in enumerate(rows, 1):
281
+ xs.append(numeric(row, index, x))
282
+ ys.append(numeric(row, index, y))
283
+ identities.append(_category(row, index, series) if series is not None else "series")
284
+ labels = {key: key for key in dict.fromkeys(identities)}
285
+ data: dict[str, Any] = {"x": xs, "y": ys, "series": identities, "seriesLabels": labels}
286
+ if summary is not None:
287
+ data["summary"] = summary
288
+ if (domain := _domain(x_domain, "x_domain")) is not None:
289
+ data["xDomain"] = domain
290
+ if (domain := _domain(y_domain, "y_domain")) is not None:
291
+ data["yDomain"] = domain
292
+ return portable.export_contract(renderer="scatter", spec=spec, data=data, theme=theme,
293
+ profile=profile, timeline=timeline, motion=motion,
294
+ application_profile=application_profile)
295
+
296
+
297
+ def _strip(rows: list[Mapping[str, Any]], *, numeric, spec, value: str, group: str,
298
+ group_order, series: str | None, summary, y_domain, theme, profile,
299
+ timeline, motion, application_profile):
300
+ groups = _string_order(group_order, "group_order")
301
+ if series is not None:
302
+ _column_name(series, "series")
303
+ values, assignments, identities = [], [], []
304
+ for index, row in enumerate(rows, 1):
305
+ values.append(numeric(row, index, value))
306
+ assignments.append(_category(row, index, group))
307
+ identities.append(_category(row, index, series) if series is not None else "series")
308
+ _require_members(list(dict.fromkeys(assignments)), groups, "group_order", group)
309
+ labels = {key: key for key in dict.fromkeys(identities)}
310
+ data: dict[str, Any] = {
311
+ "values": values, "groups": groups, "group": assignments,
312
+ "series": identities, "seriesLabels": labels,
313
+ }
314
+ if summary is not None:
315
+ data["summary"] = summary
316
+ if (domain := _domain(y_domain, "y_domain")) is not None:
317
+ data["yDomain"] = domain
318
+ return portable.export_contract(renderer="strip_summary", spec=spec, data=data,
319
+ theme=theme, profile=profile, timeline=timeline,
320
+ motion=motion, application_profile=application_profile)
321
+
322
+
323
+ def line_from_records(records_or_dataframe_like, *, spec, x, series, value,
324
+ x_order, series_order, x_domain=None, y_domain=None,
325
+ reveal_order="random", theme="slipware",
326
+ profile="deep_scope", timeline=None, motion=None,
327
+ application_profile="atlas"):
328
+ required = _common_columns((x, "x"), (series, "series"), (value, "value"))
329
+ rows = _record_rows(records_or_dataframe_like, required)
330
+ return _line(rows, numeric=_record_number, spec=spec, x=x, series=series,
331
+ value=value, x_order=x_order, series_order=series_order,
332
+ x_domain=x_domain, y_domain=y_domain, reveal_order=reveal_order,
333
+ theme=theme, profile=profile, timeline=timeline, motion=motion,
334
+ application_profile=application_profile)
335
+
336
+
337
+ def line_from_csv(source, *, spec, x, series, value, x_order, series_order,
338
+ x_domain=None, y_domain=None, reveal_order="random",
339
+ theme="slipware", profile="deep_scope", timeline=None,
340
+ motion=None, application_profile="atlas"):
341
+ required = _common_columns((x, "x"), (series, "series"), (value, "value"))
342
+ rows = _csv_rows(source, required)
343
+ return _line(rows, numeric=_csv_number, spec=spec, x=x, series=series,
344
+ value=value, x_order=x_order, series_order=series_order,
345
+ x_domain=x_domain, y_domain=y_domain, reveal_order=reveal_order,
346
+ theme=theme, profile=profile, timeline=timeline, motion=motion,
347
+ application_profile=application_profile)
348
+
349
+
350
+ def scatter_from_records(records_or_dataframe_like, *, spec, x, y, series=None,
351
+ x_domain=None, y_domain=None, summary=None,
352
+ theme="slipware", profile="deep_scope", timeline=None,
353
+ motion=None, application_profile="atlas"):
354
+ required = _common_columns((x, "x"), (y, "y"), *((series, "series"),) if series is not None else ())
355
+ rows = _record_rows(records_or_dataframe_like, required)
356
+ return _scatter(rows, numeric=_record_number, spec=spec, x=x, y=y,
357
+ series=series, x_domain=x_domain, y_domain=y_domain,
358
+ summary=summary, theme=theme, profile=profile,
359
+ timeline=timeline, motion=motion,
360
+ application_profile=application_profile)
361
+
362
+
363
+ def scatter_from_csv(source, *, spec, x, y, series=None, x_domain=None,
364
+ y_domain=None, summary=None, theme="slipware",
365
+ profile="deep_scope", timeline=None, motion=None,
366
+ application_profile="atlas"):
367
+ required = _common_columns((x, "x"), (y, "y"), *((series, "series"),) if series is not None else ())
368
+ rows = _csv_rows(source, required)
369
+ return _scatter(rows, numeric=_csv_number, spec=spec, x=x, y=y,
370
+ series=series, x_domain=x_domain, y_domain=y_domain,
371
+ summary=summary, theme=theme, profile=profile,
372
+ timeline=timeline, motion=motion,
373
+ application_profile=application_profile)
374
+
375
+
376
+ def strip_from_records(records_or_dataframe_like, *, spec, value, group,
377
+ group_order, series=None, summary=None, y_domain=None,
378
+ theme="slipware", profile="deep_scope", timeline=None,
379
+ motion=None, application_profile="atlas"):
380
+ required = _common_columns((value, "value"), (group, "group"), *((series, "series"),) if series is not None else ())
381
+ rows = _record_rows(records_or_dataframe_like, required)
382
+ return _strip(rows, numeric=_record_number, spec=spec, value=value,
383
+ group=group, group_order=group_order, series=series,
384
+ summary=summary, y_domain=y_domain, theme=theme,
385
+ profile=profile, timeline=timeline, motion=motion,
386
+ application_profile=application_profile)
387
+
388
+
389
+ def strip_from_csv(source, *, spec, value, group, group_order, series=None,
390
+ summary=None, y_domain=None, theme="slipware",
391
+ profile="deep_scope", timeline=None, motion=None,
392
+ application_profile="atlas"):
393
+ required = _common_columns((value, "value"), (group, "group"), *((series, "series"),) if series is not None else ())
394
+ rows = _csv_rows(source, required)
395
+ return _strip(rows, numeric=_csv_number, spec=spec, value=value,
396
+ group=group, group_order=group_order, series=series,
397
+ summary=summary, y_domain=y_domain, theme=theme,
398
+ profile=profile, timeline=timeline, motion=motion,
399
+ application_profile=application_profile)
@@ -0,0 +1,63 @@
1
+ """Application profiles for publication, atlas, and presentation output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ from dataclasses import dataclass
7
+ from typing import Any, Mapping
8
+
9
+
10
+ APPLICATION_PROFILE_VERSION = "figurestead.application-profile/1"
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class ApplicationProfile:
15
+ key: str
16
+ name: str
17
+ surface: str
18
+ density: str
19
+ typography: str
20
+ legend: str
21
+ ambient: str
22
+ motion: str
23
+ line_width: float
24
+ marker_scale: float
25
+ panel_surface: bool
26
+ frame: bool
27
+
28
+
29
+ APPLICATION_PROFILES = {
30
+ "paper": ApplicationProfile("paper", "Paper", "neutral", "compact", "publication", "outside", "none", "none", 1.55, .92, False, False),
31
+ "atlas": ApplicationProfile("atlas", "Atlas", "themed", "balanced", "scientific", "auto", "none", "semantic", 2.15, 1.12, True, True),
32
+ "talk": ApplicationProfile("talk", "Talk", "themed", "open", "display", "auto", "none", "semantic", 2.75, 1.34, True, True),
33
+ }
34
+
35
+
36
+ def get_application_profile(profile: str | ApplicationProfile = "atlas") -> ApplicationProfile:
37
+ if isinstance(profile, ApplicationProfile):
38
+ return profile
39
+ try:
40
+ return APPLICATION_PROFILES[profile]
41
+ except KeyError as exc:
42
+ raise ValueError(f"Unknown application profile {profile!r}; choose: {', '.join(APPLICATION_PROFILES)}") from exc
43
+
44
+
45
+ def apply_application_profile(contract: Mapping[str, Any], profile: str | ApplicationProfile = "atlas", *, motion: str | None = None, ambient: str | None = None) -> dict[str, Any]:
46
+ result = deepcopy(dict(contract))
47
+ value = get_application_profile(profile)
48
+ previous = get_application_profile(result.get("view", {}).get("profile", "atlas"))
49
+ previous_presentation = {
50
+ "panelSurface": previous.panel_surface, "frame": previous.frame,
51
+ "legend": "outside-right" if previous.legend == "outside" else "auto" if previous.legend == "auto" else "bottom-right",
52
+ "lineWidth": previous.line_width, "markerScale": previous.marker_scale,
53
+ }
54
+ result["view"] = {"profile": value.key, "motion": motion or value.motion, "ambient": ambient or value.ambient, "strategy": "auto" if (motion or value.motion) == "semantic" else "none"}
55
+ for panel in result.get("panels", []):
56
+ overrides = {key: item for key, item in dict(panel.get("presentation", {})).items() if previous_presentation.get(key) != item}
57
+ panel["presentation"] = {
58
+ "panelSurface": value.panel_surface, "frame": value.frame,
59
+ "legend": "outside-right" if value.legend == "outside" else "auto" if value.legend == "auto" else "bottom-right",
60
+ "lineWidth": value.line_width, "markerScale": value.marker_scale,
61
+ **overrides,
62
+ }
63
+ return result