mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-12 09:47:20 +08:00
Render the miss graph side by side with the code graph instead of
swapping layouts:
- /api/layout (code graph) now also attaches "missed_graph": {nodes,
edges, offset} — the shadow-project layout placed below the primary
cluster (same satellite pattern as linked_projects; -Y slot so
cross-repo satellites collide last). graph=missed stays the isolated
view for API users
- the UI paints the skeleton white and ghostly beside the galaxy,
auto-frames BOTH clusters on load, and navigates naturally: clicking
the skeleton flies the camera into it (file labels + detail panel),
clicking a code node flies back to the code side, and clicking empty
space while the skeleton has focus returns to the overview (the
galaxy can be entirely off-screen at that point)
- the sidebar toggle now shows/hides the skeleton (with a missed-file
count) rather than swapping the layout
Verified in the browser: overview composition, skeleton focus with
split.c labeled, and the empty-space return flight; ui/httpd suites and
frontend build + tests green.
Refs #963
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
333 lines
9.9 KiB
TypeScript
333 lines
9.9 KiB
TypeScript
import { useState, useRef, useEffect, useCallback } from "react";
|
||
import { Canvas, useThree, useFrame } from "@react-three/fiber";
|
||
import { OrbitControls } from "@react-three/drei";
|
||
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
|
||
import { EffectComposer, Bloom } from "@react-three/postprocessing";
|
||
import * as THREE from "three";
|
||
import { NodeCloud } from "./NodeCloud";
|
||
import { EdgeLines } from "./EdgeLines";
|
||
import { NodeLabels } from "./NodeLabels";
|
||
import { NodeTooltip } from "./NodeTooltip";
|
||
import type { GraphData, GraphNode, LinkedProject } from "../lib/types";
|
||
import {
|
||
DEFAULT_DISPLAY_SETTINGS,
|
||
bloomIntensityScale,
|
||
nodeBoostScale,
|
||
type DisplaySettings,
|
||
} from "../lib/density";
|
||
|
||
const BASE_BLOOM_INTENSITY = 1.45;
|
||
|
||
/* ── Camera fly-to animation ────────────────────────────── */
|
||
|
||
interface CameraTarget {
|
||
position: THREE.Vector3;
|
||
lookAt: THREE.Vector3;
|
||
}
|
||
|
||
function CameraAnimator({
|
||
target,
|
||
controlsRef,
|
||
}: {
|
||
target: CameraTarget | null;
|
||
controlsRef: React.RefObject<OrbitControlsImpl | null>;
|
||
}) {
|
||
const { camera } = useThree();
|
||
const targetRef = useRef<CameraTarget | null>(null);
|
||
const progress = useRef(1);
|
||
|
||
useEffect(() => {
|
||
if (target) {
|
||
targetRef.current = target;
|
||
progress.current = 0;
|
||
}
|
||
}, [target]);
|
||
|
||
useFrame(() => {
|
||
if (!targetRef.current || progress.current >= 1) return;
|
||
|
||
progress.current = Math.min(1, progress.current + 0.02);
|
||
const t = 1 - Math.pow(1 - progress.current, 3); /* ease-out cubic */
|
||
|
||
camera.position.lerp(targetRef.current.position, t * 0.08);
|
||
|
||
/* Move the OrbitControls pivot to the focus point as well. Otherwise the
|
||
* controls keep their target at the origin and re-center the view on the
|
||
* next frame, snapping the camera back to the middle after the fly-to. */
|
||
const controls = controlsRef.current;
|
||
if (controls) {
|
||
controls.target.lerp(targetRef.current.lookAt, t * 0.08);
|
||
controls.update();
|
||
} else {
|
||
camera.lookAt(targetRef.current.lookAt);
|
||
}
|
||
});
|
||
|
||
return null;
|
||
}
|
||
|
||
/* ── Idle auto-rotation ──────────────────────────────────── */
|
||
|
||
const IDLE_TIMEOUT_MS = 60_000;
|
||
export const GRAPH_CANVAS_DPR: [number, number] = [1, 1.5];
|
||
export const GRAPH_COMPOSER_MULTISAMPLING = 0;
|
||
|
||
function IdleAutoRotate({
|
||
controlsRef,
|
||
}: {
|
||
controlsRef: React.RefObject<OrbitControlsImpl | null>;
|
||
}) {
|
||
const lastInteraction = useRef(Date.now());
|
||
|
||
/* Reset timer on any pointer/wheel event */
|
||
const resetTimer = useCallback(() => {
|
||
lastInteraction.current = Date.now();
|
||
if (controlsRef.current) {
|
||
controlsRef.current.autoRotate = false;
|
||
}
|
||
}, [controlsRef]);
|
||
|
||
useEffect(() => {
|
||
const canvas = document.querySelector("canvas");
|
||
if (!canvas) return;
|
||
|
||
canvas.addEventListener("pointerdown", resetTimer);
|
||
canvas.addEventListener("wheel", resetTimer);
|
||
return () => {
|
||
canvas.removeEventListener("pointerdown", resetTimer);
|
||
canvas.removeEventListener("wheel", resetTimer);
|
||
};
|
||
}, [resetTimer]);
|
||
|
||
useFrame(() => {
|
||
if (!controlsRef.current) return;
|
||
const idle = Date.now() - lastInteraction.current > IDLE_TIMEOUT_MS;
|
||
controlsRef.current.autoRotate = idle;
|
||
});
|
||
|
||
return null;
|
||
}
|
||
|
||
/* ── Main scene ─────────────────────────────────────────── */
|
||
|
||
interface GraphSceneProps {
|
||
data: GraphData;
|
||
/* Missed skeleton (#963): pre-offset, pre-painted white nodes + edges of
|
||
* the not-fully-indexed files, rendered as a ghost cluster beside the
|
||
* galaxy. null hides it. */
|
||
missed?: { nodes: GraphNode[]; edges: GraphData["edges"] } | null;
|
||
highlightedIds: Set<number> | null;
|
||
cameraTarget: CameraTarget | null;
|
||
showLabels: boolean;
|
||
display?: DisplaySettings;
|
||
onNodeClick: (node: GraphNode) => void;
|
||
/* Fired when a click hits empty space (no node). Used to fly back to the
|
||
* overview after focusing the missed skeleton. */
|
||
onBackgroundClick?: () => void;
|
||
}
|
||
|
||
export type { CameraTarget };
|
||
|
||
export function GraphScene({
|
||
data,
|
||
missed = null,
|
||
highlightedIds,
|
||
cameraTarget,
|
||
showLabels,
|
||
display = DEFAULT_DISPLAY_SETTINGS,
|
||
onNodeClick,
|
||
onBackgroundClick,
|
||
}: GraphSceneProps) {
|
||
const [hovered, setHovered] = useState<GraphNode | null>(null);
|
||
const controlsRef = useRef<OrbitControlsImpl | null>(null);
|
||
|
||
/* Adaptive density defaults × user multipliers. The automatic scale keeps
|
||
* contrast roughly constant as the graph grows; the sliders nudge it.
|
||
* NodeCloud applies `nodeBoost` directly (no internal density scaling),
|
||
* whereas EdgeLines scales by edge density itself — so it receives only the
|
||
* user edge-brightness multiplier to avoid double-applying. */
|
||
const nodeBoost = nodeBoostScale(data.nodes.length) * display.nodeGlow;
|
||
const bloomIntensity =
|
||
BASE_BLOOM_INTENSITY * bloomIntensityScale(data.nodes.length) * display.bloom;
|
||
|
||
return (
|
||
<Canvas
|
||
camera={{ position: [0, 0, 800], fov: 50, near: 0.1, far: 100000 }}
|
||
style={{ background: "#06090f" }}
|
||
dpr={GRAPH_CANVAS_DPR}
|
||
gl={{
|
||
antialias: false,
|
||
alpha: false,
|
||
powerPreference: "high-performance",
|
||
}}
|
||
onPointerMissed={onBackgroundClick}
|
||
>
|
||
<color attach="background" args={["#06090f"]} />
|
||
<ambientLight intensity={0.5} />
|
||
<pointLight position={[500, 500, 500]} intensity={0.6} />
|
||
<pointLight
|
||
position={[-300, -200, -300]}
|
||
intensity={0.4}
|
||
color="#6040ff"
|
||
/>
|
||
|
||
<EdgeLines
|
||
nodes={data.nodes}
|
||
edges={data.edges}
|
||
highlightedIds={highlightedIds}
|
||
brightness={display.edgeBrightness}
|
||
/>
|
||
<NodeCloud
|
||
nodes={data.nodes}
|
||
highlightedIds={highlightedIds}
|
||
onHover={setHovered}
|
||
onClick={onNodeClick}
|
||
boost={nodeBoost}
|
||
/>
|
||
{showLabels && <NodeLabels nodes={data.nodes} highlightedIds={highlightedIds} />}
|
||
|
||
{/* Missed skeleton (#963): white ghost of the not-fully-indexed files.
|
||
* Clicks route through the same handler — GraphTab re-centers the
|
||
* camera on the whole skeleton cluster. */}
|
||
{missed && missed.nodes.length > 0 && (
|
||
<group>
|
||
<EdgeLines
|
||
nodes={missed.nodes}
|
||
edges={missed.edges}
|
||
highlightedIds={null}
|
||
opacity={0.28}
|
||
brightness={display.edgeBrightness}
|
||
/>
|
||
<NodeCloud
|
||
nodes={missed.nodes}
|
||
highlightedIds={null}
|
||
onHover={setHovered}
|
||
onClick={onNodeClick}
|
||
opacity={0.6}
|
||
boost={nodeBoost * 0.75}
|
||
/>
|
||
{showLabels && <NodeLabels nodes={missed.nodes} highlightedIds={null} />}
|
||
</group>
|
||
)}
|
||
|
||
{/* Satellite galaxies for cross-repo linked projects */}
|
||
{data.linked_projects?.map((lp: LinkedProject) => {
|
||
const offsetNodes = lp.nodes.map((n) => ({
|
||
...n,
|
||
x: n.x + lp.offset.x,
|
||
y: n.y + lp.offset.y,
|
||
z: n.z + lp.offset.z,
|
||
}));
|
||
return (
|
||
<group key={lp.project}>
|
||
<EdgeLines
|
||
nodes={offsetNodes}
|
||
edges={lp.edges}
|
||
highlightedIds={null}
|
||
opacity={0.3}
|
||
brightness={display.edgeBrightness}
|
||
/>
|
||
<NodeCloud
|
||
nodes={offsetNodes}
|
||
highlightedIds={null}
|
||
onHover={setHovered}
|
||
onClick={onNodeClick}
|
||
opacity={0.5}
|
||
boost={nodeBoost}
|
||
/>
|
||
{/* Inter-galaxy CROSS_* edges: source is in primary, target in
|
||
* this linked project's offset nodes. */}
|
||
{lp.cross_edges && lp.cross_edges.length > 0 && (
|
||
<EdgeLines
|
||
nodes={data.nodes}
|
||
targetNodes={offsetNodes}
|
||
edges={lp.cross_edges}
|
||
highlightedIds={highlightedIds}
|
||
opacity={0.85}
|
||
brightness={display.edgeBrightness}
|
||
/>
|
||
)}
|
||
</group>
|
||
);
|
||
})}
|
||
|
||
{hovered && <NodeTooltip node={hovered} />}
|
||
|
||
<CameraAnimator target={cameraTarget} controlsRef={controlsRef} />
|
||
<IdleAutoRotate controlsRef={controlsRef} />
|
||
|
||
<EffectComposer multisampling={GRAPH_COMPOSER_MULTISAMPLING}>
|
||
<Bloom
|
||
luminanceThreshold={0.3}
|
||
luminanceSmoothing={0.7}
|
||
intensity={bloomIntensity}
|
||
mipmapBlur
|
||
radius={0.6}
|
||
/>
|
||
</EffectComposer>
|
||
|
||
<OrbitControls
|
||
ref={controlsRef}
|
||
enableDamping
|
||
dampingFactor={0.08}
|
||
rotateSpeed={0.5}
|
||
zoomSpeed={1.5}
|
||
minDistance={10}
|
||
maxDistance={50000}
|
||
autoRotateSpeed={0.4}
|
||
/>
|
||
</Canvas>
|
||
);
|
||
}
|
||
|
||
/* ── Helper: compute camera target from node IDs ────────── */
|
||
|
||
export function computeCameraTarget(
|
||
nodes: GraphNode[],
|
||
ids: Set<number>,
|
||
): CameraTarget | null {
|
||
if (ids.size === 0) return null;
|
||
|
||
let cx = 0,
|
||
cy = 0,
|
||
cz = 0,
|
||
count = 0;
|
||
for (const node of nodes) {
|
||
if (ids.has(node.id)) {
|
||
cx += node.x;
|
||
cy += node.y;
|
||
cz += node.z;
|
||
count++;
|
||
}
|
||
}
|
||
if (count === 0) return null;
|
||
|
||
cx /= count;
|
||
cy /= count;
|
||
cz /= count;
|
||
|
||
/* Distance based on cluster spread — ensure we never zoom too close */
|
||
let maxDist = 0;
|
||
for (const node of nodes) {
|
||
if (ids.has(node.id)) {
|
||
const d = Math.sqrt(
|
||
(node.x - cx) ** 2 + (node.y - cy) ** 2 + (node.z - cz) ** 2,
|
||
);
|
||
if (d > maxDist) maxDist = d;
|
||
}
|
||
}
|
||
|
||
/* Minimum distance scales with count: single node = 300, cluster = spread-based */
|
||
const spreadDist = maxDist * 3;
|
||
const minDist = count <= 5 ? 300 : 200;
|
||
const distance = Math.max(minDist, spreadDist);
|
||
const lookAt = new THREE.Vector3(cx, cy, cz);
|
||
const position = new THREE.Vector3(
|
||
cx + distance * 0.2,
|
||
cy + distance * 0.15,
|
||
cz + distance,
|
||
);
|
||
|
||
return { position, lookAt };
|
||
}
|