kymo 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.
- kymo/__init__.py +33 -0
- kymo/alignment.py +534 -0
- kymo/cli.py +100 -0
- kymo/dsl.py +644 -0
- kymo/icons.py +530 -0
- kymo/layout.py +457 -0
- kymo/model.py +294 -0
- kymo/to_excalidraw.py +612 -0
- kymo/to_figma.py +319 -0
- kymo/to_svg.py +682 -0
- kymo/to_webp.py +197 -0
- kymo-0.1.0.dist-info/METADATA +76 -0
- kymo-0.1.0.dist-info/RECORD +16 -0
- kymo-0.1.0.dist-info/WHEEL +4 -0
- kymo-0.1.0.dist-info/entry_points.txt +2 -0
- kymo-0.1.0.dist-info/licenses/LICENSE +201 -0
kymo/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""kymo — diagram-as-code DSL → animated SVG / WebP / Figma / Excalidraw.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
|
|
5
|
+
from kymo import parse, layout, resolve_alignments, render
|
|
6
|
+
|
|
7
|
+
diagram, layout_spec, external = parse(source_text)
|
|
8
|
+
if layout_spec:
|
|
9
|
+
layout(diagram, layout_spec, external)
|
|
10
|
+
resolve_alignments(diagram)
|
|
11
|
+
svg = render(diagram, animate=True)
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from .alignment import resolve_alignments
|
|
16
|
+
from .dsl import parse
|
|
17
|
+
from .layout import layout
|
|
18
|
+
from .model import Component, Diagram, Edge, Region
|
|
19
|
+
from .to_svg import render
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"parse",
|
|
25
|
+
"layout",
|
|
26
|
+
"resolve_alignments",
|
|
27
|
+
"render",
|
|
28
|
+
"Component",
|
|
29
|
+
"Diagram",
|
|
30
|
+
"Edge",
|
|
31
|
+
"Region",
|
|
32
|
+
"__version__",
|
|
33
|
+
]
|
kymo/alignment.py
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
"""Local alignment — parent/child positioning + auto-bounded regions.
|
|
2
|
+
|
|
3
|
+
Components with a `parent` reference have their `pos` computed AFTER all
|
|
4
|
+
absolutely-positioned anchors are placed. Each child declares which side
|
|
5
|
+
of its parent it sits against (`align`) and how much breathing room
|
|
6
|
+
(`align_gap`).
|
|
7
|
+
|
|
8
|
+
Regions with a `contains` list have their `bounds` computed AFTER all
|
|
9
|
+
component positions are resolved — the bounding box automatically grows
|
|
10
|
+
to enclose every listed component INCLUDING its label area.
|
|
11
|
+
|
|
12
|
+
Resolution is depth-first with cycle detection; safe to call once per
|
|
13
|
+
diagram before rendering.
|
|
14
|
+
|
|
15
|
+
Example:
|
|
16
|
+
|
|
17
|
+
Component("orch", pos=(860, 200))
|
|
18
|
+
Component("researcher", parent="orch", align="right", align_gap=50)
|
|
19
|
+
Component("planner", parent="orch", align="bottom", align_gap=70)
|
|
20
|
+
Component("fs", parent="planner", align="right", align_gap=70)
|
|
21
|
+
Component("todo", parent="fs", align="right", align_gap=20)
|
|
22
|
+
|
|
23
|
+
Moving `orch` by 50 px moves every descendant by 50 px automatically.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from .model import Component, Diagram, LABEL_HEIGHT
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Approximate per-char widths used to estimate label extent. The renderer
|
|
31
|
+
# does not have access to real font metrics; these constants are tuned for
|
|
32
|
+
# the diagram's font stack (14 px bold name, 11.5 px regular subtitle).
|
|
33
|
+
# Slight overestimate is intentional — narrow labels never overlap, wide
|
|
34
|
+
# labels get just-enough breathing room.
|
|
35
|
+
_NAME_CHAR_W = 7 # 14 px bold sans average ≈ 7 px/char
|
|
36
|
+
_SUB_CHAR_W = 6 # 11.5 px regular sans ≈ 6 px/char
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _label_half_width(c: Component) -> int:
|
|
40
|
+
"""Approximate half-width of the longest text line under `c`'s icon.
|
|
41
|
+
Used by the auto-layout resolver to prevent adjacent components' labels
|
|
42
|
+
from overlapping. Returns 0 for shapes that don't render labels
|
|
43
|
+
(annotation, badge)."""
|
|
44
|
+
if c.shape in ("annotation", "badge"):
|
|
45
|
+
return 0
|
|
46
|
+
name_w = len(c.name) * _NAME_CHAR_W
|
|
47
|
+
sub_w = len(c.subtitle) * _SUB_CHAR_W
|
|
48
|
+
return max(name_w, sub_w) // 2
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _effective_half(c: Component) -> tuple[int, int]:
|
|
52
|
+
"""`Component.half` widened to whichever is bigger: icon or label.
|
|
53
|
+
Heights stay icon-only (`half[1]`); the label area below the icon is
|
|
54
|
+
accounted for separately via `LABEL_HEIGHT`."""
|
|
55
|
+
hw, hh = c.half
|
|
56
|
+
return (max(hw, _label_half_width(c)), hh)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def resolve_alignments(diagram: Diagram) -> None:
|
|
60
|
+
"""Five-pass resolver:
|
|
61
|
+
|
|
62
|
+
1. `_resolve_auto_layouts` — Figma-style: regions with `layout` direction
|
|
63
|
+
position every child component along that axis with `gap` spacing.
|
|
64
|
+
2. `_resolve_component_alignments` — pairwise parent/child anchoring
|
|
65
|
+
(`align="right"` etc) for components not placed by auto-layout.
|
|
66
|
+
3. `_resolve_region_bounds` — region bounding boxes computed from the
|
|
67
|
+
enclosed components (after their positions are final).
|
|
68
|
+
4. `_stagger_fanin_edges` — when several edges converge on the same
|
|
69
|
+
anchor (e.g. 3 webs → 1 userdb), spread their dst attach points
|
|
70
|
+
so the arrowheads don't pile up at one pixel.
|
|
71
|
+
5. `_auto_size_canvas` — if `diagram.width`/`height` are 0, derive
|
|
72
|
+
them from the resolved geometry (component + region + via extents)
|
|
73
|
+
plus a margin. Explicit `canvas W x H` directive overrides.
|
|
74
|
+
|
|
75
|
+
Mutates `diagram` in place."""
|
|
76
|
+
_resolve_auto_layouts(diagram)
|
|
77
|
+
_resolve_component_alignments(diagram)
|
|
78
|
+
_resolve_region_bounds(diagram)
|
|
79
|
+
_stagger_fanin_edges(diagram)
|
|
80
|
+
_stagger_trunk_lanes(diagram)
|
|
81
|
+
_auto_size_canvas(diagram)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _stagger_trunk_lanes(diagram: Diagram, min_step: int = 8, max_step: int = 16) -> None:
|
|
85
|
+
"""Sugiyama-style channel routing: when several Z-shape edges run
|
|
86
|
+
through the same vertical (or horizontal) corridor between two
|
|
87
|
+
component columns, assign each edge its own trunk-axis lane so the
|
|
88
|
+
long perpendicular segments stay parallel instead of stacking on
|
|
89
|
+
top of one another.
|
|
90
|
+
|
|
91
|
+
A "channel" is keyed by the pair of orthogonal coordinates the
|
|
92
|
+
edges enter/exit on — e.g. for horizontal anchors, all edges whose
|
|
93
|
+
`(src.right.x, dst.left.x)` collapse to the same gap share a
|
|
94
|
+
channel. Within each channel of N edges, lane offsets are
|
|
95
|
+
`(i − (N−1)/2) · step` (centred on the channel midpoint), sorted
|
|
96
|
+
by `(src.y, dst.y)` for a stable, geometrically sensible ordering.
|
|
97
|
+
|
|
98
|
+
Mutates each edge's `trunk_offset`; route_edge then shifts the
|
|
99
|
+
midpoint coord on the perpendicular axis."""
|
|
100
|
+
from collections import defaultdict
|
|
101
|
+
from .model import resolve_anchors
|
|
102
|
+
|
|
103
|
+
horiz: dict[tuple[int, int], list] = defaultdict(list)
|
|
104
|
+
vert: dict[tuple[int, int], list] = defaultdict(list)
|
|
105
|
+
for e in diagram.edges:
|
|
106
|
+
if e.via: # explicit routing wins
|
|
107
|
+
continue
|
|
108
|
+
src = diagram.get_node(e.src)
|
|
109
|
+
dst = diagram.get_node(e.dst)
|
|
110
|
+
if src is None or dst is None:
|
|
111
|
+
continue
|
|
112
|
+
sa, da = resolve_anchors(e, src, dst)
|
|
113
|
+
sp = src.anchor(sa)
|
|
114
|
+
dp = dst.anchor(da)
|
|
115
|
+
if sp[0] == dp[0] or sp[1] == dp[1]: # already axis-aligned
|
|
116
|
+
continue
|
|
117
|
+
if sa in ("left", "right"):
|
|
118
|
+
# Horizontal anchors → vertical trunk at midpoint x. Channel
|
|
119
|
+
# key snaps endpoints to the grid so near-equal x's group.
|
|
120
|
+
horiz[(round(sp[0] / 8) * 8, round(dp[0] / 8) * 8)].append((e, sp, dp))
|
|
121
|
+
else:
|
|
122
|
+
vert[(round(sp[1] / 8) * 8, round(dp[1] / 8) * 8)].append((e, sp, dp))
|
|
123
|
+
|
|
124
|
+
def assign(entries: list, sort_idx: int, channel_width: int) -> None:
|
|
125
|
+
n = len(entries)
|
|
126
|
+
if n <= 1:
|
|
127
|
+
return
|
|
128
|
+
# Only stagger when trunks would actually OVERLAP on the
|
|
129
|
+
# perpendicular axis. Two edges whose trunk-axis ranges don't
|
|
130
|
+
# intersect can share the channel midpoint — their trunks
|
|
131
|
+
# diverge from there (eg. one going up, one going down) and a
|
|
132
|
+
# forced lane offset just produces visible asymmetry (one
|
|
133
|
+
# bends early, the other late) without any overlap benefit.
|
|
134
|
+
def overlaps(t1, t2) -> bool:
|
|
135
|
+
r1 = sorted([t1[1][sort_idx], t1[2][sort_idx]])
|
|
136
|
+
r2 = sorted([t2[1][sort_idx], t2[2][sort_idx]])
|
|
137
|
+
return max(r1[0], r2[0]) < min(r1[1], r2[1])
|
|
138
|
+
|
|
139
|
+
if not any(overlaps(entries[i], entries[j])
|
|
140
|
+
for i in range(n) for j in range(i + 1, n)):
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
# Lane step is `max_step` (16 px) by default for clearly visible
|
|
144
|
+
# separation, but shrinks toward `min_step` (8 px) when the
|
|
145
|
+
# channel is too narrow to fit `(n+1)` lanes at the full step.
|
|
146
|
+
step = max(min_step, min(max_step, channel_width // (n + 1)))
|
|
147
|
+
entries.sort(key=lambda t: (t[1][sort_idx], t[2][sort_idx]))
|
|
148
|
+
mid = (n - 1) / 2
|
|
149
|
+
for i, (e, _sp, _dp) in enumerate(entries):
|
|
150
|
+
e.trunk_offset = round((i - mid) * step)
|
|
151
|
+
|
|
152
|
+
for (src_x, dst_x), entries in horiz.items():
|
|
153
|
+
assign(entries, sort_idx=1, channel_width=abs(dst_x - src_x))
|
|
154
|
+
for (src_y, dst_y), entries in vert.items():
|
|
155
|
+
assign(entries, sort_idx=0, channel_width=abs(dst_y - src_y))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _stagger_fanin_edges(diagram: Diagram) -> None:
|
|
159
|
+
"""ELK-style port distribution at both endpoints:
|
|
160
|
+
|
|
161
|
+
- **Fan-in** (N srcs → 1 dst, N ≥ 2): stagger `dst_offset` so
|
|
162
|
+
arrowheads land at distinct points on the destination edge.
|
|
163
|
+
- **Fan-out** (1 src → N dsts, N ≥ 3): stagger `src_offset` so
|
|
164
|
+
departing arrows leave the source edge at distinct ports.
|
|
165
|
+
The N ≥ 3 threshold keeps the "tree-branch" look for 2-edge
|
|
166
|
+
fan-out (one up, one down) — those naturally diverge and an
|
|
167
|
+
extra src spread just produces visible staircase steps.
|
|
168
|
+
|
|
169
|
+
Spacing is capped at 16 px per step and at the icon's cross-axis
|
|
170
|
+
dimension so offsets stay inside the icon. Pre-existing explicit
|
|
171
|
+
offsets are accumulated on top of, not replaced."""
|
|
172
|
+
from collections import defaultdict
|
|
173
|
+
from .model import resolve_anchors
|
|
174
|
+
|
|
175
|
+
fanin: dict[tuple[str, str], list[tuple]] = defaultdict(list)
|
|
176
|
+
fanout: dict[tuple[str, str], list[tuple]] = defaultdict(list)
|
|
177
|
+
for e in diagram.edges:
|
|
178
|
+
src = diagram.get_node(e.src)
|
|
179
|
+
dst = diagram.get_node(e.dst)
|
|
180
|
+
if src is None or dst is None:
|
|
181
|
+
continue
|
|
182
|
+
sa, da = resolve_anchors(e, src, dst)
|
|
183
|
+
fanin[(e.dst, da)].append((e, src, dst, sa, da))
|
|
184
|
+
fanout[(e.src, sa)].append((e, src, dst, sa, da))
|
|
185
|
+
|
|
186
|
+
STEP = 16
|
|
187
|
+
|
|
188
|
+
def spread(entries, anchor: str, attr: str, node_idx: int,
|
|
189
|
+
min_count: int) -> None:
|
|
190
|
+
"""Stagger `attr` (src_offset/dst_offset) on edges sharing `anchor`.
|
|
191
|
+
Skipped unless `len(entries) >= min_count`. Sorted by the OTHER
|
|
192
|
+
endpoint's position on the perpendicular axis so ports come out
|
|
193
|
+
in a non-crossing order (a → c-d-e from top to bottom → port
|
|
194
|
+
from top to bottom on a.right).
|
|
195
|
+
|
|
196
|
+
Edges marked `{ shared }` opt out — they stay on the centre
|
|
197
|
+
port. They're still counted in `min_count` so a 3-edge fan-out
|
|
198
|
+
with one `shared` still triggers stagger for the other two."""
|
|
199
|
+
n = len(entries)
|
|
200
|
+
if n < min_count:
|
|
201
|
+
return
|
|
202
|
+
# For fan-out (attr == "src_offset"), drop shared-port edges.
|
|
203
|
+
if attr == "src_offset":
|
|
204
|
+
entries = [t for t in entries if not getattr(t[0], "shared_port", False)]
|
|
205
|
+
if len(entries) < 1:
|
|
206
|
+
return
|
|
207
|
+
n = len(entries)
|
|
208
|
+
node = entries[0][node_idx]
|
|
209
|
+
other_idx = 2 if node_idx == 1 else 1
|
|
210
|
+
if anchor in ("left", "right"):
|
|
211
|
+
cross_span = node.half[1] * 2
|
|
212
|
+
entries.sort(key=lambda t: t[other_idx].pos[1])
|
|
213
|
+
spread_total = min(cross_span - 16, STEP * (n - 1))
|
|
214
|
+
mid = (n - 1) / 2
|
|
215
|
+
for i, t in enumerate(entries):
|
|
216
|
+
e = t[0]
|
|
217
|
+
dy = round((i - mid) / max(mid, 1) * (spread_total / 2))
|
|
218
|
+
cur = getattr(e, attr)
|
|
219
|
+
setattr(e, attr, (cur[0], cur[1] + dy))
|
|
220
|
+
elif anchor in ("top", "bottom"):
|
|
221
|
+
cross_span = node.half[0] * 2
|
|
222
|
+
entries.sort(key=lambda t: t[other_idx].pos[0])
|
|
223
|
+
spread_total = min(cross_span - 16, STEP * (n - 1))
|
|
224
|
+
mid = (n - 1) / 2
|
|
225
|
+
for i, t in enumerate(entries):
|
|
226
|
+
e = t[0]
|
|
227
|
+
dx = round((i - mid) / max(mid, 1) * (spread_total / 2))
|
|
228
|
+
cur = getattr(e, attr)
|
|
229
|
+
setattr(e, attr, (cur[0] + dx, cur[1]))
|
|
230
|
+
|
|
231
|
+
for (_, da), entries in fanin.items():
|
|
232
|
+
spread(entries, da, "dst_offset", node_idx=2, min_count=2)
|
|
233
|
+
for (_, sa), entries in fanout.items():
|
|
234
|
+
spread(entries, sa, "src_offset", node_idx=1, min_count=3)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _auto_size_canvas(diagram: Diagram, margin: int = 30) -> None:
|
|
238
|
+
"""Compute canvas dimensions from resolved geometry when not specified.
|
|
239
|
+
|
|
240
|
+
Walks all components (icon + label extents), regions (rect bounds),
|
|
241
|
+
and edge via points / explicit label positions. Computes both min and
|
|
242
|
+
max extents — if the leftmost/topmost extent sits closer to the canvas
|
|
243
|
+
edge than `margin`, every element is shifted to give it that margin.
|
|
244
|
+
This lets orphan components (no region/layout) render correctly at
|
|
245
|
+
their default `(0, 0)` position. Only overwrites `diagram.width` /
|
|
246
|
+
`diagram.height` if they are 0 (sentinel)."""
|
|
247
|
+
if diagram.width > 0 and diagram.height > 0:
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
min_x = min_y = 10**9
|
|
251
|
+
max_x = max_y = -10**9
|
|
252
|
+
|
|
253
|
+
for c in diagram.components:
|
|
254
|
+
eff_hw = max(c.half[0], _label_half_width(c))
|
|
255
|
+
lh = LABEL_HEIGHT.get(c.shape, 0) if (c.name or c.subtitle) else 0
|
|
256
|
+
left = c.pos[0] - eff_hw
|
|
257
|
+
right = c.pos[0] + eff_hw
|
|
258
|
+
top = c.pos[1] - c.half[1]
|
|
259
|
+
bottom = c.pos[1] + c.half[1] + lh
|
|
260
|
+
if left < min_x: min_x = left
|
|
261
|
+
if top < min_y: min_y = top
|
|
262
|
+
if right > max_x: max_x = right
|
|
263
|
+
if bottom > max_y: max_y = bottom
|
|
264
|
+
|
|
265
|
+
for r in diagram.regions:
|
|
266
|
+
if r.bounds == (0, 0, 0, 0):
|
|
267
|
+
continue # invisible layout-only
|
|
268
|
+
x, y, w, h = r.bounds
|
|
269
|
+
if x < min_x: min_x = x
|
|
270
|
+
if y < min_y: min_y = y
|
|
271
|
+
if x + w > max_x: max_x = x + w
|
|
272
|
+
if y + h > max_y: max_y = y + h
|
|
273
|
+
|
|
274
|
+
for e in diagram.edges:
|
|
275
|
+
for vx, vy in e.via:
|
|
276
|
+
if vx < min_x: min_x = vx
|
|
277
|
+
if vy < min_y: min_y = vy
|
|
278
|
+
if vx > max_x: max_x = vx
|
|
279
|
+
if vy > max_y: max_y = vy
|
|
280
|
+
if e.label_pos is not None:
|
|
281
|
+
lx, ly = e.label_pos
|
|
282
|
+
if lx < min_x: min_x = lx
|
|
283
|
+
if ly < min_y: min_y = ly
|
|
284
|
+
if lx > max_x: max_x = lx
|
|
285
|
+
if ly > max_y: max_y = ly
|
|
286
|
+
|
|
287
|
+
if min_x > max_x: # nothing to size against
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
dx = margin - min_x if min_x < margin else 0
|
|
291
|
+
dy = margin - min_y if min_y < margin else 0
|
|
292
|
+
if dx or dy:
|
|
293
|
+
for c in diagram.components:
|
|
294
|
+
c.pos = (c.pos[0] + dx, c.pos[1] + dy)
|
|
295
|
+
for r in diagram.regions:
|
|
296
|
+
if r.bounds == (0, 0, 0, 0):
|
|
297
|
+
continue
|
|
298
|
+
x, y, w, h = r.bounds
|
|
299
|
+
r.bounds = (x + dx, y + dy, w, h)
|
|
300
|
+
for e in diagram.edges:
|
|
301
|
+
e.via = [(vx + dx, vy + dy) for vx, vy in e.via]
|
|
302
|
+
if e.label_pos is not None:
|
|
303
|
+
e.label_pos = (e.label_pos[0] + dx, e.label_pos[1] + dy)
|
|
304
|
+
max_x += dx
|
|
305
|
+
max_y += dy
|
|
306
|
+
|
|
307
|
+
if diagram.width == 0:
|
|
308
|
+
diagram.width = max_x + margin
|
|
309
|
+
if diagram.height == 0:
|
|
310
|
+
diagram.height = max_y + margin
|
|
311
|
+
|
|
312
|
+
# Enforce min 4:3 (landscape) aspect: pad width and re-center
|
|
313
|
+
# horizontally so a tall vertical stack doesn't look like a column.
|
|
314
|
+
# Wide canvases are left untouched.
|
|
315
|
+
if diagram.width * 3 < diagram.height * 4:
|
|
316
|
+
new_w = (diagram.height * 4) // 3
|
|
317
|
+
shift = (new_w - diagram.width) // 2
|
|
318
|
+
for c in diagram.components:
|
|
319
|
+
c.pos = (c.pos[0] + shift, c.pos[1])
|
|
320
|
+
for r in diagram.regions:
|
|
321
|
+
if r.bounds == (0, 0, 0, 0):
|
|
322
|
+
continue
|
|
323
|
+
x, y, w, h = r.bounds
|
|
324
|
+
r.bounds = (x + shift, y, w, h)
|
|
325
|
+
for e in diagram.edges:
|
|
326
|
+
e.via = [(vx + shift, vy) for vx, vy in e.via]
|
|
327
|
+
if e.label_pos is not None:
|
|
328
|
+
e.label_pos = (e.label_pos[0] + shift, e.label_pos[1])
|
|
329
|
+
diagram.width = new_w
|
|
330
|
+
|
|
331
|
+
_snap_to_grid(diagram)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _snap_to_grid(diagram: Diagram, grid: int = 8) -> None:
|
|
335
|
+
"""Round all coordinates to multiples of `grid` (default 8px — matches
|
|
336
|
+
Material Design / iOS / Figma defaults). Component centers, edge
|
|
337
|
+
waypoints, label positions and canvas dimensions snap directly.
|
|
338
|
+
Region bounds are **re-derived** from the snapped component
|
|
339
|
+
positions (using the region's padding) rather than snapped
|
|
340
|
+
independently — that prevents a region rect from drifting off-centre
|
|
341
|
+
relative to its content when `s(x)` rounds down but `s_up(w)` rounds
|
|
342
|
+
up. Shape widths (hex 70, circle 76, cube 80) are left alone so
|
|
343
|
+
their edges may sit a half-pixel off the grid."""
|
|
344
|
+
def s(v: int) -> int:
|
|
345
|
+
return round(v / grid) * grid
|
|
346
|
+
|
|
347
|
+
def s_up(v: int) -> int:
|
|
348
|
+
return ((v + grid - 1) // grid) * grid
|
|
349
|
+
|
|
350
|
+
for c in diagram.components:
|
|
351
|
+
c.pos = (s(c.pos[0]), s(c.pos[1]))
|
|
352
|
+
for e in diagram.edges:
|
|
353
|
+
e.via = [(s(vx), s(vy)) for vx, vy in e.via]
|
|
354
|
+
if e.label_pos is not None:
|
|
355
|
+
e.label_pos = (s(e.label_pos[0]), s(e.label_pos[1]))
|
|
356
|
+
|
|
357
|
+
by_id = {c.id: c for c in diagram.components}
|
|
358
|
+
for r in diagram.regions:
|
|
359
|
+
if r.bounds == (0, 0, 0, 0) or not r.contains:
|
|
360
|
+
continue
|
|
361
|
+
# Re-derive bounds from now-snapped positions so the rect stays
|
|
362
|
+
# centred on its content. Mirror `_resolve_region_bounds` (label-
|
|
363
|
+
# aware so unlabelled components don't reserve an empty band).
|
|
364
|
+
cells = [by_id[cid] for cid in r.contains if cid in by_id]
|
|
365
|
+
if not cells:
|
|
366
|
+
continue
|
|
367
|
+
pad_x, pad_y = r.padding
|
|
368
|
+
pad_b = r.padding_bottom if r.padding_bottom is not None else pad_y
|
|
369
|
+
eff_hws = [max(c.half[0], _label_half_width(c)) for c in cells]
|
|
370
|
+
xs_left = [c.pos[0] - ew for c, ew in zip(cells, eff_hws)]
|
|
371
|
+
xs_right = [c.pos[0] + ew for c, ew in zip(cells, eff_hws)]
|
|
372
|
+
ys_top = [c.pos[1] - c.half[1] for c in cells]
|
|
373
|
+
ys_bot = [c.pos[1] + c.half[1]
|
|
374
|
+
+ (LABEL_HEIGHT.get(c.shape, 0) if (c.name or c.subtitle) else 0)
|
|
375
|
+
for c in cells]
|
|
376
|
+
x = min(xs_left) - pad_x
|
|
377
|
+
y = min(ys_top) - pad_y
|
|
378
|
+
w = max(xs_right) - x + pad_x
|
|
379
|
+
h = max(ys_bot) - y + pad_b
|
|
380
|
+
r.bounds = (x, y, w, h)
|
|
381
|
+
|
|
382
|
+
diagram.width = s_up(diagram.width)
|
|
383
|
+
diagram.height = s_up(diagram.height)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _resolve_auto_layouts(diagram: Diagram) -> None:
|
|
387
|
+
"""For each region with `layout` set, position every contained component
|
|
388
|
+
along the layout axis starting from the region's `pos` (top-left).
|
|
389
|
+
|
|
390
|
+
Regions without a `pos` are assumed to be positioned by an enclosing
|
|
391
|
+
`layout { … }` tree (which already laid out their children via
|
|
392
|
+
`apply_layout_tree` at parse time) — skip them silently."""
|
|
393
|
+
for r in diagram.regions:
|
|
394
|
+
if r.layout is None:
|
|
395
|
+
continue
|
|
396
|
+
if r.pos is None:
|
|
397
|
+
continue
|
|
398
|
+
if not r.contains:
|
|
399
|
+
continue
|
|
400
|
+
|
|
401
|
+
children = [diagram.get(cid) for cid in r.contains]
|
|
402
|
+
pad_x, pad_y = r.padding
|
|
403
|
+
ox, oy = r.pos
|
|
404
|
+
cursor_x = ox + pad_x
|
|
405
|
+
cursor_y = oy + pad_y
|
|
406
|
+
|
|
407
|
+
# Use EFFECTIVE half-width (max of icon and label) so adjacent
|
|
408
|
+
# children's labels don't overlap. `gap` then measures the clear
|
|
409
|
+
# distance between adjacent LABEL ends, not icon edges.
|
|
410
|
+
effs = [_effective_half(c) for c in children]
|
|
411
|
+
|
|
412
|
+
if r.layout == "horizontal":
|
|
413
|
+
# Cross-axis: vertical alignment based on the tallest child icon
|
|
414
|
+
max_h = max(eh for _, eh in effs)
|
|
415
|
+
for c, (ew, eh) in zip(children, effs):
|
|
416
|
+
_, ch = c.half
|
|
417
|
+
if r.align == "start":
|
|
418
|
+
cy = cursor_y + ch
|
|
419
|
+
elif r.align == "end":
|
|
420
|
+
cy = cursor_y + 2 * max_h - ch
|
|
421
|
+
else: # center (default)
|
|
422
|
+
cy = cursor_y + max_h
|
|
423
|
+
c.pos = (cursor_x + ew, cy)
|
|
424
|
+
cursor_x += ew * 2 + r.gap
|
|
425
|
+
|
|
426
|
+
else: # vertical
|
|
427
|
+
# Cross-axis: horizontal alignment based on the widest child
|
|
428
|
+
# (icon or label, whichever is wider).
|
|
429
|
+
max_w = max(ew for ew, _ in effs)
|
|
430
|
+
for c, (ew, eh) in zip(children, effs):
|
|
431
|
+
cw, _ = c.half
|
|
432
|
+
if r.align == "start":
|
|
433
|
+
cx = cursor_x + cw
|
|
434
|
+
elif r.align == "end":
|
|
435
|
+
cx = cursor_x + 2 * max_w - cw
|
|
436
|
+
else: # center
|
|
437
|
+
cx = cursor_x + max_w
|
|
438
|
+
c.pos = (cx, cursor_y + eh)
|
|
439
|
+
cursor_y += eh * 2 + r.gap
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _resolve_component_alignments(diagram: Diagram) -> None:
|
|
443
|
+
resolved: set[str] = set()
|
|
444
|
+
|
|
445
|
+
def resolve(cid: str, path: tuple[str, ...] = ()) -> None:
|
|
446
|
+
if cid in resolved:
|
|
447
|
+
return
|
|
448
|
+
if cid in path:
|
|
449
|
+
chain = " → ".join((*path, cid))
|
|
450
|
+
raise ValueError(f"alignment cycle: {chain}")
|
|
451
|
+
|
|
452
|
+
comp = diagram.get(cid)
|
|
453
|
+
if comp.parent is None:
|
|
454
|
+
resolved.add(cid)
|
|
455
|
+
return
|
|
456
|
+
|
|
457
|
+
# Resolve the parent first (depth-first).
|
|
458
|
+
resolve(comp.parent, path + (cid,))
|
|
459
|
+
parent = diagram.get(comp.parent)
|
|
460
|
+
|
|
461
|
+
if comp.align is None:
|
|
462
|
+
raise ValueError(
|
|
463
|
+
f"component {cid!r} has parent={comp.parent!r} but no align side"
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
comp.pos = _align_to(parent, comp, comp.align, comp.align_gap, comp.align_offset)
|
|
467
|
+
resolved.add(cid)
|
|
468
|
+
|
|
469
|
+
for c in diagram.components:
|
|
470
|
+
resolve(c.id)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _resolve_region_bounds(diagram: Diagram) -> None:
|
|
474
|
+
"""For each region with `contains`, compute its bounding box as the
|
|
475
|
+
envelope of all listed components (icon + label area), padded.
|
|
476
|
+
|
|
477
|
+
`padding_bottom` (when set on the region) overrides the default
|
|
478
|
+
bottom padding (`padding[1]`); useful to compensate visually for the
|
|
479
|
+
region label sitting ABOVE the rect."""
|
|
480
|
+
for r in diagram.regions:
|
|
481
|
+
if not r.contains:
|
|
482
|
+
continue
|
|
483
|
+
cells = [diagram.get(cid) for cid in r.contains]
|
|
484
|
+
pad_x, pad_y = r.padding
|
|
485
|
+
pad_b = r.padding_bottom if r.padding_bottom is not None else pad_y
|
|
486
|
+
|
|
487
|
+
# Use effective half-width so the rect wraps labels too, not just
|
|
488
|
+
# icons. Without this, long labels (e.g., "Academic Papers") would
|
|
489
|
+
# extend past the region rect.
|
|
490
|
+
eff_hws = [max(c.half[0], _label_half_width(c)) for c in cells]
|
|
491
|
+
xs_left = [c.pos[0] - ew for c, ew in zip(cells, eff_hws)]
|
|
492
|
+
xs_right = [c.pos[0] + ew for c, ew in zip(cells, eff_hws)]
|
|
493
|
+
ys_top = [c.pos[1] - c.half[1] for c in cells]
|
|
494
|
+
# Bottom extent INCLUDES label area when the component has one;
|
|
495
|
+
# an unlabelled component shouldn't reserve an empty band.
|
|
496
|
+
ys_bot = [c.pos[1] + c.half[1]
|
|
497
|
+
+ (LABEL_HEIGHT.get(c.shape, 0) if (c.name or c.subtitle) else 0)
|
|
498
|
+
for c in cells]
|
|
499
|
+
|
|
500
|
+
x = min(xs_left) - pad_x
|
|
501
|
+
y = min(ys_top) - pad_y
|
|
502
|
+
w = max(xs_right) - x + pad_x
|
|
503
|
+
h = max(ys_bot) - y + pad_b
|
|
504
|
+
r.bounds = (x, y, w, h)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _align_to(parent: Component, child: Component, side: str,
|
|
508
|
+
gap: int, offset: tuple[int, int]) -> tuple[int, int]:
|
|
509
|
+
"""Return the (cx, cy) for `child` aligned on `side` of `parent`."""
|
|
510
|
+
px, py = parent.pos
|
|
511
|
+
p_hw, p_hh = parent.half
|
|
512
|
+
p_label = LABEL_HEIGHT.get(parent.shape, 0)
|
|
513
|
+
c_hw, c_hh = child.half
|
|
514
|
+
ox, oy = offset
|
|
515
|
+
|
|
516
|
+
match side:
|
|
517
|
+
case "right":
|
|
518
|
+
# parent's right edge + gap + child half-width
|
|
519
|
+
cx = px + p_hw + gap + c_hw
|
|
520
|
+
cy = py
|
|
521
|
+
case "left":
|
|
522
|
+
cx = px - p_hw - gap - c_hw
|
|
523
|
+
cy = py
|
|
524
|
+
case "bottom":
|
|
525
|
+
# parent's bottom INCLUDING label area; child sits below that
|
|
526
|
+
cx = px
|
|
527
|
+
cy = py + p_hh + p_label + gap + c_hh
|
|
528
|
+
case "top":
|
|
529
|
+
cx = px
|
|
530
|
+
cy = py - p_hh - gap - c_hh
|
|
531
|
+
case _:
|
|
532
|
+
raise ValueError(f"unknown align side: {side!r}")
|
|
533
|
+
|
|
534
|
+
return (cx + ox, cy + oy)
|
kymo/cli.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Entry point: `kymo <path> [--animate] [--figma] [--excalidraw]`
|
|
2
|
+
|
|
3
|
+
Argument is a path to a `.diagram` (DSL) or `.py` (Python form) source.
|
|
4
|
+
Output is a SVG (or Figma Plugin JS / Excalidraw scene) written next to
|
|
5
|
+
the input file:
|
|
6
|
+
|
|
7
|
+
kymo samples/aws_1.diagram → samples/aws_1.svg
|
|
8
|
+
kymo samples/aws_1.diagram --animate → samples/aws_1-animated.svg
|
|
9
|
+
kymo samples/aws_1.diagram --figma → samples/aws_1.figma.js
|
|
10
|
+
kymo samples/aws_1.diagram --excalidraw → samples/aws_1.excalidraw
|
|
11
|
+
|
|
12
|
+
`--animate` emits a `-animated.svg` companion with flowing edge dashes
|
|
13
|
+
(CSS `stroke-dashoffset` animation). Static SVG (no JS); animation runs
|
|
14
|
+
in any modern browser.
|
|
15
|
+
|
|
16
|
+
`--figma` emits Figma Plugin API JavaScript. Pass it as the `code`
|
|
17
|
+
argument to the `use_figma` MCP tool, OR paste it into Figma's plugin
|
|
18
|
+
dev console (Plugins menu → Development → Open console).
|
|
19
|
+
|
|
20
|
+
`--excalidraw` emits an Excalidraw scene v2 JSON; open it directly in
|
|
21
|
+
excalidraw.com (Menu → Open) or the Excalidraw desktop app.
|
|
22
|
+
"""
|
|
23
|
+
import sys
|
|
24
|
+
from importlib import import_module
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from .alignment import resolve_alignments
|
|
28
|
+
from .dsl import parse as parse_dsl
|
|
29
|
+
from .layout import layout
|
|
30
|
+
from .to_excalidraw import render as render_excalidraw
|
|
31
|
+
from .to_figma import render as render_figma
|
|
32
|
+
from .to_svg import render
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load(source: Path) -> tuple[object, object | None, object | None]:
|
|
36
|
+
"""Load a diagram source. Returns (DIAGRAM, LAYOUT, EXTERNAL_LAYOUT)."""
|
|
37
|
+
if source.suffix == ".diagram":
|
|
38
|
+
return parse_dsl(source.read_text(encoding="utf-8"))
|
|
39
|
+
# For .py sources, the file's parent directory must be on sys.path so the
|
|
40
|
+
# module can import its siblings (e.g. samples/data.py importing model).
|
|
41
|
+
sys.path.insert(0, str(source.parent))
|
|
42
|
+
mod = import_module(source.stem)
|
|
43
|
+
return mod.DIAGRAM, getattr(mod, "LAYOUT", None), getattr(mod, "EXTERNAL_LAYOUT", None)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main() -> None:
|
|
47
|
+
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
|
48
|
+
flags = {a for a in sys.argv[1:] if a.startswith("--")}
|
|
49
|
+
|
|
50
|
+
if not args or "--help" in flags or "-h" in flags:
|
|
51
|
+
print(__doc__)
|
|
52
|
+
sys.exit(0 if args else 1)
|
|
53
|
+
|
|
54
|
+
src = Path(args[0])
|
|
55
|
+
if not src.exists():
|
|
56
|
+
print(f"not found: {src}")
|
|
57
|
+
sys.exit(1)
|
|
58
|
+
if src.suffix not in (".diagram", ".py"):
|
|
59
|
+
print(f"unsupported source: {src} (expected .diagram or .py)")
|
|
60
|
+
sys.exit(1)
|
|
61
|
+
|
|
62
|
+
animate = "--animate" in flags
|
|
63
|
+
figma = "--figma" in flags
|
|
64
|
+
excalidraw = "--excalidraw" in flags
|
|
65
|
+
|
|
66
|
+
diagram, layout_spec, external_layout = load(src)
|
|
67
|
+
if layout_spec:
|
|
68
|
+
layout(diagram, layout_spec, external_layout)
|
|
69
|
+
resolve_alignments(diagram)
|
|
70
|
+
|
|
71
|
+
if excalidraw:
|
|
72
|
+
payload = render_excalidraw(diagram)
|
|
73
|
+
out = src.with_suffix(".excalidraw")
|
|
74
|
+
out.write_text(payload, encoding="utf-8")
|
|
75
|
+
rel = out.relative_to(Path.cwd()) if out.is_relative_to(Path.cwd()) else out
|
|
76
|
+
print(f"✓ wrote {rel} (excalidraw) ({diagram.width}×{diagram.height}, {len(payload):,} bytes)")
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
if figma:
|
|
80
|
+
payload = render_figma(diagram)
|
|
81
|
+
out = src.with_suffix(".figma.js")
|
|
82
|
+
out.write_text(payload, encoding="utf-8")
|
|
83
|
+
rel = out.relative_to(Path.cwd()) if out.is_relative_to(Path.cwd()) else out
|
|
84
|
+
print(f"✓ wrote {rel} (figma plugin js) ({diagram.width}×{diagram.height}, {len(payload):,} bytes)")
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
svg = render(diagram, animate=animate)
|
|
88
|
+
|
|
89
|
+
out = src.with_suffix(".svg")
|
|
90
|
+
if animate:
|
|
91
|
+
out = out.with_stem(out.stem + "-animated")
|
|
92
|
+
out.write_text(svg, encoding="utf-8")
|
|
93
|
+
|
|
94
|
+
rel = out.relative_to(Path.cwd()) if out.is_relative_to(Path.cwd()) else out
|
|
95
|
+
suffix = " (animated)" if animate else ""
|
|
96
|
+
print(f"✓ wrote {rel}{suffix} ({diagram.width}×{diagram.height}, {len(svg):,} bytes)")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
main()
|