Compare commits

...

38 Commits

Author SHA1 Message Date
5822d705f2
completed README 2024-09-30 15:19:36 +02:00
226efa46b7
added length support for width parmeter 2024-08-28 13:53:37 +02:00
3d9e045b56
fixed group sizing with syncs 2024-08-25 15:11:57 +02:00
eb05c41810
added show-top / -bottom options to participants 2024-08-20 23:19:43 +02:00
b9bbe6f93d
added auto fit wrapper function 2024-08-04 00:22:20 +02:00
b5c34c154f
fixed ignored comment width with sync 2024-07-31 22:46:24 +02:00
030fd3edba
added comment alignment 2024-07-31 18:11:16 +02:00
cbba14ed04
fixed and completed arrow tips 2024-07-30 18:20:50 +02:00
77a65a3199
updated TODO.md 2024-07-30 14:28:23 +02:00
ff6a7e52bc
added slanted sequences 2024-07-30 14:26:16 +02:00
ebd4d8f1ca
added synched sequences 2024-07-30 12:38:23 +02:00
3b25d68f1e
added TODO 2024-07-30 12:19:12 +02:00
0968436c8a
implemented create event + create offset 2024-07-30 12:18:57 +02:00
2c724598e9
minor style improvements 2024-06-21 18:14:40 +02:00
0cff9d6799
implemented aligned notes 2024-06-21 17:26:50 +02:00
f08f30b9e2
added notes 2024-06-21 15:59:35 +02:00
81871e2aca
updated examples 2024-06-21 02:56:49 +02:00
6500e5a4a3
added arrow tips (WIP) 2024-06-21 02:25:30 +02:00
6e7dc9913f
fixed some spacing issues 2024-06-21 02:24:35 +02:00
9c83f810bb
added events 2024-06-21 02:23:13 +02:00
fd5b147e86
minor style improvements 2024-06-20 22:44:15 +02:00
ef73f98dd0
minor fixes 2024-06-20 18:02:13 +02:00
3610c86884
removed useless invisible participants 2024-06-20 00:46:42 +02:00
29787ad8f1
fixed missing comment padding 2024-06-19 22:51:02 +02:00
0fbe1e0683
changed destruction color + default arrow style 2024-06-19 22:16:51 +02:00
816cb98491
added various participant types/icons 2024-06-19 22:01:29 +02:00
27ad9da458
added short/start/end arrows 2024-06-19 17:52:56 +02:00
8deb26441d
improved seq arrow positioning 2024-06-19 00:39:55 +02:00
a9b467152a
added participant creation 2024-06-18 23:01:53 +02:00
4dd940f584
added lifeline-style 2024-06-18 23:00:41 +02:00
ed84e06560
restructured code in separated files 2024-06-18 21:31:13 +02:00
94d0eb286e
added basis for lifelines 2024-06-18 20:32:39 +02:00
0e0be4e76a
added sequences with same endpoints 2024-06-18 17:08:06 +02:00
adc50124ad
added gaps 2024-06-18 16:24:11 +02:00
b8d0c3468c
added separators 2024-06-18 16:18:40 +02:00
dc5d868a5d
added groups 2024-06-18 16:09:06 +02:00
c7fba373a5
improved column layout 2024-06-18 14:11:03 +02:00
974eb92159
added some styling options 2024-06-18 13:51:01 +02:00
36 changed files with 2470 additions and 82 deletions

147
README.md
View File

