codefig 0.17.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.
- codefig/__init__.py +210 -0
- codefig/_validate.py +12 -0
- codefig/autowrap.py +277 -0
- codefig/charts.py +1806 -0
- codefig/code.py +602 -0
- codefig/compose.py +374 -0
- codefig/constraints.py +1571 -0
- codefig/dim.py +214 -0
- codefig/edge_align.py +231 -0
- codefig/edges.py +2824 -0
- codefig/export.py +204 -0
- codefig/figure.py +1923 -0
- codefig/fills.py +146 -0
- codefig/flows.py +102 -0
- codefig/glyphs.py +395 -0
- codefig/icon.py +288 -0
- codefig/icons/LICENSE-tabler-icons.txt +21 -0
- codefig/icons/__init__.py +101 -0
- codefig/icons/tabler.json +1 -0
- codefig/legend.py +483 -0
- codefig/lint.py +1737 -0
- codefig/lint_geom.py +528 -0
- codefig/node.py +864 -0
- codefig/notices.py +34 -0
- codefig/overlaps.py +392 -0
- codefig/policy.py +137 -0
- codefig/primitives.py +2721 -0
- codefig/py.typed +0 -0
- codefig/regions.py +727 -0
- codefig/render_svg.py +512 -0
- codefig/routing.py +932 -0
- codefig/shapes.py +642 -0
- codefig/solids.py +514 -0
- codefig/steps.py +203 -0
- codefig/styles.py +893 -0
- codefig/textutil.py +889 -0
- codefig/timeline.py +1288 -0
- codefig/tree.py +277 -0
- codefig/venues.py +93 -0
- codefig-0.17.0.dist-info/METADATA +542 -0
- codefig-0.17.0.dist-info/RECORD +44 -0
- codefig-0.17.0.dist-info/WHEEL +5 -0
- codefig-0.17.0.dist-info/licenses/LICENSE +21 -0
- codefig-0.17.0.dist-info/top_level.txt +1 -0
codefig/fills.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Paint objects usable anywhere a ``fill=`` colour is accepted (v0.4.0).
|
|
2
|
+
|
|
3
|
+
``Gradient("#dbe8fb", "#2e6fd0", angle=90)`` — linear gradient (angle: 0 = left→right,
|
|
4
|
+
90 = top→bottom; any number of colour stops). ``Split("#cfe", "#fdd")`` — two or more
|
|
5
|
+
hard-edged colour regions (``split="diagonal" | "antidiagonal" | "vertical" |
|
|
6
|
+
"horizontal"``, ``at=`` the boundary fraction), e.g. a half-frozen / half-trained module.
|
|
7
|
+
|
|
8
|
+
Both are resolved by the SVG drawing helpers into ``<linearGradient>`` defs in
|
|
9
|
+
objectBoundingBox units, so they work on rectangles, rounded boxes, ellipses and any
|
|
10
|
+
outline path (shapes), on Group / Box / Key swatches alike.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
from typing import List, Optional, Sequence, Tuple, Union
|
|
17
|
+
|
|
18
|
+
_seq = [0]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _next_id(prefix: str) -> str:
|
|
22
|
+
_seq[0] += 1
|
|
23
|
+
return f"cf-{prefix}-{_seq[0]}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Paint:
|
|
27
|
+
"""Base for non-solid fills. ``ref(buf)`` emits defs and returns ``url(#id)``."""
|
|
28
|
+
|
|
29
|
+
def stops(self) -> List[Tuple[float, str]]: # pragma: no cover - abstract
|
|
30
|
+
raise NotImplementedError
|
|
31
|
+
|
|
32
|
+
def vector(self) -> Tuple[float, float, float, float]: # pragma: no cover
|
|
33
|
+
raise NotImplementedError
|
|
34
|
+
|
|
35
|
+
def ref(self, buf: List[str]) -> str:
|
|
36
|
+
gid = _next_id(type(self).__name__.lower())
|
|
37
|
+
x1, y1, x2, y2 = self.vector()
|
|
38
|
+
st = "".join(f'<stop offset="{o:.4f}" stop-color="{c}"{self._op()} />'
|
|
39
|
+
for o, c in self.stops())
|
|
40
|
+
buf.append(f'<defs><linearGradient id="{gid}" x1="{x1:.4f}" y1="{y1:.4f}" '
|
|
41
|
+
f'x2="{x2:.4f}" y2="{y2:.4f}">{st}</linearGradient></defs>')
|
|
42
|
+
return f"url(#{gid})"
|
|
43
|
+
|
|
44
|
+
def _op(self) -> str:
|
|
45
|
+
op = getattr(self, "opacity", 1.0)
|
|
46
|
+
return f' stop-opacity="{op:.3f}"' if op < 0.999 else ""
|
|
47
|
+
|
|
48
|
+
def primary(self) -> str:
|
|
49
|
+
"""A representative solid colour (first stop) for places that need one."""
|
|
50
|
+
return self.stops()[0][1]
|
|
51
|
+
|
|
52
|
+
def __str__(self) -> str: # legend signatures / debugging
|
|
53
|
+
return f"{type(self).__name__}({', '.join(c for _, c in self.stops())})"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _angle_vector(angle: float) -> Tuple[float, float, float, float]:
|
|
57
|
+
t = math.radians(angle)
|
|
58
|
+
dx, dy = math.cos(t), math.sin(t)
|
|
59
|
+
# unit-square vector through the centre whose ends project onto the far corners
|
|
60
|
+
k = 0.5 * (abs(dx) + abs(dy))
|
|
61
|
+
return 0.5 - dx * k, 0.5 - dy * k, 0.5 + dx * k, 0.5 + dy * k
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Gradient(Paint):
|
|
65
|
+
"""Linear gradient fill: ``Gradient(c1, c2, ..., angle=0)`` (0 = left→right)."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, *colors: Union[str, Sequence[str]], angle: float = 0.0,
|
|
68
|
+
opacity: float = 1.0) -> None:
|
|
69
|
+
if len(colors) == 1 and not isinstance(colors[0], str):
|
|
70
|
+
colors = tuple(colors[0]) # type: ignore[assignment]
|
|
71
|
+
if len(colors) < 2:
|
|
72
|
+
raise ValueError("Gradient needs at least two colours")
|
|
73
|
+
self.colors = [str(c) for c in colors]
|
|
74
|
+
self.angle = float(angle)
|
|
75
|
+
self.opacity = float(opacity)
|
|
76
|
+
|
|
77
|
+
def stops(self) -> List[Tuple[float, str]]:
|
|
78
|
+
n = len(self.colors) - 1
|
|
79
|
+
return [(i / n, c) for i, c in enumerate(self.colors)]
|
|
80
|
+
|
|
81
|
+
def vector(self) -> Tuple[float, float, float, float]:
|
|
82
|
+
return _angle_vector(self.angle)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
_SPLITS = {
|
|
86
|
+
"vertical": (0.0, 0.5, 1.0, 0.5), # boundary is a vertical line: left | right
|
|
87
|
+
"horizontal": (0.5, 0.0, 0.5, 1.0), # top / bottom
|
|
88
|
+
"diagonal": (0.0, 0.0, 1.0, 1.0), # top-left ◤ / ◢ bottom-right
|
|
89
|
+
"antidiagonal": (1.0, 0.0, 0.0, 1.0), # top-right ◥ / ◣ bottom-left
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Split(Paint):
|
|
94
|
+
"""Hard-edged multi-colour fill: ``Split(c1, c2, split="diagonal", at=0.5)``.
|
|
95
|
+
|
|
96
|
+
``split``: vertical (left | right halves) · horizontal (top / bottom) · diagonal
|
|
97
|
+
(corner to corner, ◤◢) · antidiagonal (◥◣). ``at``: boundary position (two colours)
|
|
98
|
+
or a list of boundaries for more colours (default: equal bands).
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(self, *colors: Union[str, Sequence[str]], split: str = "diagonal",
|
|
102
|
+
at: Optional[Union[float, Sequence[float]]] = None, opacity: float = 1.0) -> None:
|
|
103
|
+
if len(colors) == 1 and not isinstance(colors[0], str):
|
|
104
|
+
colors = tuple(colors[0]) # type: ignore[assignment]
|
|
105
|
+
if len(colors) < 2:
|
|
106
|
+
raise ValueError("Split needs at least two colours")
|
|
107
|
+
if split not in _SPLITS:
|
|
108
|
+
raise ValueError(f"split must be one of {tuple(_SPLITS)}, got {split!r}")
|
|
109
|
+
self.colors = [str(c) for c in colors]
|
|
110
|
+
self.split = split
|
|
111
|
+
n = len(self.colors)
|
|
112
|
+
if at is None:
|
|
113
|
+
cuts = [i / n for i in range(1, n)]
|
|
114
|
+
elif isinstance(at, (int, float)):
|
|
115
|
+
if n != 2:
|
|
116
|
+
raise ValueError("a single at= works with two colours; pass a list")
|
|
117
|
+
cuts = [float(at)]
|
|
118
|
+
else:
|
|
119
|
+
cuts = [float(a) for a in at]
|
|
120
|
+
if len(cuts) != n - 1:
|
|
121
|
+
raise ValueError("at= needs len(colors) - 1 boundaries")
|
|
122
|
+
self.cuts = cuts
|
|
123
|
+
self.opacity = float(opacity)
|
|
124
|
+
|
|
125
|
+
def stops(self) -> List[Tuple[float, str]]:
|
|
126
|
+
out: List[Tuple[float, str]] = []
|
|
127
|
+
bounds = [0.0] + self.cuts + [1.0]
|
|
128
|
+
for i, c in enumerate(self.colors):
|
|
129
|
+
out.append((bounds[i], c))
|
|
130
|
+
out.append((bounds[i + 1], c))
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
def vector(self) -> Tuple[float, float, float, float]:
|
|
134
|
+
return _SPLITS[self.split]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def resolve_paint(buf: List[str], fill) -> str:
|
|
138
|
+
"""Return an SVG paint string for ``fill`` (emitting gradient defs when needed)."""
|
|
139
|
+
if fill is None:
|
|
140
|
+
return "none"
|
|
141
|
+
if isinstance(fill, Paint):
|
|
142
|
+
return fill.ref(buf)
|
|
143
|
+
return str(fill)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
__all__ = ["Gradient", "Paint", "Split", "resolve_paint"]
|
codefig/flows.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Wrapped flows (v0.7.0): a long chain of steps folded into rows.
|
|
2
|
+
|
|
3
|
+
``WrapFlow(tokens, per_row=5)`` lays the items out row by row and connects consecutive
|
|
4
|
+
items with arrows. ``mode="snake"`` (default) reverses every other row so the chain turns
|
|
5
|
+
down at the row end (boustrophedon — no long return arrows); ``mode="z"`` keeps every row
|
|
6
|
+
left → right and routes the row-to-row connector back under the row. Items may be
|
|
7
|
+
nodes or strings (→ :class:`~codefig.primitives.Box`). ``items[i]`` stays addressable
|
|
8
|
+
for extra edges / highlights; ``flow.edge(i)`` is the arrow leaving item ``i``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Dict, List, Optional, Sequence, Union
|
|
14
|
+
|
|
15
|
+
from .edges import Diagram, Edge
|
|
16
|
+
from .node import Grid, Node
|
|
17
|
+
from .primitives import Box, Spacer
|
|
18
|
+
from .styles import themed
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_ROUTER_PAD = 5.0 # obstacle padding used by Diagram routing (edges.collect_obstacles)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class WrapFlow(Diagram):
|
|
25
|
+
"""Chain of items folded into rows of ``per_row`` (see module docs).
|
|
26
|
+
|
|
27
|
+
per_row: items per row; ``max_w``: alternatively, fold so a row is at most this wide
|
|
28
|
+
(items measured at construction). gap: default spacing; ``col_gap`` (between items in
|
|
29
|
+
a row) and ``row_gap`` (between rows) override it per axis (v0.8.0). The default
|
|
30
|
+
``row_gap`` in ``mode="z"`` leaves a clear lane for the return connectors. arrows: connect consecutive items (``edge=`` dict of Edge keywords,
|
|
31
|
+
e.g. ``edge={"color": "#0072B2"}``). ``equal=True`` gives every column the same width
|
|
32
|
+
so the snake's turns stay vertical.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
flow_gap = themed(16.0)
|
|
36
|
+
|
|
37
|
+
def __init__(self, items: Sequence[Union[Node, str]], *, per_row: Optional[int] = None,
|
|
38
|
+
max_w: Optional[float] = None, mode: str = "snake", gap: Optional[float] = None,
|
|
39
|
+
row_gap: Optional[float] = None, col_gap: Optional[float] = None,
|
|
40
|
+
arrows: bool = True,
|
|
41
|
+
edge: Optional[Dict[str, Any]] = None, equal: bool = True,
|
|
42
|
+
align: str = "center", **kwargs: Any) -> None:
|
|
43
|
+
if mode not in ("snake", "z"):
|
|
44
|
+
raise ValueError("mode must be snake|z")
|
|
45
|
+
nodes: List[Node] = [Box(t) if isinstance(t, str) else t for t in items]
|
|
46
|
+
if not nodes:
|
|
47
|
+
raise ValueError("WrapFlow needs at least one item")
|
|
48
|
+
self.flow_gap = gap
|
|
49
|
+
g = float(self.flow_gap)
|
|
50
|
+
cgap = g if col_gap is None else float(col_gap)
|
|
51
|
+
if per_row is None:
|
|
52
|
+
if max_w is None:
|
|
53
|
+
per_row = len(nodes)
|
|
54
|
+
else:
|
|
55
|
+
widths = [n.measure()[0] for n in nodes]
|
|
56
|
+
wmax = max(widths)
|
|
57
|
+
per_row = max(1, int((float(max_w) + cgap) // (wmax + cgap)))
|
|
58
|
+
per_row = max(1, int(per_row))
|
|
59
|
+
self.items = nodes
|
|
60
|
+
self.per_row = per_row
|
|
61
|
+
self.mode = mode
|
|
62
|
+
cells: List[Node] = []
|
|
63
|
+
rows = (len(nodes) + per_row - 1) // per_row
|
|
64
|
+
self._rc: List[tuple] = []
|
|
65
|
+
for r in range(rows):
|
|
66
|
+
chunk = nodes[r * per_row:(r + 1) * per_row]
|
|
67
|
+
rev = mode == "snake" and r % 2 == 1
|
|
68
|
+
row: List[Node] = list(reversed(chunk)) if rev else list(chunk)
|
|
69
|
+
pad = per_row - len(chunk)
|
|
70
|
+
if pad:
|
|
71
|
+
fill = [Spacer() for _ in range(pad)]
|
|
72
|
+
row = fill + row if rev else row + fill
|
|
73
|
+
cells += row
|
|
74
|
+
for i in range(len(nodes)):
|
|
75
|
+
r, c = divmod(i, per_row)
|
|
76
|
+
rev = mode == "snake" and r % 2 == 1
|
|
77
|
+
self._rc.append((r, per_row - 1 - c if rev else c, rev))
|
|
78
|
+
if row_gap is None:
|
|
79
|
+
# z-mode return legs run along the row gap: leave a lane clear of the
|
|
80
|
+
# router's obstacle padding (5 pt each side) plus a stub's worth of air.
|
|
81
|
+
row_gap = max(g, 2 * _ROUTER_PAD + self.theme.L(6.0)) if mode == "z" else g
|
|
82
|
+
grid = Grid(cells, cols=per_row, gap=g, row_gap=float(row_gap), col_gap=cgap,
|
|
83
|
+
align=align, equal_cols=equal)
|
|
84
|
+
edges: List[Edge] = []
|
|
85
|
+
kw = dict(edge or {})
|
|
86
|
+
if arrows:
|
|
87
|
+
for i in range(len(nodes) - 1):
|
|
88
|
+
a, b = nodes[i], nodes[i + 1]
|
|
89
|
+
ra, _ca, reva = self._rc[i]
|
|
90
|
+
rb = self._rc[i + 1][0]
|
|
91
|
+
if ra == rb:
|
|
92
|
+
if reva:
|
|
93
|
+
edges.append(Edge(a.port("left"), b.port("right"), **kw))
|
|
94
|
+
else:
|
|
95
|
+
edges.append(Edge(a.port("right"), b.port("left"), **kw))
|
|
96
|
+
else:
|
|
97
|
+
edges.append(Edge(a.port("bottom"), b.port("top"), **kw))
|
|
98
|
+
super().__init__(grid, edges, **kwargs)
|
|
99
|
+
|
|
100
|
+
def edge(self, i: int) -> Edge:
|
|
101
|
+
"""The connector leaving item ``i``."""
|
|
102
|
+
return self.edges[i]
|
codefig/glyphs.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""Small generic glyph primitives (v0.2.0): Badge, Divider, BarChart, VectorFan, Swatch.
|
|
2
|
+
|
|
3
|
+
All are ordinary Nodes: they measure themselves, take part in flex layout and
|
|
4
|
+
constraints, expose ports, and are visible to overlap checks / edge routing.
|
|
5
|
+
None takes absolute coordinates.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from typing import Any, Callable, List, Optional, Sequence, Tuple, Union
|
|
12
|
+
|
|
13
|
+
from .node import Node
|
|
14
|
+
from .styles import theme_default
|
|
15
|
+
from .textutil import measure_text
|
|
16
|
+
|
|
17
|
+
_DASH = {"solid": None, "dashed": "5 3", "dotted": "1.5 2.5"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Badge(Node):
|
|
21
|
+
"""Text inside a pill, circle or rectangle sized to the text.
|
|
22
|
+
|
|
23
|
+
shape="pill" → tag/label pill (e.g. PREFILL); "circle" → circled number or
|
|
24
|
+
operator (①, ⊕ combiner); "rect" → plain chip.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
stroke = theme_default("stroke")
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
text: str = "",
|
|
32
|
+
*,
|
|
33
|
+
shape: str = "pill",
|
|
34
|
+
fill: str = "none",
|
|
35
|
+
stroke: Optional[str] = None,
|
|
36
|
+
stroke_width: float = 1.0,
|
|
37
|
+
text_color: str = "#111111",
|
|
38
|
+
font_size: Optional[float] = None,
|
|
39
|
+
weight: str = "normal",
|
|
40
|
+
pad: Optional[float] = None,
|
|
41
|
+
size: Optional[float] = None, # circle: fixed diameter
|
|
42
|
+
**kwargs: Any,
|
|
43
|
+
) -> None:
|
|
44
|
+
super().__init__(**kwargs)
|
|
45
|
+
if shape not in ("pill", "circle", "rect"):
|
|
46
|
+
raise ValueError(f"Badge shape must be pill|circle|rect, got {shape!r}")
|
|
47
|
+
self.text = text
|
|
48
|
+
self.shape = shape
|
|
49
|
+
self.fill = fill
|
|
50
|
+
self.stroke = stroke
|
|
51
|
+
self.stroke_width = float(stroke_width)
|
|
52
|
+
self.text_color = text_color
|
|
53
|
+
self.font_size = font_size
|
|
54
|
+
self.weight = weight
|
|
55
|
+
self.pad = pad
|
|
56
|
+
self.size = size
|
|
57
|
+
|
|
58
|
+
def _fs(self) -> float:
|
|
59
|
+
return self.font_size if self.font_size is not None else self.theme.font_size
|
|
60
|
+
|
|
61
|
+
def _measure(self) -> Tuple[float, float]:
|
|
62
|
+
fs = self._fs()
|
|
63
|
+
tw, th = measure_text(self.text, fs, weight=self.weight) if self.text else (0.0, fs)
|
|
64
|
+
th = min(th, fs * 1.25 * max(1, self.text.count("\n") + 1))
|
|
65
|
+
if self.shape == "circle":
|
|
66
|
+
pad = fs * 0.12 if self.pad is None else self.pad
|
|
67
|
+
d = self.size if self.size is not None else max(tw, fs) + 2 * pad
|
|
68
|
+
return d, d
|
|
69
|
+
pad = fs * 0.35 if self.pad is None else self.pad
|
|
70
|
+
return tw + 2 * pad + (fs * 0.2 if self.shape == "pill" else 0), th + 2 * pad * 0.35
|
|
71
|
+
|
|
72
|
+
def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
|
|
73
|
+
from .render_svg import draw_circle, draw_rect, draw_text
|
|
74
|
+
|
|
75
|
+
g = self.geom
|
|
76
|
+
x, y, w, h = g.x + ox, g.y + oy, g.w, g.h
|
|
77
|
+
sw = self.stroke_width if self.stroke not in (None, "none") else 0.0
|
|
78
|
+
if self.shape == "circle":
|
|
79
|
+
r = min(w, h) / 2 - sw / 2
|
|
80
|
+
draw_circle(buf, x + w / 2, y + h / 2, r, fill=self.fill, stroke=self.stroke or "none",
|
|
81
|
+
stroke_width=sw, dasharray=self._dash(sw))
|
|
82
|
+
else:
|
|
83
|
+
rad = h / 2 if self.shape == "pill" else 2
|
|
84
|
+
draw_rect(buf, x + sw / 2, y + sw / 2, w - sw, h - sw, fill=self.fill,
|
|
85
|
+
stroke=self.stroke or "none", stroke_width=sw, radius=rad,
|
|
86
|
+
dasharray=self._dash(sw))
|
|
87
|
+
if self.text:
|
|
88
|
+
fs = self._fs()
|
|
89
|
+
tw, _ = measure_text(self.text, fs, weight=self.weight)
|
|
90
|
+
draw_text(buf, self.text, x + (w - tw) / 2, y + h / 2 + fs * 0.35, font_size=fs,
|
|
91
|
+
fill=self.text_color, weight=self.weight)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Divider(Node):
|
|
95
|
+
"""A separator line. In a Row/Col with align="stretch" it spans the cross axis.
|
|
96
|
+
|
|
97
|
+
orientation: vertical (in a Row) | horizontal (in a Col).
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
orientation: str = "vertical",
|
|
103
|
+
*,
|
|
104
|
+
color: str = "#b0b0b0",
|
|
105
|
+
width: float = 1.0,
|
|
106
|
+
style: str = "dashed",
|
|
107
|
+
margin: float = 2.0,
|
|
108
|
+
**kwargs: Any,
|
|
109
|
+
) -> None:
|
|
110
|
+
kwargs.setdefault("align_self", "stretch")
|
|
111
|
+
super().__init__(**kwargs)
|
|
112
|
+
if orientation not in ("vertical", "horizontal"):
|
|
113
|
+
raise ValueError("orientation must be vertical|horizontal")
|
|
114
|
+
self.orientation = orientation
|
|
115
|
+
self.color = color
|
|
116
|
+
self.width = float(width)
|
|
117
|
+
self.style = style
|
|
118
|
+
self.margin = float(margin)
|
|
119
|
+
|
|
120
|
+
def _measure(self) -> Tuple[float, float]:
|
|
121
|
+
t = self.width + 2 * self.margin
|
|
122
|
+
return (t, 1.0) if self.orientation == "vertical" else (1.0, t)
|
|
123
|
+
|
|
124
|
+
def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
|
|
125
|
+
from .render_svg import draw_line
|
|
126
|
+
|
|
127
|
+
g = self.geom
|
|
128
|
+
if self.orientation == "vertical":
|
|
129
|
+
x = g.x + ox + g.w / 2
|
|
130
|
+
draw_line(buf, x, g.y + oy, x, g.y + oy + g.h, stroke=self.color,
|
|
131
|
+
stroke_width=self.width, dasharray=_DASH.get(self.style, self.style))
|
|
132
|
+
else:
|
|
133
|
+
y = g.y + oy + g.h / 2
|
|
134
|
+
draw_line(buf, g.x + ox, y, g.x + ox + g.w, y, stroke=self.color,
|
|
135
|
+
stroke_width=self.width, dasharray=_DASH.get(self.style, self.style))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class BarChart(Node):
|
|
139
|
+
"""Inline bar chart / sparkline: bars, highlighted bars, optional marker dots.
|
|
140
|
+
|
|
141
|
+
values: bar heights (any unit; scaled so the tallest item = ``height``).
|
|
142
|
+
highlight: {index: colour} or a set of indices (→ ``highlight_color``).
|
|
143
|
+
markers: {index: value} — a dot drawn at that value above bar ``index``.
|
|
144
|
+
baseline: draw an axis line under the bars.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
def __init__(
|
|
148
|
+
self,
|
|
149
|
+
values: Sequence[float],
|
|
150
|
+
*,
|
|
151
|
+
height: float = 60.0,
|
|
152
|
+
bar_w: float = 6.0,
|
|
153
|
+
bar_gap: float = 1.5,
|
|
154
|
+
color: str = "#c4c4c4",
|
|
155
|
+
highlight: Optional[Union[dict, Sequence[int]]] = None,
|
|
156
|
+
highlight_color: str = "#2e6fd0",
|
|
157
|
+
markers: Optional[dict] = None,
|
|
158
|
+
marker_color: str = "#7d3c98",
|
|
159
|
+
marker_size: float = 5.0,
|
|
160
|
+
baseline: bool = True,
|
|
161
|
+
baseline_color: str = "#888888",
|
|
162
|
+
**kwargs: Any,
|
|
163
|
+
) -> None:
|
|
164
|
+
super().__init__(**kwargs)
|
|
165
|
+
if not values:
|
|
166
|
+
raise ValueError("BarChart needs at least one value")
|
|
167
|
+
self.values = [float(v) for v in values]
|
|
168
|
+
self.height = float(height)
|
|
169
|
+
self.bar_w = float(bar_w)
|
|
170
|
+
self.bar_gap = float(bar_gap)
|
|
171
|
+
self.color = color
|
|
172
|
+
if highlight is None:
|
|
173
|
+
self.highlight: dict = {}
|
|
174
|
+
elif isinstance(highlight, dict):
|
|
175
|
+
self.highlight = dict(highlight)
|
|
176
|
+
else:
|
|
177
|
+
self.highlight = {int(i): highlight_color for i in highlight}
|
|
178
|
+
self.markers = dict(markers or {})
|
|
179
|
+
self.marker_color = marker_color
|
|
180
|
+
self.marker_size = float(marker_size)
|
|
181
|
+
self.baseline = baseline
|
|
182
|
+
self.baseline_color = baseline_color
|
|
183
|
+
|
|
184
|
+
def _top(self) -> float:
|
|
185
|
+
return max(list(self.values) + [float(v) for v in self.markers.values()] + [1e-9])
|
|
186
|
+
|
|
187
|
+
def _measure(self) -> Tuple[float, float]:
|
|
188
|
+
n = len(self.values)
|
|
189
|
+
w = n * self.bar_w + (n - 1) * self.bar_gap
|
|
190
|
+
return w, self.height + (self.marker_size / 2 + 1 if self.markers else 0) + 1.5
|
|
191
|
+
|
|
192
|
+
def _bar_x(self, i: int) -> float:
|
|
193
|
+
return self.geom.x + i * (self.bar_w + self.bar_gap)
|
|
194
|
+
|
|
195
|
+
def _bar_cx(self, i: int) -> float:
|
|
196
|
+
return self._bar_x(i) + self.bar_w / 2
|
|
197
|
+
|
|
198
|
+
def bar_at(self, x: float) -> int:
|
|
199
|
+
"""Index of the bar whose centre is nearest to page coordinate ``x``."""
|
|
200
|
+
return min(range(len(self.values)), key=lambda i: abs(self._bar_cx(i) - x))
|
|
201
|
+
|
|
202
|
+
def bar_anchor(self, i: int, which: str = "center"):
|
|
203
|
+
"""x-Anchor on bar ``i`` (start | center | end) — e.g. ``Brace(span=(…))``."""
|
|
204
|
+
from .constraints import Anchor
|
|
205
|
+
|
|
206
|
+
if not -len(self.values) <= i < len(self.values):
|
|
207
|
+
raise IndexError(f"bar {i} out of range")
|
|
208
|
+
i = i % len(self.values)
|
|
209
|
+
off = {"start": 0.0, "left": 0.0, "center": 0.5, "end": 1.0, "right": 1.0}[which]
|
|
210
|
+
return Anchor(self, "x", getter=lambda: self._bar_x(i) + self.bar_w * off,
|
|
211
|
+
label=f"bar[{i}].{which}")
|
|
212
|
+
|
|
213
|
+
def bars(self, i: int, j: int):
|
|
214
|
+
"""(start-of-bar-i, end-of-bar-j) anchors — a span for ``Brace(span=…)``."""
|
|
215
|
+
return self.bar_anchor(i, "start"), self.bar_anchor(j, "end")
|
|
216
|
+
|
|
217
|
+
def bar_port(self, i: int, side: str = "top"):
|
|
218
|
+
"""Port on bar ``i``: top → the bar's tip, bottom → under the baseline."""
|
|
219
|
+
from .edges import Port
|
|
220
|
+
|
|
221
|
+
if not 0 <= i < len(self.values):
|
|
222
|
+
raise IndexError(f"bar {i} out of range")
|
|
223
|
+
return Port(self, "cell", side, index=0, col_index=i)
|
|
224
|
+
|
|
225
|
+
def resolve_cell_port(self, _row: int, i: int, side: str):
|
|
226
|
+
g = self.geom
|
|
227
|
+
x = self._bar_x(i) + self.bar_w / 2
|
|
228
|
+
if side == "bottom":
|
|
229
|
+
return x, g.y + g.h, "down"
|
|
230
|
+
base = g.y + g.h - 1.5
|
|
231
|
+
return x, base - self.values[i] / self._top() * self.height, "up"
|
|
232
|
+
|
|
233
|
+
def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
|
|
234
|
+
from .render_svg import draw_circle, draw_line, draw_rect
|
|
235
|
+
|
|
236
|
+
g = self.geom
|
|
237
|
+
base = g.y + oy + g.h - 1.5
|
|
238
|
+
top = self._top()
|
|
239
|
+
for i, v in enumerate(self.values):
|
|
240
|
+
h = v / top * self.height
|
|
241
|
+
x = self._bar_x(i) + ox
|
|
242
|
+
draw_rect(buf, x, base - h, self.bar_w, h, fill=self.highlight.get(i, self.color),
|
|
243
|
+
stroke="none", stroke_width=0)
|
|
244
|
+
for i, v in self.markers.items():
|
|
245
|
+
if 0 <= int(i) < len(self.values):
|
|
246
|
+
cx = self._bar_x(int(i)) + ox + self.bar_w / 2
|
|
247
|
+
cy = base - float(v) / top * self.height
|
|
248
|
+
draw_circle(buf, cx, cy, self.marker_size / 2, fill=self.marker_color)
|
|
249
|
+
if self.baseline:
|
|
250
|
+
draw_line(buf, g.x + ox, base + 0.5, g.x + ox + g.w, base + 0.5,
|
|
251
|
+
stroke=self.baseline_color, stroke_width=self.theme.SW(1.0))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class VectorFan(Node):
|
|
255
|
+
"""Arrows radiating from one origin, with optional faint axes — the usual
|
|
256
|
+
"vectors point in many / one direction(s)" sketch.
|
|
257
|
+
|
|
258
|
+
angles: directions in degrees (0 = right, counter-clockwise).
|
|
259
|
+
lengths: optional per-arrow fractions of ``radius`` (default 1).
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
def __init__(
|
|
263
|
+
self,
|
|
264
|
+
angles: Sequence[float],
|
|
265
|
+
*,
|
|
266
|
+
lengths: Optional[Sequence[float]] = None,
|
|
267
|
+
radius: float = 22.0,
|
|
268
|
+
color: str = "#1f3a68",
|
|
269
|
+
colors: Optional[Sequence[str]] = None,
|
|
270
|
+
width: float = 1.1,
|
|
271
|
+
head_size: float = 4.5,
|
|
272
|
+
axes: bool = True,
|
|
273
|
+
axes_color: str = "#c8c8c8",
|
|
274
|
+
**kwargs: Any,
|
|
275
|
+
) -> None:
|
|
276
|
+
super().__init__(**kwargs)
|
|
277
|
+
self.angles = [float(a) for a in angles]
|
|
278
|
+
if lengths is not None and len(lengths) != len(self.angles):
|
|
279
|
+
raise ValueError("lengths must match angles")
|
|
280
|
+
self.lengths = [float(x) for x in lengths] if lengths is not None else [1.0] * len(self.angles)
|
|
281
|
+
self.radius = float(radius)
|
|
282
|
+
self.color = color
|
|
283
|
+
self.colors = list(colors) if colors is not None else None
|
|
284
|
+
self.width = float(width)
|
|
285
|
+
self.head_size = float(head_size)
|
|
286
|
+
self.axes = axes
|
|
287
|
+
self.axes_color = axes_color
|
|
288
|
+
|
|
289
|
+
def _measure(self) -> Tuple[float, float]:
|
|
290
|
+
d = 2 * (self.radius + self.head_size * 0.5 + 2)
|
|
291
|
+
return d, d
|
|
292
|
+
|
|
293
|
+
def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
|
|
294
|
+
from .render_svg import draw_arrowhead, draw_line
|
|
295
|
+
|
|
296
|
+
g = self.geom
|
|
297
|
+
cx, cy = g.x + ox + g.w / 2, g.y + oy + g.h / 2
|
|
298
|
+
r = self.radius
|
|
299
|
+
if self.axes:
|
|
300
|
+
draw_line(buf, cx - r * 1.1, cy, cx + r * 1.1, cy, stroke=self.axes_color, stroke_width=self.theme.SW(0.8))
|
|
301
|
+
draw_line(buf, cx, cy - r * 1.1, cx, cy + r * 1.1, stroke=self.axes_color, stroke_width=self.theme.SW(0.8))
|
|
302
|
+
for k, (a, f) in enumerate(zip(self.angles, self.lengths)):
|
|
303
|
+
col = self.colors[k % len(self.colors)] if self.colors else self.color
|
|
304
|
+
t = math.radians(a)
|
|
305
|
+
ex, ey = cx + math.cos(t) * r * f, cy - math.sin(t) * r * f
|
|
306
|
+
draw_line(buf, cx, cy, ex, ey, stroke=col, stroke_width=self.width)
|
|
307
|
+
draw_arrowhead(buf, ex, ey, math.atan2(ey - cy, ex - cx), size=self.head_size, color=col)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class Swatch(Node):
|
|
311
|
+
"""Legend key: kind = square | rect | dot | line | arrow.
|
|
312
|
+
|
|
313
|
+
line / arrow honour ``style`` (solid | dashed | dotted | custom dash) and arrows
|
|
314
|
+
take ``head`` / ``tail`` / ``hollow`` like Edge; square / rect take ``fill``-colour
|
|
315
|
+
``color``, ``stroke``, ``border`` and ``pattern`` (e.g. ``Hatch()``).
|
|
316
|
+
"""
|
|
317
|
+
|
|
318
|
+
def __init__(
|
|
319
|
+
self,
|
|
320
|
+
kind: str = "square",
|
|
321
|
+
*,
|
|
322
|
+
color: str = "#2e6fd0",
|
|
323
|
+
size: float = 9.0,
|
|
324
|
+
length: float = 22.0,
|
|
325
|
+
width: float = 1.5,
|
|
326
|
+
style: Any = "solid",
|
|
327
|
+
head: Any = None,
|
|
328
|
+
tail: Any = None,
|
|
329
|
+
hollow: Optional[bool] = None,
|
|
330
|
+
head_size: Optional[float] = None,
|
|
331
|
+
stroke: Optional[str] = None,
|
|
332
|
+
pattern: Any = None,
|
|
333
|
+
**kwargs: Any,
|
|
334
|
+
) -> None:
|
|
335
|
+
super().__init__(**kwargs)
|
|
336
|
+
if kind not in ("square", "rect", "dot", "line", "arrow"):
|
|
337
|
+
raise ValueError("Swatch kind must be square|rect|dot|line|arrow")
|
|
338
|
+
self.kind = kind
|
|
339
|
+
self.color = color
|
|
340
|
+
self.size = float(size)
|
|
341
|
+
self.length = float(length)
|
|
342
|
+
self.width = float(width)
|
|
343
|
+
self.style = style
|
|
344
|
+
self.head = head
|
|
345
|
+
self.tail = tail
|
|
346
|
+
self.hollow = hollow
|
|
347
|
+
self.head_size = head_size
|
|
348
|
+
self.stroke = stroke
|
|
349
|
+
self.pattern = pattern
|
|
350
|
+
|
|
351
|
+
def _measure(self) -> Tuple[float, float]:
|
|
352
|
+
if self.kind in ("line", "arrow"):
|
|
353
|
+
return self.length, max(self.size, 6.0)
|
|
354
|
+
if self.kind == "rect":
|
|
355
|
+
return self.length, self.size
|
|
356
|
+
return self.size, self.size
|
|
357
|
+
|
|
358
|
+
def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
|
|
359
|
+
from .legend import Key, draw_key
|
|
360
|
+
|
|
361
|
+
g = self.geom
|
|
362
|
+
x, y, w, h = g.x + ox, g.y + oy, g.w, g.h
|
|
363
|
+
if self.kind in ("square", "rect"):
|
|
364
|
+
s = min(w, h)
|
|
365
|
+
bw = s if self.kind == "square" else w
|
|
366
|
+
stroke = self.stroke if self.stroke is not None else (
|
|
367
|
+
"#555555" if self.border not in (None, "none") else "none")
|
|
368
|
+
k = Key("", kind="box", fill=self.color, stroke=stroke, border=self.border,
|
|
369
|
+
pattern=self.pattern, radius=0.0)
|
|
370
|
+
if stroke == "none":
|
|
371
|
+
from .render_svg import draw_rect
|
|
372
|
+
|
|
373
|
+
draw_rect(buf, x + (w - bw) / 2, y + (h - s) / 2, bw, s, fill=self.color,
|
|
374
|
+
stroke="none", stroke_width=0)
|
|
375
|
+
if self.pattern is not None:
|
|
376
|
+
from .primitives import _paint_node_pattern
|
|
377
|
+
|
|
378
|
+
_paint_node_pattern(buf, self.pattern, x + (w - bw) / 2, y + (h - s) / 2, bw, s)
|
|
379
|
+
else:
|
|
380
|
+
draw_key(buf, k, x + (w - bw) / 2, y + (h - s) / 2, bw, s, theme=self.theme)
|
|
381
|
+
elif self.kind == "dot":
|
|
382
|
+
from .render_svg import draw_circle
|
|
383
|
+
|
|
384
|
+
draw_circle(buf, x + w / 2, y + h / 2, min(w, h) / 2, fill=self.color)
|
|
385
|
+
else:
|
|
386
|
+
head = self.head if self.head is not None else ("triangle" if self.kind == "arrow" else "none")
|
|
387
|
+
from .styles import Head
|
|
388
|
+
|
|
389
|
+
if self.head_size is not None and isinstance(head, str):
|
|
390
|
+
head = Head(head, size=self.head_size)
|
|
391
|
+
elif self.head_size is None and isinstance(head, str) and head != "none":
|
|
392
|
+
head = Head(head, size=6.0)
|
|
393
|
+
k = Key("", kind="arrow", color=self.color, style=self.style, width=self.width,
|
|
394
|
+
head=head, tail=self.tail, hollow=self.hollow)
|
|
395
|
+
draw_key(buf, k, x, y, w, h, theme=self.theme)
|