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/timeline.py
ADDED
|
@@ -0,0 +1,1288 @@
|
|
|
1
|
+
"""Timelines (Gantt / pipeline schedules) and swimlanes.
|
|
2
|
+
|
|
3
|
+
* :class:`Timeline` — named lanes with tasks placed in *data units* (time, steps,
|
|
4
|
+
iterations …): ``tl.task("GPU0", 0, 2.5, "A_p")``. Idle bubbles, dependency arrows,
|
|
5
|
+
vertical markers, braces / brackets / dimension spans over a time range and an
|
|
6
|
+
optional time axis with ticks. One unit = ``unit`` pt. Covers Sarathi-Serve style
|
|
7
|
+
stall diagrams, 1F1B / GPipe pipeline schedules, Llumnix migration timelines,
|
|
8
|
+
FlexGen schedules.
|
|
9
|
+
* :class:`Lanes` / :class:`Lane` — swimlane container for arbitrary nodes with lane
|
|
10
|
+
headers (horizontal or vertical). Put it in a :class:`~codefig.edges.Diagram` to
|
|
11
|
+
draw edges that cross lanes.
|
|
12
|
+
|
|
13
|
+
No author coordinates: lanes are referenced by name and tasks by their start / dur
|
|
14
|
+
in data units; everything else is laid out automatically.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import math
|
|
20
|
+
import warnings
|
|
21
|
+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union
|
|
22
|
+
|
|
23
|
+
from .constraints import Anchor
|
|
24
|
+
from .edges import Diagram, Edge, Port
|
|
25
|
+
from .node import BoxGeom, Node
|
|
26
|
+
from .primitives import Box, Text, _brace_path_horizontal
|
|
27
|
+
from .styles import Theme, current_theme, themed
|
|
28
|
+
from .textutil import measure_text
|
|
29
|
+
|
|
30
|
+
SPAN_STYLES = ("brace", "bracket", "dim")
|
|
31
|
+
MARKER_STYLES = ("dashed", "dotted", "solid")
|
|
32
|
+
TASK_KINDS = ("task", "idle", "gap")
|
|
33
|
+
|
|
34
|
+
_DASH = {"dashed": "5 3", "dotted": "1.5 2.5", "solid": None}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TimelineWarning(UserWarning):
|
|
38
|
+
"""A task label could not be fitted inside its bar, even at the minimum size."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ============================================================================ Task
|
|
42
|
+
class Task(Box):
|
|
43
|
+
"""A bar on one :class:`Timeline` lane — created by :meth:`Timeline.task` /
|
|
44
|
+
:meth:`Timeline.idle`, not directly.
|
|
45
|
+
|
|
46
|
+
It is a :class:`~codefig.primitives.Box` (role **hard**) whose geometry comes
|
|
47
|
+
from ``start`` / ``dur`` (data units) and the lane; ``port()``, ``legend=``,
|
|
48
|
+
``pattern=``, ``step=`` and constraints work as on any Box. The label font shrinks
|
|
49
|
+
(down to 60 %) to fit the bar; a label that still does not fit emits
|
|
50
|
+
:class:`TimelineWarning`.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
lane: Lane name.
|
|
54
|
+
start: Start time (data units).
|
|
55
|
+
dur: Duration (data units).
|
|
56
|
+
end: ``start + dur``.
|
|
57
|
+
kind: ``"task"``, ``"idle"`` or ``"gap"`` (an elided stretch, :meth:`Timeline.elide`).
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, label: str = "", *, lane: str, start: float, dur: float,
|
|
61
|
+
kind: str = "task", **box_kw: Any) -> None:
|
|
62
|
+
if dur <= 0:
|
|
63
|
+
raise ValueError(f"task duration must be > 0, got {dur!r}")
|
|
64
|
+
if kind not in TASK_KINDS:
|
|
65
|
+
raise ValueError(f"kind must be one of {TASK_KINDS}")
|
|
66
|
+
box_kw.setdefault("pad", 0)
|
|
67
|
+
super().__init__(label, **box_kw)
|
|
68
|
+
self.lane = lane
|
|
69
|
+
self.start = float(start)
|
|
70
|
+
self.dur = float(dur)
|
|
71
|
+
self.kind = kind
|
|
72
|
+
self._fit_fs: Optional[float] = None
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def end(self) -> float:
|
|
76
|
+
return self.start + self.dur
|
|
77
|
+
|
|
78
|
+
def _fs(self) -> float:
|
|
79
|
+
return self._fit_fs if self._fit_fs is not None else super()._fs()
|
|
80
|
+
|
|
81
|
+
def __repr__(self) -> str:
|
|
82
|
+
return f"Task({self.text!r}, lane={self.lane!r}, start={self.start:g}, dur={self.dur:g})"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ===================================================================== guide parts
|
|
86
|
+
class _Guide(Node):
|
|
87
|
+
"""Invisible-to-check_overlaps scenery (lane bands, plot area, axis line)."""
|
|
88
|
+
|
|
89
|
+
_cf_guide = True
|
|
90
|
+
|
|
91
|
+
def __init__(self, **kw: Any) -> None:
|
|
92
|
+
kw.setdefault("obstacle", "background")
|
|
93
|
+
super().__init__(**kw)
|
|
94
|
+
|
|
95
|
+
def _measure(self) -> Tuple[float, float]:
|
|
96
|
+
return (self.geom.w, self.geom.h)
|
|
97
|
+
|
|
98
|
+
def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class LaneBand(_Guide):
|
|
103
|
+
"""Background band of one Timeline lane (``timeline.lane("GPU0")``).
|
|
104
|
+
|
|
105
|
+
Role **background** and a *guide*: check_overlaps() ignores it (its tasks sit
|
|
106
|
+
on it by design); edges may cross it. Use it as a reference for ports / Dim /
|
|
107
|
+
constraints, e.g. ``Dim(tl.lane("GPU0"), side="left")``.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(self, name: str, *, fill: Optional[str] = None, **kw: Any) -> None:
|
|
111
|
+
super().__init__(**kw)
|
|
112
|
+
self.name = name
|
|
113
|
+
self.fill = fill
|
|
114
|
+
|
|
115
|
+
def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
|
|
116
|
+
from .render_svg import draw_rect
|
|
117
|
+
|
|
118
|
+
if self.fill:
|
|
119
|
+
g = self.geom
|
|
120
|
+
draw_rect(buf, g.x + ox, g.y + oy, g.w, g.h, fill=self.fill, stroke="none",
|
|
121
|
+
stroke_width=0)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class Marker(_Guide):
|
|
125
|
+
"""Vertical guide line at time ``t`` across all lanes (``timeline.marker(...)``).
|
|
126
|
+
|
|
127
|
+
The line is a background *guide* (ignored by check_overlaps(), crossable by
|
|
128
|
+
edges); its label is a **soft** Text child placed in the band above (or below)
|
|
129
|
+
the lanes and packed so it never collides with span labels.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
width = themed(1.0, "SW")
|
|
133
|
+
|
|
134
|
+
def __init__(self, t: float, label: str = "", *, style: str = "dashed",
|
|
135
|
+
color: str = "#555555", width: Optional[float] = None, label_pos: str = "top",
|
|
136
|
+
font_size: float = 11.0, label_color: Optional[str] = None,
|
|
137
|
+
theme: Optional[Theme] = None) -> None:
|
|
138
|
+
if style not in MARKER_STYLES:
|
|
139
|
+
raise ValueError(f"marker style must be one of {MARKER_STYLES}")
|
|
140
|
+
if label_pos not in ("top", "bottom"):
|
|
141
|
+
raise ValueError("marker label_pos must be top|bottom")
|
|
142
|
+
super().__init__(theme=theme)
|
|
143
|
+
self.t = float(t)
|
|
144
|
+
self.style = style
|
|
145
|
+
self.color = color
|
|
146
|
+
self.width = width
|
|
147
|
+
self.label_pos = label_pos
|
|
148
|
+
self.label = Text(label, font_size=font_size, color=label_color or color,
|
|
149
|
+
theme=theme) if label else None
|
|
150
|
+
if self.label is not None:
|
|
151
|
+
self.label.parent = self
|
|
152
|
+
self._line: Tuple[float, float, float] = (0.0, 0.0, 0.0) # x, y0, y1 (relative)
|
|
153
|
+
|
|
154
|
+
def children(self) -> Sequence[Node]:
|
|
155
|
+
return (self.label,) if self.label is not None else ()
|
|
156
|
+
|
|
157
|
+
def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
|
|
158
|
+
from .render_svg import draw_line
|
|
159
|
+
|
|
160
|
+
# v0.12.0: draw from the (possibly translated) geom, not the layout-time
|
|
161
|
+
# snapshot — post-layout passes that move the timeline moved tasks but left
|
|
162
|
+
# marker lines ~5 pt behind (t4 eval).
|
|
163
|
+
g = self.geom
|
|
164
|
+
x, y0, y1 = g.x + g.w / 2, g.y, g.y + g.h
|
|
165
|
+
draw_line(buf, x + ox, y0 + oy, x + ox, y1 + oy, stroke=self.color,
|
|
166
|
+
stroke_width=self.width, dasharray=_DASH[self.style])
|
|
167
|
+
if self.label is not None:
|
|
168
|
+
self.label.draw(buf, ox, oy)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class TimeSpan(Node):
|
|
172
|
+
"""Brace / bracket / dimension line over a time range (``timeline.span(...)``).
|
|
173
|
+
|
|
174
|
+
Role **hard** (the drawn strip); its label is a **soft** Text child above
|
|
175
|
+
(``side="top"``) or below the strip — on the line for ``style="dim"``, where the
|
|
176
|
+
line is cut around it (knockout). Spans and marker labels are packed into levels
|
|
177
|
+
automatically so they never collide.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
width = themed(1.2, "SW")
|
|
181
|
+
|
|
182
|
+
def __init__(self, start: float, end: float, label: str = "", *, style: str = "brace",
|
|
183
|
+
side: str = "top", color: str = "#333333", width: Optional[float] = None,
|
|
184
|
+
font_size: float = 11.0, weight: str = "normal",
|
|
185
|
+
label_color: Optional[str] = None, theme: Optional[Theme] = None) -> None:
|
|
186
|
+
if style not in SPAN_STYLES:
|
|
187
|
+
raise ValueError(f"span style must be one of {SPAN_STYLES}")
|
|
188
|
+
if side not in ("top", "bottom"):
|
|
189
|
+
raise ValueError("span side must be top|bottom")
|
|
190
|
+
if end <= start:
|
|
191
|
+
raise ValueError("span end must be > start")
|
|
192
|
+
super().__init__(theme=theme)
|
|
193
|
+
self.start, self.end = float(start), float(end)
|
|
194
|
+
self.style, self.side = style, side
|
|
195
|
+
self.color, self.width = color, width
|
|
196
|
+
self.label = Text(label, font_size=font_size, weight=weight,
|
|
197
|
+
color=label_color or color, theme=theme) if label else None
|
|
198
|
+
if self.label is not None:
|
|
199
|
+
self.label.parent = self
|
|
200
|
+
|
|
201
|
+
def strip_h(self) -> float:
|
|
202
|
+
return {"brace": 9.0, "bracket": 6.0, "dim": 10.0}[self.style]
|
|
203
|
+
|
|
204
|
+
def children(self) -> Sequence[Node]:
|
|
205
|
+
return (self.label,) if self.label is not None else ()
|
|
206
|
+
|
|
207
|
+
def _measure(self) -> Tuple[float, float]:
|
|
208
|
+
return (self.geom.w, self.geom.h)
|
|
209
|
+
|
|
210
|
+
def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
|
|
211
|
+
from .render_svg import draw_arrowhead, draw_line, draw_path
|
|
212
|
+
|
|
213
|
+
g = self.geom
|
|
214
|
+
x, y, w, h = g.x + ox, g.y + oy, g.w, g.h
|
|
215
|
+
if self.style in ("brace", "bracket"):
|
|
216
|
+
d = _brace_path_horizontal(x, y, w, h, open_up=self.side == "top",
|
|
217
|
+
style="curly" if self.style == "brace" else "square")
|
|
218
|
+
draw_path(buf, d, stroke=self.color, stroke_width=self.width)
|
|
219
|
+
else:
|
|
220
|
+
cy = y + h / 2
|
|
221
|
+
segs = [(x, x + w)]
|
|
222
|
+
if self.label is not None:
|
|
223
|
+
lg = self.label.geom
|
|
224
|
+
L = self.theme.L
|
|
225
|
+
a, b = lg.x + ox - L(3), lg.x + ox + lg.w + L(3)
|
|
226
|
+
segs = [(x, a), (b, x + w)] if a > x + L(8) and b < x + w - L(8) else segs
|
|
227
|
+
for s0, s1 in segs:
|
|
228
|
+
draw_line(buf, s0, cy, s1, cy, stroke=self.color, stroke_width=self.width)
|
|
229
|
+
for xx in (x, x + w):
|
|
230
|
+
draw_line(buf, xx, y, xx, y + h, stroke=self.color, stroke_width=self.width)
|
|
231
|
+
hl, hw = self.theme.head_dims(5, 4)
|
|
232
|
+
draw_arrowhead(buf, x, cy, math.pi, size=hl, length=hl, width=hw, color=self.color)
|
|
233
|
+
draw_arrowhead(buf, x + w, cy, 0.0, size=hl, length=hl, width=hw, color=self.color)
|
|
234
|
+
if self.label is not None:
|
|
235
|
+
self.label.draw(buf, ox, oy)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class _Axis(_Guide):
|
|
239
|
+
"""Time axis: line + ticks + arrow (guide); tick labels / title are soft Text kids."""
|
|
240
|
+
|
|
241
|
+
def __init__(self, *, color: str, title: Optional[Text], tick_labels: List[Text],
|
|
242
|
+
theme: Optional[Theme] = None) -> None:
|
|
243
|
+
super().__init__(theme=theme)
|
|
244
|
+
self.color = color
|
|
245
|
+
self.title = title
|
|
246
|
+
self.tick_labels = tick_labels
|
|
247
|
+
for t in self.children():
|
|
248
|
+
t.parent = self
|
|
249
|
+
self._line: Tuple[float, float, float] = (0.0, 0.0, 0.0) # x0, x1, y (relative)
|
|
250
|
+
self._ticks: List[float] = []
|
|
251
|
+
|
|
252
|
+
def children(self) -> Sequence[Node]:
|
|
253
|
+
return list(self.tick_labels) + ([self.title] if self.title is not None else [])
|
|
254
|
+
|
|
255
|
+
def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
|
|
256
|
+
from .render_svg import draw_arrowhead, draw_line
|
|
257
|
+
|
|
258
|
+
th = self.theme
|
|
259
|
+
g = self.geom
|
|
260
|
+
lx0, lx1, ly = self._line
|
|
261
|
+
dx, dy = g.x - self.__dict__.get("_lay_x", g.x), g.y - self.__dict__.get("_lay_y", g.y)
|
|
262
|
+
x0, x1, y = lx0 + dx, lx1 + dx, ly + dy
|
|
263
|
+
draw_line(buf, x0 + ox, y + oy, x1 + ox - th.L(2), y + oy, stroke=self.color,
|
|
264
|
+
stroke_width=th.SW(1.2))
|
|
265
|
+
hl, hw = th.head_dims(7, 6)
|
|
266
|
+
draw_arrowhead(buf, x1 + ox, y + oy, 0.0, size=hl, length=hl, width=hw, color=self.color)
|
|
267
|
+
for tx in self._ticks:
|
|
268
|
+
tx += dx
|
|
269
|
+
draw_line(buf, tx + ox, y + oy, tx + ox, y + oy + th.L(4), stroke=self.color,
|
|
270
|
+
stroke_width=th.SW(1.0))
|
|
271
|
+
for t in self.children():
|
|
272
|
+
t.draw(buf, ox, oy)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class Event(_Guide):
|
|
276
|
+
"""v0.12.0: a point event on the time axis — a small ▲ under the lanes (on the
|
|
277
|
+
axis line, below its tick labels, when there is an axis) with a label under it.
|
|
278
|
+
Created by :meth:`Timeline.event`. The glyph is a guide; the label is **soft**
|
|
279
|
+
Text, packed on levels so neighbouring event labels never collide.
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
def __init__(self, t: float, label: str = "", *, color: str = "#333333",
|
|
283
|
+
font_size: float = 11.0, theme: Optional[Theme] = None) -> None:
|
|
284
|
+
super().__init__(theme=theme)
|
|
285
|
+
self.t = float(t)
|
|
286
|
+
self.color = color
|
|
287
|
+
self.label = Text(label, font_size=font_size, color=color, theme=theme) if label else None
|
|
288
|
+
if self.label is not None:
|
|
289
|
+
self.label.parent = self
|
|
290
|
+
|
|
291
|
+
def children(self) -> Sequence[Node]:
|
|
292
|
+
return (self.label,) if self.label is not None else ()
|
|
293
|
+
|
|
294
|
+
def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
|
|
295
|
+
from .render_svg import draw_path
|
|
296
|
+
|
|
297
|
+
g = self.geom
|
|
298
|
+
cx, y0, y1 = g.x + g.w / 2 + ox, g.y + oy, g.y + g.h + oy
|
|
299
|
+
d = f"M{cx:.2f},{y0:.2f} L{g.x + g.w + ox:.2f},{y1:.2f} L{g.x + ox:.2f},{y1:.2f} Z"
|
|
300
|
+
draw_path(buf, d, fill=self.color, stroke="none", stroke_width=0)
|
|
301
|
+
if self.label is not None:
|
|
302
|
+
self.label.draw(buf, ox, oy)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# ============================================================================ body
|
|
306
|
+
class _TimelineBody(Node):
|
|
307
|
+
"""Layout-only content of a Timeline (places lanes, tasks, spans, markers, axis)."""
|
|
308
|
+
|
|
309
|
+
def __init__(self, tl: "Timeline", theme: Theme) -> None:
|
|
310
|
+
super().__init__(theme=theme)
|
|
311
|
+
self.tl = tl
|
|
312
|
+
self._plan: Optional[Dict[str, Any]] = None
|
|
313
|
+
|
|
314
|
+
def children(self) -> Sequence[Node]:
|
|
315
|
+
tl = self.tl
|
|
316
|
+
out: List[Node] = [tl._plot]
|
|
317
|
+
out += [tl._bands[n] for n in tl.lanes]
|
|
318
|
+
out += [tl._headers[n] for n in tl.lanes if tl._headers[n] is not None]
|
|
319
|
+
out += tl.tasks
|
|
320
|
+
out += tl.spans
|
|
321
|
+
out += tl.markers
|
|
322
|
+
out += tl.events
|
|
323
|
+
if tl._axis is not None:
|
|
324
|
+
out.append(tl._axis)
|
|
325
|
+
return out
|
|
326
|
+
|
|
327
|
+
def invalidate(self) -> None:
|
|
328
|
+
super().invalidate()
|
|
329
|
+
self._plan = None
|
|
330
|
+
for n in self.children():
|
|
331
|
+
n.invalidate()
|
|
332
|
+
|
|
333
|
+
# ---- planning (relative coordinates, origin = body top-left) -------------
|
|
334
|
+
def _make_plan(self) -> Dict[str, Any]:
|
|
335
|
+
_L = self.theme.L
|
|
336
|
+
tl = self.tl
|
|
337
|
+
fs = tl._fs()
|
|
338
|
+
lh = fs * 1.25
|
|
339
|
+
heads = [tl._headers[n] for n in tl.lanes]
|
|
340
|
+
hw = tl.header_w if tl.header_w is not None else max(
|
|
341
|
+
[h.measure()[0] for h in heads if h is not None], default=0.0)
|
|
342
|
+
hgap = _L(8.0) if hw > 0 else 0.0
|
|
343
|
+
t0 = tl.start
|
|
344
|
+
cands = [t.end for t in tl.tasks] + [m.t for m in tl.markers] + [s.end for s in tl.spans]
|
|
345
|
+
cands += [e.t for e in tl.events]
|
|
346
|
+
if tl._ticks_explicit:
|
|
347
|
+
cands += list(tl._ticks_explicit)
|
|
348
|
+
t1 = tl.end if tl.end is not None else max(cands + [t0 + 1.0])
|
|
349
|
+
if t1 <= t0:
|
|
350
|
+
raise ValueError("Timeline end must be after start")
|
|
351
|
+
upx = (tl.width / (t1 - t0)) if tl.width is not None else tl.unit
|
|
352
|
+
px0 = hw + hgap
|
|
353
|
+
|
|
354
|
+
def X(t: float) -> float:
|
|
355
|
+
return px0 + (t - t0) * upx
|
|
356
|
+
|
|
357
|
+
# --- level packing for top / bottom annotation bands ---
|
|
358
|
+
def pack(items: List[Tuple[float, float, float, Any]]) -> List[Tuple[float, List[Any]]]:
|
|
359
|
+
levels: List[Tuple[float, List[Any], float]] = [] # (height, objs, right edge)
|
|
360
|
+
out: List[List[Any]] = []
|
|
361
|
+
hs: List[float] = []
|
|
362
|
+
rights: List[float] = []
|
|
363
|
+
last: List[Any] = []
|
|
364
|
+
|
|
365
|
+
def snug(o: Any, x0: float, x1: float) -> bool:
|
|
366
|
+
# a span whose label fits inside its own range: neighbours may touch it
|
|
367
|
+
return (isinstance(o, TimeSpan) and abs(x0 - X(o.start)) < 1e-6
|
|
368
|
+
and abs(x1 - X(o.end)) < 1e-6)
|
|
369
|
+
|
|
370
|
+
for x0, x1, h, obj in items:
|
|
371
|
+
for i in range(len(out)):
|
|
372
|
+
px0, px1, _ph, pobj = out[i][-1]
|
|
373
|
+
gap = -1e-6 if snug(obj, x0, x1) and snug(pobj, px0, px1) else _L(6.0)
|
|
374
|
+
if x0 >= rights[i] + gap:
|
|
375
|
+
out[i].append((x0, x1, h, obj))
|
|
376
|
+
hs[i] = max(hs[i], h)
|
|
377
|
+
rights[i] = x1
|
|
378
|
+
break
|
|
379
|
+
else:
|
|
380
|
+
out.append([(x0, x1, h, obj)])
|
|
381
|
+
hs.append(h)
|
|
382
|
+
rights.append(x1)
|
|
383
|
+
return [(hs[i], out[i]) for i in range(len(out))]
|
|
384
|
+
|
|
385
|
+
def _dim_fits(s: TimeSpan, a: float, b: float, lw: float) -> bool:
|
|
386
|
+
# v0.12 (B6): a dim label sits on the line only when the line keeps ≥ 8 pt
|
|
387
|
+
# each side of it; otherwise it moves off the line like a brace label
|
|
388
|
+
return s.label is None or lw + _L(6.0) < (b - a) - _L(16.0)
|
|
389
|
+
|
|
390
|
+
self._dim_fits = _dim_fits
|
|
391
|
+
|
|
392
|
+
def span_item(s: TimeSpan) -> Tuple[float, float, float, Any]:
|
|
393
|
+
a, b = X(s.start), X(s.end)
|
|
394
|
+
lw, lhh = s.label.measure() if s.label is not None else (0.0, 0.0)
|
|
395
|
+
c = (a + b) / 2
|
|
396
|
+
x0, x1 = min(a, c - lw / 2), max(b, c + lw / 2)
|
|
397
|
+
if s.style == "dim" and _dim_fits(s, a, b, lw):
|
|
398
|
+
h = max(lhh, s.strip_h())
|
|
399
|
+
else:
|
|
400
|
+
h = s.strip_h() + (_L(2.0) + lhh if s.label is not None else 0.0)
|
|
401
|
+
return (x0, x1, h, s)
|
|
402
|
+
|
|
403
|
+
def marker_item(m: Marker) -> Tuple[float, float, float, Any]:
|
|
404
|
+
lw, lhh = m.label.measure()
|
|
405
|
+
c = X(m.t)
|
|
406
|
+
return (c - lw / 2, c + lw / 2, lhh, m)
|
|
407
|
+
|
|
408
|
+
top_items = [marker_item(m) for m in tl.markers if m.label is not None and m.label_pos == "top"]
|
|
409
|
+
bot_items = [marker_item(m) for m in tl.markers if m.label is not None and m.label_pos == "bottom"]
|
|
410
|
+
top_items.sort(key=lambda it: it[0])
|
|
411
|
+
bot_items.sort(key=lambda it: it[0])
|
|
412
|
+
top_levels = pack(top_items) if top_items else []
|
|
413
|
+
bot_levels = pack(bot_items) if bot_items else []
|
|
414
|
+
# spans pack *outside* the marker-label levels
|
|
415
|
+
sp_top = sorted((span_item(s) for s in tl.spans if s.side == "top"), key=lambda i: i[0])
|
|
416
|
+
sp_bot = sorted((span_item(s) for s in tl.spans if s.side == "bottom"), key=lambda i: i[0])
|
|
417
|
+
top_levels += pack(sp_top) if sp_top else []
|
|
418
|
+
bot_levels += pack(sp_bot) if sp_bot else []
|
|
419
|
+
lgap = _L(5.0)
|
|
420
|
+
top_h = sum(h + lgap for h, _ in top_levels)
|
|
421
|
+
n = len(tl.lanes)
|
|
422
|
+
lanes_top = top_h + (_L(2.0) if top_levels else 0.0)
|
|
423
|
+
lg = tl.lane_gap if not tl._cross_deps else max(tl.lane_gap, _L(12.0))
|
|
424
|
+
lane_y = {name: lanes_top + i * (tl.lane_h + lg) for i, name in enumerate(tl.lanes)}
|
|
425
|
+
lanes_bot = lanes_top + n * tl.lane_h + max(n - 1, 0) * lg
|
|
426
|
+
place: Dict[int, Tuple[float, float, float, float]] = {} # id(obj) → x0,x1,y,h (level slot)
|
|
427
|
+
y = lanes_top - _L(2.0)
|
|
428
|
+
for h, objs in top_levels:
|
|
429
|
+
y -= lgap + h
|
|
430
|
+
for x0, x1, _h, obj in objs:
|
|
431
|
+
place[id(obj)] = (x0, x1, y, h)
|
|
432
|
+
y = lanes_bot + _L(2.0)
|
|
433
|
+
for h, objs in bot_levels:
|
|
434
|
+
y += lgap
|
|
435
|
+
for x0, x1, _h, obj in objs:
|
|
436
|
+
place[id(obj)] = (x0, x1, y, h)
|
|
437
|
+
y += h
|
|
438
|
+
bottom = y if bot_levels else lanes_bot
|
|
439
|
+
axis_y = None
|
|
440
|
+
x_right = X(t1)
|
|
441
|
+
if tl._axis is not None:
|
|
442
|
+
axis_y = bottom + _L(8.0)
|
|
443
|
+
bottom = axis_y + _L(4.0) + (_L(2.0) + lh if tl._axis.tick_labels else 0.0)
|
|
444
|
+
x_right = X(t1) + _L(14.0)
|
|
445
|
+
if tl._axis.title is not None:
|
|
446
|
+
tw, th = tl._axis.title.measure()
|
|
447
|
+
x_right += _L(4.0) + tw
|
|
448
|
+
bottom = max(bottom, axis_y + th / 2)
|
|
449
|
+
# v0.12.0 (C8): point events — ▲ under the lanes / on the axis, labels packed
|
|
450
|
+
ev_y = None
|
|
451
|
+
ev_tri = _L(5.0)
|
|
452
|
+
if tl.events:
|
|
453
|
+
if axis_y is not None:
|
|
454
|
+
ev_y = axis_y + (_L(5.0) + lh + _L(2.0) if tl._ticks is not None else _L(1.5))
|
|
455
|
+
else:
|
|
456
|
+
ev_y = bottom + _L(4.0)
|
|
457
|
+
ev_items = []
|
|
458
|
+
for e in tl.events:
|
|
459
|
+
c = X(e.t)
|
|
460
|
+
lw, lhh = e.label.measure() if e.label is not None else (0.0, 0.0)
|
|
461
|
+
ev_items.append((c - max(lw / 2, ev_tri * 0.6), c + max(lw / 2, ev_tri * 0.6), lhh, e))
|
|
462
|
+
ev_items.sort(key=lambda it: it[0])
|
|
463
|
+
yy = ev_y + ev_tri + _L(1.5)
|
|
464
|
+
for h, objs in pack(ev_items):
|
|
465
|
+
for x0, x1, _h, obj in objs:
|
|
466
|
+
place[id(obj)] = (x0, x1, yy, h)
|
|
467
|
+
yy += h + _L(1.0)
|
|
468
|
+
bottom = max(bottom, yy)
|
|
469
|
+
lefts = [0.0] + [p[0] for p in place.values()]
|
|
470
|
+
rights = [x_right] + [p[1] for p in place.values()]
|
|
471
|
+
tick_x = [X(t) for t in tl._tick_values(t0, t1)]
|
|
472
|
+
if tl._axis is not None and tl._axis.tick_labels:
|
|
473
|
+
for tx, lab in zip(tick_x, tl._axis.tick_labels):
|
|
474
|
+
w = lab.measure()[0]
|
|
475
|
+
lefts.append(tx - w / 2)
|
|
476
|
+
rights.append(tx + w / 2)
|
|
477
|
+
shift = -min(lefts)
|
|
478
|
+
width = max(rights) + shift
|
|
479
|
+
return dict(X=X, shift=shift, width=width, height=bottom, hw=hw, px0=px0, t0=t0,
|
|
480
|
+
t1=t1, upx=upx, lane_y=lane_y, lanes_top=lanes_top, lanes_bot=lanes_bot,
|
|
481
|
+
place=place, axis_y=axis_y, tick_x=tick_x, lh=lh, ev_y=ev_y, ev_tri=ev_tri)
|
|
482
|
+
|
|
483
|
+
def plan(self) -> Dict[str, Any]:
|
|
484
|
+
if self._plan is None:
|
|
485
|
+
self._plan = self._make_plan()
|
|
486
|
+
return self._plan
|
|
487
|
+
|
|
488
|
+
def _measure(self) -> Tuple[float, float]:
|
|
489
|
+
p = self.plan()
|
|
490
|
+
return (p["width"], p["height"])
|
|
491
|
+
|
|
492
|
+
def x_of(self, t: float) -> float:
|
|
493
|
+
p = self.plan()
|
|
494
|
+
return self.geom.x + p["shift"] + p["X"](t)
|
|
495
|
+
|
|
496
|
+
def _layout_children(self) -> None:
|
|
497
|
+
_L = self.theme.L
|
|
498
|
+
tl = self.tl
|
|
499
|
+
p = self.plan()
|
|
500
|
+
ox, oy = self.geom.x + p["shift"], self.geom.y
|
|
501
|
+
X = p["X"]
|
|
502
|
+
x0, x1 = X(p["t0"]) + ox, X(p["t1"]) + ox
|
|
503
|
+
top, bot = p["lanes_top"] + oy, p["lanes_bot"] + oy
|
|
504
|
+
tl._plot.layout(x0, top, x1 - x0, bot - top)
|
|
505
|
+
for name in tl.lanes:
|
|
506
|
+
ly = p["lane_y"][name] + oy
|
|
507
|
+
tl._bands[name].layout(x0, ly, x1 - x0, tl.lane_h)
|
|
508
|
+
h = tl._headers[name]
|
|
509
|
+
if h is not None:
|
|
510
|
+
hw_, hh = h.measure()
|
|
511
|
+
h.layout(ox + p["hw"] - hw_, ly + (tl.lane_h - hh) / 2, hw_, hh)
|
|
512
|
+
for t in tl.tasks:
|
|
513
|
+
a, b = X(t.start) + ox, X(t.end) + ox
|
|
514
|
+
t.layout(a, p["lane_y"][t.lane] + oy, b - a, tl.lane_h)
|
|
515
|
+
self._fit_label(t, b - a)
|
|
516
|
+
if t.step is not None:
|
|
517
|
+
t._layout_children()
|
|
518
|
+
lane_tops = [p["lane_y"][n] + oy for n in tl.lanes]
|
|
519
|
+
for m in tl.markers:
|
|
520
|
+
mx = X(m.t) + ox
|
|
521
|
+
y0, y1 = top - _L(3.0), bot + _L(3.0)
|
|
522
|
+
if m.label is not None:
|
|
523
|
+
_x0, _x1, ly, lh_ = p["place"][id(m)]
|
|
524
|
+
lw, lhh = m.label.measure()
|
|
525
|
+
m.label.layout(mx - lw / 2, ly + oy + (lh_ - lhh) / 2, lw, lhh)
|
|
526
|
+
if m.label_pos == "top":
|
|
527
|
+
y0 = m.label.geom.bottom + _L(1.0)
|
|
528
|
+
else:
|
|
529
|
+
y1 = m.label.geom.y - _L(1.0)
|
|
530
|
+
m.layout(mx - 0.5, y0, 1.0, y1 - y0)
|
|
531
|
+
m._line = (mx, y0, y1)
|
|
532
|
+
for s in tl.spans:
|
|
533
|
+
a, b = X(s.start) + ox, X(s.end) + ox
|
|
534
|
+
_x0, _x1, ly, lvl_h = p["place"][id(s)]
|
|
535
|
+
# v0.12 (B5): spans that touch on one level keep a hairline gap
|
|
536
|
+
for o in tl.spans:
|
|
537
|
+
if o is s or o.side != s.side or p["place"][id(o)][2] != p["place"][id(s)][2]:
|
|
538
|
+
continue
|
|
539
|
+
if abs(o.end - s.start) < 1e-9:
|
|
540
|
+
a += _L(1.0)
|
|
541
|
+
if abs(o.start - s.end) < 1e-9:
|
|
542
|
+
b -= _L(1.0)
|
|
543
|
+
ly += oy
|
|
544
|
+
sh = s.strip_h()
|
|
545
|
+
lw, lhh = s.label.measure() if s.label is not None else (0.0, 0.0)
|
|
546
|
+
if s.style == "dim" and not self._dim_fits(s, X(s.start), X(s.end), lw):
|
|
547
|
+
if s.side == "top":
|
|
548
|
+
sy = ly + lvl_h - sh
|
|
549
|
+
s.layout(a, sy, b - a, sh)
|
|
550
|
+
s.label.layout((a + b) / 2 - lw / 2, sy - _L(1.0) - lhh, lw, lhh)
|
|
551
|
+
else:
|
|
552
|
+
s.layout(a, ly, b - a, sh)
|
|
553
|
+
s.label.layout((a + b) / 2 - lw / 2, ly + sh + _L(1.0), lw, lhh)
|
|
554
|
+
elif s.style == "dim":
|
|
555
|
+
cy = ly + lvl_h / 2 if s.side == "top" else ly + lvl_h / 2
|
|
556
|
+
s.layout(a, cy - sh / 2, b - a, sh)
|
|
557
|
+
if s.label is not None:
|
|
558
|
+
s.label.layout((a + b) / 2 - lw / 2, cy - lhh / 2, lw, lhh)
|
|
559
|
+
elif s.side == "top": # strip nearest the lanes, label above it
|
|
560
|
+
sy = ly + lvl_h - sh
|
|
561
|
+
s.layout(a, sy, b - a, sh)
|
|
562
|
+
if s.label is not None:
|
|
563
|
+
s.label.layout((a + b) / 2 - lw / 2, sy - _L(2.0) - lhh, lw, lhh)
|
|
564
|
+
else:
|
|
565
|
+
s.layout(a, ly, b - a, sh)
|
|
566
|
+
if s.label is not None:
|
|
567
|
+
s.label.layout((a + b) / 2 - lw / 2, ly + sh + _L(2.0), lw, lhh)
|
|
568
|
+
for e in tl.events:
|
|
569
|
+
ex = X(e.t) + ox
|
|
570
|
+
tri = p["ev_tri"]
|
|
571
|
+
e.layout(ex - tri * 0.6, p["ev_y"] + oy, tri * 1.2, tri)
|
|
572
|
+
if e.label is not None:
|
|
573
|
+
_x0, _x1, ly, _h = p["place"][id(e)]
|
|
574
|
+
lw, lhh = e.label.measure()
|
|
575
|
+
e.label.layout(ex - lw / 2, ly + oy, lw, lhh)
|
|
576
|
+
ax = tl._axis
|
|
577
|
+
if ax is not None:
|
|
578
|
+
ay = p["axis_y"] + oy
|
|
579
|
+
xe = X(p["t1"]) + ox + _L(14.0)
|
|
580
|
+
ax._line = (X(p["t0"]) + ox, xe, ay)
|
|
581
|
+
ax._ticks = [tx + ox for tx in p["tick_x"]] if tl._ticks is not None else []
|
|
582
|
+
ax.layout(x0, ay - 1, xe - x0, 2)
|
|
583
|
+
ax.__dict__["_lay_x"], ax.__dict__["_lay_y"] = ax.geom.x, ax.geom.y
|
|
584
|
+
for tx, lab in zip(p["tick_x"], ax.tick_labels):
|
|
585
|
+
w, h = lab.measure()
|
|
586
|
+
lab.layout(tx + ox - w / 2, ay + _L(5.0), w, h)
|
|
587
|
+
if ax.title is not None:
|
|
588
|
+
w, h = ax.title.measure()
|
|
589
|
+
ax.title.layout(xe + _L(4.0), ay - h / 2, w, h)
|
|
590
|
+
del lane_tops
|
|
591
|
+
|
|
592
|
+
def _fit_label(self, t: Task, w: float) -> None:
|
|
593
|
+
_L = self.theme.L
|
|
594
|
+
t._fit_fs = None
|
|
595
|
+
if not t.text:
|
|
596
|
+
return
|
|
597
|
+
fs0 = t._fs()
|
|
598
|
+
lw, lh = measure_text(t.text, fs0, weight=t.weight)
|
|
599
|
+
avail_w = w - _L(4.0)
|
|
600
|
+
avail_h = self.tl.lane_h - _L(2.0)
|
|
601
|
+
k = min(1.0, avail_w / lw if lw > 0 else 1.0, avail_h / lh if lh > 0 else 1.0)
|
|
602
|
+
if k < 1.0:
|
|
603
|
+
th = self.theme
|
|
604
|
+
floor = fs0 * 0.6 if th.unit == 1.0 else min(fs0, th.small_size)
|
|
605
|
+
fs = max(floor, fs0 * k)
|
|
606
|
+
t._fit_fs = fs
|
|
607
|
+
if fs0 * k < floor - 1e-6:
|
|
608
|
+
warnings.warn(TimelineWarning(
|
|
609
|
+
f"task label {t.text!r} ({t.dur:g} units on lane {t.lane!r}) does not "
|
|
610
|
+
f"fit its {w:.1f}-wide bar; shorten it, use a larger unit, or put it in "
|
|
611
|
+
f"a legend"), stacklevel=2)
|
|
612
|
+
|
|
613
|
+
def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
|
|
614
|
+
for n in self.children():
|
|
615
|
+
n.draw(buf, ox, oy)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
# ======================================================================== Timeline
|
|
619
|
+
class Timeline(Diagram):
|
|
620
|
+
"""Gantt-style schedule: named lanes, tasks in data units, bubbles, dependencies,
|
|
621
|
+
markers, spans and an optional time axis.
|
|
622
|
+
|
|
623
|
+
The Timeline is a :class:`~codefig.edges.Diagram` — dependency arrows
|
|
624
|
+
(:meth:`depend`) and any extra :class:`~codefig.edges.Edge` /
|
|
625
|
+
:class:`~codefig.dim.Dim` added with ``tl.add_edge(...)`` are routed over it.
|
|
626
|
+
|
|
627
|
+
Layering: tasks are **hard** Boxes; lane headers, marker / span / tick labels are
|
|
628
|
+
**soft** Text; spans are **hard**; lane bands, marker lines and the axis line are
|
|
629
|
+
background *guides* that check_overlaps() ignores.
|
|
630
|
+
|
|
631
|
+
Args:
|
|
632
|
+
lanes: Lane names, top to bottom (also the header texts; ``""`` hides one).
|
|
633
|
+
unit: pt per data unit (default 36). Ignored when ``width`` is set.
|
|
634
|
+
width: Plot width in pt for the whole ``start``..``end`` range (overrides ``unit``);
|
|
635
|
+
the lane-header column comes on top of it.
|
|
636
|
+
start: First visible time (default 0).
|
|
637
|
+
end: Last visible time (default: the latest task / marker / span end).
|
|
638
|
+
axis: ``False`` (default, no axis), ``True`` (arrow axis below the lanes) or a
|
|
639
|
+
title string drawn at the arrow tip (e.g. ``"Time"``).
|
|
640
|
+
ticks: ``None`` (no ticks), a step in data units (``1`` → every unit) or an
|
|
641
|
+
explicit list of tick times. Ticks imply ``axis=True``.
|
|
642
|
+
tick_format: ``str.format`` pattern or callable for tick labels (default ``"{:g}"``).
|
|
643
|
+
lane_h: Lane (bar) height in pt (default 24).
|
|
644
|
+
lane_gap: Gap between lanes in pt (default 4; at least 12 while a cross-lane
|
|
645
|
+
:meth:`depend` arrow exists).
|
|
646
|
+
lane_fill: Band colour behind each lane — one colour, a list cycled over lanes,
|
|
647
|
+
or ``None`` (default, no bands).
|
|
648
|
+
header_w: Width of the lane-header column in pt (default: widest header).
|
|
649
|
+
header_weight: Header font weight (default ``"bold"``).
|
|
650
|
+
font_size: Base font size in pt (default ``0.92 × theme.font_size``); task
|
|
651
|
+
labels use 0.92 × that and shrink to fit their bar.
|
|
652
|
+
task_fill: Default task fill (default ``"#dbe8f6"``).
|
|
653
|
+
idle_fill: Default fill of :meth:`idle` bubbles (default ``"#d9d9d9"``).
|
|
654
|
+
stroke: Task outline colour (default ``"#333333"``).
|
|
655
|
+
stroke_width: Task outline width in pt (default 1.0).
|
|
656
|
+
theme: Theme.
|
|
657
|
+
|
|
658
|
+
Example:
|
|
659
|
+
>>> from codefig import Figure, Timeline
|
|
660
|
+
>>> tl = Timeline(["GPU0", "GPU1"], unit=30, axis="Time")
|
|
661
|
+
>>> a = tl.task("GPU0", 0, 2, "A_p", fill="#9ecae1")
|
|
662
|
+
>>> b = tl.task("GPU1", 2, 2, "A_p", fill="#9ecae1")
|
|
663
|
+
>>> _ = tl.idle("GPU1", 0, 2)
|
|
664
|
+
>>> _ = tl.depend(a, b)
|
|
665
|
+
>>> _ = tl.span(0, 2, "**bubble**", side="bottom")
|
|
666
|
+
>>> fig = Figure(size="auto", cols=1)
|
|
667
|
+
>>> _ = fig.panel("a").set(tl)
|
|
668
|
+
>>> svg = fig.to_svg()
|
|
669
|
+
"""
|
|
670
|
+
|
|
671
|
+
def __init__(
|
|
672
|
+
self,
|
|
673
|
+
lanes: Sequence[str],
|
|
674
|
+
*,
|
|
675
|
+
unit: Optional[float] = None,
|
|
676
|
+
width: Optional[float] = None,
|
|
677
|
+
start: float = 0.0,
|
|
678
|
+
end: Optional[float] = None,
|
|
679
|
+
axis: Union[bool, str] = False,
|
|
680
|
+
ticks: Union[None, float, Sequence[float]] = None,
|
|
681
|
+
tick_format: Any = "{:g}",
|
|
682
|
+
lane_h: Optional[float] = None,
|
|
683
|
+
lane_gap: Optional[float] = None,
|
|
684
|
+
lane_fill: Union[None, str, Sequence[str]] = None,
|
|
685
|
+
header_w: Optional[float] = None,
|
|
686
|
+
header_weight: str = "bold",
|
|
687
|
+
font_size: Optional[float] = None,
|
|
688
|
+
task_fill: str = "#dbe8f6",
|
|
689
|
+
idle_fill: str = "#d9d9d9",
|
|
690
|
+
stroke: str = "#333333",
|
|
691
|
+
stroke_width: Optional[float] = None,
|
|
692
|
+
theme: Optional[Theme] = None,
|
|
693
|
+
**kwargs: Any,
|
|
694
|
+
) -> None:
|
|
695
|
+
lanes = [str(l) for l in lanes]
|
|
696
|
+
if not lanes:
|
|
697
|
+
raise ValueError("Timeline needs at least one lane")
|
|
698
|
+
if len(set(lanes)) != len(lanes):
|
|
699
|
+
raise ValueError("Timeline lane names must be unique")
|
|
700
|
+
if (unit is not None and unit <= 0) or (lane_h is not None and lane_h <= 0):
|
|
701
|
+
raise ValueError("unit and lane_h must be > 0")
|
|
702
|
+
th = theme
|
|
703
|
+
self.lanes: List[str] = lanes
|
|
704
|
+
self.unit = unit
|
|
705
|
+
self.width = float(width) if width is not None else None
|
|
706
|
+
self.start = float(start)
|
|
707
|
+
self.end = float(end) if end is not None else None
|
|
708
|
+
self.lane_h = lane_h
|
|
709
|
+
self.lane_gap = lane_gap
|
|
710
|
+
self._cross_deps = 0
|
|
711
|
+
self.header_w = float(header_w) if header_w is not None else None
|
|
712
|
+
self.font_size = font_size
|
|
713
|
+
self.task_fill = task_fill
|
|
714
|
+
self.idle_fill = idle_fill
|
|
715
|
+
self.stroke = stroke
|
|
716
|
+
self.stroke_width = stroke_width
|
|
717
|
+
self.tick_format = tick_format
|
|
718
|
+
if ticks is not None and not isinstance(ticks, (int, float)):
|
|
719
|
+
self._ticks_explicit: Optional[List[float]] = [float(t) for t in ticks]
|
|
720
|
+
else:
|
|
721
|
+
self._ticks_explicit = None
|
|
722
|
+
self._ticks = ticks
|
|
723
|
+
if ticks is not None and isinstance(ticks, (int, float)) and ticks <= 0:
|
|
724
|
+
raise ValueError("ticks step must be > 0")
|
|
725
|
+
fills: List[Optional[str]]
|
|
726
|
+
if lane_fill is None or isinstance(lane_fill, str):
|
|
727
|
+
fills = [lane_fill] * len(lanes)
|
|
728
|
+
else:
|
|
729
|
+
lf = list(lane_fill)
|
|
730
|
+
fills = [lf[i % len(lf)] for i in range(len(lanes))]
|
|
731
|
+
self.tasks: List[Task] = []
|
|
732
|
+
self.markers: List[Marker] = []
|
|
733
|
+
self.spans: List[TimeSpan] = []
|
|
734
|
+
self.events: List[Event] = []
|
|
735
|
+
self._plot = _Guide(theme=th)
|
|
736
|
+
self._bands: Dict[str, LaneBand] = {n: LaneBand(n, fill=f, theme=th)
|
|
737
|
+
for n, f in zip(lanes, fills)}
|
|
738
|
+
self._headers: Dict[str, Optional[Text]] = {
|
|
739
|
+
n: (Text(n, font_size=self._fs_spec(), weight=header_weight, align="right", theme=th)
|
|
740
|
+
if n else None) for n in lanes}
|
|
741
|
+
self._axis: Optional[_Axis] = None
|
|
742
|
+
self._want_axis = axis
|
|
743
|
+
body = _TimelineBody(self, th)
|
|
744
|
+
super().__init__(body, [], theme=theme, **kwargs)
|
|
745
|
+
self.body = body
|
|
746
|
+
self._build_axis()
|
|
747
|
+
for n in body.children():
|
|
748
|
+
n.parent = body
|
|
749
|
+
|
|
750
|
+
# ---- helpers ------------------------------------------------------------
|
|
751
|
+
unit = themed(36.0)
|
|
752
|
+
lane_h = themed(24.0)
|
|
753
|
+
lane_gap = themed(4.0)
|
|
754
|
+
stroke_width = themed(1.0, "SW")
|
|
755
|
+
|
|
756
|
+
@property
|
|
757
|
+
def _theme_(self) -> Theme:
|
|
758
|
+
return self.theme
|
|
759
|
+
|
|
760
|
+
def _fs_spec(self, k: float = 1.0) -> Any:
|
|
761
|
+
"""Font size for child Text: a number, or lazy ``theme -> size``."""
|
|
762
|
+
if self.font_size is not None:
|
|
763
|
+
return float(self.font_size) * k
|
|
764
|
+
return lambda th: th.font_size * 0.92 * k
|
|
765
|
+
|
|
766
|
+
def _fs(self) -> float:
|
|
767
|
+
return float(self.font_size) if self.font_size is not None else self._theme_.font_size * 0.92
|
|
768
|
+
|
|
769
|
+
def _adopt(self, n: Node) -> None:
|
|
770
|
+
n.parent = self.body
|
|
771
|
+
self.body._plan = None
|
|
772
|
+
self.body.invalidate()
|
|
773
|
+
Node.invalidate(self)
|
|
774
|
+
|
|
775
|
+
def _tick_values(self, t0: float, t1: float) -> List[float]:
|
|
776
|
+
if self._ticks is None:
|
|
777
|
+
return []
|
|
778
|
+
if self._ticks_explicit is not None:
|
|
779
|
+
return list(self._ticks_explicit)
|
|
780
|
+
step = float(self._ticks) # type: ignore[arg-type]
|
|
781
|
+
n = int(math.floor((t1 - t0) / step + 1e-9))
|
|
782
|
+
return [t0 + i * step for i in range(n + 1)]
|
|
783
|
+
|
|
784
|
+
def _fmt(self, t: float) -> str:
|
|
785
|
+
f = self.tick_format
|
|
786
|
+
return f(t) if callable(f) else str(f).format(t)
|
|
787
|
+
|
|
788
|
+
def _build_axis(self) -> None:
|
|
789
|
+
if not self._want_axis and self._ticks is None:
|
|
790
|
+
self._axis = None
|
|
791
|
+
return
|
|
792
|
+
title = None
|
|
793
|
+
if isinstance(self._want_axis, str) and self._want_axis:
|
|
794
|
+
title = Text(self._want_axis, font_size=self._fs_spec(), theme=self._theme)
|
|
795
|
+
# tick labels need the final range — built lazily in _refresh_ticks()
|
|
796
|
+
self._axis = _Axis(color=self.stroke, title=title, tick_labels=[], theme=self._theme)
|
|
797
|
+
self._axis.parent = self.body
|
|
798
|
+
|
|
799
|
+
def _refresh_ticks(self) -> None:
|
|
800
|
+
if self._axis is None or self._ticks is None:
|
|
801
|
+
return
|
|
802
|
+
cands = [t.end for t in self.tasks] + [m.t for m in self.markers] + [s.end for s in self.spans]
|
|
803
|
+
cands += [e.t for e in self.events]
|
|
804
|
+
t1 = self.end if self.end is not None else max(cands + [self.start + 1.0])
|
|
805
|
+
vals = self._tick_values(self.start, t1)
|
|
806
|
+
fs = self._fs_spec(0.9)
|
|
807
|
+
self._axis.tick_labels = [Text(self._fmt(v), font_size=fs, color="#333333",
|
|
808
|
+
theme=self._theme) for v in vals]
|
|
809
|
+
for t in self._axis.tick_labels:
|
|
810
|
+
t.parent = self._axis
|
|
811
|
+
|
|
812
|
+
def measure(self) -> Tuple[float, float]:
|
|
813
|
+
if self._measured is None:
|
|
814
|
+
self._refresh_ticks()
|
|
815
|
+
self.body._plan = None
|
|
816
|
+
return super().measure()
|
|
817
|
+
|
|
818
|
+
def _lane_check(self, lane: str) -> None:
|
|
819
|
+
if lane not in self._bands:
|
|
820
|
+
raise KeyError(f"unknown lane {lane!r}; lanes are {self.lanes}")
|
|
821
|
+
|
|
822
|
+
# ---- public API -----------------------------------------------------------
|
|
823
|
+
def task(self, lane: str, start: float, dur: float, label: str = "", *,
|
|
824
|
+
fill: Optional[Any] = None, hatch: Any = None, text_color: Optional[str] = None,
|
|
825
|
+
font_size: Optional[float] = None, weight: str = "normal",
|
|
826
|
+
stroke: Optional[str] = None, stroke_width: Optional[float] = None,
|
|
827
|
+
legend: Optional[str] = None, id: Optional[str] = None,
|
|
828
|
+
kind: str = "task", **box_kw: Any) -> Task:
|
|
829
|
+
"""Add a bar on ``lane`` from ``start`` for ``dur`` data units.
|
|
830
|
+
|
|
831
|
+
Args:
|
|
832
|
+
lane: Lane name (must be one of ``lanes``).
|
|
833
|
+
start: Start time in data units.
|
|
834
|
+
dur: Duration in data units (> 0).
|
|
835
|
+
label: Text centred in the bar (rich text / ``A_p`` subscripts allowed);
|
|
836
|
+
shrinks down to 60 % to fit.
|
|
837
|
+
fill: Bar colour (default ``task_fill``; white when ``hatch`` is set).
|
|
838
|
+
hatch: ``True`` (grey 45° hatching), a colour string (hatching in that
|
|
839
|
+
colour) or a :class:`~codefig.legend.Hatch`.
|
|
840
|
+
text_color: Label colour (default ``"#111111"``).
|
|
841
|
+
font_size: Label size in pt (default ``0.92 × font_size``).
|
|
842
|
+
weight: Label weight.
|
|
843
|
+
stroke / stroke_width: Outline (defaults: the Timeline's).
|
|
844
|
+
legend: Legend entry for an auto :class:`~codefig.legend.Legend`.
|
|
845
|
+
id: Node id (for ``check_overlaps()`` messages).
|
|
846
|
+
kind: ``"task"`` or ``"idle"`` (used by :meth:`idle`).
|
|
847
|
+
**box_kw: Further :class:`~codefig.primitives.Box` options (``border=``,
|
|
848
|
+
``rounded=``, ``step=`` …).
|
|
849
|
+
|
|
850
|
+
Returns:
|
|
851
|
+
The :class:`Task` node (use it for :meth:`depend`, ports, constraints).
|
|
852
|
+
"""
|
|
853
|
+
from .legend import Hatch
|
|
854
|
+
|
|
855
|
+
self._lane_check(lane)
|
|
856
|
+
pattern = None
|
|
857
|
+
if hatch is not None and hatch is not False:
|
|
858
|
+
if isinstance(hatch, Hatch):
|
|
859
|
+
pattern = hatch
|
|
860
|
+
elif isinstance(hatch, str):
|
|
861
|
+
pattern = Hatch(color=hatch, spacing=4.0, width=1.0)
|
|
862
|
+
else:
|
|
863
|
+
pattern = Hatch(spacing=4.0, width=0.9)
|
|
864
|
+
if fill is None:
|
|
865
|
+
fill = "#ffffff"
|
|
866
|
+
if fill is None:
|
|
867
|
+
fill = self.task_fill if kind == "task" else self.idle_fill
|
|
868
|
+
t = Task(label, lane=lane, start=start, dur=dur, kind=kind, fill=fill, pattern=pattern,
|
|
869
|
+
text_color=text_color or ("#444444" if kind == "idle" else "#111111"),
|
|
870
|
+
font_size=font_size if font_size is not None else self._fs() * 0.92,
|
|
871
|
+
weight=weight, stroke=stroke or self.stroke,
|
|
872
|
+
stroke_width=stroke_width if stroke_width is not None else self.stroke_width,
|
|
873
|
+
legend=legend, id=id, theme=self._theme, **box_kw)
|
|
874
|
+
self.tasks.append(t)
|
|
875
|
+
self._adopt(t)
|
|
876
|
+
return t
|
|
877
|
+
|
|
878
|
+
def idle(self, lane: str, start: float, dur: float, label: str = "", **kw: Any) -> Task:
|
|
879
|
+
"""Add an idle period / pipeline bubble (same args as :meth:`task`).
|
|
880
|
+
|
|
881
|
+
Drawn hatched (grey 45° lines on white) by default; ``hatch=False`` gives a
|
|
882
|
+
plain ``idle_fill`` grey bar, ``hatch="#c0392b"`` / a :class:`~codefig.legend.Hatch`
|
|
883
|
+
other hatchings. Example: ``tl.idle("GPU1", 0, 2, legend="bubble")``.
|
|
884
|
+
"""
|
|
885
|
+
kw.setdefault("kind", "idle")
|
|
886
|
+
kw.setdefault("hatch", True)
|
|
887
|
+
return self.task(lane, start, dur, label, **kw)
|
|
888
|
+
|
|
889
|
+
def seq(self, lane: str, start: float, items: Iterable[Any], *, gap: float = 0.0) -> List[Task]:
|
|
890
|
+
"""Add back-to-back tasks on one lane starting at ``start``.
|
|
891
|
+
|
|
892
|
+
Args:
|
|
893
|
+
lane: Lane name.
|
|
894
|
+
start: Start of the first task (data units).
|
|
895
|
+
items: Each a ``(dur, label)`` / ``(dur, label, fill)`` tuple or a dict of
|
|
896
|
+
:meth:`task` keyword args with ``dur`` (``{"dur": 1, "label": "F1",
|
|
897
|
+
"kind": "idle"}``).
|
|
898
|
+
gap: Idle gap between consecutive items (data units, default 0).
|
|
899
|
+
|
|
900
|
+
Returns:
|
|
901
|
+
The created tasks in order.
|
|
902
|
+
"""
|
|
903
|
+
out: List[Task] = []
|
|
904
|
+
t = float(start)
|
|
905
|
+
for it in items:
|
|
906
|
+
if isinstance(it, dict):
|
|
907
|
+
kw = dict(it)
|
|
908
|
+
dur = float(kw.pop("dur"))
|
|
909
|
+
label = kw.pop("label", "")
|
|
910
|
+
else:
|
|
911
|
+
dur, label = float(it[0]), (it[1] if len(it) > 1 else "")
|
|
912
|
+
kw = {"fill": it[2]} if len(it) > 2 else {}
|
|
913
|
+
out.append(self.task(lane, t, dur, label, **kw))
|
|
914
|
+
t += dur + gap
|
|
915
|
+
return out
|
|
916
|
+
|
|
917
|
+
def depend(self, a: Task, b: Task, **edge_kw: Any) -> Edge:
|
|
918
|
+
"""Dependency arrow from task ``a`` to task ``b`` (added to this Timeline).
|
|
919
|
+
|
|
920
|
+
Same lane: ``a.right → b.left``. Different lanes: from ``a``'s bottom (top)
|
|
921
|
+
edge just before its end to ``b``'s top (bottom) edge just after its start
|
|
922
|
+
(inset ``min(15 % of the bar, 6 pt)``). Straight by default; pass any
|
|
923
|
+
:class:`~codefig.edges.Edge` keyword (``route="ortho"``, ``style="dashed"``,
|
|
924
|
+
``label=`` …) to override. While a cross-lane dependency exists the gap between
|
|
925
|
+
lanes is at least 12 px (``lane_gap`` is raised) so the arrow stays visible.
|
|
926
|
+
|
|
927
|
+
Returns:
|
|
928
|
+
The created Edge.
|
|
929
|
+
"""
|
|
930
|
+
ia, ib = self.lanes.index(a.lane), self.lanes.index(b.lane)
|
|
931
|
+
if ia == ib:
|
|
932
|
+
src, dst = a.port("right"), b.port("left")
|
|
933
|
+
else:
|
|
934
|
+
upx = self.unit if self.width is None else None
|
|
935
|
+
|
|
936
|
+
def inset(t: Task) -> float:
|
|
937
|
+
f = 0.15
|
|
938
|
+
if upx:
|
|
939
|
+
f = min(f, 6.0 / max(t.dur * upx, 1e-6))
|
|
940
|
+
return f
|
|
941
|
+
|
|
942
|
+
fa, fb = 1.0 - inset(a), inset(b)
|
|
943
|
+
if ia < ib:
|
|
944
|
+
src, dst = a.port("bottom", at=fa), b.port("top", at=fb)
|
|
945
|
+
else:
|
|
946
|
+
src, dst = a.port("top", at=fa), b.port("bottom", at=fb)
|
|
947
|
+
self._cross_deps += 1
|
|
948
|
+
self.body.invalidate()
|
|
949
|
+
kw: Dict[str, Any] = dict(route="straight", color="#333333", stub=0.0, overlap="allow")
|
|
950
|
+
if self.theme.unit == 1.0: # classic px look
|
|
951
|
+
kw.update(width=1.2, head_size=7)
|
|
952
|
+
kw.update(edge_kw)
|
|
953
|
+
e = Edge(src, dst, **kw)
|
|
954
|
+
self.edges.append(e)
|
|
955
|
+
return e
|
|
956
|
+
|
|
957
|
+
def marker(self, t: float, label: str = "", *, style: str = "dashed",
|
|
958
|
+
color: Optional[str] = None, width: Optional[float] = None, label_pos: str = "top",
|
|
959
|
+
font_size: Optional[float] = None, label_color: Optional[str] = None) -> Marker:
|
|
960
|
+
"""Vertical guide line at time ``t`` across all lanes (iteration boundaries,
|
|
961
|
+
deadlines, "migration starts" …).
|
|
962
|
+
|
|
963
|
+
Args:
|
|
964
|
+
t: Time in data units.
|
|
965
|
+
label: Optional label above (``label_pos="top"``) or below the lanes.
|
|
966
|
+
style: ``"dashed"`` (default), ``"dotted"`` or ``"solid"``.
|
|
967
|
+
color: Line / label colour (default ``"#555555"``).
|
|
968
|
+
width: Line width (default: the theme thin stroke; 1 px classic).
|
|
969
|
+
label_pos: ``"top"`` or ``"bottom"``.
|
|
970
|
+
font_size: Label size (default ``0.85 × font_size``).
|
|
971
|
+
label_color: Label colour (default: ``color``).
|
|
972
|
+
"""
|
|
973
|
+
m = Marker(t, label, style=style, color=color or "#555555", width=width,
|
|
974
|
+
label_pos=label_pos, font_size=font_size or self._fs() * 0.85,
|
|
975
|
+
label_color=label_color, theme=self._theme)
|
|
976
|
+
self.markers.append(m)
|
|
977
|
+
self._adopt(m)
|
|
978
|
+
return m
|
|
979
|
+
|
|
980
|
+
def span(self, start: float, end: float, label: str = "", *, style: str = "brace",
|
|
981
|
+
side: str = "top", color: Optional[str] = None, width: Optional[float] = None,
|
|
982
|
+
font_size: Optional[float] = None, weight: str = "normal",
|
|
983
|
+
label_color: Optional[str] = None) -> TimeSpan:
|
|
984
|
+
"""Brace / bracket / dimension line over ``start``..``end`` (data units).
|
|
985
|
+
|
|
986
|
+
Args:
|
|
987
|
+
start / end: Time range in data units.
|
|
988
|
+
label: Text beside the span (rich text, e.g. ``"**Bubble** due to …"``).
|
|
989
|
+
style: ``"brace"`` (default, curly), ``"bracket"`` (square) or ``"dim"``
|
|
990
|
+
(``|←— label —→|``).
|
|
991
|
+
side: ``"top"`` (above the lanes, default) or ``"bottom"``.
|
|
992
|
+
color: Stroke colour (default ``"#333333"``).
|
|
993
|
+
width: Stroke width (default: the theme edge width; 1.2 px classic).
|
|
994
|
+
font_size: Label size (default ``0.85 × font_size``).
|
|
995
|
+
weight: Label weight.
|
|
996
|
+
label_color: Label colour (default: ``color``).
|
|
997
|
+
"""
|
|
998
|
+
s = TimeSpan(start, end, label, style=style, side=side, color=color or "#333333",
|
|
999
|
+
width=width, font_size=font_size or self._fs() * 0.85, weight=weight,
|
|
1000
|
+
label_color=label_color, theme=self._theme)
|
|
1001
|
+
self.spans.append(s)
|
|
1002
|
+
self._adopt(s)
|
|
1003
|
+
return s
|
|
1004
|
+
|
|
1005
|
+
def elide(self, lane: str, start: float, end: float, label: str = "…", **kw: Any) -> Task:
|
|
1006
|
+
"""v0.12.0: elide ``start``..``end`` on ``lane`` — no bar, just a centred
|
|
1007
|
+
``"…"`` (or ``label``) where the schedule continues off-page, e.g. the long
|
|
1008
|
+
middle of a migration. Returns the (borderless, unfilled) :class:`Task`.
|
|
1009
|
+
|
|
1010
|
+
Named ``elide`` (not ``gap``) because ``gap`` means spacing everywhere else.
|
|
1011
|
+
|
|
1012
|
+
Example: ``tl.elide("GPU0", 4, 6)``.
|
|
1013
|
+
"""
|
|
1014
|
+
if end <= start:
|
|
1015
|
+
raise ValueError("elide end must be after start")
|
|
1016
|
+
kw.setdefault("fill", "none")
|
|
1017
|
+
kw.setdefault("stroke", "none")
|
|
1018
|
+
kw.setdefault("text_color", "#444444")
|
|
1019
|
+
kw["kind"] = "gap"
|
|
1020
|
+
return self.task(lane, start, end - start, label, **kw)
|
|
1021
|
+
|
|
1022
|
+
def event(self, t: float, label: str = "", *, color: Optional[str] = None,
|
|
1023
|
+
font_size: Optional[float] = None) -> Event:
|
|
1024
|
+
"""v0.12.0: point event at time ``t`` — a small ▲ on the time axis (below
|
|
1025
|
+
its tick labels; under the lanes when there is no axis) with ``label`` under
|
|
1026
|
+
it. Labels of nearby events stack on levels instead of colliding.
|
|
1027
|
+
|
|
1028
|
+
Example: ``tl.event(3, "migration triggered")``.
|
|
1029
|
+
"""
|
|
1030
|
+
e = Event(t, label, color=color or "#333333",
|
|
1031
|
+
font_size=font_size or self._fs() * 0.85, theme=self._theme)
|
|
1032
|
+
self.events.append(e)
|
|
1033
|
+
self._adopt(e)
|
|
1034
|
+
return e
|
|
1035
|
+
|
|
1036
|
+
def time_anchor(self, t: float, side: str = "bottom") -> Port:
|
|
1037
|
+
"""v0.12.0: a port at time ``t`` on the lane area's ``side`` (``"top"`` /
|
|
1038
|
+
``"bottom"``) — the end point for a :class:`~codefig.edges.Guide` that links
|
|
1039
|
+
the same instant on two Timelines, e.g.
|
|
1040
|
+
``Guide(tl1.time_anchor(3), tl2.time_anchor(3, "top"))``.
|
|
1041
|
+
Same as :meth:`port_at` with a bottom default.
|
|
1042
|
+
"""
|
|
1043
|
+
return self.port_at(t, side)
|
|
1044
|
+
|
|
1045
|
+
def lane(self, name: str) -> LaneBand:
|
|
1046
|
+
"""The background band node of lane ``name`` (for ports / Dim / constraints)."""
|
|
1047
|
+
self._lane_check(name)
|
|
1048
|
+
return self._bands[name]
|
|
1049
|
+
|
|
1050
|
+
def at(self, t: float) -> Anchor:
|
|
1051
|
+
"""x anchor at time ``t`` (for constraints, ``Dim`` and ``port(align=...)``)."""
|
|
1052
|
+
return Anchor(self._plot, "x", getter=lambda: self.body.x_of(t), label=f"t={t:g}")
|
|
1053
|
+
|
|
1054
|
+
def port_at(self, t: float, side: str = "top") -> Port:
|
|
1055
|
+
"""Port on the top / bottom edge of the lane area at time ``t``."""
|
|
1056
|
+
if side not in ("top", "bottom"):
|
|
1057
|
+
raise ValueError("port_at side must be top|bottom")
|
|
1058
|
+
return self._plot.port(side, align=self.at(t))
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
# =========================================================================== Lanes
|
|
1062
|
+
class Lane(Node):
|
|
1063
|
+
"""One swimlane of a :class:`Lanes` container: a header and its content.
|
|
1064
|
+
|
|
1065
|
+
Role **background** (content nodes sit on it; edges may cross it). The header is a
|
|
1066
|
+
**soft** Text child.
|
|
1067
|
+
|
|
1068
|
+
Args:
|
|
1069
|
+
title: Header text (rich text allowed).
|
|
1070
|
+
content: The node in the lane (use a Row / Col / Diagram for several).
|
|
1071
|
+
fill: Lane background (default: the container's alternating ``lane_fill``).
|
|
1072
|
+
header_fill: Header cell colour (default: the container's ``header_fill``).
|
|
1073
|
+
|
|
1074
|
+
Example:
|
|
1075
|
+
>>> from codefig import Lane, Box
|
|
1076
|
+
>>> lane = Lane("Client", Box("send request"))
|
|
1077
|
+
"""
|
|
1078
|
+
|
|
1079
|
+
def __init__(self, title: str, content: Optional[Node] = None, *,
|
|
1080
|
+
fill: Optional[str] = None, header_fill: Optional[str] = None,
|
|
1081
|
+
**kw: Any) -> None:
|
|
1082
|
+
kw.setdefault("obstacle", "background")
|
|
1083
|
+
super().__init__(**kw)
|
|
1084
|
+
self.title = title
|
|
1085
|
+
self.content = content
|
|
1086
|
+
self.fill = fill
|
|
1087
|
+
self.header_fill = header_fill
|
|
1088
|
+
self.header: Optional[Text] = None
|
|
1089
|
+
if content is not None:
|
|
1090
|
+
content.parent = self
|
|
1091
|
+
self._hdr_box = BoxGeom()
|
|
1092
|
+
|
|
1093
|
+
def children(self) -> Sequence[Node]:
|
|
1094
|
+
out: List[Node] = []
|
|
1095
|
+
if self.header is not None:
|
|
1096
|
+
out.append(self.header)
|
|
1097
|
+
if self.content is not None:
|
|
1098
|
+
out.append(self.content)
|
|
1099
|
+
return out
|
|
1100
|
+
|
|
1101
|
+
def _measure(self) -> Tuple[float, float]:
|
|
1102
|
+
return self.content.measure() if self.content is not None else (0.0, 0.0)
|
|
1103
|
+
|
|
1104
|
+
def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
|
|
1105
|
+
if self.content is not None:
|
|
1106
|
+
self.content.draw(buf, ox, oy)
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
class Lanes(Node):
|
|
1110
|
+
"""Swimlane container: lanes with headers, stacked (``"horizontal"`` lanes, header
|
|
1111
|
+
on the left) or side by side (``"vertical"`` lanes, header on top).
|
|
1112
|
+
|
|
1113
|
+
Role **background** (as is each :class:`Lane`); lane contents are ordinary nodes
|
|
1114
|
+
and keep their own roles. Lanes share one extent (all horizontal lanes are equally
|
|
1115
|
+
wide; vertical lanes equally tall). Wrap the container in a
|
|
1116
|
+
:class:`~codefig.edges.Diagram` to draw edges between nodes in different lanes.
|
|
1117
|
+
|
|
1118
|
+
Args:
|
|
1119
|
+
lanes: :class:`Lane` objects or ``(title, node)`` tuples.
|
|
1120
|
+
direction: ``"horizontal"`` (default) or ``"vertical"``.
|
|
1121
|
+
header_size: Header thickness in pt (default: fits the titles; horizontal
|
|
1122
|
+
headers are rotated −90° when that is narrower, see ``header_rotate``).
|
|
1123
|
+
header_rotate: ``True`` / ``False`` / ``None`` (default: rotate horizontal-lane
|
|
1124
|
+
titles when any is wider than 1.6 × the lane height would allow).
|
|
1125
|
+
lane_fill: Lane background colour or list cycled over lanes
|
|
1126
|
+
(default ``("#f5f7fa", "#ffffff")``).
|
|
1127
|
+
header_fill: Header cell colour (default ``"#e6ebf1"``).
|
|
1128
|
+
stroke: Lane border colour (default ``"#8a96a3"``).
|
|
1129
|
+
stroke_width: Border width in pt (default 1.0).
|
|
1130
|
+
pad: Padding around each lane's content in pt (default 12).
|
|
1131
|
+
align: Content placement along the lane: ``"start"`` (default), ``"center"``
|
|
1132
|
+
or ``"end"``.
|
|
1133
|
+
font_size: Header size (default ``theme.font_size``).
|
|
1134
|
+
weight: Header weight (default ``"bold"``).
|
|
1135
|
+
min_extent: Minimum lane length in pt along the lane direction (default 0).
|
|
1136
|
+
|
|
1137
|
+
Example:
|
|
1138
|
+
>>> from codefig import Figure, Lanes, Lane, Box, Diagram, Edge
|
|
1139
|
+
>>> a, b = Box("submit"), Box("schedule")
|
|
1140
|
+
>>> lanes = Lanes([Lane("User", a), ("Scheduler", b)])
|
|
1141
|
+
>>> d = Diagram(lanes, [Edge(a, b)])
|
|
1142
|
+
>>> fig = Figure(size="auto", cols=1)
|
|
1143
|
+
>>> _ = fig.panel("a").set(d)
|
|
1144
|
+
>>> svg = fig.to_svg()
|
|
1145
|
+
"""
|
|
1146
|
+
|
|
1147
|
+
stroke_width = themed(1.0, "SW")
|
|
1148
|
+
pad = themed(12.0)
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
def __init__(self, lanes: Sequence[Union[Lane, Tuple[str, Node]]], *,
|
|
1152
|
+
direction: str = "horizontal", header_size: Optional[float] = None,
|
|
1153
|
+
header_rotate: Optional[bool] = None,
|
|
1154
|
+
lane_fill: Union[str, Sequence[str], None] = ("#f5f7fa", "#ffffff"),
|
|
1155
|
+
header_fill: str = "#e6ebf1", stroke: str = "#8a96a3",
|
|
1156
|
+
stroke_width: Optional[float] = None, pad: Optional[float] = None,
|
|
1157
|
+
align: str = "start",
|
|
1158
|
+
font_size: Optional[float] = None, weight: str = "bold",
|
|
1159
|
+
min_extent: float = 0.0, **kw: Any) -> None:
|
|
1160
|
+
if direction not in ("horizontal", "vertical"):
|
|
1161
|
+
raise ValueError("Lanes direction must be horizontal|vertical")
|
|
1162
|
+
if align not in ("start", "center", "end"):
|
|
1163
|
+
raise ValueError("Lanes align must be start|center|end")
|
|
1164
|
+
kw.setdefault("obstacle", "background")
|
|
1165
|
+
super().__init__(**kw)
|
|
1166
|
+
self.lanes: List[Lane] = [l if isinstance(l, Lane) else Lane(l[0], l[1], theme=self._theme)
|
|
1167
|
+
for l in lanes]
|
|
1168
|
+
if not self.lanes:
|
|
1169
|
+
raise ValueError("Lanes needs at least one lane")
|
|
1170
|
+
self.direction = direction
|
|
1171
|
+
self.header_size = header_size
|
|
1172
|
+
self.header_rotate = header_rotate
|
|
1173
|
+
if lane_fill is None or isinstance(lane_fill, str):
|
|
1174
|
+
fills = [lane_fill] * len(self.lanes)
|
|
1175
|
+
else:
|
|
1176
|
+
lf = list(lane_fill)
|
|
1177
|
+
fills = [lf[i % len(lf)] for i in range(len(self.lanes))]
|
|
1178
|
+
self.stroke, self.stroke_width = stroke, stroke_width
|
|
1179
|
+
self.pad, self.align, self.min_extent = pad, align, float(min_extent)
|
|
1180
|
+
self.weight = weight
|
|
1181
|
+
fs = font_size
|
|
1182
|
+
self.font_size = fs
|
|
1183
|
+
for l, f in zip(self.lanes, fills):
|
|
1184
|
+
l.parent = self
|
|
1185
|
+
if l.fill is None:
|
|
1186
|
+
l.fill = f
|
|
1187
|
+
if l.header_fill is None:
|
|
1188
|
+
l.header_fill = header_fill
|
|
1189
|
+
l.header = Text(l.title, font_size=fs, weight=weight, theme=self._theme)
|
|
1190
|
+
l.header.parent = l
|
|
1191
|
+
self._rot = False
|
|
1192
|
+
self._hs = 0.0
|
|
1193
|
+
|
|
1194
|
+
def children(self) -> Sequence[Node]:
|
|
1195
|
+
return self.lanes
|
|
1196
|
+
|
|
1197
|
+
def invalidate(self) -> None:
|
|
1198
|
+
super().invalidate()
|
|
1199
|
+
for l in self.lanes:
|
|
1200
|
+
l.invalidate()
|
|
1201
|
+
if l.header is not None:
|
|
1202
|
+
l.header.invalidate()
|
|
1203
|
+
|
|
1204
|
+
def _sizes(self) -> Tuple[List[Tuple[float, float]], float, float, bool]:
|
|
1205
|
+
cs = [l.measure() for l in self.lanes]
|
|
1206
|
+
hs = [l.header.measure() for l in self.lanes] # type: ignore[union-attr]
|
|
1207
|
+
p = self.pad
|
|
1208
|
+
if self.direction == "horizontal":
|
|
1209
|
+
lane_hs = [ch + 2 * p for _cw, ch in cs]
|
|
1210
|
+
rot = self.header_rotate
|
|
1211
|
+
if rot is None:
|
|
1212
|
+
rot = any(hw > 1.6 * max(lh, 1.0) for (hw, _hh), lh in zip(hs, lane_hs)) or False
|
|
1213
|
+
rot = bool(rot) and max(hw for hw, _ in hs) > 60
|
|
1214
|
+
if rot:
|
|
1215
|
+
for l in self.lanes:
|
|
1216
|
+
l.header.rotate = -90.0 # type: ignore[union-attr]
|
|
1217
|
+
l.header.invalidate() # type: ignore[union-attr]
|
|
1218
|
+
hs = [l.header.measure() for l in self.lanes] # type: ignore[union-attr]
|
|
1219
|
+
size = self.header_size if self.header_size is not None else (
|
|
1220
|
+
max(hw for hw, _ in hs) + 14.0)
|
|
1221
|
+
return cs, size, 0.0, bool(rot)
|
|
1222
|
+
size = self.header_size if self.header_size is not None else (
|
|
1223
|
+
max(hh for _, hh in hs) + 10.0)
|
|
1224
|
+
return cs, size, 0.0, False
|
|
1225
|
+
|
|
1226
|
+
def _measure(self) -> Tuple[float, float]:
|
|
1227
|
+
cs, hsz, _x, rot = self._sizes()
|
|
1228
|
+
self._rot, self._hs = rot, hsz
|
|
1229
|
+
p = self.pad
|
|
1230
|
+
if self.direction == "horizontal":
|
|
1231
|
+
ext = max([cw for cw, _ in cs] + [self.min_extent - 2 * p]) + 2 * p
|
|
1232
|
+
hs = [l.header.measure()[1] for l in self.lanes] # type: ignore[union-attr]
|
|
1233
|
+
heights = [max(ch + 2 * p, hh + 10.0) for (_cw, ch), hh in zip(cs, hs)]
|
|
1234
|
+
self._lens = heights
|
|
1235
|
+
return (hsz + ext, sum(heights))
|
|
1236
|
+
ext = max([ch for _, ch in cs] + [self.min_extent - 2 * p]) + 2 * p
|
|
1237
|
+
ws = [l.header.measure()[0] for l in self.lanes] # type: ignore[union-attr]
|
|
1238
|
+
widths = [max(cw + 2 * p, hw + 16.0) for (cw, _ch), hw in zip(cs, ws)]
|
|
1239
|
+
self._lens = widths
|
|
1240
|
+
return (sum(widths), hsz + ext)
|
|
1241
|
+
|
|
1242
|
+
def _layout_children(self) -> None:
|
|
1243
|
+
g = self.geom
|
|
1244
|
+
self.measure()
|
|
1245
|
+
p, hs = self.pad, self._hs
|
|
1246
|
+
pos = g.x if self.direction == "vertical" else g.y
|
|
1247
|
+
for l, ln in zip(self.lanes, self._lens):
|
|
1248
|
+
cw, ch = l.content.measure() if l.content is not None else (0.0, 0.0)
|
|
1249
|
+
hdr = l.header
|
|
1250
|
+
assert hdr is not None
|
|
1251
|
+
hw, hh = hdr.measure()
|
|
1252
|
+
if self.direction == "horizontal":
|
|
1253
|
+
l.layout(g.x, pos, g.w, ln)
|
|
1254
|
+
l._hdr_box = BoxGeom(g.x, pos, hs, ln)
|
|
1255
|
+
hdr.layout(g.x + (hs - hw) / 2, pos + (ln - hh) / 2, hw, hh)
|
|
1256
|
+
body_x, body_w = g.x + hs + p, g.w - hs - 2 * p
|
|
1257
|
+
fx = {"start": 0.0, "center": 0.5, "end": 1.0}[self.align]
|
|
1258
|
+
if l.content is not None:
|
|
1259
|
+
l.content.layout(body_x + (body_w - cw) * fx, pos + (ln - ch) / 2, cw, ch)
|
|
1260
|
+
else:
|
|
1261
|
+
l.layout(pos, g.y, ln, g.h)
|
|
1262
|
+
l._hdr_box = BoxGeom(pos, g.y, ln, hs)
|
|
1263
|
+
hdr.layout(pos + (ln - hw) / 2, g.y + (hs - hh) / 2, hw, hh)
|
|
1264
|
+
body_y, body_h = g.y + hs + p, g.h - hs - 2 * p
|
|
1265
|
+
fy = {"start": 0.0, "center": 0.5, "end": 1.0}[self.align]
|
|
1266
|
+
if l.content is not None:
|
|
1267
|
+
l.content.layout(pos + (ln - cw) / 2, body_y + (body_h - ch) * fy, cw, ch)
|
|
1268
|
+
pos += ln
|
|
1269
|
+
|
|
1270
|
+
def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
|
|
1271
|
+
from .render_svg import draw_rect
|
|
1272
|
+
|
|
1273
|
+
sw = self.stroke_width
|
|
1274
|
+
for l in self.lanes:
|
|
1275
|
+
lg, hg = l.geom, l._hdr_box
|
|
1276
|
+
if l.fill:
|
|
1277
|
+
draw_rect(buf, lg.x + ox, lg.y + oy, lg.w, lg.h, fill=l.fill, stroke="none",
|
|
1278
|
+
stroke_width=0)
|
|
1279
|
+
draw_rect(buf, hg.x + ox, hg.y + oy, hg.w, hg.h, fill=l.header_fill or "none",
|
|
1280
|
+
stroke="none", stroke_width=0)
|
|
1281
|
+
draw_rect(buf, lg.x + ox, lg.y + oy, lg.w, lg.h, fill="none", stroke=self.stroke,
|
|
1282
|
+
stroke_width=sw)
|
|
1283
|
+
l.header.draw(buf, ox, oy) # type: ignore[union-attr]
|
|
1284
|
+
l.draw(buf, ox, oy)
|
|
1285
|
+
|
|
1286
|
+
|
|
1287
|
+
__all__ = ["Timeline", "Task", "TimeSpan", "Marker", "LaneBand", "Lanes", "Lane",
|
|
1288
|
+
"TimelineWarning", "SPAN_STYLES", "MARKER_STYLES", "TASK_KINDS"]
|