@ -1,3 +1,150 @@
# chronos
A Typst package to draw sequence diagrams with CeTZ
---
This package lets you render sequence diagrams directly in Typst. The following boilerplate code creates an empty sequence diagram with two participants:
<table>
<tr>
<td><strong>Typst</strong></td>
<td><strong>Result</strong></td>
</tr>
<tr>
<td>
```typst
#import "@preview/chronos:0.1.0"
#chronos.diagram({
import chronos: *
_par("Alice")
_par("Bob")
})
```
</td>
<td><img src="./gallery/readme/boilerplate.png"></td>
</tr>
</table>
> *Disclaimer*\
> The package cannot parse PlantUML syntax for the moment, and thus requires the use of element functions, as shown in the examples.
> A PlantUML parser is in the TODO list, just not the top priority
## Basic sequences
You can make basic sequences using the `_seq` function:
<table>
<tr>
<td><strong>Typst</strong></td>
<td><strong>Result</strong></td>
</tr>
<tr>
<td>
```typst
#chronos.diagram({
import chronos: *
_par("Alice")
_par("Bob")
_seq("Alice", "Bob", comment: "Hello")
_seq("Bob", "Bob", comment: "Think")
_seq("Bob", "Alice", comment: "Hi")
})
```
</td>
<td><img src="./gallery/readme/simple_sequence.png"></td>
</tr>
</table>
You can make lifelines using the following parameters of the `_seq` function:
- `enable-dst`: enables the destination lifeline
- `create-dst`: creates the destination lifeline and participant
- `disable-dst`: disables the destination lifeline
- `destroy-dst`: destroys the destination lifeline and participant
- `disable-src`: disables the source lifeline
- `destroy-src`: destroy the source lifeline and participant
<table>
<tr>
<td><strong>Typst</strong></td>
<td><strong>Result</strong></td>
</tr>
<tr>
<td>
```typst
#chronos.diagram({
import chronos: *
_par("A", display-name: "Alice")
_par("B", display-name: "Bob")
_par("C", display-name: "Charlie")
_par("D", display-name: "Derek")
_seq("A", "B", comment: "hello", enable-dst: true)
_seq("B", "B", comment: "self call", enable-dst: true)
_seq("C", "B", comment: "hello from thread 2", enable-dst: true, lifeline-style: (fill: rgb("#005500")))
_seq("B", "D", comment: "create", create-dst: true)
_seq("B", "C", comment: "done in thread 2", disable-src: true, dashed: true)
_seq("B", "B", comment: "rc", disable-src: true, dashed: true)
_seq("B", "D", comment: "delete", destroy-dst: true)
_seq("B", "A", comment: "success", disable-src: true, dashed: true)
})
```
</td>
<td><img src="./gallery/readme/lifelines.png"></td>
</tr>
</table>
## Showcase
Several features have already been implemented in Chronos. Don't hesitate to checkout the examples in the [gallery](./gallery) folder to see what you can do.
#### Quick example reference:
<table>
<tr>
<td><strong>Example</strong></td>
<td><strong>Features</strong></td>
</tr>
<tr>
<td>
`example1` <br>([PDF](./gallery/example1.pdf)|[Typst](./gallery/example1.typ))
</td>
<td>Simple cases, color sequences, groups, separators, gaps, self-sequences</td>
</tr>
<tr>
<td>
`example2` <br>([PDF](./gallery/example2.pdf)|[Typst](./gallery/example2.typ))
</td>
<td>Lifelines, found/lost messages, synchronized sequences, slanted sequences</td>
</tr>
<tr>
<td>
`example3` <br>([PDF](./gallery/example3.pdf)|[Typst](./gallery/example3.typ))
</td>
<td>Participant shapes, sequence tips, hidden partipicant ends</td>
</tr>
<tr>
<td>
`notes` <br>([PDF](./gallery/notes.pdf)|[Typst](./gallery/notes.typ))
</td>
<td>Notes (duh), deferred participant creation</td>
</tr>
</table>
> [!NOTE]
>
> Many examples were taken/adapted from the PlantUML [documentation](https://plantuml.com/sequence-diagram) on sequence diagrams

27
TODO.md Normal file
View File

@ -0,0 +1,27 @@
# TODO
- [x] Basic participants
- [x] Basic sequences
- [x] Separators
- [x] Gaps
- [x] Groups
- [x] Self arrows
- [x] Arrow from start / to end, small arrows
- [x] Lifelines
- [x] Different types of participants
- [x] Notes
- [x] Synchronized arrows
- [x] Slanted arrows
- [x] Different types of arrow tips
- [x] Sequence comment alignment
- [x] Fix group size with syncs
- [ ] Fix column spacing with notes over multiple columns
- [ ] Fix notes with arrows from start / to end / small arrows
- [ ] Fix group size with self arrows + notes
- [ ] Unify styling structure
- [ ] Add args verification to catch user errors + pretty error messages
- [ ] PlantUML parser
- [ ] (Message numbering)
- [ ] Different types of groups (alt/loop/etc.)
- [ ] Delays
- [ ] Auto-fit in parent

27
gallery.bash Normal file
View File

@ -0,0 +1,27 @@
#!/bin/bash
echo
echo "Generating gallery PDFs"
set -- ./gallery/*.typ
cnt="$#"
i=1
for f
do
f2="${f/typ/pdf}"
echo "($i/$cnt) $f -> $f2"
typst c --root ./ "$f" "$f2"
i=$((i+1))
done
set -- ./gallery/readme/*.typ
cnt="$#"
i=1
for f
do
f2="${f/typ/png}"
echo "($i/$cnt) $f -> $f2"
typst c --root ./ "$f" "$f2"
i=$((i+1))
done

Binary file not shown.

View File

@ -10,10 +10,65 @@ Alice <-- Bob: Another authentication Response
#chronos.diagram({
import "/src/diagram.typ": *
import chronos: *
_seq("Alice", "Bob", comment: "Authentication Request")
_seq("Bob", "Alice", comment: "Authentication Response", style: "dashed")
_seq("Bob", "Alice", comment: "Authentication Response", dashed: true)
_seq("Alice", "Bob", comment: "Another authentication Request")
_seq("Bob", "Alice", comment: "Another authentication Response", style: "dashed")
_seq("Bob", "Alice", comment: "Another authentication Response", dashed: true)
})
#chronos.diagram({
import chronos: *
_seq("Bob", "Alice", comment: "bonjour", color: red)
_seq("Alice", "Bob", comment: "ok", color: blue)
})
#chronos.diagram({
import chronos: *
_seq("Alice", "Bob", comment: "This is a test")
_seq("Alice", "Callum", comment: "This is another test with a long text")
})
#chronos.diagram({
import chronos: *
_seq("Alice", "Bob", comment: "Authentication Request")
_seq("Bob", "Alice", comment: "Authentication Failure")
_grp("My own label", desc: "My own label2", {
_seq("Alice", "Log", comment: "Log attack start")
_grp("loop", desc: "1000 times", {
_seq("Alice", "Bob", comment: "DNS Attack")
})
_seq("Alice", "Bob", comment: "Log attack end")
})
})
#chronos.diagram({
import chronos: *
_sep("Initialization")
_seq("Alice", "Bob", comment: "Authentication Request")
_seq("Bob", "Alice", comment: "Authentication Response", dashed: true)
_sep("Repetition")
_seq("Alice", "Bob", comment: "Another authentication Request")
_seq("Bob", "Alice", comment: "another authentication Response", dashed: true)
})
#chronos.diagram({
import chronos: *
_seq("Alice", "Bob", comment: "message 1")
_seq("Bob", "Alice", comment: "ok", dashed: true)
_gap()
_seq("Alice", "Bob", comment: "message 2")
_seq("Bob", "Alice", comment: "ok", dashed: true)
_gap(size: 45)
_seq("Alice", "Bob", comment: "message 3")
_seq("Bob", "Alice", comment: "ok", dashed: true)
})
#chronos.diagram({
import chronos: *
_seq("Alice", "Alice", comment: "On the\nright")
_seq("Alice", "Alice", flip: true, comment: "On the\nleft")
})

BIN
gallery/example2.pdf Normal file

Binary file not shown.

169
gallery/example2.typ Normal file
View File

@ -0,0 +1,169 @@
#import "/src/lib.typ" as chronos
#chronos.diagram({
import chronos: *
_seq("User", "A", comment: "DoWork", enable-dst: true)
_seq("A", "B", comment: [#sym.quote.angle.l createRequest #sym.quote.angle.r], enable-dst: true)
_seq("B", "C", comment: "DoWork", enable-dst: true)
_seq("C", "B", comment: "WorkDone", destroy-src: true, disable-src: true, dashed: true)
_seq("B", "A", comment: "RequestCreated", disable-src: true, dashed: true)
_seq("A", "User", comment: "Done", disable-src: true)
})
#chronos.diagram({
import chronos: *
_seq("User", "A", comment: "DoWork", enable-dst: true, lifeline-style: (fill: rgb("#FFBBBB")))
_seq("A", "A", comment: "Internal call", enable-dst: true, lifeline-style: (fill: rgb("#E9967A")))
_seq("A", "B", comment: [#sym.quote.angle.l createRequest #sym.quote.angle.r], enable-dst: true)
_seq("B", "A", comment: "RequestCreated", disable-src: true, disable-dst: true, dashed: true)
_seq("A", "User", comment: "Done", disable-src: true)
})
#chronos.diagram({
import chronos: *
_seq("alice", "bob", comment: "hello", enable-dst: true)
_seq("bob", "bob", comment: "self call", enable-dst: true)
_seq("bill", "bob", comment: "hello from thread 2", enable-dst: true, lifeline-style: (fill: rgb("#005500")))
_seq("bob", "george", comment: "create", create-dst: true)
_seq("bob", "bill", comment: "done in thread 2", disable-src: true, dashed: true)
_seq("bob", "bob", comment: "rc", disable-src: true, dashed: true)
_seq("bob", "george", comment: "delete", destroy-dst: true)
_seq("bob", "alice", comment: "success", disable-src: true, dashed: true)
})
#chronos.diagram({
import chronos: *
_seq("alice", "bob", comment: "hello1", enable-dst: true)
_seq("bob", "charlie", comment: "hello2", enable-dst: true, disable-src: true)
_seq("charlie", "alice", comment: "ok", dashed: true, disable-src: true)
})
#chronos.diagram({
import chronos: *
_seq("?", "Alice", comment: [?->\ *short* to actor1])
_seq("[", "Alice", comment: [\[->\ *from start* to actor1])
_seq("[", "Bob", comment: [\[->\ *from start* to actor2])
_seq("?", "Bob", comment: [?->\ *short* to actor2])
_seq("Alice", "]", comment: [->\]\ from actor1 *to end*])
_seq("Alice", "?", comment: [->?\ *short* from actor1])
_seq("Alice", "Bob", comment: [->\ from actor1 to actor2])
})
#chronos.diagram({
import chronos: *
_par("alice", display-name: "Alice")
_par("bob", display-name: "Bob")
_par("craig", display-name: "Craig")
_seq("bob", "alice")
_seq("bob", "craig")
_gap()
_sync({
_seq("bob", "alice", comment: "Synched", comment-align: "start")
_seq("bob", "craig", comment: "Synched", comment-align: "start")
})
_gap()
_seq("alice", "bob")
_seq("craig", "bob")
_gap()
_sync({
_seq("alice", "bob")
_seq("craig", "bob")
})
_gap()
_sync({
_seq("alice", "bob", enable-dst: true)
_seq("craig", "bob")
})
_gap()
_evt("bob", "disable")
})
#chronos.diagram({
import chronos: *
_par("alice", display-name: "Alice")
_par("bob", display-name: "Bob")
_par("craig", display-name: "Craig")
_seq("alice", "bob")
_seq("bob", "craig", slant: auto)
_seq("alice", "craig", slant: 20)
_sync({
_seq("alice", "bob", slant: 10)
_seq("craig", "bob", slant: 20)
})
_sync({
_seq("alice", "bob", slant: auto)
_seq("bob", "alice", slant: auto)
})
_gap()
_evt("bob", "disable")
})
#grid(columns: 2, column-gutter: 2em,
chronos.diagram({
import chronos: *
_par("alice", display-name: "Alice")
_par("bob", display-name: "Bob")
_seq("alice", "bob", comment: "This is a very long comment")
// Left to right
_seq("alice", "bob", comment: "Start aligned", comment-align: "start")
_seq("alice", "bob", comment: "End aligned", comment-align: "end")
_seq("alice", "bob", comment: "Left aligned", comment-align: "left")
_seq("alice", "bob", comment: "Right aligned", comment-align: "right")
_seq("alice", "bob", comment: "Centered", comment-align: "center")
_gap()
// Right to left
_seq("bob", "alice", comment: "Start aligned", comment-align: "start")
_seq("bob", "alice", comment: "End aligned", comment-align: "end")
_seq("bob", "alice", comment: "Left aligned", comment-align: "left")
_seq("bob", "alice", comment: "Right aligned", comment-align: "right")
_seq("bob", "alice", comment: "Centered", comment-align: "center")
_gap()
// Slant left to right
_seq("alice", "bob", comment: "Start aligned", comment-align: "start", slant: 10)
_seq("alice", "bob", comment: "End aligned", comment-align: "end", slant: 10)
_seq("alice", "bob", comment: "Left aligned", comment-align: "left", slant: 10)
_seq("alice", "bob", comment: "Right aligned", comment-align: "right", slant: 10)
_seq("alice", "bob", comment: "Centered", comment-align: "center", slant: 10)
_gap()
// Slant right to left
_seq("bob", "alice", comment: "Start aligned", comment-align: "start", slant: 10)
_seq("bob", "alice", comment: "End aligned", comment-align: "end", slant: 10)
_seq("bob", "alice", comment: "Left aligned", comment-align: "left", slant: 10)
_seq("bob", "alice", comment: "Right aligned", comment-align: "right", slant: 10)
_seq("bob", "alice", comment: "Centered", comment-align: "center", slant: 10)
}),
chronos.diagram({
import chronos: *
_par("alice", display-name: "Alice")
_seq("alice", "alice", comment: "Start aligned", comment-align: "start")
_seq("alice", "alice", comment: "End aligned", comment-align: "end")
_seq("alice", "alice", comment: "Left aligned", comment-align: "left")
_seq("alice", "alice", comment: "Right aligned", comment-align: "right")
_seq("alice", "alice", comment: "Centered", comment-align: "center")
_seq("alice", "alice", comment: "Start aligned", comment-align: "start", flip: true)
_seq("alice", "alice", comment: "End aligned", comment-align: "end", flip: true)
_seq("alice", "alice", comment: "Left aligned", comment-align: "left", flip: true)
_seq("alice", "alice", comment: "Right aligned", comment-align: "right", flip: true)
_seq("alice", "alice", comment: "Centered", comment-align: "center", flip: true)
})
)

BIN
gallery/example3.pdf Normal file

Binary file not shown.

147
gallery/example3.typ Normal file
View File

@ -0,0 +1,147 @@
#import "/src/lib.typ" as chronos
#set page(width: auto, height: auto)
#let TYPST = image("typst.png", width: 1.5cm, height: 1.5cm, fit: "contain")
#let FERRIS = image("ferris.png", width: 1.5cm, height: 1.5cm, fit: "contain")
#let ME = image("me.jpg", width: 1.5cm, height: 1.5cm, fit: "contain")
#chronos.diagram({
import chronos: *
_par("Foo", display-name: "Participant", shape: "participant")
_par("Foo1", display-name: "Actor", shape: "actor")
_par("Foo2", display-name: "Boundary", shape: "boundary")
_par("Foo3", display-name: "Control", shape: "control")
_par("Foo4", display-name: "Entity", shape: "entity")
_par("Foo5", display-name: "Database", shape: "database")
_par("Foo6", display-name: "Collections", shape: "collections")
_par("Foo7", display-name: "Queue", shape: "queue")
_par("Foo8", display-name: "Typst", shape: "custom", custom-image: TYPST)
_par("Foo9", display-name: "Ferris", shape: "custom", custom-image: FERRIS)
_par("Foo10", display-name: "Baryhobal", shape: "custom", custom-image: ME)
_seq("Foo", "Foo1", comment: "To actor")
_seq("Foo", "Foo2", comment: "To boundary")
_seq("Foo", "Foo3", comment: "To control")
_seq("Foo", "Foo4", comment: "To entity")
_seq("Foo", "Foo5", comment: "To database")
_seq("Foo", "Foo6", comment: "To collections")
_seq("Foo", "Foo7", comment: "To queue")
_seq("Foo", "Foo8", comment: "To Typst")
_seq("Foo", "Foo9", comment: "To ferris")
_seq("Foo", "Foo10", comment: "To Baryhobal")
})
#pagebreak()
#chronos.diagram({
import chronos: *
_par("me", display-name: "Me", shape: "custom", custom-image: ME)
_par("typst", display-name: "Typst", shape: "custom", custom-image: TYPST)
_par("rust", display-name: "Rust", shape: "custom", custom-image: FERRIS)
_seq("me", "typst", comment: "opens document", enable-dst: true)
_seq("me", "typst", comment: "types document")
_seq("typst", "rust", comment: "compiles content", enable-dst: true)
_seq("rust", "typst", comment: "renders document", disable-src: true)
_seq("typst", "me", comment: "displays document")
_evt("typst", "disable")
})
#pagebreak()
#stack(dir: ltr, spacing: 1em,
chronos.diagram({
import chronos: *
_par("a", display-name: "Alice")
_par("b", display-name: "Bob")
_seq("a", "b", end-tip: ">", comment: `->`)
_seq("a", "b", end-tip: ">>", comment: `->>`)
_seq("a", "b", end-tip: "\\", comment: `-\`)
_seq("a", "b", end-tip: "\\\\", comment: `-\\`)
_seq("a", "b", end-tip: "/", comment: `-/`)
_seq("a", "b", end-tip: "//", comment: `-//`)
_seq("a", "b", end-tip: "x", comment: `->x`)
_seq("a", "b", start-tip: "x", comment: `x->`)
_seq("a", "b", start-tip: "o", comment: `o->`)
_seq("a", "b", end-tip: ("o", ">"), comment: `->o`)
_seq("a", "b", start-tip: "o", end-tip: ("o", ">"), comment: `o->o`)
_seq("a", "b", start-tip: ">", end-tip: ">", comment: `<->`)
_seq("a", "b", start-tip: ("o", ">"), end-tip: ("o", ">"), comment: `o<->o`)
_seq("a", "b", start-tip: "x", end-tip: "x", comment: `x<->x`)
_seq("a", "b", end-tip: ("o", ">>"), comment: `->>o`)
_seq("a", "b", end-tip: ("o", "\\"), comment: `-\o`)
_seq("a", "b", end-tip: ("o", "\\\\"), comment: `-\\o`)
_seq("a", "b", end-tip: ("o", "/"), comment: `-/o`)
_seq("a", "b", end-tip: ("o", "//"), comment: `-//o`)
_seq("a", "b", start-tip: "x", end-tip: ("o", ">"), comment: `x->o`)
}),
chronos.diagram({
import chronos: *
_par("a", display-name: "Alice")
_par("b", display-name: "Bob")
_seq("b", "a", end-tip: ">", comment: `->`)
_seq("b", "a", end-tip: ">>", comment: `->>`)
_seq("b", "a", end-tip: "\\", comment: `-\`)
_seq("b", "a", end-tip: "\\\\", comment: `-\\`)
_seq("b", "a", end-tip: "/", comment: `-/`)
_seq("b", "a", end-tip: "//", comment: `-//`)
_seq("b", "a", end-tip: "x", comment: `->x`)
_seq("b", "a", start-tip: "x", comment: `x->`)
_seq("b", "a", start-tip: "o", comment: `o->`)
_seq("b", "a", end-tip: ("o", ">"), comment: `->o`)
_seq("b", "a", start-tip: "o", end-tip: ("o", ">"), comment: `o->o`)
_seq("b", "a", start-tip: ">", end-tip: ">", comment: `<->`)
_seq("b", "a", start-tip: ("o", ">"), end-tip: ("o", ">"), comment: `o<->o`)
_seq("b", "a", start-tip: "x", end-tip: "x", comment: `x<->x`)
_seq("b", "a", end-tip: ("o", ">>"), comment: `->>o`)
_seq("b", "a", end-tip: ("o", "\\"), comment: `-\o`)
_seq("b", "a", end-tip: ("o", "\\\\"), comment: `-\\o`)
_seq("b", "a", end-tip: ("o", "/"), comment: `-/o`)
_seq("b", "a", end-tip: ("o", "//"), comment: `-//o`)
_seq("b", "a", start-tip: "x", end-tip: ("o", ">"), comment: `x->o`)
}),
chronos.diagram({
import chronos: *
_par("a", display-name: "Alice")
_par("b", display-name: "Bob")
_seq("a", "a", end-tip: ">", comment: `->`)
_seq("a", "a", end-tip: ">>", comment: `->>`)
_seq("a", "a", end-tip: "\\", comment: `-\`)
_seq("a", "a", end-tip: "\\\\", comment: `-\\`)
_seq("a", "a", end-tip: "/", comment: `-/`)
_seq("a", "a", end-tip: "//", comment: `-//`)
_seq("a", "a", end-tip: "x", comment: `->x`)
_seq("a", "a", start-tip: "x", comment: `x->`)
_seq("a", "a", start-tip: "o", comment: `o->`)
_seq("a", "a", end-tip: ("o", ">"), comment: `->o`)
_seq("a", "a", start-tip: "o", end-tip: ("o", ">"), comment: `o->o`)
_seq("a", "a", start-tip: ">", end-tip: ">", comment: `<->`)
_seq("a", "a", start-tip: ("o", ">"), end-tip: ("o", ">"), comment: `o<->o`)
_seq("a", "a", start-tip: "x", end-tip: "x", comment: `x<->x`)
_seq("a", "a", end-tip: ("o", ">>"), comment: `->>o`)
_seq("a", "a", end-tip: ("o", "\\"), comment: `-\o`)
_seq("a", "a", end-tip: ("o", "\\\\"), comment: `-\\o`)
_seq("a", "a", end-tip: ("o", "/"), comment: `-/o`)
_seq("a", "a", end-tip: ("o", "//"), comment: `-//o`)
_seq("a", "a", start-tip: "x", end-tip: ("o", ">"), comment: `x->o`)
})
)
#chronos.diagram({
import chronos: *
_par("a", display-name: "Alice")
_par("b", display-name: "Bob", show-bottom: false)
_par("c", display-name: "Caleb", show-top: false)
_par("d", display-name: "Danny", show-bottom: false, show-top: false)
_gap()
})

BIN
gallery/ferris.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
gallery/gitea.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
gallery/me.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 530 KiB

BIN
gallery/notes.pdf Normal file

Binary file not shown.

145
gallery/notes.typ Normal file
View File

@ -0,0 +1,145 @@
#import "/src/lib.typ" as chronos: *
#set page(width: auto, height: auto)
#chronos.diagram({
_par("a", display-name: "Alice")
_par("b", display-name: "Bob")
_seq("a", "b", comment: [hello])
_note("left", [this is a first note])
_seq("b", "a", comment: [ok])
_note("right", [this is another note])
_seq("b", "b", comment: [I am thinking])
_note("left", [a note\ can also be defined\ on several lines])
})
#pagebreak()
#chronos.diagram({
_par("a", display-name: "Alice")
_par("b", display-name: "Bob")
_note("left", [This is displayed\ left of Alice.], pos: "a", color: rgb("#00FFFF"))
_note("right", [This is displayed right of Alice.], pos: "a")
_note("over", [This is displayed over Alice.], pos: "a")
_note("over", [This is displayed\ over Bob and Alice.], pos: ("a", "b"), color: rgb("#FFAAAA"))
_note("over", [This is yet another\ example of\ a long note.], pos: ("a", "b"))
})
#pagebreak()
#chronos.diagram({
_par("caller")
_par("server")
_seq("caller", "server", comment: [conReq])
_note("over", [idle], pos: "caller", shape: "hex")
_seq("server", "caller", comment: [conConf])
_note("over", ["r" as rectangle\ "h" as hexagon], pos: "server", shape: "rect")
_note("over", [this is\ on several\ lines], pos: "server", shape: "rect")
_note("over", [this is\ on several\ lines], pos: "caller", shape: "hex")
})
#pagebreak()
#chronos.diagram({
_par("a", display-name: "Alice")
_par("b", display-name: "Bob")
_par("c", display-name: "Charlie")
_seq("a", "b", comment: [m1])
_seq("b", "c", comment: [m2])
_note("over", [Old method for note over all part. with:\ `note over FirstPart, LastPart`.], pos: ("a", "c"))
_note("across", [New method with:\ `note across`.])
_seq("b", "a")
_note("across", [Note across all part.], shape: "hex")
})
#pagebreak()
#chronos.diagram({
_par("a", display-name: "Alice")
_par("b", display-name: "Bob")
_note("over", [initial state of Alice], pos: "a")
_note("over", [initial state of Bob], pos: "b")
_seq("b", "a", comment: [hello])
})
#chronos.diagram({
_par("a", display-name: "Alice")
_par("b", display-name: "Bob")
_par("c", display-name: "Charlie")
_par("d", display-name: "Donald")
_par("e", display-name: "Eddie")
_note("over", [initial state of Alice], pos: "a")
_note("over", [initial state of Bob the builder], pos: "b", aligned: true)
_note("over", [Note 1], pos: "a")
_note("over", [Note 2], pos: "b", aligned: true)
_note("over", [Note 3], pos: "c", aligned: true)
_seq("a", "d")
_note("over", [this is an extremely long note], pos: ("d", "e"))
})
#pagebreak()
#chronos.diagram({
_par("a", display-name: [Alice])
_par("b", display-name: [The *Famous* Bob])
_seq("a", "b", comment: [hello #strike([there])])
_gap()
_seq("b", "a", comment: [ok])
_note("left", [
This is *bold*\
This is _italics_\
This is `monospaced`\
This is #strike([stroked])\
This is #underline([underlined])\
This is #underline([waved])\
])
_seq("a", "b", comment: [A _well formatted_ message])
_note("right", [
This is #box(text([displayed], size: 18pt), fill: rgb("#5F9EA0"))\
#underline([left of]) Alice.
], pos: "a")
_note("left", [
#underline([This], stroke: red) is #text([displayed], fill: rgb("#118888"))\
*#text([left of], fill: rgb("#800080")) #strike([Alice], stroke: red) Bob.*
], pos: "b")
_note("over", [
#underline([This is hosted], stroke: rgb("#FF33FF")) by #box(baseline: 50%, image("gitea.png", width: 1cm, height: 1cm, fit: "contain"))
], pos: ("a", "b"))
})
// TODO
/*
#pagebreak()
#chronos.diagram({
_par("a", display-name: [Alice])
_par("b", display-name: [Bob])
_seq("a", "b", comment: [Hello])
_note("left", [This is a note])
_seq("[", "a", comment: [Test])
_note("left", [This is also a note])
})*/
#pagebreak()
#chronos.diagram({
_seq("Bob", "Alice", comment: [Hello])
_evt("Other", "create")
})

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -0,0 +1,13 @@
#import "/src/lib.typ" as chronos
#set page(
width: auto,
height: auto,
margin: 0.5cm
)
#chronos.diagram({
import chronos: *
_par("Alice")
_par("Bob")
})

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@ -0,0 +1,24 @@
#import "/src/lib.typ" as chronos
#set page(
width: auto,
height: auto,
margin: 0.5cm
)
#chronos.diagram({
import chronos: *
_par("A", display-name: "Alice")
_par("B", display-name: "Bob")
_par("C", display-name: "Charlie")
_par("D", display-name: "Derek")
_seq("A", "B", comment: "hello", enable-dst: true)
_seq("B", "B", comment: "self call", enable-dst: true)
_seq("C", "B", comment: "hello from thread 2", enable-dst: true, lifeline-style: (fill: rgb("#005500")))
_seq("B", "D", comment: "create", create-dst: true)
_seq("B", "C", comment: "done in thread 2", disable-src: true, dashed: true)
_seq("B", "B", comment: "rc", disable-src: true, dashed: true)
_seq("B", "D", comment: "delete", destroy-dst: true)
_seq("B", "A", comment: "success", disable-src: true, dashed: true)
})

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -0,0 +1,17 @@
#import "/src/lib.typ" as chronos
#set page(
width: auto,
height: auto,
margin: 0.5cm
)
#chronos.diagram({
import chronos: *
_par("Alice")
_par("Bob")
_seq("Alice", "Bob", comment: "Hello")
_seq("Bob", "Bob", comment: "Think")
_seq("Bob", "Alice", comment: "Hi")
})

