@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
352 lines (351 loc) • 15.1 kB
JavaScript
/**
* Deterministic 2D force layout for the terminal graph view.
*
* The web viewer leans on 3d-force-graph; in the terminal we want something
* dependency-free, fast enough for a few hundred nodes, and — above all —
* deterministic, so the same store draws the same map every time the view
* opens. All jitter is derived from node ids via `seeded()`; nothing here
* calls Math.random.
*
* The store's topology is a star (project → findings/tasks/fragments/refs),
* so the simulation adds a gentle pull toward each node's home project. That
* keeps clusters readable instead of letting repulsion smear everything into
* one ring.
*/
import { seeded } from "../../graph-core/model.js";
/** Ideal edge length in world units. Everything else is scaled off this. */
export const IDEAL_DISTANCE = 12;
/**
* A terminal canvas is far wider than it is tall. A layout that settles into a
* circle therefore strands most of the screen, so the simulation is told the
* canvas aspect and lays the graph out as a matching ellipse: the project ring
* is stretched horizontally and vertical drift is damped by the same factor.
*/
export const DEFAULT_ASPECT = 2.4;
const MAX_ASPECT = 3.5;
export function normalizeAspect(aspect) {
if (!aspect || !Number.isFinite(aspect) || aspect <= 0)
return DEFAULT_ASPECT;
return Math.max(1, Math.min(aspect, MAX_ASPECT));
}
/** Spring strength by edge flavour: spokes hold the clusters, enrichment edges just nudge. */
const LINK_STRENGTH = {
star: 1,
fragment: 0.25,
supersedes: 0.6,
contradicts: 0.4,
};
/**
* How far a project's own nodes sprawl from it, estimated from how many it has.
* The ring is sized from this so clusters end up close to touching; sizing the
* ring by project count alone left the same gap at every scale and the graph
* never filled more than about a seventh of its own bounds.
*/
function clusterRadius(leafCount) {
return IDEAL_DISTANCE * (0.9 + 0.42 * Math.sqrt(Math.max(0, leafCount)));
}
/** Project ids in stable order plus, for every node, the project(s) it hangs off. */
export function homes(nodes, links) {
const byId = new Map(nodes.map((node) => [node.id, node]));
const projects = nodes.filter((node) => node.kind === "project").sort((a, b) => a.id.localeCompare(b.id));
const projectByName = new Map(projects.map((node) => [node.project ?? node.id, node.id]));
const homeOf = new Map();
for (const node of nodes) {
if (node.kind === "project") {
homeOf.set(node.id, [node.id]);
continue;
}
const own = node.project ? projectByName.get(node.project) : undefined;
homeOf.set(node.id, own ? [own] : []);
}
for (const link of links) {
if (link.kind && link.kind !== "star")
continue;
const a = byId.get(link.source);
const b = byId.get(link.target);
if (!a || !b)
continue;
if (a.kind === "project" && b.kind !== "project")
pushUnique(homeOf.get(b.id), a.id);
else if (b.kind === "project" && a.kind !== "project")
pushUnique(homeOf.get(a.id), b.id);
}
return { projects, homeOf };
}
function pushUnique(list, value) {
if (!list.includes(value))
list.push(value);
}
/**
* Starting positions: projects on a ring, leaves in a jittered blob around
* their home project, multi-home fragments at the centroid of their homes,
* orphans on an outer ring.
*/
export function seedPositions(nodes, links, aspect = DEFAULT_ASPECT) {
const positions = new Map();
const ratio = normalizeAspect(aspect);
const { projects, homeOf } = homes(nodes, links);
// Pack the ring so neighbouring clusters nearly touch: the circumference has
// to carry one cluster diameter per project, with a little air between.
const leavesPer = new Map();
for (const [id, hosts] of homeOf) {
if (hosts.length === 0)
continue;
const owner = hosts[0];
if (owner === id)
continue;
leavesPer.set(owner, (leavesPer.get(owner) ?? 0) + 1);
}
// Each project gets a slice of the ring proportional to its cluster, not an
// equal share. Equal slices gave a one-finding project the same forty
// degrees as a fifty-finding one, so small and new projects sat alone in
// empty space at full ring distance while the heavy clusters merged into a
// blob — "most of it in the middle, a few way out", and the camera fitted
// to the few. Small clusters also sit a little inside the ring, so their
// outer edge lines up with their neighbours' rather than their centre.
const radii = projects.map((p) => clusterRadius(leavesPer.get(p.id) ?? 0));
const spread = Math.max(...radii, IDEAL_DISTANCE);
const slices = radii.map((r) => r * 2.3);
const circumference = slices.reduce((sum, w) => sum + w, 0);
const ring = Math.max(IDEAL_DISTANCE * 2.2, circumference / (2 * Math.PI));
// Projects sit at the start of their slice, so equal clusters land exactly
// where the equal-slice ring put them (two projects side by side, not
// stacked, which is the worst case on a wide screen).
let offset = 0;
projects.forEach((project, i) => {
const angle = (offset / Math.max(circumference, 1)) * Math.PI * 2;
offset += slices[i];
const radius = Math.max(IDEAL_DISTANCE * 1.6, ring - (spread - radii[i]) * 0.7);
positions.set(project.id, { x: Math.cos(angle) * radius * ratio, y: Math.sin(angle) * radius });
});
if (projects.length === 1)
positions.set(projects[0].id, { x: 0, y: 0 });
const outer = ring + IDEAL_DISTANCE * 3;
for (const node of nodes) {
if (node.kind === "project")
continue;
const home = (homeOf.get(node.id) ?? []).map((id) => positions.get(id)).filter((p) => Boolean(p));
const angle = seeded(node.id, "angle") * Math.PI * 2;
if (home.length === 0) {
positions.set(node.id, { x: Math.cos(angle) * outer * ratio, y: Math.sin(angle) * outer });
continue;
}
const cx = home.reduce((sum, p) => sum + p.x, 0) / home.length;
const cy = home.reduce((sum, p) => sum + p.y, 0) / home.length;
const radius = home.length > 1 ? IDEAL_DISTANCE * 0.4 : IDEAL_DISTANCE * (0.6 + seeded(node.id, "radius") * 1.2);
positions.set(node.id, { x: cx + Math.cos(angle) * radius, y: cy + Math.sin(angle) * radius });
}
return positions;
}
export class ForceSim {
positions;
ids;
index;
mass;
anchors;
isProject;
links;
temperature;
startTemperature;
aspect;
/** How far apart projects push each other; scaled to the clusters they carry. */
projectCutoff;
/**
* The ring radius each project should sit at, measured in aspect-corrected
* space. Seeding a packed ring is not enough on its own: mutual repulsion
* between many projects expands it again, which is how a forty-project store
* ended up sprawling to nearly twice its seeded size. A radial spring holds
* each project at the radius the packing chose.
*/
targetRadius;
constructor(nodes, links, seed, aspect = DEFAULT_ASPECT) {
this.aspect = normalizeAspect(aspect);
const leaves = nodes.filter((node) => node.kind !== "project").length;
const projectCount = Math.max(1, nodes.length - leaves);
this.projectCutoff = clusterRadius(leaves / projectCount) * 2.4;
this.ids = nodes.map((node) => node.id);
this.index = new Map(this.ids.map((id, i) => [id, i]));
this.mass = nodes.map((node) => (node.kind === "project" ? 4 : 1) * Math.max(0.6, node.size / 12));
this.isProject = nodes.map((node) => node.kind === "project");
const { homeOf } = homes(nodes, links);
this.anchors = nodes.map((node) => (homeOf.get(node.id) ?? []).map((id) => this.index.get(id)).filter((i) => i !== undefined));
this.links = [];
for (const link of links) {
const a = this.index.get(link.source);
const b = this.index.get(link.target);
if (a === undefined || b === undefined || a === b)
continue;
this.links.push({ a, b, strength: LINK_STRENGTH[link.kind ?? "star"] });
}
const seeded = seedPositions(nodes, links, this.aspect);
this.positions = new Map();
for (const id of this.ids)
this.positions.set(id, { ...(seed?.get(id) ?? seeded.get(id)) });
this.targetRadius = this.ids.map((id, i) => {
if (!this.isProject[i])
return 0;
const p = seeded.get(id);
return Math.hypot(p.x / this.aspect, p.y);
});
this.startTemperature = IDEAL_DISTANCE * Math.max(1, Math.sqrt(nodes.length) / 4);
this.temperature = this.startTemperature;
}
/** 1 at the start, 0 when settled; mirrors d3's `alpha` for hosts that animate. */
get alpha() {
return this.startTemperature > 0 ? this.temperature / this.startTemperature : 0;
}
get settled() {
return this.temperature < 0.15;
}
/**
* Reuse a previous layout's positions for nodes that survived a data
* refresh, and reheat only mildly so the map shifts instead of scrambling.
*/
warmStart(previous) {
let reused = 0;
for (const id of this.ids) {
const prev = previous.get(id);
if (!prev)
continue;
this.positions.set(id, { ...prev });
reused++;
}
if (reused > 0)
this.temperature = Math.max(0.15, this.startTemperature * (reused === this.ids.length ? 0.15 : 0.4));
}
/** Reheat to the initial temperature (e.g. the `r` relayout key). */
reheat() {
this.temperature = this.startTemperature;
}
tick(count = 1) {
for (let n = 0; n < count; n++) {
if (this.settled)
return;
this.step();
}
}
/** Run until settled or the tick budget is spent. */
settle(maxTicks = 400) {
for (let n = 0; n < maxTicks && !this.settled; n++)
this.step();
}
step() {
const count = this.ids.length;
if (count === 0) {
this.temperature = 0;
return;
}
const xs = new Float64Array(count);
const ys = new Float64Array(count);
const dx = new Float64Array(count);
const dy = new Float64Array(count);
for (let i = 0; i < count; i++) {
const p = this.positions.get(this.ids[i]);
xs[i] = p.x;
ys[i] = p.y;
}
const k = IDEAL_DISTANCE;
const k2 = k * k;
// Repulsion: k² / d, weighted by mass so projects carve out room.
for (let i = 0; i < count; i++) {
for (let j = i + 1; j < count; j++) {
let ddx = xs[i] - xs[j];
let ddy = ys[i] - ys[j];
let dist2 = ddx * ddx + ddy * ddy;
if (dist2 < 0.01) {
// Coincident nodes: split them deterministically by index.
ddx = ((i * 7919 + j * 104729) % 17) / 17 - 0.5;
ddy = ((i * 15485863 + j * 32452843) % 13) / 13 - 0.5;
dist2 = ddx * ddx + ddy * ddy || 0.01;
}
const dist = Math.sqrt(dist2);
const cutoff = this.isProject[i] || this.isProject[j] ? this.projectCutoff : k * 6;
if (dist > cutoff)
continue;
const force = (k2 / dist) * Math.sqrt(this.mass[i] * this.mass[j]);
const fx = (ddx / dist) * force;
const fy = (ddy / dist) * force;
dx[i] += fx;
dy[i] += fy;
dx[j] -= fx;
dy[j] -= fy;
}
}
// Attraction along links: d² / k, scaled by the edge flavour.
for (const link of this.links) {
const ddx = xs[link.a] - xs[link.b];
const ddy = ys[link.a] - ys[link.b];
const dist = Math.sqrt(ddx * ddx + ddy * ddy) || 0.01;
const force = ((dist * dist) / k) * link.strength;
const fx = (ddx / dist) * force;
const fy = (ddy / dist) * force;
dx[link.a] -= fx;
dy[link.a] -= fy;
dx[link.b] += fx;
dy[link.b] += fy;
}
// Cluster pull toward the home project(s); projects drift gently to the origin.
for (let i = 0; i < count; i++) {
if (this.isProject[i]) {
// Hold the project on its ring. The error is measured in
// aspect-corrected space so the restoring force preserves the wide
// ellipse rather than pulling the layout back into a circle. A soft
// spring on purpose: pinning the radius outright left the clusters no
// room to breathe once the node cap made them larger than the seeded
// ring allowed for, and a forty-project store folded into a fan.
const nx = xs[i] / this.aspect;
const ny = ys[i];
const radius = Math.hypot(nx, ny);
if (radius > 0.01) {
const pull = (this.targetRadius[i] - radius) * 0.18;
dx[i] += (nx / radius) * pull * this.aspect;
dy[i] += (ny / radius) * pull;
}
continue;
}
const anchors = this.anchors[i];
if (!anchors.length)
continue;
let ax = 0;
let ay = 0;
for (const a of anchors) {
ax += xs[a];
ay += ys[a];
}
ax /= anchors.length;
ay /= anchors.length;
dx[i] += (ax - xs[i]) * 0.08;
dy[i] += (ay - ys[i]) * 0.08 * Math.sqrt(this.aspect);
}
// Apply, capped by the current temperature.
const cap = this.temperature;
for (let i = 0; i < count; i++) {
const len = Math.sqrt(dx[i] * dx[i] + dy[i] * dy[i]);
if (len === 0)
continue;
const scale = Math.min(len, cap) / len;
const p = this.positions.get(this.ids[i]);
p.x = xs[i] + dx[i] * scale;
p.y = ys[i] + dy[i] * scale;
}
this.temperature *= 0.94;
}
}
/** Axis-aligned bounds of a position set; null when empty. */
export function bounds(positions) {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const p of positions) {
if (p.x < minX)
minX = p.x;
if (p.y < minY)
minY = p.y;
if (p.x > maxX)
maxX = p.x;
if (p.y > maxY)
maxY = p.y;
}
return Number.isFinite(minX) ? { minX, minY, maxX, maxY } : null;
}