mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 15:03:57 +00:00
322 lines
9.9 KiB
TypeScript
322 lines
9.9 KiB
TypeScript
/**
|
|
* WebGL memory-graph renderer — Pixi.js draw loop driven by a d3-force
|
|
* simulation. This is the same stack Obsidian's graph view uses (Pixi for
|
|
* GPU rendering, force-directed physics) so it stays smooth well past the
|
|
* 1000-node cap.
|
|
*
|
|
* The renderer is fully imperative: a React wrapper mounts it into a host
|
|
* element and feeds hover/open back through callbacks. All interaction
|
|
* (drag a node, drag the background to pan, wheel to zoom) is hit-tested
|
|
* against the simulation positions in `memoryGraphLayout`, so there are no
|
|
* per-node DOM objects — the whole graph is a single canvas.
|
|
*
|
|
* Drawing is dirty-flagged: while the simulation is warm (or the user is
|
|
* interacting) we redraw each frame; once it cools the loop idles.
|
|
*/
|
|
import { Application, Container, type FederatedPointerEvent, Graphics } from 'pixi.js';
|
|
|
|
import {
|
|
createSimulation,
|
|
nodeColor,
|
|
nodeGlows,
|
|
nodeRadius,
|
|
pickNode,
|
|
type SimLink,
|
|
type SimNode,
|
|
ZOOM_MAX,
|
|
ZOOM_MIN,
|
|
} from './memoryGraphLayout';
|
|
|
|
export interface PixiGraphOptions {
|
|
simNodes: SimNode[];
|
|
links: SimLink[];
|
|
dark: boolean;
|
|
onHover: (node: SimNode | null) => void;
|
|
onOpen: (node: SimNode) => void;
|
|
}
|
|
|
|
export interface PixiGraphHandle {
|
|
resetView(): void;
|
|
setTheme(dark: boolean): void;
|
|
updateGraph(simNodes: SimNode[], links: SimLink[]): void;
|
|
destroy(): void;
|
|
}
|
|
|
|
function colorNum(hex: string): number {
|
|
return parseInt(hex.replace('#', ''), 16);
|
|
}
|
|
|
|
/**
|
|
* Mount a Pixi graph into `host`. Resolves once the WebGL context is live;
|
|
* rejects/throws if Pixi can't initialise (caller falls back to SVG).
|
|
*/
|
|
export async function mountPixiGraph(
|
|
host: HTMLElement,
|
|
opts: PixiGraphOptions
|
|
): Promise<PixiGraphHandle> {
|
|
const app = new Application();
|
|
await app.init({
|
|
resizeTo: host,
|
|
backgroundAlpha: 0,
|
|
antialias: true,
|
|
autoDensity: true,
|
|
resolution: typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1,
|
|
// Match Obsidian — force the WebGL backend rather than letting Pixi
|
|
// probe WebGPU, which is uneven across the CEF runtime.
|
|
preference: 'webgl',
|
|
});
|
|
host.appendChild(app.canvas);
|
|
app.canvas.style.width = '100%';
|
|
app.canvas.style.height = '100%';
|
|
app.canvas.style.display = 'block';
|
|
|
|
const world = new Container();
|
|
const edgeG = new Graphics();
|
|
const nodeG = new Graphics();
|
|
world.addChild(edgeG);
|
|
world.addChild(nodeG);
|
|
app.stage.addChild(world);
|
|
|
|
const recenter = () => world.position.set(app.screen.width / 2, app.screen.height / 2);
|
|
recenter();
|
|
|
|
const sim = createSimulation(opts.simNodes, opts.links);
|
|
sim.alpha(1);
|
|
|
|
let dark = opts.dark;
|
|
let dirty = true;
|
|
let hoveredId: string | null = null;
|
|
// Auto-fit the whole graph into view until the user pans/zooms/drags,
|
|
// so the initial frame is zoomed out to show as much as possible.
|
|
let userInteracted = false;
|
|
|
|
/** Scale + centre the world so every node's disc fits the viewport. */
|
|
const fitToView = () => {
|
|
if (opts.simNodes.length === 0) return;
|
|
let minX = Infinity;
|
|
let minY = Infinity;
|
|
let maxX = -Infinity;
|
|
let maxY = -Infinity;
|
|
for (const n of opts.simNodes) {
|
|
const r = nodeRadius(n) + 6;
|
|
if (n.x - r < minX) minX = n.x - r;
|
|
if (n.y - r < minY) minY = n.y - r;
|
|
if (n.x + r > maxX) maxX = n.x + r;
|
|
if (n.y + r > maxY) maxY = n.y + r;
|
|
}
|
|
if (!Number.isFinite(minX)) return;
|
|
const pad = 48;
|
|
const w = Math.max(1, maxX - minX);
|
|
const h = Math.max(1, maxY - minY);
|
|
const scale = Math.min(
|
|
ZOOM_MAX,
|
|
Math.max(ZOOM_MIN, Math.min((app.screen.width - pad) / w, (app.screen.height - pad) / h))
|
|
);
|
|
world.scale.set(scale);
|
|
const cx = (minX + maxX) / 2;
|
|
const cy = (minY + maxY) / 2;
|
|
world.position.set(app.screen.width / 2 - cx * scale, app.screen.height / 2 - cy * scale);
|
|
};
|
|
|
|
const draw = () => {
|
|
edgeG.clear();
|
|
for (const l of opts.links) {
|
|
const s = l.source as SimNode;
|
|
const t = l.target as SimNode;
|
|
if (!s || !t || typeof s.x !== 'number' || typeof t.x !== 'number') continue;
|
|
edgeG.moveTo(s.x, s.y);
|
|
edgeG.lineTo(t.x, t.y);
|
|
}
|
|
edgeG.stroke({ width: 0.8, color: dark ? 0x475569 : 0xcbd5e1, alpha: 0.7 });
|
|
|
|
nodeG.clear();
|
|
// Halos first so the structural levels "light up" beneath the discs.
|
|
for (const n of opts.simNodes) {
|
|
if (!nodeGlows(n)) continue;
|
|
nodeG
|
|
.circle(n.x, n.y, nodeRadius(n) + 5)
|
|
.fill({ color: colorNum(nodeColor(n)), alpha: 0.18 });
|
|
}
|
|
for (const n of opts.simNodes) {
|
|
const hover = n.id === hoveredId;
|
|
const r = nodeRadius(n) + (hover ? 2 : 0);
|
|
nodeG.circle(n.x, n.y, r).fill({ color: colorNum(nodeColor(n)), alpha: 1 });
|
|
if (hover) nodeG.circle(n.x, n.y, r).stroke({ width: 1.4, color: 0x0f172a, alpha: 0.9 });
|
|
}
|
|
};
|
|
|
|
app.ticker.add(() => {
|
|
let changed = dirty;
|
|
if (sim.alpha() > sim.alphaMin()) {
|
|
sim.tick();
|
|
changed = true;
|
|
}
|
|
if (changed) {
|
|
// Keep the whole graph framed while it settles, until the user
|
|
// takes over the camera.
|
|
if (!userInteracted) fitToView();
|
|
draw();
|
|
dirty = false;
|
|
}
|
|
});
|
|
|
|
// ── interaction ────────────────────────────────────────────────────
|
|
app.stage.eventMode = 'static';
|
|
app.stage.hitArea = app.screen;
|
|
let drag:
|
|
| { node: SimNode; moved: boolean }
|
|
| { panX: number; panY: number; px: number; py: number; moved: boolean }
|
|
| null = null;
|
|
|
|
const setCursor = (c: string) => {
|
|
app.canvas.style.cursor = c;
|
|
};
|
|
|
|
app.stage.on('pointerdown', (e: FederatedPointerEvent) => {
|
|
userInteracted = true; // hand the camera to the user
|
|
const p = world.toLocal(e.global);
|
|
const node = pickNode(opts.simNodes, p.x, p.y);
|
|
if (node) {
|
|
sim.alpha(0.3);
|
|
node.fx = node.x;
|
|
node.fy = node.y;
|
|
drag = { node, moved: false };
|
|
setCursor('grabbing');
|
|
} else {
|
|
drag = {
|
|
panX: world.position.x,
|
|
panY: world.position.y,
|
|
px: e.global.x,
|
|
py: e.global.y,
|
|
moved: false,
|
|
};
|
|
setCursor('grabbing');
|
|
}
|
|
});
|
|
|
|
app.stage.on('pointermove', (e: FederatedPointerEvent) => {
|
|
if (drag) {
|
|
if ('node' in drag) {
|
|
const p = world.toLocal(e.global);
|
|
drag.node.fx = p.x;
|
|
drag.node.fy = p.y;
|
|
drag.moved = true;
|
|
if (sim.alpha() < 0.1) sim.alpha(0.1);
|
|
} else {
|
|
world.position.set(drag.panX + (e.global.x - drag.px), drag.panY + (e.global.y - drag.py));
|
|
drag.moved = true;
|
|
}
|
|
dirty = true;
|
|
return;
|
|
}
|
|
const p = world.toLocal(e.global);
|
|
const node = pickNode(opts.simNodes, p.x, p.y);
|
|
const id = node ? node.id : null;
|
|
setCursor(node ? 'pointer' : 'grab');
|
|
if (id !== hoveredId) {
|
|
hoveredId = id;
|
|
dirty = true;
|
|
opts.onHover(node ?? null);
|
|
}
|
|
});
|
|
|
|
const endDrag = (open: boolean) => {
|
|
const d = drag;
|
|
if (d && 'node' in d) {
|
|
// Release the pin so physics resumes for that node.
|
|
d.node.fx = null;
|
|
d.node.fy = null;
|
|
if (open && !d.moved) opts.onOpen(d.node);
|
|
}
|
|
drag = null;
|
|
setCursor('grab');
|
|
};
|
|
app.stage.on('pointerup', () => endDrag(true));
|
|
app.stage.on('pointerupoutside', () => endDrag(false));
|
|
|
|
const onWheel = (e: WheelEvent) => {
|
|
e.preventDefault();
|
|
userInteracted = true;
|
|
const gx = e.offsetX;
|
|
const gy = e.offsetY;
|
|
// Graph point under the cursor, kept fixed across the zoom.
|
|
const lx = (gx - world.position.x) / world.scale.x;
|
|
const ly = (gy - world.position.y) / world.scale.y;
|
|
const next = Math.min(
|
|
ZOOM_MAX,
|
|
Math.max(ZOOM_MIN, world.scale.x * Math.exp(-e.deltaY * 0.0015))
|
|
);
|
|
world.scale.set(next);
|
|
world.position.set(gx - lx * next, gy - ly * next);
|
|
dirty = true;
|
|
};
|
|
app.canvas.addEventListener('wheel', onWheel, { passive: false });
|
|
|
|
app.renderer.on('resize', () => {
|
|
dirty = true;
|
|
});
|
|
|
|
return {
|
|
resetView() {
|
|
userInteracted = false;
|
|
sim.alpha(0.3);
|
|
dirty = true;
|
|
},
|
|
setTheme(next: boolean) {
|
|
dark = next;
|
|
dirty = true;
|
|
},
|
|
updateGraph(nextNodes: SimNode[], nextLinks: SimLink[]) {
|
|
const oldById = new Map(opts.simNodes.map(n => [n.id, n]));
|
|
|
|
for (const n of nextNodes) {
|
|
const old = oldById.get(n.id);
|
|
if (old) {
|
|
n.x = old.x;
|
|
n.y = old.y;
|
|
n.vx = old.vx ?? 0;
|
|
n.vy = old.vy ?? 0;
|
|
n.fx = old.fx ?? undefined;
|
|
n.fy = old.fy ?? undefined;
|
|
} else {
|
|
// New node — seed near its parent or at a small random offset
|
|
// from the centroid so it animates into place.
|
|
const parentLink = nextLinks.find(
|
|
l => (typeof l.source === 'string' ? l.source : (l.source as SimNode).id) === n.id
|
|
);
|
|
const parentId =
|
|
parentLink &&
|
|
(typeof parentLink.target === 'string'
|
|
? parentLink.target
|
|
: (parentLink.target as SimNode).id);
|
|
const parent = parentId ? oldById.get(parentId) : undefined;
|
|
if (parent) {
|
|
n.x = parent.x + (Math.random() - 0.5) * 40;
|
|
n.y = parent.y + (Math.random() - 0.5) * 40;
|
|
} else {
|
|
n.x = (Math.random() - 0.5) * 100;
|
|
n.y = (Math.random() - 0.5) * 100;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Hot-swap the simulation's node and link arrays.
|
|
opts.simNodes = nextNodes;
|
|
opts.links = nextLinks;
|
|
sim.nodes(nextNodes);
|
|
const linkForce = sim.force('link') as ReturnType<typeof import('d3-force').forceLink>;
|
|
if (linkForce && typeof linkForce.links === 'function') {
|
|
linkForce.links(nextLinks);
|
|
}
|
|
// Gentle reheat so new nodes settle without disrupting existing ones.
|
|
sim.alpha(0.3);
|
|
dirty = true;
|
|
},
|
|
destroy() {
|
|
sim.stop();
|
|
app.canvas.removeEventListener('wheel', onWheel);
|
|
app.destroy(true, { children: true });
|
|
},
|
|
};
|
|
}
|