BIN
gallery/typst.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

30
src/consts.typ Normal file
View File

@ -0,0 +1,30 @@
#let Y-SPACE = 10
#let PAR-SPACE = 10
#let COMMENT-PAD = 8
#let LIFELINE-W = 10
#let CREATE-OFFSET = 15
#let DEFAULT-SLANT = 10
#let CROSS-TIP-SIZE = 4
#let CIRCLE-TIP-RADIUS = 3
#let SYM-GAP = 5
#let PAR-PAD = (5pt, 3pt)
#let ACTOR-WIDTH = 20
#let BOUNDARY-HEIGHT = 20
#let CONTROL-HEIGHT = 20
#let ENTITY-HEIGHT = 20
#let DATABASE-WIDTH = 24
#let COLLECTIONS-PAD = (5pt, 3pt)
#let COLLECTIONS-DX = 3
#let COLLECTIONS-DY = 3
#let QUEUE-PAD = (5pt, 3pt)
#let NOTE-PAD = (6, 3)
#let NOTE-CORNER-SIZE = 6
#let NOTE-GAP = 3
#let NOTE-HEX-PAD = (6, 8)
#let COL-DESTRUCTION = rgb("#A80238")
#let COL-GRP-NAME = rgb("#EEEEEE")
#let COL-SEP-NAME = rgb("#EEEEEE")
#let COL-NOTE = rgb("#FEFFDD")

