207 lines
5.5 KiB
TypeScript
207 lines
5.5 KiB
TypeScript
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<Body> {
|
|
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<Body, Link>;
|
|
private readonly linkForce = forceLink<Body, Link>([])
|
|
.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<string, Body>();
|
|
private clock = 0;
|
|
|
|
constructor() {
|
|
this.sim = forceSimulation<Body>([])
|
|
.stop()
|
|
.alphaDecay(ALPHA_DECAY)
|
|
.velocityDecay(VELOCITY_DECAY)
|
|
.force(
|
|
"charge",
|
|
forceManyBody<Body>().strength(CHARGE).distanceMax(CHARGE_REACH)
|
|
)
|
|
.force("link", this.linkForce)
|
|
.force(
|
|
"collide",
|
|
forceCollide<Body>((body) => (body.ring === 0 ? COLLIDE_HUB : COLLIDE))
|
|
)
|
|
.force(
|
|
"radial",
|
|
forceRadial<Body>((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<string>();
|
|
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 };
|
|
}
|
|
}
|