mosaickit 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.
- mosaickit/__init__.py +103 -0
- mosaickit/canvas/__init__.py +7 -0
- mosaickit/canvas/animation.py +51 -0
- mosaickit/canvas/canvas.py +110 -0
- mosaickit/canvas/grid.py +186 -0
- mosaickit/canvas/layout.py +11 -0
- mosaickit/canvas/span.py +19 -0
- mosaickit/canvas_spec.py +56 -0
- mosaickit/colors.py +51 -0
- mosaickit/config.py +101 -0
- mosaickit/errors.py +17 -0
- mosaickit/interval.py +20 -0
- mosaickit/parameter/__init__.py +5 -0
- mosaickit/parameter/binding.py +55 -0
- mosaickit/parameter/expression.py +125 -0
- mosaickit/parameter/parameter.py +49 -0
- mosaickit/py.typed +0 -0
- mosaickit/rendering/__init__.py +12 -0
- mosaickit/rendering/cache.py +70 -0
- mosaickit/rendering/context.py +17 -0
- mosaickit/rendering/matplotlib/__init__.py +3 -0
- mosaickit/rendering/matplotlib/animation.py +51 -0
- mosaickit/rendering/matplotlib/arrows.py +55 -0
- mosaickit/rendering/matplotlib/artists/__init__.py +14 -0
- mosaickit/rendering/matplotlib/artists/fill_artist.py +21 -0
- mosaickit/rendering/matplotlib/artists/marker_artist.py +18 -0
- mosaickit/rendering/matplotlib/artists/path_artist.py +42 -0
- mosaickit/rendering/matplotlib/artists/text_artist.py +25 -0
- mosaickit/rendering/matplotlib/fonts.py +5 -0
- mosaickit/rendering/matplotlib/legend.py +21 -0
- mosaickit/rendering/matplotlib/renderer.py +145 -0
- mosaickit/rendering/plan.py +74 -0
- mosaickit/rendering/protocol.py +22 -0
- mosaickit/rendering/registry.py +23 -0
- mosaickit/scene/__init__.py +33 -0
- mosaickit/scene/arrow.py +22 -0
- mosaickit/scene/axis_spec.py +70 -0
- mosaickit/scene/fill.py +18 -0
- mosaickit/scene/group.py +15 -0
- mosaickit/scene/layer.py +61 -0
- mosaickit/scene/legend.py +17 -0
- mosaickit/scene/marker.py +17 -0
- mosaickit/scene/path.py +17 -0
- mosaickit/scene/scene.py +67 -0
- mosaickit/scene/text.py +28 -0
- mosaickit/styles/__init__.py +19 -0
- mosaickit/styles/arrow.py +14 -0
- mosaickit/styles/base.py +38 -0
- mosaickit/styles/fill.py +17 -0
- mosaickit/styles/legend.py +16 -0
- mosaickit/styles/marker.py +23 -0
- mosaickit/styles/stroke.py +33 -0
- mosaickit/styles/text.py +25 -0
- mosaickit/themes/__init__.py +15 -0
- mosaickit/themes/_walk.py +5 -0
- mosaickit/themes/builtins.py +26 -0
- mosaickit/themes/registry.py +29 -0
- mosaickit/themes/resolution.py +10 -0
- mosaickit/themes/role_pack.py +21 -0
- mosaickit/themes/theme.py +68 -0
- mosaickit-0.1.0.dist-info/METADATA +204 -0
- mosaickit-0.1.0.dist-info/RECORD +64 -0
- mosaickit-0.1.0.dist-info/WHEEL +4 -0
- mosaickit-0.1.0.dist-info/licenses/LICENSE +21 -0
mosaickit/__init__.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Domain-neutral diagram construction and rendering."""
|
|
2
|
+
|
|
3
|
+
from mosaickit.canvas import Animation, Canvas, CanvasGrid, Layout, Span
|
|
4
|
+
from mosaickit.canvas_spec import CanvasSpec
|
|
5
|
+
from mosaickit.colors import TRANSPARENT, Color
|
|
6
|
+
from mosaickit.config import Config, use_config
|
|
7
|
+
from mosaickit.errors import BindingError, ConfigurationError, MosaicKitError, RenderError
|
|
8
|
+
from mosaickit.interval import Interval
|
|
9
|
+
from mosaickit.parameter import Constant, Expression, Parameter, ParameterValues
|
|
10
|
+
from mosaickit.rendering import (
|
|
11
|
+
CacheBypassWarning,
|
|
12
|
+
CacheKey,
|
|
13
|
+
RenderCache,
|
|
14
|
+
Renderer,
|
|
15
|
+
RendererRegistry,
|
|
16
|
+
SaveOptions,
|
|
17
|
+
)
|
|
18
|
+
from mosaickit.scene import (
|
|
19
|
+
ArrowLayer,
|
|
20
|
+
AxisSpec,
|
|
21
|
+
FillLayer,
|
|
22
|
+
GroupLayer,
|
|
23
|
+
Layer,
|
|
24
|
+
LegendLayer,
|
|
25
|
+
MarkerLayer,
|
|
26
|
+
PathLayer,
|
|
27
|
+
Scene,
|
|
28
|
+
TextLayer,
|
|
29
|
+
box_frame,
|
|
30
|
+
build_axes,
|
|
31
|
+
crosshair_axes,
|
|
32
|
+
quadrant_axes,
|
|
33
|
+
)
|
|
34
|
+
from mosaickit.styles import (
|
|
35
|
+
ArrowPlacement,
|
|
36
|
+
ArrowStyle,
|
|
37
|
+
DashStyle,
|
|
38
|
+
Fill,
|
|
39
|
+
LegendStyle,
|
|
40
|
+
Marker,
|
|
41
|
+
SparseStyle,
|
|
42
|
+
Stroke,
|
|
43
|
+
TextStyle,
|
|
44
|
+
)
|
|
45
|
+
from mosaickit.themes import RolePack, StyleBundle, Theme, ThemeRegistry, expand_roles
|
|
46
|
+
|
|
47
|
+
__version__ = "0.1.0"
|
|
48
|
+
__all__ = [
|
|
49
|
+
"Animation",
|
|
50
|
+
"ArrowLayer",
|
|
51
|
+
"ArrowPlacement",
|
|
52
|
+
"ArrowStyle",
|
|
53
|
+
"AxisSpec",
|
|
54
|
+
"BindingError",
|
|
55
|
+
"CacheBypassWarning",
|
|
56
|
+
"CacheKey",
|
|
57
|
+
"Canvas",
|
|
58
|
+
"CanvasGrid",
|
|
59
|
+
"CanvasSpec",
|
|
60
|
+
"Color",
|
|
61
|
+
"Config",
|
|
62
|
+
"ConfigurationError",
|
|
63
|
+
"Constant",
|
|
64
|
+
"DashStyle",
|
|
65
|
+
"MosaicKitError",
|
|
66
|
+
"Expression",
|
|
67
|
+
"Fill",
|
|
68
|
+
"FillLayer",
|
|
69
|
+
"GroupLayer",
|
|
70
|
+
"Interval",
|
|
71
|
+
"Layer",
|
|
72
|
+
"Layout",
|
|
73
|
+
"LegendLayer",
|
|
74
|
+
"LegendStyle",
|
|
75
|
+
"Marker",
|
|
76
|
+
"MarkerLayer",
|
|
77
|
+
"Parameter",
|
|
78
|
+
"ParameterValues",
|
|
79
|
+
"PathLayer",
|
|
80
|
+
"RenderCache",
|
|
81
|
+
"RenderError",
|
|
82
|
+
"Renderer",
|
|
83
|
+
"RendererRegistry",
|
|
84
|
+
"RolePack",
|
|
85
|
+
"SaveOptions",
|
|
86
|
+
"Scene",
|
|
87
|
+
"Span",
|
|
88
|
+
"SparseStyle",
|
|
89
|
+
"Stroke",
|
|
90
|
+
"StyleBundle",
|
|
91
|
+
"TRANSPARENT",
|
|
92
|
+
"TextLayer",
|
|
93
|
+
"TextStyle",
|
|
94
|
+
"Theme",
|
|
95
|
+
"ThemeRegistry",
|
|
96
|
+
"__version__",
|
|
97
|
+
"box_frame",
|
|
98
|
+
"build_axes",
|
|
99
|
+
"crosshair_axes",
|
|
100
|
+
"expand_roles",
|
|
101
|
+
"quadrant_axes",
|
|
102
|
+
"use_config",
|
|
103
|
+
]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from mosaickit.canvas.animation import Animation
|
|
2
|
+
from mosaickit.canvas.canvas import Canvas
|
|
3
|
+
from mosaickit.canvas.grid import CanvasGrid
|
|
4
|
+
from mosaickit.canvas.layout import Layout
|
|
5
|
+
from mosaickit.canvas.span import Span
|
|
6
|
+
|
|
7
|
+
__all__ = ["Animation", "Canvas", "CanvasGrid", "Layout", "Span"]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from mosaickit.canvas.canvas import Canvas
|
|
9
|
+
from mosaickit.errors import ConfigurationError, RenderError
|
|
10
|
+
from mosaickit.parameter import Parameter, ParameterValues
|
|
11
|
+
from mosaickit.rendering import RenderCache, Renderer
|
|
12
|
+
from mosaickit.scene import Scene
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class Animation:
|
|
17
|
+
template: Canvas
|
|
18
|
+
parameter: Parameter
|
|
19
|
+
values: tuple[Any, ...]
|
|
20
|
+
fps: int = 30
|
|
21
|
+
|
|
22
|
+
def __post_init__(self) -> None:
|
|
23
|
+
if isinstance(self.fps, bool) or not isinstance(self.fps, int) or self.fps < 1:
|
|
24
|
+
raise ConfigurationError("Animation.fps must be a positive integer")
|
|
25
|
+
values = ParameterValues(self.parameter, self.values).values
|
|
26
|
+
if not values:
|
|
27
|
+
raise ConfigurationError("Animation requires at least one value")
|
|
28
|
+
object.__setattr__(self, "values", values)
|
|
29
|
+
object.__setattr__(self, "template", self.template.copy())
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def sweep(canvas: Canvas, values: ParameterValues, *, fps: int = 30) -> Animation:
|
|
33
|
+
return Animation(canvas, values.parameter, values.values, fps)
|
|
34
|
+
|
|
35
|
+
def frames(self) -> Iterator[Scene]:
|
|
36
|
+
for value in self.values:
|
|
37
|
+
yield self.template.bind(self.parameter, value).snapshot()
|
|
38
|
+
|
|
39
|
+
def save(
|
|
40
|
+
self,
|
|
41
|
+
path: str | Path,
|
|
42
|
+
*,
|
|
43
|
+
renderer: Renderer | str | None = None,
|
|
44
|
+
cache: RenderCache | None = None,
|
|
45
|
+
) -> list[Path]:
|
|
46
|
+
selected = self.template._renderer(renderer)
|
|
47
|
+
if not hasattr(selected, "save_animation"):
|
|
48
|
+
raise RenderError(f"Renderer {selected.name!r} does not support animation output")
|
|
49
|
+
return selected.save_animation(
|
|
50
|
+
self, Path(path), cache if cache is not None else RenderCache()
|
|
51
|
+
)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Fluent scene construction with isolated render snapshots."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Mapping
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from mosaickit.canvas_spec import CanvasSpec
|
|
11
|
+
from mosaickit.config import Config, current_config
|
|
12
|
+
from mosaickit.errors import RenderError
|
|
13
|
+
from mosaickit.parameter import Parameter, bind
|
|
14
|
+
from mosaickit.rendering import RenderCache, Renderer, RendererRegistry, SaveOptions
|
|
15
|
+
from mosaickit.rendering.context import _RenderContext
|
|
16
|
+
from mosaickit.scene import Layer, Scene
|
|
17
|
+
from mosaickit.themes import StyleBundle, Theme
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Canvas:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
spec: CanvasSpec | None = None,
|
|
24
|
+
theme: Theme | None = None,
|
|
25
|
+
config: Config | None = None,
|
|
26
|
+
renderer: Renderer | str | None = None,
|
|
27
|
+
*,
|
|
28
|
+
role_overrides: Mapping[str, StyleBundle] | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self.config = config if config is not None else current_config()
|
|
31
|
+
self.spec = spec if spec is not None else self.config.canvas_spec
|
|
32
|
+
self.theme = theme if theme is not None else self.config.theme
|
|
33
|
+
self.renderer = renderer if renderer is not None else self.config.renderer
|
|
34
|
+
self.role_overrides = MappingProxyType(dict(role_overrides or {}))
|
|
35
|
+
self._scene = Scene.empty()
|
|
36
|
+
self._bindings: tuple = ()
|
|
37
|
+
|
|
38
|
+
def add(self, layer: Layer) -> Canvas:
|
|
39
|
+
self._scene = self._scene.add(layer)
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
def extend(self, layers: Iterable[Layer]) -> Canvas:
|
|
43
|
+
self._scene = self._scene.extend(layers)
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
def remove(self, layer_id: str) -> Canvas:
|
|
47
|
+
self._scene = self._scene.remove(layer_id)
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
def clear(self) -> Canvas:
|
|
51
|
+
self._scene = self._scene.clear()
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
def snapshot(self) -> Scene:
|
|
55
|
+
return self._scene
|
|
56
|
+
|
|
57
|
+
def copy(self) -> Canvas:
|
|
58
|
+
canvas = Canvas(
|
|
59
|
+
self.spec, self.theme, self.config, self.renderer, role_overrides=self.role_overrides
|
|
60
|
+
)
|
|
61
|
+
canvas._scene = self._scene
|
|
62
|
+
canvas._bindings = self._bindings
|
|
63
|
+
return canvas
|
|
64
|
+
|
|
65
|
+
def bind(self, parameter: Parameter | Mapping[Parameter, Any], value: Any = None) -> Canvas:
|
|
66
|
+
bindings = dict(parameter) if isinstance(parameter, Mapping) else {parameter: value}
|
|
67
|
+
for item, bound in bindings.items():
|
|
68
|
+
item.evaluate({item: bound})
|
|
69
|
+
canvas = self.copy()
|
|
70
|
+
canvas._scene = bind(self._scene, bindings)
|
|
71
|
+
canvas._bindings = (*self._bindings, *bindings.items())
|
|
72
|
+
return canvas
|
|
73
|
+
|
|
74
|
+
def _context(self, cache: RenderCache | None = None) -> _RenderContext:
|
|
75
|
+
return _RenderContext(
|
|
76
|
+
self.spec,
|
|
77
|
+
self.theme,
|
|
78
|
+
self.config.role_overrides,
|
|
79
|
+
self.role_overrides,
|
|
80
|
+
cache if cache is not None else RenderCache(),
|
|
81
|
+
self._bindings,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def _renderer(self, renderer: Renderer | str | None = None) -> Renderer:
|
|
85
|
+
selected = self.renderer if renderer is None else renderer
|
|
86
|
+
return RendererRegistry().get(selected) if isinstance(selected, str) else selected
|
|
87
|
+
|
|
88
|
+
def render(
|
|
89
|
+
self, *, renderer: Renderer | str | None = None, cache: RenderCache | None = None
|
|
90
|
+
) -> Any:
|
|
91
|
+
return self._renderer(renderer).render(self.snapshot(), self._context(cache))
|
|
92
|
+
|
|
93
|
+
def save(
|
|
94
|
+
self,
|
|
95
|
+
target: str | Path,
|
|
96
|
+
*,
|
|
97
|
+
renderer: Renderer | str | None = None,
|
|
98
|
+
cache: RenderCache | None = None,
|
|
99
|
+
**options: Any,
|
|
100
|
+
) -> list[Path]:
|
|
101
|
+
target = Path(target)
|
|
102
|
+
if target.suffix.lower() not in {".png", ".pdf", ".svg"}:
|
|
103
|
+
raise RenderError(f"Unsupported output format: {target.suffix!r}")
|
|
104
|
+
selected = self._renderer(renderer)
|
|
105
|
+
result = selected.render(self.snapshot(), self._context(cache))
|
|
106
|
+
try:
|
|
107
|
+
return selected.save(result, target, SaveOptions(**options))
|
|
108
|
+
finally:
|
|
109
|
+
if hasattr(result, "close"):
|
|
110
|
+
result.close()
|
mosaickit/canvas/grid.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Validated grid placement with real row and column spans."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from mosaickit.canvas.canvas import Canvas
|
|
11
|
+
from mosaickit.canvas.layout import Layout
|
|
12
|
+
from mosaickit.canvas.span import Span
|
|
13
|
+
from mosaickit.errors import ConfigurationError, RenderError
|
|
14
|
+
from mosaickit.parameter import ParameterValues
|
|
15
|
+
from mosaickit.rendering import RenderCache, Renderer, RendererRegistry, SaveOptions
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class _Placement:
|
|
20
|
+
canvas: Canvas
|
|
21
|
+
row: int
|
|
22
|
+
col: int
|
|
23
|
+
rows: int
|
|
24
|
+
cols: int
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CanvasGrid:
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
cells: Any,
|
|
31
|
+
rows: int | None = None,
|
|
32
|
+
cols: int | None = None,
|
|
33
|
+
shape: tuple[int, int] | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
if shape is not None:
|
|
36
|
+
if rows is not None or cols is not None or len(shape) != 2:
|
|
37
|
+
raise ConfigurationError("Use shape=(rows, cols) or rows/cols, not both")
|
|
38
|
+
rows, cols = shape
|
|
39
|
+
for name, value in (("rows", rows), ("cols", cols)):
|
|
40
|
+
if value is not None and (
|
|
41
|
+
isinstance(value, bool) or not isinstance(value, int) or value < 1
|
|
42
|
+
):
|
|
43
|
+
raise ConfigurationError(f"CanvasGrid.{name} must be a positive integer")
|
|
44
|
+
cells = list(cells)
|
|
45
|
+
if not cells:
|
|
46
|
+
raise ConfigurationError("CanvasGrid requires at least one cell")
|
|
47
|
+
nested = isinstance(cells[0], (list, tuple))
|
|
48
|
+
if nested:
|
|
49
|
+
if not all(isinstance(row, (list, tuple)) for row in cells):
|
|
50
|
+
raise ConfigurationError("CanvasGrid cannot mix rows and flat cells")
|
|
51
|
+
matrix = [list(row) for row in cells]
|
|
52
|
+
rows = rows if rows is not None else len(matrix)
|
|
53
|
+
if len(matrix) > rows:
|
|
54
|
+
raise ConfigurationError(f"Grid has {len(matrix)} rows; expected at most {rows}")
|
|
55
|
+
cols = (
|
|
56
|
+
cols
|
|
57
|
+
if cols is not None
|
|
58
|
+
else max(
|
|
59
|
+
sum(cell.cols if isinstance(cell, Span) else 1 for cell in row)
|
|
60
|
+
for row in matrix
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
else:
|
|
64
|
+
if any(isinstance(cell, Span) and cell.rows != 1 for cell in cells):
|
|
65
|
+
raise ConfigurationError("Row spans require nested grid rows")
|
|
66
|
+
count = sum(cell.cols if isinstance(cell, Span) else 1 for cell in cells)
|
|
67
|
+
cols = (
|
|
68
|
+
cols
|
|
69
|
+
if cols is not None
|
|
70
|
+
else (math.ceil(count / rows) if rows else math.ceil(math.sqrt(count)))
|
|
71
|
+
)
|
|
72
|
+
rows = rows if rows is not None else math.ceil(count / cols)
|
|
73
|
+
if count > rows * cols:
|
|
74
|
+
raise ConfigurationError(
|
|
75
|
+
f"Grid has {count} cells; shape ({rows}, {cols}) is too small"
|
|
76
|
+
)
|
|
77
|
+
matrix = [[]]
|
|
78
|
+
width = 0
|
|
79
|
+
for cell in cells:
|
|
80
|
+
span_width = cell.cols if isinstance(cell, Span) else 1
|
|
81
|
+
if width + span_width > cols:
|
|
82
|
+
matrix.append([])
|
|
83
|
+
width = 0
|
|
84
|
+
matrix[-1].append(cell)
|
|
85
|
+
width += span_width
|
|
86
|
+
if not cols:
|
|
87
|
+
raise ConfigurationError("CanvasGrid rows cannot all be empty")
|
|
88
|
+
occupied: set[tuple[int, int]] = set()
|
|
89
|
+
placements = []
|
|
90
|
+
for row_index, row in enumerate(matrix):
|
|
91
|
+
cursor = 0
|
|
92
|
+
for cell_index, cell in enumerate(row):
|
|
93
|
+
while (row_index, cursor) in occupied:
|
|
94
|
+
cursor += 1
|
|
95
|
+
span = cell if isinstance(cell, Span) else Span(cell) if cell is not None else None
|
|
96
|
+
height, width = (span.rows, span.cols) if span else (1, 1)
|
|
97
|
+
if cursor + width > cols or row_index + height > rows:
|
|
98
|
+
raise ConfigurationError(
|
|
99
|
+
f"Grid row {row_index}, cell {cell_index}: span "
|
|
100
|
+
f"({height}, {width}) exceeds shape ({rows}, {cols})"
|
|
101
|
+
)
|
|
102
|
+
region = {
|
|
103
|
+
(r, c)
|
|
104
|
+
for r in range(row_index, row_index + height)
|
|
105
|
+
for c in range(cursor, cursor + width)
|
|
106
|
+
}
|
|
107
|
+
if region & occupied:
|
|
108
|
+
raise ConfigurationError(
|
|
109
|
+
f"Grid row {row_index}, cell {cell_index}: overlapping span"
|
|
110
|
+
)
|
|
111
|
+
occupied.update(region)
|
|
112
|
+
if span:
|
|
113
|
+
placements.append(
|
|
114
|
+
_Placement(span.canvas.copy(), row_index, cursor, height, width)
|
|
115
|
+
)
|
|
116
|
+
cursor += width
|
|
117
|
+
if nested:
|
|
118
|
+
for row_index in range(rows):
|
|
119
|
+
missing = [col for col in range(cols) if (row_index, col) not in occupied]
|
|
120
|
+
if missing:
|
|
121
|
+
raise ConfigurationError(
|
|
122
|
+
f"Grid row {row_index}: missing columns {missing}; use None for empty cells"
|
|
123
|
+
)
|
|
124
|
+
self.rows, self.cols = rows, cols
|
|
125
|
+
self.placements = tuple(placements)
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def sweep(
|
|
129
|
+
cls, template: Canvas, values: ParameterValues, *, cols: int | None = None
|
|
130
|
+
) -> CanvasGrid:
|
|
131
|
+
return cls([template.bind(values.parameter, value) for value in values.values], cols=cols)
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def from_layout(cls, canvases: list[Canvas], layout: Layout) -> CanvasGrid:
|
|
135
|
+
if layout == Layout.TOP_TWO_BOTTOM_ONE:
|
|
136
|
+
if len(canvases) != 3:
|
|
137
|
+
raise ConfigurationError("TOP_TWO_BOTTOM_ONE requires 3 canvases")
|
|
138
|
+
return cls([[canvases[0], canvases[1]], [Span(canvases[2], cols=2)]])
|
|
139
|
+
if layout == Layout.TOP_ONE_BOTTOM_TWO:
|
|
140
|
+
if len(canvases) != 3:
|
|
141
|
+
raise ConfigurationError("TOP_ONE_BOTTOM_TWO requires 3 canvases")
|
|
142
|
+
return cls([[Span(canvases[0], cols=2)], [canvases[1], canvases[2]]])
|
|
143
|
+
shapes = {
|
|
144
|
+
Layout.SINGLE: (1, 1),
|
|
145
|
+
Layout.STACKED: (2, 1),
|
|
146
|
+
Layout.SIDE_BY_SIDE: (1, 2),
|
|
147
|
+
Layout.GRID_2X2: (2, 2),
|
|
148
|
+
Layout.GRID_3X3: (3, 3),
|
|
149
|
+
}
|
|
150
|
+
return cls(canvases, shape=shapes[layout])
|
|
151
|
+
|
|
152
|
+
def _renderer(self, renderer: Renderer | str | None) -> Renderer:
|
|
153
|
+
if renderer is None:
|
|
154
|
+
return (
|
|
155
|
+
self.placements[0].canvas._renderer()
|
|
156
|
+
if self.placements
|
|
157
|
+
else RendererRegistry().get("matplotlib")
|
|
158
|
+
)
|
|
159
|
+
return RendererRegistry().get(renderer) if isinstance(renderer, str) else renderer
|
|
160
|
+
|
|
161
|
+
def render(
|
|
162
|
+
self, *, renderer: Renderer | str | None = None, cache: RenderCache | None = None
|
|
163
|
+
) -> Any:
|
|
164
|
+
selected = self._renderer(renderer)
|
|
165
|
+
if not hasattr(selected, "render_grid"):
|
|
166
|
+
raise RenderError(f"Renderer {selected.name!r} does not support grids")
|
|
167
|
+
return selected.render_grid(self, cache if cache is not None else RenderCache())
|
|
168
|
+
|
|
169
|
+
def save(
|
|
170
|
+
self,
|
|
171
|
+
target: str | Path,
|
|
172
|
+
*,
|
|
173
|
+
renderer: Renderer | str | None = None,
|
|
174
|
+
cache: RenderCache | None = None,
|
|
175
|
+
**options: Any,
|
|
176
|
+
) -> list[Path]:
|
|
177
|
+
target = Path(target)
|
|
178
|
+
if target.suffix.lower() not in {".png", ".pdf", ".svg"}:
|
|
179
|
+
raise RenderError(f"Unsupported output format: {target.suffix!r}")
|
|
180
|
+
selected = self._renderer(renderer)
|
|
181
|
+
result = self.render(renderer=selected, cache=cache)
|
|
182
|
+
try:
|
|
183
|
+
return selected.save(result, target, SaveOptions(**options))
|
|
184
|
+
finally:
|
|
185
|
+
if hasattr(result, "close"):
|
|
186
|
+
result.close()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Layout(str, Enum):
|
|
5
|
+
SINGLE = "single"
|
|
6
|
+
STACKED = "stacked"
|
|
7
|
+
SIDE_BY_SIDE = "side_by_side"
|
|
8
|
+
GRID_2X2 = "grid_2x2"
|
|
9
|
+
GRID_3X3 = "grid_3x3"
|
|
10
|
+
TOP_TWO_BOTTOM_ONE = "top_two_bottom_one"
|
|
11
|
+
TOP_ONE_BOTTOM_TWO = "top_one_bottom_two"
|
mosaickit/canvas/span.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from mosaickit.canvas.canvas import Canvas
|
|
4
|
+
from mosaickit.errors import ConfigurationError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class Span:
|
|
9
|
+
canvas: Canvas
|
|
10
|
+
rows: int = 1
|
|
11
|
+
cols: int = 1
|
|
12
|
+
|
|
13
|
+
def __post_init__(self) -> None:
|
|
14
|
+
if not isinstance(self.canvas, Canvas):
|
|
15
|
+
raise ConfigurationError("Span.canvas must be a Canvas")
|
|
16
|
+
for name in ("rows", "cols"):
|
|
17
|
+
value = getattr(self, name)
|
|
18
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
19
|
+
raise ConfigurationError(f"Span.{name} must be a positive integer")
|
mosaickit/canvas_spec.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Physical and coordinate dimensions of a diagram."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from mosaickit.errors import ConfigurationError
|
|
10
|
+
from mosaickit.interval import Interval
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class CanvasSpec:
|
|
15
|
+
x_range: tuple[float, float] = (0.0, 10.0)
|
|
16
|
+
y_range: tuple[float, float] = (0.0, 10.0)
|
|
17
|
+
width: float = 6.0
|
|
18
|
+
height: float = 6.0
|
|
19
|
+
dpi: int = 300
|
|
20
|
+
x_label: str = "X"
|
|
21
|
+
y_label: str = "Y"
|
|
22
|
+
title: str | None = None
|
|
23
|
+
|
|
24
|
+
def __post_init__(self) -> None:
|
|
25
|
+
for name in ("x_range", "y_range"):
|
|
26
|
+
value = getattr(self, name)
|
|
27
|
+
try:
|
|
28
|
+
interval = Interval(*value)
|
|
29
|
+
except (TypeError, ValueError) as exc:
|
|
30
|
+
raise ConfigurationError(f"CanvasSpec.{name}: {exc}") from exc
|
|
31
|
+
object.__setattr__(self, name, (interval.lo, interval.hi))
|
|
32
|
+
for name in ("width", "height"):
|
|
33
|
+
value = getattr(self, name)
|
|
34
|
+
if not math.isfinite(value) or value <= 0:
|
|
35
|
+
raise ConfigurationError(f"CanvasSpec.{name} must be positive and finite")
|
|
36
|
+
if isinstance(self.dpi, bool) or not isinstance(self.dpi, int) or not 1 <= self.dpi <= 1200:
|
|
37
|
+
raise ConfigurationError("CanvasSpec.dpi must be an integer in [1, 1200]")
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def x_min(self) -> float:
|
|
41
|
+
return self.x_range[0]
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def x_max(self) -> float:
|
|
45
|
+
return self.x_range[1]
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def y_min(self) -> float:
|
|
49
|
+
return self.y_range[0]
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def y_max(self) -> float:
|
|
53
|
+
return self.y_range[1]
|
|
54
|
+
|
|
55
|
+
def replace(self, **changes: Any) -> CanvasSpec:
|
|
56
|
+
return replace(self, **changes)
|
mosaickit/colors.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Immutable, backend-independent RGBA colors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import string
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from mosaickit.errors import ConfigurationError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class Color:
|
|
14
|
+
red: float
|
|
15
|
+
green: float
|
|
16
|
+
blue: float
|
|
17
|
+
alpha: float = 1.0
|
|
18
|
+
|
|
19
|
+
def __post_init__(self) -> None:
|
|
20
|
+
if not all(math.isfinite(v) and 0 <= v <= 1 for v in self.channels):
|
|
21
|
+
raise ConfigurationError("Color channels must be finite and in [0, 1]")
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def channels(self) -> tuple[float, float, float, float]:
|
|
25
|
+
return self.red, self.green, self.blue, self.alpha
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_channels(cls, red: float, green: float, blue: float, alpha: float = 1) -> Color:
|
|
29
|
+
return cls(red, green, blue, alpha)
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_hex(cls, value: str) -> Color:
|
|
33
|
+
if not isinstance(value, str) or not value.startswith("#"):
|
|
34
|
+
raise ConfigurationError("Color must be #RGB, #RRGGBB, or #RRGGBBAA")
|
|
35
|
+
digits = value[1:]
|
|
36
|
+
if len(digits) == 3:
|
|
37
|
+
digits = "".join(c * 2 for c in digits)
|
|
38
|
+
if len(digits) not in (6, 8) or any(char not in string.hexdigits for char in digits):
|
|
39
|
+
raise ConfigurationError(f"Invalid hex color: {value!r}")
|
|
40
|
+
try:
|
|
41
|
+
channels = [int(digits[i : i + 2], 16) / 255 for i in range(0, len(digits), 2)]
|
|
42
|
+
except ValueError as exc:
|
|
43
|
+
raise ConfigurationError(f"Invalid hex color: {value!r}") from exc
|
|
44
|
+
return cls(*channels)
|
|
45
|
+
|
|
46
|
+
def to_hex(self, *, include_alpha: bool | None = None) -> str:
|
|
47
|
+
alpha = self.alpha != 1 if include_alpha is None else include_alpha
|
|
48
|
+
return "#" + "".join(f"{round(v * 255):02X}" for v in self.channels[: 4 if alpha else 3])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
TRANSPARENT = Color(0, 0, 0, 0)
|