View File

@ -1,49 +1,195 @@
#import "utils.typ": get-group-span, fit-canvas
#import "renderer.typ": render
#import "participant.typ" as participant: _par, PAR-SPECIALS
#let _seq(p1, p2, comment: none, style: auto) = {
#let _gap(size: 20) = {
return ((
type: "seq",
p1: p1,
p2: p2,
comment: comment,
style: style
type: "gap",
size: size
),)
}
#let _par(name, display-name: auto, start-at: 0) = {
#let _evt(participant, event) = {
return ((
type: "par",
name: name,
display-name: if display-name == auto {name} else {display-name},
start-at: start-at
type: "evt",
participant: participant,
event: event,
lifeline-style: auto
),)
}
#let _par-exists(participants, name) = {
for p in participants {
if name == p.name {
return true
}
#let diagram(elements, width: auto) = {
if elements == none {
return
}
return false
}
#let diagram(elements) = {
let participants = ()
for elmt in elements {
let elmts = elements
let i = 0
// Flatten groups
while i < elmts.len() {
let elmt = elmts.at(i)
if elmt.type == "grp" {
let grp-elmts = elmt.elmts
elmt.elmts = elmt.elmts.map(e => {
if e.type == "seq" {
if e.p1 == "?" {
e.p1 = "?" + e.p2
} else if e.p2 == "?" {
e.p2 = e.p1 + "?"
}
}
e
})
elmts.at(i) = elmt
elmts = (
elmts.slice(0, i + 1) +
grp-elmts +
((
type: "grp-end"
),) +
elmts.slice(i+1)
)
}
i += 1
}
// List participants
let linked = ()
let last-seq = none
let last-note = none
for (i, elmt) in elmts.enumerate() {
if elmt.type == "par" {
participants.push(elmt)
} else if elmt.type == "seq" {
if not _par-exists(participants, elmt.p1) {
if not participant._exists(participants, elmt.p1) {
participants.push(_par(elmt.p1).first())
}
if not _par-exists(participants, elmt.p2) {
participants.push(_par(elmt.p2).first())
if not participant._exists(participants, elmt.p2) {
let par = _par(elmt.p2, from-start: not elmt.create-dst).first()
participants.push(par)
} else if elmt.create-dst {
let i = participants.position(p => p.name == elmt.p2)
participants.at(i).from-start = false
}
let p1 = elmt.p1
let p2 = elmt.p2
if elmt.p1 == "?" {
p1 = "?" + elmt.p2
}
if elmt.p2 == "?" {
p2 = elmt.p1 + "?"
}
linked.push(p1)
linked.push(p2)
last-seq = (
elmt: elmt,
i: i,
p1: p1,
p2: p2
)
} else if elmt.type == "note" {
elmt.insert("linked", elmt.pos == none and elmt.side != "across")
if elmt.pos == none and elmt.side != "across" {
let names = participants.map(p => p.name)
let i1 = names.position(n => n == last-seq.p1)
let i2 = names.position(n => n == last-seq.p2)
let pars = ((i1, last-seq.p1), (i2, last-seq.p2)).sorted(key: p => p.first())
if elmt.side == "left" {
elmt.pos = pars.first().last()
} else if elmt.side == "right" {
elmt.pos = pars.last().last()
}
let seq = last-seq.elmt
seq.insert("linked-note", elmt)
elmts.at(last-seq.i) = seq
}
if elmt.aligned {
let n = last-note.elmt
n.aligned-with = elmt
elmts.at(last-note.i) = n
}
elmts.at(i) = elmt
if elmt.side == "left" {
linked.push("[")
} else if elmt.side == "right" {
linked.push("]")
}
last-note = (
elmt: elmt,
i: i
)
} else if elmt.type == "evt" {
let par = elmt.participant
if not participant._exists(participants, par) {
let p = _par(par, from-start: elmt.event != "create").first()
participants.push(p)
} else if elmt.event == "create" {
let i = participants.position(p => p.name == par)
participants.at(i).from-start = false
}
}
}
linked = linked.dedup()
let pars = participants
participants = ()
if "[" in linked {
participants.push(_par("[", invisible: true).first())
}
for (i, p) in pars.enumerate() {
let before = _par("?" + p.name, invisible: true).first()
let after = _par(p.name + "?", invisible: true).first()
if before.name in linked {
if participants.len() == 0 or not participants.last().name.ends-with("?") {
participants.push(before)
} else {
participants.insert(-1, before)
}
}
participants.push(p)
if after.name in linked {
participants.push(after)
}
}
if "]" in linked {
participants.push(_par("]", invisible: true).first())
}
// Add index to participant
for (i, p) in participants.enumerate() {
p.insert("i", i)
participants.at(i) = p
}
// Compute groups spans (horizontal)
for (i, elmt) in elmts.enumerate() {
if elmt.type == "grp" {
let (min-i, max-i) = get-group-span(participants, elmt)
elmts.at(i).insert("min-i", min-i)
elmts.at(i).insert("max-i", max-i)
} else if elmt.type == "seq" {
if elmt.p1 == "?" {
elmts.at(i).p1 = "?" + elmt.p2
} else if elmt.p2 == "?" {
elmts.at(i).p2 = elmt.p1 + "?"
}
}
}
render(participants, elements)
set text(font: "Source Sans 3")
let canvas = render(participants, elmts)
fit-canvas(canvas, width: width)
}
#let from-plantuml(code) = {

54
src/group.typ Normal file
View File

@ -0,0 +1,54 @@
#import "@preview/cetz:0.2.2": draw
#import "consts.typ": *
#let _grp(name, desc: none, type: "default", elmts) = {
return ((
type: "grp",
name: name,
desc: desc,
grp-type: type,
elmts: elmts
),)
}
#let render(x0, x1, y0, y1, group) = {
let shapes = ()
let name = text(group.name, weight: "bold")
let m = measure(box(name))
let w = m.width / 1pt + 15
let h = m.height / 1pt + 6
shapes += draw.rect(
(x0, y0),
(x1, y1)
)
shapes += draw.merge-path(
fill: COL-GRP-NAME,
close: true,
{
draw.line(
(x0, y0),
(x0 + w, y0),
(x0 + w, y0 - h / 2),
(x0 + w - 5, y0 - h),
(x0, y0 - h)
)
}
)
shapes += draw.content(
(x0, y0),
name,
anchor: "north-west",
padding: (left: 5pt, right: 10pt, top: 3pt, bottom: 3pt)
)
if group.desc != none {
shapes += draw.content(
(x0 + w, y0),
text([\[#group.desc\]], weight: "bold", size: .8em),
anchor: "north-west",
padding: 3pt
)
}
return shapes
}

View File

@ -1 +1,8 @@
#import "diagram.typ": diagram, from-plantuml
#import "diagram.typ": diagram, from-plantuml, _gap, _evt
#import "sequence.typ": _seq
#import "group.typ": _grp
#import "participant.typ": _par
#import "separator.typ": _sep
#import "note.typ": _note
#import "sync.typ": _sync

182
src/note.typ Normal file
View File

@ -0,0 +1,182 @@
#import "@preview/cetz:0.2.2": draw
#import "consts.typ": *
#let SIDES = (
"left",
"right",
"over",
"across"
)
#let SHAPES = (
"default",
"rect",
"hex"
)
#let _note(side, content, pos: none, color: COL-NOTE, shape: "default", aligned: false) = {
if side == "over" {
if pos == none {
panic("Pos cannot be none with side 'over'")
}
}
if aligned {
if side != "over" {
panic("Aligned notes can only be over a participant (got side '" + side + "')")
}
}
return ((
type: "note",
side: side,
content: content,
pos: pos,
color: color,
shape: shape,
aligned: aligned,
aligned-with: none
),)
}
#let get-note-box(note) = {
let PAD = if note.shape == "hex" {NOTE-HEX-PAD} else {NOTE-PAD}
let inset = (
left: PAD.last() * 1pt,
right: PAD.last() * 1pt,
top: PAD.first() * 1pt,
bottom: PAD.first() * 1pt,
)
if note.shape == "default" {
inset.right += NOTE-CORNER-SIZE * 1pt
}
if note.side == "left" {
inset.right += NOTE-GAP * 1pt
} else if note.side == "right" {
inset.left += NOTE-GAP * 1pt
}
return box(note.content, inset: inset)
}
#let get-size(note) = {
let PAD = if note.shape == "hex" {NOTE-HEX-PAD} else {NOTE-PAD}
let m = measure(box(note.content))
let w = m.width / 1pt + PAD.last() * 2
let h = m.height / 1pt + PAD.first() * 2
if note.shape == "default" {
w += NOTE-CORNER-SIZE
}
return (
width: w,
height: h
)
}
#let _get-base-x(pars-i, x-pos, note) = {
if note.side == "across" {
return (x-pos.first() + x-pos.last()) / 2
}
if note.side == "over" {
if type(note.pos) == array {
let xs = note.pos.map(par => x-pos.at(pars-i.at(par)))
return (calc.min(..xs) + calc.max(..xs)) / 2
}
}
return x-pos.at(pars-i.at(note.pos))
}
#let render(pars-i, x-pos, note, y, lifelines) = {
let shapes = ()
let PAD = if note.shape == "hex" {NOTE-HEX-PAD} else {NOTE-PAD}
let m = measure(box(note.content))
let w = m.width / 1pt + PAD.last() * 2
let h = m.height / 1pt + PAD.first() * 2
let total-w = w
if note.shape == "default" {
total-w += NOTE-CORNER-SIZE
}
let base-x = _get-base-x(pars-i, x-pos, note)
let i = none
if note.pos != none and type(note.pos) == str {
i = pars-i.at(note.pos)
}
let x0 = base-x
if note.side == "left" {
x0 -= NOTE-GAP
x0 -= total-w
if lifelines.at(i).level != 0 {
x0 -= LIFELINE-W / 2
}
} else if note.side == "right" {
x0 += NOTE-GAP
x0 += lifelines.at(i).level * LIFELINE-W / 2
} else if note.side == "over" or note.side == "across" {
x0 -= total-w / 2
}
let x1 = x0 + w
let x2 = x0 + total-w
let y0 = y
if note.linked {
y0 += h / 2
}
let y1 = y0 - h
if note.shape == "default" {
shapes += draw.merge-path(
stroke: black + .5pt,
fill: note.color,
close: true,
{
draw.line(
(x0, y0),
(x1, y0),
(x2, y0 - NOTE-CORNER-SIZE),
(x2, y1),
(x0, y1)
)
}
)
shapes += draw.line((x1, y0), (x1, y0 - NOTE-CORNER-SIZE), (x2, y0 - NOTE-CORNER-SIZE), stroke: black + .5pt)
} else if note.shape == "rect" {
shapes += draw.rect(
(x0, y0),
(x2, y1),
stroke: black + .5pt,
fill: note.color
)
} else if note.shape == "hex" {
let lx = x0 + PAD.last()
let rx = x2 - PAD.last()
let my = (y0 + y1) / 2
shapes += draw.merge-path(
stroke: black + .5pt,
fill: note.color,
close: true,
{
draw.line(
(lx, y0),
(rx, y0),
(x2, my),
(rx, y1),
(lx, y1),
(x0, my),
)
}
)
}
shapes += draw.content(
((x0 + x1)/2, (y0 + y1)/2),
note.content,
anchor: "center"
)
if note.aligned-with == none and (note.pos != none or note.side == "across") {
y -= h
}
let r = (y, shapes)
return r
}

