Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Unreleased

- Make community detail graphs easier to scan in both exported HTML and VS
Code by grouping node kinds into accessible color-and-shape families,
coloring edges by relationship purpose while retaining confidence strokes,
and showing a compact legend for the categories present in the subgraph.

- Refactor universal language metadata around `UniversalEvidenceProducer` and
`UniversalEvidencePipeline`. `UniversalCandidate`/`UniversalComplete` are
now the clearer lifecycle states `Qualifying`/`Qualified`; the serialized
Expand Down
112 changes: 56 additions & 56 deletions crates/compass-output/assets/viewer/graph.js

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions crates/compass-output/assets/viewer/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
"viewerSchema": "compass.viewer.graph/1",
"files": {
"graph.js": {
"bytes": 1068368,
"sha256": "d4aaf028cdcfe96a788d71fcc3a3d886ba3771e65466db1bf535e790144515b3"
"bytes": 1073303,
"sha256": "f9dc29ba4c45a4e0c401de17e5e62c1e8e5d4e93edfa75dd80ebac736ef82879"
},
"viewer.css": {
"bytes": 234489,
"sha256": "9c39d5cea6a512de1b9f46d10e051480fff84510ae92a873423186c3b30b32a8"
"bytes": 238577,
"sha256": "a402fb8be1c3c232645e48309438dc66a708b6f7bb7f9b19c177c63e0c4f9708"
}
}
}
2 changes: 1 addition & 1 deletion crates/compass-output/assets/viewer/viewer.css

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions packages/compass-viewer/src/graph/CompassGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { CodeQueryResponse } from "../contracts/codeQuery";
import { GraphInspector } from "./GraphInspector";
import { GraphTransitionScreen } from "./GraphTransitionScreen";
import { GraphToolbar } from "./GraphToolbar";
import { GraphSemanticLegend } from "./GraphSemanticLegend";
import { InspectorResizeHandle } from "./InspectorResizeHandle";
import {
normalizeInspectorLayout,
Expand Down Expand Up @@ -475,6 +476,7 @@ function CompassGraphView({
: undefined}
layoutSpacing={state.layoutSpacing}
showMinimap={state.showMinimap}
semanticDetail={detailCommunityId !== undefined && !comparisonMode}
hiddenCommunities={state.hiddenCommunities}
hiddenChanges={state.hiddenChanges}
onFocus={focus}
Expand Down Expand Up @@ -583,6 +585,9 @@ function CompassGraphView({
})}
</div>
)}
{detailCommunityId !== undefined && !comparisonMode ? (
<GraphSemanticLegend model={model} />
) : null}
{communityError && (
<div
className="absolute bottom-4 left-4 z-20 max-w-md rounded-md border border-destructive/50 bg-background/95 px-3 py-2 text-sm text-destructive shadow-lg"
Expand Down
16 changes: 14 additions & 2 deletions packages/compass-viewer/src/graph/GraphMinimap.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ const model: GraphViewModel = {
title: "Minimap fixture",
stats: { nodes: 2, edges: 1, communities: 1, aggregated: false },
nodes: [
{ id: "a", label: "A", community: 0 },
{ id: "b", label: "B", community: 0 }
{ id: "a", label: "A", community: 0, kind: "function" },
{ id: "b", label: "B", community: 0, kind: "class" }
],
edges: [{ id: "a-b", source: "a", target: "b", relation: "calls" }],
communities: [{ id: 0, label: "Core", color: "#4e79a7", hidden: false }],
Expand Down Expand Up @@ -68,4 +68,16 @@ describe("GraphMinimap", () => {
expect(minimap.querySelectorAll("circle")).toHaveLength(2);
expect(minimap.querySelectorAll("line")).toHaveLength(1);
});

it("mirrors semantic node categories in a community detail minimap", () => {
const { container } = render(<GraphMinimap
model={model}
snapshot={snapshot}
focusedNodeId={null}
semanticDetail
onNavigate={vi.fn()}
/>);
expect(container.querySelector('[data-node-category="callable"]')).toBeTruthy();
expect(container.querySelector('[data-node-category="type"]')).toBeTruthy();
});
});
14 changes: 11 additions & 3 deletions packages/compass-viewer/src/graph/GraphMinimap.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useMemo, type MouseEvent } from "react";
import type { GraphViewModel } from "../contracts/graph";
import { nodeSemanticCategory, nodeSemanticCssColor } from "./semanticAppearance";

