feat(ui): missed skeleton beside the code galaxy with click-to-focus

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>
This commit is contained in:
Martin Vogel
2026-07-08 20:07:01 +02:00
parent 664313e4ac
commit afc649f4e8
5 changed files with 195 additions and 22 deletions
+18 -11
View File
@@ -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({
</div>
</ScrollArea>
{/* 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. */}
<div className="px-4 pt-2 border-t border-border/30 space-y-2 shrink-0">
<div className="flex items-center justify-between">
<span className="text-[10px] text-foreground/30 uppercase tracking-widest">
Graph
Missed files
</span>
{missedCount > 0 && (
<span className="text-[10px] text-foreground/50 tabular-nums">
{missedCount.toLocaleString()} files
</span>
)}
</div>
<CheckRow
checked={missedView}
onToggle={onToggleMissedView}
label="Missed files (not fully indexed)"
label="Show missed skeleton"
/>
{missedView && (
<p className="text-[9px] leading-snug text-foreground/30">
Best-effort: files with unparseable regions or skips. Empty = no
known misses (not a completeness guarantee).
</p>
)}
<p className="text-[9px] leading-snug text-foreground/30">
{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)."}
</p>
</div>
{/* Dead-code view */}
+34
View File
@@ -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<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);
@@ -151,6 +160,7 @@ export function GraphScene({
alpha: false,
powerPreference: "high-performance",
}}
onPointerMissed={onBackgroundClick}
>
<color attach="background" args={["#06090f"]} />
<ambientLight intensity={0.5} />
@@ -176,6 +186,30 @@ export function GraphScene({
/>
{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) => ({
+68 -9
View File
@@ -102,10 +102,11 @@ export function GraphTab({ project }: GraphTabProps) {
const [enabledLabels, setEnabledLabels] = useState<Set<string>>(new Set());
const [enabledEdgeTypes, setEnabledEdgeTypes] = useState<Set<string>>(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)}
/>
<Sidebar
nodes={filteredData.nodes}
@@ -392,11 +449,13 @@ export function GraphTab({ project }: GraphTabProps) {
<ErrorBoundary>
<GraphScene
data={filteredData}
missed={showMissedSkeleton ? missedSkeleton : null}
highlightedIds={highlightedIds}
cameraTarget={cameraTarget}
showLabels={showLabels}
display={display}
onNodeClick={handleNodeClick}
onBackgroundClick={handleBackgroundClick}
/>
</ErrorBoundary>
+10
View File
@@ -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 {
+65 -2
View File
@@ -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++) {