357
src/participant.typ Normal file
View File

@ -0,0 +1,357 @@
#import "@preview/cetz:0.2.2": draw
#import "consts.typ": *
#let PAR-SPECIALS = "?[]"
#let SHAPES = (
"participant",
"actor",
"boundary",
"control",
"entity",
"database",
"collections",
"queue",
"custom"
)
#let _par(
name,
display-name: auto,
from-start: true,
invisible: false,
shape: "participant",
color: rgb("#E2E2F0"),
custom-image: none,
show-bottom: true,
show-top: true,
) = {
return ((
type: "par",
name: name,
display-name: if display-name == auto {name} else {display-name},
from-start: from-start,
invisible: invisible,
shape: shape,
color: color,
custom-image: custom-image,
show-bottom: show-bottom,
show-top: show-top
),)
}
#let _exists(participants, name) = {
if name == "?" or name == "[" or name == "]" {
return true
}
for p in participants {
if name == p.name {
return true
}
}
return false
}
#let get-size(par) = {
if par.invisible {
return (width: 0pt, height: 0pt)
}
let m = measure(box(par.display-name))
let w = m.width
let h = m.height
let (shape-w, shape-h) = (
participant: (w + PAR-PAD.last() * 2, h + PAR-PAD.first() * 2),
actor: (ACTOR-WIDTH * 1pt, ACTOR-WIDTH * 2pt + SYM-GAP * 1pt + h),
boundary: (BOUNDARY-HEIGHT * 2pt, BOUNDARY-HEIGHT * 1pt + SYM-GAP * 1pt + h),
control: (CONTROL-HEIGHT * 1pt, CONTROL-HEIGHT * 1pt + SYM-GAP * 1pt + h),
entity: (ENTITY-HEIGHT * 1pt, ENTITY-HEIGHT * 1pt + 2pt + SYM-GAP * 1pt + h),
database: (DATABASE-WIDTH * 1pt, DATABASE-WIDTH * 4pt / 3 + SYM-GAP * 1pt + h),
collections: (
w + COLLECTIONS-PAD.last() * 2 + calc.abs(COLLECTIONS-DX) * 1pt,
h + COLLECTIONS-PAD.first() * 2 + calc.abs(COLLECTIONS-DY) * 1pt,
),
queue: (
w + QUEUE-PAD.last() * 2 + 3 * (h + QUEUE-PAD.first() * 2) / 4,
h + QUEUE-PAD.first() * 2
),
custom: (
measure(par.custom-image).width,
measure(par.custom-image).height + SYM-GAP * 1pt + h
)
).at(par.shape)
return (
width: calc.max(w, shape-w),
height: calc.max(h, shape-h)
)
}
#let _render-participant(x, y, p, m, bottom) = {
let w = m.width / 1pt
let h = m.height / 1pt
let x0 = x - w / 2 - PAR-PAD.last() / 1pt
let x1 = x + w / 2 + PAR-PAD.last() / 1pt
let y0 = y + h + PAR-PAD.first() / 1pt * 2
if bottom {
y0 = y
}
let y1 = y0 - h - PAR-PAD.first() / 1pt * 2
draw.rect(
(x0, y0),
(x1, y1),
radius: 2pt,
fill: p.color,
stroke: black + .5pt
)
draw.content(
((x0 + x1) / 2, (y0 + y1) / 2),
p.display-name,
anchor: "center"
)
}
#let _render-actor(x, y, p, m, bottom) = {
let w2 = ACTOR-WIDTH / 2
let head-r = ACTOR-WIDTH / 4
let height = ACTOR-WIDTH * 2
let arms-y = height * 0.375
let y0 = if bottom {y - m.height / 1pt - SYM-GAP} else {y + m.height / 1pt + height + SYM-GAP}
draw.circle(
(x, y0 - head-r),
radius: head-r,
fill: p.color,
stroke: black + .5pt
)
draw.line((x, y0 - head-r * 2), (x, y0 - height + w2), stroke: black + .5pt)
draw.line((x - w2, y0 - arms-y), (x + w2, y0 - arms-y), stroke: black + .5pt)
draw.line((x - w2, y0 - height), (x, y0 - height + w2), (x + w2, y0 - height), stroke: black + .5pt)
draw.content(
(x, y),
p.display-name,
anchor: if bottom {"north"} else {"south"}
)
}
#let _render-boundary(x, y, p, m, bottom) = {
let circle-r = BOUNDARY-HEIGHT / 2
let y0 = if bottom {y - m.height / 1pt - SYM-GAP} else {y + m.height / 1pt + BOUNDARY-HEIGHT + SYM-GAP}
let x0 = x - BOUNDARY-HEIGHT
let y1 = y0 - circle-r
let y2 = y0 - BOUNDARY-HEIGHT
draw.circle(
(x + circle-r, y1),
radius: circle-r,
fill: p.color,
stroke: black + .5pt
)
draw.line(
(x0, y0), (x0, y2),
stroke: black + .5pt
)
draw.line(
(x0, y1), (x, y1),
stroke: black + .5pt
)
draw.content(
(x, y),
p.display-name,
anchor: if bottom {"north"} else {"south"}
)
}
#let _render-control(x, y, p, m, bottom) = {
let r = CONTROL-HEIGHT / 2
let y0 = if bottom {y - m.height / 1pt - SYM-GAP} else {y + m.height / 1pt + CONTROL-HEIGHT + SYM-GAP}
draw.circle(
(x, y0 - r),
radius: r,
fill: p.color,
stroke: black + .5pt
)
draw.mark((x, y0), (x - r / 2, y0), symbol: "stealth", fill: black)
draw.content(
(x, y),
p.display-name,
anchor: if bottom {"north"} else {"south"}
)
}
#let _render-entity(x, y, p, m, bottom) = {
let r = ENTITY-HEIGHT / 2
let y0 = if bottom {y - m.height / 1pt - SYM-GAP} else {y + m.height / 1pt + ENTITY-HEIGHT + SYM-GAP}
let y1 = y0 - ENTITY-HEIGHT - 1.5
draw.circle(
(x, y0 - r),
radius: r,
fill: p.color,
stroke: black + .5pt
)
draw.line(
(x - r, y1),
(x + r, y1),
stroke: black + .5pt
)
draw.content(
(x, y),
p.display-name,
anchor: if bottom {"north"} else {"south"}
)
}
#let _render-database(x, y, p, m, bottom) = {
let height = DATABASE-WIDTH * 4 / 3
let rx = DATABASE-WIDTH / 2
let ry = rx / 2
let y0 = if bottom {y - m.height / 1pt - SYM-GAP} else {y + m.height / 1pt + height + SYM-GAP}
let y1 = y0 - height
draw.merge-path(
close: true,
fill: p.color,
stroke: black + .5pt,
{
draw.bezier((x - rx, y0 - ry), (x, y0), (x - rx, y0 - ry/2), (x - rx/2, y0))
draw.bezier((), (x + rx, y0 - ry), (x + rx/2, y0), (x + rx, y0 - ry/2))
draw.line((), (x + rx, y1 + ry))
draw.bezier((), (x, y1), (x + rx, y1 + ry/2), (x + rx/2, y1))
draw.bezier((), (x - rx, y1 + ry), (x - rx/2, y1), (x - rx, y1 + ry/2))
}
)
draw.merge-path(
stroke: black + .5pt,
{
draw.bezier((x - rx, y0 - ry), (x, y0 - ry*2), (x - rx, y0 - 3*ry/2), (x - rx/2, y0 - ry*2))
draw.bezier((), (x + rx, y0 - ry), (x + rx/2, y0 - ry*2), (x + rx, y0 - 3*ry/2))
}
)
draw.content(
(x, y),
p.display-name,
anchor: if bottom {"north"} else {"south"}
)
}
#let _render-collections(x, y, p, m, bottom) = {
let w = m.width / 1pt
let h = m.height / 1pt
let dx = COLLECTIONS-DX
let dy = COLLECTIONS-DY
let total-w = w + PAR-PAD.last() * 2 / 1pt + calc.abs(dx)
let total-h = h + PAR-PAD.first() * 2 / 1pt + calc.abs(dy)
let x0 = x - total-w / 2
let x1 = x0 + calc.abs(dx)
let x3 = x0 + total-w
let x2 = x3 - calc.abs(dx)
let y0 = if bottom {y} else {y + total-h}
let y1 = y0 - calc.abs(dy)
let y3 = y0 - total-h
let y2 = y3 + calc.abs(dy)
let r1 = (x1, y0, x3, y2)
let r2 = (x0, y1, x2, y3)
if dx < 0 {
r1.at(0) = x0
r1.at(2) = x2
r2.at(0) = x1
r2.at(2) = x3
}
if dy < 0 {
r1.at(1) = y1
r1.at(3) = y3
r2.at(1) = y0
r2.at(3) = y2
}
draw.rect(
(r1.at(0), r1.at(1)),
(r1.at(2), r1.at(3)),
fill: p.color,
stroke: black + .5pt
)
draw.rect(
(r2.at(0), r2.at(1)),
(r2.at(2), r2.at(3)),
fill: p.color,
stroke: black + .5pt
)
draw.content(
((r2.at(0) + r2.at(2)) / 2, (r2.at(1) + r2.at(3)) / 2),
p.display-name,
anchor: "center"
)
}
#let _render-queue(x, y, p, m, bottom) = {
let w = (m.width + QUEUE-PAD.last() * 2) / 1pt
let h = (m.height + QUEUE-PAD.first() * 2) / 1pt
let total-h = h
let ry = total-h / 2
let rx = ry / 2
let total-w = w + 3 + 3 * rx
let x0 = x - total-w / 2
let y0 = if bottom {y} else {y + total-h}
let y1 = y0 - total-h
let x-left = x0 + rx
let x-right = x-left + w + rx
draw.merge-path(
close: true,
fill: p.color,
stroke: black + .5pt,
{
draw.bezier((x-right, y0), (x-right + rx, y0 - ry), (x-right + rx/2, y0), (x-right + rx, y0 - ry/2))
draw.bezier((), (x-right, y1), (x-right + rx, y1 + ry/2), (x-right + rx/2, y1))
draw.line((), (x-left, y1))
draw.bezier((), (x-left - rx, y0 - ry), (x-left - rx/2, y1), (x-left - rx, y1 + ry/2))
draw.bezier((), (x-left, y0), (x-left - rx, y0 - ry/2), (x-left - rx/2, y0))
}
)
draw.merge-path(
stroke: black + .5pt,
{
draw.bezier((x-right, y0), (x-right - rx, y0 - ry), (x-right - rx/2, y0), (x-right - rx, y0 - ry/2))
draw.bezier((), (x-right, y1), (x-right - rx, y1 + ry/2), (x-right - rx/2, y1))
}
)
draw.content(
((x-left + x-right - rx) / 2, y0 - ry),
p.display-name,
anchor: "center"
)
}
#let _render-custom(x, y, p, m, bottom) = {
let image-m = measure(p.custom-image)
let y0 = if bottom {y - m.height / 1pt - SYM-GAP} else {y + m.height / 1pt + image-m.height / 1pt + SYM-GAP}
draw.content((x - image-m.width / 2pt, y0), p.custom-image, anchor: "north-west")
draw.content(
(x, y),
p.display-name,
anchor: if bottom {"north"} else {"south"}
)
}
#let render(x-pos, p, y: 0, bottom: false) = {
let m = measure(box(p.display-name))
let func = (
participant: _render-participant,
actor: _render-actor,
boundary: _render-boundary,
control: _render-control,
entity: _render-entity,
database: _render-database,
collections: _render-collections,
queue: _render-queue,
custom: _render-custom,
).at(p.shape)
func(x-pos.at(p.i), y, p, m, bottom)
}

