brane 0.1.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +367 -0
- package/CHANGELOG.md +66 -0
- package/CONTRIBUTING.md +101 -0
- package/DESIGN.md +405 -0
- package/Dockerfile +61 -0
- package/LICENSE +656 -17
- package/README.md +174 -2
- package/SECURITY.md +213 -0
- package/dist/absorb/brane-id.js +11 -0
- package/dist/absorb/chatgpt.js +161 -0
- package/dist/absorb/claude-code.js +504 -0
- package/dist/absorb/index.js +543 -0
- package/dist/absorb/organelle.js +345 -0
- package/dist/absorb/run.js +520 -0
- package/dist/absorb/sources.js +168 -0
- package/dist/absorb/types.js +3 -0
- package/dist/agent-env.js +45 -0
- package/dist/agents.js +1168 -0
- package/dist/claude-live-session.js +124 -0
- package/dist/cli.js +50 -0
- package/dist/codex-tool-log.js +144 -0
- package/dist/commands/absorb.js +291 -0
- package/dist/commands/attach.js +31 -0
- package/dist/commands/certify.js +62 -0
- package/dist/commands/config.js +133 -0
- package/dist/commands/cost.js +126 -0
- package/dist/commands/daemon.js +350 -0
- package/dist/commands/doctor.js +262 -0
- package/dist/commands/fold.js +95 -0
- package/dist/commands/list.js +89 -0
- package/dist/commands/mcp.js +84 -0
- package/dist/commands/meld.js +360 -0
- package/dist/commands/memory.js +215 -0
- package/dist/commands/revive.js +74 -0
- package/dist/commands/run.js +85 -0
- package/dist/commands/spawn.js +1101 -0
- package/dist/commands/token.js +154 -0
- package/dist/copilot-tool-log.js +145 -0
- package/dist/cost/pricing.js +116 -0
- package/dist/cost/reprice.js +136 -0
- package/dist/crush-tool-log.js +293 -0
- package/dist/daemon/agent-scope.js +116 -0
- package/dist/daemon/awaiting.js +54 -0
- package/dist/daemon/cert-manager.js +349 -0
- package/dist/daemon/client.js +262 -0
- package/dist/daemon/gated-routes.js +112 -0
- package/dist/daemon/keep-awake.js +103 -0
- package/dist/daemon/local-socket.js +145 -0
- package/dist/daemon/presence-gate.js +206 -0
- package/dist/daemon/server.js +6353 -0
- package/dist/daemon/signal-source.js +35 -0
- package/dist/daemon/tailscale-rebind.js +58 -0
- package/dist/daemon/token-ring.js +263 -0
- package/dist/daemon/token-routes.js +190 -0
- package/dist/db/events.js +152 -0
- package/dist/db/mentions.js +179 -0
- package/dist/db/schema.js +643 -0
- package/dist/db/tokens.js +624 -0
- package/dist/db/tool-uses.js +647 -0
- package/dist/diagram/index.js +85 -0
- package/dist/diagram/label.js +455 -0
- package/dist/diagram/layout.js +476 -0
- package/dist/diagram/links.js +50 -0
- package/dist/diagram/metrics.js +152 -0
- package/dist/diagram/parse.js +487 -0
- package/dist/diagram/svg.js +389 -0
- package/dist/diagram/types.js +9 -0
- package/dist/export.js +629 -0
- package/dist/files/archive-types.js +2 -0
- package/dist/files/archive.js +182 -0
- package/dist/files/browse.js +327 -0
- package/dist/files/bytes.js +162 -0
- package/dist/files/codecs.js +33 -0
- package/dist/files/find.js +212 -0
- package/dist/files/tar.js +223 -0
- package/dist/files/zip.js +209 -0
- package/dist/folds.js +72 -0
- package/dist/grammars/ebnf.js +92 -0
- package/dist/highlight.js +250 -0
- package/dist/ids.js +24 -0
- package/dist/markdown-diagnostics.js +233 -0
- package/dist/math.js +168 -0
- package/dist/mcp/server.js +886 -0
- package/dist/memory/embedder.js +145 -0
- package/dist/memory/embedding-worker.js +71 -0
- package/dist/memory/embeddings.js +91 -0
- package/dist/memory/index.js +232 -0
- package/dist/memory/storage.js +587 -0
- package/dist/memory/wiki.js +688 -0
- package/dist/memory/wordpiece.js +247 -0
- package/dist/mentions-scan.js +40 -0
- package/dist/presence.js +259 -0
- package/dist/session-affinity.js +185 -0
- package/dist/shared/api-request.js +124 -0
- package/dist/shared/app-origin.js +26 -0
- package/dist/shared/block-glyphs.js +114 -0
- package/dist/shared/diagram-css.js +17 -0
- package/dist/shared/export-selection.js +101 -0
- package/dist/shared/find-in-text.js +110 -0
- package/dist/shared/glow-hues.js +225 -0
- package/dist/shared/hex-dump.js +62 -0
- package/dist/shared/hex-pages.js +52 -0
- package/dist/shared/hit-drum.js +79 -0
- package/dist/shared/known-nids.js +58 -0
- package/dist/shared/list-scroll-keys.js +56 -0
- package/dist/shared/markdown-lists.js +72 -0
- package/dist/shared/markdown-math.js +485 -0
- package/dist/shared/markdown-mentions.js +79 -0
- package/dist/shared/markdown-nids.js +41 -0
- package/dist/shared/markdown-protocols.js +53 -0
- package/dist/shared/markdown-refs.js +123 -0
- package/dist/shared/membrane-invasion.js +342 -0
- package/dist/shared/membrane-motion.js +83 -0
- package/dist/shared/membrane-palette.js +253 -0
- package/dist/shared/membrane-terrain.js +212 -0
- package/dist/shared/message-selection.js +123 -0
- package/dist/shared/mo-glyph.js +57 -0
- package/dist/shared/narrative-format.js +237 -0
- package/dist/shared/narrative-menu.js +84 -0
- package/dist/shared/narrative-sort.js +47 -0
- package/dist/shared/narrative-status.js +52 -0
- package/dist/shared/no-pan.js +85 -0
- package/dist/shared/pane-layout.js +50 -0
- package/dist/shared/payload-inline.js +83 -0
- package/dist/shared/speaker-color.js +61 -0
- package/dist/shared/token-rotation.js +14 -0
- package/dist/shared/view-url.js +216 -0
- package/dist/shared/wiki-links.js +70 -0
- package/dist/shared/wiki-window.js +4 -0
- package/dist/tailscale.js +55 -0
- package/dist/test/absorb-claude.test.js +892 -0
- package/dist/test/agent-scope.test.js +193 -0
- package/dist/test/agents.test.js +55 -0
- package/dist/test/api-request.test.js +122 -0
- package/dist/test/app-host-literal.test.js +53 -0
- package/dist/test/awaiting-glow.test.js +54 -0
- package/dist/test/awaiting.test.js +122 -0
- package/dist/test/bind-token.test.js +88 -0
- package/dist/test/block-glyphs.test.js +68 -0
- package/dist/test/cert-manager.test.js +262 -0
- package/dist/test/claude-live-session.test.js +110 -0
- package/dist/test/codex-tool-log.test.js +568 -0
- package/dist/test/config.test.js +71 -0
- package/dist/test/copilot-tool-log.test.js +93 -0
- package/dist/test/cors-preflight.test.js +56 -0
- package/dist/test/cost-linger.test.js +31 -0
- package/dist/test/cost-pricing.test.js +72 -0
- package/dist/test/cost-reprice.test.js +139 -0
- package/dist/test/cost-sessions.test.js +301 -0
- package/dist/test/crush-tool-log.test.js +289 -0
- package/dist/test/daemon-client.test.js +109 -0
- package/dist/test/daemon-socket.test.js +293 -0
- package/dist/test/diagram-arrows.test.js +205 -0
- package/dist/test/diagram-export.test.js +210 -0
- package/dist/test/diagram-labels.test.js +313 -0
- package/dist/test/diagram-parse.test.js +191 -0
- package/dist/test/diagram-render.test.js +175 -0
- package/dist/test/diagram-review.test.js +288 -0
- package/dist/test/diagram-self-loop-labels.test.js +303 -0
- package/dist/test/embedder.test.js +84 -0
- package/dist/test/events.test.js +123 -0
- package/dist/test/export-selection.test.js +102 -0
- package/dist/test/export.test.js +505 -0
- package/dist/test/files-browse.test.js +393 -0
- package/dist/test/files-find.test.js +272 -0
- package/dist/test/find-in-text.test.js +86 -0
- package/dist/test/first-paint-viewport.test.js +59 -0
- package/dist/test/fixtures/embedding-golden.js +23 -0
- package/dist/test/fixtures/wordpiece-golden.js +167 -0
- package/dist/test/glow-hues.test.js +202 -0
- package/dist/test/harness-cost.test.js +423 -0
- package/dist/test/helpers.js +21 -0
- package/dist/test/hex-pages.test.js +68 -0
- package/dist/test/highlight.test.js +69 -0
- package/dist/test/hit-drum.test.js +71 -0
- package/dist/test/hook-config.test.js +299 -0
- package/dist/test/json-gzip.test.js +70 -0
- package/dist/test/key-routing.test.js +58 -0
- package/dist/test/known-nids.test.js +106 -0
- package/dist/test/link-preview.test.js +67 -0
- package/dist/test/list-scroll-keys.test.js +66 -0
- package/dist/test/listen-own-words.test.js +147 -0
- package/dist/test/listen-timeout.test.js +33 -0
- package/dist/test/markdown-diagnostics.test.js +121 -0
- package/dist/test/markdown-lists.test.js +182 -0
- package/dist/test/markdown-math.test.js +256 -0
- package/dist/test/markdown-nids.test.js +146 -0
- package/dist/test/markdown-protocols.test.js +139 -0
- package/dist/test/mcp-bridge-cancel.test.js +98 -0
- package/dist/test/mcp-bridge-reconnect.test.js +121 -0
- package/dist/test/mcp-narrative.test.js +193 -0
- package/dist/test/mcp.test.js +548 -0
- package/dist/test/meld.test.js +143 -0
- package/dist/test/membrane-invasion.test.js +482 -0
- package/dist/test/membrane-motion.test.js +101 -0
- package/dist/test/membrane-palette.test.js +166 -0
- package/dist/test/membrane-terrain.test.js +170 -0
- package/dist/test/memory-log.test.js +202 -0
- package/dist/test/mentions-e2e.test.js +261 -0
- package/dist/test/mentions-ledger.test.js +265 -0
- package/dist/test/mentions-listen.test.js +265 -0
- package/dist/test/mentions.test.js +238 -0
- package/dist/test/message-selection.test.js +114 -0
- package/dist/test/mo-glyph.test.js +22 -0
- package/dist/test/model-resolution.test.js +166 -0
- package/dist/test/narrate-validate.test.js +77 -0
- package/dist/test/narrate-wait.test.js +160 -0
- package/dist/test/narrative-format.test.js +219 -0
- package/dist/test/narrative-menu.test.js +150 -0
- package/dist/test/narrative-sort.test.js +75 -0
- package/dist/test/no-pan.test.js +60 -0
- package/dist/test/pane-layout.test.js +153 -0
- package/dist/test/paths.test.js +37 -0
- package/dist/test/payload-inline.test.js +91 -0
- package/dist/test/precache-fonts.test.js +72 -0
- package/dist/test/presence-gate.test.js +440 -0
- package/dist/test/presence.test.js +160 -0
- package/dist/test/read-events.test.js +220 -0
- package/dist/test/remote-meld.test.js +128 -0
- package/dist/test/revive-cli.test.js +93 -0
- package/dist/test/search-floor.test.js +206 -0
- package/dist/test/search-fusion.test.js +168 -0
- package/dist/test/search.test.js +205 -0
- package/dist/test/security.test.js +48 -0
- package/dist/test/selection-wiring.test.js +61 -0
- package/dist/test/session-affinity.test.js +295 -0
- package/dist/test/signal-source.test.js +32 -0
- package/dist/test/socket-account.test.js +268 -0
- package/dist/test/spawn-toml.test.js +72 -0
- package/dist/test/speaker-color.test.js +90 -0
- package/dist/test/tailscale-rebind.test.js +79 -0
- package/dist/test/terminal-cursor.test.js +61 -0
- package/dist/test/terminal-resync.test.js +23 -0
- package/dist/test/terminal-size.test.js +36 -0
- package/dist/test/thin-cli.test.js +382 -0
- package/dist/test/token-bootstrap.test.js +272 -0
- package/dist/test/token-devices.test.js +646 -0
- package/dist/test/token-lapse.test.js +94 -0
- package/dist/test/token-ring.test.js +188 -0
- package/dist/test/token-rotate-cli.test.js +58 -0
- package/dist/test/token-routes.test.js +95 -0
- package/dist/test/tool-uses.test.js +1006 -0
- package/dist/test/usage-ledger.test.js +126 -0
- package/dist/test/view-url.test.js +627 -0
- package/dist/test/wiki-links.test.js +57 -0
- package/dist/test/wiki-sources.test.js +85 -0
- package/dist/test/wiki-window.test.js +31 -0
- package/dist/test/wiki.test.js +399 -0
- package/dist/test/wordpiece.test.js +68 -0
- package/dist/test/wrangler-route.test.js +27 -0
- package/dist/tls.js +148 -0
- package/dist/tmux.js +213 -0
- package/dist/user-agent.js +46 -0
- package/docker-entrypoint.sh +154 -0
- package/docs/README.md +3 -0
- package/docs/absorb.md +231 -0
- package/docs/cost-ledger.md +125 -0
- package/docs/daemon-api.md +189 -0
- package/docs/diagrams.md +163 -0
- package/docs/doctor.md +36 -0
- package/docs/export-scope.md +69 -0
- package/docs/files-view.md +133 -0
- package/docs/link-previews.md +38 -0
- package/docs/math.md +32 -0
- package/docs/mcp.md +69 -0
- package/docs/memory-search.md +75 -0
- package/docs/mentions.md +101 -0
- package/docs/multi-brane.md +14 -0
- package/docs/passkeys.md +165 -0
- package/docs/presence.md +202 -0
- package/docs/releasing.md +104 -0
- package/docs/rendering-warnings.md +22 -0
- package/docs/request-layer.md +72 -0
- package/docs/selection-store.md +50 -0
- package/docs/session-affinity.md +61 -0
- package/docs/terminal-mirroring.md +243 -0
- package/docs/token-rotation.md +431 -0
- package/docs/view-urls.md +134 -0
- package/docs/web-app.md +23 -0
- package/docs/wiki.md +43 -0
- package/expo/dist/_expo/static/js/web/entry-e94247f119fdcd3b424a1764233a41d2.js +1099 -0
- package/expo/dist/apple-touch-icon.png +0 -0
- package/expo/dist/favicon.svg +3 -0
- package/expo/dist/index.html +177 -0
- package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/expo/dist/katex/katex.min.css +1 -0
- package/expo/dist/manifest.json +16 -0
- package/expo/dist/metadata.json +1 -0
- package/expo/dist/og/membrane.v1.png +0 -0
- package/expo/dist/pdf.min.mjs +28 -0
- package/expo/dist/pdf.worker.min.mjs +28 -0
- package/expo/dist/sw.js +154 -0
- package/package.json +79 -27
- package/protocols/LIVE_loop.md +407 -0
- package/protocols/bebrief.md +41 -0
- package/protocols/digest.md +231 -0
- package/protocols/fly.md +266 -0
- package/protocols/idle.md +81 -0
- package/protocols/keeper.md +829 -0
- package/protocols/markdown.md +80 -0
- package/protocols/memory.md +419 -0
- package/protocols/mentions.md +93 -0
- package/protocols/payloads.md +53 -0
- package/protocols/prelude.md +91 -0
- package/protocols/ramify.md +105 -0
- package/protocols/review.md +356 -0
- package/protocols/rrr.md +232 -0
- package/protocols/scribe.md +429 -0
- package/protocols/summarize.md +203 -0
- package/protocols/twin.md +174 -0
- package/protocols/wiki.md +438 -0
- package/protocols/worker.md +38 -0
- package/wiki/shared/append-only-event-log.md +148 -0
- package/wiki/shared/index.md +53 -0
- package/wiki/shared/owning-your-narratives.md +161 -0
- package/wiki/shared/premature-ontologies.md +435 -0
- package/wiki/shared/sedimentary-superintelligence.md +128 -0
- package/wiki/shared/sequestering-the-narrative.md +143 -0
- package/wiki/shared/why-wikis-work.md +216 -0
- package/.travis.yml +0 -7
- package/lib/brane.cjs.js +0 -12
- package/lib/brane.cjs.js.map +0 -1
- package/lib/brane.d.ts +0 -11
- package/lib/brane.js +0 -6
- package/lib/brane.js.map +0 -1
- package/lib/tests.d.ts +0 -1
- package/lib/tests.js +0 -8
- package/lib/tests.js.map +0 -1
- package/rollup.config.js +0 -21
- package/scripts/test.sh +0 -8
- package/src/brane.ts +0 -21
- package/src/tests.ts +0 -8
- package/tsconfig.json +0 -16
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
/** Layout: dagre for placement and routing, plus the one thing dagre gets
|
|
2
|
+
* wrong for our purposes — where an edge attaches to its node.
|
|
3
|
+
*
|
|
4
|
+
* **Face projection.** dagre picks an edge's terminal point by intersecting
|
|
5
|
+
* the node's box with the chord from the node's *centre* to the first bend.
|
|
6
|
+
* That chord is diagonal, so every edge is handed a diagonal start and the
|
|
7
|
+
* arrowhead arrives tilted, whatever curve is fitted afterwards. Measured on
|
|
8
|
+
* the LIVE loop diagram, `Listen → Interpret` attaches at x=128 while the
|
|
9
|
+
* edge is heading for x=156: 28px of lean, baked in before rendering.
|
|
10
|
+
*
|
|
11
|
+
* Projecting the bend onto the face instead — clamped to the face's extent,
|
|
12
|
+
* held off the corners — drops mean arrival angle from 31.4° to 8.7° while
|
|
13
|
+
* leaving the curve untouched, so the calm single-sweep shape of `curveBasis`
|
|
14
|
+
* survives exactly. Every alternative tried (clamped-tangent Hermite, basis
|
|
15
|
+
* with normal stubs, a straight terminal segment) bought the same squareness
|
|
16
|
+
* with extra inflections, and inflections are what read as "busy".
|
|
17
|
+
*
|
|
18
|
+
* Self-loops are not given to dagre at all: it returns a degenerate path with
|
|
19
|
+
* duplicate points that doubles back outside the node. They are synthesised,
|
|
20
|
+
* and everything about them has to be too — including the two things dagre
|
|
21
|
+
* would otherwise have done for free. Their **label** needs a position of its
|
|
22
|
+
* own (the first draft left `labelPos` undefined and the renderer dropped the
|
|
23
|
+
* label, silently: a Rule 30 state graph rendered with all 24 arrows and
|
|
24
|
+
* none of the three loop labels that told the vacuum from the two stripe
|
|
25
|
+
* phases). And their **space** has to be reserved, because dagre cannot avoid
|
|
26
|
+
* what it never saw: the node is handed to dagre inflated by the bow and its
|
|
27
|
+
* chip, so no other edge's label is routed into the loop.
|
|
28
|
+
*
|
|
29
|
+
* A diagram that draws but drops part of itself is a class the `warnings`
|
|
30
|
+
* contract cannot reach by construction — the operator sees no source, so
|
|
31
|
+
* there is no fallback to report. The only defence is not to drop anything.
|
|
32
|
+
*/
|
|
33
|
+
import dagre from "@dagrejs/dagre";
|
|
34
|
+
import { LABEL_FONT_SIZE, LINE_HEIGHT, measureEdgeLabel, measureNode } from "./metrics.js";
|
|
35
|
+
import { isTable, wrapRuns } from "./label.js";
|
|
36
|
+
const NODE_SEP = 46;
|
|
37
|
+
const RANK_SEP = 52;
|
|
38
|
+
const MARGIN = 14;
|
|
39
|
+
/** A subgraph title wraps to its box less this on each side, and never to
|
|
40
|
+
* less than the floor, so a one-node box can't stack it a word per line. */
|
|
41
|
+
const TITLE_PAD = 14;
|
|
42
|
+
const TITLE_MIN_WRAP = 120;
|
|
43
|
+
/** How far an attachment is held off a corner, so an edge never appears to
|
|
44
|
+
* hang from the very tip of a box. */
|
|
45
|
+
const CORNER_INSET = 7;
|
|
46
|
+
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
47
|
+
/** Half-extent of a shape along the face normal, at lateral offset `u` from
|
|
48
|
+
* the centre. `half` is the half-size along the normal, `halfLat` along the
|
|
49
|
+
* face. A box is flat; a circle and a diamond are not, and attaching an edge
|
|
50
|
+
* at a box's offset would leave it floating beside a diamond's slope. */
|
|
51
|
+
export function faceOffset(shape, half, halfLat, u) {
|
|
52
|
+
const t = halfLat === 0 ? 0 : Math.abs(u) / halfLat;
|
|
53
|
+
switch (shape) {
|
|
54
|
+
case "circle":
|
|
55
|
+
return half * Math.sqrt(Math.max(0, 1 - t * t));
|
|
56
|
+
case "diamond":
|
|
57
|
+
return half * Math.max(0, 1 - t);
|
|
58
|
+
case "hexagon":
|
|
59
|
+
// Flat across the middle 70%, then sloping to the points. Clamped at
|
|
60
|
+
// zero: `t` exceeds 1 whenever a bend sits outside the node's lateral
|
|
61
|
+
// extent, and an unclamped slope would return a negative half-extent —
|
|
62
|
+
// an attachment on the *far* side of the centre from its own face.
|
|
63
|
+
return t <= 0.7 ? half : Math.max(0, half * (1 - (t - 0.7) / 0.3));
|
|
64
|
+
case "round":
|
|
65
|
+
case "stadium":
|
|
66
|
+
case "cylinder":
|
|
67
|
+
case "subroutine":
|
|
68
|
+
case "asymmetric":
|
|
69
|
+
case "rect":
|
|
70
|
+
default:
|
|
71
|
+
return half;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Which face of `n` the point `p` faces, as an outward unit normal.
|
|
75
|
+
*
|
|
76
|
+
* Decided by direction, not by distance to the nearest face line. The first
|
|
77
|
+
* draft took the nearest face, which is the same answer for a point *on* the
|
|
78
|
+
* box — every point dagre returned, then. A loop-bearing node now hands
|
|
79
|
+
* dagre a taller box, so an edge into its top arrives well above the drawn
|
|
80
|
+
* face, and from up there the *side* face lines are the nearer ones: the
|
|
81
|
+
* edge attached at a corner of the wrong face, seven pixels down. */
|
|
82
|
+
function faceNormal(n, p, rankFaces) {
|
|
83
|
+
const dx = p.x - n.x, dy = p.y - n.y;
|
|
84
|
+
const hx = Math.max(n.width / 2, 1e-6), hy = Math.max(n.height / 2, 1e-6);
|
|
85
|
+
if (rankFaces === "vertical")
|
|
86
|
+
return { x: 0, y: dy < 0 ? -1 : 1 };
|
|
87
|
+
if (rankFaces === "horizontal")
|
|
88
|
+
return { x: dx < 0 ? -1 : 1, y: 0 };
|
|
89
|
+
if (Math.abs(dy) / hy >= Math.abs(dx) / hx) {
|
|
90
|
+
return { x: 0, y: dy < 0 ? -1 : 1 };
|
|
91
|
+
}
|
|
92
|
+
return { x: dx < 0 ? -1 : 1, y: 0 };
|
|
93
|
+
}
|
|
94
|
+
/** The B5 attachment: slide the terminal point along its face until it lines
|
|
95
|
+
* up under the bend the edge is actually heading for. */
|
|
96
|
+
export function projectAttachment(n, p, toward,
|
|
97
|
+
/** Pin the edge to the node's rank-facing sides. Set for a node with
|
|
98
|
+
* self-loops, whose cross-axis faces belong to the loops: dagre gives it a
|
|
99
|
+
* box widened on both sides, so an edge arriving at a shallow angle meets
|
|
100
|
+
* that box's rank face well outside the drawn node, where the nearest
|
|
101
|
+
* drawn face would otherwise be the loop's. */
|
|
102
|
+
rankFaces) {
|
|
103
|
+
const normal = faceNormal(n, p, rankFaces);
|
|
104
|
+
const hx = n.width / 2, hy = n.height / 2;
|
|
105
|
+
if (normal.y !== 0) {
|
|
106
|
+
const limit = Math.max(0, hx - CORNER_INSET);
|
|
107
|
+
const u = clamp(toward.x - n.x, -limit, limit);
|
|
108
|
+
return { x: n.x + u, y: n.y + normal.y * faceOffset(n.shape, hy, hx, u) };
|
|
109
|
+
}
|
|
110
|
+
const limit = Math.max(0, hy - CORNER_INSET);
|
|
111
|
+
const u = clamp(toward.y - n.y, -limit, limit);
|
|
112
|
+
return { x: n.x + normal.x * faceOffset(n.shape, hx, hy, u), y: n.y + u };
|
|
113
|
+
}
|
|
114
|
+
/** How far the innermost self-loop's bow *reaches* from the face. The two
|
|
115
|
+
* deep control points sit further out — `curveBasis` does not pass through
|
|
116
|
+
* them — so geometry here is in terms of the reach, and the control depth is
|
|
117
|
+
* derived: `controlDepth(reach)`. With the face-level points either side (or
|
|
118
|
+
* the sweep knots, a few pixels off the face), the middle B-spline segment
|
|
119
|
+
* peaks at t = ½ with weights 1/48, 23/48, 23/48, 1/48, so the drawn apex is
|
|
120
|
+
* 23/24 of the control depth. #504 used 5/6, which overshot by 15% of the
|
|
121
|
+
* reach: harmless on one loop (the label gap absorbed it), but in a stack the
|
|
122
|
+
* reach grows with the chips inside, and an outer arc ran through its own
|
|
123
|
+
* label. The innermost bow keeps its 26px control depth. */
|
|
124
|
+
const APEX = 23 / 24;
|
|
125
|
+
const controlDepth = (reach) => reach / APEX;
|
|
126
|
+
const LOOP_REACH = 26 * APEX;
|
|
127
|
+
/** Gap between a bow's apex and its chip. */
|
|
128
|
+
const LOOP_LABEL_GAP = 3;
|
|
129
|
+
/** A second loop on the same node bows outside the first one's chip: reach
|
|
130
|
+
* grows by the chip plus a gap plus this much clear. Two loops used to draw
|
|
131
|
+
* the same bow twice, and would have stacked their chips as well. */
|
|
132
|
+
const LOOP_STACK_CLEAR = 6;
|
|
133
|
+
function loopChip(label) {
|
|
134
|
+
return label?.some((l) => l.length) ? measureEdgeLabel(label) : { width: 0, height: 0 };
|
|
135
|
+
}
|
|
136
|
+
const loopSideFor = (horizontal) => (horizontal ? "bottom" : "right");
|
|
137
|
+
/** A chip's extent along the bow's outward direction — the size that stacks
|
|
138
|
+
* when a node has several loops. #504 always used `height`, which is only
|
|
139
|
+
* right for a bow that points up or down; beside the node, chips stack by
|
|
140
|
+
* width, and two loops with a wider first label overlapped. */
|
|
141
|
+
const outward = (side, chip) => side === "right" ? chip.width : chip.height;
|
|
142
|
+
/** Reach of the `index`th loop on a node whose earlier loops carry `chips`. */
|
|
143
|
+
function loopReach(side, chips, index) {
|
|
144
|
+
let reach = LOOP_REACH;
|
|
145
|
+
for (let k = 0; k < index; k++) {
|
|
146
|
+
const extent = outward(side, chips[k]);
|
|
147
|
+
reach += (extent ? extent + LOOP_LABEL_GAP : 0) + LOOP_STACK_CLEAR;
|
|
148
|
+
}
|
|
149
|
+
return reach;
|
|
150
|
+
}
|
|
151
|
+
/** Where a bow's legs leave the face, measured from the node's centre line. */
|
|
152
|
+
const bowLeg = (side, n) => side === "right" ? Math.min(n.height / 2 * 0.6, 12) : Math.min(n.width / 2 * 0.5, 16);
|
|
153
|
+
/** How far across its outward direction the `index`th bow's control points
|
|
154
|
+
* sit, from the node's centre line. Normally the legs flare 0.35 R past
|
|
155
|
+
* where they leave the face. An outer bow of a stack must also clear every
|
|
156
|
+
* chip inside it — its legs run past them on the way out — and beside a
|
|
157
|
+
* node that is a chip's height, but below one it is a chip's *width*, which
|
|
158
|
+
* can be far wider than the node. The 5/4 is the control polygon's slack:
|
|
159
|
+
* `curveBasis` stays inside it. */
|
|
160
|
+
function bowFlare(side, n, reach, inner) {
|
|
161
|
+
let flare = bowLeg(side, n) + controlDepth(reach) * 0.35;
|
|
162
|
+
for (const chip of inner) {
|
|
163
|
+
const across = side === "right" ? chip.height : chip.width;
|
|
164
|
+
if (across)
|
|
165
|
+
flare = Math.max(flare, (across / 2 + LOOP_LABEL_GAP + LOOP_STACK_CLEAR) * 5 / 4);
|
|
166
|
+
}
|
|
167
|
+
return flare;
|
|
168
|
+
}
|
|
169
|
+
/** Every loop's flare on one node, each at least a stack-clearance outside
|
|
170
|
+
* the one before. Sized by the chips alone, two outer bows around the same
|
|
171
|
+
* wide inner chip took the same flare and drew one pair of sides between
|
|
172
|
+
* them, so which bottom belonged to which label was a guess. */
|
|
173
|
+
function bowFlares(side, n, chips) {
|
|
174
|
+
const out = [];
|
|
175
|
+
chips.forEach((_, k) => {
|
|
176
|
+
const own = bowFlare(side, n, loopReach(side, chips, k), chips.slice(0, k));
|
|
177
|
+
out.push(k === 0 ? own : Math.max(own, out[k - 1] + LOOP_STACK_CLEAR));
|
|
178
|
+
});
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
/** A self-loop dagre never sees: a bow off a cross-axis face (see LoopSide),
|
|
182
|
+
* with its label just past the apex. `reach` is where the drawn curve gets
|
|
183
|
+
* to. */
|
|
184
|
+
function selfLoopPoints(n, side, reach, chip, flare) {
|
|
185
|
+
const hx = n.width / 2, hy = n.height / 2;
|
|
186
|
+
const leg = bowLeg(side, n);
|
|
187
|
+
// An outer bow whose inner chips are wider than its legs' flare turns
|
|
188
|
+
// outward at once — a knot just off the face — so its legs are already past
|
|
189
|
+
// those chips' edges by the time they reach the chips' depth. Its deep
|
|
190
|
+
// corners are doubled, so the B-spline hugs the rectangle rather than
|
|
191
|
+
// cutting the corner diagonally across the chip inside it; with the middle
|
|
192
|
+
// segment's four points then all at one depth, the drawn apex is that depth
|
|
193
|
+
// exactly, not 23/24 of it.
|
|
194
|
+
const sweep = flare > leg + controlDepth(reach) * 0.35 + 1e-9;
|
|
195
|
+
const R = sweep ? reach : controlDepth(reach);
|
|
196
|
+
// Built in the bow's own frame — `u` out from the face, `v` along it — and
|
|
197
|
+
// mapped onto the side, so the two sides can't drift apart.
|
|
198
|
+
const frame = sweep
|
|
199
|
+
? [[0, -leg], [LOOP_LABEL_GAP, -flare], [R, -flare], [R, -flare], [R, flare], [R, flare], [LOOP_LABEL_GAP, flare], [0, leg]]
|
|
200
|
+
: [[0, -leg], [R, -flare], [R, flare], [0, leg]];
|
|
201
|
+
if (side === "right") {
|
|
202
|
+
return {
|
|
203
|
+
points: frame.map(([u, v]) => ({ x: n.x + hx + u, y: n.y + v })),
|
|
204
|
+
labelPos: chip.width
|
|
205
|
+
? { x: n.x + hx + reach + LOOP_LABEL_GAP + chip.width / 2, y: n.y }
|
|
206
|
+
: undefined,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
points: frame.map(([u, v]) => ({ x: n.x - v, y: n.y + hy + u })),
|
|
211
|
+
labelPos: chip.width
|
|
212
|
+
? { x: n.x, y: n.y + hy + reach + LOOP_LABEL_GAP + chip.height / 2 }
|
|
213
|
+
: undefined,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/** The box dagre is given for a node with self-loops: the measured node grown
|
|
217
|
+
* by the outermost loop's reach and chip on the loop's side — and, because
|
|
218
|
+
* dagre centres a node in its box, by the same on the opposite side — and,
|
|
219
|
+
* along the rank axis, to the widest bow flare or chip if that is larger
|
|
220
|
+
* than the node. The first growth is on the cross axis, where `nodesep`
|
|
221
|
+
* already spaces nodes and no rank edge runs; the second is usually nothing
|
|
222
|
+
* for a single loop. #504 grew only the outward size, and a chip wider than
|
|
223
|
+
* its node reached into the next node of the rank.
|
|
224
|
+
*
|
|
225
|
+
* The trade, measured over 400 random graphs per direction against #504's
|
|
226
|
+
* layout: a TD/BT diagram's height — the chat bubble's — drops (mean 421 →
|
|
227
|
+
* 326 px), and an LR/RL diagram's grows (180 → 254 px), because its loops
|
|
228
|
+
* now hang below the node instead of beside it. LR/RL grows in exchange for
|
|
229
|
+
* its labels no longer sitting on rank edges; #505's scroller carries the
|
|
230
|
+
* width either way. */
|
|
231
|
+
function loopBox(loops, side, width, height) {
|
|
232
|
+
if (loops.length === 0)
|
|
233
|
+
return { width, height };
|
|
234
|
+
const chips = loops.map((e) => loopChip(e.labelRuns));
|
|
235
|
+
const flares = bowFlares(side, { width, height }, chips);
|
|
236
|
+
let reserve = 0, across = side === "right" ? height : width;
|
|
237
|
+
chips.forEach((chip, k) => {
|
|
238
|
+
const reach = loopReach(side, chips, k);
|
|
239
|
+
const extent = outward(side, chip);
|
|
240
|
+
reserve = Math.max(reserve, reach + (extent ? LOOP_LABEL_GAP + extent : 0));
|
|
241
|
+
const chipAcross = side === "right" ? chip.height : chip.width;
|
|
242
|
+
across = Math.max(across, 2 * flares[k], chipAcross);
|
|
243
|
+
});
|
|
244
|
+
return side === "right"
|
|
245
|
+
? { width: width + 2 * reserve, height: across }
|
|
246
|
+
: { width: across, height: height + 2 * reserve };
|
|
247
|
+
}
|
|
248
|
+
/** Where an edge attached at `end` on a loop-bearing node crosses the face of
|
|
249
|
+
* dagre's inflated box, level with `end` — on the side the edge comes from.
|
|
250
|
+
* Null when that box is no larger than the node along the rank axis, where
|
|
251
|
+
* the knot would sit on the attachment itself. */
|
|
252
|
+
function rankKnot(n, box, end, from, horizontal) {
|
|
253
|
+
if (horizontal) {
|
|
254
|
+
const half = box.width / 2;
|
|
255
|
+
if (half - n.width / 2 < 0.5)
|
|
256
|
+
return null;
|
|
257
|
+
return { x: n.x + (from.x < n.x ? -half : half), y: end.y };
|
|
258
|
+
}
|
|
259
|
+
const half = box.height / 2;
|
|
260
|
+
if (half - n.height / 2 < 0.5)
|
|
261
|
+
return null;
|
|
262
|
+
return { x: end.x, y: n.y + (from.y < n.y ? -half : half) };
|
|
263
|
+
}
|
|
264
|
+
export function layoutDiagram(ir) {
|
|
265
|
+
const g = new dagre.graphlib.Graph({ multigraph: true, compound: true });
|
|
266
|
+
g.setGraph({
|
|
267
|
+
rankdir: ir.direction,
|
|
268
|
+
nodesep: NODE_SEP,
|
|
269
|
+
ranksep: RANK_SEP,
|
|
270
|
+
marginx: MARGIN,
|
|
271
|
+
marginy: MARGIN,
|
|
272
|
+
});
|
|
273
|
+
g.setDefaultEdgeLabel(() => ({}));
|
|
274
|
+
const horizontal = ir.direction === "LR" || ir.direction === "RL";
|
|
275
|
+
const side = loopSideFor(horizontal);
|
|
276
|
+
const loops = ir.edges.filter((e) => e.from === e.to);
|
|
277
|
+
const linked = ir.edges.filter((e) => e.from !== e.to);
|
|
278
|
+
const loopsOf = new Map();
|
|
279
|
+
for (const e of loops)
|
|
280
|
+
loopsOf.set(e.from, [...(loopsOf.get(e.from) ?? []), e]);
|
|
281
|
+
const byId = new Map();
|
|
282
|
+
for (const n of ir.nodes) {
|
|
283
|
+
// `classDef`/`class`/`:::` resolved here rather than left in the IR. The
|
|
284
|
+
// first draft parsed them, carried them through, and never read them: a
|
|
285
|
+
// `classDef hot fill:#f00` produced no `#f00` anywhere, while the same
|
|
286
|
+
// colour via `style` worked. Classes apply in declaration order and an
|
|
287
|
+
// inline `style` wins over all of them, which is mermaid's precedence.
|
|
288
|
+
const style = n.classes.reduce((acc, c) => Object.assign(acc, ir.classDefs[c] ?? {}), {});
|
|
289
|
+
Object.assign(style, n.style ?? {});
|
|
290
|
+
const runs = withFontStyle(n.runs, style);
|
|
291
|
+
const { width, height } = measureNode(runs);
|
|
292
|
+
const placed = {
|
|
293
|
+
id: n.id, lines: n.lines, runs, shape: n.shape, classes: n.classes,
|
|
294
|
+
style: Object.keys(style).length ? style : undefined,
|
|
295
|
+
x: 0, y: 0, width, height,
|
|
296
|
+
};
|
|
297
|
+
byId.set(n.id, placed);
|
|
298
|
+
// The drawn node keeps its measured size; only dagre's idea of it grows.
|
|
299
|
+
// `projectAttachment` snaps every edge end back onto the measured face.
|
|
300
|
+
g.setNode(n.id, loopBox(loopsOf.get(n.id) ?? [], side, width, height));
|
|
301
|
+
}
|
|
302
|
+
for (const sg of ir.subgraphs) {
|
|
303
|
+
g.setNode(sg.id, { label: sg.title, clusterLabelPos: "top" });
|
|
304
|
+
for (const id of sg.nodes)
|
|
305
|
+
if (byId.has(id))
|
|
306
|
+
g.setParent(id, sg.id);
|
|
307
|
+
}
|
|
308
|
+
linked.forEach((e, i) => {
|
|
309
|
+
const attrs = {};
|
|
310
|
+
if (e.label && e.labelRuns) {
|
|
311
|
+
const m = measureEdgeLabel(e.labelRuns);
|
|
312
|
+
attrs.width = m.width;
|
|
313
|
+
attrs.height = m.height;
|
|
314
|
+
attrs.labelpos = "c";
|
|
315
|
+
}
|
|
316
|
+
g.setEdge(e.from, e.to, attrs, `e${i}`);
|
|
317
|
+
});
|
|
318
|
+
dagre.layout(g);
|
|
319
|
+
for (const [id, placed] of byId) {
|
|
320
|
+
const n = g.node(id);
|
|
321
|
+
if (!n)
|
|
322
|
+
continue;
|
|
323
|
+
placed.x = n.x;
|
|
324
|
+
placed.y = n.y;
|
|
325
|
+
}
|
|
326
|
+
const edges = linked.map((e, i) => {
|
|
327
|
+
const d = g.edge({ v: e.from, w: e.to, name: `e${i}` });
|
|
328
|
+
const raw = (d?.points ?? []).map((p) => ({ x: p.x, y: p.y }));
|
|
329
|
+
const src = byId.get(e.from), dst = byId.get(e.to);
|
|
330
|
+
const points = raw.map((p) => ({ ...p }));
|
|
331
|
+
if (points.length >= 2 && src && dst) {
|
|
332
|
+
const rankFaces = (id) => (loopsOf.has(id) ? (horizontal ? "horizontal" : "vertical") : undefined);
|
|
333
|
+
points[0] = projectAttachment(src, points[0], points[1], rankFaces(e.from));
|
|
334
|
+
points[points.length - 1] =
|
|
335
|
+
projectAttachment(dst, points[points.length - 1], points[points.length - 2], rankFaces(e.to));
|
|
336
|
+
// On a loop-bearing node, dagre ended the edge on the box it was given,
|
|
337
|
+
// which is wider than the drawn node, and the attachment above pulls the
|
|
338
|
+
// end back onto the drawn face. The leg between them ran diagonally —
|
|
339
|
+
// through the loop's label, when the label was wider than the node
|
|
340
|
+
// (LR fan-in: claude-w6c1k8h4:7). A knot where the edge meets dagre's
|
|
341
|
+
// box, level with the attachment, makes the last leg run straight along
|
|
342
|
+
// the rank axis inside the node's own band, which no chip enters.
|
|
343
|
+
if (loopsOf.has(e.to)) {
|
|
344
|
+
const k = rankKnot(dst, g.node(e.to), points[points.length - 1], points[points.length - 2], horizontal);
|
|
345
|
+
if (k)
|
|
346
|
+
points.splice(points.length - 1, 0, k);
|
|
347
|
+
}
|
|
348
|
+
if (loopsOf.has(e.from)) {
|
|
349
|
+
const k = rankKnot(src, g.node(e.from), points[0], points[1], horizontal);
|
|
350
|
+
if (k)
|
|
351
|
+
points.splice(1, 0, k);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
from: e.from, to: e.to, points,
|
|
356
|
+
label: e.label, labelRuns: e.labelRuns,
|
|
357
|
+
labelPos: d?.x !== undefined && d?.y !== undefined ? { x: d.x, y: d.y } : undefined,
|
|
358
|
+
arrow: e.arrow, line: e.line, selfLoop: false,
|
|
359
|
+
};
|
|
360
|
+
});
|
|
361
|
+
for (const [id, own] of loopsOf) {
|
|
362
|
+
const n = byId.get(id);
|
|
363
|
+
if (!n)
|
|
364
|
+
continue;
|
|
365
|
+
const chips = own.map((e) => loopChip(e.labelRuns));
|
|
366
|
+
const flares = bowFlares(side, n, chips);
|
|
367
|
+
own.forEach((e, k) => {
|
|
368
|
+
const reach = loopReach(side, chips, k);
|
|
369
|
+
const flare = flares[k];
|
|
370
|
+
const { points, labelPos } = selfLoopPoints(n, side, reach, chips[k], flare);
|
|
371
|
+
edges.push({
|
|
372
|
+
from: e.from, to: e.to, points,
|
|
373
|
+
label: e.label, labelRuns: e.labelRuns, labelPos,
|
|
374
|
+
arrow: e.arrow, line: e.line, selfLoop: true,
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
const clusters = ir.subgraphs.flatMap((sg) => {
|
|
379
|
+
const c = g.node(sg.id);
|
|
380
|
+
// An empty or unsized cluster is dropped rather than drawn: dagre leaves
|
|
381
|
+
// its geometry undefined, and emitting that produced `width="NaN"` and a
|
|
382
|
+
// title floating away from its own members.
|
|
383
|
+
if (!c || ![c.x, c.y, c.width, c.height].every(Number.isFinite))
|
|
384
|
+
return [];
|
|
385
|
+
if (c.width <= 0 || c.height <= 0)
|
|
386
|
+
return [];
|
|
387
|
+
// The members size the box and the title fits what they leave: it wraps
|
|
388
|
+
// to the box's width, and the box grows upward a line for each line past
|
|
389
|
+
// the one dagre left room for. Upward is into the rank or node gap above,
|
|
390
|
+
// which nothing reserved, so a title of many lines can reach a neighbour
|
|
391
|
+
// there; a title is rarely more than a few words.
|
|
392
|
+
const titleLines = wrapRuns(sg.titleRuns, Math.max(c.width - 2 * TITLE_PAD, TITLE_MIN_WRAP), LABEL_FONT_SIZE);
|
|
393
|
+
const grow = (titleLines.length - 1) * LABEL_FONT_SIZE * LINE_HEIGHT;
|
|
394
|
+
return [{
|
|
395
|
+
id: sg.id, title: sg.title, titleRuns: sg.titleRuns, titleLines,
|
|
396
|
+
x: c.x, y: c.y - grow / 2, width: c.width, height: c.height + grow,
|
|
397
|
+
}];
|
|
398
|
+
});
|
|
399
|
+
const graph = g.graph();
|
|
400
|
+
// A synthesised self-loop bows outside whatever dagre measured — and on a
|
|
401
|
+
// top-rank node it bows *above* the origin, which the first draft clipped
|
|
402
|
+
// because it only ever grew the maximum. So take the union in both
|
|
403
|
+
// directions and shift everything back into view. The union covers the
|
|
404
|
+
// label chips too, by their rect and not their centre: a chip is wider than
|
|
405
|
+
// the point it hangs on, and on a right-hand node a loop's chip sits past
|
|
406
|
+
// `graph.width`, where only the maximum side would notice.
|
|
407
|
+
let minX = 0, minY = 0;
|
|
408
|
+
let maxX = graph.width ?? 0, maxY = graph.height ?? 0;
|
|
409
|
+
for (const e of edges) {
|
|
410
|
+
for (const p of e.points) {
|
|
411
|
+
minX = Math.min(minX, p.x - MARGIN);
|
|
412
|
+
minY = Math.min(minY, p.y - MARGIN);
|
|
413
|
+
maxX = Math.max(maxX, p.x + MARGIN);
|
|
414
|
+
maxY = Math.max(maxY, p.y + MARGIN);
|
|
415
|
+
}
|
|
416
|
+
if (e.label && e.labelPos) {
|
|
417
|
+
const { width, height } = measureEdgeLabel(e.labelRuns ?? e.label);
|
|
418
|
+
minX = Math.min(minX, e.labelPos.x - width / 2 - MARGIN);
|
|
419
|
+
minY = Math.min(minY, e.labelPos.y - height / 2 - MARGIN);
|
|
420
|
+
maxX = Math.max(maxX, e.labelPos.x + width / 2 + MARGIN);
|
|
421
|
+
maxY = Math.max(maxY, e.labelPos.y + height / 2 + MARGIN);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
// dagre never sees a cluster's title or the growth that holds it: a box
|
|
425
|
+
// grown upward can rise past y = 0, and a title wider than its box (one
|
|
426
|
+
// word too long, or a box under the wrap floor) past x = 0, where no scroll
|
|
427
|
+
// reaches. Both join the union like an edge label does.
|
|
428
|
+
for (const c of clusters) {
|
|
429
|
+
const { width } = measureEdgeLabel(c.titleLines);
|
|
430
|
+
minX = Math.min(minX, c.x - width / 2 - MARGIN);
|
|
431
|
+
maxX = Math.max(maxX, c.x + width / 2 + MARGIN);
|
|
432
|
+
minY = Math.min(minY, c.y - c.height / 2 - MARGIN);
|
|
433
|
+
}
|
|
434
|
+
if (minX < 0 || minY < 0) {
|
|
435
|
+
const dx = -Math.min(0, minX), dy = -Math.min(0, minY);
|
|
436
|
+
for (const n of byId.values()) {
|
|
437
|
+
n.x += dx;
|
|
438
|
+
n.y += dy;
|
|
439
|
+
}
|
|
440
|
+
for (const c of clusters) {
|
|
441
|
+
c.x += dx;
|
|
442
|
+
c.y += dy;
|
|
443
|
+
}
|
|
444
|
+
for (const e of edges) {
|
|
445
|
+
for (const p of e.points) {
|
|
446
|
+
p.x += dx;
|
|
447
|
+
p.y += dy;
|
|
448
|
+
}
|
|
449
|
+
if (e.labelPos) {
|
|
450
|
+
e.labelPos.x += dx;
|
|
451
|
+
e.labelPos.y += dy;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
maxX += dx;
|
|
455
|
+
maxY += dy;
|
|
456
|
+
}
|
|
457
|
+
return { width: Math.ceil(maxX), height: Math.ceil(maxY), nodes: [...byId.values()], edges, clusters };
|
|
458
|
+
}
|
|
459
|
+
/** A node-wide `font-weight` / `font-style` (from `style` or a `classDef`) as
|
|
460
|
+
* run flags. Folding it in here rather than setting it on the `<text>` means
|
|
461
|
+
* the box is measured in the weight it is drawn in, and a run the label
|
|
462
|
+
* already made bold stays bold. */
|
|
463
|
+
function withFontStyle(runs, style) {
|
|
464
|
+
const w = style["font-weight"]?.toLowerCase();
|
|
465
|
+
const bold = w === "bold" || w === "bolder" || (!!w && /^[6-9]00$/.test(w));
|
|
466
|
+
const italic = /^(italic|oblique)$/i.test(style["font-style"] ?? "");
|
|
467
|
+
if (!bold && !italic)
|
|
468
|
+
return runs;
|
|
469
|
+
const restyle = (line) => line.map((r) => ({
|
|
470
|
+
...r, ...(bold ? { bold: true } : {}), ...(italic ? { italic: true } : {}),
|
|
471
|
+
}));
|
|
472
|
+
return runs.map((line) => (isTable(line)
|
|
473
|
+
? { ...line, header: line.header.map(restyle), rows: line.rows.map((row) => row.map(restyle)) }
|
|
474
|
+
: restyle(line)));
|
|
475
|
+
}
|
|
476
|
+
//# sourceMappingURL=layout.js.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Where brane's own grammars reach inside a diagram.
|
|
2
|
+
*
|
|
3
|
+
* A ```mermaid fence is the one place in brane where prose goes in and
|
|
4
|
+
* nothing brane knows about comes out: a node labelled `claude-0qjv4nny` is
|
|
5
|
+
* dead text, where the same characters in a paragraph are a tappable link.
|
|
6
|
+
* This closes that, and it is the reason brane renders diagrams itself
|
|
7
|
+
* rather than renting a renderer — no off-the-shelf one can look up whether
|
|
8
|
+
* a narrative exists.
|
|
9
|
+
*
|
|
10
|
+
* **The dialect gains no syntax for it.** A label that happens to be a
|
|
11
|
+
* narrative reference is linked; everything else is left as text. So the same
|
|
12
|
+
* fence still renders on GitHub, where the label is simply not a link — which
|
|
13
|
+
* matters, because GitHub is where these are usually read.
|
|
14
|
+
*
|
|
15
|
+
* The existence check is the same rule `markdown-nids.ts` applies in prose:
|
|
16
|
+
* only narratives that actually exist are linked, so an arbitrary string of
|
|
17
|
+
* the same shape doesn't become a dead link.
|
|
18
|
+
*/
|
|
19
|
+
import { NID_REF_PATTERN, braneNidUrl } from "../shared/markdown-nids.js";
|
|
20
|
+
/** A label that is *entirely* a reference.
|
|
21
|
+
*
|
|
22
|
+
* Whole-label only: a reference buried in a sentence inside a box would need
|
|
23
|
+
* the text split into tspans with their own anchors, which is a second
|
|
24
|
+
* rendering path for a case the corpus does not contain.
|
|
25
|
+
*
|
|
26
|
+
* **`@` is deliberately not accepted.** A diagram node *cites*; it does not
|
|
27
|
+
* address. The first draft matched `@?`, which made `A[@claude-x]` render as
|
|
28
|
+
* a link — but `mentions-scan` walks the rendered markdown, where a fence is
|
|
29
|
+
* inert, so no mention row was ever written. That breaks the rule the
|
|
30
|
+
* Mentions design rests on: a handle notifies *iff* the operator sees it as
|
|
31
|
+
* one. Refusing the `@` here keeps the two in step, and addressing someone
|
|
32
|
+
* from inside a picture is a capability nobody asked for. */
|
|
33
|
+
const WHOLE_REF = new RegExp(`^\\s*${NID_REF_PATTERN}\\s*$`);
|
|
34
|
+
export function nidHrefFor(db, target = braneNidUrl) {
|
|
35
|
+
const exists = db.prepare("SELECT 1 FROM narratives WHERE id = ?");
|
|
36
|
+
const seen = new Map();
|
|
37
|
+
return (label) => {
|
|
38
|
+
const m = WHOLE_REF.exec(label);
|
|
39
|
+
if (!m)
|
|
40
|
+
return null;
|
|
41
|
+
const [, nid, seq] = m;
|
|
42
|
+
let ok = seen.get(nid);
|
|
43
|
+
if (ok === undefined) {
|
|
44
|
+
ok = exists.get(nid) !== undefined;
|
|
45
|
+
seen.set(nid, ok);
|
|
46
|
+
}
|
|
47
|
+
return ok ? target(nid, seq) : null;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=links.js.map
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/** Text measurement without a browser.
|
|
2
|
+
*
|
|
3
|
+
* Layout needs to know how wide a label is before there is anything to
|
|
4
|
+
* measure it with: the daemon has no DOM, and the whole reason brane does not
|
|
5
|
+
* ship mermaid is that mermaid needs one. So widths come from a table.
|
|
6
|
+
*
|
|
7
|
+
* The table is Helvetica's own advance widths (units of 1/1000 em, from its
|
|
8
|
+
* AFM), which is what `system-ui` resolves to closely enough on every
|
|
9
|
+
* platform brane runs on. It is a real metric set, not an estimate, and it is
|
|
10
|
+
* exact for the ASCII that node labels are overwhelmingly made of.
|
|
11
|
+
*
|
|
12
|
+
* **It cannot be ASCII-only.** One diagram in the archive is a Greek
|
|
13
|
+
* morphology state chart — `πραΰς`, `πραεῖσι(ν)` — and a table that fell back
|
|
14
|
+
* to a single constant for everything outside ASCII would lay it out as
|
|
15
|
+
* overlapping boxes. So non-ASCII resolves per Unicode block, with CJK and
|
|
16
|
+
* Hangul full-width, combining marks zero-width, and emoji wider than a
|
|
17
|
+
* letter. Approximate, but approximate in the right direction per script,
|
|
18
|
+
* which is what keeps a box from clipping its own text.
|
|
19
|
+
*/
|
|
20
|
+
/** Helvetica advance widths, 1/1000 em, for U+0020..U+007E. */
|
|
21
|
+
const ASCII = [
|
|
22
|
+
278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278,
|
|
23
|
+
556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556,
|
|
24
|
+
1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778,
|
|
25
|
+
667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556,
|
|
26
|
+
333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556,
|
|
27
|
+
556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584,
|
|
28
|
+
];
|
|
29
|
+
/** Per-block widths for everything else, same units. Ordered, first match
|
|
30
|
+
* wins; `max` is inclusive. */
|
|
31
|
+
const BLOCKS = [
|
|
32
|
+
{ min: 0x0300, max: 0x036f, w: 0 }, // combining diacriticals
|
|
33
|
+
{ min: 0x00a0, max: 0x024f, w: 556 }, // Latin-1 supplement, Latin Extended-A/B
|
|
34
|
+
{ min: 0x0370, max: 0x03ff, w: 600 }, // Greek
|
|
35
|
+
{ min: 0x0400, max: 0x04ff, w: 600 }, // Cyrillic
|
|
36
|
+
{ min: 0x0590, max: 0x05ff, w: 520 }, // Hebrew
|
|
37
|
+
{ min: 0x0600, max: 0x06ff, w: 520 }, // Arabic
|
|
38
|
+
{ min: 0x1f00, max: 0x1fff, w: 600 }, // Greek Extended — the morphology chart lives here
|
|
39
|
+
{ min: 0x2000, max: 0x206f, w: 400 }, // general punctuation
|
|
40
|
+
{ min: 0x2190, max: 0x21ff, w: 800 }, // arrows
|
|
41
|
+
{ min: 0x2200, max: 0x22ff, w: 700 }, // mathematical operators
|
|
42
|
+
{ min: 0x2500, max: 0x257f, w: 600 }, // box drawing
|
|
43
|
+
{ min: 0x2e80, max: 0x9fff, w: 1000 }, // CJK
|
|
44
|
+
{ min: 0xac00, max: 0xd7af, w: 1000 }, // Hangul
|
|
45
|
+
{ min: 0xf900, max: 0xfaff, w: 1000 }, // CJK compatibility
|
|
46
|
+
{ min: 0xff00, max: 0xff60, w: 1000 }, // fullwidth forms
|
|
47
|
+
{ min: 0x1f300, max: 0x1faff, w: 1200 }, // emoji
|
|
48
|
+
];
|
|
49
|
+
/** Anything unrecognised. A letter-ish default beats both 0 (text spills) and
|
|
50
|
+
* full width (boxes gape). */
|
|
51
|
+
const DEFAULT_WIDTH = 600;
|
|
52
|
+
export function charWidth(cp) {
|
|
53
|
+
if (cp >= 0x20 && cp <= 0x7e)
|
|
54
|
+
return ASCII[cp - 0x20];
|
|
55
|
+
if (cp === 0x09)
|
|
56
|
+
return ASCII[0] * 4;
|
|
57
|
+
if (cp < 0x20 || (cp >= 0x7f && cp <= 0x9f))
|
|
58
|
+
return 0;
|
|
59
|
+
for (const b of BLOCKS)
|
|
60
|
+
if (cp >= b.min && cp <= b.max)
|
|
61
|
+
return b.w;
|
|
62
|
+
return DEFAULT_WIDTH;
|
|
63
|
+
}
|
|
64
|
+
/** Width of `text` in px at `fontSize`. Iterates code points, so a surrogate
|
|
65
|
+
* pair counts once — an emoji measured as two halves is measured twice. */
|
|
66
|
+
export function textWidth(text, fontSize) {
|
|
67
|
+
let total = 0;
|
|
68
|
+
for (const ch of text)
|
|
69
|
+
total += charWidth(ch.codePointAt(0));
|
|
70
|
+
return (total / 1000) * fontSize;
|
|
71
|
+
}
|
|
72
|
+
/** Helvetica-Bold advance widths, 1/1000 em, for U+0020..U+007E — the bold
|
|
73
|
+
* face's own AFM, not a scale factor. Bold widens unevenly (`r` 333 → 389,
|
|
74
|
+
* `i` 222 → 278, `?` 556 → 611), so a uniform stretch would misjudge exactly
|
|
75
|
+
* the short labels where a box's padding is thinnest. */
|
|
76
|
+
const ASCII_BOLD = [
|
|
77
|
+
278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278,
|
|
78
|
+
556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611,
|
|
79
|
+
975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778,
|
|
80
|
+
667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556,
|
|
81
|
+
333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611,
|
|
82
|
+
611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584,
|
|
83
|
+
];
|
|
84
|
+
/** Outside ASCII, bold is regular plus this much: approximate, and on the
|
|
85
|
+
* wide side, which clips nothing. */
|
|
86
|
+
const BOLD_BLOCK_SCALE = 1.08;
|
|
87
|
+
/** Monospace advance: Menlo, SF Mono and Courier are all 600–602. Full-width
|
|
88
|
+
* scripts stay full width in a monospace face too. */
|
|
89
|
+
const MONO_WIDTH = 602;
|
|
90
|
+
export const MONO_FAMILY = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
|
|
91
|
+
export function runWidth(run, fontSize) {
|
|
92
|
+
if (!run.bold && !run.code)
|
|
93
|
+
return textWidth(run.text, fontSize);
|
|
94
|
+
let total = 0;
|
|
95
|
+
for (const ch of run.text) {
|
|
96
|
+
const cp = ch.codePointAt(0);
|
|
97
|
+
const w = charWidth(cp);
|
|
98
|
+
if (run.code)
|
|
99
|
+
total += w === 0 ? 0 : w >= 1000 ? w : MONO_WIDTH;
|
|
100
|
+
else
|
|
101
|
+
total += cp >= 0x20 && cp <= 0x7e ? ASCII_BOLD[cp - 0x20] : w * BOLD_BLOCK_SCALE;
|
|
102
|
+
}
|
|
103
|
+
return (total / 1000) * fontSize;
|
|
104
|
+
}
|
|
105
|
+
/** Space between table columns, and how far a table's rule overhangs its
|
|
106
|
+
* outermost cells. */
|
|
107
|
+
export const TABLE_GAP = 16;
|
|
108
|
+
const cellsWidth = (cells, fontSize) => cells.reduce((w, r) => w + runWidth(r, fontSize), 0);
|
|
109
|
+
/** Each column's width: the widest cell in it, header included. */
|
|
110
|
+
export function tableColumns(t, fontSize) {
|
|
111
|
+
return t.header.map((_, i) => Math.max(...[t.header, ...t.rows].map((row) => cellsWidth(row[i] ?? [], fontSize))));
|
|
112
|
+
}
|
|
113
|
+
export function tableWidth(t, fontSize) {
|
|
114
|
+
const cols = tableColumns(t, fontSize);
|
|
115
|
+
return cols.reduce((a, b) => a + b, 0) + TABLE_GAP * Math.max(0, cols.length - 1);
|
|
116
|
+
}
|
|
117
|
+
function lineWidth(line, fontSize) {
|
|
118
|
+
if (typeof line === "string")
|
|
119
|
+
return textWidth(line, fontSize);
|
|
120
|
+
if (!Array.isArray(line))
|
|
121
|
+
return tableWidth(line, fontSize);
|
|
122
|
+
return cellsWidth(line, fontSize);
|
|
123
|
+
}
|
|
124
|
+
/** Line slots: one per line, and a table's header plus each of its rows. */
|
|
125
|
+
export const lineSlots = (lines) => lines.reduce((n, l) => n + (typeof l === "string" || Array.isArray(l) ? 1 : 1 + l.rows.length), 0);
|
|
126
|
+
export const FONT_SIZE = 14;
|
|
127
|
+
export const LINE_HEIGHT = 1.35;
|
|
128
|
+
export const LABEL_FONT_SIZE = 12;
|
|
129
|
+
/** Padding inside a node box, and the floor on its size. */
|
|
130
|
+
const PAD_X = 18;
|
|
131
|
+
const PAD_Y = 11;
|
|
132
|
+
const MIN_W = 44;
|
|
133
|
+
const MIN_H = 34;
|
|
134
|
+
export function measureNode(lines) {
|
|
135
|
+
const w = Math.max(0, ...lines.map((l) => lineWidth(l, FONT_SIZE)));
|
|
136
|
+
const h = Math.max(1, lineSlots(lines)) * FONT_SIZE * LINE_HEIGHT;
|
|
137
|
+
return {
|
|
138
|
+
width: Math.max(MIN_W, Math.ceil(w + PAD_X * 2)),
|
|
139
|
+
height: Math.max(MIN_H, Math.ceil(h + PAD_Y * 2)),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/** An edge label's chip. Multi-line since `<br/>` and markdown strings reach
|
|
143
|
+
* edge labels too; one line measures exactly as it always did. */
|
|
144
|
+
export function measureEdgeLabel(label) {
|
|
145
|
+
const lines = typeof label === "string" ? [label] : label;
|
|
146
|
+
const w = Math.max(0, ...lines.map((l) => lineWidth(l, LABEL_FONT_SIZE)));
|
|
147
|
+
return {
|
|
148
|
+
width: Math.ceil(w + 10),
|
|
149
|
+
height: Math.ceil(Math.max(1, lines.length) * LABEL_FONT_SIZE * LINE_HEIGHT + 4),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
//# sourceMappingURL=metrics.js.map
|