import { forceCenter, forceCollide, forceLink, forceManyBody, forceRadial, forceSimulation, type Simulation, type SimulationLinkDatum, type SimulationNodeDatum, } from "d3-force"; // d3-force under a small API: rings keep the picture legible, collision // keeps dots apart, a faint alpha target keeps the graph breathing. export interface Body extends SimulationNodeDatum { id: string; ring: number; x: number; y: number; } interface Link extends SimulationLinkDatum { source: Body; target: Body; } const RING_RADIUS = [0, 96, 176, 236]; const CHARGE = -150; const CHARGE_REACH = 340; const LINK_REST = 62; const LINK_RING_GAP = 14; const LINK_STRENGTH = 0.45; const COLLIDE_HUB = 24; const COLLIDE = 13; const RADIAL_HUB = 1; const RADIAL = 0.07; const CENTER = 0.02; const ALPHA_DECAY = 0.03; const VELOCITY_DECAY = 0.45; const BREATH_TARGET = 0.012; const SETTLED = 0.004; const JITTER = 0.06; const TAU = Math.PI * 2; const HASH_MOD = 1_000_003; const HASH_BASE = 31; function hash(id: string): number { let h = 0; for (const ch of id) { h = (h * HASH_BASE + ch.charCodeAt(0)) % HASH_MOD; } return h; } export function ringRadius(ring: number): number { return RING_RADIUS[Math.min(ring, RING_RADIUS.length - 1)] ?? 0; } export class Force { bodies: Body[] = []; private readonly sim: Simulation; private readonly linkForce = forceLink([]) .id((body) => body.id) .distance( (link) => LINK_REST + Math.abs(link.source.ring - link.target.ring) * LINK_RING_GAP ) .strength(LINK_STRENGTH); private readonly index = new Map(); private clock = 0; constructor() { this.sim = forceSimulation([]) .stop() .alphaDecay(ALPHA_DECAY) .velocityDecay(VELOCITY_DECAY) .force( "charge", forceManyBody().strength(CHARGE).distanceMax(CHARGE_REACH) ) .force("link", this.linkForce) .force( "collide", forceCollide((body) => (body.ring === 0 ? COLLIDE_HUB : COLLIDE)) ) .force( "radial", forceRadial((body) => ringRadius(body.ring), 0, 0).strength( (body) => (body.ring === 0 ? RADIAL_HUB : RADIAL) ) ) .force("center", forceCenter(0, 0).strength(CENTER)); } // Keep what is placed, seat newcomers on their ring beside a neighbour // when they have one, forget what is gone. sync( nodes: { id: string; ring: number }[], edges: { from: string; to: string }[] ): void { const keep = new Set(); const fresh: Body[] = []; for (const node of nodes) { keep.add(node.id); const known = this.index.get(node.id); if (known) { known.ring = node.ring; continue; } const angle = (hash(node.id) % 360) * (TAU / 360); const r = ringRadius(node.ring); const body: Body = { id: node.id, ring: node.ring, x: Math.cos(angle) * r, y: Math.sin(angle) * r, }; this.index.set(node.id, body); fresh.push(body); } for (const id of [...this.index.keys()]) { if (!keep.has(id)) { this.index.delete(id); } } this.bodies = nodes .map((node) => this.index.get(node.id)) .filter((body): body is Body => body !== undefined); const links: Link[] = []; for (const edge of edges) { const source = this.index.get(edge.from); const target = this.index.get(edge.to); if (source && target && source !== target) { links.push({ source, target }); } } for (const body of fresh) { const link = links.find((l) => l.source === body || l.target === body); if (link) { const other = link.source === body ? link.target : link.source; const r = ringRadius(body.ring); const angle = Math.atan2(other.y, other.x) + (hash(body.id) % 7) * 0.09; body.x = Math.cos(angle) * r; body.y = Math.sin(angle) * r; } } this.sim.nodes(this.bodies); this.linkForce.links(links); if (fresh.length > 0) { this.reheat(0.7); } } body(id: string): Body | undefined { return this.index.get(id); } get alpha(): number { return this.sim.alpha(); } reheat(to = 0.4): void { this.sim.alpha(Math.max(this.sim.alpha(), to)); } get settled(): boolean { return this.sim.alpha() < SETTLED; } // One tick; ``breathe`` keeps a faint target so the graph never freezes. step(breathe: boolean): void { this.clock += 1 / 60; this.sim.alphaTarget(breathe ? BREATH_TARGET : 0); if (breathe) { for (const body of this.bodies) { if (body.fx !== null && body.fx !== undefined) { continue; } const seed = hash(body.id) % 11; body.vx = (body.vx ?? 0) + Math.sin(this.clock * 0.6 + seed) * JITTER; body.vy = (body.vy ?? 0) + Math.cos(this.clock * 0.5 + seed * 1.3) * JITTER; } } this.sim.tick(); } bounds(): { x: number; y: number; w: number; h: number } { if (this.bodies.length === 0) { return { h: 1, w: 1, x: 0, y: 0 }; } let minX = Number.POSITIVE_INFINITY; let minY = Number.POSITIVE_INFINITY; let maxX = Number.NEGATIVE_INFINITY; let maxY = Number.NEGATIVE_INFINITY; for (const body of this.bodies) { minX = Math.min(minX, body.x); minY = Math.min(minY, body.y); maxX = Math.max(maxX, body.x); maxY = Math.max(maxY, body.y); } return { h: maxY - minY || 1, w: maxX - minX || 1, x: minX, y: minY }; } }