View File

@ -1,19 +1,36 @@
#import "@preview/cetz:0.2.2": canvas, draw
#import "utils.typ": get-participants-i, get-style
#import "group.typ"
#import "participant.typ"
#import participant: PAR-SPECIALS
#import "sequence.typ"
#import "separator.typ"
#import "sync.typ"
#import "consts.typ": *
#import "note.typ" as note: get-note-box
#let X-SPACE = 2
#let Y-SPACE = 30
#let get-participants-i(participants) = {
let pars-i = (:)
for (i, p) in participants.enumerate() {
pars-i.insert(p.name, i)
}
return pars-i
}
#let DEBUG-INVISIBLE = false
#let get-columns-width(participants, elements) = {
participants = participants.map(p => {
p.insert("lifeline-lvl", 0)
p.insert("max-lifelines", 0)
p
})
let pars-i = get-participants-i(participants)
let cells = ()
// Unwrap syncs
let i = 0
while i < elements.len() {
let elmt = elements.at(i)
if elmt.type == "sync" {
elements = elements.slice(0, i + 1) + elmt.elmts + elements.slice(i + 1)
}
i += 1
}
// Compute max lifeline levels
for elmt in elements {
if elmt.type == "seq" {
let com = if elmt.comment == none {""} else {elmt.comment}
@ -27,82 +44,384 @@
cell: box(com, inset: 3pt)
)
)
if elmt.disable-src or elmt.destroy-src {
let p = participants.at(i1)
p.lifeline-lvl -= 1
participants.at(i1) = p
}
if elmt.disable-dst {
let p = participants.at(i2)
p.lifeline-lvl -= 1
participants.at(i2) = p
}
if elmt.enable-dst {
let p = participants.at(i2)
p.lifeline-lvl += 1
p.max-lifelines = calc.max(p.max-lifelines, p.lifeline-lvl)
participants.at(i2) = p
}
} else if elmt.type == "evt" {
let par-name = elmt.participant
let i = pars-i.at(par-name)
let par = participants.at(i)
if elmt.event == "disable" or elmt.event == "destroy" {
par.lifeline-lvl -= 1
} else if elmt.event == "enable" {
par.lifeline-lvl += 1
par.max-lifelines = calc.max(par.max-lifelines, par.lifeline-lvl)
}
participants.at(i) = par
} else if elmt.type == "note" {
let (p1, p2) = (none, none)
let cell = none
if elmt.side == "left" {
p1 = "["
p2 = elmt.pos
cell = get-note-box(elmt)
} else if elmt.side == "right" {
p1 = elmt.pos
p2 = "]"
cell = get-note-box(elmt)
} else if elmt.side == "over" {
if elmt.aligned-with != none {
let box1 = get-note-box(elmt)
let box2 = get-note-box(elmt.aligned-with)
let m1 = measure(box1)
let m2 = measure(box2)
cell = box(width: (m1.width + m2.width) / 2, height: calc.max(m1.height, m2.height))
p1 = elmt.pos
p2 = elmt.aligned-with.pos
}
}
if p1 != none and p2 != none and cell != none {
let i1 = pars-i.at(p1)
let i2 = pars-i.at(p2)
cells.push(
(
elmt: elmt,
i1: calc.min(i1, i2),
i2: calc.max(i1, i2),
cell: cell
)
)
}
}
}
let widths = participants.slice(0, -1).map(_ => 0)
// Compute column widths
// Compute minimum widths for participant names and shapes
let widths = ()
for i in range(participants.len() - 1) {
let p1 = participants.at(i)
let p2 = participants.at(i + 1)
let m1 = participant.get-size(p1)
let m2 = participant.get-size(p2)
let w1 = m1.width
let w2 = m2.width
widths.push(w1 / 2pt + w2 / 2pt + PAR-SPACE)
}
// Compute minimum width for over notes
for n in elements.filter(e => (e.type == "note" and
e.side == "over" and
type(e.pos) == str)) {
let m = note.get-size(n)
let i = pars-i.at(n.pos)
if i < widths.len() {
widths.at(i) = calc.max(
widths.at(i),
m.width / 2 + NOTE-GAP
)
}
if i > 0 {
widths.at(i - 1) = calc.max(
widths.at(i - 1),
m.width / 2 + NOTE-GAP
)
}
}
// Compute minimum width for simple sequences (spanning 1 column)
for cell in cells.filter(c => c.i2 - c.i1 == 1) {
let m = measure(cell.cell)
widths.at(cell.i1) = calc.max(
widths.at(cell.i1),
m.width / 1pt
m.width / 1pt + COMMENT-PAD
)
}
// Compute minimum width for self sequences
for cell in cells.filter(c => c.elmt.type == "seq" and c.i1 == c.i2) {
let m = measure(cell.cell)
let i = cell.i1
if cell.elmt.flip {
i -= 1
}
if 0 <= i and i < widths.len() {
widths.at(i) = calc.max(
widths.at(i),
m.width / 1pt + COMMENT-PAD
)
}
}
// Compute remaining widths for longer sequences (spanning multiple columns)
let multicol-cells = cells.filter(c => c.i2 - c.i1 > 1)
multicol-cells = multicol-cells.sorted(key: c => {
c.i1 * 1000 + c.i2
})
for cell in multicol-cells {
let m = measure(cell.cell)
widths.at(cell.i2 - 1) = calc.max(
widths.at(cell.i2 - 1),
m.width / 1pt + COMMENT-PAD - widths.slice(cell.i1, cell.i2 - 1).sum()
)
}
// Add lifeline widths
for (i, w) in widths.enumerate() {
let p1 = participants.at(i)
let p2 = participants.at(i + 1)
let w = w + p1.max-lifelines * LIFELINE-W / 2
if p2.max-lifelines != 0 {
w += LIFELINE-W / 2
}
widths.at(i) = w
}
return widths
}
#let render(participants, elements) = context canvas(length: 1pt, {
let shapes = ()
let pars-i = get-participants-i(participants)
let widths = get-columns-width(participants, elements)
// Compute each column's X position
let x-pos = (0,)
for width in widths {
x-pos.push(x-pos.last() + width)
}
// Draw participants
for (i, p) in participants.enumerate() {
draw.content(
(x-pos.at(i), 0),
p.display-name,
name: p.name,
frame: "rect",
padding: (5pt, 3pt),
anchor: "south"
)
let draw-seq = sequence.render.with(pars-i, x-pos, participants)
let draw-group = group.render.with()
let draw-sep = separator.render.with(x-pos)
let draw-par = participant.render.with(x-pos)
let draw-note = note.render.with(pars-i, x-pos)
let draw-sync = sync.render.with(pars-i, x-pos, participants)
// Draw participants (start)
for p in participants {
if p.from-start and not p.invisible and p.show-top {
shapes += draw-par(p)
}
}
let y = -Y-SPACE
// Draw sequences
let y = 0
let groups = ()
let lifelines = participants.map(_ => (
level: 0,
lines: ()
))
// Draw elemnts
for elmt in elements {
// Sequences
if elmt.type == "seq" {
let x1 = x-pos.at(pars-i.at(elmt.p1))
let x2 = x-pos.at(pars-i.at(elmt.p2))
draw.line(
(x1, y),
(x2, y),
mark: (end: "straight")
let shps
(y, lifelines, shps) = draw-seq(elmt, y, lifelines)
shapes += shps
// Groups (start) -> reserve space for labels + store position
} else if elmt.type == "grp" {
y -= Y-SPACE
let m = measure(
box(
elmt.name,
inset: (left: 5pt, right: 5pt, top: 3pt, bottom: 3pt),
)
)
if elmt.comment != none {
groups = groups.map(g => {
if g.at(1).min-i == elmt.min-i { g.at(2) += 1 }
if g.at(1).max-i == elmt.max-i { g.at(3) += 1 }
g
})
groups.push((y, elmt, 0, 0))
y -= m.height / 1pt
// Groups (end) -> actual drawing
} else if elmt.type == "grp-end" {
y -= Y-SPACE
let (start-y, group, start-lvl, end-lvl) = groups.pop()
let x0 = x-pos.at(group.min-i) - start-lvl * 10 - 20
let x1 = x-pos.at(group.max-i) + end-lvl * 10 + 20
shapes += draw-group(x0, x1, start-y, y, group)
// Separator
} else if elmt.type == "sep" {
let shps
(y, shps) = draw-sep(elmt, y)
shapes += shps
// Gap
} else if elmt.type == "gap" {
y -= elmt.size
// Event
} else if elmt.type == "evt" {
let par-name = elmt.participant
let i = pars-i.at(par-name)
let par = participants.at(i)
let line = lifelines.at(i)
if elmt.event == "disable" {
line.level -= 1
line.lines.push(("disable", y))
} else if elmt.event == "destroy" {
line.lines.push(("destroy", y))
} else if elmt.event == "enable" {
line.level += 1
line.lines.push(("enable", y, elmt.lifeline-style))
} else if elmt.event == "create" {
y -= CREATE-OFFSET
shapes += participant.render(x-pos, par, y: y)
line.lines.push(("create", y))
}
lifelines.at(i) = line
// Note
} else if elmt.type == "note" {
if not elmt.linked {
if not elmt.aligned {
y -= Y-SPACE
}
let shps
(y, shps) = draw-note(elmt, y, lifelines)
shapes += shps
}
// Synched sequences
} else if elmt.type == "sync" {
let shps
(y, lifelines, shps) = draw-sync(elmt, y, lifelines)
shapes += shps
}
}
y -= Y-SPACE
// Draw vertical lines + lifelines + end participants
shapes += draw.on-layer(-1, {
if DEBUG-INVISIBLE {
for p in participants.filter(p => p.invisible) {
let color = if p.name.starts-with("?") {green} else if p.name.ends-with("?") {red} else {blue}
let x = x-pos.at(p.i)
draw.line(
(x, 0),
(x, y),
stroke: (paint: color, dash: "dotted")
)
draw.content(
(calc.min(x1, x2), y),
elmt.comment,
anchor: "south-west",
padding: 3pt
(x, 0),
p.display-name,
anchor: "west",
angle: 90deg
)
}
y -= Y-SPACE
}
}
// Draw vertical lines + end participants
draw.on-layer(-1, {
for (i, p) in participants.enumerate() {
let x = x-pos.at(i)
for p in participants.filter(p => not p.invisible) {
let x = x-pos.at(p.i)
// Draw vertical line
let last-y = 0
let rects = ()
let destructions = ()
let lines = ()
// Compute lifeline rectangles + destruction positions
for line in lifelines.at(p.i).lines {
let event = line.first()
if event == "create" {
last-y = line.at(1)
} else if event == "enable" {
if lines.len() == 0 {
draw.line(
(x, last-y),
(x, line.at(1)),
stroke: (
dash: "dashed",
paint: gray.darken(40%),
thickness: .5pt
)
)
}
lines.push(line)
} else if event == "disable" or event == "destroy" {
let lvl = 0
if lines.len() != 0 {
let l = lines.pop()
lvl = lines.len()
rects.push((
x + lvl * LIFELINE-W / 2,
l.at(1),
line.at(1),
l.at(2)
))
last-y = line.at(1)
}
if event == "destroy" {
destructions.push((x + lvl * LIFELINE-W / 2, line.at(1)))
}
}
}
draw.line(
(x, 0),
(x, last-y),
(x, y),
stroke: (dash: "dashed", paint: gray.darken(40%))
)
draw.content(
(x, y),
p.display-name,
name: p.name,
frame: "rect",
padding: (5pt, 3pt),
anchor: "north"
stroke: (
dash: "dashed",
paint: gray.darken(40%),
thickness: .5pt
)
)
// Draw lifeline rectangles (reverse for bottom to top)
for rect in rects.rev() {
let (cx, y0, y1, style) = rect
let style = get-style("lifeline", style)
draw.rect(
(cx - LIFELINE-W / 2, y0),
(cx + LIFELINE-W / 2, y1),
..style
)
}
// Draw lifeline destructions
for dest in destructions {
let (cx, cy) = dest
draw.line((cx - 8, cy - 8), (cx + 8, cy + 8), stroke: COL-DESTRUCTION + 2pt)
draw.line((cx - 8, cy + 8), (cx + 8, cy - 8), stroke: COL-DESTRUCTION + 2pt)
}
// Draw participants (end)
if p.show-bottom {
draw-par(p, y: y, bottom: true)
}
}
})
shapes
})

55
src/separator.typ Normal file
View File

@ -0,0 +1,55 @@
#import "@preview/cetz:0.2.2": draw
#import "consts.typ": *
#let _sep(name) = {
return ((
type: "sep",
name: name
),)
}
#let render(x-pos, elmt, y) = {
let shapes = ()
y -= Y-SPACE
let x0 = x-pos.first() - 20
let x1 = x-pos.last() + 20
let m = measure(
box(
elmt.name,
inset: (left: 3pt, right: 3pt, top: 5pt, bottom: 5pt)
)
)
let w = m.width / 1pt
let h = m.height / 1pt
let cx = (x0 + x1) / 2
let xl = cx - w / 2
let xr = cx + w / 2
y -= h / 2
shapes += draw.rect(
(x0, y),
(x1, y - 3),
stroke: none,
fill: white
)
shapes += draw.line((x0, y), (x1, y))
//shapes += draw.line((x0, y), (xl, y))
//shapes += draw.line((xr, y), (x1, y))
y -= 3
shapes += draw.line((x0, y), (x1, y))
//shapes += draw.line((x0, y), (xl, y))
//shapes += draw.line((xr, y), (x1, y))
shapes += draw.content(
((x0 + x1) / 2, y + 1.5),
elmt.name,
anchor: "center",
padding: (5pt, 3pt),
frame: "rect",
fill: COL-SEP-NAME
)
y -= h / 2
let r = (y, shapes)
return r
}

370
src/sequence.typ Normal file
View File

@ -0,0 +1,370 @@
#import "@preview/cetz:0.2.2": draw, vector
#import "consts.typ": *
#import "participant.typ"
#import "note.typ"
#let get-arrow-marks(sym, color) = {
if type(sym) == array {
return sym.map(s => get-arrow-marks(s, color))
}
(
"": none,
">": (symbol: ">", fill: color),
">>": (symbol: "straight"),
"\\": (symbol: ">", fill: color, harpoon: true, flip: true),
"\\\\": (symbol: "straight", harpoon: true, flip: true),
"/": (symbol: ">", fill: color, harpoon: true),
"//": (symbol: "straight", harpoon: true),
"x": none,
"o": none,
).at(sym)
}
#let reverse-arrow-mark(mark) = {
if type(mark) == array {
return mark.map(m => reverse-arrow-mark(m))
}
let mark2 = mark
if type(mark) == dictionary and mark.at("harpoon", default: false) {
let flipped = mark.at("flip", default: false)
mark2.insert("flip", not flipped)
}
return mark2
}
#let is-tip-of-type(type_, tip) = {
if type(tip) == str and tip == type_ {
return true
}
if type(tip) == array and tip.contains(type_) {
return true
}
return false
}
#let is-circle-tip = is-tip-of-type.with("o")
#let is-cross-tip = is-tip-of-type.with("x")
#let _seq(
p1,
p2,
comment: none,
comment-align: "left",
dashed: false,
start-tip: "",
end-tip: ">",
color: black,
flip: false,
enable-dst: false,
create-dst: false,
disable-dst: false,
destroy-dst: false,
disable-src: false,
destroy-src: false,
lifeline-style: auto,
slant: none
) = {
return ((
type: "seq",
p1: p1,
p2: p2,
comment: comment,
comment-align: comment-align,
dashed: dashed,
start-tip: start-tip,
end-tip: end-tip,
color: color,
flip: flip,
enable-dst: enable-dst,
create-dst: create-dst,
disable-dst: disable-dst,
destroy-dst: destroy-dst,
disable-src: disable-src,
destroy-src: destroy-src,
lifeline-style: lifeline-style,
slant: slant
),)
}
#let render(pars-i, x-pos, participants, elmt, y, lifelines) = {
let shapes = ()
y -= Y-SPACE
let h = 0
// Reserve space for comment
if elmt.comment != none {
h = calc.max(h, measure(box(elmt.comment)).height / 1pt + 6)
}
if "linked-note" in elmt {
h = calc.max(h, note.get-size(elmt.linked-note).height / 2)
}
y -= h
let i1 = pars-i.at(elmt.p1)
let i2 = pars-i.at(elmt.p2)
let start-info = (
i: i1,
x: x-pos.at(i1),
y: y,
ll-lvl: lifelines.at(i1).level * LIFELINE-W / 2
)
let end-info = (
i: i2,
x: x-pos.at(i2),
y: y,
ll-lvl: lifelines.at(i2).level * LIFELINE-W / 2
)
let slant = if elmt.slant == auto {
DEFAULT-SLANT
} else if elmt.slant != none {
elmt.slant
} else {
0
}
end-info.y -= slant
if elmt.p1 == elmt.p2 {
end-info.y -= 10
}
if elmt.disable-src {
let src-line = lifelines.at(i1)
src-line.level -= 1
src-line.lines.push(("disable", start-info.y))
lifelines.at(i1) = src-line
}
if elmt.destroy-src {
let src-line = lifelines.at(i1)
src-line.lines.push(("destroy", start-info.y))
lifelines.at(i1) = src-line
}
if elmt.disable-dst {
let dst-line = lifelines.at(i2)
dst-line.level -= 1
dst-line.lines.push(("disable", end-info.y))
lifelines.at(i2) = dst-line
}
if elmt.destroy-dst {
let dst-line = lifelines.at(i2)
dst-line.lines.push(("destroy", end-info.y))
lifelines.at(i2) = dst-line
}
if elmt.enable-dst {
let dst-line = lifelines.at(i2)
dst-line.level += 1
lifelines.at(i2) = dst-line
}
if elmt.create-dst {
let par = participants.at(i2)
let m = measure(box(par.display-name))
let f = if i1 > i2 {-1} else {1}
end-info.x -= (m.width + PAR-PAD.last() * 2) / 2pt * f
shapes += participant.render(x-pos, par, y: end-info.y - CREATE-OFFSET)
}
end-info.ll-lvl = lifelines.at(i2).level * LIFELINE-W / 2
// Compute left/right position at start/end
start-info.insert("lx", start-info.x)
if start-info.ll-lvl != 0 { start-info.lx -= LIFELINE-W / 2 }
end-info.insert("lx", end-info.x)
if end-info.ll-lvl != 0 { end-info.lx -= LIFELINE-W / 2 }
start-info.insert("rx", start-info.x + start-info.ll-lvl)
end-info.insert("rx", end-info.x + end-info.ll-lvl)
// Choose correct points to link
let x1 = start-info.rx
let x2 = end-info.lx
if (start-info.i > end-info.i) {
x1 = start-info.lx
x2 = end-info.rx
}
let style = (
mark: (
start: get-arrow-marks(elmt.start-tip, elmt.color),
end: get-arrow-marks(elmt.end-tip, elmt.color),
scale: 1.2
),
stroke: (
dash: if elmt.dashed {(2pt,2pt)} else {"solid"},
paint: elmt.color,
thickness: .5pt
)
)
let y0 = start-info.y
if "linked-note" in elmt {
let shps = note.render(pars-i, x-pos, elmt.linked-note, start-info.y, lifelines).last()
shapes += shps
}
let flip-mark = end-info.i <= start-info.i
if elmt.flip {
flip-mark = not flip-mark
}
if flip-mark {
style.mark.end = reverse-arrow-mark(style.mark.end)
}
let pts
let comment-pt
let comment-anchor
let comment-angle = 0deg
if elmt.p1 == elmt.p2 {
if elmt.flip {
x1 = start-info.lx
} else {
x2 = end-info.rx
}
let x-mid = if elmt.flip {
calc.min(x1, x2) - 20
} else {
calc.max(x1, x2) + 20
}
pts = (
(x1, start-info.y),
(x-mid, start-info.y),
(x-mid, end-info.y),
(x2, end-info.y)
)
if elmt.comment != none {
comment-anchor = (
start: if x-mid < x1 {"south-east"} else {"south-west"},
end: if x-mid < x1 {"south-west"} else {"south-east"},
left: "south-west",
right: "south-east",
center: "south",
).at(elmt.comment-align)
comment-pt = (
start: pts.first(),
end: pts.at(1),
left: if x-mid < x1 {pts.at(1)} else {pts.first()},
right: if x-mid < x1 {pts.first()} else {pts.at(1)},
center: (pts.first(), 50%, pts.at(1))
).at(elmt.comment-align)
}
} else {
pts = (
(x1, start-info.y),
(x2, end-info.y)
)
if elmt.comment != none {
let start-pt = pts.first()
let end-pt = pts.last()
if elmt.start-tip != "" {
start-pt = (pts.first(), COMMENT-PAD, pts.last())
}
if elmt.end-tip != "" {
end-pt = (pts.last(), COMMENT-PAD, pts.first())
}
comment-pt = (
start: start-pt,
end: end-pt,
left: if x2 < x1 {end-pt} else {start-pt},
right: if x2 < x1 {start-pt} else {end-pt},
center: (start-pt, 50%, end-pt)
).at(elmt.comment-align)
comment-anchor = (
start: if x2 < x1 {"south-east"} else {"south-west"},
end: if x2 < x1 {"south-west"} else {"south-east"},
left: "south-west",
right: "south-east",
center: "south",
).at(elmt.comment-align)
}
let (p1, p2) = pts
if x2 < x1 {
(p1, p2) = (p2, p1)
}
comment-angle = vector.angle2(p1, p2)
}
// Start circle tip
if is-circle-tip(elmt.start-tip) {
shapes += draw.circle(pts.first(), radius: CIRCLE-TIP-RADIUS, stroke: elmt.color, fill: none, name: "_circle-start-tip")
pts.at(0) = "_circle-start-tip"
// Start cross tip
} else if is-cross-tip(elmt.start-tip) {
let size = CROSS-TIP-SIZE
let cross-pt = (pts.first(), size * 2, pts.at(1))
shapes += draw.line(
(rel: (-size, -size), to: cross-pt),
(rel: (size, size), to: cross-pt),
stroke: elmt.color + 1.5pt
)
shapes += draw.line(
(rel: (-size, size), to: cross-pt),
(rel: (size, -size), to: cross-pt),
stroke: elmt.color + 1.5pt
)
pts.at(0) = cross-pt
}
// End circle tip
if is-circle-tip(elmt.end-tip) {
shapes += draw.circle(pts.last(), radius: 3, stroke: elmt.color, fill: none, name: "_circle-end-tip")
pts.at(pts.len() - 1) = "_circle-end-tip"
// End cross tip
} else if is-cross-tip(elmt.end-tip) {
let size = CROSS-TIP-SIZE
let cross-pt = (pts.last(), size * 2, pts.at(pts.len() - 2))
shapes += draw.line(
(rel: (-size, -size), to: cross-pt),
(rel: (size, size), to: cross-pt),
stroke: elmt.color + 1.5pt
)
shapes += draw.line(
(rel: (-size, size), to: cross-pt),
(rel: (size, -size), to: cross-pt),
stroke: elmt.color + 1.5pt
)
pts.at(pts.len() - 1) = cross-pt
}
shapes += draw.line(..pts, ..style)
if elmt.comment != none {
shapes += draw.content(
comment-pt,
elmt.comment,
anchor: comment-anchor,
angle: comment-angle,
padding: 3pt
)
}
if elmt.enable-dst {
let dst-line = lifelines.at(i2)
dst-line.lines.push(("enable", end-info.y, elmt.lifeline-style))
lifelines.at(i2) = dst-line
}
if elmt.create-dst {
end-info.y -= CREATE-OFFSET
let dst-line = lifelines.at(i2)
dst-line.lines.push(("create", end-info.y))
lifelines.at(i2) = dst-line
}
if "linked-note" in elmt {
let m = note.get-size(elmt.linked-note)
end-info.y = calc.min(end-info.y, y0 - m.height / 2)
}
let r = (end-info.y, lifelines, shapes)
return r
}

