Files
openhuman/app/src/components/intelligence/memoryGraphLayout.ts
T

225 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Shared, render-agnostic layout + palette helpers for the memory graph.
*
* Physics is d3-force (BarnesHut quadtree charge, O(n log n)) so the
* 1000-node cap settles smoothly — the same model Obsidian's graph is
* built on. Both the WebGL (Pixi) renderer and the SVG fallback consume
* these helpers so colours, radii, edge derivation and hit-testing stay
* identical across paths.
*/
import {
forceCenter,
forceCollide,
forceLink,
forceManyBody,
forceSimulation,
type Simulation,
type SimulationLinkDatum,
type SimulationNodeDatum,
} from 'd3-force';
import { type GraphEdge, type GraphMode, type GraphNode } from '../../utils/tauriCommands';
/**
* Per-level palette — each tree level "lights up" in its own hue
* (mirrors the Obsidian `path:L{n}` colour groups).
*/
export const LEVEL_COLOR = [
'#7C3AED', // L0
'#4A83DD', // L1
'#1FB6C7', // L2
'#34C77B', // L3
'#E8A653', // L4
'#E0654A', // L5
'#C026D3', // L6+
];
export const LEAF_COLOR = '#94A3B8'; // raw chunks / leaves (no level)
export const CONTACT_COLOR = '#A78BFA'; // person entities (contacts mode)
export const SOURCE_COLOR = '#F97316'; // synthetic source root nodes
export const ROOT_COLOR = '#8B5CF6'; // master root hub (purple)
/** Layout is computed in this fixed coordinate space; the renderer pans/zooms it. */
export const VIEWPORT_W = 1100;
export const VIEWPORT_H = 640;
// Lower bound shared by auto-fit framing and manual wheel zoom-out. Kept very
// small (20× zoom-out) so large clouds — e.g. a Notion connection's hundreds
// of page-chunk leaves — can be framed in full. At 0.3 the auto-fit was
// clamped above the scale needed to show every node, so big graphs rendered
// "too zoomed in" with the outer nodes spilling off-screen. Using one shared
// floor (rather than a separate, lower auto-fit floor) avoids a zoom-snap
// where the first wheel tick would jump back up to the manual floor.
export const ZOOM_MIN = 0.05;
export const ZOOM_MAX = 4;
export function levelColor(level: number | null | undefined): string {
if (level == null) return LEAF_COLOR;
return LEVEL_COLOR[Math.max(0, level) % LEVEL_COLOR.length];
}
export function nodeColor(node: GraphNode): string {
if (node.kind === 'root') return ROOT_COLOR;
if (node.kind === 'source') return SOURCE_COLOR;
if (node.kind === 'summary') return levelColor(node.level);
if (node.kind === 'contact') return CONTACT_COLOR;
return LEAF_COLOR; // chunk
}
export function nodeRadius(node: GraphNode): number {
if (node.kind === 'root') return 20;
if (node.kind === 'source') return 16;
if (node.kind === 'summary') {
// Higher levels render slightly larger, but the size MUST be capped:
// document source trees place their cross-document merge tier at a large
// synthetic level (MERGE_LEVEL_BASE = 1000+), so the raw `level * 2.5`
// would explode to thousands of px — rendering giant discs and, via the
// `forceCollide(nodeRadius + 2)` term, blowing the whole layout apart.
// The cap keeps merge nodes the largest summaries without distorting it.
const level = node.level ?? 0;
return Math.min(5 + level * 2.5, 14);
}
if (node.kind === 'contact') return 9;
return 3; // chunk / document leaf
}
/** Source / summary / contact nodes glow; leaves stay flat so the structure pops. */
export function nodeGlows(node: GraphNode): boolean {
return node.kind !== 'chunk';
}
/** A graph node carrying mutable physics state (x/y/vx/vy populated by d3-force). */
export interface SimNode extends GraphNode, SimulationNodeDatum {
x: number;
y: number;
}
export type SimLink = SimulationLinkDatum<SimNode>;
/**
* Seed node positions on a ring centred on the origin and derive links.
* Tree mode draws an edge from each node to its `parent_id`; contacts mode
* uses the explicit `edges`. Dangling endpoints are dropped.
*/
export const ROOT_NODE_ID = '__root__';
export function buildGraph(
nodes: GraphNode[],
edges: GraphEdge[],
mode: GraphMode
): { simNodes: SimNode[]; links: SimLink[] } {
const ids = new Set(nodes.map(n => n.id));
// Synthetic master root at the origin — all source nodes fan out from it.
const rootNode: SimNode = { kind: 'root', id: ROOT_NODE_ID, label: 'Memory', x: 0, y: 0 };
const simNodes: SimNode[] = [rootNode];
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i];
const angle = (i / Math.max(1, nodes.length)) * Math.PI * 2;
const r = 180 + (i % 7) * 14;
simNodes.push({ ...n, x: Math.cos(angle) * r, y: Math.sin(angle) * r });
}
const links: SimLink[] = [];
// Link every source node to the master root.
for (const n of nodes) {
if (n.kind === 'source') {
links.push({ source: n.id, target: ROOT_NODE_ID });
}
}
if (mode === 'tree') {
for (const n of nodes) {
if (!n.parent_id || !ids.has(n.parent_id) || !ids.has(n.id)) continue;
links.push({ source: n.id, target: n.parent_id });
}
} else {
for (const e of edges) {
if (!ids.has(e.from) || !ids.has(e.to)) continue;
links.push({ source: e.from, target: e.to });
}
}
return { simNodes, links };
}
/**
* A cooled d3-force simulation (call `.tick()` from the render loop). Charge
* = Coulomb repulsion (BarnesHut), link = Hooke spring, plus centring and
* a soft collide so nodes don't stack.
*/
export function createSimulation(
simNodes: SimNode[],
links: SimLink[]
): Simulation<SimNode, SimLink> {
return forceSimulation(simNodes)
.force(
'charge',
forceManyBody<SimNode>()
.strength(n => {
if (n.kind === 'root') return -650;
if (n.kind === 'source') return -280;
return -140;
})
.distanceMax(300)
)
.force(
'link',
forceLink<SimNode, SimLink>(links)
.id(d => d.id)
.distance(link => {
const src = link.source as SimNode;
const tgt = link.target as SimNode;
if (src.kind === 'root' || tgt.kind === 'root') return 90;
if (src.kind === 'source' || tgt.kind === 'source') return 40;
return 22;
})
.strength(0.7)
)
.force('center', forceCenter(0, 0).strength(0.12))
.force(
'collide',
forceCollide<SimNode>().radius(n => {
if (n.kind === 'root') return 80;
if (n.kind === 'source') return 40;
return nodeRadius(n) + 2;
})
)
.stop();
}
/**
* Nearest node whose disc (radius + slop) contains the point, or null.
* Linear scan — trivial at the 1000-node cap and only runs on pointer
* events, never per frame.
*/
export function pickNode(simNodes: SimNode[], x: number, y: number, slop = 4): SimNode | null {
let best: SimNode | null = null;
let bestD = Infinity;
for (const n of simNodes) {
const r = nodeRadius(n) + slop;
const dx = n.x - x;
const dy = n.y - y;
const d = dx * dx + dy * dy;
if (d <= r * r && d < bestD) {
bestD = d;
best = n;
}
}
return best;
}
/** Does the renderer have a usable WebGL context? Drives Pixi-vs-SVG. */
export function supportsWebGL(): boolean {
if (typeof document === 'undefined') return false;
try {
const canvas = document.createElement('canvas');
return !!(
canvas.getContext('webgl2') ||
canvas.getContext('webgl') ||
canvas.getContext('experimental-webgl')
);
} catch {
return false;
}
}