feat(ui): graph on d3-force with steady labels, quieter quota text, flat rail

This commit is contained in:
hh committed 2026-09-02 04:35:01 +02:00
1 parent c6401f1114
commit f35980ba85
7 files changed
+206 -157

No files matched your search

+93 -92
View File
@@ -1,32 +1,46 @@
// A small force layout: rings keep the structure legible, springs and
// repulsion keep neighbours apart, a breath keeps it alive between touches.
import {
forceCenter,
forceCollide,
forceLink,
forceManyBody,
forceRadial,
forceSimulation,
type Simulation,
type SimulationLinkDatum,
type SimulationNodeDatum,
} from "d3-force";
export interface Body {
fixed: boolean;
// 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;
vx: number;
vy: number;
x: number;
y: number;
}
interface Link {
a: Body;
b: Body;
interface Link extends SimulationLinkDatum<Body> {
source: Body;
target: Body;
}
const RING_RADIUS = [0, 92, 168, 224];
const LINK_STRENGTH = 0.06;
const LINK_REST = 70;
const CHARGE = 1600;
const CHARGE_REACH = 320;
const RADIAL = 0.05;
const HUB_HOLD = 0.4;
const DECAY = 0.55;
const ALPHA_EASE = 0.025;
const BREATH = 0.35;
const MIN_D2 = 36;
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;
@@ -45,14 +59,43 @@ export function ringRadius(ring: number): number {
export class Force {
bodies: Body[] = [];
alpha = 1;
alphaTarget = 0;
private links: Link[] = [];
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;
// Keep what is already placed, seat newcomers on their ring near a
// neighbour when they have one, forget what is gone.
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 }[]
@@ -69,11 +112,8 @@ export class Force {
const angle = (hash(node.id) % 360) * (TAU / 360);
const r = ringRadius(node.ring);
const body: Body = {
fixed: false,
id: node.id,
ring: node.ring,
vx: 0,
vy: 0,
x: Math.cos(angle) * r,
y: Math.sin(angle) * r,
};
@@ -88,26 +128,28 @@ export class Force {
this.bodies = nodes
.map((node) => this.index.get(node.id))
.filter((body): body is Body => body !== undefined);
this.links = [];
const links: Link[] = [];
for (const edge of edges) {
const a = this.index.get(edge.from);
const b = this.index.get(edge.to);
if (a && b && a !== b) {
this.links.push({ a, b });
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 = this.links.find((l) => l.a === body || l.b === body);
const link = links.find((l) => l.source === body || l.target === body);
if (link) {
const other = link.a === body ? link.b : link.a;
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.6);
this.reheat(0.7);
}
}
@@ -115,75 +157,34 @@ export class Force {
return this.index.get(id);
}
get alpha(): number {
return this.sim.alpha();
}
reheat(to = 0.4): void {
this.alpha = Math.max(this.alpha, to);
this.sim.alpha(Math.max(this.sim.alpha(), to));
}
get settled(): boolean {
return this.alpha < 0.004 && this.alphaTarget === 0;
return this.sim.alpha() < SETTLED;
}
// One tick; ``breathe`` adds the slow drift of a graph nobody is touching.
// One tick; ``breathe`` keeps a faint target so the graph never freezes.
step(breathe: boolean): void {
this.clock += 1 / 60;
const { alpha, bodies } = this;
for (const link of this.links) {
const dx = link.b.x - link.a.x;
const dy = link.b.y - link.a.y;
const d = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
const rest = LINK_REST + Math.abs(link.a.ring - link.b.ring) * 10;
const f = ((d - rest) / d) * LINK_STRENGTH * alpha;
link.a.vx += dx * f;
link.a.vy += dy * f;
link.b.vx -= dx * f;
link.b.vy -= dy * f;
}
for (let i = 0; i < bodies.length; i += 1) {
const a = bodies[i];
for (let j = i + 1; j < bodies.length; j += 1) {
const b = bodies[j];
const dx = b.x - a.x;
const dy = b.y - a.y;
const d2 = Math.max(dx * dx + dy * dy, MIN_D2);
if (d2 > CHARGE_REACH * CHARGE_REACH) {
this.sim.alphaTarget(breathe ? BREATH_TARGET : 0);
if (breathe) {
for (const body of this.bodies) {
if (body.fx !== null && body.fx !== undefined) {
continue;
}
const f = (CHARGE / d2) * alpha;
const d = Math.sqrt(d2);
const fx = (dx / d) * f;
const fy = (dy / d) * f;
a.vx -= fx;
a.vy -= fy;
b.vx += fx;
b.vy += fy;
}
}
for (const body of bodies) {
if (body.fixed) {
body.vx = 0;
body.vy = 0;
continue;
}
if (body.ring === 0) {
body.vx -= body.x * HUB_HOLD * alpha;
body.vy -= body.y * HUB_HOLD * alpha;
} else {
const r = Math.max(Math.sqrt(body.x * body.x + body.y * body.y), 1);
const pull = ((ringRadius(body.ring) - r) / r) * RADIAL * alpha;
body.vx += body.x * pull;
body.vy += body.y * pull;
}
if (breathe) {
const seed = hash(body.id) % 11;
body.vx += Math.sin(this.clock * 0.6 + seed) * BREATH * 0.02;
body.vy += Math.cos(this.clock * 0.5 + seed * 1.3) * BREATH * 0.02;
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;
}
body.vx *= DECAY;
body.vy *= DECAY;
body.x += body.vx;
body.y += body.vy;
}
this.alpha += (this.alphaTarget - this.alpha) * ALPHA_EASE;
this.sim.tick();
}
bounds(): { x: number; y: number; w: number; h: number } {