figkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
figkit/shapes.py ADDED
@@ -0,0 +1,764 @@
1
+ """Drawable shapes: boxes, ellipses, polygons, paths and friends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ from .core import Element
8
+ from .geom import Affine, BBox, Point, _expand_spec, to_point
9
+ from .paint import paint_attrs
10
+ from .svgdoc import Node, RenderContext
11
+ from .text import Text
12
+ from .svgpath import (fmt, path_bbox, path_from_points, rounded_polyline,
13
+ transform_path_data)
14
+
15
+ __all__ = [
16
+ "Shape", "Box", "Rect", "Pill", "Ellipse", "Circle", "Diamond", "Triangle",
17
+ "Hexagon", "Parallelogram", "Cylinder", "Note", "Chevron", "Stadium",
18
+ "Path", "Polygon", "Polyline", "Line", "Dot", "Marker", "Star",
19
+ ]
20
+
21
+
22
+ # ==========================================================================
23
+ # Base class for label-carrying shapes
24
+ # ==========================================================================
25
+
26
+ class Shape(Element):
27
+ """A filled/stroked shape that can auto-size around a text label.
28
+
29
+ Subclasses override :meth:`shape_nodes` to draw themselves.
30
+ """
31
+
32
+ role = "box"
33
+ default_padding = (8, 12)
34
+
35
+ def __init__(self, text=None, x: float = 0.0, y: float = 0.0,
36
+ w: float = None, h: float = None, *, padding=None,
37
+ min_w: float = 0.0, min_h: float = 0.0,
38
+ max_w: float = None, wrap=None, content: Element = None,
39
+ label_style=None, markup: bool = False, aspect: float = None,
40
+ **kw):
41
+ self._label: Text | None = None
42
+ self._content: Element | None = None
43
+ self.min_w = float(min_w or 0)
44
+ self.min_h = float(min_h or 0)
45
+ self.max_w = max_w
46
+ self.aspect = aspect
47
+ self._wrap = wrap
48
+ self._label_style = label_style
49
+ self._markup = markup
50
+ if padding is not None:
51
+ kw["padding"] = padding
52
+ super().__init__(x, y, w, h, **kw)
53
+ if text is not None and text != "":
54
+ self._label = Text(text, markup=markup, add=False,
55
+ style=label_style)
56
+ self._label.parent = self
57
+ self._label.role = "text"
58
+ if content is not None:
59
+ self.set_content(content)
60
+ self.invalidate()
61
+
62
+ # -- content ---------------------------------------------------------
63
+ @property
64
+ def label(self) -> Text | None:
65
+ """The inner :class:`~figkit.text.Text` element (or ``None``)."""
66
+ self._ensure()
67
+ return self._label
68
+
69
+ @property
70
+ def text(self) -> str:
71
+ return self._label.text if self._label else ""
72
+
73
+ @text.setter
74
+ def text(self, value) -> None:
75
+ if self._label is None:
76
+ self._label = Text(value, markup=self._markup, add=False,
77
+ style=self._label_style)
78
+ self._label.parent = self
79
+ else:
80
+ self._label.text = value
81
+ self.invalidate()
82
+
83
+ def set_text(self, value, **style) -> "Shape":
84
+ self.text = value
85
+ if style and self._label is not None:
86
+ self._label.restyle(**style)
87
+ return self
88
+
89
+ def set_content(self, element: Element) -> "Shape":
90
+ """Put an arbitrary element inside; the box sizes around it."""
91
+ if element.parent is not None:
92
+ element.parent.remove_child(element)
93
+ self._content = element
94
+ element.parent = self
95
+ return self.invalidate()
96
+
97
+ @property
98
+ def content(self) -> Element | None:
99
+ return self._content
100
+
101
+ def padding(self) -> tuple:
102
+ pad = self.prop("padding", None)
103
+ if pad is None:
104
+ pad = self.default_padding
105
+ return _expand_spec(pad)
106
+
107
+ # -- measurement -----------------------------------------------------
108
+ def _inner_size(self) -> tuple:
109
+ top, right, bottom, left = self.padding()
110
+ # wrap: None -> wrap to the inner width when the width is pinned;
111
+ # True -> same; False -> never wrap; a number -> wrap at it.
112
+ wrap = self._wrap
113
+ inner_w = max(1.0, float(self._w or 0) - left - right)
114
+ # Padding can exceed a small shape (a Circle with generous padding, say).
115
+ # Wrapping to a sliver would collapse the label to zero width, so only
116
+ # wrap when there is a usable amount of room.
117
+ usable = inner_w if inner_w > 8 else None
118
+ if wrap is False:
119
+ wrap = None
120
+ elif wrap is True or wrap is None:
121
+ wrap = usable if self._explicit_w else None
122
+ if self._label is not None:
123
+ self._label._wrap = wrap if wrap else None
124
+ self._label.invalidate()
125
+ lb = self._label.local_bbox
126
+ cw, ch = lb.w, lb.h
127
+ else:
128
+ cw = ch = 0.0
129
+ if self._content is not None:
130
+ cb = self._content.bbox
131
+ cw = max(cw, cb.w)
132
+ ch = max(ch, cb.h) if self._label is None else ch + cb.h
133
+ return cw, ch
134
+
135
+ def _measure(self) -> None:
136
+ top, right, bottom, left = self.padding()
137
+ cw, ch = self._inner_size()
138
+ if not self._explicit_w:
139
+ self._w = max(self.min_w, cw + left + right)
140
+ if self.max_w:
141
+ self._w = min(self._w, float(self.max_w))
142
+ if not self._explicit_h:
143
+ self._h = max(self.min_h, ch + top + bottom)
144
+ if self.aspect:
145
+ if self._explicit_w and not self._explicit_h:
146
+ self._h = self._w / self.aspect
147
+ elif self._explicit_h and not self._explicit_w:
148
+ self._w = self._h * self.aspect
149
+ elif not self._explicit_w and not self._explicit_h:
150
+ if self._w / max(self._h, 1e-6) < self.aspect:
151
+ self._w = self._h * self.aspect
152
+ else:
153
+ self._h = self._w / self.aspect
154
+ self._place_content()
155
+
156
+ def _inner_box(self) -> BBox:
157
+ """The content area. Falls back to the whole shape when padding
158
+ would leave no room — otherwise content drifts toward the padding
159
+ instead of staying centred on a small shape."""
160
+ top, right, bottom, left = self.padding()
161
+ w = (self._w or 0) - left - right
162
+ h = (self._h or 0) - top - bottom
163
+ x, y = self._x + left, self._y + top
164
+ if w <= 0:
165
+ x, w = self._x, (self._w or 0)
166
+ if h <= 0:
167
+ y, h = self._y, (self._h or 0)
168
+ return BBox(x, y, w, h)
169
+
170
+ def _place_content(self) -> None:
171
+ inner = self._inner_box()
172
+ halign = str(self.prop("text_align", "center")).lower()
173
+ valign = str(self.prop("valign", "center")).lower()
174
+ items = [i for i in (self._content, self._label) if i is not None]
175
+ if not items:
176
+ return
177
+ # Stack content above label when both are present.
178
+ total_h = sum(i.local_bbox.h for i in items)
179
+ y = _valign_start(inner, total_h, valign)
180
+ for item in items:
181
+ ib = item.local_bbox
182
+ x = _halign_start(inner, ib.w, halign)
183
+ if item is self._label:
184
+ item._x = x if not self._label._wrap else inner.x
185
+ if self._label._wrap and inner.w > 0:
186
+ item._w = inner.w
187
+ item._y = y
188
+ if valign in ("center", "middle", "c"):
189
+ lay = self._label.layout
190
+ band = max(0.0, lay.baseline_last - lay.cap_top)
191
+ item._y = inner.cy - band / 2.0 - lay.cap_top
192
+ if len(items) > 1:
193
+ item._y = y
194
+ else:
195
+ item.place_local(x, y, anchor="nw")
196
+ y += ib.h
197
+
198
+ # -- drawing ---------------------------------------------------------
199
+ def shape_nodes(self, ctx: RenderContext, bb: BBox) -> list:
200
+ raise NotImplementedError
201
+
202
+ def path_data(self, bb: BBox = None) -> str:
203
+ """Outline of the shape as SVG path data (used by some connectors)."""
204
+ bb = bb or self.local_bbox
205
+ return path_from_points(bb.corners, close=True)
206
+
207
+ def _render_content(self, ctx: RenderContext):
208
+ self._ensure()
209
+ bb = self.local_bbox
210
+ nodes = list(self.shape_nodes(ctx, bb) or [])
211
+ if self._content is not None:
212
+ n = self._content.render(ctx)
213
+ if n is not None:
214
+ nodes.append(n)
215
+ if self._label is not None:
216
+ n = self._label.render(ctx)
217
+ if n is not None:
218
+ nodes.append(n)
219
+ return nodes or None
220
+
221
+
222
+ def _halign_start(inner: BBox, w: float, align: str) -> float:
223
+ if align in ("left", "start", "w"):
224
+ return inner.x
225
+ if align in ("right", "end", "e"):
226
+ return inner.x1 - w
227
+ return inner.x + (inner.w - w) / 2.0
228
+
229
+
230
+ def _valign_start(inner: BBox, h: float, align: str) -> float:
231
+ if align in ("top", "start", "n"):
232
+ return inner.y
233
+ if align in ("bottom", "end", "s"):
234
+ return inner.y1 - h
235
+ return inner.y + (inner.h - h) / 2.0
236
+
237
+
238
+ # ==========================================================================
239
+ # Rectangles
240
+ # ==========================================================================
241
+
242
+ class Box(Shape):
243
+ """A rectangle with an optional label. The workhorse of most figures.
244
+
245
+ >>> Box("Feature\\nExtractor", w=120, style="block")
246
+ """
247
+
248
+ role = "box"
249
+
250
+ def _radius(self, bb: BBox) -> float:
251
+ r = self.prop("radius", 0) or 0
252
+ if isinstance(r, str):
253
+ if r.lower() in ("pill", "full", "round"):
254
+ return min(bb.w, bb.h) / 2.0
255
+ r = float(r)
256
+ return max(0.0, min(float(r), bb.w / 2.0, bb.h / 2.0))
257
+
258
+ def shape_nodes(self, ctx: RenderContext, bb: BBox) -> list:
259
+ attrs = paint_attrs(self, ctx, bbox=bb)
260
+ r = self._radius(bb)
261
+ node = Node("rect", x=bb.x, y=bb.y, width=bb.w, height=bb.h, **attrs)
262
+ if r:
263
+ node.set(rx=r, ry=r)
264
+ return [node]
265
+
266
+ def path_data(self, bb: BBox = None) -> str:
267
+ bb = bb or self.local_bbox
268
+ r = self._radius(bb)
269
+ if not r:
270
+ return path_from_points(bb.corners, close=True)
271
+ return rounded_polyline(list(bb.corners), radius=r, close=True)
272
+
273
+
274
+ Rect = Box
275
+
276
+
277
+ class Pill(Box):
278
+ """A fully rounded box (radius = half the height)."""
279
+
280
+ def _radius(self, bb: BBox) -> float:
281
+ r = self.prop("radius", None)
282
+ if r is None or (isinstance(r, str) and r.lower() in ("pill", "full")):
283
+ return min(bb.w, bb.h) / 2.0
284
+ return super()._radius(bb)
285
+
286
+
287
+ Stadium = Pill
288
+
289
+
290
+ # ==========================================================================
291
+ # Ellipses
292
+ # ==========================================================================
293
+
294
+ class Ellipse(Shape):
295
+ """An ellipse that grows to fit its label."""
296
+
297
+ role = "ellipse"
298
+ default_padding = (10, 18)
299
+
300
+ def _measure(self) -> None:
301
+ super()._measure()
302
+ if self._label is not None and not (self._explicit_w and self._explicit_h):
303
+ # a label needs sqrt(2) more room inside an ellipse than a box
304
+ top, right, bottom, left = self.padding()
305
+ cw, ch = self._inner_size()
306
+ if not self._explicit_w:
307
+ self._w = max(self.min_w, cw * 1.42 + left + right)
308
+ if not self._explicit_h:
309
+ self._h = max(self.min_h, ch * 1.42 + top + bottom)
310
+ self._place_content()
311
+
312
+ def shape_nodes(self, ctx: RenderContext, bb: BBox) -> list:
313
+ attrs = paint_attrs(self, ctx, bbox=bb)
314
+ return [Node("ellipse", cx=bb.cx, cy=bb.cy, rx=bb.w / 2.0,
315
+ ry=bb.h / 2.0, **attrs)]
316
+
317
+ def anchor_point(self, name: str) -> Point:
318
+ return self.bbox.at_angle({"e": 0, "se": 45, "s": 90, "sw": 135,
319
+ "w": 180, "nw": 225, "n": 270,
320
+ "ne": 315}.get(name, 0))
321
+
322
+ def path_data(self, bb: BBox = None) -> str:
323
+ bb = bb or self.local_bbox
324
+ rx, ry = bb.w / 2.0, bb.h / 2.0
325
+ return (f"M{fmt(bb.x)} {fmt(bb.cy)}"
326
+ f"A{fmt(rx)} {fmt(ry)} 0 1 0 {fmt(bb.x1)} {fmt(bb.cy)}"
327
+ f"A{fmt(rx)} {fmt(ry)} 0 1 0 {fmt(bb.x)} {fmt(bb.cy)}Z")
328
+
329
+
330
+ class Circle(Ellipse):
331
+ """An ellipse constrained to a 1:1 aspect ratio."""
332
+
333
+ def __init__(self, text=None, x=0.0, y=0.0, r: float = None, **kw):
334
+ if r is not None:
335
+ kw.setdefault("w", r * 2)
336
+ kw.setdefault("h", r * 2)
337
+ super().__init__(text, x, y, **kw)
338
+ self.aspect = 1.0
339
+
340
+ def _measure(self) -> None:
341
+ super()._measure()
342
+ if not (self._explicit_w and self._explicit_h):
343
+ d = max(self._w or 0, self._h or 0)
344
+ self._w = self._h = d
345
+ self._place_content()
346
+
347
+ @property
348
+ def radius(self) -> float:
349
+ return self.bbox.w / 2.0
350
+
351
+
352
+ # ==========================================================================
353
+ # Polygonal shapes
354
+ # ==========================================================================
355
+
356
+ class _PolyShape(Shape):
357
+ """Shape defined by fractional ``(u, v)`` vertices inside its bbox."""
358
+
359
+ role = "box"
360
+ points_uv: tuple = ()
361
+ inset = (0.0, 0.0) # extra padding fraction (x, y) for the label
362
+
363
+ def _measure(self) -> None:
364
+ super()._measure()
365
+ fx, fy = self.inset
366
+ if self._label is not None and (fx or fy):
367
+ if not self._explicit_w:
368
+ self._w = self._w * (1 + fx)
369
+ if not self._explicit_h:
370
+ self._h = self._h * (1 + fy)
371
+ self._place_content()
372
+
373
+ def vertices(self, bb: BBox) -> list:
374
+ return [bb.uv(u, v) for u, v in self.points_uv]
375
+
376
+ def path_data(self, bb: BBox = None) -> str:
377
+ bb = bb or self.local_bbox
378
+ r = self.prop("radius", 0) or 0
379
+ pts = self.vertices(bb)
380
+ if r:
381
+ return rounded_polyline(pts, radius=float(r), close=True)
382
+ return path_from_points(pts, close=True)
383
+
384
+ def shape_nodes(self, ctx: RenderContext, bb: BBox) -> list:
385
+ attrs = paint_attrs(self, ctx, bbox=bb)
386
+ return [Node("path", d=self.path_data(bb), **attrs)]
387
+
388
+
389
+ class Diamond(_PolyShape):
390
+ """A rhombus — the decision node of a flow chart."""
391
+
392
+ points_uv = ((0.5, 0.0), (1.0, 0.5), (0.5, 1.0), (0.0, 0.5))
393
+ inset = (0.7, 0.7)
394
+
395
+
396
+ class Triangle(_PolyShape):
397
+ """An upward-pointing triangle."""
398
+
399
+ points_uv = ((0.5, 0.0), (1.0, 1.0), (0.0, 1.0))
400
+ inset = (0.5, 0.25)
401
+
402
+
403
+ class Hexagon(_PolyShape):
404
+ """A flat-topped hexagon, often used for a preparation or gate step."""
405
+
406
+ points_uv = ((0.25, 0.0), (0.75, 0.0), (1.0, 0.5),
407
+ (0.75, 1.0), (0.25, 1.0), (0.0, 0.5))
408
+ inset = (0.3, 0.0)
409
+
410
+
411
+ class Parallelogram(_PolyShape):
412
+ """A slanted box — conventionally input or output."""
413
+
414
+ points_uv = ((0.18, 0.0), (1.0, 0.0), (0.82, 1.0), (0.0, 1.0))
415
+ inset = (0.25, 0.0)
416
+
417
+
418
+ class Chevron(_PolyShape):
419
+ """An arrow-shaped block, for stages that chain left to right."""
420
+
421
+ points_uv = ((0.0, 0.0), (0.82, 0.0), (1.0, 0.5), (0.82, 1.0),
422
+ (0.0, 1.0), (0.18, 0.5))
423
+ inset = (0.3, 0.0)
424
+
425
+
426
+ class Star(_PolyShape):
427
+ """An n-pointed star. ``inner`` sets how deep the notches cut."""
428
+
429
+ def __init__(self, text=None, x=0.0, y=0.0, points: int = 5,
430
+ inner: float = 0.42, **kw):
431
+ self.n_points = int(points)
432
+ self.inner_ratio = float(inner)
433
+ super().__init__(text, x, y, **kw)
434
+ if not self._explicit_w:
435
+ self._w = self._w or 40
436
+ if not self._explicit_h:
437
+ self._h = self._h or 40
438
+
439
+ def vertices(self, bb: BBox) -> list:
440
+ pts = []
441
+ n = self.n_points
442
+ for i in range(2 * n):
443
+ r = 1.0 if i % 2 == 0 else self.inner_ratio
444
+ a = -math.pi / 2 + i * math.pi / n
445
+ pts.append(Point(bb.cx + math.cos(a) * r * bb.w / 2,
446
+ bb.cy + math.sin(a) * r * bb.h / 2))
447
+ return pts
448
+
449
+
450
+ class Cylinder(Shape):
451
+ """Database / storage symbol."""
452
+
453
+ role = "box"
454
+ default_padding = (16, 14)
455
+
456
+ def __init__(self, text=None, x=0.0, y=0.0, ellipse_h: float = None, **kw):
457
+ self.ellipse_h = ellipse_h
458
+ super().__init__(text, x, y, **kw)
459
+
460
+ def _eh(self, bb: BBox) -> float:
461
+ return (self.ellipse_h if self.ellipse_h is not None
462
+ else min(bb.h * 0.22, bb.w * 0.3))
463
+
464
+ def shape_nodes(self, ctx: RenderContext, bb: BBox) -> list:
465
+ attrs = paint_attrs(self, ctx, bbox=bb)
466
+ eh = self._eh(bb)
467
+ rx, ry = bb.w / 2.0, eh / 2.0
468
+ body = (f"M{fmt(bb.x)} {fmt(bb.y + ry)}"
469
+ f"A{fmt(rx)} {fmt(ry)} 0 0 1 {fmt(bb.x1)} {fmt(bb.y + ry)}"
470
+ f"L{fmt(bb.x1)} {fmt(bb.y1 - ry)}"
471
+ f"A{fmt(rx)} {fmt(ry)} 0 0 1 {fmt(bb.x)} {fmt(bb.y1 - ry)}Z")
472
+ top = (f"M{fmt(bb.x)} {fmt(bb.y + ry)}"
473
+ f"A{fmt(rx)} {fmt(ry)} 0 0 0 {fmt(bb.x1)} {fmt(bb.y + ry)}")
474
+ line_attrs = dict(attrs)
475
+ line_attrs["fill"] = "none"
476
+ return [Node("path", d=body, **attrs), Node("path", d=top, **line_attrs)]
477
+
478
+
479
+ class Note(Shape):
480
+ """Document/note shape with a folded corner."""
481
+
482
+ role = "box"
483
+
484
+ def __init__(self, text=None, x=0.0, y=0.0, fold: float = 14.0, **kw):
485
+ self.fold = float(fold)
486
+ super().__init__(text, x, y, **kw)
487
+
488
+ def shape_nodes(self, ctx: RenderContext, bb: BBox) -> list:
489
+ attrs = paint_attrs(self, ctx, bbox=bb)
490
+ f = min(self.fold, bb.w / 2, bb.h / 2)
491
+ body = (f"M{fmt(bb.x)} {fmt(bb.y)}H{fmt(bb.x1 - f)}L{fmt(bb.x1)} "
492
+ f"{fmt(bb.y + f)}V{fmt(bb.y1)}H{fmt(bb.x)}Z")
493
+ fold = (f"M{fmt(bb.x1 - f)} {fmt(bb.y)}V{fmt(bb.y + f)}H{fmt(bb.x1)}")
494
+ fold_attrs = dict(attrs)
495
+ fold_attrs["fill"] = "none"
496
+ return [Node("path", d=body, **attrs), Node("path", d=fold, **fold_attrs)]
497
+
498
+
499
+ # ==========================================================================
500
+ # Pure geometry
501
+ # ==========================================================================
502
+
503
+ class Path(Element):
504
+ """A raw SVG path. Bounds are computed exactly from the path data."""
505
+
506
+ role = "path"
507
+ STROKE_WIDTH_ALIAS = True
508
+
509
+ def __init__(self, d: str, x: float = None, y: float = None, **kw):
510
+ self._d = d or ""
511
+ x0, y0, x1, y1 = path_bbox(self._d)
512
+ self._src = BBox.from_corners(x0, y0, x1, y1)
513
+ self._scale = (1.0, 1.0)
514
+ super().__init__(self._src.x if x is None else x,
515
+ self._src.y if y is None else y,
516
+ self._src.w, self._src.h, **kw)
517
+ self._explicit_w = self._explicit_h = False
518
+
519
+ @property
520
+ def d(self) -> str:
521
+ return self._d
522
+
523
+ @d.setter
524
+ def d(self, value: str) -> None:
525
+ self._d = value or ""
526
+ x0, y0, x1, y1 = path_bbox(self._d)
527
+ self._src = BBox.from_corners(x0, y0, x1, y1)
528
+ self._w, self._h = self._src.w, self._src.h
529
+ self.invalidate()
530
+
531
+ def _measure(self) -> None:
532
+ self._w = self._src.w * self._scale[0]
533
+ self._h = self._src.h * self._scale[1]
534
+
535
+ def resize(self, w: float = None, h: float = None, anchor: str = "nw"):
536
+ keep = self.bbox.anchor(anchor)
537
+ sx = (w / self._src.w) if (w and self._src.w) else self._scale[0]
538
+ sy = (h / self._src.h) if (h and self._src.h) else self._scale[1]
539
+ if w and not h:
540
+ sy = sx
541
+ if h and not w:
542
+ sx = sy
543
+ self._scale = (sx, sy)
544
+ self.invalidate()
545
+ self._ensure()
546
+ now = self.bbox.anchor(anchor)
547
+ return self.move(keep.x - now.x, keep.y - now.y)
548
+
549
+ def transformed_d(self) -> str:
550
+ sx, sy = self._scale
551
+ m = (Affine.translate(self._x, self._y) @ Affine.scale(sx, sy)
552
+ @ Affine.translate(-self._src.x, -self._src.y))
553
+ return self._d if m.is_identity else transform_path_data(self._d, m)
554
+
555
+ def _render_content(self, ctx: RenderContext):
556
+ self._ensure()
557
+ attrs = paint_attrs(self, ctx, bbox=self.local_bbox)
558
+ return Node("path", d=self.transformed_d(), **attrs)
559
+
560
+ def path_data(self, bb: BBox = None) -> str:
561
+ return self.transformed_d()
562
+
563
+
564
+ class Polyline(Element):
565
+ """An open polyline through a list of points (optionally rounded).
566
+
567
+ Points may be raw coordinates *or* live references (anchors/elements).
568
+ With live references the polyline re-resolves them on every read, so it
569
+ follows whatever it was attached to.
570
+ """
571
+
572
+ role = "path"
573
+ closed = False
574
+ STROKE_WIDTH_ALIAS = True
575
+
576
+ def __init__(self, points, close: bool = None, **kw):
577
+ self._refs = list(points)
578
+ self._live = any(_is_live(p) for p in self._refs)
579
+ self._points = [to_point(p) for p in self._refs]
580
+ if close is not None:
581
+ self.closed = bool(close)
582
+ bb = BBox.from_points(self._points)
583
+ super().__init__(bb.x, bb.y, bb.w, bb.h, **kw)
584
+ self._src = bb
585
+ self._explicit_w = self._explicit_h = False
586
+
587
+ @property
588
+ def live(self) -> bool:
589
+ return self._live
590
+
591
+ @property
592
+ def points(self) -> list:
593
+ if self._live:
594
+ return [to_point(r) for r in self._refs]
595
+ off = Point(self._x - self._src.x, self._y - self._src.y)
596
+ return [p + off for p in self._points]
597
+
598
+ @points.setter
599
+ def points(self, value) -> None:
600
+ self._refs = list(value)
601
+ self._live = any(_is_live(p) for p in self._refs)
602
+ self._points = [to_point(p) for p in self._refs]
603
+ self._src = BBox.from_points(self._points)
604
+ self._x, self._y = self._src.x, self._src.y
605
+ self.invalidate()
606
+
607
+ def _measure(self) -> None:
608
+ if self._live:
609
+ bb = BBox.from_points(self.points)
610
+ self._x, self._y, self._w, self._h = bb.x, bb.y, bb.w, bb.h
611
+ else:
612
+ self._w, self._h = self._src.w, self._src.h
613
+
614
+ @property
615
+ def local_bbox(self) -> BBox:
616
+ if self._live:
617
+ self._dirty = True # endpoints may have moved
618
+ self._ensure()
619
+ return BBox(self._x, self._y, self._w or 0.0, self._h or 0.0)
620
+
621
+ def resize(self, w: float = None, h: float = None, anchor: str = "nw"):
622
+ """Scale the point set (only meaningful for static polylines)."""
623
+ if self._live or not self._src.w or not self._src.h:
624
+ return self
625
+ keep = self.bbox.anchor(anchor)
626
+ sx = (w / self._src.w) if w else 1.0
627
+ sy = (h / self._src.h) if h else 1.0
628
+ if w and not h:
629
+ sy = sx
630
+ if h and not w:
631
+ sx = sy
632
+ ox, oy = self._src.x, self._src.y
633
+ self._points = [Point(ox + (p.x - ox) * sx, oy + (p.y - oy) * sy)
634
+ for p in self._points]
635
+ self._src = BBox.from_points(self._points)
636
+ self.invalidate()
637
+ self._ensure()
638
+ now = self.bbox.anchor(anchor)
639
+ return self.move(keep.x - now.x, keep.y - now.y)
640
+
641
+ def path_data(self, bb: BBox = None) -> str:
642
+ r = self.prop("radius", 0) or 0
643
+ pts = self.points
644
+ if r:
645
+ return rounded_polyline(pts, float(r), close=self.closed)
646
+ return path_from_points(pts, close=self.closed)
647
+
648
+ def _render_content(self, ctx: RenderContext):
649
+ self._ensure()
650
+ attrs = paint_attrs(self, ctx, bbox=self.local_bbox)
651
+ if not self.closed:
652
+ attrs["fill"] = attrs.get("fill", "none")
653
+ if self.prop("fill", None) is None:
654
+ attrs["fill"] = "none"
655
+ return Node("path", d=self.path_data(), **attrs)
656
+
657
+
658
+ class Polygon(Polyline):
659
+ """A closed polyline."""
660
+
661
+ role = "box"
662
+ closed = True
663
+
664
+
665
+ class Line(Polyline):
666
+ """A straight segment between two points, anchors or elements."""
667
+
668
+ role = "line"
669
+
670
+ def __init__(self, start, end, **kw):
671
+ super().__init__([start, end], **kw)
672
+
673
+ @property
674
+ def start(self) -> Point:
675
+ return self.points[0]
676
+
677
+ @property
678
+ def end(self) -> Point:
679
+ return self.points[1]
680
+
681
+
682
+ def _is_live(obj) -> bool:
683
+ """True if ``obj`` resolves to a different point when things move."""
684
+ from .core import Anchor
685
+ return isinstance(obj, (Anchor, Element))
686
+
687
+
688
+ class Dot(Element):
689
+ """A small filled circle — plot markers, junction points, bullets."""
690
+
691
+ role = "marker"
692
+
693
+ def __init__(self, center=(0, 0), r: float = 3.0, **kw):
694
+ p = to_point(center)
695
+ self._r = float(r)
696
+ super().__init__(p.x - r, p.y - r, r * 2, r * 2, **kw)
697
+
698
+ @property
699
+ def r(self) -> float:
700
+ return self.bbox.w / 2.0
701
+
702
+ def _measure(self) -> None:
703
+ if self._w is None:
704
+ self._w = self._r * 2
705
+ if self._h is None:
706
+ self._h = self._r * 2
707
+
708
+ def _render_content(self, ctx: RenderContext):
709
+ self._ensure()
710
+ bb = self.local_bbox
711
+ attrs = paint_attrs(self, ctx, bbox=bb)
712
+ return Node("ellipse", cx=bb.cx, cy=bb.cy, rx=bb.w / 2,
713
+ ry=bb.h / 2, **attrs)
714
+
715
+
716
+ class Marker(Element):
717
+ """A plot marker: circle, square, diamond, triangle, cross, plus, x."""
718
+
719
+ role = "marker"
720
+ SHAPES = ("circle", "square", "diamond", "triangle", "cross", "plus",
721
+ "x", "star")
722
+
723
+ def __init__(self, center=(0, 0), size: float = 7.0, shape: str = "circle",
724
+ **kw):
725
+ p = to_point(center)
726
+ self.shape = str(shape).lower()
727
+ super().__init__(p.x - size / 2, p.y - size / 2, size, size, **kw)
728
+
729
+ def _render_content(self, ctx: RenderContext):
730
+ self._ensure()
731
+ bb = self.local_bbox
732
+ attrs = paint_attrs(self, ctx, bbox=bb)
733
+ s = self.shape
734
+ if s == "circle":
735
+ return Node("ellipse", cx=bb.cx, cy=bb.cy, rx=bb.w / 2,
736
+ ry=bb.h / 2, **attrs)
737
+ if s == "square":
738
+ return Node("rect", x=bb.x, y=bb.y, width=bb.w, height=bb.h, **attrs)
739
+ if s in ("cross", "plus", "x"):
740
+ line_attrs = dict(attrs)
741
+ line_attrs["fill"] = "none"
742
+ if line_attrs.get("stroke", "none") == "none":
743
+ line_attrs["stroke"] = self.prop("fill", "#000")
744
+ line_attrs.setdefault("stroke-width",
745
+ self.prop("stroke_width", 1.5) or 1.5)
746
+ if s == "plus":
747
+ d = (f"M{fmt(bb.x)} {fmt(bb.cy)}H{fmt(bb.x1)}"
748
+ f"M{fmt(bb.cx)} {fmt(bb.y)}V{fmt(bb.y1)}")
749
+ else:
750
+ d = (f"M{fmt(bb.x)} {fmt(bb.y)}L{fmt(bb.x1)} {fmt(bb.y1)}"
751
+ f"M{fmt(bb.x1)} {fmt(bb.y)}L{fmt(bb.x)} {fmt(bb.y1)}")
752
+ return Node("path", d=d, **line_attrs)
753
+ uv = {"diamond": ((0.5, 0), (1, 0.5), (0.5, 1), (0, 0.5)),
754
+ "triangle": ((0.5, 0), (1, 1), (0, 1))}.get(s)
755
+ if uv:
756
+ return Node("path", d=path_from_points([bb.uv(*p) for p in uv],
757
+ close=True), **attrs)
758
+ pts = []
759
+ for i in range(10):
760
+ r = 1.0 if i % 2 == 0 else 0.45
761
+ a = -math.pi / 2 + i * math.pi / 5
762
+ pts.append(Point(bb.cx + math.cos(a) * r * bb.w / 2,
763
+ bb.cy + math.sin(a) * r * bb.h / 2))
764
+ return Node("path", d=path_from_points(pts, close=True), **attrs)