diff --git a/graph-ui/src/components/FilterPanel.tsx b/graph-ui/src/components/FilterPanel.tsx index db2d0a513..b2e221531 100644 --- a/graph-ui/src/components/FilterPanel.tsx +++ b/graph-ui/src/components/FilterPanel.tsx @@ -22,8 +22,9 @@ interface FilterPanelProps { onToggleShowOnlyDead: () => void; onToggleHideEntryPoints: () => void; onToggleHideTests: () => void; - /* Missed graph (#963): file structure of not-fully-indexed files */ + /* Missed skeleton (#963): white satellite of not-fully-indexed files */ missedView: boolean; + missedCount: number; onToggleMissedView: () => void; } @@ -80,6 +81,7 @@ export function FilterPanel({ onToggleHideEntryPoints, onToggleHideTests, missedView, + missedCount, onToggleMissedView, }: FilterPanelProps) { const { labelCounts, edgeTypeCounts, statusCounts } = useMemo(() => { @@ -168,25 +170,30 @@ export function FilterPanel({ - {/* Missed graph (#963): swaps the layout to the file structure of files - the indexer could not fully cover (best-effort signal). */} + {/* Missed skeleton (#963): white satellite cluster of files the indexer + could not fully cover, shown beside the code galaxy. Click it to + focus; click the code galaxy to come back. */}
- Graph + Missed files + {missedCount > 0 && ( + + {missedCount.toLocaleString()} files + + )}
- {missedView && ( -

- Best-effort: files with unparseable regions or skips. Empty = no - known misses (not a completeness guarantee). -

- )} +

+ {missedCount > 0 + ? "White satellite = files not fully indexed (best-effort). Click it to focus, click the galaxy to return." + : "No known misses (best-effort — not a completeness guarantee)."} +

{/* Dead-code view */} diff --git a/graph-ui/src/components/GraphScene.tsx b/graph-ui/src/components/GraphScene.tsx index 663c2ad32..cad93d926 100644 --- a/graph-ui/src/components/GraphScene.tsx +++ b/graph-ui/src/components/GraphScene.tsx @@ -112,22 +112,31 @@ function IdleAutoRotate({ 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 | 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(null); const controlsRef = useRef(null); @@ -151,6 +160,7 @@ export function GraphScene({ alpha: false, powerPreference: "high-performance", }} + onPointerMissed={onBackgroundClick} > @@ -176,6 +186,30 @@ export function GraphScene({ /> {showLabels && } + {/* 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 && ( + + + + {showLabels && } + + )} + {/* Satellite galaxies for cross-repo linked projects */} {data.linked_projects?.map((lp: LinkedProject) => { const offsetNodes = lp.nodes.map((n) => ({ diff --git a/graph-ui/src/components/GraphTab.tsx b/graph-ui/src/components/GraphTab.tsx index 56f549cd2..1144b9f64 100644 --- a/graph-ui/src/components/GraphTab.tsx +++ b/graph-ui/src/components/GraphTab.tsx @@ -102,10 +102,11 @@ export function GraphTab({ project }: GraphTabProps) { const [enabledLabels, setEnabledLabels] = useState>(new Set()); const [enabledEdgeTypes, setEnabledEdgeTypes] = useState>(new Set()); - /* Missed graph (#963): render the file structure of files the indexer - * could not fully cover instead of the code graph. Server-side option - * (`graph=missed` on /api/layout) — toggling refetches. */ - const [missedView, setMissedView] = useState(false); + /* Missed skeleton (#963): the file structure of files the indexer could + * not fully cover, shown as a white satellite cluster beside the code + * galaxy. Toggle only hides/shows it — the data rides along with every + * code-graph layout. */ + const [showMissedSkeleton, setShowMissedSkeleton] = useState(true); /* Dead-code view: recolor by status + status-based filters */ const [deadCodeView, setDeadCodeView] = useState(false); @@ -189,11 +190,53 @@ export function GraphTab({ project }: GraphTabProps) { /* …and fetch only once budget and project agree (one fetch per change). */ useEffect(() => { if (project && budget.project === project) { - fetchOverview(project, budget.value, missedView ? "missed" : "code"); + fetchOverview(project, budget.value); setHighlightedIds(null); setSelectedPath(null); } - }, [project, budget, missedView, fetchOverview]); + }, [project, budget, fetchOverview]); + + /* Missed skeleton: offset into place and paint white — a ghost of the + * files the graph could not fully cover, sitting beside the galaxy. */ + const missedSkeleton = useMemo(() => { + const mg = data?.missed_graph; + if (!mg || mg.nodes.length === 0) return null; + const nodes = mg.nodes.map((n) => ({ + ...n, + x: n.x + mg.offset.x, + y: n.y + mg.offset.y, + z: n.z + mg.offset.z, + color: "#e9eef5", + })); + return { nodes, edges: mg.edges, ids: new Set(nodes.map((n) => n.id)) }; + }, [data]); + + /* Overview framing: both clusters (galaxy + skeleton) in one shot. */ + const overviewTarget = useMemo(() => { + if (!data) return null; + const all = missedSkeleton ? [...data.nodes, ...missedSkeleton.nodes] : data.nodes; + return computeCameraTarget(all, new Set(all.map((n) => n.id))); + }, [data, missedSkeleton]); + + /* With a skeleton beside the galaxy, auto-frame BOTH clusters on load so + * the side-by-side composition is visible without manual zooming. */ + useEffect(() => { + if (missedSkeleton && overviewTarget) { + setCameraTarget(overviewTarget); + } + }, [missedSkeleton, overviewTarget]); + + /* Clicking empty space while the skeleton has focus flies back to the + * overview (the galaxy may be entirely off-screen at that point, so there + * is no code node to click). No-op during normal galaxy exploration. */ + const handleBackgroundClick = useCallback(() => { + if (selectedNode && missedSkeleton?.ids.has(selectedNode.id) && overviewTarget) { + setSelectedNode(null); + setHighlightedIds(null); + setSelectedPath(null); + setCameraTarget(overviewTarget); + } + }, [selectedNode, missedSkeleton, overviewTarget]); /* Fetch git remote metadata for GitHub deep-links */ useEffect(() => { @@ -231,6 +274,19 @@ export function GraphTab({ project }: GraphTabProps) { const handleNodeClick = useCallback( (node: GraphNode) => { if (!filteredData) return; + + /* Clicking the missed skeleton re-centers the camera on that whole + * cluster (it's small — the natural focus unit is the skeleton, not a + * single node); clicking any code node flies back to the code galaxy + * via the normal per-node focus below. */ + if (missedSkeleton?.ids.has(node.id)) { + setSelectedNode(node); + setHighlightedIds(null); + setSelectedPath(node.file_path ?? null); + setCameraTarget(computeCameraTarget(missedSkeleton.nodes, missedSkeleton.ids)); + return; + } + setSelectedNode(node); /* Highlight the node and its direct connections */ @@ -243,7 +299,7 @@ export function GraphTab({ project }: GraphTabProps) { setSelectedPath(node.file_path ?? null); setCameraTarget(computeCameraTarget(filteredData.nodes, connectedIds)); }, - [filteredData], + [filteredData, missedSkeleton], ); const handleNavigateToNode = useCallback( @@ -356,8 +412,9 @@ export function GraphTab({ project }: GraphTabProps) { onToggleShowOnlyDead={() => setShowOnlyDead((v) => !v)} onToggleHideEntryPoints={() => setHideEntryPoints((v) => !v)} onToggleHideTests={() => setHideTests((v) => !v)} - missedView={missedView} - onToggleMissedView={() => setMissedView((v) => !v)} + missedView={showMissedSkeleton} + missedCount={data?.missed_graph?.nodes.filter((n) => n.label === "File").length ?? 0} + onToggleMissedView={() => setShowMissedSkeleton((v) => !v)} /> diff --git a/graph-ui/src/lib/types.ts b/graph-ui/src/lib/types.ts index a10b53d95..4d8ffa638 100644 --- a/graph-ui/src/lib/types.ts +++ b/graph-ui/src/lib/types.ts @@ -50,11 +50,21 @@ export interface LinkedProject { cross_edges: GraphEdge[]; } +/* Missed-graph skeleton (#963): the file structure of files the indexer + * could not fully cover, laid out as a satellite cluster beside the code + * galaxy (server-computed offset, same shape as LinkedProject's). */ +export interface MissedGraph { + nodes: GraphNode[]; + edges: GraphEdge[]; + offset: { x: number; y: number; z: number }; +} + export interface GraphData { nodes: GraphNode[]; edges: GraphEdge[]; total_nodes: number; linked_projects?: LinkedProject[]; + missed_graph?: MissedGraph; } export interface Project { diff --git a/src/ui/http_server.c b/src/ui/http_server.c index 939c1d76e..4d26f0561 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -1366,6 +1366,63 @@ static double layout_radius(const cbm_layout_result_t *r) { return sqrt(max_r2); } +/* Attach the missed-graph skeleton (#963) to the primary layout doc as + * "missed_graph": {"nodes":[...], "edges":[...], "offset":{x,y,z}} + * — the file structure of files the indexer could not fully cover, laid out + * as a satellite cluster beside the code galaxy (the UI renders it as a white + * skeleton; clicking it re-centers the camera there). The offset sits on the + * -Y side: linked-project satellites spread counter-clockwise from +X, so + * this slot collides last. Returns true when a non-empty skeleton was + * attached; no-op when the project has no missed files. */ +static bool attach_missed_graph(yyjson_mut_doc *mdoc, yyjson_mut_val *mroot, cbm_store_t *store, + const char *project, double primary_radius) { + char covproj[512]; + cbm_store_coverage_shadow_project(covproj, sizeof(covproj), project); + cbm_layout_result_t *ml = cbm_layout_compute(store, covproj, CBM_LAYOUT_OVERVIEW, NULL, 0, 0); + if (!ml) { + return false; + } + if (ml->node_count == 0) { + cbm_layout_free(ml); + return false; + } + double miss_radius = layout_radius(ml); + char *mjson = cbm_layout_to_json(ml); + cbm_layout_free(ml); + if (!mjson) { + return false; + } + yyjson_doc *mldoc = yyjson_read(mjson, strlen(mjson), 0); + free(mjson); + if (!mldoc) { + return false; + } + yyjson_mut_val *entry = yyjson_mut_obj(mdoc); + yyjson_val *mlroot = yyjson_doc_get_root(mldoc); + yyjson_val *mn = yyjson_obj_get(mlroot, "nodes"); + yyjson_val *me = yyjson_obj_get(mlroot, "edges"); + if (mn) { + yyjson_mut_obj_add_val(mdoc, entry, "nodes", yyjson_val_mut_copy(mdoc, mn)); + } + if (me) { + yyjson_mut_obj_add_val(mdoc, entry, "edges", yyjson_val_mut_copy(mdoc, me)); + } + yyjson_doc_free(mldoc); + + double dist = primary_radius + miss_radius + LAYOUT_GALAXY_PAD; + if (dist < LAYOUT_GALAXY_SPACING) { + dist = LAYOUT_GALAXY_SPACING; + } + yyjson_mut_val *offset = yyjson_mut_obj(mdoc); + yyjson_mut_obj_add_real(mdoc, offset, "x", 0.0); + yyjson_mut_obj_add_real(mdoc, offset, "y", -dist); + yyjson_mut_obj_add_real(mdoc, offset, "z", 0.0); + yyjson_mut_obj_add_val(mdoc, entry, "offset", offset); + + yyjson_mut_obj_add_val(mdoc, mroot, "missed_graph", entry); + return true; +} + static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) { char project[256] = {0}; char max_str[32] = {0}; @@ -1440,14 +1497,16 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) { return; } - if (linked_count == 0) { + /* Fast path: no satellites to attach. The missed skeleton only decorates + * the CODE graph — a graph=missed request already IS the miss graph. */ + if (linked_count == 0 && missed_graph) { cbm_store_close(store); cbm_http_replyf(c, 200, g_cors_json, "%s", primary_json); free(primary_json); return; } - /* Parse primary JSON and append linked_projects array */ + /* Parse primary JSON and append missed_graph + linked_projects */ yyjson_doc *pdoc = yyjson_read(primary_json, strlen(primary_json), 0); free(primary_json); if (!pdoc) { @@ -1460,6 +1519,10 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) { yyjson_doc_free(pdoc); yyjson_mut_val *mroot = yyjson_mut_doc_get_root(mdoc); + if (!missed_graph) { + (void)attach_missed_graph(mdoc, mroot, store, project, primary_radius); + } + yyjson_mut_val *lp_arr = yyjson_mut_arr(mdoc); for (int li = 0; li < linked_count; li++) {