Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/lib/engine/simulation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
216 changes: 145 additions & 71 deletions src/lib/engine/simulation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import {
forceSimulation,
forceLink,
forceManyBody,
forceCenter,
forceCollide,
forceRadial,
forceX,
Expand All @@ -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
Expand All @@ -32,115 +37,184 @@ interface SimLink extends SimulationLinkDatum<SimNode> {
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<string, Anchor> {
const anchors = new Map<string, Anchor>();
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<SimNode, SimLink> {
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,
target: e.target,
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<string, { x: number; y: number }>();
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<SimNode>(simNodes)
.force('center', forceCenter(0, 0).strength(0.05))
.force(
'charge',
forceManyBody<SimNode>()
.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<SimNode, SimLink>(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<SimNode>(200, 0, 0).strength((d) =>
d.isBorn !== false && d.type === 'category' ? 0.3 : 0
'anchor-x',
forceX<SimNode>((d) => d.ax).strength((d) =>
!born(d) ? 0 : d.type === 'subcategory' ? 0.1 : 0.08
)
)
.force(
'radial-subcategories',
forceRadial<SimNode>(340, 0, 0).strength((d) =>
d.isBorn !== false && d.type === 'subcategory' ? 0.18 : 0
'anchor-y',
forceY<SimNode>((d) => d.ay).strength((d) =>
!born(d) ? 0 : d.type === 'subcategory' ? 0.1 : 0.08
)
)
.force(
'radial-protocols',
forceRadial<SimNode>(470, 0, 0).strength((d) =>
d.isBorn !== false && d.type === 'protocol' ? 0.12 : 0
'radial',
forceRadial<SimNode>((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<SimNode>((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<SimNode>((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<SimNode>()
.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)
Expand Down
7 changes: 7 additions & 0 deletions src/lib/utils/text-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]]');
});
});
23 changes: 17 additions & 6 deletions src/lib/utils/text-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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');
}
Loading