const WIDTH = 176;
const HEIGHT = 108;
Expand Down Expand Up @@ -73,13 +74,15 @@ export function GraphMinimap({
visibleNodeIds,
visibleEdgeIds,
focusedNodeId,
semanticDetail = false,
onNavigate
}: {
model: GraphViewModel;
snapshot: GraphMinimapSnapshot;
visibleNodeIds?: ReadonlySet<string> | undefined;
visibleEdgeIds?: ReadonlySet<string> | undefined;
focusedNodeId: string | null;
semanticDetail?: boolean;
onNavigate(position: { x: number; y: number }): void;
}) {
const geometry = useMemo(() => graphMinimapGeometry(snapshot), [snapshot]);
Expand Down Expand Up @@ -140,9 +143,14 @@ export function GraphMinimap({
cx={point.x}
cy={point.y}
r={node.id === focusedNodeId ? 3 : 1.65}
fill={node.color?.background
?? communityColors.get(node.community)
?? "currentColor"}
fill={semanticDetail
? nodeSemanticCssColor(nodeSemanticCategory(node.kind))
: node.color?.background
?? communityColors.get(node.community)
?? "currentColor"}
data-node-category={semanticDetail
? nodeSemanticCategory(node.kind)
: undefined}
data-focused={node.id === focusedNodeId ? "true" : undefined}
/>
);
Expand Down
39 changes: 39 additions & 0 deletions packages/compass-viewer/src/graph/GraphSemanticLegend.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// @vitest-environment jsdom

import { cleanup, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import type { GraphViewModel } from "../contracts/graph";
import { GraphSemanticLegend } from "./GraphSemanticLegend";

const model: GraphViewModel = {
schema: "compass.viewer.graph/1",
title: "Semantic fixture",
stats: { nodes: 3, edges: 2, communities: 1, aggregated: false },
nodes: [
{ id: "run", label: "run", community: 0, kind: "function" },
{ id: "store", label: "Store", community: 0, kind: "class" },
{ id: "api", label: "api", community: 0, kind: "module" }
],
edges: [
{ id: "run-store", source: "run", target: "store", relation: "calls" },
{ id: "api-run", source: "api", target: "run", relation: "imports" }
],
communities: [{ id: 0, label: "Core", color: "#4e79a7", hidden: false }],
hyperedges: []
};

describe("GraphSemanticLegend", () => {
afterEach(cleanup);

it("explains only the node and edge categories present in the subgraph", () => {
render(<GraphSemanticLegend model={model} />);
const legend = screen.getByRole("complementary", { name: "Graph visual legend" });
expect(within(legend).getByText("Callable")).toBeTruthy();
expect(within(legend).getByText("Type")).toBeTruthy();
expect(within(legend).getByText("Module / file")).toBeTruthy();
expect(within(legend).getByText("Execution")).toBeTruthy();
expect(within(legend).getByText("Dependency")).toBeTruthy();
expect(within(legend).queryByText("Boundary / data")).toBeNull();
expect(within(legend).queryByText("Data / event flow")).toBeNull();
});
});
76 changes: 76 additions & 0 deletions packages/compass-viewer/src/graph/GraphSemanticLegend.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { useMemo } from "react";
import type { GraphViewModel } from "../contracts/graph";
import {
EDGE_SEMANTIC_CATEGORIES,
NODE_SEMANTIC_CATEGORIES,
edgeSemanticCategory,
nodeSemanticCategory,
type EdgeSemanticCategory,
type NodeSemanticCategory
} from "./semanticAppearance";

const NODE_LABELS: Record<NodeSemanticCategory, string> = {
callable: "Callable",
type: "Type",
module: "Module / file",
boundary: "Boundary / data",
other: "Other"
};

const EDGE_LABELS: Record<EdgeSemanticCategory, string> = {
execution: "Execution",
dependency: "Dependency",
structure: "Structure",
flow: "Data / event flow",
other: "Other"
};

export function GraphSemanticLegend({ model }: { model: GraphViewModel }) {
const nodeCounts = useMemo(() => {
const counts = new Map<NodeSemanticCategory, number>();
for (const node of model.nodes) {
const category = nodeSemanticCategory(node.kind);
counts.set(category, (counts.get(category) ?? 0) + 1);
}
return counts;
}, [model.nodes]);
const edgeCounts = useMemo(() => {
const counts = new Map<EdgeSemanticCategory, number>();
for (const edge of model.edges) {
const category = edgeSemanticCategory(edge.relation);
counts.set(category, (counts.get(category) ?? 0) + 1);
}
return counts;
}, [model.edges]);

return (
<aside className="compass-semantic-legend compass-glass-panel" aria-label="Graph visual legend">
<section aria-label="Node categories">
<strong>Nodes</strong>
{NODE_SEMANTIC_CATEGORIES
.filter((category) => (nodeCounts.get(category) ?? 0) > 0)
.map((category) => (
<span key={category} className="compass-semantic-legend-item">
<i data-node-category={category} aria-hidden="true" />
{NODE_LABELS[category]}
<small>{nodeCounts.get(category)}</small>
</span>
))}
</section>
{model.edges.length > 0 ? (
<section aria-label="Relationship categories">
<strong>Edges</strong>
{EDGE_SEMANTIC_CATEGORIES
.filter((category) => (edgeCounts.get(category) ?? 0) > 0)
.map((category) => (
<span key={category} className="compass-semantic-legend-item">
<i data-edge-category={category} aria-hidden="true" />
{EDGE_LABELS[category]}
<small>{edgeCounts.get(category)}</small>
</span>
))}
</section>
) : null}
</aside>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ const model: GraphViewModel = {
title: "Fixture",
stats: { nodes: 2, edges: 1, communities: 1, aggregated: false },
nodes: [
{ id: "caller", label: "caller", community: 0 },
{ id: "callee", label: "callee", community: 0 }
{ id: "caller", label: "caller", community: 0, kind: "function" },
{ id: "callee", label: "callee", community: 0, kind: "class" }
],
edges: [{
id: "caller-callee",
Expand Down Expand Up @@ -154,6 +154,32 @@ describe("VisNetworkCanvas hover lifecycle", () => {
expect(mock.dataSets[1]?.[0]).not.toHaveProperty("title");
});

it("uses semantic node shapes and relationship colors in community detail", () => {
render(<VisNetworkCanvas
model={model}
focusedNodeId={null}
physicsRunning={false}
layoutStyle="automatic"
forceLabels={false}
semanticDetail
hiddenCommunities={new Set()}
hiddenChanges={new Set()}
onFocus={vi.fn()}
onOpenSource={vi.fn()}
onOpenRelationshipSource={vi.fn()}
onHover={vi.fn()}
onHoverEdge={vi.fn()}
onClear={vi.fn()}
onStabilized={vi.fn()}
/>);

expect(mock.dataSets[0]?.map((node) => [node.id, node.shape])).toEqual([
["caller", "dot"],
["callee", "diamond"]
]);
expect(mock.dataSets[1]?.[0]?.color).toEqual({ color: "#5fa8ff", opacity: 0.35 });
});

it("clears transient hover when the pointer leaves the graph region", () => {
const onHover = vi.fn();
const onHoverEdge = vi.fn();
Expand Down
15 changes: 15 additions & 0 deletions packages/compass-viewer/src/graph/VisNetworkCanvas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,21 @@ describe("graphNodeColor", () => {
});
});

it("uses the semantic detail palette ahead of the export community color", () => {
const semanticPalette = {
callable: { background: "#112233", border: "#5fa8ff" },
type: { background: "#332211", border: "#e3b341" },
module: { background: "#113322", border: "#56d4b4" },
boundary: { background: "#331111", border: "#ff9b87" },
other: { background: "#222222", border: "#8b949e" }
};
expect(graphNodeColor(model, { ...node, kind: "function" }, undefined, undefined, undefined,
semanticPalette)).toEqual({
background: "#112233",
border: "#5fa8ff"
});
});

it("derives relationship labels for rich hover content", () => {
const edge = {
id: "run-helper",
Expand Down
Loading
Loading