From 4da7a5f301eb1f3df906aff4f1bc5f1e7a0e0154 Mon Sep 17 00:00:00 2001 From: NeoVand Date: Fri, 19 Jun 2026 10:15:20 -0500 Subject: [PATCH 1/2] fix(content): stripRichTextMarkup must not corrupt Mermaid node syntax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mermaid reuses {{…}} (hexagon) and [[…]] (subroutine) for node shapes — e.g. the Transport story's `T3{{"Middlebox"}}`. stripRichTextMarkup (which also cleans Mermaid node labels) was turning that into invalid Mermaid, so the diagram failed to render. Now only slug-shaped ids are resolved; non-slug {{…}}/[[…]] (Mermaid syntax) pass through untouched — which also restores labels that were previously dropped. Regression tests added. Co-Authored-By: Claude Fable 5 --- src/lib/utils/text-parser.test.ts | 7 +++++++ src/lib/utils/text-parser.ts | 23 +++++++++++++++++------ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/lib/utils/text-parser.test.ts b/src/lib/utils/text-parser.test.ts index d2ad7e1..672c5a6 100644 --- a/src/lib/utils/text-parser.test.ts +++ b/src/lib/utils/text-parser.test.ts @@ -117,4 +117,11 @@ describe('stripRichTextMarkup', () => { expect(out).not.toContain('{{'); expect(out.toLowerCase()).toContain('handshake'); }); + + it('leaves Mermaid node-shape syntax untouched (non-slug ids)', () => { + // Mermaid reuses {{…}} (hexagon) and [[…]] (subroutine) — these are not + // our refs and must survive stripping so diagram labels stay valid. + expect(stripRichTextMarkup('T3{{"Middlebox"}}')).toBe('T3{{"Middlebox"}}'); + expect(stripRichTextMarkup('N[[Subroutine Step]]')).toBe('N[[Subroutine Step]]'); + }); }); diff --git a/src/lib/utils/text-parser.ts b/src/lib/utils/text-parser.ts index a9859a9..0a2ceca 100644 --- a/src/lib/utils/text-parser.ts +++ b/src/lib/utils/text-parser.ts @@ -243,6 +243,10 @@ function bracketFallbackLabel(rawId: string): string { } } +/** A real cross-reference id is a lowercase slug (optionally `prefix:id` or + * `part/chapter`). Used to tell our refs apart from Mermaid shape syntax. */ +const REF_ID = /^[a-z0-9][a-z0-9:/-]*$/; + /** * Strip rich-text atoms ([[…]] / {{…}} / **…**) down to their label * text. For surfaces that render strings as raw DOM text (screen-reader @@ -261,12 +265,19 @@ export function stripRichTextMarkup(raw: string): string { // abbreviation, concept term, …) rather than vanishing — otherwise a bare // `[[tcp]]` in a stripped field (tooltip / aria-label) would silently drop // the word. + // + // IMPORTANT: only resolve when the id is slug-shaped (lowercase kebab/colon). + // Mermaid reuses `{{…}}` (hexagon) and `[[…]]` (subroutine) for node *shapes*, + // e.g. `T3{{"Middlebox"}}`. Those aren't our refs — left untouched so this + // function (which also cleans Mermaid node labels) doesn't corrupt diagrams. return raw - .replace(/\[\[([^\][|]+)(?:\|([^\]]+))?\]\]/g, (_m, id, label) => - label != null ? label : bracketFallbackLabel(id) - ) - .replace(/\{\{([^}{|]+)(?:\|([^}]+))?\}\}/g, (_m, id, label) => - label != null ? label : (getConceptById(id)?.term ?? id) - ) + .replace(/\[\[([^\][|]+)(?:\|([^\]]+))?\]\]/g, (m, id, label) => { + if (label != null) return label; + return REF_ID.test(id) ? bracketFallbackLabel(id) : m; + }) + .replace(/\{\{([^}{|]+)(?:\|([^}]+))?\}\}/g, (m, id, label) => { + if (label != null) return label; + return REF_ID.test(id) ? (getConceptById(id)?.term ?? id) : m; + }) .replace(/\*\*([^*]+)\*\*/g, '$1'); } From d16fe047569691f55d74cd775c90e3d65f7ba46f Mon Sep 17 00:00:00 2001 From: NeoVand Date: Fri, 19 Jun 2026 10:15:20 -0500 Subject: [PATCH 2/2] feat(layout): compact, organic force layout with clearly-fanned branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework createSimulation to anchor each node to its hierarchical slice — every subcategory gets its own weighted angular wedge and protocols fan under it — so categories no longer collapse into a single spoke (the Web/API tangle and the Utilities vertical line). Nodes seed near their anchor; local charge (capped distanceMax) + short, firm links keep the graph about as dense as the radial layout while staying organic. Settles at ~510px max radius vs the old tangle. Co-Authored-By: Claude Fable 5 --- src/lib/engine/simulation.test.ts | 2 +- src/lib/engine/simulation.ts | 216 ++++++++++++++++++++---------- 2 files changed, 146 insertions(+), 72 deletions(-) diff --git a/src/lib/engine/simulation.test.ts b/src/lib/engine/simulation.test.ts index 6ddac0e..91463fd 100644 --- a/src/lib/engine/simulation.test.ts +++ b/src/lib/engine/simulation.test.ts @@ -9,7 +9,7 @@ function gnode(id: string): GraphNode { } function snode(id: string, x: number, y: number): SimNode { - return { id, type: 'protocol', radius: 10, x, y, vx: 1, vy: 2 }; + return { id, type: 'protocol', radius: 10, ax: 0, ay: 0, ar: 0, x, y, vx: 1, vy: 2 }; } /** Stub d3 simulation exposing only the .nodes() syncPositions reads. */ diff --git a/src/lib/engine/simulation.ts b/src/lib/engine/simulation.ts index a6c8d2f..accf75c 100644 --- a/src/lib/engine/simulation.ts +++ b/src/lib/engine/simulation.ts @@ -2,7 +2,6 @@ import { forceSimulation, forceLink, forceManyBody, - forceCenter, forceCollide, forceRadial, forceX, @@ -12,13 +11,19 @@ import { type SimulationLinkDatum } from 'd3-force'; import type { GraphNode, GraphEdge } from '$lib/data/types'; -import { categories } from '$lib/data/categories'; export interface SimNode extends SimulationNodeDatum { id: string; type: 'hub' | 'category' | 'subcategory' | 'protocol'; radius: number; categoryId?: string; + subcategoryId?: string; + /** Hierarchical angular anchor (x/y) and its ring radius — each subcategory + * gets its own slice of its category's wedge so branches fan out cleanly + * instead of collapsing into a single spoke. */ + ax: number; + ay: number; + ar: number; /** * Set false to make the node inert in the layout — no charge, no link * pull, no collision. Used during the chronological bloom so unborn @@ -32,22 +37,117 @@ interface SimLink extends SimulationLinkDatum { color: string; } +/** Deterministic [0,1) hash of a string, so seed jitter is stable per reload. */ +function det(seed: string): number { + let h = 2166136261; + for (let i = 0; i < seed.length; i++) { + h ^= seed.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return (h >>> 0) / 4294967296; +} + +interface Anchor { + x: number; + y: number; + r: number; +} + +/** + * Compute a hierarchical angular anchor for every node: subcategories fan + * across their category's wedge (weighted by protocol count) and protocols + * across their subcategory's slice. This is the skeleton that keeps each + * category blooming into a fan of distinct branches instead of collapsing + * into one spoke. Mirrors the radial layout's angular maths. + */ +function hierarchicalAnchors( + nodes: GraphNode[], + R: { cat: number; sub: number; proto: number } +): Map { + const anchors = new Map(); + const hub = nodes.find((n) => n.type === 'hub'); + const catNodes = nodes.filter((n) => n.type === 'category'); + const subNodes = nodes.filter((n) => n.type === 'subcategory'); + const protoNodes = nodes.filter((n) => n.type === 'protocol'); + + if (hub) anchors.set(hub.id, { x: 0, y: 0, r: 0 }); + + const CAT_FILL = 0.84; + const SUB_FILL = 0.9; + const MIN_SUB_WEIGHT = 0.6; + const place = (id: string, angle: number, r: number) => + anchors.set(id, { x: Math.cos(angle) * r, y: Math.sin(angle) * r, r }); + + catNodes.forEach((cat, i) => { + const catAngle = (i / catNodes.length) * Math.PI * 2 - Math.PI / 2; + place(cat.id, catAngle, R.cat); + + const cSubs = subNodes.filter((s) => s.categoryId === cat.id); + const catSpan = ((2 * Math.PI) / catNodes.length) * CAT_FILL; + const weights = cSubs.map((sub) => + Math.max(protoNodes.filter((p) => p.subcategoryId === sub.id).length, MIN_SUB_WEIGHT) + ); + const total = weights.reduce((a, b) => a + b, 0) || 1; + + let offset = 0; + cSubs.forEach((sub, j) => { + const subWidth = catSpan * (weights[j] / total); + const subAngle = catAngle - catSpan / 2 + offset + subWidth / 2; + offset += subWidth; + place(sub.id, subAngle, R.sub); + + const sProtos = protoNodes.filter((p) => p.subcategoryId === sub.id); + const subSpan = subWidth * SUB_FILL; + sProtos.forEach((proto, k) => + place(proto.id, subAngle - subSpan / 2 + ((k + 0.5) / sProtos.length) * subSpan, R.proto) + ); + }); + + const orphans = protoNodes.filter((p) => p.categoryId === cat.id && !p.subcategoryId); + orphans.forEach((proto, j) => + place(proto.id, catAngle - catSpan / 2 + ((j + 0.5) / orphans.length) * catSpan, R.proto) + ); + }); + + return anchors; +} + +/** + * The graph's force layout. Each node is anchored to its hierarchical slice + * (see {@link hierarchicalAnchors}) and seeded near that anchor, then relaxed + * with local charge, tree links, and collision — organic spacing, but compact + * and with every branch clearly fanned out. Tuned so the settled graph stays + * about as dense as a tidy radial layout (max node radius ~510px). + */ export function createSimulation( nodes: GraphNode[], edges: GraphEdge[] ): Simulation { - const simNodes: SimNode[] = nodes.map((n) => ({ - id: n.id, - type: n.type, - radius: n.radius, - categoryId: n.categoryId, - x: n.x, - y: n.y, - vx: n.vx, - vy: n.vy, - fx: n.fx ?? undefined, - fy: n.fy ?? undefined - })); + const anchors = hierarchicalAnchors(nodes, { cat: 185, sub: 320, proto: 450 }); + const SEED_JITTER = 28; + + const simNodes: SimNode[] = nodes.map((n) => { + const a = anchors.get(n.id) ?? { x: 0, y: 0, r: 0 }; + const pinned = n.type === 'hub'; + return { + id: n.id, + type: n.type, + radius: n.radius, + categoryId: n.categoryId, + subcategoryId: n.subcategoryId, + ax: a.x, + ay: a.y, + ar: a.r, + // Seed near the hierarchical anchor (not all at 0,0) so the warm-up + // unfolds from a sensible fan rather than a degenerate point. + x: pinned ? 0 : a.x + (det(n.id + 'x') - 0.5) * 2 * SEED_JITTER, + y: pinned ? 0 : a.y + (det(n.id + 'y') - 0.5) * 2 * SEED_JITTER, + vx: 0, + vy: 0, + fx: pinned ? 0 : (n.fx ?? undefined), + fy: pinned ? 0 : (n.fy ?? undefined) + }; + }); const simLinks: SimLink[] = edges.map((e) => ({ source: e.source, @@ -55,92 +155,66 @@ export function createSimulation( color: e.color })); - // Per-category angular anchors: each category claims a fixed direction - // from the hub. Subcategories and protocols are pulled toward that - // direction so cluster identity reads visually even after the sim settles. - const CLUSTER_ANCHOR_RADIUS = 360; - const catAnchors = new Map(); - categories.forEach((cat, i) => { - const angle = (i / categories.length) * Math.PI * 2 - Math.PI / 2; - catAnchors.set(cat.id, { - x: Math.cos(angle) * CLUSTER_ANCHOR_RADIUS, - y: Math.sin(angle) * CLUSTER_ANCHOR_RADIUS - }); - }); + const born = (d: SimNode) => d.isBorn !== false; const simulation = forceSimulation(simNodes) - .force('center', forceCenter(0, 0).strength(0.05)) .force( 'charge', forceManyBody() .strength((d) => { - if (d.isBorn === false) return 0; - if (d.type === 'hub') return -800; - if (d.type === 'category') return -400; - if (d.type === 'subcategory') return -260; - return -170; + if (!born(d)) return 0; + if (d.type === 'category') return -520; + if (d.type === 'subcategory') return -320; + return -185; // hub (pinned) + protocols }) - .distanceMax(500) + // Local repulsion only — keeps the graph from ballooning outward. + .distanceMax(460) ) .force( 'link', forceLink(simLinks) .id((d) => d.id) .distance((d) => { - const src = d.source as SimNode; - if (src.type === 'hub') return 180; - if (src.type === 'category') return 120; - return 90; + const s = (d.source as SimNode).type; + const t = (d.target as SimNode).type; + if (s === 'hub' || t === 'hub') return 180; + if (s === 'category' || t === 'category') return 130; + return 88; }) .strength((d) => { - const src = d.source as SimNode; - const tgt = d.target as SimNode; - if (src.isBorn === false || tgt.isBorn === false) return 0; - if (src.type === 'hub') return 0.4; - return 0.6; + const s = d.source as SimNode; + const t = d.target as SimNode; + if (!born(s) || !born(t)) return 0; + if (s.type === 'hub' || t.type === 'hub') return 0.2; + if (s.type === 'category' || t.type === 'category') return 0.4; + return 0.55; }) ) + // Pull each node toward its hierarchical anchor — light enough to stay + // organic, firm enough that branches keep their own angular slice. .force( - 'radial-categories', - forceRadial(200, 0, 0).strength((d) => - d.isBorn !== false && d.type === 'category' ? 0.3 : 0 + 'anchor-x', + forceX((d) => d.ax).strength((d) => + !born(d) ? 0 : d.type === 'subcategory' ? 0.1 : 0.08 ) ) .force( - 'radial-subcategories', - forceRadial(340, 0, 0).strength((d) => - d.isBorn !== false && d.type === 'subcategory' ? 0.18 : 0 + 'anchor-y', + forceY((d) => d.ay).strength((d) => + !born(d) ? 0 : d.type === 'subcategory' ? 0.1 : 0.08 ) ) .force( - 'radial-protocols', - forceRadial(470, 0, 0).strength((d) => - d.isBorn !== false && d.type === 'protocol' ? 0.12 : 0 + 'radial', + forceRadial((d) => d.ar, 0, 0).strength((d) => + !born(d) ? 0 : d.type === 'category' ? 0.06 : d.type === 'subcategory' ? 0.05 : 0.04 ) ) - .force( - 'cluster-x', - forceX((d) => catAnchors.get(d.categoryId ?? '')?.x ?? 0).strength((d) => { - if (d.isBorn === false) return 0; - if (d.type === 'subcategory') return 0.12; - if (d.type === 'protocol') return 0.09; - return 0; - }) - ) - .force( - 'cluster-y', - forceY((d) => catAnchors.get(d.categoryId ?? '')?.y ?? 0).strength((d) => { - if (d.isBorn === false) return 0; - if (d.type === 'subcategory') return 0.12; - if (d.type === 'protocol') return 0.09; - return 0; - }) - ) .force( 'collide', forceCollide() - .radius((d) => (d.isBorn === false ? 0 : d.radius + 14)) - .strength(0.95) + .radius((d) => (born(d) ? d.radius + 15 : 0)) + .strength(0.9) .iterations(2) ) .alphaDecay(0.02)