Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/assets/typst/packs/preview/fletcher/0.5.7.pack

140 KiB, 1 run

created by r2519314175:1067, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1DAIMOND TYPST PACK 1
2namespace preview
3name fletcher
4version 0.5.7
5entrypoint src/exports.typ
6file 1069 LICENSE
7file 8747 src/coords.typ
8file 8409 src/default-marks.typ
9file 30 src/deps.typ
10file 16875 src/diagram.typ
11file 23951 src/draw.typ
12file 34415 src/edge.typ
13file 205 src/exports.typ
14file 10134 src/marks.typ
15file 17341 src/node.typ
16file 12453 src/shapes.typ
17file 8826 src/utils.typ
18file 502 typst.toml
19
20MIT License
21
22Copyright (c) 2023 Joseph Wilson
23
24Permission is hereby granted, free of charge, to any person obtaining a copy
25of this software and associated documentation files (the "Software"), to deal
26in the Software without restriction, including without limitation the rights
27to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
28copies of the Software, and to permit persons to whom the Software is
29furnished to do so, subject to the following conditions:
30
31The above copyright notice and this permission notice shall be included in all
32copies or substantial portions of the Software.
33
34THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
35IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
36FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
37AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
38LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
39OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
40SOFTWARE.#import "utils.typ": *
41#import "deps.typ": cetz
42
43/// Convert from elastic to absolute coordinates, $(u, v) |-> (x, y)$.
44///
45/// _Elastic_ coordinates are specific to the diagram and adapt to row/column
46/// sizes; _absolute_ coordinates are the final, physical lengths which are
47/// passed to `cetz`.
48///
49/// - grid (dictionary): Representation of the grid layout, including:
50/// - `origin`
51/// - `centers`
52/// - `spacing`
53/// - `flip`
54/// The `grid` is passed to #the-param[diagram][render].
55/// - uv (array): Elastic coordinate, `(float, float)`.
56#let uv-to-xy(grid, uv) = {
57 let (i, j) = vector.sub(vector-2d(uv), grid.origin)
58
59 let (n-x, n-y) = grid.centers.map(array.len)
60 if grid.flip.xy { (n-x, n-y) = (n-y, n-x) }
61 if grid.flip.x { i = (n-x - 1) - i }
62 if grid.flip.y { j = (n-y - 1) - j }
63 if grid.flip.xy { (i, j) = (j, i) }
64
65 (i, j).zip(grid.centers, grid.spacing)
66 .map(((t, c, s)) => interp(c, t, spacing: s))
67}
68
69/// Convert from absolute to elastic coordinates, $(x, y) |-> (u, v)$.
70///
71/// Inverse of `uv-to-xy()`.
72#let xy-to-uv(grid, xy) = {
73 let (i, j) = xy.zip(grid.centers, grid.spacing)
74 .map(((x, c, s)) => interp-inv(c, x, spacing: s))
75
76 let (n-x, n-y) = grid.centers.map(array.len)
77 if grid.flip.xy { (n-x, n-y) = (n-y, n-x) }
78 if grid.flip.xy { (i, j) = (j, i) }
79 if grid.flip.x { i = (n-x - 1) - i }
80 if grid.flip.y { j = (n-y - 1) - j }
81
82 vector.add((i, j), grid.origin)
83}
84
85/// Jacobian of the coordinate map `uv-to-xy()`.
86///
87/// Used to convert a "nudge" in $u v$ coordinates to a "nudge" in $x y$
88/// coordinates. This is needed because $u v$ coordinates are non-linear
89/// (they're elastic). Uses a balanced finite differences approximation.
90///
91/// - grid (dictionary): Representation of the grid layout.
92/// The `grid` is passed to #the-param[diagram][render].
93/// - uv (array): The point `(float, float)` in the $u v$-manifold where the
94/// shift tangent vector is rooted.
95/// - duv (array): The shift tangent vector `(float, float)` in $u v$ coordinates.
96#let duv-to-dxy(grid, uv, duv) = {
97 let duv = vector.scale(duv, 0.5)
98 vector.sub(
99 uv-to-xy(grid, vector.add(uv, duv)),
100 uv-to-xy(grid, vector.sub(uv, duv)),
101 )
102}
103
104/// Jacobian of the coordinate map `xy-to-uv()`.
105#let dxy-to-duv(grid, xy, dxy) = {
106 let dxy = vector.scale(dxy, 0.5)
107 vector.sub(
108 xy-to-uv(grid, vector.add(xy, dxy)),
109 xy-to-uv(grid, vector.sub(xy, dxy)),
110 )
111}
112
113/// Return a vector rooted at a $x y$ coordinate with a given angle $θ$ in $x
114/// y$-space but with a length specified in either $x y$-space or $u v$-space.
115#let vector-polar-with-xy-or-uv-length(grid, xy, target-length, θ) = {
116 if type(target-length) == length {
117 vector-polar(target-length, θ)
118 } else {
119 let unit = vector-polar(1pt, θ)
120 let det = vector.len(dxy-to-duv(grid, xy, unit))
121 vector.scale(unit, target-length/det)
122 }
123}
124
125
126
127#let NAN_COORD = (float("nan"),)*2
128
129#let default-ctx = (
130 prev: (pt: (0, 0)),
131
132 // cetz anchors assume y axis going up.
133 // see lines ending with the comment
134 // CETZ Y AXIS
135 transform:
136 ((1, 0, 0, 0),
137 (0,-1, 0, 0),
138 (0, 0, 1, 0),
139 (0, 0, 0, 1)),
140
141 nodes: (:),
142 length: 1cm,
143 em-size: (width: 11pt, height: 11pt),
144 style: cetz.styles.default,
145 groups: (),
146 debug: false,
147)
148
149
150#let resolve-system(coord) = {
151 if type(coord) == dictionary and ("u", "v").all(k => k in coord) {
152 return "uv"
153 } else if type(coord) == label {
154 return "element"
155 }
156
157 let cetz-system = cetz.coordinate.resolve-system(coord)
158 if cetz-system == "xyz" and coord.len() == 2 {
159 if coord.all(x => type(x) == length) {
160 "xyz"
161 } else if coord.all(x => type(x) in (int, float)) {
162 "uv"
163 } else {
164 error("Coordinates must be two numbers (for elastic coordinates) or two lengths (for physical coordinates); got #0.", coord)
165 }
166 } else {
167 cetz-system
168 }
169}
170
171#let resolve-anchor(ctx, c) = {
172 // (name: <string>, anchor: <number, angle, string> or <none>)
173 // "name.anchor"
174 // "name"
175 if type(c) == label { c = str(c) }
176
177 let (name, anchor) = if type(c) == str {
178 let (name, ..anchor) = c.split(".")
179 if anchor.len() == 0 {
180 anchor = "default"
181 }
182 (name, anchor)
183 } else {
184 (str(c.name), c.at("anchor", default: "default"))
185 }
186
187 if name not in ctx.nodes {
188 error("Node #0 not found. Named nodes are: #..1.", name, ctx.nodes.keys())
189 }
190
191 // Resolve length anchors
192 if type(anchor) == length {
193 anchor = util.resolve-number(ctx, anchor)
194 }
195
196 let calculate-anchors = ctx.nodes.at(name).anchors
197
198 (calculate-anchors)(anchor)
199}
200
201
202
203#let resolve-relative(resolve, ctx, c) = {
204 // (rel: <coordinate>, update: <bool> or <none>, to: <coordinate>)
205 let update = c.at("update", default: true)
206
207 let target-system = ctx.target-system
208 let sub-ctx = ctx + (target-system: auto)
209
210 let (ctx, rel) = resolve(sub-ctx, c.rel, update: false)
211
212 ctx.target-system = target-system
213 let (ctx, to) = if "to" in c {
214 resolve(ctx, c.to, update: false)
215 } else {
216 (ctx, ctx.prev.pt)
217 }
218
219 if is-nan-vector(to) {
220 return (coord: to, update: update)
221 }
222
223
224 let is-xy(coord) = coord.any(x => type(x) == length)
225 let is-uv(coord) = not is-xy(coord)
226
227 let error-value = (coord: NAN_COORD, update: update)
228
229 if is-xy(rel) and is-uv(to) {
230 if "grid" not in ctx { return error-value }
231 to = uv-to-xy(ctx.grid, to)
232 } else if is-uv(rel) and is-xy(to) {
233 if "grid" not in ctx { return error-value }
234 to = xy-to-uv(ctx.grid, to)
235 }
236
237 c = vector.add(rel, to)
238
239 if ctx.target-system == "xyz" and is-uv(c) {
240 if "grid" not in ctx { return error-value }
241 c = uv-to-xy(ctx.grid, c)
242 } else if ctx.target-system == "uv" and is-xy(c) {
243 if "grid" not in ctx { return error-value }
244 c = xy-to-uv(ctx.grid, c)
245 }
246
247 (coord: c, update: update)
248}
249
250
251/// Resolve CeTZ-style coordinate expressions to absolute vectors.
252///
253/// This is an drop-in replacement of `cetz.coordinate.resolve()` but extended
254/// to handle fletcher's elastic $u v$ coordinates alongside CeTZ' physical $x
255/// y$ coordinates. The target coordinate system must be specified in the
256/// context object `ctx`.
257///
258/// Resolving $u v$ coordinates to or from $x y$ coordinates requires the
259/// diagram's `grid`, which defines the non-linear maps `uv-to-xy()` and
260/// `xy-to-uv()`. The `grid` may be supplied in the context object `ctx`.
261///
262/// If `grid` is not supplied, *coordinate resolution may fail*, in which case
263/// the vector #fletcher.NAN_COORD is returned.
264///
265/// - ctx (dictionary): CeTZ canvas context object, additionally containing:
266/// - `target-system`: the target coordinate system to resolve to, one of
267/// `"uv"` or `"xyz"`.
268/// - `grid` (optional): the diagram's grid specification, defining the
269/// coordinate maps $u v <-> x y$. If not given, coordinates requiring this
270/// map resolve to #fletcher.NAN_COORD.
271///
272/// - ..coordinates (coordinate): CeTZ-style coordinate expression(s), e.g.,
273/// `(1, 2)`, `(45deg, 2cm)`, or `(rel: (+1, 0), to: "name")`.
274#let resolve(ctx, ..coordinates, update: true) = {
275 assert(ctx.target-system in (auto, "uv", "xyz"))
276
277 let result = ()
278 for c in coordinates.pos() {
279 let t = resolve-system(c)
280 let out = if t == "uv" {
281 if ctx.target-system in (auto, "uv") {
282 let (u, v) = c // also works for dictionaries
283 (u, v)
284 } else if ctx.target-system == "xyz" {
285 if "grid" in ctx { uv-to-xy(ctx.grid, c) }
286 else { NAN_COORD }
287 }
288 } else if t == "xyz" {
289 let c = cetz.coordinate.resolve-xyz(c)
290 c = vector-2d(c).map(x => x.abs + x.em*ctx.em-size.width)
291 if ctx.target-system in (auto, "xyz") {
292 c
293 } else if ctx.target-system == "uv" {
294 if "grid" in ctx { xy-to-uv(ctx.grid, c) }
295 else { NAN_COORD }
296 }
297 } else if t == "previous" {
298 (ctx, c) = resolve(ctx, ctx.prev.pt)
299 c
300 } else if t == "polar" {
301 c = vector-2d(cetz.coordinate.resolve-polar(c))
302 resolve(ctx, c).at(1) // ensure uv <-> xyz conversion
303 } else if t == "barycentric" {
304 cetz.coordinate.resolve-barycentric(ctx, c)
305 } else if t in ("element", "anchor") {
306 resolve-anchor(ctx, c)
307 } else if t == "tangent" {
308 cetz.coordinate.resolve-tangent(resolve, ctx, c)
309 } else if t == "perpendicular" {
310 cetz.coordinate.resolve-perpendicular(resolve, ctx, c)
311 } else if t == "relative" {
312 let result = resolve-relative(resolve, ctx, c)
313 update = result.update
314 result.coord
315 } else if t == "lerp" {
316 cetz.coordinate.resolve-lerp(resolve, ctx, c)
317 } else if t == "function" {
318 cetz.coordinate.resolve-function(resolve, ctx, c)
319 } else {
320 error("Failed to resolve coordinate #0.", c)
321 }
322
323 out = vector-2d(out)
324
325 if update { ctx.prev.pt = out }
326 result.push(out)
327 }
328
329 assert(result.all(c => c.len() == 2))
330
331 return (ctx, ..result)
332}
333
334
335#let is-grid-independent-uv-coordinate(coord) = {
336 let ctx = default-ctx + (target-system: "uv")
337 (ctx, coord) = resolve(ctx, coord)
338 not is-nan-vector(coord)
339}
340
341#import "deps.typ"
342#import deps.cetz.draw
343
344#let DEFAULT_MARKS = (
345 // all numbers are interpreted as multiples of stroke thickness
346
347 head: (
348 size: 7, // radius of curvature
349 sharpness: 24.7deg, // angle at vertex between central line and arrow's edge
350 delta: 53.5deg, // angle spanned by arc of curved arrow edge
351
352 tip-origin: 0.5,
353 tail-end: mark => calc.min(..mark.extrude),
354 tail-origin: mark => {
355 let dx = calc.cos(mark.sharpness) + calc.cos(mark.sharpness + mark.delta)
356 mark.tail-end - mark.size*mark.delta/1.8rad*dx
357 },
358
359 stroke: (cap: "round"),
360
361 draw: mark => {
362 for flip in (+1, -1) {
363 draw.arc(
364 (0, 0),
365 radius: mark.size,
366 start: flip*(90deg + mark.sharpness),
367 delta: flip*mark.delta,
368 fill: none,
369 )
370 }
371 },
372
373 cap-offset: (mark, y) => {
374 import calc: sin, sqrt, pow, cos, abs, max
375 let r = mark.size
376 let θ = mark.sharpness
377 r*(sin(θ) - sqrt(max(0, 1 - pow(cos(θ) - abs(y)/r, 2))))
378 },
379
380 ),
381
382 doublehead: (
383 inherit: "head",
384 size: 10.56,
385 sharpness: 19.4deg,
386 delta: 43.5deg,
387 ),
388
389 triplehead: (
390 inherit: "head",
391 size: 13.5,
392 sharpness: 25.5deg,
393 delta: 42.6deg,
394 ),
395
396 harpoon: (
397 inherit: "head",
398 draw: mark => {
399 draw.arc(
400 (0, 0),
401 radius: mark.size,
402 start: -(90deg + mark.sharpness),
403 delta: -mark.delta,
404 fill: none,
405 )
406 },
407 ),
408
409 straight: (
410 size: 10,
411 sharpness: 20deg,
412
413 tip-origin: mark => 0.5/calc.sin(mark.sharpness),
414 tail-origin: mark => -mark.size*calc.cos(mark.sharpness),
415
416 fill: none,
417
418 draw: mark => {
419 draw.line(
420 (180deg + mark.sharpness, mark.size),
421 (0, 0),
422 (180deg - mark.sharpness, mark.size),
423 )
424 },
425
426 cap-offset: (mark, y) => calc.tan(mark.sharpness + 90deg)*calc.abs(y),
427 ),
428
429 solid: (
430 inherit: "straight",
431
432 tip-origin: 0,
433 tip-end: mark => -0.5/calc.sin(mark.sharpness),
434 tail-end: mark => -0.5/calc.sin(mark.sharpness),
435
436 stroke: none,
437 fill: auto,
438 ),
439
440 stealth: (
441 size: 6,
442 stealth: 0.3,
443 angle: 25deg,
444 rear-angle: mark => calc.atan2(mark.stealth, calc.tan(mark.angle)),
445
446 tip-origin: mark => 0.5/calc.sin(mark.angle),
447 tip-end: mark => mark.size*(mark.stealth - 1)*calc.cos(mark.angle),
448 tail-origin: mark => {
449 if mark.stealth > 0 {
450 let wing-angle = (mark.rear-angle - mark.angle)/2
451
452 let miter-limit = if mark.stroke == none { 0 }
453 else { stroke(mark.stroke).miter-limit }
454
455 let miter-length = 1/calc.sin(wing-angle)
456 // stealth arrows with sharp wings look bigger due to long miter lengths
457 let extra-size = if miter-length < miter-limit {
458 // so account for extra apparent size
459 0.4*miter-length
460 } else {
461 // unless over miter limit, since wings get clipped
462 0
463 }
464
465 -(mark.size + extra-size)*calc.cos(mark.angle)
466 } else {
467 // negative stealth looks like a diamond
468 mark.tip-end - 0.5/calc.sin(mark.rear-angle)
469 }
470 },
471
472 stroke: (miter-limit: 20),
473
474 draw: mark => {
475 draw.line(
476 (0,0),
477 (180deg + mark.angle, mark.size),
478 (mark.tip-end, 0),
479 (180deg - mark.angle, mark.size),
480 close: true,
481 )
482 },
483
484 cap-offset: (mark, y) => if mark.tip {
485 -mark.stealth/calc.tan(mark.angle)*calc.abs(y)
486 } else {
487 calc.tan(mark.angle + 90deg)*calc.abs(y)
488 },
489 ),
490
491 latex: (
492 size: 23, // radius of curvature
493 sharpness: 10deg, // angle at vertex between central line and arrow's edge
494 delta: 20deg, // angle spanned by arc of curved arrow edge
495
496 tip-end: mark => mark.size*(calc.sin(mark.sharpness) - calc.sin(mark.sharpness + mark.delta)),
497 tail-end: mark => mark.tip-end/2,
498 tail-origin: mark => mark.tip-end,
499
500 fill: auto,
501 stroke: none,
502 draw: mark => {
503 for flip in (+1, -1) {
504 draw.merge-path({
505 draw.arc(
506 (0, 0),
507 radius: mark.size,
508 start: flip*(90deg + mark.sharpness),
509 delta: flip*mark.delta,
510 fill: none,
511 )
512 draw.line((), ((), "|-", (0, flip*1e-1)))
513 })
514 }
515 }
516 ),
517
518 cone: (
519 size: 8,
520 radius: 6,
521 angle: 30deg,
522
523 tip-end: mark => -mark.size,
524 tail-end: mark => mark.tip-end/2,
525 tail-origin: mark => mark.tip-end,
526
527 stroke: none,
528 draw: mark => {
529 for flip in (+1, -1) {
530 draw.merge-path({
531 draw.arc(
532 (-mark.size, -flip*1e-1),
533 radius: mark.radius,
534 start: 0deg,
535 stop: flip*mark.angle,
536 )
537 draw.line((), (0, 0))
538 })
539 }
540 }
541 ),
542
543 circle: (
544 size: 2,
545
546 tip-end: mark => -mark.size,
547 tail-end: mark => mark.size,
548 tip-origin: mark => mark.size + 0.5,
549 tail-origin: mark => -(mark.size + 0.5),
550
551 fill: none,
552
553 draw: mark => draw.circle((0,0), radius: mark.size, fill: mark.fill),
554
555 cap-offset: (mark, y) => {
556 let r = mark.size
557 let o = r - calc.sqrt(calc.max(0, r*r - y*y))
558 if not mark.tip { o *= -1 }
559 o
560 },
561 ),
562
563 square: (
564 size: 2,
565 angle: 0deg,
566 fill: none,
567 tip-origin: mark => +(mark.size + 0.5)/calc.cos(mark.angle),
568 tail-origin: mark => -(mark.size + 0.5)/calc.cos(mark.angle),
569 tip-end: mark => -mark.size/calc.cos(mark.angle),
570 tail-end: mark => +mark.size/calc.cos(mark.angle),
571 draw: mark => {
572 let x = mark.size
573 draw.rotate(mark.angle)
574 draw.rect(
575 (-x, -x), (+x, +x),
576 )
577 }
578 ),
579
580 diamond: (
581 inherit: "stealth",
582 size: 4,
583 angle: 45deg,
584 stealth: -1,
585 fill: none,
586 ),
587
588 bar: (
589 size: 4.9,
590 angle: 90deg,
591
592 tail-origin: mark => calc.min(..mark.extrude),
593
594 draw: mark => draw.line(
595 (mark.angle, -mark.size),
596 (mark.angle, +mark.size),
597 ),
598 cap-offset: (mark, y) => {
599 let o = y*calc.tan(mark.angle - 90deg)
600 // if mark.tip { o *= -1 }
601 -o
602 },
603 ),
604
605 cross: (
606 size: 4,
607 angle: 45deg,
608 draw: mark => {
609 draw.line((+mark.angle, -mark.size), (+mark.angle, +mark.size))
610 draw.line((-mark.angle, -mark.size), (-mark.angle, +mark.size))
611 },
612
613 cap-offset: (mark, y) => calc.tan(mark.angle + 90deg)*calc.abs(y),
614 ),
615
616 hook: (
617 size: 2.88,
618 rim: 0.85,
619
620 tip-origin: mark => mark.size + 0.5,
621
622 stroke: (cap: "round"),
623
624 draw: mark => {
625 draw.arc(
626 (0,0),
627 start: -90deg,
628 stop: +90deg,
629 radius: mark.size,
630 fill: none,
631 )
632 draw.line((), (rel: (-mark.rim, 0)))
633 },
634 ),
635
636 hooks: (
637 inherit: "hook",
638 draw: mark => {
639 for flip in (-1, +1) {
640 draw.arc(
641 (0,0),
642 start: -flip*90deg,
643 stop: +flip*90deg,
644 radius: mark.size,
645 fill: none,
646 )
647 }
648 },
649 ),
650
651 ">": (inherit: "head", rev: false),
652 "<": (inherit: "head", rev: true),
653
654 ">>": (inherit: "head", extrude: (-2.88, 0), rev: false),
655 "<<": (inherit: "head", extrude: (-2.88, 0), rev: true),
656
657 ">>>": (inherit: "head", extrude: (-6, -3, 0), rev: false),
658 "<<<": (inherit: "head", extrude: (-6, -3, 0), rev: true),
659
660 "|>": (inherit: "solid", rev: false),
661 "<|": (inherit: "solid", rev: true),
662
663 "}>": (inherit: "stealth", rev: false),
664 "<{": (inherit: "stealth", rev: true),
665
666 "|": (inherit: "bar"),
667 "||": (inherit: "bar", extrude: (-3, 0)),
668 "|||": (inherit: "bar", extrude: (-6, -3, 0)),
669
670 "/": (inherit: "bar", angle: +60deg, rev: false),
671 "\\": (inherit: "bar", angle: -60deg, rev: false),
672
673 "x": (inherit: "cross"),
674 "X": (inherit: "cross", size: 7),
675
676 "o": (inherit: "circle"),
677 "O": (inherit: "circle", size: 4),
678 "*": (inherit: "circle", fill: auto),
679 "@": (inherit: "circle", size: 4, fill: auto),
680
681 "[]": (inherit: "square"),
682 "<>": (inherit: "diamond"),
683
684
685
686
687 // crow's foot notation
688 crowfoot: (
689 many-width: 5,
690 many-length: 8,
691 one-width: 5,
692 zero-width: 3.5,
693 gap: 3,
694 first-gap: 5,
695 many: true,
696 one: true,
697 zero: true,
698 tail-origin: mark => -mark.many-length,
699 zero-fill: white,
700 fill: none,
701 draw: mark => {
702 let x = 0
703 if mark.many {
704 draw.line((0, mark.many-width), (-mark.many-length - .5, 0), (0, -mark.many-width))
705 x -= mark.many-length
706 }
707 if mark.one {
708 x -= mark.gap
709 x = calc.min(x, -mark.first-gap)
710 draw.line((x, mark.one-width), (x, -mark.one-width))
711 }
712 if mark.zero {
713 x -= mark.gap
714 draw.circle((x - mark.zero-width, 0), radius: mark.zero-width, fill: mark.zero-fill)
715 }
716 }
717 ),
718 "n": (inherit: "crowfoot", zero: false, one: false, many: true),
719 "n!": (inherit: "crowfoot", zero: false, one: true, many: true),
720 "n?": (inherit: "crowfoot", zero: true, one: false, many: true),
721 "1": (inherit: "crowfoot", zero: false, one: true, many: false),
722 "1!": (inherit: "crowfoot", zero: false, one: true, many: false, extrude: mark => (0, -calc.max(4, mark.gap))),
723 "1?": (inherit: "crowfoot", zero: true, one: true, many: false),
724
725)
726
727#let MARKS = state("fletcher-marks", DEFAULT_MARKS)
728#import "@preview/cetz:0.3.4"
729#import "utils.typ": *
730#import "node.typ": *
731#import "edge.typ": *
732#import "draw.typ": draw-diagram
733#import "coords.typ": *
734
735
736
737/// Interpret #the-param[diagram][axes].
738///
739/// Returns a dictionary with:
740/// - `x`: Whether $u$ is reversed
741/// - `y`: Whether $v$ is reversed
742/// - `xy`: Whether the axes are swapped
743///
744/// - axes (array): Pair of directions specifying the interpretation of $(u, v)$
745/// coordinates. For example, `(ltr, ttb)` means $u$ goes $arrow.r$ and $v$
746/// goes $arrow.b$.
747/// -> dictionary
748#let interpret-axes(axes) = {
749 let dirs = axes.map(direction.axis)
750 let flip
751 if dirs == ("horizontal", "vertical") {
752 flip = false
753 } else if dirs == ("vertical", "horizontal") {
754 flip = true
755 } else {
756 error("Axes #0 cannot both be in the same direction. Try `axes: (ltr, ttb)`.", axes)
757 }
758
759 (
760 flip: (
761 x: axes.at(0) in (rtl, ttb),
762 y: axes.at(1) in (rtl, ttb),
763 xy: flip,
764 )
765 )
766}
767
768/// Convert an array of rects `(center: (x, y), size: (w, h))` with fractional
769/// positions into rects with integral positions.
770///
771/// If a rect is centered at a factional position `floor(x) < x < ceil(x)`, it
772/// will be replaced by two new rects centered at `floor(x)` and `ceil(x)`. The
773/// total width of the original rect is split across the two new rects according
774/// two which one is closer. (E.g., if the original rect is at `x = 0.25`, the
775/// new rect at `x = 0` has 75% the original width and the rect at `x = 1` has
776/// 25%.) The same splitting procedure is done for `y` positions and heights.
777///
778/// This is the algorithm used to determine grid layout in diagrams.
779///
780/// - rects (array): An array of rects of the form
781/// `(center: (x, y), size: (width, height))`. The coordinates `x` and `y` may
782/// be floats.
783/// -> array
784#let expand-fractional-rects(rects) = {
785 let new-rects
786 for axis in (0, 1) {
787 new-rects = ()
788 for rect in rects {
789 let coord = rect.center.at(axis)
790 let size = rect.size.at(axis)
791
792 if calc.fract(coord) == 0 {
793 rect.center.at(axis) = calc.trunc(coord)
794 new-rects.push(rect)
795 } else {
796 rect.center.at(axis) = calc.floor(coord)
797 rect.size.at(axis) = size*(calc.ceil(coord) - coord)
798 new-rects.push(rect)
799
800 rect.center.at(axis) = calc.ceil(coord)
801 rect.size.at(axis) = size*(coord - calc.floor(coord))
802 new-rects.push(rect)
803 }
804 }
805 rects = new-rects
806 }
807 new-rects
808}
809
810
811/// Determine the number and sizes of grid cells needed for a diagram with the
812/// given nodes and edges.
813///
814/// Returns a dictionary with:
815/// - `origin: (u-min, v-min)` Coordinate at the grid corner where elastic/`uv`
816/// coordinates are minimised.
817/// - `cell-sizes: (x-sizes, y-sizes)` Lengths and widths of each row and
818/// column.
819///
820/// - flip (dictionary): Describes axis order and orientation.
821/// - verts (array): Points that should be contained in the resulting grid.
822/// - rects (array): Rectangles (dictionaries of the form `(center, size)` which
823/// are used to determine cell sizes.
824#let compute-cell-sizes(flip, verts, rects) = {
825
826 if flip.xy {
827 // if x/y axes are flipped, transpose rectangles
828 rects = rects.map( ((center, size)) => {
829 (center: center, size: size.rev())
830 })
831 }
832 rects = expand-fractional-rects(rects)
833
834 // all points in diagram that should be spanned by coordinate grid
835 let points = rects.map(r => r.center)
836 points += verts
837
838 if points.len() == 0 { points.push((0,0)) }
839
840 let min-max-int(a) = (calc.floor(calc.min(..a)), calc.ceil(calc.max(..a)))
841 let (x-min, x-max) = min-max-int(points.map(p => p.at(0)))
842 let (y-min, y-max) = min-max-int(points.map(p => p.at(1)))
843 let origin = (x-min, y-min)
844 let bounding-dims = (x-max - x-min + 1, y-max - y-min + 1)
845
846 // Initialise row and column sizes
847 let cell-sizes = bounding-dims.map(n => (0pt,)*n)
848
849 // Expand cells to fit rects
850 for rect in rects {
851 let indices = vector.sub(rect.center, origin)
852 if flip.x { indices.at(0) = -1 - indices.at(0) }
853 if flip.y { indices.at(1) = -1 - indices.at(1) }
854 for axis in (0, 1) {
855 let i = indices.at(axis)
856 cell-sizes.at(axis).at(i) = calc.max(
857 cell-sizes.at(axis).at(i),
858 rect.size.at(axis),
859 )
860 }
861 }
862
863 if flip.xy {
864 cell-sizes = cell-sizes.rev()
865 }
866
867 (origin: origin, cell-sizes: cell-sizes)
868}
869
870/// Determine the centers of grid cells from their sizes and spacing between
871/// them.
872///
873/// Returns the a dictionary with:
874/// - `centers: (x-centers, y-centers)` Positions of each row and column,
875/// measured from the corner of the bounding box.
876/// - `bounding-size: (x-size, y-size)` Dimensions of the bounding box.
877///
878/// - grid (dictionary): Representation of the grid layout, including:
879/// - `cell-sizes: (x-sizes, y-sizes)` Lengths and widths of each row and
880/// column.
881/// - `spacing: (x-spacing, y-spacing)` Gap to leave between cells.
882/// -> dictionary
883#let compute-cell-centers(grid) = {
884 // (x: (c1x, c2x, ...), y: ...)
885 let centers = array.zip(grid.cell-sizes, grid.spacing)
886 .map(((sizes, spacing)) => {
887 array.zip(cumsum(sizes), sizes, range(sizes.len()))
888 .map(((end, size, i)) => end - size/2 + spacing*i)
889 })
890
891 let bounding-size = array.zip(centers, grid.cell-sizes)
892 .map(((centers, sizes)) => centers.at(-1) + sizes.at(-1)/2)
893
894 (
895 centers: centers,
896 bounding-size: bounding-size,
897 )
898}
899
900/// Determine the number, sizes and relative positions of rows and columns in
901/// the diagram's coordinate grid.
902///
903/// Rows and columns are sized to fit nodes. Coordinates are not required to
904/// start at the origin, `(0,0)`.
905#let compute-grid(rects, verts, options) = {
906 let grid = (
907 axes: options.axes,
908 spacing: options.spacing,
909 )
910
911 grid += interpret-axes(grid.axes)
912 grid += compute-cell-sizes(grid.flip, verts, rects)
913
914 // enforce minimum cell size
915 grid.cell-sizes = grid.cell-sizes.zip(options.cell-size)
916 .map(((sizes, min-size)) => sizes.map(calc.max.with(min-size)))
917
918 grid += compute-cell-centers(grid)
919
920 assert(grid.centers.at(0).len() == grid.cell-sizes.at(0).len())
921 assert(grid.centers.at(1).len() == grid.cell-sizes.at(1).len())
922
923 grid
924}
925
926#let extract-nodes-and-edges-from-equation(eq) = {
927 assert(eq.func() == math.equation)
928 let terms = flatten-sequence-to-array(eq.body)
929
930 let edges = ()
931 let nodes = ()
932
933 // convert math matrix into array-of-arrays matrix
934 let matrix = ((none,),)
935 let (x, y) = (0, 0)
936 for child in terms {
937 if child.func() == metadata {
938 if child.value.class == "node" {
939 let node = child.value
940 node.pos = (raw: (x, y))
941 nodes.push(node)
942 } else if child.value.class == "edge" {
943 let edge = child.value
944 edge.vertices.at(0) = map-auto(edge.vertices.at(0), (x, y))
945 if edge.label != none { edge.label = $edge.label$ } // why is this needed?
946 edge.vertices.at(-1) = map-auto(edge.vertices.at(-1), (rel: (1, 0)))
947 edge.node-index = none
948 edges.push(edge)
949 }
950 } else if repr(child.func()) == "linebreak" {
951 y += 1
952 x = 0
953 matrix.push((none,))
954 } else if repr(child.func()) == "align-point" {
955 x += 1
956 matrix.at(-1).push(none)
957 } else {
958 matrix.at(-1).at(-1) += child
959 }
960 }
961
962 // turn matrix into an array of nodes
963 for (y, row) in matrix.enumerate() {
964 for (x, item) in row.enumerate() {
965 if not is-space(item) {
966 nodes.push(node((x, y), $item$).value)
967 }
968 }
969 }
970
971
972 (
973 nodes: nodes,
974 edges: edges,
975 )
976}
977
978
979
980#let interpret-diagram-args(args) = {
981 if args.named().len() > 0 {
982 error("Unexpected named argument(s) #..0.", args.named().keys())
983 }
984
985 let positional-args = args.pos().flatten().join() + [] // join to ensure sequence
986 let objects = positional-args.children
987
988 let nodes = ()
989 let edges = ()
990
991 for obj in objects {
992 if obj.func() == metadata {
993 if obj.value.class == "node" {
994 let node = obj.value
995 nodes.push(node)
996
997 } else if obj.value.class == "edge" {
998 let edge = obj.value
999 edge.node-index = nodes.len()
1000 edges.push(edge)
1001 }
1002
1003 } else if obj.func() == math.equation {
1004 let result = extract-nodes-and-edges-from-equation(obj)
1005 nodes += result.nodes
1006 edges += result.edges
1007
1008 } else {
1009 panic("Unrecognised value passed to diagram:", obj)
1010 }
1011 }
1012
1013 (
1014 nodes: nodes,
1015 edges: edges,
1016 )
1017
1018}
1019
1020
1021
1022/// Draw a diagram containing `node()`s and `edge()`s.
1023///
1024/// - ..args (array): Content to draw in the diagram, including nodes and edges.
1025///
1026/// The results of `node()` and `edge()` can be _joined_, meaning you can
1027/// specify them as separate arguments, or in a block:
1028///
1029/// ```typ
1030/// #diagram(
1031/// // one object per argument
1032/// node((0, 0), $A$),
1033/// node((1, 0), $B$),
1034/// {
1035/// // multiple objects in a block
1036/// // can use scripting, loops, etc
1037/// node((2, 0), $C$)
1038/// node((3, 0), $D$)
1039/// },
1040/// for x in range(4) { node((x, 1) [#x]) },
1041/// )
1042/// ```
1043///
1044/// Nodes and edges can also be specified in math-mode.
1045///
1046/// ```typ
1047/// #diagram($
1048/// A & B \ // two nodes at (0,0) and (1,0)
1049/// C edge(->) & D \ // an edge from (0,1) to (1,1)
1050/// node(sqrt(pi), stroke: #1pt) // a node with options
1051/// $)
1052/// ```
1053///
1054/// - debug (bool, 1, 2, 3): Level of detail for drawing debug information.
1055/// Level `1` or `true` shows a coordinate grid; higher levels show bounding boxes and
1056/// anchors, etc.
1057///
1058/// - spacing (length, pair of lengths): Gaps between rows and columns. Ensures
1059/// that nodes at adjacent grid points are at least this far apart (measured as
1060/// the space between their bounding boxes).
1061///
1062/// Separate horizontal/vertical gutters can be specified with `(x, y)`. A
1063/// single length `d` is short for `(d, d)`.
1064///
1065/// - cell-size (length, pair of lengths): Minimum size of all rows and columns.
1066/// A single length `d` is short for `(d, d)`.
1067///
1068/// - node-inset (length, pair of lengths): Default value of
1069/// #the-param[node][inset].
1070///
1071/// - node-outset (length, pair of lengths): Default value of
1072/// #the-param[node][outset].
1073///
1074/// - node-shape (rect, circle, function): Default value of
1075/// #the-param[node][shape].
1076///
1077/// - node-stroke (stroke, none): Default value of #the-param[node][stroke].
1078///
1079/// The default stroke is folded with the stroke specified for the node. For
1080/// example, if `node-stroke` is `1pt` and #the-param[node][stroke] is `red`,
1081/// then the resulting stroke is `1pt + red`.
1082///
1083/// - node-fill (paint): Default value of #the-param[node][fill].
1084///
1085/// - edge-stroke (stroke): Default value of #the-param[edge][stroke]. By
1086/// default, this is chosen to match the thickness of mathematical arrows such
1087/// as $A -> B$ in the current font size.
1088///
1089/// The default stroke is folded with the stroke specified for the edge. For
1090/// example, if `edge-stroke` is `1pt` and #the-param[edge][stroke] is `red`,
1091/// then the resulting stroke is `1pt + red`.
1092///
1093/// - node-corner-radius (length, none): Default value of
1094/// #the-param[node][corner-radius].
1095///
1096/// - edge-corner-radius (length, none): Default value of
1097/// #the-param[edge][corner-radius].
1098///
1099/// - node-defocus (number): Default value of #the-param[node][defocus].
1100///
1101/// - label-sep (length): Default value of #the-param[edge][label-sep].
1102///
1103/// - label-size (length): Default value of #the-param[edge][label-size].
1104///
1105/// - label-wrapper (function): Default value of
1106/// #the-param[edge][label-wrapper].
1107///
1108/// - mark-scale (percent): Default value of #the-param[edge][mark-scale].
1109///
1110/// - crossing-fill (paint): Color to use behind connectors or labels to give
1111/// the illusion of crossing over other objects. See
1112/// #the-param[edge][crossing-fill].
1113///
1114/// - crossing-thickness (number): Default thickness of the occlusion made by
1115/// crossing connectors. See #param[edge][crossing-thickness].
1116///
1117/// - axes (pair of directions): The orientation of the diagram's axes.
1118///
1119/// This defines the elastic coordinate system used by nodes and edges. To make
1120/// the $y$ coordinate increase up the page, use `(ltr, btt)`. For the matrix
1121/// convention `(row, column)`, use `(ttb, ltr)`.
1122///
1123/// #stack(
1124/// dir: ltr,
1125/// spacing: 1fr,
1126/// fletcher.diagram(
1127/// axes: (ltr, ttb),
1128/// debug: 1,
1129/// node((0,0), $(0,0)$),
1130/// edge((0,0), (1,0), "->"),
1131/// node((1,0), $(1,0)$),
1132/// node((1,1), $(1,1)$),
1133/// node((0.5,0.5), `axes: (ltr, ttb)`),
1134/// ),
1135/// fletcher.diagram(
1136/// axes: (ltr, btt),
1137/// debug: 1,
1138/// node((0,0), $(0,0)$),
1139/// edge((0,0), (1,0), "->"),
1140/// node((1,0), $(1,0)$),
1141/// node((1,1), $(1,1)$),
1142/// node((0.5,0.5), `axes: (ltr, btt)`),
1143/// ),
1144/// fletcher.diagram(
1145/// axes: (ttb, ltr),
1146/// debug: 1,
1147/// node((0,0), $(0,0)$),
1148/// edge((0,0), (1,0), "->", bend: -20deg),
1149/// node((1,0), $(1,0)$),
1150/// node((1,1), $(1,1)$),
1151/// node((0.5,0.5), `axes: (ttb, ltr)`),
1152/// ),
1153/// )
1154///
1155/// - render (function): After the node sizes and grid layout have been
1156/// determined, the `render` function is called with the following arguments:
1157/// - `grid`: a dictionary of the row and column widths and positions;
1158/// - `nodes`: an array of nodes (dictionaries) with computed attributes
1159/// (including size and physical coordinates);
1160/// - `edges`: an array of connectors (dictionaries) in the diagram; and
1161/// - `options`: other diagram attributes.
1162///
1163/// This callback is exposed so you can access the above data and draw things
1164/// directly with CeTZ.
1165#let diagram(
1166 ..args,
1167 debug: false,
1168 axes: (ltr, ttb),
1169 spacing: 3em,
1170 cell-size: 0pt,
1171 edge-stroke: 0.048em,
1172 node-stroke: none,
1173 edge-corner-radius: 2.5pt,
1174 node-corner-radius: none,
1175 node-inset: 6pt,
1176 node-outset: 0pt,
1177 node-shape: auto,
1178 node-fill: none,
1179 node-defocus: 0.2,
1180 label-sep: 0.4em,
1181 label-size: 1em,
1182 label-wrapper: edge => box(
1183 [#edge.label],
1184 inset: .2em,
1185 radius: .2em,
1186 fill: edge.label-fill,
1187 ),
1188 mark-scale: 100%,
1189 crossing-fill: white,
1190 crossing-thickness: 5,
1191 render: (grid, nodes, edges, options) => {
1192 cetz.canvas(draw-diagram(grid, nodes, edges, debug: options.debug))
1193 },
1194) = {
1195
1196 let spacing = as-pair(spacing).map(as-length)
1197 let cell-size = as-pair(cell-size).map(as-length)
1198
1199 let options = (
1200 debug: int(debug),
1201 axes: axes,
1202 spacing: spacing,
1203 cell-size: cell-size,
1204 node-inset: node-inset,
1205 node-outset: node-outset,
1206 node-shape: node-shape,
1207 node-stroke: node-stroke,
1208 node-fill: node-fill,
1209 node-corner-radius: node-corner-radius,
1210 edge-corner-radius: edge-corner-radius,
1211 node-defocus: node-defocus,
1212 label-sep: label-sep,
1213 label-size: label-size,
1214 label-wrapper: label-wrapper,
1215 edge-stroke: as-stroke(edge-stroke),
1216 mark-scale: mark-scale,
1217 crossing-fill: crossing-fill,
1218 crossing-thickness: crossing-thickness,
1219 )
1220
1221 let (nodes, edges) = interpret-diagram-args(args)
1222
1223 box(context {
1224 let options = options
1225
1226 options.em-size = 1em.to-absolute()
1227 options.spacing = options.spacing.map(length.to-absolute)
1228 options.cell-size = options.cell-size.map(length.to-absolute)
1229
1230 let nodes = nodes.map(node => {
1231 node = resolve-node-options(node, options)
1232 node = measure-node-size(node)
1233 node
1234 })
1235 let edges = edges.map(edge => resolve-edge-options(edge, options))
1236
1237 // PHASE 1: Resolve uv coordinates where possible
1238
1239 // try resolving node uv coordinates. this resolves to NaN coords if the
1240 // resolution fails (e.g., if the coords depend on physical lengths)
1241 let (ctx-with-uv-anchors, nodes) = resolve-node-coordinates(
1242 nodes, ctx: (target-system: "uv"))
1243
1244
1245 // nodes and edges whose uv coordinates can be resolved without knowing the grid
1246 let rects-affecting-grid = nodes
1247 .filter(node => not is-nan-vector(node.pos.uv))
1248 .map(node => (center: node.pos.uv, size: node.size))
1249
1250 let vertices-affecting-grid = (edges
1251 .map(edge => resolve-edge-vertices(edge, nodes, ctx: ctx-with-uv-anchors + (target-system: "uv")))
1252 .join() + ()) // coerce none to ()
1253 .filter(vert => not is-nan-vector(vert))
1254
1255
1256 // PHASE 2: Determine elastic grid (row/column sizes) and resolve xy coordinates
1257
1258 // determine diagram's elastic grid layout
1259 let grid = compute-grid(rects-affecting-grid, vertices-affecting-grid, options)
1260
1261 let ctx-with-xyz-anchors
1262
1263 // we run multiple passes so that anchors on enclose nodes
1264 // have a chance to resolve
1265 // (a better way would be to resolve coordinates and enclose nodes together)
1266 for i in range(5) {
1267 // now with grid determined, compute final (physical) coordinates for nodes and edges
1268 (ctx-with-xyz-anchors, nodes) = resolve-node-coordinates(
1269 nodes, ctx: (target-system: "xyz", grid: grid))
1270
1271 // resolve enclosing nodes
1272 nodes = resolve-node-enclosures(nodes, ctx-with-xyz-anchors)
1273 }
1274
1275 // resolve edges
1276 edges = edges.map(edge => {
1277 edge.final-vertices = resolve-edge-vertices(
1278 edge, ctx: ctx-with-xyz-anchors + (target-system: "xyz", grid: grid), nodes
1279 )
1280
1281 edge = convert-edge-corner-to-poly(edge)
1282 edge = apply-edge-shift(grid, edge)
1283 edge
1284 })
1285
1286
1287 render(grid, nodes, edges, options)
1288 })
1289}#import "utils.typ": *
1290#import "marks.typ": *
1291#import "coords.typ": uv-to-xy
1292
1293#let DEBUG_COLOR = rgb("f008")
1294#let DEBUG_COLOR2 = rgb("0f08")
1295
1296#let draw-debug(objs) = {
1297 cetz.draw.floating(objs)
1298}
1299
1300#let draw-node-outline(node) = {
1301 cetz.draw.group({
1302 cetz.draw.translate(node.pos.xyz)
1303 (node.shape)(node, node.outset)
1304 })
1305}
1306
1307#let draw-node(node, debug: 0) = {
1308
1309 let result = {
1310 if node.stroke != none or node.fill != none {
1311 cetz.draw.group({
1312 cetz.draw.translate(node.pos.xyz)
1313 for (i, extrude) in node.extrude.enumerate() {
1314 cetz.draw.set-style(
1315 fill: if i == 0 { node.fill },
1316 stroke: node.stroke,
1317 )
1318 (node.shape)(node, extrude)
1319 }
1320 })
1321 }
1322
1323 if node.label != none {
1324 let ε = 1e-10pt // temp fix for https://github.com/Jollywatt/typst-fletcher/issues/64
1325 cetz.draw.content(
1326 node.pos.xyz,
1327 box(
1328 // wrapping label in a box allows user to control its alignment
1329 align(center + horizon, node.label),
1330 stroke: if debug >= 3 { DEBUG_COLOR2 + 0.25pt },
1331 width: node.size.at(0) - 2*node.inset + ε,
1332 height: node.size.at(1) - 2*node.inset,
1333 ),
1334 anchor: "center",
1335 )
1336 }
1337 }
1338
1339 if node.layer != 0 { result = cetz.draw.on-layer(node.layer, result) }
1340
1341 (node.post)(result) // post-process (e.g., hide)
1342
1343 // Draw debug stuff
1344 if debug >= 1 {
1345 // dot at node anchor
1346 draw-debug(cetz.draw.circle(
1347 node.pos.xyz,
1348 radius: 0.5pt,
1349 fill: DEBUG_COLOR,
1350 stroke: none,
1351 ))
1352 }
1353
1354 if debug >= 2 and node.radius != 0pt {
1355 // node bounding rectangle
1356 draw-debug({
1357 cetz.draw.rect(
1358 ..rect-at(node.pos.xyz, node.size),
1359 stroke: DEBUG_COLOR + .1pt,
1360 )
1361
1362 // node anchoring outline (what edges snap to)
1363 cetz.draw.set-style(stroke: DEBUG_COLOR2 + 0.25pt)
1364 draw-node-outline(node)
1365 })
1366 }
1367
1368 if debug >= 3 and "enclosed-vertices" in node {
1369 draw-debug(node.enclosed-vertices.map(pos => {
1370 cetz.draw.circle(pos, radius: node.inset, stroke: 0.1pt + blue)
1371 }).join())
1372 }
1373}
1374
1375
1376/// Draw an edge label at point along a curve.
1377///
1378/// Label is drawn near the point `curve(edge.label-pos)`, respecting the label
1379/// options of `edge()` such as #param[edge][label-side] and
1380/// #param[edge][label-angle].
1381///
1382/// - edge (dictionary): Edge object. Must include:
1383/// - `label-pos`
1384/// - `label-sep`
1385/// - `label-side`
1386/// - `label-anchor`
1387/// - `label-angle`
1388/// - `label-wrapper`
1389/// - curve (function): Parametric curve $RR -> RR^2$ describing the shape of
1390/// the edge in $x y$ coordinates.
1391#let place-edge-label-on-curve(edge, curve, debug: 0) = {
1392
1393 let curve-point = curve(edge.label-pos)
1394 let curve-point-ε = curve(edge.label-pos + 1e-3%)
1395
1396 let θ = wrap-angle-180(angle-between(curve-point, curve-point-ε))
1397 let θ-normal = θ + if edge.label-side == right { +90deg } else { -90deg }
1398
1399 if type(edge.label-angle) == alignment {
1400 edge.label-angle = θ - (
1401 right: 0deg,
1402 top: 90deg,
1403 left: 180deg,
1404 bottom: 270deg,
1405 ).at(repr(edge.label-angle))
1406
1407 } else if edge.label-angle == auto {
1408 edge.label-angle = θ
1409 if calc.abs(edge.label-angle) > 90deg {
1410 edge.label-angle += 180deg
1411 }
1412 }
1413
1414 if edge.label-anchor == auto {
1415 edge.label-anchor = angle-to-anchor(θ-normal - edge.label-angle)
1416 }
1417
1418 let label-pos = (to: curve-point, rel: (θ-normal, -edge.label-sep))
1419
1420 cetz.draw.content(
1421 label-pos,
1422 box(
1423 {
1424 set text(edge.label-size)
1425 (edge.label-wrapper)(edge)
1426 },
1427 stroke: if debug >= 2 { DEBUG_COLOR2 + 0.25pt },
1428 ),
1429 angle: edge.label-angle,
1430 anchor: if edge.label-anchor != auto { edge.label-anchor },
1431 )
1432
1433 if debug >= 2 {
1434 draw-debug(cetz.draw.circle(
1435 label-pos,
1436 radius: 0.75pt,
1437 stroke: none,
1438 fill: DEBUG_COLOR2,
1439 ))
1440 }
1441}
1442
1443
1444// Get the arrow head adjustment for a given extrusion distance.
1445//
1446// Returns a pair `(from, to)` of distances.
1447// If `from < 0pt` and `to > 0pt`, the path length of the edge increases.
1448#let cap-offsets(edge, y) = {
1449 (0, 1).map(pos => {
1450 let mark = edge.marks.find(mark => calc.abs(mark.pos - pos) < 1e-3)
1451 if mark == none { return 0pt }
1452
1453 let is-tip = (pos == 0) == mark.rev
1454 let sign = if mark.rev { -1 } else { +1 }
1455
1456 let x = cap-offset(
1457 mark + (tip: is-tip),
1458 sign*y/edge.stroke.thickness,
1459 )
1460
1461 let origin = if is-tip { mark.tip-origin } else { mark.tail-origin }
1462 x -= origin*float(mark.scale)
1463
1464 sign*x*edge.stroke.thickness
1465 })
1466}
1467
1468#let with-decorations(edge, path) = {
1469 if edge.decorations == none { return path }
1470
1471 let has-mark-at(t) = edge.marks.find(mark => calc.abs(mark.pos - t) < 1e-3 ) != none
1472
1473 let decor = edge.decorations.with(stroke: edge.stroke)
1474
1475 // TODO: should this be an absolute offset, not 10% the path length?
1476 let ε = 1e-3 // cetz assertions sometimes fail from floating point errors
1477 decor = decor.with(
1478 start: if has-mark-at(0) { 0.1 } else { ε } * 100%,
1479 stop: if has-mark-at(1) { 0.9 } else { 1 - ε } * 100%,
1480 )
1481
1482 decor(path)
1483}
1484
1485/// Draw a straight edge.
1486///
1487/// - edge (dictionary): The edge object, a dictionary, containing:
1488/// - `vertices`: an array of two points, the line's start and end points.
1489/// - `extrude`: An array of extrusion lengths to apply a multi-stroke effect
1490/// with.
1491/// - `stroke`: The stroke style.
1492/// - `marks`: An array of marks to draw along the edge.
1493/// - `label`: Content for label.
1494/// - `label-side`, `label-pos`, `label-sep`, and `label-anchor`.
1495/// - debug (int): Level of debug details to draw.
1496#let draw-edge-line(edge, debug: 0) = {
1497 let (from, to) = edge.final-vertices
1498 let θ = angle-between(from, to)
1499
1500 // Draw line(s), one for each extrusion shift
1501 for shift in edge.extrude {
1502
1503 let offsets = cap-offsets(edge, shift)
1504 let points = (from, to).zip(offsets)
1505 .map(((point, offset)) => {
1506 // Shift line sideways (for multi-stroke effect)
1507 point = (rel: (θ + 90deg, shift), to: point)
1508 // Shift end points lengthways depending on marks
1509 point = (rel: (θ, offset), to: point)
1510 point
1511 })
1512
1513 let obj = cetz.draw.line(
1514 ..points,
1515 stroke: edge.stroke,
1516 )
1517
1518 with-decorations(edge, obj)
1519 }
1520
1521 // Draw marks
1522 let total-path-len = vector-len(vector.sub(from, to))
1523 let curve(t) = {
1524 // panic(t, total-path-len)
1525 t = relative-to-float(t, len: total-path-len)
1526 vector.lerp(from, to, t)
1527 }
1528
1529 for mark in edge.marks {
1530 place-mark-on-curve(mark, curve, stroke: edge.stroke, debug: debug >= 3)
1531 }
1532
1533 // Draw label
1534 if edge.label != none {
1535
1536 // Choose label anchor based on edge direction,
1537 // preferring to place labels above the edge
1538 if edge.label-side == auto {
1539 edge.label-side = if calc.abs(θ) < 90deg { left } else { right }
1540 }
1541
1542 place-edge-label-on-curve(edge, curve, debug: debug)
1543 }
1544
1545}
1546
1547
1548/// Draw a bent edge.
1549///
1550/// - edge (dictionary): The edge object, a dictionary, containing:
1551/// - `vertices`: an array of two points, the arc's start and end points.
1552/// - `bend`: The angle of the arc.
1553/// - `extrude`: An array of extrusion lengths to apply a multi-stroke effect
1554/// with.
1555/// - `stroke`: The stroke style.
1556/// - `marks`: An array of marks to draw along the edge.
1557/// - `label`: Content for label.
1558/// - `label-side`, `label-pos`, `label-sep`, and `label-anchor`.
1559/// - debug (int): Level of debug details to draw.
1560#let draw-edge-arc(edge, debug: 0) = {
1561 let (from, to) = edge.final-vertices
1562
1563 // Determine the arc from the stroke end points and bend angle
1564 let (center, radius, start, stop) = get-arc-connecting-points(from, to, edge.bend)
1565
1566 let bend-dir = if edge.bend > 0deg { +1 } else { -1 }
1567
1568 // Draw arc(s), one for each extrusion shift
1569 for shift in edge.extrude {
1570
1571 // Adjust arc angles to accommodate for cap offsets
1572 let (δ-start, δ-stop) = cap-offsets(edge, shift)
1573 .map(arclen => -bend-dir*arclen/radius*1rad)
1574
1575 let obj = cetz.draw.arc(
1576 center,
1577 radius: radius + shift,
1578 start: start + δ-start,
1579 stop: stop + δ-stop,
1580 anchor: "origin",
1581 stroke: edge.stroke,
1582 )
1583
1584 with-decorations(edge, obj)
1585 }
1586
1587 // Draw marks
1588 let total-path-len = calc.abs(stop - start)/1rad*radius
1589 let curve(t) = {
1590 t = relative-to-float(t, len: total-path-len)
1591 vector.add(center, vector-polar(radius, lerp(start, stop, t)))
1592 }
1593 for mark in edge.marks {
1594 place-mark-on-curve(mark, curve, stroke: edge.stroke, debug: debug >= 3)
1595 }
1596
1597 // Draw label
1598 if edge.label != none {
1599
1600 if edge.label-side == auto {
1601 // Choose label side to be on outside of arc
1602 edge.label-side = if edge.bend > 0deg { left } else { right }
1603 }
1604
1605 place-edge-label-on-curve(edge, curve, debug: debug)
1606
1607 }
1608}
1609
1610
1611
1612/// Draw a multi-segment edge
1613///
1614/// - edge (dictionary): The edge object, a dictionary, containing:
1615/// - `vertices`: an array of at least two points to draw segments between.
1616/// - `corner-radius`: Radius of curvature between segments.
1617/// - `extrude`: An array of extrusion lengths to apply a multi-stroke effect
1618/// with.
1619/// - `stroke`: The stroke style.
1620/// - `marks`: An array of marks to draw along the edge.
1621/// - `label`: Content for label.
1622/// - `label-side`, `label-pos`, `label-sep`, and `label-anchor`.
1623/// - debug (int): Level of debug details to draw.
1624#let draw-edge-polyline(edge, debug: 0) = {
1625
1626 let verts = edge.final-vertices
1627 let n-segments = verts.len() - 1
1628
1629 // angles of each segment
1630 let θs = range(n-segments).map(i => {
1631 let (vert, vert-next) = (verts.at(i), verts.at(i + 1))
1632 assert(vert != vert-next, message: "Adjacent vertices must be distinct.")
1633 angle-between(vert, vert-next)
1634 })
1635
1636
1637 // round corners
1638 let calculate-rounded-corner(i) = {
1639 let pt = verts.at(i)
1640 let Δθ = wrap-angle-180(θs.at(i) - θs.at(i - 1))
1641 let dir = if Δθ > 0deg { +1 } else { -1 } // +1 if ccw, -1 if cw
1642
1643
1644 let θ-normal = θs.at(i - 1) + Δθ/2 + 90deg // direction to center of curvature
1645
1646 let radius = edge.corner-radius
1647 // radius *= 90deg/calc.max(calc.abs(Δθ), 45deg) // visual adjustment so that tighter bends have smaller radii
1648 radius *= 1 + calc.cos(Δθ)
1649 // skip correcting the corner radius for extruded strokes if there's no stroke at all
1650 if edge.extrude != () {
1651 radius += if dir > 0 { calc.max(..edge.extrude) } else { -calc.min(..edge.extrude) }
1652 }
1653 radius *= dir // ??? makes math easier or something
1654
1655 if calc.abs(Δθ) > 179deg {
1656 // singular line; skip arc
1657 (
1658 arc-center: pt,
1659 arc-radius: 0*radius,
1660 start: θs.at(i - 1) - 90deg,
1661 delta: wrap-angle-180(Δθ),
1662 line-shift: 0*radius, // distance from vertex to beginning of arc
1663 )
1664 } else {
1665
1666 // distance from vertex to center of curvature
1667 let dist = radius/calc.cos(Δθ/2)
1668
1669 (
1670 arc-center: vector.add(pt, vector-polar(dist, θ-normal)),
1671 arc-radius: radius,
1672 start: θs.at(i - 1) - 90deg,
1673 delta: wrap-angle-180(Δθ),
1674 line-shift: radius*calc.tan(Δθ/2), // distance from vertex to beginning of arc
1675 )
1676 }
1677
1678 }
1679
1680 let rounded-corners
1681 if edge.corner-radius != none {
1682 rounded-corners = range(1, θs.len()).map(calculate-rounded-corner)
1683 }
1684
1685 let lerp-scale(t, i) = {
1686 if type(t) in (int, float) {
1687 let τ = t*n-segments - i
1688 if (0 < τ and τ <= 1 or
1689 i == 0 and τ <= 0 or
1690 i == n-segments - 1 and 1 < τ) { τ }
1691 } else {
1692 t = as-relative(t)
1693 let τ = lerp-scale(float(t.ratio), i)
1694 if τ != none {τ *100% + t.length }
1695 }
1696 }
1697
1698 let debug-stroke = edge.stroke.thickness/4 + DEBUG_COLOR2
1699
1700 // phase keeps track of how to offset dash patterns
1701 // to ensure continuity between segments
1702 let phase = 0pt
1703 let stroke-with-phase(phase) = stroke-to-dict(edge.stroke) + (
1704 dash: if type(edge.stroke.dash) == dictionary {
1705 (array: edge.stroke.dash.array, phase: phase)
1706 }
1707 )
1708
1709 // draw each segment
1710 for i in range(n-segments) {
1711 let (from, to) = (verts.at(i), verts.at(i + 1))
1712 let marks = ()
1713
1714 let Δphase = 0pt
1715
1716 if edge.corner-radius == none {
1717
1718 // add phantom marks to ensure segment joins are clean
1719 if i > 0 {
1720 let Δθ = θs.at(i) - θs.at(i - 1)
1721 marks.push((
1722 inherit: "bar",
1723 pos: 0,
1724 angle: 90deg - Δθ/2,
1725 hide: true,
1726 ))
1727 }
1728 if i < θs.len() - 1 {
1729 let Δθ = θs.at(i + 1) - θs.at(i)
1730 marks.push((
1731 inherit: "bar",
1732 pos: 1,
1733 angle: 90deg + Δθ/2,
1734 hide: true,
1735 ))
1736 }
1737
1738 Δphase += vector-len(vector.sub(from, to))
1739
1740 } else { // rounded corners
1741
1742 if i > 0 {
1743 // offset start of segment to give space for previous arc
1744 let (line-shift,) = rounded-corners.at(i - 1)
1745 from = vector.add(from, vector-polar(line-shift, θs.at(i)))
1746 }
1747
1748 if i < θs.len() - 1 {
1749
1750 let (arc-center, arc-radius, start, delta, line-shift) = rounded-corners.at(i)
1751 to = vector.add(to, vector-polar(-line-shift, θs.at(i)))
1752
1753 Δphase += vector-len(vector.sub(from, to))
1754
1755 for d in edge.extrude {
1756 if delta != 0deg {
1757 cetz.draw.arc(
1758 arc-center,
1759 radius: arc-radius - d,
1760 start: start,
1761 delta: delta,
1762 anchor: "origin",
1763 stroke: stroke-with-phase(phase + Δphase),
1764 )
1765 }
1766
1767 if debug >= 4 {
1768 cetz.draw.on-layer(1, cetz.draw.circle(
1769 arc-center,
1770 radius: arc-radius - d,
1771 stroke: debug-stroke,
1772 ))
1773
1774 }
1775 }
1776
1777 Δphase += delta/1rad*arc-radius
1778 }
1779 }
1780
1781 marks = marks.map(resolve-mark)
1782
1783 // distribute original marks across segments
1784 marks += edge.marks.map(mark => {
1785 mark.pos = lerp-scale(mark.pos, i)
1786 mark
1787 }).filter(mark => mark.pos != none)
1788
1789 let label-pos = lerp-scale(edge.label-pos, i)
1790 let label-options = if label-pos == none { (label: none) }
1791 else { (label-pos: label-pos, label: edge.label) }
1792
1793
1794 draw-edge-line(
1795 edge + (
1796 kind: "line",
1797 final-vertices: (from, to),
1798 marks: marks,
1799 stroke: stroke-with-phase(phase),
1800 ) + label-options,
1801 debug: debug,
1802 )
1803
1804 phase += Δphase
1805
1806 }
1807
1808
1809 if debug >= 4 {
1810 cetz.draw.line(
1811 ..verts,
1812 stroke: debug-stroke,
1813 )
1814 }
1815}
1816
1817
1818
1819/// Of all the intersection points within a set of CeTZ objects, find the one
1820/// which is farthest from a target point and pass it to a callback.
1821///
1822/// If no intersection points are found, use the target point itself.
1823///
1824/// - objects (cetz array, none): Objects to search within for intersections. If
1825/// `none`, callback is immediately called with `target`.
1826/// - target (point): Target point to sort intersections by proximity with, and
1827/// to use as a fallback if no intersections are found.
1828#let find-farthest-intersection(objects, target, callback) = {
1829
1830 if objects == none { return callback(target) }
1831
1832 let node-name = "intersection-finder"
1833 cetz.draw.hide(cetz.draw.intersections(node-name, objects))
1834
1835 cetz.draw.get-ctx(ctx => {
1836
1837 let calculate-anchors = ctx.nodes.at(node-name).anchors
1838 let anchor-names = calculate-anchors(())
1839 let anchor-points = anchor-names.map(calculate-anchors)
1840 .map(point => {
1841 point.at(1) *= -1 // CETZ Y AXIS
1842 vector-2d(vector.scale(point, 1cm))
1843 }).sorted(key: point => vector-len(vector.sub(point, target)))
1844
1845 let anchor = anchor-points.at(-1, default: target)
1846
1847 callback(anchor)
1848
1849 })
1850
1851}
1852
1853#let find-anchor-pair((from-group, to-group), (from-point, to-point), callback) = {
1854 find-farthest-intersection(from-group, from-point, from-anchor => {
1855 find-farthest-intersection(to-group, to-point, to-anchor => {
1856 callback((from-anchor, to-anchor))
1857 })
1858 })
1859
1860}
1861
1862/// Get the anchor point around a node outline at a certain angle.
1863#let get-node-anchor(node, θ, callback) = {
1864 let outline = cetz.draw.group({
1865 cetz.draw.translate(node.pos.xyz)
1866 (node.shape)(node, node.outset)
1867 })
1868 let dummy-line = cetz.draw.line(
1869 node.pos.xyz,
1870 (rel: (θ, 10*node.radius))
1871 )
1872
1873 find-farthest-intersection(outline + dummy-line, node.pos.xyz, callback)
1874}
1875
1876/// Return the anchor point for an edge connecting to a node with the "defocus"
1877/// adjustment.
1878///
1879/// Basically, for very long/wide nodes, don't make edges coming in from all
1880/// angles go to the exact node center, but "spread them out" a bit.
1881///
1882/// See https://www.desmos.com/calculator/irt0mvixky.
1883#let defocus-adjustment(node, θ) = {
1884 if node == none { return (0pt, 0pt) }
1885 let μ = calc.pow(node.aspect, node.defocus)
1886 (
1887 calc.max(0pt, node.size.at(0)/2*(1 - 1/μ))*calc.cos(θ),
1888 calc.max(0pt, node.size.at(1)/2*(1 - μ/1))*calc.sin(θ),
1889 )
1890
1891}
1892
1893
1894
1895#let draw-anchored-line(edge, nodes, debug: 0) = {
1896 let (from, to) = edge.final-vertices
1897 let θ = angle-between(from, to) + 90deg
1898
1899 // TODO: do defocus adjustment sensibly
1900 if nodes.at(0).len() == 1 {
1901 from = vector.add(from, defocus-adjustment(nodes.at(0).at(0), θ - 90deg))
1902 }
1903 if nodes.at(1).len() == 1 {
1904 to = vector.add(to, defocus-adjustment(nodes.at(1).at(0), θ + 90deg))
1905
1906 }
1907
1908
1909 let dummy-line = cetz.draw.line(from, to)
1910
1911 let intersection-objects = nodes.map(nodes => {
1912 nodes.map(draw-node-outline).join()
1913 dummy-line
1914 })
1915
1916
1917 find-anchor-pair(intersection-objects, (from, to), anchors => {
1918 let obj = draw-edge-line(edge + (
1919 final-vertices: anchors,
1920 ), debug: debug)
1921 (edge.post)(obj) // post-process (e.g., hide)
1922 })
1923
1924}
1925
1926
1927#let draw-anchored-arc(edge, nodes, debug: 0) = {
1928 let (from, to) = edge.final-vertices
1929 let θ = angle-between(from, to)
1930 let θs = (θ + edge.bend, θ - edge.bend + 180deg)
1931
1932 let dummy-lines = (from, to).zip(θs, nodes)
1933 .map(((point, φ, node)) => cetz.draw.line(
1934 point,
1935 vector.add(point, vector-polar(10cm, φ)), // ray emanating from node
1936 ))
1937
1938 let intersection-objects = nodes.zip(dummy-lines).map(((nodes, dummy-line)) => {
1939 nodes.map(draw-node-outline).join()
1940 dummy-line
1941 })
1942
1943 find-anchor-pair(intersection-objects, (from, to), anchors => {
1944 let obj = draw-edge-arc(edge + (final-vertices: anchors), debug: debug)
1945 (edge.post)(obj) // post-process (e.g., hide)
1946 })
1947}
1948
1949
1950#let draw-anchored-polyline(edge, nodes, debug: 0) = {
1951 assert(edge.vertices.len() >= 2, message: "Polyline requires at least two vertices")
1952 let verts = edge.final-vertices
1953 let (from, to) = (edge.final-vertices.at(0), edge.final-vertices.at(-1))
1954
1955 let end-segments = (
1956 edge.final-vertices.slice(0, 2), // first two vertices
1957 edge.final-vertices.slice(-2), // last two vertices
1958 )
1959
1960 let dummy-lines = end-segments.map(points => cetz.draw.line(..points))
1961
1962 let intersection-objects = nodes.zip(dummy-lines).map(((nodes, dummy-line)) => {
1963 nodes.map(draw-node-outline).join()
1964 dummy-line
1965 })
1966
1967 find-anchor-pair(intersection-objects, (from, to), anchors => {
1968 let edge = edge
1969 edge.final-vertices.at(0) = anchors.at(0)
1970 edge.final-vertices.at(-1) = anchors.at(1)
1971 let obj = draw-edge-polyline(edge, debug: debug)
1972 (edge.post)(obj) // post-process (e.g., hide)
1973 })
1974
1975}
1976
1977
1978#let draw-edge(edge, ..args) = {
1979 let obj = if edge.kind == "line" {
1980 draw-anchored-line(edge, ..args)
1981 } else if edge.kind == "arc" {
1982 draw-anchored-arc(edge, ..args)
1983 } else if edge.kind == "poly" {
1984 draw-anchored-polyline(edge, ..args)
1985 } else { error("Invalid edge kind #0.", edge.kind) }
1986
1987 if edge.layer != 0 { obj = cetz.draw.on-layer(edge.layer, obj)}
1988
1989 obj
1990}
1991
1992
1993
1994/// Draw diagram coordinate axes.
1995///
1996/// - grid (dictionary): Dictionary specifying the diagram's grid, containing:
1997/// - `origin: (u-min, v-min)`, the minimum values of elastic coordinates,
1998/// - `flip: (x, y, xy)`, the axes orientation (see `interpret-axes()`),
1999/// - `centers: (x-centers, y-centers)`, the physical offsets of each row and each column,
2000/// - `cell-sizes: (x-sizes, y-sizes)`, the physical sizes of each row and
2001/// each column.
2002#let draw-debug-axes(grid, debug: false, floating: true) = {
2003
2004 let (x-lims, y-lims) = range(2).map(axis => (
2005 grid.centers.at(axis).at( 0) - grid.cell-sizes.at(axis).at( 0)/2,
2006 grid.centers.at(axis).at(-1) + grid.cell-sizes.at(axis).at(-1)/2,
2007 ))
2008
2009 let (u-min, v-min) = grid.origin
2010
2011 let (u-len, v-len) = grid.centers.map(array.len)
2012 if grid.flip.xy { (u-len, v-len) = (v-len, u-len) }
2013 let v-range = range(v-min, v-min + v-len)
2014 let u-range = range(u-min, u-min + u-len)
2015
2016 if grid.flip.x { u-range = u-range.rev() }
2017 if grid.flip.y { v-range = v-range.rev() }
2018 if grid.flip.xy { (u-range, v-range) = (v-range, u-range) }
2019
2020 import cetz.draw
2021 let objs = draw.group({
2022 let (a, b) = array.zip(x-lims, y-lims)
2023 if a == b { b = vector.add(b, (1e-3pt, 1e-3pt)) }
2024 draw.rect(a, b, stroke: DEBUG_COLOR + .5pt)
2025
2026 draw.set-style(stroke: (
2027 paint: DEBUG_COLOR,
2028 thickness: .3pt,
2029 dash: "densely-dotted",
2030 ))
2031
2032 for axis in range(2) {
2033 let swap(a, b) = if axis != 1 { (a, b) } else { (b, a) }
2034 let x-range = (u-range, v-range).at(axis)
2035 let (min, max) = (y-lims, x-lims).at(axis)
2036 for (i, x) in x-range.enumerate() {
2037 // coordinate line
2038 draw.line(
2039 swap(grid.centers.at(axis).at(i), min),
2040 swap(grid.centers.at(axis).at(i), max),
2041 )
2042 // size bracket
2043 let size = grid.cell-sizes.at(axis).at(i)
2044 draw.rect(
2045 (to: swap(grid.centers.at(axis).at(i), min), rel: swap(-size/2, 0)),
2046 (to: swap(grid.centers.at(axis).at(i), min), rel: swap(+size/2, -1pt)),
2047 fill: DEBUG_COLOR,
2048 stroke: none,
2049 )
2050 // coordinate label
2051 draw.content(
2052 (to: swap(grid.centers.at(axis).at(i), min), rel: swap(0, -.2em)),
2053 text(fill: DEBUG_COLOR, size: .7em)[#x],
2054 anchor: if axis == 0 { "north" } else { "east" },
2055 )
2056 }
2057 }
2058
2059 if debug {
2060 let (u-label, v-label) = if grid.flip.xy { ($arrow$, $arrow.t.twohead$) } else { ($u$, $v$) }
2061
2062 let dir-to-arrow(dir) = {
2063 if dir == ltr { $arrow.r$ }
2064 else if dir == rtl { $arrow.l$ }
2065 else if dir == ttb { $arrow.b$ }
2066 else if dir == btt { $arrow.t$ }
2067 }
2068
2069 draw.content(
2070 (x-lims.at(0), y-lims.at(0)),
2071 pad(0.2em, text(0.5em, DEBUG_COLOR, $(#grid.axes.map(dir-to-arrow).join($,$))$)),
2072 anchor: "north-east"
2073 )
2074 }
2075
2076 })
2077
2078 if floating {
2079 cetz.draw.floating(objs)
2080 } else {
2081 objs
2082 }
2083}
2084
2085// Find candidate nodes that an edge should snap to
2086//
2087// Returns an array of zero or more nodes. False positives are acceptable.
2088#let find-snapping-nodes(grid, nodes, key) = {
2089 if type(key) == label {
2090 return nodes.filter(node => node.name == key)
2091 }
2092
2093 if type(key) == array and key.len() == 2 {
2094
2095 let xy-pos = key
2096 let candidates = nodes.filter(node => {
2097 if node.snap == false { return false }
2098 point-is-in-rect(xy-pos, (
2099 center: node.pos.xyz,
2100 size: node.size,
2101 ))
2102 })
2103
2104 if candidates.len() > 0 {
2105 // filter out nodes with lower snap priority
2106 let max-snap-priority = calc.max(..candidates.map(node => node.snap))
2107 candidates = candidates.filter(node => node.snap == max-snap-priority)
2108 }
2109
2110 return candidates
2111 }
2112
2113 error("Couldn't find node corresponding to #0 in diagram.", key)
2114}
2115
2116
2117// return a pair of arrays of nodes to which the edge should snap
2118#let find-nodes-for-edge(grid, nodes, edge) = {
2119 let select-nodes = find-snapping-nodes.with(grid, nodes)
2120 let first-last(x) = (x.at(0), x.at(-1))
2121 array.zip(
2122 edge.snap-to,
2123 first-last(edge.vertices),
2124 first-last(edge.final-vertices),
2125 ).map(((given, vertex, xy)) => {
2126 if given == none { return () } // user explicitly disabled snapping
2127 let key = map-auto(given, if type(vertex) == label { vertex } else { xy })
2128 select-nodes(key)
2129 })
2130}
2131
2132#let draw-diagram(
2133 grid,
2134 nodes,
2135 edges,
2136 debug: 0,
2137) = {
2138
2139 for edge in edges {
2140 let nodes = find-nodes-for-edge(grid, nodes, edge)
2141 draw-edge(edge, nodes, debug: debug)
2142 }
2143
2144 for node in nodes {
2145 draw-node(node, debug: debug)
2146 }
2147
2148 if debug >= 1 {
2149 draw-debug-axes(grid, debug: debug >= 2)
2150 }
2151
2152}
2153
2154/// Make diagram contents invisible, with or without affecting layout. Works by
2155/// wrapping final drawing objects in `cetz.draw.hide`.
2156///
2157/// #example(```
2158/// rect(diagram({
2159/// fletcher.hide({
2160/// node((0,0), [Can't see me])
2161/// edge("->")
2162/// })
2163/// node((1,1), [Can see me])
2164/// }))
2165/// ```)
2166///
2167/// - objects (content, array): Diagram objects to hide.
2168/// - bounds (bool): If `false`, layout is as if the objects were never there;
2169/// if `true`, the layout treats the objects is present but invisible.
2170#let hide(objects, bounds: true) = {
2171 if type(objects) == array { objects = objects.join() }
2172 let seq = objects + []
2173 seq.children.map(child => {
2174 if child.func() == metadata {
2175 let value = child.value
2176 value.post = cetz.draw.hide.with(bounds: bounds)
2177 metadata(value)
2178 } else {
2179 child
2180 }
2181 }).join()
2182}
2183#import "utils.typ": *
2184#import "marks.typ": *
2185#import "coords.typ": vector-polar-with-xy-or-uv-length, resolve, default-ctx
2186
2187#let EDGE_FLAGS = (
2188 "dashed": (dash: "dashed"),
2189 "dotted": (dash: "dotted"),
2190 "double": (extrude: (-2, +2)),
2191 "triple": (extrude: (-4, 0, +4)),
2192 "crossing": (crossing: true),
2193 "wave": (decorations: "wave"),
2194 "zigzag": (decorations: "zigzag"),
2195 "coil": (decorations: "coil"),
2196)
2197
2198#let LINE_ALIASES = (
2199 "-": (:),
2200 "=": EDGE_FLAGS.double,
2201 "==": EDGE_FLAGS.triple,
2202 "--": EDGE_FLAGS.dashed,
2203 "..": EDGE_FLAGS.dotted,
2204 "~": EDGE_FLAGS.wave,
2205 " ": (extrude: ()),
2206)
2207
2208#let MARK_SYMBOL_ALIASES = (
2209 (sym.arrow.r): "->",
2210 (sym.arrow.l): "<-",
2211 (sym.arrow.r.l): "<->",
2212 (sym.arrow.long.r): "->",
2213 (sym.arrow.long.l): "<-",
2214 (sym.arrow.long.r.l): "<->",
2215 (sym.arrow.double.r): "=>",
2216 (sym.arrow.double.l): "<=",
2217 (sym.arrow.double.r.l): "<=>",
2218 (sym.arrow.double.long.r): "=>",
2219 (sym.arrow.double.long.l): "<=",
2220 (sym.arrow.double.long.r.l): "<=>",
2221 (sym.arrow.r.tail): ">->",
2222 (sym.arrow.l.tail): "<-<",
2223 (sym.arrow.twohead): "->>",
2224 (sym.arrow.twohead.r): "->>",
2225 (sym.arrow.twohead.l): "<<-",
2226 (sym.arrow.bar): "|->",
2227 (sym.arrow.bar.double): "|=>",
2228 (sym.arrow.hook.r): "hook->",
2229 (sym.arrow.hook.l): "<-hook'",
2230 (sym.arrow.squiggly.r): "~>",
2231 (sym.arrow.squiggly.l): "<~",
2232 (sym.arrow.long.squiggly.r): "~>",
2233 (sym.arrow.long.squiggly.l): "<~",
2234)
2235
2236
2237#let interpret-marks(marks) = {
2238 marks = marks.enumerate().map(((i, mark)) => {
2239 resolve-mark(mark, defaults: (
2240 pos: i/calc.max(1, marks.len() - 1),
2241 rev: i == 0,
2242 ))
2243 }).filter(mark => mark != none) // drop empty marks
2244
2245 marks = marks.map(mark => {
2246 mark.tip = (mark.pos == 0) == mark.rev
2247 if (mark.pos not in (0, 1)) { mark.tip = none }
2248 mark
2249 })
2250
2251 marks
2252}
2253
2254
2255
2256/// Parse and interpret the marks argument provided to `edge()`. Returns a
2257/// dictionary of processed `edge()` arguments.
2258///
2259/// - arg (string, array):
2260/// Can be a string, (e.g. `"->"`, `"<=>"`), etc, or an array of marks.
2261/// A mark can be a string (e.g., `">"` or `"head"`, `"x"` or `"cross"`) or a dictionary containing the keys:
2262/// - `kind` (required) the mark name, e.g. `"solid"` or `"bar"`
2263/// - `pos` the position along the edge to place the mark, from 0 to 1
2264/// - `rev` whether to reverse the direction
2265/// - parameters specific to the kind of mark, e.g., `size` or `sharpness`
2266/// -> dictiony
2267#let interpret-marks-arg(arg) = {
2268 if type(arg) == array { return (marks: interpret-marks(arg)) }
2269
2270 if type(arg) == symbol {
2271 if str(arg) in MARK_SYMBOL_ALIASES { arg = MARK_SYMBOL_ALIASES.at(arg) }
2272 else { error("Unrecognised marks symbol #0.", arg) }
2273 }
2274
2275 assert(type(arg) == str)
2276 let text = arg
2277
2278 let mark-names = MARKS.get().keys().sorted(key: i => -i.len())
2279 let LINES = LINE_ALIASES.keys().sorted(key: i => -i.len())
2280
2281 let eat(arg, options) = {
2282 for option in options {
2283 if arg.starts-with(option) {
2284 return (arg.slice(option.len()), option)
2285 }
2286 }
2287 return (arg, none)
2288 }
2289
2290 let marks = ()
2291 let lines = ()
2292
2293 let mark
2294 let line
2295 let flip
2296
2297 // first mark, [<]-x->>
2298 (text, mark) = eat(text, mark-names)
2299
2300 // flip modifier, hook[']
2301 (text, flip) = eat(text, ("'",))
2302 if flip != none { mark += flip }
2303
2304 marks.push(mark)
2305
2306 let parse-error(suggestion) = error("Invalid marks shorthand #0. Try #1.", arg, suggestion)
2307
2308 while true {
2309 // line, <[-]x->>
2310 (text, line) = eat(text, LINES)
2311 if line == none {
2312 let suggestion = arg.slice(0, -text.len()) + "-" + text
2313 parse-error(suggestion)
2314 }
2315 lines.push(line)
2316
2317 // subsequent mark, <-[x]->>
2318 (text, mark) = eat(text, mark-names)
2319
2320 // flip modifier, hook[']
2321 (text, flip) = eat(text, ("'",))
2322 if flip != none { mark += flip }
2323
2324 marks.push(mark)
2325
2326 if text == "" { break }
2327 if mark == none {
2328 // text remains that was not recognised as mark
2329 let suggestion = marks.intersperse(lines.at(0)).join()
2330 parse-error(suggestion)
2331 }
2332 }
2333
2334
2335 if lines.dedup().len() > 1 {
2336 // different line styles were mixed
2337 let suggestion = marks.intersperse(lines.at(0)).join()
2338 parse-error(suggestion)
2339 }
2340 let line = lines.at(0)
2341
2342
2343 // make classic math arrows slightly larger on double/triple stroked lines
2344 if line == "=" {
2345 marks = marks.map(mark => {
2346 if mark == none { return }
2347 (
2348 ">": (inherit: "doublehead", rev: false),
2349 "<": (inherit: "doublehead", rev: true),
2350 ).at(mark, default: mark)
2351 })
2352 } else if line == "==" {
2353 marks = marks.map(mark => {
2354 if mark == ">" { (inherit: "triplehead", rev: false) }
2355 else if mark == "<" { (inherit: "triplehead", rev: true) }
2356 else {mark}
2357 })
2358 }
2359
2360 return (
2361 marks: interpret-marks(marks),
2362 ..LINE_ALIASES.at(lines.at(0))
2363 )
2364}
2365
2366
2367
2368/// Interpret the positional arguments given to an `edge()`
2369///
2370/// Tries to intelligently distinguish the `from`, `to`, `marks`, and `label`
2371/// arguments based on the argument types.
2372///
2373/// Generally, the following combinations are allowed:
2374///
2375/// ```
2376/// edge(..<coords>, ..<marklabel>, ..<options>)
2377/// <coords> = () or (to) or (from, to) or (from, ..vertices, to)
2378/// <marklabel> = (marks, label) or (label, marks) or (marks) or (label) or ()
2379/// <options> = any number of options specified as strings
2380/// ```
2381#let interpret-edge-args(args, options) = {
2382 if args.named().len() > 0 {
2383 error("Unexpected named argument(s) #..0.", args.named().keys())
2384 }
2385
2386 let new-options = (:)
2387 let pos = args.pos()
2388
2389 // predicates to detect the kind of a positional argument
2390 let is-coord(arg) = type(arg) in (array, dictionary, label) or arg == auto
2391 let is-rel-coord(arg) = is-coord(arg) or (
2392 type(arg) == str and arg.match(regex("^[utdblrnsew,]+$")) != none
2393 )
2394 let is-arrow-symbol(arg) = type(arg) == symbol and str(arg) in MARK_SYMBOL_ALIASES
2395 let is-edge-flag(arg) = type(arg) == str and arg in EDGE_FLAGS
2396 let is-label-side(arg) = type(arg) == alignment
2397
2398 let maybe-marks(arg) = type(arg) == str and not is-edge-flag(arg) or is-arrow-symbol(arg)
2399 let maybe-label(arg) = type(arg) != str and not is-arrow-symbol(arg) and not is-coord(arg)
2400
2401 let peek(x, ..predicates) = {
2402 let preds = predicates.pos()
2403 x.len() >= preds.len() and x.zip(preds).all(((arg, pred)) => pred(arg))
2404 }
2405
2406 let assert-not-set(key, default, ..value) = {
2407 if options.at(key) == default { return }
2408 error(
2409 "#0 specified twice with positional argument(s) #..pos and named argument #named.",
2410 key, pos: value.pos().map(repr), named: repr(options.at(key)),
2411 )
2412 }
2413
2414 let coords = ()
2415 let has-first-coord = false
2416 let has-tail-coords = false
2417
2418 // First argument(s) are coordinates
2419 // (<coord>, <rel-coord>*) => (<coord>, <rel-coord>*)
2420 // (<rel-coord>*) => (auto, <rel-coord>*)
2421 if peek(pos, is-coord) {
2422 coords.push(pos.remove(0))
2423 has-first-coord = true
2424 }
2425 while peek(pos, is-rel-coord) {
2426 if type(pos.at(0)) == str {
2427 coords += pos.remove(0).split(",")
2428 } else {
2429 coords.push(pos.remove(0))
2430 }
2431 has-tail-coords = true
2432 }
2433
2434 // Allow marks argument to be in between two coordinates
2435 // (<coord>, <marks>, <rel-coord>)
2436 // (<marks>, <rel-coord>) => (auto, <marks>, <rel-coord>)
2437 if not has-tail-coords and peek(pos, maybe-marks, is-rel-coord) {
2438 new-options.marks = pos.remove(0)
2439 assert-not-set("marks", (), new-options.marks)
2440
2441 coords.push(pos.remove(0))
2442 has-tail-coords = true
2443
2444 if peek(pos, is-rel-coord) {
2445 error("Marks argument #0 must appear after edge vertices (or between them if there are only two).", repr(new-options.marks))
2446 }
2447 }
2448 if coords.len() > 0 or options.vertices.len() == 0 {
2449 assert-not-set("vertices", (), ..coords)
2450 if not has-tail-coords { coords = (auto, ..coords) }
2451 if not has-first-coord { coords = (auto, ..coords) }
2452 new-options.vertices = coords
2453 }
2454
2455
2456 // Allow label side argument anywhere after coordinates
2457 let i = pos.position(is-label-side)
2458 if i != none {
2459 new-options.label-side = pos.remove(i)
2460 assert-not-set("label-side", auto, new-options.label-side)
2461 }
2462
2463
2464 // Accept marks and labels after vertices
2465 // (.., <marks>, <label>)
2466 // (.., <label>, <marks>)
2467 let marks
2468 let label
2469 if peek(pos, maybe-marks, maybe-label) {
2470 marks = pos.remove(0)
2471 label = pos.remove(0)
2472 } else if peek(pos, maybe-label, maybe-marks) {
2473 label = pos.remove(0)
2474 marks = pos.remove(0)
2475 } else if peek(pos, maybe-label) {
2476 label = pos.remove(0)
2477 } else if peek(pos, maybe-marks) {
2478 marks = pos.remove(0)
2479 }
2480
2481 if marks != none {
2482 if "marks" in new-options {
2483 error("Marks argument passed to `edge()` twice; found #0 and #1.", repr(new-options.marks), repr(marks))
2484 }
2485 assert-not-set("marks", (), marks)
2486 new-options.marks = marks
2487 }
2488 if label != none {
2489 assert-not-set("label", none, label)
2490 new-options.label = label
2491 }
2492
2493 // Accept any trailing positional strings as option shorthands
2494 while peek(pos, is-edge-flag) {
2495 new-options += EDGE_FLAGS.at(pos.remove(0))
2496 }
2497
2498 if pos.len() > 0 {
2499 error("Couldn't interpret `edge()` arguments #..0. Try using named arguments. Interpreted previous arguments as #1", pos, new-options)
2500 }
2501
2502 new-options
2503}
2504
2505
2506
2507
2508
2509/// Draw a connecting edge in a diagram.
2510///
2511///
2512/// - ..args (any): An edge's positional arguments may specify:
2513/// - the edge's #param[edge][vertices], each specified with a CeTZ-style coordinate
2514/// - the #param[edge][label] content
2515/// - arrow #param[edge][marks], like `"=>"` or `"<<-|-o"`
2516/// - other style flags, like `"double"` or `"wave"`
2517///
2518/// Vertex coordinates must come first, and are optional:
2519///
2520/// ```typc
2521/// edge(from, to, ..) // explicit start and end nodes
2522/// edge(to, ..) == edge(auto, to, ..) // start snaps to previous node
2523/// edge(..) == edge(auto, auto, ..) // snaps to previous and next nodes
2524/// edge(from, v1, v2, ..vs, to, ..) // a multi-segmented edge
2525/// edge(from, "->", to) // for two vertices, the marks style can come in between
2526/// ```
2527///
2528/// All vertices except the start point can be shorthand relative coordinate
2529/// string containing the characters
2530/// ${#"lrudtbnesw".clusters().map(raw).join($, $)}$ or commas.
2531///
2532/// If given as positional arguments, an edge's #param[edge][marks] and
2533/// #param[edge][label] are disambiguated by guessing based on the types. For
2534/// example, the following are equivalent:
2535///
2536/// ```typc
2537/// edge((0,0), (1,0), $f$, "->")
2538/// edge((0,0), (1,0), "->", $f$)
2539/// edge((0,0), (1,0), $f$, marks: "->")
2540/// edge((0,0), (1,0), "->", label: $f$)
2541/// edge((0,0), (1,0), label: $f$, marks: "->")
2542/// ```
2543///
2544/// Additionally, some common options are given flags that may be given as
2545/// string positional arguments. These are
2546/// #fletcher.EDGE_FLAGS.keys().map(repr).map(raw).join([, ], last: [, and ]).
2547/// For example, the following are equivalent:
2548///
2549/// ```typc
2550/// edge((0,0), (1,0), $f$, "wave", "crossing")
2551/// edge((0,0), (1,0), $f$, decorations: "wave", crossing: true)
2552/// ```
2553///
2554/// - vertices (array): Array of (at least two) coordinates for the edge.
2555///
2556/// Vertices can also be specified as leading positional arguments, but if so,
2557/// the `vertices` option must be empty. If the number of vertices is greater
2558/// than two, #param[edge][kind] defaults to `"poly"`.
2559///
2560/// - kind (string): The kind of the edge, one of `"line"`, `"arc"`, or `"poly"`.
2561/// This is chosen automatically based on the presence of other options
2562/// (#param[edge][bend] implies `"arc"`, #param[edge][corner] or additional
2563/// vertices implies `"poly"`).
2564///
2565/// - corner (none, left, right): Whether to create a right-angled corner,
2566/// turning `left` or `right`.
2567/// (Bending right means the corner sticks out to the left, and vice versa.)
2568///
2569/// #diagram(
2570/// node((0,1), `from`),
2571/// node((1,0), `to`),
2572/// edge((0,1), (1,0), `right`, "->", corner: right),
2573/// edge((0,1), (1,0), `left`, "->", corner: left),
2574/// )
2575///
2576/// - bend (angle): Edge curvature. If `0deg`, the connector is a straight line;
2577/// positive angles bend clockwise.
2578///
2579/// #diagram(debug: 0, {
2580/// node((0,0), $A$)
2581/// node((1,1), $B$)
2582/// let N = 4
2583/// range(N + 1)
2584/// .map(x => (x/N - 0.5)*2*100deg)
2585/// .map(θ => edge((0,0), (1,1), θ, bend: θ, ">->", label-side: center))
2586/// .join()
2587/// })
2588///
2589/// - loop-angle (angle): Angle around the node at which edge loops stick out at. Loops are arcs
2590/// with the same start/end point and a large #param[edge][bend] angle (e.g.,
2591/// `120deg`). This value has no effect for non-loop edges.
2592///
2593/// #diagram(debug: 0, {
2594/// node((0,0), $O$)
2595/// for θ in (0deg, -90deg, 135deg) {
2596/// edge((), "->", (), bend: 125deg, loop-angle: θ, label: θ)
2597/// }
2598/// })
2599///
2600/// - label (content): Content for the edge label. See the
2601/// #param[edge][label-pos] and #param[edge][label-side] options to control
2602/// the position (and #param[edge][label-sep] and #param[edge][label-anchor]
2603/// for finer control).
2604///
2605/// - label-side (left, right, center): Which side of the edge to place the
2606/// label on, viewed as you walk along it from base to tip.
2607///
2608/// If `center`, then the label is placed directly on the edge and
2609/// #param[edge][label-fill] defaults to `true`. When `auto`, a value of
2610/// `left` or `right` is automatically chosen so that the label is:
2611/// - roughly above the connector, in the case of straight lines; or
2612/// - on the outside of the curve, in the case of arcs.
2613///
2614/// - label-pos (float, ratio, relative length): Position of the label along the
2615/// edge, from the start to end.
2616///
2617/// A number or ratio between zero and one is interpreted as a fraction of the
2618/// edge length. Physical and relative relative lengths work too. For example,
2619/// `100% - 1em` means `1em` from the end.
2620///
2621/// #stack(
2622/// dir: ltr,
2623/// spacing: 1fr,
2624/// ..(0, 0.25, 0.5, 0.75, 1).map(p => fletcher.diagram(
2625/// cell-size: 1cm,
2626/// edge((0,0), (1,0), p, "->", label-pos: p))
2627/// ),
2628/// )
2629///
2630/// For `"poly"` edges (see @edge-types), a number does not specify a fraction
2631/// of the path length; instead, the $k$th vertex is at position $k/n$ where
2632/// $n$ is the number of vertices. Each midpoint is then at $k/n + 0.5$.
2633///
2634/// - label-sep (length): Separation between the connector and the label anchor.
2635///
2636/// With the default anchor (automatically set to `"south"` in this case):
2637///
2638/// #diagram(
2639/// debug: 2,
2640/// cell-size: 8mm,
2641/// {
2642/// for (i, s) in (-5pt, 0pt, .4em, .8em).enumerate() {
2643/// edge((2*i,0), (2*i + 1,0), s, "->", label-sep: s)
2644/// }
2645/// })
2646///
2647/// With #param[edge][label-anchor] set to `"center"`:
2648///
2649/// #diagram(
2650/// debug: 2,
2651/// cell-size: 8mm,
2652/// {
2653/// for (i, s) in (-5pt, 0pt, .4em, .8em).enumerate() {
2654/// edge((2*i,0), (2*i + 1,0), s, "->", label-sep: s, label-anchor: "center")
2655/// }
2656/// })
2657///
2658/// Set #param[diagram][debug] to `2` or higher to see label anchors and
2659/// outlines as seen here.
2660///
2661/// Default: #the-param[diagram][label-sep]
2662///
2663/// - label-angle (angle, left, right, top, bottom, auto): Angle to rotate the
2664/// label (counterclockwise).
2665///
2666/// If a direction is given, the label is rotated so that the edge travels in
2667/// that direction relative to the label. If `auto`, the best of `right` or
2668/// `left` is chosen.
2669///
2670/// #for angle in (0deg, 90deg, auto, right, top, left) {
2671/// diagram(edge((0,1), (2,0), "->", [#angle], label-angle: angle))
2672/// }
2673///
2674/// - label-anchor (anchor): The CeTZ-style anchor point of the label to use for
2675/// placement (e.g., `"north-east"` or `"center"`). If `auto`, the best anchor
2676/// is chosen based on #param[edge][label-side], #param[edge][label-angle],
2677/// and the edge's direction.
2678///
2679/// - label-fill (bool, paint): The background fill for the label. If `true`,
2680/// defaults to the value of #param[edge][crossing-fill]. If `false` or
2681/// `none`, no fill is used. If `auto`, then defaults to `true` if the label
2682/// is covering the edge (#param[edge][label-side]`: center`).
2683///
2684/// - label-size (auto, length): The default text size to apply to edge labels.
2685///
2686/// Default: #the-param[diagram][label-size]
2687///
2688/// - label-wrapper (auto, function): Callback function accepting a node
2689/// dictionary and returning the label content. This is used to add a label
2690/// background (see #param[edge][crossing-fill]), and can be used to adjust
2691/// the label's padding, outline, and so on.
2692///
2693/// ```example
2694/// #diagram(edge($f$, label-wrapper: e =>
2695/// circle(e.label, fill: e.label-fill)))
2696/// ```
2697///
2698/// Default: #the-param[diagram][label-wrapper]
2699///
2700/// - stroke (stroke): Stroke style of the edge. Arrows/marks scale with the
2701/// stroke thickness (and with #param[edge][mark-scale]).
2702///
2703/// - dash (string): The stroke's dash style. This is also set by some mark
2704/// styles. For example, setting `marks: "<..>"` applies `dash: "dotted"`.
2705///
2706/// - decorations (none, string, function): Apply a CeTZ path decoration to the
2707/// stroke. Preset options are `"wave"`, `"zigzag"`, and `"coil"` (which may
2708/// also be passed as convenience positional arguments), but a decoration
2709/// function may also be specified.
2710///
2711/// ```example
2712/// #diagram(
2713/// $
2714/// A edge("wave") &
2715/// B edge("zigzag") &
2716/// C edge("coil") & D \
2717/// alpha &&& omega
2718/// $,
2719/// edge((0,1), (3,1), "<->", decorations:
2720/// cetz.decorations.wave
2721/// .with(amplitude: .4)
2722/// )
2723/// )
2724/// ```
2725///
2726/// - marks (array): The marks (arrowheads) to draw along an edge's stroke. This
2727/// may be:
2728///
2729/// - A shorthand string such as `"->"` or `"hook'-/->>"`. Specifically,
2730/// shorthand strings are of the form $M_1 L M_2$ or $M_1 L M_2 L M_3$, etc,
2731/// where
2732///
2733/// $ M_i in #`fletcher.MARKS` = #context math.mat(..fletcher.MARKS.get().keys().map(i => $#raw(lang: none, i),$).chunks(6), delim: "{") $
2734/// is a mark name and
2735/// $ L in #`fletcher.LINE_ALIASES` = {#fletcher.LINE_ALIASES.keys().map(raw.with(lang: none)).join($,$)} $
2736/// is the line style.
2737///
2738/// - An array of mark names as strings or _mark objects_ (dictionaries of
2739/// parameters with a `draw` entry).
2740///
2741/// Shorthands are expanded into other arguments. For example,
2742/// `edge(p1, p2, "=>")` is short for `edge(p1, p2, marks: (none, "head"), "double")`, or more precisely, the result of `edge(p1, p2, ..fletcher.interpret-marks-arg("=>"))`.
2743///
2744/// #table(
2745/// columns: (1fr, 4fr),
2746/// align: (center + horizon, horizon),
2747/// [Result], [Value of `marks`],
2748/// ..(
2749/// "->",
2750/// ">>-->",
2751/// "<=>",
2752/// "==>",
2753/// "->>-",
2754/// "x-/-@",
2755/// "|..|",
2756/// "hook->>",
2757/// "hook'->>",
2758/// "||-*-harpoon'",
2759/// ("X", (inherit: "head", size: 15, sharpness: 40deg),), ((inherit:
2760/// "circle", pos: 0.5, fill: auto),),
2761/// ).map(arg => (
2762/// fletcher.diagram(edge((0,0), (1,0), marks: arg, stroke: 0.8pt)),
2763/// raw(repr(arg)),
2764/// )).join()
2765/// )
2766///
2767/// - mark-scale (percent): Scale factor for marks or arrowheads, relative to
2768/// the #param[edge][stroke] thickness. See also #the-param[diagram][mark-scale].
2769///
2770/// #diagram(
2771/// label-sep: 10pt,
2772/// edge-stroke: 1pt,
2773/// for i in range(3) {
2774/// let s = (1 + i/2)*100%
2775/// edge((2*i,0), (2*i + 1,0), label: s, "->", mark-scale: s)
2776/// }
2777/// )
2778///
2779/// Note that the default arrowheads scale automatically with double and
2780/// triple strokes:
2781///
2782/// #diagram(
2783/// label-sep: 10pt,
2784/// edge-stroke: 1pt,
2785/// for (i, s) in ("->", "=>", "==>").enumerate() {
2786/// edge((2*i,0), (2*i + 1,0), s, label: raw(s, lang: none))
2787/// }
2788/// )
2789///
2790/// - extrude (array): Draw a separate stroke for each extrusion offset to
2791/// obtain a multi-stroke effect. Offsets may be numbers (specifying multiples
2792/// of the stroke's thickness) or lengths.
2793///
2794/// #diagram({
2795/// (
2796/// (0,),
2797/// (-1.5,+1.5),
2798/// (-2,0,+2),
2799/// (-.5em,),
2800/// (0, 5pt,),
2801/// ).enumerate().map(((i, e)) => {
2802/// edge(
2803/// (2*i, 0), (2*i + 1, 0), [#e], "|->",
2804/// extrude: e, stroke: 1pt, label-sep: 1em)
2805/// }).join()
2806/// })
2807///
2808/// Notice how the ends of the line need to shift a little depending on the
2809/// mark. This offset is computed with `cap-offset()`.
2810///
2811/// See also #the-param[node][extrude].
2812///
2813/// - crossing (bool): If `true`, draws a backdrop of color
2814/// #param[edge][crossing-fill] to give the illusion of lines crossing each
2815/// other.
2816///
2817/// #diagram({
2818/// edge((0,1), (1,0), stroke: 1pt)
2819/// edge((0,0), (1,1), stroke: 1pt)
2820/// edge((2,1), (3,0), stroke: 1pt)
2821/// edge((2,0), (3,1), stroke: 1pt, crossing: true)
2822/// })
2823///
2824/// You can also pass `"crossing"` as a positional argument as a shorthand for
2825/// `crossing: true`.
2826///
2827/// - crossing-thickness (number): Thickness of the "crossing" background stroke
2828/// (applicable if #param[edge][crossing] is `true`) in multiples of the
2829/// normal stroke's thickness.
2830///
2831/// #diagram({
2832/// (1, 2, 4, 8).enumerate().map(((i, x)) => {
2833/// edge((2*i, 1), (2*i + 1, 0), stroke: 1pt, label-sep: 1em)
2834/// edge((2*i, 0), (2*i + 1, 1), raw(str(x)), stroke: 1pt, label-sep:
2835/// 2pt, label-pos: 0.3, crossing: true, crossing-thickness: x)
2836/// }).join()
2837/// })
2838///
2839/// Default: #the-param[diagram][crossing-thickness]
2840///
2841/// - crossing-fill (paint): Color to use behind connectors or labels to give
2842/// the illusion of crossing over other objects.
2843///
2844/// #let cross(x, fill) = {
2845/// edge((2*x + 0,1), (2*x + 1,0), stroke: 1pt)
2846/// edge((2*x + 0,0), (2*x + 1,1), $f$, stroke: 1pt, crossing: true, crossing-fill: fill, label-fill: true)
2847/// }
2848/// #diagram(crossing-thickness: 5, {
2849/// cross(0, white)
2850/// cross(1, blue.lighten(50%))
2851/// })
2852///
2853/// Default: #the-param[diagram][crossing-fill]
2854///
2855/// - corner-radius (length, none): Radius of rounded corners for edges with
2856/// multiple segments. Note that `none` is distinct from `0pt`.
2857///
2858/// #for (i, r) in (none, 0pt, 5pt).enumerate() {
2859/// if i > 0 { h(1fr) }
2860/// fletcher.diagram(
2861/// edge-stroke: 1pt,
2862/// edge((3*i, 0), "r,t,rd,r", "=>", raw(repr(r)), label-pos: 0.6, corner-radius: r)
2863/// )
2864/// }
2865///
2866/// This length specifies the corner radius for right-angled bends. The actual
2867/// radius is smaller for acute angles and larger for obtuse angles to balance
2868/// things visually. (Trust me, it looks naff otherwise!)
2869///
2870/// Default: #the-param[diagram][edge-corner-radius]
2871///
2872/// - shift (length, number, pair): Amount to shift the edge sideways by,
2873/// perpendicular to its direction. A pair `(from, to)` controls the shifts at
2874/// each end of the edge independently, and a single shift `s` is short for
2875/// `(s, s)`. Shifts can absolute lengths (e.g., `5pt`) or coordinate
2876/// differences (e.g., `0.1`).
2877///
2878/// #diagram(
2879/// node((0,0), $A$), node((1,0), $B$),
2880/// edge((0,0), (1,0), "->", `3pt`, shift: 3pt),
2881/// edge((0,0), (1,0), "->", `-3pt`, shift: -3pt, label-side: right),
2882/// )
2883///
2884/// If an edge has many vertices, the shifts only affect the first and last
2885/// segments of the edge.
2886///
2887/// ```example
2888/// #diagram(
2889/// node-fill: luma(70%),
2890/// node((0,0), [Hello]),
2891/// edge("u,r,d", "->"),
2892/// edge("u,r,d", "-->", shift: 8pt),
2893/// node((1,0), [World]),
2894/// )
2895/// ```
2896///
2897/// - snap-to (pair): The nodes the start and end of an edge should snap to.
2898/// Each node can be a position or node #param[node][name], or `none` to disable
2899/// snapping. See also #the-param[node][snap].
2900///
2901/// By default, an edge's first and last #param[edge][vertices] snap to nearby
2902/// nodes. This option can be used in case automatic snapping fails (if there
2903/// are many nodes close together, for example.)
2904///
2905/// - layer (number): Layer on which to draw the edge.
2906///
2907/// Objects on a higher `layer` are drawn on top of objects on a lower
2908/// `layer`. Objects on the same layer are drawn in the order they are passed
2909/// to `diagram()`.
2910///
2911/// - floating (bool): Whether the edge should be _floating_ so as not to affect
2912/// the diagram's bounding box.
2913///
2914/// When `floating: true`, the edge is wrapped in `cetz.draw.floating(..)` which
2915/// prevents the objects from affecting the canvas' bounding box.
2916///
2917/// ```example
2918/// An inline #diagram($
2919/// A edge(->, bend: #45deg, floating: #true) & B
2920/// $) diagram.
2921///
2922/// #rect(width: 7cm, align(center, diagram(
2923/// node((0,1), $A$),
2924/// edge("->", floating: true, [centered despite label]),
2925/// node((0,0), $B$),
2926/// )))
2927/// ```
2928///
2929/// - post (function): Callback function to intercept `cetz` objects before they
2930/// are drawn to the canvas.
2931///
2932/// This can be used to hide elements without affecting layout (for use with
2933/// #link("https://github.com/touying-typ/touying")[Touying], for example).
2934/// The `hide()` function also helps for this purpose.
2935///
2936#let edge(
2937 ..args,
2938 vertices: (),
2939 label: none,
2940 label-side: auto,
2941 label-pos: 50%,
2942 label-sep: auto,
2943 label-angle: 0deg,
2944 label-anchor: auto,
2945 label-fill: auto,
2946 label-size: auto,
2947 label-wrapper: auto,
2948 stroke: auto,
2949 dash: none,
2950 decorations: none,
2951 extrude: (0,),
2952 shift: 0pt,
2953 kind: auto,
2954 bend: 0deg,
2955 loop-angle: none,
2956 corner: none,
2957 corner-radius: auto,
2958 marks: (),
2959 mark-scale: 100%,
2960 crossing: false,
2961 crossing-thickness: auto,
2962 crossing-fill: auto,
2963 snap-to: (auto, auto),
2964 layer: 0,
2965 floating: false,
2966 post: x => x,
2967) = {
2968
2969 let options = (
2970 vertices: vertices,
2971 label: label,
2972 label-pos: as-relative(label-pos),
2973 label-sep: label-sep,
2974 label-angle: label-angle,
2975 label-anchor: label-anchor,
2976 label-side: label-side,
2977 label-fill: label-fill,
2978 label-size: label-size,
2979 label-wrapper: label-wrapper,
2980 stroke: stroke,
2981 dash: dash,
2982 decorations: decorations,
2983 kind: kind,
2984 bend: bend,
2985 loop-angle: pass-none(as-angle)(loop-angle),
2986 corner: corner,
2987 corner-radius: corner-radius,
2988 extrude: extrude,
2989 shift: shift,
2990 marks: marks,
2991 mark-scale: mark-scale,
2992 crossing: crossing,
2993 crossing-thickness: crossing-thickness,
2994 crossing-fill: crossing-fill,
2995 snap-to: as-pair(snap-to),
2996 layer: layer,
2997 post: post,
2998 floating: as-bool(floating, message: "`floating` must be boolean"),
2999 )
3000
3001 options += interpret-edge-args(args, options)
3002
3003 // relative coordinate shorthands
3004 let interpret-coord-str(coord) = {
3005 if type(coord) != str { return coord }
3006 let rel = (0, 0)
3007 let dirs = (
3008 "t": ( 0,-1), "n": ( 0,-1), "u": ( 0,-1),
3009 "b": ( 0,+1), "s": ( 0,+1), "d": ( 0,+1),
3010 "l": (-1, 0), "w": (-1, 0),
3011 "r": (+1, 0), "e": (+1, 0),
3012 )
3013 for char in coord.clusters() {
3014 rel = vector.add(rel, dirs.at(char))
3015 }
3016 (rel: rel)
3017 }
3018 options.vertices = options.vertices.map(interpret-coord-str)
3019
3020
3021
3022 if options.label-side not in (left, center, right, auto) {
3023 error("`label-side` must be one of `left`, `center`, `right`, or `auto`; got #0.", options.label-side)
3024 }
3025 if options.label-side == center {
3026 options.label-anchor = "center"
3027 options.label-sep = 0pt
3028 }
3029
3030 if type(options.shift) != array { options.shift = (options.shift, options.shift) }
3031
3032
3033 let obj = (
3034 class: "edge",
3035 ..options,
3036 is-crossing-background: false,
3037 )
3038
3039 // for the crossing effect, add another edge underneath
3040 if options.crossing {
3041 metadata((
3042 ..obj,
3043 is-crossing-background: true
3044 ))
3045 }
3046
3047 metadata(obj)
3048}
3049
3050
3051
3052#let resolve-edge-options(edge, options) = {
3053 // let to-pt(len) = to-abs-length(len, options.em-size)
3054
3055 edge += interpret-marks-arg(edge.marks)
3056
3057 if edge.stroke == none {
3058 // hack: for no stroke, it's easier to do the following.
3059 // then we have the guarantee that edge.stroke is actually
3060 // a stroke, not possibly none
3061 edge.extrude = ()
3062 edge.marks = ()
3063 edge.stroke = stroke((:))
3064 }
3065
3066 edge.stroke = (
3067 (
3068 cap: "round",
3069 dash: edge.dash,
3070 thickness: 0.048em, // guarantees thickness is a length, not auto
3071 ) +
3072 stroke-to-dict(options.edge-stroke) +
3073 stroke-to-dict(map-auto(edge.stroke, (:)))
3074 )
3075 edge.stroke.thickness = edge.stroke.thickness.to-absolute()
3076
3077 edge.extrude = as-array(edge.extrude).map(as-number-or-length.with(
3078 message: "`extrude` must be a number, length, or an array of those"
3079 )).map(d => {
3080 if type(d) == length { d.to-absolute() }
3081 else { d*edge.stroke.thickness }
3082 })
3083
3084 if type(edge.decorations) == str {
3085 edge.decorations = (
3086 "wave": cetz.decorations.wave.with(
3087 amplitude: .12,
3088 segment-length: .2,
3089 ),
3090 "zigzag": cetz.decorations.zigzag.with(
3091 amplitude: .12,
3092 segment-length: .2,
3093 ),
3094 "coil": cetz.decorations.coil.with(
3095 amplitude: .15,
3096 segment-length: .15,
3097 factor: 140%,
3098 ),
3099 ).at(edge.decorations)
3100 }
3101
3102 edge.crossing-fill = map-auto(edge.crossing-fill, options.crossing-fill)
3103 edge.crossing-thickness = map-auto(edge.crossing-thickness, options.crossing-thickness)
3104 edge.corner-radius = map-auto(edge.corner-radius, options.edge-corner-radius)
3105
3106 if edge.is-crossing-background {
3107 edge.stroke = (
3108 thickness: edge.crossing-thickness*edge.stroke.thickness,
3109 paint: edge.crossing-fill,
3110 cap: "round",
3111 )
3112 edge.marks = ()
3113 edge.extrude = edge.extrude.map(e => e/edge.crossing-thickness)
3114 }
3115
3116 edge.stroke = as-stroke(edge.stroke)
3117
3118 if edge.kind == auto {
3119 if edge.vertices.len() > 2 { edge.kind = "poly" }
3120 else if edge.corner != none { edge.kind = "corner" }
3121 else if edge.bend != 0deg { edge.kind = "arc" }
3122 else { edge.kind = "line" }
3123 }
3124
3125 // Scale marks
3126 edge.mark-scale *= options.mark-scale
3127 edge.marks = edge.marks.map(mark => {
3128 mark.scale *= edge.mark-scale
3129 mark
3130 })
3131
3132 edge.label-sep = map-auto(edge.label-sep, options.label-sep).to-absolute()
3133 edge.label-size = map-auto(edge.label-size, options.label-size)
3134
3135 edge.label-fill = map-auto(edge.label-fill, edge.label-side == center)
3136 if edge.label-fill == true { edge.label-fill = edge.crossing-fill }
3137 if edge.label-fill == false { edge.label-fill = none }
3138
3139 edge.label-wrapper = map-auto(edge.label-wrapper, options.label-wrapper)
3140
3141 if edge.floating {
3142 edge.post = x => cetz.draw.floating((edge.post)(x))
3143 }
3144
3145 edge
3146}
3147
3148
3149#let resolve-edge-vertices(edge, ctx: (:), nodes) = {
3150
3151 let adjacent-node-pos(forward, default) = {
3152 if edge.node-index == none { return default }
3153 let indices = if forward {
3154 range(edge.node-index, nodes.len())
3155 } else {
3156 range(0, edge.node-index).rev()
3157 }
3158 for i in indices {
3159 if nodes.at(i).snap != false {
3160 return nodes.at(i).pos.at(ctx.target-system)
3161 }
3162 }
3163 return default
3164 }
3165
3166 let prev-pos = adjacent-node-pos(false, (0, 0))
3167 let next-pos = adjacent-node-pos(true, (rel: (1, 0)))
3168
3169 let ctx = default-ctx + ctx + (
3170 prev: (pt: prev-pos),
3171 )
3172
3173 edge.vertices.at(0) = map-auto(edge.vertices.at(0), prev-pos)
3174 edge.vertices.at(-1) = map-auto(edge.vertices.at(-1), next-pos)
3175
3176 let (ctx, ..verts) = resolve(ctx, ..edge.vertices)
3177 verts.map(vector-2d)
3178
3179}
3180
3181
3182
3183#let convert-edge-corner-to-poly(edge) = {
3184 if edge.kind != "corner" { return edge }
3185
3186 let (from, to) = edge.final-vertices
3187 let θ = angle-between(from, to)
3188
3189 let bend-dir = (
3190 if edge.corner == right { true }
3191 else if edge.corner == left { false }
3192 else { error("Edge `corner` option must be `left` or `right`.") }
3193 )
3194
3195 let θ-floor = calc.floor(θ/90deg)*90deg
3196 let θ-ceil = calc.ceil(θ/90deg)*90deg
3197 let θs = if bend-dir {
3198 (θ-ceil, θ-floor + 180deg)
3199 } else {
3200 (θ-floor, θ-ceil + 180deg)
3201 }
3202
3203 let corner-point = if calc.even(calc.floor(θ/90deg) + int(bend-dir)) {
3204 (to.at(0), from.at(1))
3205 } else {
3206 (from.at(0), to.at(1))
3207 }
3208
3209 let label-side = map-auto(edge.label-side, if bend-dir { left } else { right })
3210
3211 edge + (
3212 kind: "poly",
3213 final-vertices: (from, corner-point, to),
3214 label-side: label-side,
3215 )
3216}
3217
3218
3219
3220
3221// For straight edges, `shift` translates the line laterally
3222#let apply-edge-shift-line(grid, edge) = {
3223 let (from-xy, to-xy) = edge.final-vertices
3224 let θ = angle-between(from-xy, to-xy) + 90deg
3225
3226 let (δ-from, δ-to) = edge.shift
3227 let δ⃗-from = vector-polar-with-xy-or-uv-length(grid, from-xy, δ-from, θ)
3228 let δ⃗-to = vector-polar-with-xy-or-uv-length(grid, to-xy, δ-to, θ)
3229
3230 edge.final-vertices.at( 0) = vector.add(from-xy, δ⃗-from)
3231 edge.final-vertices.at(-1) = vector.add(to-xy, δ⃗-to)
3232
3233 edge
3234}
3235
3236// For arc edges, `shift` grows/shrinks the arc concentrically
3237#let apply-edge-shift-arc(grid, edge) = {
3238 let (from-xy, to-xy) = edge.final-vertices
3239
3240 let θ = angle-between(from-xy, to-xy) + 90deg
3241 let (θ-from, θ-to) = (θ + edge.bend, θ - edge.bend)
3242
3243 let (δ-from, δ-to) = edge.shift
3244 let δ⃗-from = vector-polar-with-xy-or-uv-length(grid, from-xy, δ-from, θ-from)
3245 let δ⃗-to = vector-polar-with-xy-or-uv-length(grid, to-xy, δ-to, θ-to)
3246
3247 edge.final-vertices.at( 0) = vector.add(from-xy, δ⃗-from)
3248 edge.final-vertices.at(-1) = vector.add(to-xy, δ⃗-to)
3249
3250 if edge.loop-angle != none {
3251 let a = edge.loop-angle + 90deg
3252 edge.final-vertices.at( 0) = vector.add(edge.final-vertices.at( 0), vector-polar(+1e-4pt, a))
3253 edge.final-vertices.at(-1) = vector.add(edge.final-vertices.at(-1), vector-polar(-1e-4pt, a))
3254 }
3255
3256 edge
3257}
3258
3259// For poly edges, `shift` affects the first/last line segments
3260#let apply-edge-shift-poly(grid, edge) = {
3261 let end-segments = (
3262 edge.final-vertices.slice(0, 2), // first two vertices
3263 edge.final-vertices.slice(-2), // last two vertices
3264 )
3265
3266 let θs = (
3267 angle-between(..end-segments.at(0)) + 180deg,
3268 angle-between(..end-segments.at(1)) + 180deg,
3269 )
3270
3271 let ends = (edge.final-vertices.at(0), edge.final-vertices.at(-1))
3272 let δs = edge.shift.zip(ends, θs).map(((d, xy, θ)) => {
3273 vector-polar-with-xy-or-uv-length(grid, xy, d, θ + 90deg)
3274 })
3275
3276 // the `shift` option is nicer if it shifts the entire segment, not just the first vertex
3277 // first segment
3278 edge.final-vertices.at(0) = vector.add(edge.final-vertices.at(0), δs.at(0))
3279 edge.final-vertices.at(1) = vector.add(edge.final-vertices.at(1), δs.at(0))
3280 // last segment
3281 edge.final-vertices.at(-2) = vector.add(edge.final-vertices.at(-2), δs.at(1))
3282 edge.final-vertices.at(-1) = vector.add(edge.final-vertices.at(-1), δs.at(1))
3283
3284 edge
3285}
3286
3287
3288/// Apply #the-param[edge][shift] by translating edge vertices.
3289///
3290/// - grid (dictionary): Representation of the grid layout. This is needed to
3291/// support shifts specified as coordinate lengths.
3292/// - edge (dictionary): The edge with a `shift` entry.
3293#let apply-edge-shift(grid, edge) = {
3294 if edge.kind == "line" { apply-edge-shift-line(grid, edge) }
3295 else if edge.kind == "arc" { apply-edge-shift-arc(grid, edge) }
3296 else if edge.kind == "poly" { apply-edge-shift-poly(grid, edge) }
3297 else { edge }
3298}
3299
3300#import "deps.typ": cetz
3301
3302#import "marks.typ": *
3303#import "draw.typ": *
3304#import "shapes.typ"
3305#import "node.typ": *
3306#import "edge.typ": *
3307#import "diagram.typ": *
3308#import "coords.typ": *
3309#import "utils.typ"
3310#import "utils.typ": *
3311#import "deps.typ": cetz
3312#import cetz.draw
3313#import "default-marks.typ": *
3314
3315#let MARK_REQUIRED_DEFAULTS = (
3316 rev: false,
3317 flip: false,
3318 scale: 100%,
3319 extrude: (0,),
3320 tip-end: 0,
3321 tail-end: 0,
3322 tip-origin: 0,
3323 tail-origin: 0,
3324)
3325
3326
3327/// For a given mark, determine where that the stroke should terminate at,
3328/// relative to the mark's origin point, as a function of the shift.
3329///
3330/// Imagine the tip-origin of the mark is at $(x, y) = (0, 0)$. A stroke along
3331/// the line $y = "shift"$ coming from $x = -oo$ terminates at $x = "offset"$, where
3332/// $"offset"$ is the result of this function.
3333/// Units are in multiples of stroke thickness.
3334///
3335/// This is used to correctly implement multi-stroke marks, e.g.,
3336/// #diagram(edge("<==>")). The function `mark-debug()` can help visualise a
3337/// mark's cap offset.
3338///
3339/// ```example
3340/// #fletcher.mark-debug("O")
3341/// ```
3342///
3343/// The dashed green line shows the stroke tip end as a function of $y$, and the
3344/// dashed red line shows where the stroke ends if the mark is acting as a tail.
3345#let cap-offset(mark, shift) = {
3346 let o = 0
3347 let scale = float(mark.scale)
3348 if "cap-offset" in mark {
3349 o = (mark.cap-offset)(mark, shift/scale)
3350 }
3351 o += if mark.tip { mark.tip-end } else { mark.tail-end }
3352 o*scale
3353}
3354
3355
3356#let apply-mark-inheritances(mark) = {
3357 let marks = MARKS.get()
3358 while "inherit" in mark {
3359
3360 if mark.inherit.at(-1) == "'" {
3361 mark.flip = not mark.at("flip", default: false)
3362 mark.inherit = mark.inherit.slice(0, -1)
3363 }
3364
3365 if mark.inherit not in marks {
3366 error("Mark inherits from #0 which is not defined.", repr(mark.inherit))
3367 }
3368
3369 let parent = marks.at(mark.remove("inherit"))
3370 mark = parent + mark
3371 }
3372 mark
3373}
3374
3375
3376
3377/// Resolve a mark dictionary by applying inheritance, adding any required
3378/// entries, and evaluating any closure entries.
3379///
3380/// ```example
3381/// #context fletcher.resolve-mark((
3382/// a: 1,
3383/// b: 2,
3384/// c: mark => mark.a + mark.b,
3385/// ))
3386/// ```
3387///
3388#let resolve-mark(mark, defaults: (:)) = {
3389 if mark == none { return none }
3390
3391 if type(mark) == str { mark = (inherit: mark) }
3392
3393 mark = apply-mark-inheritances(mark)
3394
3395 // be careful to preserve the insertion order of mark
3396 // as this defines the evaluation order of mark parameters
3397 for (k, v) in MARK_REQUIRED_DEFAULTS + defaults {
3398 if k not in mark {
3399 mark.insert(k, v)
3400 }
3401 }
3402
3403 for (key, value) in mark {
3404 if key == "cap-offset" { continue }
3405
3406 if type(value) == function {
3407 mark.at(key) = value(mark)
3408 }
3409 }
3410
3411 mark
3412}
3413
3414
3415/// Draw a mark at a given position and angle
3416///
3417/// - mark (dictionary): Mark object to draw. Must contain a `draw` entry.
3418/// - stroke (stroke): Stroke style for the mark. The stroke's paint is used as
3419/// the default fill style.
3420/// - origin (point): Coordinate of the mark's origin (as defined by
3421/// `tip-origin` or `tail-origin`).
3422/// - angle (angle): Angle of the mark, `0deg` being $->$, counterclockwise.
3423/// - debug (bool): Whether to draw the origin points.
3424#let draw-mark(
3425 mark,
3426 stroke: 1pt,
3427 origin: (0,0),
3428 angle: 0deg,
3429 debug: false
3430) = {
3431 mark = resolve-mark(mark)
3432 stroke = as-stroke(stroke)
3433
3434 let thickness = stroke.thickness
3435
3436 let fill = mark.at("fill", default: auto)
3437 fill = map-auto(fill, stroke.paint)
3438 fill = map-auto(fill, black)
3439
3440 let stroke = stroke-to-dict(stroke)
3441 stroke.dash = none
3442
3443 if "stroke" in mark {
3444 if mark.stroke == none { stroke = none }
3445 else if mark.stroke == auto { }
3446 else { stroke += stroke-to-dict(mark.stroke) }
3447 }
3448
3449 if "draw" not in mark {
3450 error("Mark object must contain `draw` or `inherit`; resolved to #0.", mark)
3451 }
3452
3453 draw.group({
3454 draw.set-style(
3455 stroke: stroke,
3456 fill: fill,
3457 )
3458
3459 draw.translate(origin)
3460 draw.rotate(angle)
3461 draw.scale(thickness/1cm*float(mark.scale))
3462
3463 if mark.at("rev", default: false) {
3464 draw.translate(x: mark.tail-origin)
3465 draw.scale(x: -1)
3466 if debug {
3467 draw.content((0,10), text(0.25em, red)[rev])
3468 }
3469 } else {
3470 draw.translate(x: -mark.tip-origin)
3471 }
3472
3473 if mark.flip {
3474 draw.scale(y: -1)
3475 }
3476
3477 for e in mark.extrude {
3478 draw.group({
3479 draw.translate(x: e)
3480 mark.draw
3481 })
3482 }
3483
3484 if debug {
3485 let tip = mark.at("tip", default: none)
3486 if tip == true {
3487 draw.content((0,-10), text(0.25em, green)[tip])
3488 } else if tip == false {
3489 draw.content((0,-10), text(0.25em, orange)[tail])
3490 }
3491 }
3492
3493 })
3494}
3495
3496/// Visualise a mark's anatomy.
3497///
3498/// ```example
3499/// #context {
3500/// let mark = fletcher.MARKS.get().stealth
3501/// // make a wide stealth arrow
3502/// mark += (angle: 45deg)
3503/// fletcher.mark-debug(mark)
3504/// }
3505/// ```
3506///
3507/// - Green/left stroke: the edge's stroke when the mark is at the tip.
3508/// - Red/right stroke: edge's stroke if the mark is at the start acting as a
3509/// tail.
3510/// - Blue-white dot: the origin point $(0, 0)$ in the mark's coordinate frame.
3511/// - `tip-origin`: the $x$-coordinate of the point of the mark's tip.
3512/// - `tail-origin`: the $x$-coordinate of the mark's tip when it is acting as a
3513/// reversed tail mark.
3514/// - `tip-end`: The $x$-coordinate of the end point of the edge's stroke (green
3515/// stroke).
3516/// - `tail-end`: The $x$-coordinate of the end point of the edge's stroke when
3517/// acting as a tail mark (red stroke).
3518/// - Dashed green/red lines: The stroke end points as a function of $y$. This
3519/// is controlled by the special `cap-offset` mark property and is used for
3520/// multi-stroke effects like #diagram(edge(">==>")). See `cap-offset()`.
3521///
3522/// This is mainly useful for designing your own marks.
3523///
3524/// - mark (string, dictionary): The mark name or dictionary.
3525/// - stroke (stroke): The stroke style, whose paint and thickness applies both
3526/// to the stroke and the mark itself.
3527///
3528/// - show-labels (bool): Whether to label the tip/tail origin/end points.
3529/// - show-offsets (bool): Whether to visualise the `cap-offset()` values.
3530/// - offset-range (number): The span above and below the stroke line to plot
3531/// the cap offsets, in multiples of the stroke's thickness.
3532#let mark-debug(
3533 mark,
3534 stroke: 5pt,
3535 show-labels: true,
3536 show-offsets: true,
3537 offset-range: 6,
3538) = context {
3539 let mark = resolve-mark(mark)
3540 let stroke = as-stroke(stroke)
3541
3542 let t = stroke.thickness
3543 let scale = float(mark.scale)
3544
3545
3546 cetz.canvas({
3547
3548 draw-mark(mark, stroke: stroke)
3549
3550 if mark.at("rev", default: false) {
3551 draw.scale(x: -1)
3552 draw.translate(x: -t*mark.tail-origin*scale)
3553 } else {
3554 draw.translate(x: -t*mark.tip-origin*scale)
3555 }
3556
3557
3558 if show-offsets {
3559
3560 let samples = 100
3561 let ys = range(samples + 1)
3562 .map(n => n/samples)
3563 .map(y => (2*y - 1)*offset-range)
3564
3565 let tip-points = ys.map(y => {
3566 let o = cap-offset(mark + (tip: true), y)
3567 (o*t, y*t)
3568 })
3569
3570 let tail-points = ys.map(y => {
3571 let o = cap-offset(mark + (tip: false), y)
3572 (o*t, y*t)
3573 })
3574
3575 draw.line(
3576 ..tip-points,
3577 stroke: (
3578 paint: rgb("0f0"),
3579 thickness: 0.4pt,
3580 dash: (array: (3pt, 3pt), phase: 0pt),
3581 ),
3582 )
3583 draw.line(
3584 ..tail-points,
3585 stroke: (
3586 paint: rgb("f00"),
3587 thickness: 0.4pt,
3588 dash: (array: (3pt, 3pt), phase: 3pt),
3589 ),
3590 )
3591
3592
3593 }
3594
3595 if show-labels {
3596 for (i, (item, y, color)) in (
3597 ("tip-end", +1.00, "0f0"),
3598 ("tail-end", -1.00, "f00"),
3599 ("tip-origin", +0.75, "0ff"),
3600 ("tail-origin", -0.75, "f0f"),
3601 ).enumerate() {
3602 let x = mark.at(item)*float(mark.scale)
3603 let c = rgb(color)
3604 draw.line((t*x, 0), (t*x, y), stroke: 0.5pt + c)
3605 draw.content(
3606 (t*x, y),
3607 pad(2pt, text(0.75em, fill: c, raw(item))),
3608 anchor: if y < 0 { "north" } else { "south" },
3609 )
3610 }
3611 }
3612
3613 // draw tip/tail stroke previews
3614 let (min, max) = min-max((
3615 "tip-end",
3616 "tail-end",
3617 "tip-origin",
3618 "tail-origin",
3619 ).map(i => mark.at(i)))
3620
3621 let l = calc.max(5, max - min)
3622
3623 draw.line(
3624 (t*mark.tip-end, 0),
3625 (t*(min - l), 0),
3626 stroke: rgb("0f06") + t,
3627 )
3628 draw.line(
3629 (t*mark.tail-end, 0),
3630 (t*(max + l), 0),
3631 stroke: rgb("f006") + t,
3632 )
3633
3634 // draw true origin dot
3635 draw.circle(
3636 (0, 0),
3637 radius: t/4,
3638 stroke: rgb("00f") + 1pt,
3639 fill: white,
3640 )
3641 })
3642}
3643
3644#let mark-demo(
3645 mark,
3646 stroke: 2pt,
3647 width: 3cm,
3648 height: 1cm,
3649) = context {
3650 let mark = resolve-mark(mark)
3651 let stroke = as-stroke(stroke)
3652
3653 let t = stroke.thickness*float(mark.scale)
3654
3655 cetz.canvas({
3656
3657 for x in (0, width) {
3658 draw.line(
3659 (x, +0.5*height),
3660 (x, -1.5*height),
3661 stroke: red.transparentize(50%) + 0.5pt,
3662 )
3663 }
3664
3665 let x = t*(mark.tip-origin - mark.tip-end)
3666 draw.line(
3667 (x, 0),
3668 (rel: (-x, 0), to: (width, 0)),
3669 stroke: stroke,
3670 )
3671
3672 let mark-length = t*(mark.tip-origin - mark.tail-origin)
3673 draw-mark(
3674 mark + (rev: true),
3675 stroke: stroke,
3676 origin: (mark-length, 0),
3677 angle: 0deg,
3678 )
3679 draw-mark(
3680 mark + (rev: false),
3681 stroke: stroke,
3682 origin: (width, 0),
3683 angle: 0deg,
3684 )
3685
3686 draw.translate((0, -height))
3687
3688 let x = t*(mark.tail-end - mark.tail-origin)
3689 draw.line(
3690 (x, 0),
3691 (rel: (-x, 0), to: (width, 0)),
3692 stroke: stroke,
3693 )
3694
3695 draw-mark(
3696 mark + (rev: true),
3697 stroke: stroke,
3698 origin: (0, 0),
3699 angle: 180deg,
3700 )
3701 draw-mark(
3702 mark + (rev: false),
3703 stroke: stroke,
3704 origin: (width - mark-length, 0),
3705 angle: 180deg,
3706 )
3707
3708 })
3709}
3710
3711
3712#let place-mark-on-curve(mark, path, stroke: 1pt + black, debug: false) = {
3713 if mark.at("hide", default: false) { return }
3714
3715 let ε = 1e-4
3716
3717 // calculate velocity of parametrised path at point
3718 let point = path(mark.pos)
3719 let point-plus-ε = path(mark.pos + ε)
3720 let grad = vector-len(vector.sub(point-plus-ε, point))/ε
3721 if grad == 0pt { grad = ε*1pt }
3722
3723 let mark-length = mark.at("tip-origin", default: 0) - mark.at("tail-origin", default: 0)
3724 mark-length *= float(mark.scale)
3725 let Δt = mark-length*stroke.thickness/grad
3726 if Δt == 0 { Δt = ε } // avoid Δt = 0 so the two points are distinct
3727
3728 let t = lerp(Δt, 1, mark.pos)
3729 let tip-point = path(t)
3730 let tail-point = path(t - Δt)
3731 let θ = angle-between(tail-point, tip-point)
3732
3733 draw-mark(mark, origin: tip-point, angle: θ, stroke: stroke)
3734
3735 if debug {
3736 draw.circle(
3737 tip-point,
3738 radius: .2pt,
3739 fill: rgb("0f0"),
3740 stroke: none
3741 )
3742 draw.circle(
3743 tail-point,
3744 radius: .2pt,
3745 fill: rgb("f00"),
3746 stroke: none
3747 )
3748 }
3749
3750}
3751#import "utils.typ": *
3752#import "coords.typ": uv-to-xy, default-ctx, resolve, NAN_COORD, resolve-system
3753#import "shapes.typ"
3754
3755
3756/// Draw a labelled node in a diagram which can connect to edges.
3757///
3758/// - ..args (any): The first positional argument is #param[node][pos] and the
3759/// second, if given, is #param[node][label].
3760///
3761/// - pos (coordinate): Position of the node, or its center coordinate. This may
3762/// be an elastic (row/column) coordinate like `(2, 1)`, or a CeTZ-style
3763/// coordinate expression like `(rel: (30deg, 1cm), to: (2, 1))`.
3764///
3765/// See the options of `diagram()` to control the physical scale of elastic
3766/// coordinates.
3767///
3768/// - name (label, string, none): An optional name to give the node.
3769///
3770/// Names can sometimes be used in place of coordinates. For example:
3771///
3772/// ```example
3773/// #diagram(
3774/// node((0,0), $A$, name: <A>),
3775/// node((1,0.6), $B$, name: <B>),
3776/// edge(<A>, <B>, "->"),
3777/// node((rel: (1, 0), to: <B>), $C$)
3778/// )
3779/// ```
3780///
3781/// Node names are _labels_ (instead of strings like in CeTZ) to disambiguate
3782/// them from other positional string arguments given to `edge()`. If a string
3783/// is given, it is converted. (Since these labels are never inserted into the
3784/// final document, they cannot interfere with other document labels.)
3785///
3786/// - label (content): Content to display inside the node.
3787///
3788/// If a node is larger than its label, you can wrap the label in `align()` to
3789/// control the label alignment within the node.
3790///
3791/// ```example
3792/// #diagram(
3793/// node((0,0), align(bottom + left)[¡Hola!],
3794/// width: 3cm, height: 2cm, fill: yellow),
3795/// )
3796/// ```
3797///
3798/// - inset (length): Padding between the node's content and its outline.
3799///
3800/// In debug mode, the inset is visualised by a thin green outline.
3801///
3802/// ```example
3803/// #diagram(
3804/// debug: 3,
3805/// node-stroke: 1pt,
3806/// node((0,0), [Hello,]),
3807/// edge(),
3808/// node((1,0), [World!], inset: 10pt),
3809/// )
3810/// ```
3811///
3812/// Defaults to #the-param[diagram][node-inset].
3813///
3814/// - outset (length): Margin between the node's bounds to the anchor
3815/// points for connecting edges.
3816///
3817/// This does not affect node layout, only how closely edges connect to the
3818/// node.
3819///
3820/// In debug mode, the outset is visualised by a thin green outline.
3821///
3822/// ```example
3823/// #diagram(
3824/// debug: 3,
3825/// node-stroke: 1pt,
3826/// node((0,0), [Hello,]),
3827/// edge(),
3828/// node((1,0), [World!], outset: 10pt),
3829/// )
3830/// ```
3831///
3832/// Defaults to #the-param[diagram][node-outset].
3833///
3834/// - width (length, auto): Width of the node. If `auto`, the node's width is
3835/// the width of the node #param[node][label], plus twice the
3836/// #param[node][inset].
3837///
3838/// If the width is not `auto`, you can use `align` to control the placement of the node's #param[node][label].
3839///
3840/// - height (length, auto): Height of the node. If `auto`, the node's height is the height of the node #param[node][label], plus twice the #param[node][inset].
3841///
3842/// If the height is not `auto`, you can use `align` to control the placement of the node's #param[node][label].
3843///
3844/// - enclose (array): Positions or names of other nodes to enclose by enlarging
3845/// this node.
3846///
3847/// If given, causes the node to resize so that its bounding rectangle
3848/// surrounds the given nodes. The center #param[node][pos] does not affect
3849/// the node's position if `enclose` is given, but still affects connecting
3850/// edges.
3851///
3852/// ```example
3853/// #diagram(
3854/// node-stroke: 1pt,
3855/// node((0,0), [ABC], name: <A>),
3856/// node((1,1), [XYZ], name: <Z>),
3857/// node(
3858/// text(teal)[Node group], stroke: teal,
3859/// enclose: (<A>, <Z>), name: <group>),
3860/// edge(<group>, (3,0.5), stroke: teal),
3861/// )
3862/// ```
3863///
3864/// - shape (rect, circle, function): Shape of the node's outline. If `auto`,
3865/// one of `rect` or `circle` is chosen depending on the aspect ratio of the
3866/// node's label.
3867///
3868/// Other shapes are defined in the `fletcher.shapes`
3869/// submodule, including
3870/// #{
3871/// dictionary(fletcher.shapes).pairs()
3872/// .filter(((k, v)) => type(v) != module)
3873/// .map(((k, v)) => [#raw(k)])
3874/// .join(last: [, and ])[, ]
3875/// }.
3876///
3877/// Custom shapes should be specified as a function `(node, extrude, ..parameters) => (..)`
3878/// which returns `cetz` objects.
3879/// - The `node` argument is a dictionary containing the node's attributes,
3880/// including its dimensions (`node.size`), and other options (such as
3881/// `node.corner-radius`).
3882/// - The `extrude` argument is a length which the shape outline should be
3883/// extruded outwards by. This serves two functions: to support automatic
3884/// edge anchoring with a non-zero node `outset`, and to create multi-stroke
3885/// effects using the `extrude` node option.
3886/// See the
3887/// #link("https://github.com/Jollywatt/typst-fletcher/blob/master/src/shapes.typ",
3888/// ```plain src/shapes.typ```) source file for example shape implementations.
3889///
3890/// Defaults to #the-param[diagram][node-shape].
3891///
3892/// - stroke (stroke): Stroke style for the node outline.
3893///
3894/// Defaults to #the-param[diagram][node-stroke].
3895///
3896/// - fill (paint): Fill style of the node. The fill is drawn within the node
3897/// outline as defined by the first #param(full: false)[node][extrude] value.
3898///
3899/// Defaults to #the-param[diagram][node-fill].
3900///
3901/// - defocus (number): Strength of the "defocus" adjustment for connectors
3902/// incident with this node.
3903///
3904/// This affects how connectors attach to non-square nodes. If `0`, the
3905/// adjustment is disabled and connectors are always directed at the node's
3906/// exact center.
3907///
3908/// #stack(
3909/// dir: ltr,
3910/// spacing: 1fr,
3911/// ..(0.2, 0, -1).enumerate().map(((i, defocus)) => {
3912/// fletcher.diagram(spacing: 8mm, {
3913/// node((i, 0), raw("defocus: "+str(defocus)), stroke: black, defocus: defocus)
3914/// for y in (-1, +1) {
3915/// edge((i - 1, y), (i, 0))
3916/// edge((i, y), (i, 0))
3917/// edge((i + 1, y), (i, 0))
3918/// }
3919/// })
3920/// })
3921/// )
3922///
3923/// Defaults to #the-param[diagram][node-defocus].
3924///
3925/// - extrude (array): Draw strokes around the node at the given offsets to
3926/// obtain a multi-stroke effect. Offsets may be numbers (specifying multiples
3927/// of the stroke's thickness) or lengths.
3928///
3929/// The node's fill is drawn within the boundary defined by the first offset in
3930/// the array.
3931///
3932/// #diagram(
3933/// node-stroke: 1pt,
3934/// node-fill: red.lighten(70%),
3935/// node((0,0), `(0,)`),
3936/// node((1,0), `(0, 2)`, extrude: (0, 2)),
3937/// node((2,0), `(2, 0)`, extrude: (2, 0)),
3938/// node((3,0), `(0, -2.5, 2mm)`, extrude: (0, -2.5, 2mm)),
3939/// )
3940///
3941/// See also #the-param[edge][extrude].
3942///
3943/// - corner-radius (length): Radius of rounded corners, if supported by the
3944/// node #param[node][shape].
3945///
3946/// Defaults to #the-param[diagram][node-corner-radius].
3947///
3948/// - layer (number): Layer on which to draw the node.
3949///
3950/// Objects on a higher `layer` are drawn on top of objects on a lower
3951/// `layer`. Objects on the same layer are drawn in the order they are passed
3952/// to `diagram()`.
3953///
3954/// Defaults to layer `0` unless the node #param[node][enclose]s
3955/// points, in which case `layer` defaults to `-1`.
3956///
3957/// - snap (number, false): The snapping priority for edges connecting to this
3958/// node. A higher priority means edges will automatically snap to this node
3959/// over other overlapping nodes. If `false`, edges only snap to this node if
3960/// manually set with #the-param[edge][snap-to].
3961///
3962/// Setting a lower value is useful if the node #param[node][enclose]s other
3963/// nodes that you want to snap to first.
3964///
3965/// - post (function): Callback function to intercept `cetz` objects before they
3966/// are drawn to the canvas.
3967///
3968/// This can be used to hide elements without affecting layout (for use with
3969/// #link("https://github.com/touying-typ/touying")[Touying], for example).
3970/// The `hide()` function also helps for this purpose.
3971///
3972#let node(
3973 ..args,
3974 pos: auto,
3975 name: none,
3976 label: none,
3977 inset: auto,
3978 outset: auto,
3979 fill: auto,
3980 stroke: auto,
3981 extrude: (0,),
3982 width: auto,
3983 height: auto,
3984 radius: auto,
3985 enclose: (),
3986 corner-radius: auto,
3987 shape: auto,
3988 defocus: auto,
3989 snap: 0,
3990 layer: auto,
3991 post: x => x,
3992) = {
3993 if args.named().len() > 0 { error("Unexpected named argument(s) #..0.", args.named().keys()) }
3994 if args.pos().len() > 2 { error("`node()` can have up to two positional arguments; the position and label.") }
3995
3996 // interpret first two positional arguments
3997 if args.pos().len() == 2 {
3998 (pos, label) = args.pos()
3999 } else if args.pos().len() == 1 {
4000 let arg = args.pos().at(0)
4001 // one positional argument may be the coordinate or the label
4002 if type(arg) in (array, dictionary, label) {
4003 pos = arg
4004 label = none
4005 } else {
4006 pos = if enclose.len() > 0 { auto } else { () }
4007 label = arg
4008 }
4009 }
4010
4011 let extrude = as-array(extrude).map(as-number-or-length.with(
4012 message: "`extrude` must be a number, length, or an array of those"
4013 ))
4014
4015 if not (type(snap) in (int, float) or snap == false) {
4016 error("`snap` must be a number specifying priority or `false` to disable; got #0.", repr(snap))
4017 }
4018
4019 metadata((
4020 class: "node",
4021 pos: (raw: pos),
4022 name: pass-none(as-label)(name),
4023 label: label,
4024 inset: inset,
4025 outset: outset,
4026 enclose: as-array(enclose),
4027 size: (width, height),
4028 radius: radius,
4029 shape: shape,
4030 stroke: stroke,
4031 fill: fill,
4032 corner-radius: corner-radius,
4033 defocus: defocus,
4034 extrude: extrude,
4035 layer: layer,
4036 snap: snap,
4037 post: post,
4038 ))
4039}
4040
4041
4042
4043#let resolve-node-options(node, options) = {
4044
4045 node.stroke = map-auto(node.stroke, options.node-stroke)
4046 if node.stroke != none {
4047 let base-stroke = pass-none(stroke-to-dict)(options.node-stroke)
4048 node.stroke = base-stroke + stroke-to-dict(node.stroke)
4049 }
4050 node.stroke = pass-none(stroke)(node.stroke) // guarantee stroke or none
4051
4052 node.fill = map-auto(node.fill, options.node-fill)
4053 node.corner-radius = map-auto(node.corner-radius, options.node-corner-radius)
4054 node.inset = map-auto(node.inset, options.node-inset).to-absolute()
4055 node.outset = map-auto(node.outset, options.node-outset).to-absolute()
4056 node.defocus = map-auto(node.defocus, options.node-defocus)
4057
4058 node.size = node.size.map(pass-auto(length.to-absolute))
4059 node.radius = pass-auto(length.to-absolute)(node.radius)
4060
4061 node.shape = map-auto(node.shape, options.node-shape)
4062
4063 if node.shape == auto {
4064 if node.radius != auto { node.shape = "circle" }
4065 if node.size != (auto, auto) { node.shape = "rect" }
4066 }
4067
4068 let thickness = if node.stroke == none { 1pt } else {
4069 map-auto(node.stroke.thickness, 1pt)
4070 }
4071
4072 node.extrude = node.extrude.map(d => {
4073 if type(d) == length { d }
4074 else { d*thickness }
4075 }).map(length.to-absolute)
4076
4077 if type(node.outset) in (int, float) {
4078 node.outset *= thickness
4079 }
4080
4081 let default-layer = if node.enclose.len() > 0 { -1 } else { 0 }
4082 node.layer = map-auto(node.layer, default-layer)
4083
4084 node
4085}
4086
4087
4088/// Measure node labels with the style context and resolve node shapes.
4089///
4090/// Widths and heights that are `auto` are determined by measuring the size of
4091/// the node's label.
4092#let measure-node-size(node) = {
4093
4094 // Width and height explicitly given
4095 if auto not in node.size {
4096 let (width, height) = node.size
4097 node.radius = vector-len((width/2, height/2))
4098 node.aspect = width/height
4099
4100 // Radius explicitly given
4101 } else if node.radius != auto {
4102 node.size = (2*node.radius, 2*node.radius)
4103 node.aspect = 1
4104
4105 // Width and/or height set to auto
4106 } else {
4107
4108 let inner-size = node.size.map(pass-auto(i => i - 2*node.inset))
4109
4110 // Determine physical size of node content
4111 let (width, height) = measure(box(
4112 node.label,
4113 width: inner-size.at(0),
4114 height: inner-size.at(1),
4115 ))
4116
4117 // let (width, height) = node.inner-size
4118 let radius = vector-len((width/2, height/2)) // circumcircle
4119
4120 node.aspect = if width == 0pt or height == 0pt { 1 } else { width/height }
4121
4122 if node.shape == auto {
4123 let is-roundish = calc.max(node.aspect, 1/node.aspect) < 1.5
4124 node.shape = if is-roundish { "circle" } else { "rect" }
4125 }
4126
4127 // Add node inset
4128 if radius != 0pt { radius += node.inset }
4129 if width != 0pt and height != 0pt {
4130 width += 2*node.inset
4131 height += 2*node.inset
4132 }
4133
4134 // If width/height/radius is auto, set to measured width/height/radius
4135 node.size = node.size.zip((width, height))
4136 .map(((given, measured)) => map-auto(given, measured))
4137 node.radius = map-auto(node.radius, radius)
4138
4139 }
4140
4141 if node.shape in (circle, "circle") { node.shape = shapes.circle }
4142 if node.shape in (rect, "rect") { node.shape = shapes.rect }
4143
4144 node
4145}
4146
4147
4148/// Process the `enclose` options of an array of nodes.
4149#let resolve-node-enclosures(nodes, ctx) = {
4150
4151 let nodes = nodes.map(node => {
4152 // not an enclose node, leave as is
4153 if node.enclose.len() == 0 { return node }
4154
4155 let enclosed-vertices = node.enclose.map(key => {
4156 let near-node = find-node(nodes, key)
4157
4158 // if near-node == none or near-node.pos.raw == auto {
4159 if near-node == none {
4160 // if enclosed point doesn't resolve to a node
4161 // enclose the point itself
4162 let (_, coord) = resolve(ctx, key)
4163 (coord,)
4164 } else {
4165 // if enclosed point resolves to a node
4166 // enclose its bounding box
4167 let (x, y) = near-node.pos.xyz
4168 if "bounding-center" in near-node {
4169 (x, y) = near-node.bounding-center
4170 }
4171 let (w, h) = near-node.size
4172 (
4173 (x - w/2, y - h/2),
4174 (x - w/2, y + h/2),
4175 (x + w/2, y - h/2),
4176 (x + w/2, y + h/2),
4177 )
4178 }
4179 }).join()
4180
4181 let (center, size) = bounding-rect(enclosed-vertices)
4182
4183 node.pos.xyz = center
4184 node.bounding-center = center
4185 node.size = vector-max(
4186 size.map(d => d + node.inset*2),
4187 node.size,
4188 )
4189 node.resolved-enclose = true
4190 node.shape = shapes.rect // TODO: support different node shapes with enclose
4191
4192 node
4193 })
4194
4195 nodes
4196}
4197
4198
4199#let register-node-anchors(ctx, node) = {
4200 if node.name == none { return ctx }
4201 let node-origin = node.pos.at(ctx.target-system)
4202 let calculate-anchors
4203
4204 if ctx.target-system == "uv" {
4205 // anchors don't make sense in elastic coordinates
4206 // so just give access to the origin, but make
4207 // everything else indeterminate (NAN_COORD)
4208 calculate-anchors = (a) => {
4209 if a == () {
4210 ("default",)
4211 } else {
4212 if a == "default" {
4213 node-origin
4214 } else {
4215 NAN_COORD
4216 }
4217 }
4218 }
4219 } else if ctx.target-system == "xyz" {
4220 if is-nan-vector(node-origin) { return ctx }
4221
4222 // do not compute anchors for enclose nodes before they have been resolved
4223 if node.enclose.len() > 0 and "resolved-enclose" not in node {
4224 calculate-anchors = (k) => NAN_COORD
4225 } else {
4226 let cetz-obj = (node.shape)(node, node.outset).at(0)
4227 calculate-anchors = (k) => {
4228 if k == "default" { return node-origin }
4229 let a = ((cetz-obj)(ctx).anchors)(k)
4230 if not is-number-vector(a) { return a }
4231 a.at(1) *= -1 // CETZ Y AXIS
4232 vector.add(
4233 node-origin, // node center
4234 vector-2d(vector.scale(a, ctx.length)),
4235 )
4236 }
4237 }
4238 }
4239
4240 ctx.nodes.insert(str(node.name), (anchors: calculate-anchors))
4241 ctx
4242
4243}
4244
4245/// Resolve node positions to a target coordinate system in sequence.
4246///
4247/// CeTZ-style coordinate expressions work, with the previous coordinate `()`
4248/// referring to the resolved position of the previous node.
4249///
4250/// The resolved coordinates are added to each node's `pos` dictionary.
4251///
4252/// - nodes (array): Array of nodes, each a dictionary containing a `pos` entry,
4253/// which should be a CeTZ-compatible coordinate expression.
4254/// - ctx (dictionary): CeTZ-style context to be passed to `resolve(ctx, ..)`.
4255/// This must contain `target-system`, and optionally `grid`.
4256/// -> array
4257#let resolve-node-coordinates(nodes, ctx: (:)) = {
4258 let ctx = default-ctx + ctx
4259 let system = ctx.target-system
4260
4261 // nodes which enclose other points are allowed to have
4262 // position `auto`; they are placed after normal nodes
4263 let auto-placed-nodes = ()
4264
4265 let coord
4266 for (i, node) in nodes.enumerate() {
4267
4268 if node.pos.raw == auto {
4269 // this node encloses other nodes
4270 if ctx.target-system == "xyz" {
4271 // resolve center from uv coords, if possible
4272 if not is-nan-vector(node.pos.uv) {
4273 (ctx, coord) = resolve(ctx, node.pos.uv)
4274 }
4275 } else {
4276 // otherwise, we must find bounding box center later
4277 auto-placed-nodes.push(i)
4278 coord = NAN_COORD
4279 }
4280 } else {
4281 // this node has a center that may be resolvable
4282 (ctx, coord) = resolve(ctx, node.pos.raw)
4283 }
4284
4285 node.pos.insert(ctx.target-system, coord)
4286 nodes.at(i) = node
4287 ctx = register-node-anchors(ctx, node)
4288 }
4289
4290 for i in auto-placed-nodes {
4291 let node = nodes.at(i)
4292
4293 // the center of enclosing nodes defaults to the center
4294 // of the bounding rect of the points they enclose
4295 let enclosed-points = node.enclose.map(key => {
4296 let node = find-node(nodes, key)
4297 if node == none {
4298 // enclose key doesn't correspond to node
4299 // interpret key as real coordinate
4300 let (_, coord) = resolve(ctx, key)
4301 coord
4302 } else {
4303 node.pos.at(ctx.target-system)
4304 }
4305 }).filter(coord => not is-nan-vector(coord))
4306
4307 let coord = if enclosed-points.len() > 0 {
4308 bounding-rect(enclosed-points).center
4309 } else { NAN_COORD }
4310
4311 nodes.at(i).pos.insert(ctx.target-system, coord)
4312
4313 }
4314
4315 (ctx, nodes)
4316}
4317#import "deps.typ": cetz
4318#import cetz: draw, vector
4319
4320/// The standard rectangle node shape.
4321///
4322/// A string `"rect"` or the element function `rect` given to
4323/// #the-param[node][shape] are interpreted as this shape.
4324///
4325/// #diagram(
4326/// node-stroke: green,
4327/// node-fill: green.lighten(90%),
4328/// node((0,0), `rect`, shape: fletcher.shapes.rect)
4329/// )
4330///
4331#let rect(node, extrude) = {
4332 let r = node.corner-radius
4333 let (w, h) = node.size.map(i => i/2 + extrude)
4334 draw.rect(
4335 (-w, -h), (+w, +h),
4336 radius: if r != none { r + extrude },
4337 )
4338}
4339
4340/// The standard circle node shape.
4341///
4342/// A string `"circle"` or the element function `circle` given to
4343/// #the-param[node][shape] are interpreted as this shape.
4344///
4345/// #diagram(
4346/// node-stroke: red,
4347/// node-fill: red.lighten(90%),
4348/// node((0,0), `circle`, shape: fletcher.shapes.circle)
4349/// )
4350///
4351#let circle(node, extrude) = draw.circle((0, 0), radius: node.radius + extrude)
4352
4353/// An elliptical node shape.
4354///
4355/// #diagram(
4356/// node-stroke: orange,
4357/// node-fill: orange.lighten(90%),
4358/// node((0,0), `ellipse`, shape: fletcher.shapes.ellipse)
4359/// )
4360///
4361/// - scale (number): Scale factor for ellipse radii.
4362#let ellipse(node, extrude, scale: 1) = {
4363 draw.circle(
4364 (0, 0),
4365 radius: vector.scale(node.size, 0.5).map(x => x*scale + extrude),
4366 )
4367}
4368
4369
4370/// A capsule node shape.
4371///
4372/// #diagram(
4373/// node-stroke: teal,
4374/// node-fill: teal.lighten(90%),
4375/// node((0,0), `pill`, shape: fletcher.shapes.pill)
4376/// )
4377///
4378#let pill(node, extrude) = {
4379 let size = node.size.map(i => i + 2*extrude)
4380 draw.rect(
4381 vector.scale(size, -0.5),
4382 vector.scale(size, +0.5),
4383 radius: calc.min(..size)/2,
4384 )
4385}
4386
4387
4388/// A slanted rectangle node shape.
4389///
4390/// #diagram(
4391/// node-stroke: olive,
4392/// node-fill: olive.lighten(90%),
4393/// node((0,0), `parallelogram`, shape: fletcher.shapes.parallelogram)
4394/// )
4395///
4396/// - angle (angle): Angle of the slant, `0deg` is a rectangle. Don't set to
4397/// `90deg` unless you want your document to be larger than the solar system.
4398///
4399/// - fit (number): Adjusts how comfortably the parallelogram fits the label's bounding box.
4400///
4401/// #for (i, fit) in (0, 0.5, 1).enumerate() {
4402/// let s = fletcher.shapes.parallelogram.with(fit: fit, angle: 35deg)
4403/// let l = box(
4404/// stroke: (dash: "dashed", thickness: 0.5pt),
4405/// inset: 10pt,
4406/// raw("fit: " + repr(fit)),
4407/// )
4408/// diagram(node((i, 0), l,
4409/// inset: 0pt,
4410/// shape: s,
4411/// stroke: olive,
4412/// fill: olive.lighten(90%),
4413/// ))
4414/// h(5mm)
4415/// }
4416#let parallelogram(node, extrude, flip: false, angle: 20deg, fit: 0.8) = {
4417 let (w, h) = node.size
4418 if flip { (w, h) = (h, w) }
4419
4420 let (x, y) = (w/2 + extrude*calc.cos(angle), h/2 + extrude)
4421 let δ = h/2*calc.tan(angle)
4422 let μ = extrude*calc.tan(angle)
4423 x += δ*fit
4424
4425 let verts = (
4426 (-x - μ, -y),
4427 (+x - δ, -y),
4428 (+x + μ, +y),
4429 (-x + δ, +y),
4430 )
4431
4432 if flip { verts = verts.map(((i, j)) => (j, i)) }
4433
4434 let obj = draw.line(..verts, close: true)
4435 draw.group(obj) // enables cetz border anchors
4436}
4437
4438
4439/// An isosceles trapezium node shape.
4440///
4441/// #diagram(
4442/// node-stroke: green,
4443/// node-fill: green.lighten(90%),
4444/// node((0,0), `trapezium`, shape: fletcher.shapes.trapezium)
4445/// )
4446///
4447/// - angle (angle): Angle of the slant, `0deg` is a rectangle. Don't set to
4448/// `90deg` unless you want your document to be larger than the solar system.
4449///
4450/// - fit (number): Adjusts how comfortably the trapezium fits the label's bounding box.
4451///
4452/// #for (i, fit) in (0, 0.5, 1).enumerate() {
4453/// let s = fletcher.shapes.trapezium.with(fit: fit, angle: 35deg)
4454/// let l = box(
4455/// stroke: (dash: "dashed", thickness: 0.5pt),
4456/// inset: 10pt,
4457/// raw("fit: " + repr(fit)),
4458/// )
4459/// diagram(node((i, 0), l,
4460/// inset: 0pt,
4461/// shape: s,
4462/// stroke: green,
4463/// fill: green.lighten(90%),
4464/// ))
4465/// h(5mm)
4466/// }
4467///
4468/// - dir (top, bottom, left, right): The side the shorter parallel edge is on.
4469#let trapezium(node, extrude, dir: top, angle: 20deg, fit: 0.8) = {
4470 assert(dir in (top, bottom, left, right))
4471
4472 let flip = dir in (right, left) // flip along diagonal line x = y
4473 let rotate = dir in (bottom, left) // rotate 180deg
4474
4475 let (w, h) = node.size
4476 if flip { (w, h) = (h, w) }
4477
4478 let (x, y) = (w/2 + extrude*calc.cos(angle), h/2 + extrude)
4479 let δ = h/2*calc.tan(angle)
4480 let μ = extrude*calc.tan(angle)
4481 x += δ*fit
4482
4483 let verts = (
4484 (-x - μ, -y),
4485 (+x + μ, -y),
4486 (+x - δ, +y),
4487 (-x + δ, +y),
4488 )
4489
4490 if flip { verts = verts.map(((i, j)) => (j, i)) }
4491 if rotate { verts = verts.map(((i, j)) => (-i, -j)) }
4492
4493 let obj = draw.line(..verts, close: true)
4494 draw.group(obj) // enables cetz border anchors
4495}
4496
4497/// A rhombus node shape.
4498///
4499/// #diagram(
4500/// node-stroke: purple,
4501/// node-fill: purple.lighten(90%),
4502/// node((0,0), `diamond`, shape: fletcher.shapes.diamond)
4503/// )
4504///
4505/// - fit (number): Adjusts how comfortably the diamond fits the label's bounding box.
4506///
4507/// #for (i, fit) in (0, 0.5, 1).enumerate() {
4508/// let s = fletcher.shapes.diamond.with(fit: fit)
4509/// let l = box(
4510/// stroke: (dash: "dashed", thickness: 0.5pt),
4511/// inset: 10pt,
4512/// raw("fit: " + repr(fit)),
4513/// )
4514/// diagram(node((i, 0), l,
4515/// inset: 0pt,
4516/// shape: s,
4517/// stroke: purple,
4518/// fill: purple.lighten(90%),
4519/// ))
4520/// h(5mm)
4521/// }
4522#let diamond(node, extrude, fit: 0.5) = {
4523 let (w, h) = node.size
4524 let φ = calc.atan2(w/1pt, h/1pt)
4525 let x = w/2*(1 + fit) + extrude/calc.sin(φ)
4526 let y = h/2*(1 + fit) + extrude/calc.cos(φ)
4527 let obj = draw.line(
4528 (-x, 0pt),
4529 (0pt, -y),
4530 (+x, 0pt),
4531 (0pt, +y),
4532 close: true,
4533 )
4534 draw.group(obj) // enables cetz border anchors
4535}
4536
4537/// An isosceles triangle node shape.
4538///
4539/// One of #param[triangle][angle] or #param[triangle][aspect] may be given, but
4540/// not both. The triangle's base coincides with the label's base and widens to
4541/// enclose the label; see https://www.desmos.com/calculator/i4i9svunj4.
4542///
4543/// #diagram(
4544/// node-stroke: fuchsia,
4545/// node-fill: fuchsia.lighten(90%),
4546/// node((0,0), `triangle`, shape: fletcher.shapes.triangle)
4547/// )
4548///
4549/// - dir (top, bottom, left, right): Direction the triangle points.
4550/// - aspect (number, auto): Aspect ratio of triangle, or the ratio of its base
4551/// to its height.
4552/// - angle (angle, auto): Angle of the triangle opposite the base.
4553/// - fit (number): Adjusts how comfortably the triangle fits the label's bounding box.
4554///
4555/// #for (i, fit) in (0, 0.5, 1).enumerate() {
4556/// let s = fletcher.shapes.triangle.with(fit: fit, angle: 120deg)
4557/// let l = box(
4558/// stroke: (dash: "dashed", thickness: 0.5pt),
4559/// inset: 10pt,
4560/// raw("fit: " + repr(fit)),
4561/// )
4562/// diagram(node((i, 0), l,
4563/// inset: 0pt,
4564/// shape: s,
4565/// stroke: fuchsia,
4566/// fill: fuchsia.lighten(90%),
4567/// ))
4568/// h(5mm)
4569/// }
4570#let triangle(node, extrude, dir: top, angle: auto, aspect: auto, fit: 0.8) = {
4571 assert(dir in (top, bottom, left, right))
4572
4573 let flip = dir in (right, left) // flip along diagonal line x = y
4574 let rotate = dir in (bottom, left) // rotate 180deg
4575
4576 let (w, h) = node.size
4577 if flip { (w, h) = (h, w) }
4578
4579 if angle == auto and aspect == auto { aspect = w/h }
4580 if angle == auto { angle = 2*calc.atan(aspect/2) }
4581 if aspect == auto { aspect = 2*calc.tan(angle/2) }
4582
4583 let a = aspect*h/2 + fit*w/2
4584 let b = (a + fit*w/2)/aspect
4585
4586 a += extrude*calc.tan(45deg + angle/4)
4587 b += extrude/calc.cos(90deg - angle/2)
4588
4589 let verts = (
4590 (-a, -h/2 - extrude),
4591 (+a, -h/2 - extrude),
4592 (0, +b),
4593 )
4594
4595 if flip { verts = verts.map(((i, j)) => (j, i)) }
4596 if rotate { verts = verts.map(((i, j)) => (-i, -j)) }
4597
4598 let obj = draw.line(..verts, close: true)
4599 draw.group(obj) // enables cetz border anchors
4600}
4601
4602
4603/// A pentagonal house-like node shape.
4604///
4605/// #diagram(
4606/// node-stroke: eastern,
4607/// node-fill: eastern.lighten(90%),
4608/// node((0,0), `house`, shape: fletcher.shapes.house)
4609/// )
4610///
4611/// - dir (top, bottom, left, right): Direction of the roof of the house.
4612/// - angle (angle): The slant of the roof. A plain rectangle is `0deg`, and
4613/// `90deg` is a sky scraper stretching past Pluto.
4614#let house(node, extrude, dir: top, angle: 10deg) = {
4615 let flip = dir in (right, left) // flip along diagonal line x = y
4616 let rotate = dir in (bottom, left) // rotate 180deg
4617
4618 let (w, h) = node.size
4619 if flip { (w, h) = (h, w) }
4620
4621 let (x, y) = (w/2 + extrude, h/2 + extrude)
4622 let a = h/2 + extrude*calc.tan(45deg - angle/2)
4623 let b = h/2 + w/2*calc.tan(angle) + extrude/calc.cos(angle)
4624
4625 let verts = (
4626 (-x, -y),
4627 (-x, a),
4628 (0pt, b),
4629 (+x, a),
4630 (+x, -y),
4631 )
4632
4633 if flip { verts = verts.map(((i, j)) => (j, i)) }
4634 if rotate { verts = verts.map(((i, j)) => (-i, -j)) }
4635
4636 let obj = draw.line(..verts, close: true)
4637 draw.group(obj) // enables cetz border anchors
4638}
4639
4640
4641
4642
4643/// A chevron node shape.
4644///
4645/// #diagram(
4646/// node-stroke: yellow,
4647/// node-fill: yellow.lighten(90%),
4648/// node((0,0), `chevron`, shape: fletcher.shapes.chevron)
4649/// )
4650///
4651/// - dir (top, bottom, left, right): Direction the chevron points.
4652/// - angle (angle): The slant of the arrow. A plain rectangle is `0deg`.
4653/// - fit (number): Adjusts how comfortably the chevron fits the label's bounding box.
4654///
4655/// #for (i, fit) in (0, 0.5, 1).enumerate() {
4656/// let s = fletcher.shapes.chevron.with(fit: fit)
4657/// let l = box(
4658/// stroke: (dash: "dashed", thickness: 0.5pt),
4659/// inset: 10pt,
4660/// raw("fit: " + repr(fit)),
4661/// )
4662/// diagram(node((i, 0), l,
4663/// inset: 0pt,
4664/// shape: s,
4665/// stroke: yellow,
4666/// fill: yellow.lighten(90%),
4667/// ))
4668/// h(5mm)
4669/// }
4670#let chevron(node, extrude, dir: right, angle: 30deg, fit: 0.8) = {
4671 let flip = dir in (right, left) // flip along diagonal line x = y
4672 let rotate = dir in (bottom, left) // rotate 180deg
4673
4674 let (w, h) = node.size
4675 if flip { (w, h) = (h, w) }
4676
4677 let (x, y) = (w/2 + extrude, h/2 + extrude)
4678 let c = w/2*calc.tan(angle)
4679 let α = extrude*calc.tan(45deg - angle/2)
4680 let β = extrude*calc.tan(45deg + angle/2)
4681 let ɣ = extrude/calc.cos(angle) - c
4682 let δ = c*fit
4683 let y = h/2 + c*fit
4684
4685 let verts = (
4686 (-x, +y + α - c),
4687 (0pt, +y + ɣ + c),
4688 (+x, +y + α - c),
4689
4690 (+x, -y - β),
4691 (0pt, -y - ɣ),
4692 (-x, -y - β),
4693 )
4694
4695 if flip { verts = verts.map(((i, j)) => (j, i)) }
4696 if rotate { verts = verts.map(((i, j)) => (-i, -j)) }
4697
4698
4699 let obj = draw.line(..verts, close: true)
4700 draw.group(obj) // enables cetz border anchors
4701}
4702
4703
4704
4705
4706
4707/// An (irregular) hexagon node shape.
4708///
4709/// #diagram(
4710/// node-stroke: aqua,
4711/// node-fill: aqua.lighten(90%),
4712/// node((0,0), `hexagon`, shape: fletcher.shapes.hexagon)
4713/// )
4714///
4715/// - angle (angle): Half the exterior angle, `0deg` being a rectangle.
4716/// - fit (number): Adjusts how comfortably the hexagon fits the label's bounding box.
4717///
4718/// #for (i, fit) in (0, 0.5, 1).enumerate() {
4719/// let s = fletcher.shapes.hexagon.with(fit: fit)
4720/// let l = box(
4721/// stroke: (dash: "dashed", thickness: 0.5pt),
4722/// inset: 10pt,
4723/// raw("fit: " + repr(fit)),
4724/// )
4725/// diagram(node((i, 0), l,
4726/// inset: 0pt,
4727/// shape: s,
4728/// stroke: aqua,
4729/// fill: aqua.lighten(90%),
4730/// ))
4731/// h(5mm)
4732/// }
4733#let hexagon(node, extrude, angle: 30deg, fit: 0.8) = {
4734 let (w, h) = node.size
4735 let f = h/2*calc.tan(angle)*(1 - fit)
4736 let x = w/2 + extrude*calc.tan(45deg - angle/2) - f
4737 let y = h/2 + extrude
4738 let z = y*calc.tan(angle)
4739 let obj = draw.line(
4740 (+x, -y),
4741 (+x + z, 0pt),
4742 (+x, +y),
4743
4744 (-x, +y),
4745 (-x - z, 0pt),
4746 (-x, -y),
4747
4748 close: true,
4749 )
4750 draw.group(obj) // enables cetz border anchors
4751}
4752
4753
4754/// A truncated rectangle node shape.
4755///
4756/// #diagram(
4757/// node-stroke: maroon,
4758/// node-fill: maroon.lighten(90%),
4759/// node((0,0), `octagon`, shape: fletcher.shapes.octagon)
4760/// )
4761///
4762/// - truncate (number, length): Size of the truncated corners. A number is
4763/// interpreted as a multiple of the smaller of the node's width or height.
4764#let octagon(node, extrude, truncate: 0.5) = {
4765 let (w, h) = node.size
4766 let (x, y) = (w/2 + extrude, h/2 + extrude)
4767
4768 let d
4769 if type(truncate) == length { d = truncate }
4770 else { d = truncate*calc.min(w/2, h/2)}
4771 d += extrude*0.5857864376 // (1 - calc.tan(calc.pi/8))
4772
4773 let obj = draw.line(
4774 (-x + d, -y ),
4775 (-x , -y + d),
4776 (-x , +y - d),
4777 (-x + d, +y ),
4778 (+x - d, +y ),
4779 (+x , +y - d),
4780 (+x , -y + d),
4781 (+x - d, -y ),
4782 close: true,
4783 )
4784 draw.group(obj) // enables cetz border anchors
4785}
4786#import "deps.typ": cetz
4787#import cetz: vector
4788
4789#let error(message, ..args) = {
4790 let pairs = args.pos().enumerate() + args.named().pairs()
4791 let ticks(x) = "`" + if type(x) == str { x } else { repr(x) } + "`"
4792 for (k, v) in pairs {
4793 if type(v) == array {
4794 let replacement = if v.len() > 0 {
4795 v.map(ticks).join(", ")
4796 } else { "()" }
4797 message = message.replace("#.." + str(k), replacement)
4798 }
4799 if type(v) != str { v = repr(v) }
4800 message = message.replace("#" + str(k), ticks(v))
4801 }
4802 assert(false, message: message)
4803}
4804
4805
4806// Replace `auto` with a value
4807#let map-auto(value, fallback) = if value == auto { fallback } else { value }
4808
4809// Make a function propagate `auto`
4810#let pass-auto(f) = x => if x == auto { x } else { f(x) }
4811
4812// Make a function propagage `none`
4813#let pass-none(f) = x => if x == none { x } else { f(x) }
4814
4815#let as-bool(obj, message: "Expected boolean") = {
4816 if type(obj) == bool { obj }
4817 else { error(message + "; got #0.", repr(obj)) }
4818}
4819
4820// for when `stroke` is already in namespace
4821#let as-stroke(x) = stroke(x)
4822
4823#let as-label(x) = {
4824 if type(x) == label { x }
4825 else if type(x) == str { label(x) }
4826 else { error("Expected label or string; got #0.", repr(x)) }
4827}
4828
4829#let as-pair(obj) = {
4830 if type(obj) == array {
4831 if obj.len() == 2 { obj }
4832 else { error("Expected a pair (array of length 2); got #0.", repr(obj))}
4833 } else { (obj, obj) }
4834}
4835
4836#let as-array(obj) = if type(obj) == array { obj } else { (obj,) }
4837
4838#let as-number-or-length(obj, message: "Expected a number or length") = {
4839 if type(obj) in (int, float, length) { obj }
4840 else { error(message + "; got #0.", repr(obj)) }
4841}
4842
4843#let as-relative(obj, message: "Expected float or relative length") = {
4844 if type(obj) == relative { obj }
4845 else if type(obj) in (int, float) { obj*100% + 0pt }
4846 else if type(obj) in (ratio, length) { obj + 0% + 0pt }
4847 else { error(message + "; got #0.", repr(obj)) }
4848}
4849
4850#let relative-to-float(t, len: float("inf")*1pt) = {
4851 len = len.to-absolute()
4852 if type(t) in (int, float, ratio) { float(t) }
4853 else if type(t) == length { t.to-absolute()/len }
4854 else if type(t) == relative { float(t.ratio) + t.length.to-absolute()/len }
4855 else { error("Cannot convert #0 to float.", t) }
4856}
4857
4858
4859#let as-length(obj, message: "Expected a length") = {
4860 if type(obj) == length { obj }
4861 else { error(message + "; got #0.", repr(obj)) }
4862}
4863
4864#let as-angle(obj, message: "Expected an angle") = {
4865 if type(obj) == angle { obj }
4866 else { error(message + "; got #0.", repr(obj)) }
4867}
4868
4869#let stroke-to-dict(s) = {
4870 let s = as-stroke(s)
4871 let d = (
4872 paint: s.paint,
4873 thickness: s.thickness,
4874 cap: s.cap,
4875 join: s.join,
4876 dash: s.dash,
4877 miter-limit: s.miter-limit,
4878 )
4879
4880 // remove auto entries to allow folding strokes by joining dicts
4881 for (key, value) in d {
4882 if value == auto {
4883 let _ = d.remove(key)
4884 }
4885 }
4886
4887 d
4888}
4889
4890
4891#let min-max(array) = (calc.min(..array), calc.max(..array))
4892#let cumsum(array) = {
4893 let sum = array.at(0)
4894 for i in range(1, array.len()) {
4895 sum += array.at(i)
4896 array.at(i) = sum
4897 }
4898 array
4899}
4900
4901#let vector-len((x, y)) = 1pt*calc.sqrt((x/1pt)*(x/1pt) + (y/1pt)*(y/1pt))
4902#let vector-set-len(len, v) = vector.scale(v, len/vector-len(v))
4903#let vector-unitless(v) = v.map(x => if type(x) == length { x.pt() } else { x })
4904#let vector-2d((x, y, ..z)) = (x, y)
4905#let vector-max(a, b) = array.zip(a, b).map(vals => calc.max(..vals))
4906
4907#let vector-polar(r, θ) = (r*calc.cos(θ), r*calc.sin(θ))
4908#let vector-angle(v) = calc.atan2(..vector-unitless(v))
4909#let angle-between(from, to) = vector-angle(vector.sub(to, from))
4910
4911// Ensure angle is in range 0deg <= θ < 360deg
4912#let wrap-angle-360(θ) = calc.rem-euclid(θ/360deg, 1)*360deg
4913
4914// Ensure angle is in range -180deg <= θ <= 180deg
4915#let wrap-angle-180(θ) = (θ/360deg - calc.round(θ/360deg))*360deg
4916
4917#let angle-to-anchor(θ) = {
4918 let i = calc.rem(8*θ/1rad/calc.tau, 8)
4919 (
4920 "east",
4921 "north-east",
4922 "north",
4923 "north-west",
4924 "west",
4925 "south-west",
4926 "south",
4927 "south-east",
4928 ).at(int(calc.round(i)))
4929}
4930
4931
4932#let is-length-vector(v) = v.all(x => type(x) == length)
4933#let is-number-vector(v) = v.all(x => type(x) in (int, float))
4934#let is-nan-vector(v) = is-number-vector(v) and v.any(x => float(x).is-nan())
4935
4936
4937#let lerp(a, b, t) = a*(1 - t) + b*t
4938
4939/// Linearly interpolate an array with linear behaviour outside bounds
4940///
4941/// - values (array): Array of lengths defining interpolation function.
4942/// - index (int, float): Index-coordinate to sample.
4943/// - spacing (length): Gradient for linear extrapolation beyond array bounds.
4944#let interp(values, index, spacing: 0pt) = {
4945 let max-index = values.len() - 1
4946 if index < 0 {
4947 values.at(0) + spacing*index
4948 } else if index > max-index {
4949 values.at(-1) + spacing*(index - max-index)
4950 } else {
4951 lerp(
4952 values.at(calc.floor(index)),
4953 values.at(calc.ceil(index)),
4954 calc.fract(index),
4955 )
4956 }
4957}
4958
4959
4960/// Inverse of `interp()`.
4961///
4962/// - values (array): Array of lengths defining interpolation function.
4963/// - value: Value to find the interpolated index of.
4964/// - spacing (length): Gradient for linear extrapolation beyond array bounds.
4965#let interp-inv(values, value, spacing: 0pt) = {
4966 let i = 0
4967 while i < values.len() {
4968 if values.at(i) >= value { break }
4969 i += 1
4970 }
4971 let (first, last) = (values.at(0), values.at(-1))
4972
4973 // avoids division by zero when numerator and denominator both vanish
4974 let div(a, b) = if calc.abs(a) < 1e-3pt { 0 } else { a/b }
4975
4976 if value < first {
4977 div(value - first, spacing)
4978 } else if value >= last {
4979 values.len() - 1 + div(value - last, spacing)
4980 } else {
4981 let (prev, nearest) = (values.at(i - 1), values.at(i))
4982 i - 1 + div(value - prev, nearest - prev)
4983 }
4984}
4985
4986
4987#let rect-at(center, size) = (-1, +1).map(dir => {
4988 vector.add(center, vector.scale(size, dir/2))
4989})
4990
4991#let point-is-in-rect(point, (center, size)) = {
4992 point.zip(center, size).all(((x, o, s)) => {
4993 calc.abs(x - o) <= s/2
4994 })
4995}
4996
4997#let bounding-rect(points) = {
4998 let (xs, ys) = array.zip(..points)
4999 let p1 = (calc.min(..xs), calc.min(..ys))
5000 let p2 = (calc.max(..xs), calc.max(..ys))
5001 (
5002 center: vector.scale(vector.add(p1, p2), 0.5),
5003 size: vector.sub(p2, p1)
5004 )
5005}
5006
5007
5008/// Determine arc between two points with a given bend angle
5009///
5010/// The bend angle is the angle between chord of the arc (line connecting the
5011/// points) and the tangent to the arc and the first point.
5012///
5013/// Returns a dictionary containing:
5014/// - `center`: the center of the arc's curvature
5015/// - `radius`
5016/// - `start`: the start angle of the arc
5017/// - `stop`: the end angle of the arc
5018///
5019/// - from (point): 2D vector of initial point.
5020/// - to (point): 2D vector of final point.
5021/// - angle (angle): The bend angle between chord of the arc (line connecting the
5022/// points) and the tangent to the arc and the first point.
5023/// -> dictionary
5024///
5025/// #diagram(spacing: 2cm, {
5026/// for (i, θ) in (0deg, 45deg, -90deg).enumerate() {
5027/// edge((2*i, 0), (2*i + 1, 0), marks: (none, "head"), bend: θ)
5028/// edge((2*i, 0), (2*i + 1, 0), [#θ], label-side: center, dash:
5029/// "dotted")
5030/// }
5031/// })
5032#let get-arc-connecting-points(from, to, angle) = {
5033 // TODO: properly handle trivial arcs
5034 if from == to { to = vector.add(to, (0pt, 1e-4pt)) }
5035
5036 let mid = vector.scale(vector.add(from, to), 0.5)
5037 let (dx, dy) = vector.sub(to, from)
5038 let perp = (dy, -dx)
5039
5040 let center = vector.add(mid, vector.scale(perp, 0.5/calc.tan(angle)))
5041
5042 let radius = vector-len(vector.sub(to, center))
5043
5044 let start = angle-between(center, from)
5045 let stop = angle-between(center, to)
5046
5047 if start < stop and angle > 0deg { start += 360deg }
5048 if start > stop and angle < 0deg { start -= 360deg }
5049
5050 (center: center, radius: radius, start: start, stop: stop)
5051}
5052
5053/// Return true if a content element is a space or sequence of spaces
5054#let is-space(el) = {
5055 if el == none { return true }
5056 if repr(el.func()) == "space" { return true }
5057 if repr(el.func()) == "sequence" { return el.children.all(is-space) }
5058 return false
5059}
5060
5061#let is-sequence(it) = {
5062 type(it) == content and repr(it.func()) == "sequence"
5063}
5064
5065#let flatten-sequence-to-array(it) = {
5066 if is-sequence(it) {
5067 it.children.map(flatten-sequence-to-array).join() + ()
5068 } else { (it,) }
5069}
5070
5071
5072// find a node near a given uv coordinate
5073#let find-node-at(nodes, uv, snap: true) = {
5074 nodes.filter(node => {
5075 if is-nan-vector(node.pos.uv) { return false }
5076
5077 if snap {
5078 // node must be within a one-unit block around pos
5079 vector.sub(node.pos.uv, uv).all(Δ => calc.abs(Δ) < 0.1)
5080 } else {
5081 node.pos.uv == uv
5082 }
5083 })
5084 .sorted(key: node => vector.len(vector.sub(node.pos.uv, uv)))
5085 .at(0, default: none)
5086}
5087
5088#let find-node(nodes, key, snap: true) = {
5089 if type(key) == label {
5090 let node = nodes.find(node => node.name == key)
5091 assert(node != none, message: "Couldn't find node with name " + repr(key))
5092 node
5093 } else if type(key) == array and is-number-vector(key) {
5094 find-node-at(nodes, key, snap: snap)
5095 } else {
5096 none
5097 }
5098}
5099[package]
5100name = "fletcher"
5101version = "0.5.7"
5102compiler = "0.13.0"
5103entrypoint = "src/exports.typ"
5104authors = ["Joseph Wilson (Jollywatt)"]
5105license = "MIT"
5106description = "Draw diagrams with nodes and arrows."
5107repository = "https://github.com/Jollywatt/typst-fletcher"
5108categories = ["visualization", "components"]
5109keywords = [
5110 "commutative",
5111 "commuting",
5112 "commute",
5113 "diagram",
5114 "category",
5115 "flowchart",
5116 "DAG",
5117 "graph",
5118 "finite state",
5119 "network",
5120 "node",
5121 "arrow",
5122]
5123exclude = ["docs/", "tests/"]