@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
139 lines (138 loc) • 6.04 kB
JavaScript
/**
* The graph in three dimensions.
*
* Not a second layout: the settled 2D map is lifted into a sphere. Projects
* sit on a fibonacci sphere in stable order, each cluster keeps the shape the
* force layout gave it and gains a seeded depth, fragments shared between
* projects fall to the middle where their homes' centroid is, and orphans go
* to an outer shell. Then a yaw/pitch camera with a little perspective
* projects it, and depth comes back as brightness and size.
*
* Also here: the SGR mouse protocol, because drag-to-orbit is the whole
* point. Pure functions throughout; the controller owns the state.
*/
import { homes } from "./layout.js";
export const DEFAULT_ORBIT = { yaw: 0, pitch: 0.32, zoom: 1 };
export const PITCH_LIMIT = 1.25;
function seeded(id, salt) {
let h = 2166136261;
for (const ch of `${id}:${salt}`) {
h ^= ch.charCodeAt(0);
h = Math.imul(h, 16777619);
}
return ((h >>> 0) % 10000) / 10000;
}
/** Lift the 2D layout onto a sphere. Deterministic for the same inputs. */
export function buildOrbitLayout(nodes, links, flat) {
const { projects, homeOf } = homes(nodes, links);
const positions = new Map();
const n = projects.length;
// How far each cluster reaches in the flat layout sets both its depth and
// how big the sphere has to be for neighbours not to run into each other.
const reach = new Map();
for (const node of nodes) {
if (node.kind === "project")
continue;
const hosts = homeOf.get(node.id) ?? [];
if (hosts.length !== 1)
continue;
const home = flat.get(hosts[0]);
const p = flat.get(node.id);
if (!home || !p)
continue;
reach.set(hosts[0], Math.max(reach.get(hosts[0]) ?? 0, Math.hypot(p.x - home.x, p.y - home.y)));
}
const maxReach = Math.max(12, ...reach.values());
// Enough surface for every cluster: the sphere's area carries n discs.
const radius = Math.max(maxReach * 1.6, Math.sqrt(n) * maxReach * 0.95);
const golden = Math.PI * (3 - Math.sqrt(5));
projects.forEach((project, i) => {
const y = n === 1 ? 0 : 1 - (i / (n - 1)) * 2;
const r = Math.sqrt(Math.max(0, 1 - y * y));
const a = golden * i;
positions.set(project.id, { x: Math.cos(a) * r * radius, y: y * radius * 0.82, z: Math.sin(a) * r * radius });
});
for (const node of nodes) {
if (node.kind === "project")
continue;
const hosts = (homeOf.get(node.id) ?? []).map((id) => positions.get(id)).filter((p) => Boolean(p));
if (hosts.length === 0) {
const u = seeded(node.id, "u") * Math.PI * 2;
const v = Math.acos(2 * seeded(node.id, "v") - 1);
const rr = radius * 1.28;
positions.set(node.id, { x: Math.sin(v) * Math.cos(u) * rr, y: Math.cos(v) * rr * 0.82, z: Math.sin(v) * Math.sin(u) * rr });
continue;
}
if (hosts.length > 1) {
// Shared: the centroid of its homes, which lies inside the sphere.
const c = hosts.reduce((s, p) => ({ x: s.x + p.x, y: s.y + p.y, z: s.z + p.z }), { x: 0, y: 0, z: 0 });
positions.set(node.id, { x: (c.x / hosts.length) * 0.55, y: (c.y / hosts.length) * 0.55, z: (c.z / hosts.length) * 0.55 });
continue;
}
const homeId = (homeOf.get(node.id) ?? [])[0];
const home2 = flat.get(homeId);
const p2 = flat.get(node.id);
const home3 = hosts[0];
const dx = home2 && p2 ? p2.x - home2.x : 0;
const dy = home2 && p2 ? p2.y - home2.y : 0;
const depth = (seeded(node.id, "z") * 2 - 1) * (reach.get(homeId) ?? maxReach) * 0.7;
positions.set(node.id, { x: home3.x + dx * 0.85, y: home3.y + dy * 0.85, z: home3.z + depth });
}
return { positions, radius };
}
/** World point → dot coordinates, with depth. */
export function projectOrbit(v, cam, viewport, radius) {
const cy = Math.cos(cam.yaw);
const sy = Math.sin(cam.yaw);
const x1 = v.x * cy - v.z * sy;
const z1 = v.x * sy + v.z * cy;
const cp = Math.cos(cam.pitch);
const sp = Math.sin(cam.pitch);
const y2 = v.y * cp - z1 * sp;
const z2 = v.y * sp + z1 * cp;
// z2 toward the viewer. Depth 0 is the near pole of the outer shell.
const span = radius * 1.4;
const t = Math.max(0, Math.min(1, (span - z2) / (2 * span)));
const focal = radius * 4.5;
const f = focal / (focal + (span - z2));
const sx = ((viewport.width * 0.5) / radius) * cam.zoom;
const sYy = ((viewport.height * 0.5) / radius) * cam.zoom;
return { x: viewport.width / 2 + x1 * f * sx, y: viewport.height / 2 + y2 * f * sYy, t };
}
/** The yaw that brings a point to the front, facing the viewer. */
export function yawToFace(v) {
// After the yaw rotation z' = x·sin(yaw) + z·cos(yaw), which peaks at atan2(x, z).
return Math.atan2(v.x, v.z);
}
/** Shortest signed distance from yaw a to yaw b. */
export function yawDelta(a, b) {
let d = (b - a) % (Math.PI * 2);
if (d > Math.PI)
d -= Math.PI * 2;
if (d < -Math.PI)
d += Math.PI * 2;
return d;
}
/**
* SGR mouse reporting (mode 1006): `ESC [ < b ; x ; y M` for a press or
* motion, `m` for a release. Bit 5 of b marks motion, bit 6 the wheel.
* Coordinates are 1-based; returned 0-based.
*/
export function parseMouse(key) {
const m = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/.exec(key);
if (!m)
return null;
const b = Number(m[1]);
const col = Number(m[2]) - 1;
const row = Number(m[3]) - 1;
if (b & 64)
return { type: "wheel", delta: (b & 1) === 0 ? -1 : 1, col, row };
const button = b & 3;
if (m[4] === "m")
return { type: "release", button, col, row };
if (b & 32)
return { type: "drag", button, col, row };
return { type: "press", button, col, row };
}
export const MOUSE_ON = "\x1b[?1000h\x1b[?1002h\x1b[?1006h";
export const MOUSE_OFF = "\x1b[?1006l\x1b[?1002l\x1b[?1000l";