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 };
}
}