27
src/sync.typ Normal file
View File

@ -0,0 +1,27 @@
#import "sequence.typ"
#let _sync(elmts) = {
return ((
type: "sync",
elmts: elmts
),)
}
#let render(pars-i, x-pos, participants, elmt, y, lifelines) = {
let draw-seq = sequence.render.with(pars-i, x-pos, participants)
let shapes = ()
let end-y = y
for e in elmt.elmts {
let yi
let shps
(yi, lifelines, shps) = draw-seq(e, y, lifelines)
shapes += shps
end-y = calc.min(end-y, yi)
}
let r = (end-y, lifelines, shapes)
return r
}

70
src/utils.typ Normal file
View File

@ -0,0 +1,70 @@
#let get-participants-i(participants) = {
let pars-i = (:)
for (i, p) in participants.enumerate() {
pars-i.insert(p.name, i)
}
return pars-i
}
#let get-group-span(participants, group) = {
let min-i = participants.len() - 1
let max-i = 0
let pars-i = get-participants-i(participants)
for elmt in group.elmts {
if elmt.type == "seq" {
let i1 = pars-i.at(elmt.p1)
let i2 = pars-i.at(elmt.p2)
min-i = calc.min(min-i, i1, i2)
max-i = calc.max(max-i, i1, i2)
} else if elmt.type == "grp" {
let (i0, i1) = get-group-span(participants, elmt)
min-i = calc.min(min-i, i0)
max-i = calc.max(max-i, i1)
} else if elmt.type == "sync" {
let (i0, i1) = get-group-span(participants, elmt)
min-i = calc.min(min-i, i0)
max-i = calc.max(max-i, i1)
}
}
return (min-i, max-i)
}
#let get-style(base-name, mods) = {
let style = if base-name == "lifeline" {(
fill: white,
stroke: black + 1pt
)}
if mods == auto {
return style
}
if type(mods) == dictionary {
return style + mods
}
panic("Invalid type for parameter mods, expected auto or dictionary, got " + str(type(mods)))
}
#let fit-canvas(canvas, width: auto) = layout(size => {
let m = measure(canvas)
let w = m.width
let h = m.height
let r = if w == 0pt {0} else {
if width == auto {1}
else if type(width) == length {
width / w
} else {
size.width * width / w
}
}
let new-w = w * r
let new-h = h * r
r *= 100%
box(
width: new-w,
height: new-h,
scale(x: r, y: r, reflow: true, canvas)
)
})

View File

@ -11,4 +11,4 @@ categories = ["visualization"]
license = "Apache-2.0"
description = "A package to draw sequence diagrams with CeTZ"
keywords = ["sequence", "diagram", "plantuml"]
exclude = [ "/gallery/*" ]
exclude = [ "/gallery/*", "/gallery.bash", "/TODO.md" ]