diff --git a/packages/preview/fletcher/0.3.0/LICENSE b/packages/preview/fletcher/0.3.0/LICENSE new file mode 100644 index 000000000..f8de216ae --- /dev/null +++ b/packages/preview/fletcher/0.3.0/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Joseph Wilson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/preview/fletcher/0.3.0/README.md b/packages/preview/fletcher/0.3.0/README.md new file mode 100644 index 000000000..80eb62017 --- /dev/null +++ b/packages/preview/fletcher/0.3.0/README.md @@ -0,0 +1,50 @@ +# Fletcher + +_(noun) a maker of arrows_ + +[![Manual](https://img.shields.io/badge/docs-manual.pdf-blue)](https://github.com/Jollywatt/typst-fletcher/raw/v0.3.0/docs/manual.pdf) +![Version](https://img.shields.io/badge/version-0.3.0-blue) + +A [Typst]("https://typst.app/") package for drawing diagrams with arrows, +built on top of [CeTZ]("https://github.com/johannes-wolf/cetz"). + + + + logo + + +```typ +#fletcher.diagram(cell-size: 15mm, crossing-fill: none, { + let (src, img, quo) = ((0, 1), (1, 1), (0, 0)) + node(src, $G$) + node(img, $im f$) + node(quo, $G slash ker(f)$) + edge(src, img, $f$, "->") + edge(quo, img, $tilde(f)$, "hook-->", label-side: right) + edge(src, quo, $pi$, "->>") +}), + +#fletcher.diagram( + node-stroke: c, + node-fill: rgb("aafa"), + node-outset: 2pt, + node((0,0), `typst`), + node((1,0), "A"), + node((2,0), "B", stroke: c + 2pt), + node((2,1), "C", extrude: (+1, -1)), + + edge((0,0), (1,0), "->", bend: 20deg), + edge((0,0), (1,0), "<-", bend: -20deg), + edge((1,0), (2,1), "=>", corner: left), + edge((1,0), (2,0), "..>", bend: -0deg), +), + +``` + +## Todo + +- [x] Mathematical arrow styles +- [ ] Support CeTZ arrowheads +- [ ] Allow referring to node coordinates by their content +- [ ] Support loops connecting a node to itself +- [ ] More ergonomic syntax to avoid repeating coordinates? \ No newline at end of file diff --git a/packages/preview/fletcher/0.3.0/docs/examples/example-1.svg b/packages/preview/fletcher/0.3.0/docs/examples/example-1.svg new file mode 100644 index 000000000..b654eae8d --- /dev/null +++ b/packages/preview/fletcher/0.3.0/docs/examples/example-1.svg @@ -0,0 +1,362 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/preview/fletcher/0.3.0/docs/examples/example-2.svg b/packages/preview/fletcher/0.3.0/docs/examples/example-2.svg new file mode 100644 index 000000000..2b716561c --- /dev/null +++ b/packages/preview/fletcher/0.3.0/docs/examples/example-2.svg @@ -0,0 +1,362 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/preview/fletcher/0.3.0/docs/examples/example.typ b/packages/preview/fletcher/0.3.0/docs/examples/example.typ new file mode 100644 index 000000000..2c3e0e5f5 --- /dev/null +++ b/packages/preview/fletcher/0.3.0/docs/examples/example.typ @@ -0,0 +1,46 @@ +#import "/src/exports.typ" as fletcher: node, edge + +#for dark in (false, true) [ + +#let c = if dark { white } else { black } +#set page(width: 22cm, height: 9cm, margin: 1cm) + +#set text(fill: white) if dark + + +#show: scale.with(200%, origin: top + left) + +#let edge = edge.with(paint: c) + +#stack( + dir: ltr, + spacing: 1cm, + +fletcher.diagram(cell-size: 15mm, crossing-fill: none, { + let (src, img, quo) = ((0, 1), (1, 1), (0, 0)) + node(src, $G$) + node(img, $im f$) + node(quo, $G slash ker(f)$) + edge(src, img, $f$, "->") + edge(quo, img, $tilde(f)$, "hook-->", label-side: right) + edge(src, quo, $pi$, "->>") +}), + +fletcher.diagram( + node-stroke: c, + node-fill: rgb("aafa"), + node-outset: 2pt, + node((0,0), `typst`), + node((1,0), "A"), + node((2,0), "B", stroke: c + 2pt), + node((2,1), "C", extrude: (+1, -1)), + + edge((0,0), (1,0), "->", bend: 20deg), + edge((0,0), (1,0), "<-", bend: -20deg), + edge((1,0), (2,1), "=>", corner: left), + edge((1,0), (2,0), "..>", bend: -0deg), +), + +) + +] \ No newline at end of file diff --git a/packages/preview/fletcher/0.3.0/docs/manual.pdf b/packages/preview/fletcher/0.3.0/docs/manual.pdf new file mode 100644 index 000000000..d0b0e6f72 Binary files /dev/null and b/packages/preview/fletcher/0.3.0/docs/manual.pdf differ diff --git a/packages/preview/fletcher/0.3.0/docs/manual.typ b/packages/preview/fletcher/0.3.0/docs/manual.typ new file mode 100644 index 000000000..4aa673e29 --- /dev/null +++ b/packages/preview/fletcher/0.3.0/docs/manual.typ @@ -0,0 +1,564 @@ +#import "@preview/tidy:0.1.0" +#import "/src/exports.typ" as fletcher: node, edge +#import "/src/main.typ": parse-arrow-shorthand + +#set page(numbering: "1") +#set par(justify: true) +#show link: underline.with(stroke: blue.lighten(50%)) + +#let VERSION = toml("/typst.toml").package.version + +#let scope = ( + fletcher: fletcher, + diagram: fletcher.diagram, + node: node, + edge: edge, + parse-arrow-shorthand: parse-arrow-shorthand, + cetz: fletcher.cetz, +) +#let show-module(path) = { + show heading.where(level: 3): it => { + align(center, line(length: 100%, stroke: black.lighten(70%))) + block(text(1.3em, raw(it.body.text + "()"))) + } + tidy.show-module( + tidy.parse-module( + read(path), + scope: scope, + ), + show-outline: false, + ) +} + +#show raw.where(block: false): it => { + if it.text.ends-with("()") { + link(label(it.text), it.text) + } else { it } +} + +#v(.2fr) + +#align(center)[ + #fletcher.diagram( + spacing: 2.3cm, + node((0,1), $A$), + node((1,1), $B$), + edge((0,1), (1,1), $f$, ">>->"), + ) + + #text(2em, strong(`fletcher`)) \ + _(noun) a maker of arrows_ + + A #link("https://typst.app/")[Typst] package for diagrams with lots of arrows, + built on top of #link("https://github.com/johannes-wolf/cetz")[CeTZ]. + + #emph[ + Commutative diagrams, + finite state machines, + control systems block diagrams... + ] + + #link("https://github.com/Jollywatt/typst-fletcher")[`github.com/Jollywatt/typst-fletcher`] + + Version #VERSION *(not yet stable)* +] + +#v(1fr) + +#outline(indent: 1em, target: + heading.where(level: 1) + .or(heading.where(level: 2)) + .or(heading.where(level: 3)), +) + +#v(1fr) + +#pagebreak() + +#align(center)[ + +/* + #fletcher.diagram( + // node-stroke: 1pt , + node-fill: luma(90%), + node((0,0), "edge types"), + edge((0,0), (1,1), [arc], "..>", bend: +60deg), + edge((0,0), (1,0), [line], "-->"), + edge((0,0), (1,-1), [corner], "->", corner: right), + ) + + #v(1fr) + + #fletcher.diagram( + cell-size: 1cm, + node-inset: 1.5em, + spacing: 20mm, + debug: 0, + node-defocus: 0.1, + node((0,2), $pi_1(X sect Y)$), + node((0,1), $pi_1(X)$), + node((1,2), $pi_1(Y)$), + node((1,1), $pi_1(X) ast.op_(pi_1(X sect Y)) pi_1(X)$), + edge((0,2), (0,1), $i_2$, "->", extrude: (-1.5,1.5)), + edge((0,2), (1,2), $i_1$, "hook->"), + edge((1,2), (2,0), $j_2$, "<->", bend: 20deg, extrude: (-1.5,1.5)), + edge((0,1), (2,0), $j_1$, "->>", bend: -15deg, dash: "dotted"), + edge((0,1), (1,1), "hook->>", dash: "dashed"), + edge((1,2), (1,1), "|->"), + node((2,0), $pi_1(X union Y)$), + edge((1,1), (2,0), $k$, "<-->", label-sep: 0pt, paint: green, thickness: 1pt), + ) + + #v(1fr) + + // #fletcher.diagram( + // cell-size: 3cm, + // node-defocus: 0, + // node-inset: 10pt, + // { + // let cube-vertices = ((0,0,0), (0,0,1), (0,1,0), (0,1,1), (1,0,0), (1,0,1), (1,1,0), (1,1,1)) + // let proj((x, y, z)) = (x + z*(0.4 - 0.1*x), y + z*(0.4 - 0.1*y)) + // for i in range(8) { + // let to = cube-vertices.at(i) + // node(proj(to), [#to]) + // for j in range(i) { + // let from = cube-vertices.at(j) + // // test for adjancency + // if from.zip(to).map(((i, j) ) => int(i == j)).sum() == 2 { + // edge(proj(from), proj(to), "->", crossing: to.at(2) == 0) + // } + // } + // } + // edge(proj((1,1,1)), (2, 0.8), dash: "dotted") + // edge(proj((1,0,1)), (2, 0.8), dash: "dotted") + // node((2, 0.8), "fractional coords") + // }) + + // #v(1fr) + */ + + + #let c(x, y, z) = (x + 0.5*z, y + 0.4*z) + #fletcher.diagram( + spacing: 4cm, + node-defocus: 0, + { + + let v000 = c(0, 0, 0) + + node(v000, $P$) + node(c(1,0,0), $P$) + node(c(2,0,0), $X$) + node(c(0,1,0), $J P$) + node(c(1,1,0), $J P$) + node(c(2,1,0), $"CP"$) + + node(c(0,0,1), $pi^*(T X times.circle T^* X)$) + node(c(1,0,1), $pi^*(T X times.circle T^* X)$) + node(c(2,0,1), $T X times.circle T^* X$) + node(c(0,1,1), $T P times.circle pi^* T^* X$) + node(c(1,1,1), $T P times.circle pi^* T^* X$) + node(c(2,1,1), $T_G P times.circle T^* X$) + + + // aways + edge(v000, c(0,0,1), $"Id"$, "->", bend: 0deg) + edge(c(1,0,0), c(1,0,1), $"Id"$, "->") + edge(c(2,0,0), c(2,0,1), $"Id"$, "->") + + edge(c(0,1,0), c(0,1,1), $i_J$, "hook->") + edge(c(1,1,0), c(1,1,1), $i_J$, "hook->") + edge(c(2,1,0), c(2,1,1), $i_C$, "hook->") + + // downs + edge(c(0,1,0), v000, $pi_J$, "==>", label-pos: 0.2) + edge(c(1,1,0), c(1,0,0), $pi_J$, "->", label-pos: 0.2) + edge(c(2,1,0), c(2,0,0), $pi_"CP"$, "->", label-pos: 0.2) + + edge(c(0,1,1), c(0,0,1), $c_pi$, "..>", label-pos: 0.2) + edge(c(1,1,1), c(1,0,1), $c_pi$, "->", label-pos: 0.2) + edge(c(2,1,1), c(2,0,1), $overline(c)_pi$, "->", label-pos: 0.2) + + // acrosses + edge(v000, c(1,0,0), $lambda_g$, "->") + edge(c(1,0,0), c(2,0,0), $pi^G=pi$, "->") + + edge(c(0,0,1), c(1,0,1), $lambda_g times 1$, "..>", label-pos: 0.2) + edge(c(1,0,1), c(2,0,1), $pi^G$, "..>", label-pos: 0.2) + + edge(c(0,1,0), c(1,1,0), $j lambda_g$, "->", label-pos: 0.7) + + edge(c(0,1,1), c(1,1,1), $dif lambda_g times.circle (lambda_g times 1)$, "->") + edge(c(1,1,1), c(2,1,1), $pi^G$, "->") + + edge(c(1,1,1), c(2,1,1), $Ω$, "<..>", bend: 60deg) + }) + + #v(1fr) + + #fletcher.diagram( + node-defocus: 0, + spacing: (1cm, 2cm), + edge-thickness: 1pt, + crossing-thickness: 5, + mark-scale: 70%, + node-fill: luma(97%), + node-outset: 3pt, + node((0,0), "magma"), + + node((-1,-1), "semigroup"), + node(( 0,-1), "unital magma"), + node((+1,-1), "quasigroup"), + + node((-1,-2), "monoid"), + node(( 0,-2), "inverse semigroup"), + node((+1,-2), "loop"), + + node(( 0,-3), "group"), + + { + let quad(a, b, label, paint, ..args) = { + paint = paint.darken(25%) + edge(a, b, text(paint, label), "-|>", paint: paint, label-side: center, ..args) + } + + quad((0,0), (-1,-1), "Assoc", blue) + quad((0,-1), (-1,-2), "Assoc", blue, label-pos: 0.3) + quad((1,-2), (0,-3), "Assoc", blue) + + quad((0,0), (0,-1), "Id", red) + quad((-1,-1), (-1,-2), "Id", red, label-pos: 0.3) + quad((+1,-1), (+1,-2), "Id", red, label-pos: 0.3) + quad((0,-2), (0,-3), "Id", red) + + quad((0,0), (1,-1), "Div", yellow) + quad((-1,-1), (0,-2), "Div", yellow, label-pos: 0.3, "crossing") + + quad((-1,-2), (0,-3), "Inv", green) + quad((0,-1), (+1,-2), "Inv", green, label-pos: 0.3) + + quad((1,-1), (0,-2), "Assoc", blue, label-pos: 0.3, "crossing") + }, + ) + + #v(1fr) + + #{ + set text(white, font: "Fira Sans") + let colors = (maroon, olive, eastern) + fletcher.diagram( + edge-thickness: 1pt, + node((0,0), [input], fill: colors.at(0)), + edge((0,0), (1,0)), + edge((1,0), (2,+1), "-|>", corner: left), + edge((1,0), (2,-1), corner: right), + node((2,+1), [control unit (CU)], fill: colors.at(1)), + edge((2,+1), (2,0), "<|-|>"), + node((2, 0), align(center)[arithmetic & logic \ unit (ALU)], fill: colors.at(1)), + edge((2, 0), (2,-1), "<|-|>"), + node((2,-1), [memory unit (MU)], fill: colors.at(1)), + edge((2,+1), (3,0), corner: left), + edge((2,-1), (3,0), "<|-", corner: right), + edge((3,0), (4,0), "-|>"), + node((4,0), [output], fill: colors.at(2)) + ) + } + + #v(1fr) + +] + + + +#show heading.where(level: 1): it => pagebreak(weak: true) + it + v(0.5em) + += Examples + +#raw(lang: "typ", "#import \"@preview/fletcher:" + VERSION + "\" as fletcher: node, edge") + +#let code-example(src) = ( + { + set text(.85em) + box(src) // box to prevent pagebreaks + }, + eval( + src.text, + mode: "markup", + scope: scope + ), +) + +#table( + columns: (2fr, 1fr), + align: (horizon, left), + inset: 10pt, + + ..code-example(```typ + #fletcher.diagram({ + let (src, img, quo) = ((0, 1), (1, 1), (0, 0)) + node(src, $G$) + node(img, $im f$) + node(quo, $G slash ker(f)$) + edge(src, img, $f$, "->") + edge(quo, img, $tilde(f)$, "hook-->", label-side: right) + edge(src, quo, $pi$, "->>") + }) + ```), + + ..code-example(```typ + An equation $f: A -> B$ and \ + a diagram #fletcher.diagram( + node-inset: 4pt, + node((0,0), $A$), + edge((0,0), (1,0), text(0.8em, $f$), "->", label-sep: 1pt), + node((1,0), $B$), + ). + ```), + + ..code-example(```typ + #fletcher.diagram(spacing: 2cm, { + let (A, B) = ((0,0), (1,0)) + node(A, $cal(A)$) + node(B, $cal(B)$) + edge(A, B, $F$, "->", bend: +35deg) + edge(A, B, $G$, "->", bend: -35deg) + let h = 0.21 + edge((.5,+h), (.5,-h), $alpha$, "=>") + }) + ```), + + ..code-example(```typ + #fletcher.diagram( + spacing: (8mm, 3mm), // wide columns, narrow rows + node-stroke: 1pt, // outline node shapes + edge-thickness: 1pt, // thickness of lines + mark-scale: 60%, // make arrowheads smaller + edge((-2,0), (-1,0)), + edge((-1,0), (0,+1), $f$, "..|>", corner: left), + edge((-1,0), (0,-1), $g$, "-|>", corner: right), + node((0,+1), $F(s)$), + node((0,-1), $G(s)$), + edge((0,+1), (1,0), "..|>", corner: left), + edge((0,-1), (1,0), "-|>", corner: right), + node((1,0), $ + $, inset: 1pt), + edge((1,0), (2,0), "-|>"), + ) + ```), + + ..code-example(```typ + #fletcher.diagram( + node-stroke: black + 0.5pt, + node-fill: blue.lighten(90%), + node-outset: 4pt, + spacing: (15mm, 8mm), + node((0,0), [1], extrude: (4, 0)), // double stroke effect + node((1,0), [2]), + node((2,1), [3a]), + node((2,-1), [3b]), + edge((0,0), (1,0), "->"), + edge((1,0), (2,+1), "->", bend: -15deg), + edge((1,0), (2,-1), "->", bend: +15deg), + edge((2,-1), (2,-1), "->", bend: +130deg, label: [loop!]), + ) + ```) +) + + + + + +#set raw(lang: "typc") +#let fn-link(name) = link(label(name), raw(name)) + += Details + +The recommended way to load the package is: +#raw(lang: "typ", "#import \"@preview/fletcher:" + VERSION + "\" as fletcher: node, edge", block: true) +Other functions (including internal functions) are exported, so avoid importing everything with #raw(lang: none, "*") and access them as needed with, e.g., `fletcher.diagram`. + +== Nodes + +#link(label("node()"))[`node((x, y), label, ..options)`] + +Nodes are content placed in the diagram at a particular coordinate. They fit to the size of their label (with an `inset` and `outset`), can be circular or rectangular (`shape`), and can be given a `stroke` and `fill`. + +=== Elastic coordinates + +Diagrams are laid out on a flexible coordinate grid. +When a node is placed, the rows and columns grow to accommodate the node's size, like a table. +See the `diagram()` parameters for more control: `node-size` is the minimum row and column width, and `spacing` is the gutter between rows and columns, respectively. + +Elastic coordinates can be demonstrated more clearly with a debug grid and no spacing. + +#stack( + dir: ltr, + spacing: 1fr, + ..code-example(```typ + #let b(c, w, h) = box(fill: c.lighten(50%), width: w, height: h) + #fletcher.diagram( + debug: 1, + spacing: 0pt, + node-inset: 0pt, + node((0,-1), b(blue, 5mm, 10mm)), + node((1, 0), b(green, 20mm, 5mm)), + node((1, 1), b(red, 5mm, 5mm)), + node((0, 1), b(orange, 10mm, 10mm)), + ) + ```) +) + +=== Fractional coordinates + +Rows and columns are at integer coordinates, but nodes may have fractional coordinates. +These are dealt with by linearly interpolating the diagram between what it would be if the coordinates were rounded up or down. Both the node's position and its influence on row/column sizes are interpolated. + +As a result, diagrams are responsive to node sizes (like tables) while also allowing precise positioning. +// For example, see how the column sizes change as the green box moves from $(0, 0)$ to $(1, 0)$: + +#stack( + dir: ltr, + spacing: 1fr, + ..(0, .25, .5, .75, 1).map(t => { + fletcher.diagram( + debug: 1, + spacing: 0mm, + node-inset: 0pt, + node((0,-1), box(fill: blue.lighten(50%), width: 5mm, height: 10mm)), + node((t, 0), box(fill: green.lighten(50%), width: 20mm, height: 5mm, align(center + horizon, $(#t, 0)$))), + node((1, 1), box(fill: red.lighten(50%), width: 5mm, height: 5mm)), + node((0, 1), box(fill: orange.lighten(50%), width: 10mm, height: 10mm)), + ) + }), +) + + +== Edges + +#link(label("edge()"))[`edge(node-1, node-2, label, marks, ..options)`] + +Edges connect two coordinates. If there is a node at an endpoint, the edge attaches to the nodes' bounding circle or rectangle. Edges can have `label`s, can `bend` into arcs, and can have various arrow `marks`. + +#stack(dir: ltr, spacing: 1fr, ..code-example(```typ +#fletcher.diagram(spacing: (12mm, 6mm), { + let (a, b, c, abc) = ((-1,0), (0,-1), (1,0), (0,1)) + node(abc, $A times B times C$) + node(a, $A$) + node(b, $B$) + node(c, $C$) + edge(a, b, bend: -10deg, "dashed") + edge(c, b, bend: +10deg, "dotted") + edge(a, abc, $a$) + edge(b, abc, "<=>") + edge(c, abc, $c$) +}) +```)) + +=== Marks and arrows + +A few mathematical arrow heads are supported, designed to match the symbols $arrow$, $arrow.double$, $arrow.twohead$, $arrow.hook$, $arrow.bar$, etc. +See the `marks` argument of `edge()` for details. + +#align(center, fletcher.diagram( + debug: 0, + spacing: (15mm, 10mm), +{ + for (i, str) in ( + "->", + "=>", + "|->", + "hook->>", + ).enumerate() { + for j in range(2) { + edge((2*i, -j), (2*i + 1, -j), str, bend: 40deg*j, thickness: 1pt) + } + } +})) + +Most marks have some parameters like size or sharpness angle that you can customize. This isn't a stable feature, but here's something to get you started: + +#stack(dir: ltr, spacing: 1fr, ..code-example(```typ +#fletcher.diagram( + edge-thickness: 1.5pt, + spacing: (4cm, 1cm), + { + let custom-head = ( // sharper arrow head + kind: "head", + sharpness: 10deg, + size: 70, + delta: 10deg, + ) + edge((0,1), (1,1), marks: (custom-head, custom-head + (sharpness: 20deg))) + edge((0,0), (1,0), marks: ("bar", (kind: "bar", size: 2))) // smaller bar + }, +) +```)) + +=== CeTZ integration +Currently, only straight, arc and right-angled connectors are supported. +However, an escape hatch is provided with the `render` argument of `diagram()` so you can intercept diagram data and draw things using CeTZ directly. + +Here is an example of how you might hack together a Bézier connector using the same functions that `fletcher` uses internally to anchor edges to nodes and draw arrow heads: + +#stack(dir: ltr, spacing: 1fr, ..code-example(```typ +#fletcher.diagram( + node((0,0), $A$), + node((2,1), [Bézier]), + render: (grid, nodes, edges, options) => { + // cetz is also exported as fletcher.cetz + cetz.canvas({ + // this is the default code to render the diagram + fletcher.draw-diagram(grid, nodes, edges, options) + + // retrieve node data by coordinates + let n1 = fletcher.find-node-at(nodes, (0,0)) + let n2 = fletcher.find-node-at(nodes, (2,1)) + + // get anchor points for the connector + let p1 = fletcher.get-node-anchor(n1, 0deg) + let p2 = fletcher.get-node-anchor(n2, -90deg) + + // make some control points + let c1 = cetz.vector.add(p1, (20pt, 0pt)) + let c2 = cetz.vector.add(p2, (0pt, -70pt)) + + cetz.draw.bezier(p1, p2, c1, c2) + + // place an arrow head at a given point and angle + fletcher.draw-arrow-cap(p2, 90deg, 1pt + black, "twohead") + }) + } +) +```)) + + +=== The `defocus` adjustment + +For aesthetic reasons, lines connecting to a node need not focus to the node's exact center, especially if the node is short and wide or tall and narrow. +Notice the difference the figures below. "Defocusing" the connecting lines can make the diagram look more comfortable. + +#align(center, stack( + dir: ltr, + spacing: 20%, + ..(("With", 0.2), ("Without", 0)).map(((with, d)) => { + figure( + caption: [#with defocus], + fletcher.diagram( + spacing: (10mm, 9mm), + node-defocus: d, + node((0,1), $A times B times C$), + edge((-1,0), (0,1)), + edge((+1,0), (0,1)), + edge((0,-1), (0,1)), + ) + ) + }) +)) + +See the `node-defocus` argument of #link(label("diagram()"))[`diagram()`] for details. + += Function reference +#show-module("/src/main.typ") +#show-module("/src/layout.typ") +#show-module("/src/draw.typ") +#show-module("/src/marks.typ") +#show-module("/src/utils.typ") \ No newline at end of file diff --git a/packages/preview/fletcher/0.3.0/src/draw.typ b/packages/preview/fletcher/0.3.0/src/draw.typ new file mode 100644 index 000000000..9ce65e4cf --- /dev/null +++ b/packages/preview/fletcher/0.3.0/src/draw.typ @@ -0,0 +1,464 @@ +#import "@preview/cetz:0.1.2" as cetz: vector +#import "utils.typ": * +#import "marks.typ": * + +/// Get the point at which a connector should attach to a node from a given +/// angle, taking into account the node's size and shape. +/// +/// - node (dictionary): The node to connect to. +/// - θ (angle): The desired angle from the node's center to the connection +/// point. +/// -> point +#let get-node-anchor(node, θ) = { + + if node.radius < 1e-3pt { return node.real-pos } + + if node.shape == "circle" { + vector.add( + node.real-pos, + vector-polar(node.radius + node.outset, θ), + ) + + } else if node.shape == "rect" { + let origin = node.real-pos + let μ = calc.pow(node.aspect, node.defocus) + let origin-δ = ( + calc.max(0pt, node.size.at(0)/2*(1 - 1/μ))*calc.cos(θ), + calc.max(0pt, node.size.at(1)/2*(1 - μ/1))*calc.sin(θ), + ) + let crossing-line = ( + vector.add(origin, origin-δ), + vector.add(origin, vector-polar(1e3*node.radius, θ)), + ) + + intersect-rect-with-crossing-line(node.outer-rect, crossing-line) + + } else { panic(node.shape) } +} + +/// Get the points where a connector between two nodes should be drawn between, +/// taking into account the nodes' sizes and relative positions. +/// +/// - edge (dictionary): The connector whose end points should be determined. +/// - nodes (pair of dictionaries): The start and end nodes of the connector. +/// -> pair of points +#let get-edge-anchors(edge, nodes) = { + let center-center-line = nodes.map(node => node.real-pos) + + let v = vector.sub(..center-center-line) + let θ = vector-angle(v) // approximate angle of connector + + if edge.kind in ("line", "arc") { + let δ = edge.bend + let incident-angles = (θ + δ + 180deg, θ - δ) + + let points = zip(nodes, incident-angles).map(((node, θ)) => { + get-node-anchor(node, θ) + }) + + return points + } else if edge.kind == "corner" { + + zip(nodes, (θ + 180deg, θ)).map(((node, θ)) => { + get-node-anchor(node, θ) + }) + } + +} + +#let draw-edge-label(edge, label-pos, options) = { + + cetz.draw.content( + label-pos, + box( + fill: edge.crossing-fill, + inset: .2em, + radius: .2em, + stroke: if options.debug >= 2 { DEBUG_COLOR + 0.25pt }, + $ #edge.label $, + ), + anchor: if edge.label-anchor != auto { edge.label-anchor }, + ) + + if options.debug >= 2 { + cetz.draw.circle( + label-pos, + radius: edge.stroke.thickness, + stroke: none, + fill: DEBUG_COLOR, + ) + } + + +} + +// Get the arrow head adjustment for a given extrusion distance +#let cap-offsets(edge, y) = { + zip(edge.marks, (+1, -1)).map(((mark, dir)) => { + dir*cap-offset(mark, y/edge.stroke.thickness)*edge.stroke.thickness + }) +} + + +#let draw-edge-line(edge, nodes, options) = { + + // Stroke end points, before adjusting for the arrow heads + let cap-points = get-edge-anchors(edge, nodes) + let θ = vector-angle(vector.sub(..cap-points)) + + let cap-angles = (θ, θ + 180deg) + + for shift in edge.extrude { + let shifted-line-points = cap-points + .zip(cap-offsets(edge, shift)) + .map(((point, offset)) => vector.add( + point, + vector.add( + // Shift end points lengthways depending on markers + vector-polar(offset, θ), + // Shift line sideways (for double line effects, etc) + vector-polar(shift, θ + 90deg), + ) + )) + + cetz.draw.line( + ..shifted-line-points, + stroke: edge.stroke, + ) + } + + // Draw marks + for (mark, pt, θ) in zip(edge.marks, cap-points, cap-angles) { + if mark == none { continue } + draw-arrow-cap(pt, θ, edge.stroke, mark) + } + + // Draw label + if edge.label != none { + + // Choose label anchor based on connector direction + if edge.label-side == auto { + edge.label-side = if calc.abs(θ) > 90deg { left } else { right } + } + let label-dir = if edge.label-side == right { +1 } else { -1 } + + if edge.label-anchor == auto { + edge.label-anchor = angle-to-anchor(θ - label-dir*90deg) + } + + edge.label-sep = to-abs-length(edge.label-sep, options.em-size) + let label-pos = vector.add( + vector.lerp(..cap-points, edge.label-pos), + vector-polar(edge.label-sep, θ + label-dir*90deg), + ) + draw-edge-label(edge, label-pos, options) + } + + +} + +#let draw-edge-arc(edge, nodes, options) = { + + // Stroke end points, before adjusting for the arrow heads + let cap-points = get-edge-anchors(edge, nodes) + let θ = vector-angle(vector.sub(..cap-points)) + + let (center, radius, start, stop) = get-arc-connecting-points(..cap-points, edge.bend) + + let bend-dir = if edge.bend > 0deg { +1 } else { -1 } + let δ = bend-dir*90deg + let cap-angles = (start + δ, stop - δ) + + + for shift in edge.extrude { + let (start, stop) = (start, stop) + .zip(cap-offsets(edge, shift)) + .map(((θ, arclen)) => θ + bend-dir*arclen/radius*1rad) + + cetz.draw.arc( + center, + radius: radius + shift, + start: start, + stop: stop, + anchor: "center", + stroke: edge.stroke, + ) + } + + + // Draw marks + for (mark, pt, θ) in zip(edge.marks, cap-points, cap-angles) { + if mark == none { continue } + draw-arrow-cap(pt, θ, edge.stroke, mark) + } + + // Draw label + if edge.label != none { + + if edge.label-side == auto { + edge.label-side = if edge.bend > 0deg { left } else { right } + } + let label-dir = if edge.label-side == left { +1 } else { -1 } + + if edge.label-anchor == auto { + // Choose label anchor based on connector direction + edge.label-anchor = angle-to-anchor(θ + label-dir*90deg) + } + + edge.label-sep = to-abs-length(edge.label-sep, options.em-size) + let label-pos = vector.add( + center, + vector-polar( + radius + label-dir*bend-dir*edge.label-sep, + lerp(start, stop, edge.label-pos), + ) + ) + + draw-edge-label(edge, label-pos, options) + } + + + + if options.debug >= 3 { + for (cell, point) in zip(nodes, cap-points) { + cetz.draw.line( + cell.real-pos, + point, + stroke: DEBUG_COLOR + 0.1pt, + ) + } + } + + +} + + +#let draw-edge-corner(edge, nodes, options) = { + + let θ = vector-angle(vector.sub(..edge.points)) + + let θ-floor = calc.floor(θ/90deg)*90deg + let θ-ceil = calc.ceil(θ/90deg)*90deg + + // angles that arrow heads would point + let cap-angles = if edge.corner == left { + (θ-ceil, θ-floor + 180deg) + } else if edge.corner == right { + (θ-floor, θ-ceil + 180deg) + } + + let cap-points = zip(nodes, cap-angles).map(((node, φ)) => { + // todo: defocus? + get-node-anchor(node, lerp(φ, θ, 0) + 180deg) + }) + + + let i = if edge.corner == left { 1 } else { 0 } + let corner = if calc.even(calc.floor(θ/90deg) + i) { + (cap-points.at(1).at(0), cap-points.at(0).at(1)) + } else { + (cap-points.at(0).at(0), cap-points.at(1).at(1)) + } + + + let verts = ( + cap-points.at(0), + corner, + cap-points.at(1), + ) + + // Compute the three points of the right angle, + // taking into account extrusions and mark offsets + let get-vertices(shift) = { + let (a, b) = cap-angles.zip((-1, +1)).map(((θ, dir)) => { + vector-polar(shift, θ + dir*90deg) + }) + + // apply extrusions + let verts = verts.zip((a, vector.add(a, b), b)) + .map(((v, o)) => vector.add(v, o)) + + // apply mark offsets + + let offsets = zip(cap-offsets(edge, shift), cap-angles).map(((o, θ)) => { + vector-polar(o, θ) + }) + + ( + vector.add(verts.at(0), offsets.at(0)), + verts.at(1), + vector.sub(verts.at(2), offsets.at(1)), + ) + + } + + for shift in edge.extrude { + cetz.draw.line( + ..get-vertices(shift), + stroke: edge.stroke, + ) + } + + // Draw marks + for (mark, pt, θ) in zip(edge.marks, cap-points, cap-angles) { + if mark == none { continue } + draw-arrow-cap(pt, θ, edge.stroke, mark) + } + + // Draw label + if edge.label != none { + + if edge.label-side == auto { edge.label-side = edge.corner } + let label-dir = if edge.label-side == left { +1 } else { -1 } + + if edge.label-anchor == auto { + // Choose label anchor based on connector direction + edge.label-anchor = angle-to-anchor(θ + label-dir*90deg) + } + + let v = get-vertices(label-dir*edge.label-sep) + let label-pos = zip(..v).map(coords => { + lerp-at(coords, 2*edge.label-pos) + }) + + draw-edge-label(edge, label-pos, options) + + } + +} + +#let draw-edge(edge, nodes, options) = { + if edge.kind == "line" { draw-edge-line(edge, nodes, options) } + else if edge.kind == "arc" { draw-edge-arc(edge, nodes, options) } + else if edge.kind == "corner" { draw-edge-corner(edge, nodes, options) } + else { panic(edge.kind) } +} + + +#let draw-node(node, options) = { + if node.label == none { return } + + if node.stroke != none or node.fill != none { + + for (i, offset) in node.extrude.enumerate() { + let fill = if i == 0 { node.fill } else { none } + + if node.shape == "rect" { + let radii = vector.scale(node.size, 0.5).map(x => x + offset) + cetz.draw.rect( + ..rect-at(node.real-pos, radii), + stroke: node.stroke, + fill: fill, + ) + } + if node.shape == "circle" { + cetz.draw.circle( + node.real-pos, + radius: node.radius + offset, + stroke: node.stroke, + fill: fill, + ) + } + } + } + + cetz.draw.content(node.real-pos, node.label, anchor: "center") + + if options.debug >= 1 { + cetz.draw.circle( + node.real-pos, + radius: 1pt, + fill: DEBUG_COLOR, + stroke: none, + ) + } + + if options.debug >= 2 { + if options.debug >= 3 or node.shape == "rect" { + cetz.draw.rect( + ..node.rect, + stroke: DEBUG_COLOR + 0.25pt, + ) + } + if options.debug >= 3 or node.shape == "circle" { + cetz.draw.circle( + node.real-pos, + radius: node.radius, + stroke: DEBUG_COLOR + 0.25pt, + ) + } + } +} + + + +#let find-node-at(nodes, pos) = { + nodes.filter(node => node.pos == pos) + .sorted(key: node => node.radius).last() +} + +#let draw-diagram( + grid, + nodes, + arrows, + options, +) = { + + for node in nodes { + draw-node(node, options) + } + + for arrow in arrows { + let nodes = arrow.points.map(find-node-at.with(nodes)) + + let intersection-stroke = if options.debug >= 2 { + (paint: DEBUG_COLOR, thickness: 0.25pt) + } + + draw-edge(arrow, nodes, options) + } + + // draw axes + if options.debug >= 1 { + + cetz.draw.rect( + (0,0), + grid.bounding-size, + stroke: DEBUG_COLOR + 0.25pt + ) + + for (axis, coord) in ((0, (x,y) => (x,y)), (1, (y,x) => (x,y))) { + + for (i, x) in grid.centers.at(axis).enumerate() { + let size = grid.sizes.at(axis).at(i) + + // coordinate label + cetz.draw.content( + coord(x, -.4em), + text(fill: DEBUG_COLOR, size: .75em)[#(grid.origin.at(axis) + i)], + anchor: if axis == 0 { "top" } else { "right" } + ) + + // size bracket + cetz.draw.line( + ..(+1, -1).map(dir => coord(x + dir*max(size, 1e-6pt)/2, 0)), + stroke: DEBUG_COLOR + .75pt, + mark: (start: "|", end: "|") + ) + + // gridline + cetz.draw.line( + coord(x, 0), + coord(x, grid.bounding-size.at(1 - axis)), + stroke: ( + paint: DEBUG_COLOR, + thickness: .3pt, + dash: "densely-dotted", + ), + ) + } + } + } +} + + diff --git a/packages/preview/fletcher/0.3.0/src/exports.typ b/packages/preview/fletcher/0.3.0/src/exports.typ new file mode 100644 index 000000000..267bfebf4 --- /dev/null +++ b/packages/preview/fletcher/0.3.0/src/exports.typ @@ -0,0 +1,7 @@ +#import "@preview/cetz:0.1.2" as cetz + +#import "marks.typ": * +#import "draw.typ": * +#import "layout.typ": * +#import "main.typ": * +#import "utils.typ" \ No newline at end of file diff --git a/packages/preview/fletcher/0.3.0/src/layout.typ b/packages/preview/fletcher/0.3.0/src/layout.typ new file mode 100644 index 000000000..ca5e69635 --- /dev/null +++ b/packages/preview/fletcher/0.3.0/src/layout.typ @@ -0,0 +1,138 @@ +#import "utils.typ": * + + +#let compute-node-sizes(nodes, styles) = nodes.map(node => { + + // Determine physical size of node content + let (width, height) = measure(node.label, styles) + node.size = (width, height) + node.radius = vector-len((width/2, height/2)) + node.aspect = if height == 0pt { 1 } else { width/height } + + if node.shape == auto { + let is-roundish = max(node.aspect, 1/node.aspect) < 1.5 + node.shape = if is-roundish { "circle" } else { "rect" } + } + + // add node inset + if node.radius != 0pt { + if node.shape == "circle" { + node.radius += node.inset/2 + } else { + node.size = node.size.map(x => x + node.inset) + } + } + + node +}) + +#let to-physical-coords(grid, coord) = { + zip(grid.centers, coord, grid.origin) + .map(((c, x, o)) => lerp-at(c, x - o)) +} + +#let compute-node-positions(nodes, grid, options) = nodes.map(node => { + + node.real-pos = to-physical-coords(grid, node.pos) + + node.rect = (-1, +1).map(dir => vector.add( + node.real-pos, + vector.scale(node.size, dir/2), + )) + + node.outer-rect = rect-at( + node.real-pos, + node.size.map(x => x/2 + node.outset), + ) + + node + +}) + + +/// Convert an array of rects with fractional positions into rects with integral +/// positions. +/// +/// If a rect is centered at a factional position `floor(x) < x < ceil(x)`, it +/// will be replaced by two new rects centered at `floor(x)` and `ceil(x)`. The +/// total width of the original rect is split across the two new rects according +/// two which one is closer. (E.g., if the original rect is at `x = 0.25`, the +/// new rect at `x = 0` has 75% the original width and the rect at `x = 1` has +/// 25%.) The same splitting procedure is done for `y` positions and heights. +/// +/// - rects (array of rects): An array of rectangles of the form +/// `(pos: (x, y), size: (width, height))`. The coordinates `x` and `y` may be +/// floats. +/// -> array of rects +#let expand-fractional-rects(rects) = { + let new-rects + for axis in (0, 1) { + new-rects = () + for rect in rects { + let coord = rect.pos.at(axis) + let size = rect.size.at(axis) + + if calc.fract(coord) == 0 { + rect.pos.at(axis) = calc.trunc(coord) + new-rects.push(rect) + } else { + rect.pos.at(axis) = floor(coord) + rect.size.at(axis) = size*(ceil(coord) - coord) + new-rects.push(rect) + + rect.pos.at(axis) = ceil(coord) + rect.size.at(axis) = size*(coord - floor(coord)) + new-rects.push(rect) + } + } + rects = new-rects + } + new-rects +} + +/// Determine the number, sizes and positions of rows and columns. +#let compute-grid(nodes, options) = { + let rects = nodes.map(node => (pos: node.pos, size: node.size)) + rects = expand-fractional-rects(rects) + + // (x: (x-min, x-max), y: ...) + let bounding-rect = zip((0, 0), ..rects.map(n => n.pos)).map(min-max) + let bounding-dims = bounding-rect.map(((min, max)) => max - min + 1) + let origin = bounding-rect.map(((min, max)) => min) + + // Initialise row and column sizes to minimum size + let cell-sizes = zip(options.cell-size, bounding-dims) + .map(((min-size, n)) => range(n).map(_ => min-size)) + + // Expand cells to fit rects + for rect in rects { + let indices = vector.sub(rect.pos, origin) + for axis in (0, 1) { + cell-sizes.at(axis).at(indices.at(axis)) = max( + cell-sizes.at(axis).at(indices.at(axis)), + rect.size.at(axis), + ) + + } + } + + // (x: (c1x, c2x, ...), y: ...) + let cell-centers = zip(cell-sizes, options.spacing) + .map(((sizes, spacing)) => { + zip(cumsum(sizes), sizes, range(sizes.len())) + .map(((end, size, i)) => end - size/2 + spacing*i) + }) + + let total-size = cell-centers.zip(cell-sizes).map(((centers, sizes)) => { + centers.at(-1) + sizes.at(-1)/2 + }) + + ( + centers: cell-centers, + sizes: cell-sizes, + origin: origin, + bounding-size: total-size + ) +} + + diff --git a/packages/preview/fletcher/0.3.0/src/main.typ b/packages/preview/fletcher/0.3.0/src/main.typ new file mode 100644 index 000000000..6baf9e58f --- /dev/null +++ b/packages/preview/fletcher/0.3.0/src/main.typ @@ -0,0 +1,625 @@ +#import calc: floor, ceil, min, max +#import "utils.typ": * +#import "layout.typ": * +#import "draw.typ": * +#import "marks.typ": * + +/// Draw a labelled node in an arrow diagram. +/// +/// - pos (point): Dimensionless "elastic coordinates" `(x, y)` of the node, +/// where `x` is the column and `y` is the row (increasing upwards). The +/// coordinates are usually integers, but can be fractional. +/// +/// See the `diagram()` options to control the physical scale of elastic +/// coordinates. +/// +/// - label (content): Node content to display. +/// - inset (length, auto): Padding between the node's content and its bounding +/// box or bounding circle. If `auto`, defaults to the `node-inset` option of +/// `diagram()`. +/// - outset (length, auto): Margin between the node's bounds to the anchor +/// points for connecting edges. +/// - shape (string, auto): Shape of the node, one of `"rect"` or `"circle"`. If +/// `auto`, shape is automatically chosen depending on the aspect ratio of the +/// node's label. +/// - stroke (stroke): Stroke of the node. Defaults to the `node-stroke` option +/// of `diagram()`. +/// - fill (paint): Fill of the node. Defaults to the `node-fill` option of +/// `diagram()`. +/// - defocus (number): Strength of the "defocus" adjustment for connectors +/// incident with this node. If `auto`, defaults to the `node-defocus` option +/// of `diagram()` . +/// - extrude (array): Draw strokes around the node at the given offsets to +/// obtain a multi-stroke effect. Offsets may be numbers (specifying multiples +/// of the stroke's thickness) or lengths. +/// +/// The node's fill is drawn within the boundary defined by the first offset in +/// the array. +/// +/// #fletcher.diagram( +/// node-stroke: 1pt, +/// node-fill: red.lighten(70%), +/// node((0,0), `(0,)`), +/// node((1,0), `(0, 2)`, extrude: (0, 2)), +/// node((2,0), `(2, 0)`, extrude: (2, 0)), +/// node((3,0), `(0, -2.5, 2mm)`, extrude: (0, -2.5, 2mm)), +/// ) +/// +/// See also the `extrude` option of `edge()`. +#let node( + pos, + label, + inset: auto, + outset: auto, + shape: auto, + stroke: auto, + fill: auto, + defocus: auto, + extrude: (0,), +) = { + assert(type(pos) == array and pos.len() == 2) + + if type(label) == content and label.func() == circle { panic(label) } + (( + class: "node", + pos: pos, + label: label, + inset: inset, + outset: outset, + shape: shape, + stroke: stroke, + fill: fill, + defocus: defocus, + extrude: extrude, + ),) +} + + + +#let interpret-edge-args(args) = { + let named-args = (:) + + if args.named().len() > 0 { + panic("Unexpected named argument(s):", args) + } + + let pos = args.pos() + + // interpret first non-string argument as the label + if pos.len() >= 1 and type(pos.at(0)) != str { + named-args.label = pos.remove(0) + } + + // interpret a string that's not an argument shorthand as + // a marks/arrowhead shorthand + if (pos.len() >= 1 and type(pos.at(0)) == str and + pos.at(0) not in EDGE_ARGUMENT_SHORTHANDS) { + named-args.marks = pos.remove(0) + } + + for arg in pos { + if type(arg) == str and arg in EDGE_ARGUMENT_SHORTHANDS { + named-args += EDGE_ARGUMENT_SHORTHANDS.at(arg) + } else { + panic( + "Unrecognised argument " + repr(arg) + ". Must be one of:", + EDGE_ARGUMENT_SHORTHANDS.keys(), + ) + } + } + + named-args + +} + +/// Draw a connecting line or arc in an arrow diagram. +/// +/// - from (elastic coord): Start coordinate `(x, y)` of connector. If there is +/// a node at that point, the connector is adjusted to begin at the node's +/// bounding rectangle/circle. +/// - to (elastic coord): End coordinate `(x, y)` of connector. If there is a +/// node at that point, the connector is adjusted to end at the node's bounding +/// rectangle/circle. +/// +/// - ..args (any): The connector's `label` and `marks` named arguments can also +/// be specified as positional arguments. For example, the following are equivalent: +/// ```typc +/// edge((0,0), (1,0), $f$, "->") +/// edge((0,0), (1,0), $f$, marks: "->") +/// edge((0,0), (1,0), "->", label: $f$) +/// edge((0,0), (1,0), label: $f$, marks: "->") +/// ``` +/// +/// - label-pos (number): Position of the label along the connector, from the +/// start to end (from `0` to `1`). +/// +/// #stack( +/// dir: ltr, +/// spacing: 1fr, +/// ..(0, 0.25, 0.5, 0.75, 1).map(p => fletcher.diagram( +/// cell-size: 1cm, +/// edge((0,0), (1,0), p, "->", label-pos: p)) +/// ), +/// ) +/// - label-sep (number): Separation between the connector and the label anchor. +/// +/// With the default anchor (`"bottom"`): +/// +/// #fletcher.diagram( +/// debug: 2, +/// cell-size: 8mm, +/// { +/// for (i, s) in (-5pt, 0pt, .4em, .8em).enumerate() { +/// edge((2*i,0), (2*i + 1,0), s, "->", label-sep: s) +/// } +/// }) +/// +/// With `label-anchor: "center"`: +/// +/// #fletcher.diagram( +/// debug: 2, +/// cell-size: 8mm, +/// { +/// for (i, s) in (-5pt, 0pt, .4em, .8em).enumerate() { +/// edge((2*i,0), (2*i + 1,0), s, "->", label-sep: s, label-anchor: "center") +/// } +/// }) +/// +/// - label (content): Content for connector label. See `label-side` to control +/// the position (and `label-sep`, `label-pos` and `label-anchor` for finer +/// control). +/// +/// - label-side (left, right, center): Which side of the connector to place the +/// label on, viewed as you walk along it. If `center`, then the label is place +/// over the connector. When `auto`, a value of `left` or `right` is chosen to +/// automatically so that the label is +/// - roughly above the connector, in the case of straight lines; or +/// - on the outside of the curve, in the case of arcs. +/// +/// - label-anchor (anchor): The anchor point to place the label at, such as +/// `"top-right"`, `"center"`, `"bottom"`, etc. If `auto`, the anchor is +/// automatically chosen based on `label-side` and the angle of the connector. +/// +/// - paint (paint): Paint (color or gradient) of the connector stroke. +/// - thickness (length): Thickness the connector stroke. Marks (arrow heads) +/// scale with this thickness. +/// - dash (dash type): Dash style for the connector stroke. +/// - bend (angle): Curvature of the connector. If `0deg`, the connector is a +/// straight line; positive angles bend clockwise. +/// +/// #fletcher.diagram(debug: 0, { +/// node((0,0), $A$) +/// node((1,1), $B$) +/// let N = 4 +/// range(N + 1) +/// .map(x => (x/N - 0.5)*2*100deg) +/// .map(θ => edge((0,0), (1,1), θ, bend: θ, ">->", label-side: center)) +/// .join() +/// }) +/// +/// - marks (pair of strings): +/// The start and end marks or arrow heads of the connector. A shorthand such as +/// `"->"` can used instead. For example, +/// `edge(p1, p2, "->")` is short for `edge(p1, p2, marks: (none, "head"))`. +/// +/// #table( +/// columns: 3, +/// align: horizon, +/// [Arrow], [Shorthand], [Arguments], +/// ..( +/// "-", +/// "--", +/// "..", +/// "->", +/// "<=>", +/// ">>-->", +/// "|..|", +/// "hook->>", +/// "hook'->>", +/// ">-harpoon", +/// ">-harpoon'", +/// ).map(str => ( +/// fletcher.diagram(edge((0,0), (1,0), str)), +/// raw(str, lang: none), +/// raw(repr(parse-arrow-shorthand(str))), +/// )).join() +/// ) +/// +/// - mark-scale (percent): +/// Scale factor for connector marks or arrow heads. This defaults to `100%` for +/// single lines, `120%` for double lines and `150%` for triple lines. Does not +/// affect the stroke thickness of the mark. +/// +/// #{ +/// set raw(lang: none) +/// fletcher.diagram( +/// edge-thickness: 1pt, +/// edge((0,0), (1,0), `->`, "->"), +/// edge((2,0), (3,0), `=>`, "=>"), +/// edge((4,0), (5,0), `==>`, "==>"), +/// ) +/// } +/// +/// - extrude (array): Draw a separate stroke for each extrusion offset to +/// obtain a multi-stroke effect. Offsets may be numbers (specifying multiples +/// of the stroke's thickness) or lengths. +/// +/// #fletcher.diagram({ +/// ( +/// (0,), +/// (-1.5,+1.5), +/// (-2,0,+2), +/// (-4.5,), +/// (4.5,), +/// ).enumerate().map(((i, e)) => { +/// edge( +/// (2*i, 0), (2*i + 1, 0), [#e], "|->", +/// extrude: e, thickness: 1pt, label-sep: 1em) +/// }).join() +/// }) +/// +/// Notice how the ends of the line need to shift a little depending on the +/// mark. For basic arrow heads, this offset is computed with +/// `round-arrow-cap-offset()`. +/// +/// - crossing (bool): If `true`, draws a white backdrop to give the illusion of +/// lines crossing each other. +/// +/// #fletcher.diagram({ +/// edge((0,1), (1,0), thickness: 1pt) +/// edge((0,0), (1,1), thickness: 1pt) +/// edge((2,1), (3,0), thickness: 1pt) +/// edge((2,0), (3,1), thickness: 1pt, crossing: true) +/// }) +/// +/// - crossing-thickness (number): Thickness of the white "crossing" background +/// stroke, if `crossing: true`, in multiples of the normal stroke's thickness. +/// Defaults to the `crossing-thickness` option of `diagram()`. +/// +/// #fletcher.diagram({ +/// (1, 2, 5, 8, 12).enumerate().map(((i, x)) => { +/// edge((2*i, 1), (2*i + 1, 0), thickness: 1pt, label-sep: 1em) +/// edge((2*i, 0), (2*i + 1, 1), raw(str(x)), thickness: 1pt, label-sep: +/// 1em, crossing: true, crossing-thickness: x) +/// }).join() +/// }) +/// +/// - crossing-fill (paint): Color to use behind connectors or labels to give the illusion of crossing over other objects. Defaults to the `crossing-fill` option of +/// `diagram()`. +/// +/// #let cross(x, fill) = { +/// edge((2*x + 0,1), (2*x + 1,0), thickness: 1pt) +/// edge((2*x + 0,0), (2*x + 1,1), $f$, thickness: 1pt, crossing: true, crossing-fill: fill) +/// } +/// #fletcher.diagram(crossing-thickness: 5, { +/// cross(0, white) +/// cross(1, blue.lighten(50%)) +/// cross(2, luma(98%)) +/// }) +/// +#let edge( + from, + to, + ..args, + label: none, + label-side: auto, + label-pos: 0.5, + label-sep: auto, + label-anchor: auto, + paint: black, + thickness: auto, + dash: none, + kind: auto, + bend: 0deg, + corner: none, + marks: (none, none), + mark-scale: 100%, + extrude: (0,), + crossing: false, + crossing-thickness: auto, + crossing-fill: auto, +) = { + + let options = ( + label: label, + label-pos: label-pos, + label-sep: label-sep, + label-anchor: label-anchor, + label-side: label-side, + paint: paint, + thickness: thickness, + dash: dash, + kind: kind, + bend: bend, + corner: corner, + marks: marks, + mark-scale: mark-scale, + extrude: extrude, + crossing: crossing, + crossing-thickness: crossing-thickness, + crossing-fill: crossing-fill, + ) + options += interpret-edge-args(args) + + if type(options.marks) == str { + options += parse-arrow-shorthand(options.marks) + } + + options.marks = options.marks.map(interpret-mark) + + + let stroke = ( + paint: options.paint, + cap: "round", + thickness: options.thickness, + dash: options.dash, + ) + + if options.label-side == center { + options.label-anchor = "center" + options.label-sep = 0pt + } + + let obj = ( + class: "edge", + points: (from, to), + label: options.label, + label-pos: options.label-pos, + label-sep: options.label-sep, + label-anchor: options.label-anchor, + label-side: options.label-side, + paint: options.paint, + kind: options.kind, + bend: options.bend, + corner: options.corner, + stroke: stroke, + marks: options.marks, + mark-scale: options.mark-scale, + extrude: options.extrude, + is-crossing-background: false, + crossing-thickness: crossing-thickness, + crossing-fill: crossing-fill, + ) + + // add empty nodes at terminal points + node(from, none) + node(to, none) + + if options.crossing { + (( + ..obj, + is-crossing-background: true + ),) + } + + (obj,) +} + + +#let apply-defaults(nodes, edges, options) = { + let to-pt(len) = len.abs + len.em*options.em-size + + ( + nodes: nodes.map(node => { + if node.stroke == auto {node.stroke = options.node-stroke } + if node.fill == auto { node.fill = options.node-fill } + if node.inset == auto { node.inset = options.node-inset } + if node.outset == auto { node.outset = options.node-outset } + if node.defocus == auto { node.defocus = options.node-defocus } + + let real-stroke-thickness = if type(node.stroke) == stroke { + node.stroke.thickness + } else if type(node.stroke) == length { + node.stroke + } else { 1pt } + + node.extrude = node.extrude.map(d => { + if type(d) == length { to-pt(d) } + else { d*real-stroke-thickness } + }) + + node.inset = to-pt(node.inset) + node.outset = to-pt(node.outset) + + node + }), + + edges: edges.map(edge => { + if edge.stroke.thickness == auto { edge.stroke.thickness = options.edge-thickness } + if edge.crossing-fill == auto { edge.crossing-fill = options.crossing-fill } + if edge.crossing-thickness == auto { edge.crossing-thickness = options.crossing-thickness } + if edge.label-sep == auto { edge.label-sep = options.label-sep } + + if edge.is-crossing-background { + edge.stroke = ( + thickness: edge.crossing-thickness*edge.stroke.thickness, + paint: edge.crossing-fill, + cap: "round", + ) + edge.marks = (none, none) + edge.extrude = edge.extrude.map(e => e/edge.crossing-thickness) + } + + if edge.kind == auto { + if edge.corner != none { edge.kind = "corner" } + else if edge.bend != 0deg { edge.kind = "arc" } + else { edge.kind = "line" } + } + + + edge.mark-scale *= options.mark-scale + + edge.marks = edge.marks.map(mark => { + if mark != none { + mark.size *= edge.mark-scale/100% + } + mark + }) + + edge.stroke.thickness = to-pt(edge.stroke.thickness) + edge.label-sep = to-pt(edge.label-sep) + + edge.extrude = edge.extrude.map(d => { + if type(d) == length { to-pt(d) } + else { d*edge.stroke.thickness } + }) + + for d in edge.extrude { + if type(d) != length { panic(edge) } + } + + edge + }), + ) +} + + +/// Draw an arrow diagram. +/// +/// - ..objects (array): An array of dictionaries specifying the diagram's +/// nodes and connections. +/// +/// The results of `node()` and `edge()` can be joined, meaning you can specify +/// them as separate arguments, or in a block: +/// +/// ```typ +/// #fletcher.diagram( +/// // one object per argument +/// node((0, 0), $A$), +/// node((1, 0), $B$), +/// { +/// // multiple objects in a block +/// // can use scripting, loops, etc +/// node((2, 0), $C$) +/// node((3, 0), $D$) +/// }, +/// ) +/// ``` +/// +/// - debug (bool, 1, 2, 3): Level of detail for drawing debug information. +/// Level `1` shows a coordinate grid; higher levels show bounding boxes and +/// anchors, etc. +/// +/// - spacing (length, pair of lengths): Gaps between rows and columns. Ensures +/// that nodes at adjacent grid points are at least this far apart (measured as +/// the space between their bounding boxes). +/// +/// Separate horizontal/vertical gutters can be specified with `(x, y)`. A +/// single length `d` is short for `(d, d)`. +/// +/// - cell-size (length, pair of lengths): Minimum size of all rows and columns. +/// +/// - node-inset (length, pair of lengths): Default padding between a node's +/// content and its bounding box. +/// - node-outset (length, pair of lengths): Default padding between a node's +/// boundary and where edges terminate. +/// - node-stroke (stroke): Default stroke for all nodes in diagram. Overridden +/// by individual node options. +/// - node-fill (paint): Default fill for all nodes in diagram. Overridden by +/// individual node options. +/// +/// - node-defocus (number): Default strength of the "defocus" adjustment for +/// nodes. This affects how connectors attach to non-square nodes. If +/// `0`, the adjustment is disabled and connectors are always directed at the +/// node's exact center. +/// +/// #stack( +/// dir: ltr, +/// spacing: 1fr, +/// ..(0.2, 0, -1).enumerate().map(((i, defocus)) => { +/// fletcher.diagram(spacing: 8mm, { +/// node((i, 0), raw("defocus: "+str(defocus)), stroke: black, defocus: defocus) +/// for y in (-1, +1) { +/// edge((i - 1, y), (i, 0)) +/// edge((i, y), (i, 0)) +/// edge((i + 1, y), (i, 0)) +/// } +/// }) +/// }) +/// ) +/// +/// - crossing-fill (paint): Color to use behind connectors or labels to give +/// the illusion of crossing over other objects. See the `crossing-fill` option +/// of `edge()`. +/// +/// - crossing-thickness (number): Default thickness of the occlusion made by +/// crossing connectors. See the `crossing-thickness` option of `edge()`. +/// +/// +/// - render (function): After the node sizes and grid layout have been +/// determined, the `render` function is called with the following arguments: +/// - `grid`: a dictionary of the row and column widths and positions; +/// - `nodes`: an array of nodes (dictionaries) with computed attributes +/// (including size and physical coordinates); +/// - `edges`: an array of connectors (dictionaries) in the diagram; and +/// - `options`: other diagram attributes. +/// +/// This callback is exposed so you can access the above data and draw things +/// directly with CeTZ. +#let diagram( + ..objects, + debug: false, + spacing: 3em, + cell-size: 0pt, + node-inset: 12pt, + node-outset: 0pt, + node-stroke: none, + node-fill: none, + node-defocus: 0.2, + label-sep: 0.2em, + edge-thickness: 0.048em, + mark-scale: 100%, + crossing-fill: white, + crossing-thickness: 5, + render: (grid, nodes, edges, options) => { + cetz.canvas(draw-diagram(grid, nodes, edges, options)) + }, +) = { + + if type(spacing) != array { spacing = (spacing, spacing) } + if type(cell-size) != array { cell-size = (cell-size, cell-size) } + + if objects.named().len() > 0 { + let args = objects.named().keys().join(", ") + panic("Unexpected named argument(s): " + args) + } + + let options = ( + spacing: spacing, + debug: int(debug), + node-inset: node-inset, + node-outset: node-outset, + node-stroke: node-stroke, + node-fill: node-fill, + node-defocus: node-defocus, + label-sep: label-sep, + cell-size: cell-size, + edge-thickness: edge-thickness, + mark-scale: mark-scale, + crossing-fill: crossing-fill, + crossing-thickness: crossing-thickness, + ) + + let positional-args = objects.pos().join() + let nodes = positional-args.filter(e => e.class == "node") + let edges = positional-args.filter(e => e.class == "edge") + + box(style(styles => { + + let options = options + options.em-size = measure(box(width: 1em), styles).width + let to-pt(len) = len.abs + len.em*options.em-size + options.spacing = options.spacing.map(to-pt) + options.node-inset = to-pt(options.node-inset) + options.label-sep = to-pt(options.label-sep) + + let (nodes, edges) = apply-defaults(nodes, edges, options) + + let nodes = compute-node-sizes(nodes, styles) + let grid = compute-grid(nodes, options) + let nodes = compute-node-positions(nodes, grid, options) + + render(grid, nodes, edges, options) + })) +} + diff --git a/packages/preview/fletcher/0.3.0/src/marks.typ b/packages/preview/fletcher/0.3.0/src/marks.typ new file mode 100644 index 000000000..7abad6218 --- /dev/null +++ b/packages/preview/fletcher/0.3.0/src/marks.typ @@ -0,0 +1,251 @@ +#import "@preview/cetz:0.1.2" +#import "utils.typ": * +#import calc: sqrt, abs, sin, cos, max, pow + + +#let EDGE_ARGUMENT_SHORTHANDS = ( + "dashed": (dash: "dashed"), + "dotted": (dash: "dotted"), + "double": (extrude: (-2, +2), mark-scale: 110%, mark-variant: 2), + "triple": (extrude: (-4, 0, +4), mark-scale: 150%, mark-variant: 3), + "crossing": (crossing: true), +) + + +#let parse-arrow-shorthand(str) = { + let caps = ( + "": (none, none), + ">": ("tail", "head"), + ">>": ("twotail", "twohead"), + "<": ("head", "tail"), + "<<": ("twohead", "twotail"), + "|>": ("solidtail", "solidhead"), + "<|": ("solidhead", "solidtail"), + "|": ("bar", "bar"), + "||": ("twobar", "twobar"), + "o": ("circle", "circle"), + "O": ("bigcircle", "bigcircle"), + ) + let lines = ( + "-": (:), + "=": EDGE_ARGUMENT_SHORTHANDS.double, + "==": EDGE_ARGUMENT_SHORTHANDS.triple, + "--": EDGE_ARGUMENT_SHORTHANDS.dashed, + "..": EDGE_ARGUMENT_SHORTHANDS.dotted, + ) + + let cap-selector = "(|<|>|<<|>>|hook[s']?|harpoon'?|\|\|?|o|O|<\||\|>)" + let line-selector = "(-|=|--|==|::|\.\.)" + let match = str.match(regex("^" + cap-selector + line-selector + cap-selector + "$")) + if match == none { + panic("Failed to parse '" + str + "' as a edge style shorthand.") + } + let (from, line, to) = match.captures + + from = if from in caps { caps.at(from).at(0) } else { from } + to = if to in caps { caps.at(to).at(1) } else { to } + + if line == "=" { + // make arrows slightly larger, suited for double stroked line + if from == "head" { from = "doublehead" } + if to == "head" { to = "doublehead" } + } else if line == "==" { + if from == "head" { from = "triplehead" } + if to == "head" { to = "triplehead" } + } + + ( + marks: (from, to), + ..lines.at(line), + ) +} + + + + +/// Take a string or dictionary specifying a mark and return a dictionary, +/// adding defaults for any necessary missing parameters. +#let interpret-mark(mark) = { + if mark == none { return none } + + if type(mark) == str { + mark = (kind: mark) + } + + mark.flip = mark.at("flip", default: +1) + if mark.kind.at(-1) == "'" { + mark.flip = -mark.flip + mark.kind = mark.kind.slice(0, -1) + } + + let round-style = ( + size: 7, // radius of curvature, multiples of stroke thickness + sharpness: 24deg, // angle at vertex between central line and arrow's edge + delta: 54deg, // angle spanned by arc of curved arrow edge + ) + + + if mark.kind in ("head", "harpoon", "tail") { + round-style + mark + } else if mark.kind == "twohead" { + round-style + mark + (kind: "head", extrude: (-3, 0)) + } else if mark.kind == "twotail" { + round-style + mark + (kind: "tail", extrude: (-3, 0)) + } else if mark.kind == "twobar" { + (size: 4.5) + mark + (kind: "bar", extrude: (-3, 0)) + } else if mark.kind == "doublehead" { + // tuned to match sym.arrow.double + ( + kind: "head", + size: 9.6, + sharpness: 19deg, + delta: 43.7deg, + ) + } else if mark.kind == "triplehead" { + // tuned to match sym.arrow.triple + ( + kind: "head", + size: 9, + sharpness: 25deg, + delta: 43deg, + ) + } else if mark.kind == "bar" { + (size: 4.5) + mark + } else if mark.kind in ("hook", "hooks") { + (size: 2.88, rim: 0.85) + mark + } else if mark.kind == "circle" { + (size: 2) + mark + } else if mark.kind == "bigcircle" { + (size: 4) + mark + (kind: "circle") + } else if mark.kind in ("solidhead", "solidtail") { + (size: 10, sharpness: 19deg) + mark + } else { + panic("Cannot interpret mark: " + mark.kind) + } +} + +/// Calculate cap offset of round-style arrow cap, +/// $r (sin θ - sqrt(1 - (cos θ - (|y|)/r)^2))$. +/// +/// - r (length): Radius of curvature of arrow cap. +/// - θ (angle): Angle made at the the arrow's vertex, from the central stroke +/// line to the arrow's edge. +/// - y (length): Lateral offset from the central stroke line. +#let round-arrow-cap-offset(r, θ, y) = { + r*(sin(θ) - sqrt(1 - pow(cos(θ) - abs(y)/r, 2))) +} + +#let cap-offset(mark, y) = { + mark = interpret-mark(mark) + if mark == none { return 0 } + + let offset() = round-arrow-cap-offset(mark.size, mark.sharpness, y) + + if mark.kind == "head" { offset() } + else if mark.kind in ("hook", "hook'", "hooks") { -2.65 } + else if mark.kind == "tail" { -3 - offset() } + else if mark.kind == "twohead" { offset() - 3 } + else if mark.kind == "twotail" { -3 - offset() - 3 } + else if mark.kind == "circle" { + let r = mark.size + -sqrt(max(0, r*r - y*y)) - r + } else if mark.kind == "solidhead" { + -mark.size*cos(mark.sharpness) + } else if mark.kind == "solidtail" { + -1 + } else { 0 } +} + + +#let draw-arrow-cap(p, θ, stroke, mark) = { + mark = interpret-mark(mark) + + let shift(p, x) = vector.add(p, vector-polar(stroke.thickness*x, θ)) + + // extrude draws multiple copies of the mark + // at shifted positions + if "extrude" in mark { + for x in mark.extrude { + let mark = mark + let _ = mark.remove("extrude") + draw-arrow-cap(shift(p, x), θ, stroke, mark) + } + return + } + + let stroke = (thickness: stroke.thickness, paint: stroke.paint, cap: "round") + + + if mark.kind == "harpoon" { + cetz.draw.arc( + p, + radius: mark.size*stroke.thickness, + start: θ + mark.flip*(90deg + mark.sharpness), + delta: mark.flip*mark.delta, + stroke: stroke, + ) + + } else if mark.kind == "head" { + draw-arrow-cap(p, θ, stroke, mark + (kind: "harpoon")) + draw-arrow-cap(p, θ, stroke, mark + (kind: "harpoon'")) + + } else if mark.kind == "tail" { + p = shift(p, cap-offset(mark, 0)) + draw-arrow-cap(p, θ + 180deg, stroke, mark + (kind: "head")) + + } else if mark.kind == "hook" { + p = shift(p, cap-offset(mark, 0)) + cetz.draw.arc( + p, + radius: mark.size*stroke.thickness, + start: θ + mark.flip*90deg, + delta: -mark.flip*180deg, + stroke: stroke, + ) + let q = vector.add(p, vector-polar(2*mark.size*stroke.thickness, θ - mark.flip*90deg)) + let rim = vector-polar(-mark.rim*stroke.thickness, θ) + cetz.draw.line( + q, + (rel: rim, to: q), + stroke: stroke + ) + + } else if mark.kind == "hooks" { + draw-arrow-cap(p, θ, stroke, mark + (kind: "hook")) + draw-arrow-cap(p, θ, stroke, mark + (kind: "hook'")) + + } else if mark.kind == "bar" { + let v = vector-polar(mark.size*stroke.thickness, θ + 90deg) + cetz.draw.line( + (to: p, rel: v), + (to: p, rel: vector.scale(v, -1)), + stroke: stroke, + ) + + } else if mark.kind == "circle" { + p = shift(p, -mark.size) + cetz.draw.circle( + p, + radius: mark.size*stroke.thickness, + stroke: stroke, + ) + + } else if mark.kind == "solidhead" { + cetz.draw.line( + p, + (to: p, rel: vector-polar(-mark.size*stroke.thickness, θ + mark.sharpness)), + (to: p, rel: vector-polar(-mark.size*stroke.thickness, θ - mark.sharpness)), + fill: stroke.paint, + stroke: none, + ) + + } else if mark.kind == "solidtail" { + mark += (kind: "solidhead") + p = shift(p, cap-offset(mark, 0)) + draw-arrow-cap(p, θ + 180deg, stroke, mark) + + + } else { + panic("unknown mark kind:", mark) + } +} diff --git a/packages/preview/fletcher/0.3.0/src/utils.typ b/packages/preview/fletcher/0.3.0/src/utils.typ new file mode 100644 index 000000000..be64e8e08 --- /dev/null +++ b/packages/preview/fletcher/0.3.0/src/utils.typ @@ -0,0 +1,123 @@ +#import calc: floor, ceil, min, max +#import "@preview/cetz:0.1.2": draw, vector + +#let DEBUG_COLOR = rgb("f008") + +#let zip(a, ..others) = if others.pos().len() == 0 { + a.map(i => (i,)) +} else { + a.zip(..others) +} + +#let to-abs-length(len, em-size) = { + len.abs + len.em*em-size +} + +#let min-max(array) = (calc.min(..array), calc.max(..array)) +#let cumsum(array) = { + let sum = array.at(0) + for i in range(1, array.len()) { + sum += array.at(i) + array.at(i) = sum + } + array +} + +#let vector-len((x, y)) = 1pt*calc.sqrt((x/1pt)*(x/1pt) + (y/1pt)*(y/1pt)) +#let vector-set-len(len, v) = vector.scale(v, len/vector-len(v)) +#let vector-unitless(v) = v.map(x => if type(x) == length { x.pt() } else { x }) +#let vector-polar(r, θ) = (r*calc.cos(θ), r*calc.sin(θ)) +#let vector-angle(v) = calc.atan2(..vector-unitless(v)) +#let vector-2d((x, y, ..z)) = (x, y) + +#let lerp(a, b, t) = a*(1 - t) + b*t +#let lerp-at(a, t) = { + let max-index = a.len() - 1 + lerp( + a.at(calc.clamp(floor(t), 0, max-index)), + a.at(calc.clamp(ceil(t), 0, max-index)), + calc.fract(t), + ) +} + + +#let angle-to-anchor(θ) = { + let i = calc.rem(8*θ/1rad/calc.tau, 8) + ( + "right", + "top-right", + "top", + "top-left", + "left", + "bottom-left", + "bottom", + "bottom-right", + ).at(int(calc.round(i))) +} + +#let rect-at(origin, size) = (-1, +1).map(dir => { + vector.add(origin, vector.scale(size, dir)) +}) + +#let rect-edges((x0, y0), (x1, y1)) = ( + ((x0, y0), (x1, y0)), + ((x1, y0), (x1, y1)), + ((x1, y1), (x0, y1)), + ((x0, y1), (x0, y0)), +) + + +#let intersect-rect-with-crossing-line(rect, line) = { + rect = rect.map(vector-unitless) + line = line.map(vector-unitless) + for (p1, p2) in rect-edges(..rect) { + let meet = draw.intersection.line-line(p1, p2, ..line) + if meet != none { + return vector-2d(vector.scale(meet, 1pt)) + } + } + panic("didn't intersect", rect, line) +} + + +/// Determine arc between two points with a given bend angle +/// +/// The bend angle is the angle between chord of the arc (line connecting the +/// points) and the tangent to the arc and the first point. +/// +/// Returns a dictionary containing: +/// - `center`: the center of the arc's curvature +/// - `radius` +/// - `start`: the start angle of the arc +/// - `stop`: the end angle of the arc +/// +/// - from (point): 2D vector of initial point. +/// - to (point): 2D vector of final point. +/// - angle (angle): The bend angle between chord of the arc (line connecting the +/// points) and the tangent to the arc and the first point. +/// -> dictionary +/// +/// #fletcher.diagram(spacing: 2cm, { +/// for (i, θ) in (0deg, 45deg, -90deg).enumerate() { +/// edge((2*i, 0), (2*i + 1, 0), marks: (none, "head"), bend: θ) +/// edge((2*i, 0), (2*i + 1, 0), [#θ], label-side: center, dash: +/// "dotted") +/// } +/// }) +#let get-arc-connecting-points(from, to, angle) = { + let mid = vector.scale(vector.add(from, to), 0.5) + let (dx, dy) = vector.sub(to, from) + let perp = (dy, -dx) + + let center = vector.add(mid, vector.scale(perp, 0.5/calc.tan(angle))) + + let radius = vector-len(vector.sub(to, center)) + + let start = vector-angle(vector.sub(from, center)) + let stop = vector-angle(vector.sub(to, center)) + + if start < stop and angle > 0deg { start += 360deg } + if start > stop and angle < 0deg { start -= 360deg } + + (center: center, radius: radius, start: start, stop: stop) +} \ No newline at end of file diff --git a/packages/preview/fletcher/0.3.0/test/test.pdf b/packages/preview/fletcher/0.3.0/test/test.pdf new file mode 100644 index 000000000..7d3c5c0cf Binary files /dev/null and b/packages/preview/fletcher/0.3.0/test/test.pdf differ diff --git a/packages/preview/fletcher/0.3.0/test/test.typ b/packages/preview/fletcher/0.3.0/test/test.typ new file mode 100644 index 000000000..2253a4d71 --- /dev/null +++ b/packages/preview/fletcher/0.3.0/test/test.typ @@ -0,0 +1,396 @@ +#import "@preview/cetz:0.1.2" +#import "/src/exports.typ" as fletcher: node, edge + + +#set page(width: 10cm, height: auto) +#show heading.where(level: 1): it => pagebreak(weak: true) + it + += Arrow heads +Compare to symbols $#sym.arrow$, $#sym.arrow.twohead$, $#sym.arrow.hook$, $#sym.arrow.bar$ + +#fletcher.diagram( + // debug: 1, + spacing: (10mm, 5mm), +{ + for i in (0, 1, 2) { + let x = 2*i + let bend = 40deg*i + ( + (marks: ("harpoon", "harpoon'")), + (marks: ("head", "head")), + (marks: ("tail", "tail")), + (marks: ("twotail", "twohead")), + (marks: ("twohead", "twotail")), + (marks: ("hook", "head")), + (marks: ("hook", "hook'")), + (marks: ("bar", "bar")), + (marks: ("twobar", "twobar")), + (marks: (none, none), extrude: (2.5,0,-2.5)), + (marks: ("head", "head"), extrude: (1.5,-1.5)), + (marks: ("tail", "tail"), extrude: (1.5,-1.5)), + (marks: ("bar", "head"), extrude: (2,0,-2)), + (marks: ("twotail", "twohead"), extrude: (1.5,-1.5)), + (marks: ("circle", "bigcircle")), + (marks: ("circle", "bigcircle"), extrude: (1.5, -1.5)), + (marks: ("solidtail", "solidhead")), + ).enumerate().map(((i, args)) => { + edge((x, -i), (x + 1, -i), ..args, bend: bend) + }).join() + + } + +}) + += Symbol matching + +Red is our output; cyan is reference symbol in default math font. +#{ + set text(10em) + + fletcher.diagram( + spacing: 0.815em, + crossing-fill: none, + edge( + (0,0), (1,0), + text(rgb("0ff5"), $->$), + "->", + paint: rgb("f006"), + label-side: center, + ), + ) + fletcher.diagram( + spacing: 0.835em, + crossing-fill: none, + edge( + (0,0), (1,0), + text(rgb("0ff5"), $->>$), + "->>", + paint: rgb("f006"), + label-side: center, + ), + ) + fletcher.diagram( + spacing: 0.815em, + crossing-fill: none, + edge( + (0,0), (1,0), + text(rgb("0ff5"), $arrow.hook$), + "hook->", + paint: rgb("f006"), + label-side: right, + label-sep: -0.0195em, + label-anchor: "center", + ), + ) + + fletcher.diagram( + spacing: 0.8em, + crossing-fill: none, + edge( + (0,0), (1,0), + text(rgb("0ff5"), $=>$), + "=>", + paint: rgb("f006"), + label-side: center, + ), + ) + + fletcher.diagram( + spacing: 0.83em, + crossing-fill: none, + edge( + (0,0), (1,0), + text(rgb("0ff5"), $arrow.triple$), + "==>", + paint: rgb("f006"), + label-side: center, + ), + ) +} + +$A -> B$, +#fletcher.diagram( + edge-thickness: 0.53pt, + node-inset: 5pt, + label-sep: 1pt, + // spacing: 25pt, + node((0,0), $A$), edge((0,0), (1,0), "->"), node((1,0), $B$) +) + +$A => B$, +#fletcher.diagram( + edge-thickness: 0.53pt, + node-inset: 5pt, + label-sep: 2pt, + // spacing: 25pt, + node((0,0), $A$), edge((0,0), (1,0), "=>"), node((1,0), $B$) +) + +$A arrow.triple B$, +#fletcher.diagram( + edge-thickness: 0.53pt, + node-inset: 5pt, + label-sep: 3pt, + // spacing: 25pt, + node((0,0), $A$), edge((0,0), (1,0), "==>"), node((1,0), $B$) +) + + += Double and triple lines + +#for (i, a) in ("->", "=>", "==>").enumerate() [ + Diagram #fletcher.diagram( + // node-inset: 5pt, + label-sep: 1pt + i*1pt, + node((0, -i), $A$), + edge((0, -i), (1, -i), text(0.6em, $f$), a), + node((1, -i), $B$), + ) and equation #($A -> B$, $A => B$, $A arrow.triple B$).at(i). \ +] + += Arrow head shorthands + +#import "/src/main.typ": parse-arrow-shorthand + +$ +#for i in ( + "->", + "<-", + "<->", + "<=>", + "<==>", + "|->", + "|=>", + ">->", + "->>", + "hook->", + "hook'--hook", + "|=|", + ">>-<<", + "harpoon-harpoon'", + "harpoon'-<<", + "<--hook'", + "|..|", + "hooks--hooks", + "o-O", + "o==O", + "||->>", + "<|-|>", + "|>-<|", +) { + $ #block(inset: 2pt, fill: white.darken(5%), raw(i)) + &= #fletcher.diagram(edge((0,0), (1,0), i)) \ $ +} +$ + += Connectors + + +#fletcher.diagram( + debug: 0, + cell-size: (10mm, 10mm), + node((0,1), $X$), + node((1,1), $Y$), + node((0,0), $Z$), + edge((0,1), (1,1), marks: (none, "head")), + edge((0,0), (1,1), $f$, marks: ("hook", "head"), dash: "dashed"), + edge((0,1), (0,0), marks: (none, "twohead")), + edge((0,1), (0,1), marks: (none, "head"), bend: -120deg), +) + += Arc connectors + +#fletcher.diagram( + cell-size: 3cm, +{ + node((0,0), "from") + node((1,0), "to") + for θ in (0deg, 20deg, -50deg) { + edge((0,0), (1,0), $#θ$, bend: θ, marks: (none, "head")) + } +}) + +#fletcher.diagram( + debug: 3, + node((0,0), $X$), + node((1,0), $Y$), + edge((0,0), (1,0), bend: 45deg, marks: ("head", "head")), +) + +#for (i, to) in ((0,1), (1,0), (calc.sqrt(1/2),-calc.sqrt(1/2))).enumerate() { + fletcher.diagram(debug: 0, { + node((0,0), $A$) + node(to, $B$) + let N = 6 + range(N + 1).map(x => (x/N - 0.5)*2*120deg).map(θ => edge((0,0), to, bend: θ, marks: ("tail", "head"))).join() + }) +} + += Defocus + +#let around = ( + (-1,+1), ( 0,+1), (+1,+1), + (-1, 0), (+1, 0), + (-1,-1), ( 0,-1), (+1,-1), +) + +#grid( + columns: 2, + ..(-10, -1, -.25, 0, +.25, +1, +10).map(defocus => { + ((7em, 3em), (3em, 7em)).map(((w, h)) => { + align(center + horizon, fletcher.diagram( + node-defocus: defocus, + node-inset: 0pt, + { + node((0,0), rect(width: w, height: h, inset: 0pt, align(center + horizon)[#defocus])) + for p in around { + edge(p, (0,0)) + } + })) + }) + }).join() +) + += Label placement +Default placement above the line. + +#fletcher.diagram( + // cell-size: (2.2cm, 2cm), + spacing: 2cm, + debug: 3, +{ + for p in around { + edge(p, (0,0), $f$) + } +}) + +#fletcher.diagram(spacing: 1.5cm, { + for (i, a) in (left, center, right).enumerate() { + for (j, θ) in (-30deg, 0deg, 50deg).enumerate() { + edge((2*i, j), (2*i + 1, j), label: a, "->", label-side: a, bend: θ) + } + } +}) + + += Crossing connectors + +#fletcher.diagram({ + edge((0,1), (1,0)) + edge((0,0), (1,1), crossing: true) + edge((2,1), (3,0), "|-|", bend: -20deg) + edge((2,0), (3,1), "<=>", crossing: true, bend: 20deg) +}) + + += `edge()` argument shorthands + +#fletcher.diagram( + edge((0,0), (1,1), "->", "double", bend: 45deg), + edge((1,0), (0,1), "->>", "crossing"), + edge((1,1), (2,1), $f$, "|->"), + edge((0,0), (1,0), "-", "dashed"), +) + + += Diagram-level options + +#fletcher.diagram( + node-stroke: black, + node-fill: green.lighten(80%), + label-sep: 0pt, + node((0,0), $A$), + node((1,1), $sin compose cos compose tan$, fill: none), + node((2,0), $C$), + node((3,0), $D$, shape: "rect"), + edge((0,0), (1,1), $sigma$, "-|>", bend: -45deg), + edge((2,0), (1,1), $f$, "<|-"), +) + += CeTZ integration + +#import "/src/utils.typ": vector-polar +#fletcher.diagram( + node((0,0), $A$, stroke: 1pt), + node((2,1), [Bézier], stroke: 1pt), + render: (grid, nodes, edges, options) => { + cetz.canvas({ + fletcher.draw-diagram(grid, nodes, edges, options) + + let n1 = fletcher.find-node-at(nodes, (0,0)) + let p1 = fletcher.get-node-anchor(n1, 0deg) + + let n2 = fletcher.find-node-at(nodes, (2,1)) + let p2 = fletcher.get-node-anchor(n2, -90deg) + + let c1 = cetz.vector.add(p1, vector-polar(20pt, 0deg)) + let c2 = cetz.vector.add(p2, vector-polar(70pt, -90deg)) + + fletcher.draw-arrow-cap(p1, 180deg, (thickness: 1pt, paint: black), "head") + + cetz.draw.bezier(p1, p2, c1, c2) + }) + } +) + += Node bounds + +#fletcher.diagram( + debug: 2, + node-outset: 5pt, + node-inset: 5pt, + node((0,0), `hello`, stroke: 1pt), + node((1,0), `there`, stroke: 1pt), + edge((0,0), (1,0), "<=>"), +) + + += Corner edges + +#let around = ( + (-1,+1), (+1,+1), + (-1,-1), (+1,-1), +) + +#for dir in (left, right) { + pad(1mm, fletcher.diagram( + // debug: 2, + spacing: 1cm, + node((0,0), [#dir]), + { + for c in around { + node(c, $#c$) + edge((0,0), c, $f$, "O=>", corner: dir, label-pos: 0.4) + } + } + )) +} + += Double node strokes + +#fletcher.diagram( + node-outset: 4pt, + spacing: (15mm, 8mm), + node-stroke: black + 0.5pt, + node((0, 0), $s_1$, ), + node((1, 0), $s_2$, extrude: (-1.5, 1.5), fill: blue.lighten(70%)), + edge((0, 0), (1, 0), "->", label: $a$, bend: 20deg), + edge((0, 0), (0, 0), "->", label: $b$, bend: 120deg), + edge((1, 0), (0, 0), "->", label: $b$, bend: 20deg), + edge((1, 0), (1, 0), "->", label: $a$, bend: 120deg), + edge((1,0), (2,0), "->>"), + node((2,0), $s_3$, extrude: (+1, -1), stroke: 1pt, fill: red.lighten(70%)), +) + +#fletcher.diagram( + node((0,0), `outer`, stroke: 1pt, extrude: (-1, +1), fill: green), + node((1,0), `inner`, stroke: 1pt, extrude: (+1, -1), fill: green), + node((2,0), `middle`, stroke: 1pt, extrude: (0, +2, -2), fill: green), +) + +Relative and absolute extrusion lengths + +#fletcher.diagram( + node((0,0), `outer`, stroke: 1pt, extrude: (-1mm, 0pt), fill: green), + node((1,0), `inner`, stroke: 1pt, extrude: (0, +.5em, -2pt), fill: green), +) \ No newline at end of file diff --git a/packages/preview/fletcher/0.3.0/typst.toml b/packages/preview/fletcher/0.3.0/typst.toml new file mode 100644 index 000000000..8784f6557 --- /dev/null +++ b/packages/preview/fletcher/0.3.0/typst.toml @@ -0,0 +1,21 @@ +[package] +name = "fletcher" +version = "0.3.0" +entrypoint = "src/exports.typ" +authors = ["Joseph Wilson (Jollywatt)"] +license = "MIT" +description = "Draw diagrams with nodes and arrows." +repository = "https://github.com/Jollywatt/typst-fletcher" +keywords = [ + "commutative", + "commuting", + "commute", + "diagram", + "category", + "flowchart", + "DAG", + "graph", + "finite state", + "arrows", +] +exclude = ["docs/", "test/"] \ No newline at end of file