diff --git a/packages/preview/pivot/0.2.0/.gitignore b/packages/preview/pivot/0.2.0/.gitignore new file mode 100644 index 0000000000..13ae7d1389 --- /dev/null +++ b/packages/preview/pivot/0.2.0/.gitignore @@ -0,0 +1,31 @@ +# OS +.DS_Store +Thumbs.db + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# LLM development +.claude/ +CLAUDE.md +CONTEXT.md + +# Local docs source (separate docs site) +design/ + +# Secrets +*.token +token.txt +.env +.env.* + +# Test output +tests/**/out/ +tests/**/diff/ + +# Typst build artifacts +*.pdf diff --git a/packages/preview/pivot/0.2.0/CHANGELOG.md b/packages/preview/pivot/0.2.0/CHANGELOG.md new file mode 100644 index 0000000000..3b9734fa44 --- /dev/null +++ b/packages/preview/pivot/0.2.0/CHANGELOG.md @@ -0,0 +1,42 @@ +# Changelog + +Notable changes to pivot, following +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and +[Semantic Versioning](https://semver.org/). Pre-1.0, a breaking change can land in +a minor release — each is flagged with a migration note. + +## [0.2.0] - 2026-07-03 + +### Added + +- **`flowchart`** — nodes joined by directed edges, auto-laid-out in either vertical or + horizontal (`orientation: "horizontal"`). Built from `node(id, label, shape:, fill:)` + (shapes: rounded / rectangle / diamond / parallelogram) and `edge(from, to, label:)`. + Node colour is opt-in via `fill:`. +- **`timeline`** — events on an ordered axis, in three orientations: horizontal, + vertical, and a snaked layout that wraps long runs into curved U-bends. Built + from `event(title, time:, description:, shape:, fill:)`; a sparse event is a + title only. A marker takes a shape (circle / square / triangle / diamond) and + `fill:`. + +## [0.1.0] - 2026-06-28 + +First release: the byte-region family — three views over the same bytes, sharing +one field model so they never disagree on where a field starts. + +### Added + +- **`packet`** — flat protocol-header view; fields auto-flow and wrap, narrow + labels become leader callouts, with a deduplicating bit ruler. +- **`struct`** — vertical memory map; box height tracks byte size (oversized + fields capped with a break mark), hex offsets, sub-byte fields expand in place. +- **`hexdump`** — real bytes + ASCII gutter; annotations you `fill:` are + highlighted in place, and every annotation is keyed in a byte-range legend. + `data:` takes `read(f, encoding: none)` or an int array. +- Shared elements `bytes` / `bits` / `gap` / `reserved`, with `at:` (byte offset + on `bytes`, bit offset on `bits`) and `fill:`. +- **`palette`** — Okabe–Ito colour-blind-safe highlight colours, for `fill:`. +- Theming via a `theme:` dict; built on `@preview/cetz:0.5.2` (Typst ≥ 0.14). + +[Unreleased]: https://github.com/cybermallard/typst-pivot/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/cybermallard/typst-pivot/releases/tag/v0.1.0 diff --git a/packages/preview/pivot/0.2.0/LICENSE b/packages/preview/pivot/0.2.0/LICENSE new file mode 100644 index 0000000000..d645695673 --- /dev/null +++ b/packages/preview/pivot/0.2.0/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/preview/pivot/0.2.0/NOTICE b/packages/preview/pivot/0.2.0/NOTICE new file mode 100644 index 0000000000..7ffcb45117 --- /dev/null +++ b/packages/preview/pivot/0.2.0/NOTICE @@ -0,0 +1,15 @@ +pivot +Copyright 2026 cybermallard and the pivot contributors + +Licensed under the Apache License, Version 2.0 (the "License"); see the LICENSE +file in this repository for the full terms. + +--- + +Third-party attribution + +pivot renders with CeTZ (https://github.com/cetz-package/cetz), licensed under +the GNU Lesser General Public License v3.0 or later (LGPL-3.0-or-later). CeTZ is +neither vendored nor modified by this package; the Typst compiler fetches it +independently at build time. pivot calls CeTZ's public API only and contains no +CeTZ source. diff --git a/packages/preview/pivot/0.2.0/README.md b/packages/preview/pivot/0.2.0/README.md new file mode 100644 index 0000000000..8746f18b14 --- /dev/null +++ b/packages/preview/pivot/0.2.0/README.md @@ -0,0 +1,193 @@ +# pivot + +
+ +Draw diagrams for Cyber Threat Intelligence (CTI) analysis. + + + +## Installation + +Import pivot from the preview namespace and the Typst compiler +fetches it (and CeTZ) on first build. There's no manual install step: + +```typ +#import "@preview/pivot:0.2.0": packet, struct, hexdump +``` + +## Using pivot + +Currently, there are five diagrams. Three — packet, struct, and hexdump — are +byte-region views sharing one vocabulary: `bytes(n)`, `bits(n)`, `gap(n)`, +`reserved(n)`, with `at:` (offset) and `fill:` (highlight). You describe the entity +(widths and labels); pivot derives the offset, row, and ruler number. The other two +stand alone: `timeline` plots `event(...)`s on an ordered axis — horizontal, vertical, +or snaked — and `flowchart` draws `node(...)`s joined by `edge(...)`s, laid out +automatically top-to-bottom or left-to-right. + +The gallery diagrams above are built from calls like these: + +A **`packet`** — the TCP header, with the sequence and acknowledgment numbers +highlighted (the narrow flag bits become leader callouts automatically): + +```typ +#import "@preview/pivot:0.2.0": packet, struct, hexdump, bytes, bits, gap, palette + +#packet( + bytes(2)[Source Port], bytes(2)[Destination Port], + bytes(4, fill: palette.blue)[Sequence Number], + bytes(4, fill: palette.blue)[Acknowledgment Number], + bits(4)[Data Offset], bits(6)[Reserved], + bits(1)[URG], bits(1)[ACK], bits(1)[PSH], bits(1)[RST], bits(1)[SYN], bits(1)[FIN], + bytes(2)[Window], + bytes(2)[Checksum], bytes(2)[Urgent Pointer], +) +``` + +**`struct`** — a malware C2 beacon header as a memory map: + +```typ +#struct( + bytes(4)[Magic], + bytes(1)[Version], bytes(1)[Command], bytes(2)[Bot ID], + bytes(4, fill: palette.orange)[Campaign Key], + gap(16)[unparsed], bytes(2)[Payload Len], +) +``` + +**`hexdump`** — a Gh0st RAT C2 check-in, fields annotated in the captured bytes: + +```typ +#hexdump( + data: read("ghost-checkin.bin", encoding: none), + bytes(5, at: 0x00, fill: palette.orange)[Magic: "Gh0st"], + bytes(4, at: 0x05, fill: palette.sky)[Total size (LE)], + bytes(4, at: 0x09, fill: palette.green)[Uncompressed size (LE)], + bytes(57, at: 0x0d, fill: palette.yellow)[zlib payload (0x78 9C)], +) +``` + +A **`timeline`** — a ransomware intrusion on a snaked axis. In this example shape *and* colour mark +a simplified attack-lifecycle, so each reads as a block of matching markers improving comprehension. +Colour customisable but unfilled by default: + +```typ +#import "@preview/pivot:0.2.0": timeline, event, palette + +#timeline( + orientation: "snaked", + wrap: 4, + event(time: "Day 1", fill: palette.blue, description: [Staff harvested via OSINT.])[Reconnaissance], + event(time: "Day 2", shape: "square", fill: palette.purple, description: [Macro doc to 8 staff.])[Phishing email], + event(time: "Day 2", shape: "triangle", fill: palette.green, description: [Cobalt Strike beacon.])[C2 established], + event(time: "Day 3", shape: "triangle", fill: palette.green, description: [LSASS via Mimikatz.])[Credential access], + event(time: "Day 7", shape: "diamond", fill: palette.vermillion, description: [12 GB to MEGA.])[Exfiltration], + event(time: "Day 8", shape: "diamond", fill: palette.vermillion, description: [LockBit detonation.])[Impact], +) +``` + +A **`flowchart`** — a malware-triage flow, its outcome actions coloured (red for the +malicious-path actions, green for benign). Node shape marks the role, and pivot ranks, +aligns, and routes it: + +```typ +#import "@preview/pivot:0.2.0": flowchart, node, edge, palette + +#flowchart( + node("in", [Suspicious file], shape: "rounded"), + node("hash", [Known-bad hash?], shape: "diamond"), + node("block", [Block & alert], fill: palette.vermillion), + node("det", [Detonate in sandbox], shape: "parallelogram"), + node("mal", [Malicious behaviour?], shape: "diamond"), + node("ir", [Raise incident], fill: palette.vermillion), + node("clear", [Mark benign], fill: palette.green), + node("out", [Report], shape: "rounded"), + edge("in", "hash"), + edge("hash", "block", label: [yes]), edge("hash", "det", label: [no]), + edge("det", "mal"), + edge("mal", "ir", label: [yes]), edge("mal", "clear", label: [no]), + edge("ir", "out"), edge("clear", "out"), edge("block", "out"), +) +``` + + +## Diagrams + +**Available Diagrams** + +| | | +|---|---| +| **`packet`** | Flat protocol-header view — fields wrap into rows under a bit ruler; narrow labels become leader callouts. | +| **`struct`** | Vertical memory map — box height tracks byte size, hex offsets down the side, sub-byte fields expand in place. | +| **`hexdump`** | Real bytes with an ASCII gutter, fields highlighted in place and keyed in a colour legend. | +| **`timeline`** | Events on an ordered axis — horizontal, vertical, or a snaked layout that wraps long runs into curved rows. A marker's shape and colour can be customised. | +| **`flowchart`** | Nodes joined by directed edges, auto-laid-out top-to-bottom or left-to-right. Shape can denote a node's role (rounded / rectangle / diamond / parallelogram). | + +The first three share one field vocabulary (`bytes` / `bits` / `gap` / `reserved`) +over the same model, so views of the same bytes can't disagree. `timeline` and +`flowchart` are their own families, built from `event(...)`s and +`node(...)` / `edge(...)`s. + +**Diagram Roadmap** + +_Alphabetical order, i.e., not the order in which they will be released._ + +| | | +|---|---| +| ATT&CK matrix | Technique coverage as a grid. | +| Attack tree | A hierarchical representation of paths an adversary could take to achieve a goal. | +| Bowtie | A event at the center, threats on the left, consequences on the right, annotated with preventive and mitigating barriers. | +| Diamond Model | The four vertices: adversary, capability, infrastructure, victim. | +| Knowledge graph | Typed entities as nodes joined by labelled edges. | +| Pyramid of Pain | Indicator types ranked by adversary cost. | +| Sequence | A time-ordered view of interactions between parties. | + +## Accessibility + +Readability is the default. Pivot exposes `palette.[colour]` allowing you to use the 8-colour [Okabe–Ito](https://jfly.uni-koeln.de/color/) colour-blind-safe +palette: + + + +The rest of the defaults stay legible and adjustable: + +- **Inherits the document font.** Field labels use your document's font. The bit ruler + and hexdump grid pin to the bundled monospace (DejaVu Sans Mono) to keep columns aligned. +- **Sizes are theme tokens.** `label-size`, `bit-size`, and `hexdump-size` scale + up for legibility, e.g. `theme: themes.default + (label-size: 12pt)`. + +## Documentation + +Full docs are in progress. For now, +[`examples/`](https://github.com/cybermallard/typst-pivot/tree/v0.2.0/examples) has a +runnable example for every diagram. + +### Adding a caption to a diagram + +Captions come from Typst's own `figure` function. The default caption gap is a little tight, +a slightly wider `#set figure(gap: 1em)` reads better: + +```typ +#set figure(gap: 1em) // a little more room than the 0.65em default + +#figure( + packet( + bytes(2)[Source Port], bytes(2)[Destination Port], + bytes(4)[Sequence Number], + ), + caption: [TCP header (excerpt)], +) +``` + +## Built on CeTZ + +pivot renders with CeTZ, licensed under **LGPL-3.0-or-later**. CeTZ is neither +vendored nor modified; the Typst compiler fetches it independently at build time. + +## License + +[Apache-2.0](LICENSE). See [NOTICE](NOTICE) for attribution. diff --git a/packages/preview/pivot/0.2.0/lib.typ b/packages/preview/pivot/0.2.0/lib.typ new file mode 100644 index 0000000000..594d31d8f4 --- /dev/null +++ b/packages/preview/pivot/0.2.0/lib.typ @@ -0,0 +1,16 @@ +// pivot — public API. Exports the deliberate, minimal surface. + +#import "src/field/elements.typ": bits, bytes, gap, reserved +#import "src/packet/render.typ": packet +#import "src/struct/render.typ": struct +#import "src/hexdump/render.typ": hexdump +#import "src/timeline/render.typ": timeline +#import "src/timeline/elements.typ": event +#import "src/flowchart/render.typ": flowchart +#import "src/flowchart/elements.typ": node, edge + +// Named themes. Pass `theme: themes.default + (token: value, ...)` to customise. +#import "src/theme.typ" as themes + +// The shared Okabe–Ito colour-blind-safe highlight palette, for `fill:`. +#import "src/palette.typ": palette diff --git a/packages/preview/pivot/0.2.0/src/field/elements.typ b/packages/preview/pivot/0.2.0/src/field/elements.typ new file mode 100644 index 0000000000..a4b1a6f6e9 --- /dev/null +++ b/packages/preview/pivot/0.2.0/src/field/elements.typ @@ -0,0 +1,44 @@ +// Field element constructors — the byte-region cluster's shared vocabulary +// (packet, struct, hexdump all build from these). Each returns a plain descriptor +// dict that `model` consumes; the author supplies widths and labels, never bit +// positions. Pure. +// `at:` anchors a field to an absolute offset in the constructor's own unit: +// `bytes(.., at: k)` is byte `k`, `bits(.., at: k)` is bit `k` (the model works +// in bits, so `bytes` scales its anchor up by 8). This suits byte-oriented views +// (hexdump/struct) without a separate `unit:`. `fill:` highlights a field. `gap` +// is a dashed "unparsed" span; `reserved` is a plain empty field (reserved bits). +// Labels are optional positional trailing content (as on `gap`/`reserved`): omit +// to draw an unnamed field — e.g. `bytes(4, at: 0x10, fill: palette.orange)` +// highlights a region without a legend row. + +#let bytes(n, ..rest, at: none, fill: none) = ( + kind: "field", + width: n * 8, + label: rest.pos().at(0, default: none), + anchor: if at == none { none } else { at * 8 }, + fill: fill, +) + +#let bits(n, ..rest, at: none, fill: none) = ( + kind: "field", + width: n, + label: rest.pos().at(0, default: none), + anchor: at, + fill: fill, +) + +#let gap(n, ..rest) = ( + kind: "gap", + width: n, + label: rest.pos().at(0, default: none), + anchor: none, + fill: none, +) + +#let reserved(n, ..rest) = ( + kind: "field", + width: n, + label: rest.pos().at(0, default: none), + anchor: none, + fill: none, +) diff --git a/packages/preview/pivot/0.2.0/src/field/layout.typ b/packages/preview/pivot/0.2.0/src/field/layout.typ new file mode 100644 index 0000000000..fd9a4ca8bd --- /dev/null +++ b/packages/preview/pivot/0.2.0/src/field/layout.typ @@ -0,0 +1,28 @@ +// layout: fields -> positioned segments, one field clipped to each row it +// touches. Pure, no cetz. `col-start`/`col-end` are 0-based columns within the +// row; `continued`/`continues` mark a field that arrived from / carries on to an +// adjacent row. `kind` and `fill` carry through for the renderer. + +#let layout(fields, bits-per-row: 32) = { + let segments = () + for f in fields { + let row-start = int(f.start / bits-per-row) + let row-end = int(f.end / bits-per-row) + for r in range(row-start, row-end + 1) { + let row-lo = r * bits-per-row + let bit-start = calc.max(f.start, row-lo) + let bit-end = calc.min(f.end, row-lo + bits-per-row - 1) + segments.push(( + kind: f.kind, + row: r, + col-start: bit-start - row-lo, + col-end: bit-end - row-lo, + label: f.label, + fill: f.at("fill", default: none), + continued: r > row-start, + continues: r < row-end, + )) + } + } + segments +} diff --git a/packages/preview/pivot/0.2.0/src/field/model.typ b/packages/preview/pivot/0.2.0/src/field/model.typ new file mode 100644 index 0000000000..f87e4abf7a --- /dev/null +++ b/packages/preview/pivot/0.2.0/src/field/model.typ @@ -0,0 +1,44 @@ +// model: field descriptors -> fields with derived bit positions. Pure, no cetz. +// Shared by the byte-region cluster (packet/struct/hexdump). +// `end` is inclusive. Positions are derived from widths and `anchor`s, never +// authored, so the ruler can't disagree with the boxes (veracity over +// standard-conformance). An `anchor` past the running cursor leaves an implicit +// `gap` field for the skipped span. `kind` and `fill` carry through unchanged. + +#let model(descriptors) = { + let fields = () + let cursor = 0 + for d in descriptors { + // width <= 0 can't be drawn; this `panic` names the field for the user. + // It is not unit-tested (Typst has no try/catch) — trusted by inspection. + assert( + d.width > 0, + message: "field " + repr(d.label) + ": width must be > 0", + ) + + let anchor = d.at("anchor", default: none) + let start = if anchor != none { anchor } else { cursor } + if start > cursor { + fields.push(( + kind: "gap", + start: cursor, + end: start - 1, + label: none, + fill: none, + )) + } + + let end = start + d.width - 1 + fields.push(( + kind: d.kind, + start: start, + end: end, + label: d.label, + fill: d.at("fill", default: none), + )) + // A backwards `anchor` (an intentional overlap) places one field without + // rewinding the flow — later auto-flow fields resume from the furthest point. + cursor = calc.max(cursor, end + 1) + } + fields +} diff --git a/packages/preview/pivot/0.2.0/src/flowchart/elements.typ b/packages/preview/pivot/0.2.0/src/flowchart/elements.typ new file mode 100644 index 0000000000..9a7de59fb1 --- /dev/null +++ b/packages/preview/pivot/0.2.0/src/flowchart/elements.typ @@ -0,0 +1,24 @@ +// Flowchart element constructors. A flowchart is a set of `node`s joined by +// directed `edge`s; both are passed together in one variadic `flowchart(..)` call +// and the model sorts them out. A node has an id (referenced by edges), a label +// (trailing content), a shape, and an opt-in fill. An edge names a source and a +// target node id and an optional label — a branch condition like "yes" / "no". +// Pure; no cetz. +// +// node("q", [Known-bad hash?], shape: "diamond") +// edge("q", "block", label: [yes]) + +#let node(id, label, shape: "rectangle", fill: none) = ( + kind: "node", + id: id, + label: label, + shape: shape, + fill: fill, +) + +#let edge(from, to, label: none) = ( + kind: "edge", + from: from, + to: to, + label: label, +) diff --git a/packages/preview/pivot/0.2.0/src/flowchart/layout.typ b/packages/preview/pivot/0.2.0/src/flowchart/layout.typ new file mode 100644 index 0000000000..1002701a96 --- /dev/null +++ b/packages/preview/pivot/0.2.0/src/flowchart/layout.typ @@ -0,0 +1,146 @@ +// layout: a validated graph -> ranked structure. Pure, no cetz. A DFS first marks +// back-edges (those closing a cycle) so they can be reversed just for ranking, +// keeping the ranking graph acyclic. Then longest-path ranking and a barycenter +// sweep that orders each rank to cut crossings. Edges are classified, not split: +// direct — one rank down; drawn straight/orthogonally between the two nodes. +// long — more than one rank down; routed down a side channel by the renderer. +// back — a reversed cycle edge; routed up a side channel. +// Only direct edges shape the ordering and (later) the coordinates — long and back +// edges keep clear of the body, so they don't distort the spine. +// +// Output: `cells` (one per node, with a `rank` and `order`) and `edges` (each with +// `from`, `to`, `label`, `kind`). + +#let layout(graph) = { + let nodes = graph.nodes + let edges = graph.edges + let ids = graph.ids + let n = nodes.len() + if n == 0 { return (cells: (), edges: (), ranks: 0) } + + // --- cycle removal: DFS, mark edges that close back onto the current stack --- + let succ-e = (:) + for (ei, e) in edges.enumerate() { + let u = str(ids.at(e.from)) + succ-e.insert(u, succ-e.at(u, default: ()) + ((v: ids.at(e.to), ei: ei),)) + } + let color = range(n).map(_ => 0) // 0 white, 1 on-stack, 2 done + let reversed = (:) + for start in range(n) { + if color.at(start) != 0 { continue } + color.at(start) = 1 + let stack = ((node: start, si: 0),) + while stack.len() > 0 { + let ti = stack.len() - 1 + let top = stack.at(ti) + let outs = succ-e.at(str(top.node), default: ()) + if top.si < outs.len() { + stack.at(ti) = (node: top.node, si: top.si + 1) + let nx = outs.at(top.si) + if color.at(nx.v) == 1 { + reversed.insert(str(nx.ei), true) + } else if color.at(nx.v) == 0 { + color.at(nx.v) = 1 + stack.push((node: nx.v, si: 0)) + } + } else { + color.at(top.node) = 2 + let _ = stack.pop() + } + } + } + + // --- longest-path ranking on the DAG (back-edges reversed) --- + let succ = (:) + let indeg = (:) + for i in range(n) { indeg.insert(str(i), 0) } + for (ei, e) in edges.enumerate() { + let (fu, tv) = if reversed.at(str(ei), default: false) { + (ids.at(e.to), ids.at(e.from)) + } else { + (ids.at(e.from), ids.at(e.to)) + } + succ.insert(str(fu), succ.at(str(fu), default: ()) + (tv,)) + indeg.insert(str(tv), indeg.at(str(tv)) + 1) + } + let rank = range(n).map(_ => 0) + let queue = range(n).filter(i => indeg.at(str(i)) == 0) + while queue.len() > 0 { + let u = queue.remove(0) + for v in succ.at(str(u), default: ()) { + rank.at(v) = calc.max(rank.at(v), rank.at(u) + 1) + indeg.insert(str(v), indeg.at(str(v)) - 1) + if indeg.at(str(v)) == 0 { queue.push(v) } + } + } + let ranks = calc.max(..rank) + 1 + + // --- classify edges; only direct (one-rank) edges feed the ordering --- + let up = (:) + let down = (:) + let epaths = () + for (ei, e) in edges.enumerate() { + let u = ids.at(e.from) + let v = ids.at(e.to) + let kind = if reversed.at(str(ei), default: false) { + "back" + } else if rank.at(v) - rank.at(u) == 1 { + down.insert(str(u), down.at(str(u), default: ()) + (v,)) + up.insert(str(v), up.at(str(v), default: ()) + (u,)) + "direct" + } else { + "long" + } + epaths.push((from: u, to: v, label: e.label, kind: kind)) + } + + // --- order within ranks: initial declaration order, then barycenter sweeps --- + let order-in-rank = range(ranks).map(r => range(n).filter(i => ( + rank.at(i) == r + ))) + let posx = (:) + for r in range(ranks) { + for (k, i) in order-in-rank.at(r).enumerate() { posx.insert(str(i), k) } + } + for iter in range(6) { + let downward = calc.rem(iter, 2) == 0 + let rseq = if downward { + range(1, ranks) + } else { + range(ranks - 2, -1, step: -1) + } + for r in rseq { + let adj = if downward { up } else { down } + let scored = order-in-rank + .at(r) + .map(i => { + let nb = adj.at(str(i), default: ()) + let bc = if nb.len() > 0 { + nb.map(j => posx.at(str(j))).sum() / nb.len() + } else { + posx.at(str(i)) + } + (i: i, bc: bc) + }) + let sorted = scored.sorted(key: s => s.bc) + order-in-rank.at(r) = sorted.map(s => s.i) + for (k, s) in sorted.enumerate() { posx.insert(str(s.i), k) } + } + } + + let order = (:) + for r in range(ranks) { + for (k, i) in order-in-rank.at(r).enumerate() { order.insert(str(i), k) } + } + let out-cells = nodes.map(nd => ( + index: nd.index, + rank: rank.at(nd.index), + order: order.at(str(nd.index)), + id: nd.id, + label: nd.label, + shape: nd.shape, + fill: nd.fill, + )) + + (cells: out-cells, edges: epaths, ranks: ranks) +} diff --git a/packages/preview/pivot/0.2.0/src/flowchart/model.typ b/packages/preview/pivot/0.2.0/src/flowchart/model.typ new file mode 100644 index 0000000000..a7119bba33 --- /dev/null +++ b/packages/preview/pivot/0.2.0/src/flowchart/model.typ @@ -0,0 +1,51 @@ +// model: mixed node/edge descriptors -> a validated graph. Pure, no cetz. +// Splits the argument list into nodes and edges, checks each node id is unique and +// each edge references declared nodes, and names the offender on bad input. +// Returns (nodes, edges, ids) — `ids` maps a node id to its position in `nodes`. + +#let shapes = ("rounded", "rectangle", "diamond", "parallelogram") + +#let model(items) = { + let nodes = () + let edges = () + let ids = (:) + for (i, it) in items.enumerate() { + assert( + type(it) == dictionary + and it.at("kind", default: none) in ("node", "edge"), + message: "flowchart: argument " + str(i) + " is not a node() or edge()", + ) + if it.kind == "node" { + assert( + it.id not in ids, + message: "flowchart: duplicate node id " + repr(it.id), + ) + assert( + shapes.contains(it.shape), + message: "node " + + repr(it.id) + + ": shape must be one of " + + shapes.join(", "), + ) + ids.insert(it.id, nodes.len()) + nodes.push(( + index: nodes.len(), + id: it.id, + label: it.label, + shape: it.shape, + fill: it.fill, + )) + } else { + assert( + it.from in ids, + message: "flowchart: edge references unknown node " + repr(it.from), + ) + assert( + it.to in ids, + message: "flowchart: edge references unknown node " + repr(it.to), + ) + edges.push((from: it.from, to: it.to, label: it.label)) + } + } + (nodes: nodes, edges: edges, ids: ids) +} diff --git a/packages/preview/pivot/0.2.0/src/flowchart/render.typ b/packages/preview/pivot/0.2.0/src/flowchart/render.typ new file mode 100644 index 0000000000..bb36bb5f62 --- /dev/null +++ b/packages/preview/pivot/0.2.0/src/flowchart/render.typ @@ -0,0 +1,591 @@ +#import "@preview/cetz:0.5.2" as cetz +#import "../theme.typ" as theme-mod +#import "model.typ": model +#import "layout.typ": layout + +// flowchart: nodes joined by directed edges, laid out in ranked layers. `layout` +// fixes each node's rank and order; here we measure the labels, align a node with its +// neighbours (a straight spine), then route: a direct (one-rank) edge runs straight +// into a single-input target or fans into a shared one; a long or back edge takes a +// side channel clear of the body. The engine works in a canonical vertical (top to +// bottom) flow; `orientation: "horizontal"` is the same picture transposed at the draw +// step. Node colour is opt-in (`fill:`). Returns content. +#let flowchart( + ..items, + orientation: "vertical", + theme: theme-mod.default, +) = context { + assert( + orientation in ("vertical", "horizontal"), + message: "flowchart: orientation must be \"vertical\" or \"horizontal\", got " + + repr(orientation), + ) + let g = layout(model(items.pos())) + // An empty diagram has no nodes to place and no extent to measure — draw nothing. + if g.cells.len() == 0 { return none } + + // Capture tokens before `import cetz.draw: *` shadows names like `line`/`fill`. + let pad-x = theme.flowchart-node-pad-x / 1cm + let pad-y = theme.flowchart-node-pad-y / 1cm + let min-w = theme.flowchart-node-min-width / 1cm + let node-gap = theme.flowchart-node-gap / 1cm + let rank-gap = theme.flowchart-rank-gap / 1cm + let back-margin = theme.flowchart-back-margin / 1cm + let back-gap = theme.flowchart-back-gap / 1cm + let edge-width = theme.flowchart-node-edge-width + let edge-darken = theme.flowchart-node-edge-darken + let node-outline = theme.flowchart-node-outline + let node-fill = theme.flowchart-node-fill + let label-size = theme.flowchart-label-size + let label-color = theme.flowchart-label-color + let dscale = theme.flowchart-decision-scale + let io-slant = theme.flowchart-io-slant / 1cm + let edge-stroke = theme.flowchart-edge-width + theme.flowchart-edge-color + let arrow-fill = theme.flowchart-edge-color + let arrow-scale = theme.flowchart-arrow-scale + let elabel-size = theme.flowchart-edge-label-size + let elabel-color = theme.flowchart-edge-label-color + let elabel-fill = theme.flowchart-edge-label-fill + let elabel-inset = theme.flowchart-edge-label-inset + + // Measure each label and give the node a box per shape (a diamond grows to hold + // the label, a rounded rectangle rounds its corners, a parallelogram leans off vertical). + let sized = g.cells.map(c => { + let lbl = text(size: label-size, fill: label-color, c.label) + let m = measure(lbl) + let iw = calc.max(m.width / 1cm + 2 * pad-x, min-w) + let ih = m.height / 1cm + 2 * pad-y + // The router works in a canonical (cross, flow) space: `w` is the cross-axis + // extent (growable by a merge), `h` the flow-axis extent. Most shapes are a plain + // box that just swaps axes with the flow orientation; the parallelogram is + // special — its slant always runs along the cross axis (so its flow-entry/exit + // faces stay flat and edges attach flush), so the slant room is reserved in + // whichever extent is the cross one. + let (w, h) = if c.shape == "parallelogram" { + if orientation == "horizontal" { (ih + io-slant, iw) } else { + (iw + io-slant, ih) + } + } else { + let (bw, bh) = if c.shape == "diamond" { + (iw * dscale, ih * dscale) + } else if c.shape == "rounded" { + (iw + ih, ih) + } else { + (iw, ih) + } + if orientation == "horizontal" { (bh, bw) } else { (bw, bh) } + } + // `th` is the label-height thickness — the rounded rectangle's corner radius, so a + // merge target that grows keeps flat faces (rounded corners, not a bulging capsule). + (..c, lbl: lbl, w: w, h: h, th: ih) + }) + let wof = (:) + let hof = (:) + for c in sized { + wof.insert(str(c.index), c.w) + hof.insert(str(c.index), c.h) + } + + // Nodes of each rank, in order; rank height is its tallest node. + let order-in-rank = range(g.ranks).map(r => sized + .filter(c => c.rank == r) + .sorted(key: c => c.order) + .map(c => c.index)) + let rank-h = order-in-rank.map(row => if row.len() > 0 { + calc.max(..row.map(a => hof.at(str(a)))) + } else { 0 }) + let rank-y = if g.ranks > 0 { (0,) } else { () } + for r in range(1, g.ranks) { + rank-y.push( + rank-y.at(r - 1) - (rank-h.at(r - 1) / 2 + rank-gap + rank-h.at(r) / 2), + ) + } + + // Neighbours across ranks, from the *direct* edges only (long/back edges route on + // the side and shouldn't tug nodes out of line). + let nbr = (:) + for e in g.edges { + if e.kind == "direct" { + nbr.insert(str(e.from), nbr.at(str(e.from), default: ()) + (e.to,)) + nbr.insert(str(e.to), nbr.at(str(e.to), default: ()) + (e.from,)) + } + } + + // Direct and long inputs of each node. A merge widens to seat its direct inputs + // straight and to reach the entry column of each long (side-arriving) input. + let din = (:) + let lin = (:) + for (ei, e) in g.edges.enumerate() { + if e.kind == "direct" { + din.insert(str(e.to), din.at(str(e.to), default: ()) + (e.from,)) + } else if e.kind == "long" { + lin.insert( + str(e.to), + lin.at(str(e.to), default: ()) + ((ei: ei, from: e.from),), + ) + } + } + let median = xs => { + let s = xs.sorted() + let k = s.len() + if k == 0 { 0 } else if calc.rem(k, 2) == 1 { + s.at(calc.quo(k, 2)) + } else { + (s.at(calc.quo(k, 2) - 1) + s.at(calc.quo(k, 2))) / 2 + } + } + + // Coordinate assignment: start centred by order, then relax each node toward the + // median of its neighbours (aligning chains) while a forward and a backward pass + // hold each rank's separation. Both preserve the minimum gap, so their average + // does too — the spine straightens without nodes ever overlapping. + let relax = widths => { + let x = (:) + for r in range(g.ranks) { + let row = order-in-rank.at(r) + let total = ( + row.map(a => widths.at(str(a))).sum(default: 0) + + calc.max(row.len() - 1, 0) * node-gap + ) + let cx = -total / 2 + for a in row { + x.insert(str(a), cx + widths.at(str(a)) / 2) + cx += widths.at(str(a)) + node-gap + } + } + for pass in range(12) { + let seq = if calc.rem(pass, 2) == 0 { + range(g.ranks) + } else { + range(g.ranks - 1, -1, step: -1) + } + for r in seq { + let row = order-in-rank.at(r) + let k = row.len() + if k == 0 { continue } + let want = row.map(a => { + let ns = nbr.at(str(a), default: ()) + if ns.len() > 0 { median(ns.map(nn => x.at(str(nn)))) } else { + x.at(str(a)) + } + }) + let sep = i => ( + widths.at(str(row.at(i))) / 2 + + node-gap + + widths.at(str(row.at(i + 1))) / 2 + ) + let xf = (want.at(0),) + for i in range(1, k) { + xf.push(calc.max(want.at(i), xf.at(i - 1) + sep(i - 1))) + } + let xb = range(k).map(_ => 0) + xb.at(k - 1) = want.at(k - 1) + for i in range(k - 2, -1, step: -1) { + xb.at(i) = calc.min(want.at(i), xb.at(i + 1) - sep(i)) + } + for i in range(k) { + x.insert(str(row.at(i)), (xf.at(i) + xb.at(i)) / 2) + } + } + } + x + } + + let shapeof = (:) + let rankof = (:) + for c in sized { + shapeof.insert(str(c.index), c.shape) + rankof.insert(str(c.index), c.rank) + } + + // The clearest vertical corridor for a long edge u -> v under the current widths: + // the candidate x that no node in the crossed ranks blocks, chosen closest to the + // source's own column so the edge drops straight when that column is clear (and + // only jogs when it isn't). Candidates are the endpoints (or, for a side exit, the + // source's flanks), the gaps just outside each crossed rank, and just outside the + // diagram — the last is always clear, so a corridor always exists. With `side-exit` + // (a decision leaving by a side vertex) the corridor must sit outside the source, + // its run at u.y clear of u's rank-mates; returns none if none does (caller falls + // back to a bottom exit). + let corridor = (ui, vi, side-exit, x, w) => { + let ur = rankof.at(str(ui)) + let vr = rankof.at(str(vi)) + let ux = x.at(str(ui)) + let uw = w.at(str(ui)) + let vx = x.at(str(vi)) + let m = node-gap / 2 + let minx = calc.min(..sized.map(c => ( + x.at(str(c.index)) - w.at(str(c.index)) / 2 + ))) + let maxx = calc.max(..sized.map(c => ( + x.at(str(c.index)) + w.at(str(c.index)) / 2 + ))) + let occupied = () + let cands = (vx, minx - back-margin, maxx + back-margin) + cands += if side-exit { (ux - uw / 2 - m, ux + uw / 2 + m) } else { (ux,) } + for r in range(calc.min(ur, vr) + 1, calc.max(ur, vr)) { + let row = order-in-rank.at(r) + for a in row { + occupied.push(( + x.at(str(a)) - w.at(str(a)) / 2 - m, + x.at(str(a)) + w.at(str(a)) / 2 + m, + )) + } + if row.len() > 0 { + cands.push( + calc.min(..row.map(a => x.at(str(a)) - w.at(str(a)) / 2)) - m, + ) + cands.push( + calc.max(..row.map(a => x.at(str(a)) + w.at(str(a)) / 2)) + m, + ) + } + } + let clear = cx => occupied.all(iv => cx <= iv.at(0) or cx >= iv.at(1)) + let ok = cx => { + if not clear(cx) { return false } + if not side-exit { return true } + if cx > ux - uw / 2 - m and cx < ux + uw / 2 + m { return false } + let vx2 = if cx < ux { ux - uw / 2 } else { ux + uw / 2 } + order-in-rank + .at(ur) + .filter(a => a != ui) + .all(a => ( + x.at(str(a)) + w.at(str(a)) / 2 <= calc.min(vx2, cx) + or x.at(str(a)) - w.at(str(a)) / 2 >= calc.max(vx2, cx) + )) + } + cands + .filter(ok) + // Prefer the source's own column (straight drop); break ties toward the target. + .sorted(key: c => (calc.abs(ux - c), calc.abs(c - vx))) + .at(0, default: none) + } + + // A long edge's route under the current widths: the corridor x, whether a decision + // takes a side exit, and where it enters the target's top. The entry sits on the + // corridor (a straight drop) unless that would crowd the target's direct inputs, in + // which case it steps just outside them. + let route-long = (from, to, x, w) => { + let side = shapeof.at(str(from)) == "diamond" + let cx = if side { corridor(from, to, true, x, w) } else { none } + let side-ok = cx != none + if not side-ok { cx = corridor(from, to, false, x, w) } + let din-xs = din.at(str(to), default: ()).map(s => x.at(str(s))) + let entry = cx + if din-xs.len() > 0 { + let dmin = calc.min(..din-xs) + let dmax = calc.max(..din-xs) + if entry > dmin - node-gap and entry < dmax + node-gap { + entry = if entry <= x.at(str(to)) { dmin - node-gap } else { + dmax + node-gap + } + } + } + (cx: cx, side-ok: side-ok, entry: entry) + } + + // Widths may grow: relax, then widen each node to span its merging direct inputs + // and to reach every long edge's entry column, and re-relax so the wider node still + // fits. A few rounds settle the mutual dependence (entries depend on the widths). + let w = wof + let x = relax(w) + for round in range(3) { + let ent = (:) + for c in sized { + for it in lin.at(str(c.index), default: ()) { + ent.insert(str(it.ei), route-long(it.from, c.index, x, w).entry) + } + } + for c in sized { + let d = din.at(str(c.index), default: ()) + let l = lin.at(str(c.index), default: ()) + let cxn = x.at(str(c.index)) + // A lone direct input fans in without widening; two or more merge, so the node + // grows to span them. A long input always pulls the node out to its entry. + let merge-xs = if d.len() >= 2 { d.map(s => x.at(str(s))) } else { () } + let xs = merge-xs + l.map(it => ent.at(str(it.ei))) + if xs.len() == 0 { continue } + let lo = calc.min(cxn, ..xs) + let hi = calc.max(cxn, ..xs) + let reach = calc.max(cxn - lo, hi - cxn) + w.insert(str(c.index), calc.max( + wof.at(str(c.index)), + 2 * reach + 2 * pad-x, + )) + } + x = relax(w) + } + + // Final placement (with the possibly-widened widths). + let pos = (:) + for c in sized { + pos.insert(str(c.index), ( + x: x.at(str(c.index)), + y: rank-y.at(c.rank), + rank: c.rank, + w: w.at(str(c.index)), + h: c.h, + th: c.th, + shape: c.shape, + fill: c.fill, + lbl: c.lbl, + )) + } + // How many *direct* children each node fans out to. A node with one direct child + // drops that edge straight down; a genuine fork spreads toward each target. Long and + // back edges route on the side, so they don't count as fan-out. + let fanout = (:) + for e in g.edges { + if e.kind == "direct" { + fanout.insert(str(e.from), fanout.at(str(e.from), default: 0) + 1) + } + } + + // Each long edge's route for drawing. The widen loop above only needed `.entry` + // each round; now that widths have settled, compute the full route once (corridor + + // side-ok + entry) — a straight drop, stepped aside only to clear direct inputs. + let route = (:) + for c in sized { + for it in lin.at(str(c.index), default: ()) { + route.insert(str(it.ei), route-long(it.from, c.index, x, w)) + } + } + + cetz.canvas({ + import cetz.draw: * + + // Two x-coordinates within this are treated as the same column (a straight run). + let straight-eps = 0.02 + + // Everything below is computed in the canonical downward-flow space; a horizontal + // flow is that picture transposed. `map` sends a canonical point to the canvas + // (identity for a vertical flow), and is applied to every drawn coordinate — so + // lines and boxes rotate but text, placed at `map(centre)`, stays upright. + let map = if orientation == "horizontal" { + p => (-p.at(1), -p.at(0)) + } else { + p => p + } + + // A node's outline for its shape, centred at (x, y). Border, as on the timeline + // markers: a filled node is ringed by a deeper shade of its own fill; an unfilled + // one takes the neutral outline. (Any non-colour paint has no `.darken`, so fall + // back to the fill itself rather than panic.) + let draw-node = p => { + // The outline is the node's canonical box `map`ped onto the canvas (so lines and + // corners transpose with the flow), while the label is placed upright at the + // mapped centre — text never rotates. Because the parallelogram's slant is kept + // along the cross axis and drawn in canonical space, its flow faces stay flat under the + // map; the rounded-box radius is capped by `th` so a grown merge keeps flat faces. + let (x, y, w, h) = (p.x, p.y, p.w, p.h) + let fc = if p.fill == none { node-fill } else { p.fill } + let edge = ( + edge-width + + if p.fill == none { + node-outline + } else if type(p.fill) == color { + p.fill.darken(edge-darken) + } else { + p.fill + } + ) + if p.shape == "rounded" { + rect( + map((x - w / 2, y - h / 2)), + map((x + w / 2, y + h / 2)), + radius: calc.min(w, h, p.th) / 2, + fill: fc, + stroke: edge, + ) + } else if p.shape == "diamond" { + line( + map((x, y + h / 2)), + map((x + w / 2, y)), + map((x, y - h / 2)), + map((x - w / 2, y)), + close: true, + fill: fc, + stroke: edge, + ) + } else if p.shape == "parallelogram" { + line( + map((x - w / 2, y - h / 2)), + map((x + w / 2 - io-slant, y - h / 2)), + map((x + w / 2, y + h / 2)), + map((x - w / 2 + io-slant, y + h / 2)), + close: true, + fill: fc, + stroke: edge, + ) + } else { + rect( + map((x - w / 2, y - h / 2)), + map((x + w / 2, y + h / 2)), + fill: fc, + stroke: edge, + ) + } + content(map((x, y)), p.lbl) + } + + // A point on a node's outline (top/bottom), nudged `toward` a target x — a + // diamond's faces slope, so its ports ride up toward the sides. `spread` caps + // how far along the face a port may sit. + let attach = (p, toward, top, spread) => { + let a = p.w / 2 + let b = p.h / 2 + let dx = calc.max(calc.min(toward - p.x, spread * a), -spread * a) + let ey = if p.shape == "diamond" { + let rise = b * (1 - calc.abs(dx) / a) + if top { p.y + rise } else { p.y - rise } + } else if top { p.y + b } else { p.y - b } + (p.x + dx, ey) + } + // Place a branch label on the midpoint of the path's longest vertical run. A + // label reads cleanly sitting on a vertical line (like the yes/no branches), but + // its knockout box breaks up a short horizontal stub and looks like a gap in the + // line. Fall back to the longest segment only if the path has no vertical run. + let edge-label = (lbl, pts) => { + let anchor = none + let best = -1 + for i in range(pts.len() - 1) { + let (ax, ay) = pts.at(i) + let (bx, by) = pts.at(i + 1) + let vertical = calc.abs(ax - bx) < straight-eps + let score = ( + calc.abs(ay - by) + + calc.abs(ax - bx) + + if vertical { 1000 } else { 0 } + ) + if score > best { + best = score + anchor = ((ax + bx) / 2, (ay + by) / 2) + } + } + if anchor != none { + content( + map(anchor), + box( + fill: elabel-fill, + inset: elabel-inset, + text(size: elabel-size, fill: elabel-color, lbl), + ), + ) + } + } + + // Diagram extent; back-edges climb a side channel, long edges take a corridor. + let min-x = calc.min(..pos.values().map(p => p.x - p.w / 2)) + let max-x = calc.max(..pos.values().map(p => p.x + p.w / 2)) + let center-x = (min-x + max-x) / 2 + let left-i = 0 + let right-i = 0 + + // Edges first, so nodes sit over the line ends. + for (ei, e) in g.edges.enumerate() { + let s = pos.at(str(e.from)) + let t = pos.at(str(e.to)) + if e.kind == "direct" { + let forks = fanout.at(str(e.from), default: 0) >= 2 + let pts = if ( + orientation == "horizontal" and s.shape == "diamond" and forks + ) { + // A horizontal-flow decision that forks leaves through its cross faces (the + // diamond's top and bottom points, in the final picture): out the vertex on + // the child's side, then flow into the child — cleaner than crowding the + // single flow vertex with both branches. (A lone child is the flow + // continuing, so it falls through and exits straight on.) This assumes the + // usual two-way split; 3+ children on one cross side would share a vertex. + let side = if t.x > s.x { 1 } else { -1 } + let exit = (s.x + side * s.w / 2, s.y) + let entry = attach(t, t.x, true, 1.0) + if calc.abs(t.x - s.x) >= s.w / 2 { + // Child sits beyond the vertex: straight down the flank, then flow in. + if calc.abs(exit.at(0) - entry.at(0)) < straight-eps { + (exit, entry) + } else { + (exit, (entry.at(0), s.y), entry) + } + } else { + // Child sits within the diamond's span: leave the vertex perpendicular (a + // short stub past the point, so it reads as a right-angle exit like the + // clear case), then flow along and drop into the child's column below the + // diamond, so the run never cuts back through the body. + let out = s.x + side * (s.w / 2 + node-gap / 2) + let clear-y = s.y - s.h / 2 - rank-gap / 2 + (exit, (out, s.y), (out, clear-y), (entry.at(0), clear-y), entry) + } + } else { + // Leave at the source's x (a multi-output node spreads toward each target) + // and run straight into the target's flow-entry face; bend only if the + // source overhangs the target. + let exit = attach( + s, + if fanout.at(str(e.from), default: 0) == 1 { s.x } else { t.x }, + false, + 0.7, + ) + let entry = attach(t, exit.at(0), true, 1.0) + if calc.abs(exit.at(0) - entry.at(0)) < straight-eps { + (exit, entry) + } else { + let my = (exit.at(1) + entry.at(1)) / 2 + (exit, (exit.at(0), my), (entry.at(0), my), entry) + } + } + line( + ..pts.map(map), + stroke: edge-stroke, + mark: (end: ">", fill: arrow-fill, scale: arrow-scale), + ) + if e.label != none { edge-label(e.label, pts) } + } else if e.kind == "back" { + // A loop climbs a side channel: out of the source's side, up, into the target's. + let left = s.x <= center-x + let ch = if left { + min-x - back-margin - left-i * back-gap + } else { + max-x + back-margin + right-i * back-gap + } + if left { left-i += 1 } else { right-i += 1 } + let sx = if left { s.x - s.w / 2 } else { s.x + s.w / 2 } + let tx = if left { t.x - t.w / 2 } else { t.x + t.w / 2 } + let pts = ((sx, s.y), (ch, s.y), (ch, t.y), (tx, t.y)) + line( + ..pts.map(map), + stroke: edge-stroke, + mark: (end: ">", fill: arrow-fill, scale: arrow-scale), + ) + if e.label != none { edge-label(e.label, pts) } + } else { + // A long edge drops down its corridor into the target's top — the corridor, + // vertical run, and entry share one x, so the drop is straight whenever the + // source's column is clear. A decision exits by the side vertex facing the + // corridor (kept outside the diamond, so the run leads away from it); any + // other shape (or a boxed-in diamond) exits its bottom centre and drops + // clear before jogging across only when the column is blocked. + let r = route.at(str(ei)) + let cx = r.cx + let side-ok = r.side-ok + let entry = attach(t, r.entry, true, 1.0) + let ay = entry.at(1) + rank-gap / 2 + let head = if side-ok { + let sx = if cx < s.x { s.x - s.w / 2 } else { s.x + s.w / 2 } + ((sx, s.y), (cx, s.y)) + } else { + let dy = s.y - s.h / 2 - rank-gap / 2 + ((s.x, s.y - s.h / 2), (s.x, dy), (cx, dy)) + } + let pts = head + ((cx, ay), (entry.at(0), ay), entry) + line( + ..pts.map(map), + stroke: edge-stroke, + mark: (end: ">", fill: arrow-fill, scale: arrow-scale), + ) + if e.label != none { edge-label(e.label, pts) } + } + } + + for p in pos.values() { draw-node(p) } + }) +} diff --git a/packages/preview/pivot/0.2.0/src/hexdump/layout.typ b/packages/preview/pivot/0.2.0/src/hexdump/layout.typ new file mode 100644 index 0000000000..874f3f7c4b --- /dev/null +++ b/packages/preview/pivot/0.2.0/src/hexdump/layout.typ @@ -0,0 +1,65 @@ +// layout: raw bytes -> rows for a hex dump, plus the pure formatting leaves the +// renderer needs. No cetz. A hex dump is byte-granular: each row carries its +// byte offset and the byte values that fall in it; the renderer formats the hex +// and ASCII columns and (later) overlays field annotations. `per` is the row +// width in bytes (16 is the classic xxd / `hexdump -C` width). + +#let to-bytes(data) = { + // Accept Typst `bytes` (e.g. `read(file, encoding: none)`) or an int array. + if type(data) == bytes { array(data) } else { data } +} + +#let rows(data, per: 16) = { + let b = to-bytes(data) + let out = () + let r = 0 + while r * per < b.len() { + let lo = r * per + out.push((offset: lo, bytes: b.slice(lo, calc.min(lo + per, b.len())))) + r += 1 + } + out +} + +// Two upper-case hex digits, zero-padded: 13 -> "0D", 255 -> "FF". +#let hex-byte(n) = { + let s = upper(str(n, base: 16)) + "0" * (2 - s.len()) + s +} + +// Zero-padded upper-case hex offset of a fixed digit width (default 8). +#let hex-offset(n, width: 8) = { + let s = upper(str(n, base: 16)) + "0" * calc.max(0, width - s.len()) + s +} + +// Printable ASCII renders as itself; everything else as a placeholder dot. +#let printable(n) = n >= 0x20 and n <= 0x7e +#let ascii(n) = if printable(n) { str.from-unicode(n) } else { "." } + +// Lay `n` legend entries into balanced columns: aim for `per-col` a column, but +// never exceed `max-cols` (so 1 column up to `per-col`, 2 up to `2*per-col`, 3 +// beyond — then columns just grow taller). Sizes differ by at most one, filled +// top-down then left-to-right, so there's no lonely trailing column. Returns +// `(cols, rows, positions)` where `positions.at(k) = (col, row)` for entry `k`. +#let legend-columns(n, per-col: 3, max-cols: 3) = { + if n <= 0 { + (cols: 0, rows: 0, positions: ()) + } else { + let cols = calc.min(max-cols, calc.ceil(n / per-col)) + let base = int(n / cols) + let extra = calc.rem(n, cols) + let positions = () + let c = 0 + let r = 0 + for _i in range(0, n) { + positions.push((c, r)) + r += 1 + if r >= base + (if c < extra { 1 } else { 0 }) { + c += 1 + r = 0 + } + } + (cols: cols, rows: calc.ceil(n / cols), positions: positions) + } +} diff --git a/packages/preview/pivot/0.2.0/src/hexdump/render.typ b/packages/preview/pivot/0.2.0/src/hexdump/render.typ new file mode 100644 index 0000000000..940207a019 --- /dev/null +++ b/packages/preview/pivot/0.2.0/src/hexdump/render.typ @@ -0,0 +1,202 @@ +#import "@preview/cetz:0.5.2" as cetz +#import "../theme.typ" as theme-mod +#import "../field/model.typ": model +#import "../field/layout.typ": layout +#import "layout.typ": ascii, hex-byte, hex-offset, legend-columns, rows + +// hexdump: the byte-region cluster's annotation view — real bytes laid out +// `per` to a row with an ASCII gutter, with field annotations highlighted in +// place and named in a legend below. `data` is Typst `bytes` (typically +// `read(file, encoding: none)`) or a plain int array; annotations are the shared +// `bytes`/`bits` constructors, their byte ranges rounded to whole cells. Returns +// content. +#let hexdump( + ..annotations, + data: none, + per: 16, + theme: theme-mod.default, +) = context { + assert( + data != none, + message: "hexdump: `data:` is required (e.g. read(file, encoding: none))", + ) + let rs = rows(data, per: per) + + // Annotations -> fields -> per-row segments (a range crossing a row break + // splits into one segment per row). + let fields = model(annotations.pos()) + let segs = layout(fields, bits-per-row: per * 8) + // In-grid highlighting is opt-in: a field is drawn only when it has a `fill:`. + // A hexdump has no per-field box to fall back on (unlike `packet`/`struct`), so + // colour is the sole in-grid marker. The fill is honoured whether or not the + // field is labelled (the label only names it in the legend); an unfilled field + // isn't dropped either — it's still listed in the legend below. + let field-segs = segs.filter(s => s.kind == "field" and s.fill != none) + + // One legend entry per distinct annotated field, first-appearance order, + // carrying its byte range and its `fill:` colour. The colour may be `none`: an + // unfilled annotation is still listed (we never drop what the author passed) — + // it just gets no swatch here and no in-grid highlight above. + let legend = () + for f in fields { + let is-new = ( + f.kind == "field" + and f.label != none + and legend.find(e => e.label == f.label) == none + ) + if is-new { + legend.push(( + label: f.label, + color: f.fill, + lo: int(f.start / 8), + hi: int(f.end / 8), + )) + } + } + + let mono = theme.hexdump-font + let size = theme.hexdump-size + let legend-size = theme.hexdump-legend-size + let line-h = theme.hexdump-line / 1cm + let off-fill = theme.hexdump-offset-color + let text-fill = theme.hexdump-text-color + let legend-gap = theme.hexdump-legend-gap / 1cm + let swatch = theme.hexdump-swatch / 1cm + let label-pad = theme.label-pad / 1cm + let per-col = theme.hexdump-legend-rows + let max-cols = theme.hexdump-legend-cols + let legend-col-gap = theme.hexdump-legend-col-gap / 1cm + + // The grid is monospace, so one glyph advance sizes every column; measure it + // once. `mono-text` is captured before the canvas so `text` stays the builtin. + let char-w = measure(text(font: mono, size: size, "0")).width / 1cm + let mono-text = (body, fill) => text(font: mono, size: size, fill: fill, body) + // Legend is set a notch smaller than the grid (its own size token). + let mono-legend = (body, fill) => text( + font: mono, + size: legend-size, + fill: fill, + body, + ) + + // Compact hex byte range for a legend row: "0x00–0x01" (single byte: "0x3C"). + // `range-w` reserves a column so the names line up after the ranges. + let range-of = e => { + let lo = "0x" + hex-offset(e.lo, width: 2) + if e.lo == e.hi { lo } else { lo + "–0x" + hex-offset(e.hi, width: 2) } + } + let range-w = calc.max( + 0, + ..legend.map(e => measure(mono-legend(range-of(e), off-fill)).width / 1cm), + ) + // Widest name, so a wrapped second column starts past the first's labels. + let label-w = calc.max( + 0, + ..legend.map(e => measure(text(size: legend-size, e.label)).width / 1cm), + ) + + // Lay the legend into balanced columns, capped at `max-cols` (see layout.typ). + let leg-pos = legend-columns( + legend.len(), + per-col: per-col, + max-cols: max-cols, + ).positions + + // A byte's trailing separator: none after the last, a wider gap every 8 bytes. + let sep = j => if j == per - 1 { "" } else if calc.rem(j + 1, 8) == 0 { + " " + } else { " " } + // Glyph column where byte `j`'s hex pair starts, within the hex block. + let hpos = j => range(0, j).map(k => 2 + sep(k).len()).sum(default: 0) + + // Column origins, in glyph advances from the left: the offset column (as wide + // as `hex-offset` renders) then a two-glyph gap. The hex block is padded to a + // full row so the ASCII column never shifts on a short final row. + let hex-x = (hex-offset(0).len() + 2) * char-w + let full-hex = range(0, per).map(j => "00" + sep(j)).join() + let ascii-x = hex-x + (full-hex.len() + 1) * char-w + + cetz.canvas({ + import cetz.draw: * + + // Highlights first, behind the glyphs. A byte range covers whole hex cells + // (with the separators between them) and the matching ASCII chars; a + // multi-row range tiles across the row bands. + let half = line-h / 2 + for s in field-segs { + let y = -s.row * line-h + let a = int(s.col-start / 8) + let b = int(s.col-end / 8) + let col = s.fill + rect( + (hex-x + hpos(a) * char-w, y - half), + (hex-x + (hpos(b) + 2) * char-w, y + half), + fill: col, + stroke: none, + ) + rect( + (ascii-x + (1 + a) * char-w, y - half), + (ascii-x + (2 + b) * char-w, y + half), + fill: col, + stroke: none, + ) + } + + // The dump rows, on top of the highlights. + for (i, row) in rs.enumerate() { + let y = -i * line-h + content( + (0, y), + mono-text(hex-offset(row.offset), off-fill), + anchor: "west", + ) + let cells = range(0, per) + .map(j => ( + ( + if j < row.bytes.len() { hex-byte(row.bytes.at(j)) } else { " " } + ) + + sep(j) + )) + .join() + content((hex-x, y), mono-text(cells, text-fill), anchor: "west") + let glyphs = row.bytes.map(ascii).join() + content( + (ascii-x, y), + mono-text("|" + glyphs + "|", text-fill), + anchor: "west", + ) + } + + // Legend: colour swatch + byte range + field name. Entries fill a column + // top-down, then wrap into the next column after `per-col` (e.g. >3 -> two + // columns of three), so a long field list stays compact. + let base-y = -rs.len() * line-h - legend-gap + let label-x = swatch + label-pad + range-w + label-pad + let col-width = label-x + label-w + legend-col-gap + for (k, e) in legend.enumerate() { + let (ci, ri) = leg-pos.at(k) + let lx = ci * col-width + let ly = base-y - ri * line-h + // A swatch only for a filled field; an unfilled one still lists its range + // and label, the swatch column left blank so the columns stay aligned. + if e.color != none { + rect( + (lx, ly - swatch / 2), + (lx + swatch, ly + swatch / 2), + fill: e.color, + stroke: none, + ) + } + content( + (lx + swatch + label-pad, ly), + mono-legend(range-of(e), off-fill), + anchor: "west", + ) + content( + (lx + label-x, ly), + text(size: legend-size, fill: text-fill, e.label), + anchor: "west", + ) + } + }) +} diff --git a/packages/preview/pivot/0.2.0/src/packet/render.typ b/packages/preview/pivot/0.2.0/src/packet/render.typ new file mode 100644 index 0000000000..fdd8a34dee --- /dev/null +++ b/packages/preview/pivot/0.2.0/src/packet/render.typ @@ -0,0 +1,340 @@ +#import "@preview/cetz:0.5.2" as cetz +#import "../theme.typ" as theme-mod +#import "../field/model.typ": model +#import "../field/layout.typ": layout + +// packet: the entry function. Collects element descriptors, runs the pure +// model -> layout pipeline, and draws the segments with cetz. Returns content. +// +// `callout` controls where narrow fields' labels go: +// "gap" — fanned into the enlarged gap below their row (orthogonal leaders) +// "left" — left-aligned column, leaders fanning left/right (orthogonal) +// `ruler` controls the bit-edge numbers: +// "dedup" — a boundary number is shown only the first time (top-down) it +// appears; repeats are hidden and rows that introduce no new boundary +// lose their number strip and tuck closer (default) +// "full" — every box edge is numbered on every row +#let packet( + ..args, + bits-per-row: 32, + callout: "left", + ruler: "dedup", + theme: theme-mod.default, +) = context { + let fields = model(args.pos()) + let segments = layout(fields, bits-per-row: bits-per-row) + + // Capture tokens into differently-named locals BEFORE `import cetz.draw: *`. + let cell-stroke = theme.stroke + let cell-fill = theme.fill + let gap-stroke = theme.gap-stroke + let gap-fill = theme.gap-fill + let leader-stroke = theme.leader-stroke + let bit-w = theme.bit-width / 1cm + let row-h = theme.row-height / 1cm + let row-gap = theme.row-gap / 1cm + let col-gap = theme.col-gap / 1cm + let strip = theme.strip / 1cm + let label-size = theme.label-size + let bit-size = theme.bit-size + let bit-fill = theme.bit-color + let bit-font = theme.bit-font + let label-pad = theme.label-pad / 1cm + let callout-drop = theme.callout-drop / 1cm + let callout-bottom = theme.callout-bottom / 1cm + let callout-spacing = theme.callout-spacing / 1cm + let callout-gap = theme.callout-gap / 1cm + let line-h = theme.callout-line-height / 1cm + let stub = theme.callout-stub / 1cm + let side-gap = theme.callout-side-gap / 1cm + let label-gap = theme.callout-label-pad / 1cm + let gap-drop = theme.callout-gap-drop / 1cm + let lane-top = theme.callout-gap-lane-top / 1cm + let lane-bot = theme.callout-gap-lane-bot / 1cm + let gap-leader = theme.callout-gap-leader / 1cm + + // Measure each segment's label once. "narrow" (label wider than its box -> it + // needs a callout) and the label width are then O(1) lookups, reused by the + // filters, the crowding check, the callout list, and the draw loop below. + let meas = (:) + for s in segments { + let w = if s.kind == "gap" or s.label == none { + 0.0 + } else { + measure(text(size: label-size, s.label)).width / 1cm + } + let box-w = (s.col-end - s.col-start + 1) * bit-w - col-gap - 2 * label-pad + meas.insert( + str(s.row) + "/" + str(s.col-start), + (w: w, narrow: s.kind != "gap" and s.label != none and w > box-w), + ) + } + let is-narrow = s => meas.at(str(s.row) + "/" + str(s.col-start)).narrow + let width-of = s => meas.at(str(s.row) + "/" + str(s.col-start)).w + let callout-rows = segments.filter(is-narrow).map(s => s.row).dedup() + let max-row = calc.max(0, ..segments.map(s => s.row)) + let count-in = r => segments.filter(s => s.row == r and is-narrow(s)).len() + + // A narrow field's horizontal centre (independent of the vertical layout). + let cx-of = s => (s.col-start + s.col-end + 1) * bit-w / 2 + + // The compact split's LEFT label column: hug the leftmost field, but clamp so + // the widest label can't cross the frame's left edge (x = 0). Shared by the + // crowding test and the layout so both reason about the same geometry. `lg` is + // a non-empty list of items with `.cx` (field centre) and `.w` (label width). + let left-col-x = lg => calc.max( + calc.min(..lg.map(c => c.cx)) - side-gap, + calc.max(..lg.map(c => c.w)) + label-gap, + ) + + // Per callout row: would the compact left/right split cross a label? It does + // when a left-column field's drop falls within a higher label's text — i.e. the + // row is crowded with thin segments. Such rows switch to a single column placed + // OUTSIDE the frame (the struct layout), crossing-free by construction. + let crowded = (:) + for r in callout-rows { + let info = segments + .filter(s => s.row == r and is-narrow(s)) + .sorted(key: s => s.col-start) + .map(s => (cx: cx-of(s), w: width-of(s))) + let left = info.slice(0, calc.ceil(info.len() / 2)) + // Predict the compact-split LEFT layout: labels right-aligned at + // `lx - label-gap`; a field left of `lx` drops a short straight stub. Such a + // stub crosses an earlier label when the field's centre falls within that + // label's text span. (A field right of `lx` turns in from beyond every label, + // and the right group's labels sit beyond every field, so neither can cross.) + // A row that would cross switches to the crowded single-column layout below. + let cross = false + if left.len() >= 2 { + let lx = left-col-x(left) + let label-right = lx - label-gap + for j in range(1, left.len()) { + if left.at(j).cx < lx { + for i in range(0, j) { + if ( + left.at(j).cx >= label-right - left.at(i).w + and left.at(j).cx <= label-right + ) { cross = true } + } + } + } + } + crowded.insert(str(r), cross) + } + + // Height of the annotation band below a callout row. A crowded row uses one + // tall column; otherwise the taller of the split's two columns sets the height. + let band = r => { + if callout != "left" { + callout-gap + } else if crowded.at(str(r), default: false) { + callout-drop + (count-in(r) - 1) * line-h + callout-bottom + } else { + let half = calc.ceil(count-in(r) / 2) + let lanes = calc.max(half, count-in(r) - half) + callout-drop + (lanes - 1) * line-h + callout-bottom + } + } + + // Ruler de-duplication. A boundary is keyed by (position, number) so a right + // edge `15` and a left edge `16` near the same x stay distinct. In "dedup" + // mode a key is shown only the first time it appears top-down; later repeats + // hide, and a row that introduces none keeps no number strip. "full" shows all. + let seen = () + let show-num = (:) + let row-has-num = (:) + for r in range(0, max-row + 1) { + let any = false + for s in segments.filter(s => s.row == r).sorted(key: s => s.col-start) { + let lk = (s.col-start, s.col-start) + let rk = (s.col-end + 1, s.col-end) + let sl = ruler == "full" or not seen.contains(lk) + let sr = ruler == "full" or not seen.contains(rk) + if not seen.contains(lk) { seen.push(lk) } + if not seen.contains(rk) { seen.push(rk) } + show-num.insert(str(r) + "/" + str(s.col-start), (left: sl, right: sr)) + any = any or sl or sr + } + row-has-num.insert(str(r), any) + } + + // Precompute each row's box-top y (rows stack downward; up is positive). A row + // with no visible ruler numbers needs no top strip, so it tucks closer. + let box-tops = () + let row-pad = () + let yacc = 0.0 + for r in range(0, max-row + 1) { + let pad = if row-has-num.at(str(r)) { strip } else { 0.0 } + row-pad.push(pad) + box-tops.push(-(yacc + pad)) + let gap-below = if callout-rows.contains(r) { band(r) } else { row-gap } + yacc += pad + row-h + gap-below + } + + // Geometry of a segment's box, and the narrow ones' callout data (widths + // measured here, in `context`, so leaders can stop just past each label). + let seg-box = s => { + let x0 = s.col-start * bit-w + col-gap / 2 + let x1 = (s.col-end + 1) * bit-w - col-gap / 2 + let top = box-tops.at(s.row) + (x0: x0, x1: x1, top: top, bot: top - row-h) + } + let callouts = segments + .filter(is-narrow) + .map(s => { + let b = seg-box(s) + ( + row: s.row, + cx: (b.x0 + b.x1) / 2, + by: b.bot, + label: s.label, + w: width-of(s), + ) + }) + + cetz.canvas({ + import cetz.draw: * + + for s in segments { + let b = seg-box(s) + let is-gap = s.kind == "gap" + let box-stroke = if is-gap { gap-stroke } else { cell-stroke } + let box-fill = if is-gap { gap-fill } else if s.fill != none { + s.fill + } else { cell-fill } + rect((b.x0, b.bot), (b.x1, b.top), stroke: box-stroke, fill: box-fill) + + if (not is-gap) and s.label != none and not is-narrow(s) { + content(((b.x0 + b.x1) / 2, (b.top + b.bot) / 2), text( + size: label-size, + s.label, + )) + } + + let ny = b.top + row-pad.at(s.row) / 2 + let sn = show-num.at(str(s.row) + "/" + str(s.col-start)) + if sn.left { + content( + (b.x0, ny), + text(size: bit-size, font: bit-font, fill: bit-fill, str( + s.col-start, + )), + anchor: "west", + ) + } + if sn.right { + content( + (b.x1, ny), + text(size: bit-size, font: bit-font, fill: bit-fill, str(s.col-end)), + anchor: "east", + ) + } + } + + for r in callouts.map(c => c.row).dedup() { + let group = callouts.filter(c => c.row == r).sorted(key: c => c.cx) + let n = group.len() + let row-bot = box-tops.at(r) - row-h + + if callout == "left" and crowded.at(str(r), default: false) { + // Crowded row: one column OUTSIDE the frame (the struct layout). Labels + // right-aligned just left of the leftmost field, drops stay inside the + // frame, so a drop can never cut a label; leftmost field on top keeps the + // orthogonal leaders un-crossed. + let gutter = calc.min(..group.map(c => c.cx)) - side-gap + for (i, c) in group.enumerate() { + let y = row-bot - callout-drop - i * line-h + line((c.cx, c.by), (c.cx, y), (gutter, y), stroke: leader-stroke) + content( + (gutter - label-gap, y), + text(size: label-size, c.label), + anchor: "east", + ) + } + } else if callout == "left" { + // Compact split into two columns, each anchored to its OWN fields: the + // left half fans left to just past the leftmost field, the right half + // fans right past the rightmost — so neither group strands its labels at + // the frame edge. Lane order (outermost field on top per side) keeps the + // orthogonal leaders un-crossed. Used when the row would not crowd. + let mid = calc.ceil(n / 2) + let left-grp = group.slice(0, mid) + let right-grp = group.slice(mid) + + // Left column (see `left-col-x`): hug the leftmost field, clamped to the + // frame edge. A field hard against the edge then gets a straight stub + // rather than a leader turning rightward through its own label. + let left-x = left-col-x(left-grp) + for (i, c) in left-grp.enumerate() { + let y = row-bot - callout-drop - i * line-h + // A field to the right of the column turns left onto its label; a field + // sitting over its own label (clamped column) gets a short straight + // stub instead, so the leader never runs through the text. + if c.cx >= left-x { + line((c.cx, c.by), (c.cx, y), (left-x, y), stroke: leader-stroke) + } else { + line((c.cx, c.by), (c.cx, y + stub), stroke: leader-stroke) + } + content( + (left-x - label-gap, y), + text(size: label-size, c.label), + anchor: "east", + ) + } + + // Right half — empty when the row has a single narrow field (it falls in + // `left-grp`), so guard against `calc.max` of nothing. Unlike the left, it + // extends outward unclamped: a long label on a far-right field may spill + // past the frame's right edge, the accepted fallback when a label has + // nowhere else to go. + if right-grp.len() > 0 { + let right-x = calc.max(..right-grp.map(c => c.cx)) + side-gap + let right-n = right-grp.len() + for (i, c) in right-grp.enumerate() { + let y = row-bot - callout-drop - (right-n - 1 - i) * line-h + line((c.cx, c.by), (c.cx, y), (right-x, y), stroke: leader-stroke) + content( + (right-x + label-gap, y), + text(size: label-size, c.label), + anchor: "west", + ) + } + } + } else { + // In-gap fan: orthogonal Z leaders, label row near the band's bottom. + let centroid = group.map(c => c.cx).sum() / n + let start-lx = centroid - (n - 1) * callout-spacing / 2 + let items = group + .enumerate() + .map(((i, c)) => ( + cx: c.cx, + by: c.by, + label: c.label, + lx: start-lx + i * callout-spacing, + )) + let label-y = row-bot - callout-gap + gap-drop + let lane-hi = row-bot - lane-top + let lane-lo = label-y + lane-bot + let order = items + .enumerate() + .sorted(key: ((j, it)) => -calc.abs(it.lx - centroid)) + for (rank, (j, it)) in order.enumerate() { + let t = if n <= 1 { 0 } else { rank / (n - 1) } + let lane = lane-lo + t * (lane-hi - lane-lo) + line( + (it.cx, row-bot), + (it.cx, lane), + (it.lx, lane), + (it.lx, label-y + gap-leader), + stroke: leader-stroke, + ) + content( + (it.lx, label-y), + text(size: label-size, it.label), + anchor: "north", + ) + } + } + } + }) +} diff --git a/packages/preview/pivot/0.2.0/src/palette.typ b/packages/preview/pivot/0.2.0/src/palette.typ new file mode 100644 index 0000000000..f380b75e25 --- /dev/null +++ b/packages/preview/pivot/0.2.0/src/palette.typ @@ -0,0 +1,16 @@ +// palette: the shared Okabe–Ito colour-blind-safe qualitative palette, lightened +// into highlight backgrounds that keep black text legible. One source of truth +// for field-highlight colour across all three diagrams — reach for a colour by +// name in an explicit `fill:`, e.g. `fill: palette.orange`. Timeline markers fill +// with these and rim them with a darker shade of the same hue (see render). Pure. + +#let palette = ( + orange: rgb("#E69F00").lighten(45%), + sky: rgb("#56B4E9").lighten(45%), + green: rgb("#009E73").lighten(45%), + yellow: rgb("#F0E442").lighten(45%), + blue: rgb("#0072B2").lighten(45%), + vermillion: rgb("#D55E00").lighten(45%), + purple: rgb("#CC79A7").lighten(45%), + grey: rgb("#000000").lighten(80%), +) diff --git a/packages/preview/pivot/0.2.0/src/struct/layout.typ b/packages/preview/pivot/0.2.0/src/struct/layout.typ new file mode 100644 index 0000000000..8ea3e6df65 --- /dev/null +++ b/packages/preview/pivot/0.2.0/src/struct/layout.typ @@ -0,0 +1,91 @@ +// struct/layout.typ — field model -> vertically stacked entries for the struct +// (memory-map) renderer. Pure: no cetz, no theme. +// +// Two entry types: +// "box" — a whole-byte-aligned field: one vertical box, height proportional +// to byte size (floored to `min-height`, capped at `max-height`; +// `clamped` flags the cap for a break mark). +// "bits" — a byte-run carved into sub-byte fields: one strip the height of its +// byte-run, holding `cells` for the renderer to subdivide +// horizontally. Consecutive fields are grouped until the run returns +// to a byte boundary, so a field that straddles a byte (and any whole +// bytes it pulls in) stays in one strip — true to real layouts like +// IPv4's 3 flag bits + 13-bit fragment offset. +// +// `scale`/`min-height`/`max-height`/`gap` are canvas units from the renderer. +// `start`/`size` stay in bits; offset formatting is the renderer's job. + +#let _box-height(bits, scale, min-height, max-height) = { + let raw = (bits / 8) * scale + ( + height: calc.max(min-height, calc.min(max-height, raw)), + clamped: raw > max-height, + ) +} + +#let layout( + fields, + scale: 0.3, + min-height: 0.55, + max-height: 2.2, + gap: 0.0, +) = { + let entries = () + let top = 0.0 + let i = 0 + let n = fields.len() + while i < n { + let f = fields.at(i) + let whole = calc.rem(f.start, 8) == 0 and calc.rem(f.end + 1, 8) == 0 + if whole { + let size = f.end - f.start + 1 + let h = _box-height(size, scale, min-height, max-height) + entries.push(( + type: "box", + kind: f.kind, + label: f.label, + fill: f.at("fill", default: none), + start: f.start, + size: size, + top: top, + height: h.height, + clamped: h.clamped, + )) + top += h.height + gap + i += 1 + } else { + // accumulate a bit-group until the run returns to a byte boundary + let group-start = calc.quo(f.start, 8) * 8 + let cells = () + while i < n { + let g = fields.at(i) + cells.push(( + kind: g.kind, + label: g.label, + fill: g.at("fill", default: none), + start: g.start, + size: g.end - g.start + 1, + )) + i += 1 + if calc.rem(g.end + 1, 8) == 0 { break } + } + let last = cells.last() + let size = last.start + last.size - group-start + // bit-strips are never height-capped: a multi-byte bit-run is rare, and a + // break mark across a subdivided strip would be awkward — so they render at + // true (floored) height. Model and renderer agree: bits never clamp. + let height = calc.max(min-height, (size / 8) * scale) + entries.push(( + type: "bits", + start: group-start, + size: size, + top: top, + height: height, + clamped: false, + cells: cells, + )) + top += height + gap + } + } + entries +} diff --git a/packages/preview/pivot/0.2.0/src/struct/render.typ b/packages/preview/pivot/0.2.0/src/struct/render.typ new file mode 100644 index 0000000000..34008b9a63 --- /dev/null +++ b/packages/preview/pivot/0.2.0/src/struct/render.typ @@ -0,0 +1,266 @@ +#import "@preview/cetz:0.5.2" as cetz +#import "../theme.typ" as theme-mod +#import "../field/model.typ": model +#import "layout.typ": layout + +// Hex string for a non-negative integer (no "0x" prefix). Typst markup has no +// built-in int -> hex, so build it. +#let _hex(n) = { + if n == 0 { + return "0" + } + let digits = "0123456789ABCDEF" + let s = "" + let v = n + while v > 0 { + s = digits.at(calc.rem(v, 16)) + s + v = calc.quo(v, 16) + } + s +} + +// struct: the byte-region cluster's vertical "memory map" view. Fields stack +// top-down with box height proportional to byte size (see struct/layout); byte +// offsets run down the left edge, sizes down the right. Same element vocabulary +// as packet (`bytes`/`bits`/`gap`/`reserved`); no row-wrapping. Returns content. +#let struct(..args, theme: theme-mod.default) = context { + let fields = model(args.pos()) + + // Capture tokens into renamed locals BEFORE `import cetz.draw: *` shadows them. + let cell-stroke = theme.stroke + let cell-fill = theme.fill + let gap-stroke = theme.gap-stroke + let gap-fill = theme.gap-fill + let label-size = theme.label-size + let meta-size = theme.bit-size + let meta-fill = theme.bit-color + let meta-font = theme.bit-font + let box-w = theme.struct-width / 1cm + let scale = theme.struct-byte-height / 1cm + let min-h = theme.struct-min-height / 1cm + let max-h = theme.struct-max-height / 1cm + let side-gap = theme.struct-offset-gap / 1cm + let row-gap = theme.row-gap / 1cm + let col-gap = theme.col-gap / 1cm + let break-amp = theme.struct-break-amp / 1cm + let break-pitch = theme.struct-break-pitch / 1cm + let label-pad = theme.label-pad / 1cm + let leader-stroke = theme.leader-stroke + let callout-drop = theme.callout-drop / 1cm + let callout-line-h = theme.callout-line-height / 1cm + let callout-bottom = theme.callout-bottom / 1cm + + let entries = layout( + fields, + scale: scale, + min-height: min-h, + max-height: max-h, + gap: row-gap, + ) + // Pad offsets to the width of the largest (the struct's end) so they read as + // aligned addresses: 0x00, 0x04, ... 0x10. + let pad-width = if entries.len() > 0 { + _hex(calc.quo(entries.last().start + entries.last().size, 8)).len() + } else { + 1 + } + + cetz.canvas({ + import cetz.draw: * + let x0 = 0.0 + let x1 = box-w + + // a byte offset, formatted hex with a `:bit` suffix for sub-byte starts + let off-label = bitpos => { + let byte = calc.quo(bitpos, 8) + let bit = calc.rem(bitpos, 8) + let h = _hex(byte) + while h.len() < pad-width { h = "0" + h } + "0x" + h + if bit != 0 { ":" + str(bit) } else { "" } + } + + let extra = 0.0 + for (i, e) in entries.enumerate() { + let top = -(e.top + extra) + let bot = -(e.top + e.height + extra) + + if e.type == "box" { + let is-gap = e.kind == "gap" + let box-stroke = if is-gap { gap-stroke } else { cell-stroke } + let box-fill = if is-gap { + gap-fill + } else if e.fill != none { + e.fill + } else { + cell-fill + } + // A clamped (oversized) field is capped below its true size, so its + // bottom edge becomes a torn zigzag "break mark" — the height is drawn + // short on purpose, and this says so. The size label still shows true size. + if e.clamped { + let n = 2 * int(calc.max(2, calc.round((x1 - x0) / break-pitch))) + let step = (x1 - x0) / n + let zig = range(0, n + 1).map(k => ( + x1 - k * step, + if calc.rem(k, 2) == 0 { bot } else { bot + break-amp }, + )) + line( + (x0, top), + (x1, top), + ..zig, + close: true, + stroke: box-stroke, + fill: box-fill, + ) + } else { + rect((x0, bot), (x1, top), stroke: box-stroke, fill: box-fill) + } + // Label placement, in order of preference: centred on one line if it + // fits; wrapped to at most two lines inside the box if it doesn't; and + // only when even two lines won't fit (too long, or too tall for the box) + // does it move to a leader callout centred BELOW the box. The callout + // label is centred on its leader, so the diagram itself stays centred and + // the offset column stays put on the left. + if e.label != none { + let cx = (x0 + x1) / 2 + let mid-y = (top + bot) / 2 + let avail-w = (x1 - x0) - 2 * label-pad + let nat-w = measure(text(size: label-size, e.label)).width / 1cm + if nat-w <= avail-w { + content((cx, mid-y), text(size: label-size, e.label)) + } else { + // wrap to the box width and see if it lands within two lines and the + // box height; a reference two-line block sets the line-count ceiling. + // Disable justification: a wrapped label must not inherit the + // document's `par(justify: true)`, which would stretch a line with + // few break points into huge inter-word gaps. + let wrapped = box(width: avail-w * 1cm, { + set par(justify: false) + align(center, text(size: label-size, e.label)) + }) + let wh = measure(wrapped).height / 1cm + let two-line = ( + measure(box( + width: avail-w * 1cm, + text(size: label-size)[A\ A], + )).height + / 1cm + ) + let avail-h = (top - bot) - 2 * label-pad + if wh <= two-line + 0.01 and wh <= avail-h { + content((cx, mid-y), wrapped) + } else { + // too long even for two lines: drop a leader and hang the same + // wrapped block (centred, box width) below the box, so the callout + // stays contained and centred rather than sprawling as one line. + let ly = bot - callout-drop + line((cx, bot), (cx, ly), stroke: leader-stroke) + content((cx, ly), wrapped, anchor: "north") + extra += callout-drop + wh + callout-bottom + } + } + } + } else { + // a bit-carved byte-run: one strip subdivided horizontally into cells. + // A cell shows its name inline if it fits, else its bit number and a + // leader callout below (a 1-bit cell can't hold a name). + let callouts = () + let last-j = e.cells.len() - 1 + for (j, c) in e.cells.enumerate() { + let fcx0 = x0 + (c.start - e.start) / e.size * (x1 - x0) + let fcx1 = x0 + (c.start + c.size - e.start) / e.size * (x1 - x0) + // float the cells by `col-gap`, but keep the strip's outer edges flush + // with the boxes above/below (only inset the internal boundaries). + let cx0 = fcx0 + if j == 0 { 0.0 } else { col-gap / 2 } + let cx1 = fcx1 - if j == last-j { 0.0 } else { col-gap / 2 } + let cgap = c.kind == "gap" + rect( + (cx0, bot), + (cx1, top), + stroke: if cgap { gap-stroke } else { cell-stroke }, + fill: if cgap { gap-fill } else if c.fill != none { c.fill } else { + cell-fill + }, + ) + let mid = ((cx0 + cx1) / 2, (top + bot) / 2) + let fits = ( + c.label != none + and measure(text(size: label-size, c.label)).width / 1cm + <= (cx1 - cx0) - 2 * label-pad + ) + if fits { + content(mid, text(size: label-size, c.label)) + } else { + content( + mid, + text(size: meta-size, font: meta-font, fill: meta-fill, str( + c.start - e.start, + )), + ) + if c.label != none { + callouts.push((cx: (cx0 + cx1) / 2, label: c.label)) + } + } + } + // names that did not fit: stacked in a band below the strip, ordered + // left-to-right so the orthogonal drop-then-turn leaders never cross. + let m = callouts.len() + if m > 0 { + // labels live OUTSIDE the frame (left of x0, right-aligned at the same + // gutter as the offsets); leaders drop inside the frame then turn out + // to them. Drops stay at x >= x0, labels at x < x0, so a drop can never + // cut through a label's text. + let gutter = x0 - side-gap + for (k, c) in callouts.sorted(key: it => it.cx).enumerate() { + let ly = bot - callout-drop - k * callout-line-h + line((c.cx, bot), (c.cx, ly), (gutter, ly), stroke: leader-stroke) + content( + (gutter, ly), + text(size: label-size, c.label), + anchor: "east", + ) + } + extra += callout-drop + (m - 1) * callout-line-h + callout-bottom + } + } + + // offset at the boundary it marks (left column): centred in the gap above + // each entry, except the first, which has no gap and sits at its top edge. + let off-y = if i == 0 { top } else { top + row-gap / 2 } + content( + (x0 - side-gap, off-y), + text(size: meta-size, font: meta-font, fill: meta-fill, off-label( + e.start, + )), + anchor: "east", + ) + + // size, centred on the box (right column) + let size-str = if calc.rem(e.size, 8) == 0 { + str(calc.quo(e.size, 8)) + " B" + } else { + str(e.size) + " b" + } + content( + (x1 + side-gap, (top + bot) / 2), + text(size: meta-size, font: meta-font, fill: meta-fill, size-str), + anchor: "west", + ) + } + + // closing offset: the byte just past the last field (the struct's end) + if entries.len() > 0 { + let last = entries.last() + content( + (x0 - side-gap, -(last.top + last.height + extra)), + text( + size: meta-size, + font: meta-font, + fill: meta-fill, + off-label(last.start + last.size), + ), + anchor: "east", + ) + } + }) +} diff --git a/packages/preview/pivot/0.2.0/src/theme.typ b/packages/preview/pivot/0.2.0/src/theme.typ new file mode 100644 index 0000000000..a9d22bbd4b --- /dev/null +++ b/packages/preview/pivot/0.2.0/src/theme.typ @@ -0,0 +1,106 @@ +// Style tokens. Render code reads tokens from a theme dict; no style literals +// live in draw code. `default` is the typeset baseline: monochrome line-art +// (RFC style), which is colour-blind-safe by construction — colour only enters +// when the author sets an explicit `fill:` on a field. + +#let default = ( + bit-width: 0.42cm, // width of one bit column + row-height: 0.8cm, // height of a field box + row-gap: 0.28cm, // vertical space between rows (floating boxes) + col-gap: 0.12cm, // horizontal space between adjacent fields (floating boxes) + strip: 0.4cm, // band above each row, holding the bit-edge numbers + stroke: 0.6pt + black, // box border + fill: none, // box fill (overridden per-field by `fill:`) + gap-stroke: (paint: luma(55%), thickness: 0.5pt, dash: "dashed"), // gap border + gap-fill: luma(96%), // gap (unparsed-span) fill + label-size: 9pt, // field-label text size + bit-size: 7pt, // bit-ruler number size + bit-color: luma(40%), // bit-ruler number colour + // The ruler is pinned to an embedded monospace so it stays aligned regardless + // of the document's label font (which field labels inherit). Embedded = present + // on every Typst install; override only with a font you know consumers have. + bit-font: "DejaVu Sans Mono", + // A field whose label is wider than its box (by more than this padding) gets + // an exploded callout instead of an inline label. + label-pad: 0.12cm, // inner breathing room when testing if a label fits its box + callout-drop: 0.55cm, // headroom from the row down to the first callout label + callout-bottom: 0.35cm, // space below the last callout label before the next row + callout-spacing: 1.7cm, // horizontal spacing between callout labels (gap mode) + callout-gap: 2.3cm, // enlarged inter-row gap below a row that has callouts + callout-line-height: 0.5cm, // vertical pitch of stacked callout labels (left mode) + callout-stub: 0.16cm, // left mode: short straight stub for a field over its own label + callout-side-gap: 0.35cm, // gap between the fields and a label column (each side) + callout-label-pad: 0.08cm, // gap between a leader's end and its label text + callout-gap-drop: 0.45cm, // gap mode: label row offset up from the band bottom + callout-gap-lane-top: 0.22cm, // gap mode: topmost leader lane below the row + callout-gap-lane-bot: 0.28cm, // gap mode: bottom leader lane above the labels + callout-gap-leader: 0.18cm, // gap mode: leader end just above the label + leader-stroke: 0.4pt + luma(50%), // the leader line + // struct (vertical memory-map) view + struct-width: 4cm, // width of a struct field box + struct-byte-height: 0.3cm, // box height per byte, before clamping + struct-min-height: 0.55cm, // floor so small fields stay legible + struct-max-height: 2.2cm, // ceiling so a huge field can't run off the page + struct-offset-gap: 0.25cm, // gap between the offset/size columns and the box + struct-break-amp: 0.1cm, // break-mark zigzag amplitude (clamped/oversized field) + struct-break-pitch: 0.26cm, // break-mark zigzag tooth width + // hexdump (annotated byte dump) view + hexdump-font: "DejaVu Sans Mono", // grid is monospace, pinned like the bit ruler + hexdump-size: 10pt, // hex/ASCII grid text (mono) + hexdump-legend-size: 9pt, // legend text (mono range + name), smaller than the grid + hexdump-line: 0.5cm, // vertical pitch between dump rows + hexdump-text-color: black, // hex + ASCII glyphs + hexdump-offset-color: luma(45%), // the dimmer left offset column + // Field highlights are opt-in: a hexdump colours only the annotations the + // author gives a `fill:` (e.g. `palette.orange`); there's no auto-cycle. + hexdump-legend-gap: 0.5cm, // gap from the dump down to the legend + hexdump-swatch: 0.3cm, // legend colour-swatch size + hexdump-legend-rows: 3, // target entries per column (drives the 1->2->3 switch) + hexdump-legend-cols: 3, // hard cap on legend columns + hexdump-legend-col-gap: 0.7cm, // horizontal gap between legend columns + + // timeline view (events on an axis) + timeline-margin: 0.5cm, // breathing room above and below the whole diagram + timeline-axis-stroke: 1.1pt + luma(40%), // the axis rule and the stems + timeline-pitch: 3.4cm, // spacing between consecutive events along the axis + timeline-row-height: 4.2cm, // snaked: vertical pitch between wrapped rows + timeline-turn-bulge: 3cm, // snaked: how far the U-bend curves out (control offset) + timeline-edge-nudge: 0.1cm, // snaked: optical inset for a marker flush at an edge + timeline-marker-size: 0.22cm, // marker radius + timeline-marker-edge-width: 1.1pt, // marker rim / outline weight + timeline-marker-edge-darken: 40%, // filled marker: rim = the fill darkened by this (>=3:1) + timeline-marker-outline: luma(30%), // unfilled (default) marker: a hollow outline + timeline-marker-fill: white, // hollow marker interior: knocks out the axis behind it (not transparent) + timeline-stem: 0.35cm, // gap from the marker out to its label + timeline-text-width: 2.9cm, // wrap width of the time/title/description block + timeline-text-gap: 0.1cm, // gap between the stem end and the label text + timeline-time-size: 9pt, // the time/date line (matches the title size) + timeline-title-size: 9pt, // the event title + timeline-desc-size: 8pt, // the description + timeline-time-color: luma(35%), // dimmer time/date (7:1 on white, AA) + timeline-text-color: black, // title + description + + // flowchart (nodes joined by directed edges) + flowchart-node-pad-x: 0.45cm, // horizontal padding around a node label + flowchart-node-pad-y: 0.3cm, // vertical padding around a node label + flowchart-node-min-width: 1.5cm, // floor width so small labels still read as boxes + flowchart-node-gap: 0.7cm, // horizontal gap between nodes sharing a rank + flowchart-rank-gap: 1.3cm, // vertical gap between ranks (layers) + flowchart-back-margin: 0.6cm, // gap from the diagram to a long/back edge's side channel + flowchart-back-gap: 0.45cm, // spacing between stacked back-edge channels + flowchart-node-edge-width: 1.1pt, // node border weight (matches timeline marker rim) + flowchart-node-edge-darken: 40%, // filled node: border = the fill darkened by this + flowchart-node-outline: luma(25%), // unfilled node: border colour + flowchart-node-fill: none, // node fill (opt-in per node via fill:) + flowchart-label-size: 9.5pt, // node label text + flowchart-label-color: black, // node label + flowchart-decision-scale: 1.9, // a diamond grows this much around its label + flowchart-io-slant: 0.35cm, // parallelogram lean (io nodes) + flowchart-edge-width: 0.9pt, // edge line weight + flowchart-edge-color: luma(40%), // edge line + arrowhead + flowchart-arrow-scale: 1.4, // arrowhead size + flowchart-edge-label-size: 8.5pt, // branch-label text on an edge + flowchart-edge-label-color: luma(30%), // branch label + flowchart-edge-label-fill: white, // knockout behind a branch label, so it reads over the line + flowchart-edge-label-inset: (x: 3pt, y: 1pt), // padding inside that knockout +) diff --git a/packages/preview/pivot/0.2.0/src/timeline/elements.typ b/packages/preview/pivot/0.2.0/src/timeline/elements.typ new file mode 100644 index 0000000000..c7b612cfe5 --- /dev/null +++ b/packages/preview/pivot/0.2.0/src/timeline/elements.typ @@ -0,0 +1,23 @@ +// Timeline element constructor. An `event` is one marked point on the axis: a +// required title (the trailing content, the anchor) plus optional time and +// description, a marker shape, and an optional `fill:` — a colour makes a filled +// marker (otherwise it's a hollow outline), so shape + fill can encode a stage. +// Sparse is just a title; richer events add time and a description. Pure; no cetz. +// +// event[Initial access] +// event(time: "09:32Z", description: [Phishing macro executed.])[Initial access] + +#let event( + title, + time: none, + description: none, + shape: "circle", + fill: none, +) = ( + kind: "event", + title: title, + time: time, + description: description, + shape: shape, + fill: fill, +) diff --git a/packages/preview/pivot/0.2.0/src/timeline/layout.typ b/packages/preview/pivot/0.2.0/src/timeline/layout.typ new file mode 100644 index 0000000000..bfe31beeb9 --- /dev/null +++ b/packages/preview/pivot/0.2.0/src/timeline/layout.typ @@ -0,0 +1,11 @@ +// layout: ordered events -> placement. Pure, no cetz. +// `slot` is the ordinal position along the axis (the renderer scales it by the +// pitch token); `side` alternates +1 / -1 so consecutive labels sit on opposite +// sides of the axis and don't stack on top of each other. Text measurement and +// the pixel geometry happen in `render` (it needs `context`). + +#let layout(events) = events.map(e => ( + ..e, + slot: e.index, + side: if calc.rem(e.index, 2) == 0 { 1 } else { -1 }, +)) diff --git a/packages/preview/pivot/0.2.0/src/timeline/model.typ b/packages/preview/pivot/0.2.0/src/timeline/model.typ new file mode 100644 index 0000000000..a6f5f23e6a --- /dev/null +++ b/packages/preview/pivot/0.2.0/src/timeline/model.typ @@ -0,0 +1,36 @@ +// model: event descriptors -> validated, ordered events. Pure, no cetz. +// The timeline is ordinal: events keep author order and the renderer spaces them +// evenly. The model only checks each item is an `event` with a known shape, and +// names the offender otherwise (errors on bad input name the field). + +#let shapes = ("circle", "square", "triangle", "diamond") + +#let model(items) = ( + items + .enumerate() + .map(((i, e)) => { + assert( + type(e) == dictionary and e.at("kind", default: none) == "event", + message: "timeline: argument " + str(i) + " is not an event()", + ) + assert( + e.title != none, + message: "timeline: event " + str(i) + " has no title (it is required)", + ) + assert( + shapes.contains(e.shape), + message: "event " + + repr(e.title) + + ": shape must be one of " + + shapes.join(", "), + ) + ( + index: i, + title: e.title, + time: e.time, + description: e.description, + shape: e.shape, + fill: e.fill, + ) + }) +) diff --git a/packages/preview/pivot/0.2.0/src/timeline/render.typ b/packages/preview/pivot/0.2.0/src/timeline/render.typ new file mode 100644 index 0000000000..bad7bdce2d --- /dev/null +++ b/packages/preview/pivot/0.2.0/src/timeline/render.typ @@ -0,0 +1,257 @@ +#import "@preview/cetz:0.5.2" as cetz +#import "../theme.typ" as theme-mod +#import "model.typ": model +#import "layout.typ": layout + +// timeline: events along an axis, in one of three orientations: +// "horizontal" — a rule, markers spaced left-to-right, labels above/below. +// "vertical" — a rule top-to-bottom, labels left/right. +// "snaked" — horizontal rows that wrap and reverse direction (boustrophedon), +// joined by orthogonal turns; pick it for long timelines (`wrap` +// events per row). +// Each event is a shaped marker with its time / title / description as plain text +// (no boxes), joined by a short stem. Colour is opt-in: a marker with a `fill:` is +// a light fill ringed by a same-hue rim, otherwise a hollow outline — so shape and +// colour can together encode a stage. Returns content. +#let timeline( + ..events, + orientation: "horizontal", + wrap: 5, + theme: theme-mod.default, +) = context { + assert( + orientation in ("horizontal", "vertical", "snaked"), + message: "timeline: orientation must be \"horizontal\", \"vertical\", or \"snaked\", got " + + repr(orientation), + ) + let evs = layout(model(events.pos())) + + // Capture tokens before `import cetz.draw: *` shadows names like `line`/`fill`. + let margin = theme.timeline-margin + let axis-stroke = theme.timeline-axis-stroke + let pitch = theme.timeline-pitch / 1cm + let row-h = theme.timeline-row-height / 1cm + let turn-bulge = theme.timeline-turn-bulge / 1cm + let edge-nudge = theme.timeline-edge-nudge / 1cm + let m = theme.timeline-marker-size / 1cm + let edge-width = theme.timeline-marker-edge-width + let edge-darken = theme.timeline-marker-edge-darken + let stem = theme.timeline-stem / 1cm + let text-w = theme.timeline-text-width / 1cm + let text-gap = theme.timeline-text-gap / 1cm + let marker-outline = theme.timeline-marker-outline + let marker-fill = theme.timeline-marker-fill + let time-size = theme.timeline-time-size + let title-size = theme.timeline-title-size + let desc-size = theme.timeline-desc-size + let time-fill = theme.timeline-time-color + let text-fill = theme.timeline-text-color + + // An event's label: time / title / description, centred and wrapped to a width + // (no drawn box). Justification off so a wrapped description isn't stretched. + let label-of = e => { + let parts = () + if e.time != none { + parts.push(text(size: time-size, fill: time-fill, e.time)) + } + parts.push(text( + size: title-size, + fill: text-fill, + weight: "medium", + e.title, + )) + if e.description != none { + parts.push(text(size: desc-size, fill: text-fill, e.description)) + } + box(width: text-w * 1cm, { + set par(justify: false) + align(center, parts.join(linebreak())) + }) + } + + let n = evs.len() + + // Pad the finished canvas top and bottom so the diagram doesn't crowd whatever + // sits above and below it in a document. + pad(y: margin, cetz.canvas({ + import cetz.draw: * + + // A marker of event `e` at (x, y). Colour is opt-in, like the field diagrams: + // given a `fill:` it's a light fill ringed by a deeper same-hue rim (the rim + // carries the WCAG non-text contrast a light fill can't); with no fill it's a + // hollow outline, so a default timeline stays monochrome line-art. A solid + // colour can be darkened for the rim; any other paint (gradient/tiling) has no + // `.darken`, so fall back to the fill itself rather than panic. + let marker = (x, y, e) => { + let edge = ( + edge-width + + if e.fill == none { + marker-outline + } else if type(e.fill) == color { + e.fill.darken(edge-darken) + } else { + e.fill + } + ) + // A hollow marker's interior is opaque, not transparent, so it knocks out the + // axis line passing beneath it rather than letting it strike through. + let mfill = if e.fill == none { marker-fill } else { e.fill } + if e.shape == "circle" { + circle((x, y), radius: m, fill: mfill, stroke: edge) + } else if e.shape == "square" { + rect((x - m, y - m), (x + m, y + m), fill: mfill, stroke: edge) + } else if e.shape == "diamond" { + line( + (x, y + m), + (x + m, y), + (x, y - m), + (x - m, y), + close: true, + fill: mfill, + stroke: edge, + ) + } else if e.shape == "triangle" { + line( + (x, y + m), + (x + m, y - m), + (x - m, y - m), + close: true, + fill: mfill, + stroke: edge, + ) + } + } + + if orientation == "vertical" { + line((0, 0), (0, -calc.max((n - 1) * pitch, 0)), stroke: axis-stroke) + for e in evs { + let y = -e.slot * pitch + marker(0, y, e) + let end-x = e.side * (m + stem) + line((e.side * m, y), (end-x, y), stroke: axis-stroke) + content( + (end-x + e.side * text-gap, y), + label-of(e), + anchor: if e.side > 0 { "west" } else { "east" }, + ) + } + } else if orientation == "snaked" { + let rows = calc.ceil(n / wrap) + let full-w = (wrap - 1) * pitch + // A symmetric cubic U-bend peaks at 3/4 of its control offset beyond its + // endpoints — that's how far the curve actually reaches. Inset the turns by + // this (not the full offset) so the bend meets the diagram edge and the + // flush start/end markers line up with the curve, not poke past it. + let bulge-depth = 3 / 4 * turn-bulge + let right-edge = full-w + 2 * bulge-depth // left edge is 0 + let cnt-of = r => calc.min((r + 1) * wrap, n) - r * wrap + + // A row's x-span [lx, rx]. An end is flush with a diagram edge (0 or + // `right-edge`) only when it carries the timeline's first or last marker; + // otherwise it insets by the bulge so its turn curves out *to* the edge, not + // past it. The markers then fill that span — so the start sits flush at the + // left edge (and a full final row at the right) instead of the U-bends + // pushing the box out and leaving the start indented. + let span-of = r => { + let even = calc.rem(r, 2) == 0 + let start-flush = r == 0 + let end-flush = r == rows - 1 + let left-flush = if even { start-flush } else { end-flush } + let right-flush = if even { end-flush } else { start-flush } + ( + if left-flush { 0.0 } else { bulge-depth }, + if right-flush { right-edge } else { right-edge - bulge-depth }, + ) + } + + // x of slot `s`: a row fills its span evenly; a partial final row keeps the + // nominal pitch from its start and just ends where it ends (half-way is fine). + let x-of = s => { + let r = calc.quo(s, wrap) + let c = calc.rem(s, wrap) + let even = calc.rem(r, 2) == 0 + let cnt = cnt-of(r) + let (lx, rx) = span-of(r) + let x = if r == rows - 1 and cnt < wrap { + if even { lx + c * pitch } else { rx - c * pitch } + } else { + let step = if cnt > 1 { (rx - lx) / (cnt - 1) } else { 0.0 } + if even { lx + c * step } else { rx - c * step } + } + // Optical nudge: the first marker sits flush at the left edge, where its + // disc overshoots the curve's tangent — ease it a hair inward so it reads + // as on the edge. Only the start; the end runs into the next row's turn or + // just stops, so it has no edge to overshoot. + if s == 0 { + x + edge-nudge + } else { + x + } + } + // Row rules, drawn to each row's actual event extent. + for r in range(0, rows) { + let xs = range(r * wrap, calc.min((r + 1) * wrap, n)).map(x-of) + line( + (calc.min(..xs), -r * row-h), + (calc.max(..xs), -r * row-h), + stroke: axis-stroke, + ) + } + // Turns: a smooth U-bend from a row's inset end out to the edge and down to + // the next row's inset start. Even rows bend right (to `right-edge`), odd + // rows left (to 0); both bezier handles point straight out, so the curve + // leaves and rejoins the horizontal rows tangentially. + for r in range(0, rows - 1) { + let even = calc.rem(r, 2) == 0 + let x-turn = if even { right-edge - bulge-depth } else { bulge-depth } + let out = if even { turn-bulge } else { -turn-bulge } + let y0 = -r * row-h + let y1 = -(r + 1) * row-h + bezier( + (x-turn, y0), + (x-turn, y1), + (x-turn + out, y0), + (x-turn + out, y1), + stroke: axis-stroke, + ) + } + // Label side: anchor each row's last marker above the rule (and alternate + // back from there). A turn joins one row's last marker to the next row's + // first, both at the same x in the gap between the rows; if both pointed in + // they'd overlap. Anchoring the last marker out (above) keeps that gap clear + // regardless of `wrap` parity — the global +/- alternation can't. + let side-of = e => { + let p = calc.rem(e.slot, wrap) + let cnt = cnt-of(calc.quo(e.slot, wrap)) + if calc.rem(cnt - 1 - p, 2) == 0 { 1 } else { -1 } + } + for e in evs { + let x = x-of(e.slot) + let y = -calc.quo(e.slot, wrap) * row-h + let side = side-of(e) + marker(x, y, e) + let end-y = y + side * (m + stem) + line((x, y + side * m), (x, end-y), stroke: axis-stroke) + content( + (x, end-y + side * text-gap), + label-of(e), + anchor: if side > 0 { "south" } else { "north" }, + ) + } + } else { + // horizontal (default) + line((0, 0), (calc.max((n - 1) * pitch, 0), 0), stroke: axis-stroke) + for e in evs { + let x = e.slot * pitch + marker(x, 0, e) + let end-y = e.side * (m + stem) + line((x, e.side * m), (x, end-y), stroke: axis-stroke) + content( + (x, end-y + e.side * text-gap), + label-of(e), + anchor: if e.side > 0 { "south" } else { "north" }, + ) + } + } + })) +} diff --git a/packages/preview/pivot/0.2.0/typst.toml b/packages/preview/pivot/0.2.0/typst.toml new file mode 100644 index 0000000000..5aadb47cf2 --- /dev/null +++ b/packages/preview/pivot/0.2.0/typst.toml @@ -0,0 +1,28 @@ +[package] +name = "pivot" +version = "0.2.0" +entrypoint = "lib.typ" +authors = ["cybermallard