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/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"]