Skip to content

Commit efc6e3d

Browse files
committed
feat: selectable groups in legend with two-level grouping and e2e tests
- Groups in legend panel are now clickable buttons (select/deselect) - Selected groups highlight nodes in 3D view, dims non-selected - Multi-select supported, click again to deselect - Fixed click interception by three.js OrbitControls (stopPropagation) - Two-level cloud grouping (convex/auth instead of just convex) - Removed group cap (was 8, now shows all), added 20 colors - Lowered cloud minFiles threshold for small projects - Added 7 Playwright e2e tests for group selection
1 parent a97a1d8 commit efc6e3d

8 files changed

Lines changed: 216 additions & 28 deletions

File tree

app/page.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ function App(): React.ReactElement | null {
3636
handleNavigate,
3737
handleFocus,
3838
handleSearch,
39+
selectedGroups,
40+
toggleGroup,
3941
} = useGraphContext();
4042

4143
const [fileTreeOpen, setFileTreeOpen] = useState(false);
@@ -79,6 +81,7 @@ function App(): React.ReactElement | null {
7981
forceData={forceData}
8082
circularDeps={graphData.stats.circularDeps}
8183
symbolData={symbolData}
84+
selectedGroups={selectedGroups}
8285
onNodeClick={handleNodeClick}
8386
onSymbolClick={setSelectedSymbol}
8487
/>
@@ -94,7 +97,7 @@ function App(): React.ReactElement | null {
9497
callEdges={symbolData?.callEdges ?? []}
9598
onClose={() => { setSelectedSymbol(null); }}
9699
/>
97-
<Legend view={currentView} groups={groupData} showClouds={config.showModuleBoxes} />
100+
<Legend view={currentView} groups={groupData} showClouds={config.showModuleBoxes} selectedGroups={selectedGroups} onToggleGroup={toggleGroup} />
98101
<SettingsPanel config={config} onChange={setConfig} />
99102
</>
100103
);

components/graph-canvas.tsx

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export function GraphCanvas({
6060
forceData,
6161
circularDeps,
6262
symbolData,
63+
selectedGroups,
6364
onNodeClick,
6465
onSymbolClick,
6566
}: {
@@ -71,6 +72,7 @@ export function GraphCanvas({
7172
forceData: ForceApiResponse | undefined;
7273
circularDeps: string[][];
7374
symbolData: SymbolGraphResponse | undefined;
75+
selectedGroups: Set<string>;
7476
onNodeClick: (node: GraphApiNode) => void;
7577
onSymbolClick: (symbol: SymbolApiNode) => void;
7678
}): React.ReactElement {
@@ -212,7 +214,7 @@ export function GraphCanvas({
212214

213215
// Dynamic minimum: small projects need fewer files per cloud
214216
const totalNodes = fgNodes.length;
215-
const minFiles = totalNodes > 100 ? 5 : totalNodes > 20 ? 4 : 3;
217+
const minFiles = totalNodes > 100 ? 3 : totalNodes > 20 ? 2 : 2;
216218

217219
const active = new Set<string>();
218220
groups.forEach((moduleNodes, mod) => {
@@ -405,10 +407,27 @@ export function GraphCanvas({
405407
? `${n.label as string} (${n.path as string})`
406408
: `${n.path as string} (${n.loc as number} LOC)`
407409
}
408-
nodeColor={(n: Record<string, unknown>) => n.color as string}
409-
nodeVal={(n: Record<string, unknown>) => n.size as number}
410+
nodeColor={(n: Record<string, unknown>) => {
411+
if (selectedGroups.size === 0) return n.color as string;
412+
const mod = (n.module as string) || "";
413+
return selectedGroups.has(cloudGroup(mod)) ? (n.color as string) : "#1a1a24";
414+
}}
415+
nodeVal={(n: Record<string, unknown>) => {
416+
if (selectedGroups.size === 0) return n.size as number;
417+
const mod = (n.module as string) || "";
418+
return selectedGroups.has(cloudGroup(mod)) ? (n.size as number) : 0.3;
419+
}}
410420
nodeOpacity={config.nodeOpacity}
411-
linkColor={(l: Record<string, unknown>) => l.color as string}
421+
linkColor={(l: Record<string, unknown>) => {
422+
if (selectedGroups.size === 0) return l.color as string;
423+
const src = l.source as Record<string, unknown> | string;
424+
const tgt = l.target as Record<string, unknown> | string;
425+
const srcMod = typeof src === "object" ? (src.module as string) || "" : "";
426+
const tgtMod = typeof tgt === "object" ? (tgt.module as string) || "" : "";
427+
const srcIn = selectedGroups.has(cloudGroup(srcMod));
428+
const tgtIn = selectedGroups.has(cloudGroup(tgtMod));
429+
return srcIn && tgtIn ? (l.color as string) : "rgba(30,30,40,0.1)";
430+
}}
412431
linkWidth={(l: Record<string, unknown>) => l.width as number}
413432
linkOpacity={config.linkOpacity}
414433
backgroundColor="#0a0a0f"

components/graph-provider.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ interface GraphContextValue {
3030
setSelectedSymbol: (symbol: SymbolApiNode | null) => void;
3131
focusNodeId: string | null;
3232
setFocusNodeId: (id: string | null) => void;
33+
selectedGroups: Set<string>;
34+
toggleGroup: (name: string) => void;
3335
handleNodeClick: (node: GraphApiNode) => void;
3436
handleNavigate: (nodeId: string) => void;
3537
handleFocus: (nodeId: string) => void;
@@ -51,6 +53,7 @@ export function GraphProvider({ children }: { children: React.ReactNode }) {
5153
const [selectedNode, setSelectedNode] = useState<GraphApiNode | null>(null);
5254
const [selectedSymbol, setSelectedSymbol] = useState<SymbolApiNode | null>(null);
5355
const [focusNodeId, setFocusNodeId] = useState<string | null>(null);
56+
const [selectedGroups, setSelectedGroups] = useState<Set<string>>(new Set());
5457
const isSymbolView = currentView === "symbols" || currentView === "types";
5558
const { symbolData } = useSymbolData(isSymbolView);
5659

@@ -65,6 +68,15 @@ export function GraphProvider({ children }: { children: React.ReactNode }) {
6568
}
6669
}, [projectName]);
6770

71+
const toggleGroup = useCallback((name: string) => {
72+
setSelectedGroups((prev) => {
73+
const next = new Set(prev);
74+
if (next.has(name)) next.delete(name);
75+
else next.add(name);
76+
return next;
77+
});
78+
}, []);
79+
6880
const handleNodeClick = useCallback((node: GraphApiNode) => {
6981
setSelectedNode(node);
7082
}, []);
@@ -112,6 +124,8 @@ export function GraphProvider({ children }: { children: React.ReactNode }) {
112124
setSelectedSymbol,
113125
focusNodeId,
114126
setFocusNodeId,
127+
selectedGroups,
128+
toggleGroup,
115129
handleNodeClick,
116130
handleNavigate,
117131
handleFocus,

components/legend.tsx

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,24 @@ export function Legend({
77
view,
88
groups,
99
showClouds,
10+
selectedGroups,
11+
onToggleGroup,
1012
}: {
1113
view: ViewType;
1214
groups: GroupMetrics[] | undefined;
1315
showClouds: boolean;
16+
selectedGroups: Set<string>;
17+
onToggleGroup: (name: string) => void;
1418
}): React.ReactElement {
1519
const items = LEGENDS[view] ?? [];
1620
const showGroups = showClouds && groups && groups.length > 0;
21+
const hasSelection = selectedGroups.size > 0;
1722

1823
return (
19-
<div className="fixed bottom-4 left-4 z-50 bg-[rgba(15,15,25,0.85)] border border-[#222] rounded-[10px] p-4 text-[11px] backdrop-blur-xl max-w-[260px]">
24+
<div
25+
className="fixed bottom-4 left-4 z-50 bg-[rgba(15,15,25,0.85)] border border-[#222] rounded-[10px] p-4 text-[11px] backdrop-blur-xl max-w-[260px] max-h-[70vh] overflow-y-auto pointer-events-auto"
26+
onPointerDown={(e) => { e.stopPropagation(); }}
27+
>
2028
{items.map((item, i) => (
2129
<div key={i} className="flex items-center gap-2 py-0.5">
2230
{item.color && (
@@ -31,19 +39,36 @@ export function Legend({
3139
{showGroups && (
3240
<>
3341
<div className="border-t border-[#333] my-2" />
34-
<div className="text-[10px] text-[#666] mb-1 uppercase tracking-wider">Groups</div>
35-
{groups.map((g) => (
36-
<div key={g.name} className="flex items-center gap-2 py-0.5">
37-
<span
38-
className="w-2.5 h-2.5 rounded-sm inline-block shrink-0"
39-
style={{ backgroundColor: g.color }}
40-
/>
41-
<span className="truncate">{g.name}</span>
42-
<span className="text-[#555] ml-auto shrink-0">
43-
{g.files}f {(g.importance * 100).toFixed(0)}%
44-
</span>
45-
</div>
46-
))}
42+
<div className="text-[10px] text-[#666] mb-1 uppercase tracking-wider">
43+
Groups {hasSelection && <span className="text-[#888]">({selectedGroups.size} selected)</span>}
44+
</div>
45+
{groups.map((g) => {
46+
const isSelected = selectedGroups.has(g.name);
47+
const dimmed = hasSelection && !isSelected;
48+
return (
49+
<button
50+
type="button"
51+
key={g.name}
52+
data-group={g.name}
53+
className={`flex items-center gap-2 py-0.5 cursor-pointer rounded px-1 -mx-1 transition-colors w-full text-left text-[11px] bg-transparent border-0 text-inherit ${
54+
isSelected ? "bg-[rgba(255,255,255,0.08)]" : "hover:bg-[rgba(255,255,255,0.04)]"
55+
} ${dimmed ? "opacity-40" : ""}`}
56+
onClick={(e) => { e.stopPropagation(); onToggleGroup(g.name); }}
57+
>
58+
<span
59+
className="w-2.5 h-2.5 rounded-sm inline-block shrink-0 border"
60+
style={{
61+
backgroundColor: isSelected || !hasSelection ? g.color : "transparent",
62+
borderColor: g.color,
63+
}}
64+
/>
65+
<span className="truncate">{g.name}</span>
66+
<span className="text-[#555] ml-auto shrink-0">
67+
{g.files}f {(g.importance * 100).toFixed(0)}%
68+
</span>
69+
</button>
70+
);
71+
})}
4772
</>
4873
)}
4974
</div>

e2e/groups.spec.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { test, expect } from "@playwright/test";
2+
3+
test.describe("Group Selection", () => {
4+
test.beforeEach(async ({ page }) => {
5+
await page.goto("/");
6+
await page.waitForSelector("canvas", { timeout: 15_000 });
7+
});
8+
9+
test("legend shows Groups section with group names", async ({ page }) => {
10+
await expect(page.getByText("Groups", { exact: false })).toBeVisible();
11+
12+
// Fixture has 5 groups: services, models, utils, src, types
13+
const groupButtons = page.locator("button[data-group]");
14+
await expect(groupButtons).toHaveCount(5);
15+
16+
const names = await groupButtons.locator("span.truncate").allTextContents();
17+
expect(names).toContain("services");
18+
expect(names).toContain("models");
19+
expect(names).toContain("utils");
20+
});
21+
22+
test("clicking a group selects it and shows selection count", async ({ page }) => {
23+
const servicesBtn = page.locator("button[data-group='services']");
24+
await expect(servicesBtn).toBeVisible();
25+
26+
await servicesBtn.click();
27+
28+
// Header should show "(1 selected)"
29+
await expect(page.getByText("1 selected")).toBeVisible();
30+
31+
// The clicked button should have highlighted background
32+
await expect(servicesBtn).not.toHaveClass(/opacity-40/);
33+
34+
// Other groups should be dimmed
35+
const modelsBtn = page.locator("button[data-group='models']");
36+
await expect(modelsBtn).toHaveClass(/opacity-40/);
37+
});
38+
39+
test("clicking a selected group deselects it", async ({ page }) => {
40+
const servicesBtn = page.locator("button[data-group='services']");
41+
42+
// Select
43+
await servicesBtn.click();
44+
await expect(page.getByText("1 selected")).toBeVisible();
45+
46+
// Deselect
47+
await servicesBtn.click();
48+
49+
// "selected" text should be gone
50+
await expect(page.getByText("selected")).not.toBeVisible();
51+
52+
// No group should be dimmed
53+
const allButtons = page.locator("button[data-group]");
54+
const count = await allButtons.count();
55+
for (let i = 0; i < count; i++) {
56+
await expect(allButtons.nth(i)).not.toHaveClass(/opacity-40/);
57+
}
58+
});
59+
60+
test("multi-select: clicking two groups selects both", async ({ page }) => {
61+
const servicesBtn = page.locator("button[data-group='services']");
62+
const modelsBtn = page.locator("button[data-group='models']");
63+
64+
await servicesBtn.click();
65+
await modelsBtn.click();
66+
67+
await expect(page.getByText("2 selected")).toBeVisible();
68+
69+
// Both should not be dimmed
70+
await expect(servicesBtn).not.toHaveClass(/opacity-40/);
71+
await expect(modelsBtn).not.toHaveClass(/opacity-40/);
72+
73+
// Others should be dimmed
74+
const utilsBtn = page.locator("button[data-group='utils']");
75+
await expect(utilsBtn).toHaveClass(/opacity-40/);
76+
});
77+
78+
test("deselecting one of two selected groups keeps the other", async ({ page }) => {
79+
const servicesBtn = page.locator("button[data-group='services']");
80+
const modelsBtn = page.locator("button[data-group='models']");
81+
82+
await servicesBtn.click();
83+
await modelsBtn.click();
84+
await expect(page.getByText("2 selected")).toBeVisible();
85+
86+
// Deselect services
87+
await servicesBtn.click();
88+
await expect(page.getByText("1 selected")).toBeVisible();
89+
90+
// Services should now be dimmed, models still selected
91+
await expect(servicesBtn).toHaveClass(/opacity-40/);
92+
await expect(modelsBtn).not.toHaveClass(/opacity-40/);
93+
});
94+
95+
test("group buttons have correct data-group attributes", async ({ page }) => {
96+
const groupButtons = page.locator("button[data-group]");
97+
const attrs = await groupButtons.evaluateAll(
98+
(els) => els.map((el) => el.getAttribute("data-group")),
99+
);
100+
expect(attrs.length).toBe(5);
101+
expect(attrs).toEqual(expect.arrayContaining(["services", "models", "utils", "src", "types"]));
102+
});
103+
104+
test("groups only visible when Module Clouds checkbox is checked", async ({ page }) => {
105+
// Groups should be visible initially (checkbox is checked by default)
106+
await expect(page.getByText("Groups", { exact: false })).toBeVisible();
107+
108+
// Uncheck Module Clouds
109+
const checkbox = page.locator("input[type='checkbox']");
110+
await checkbox.uncheck();
111+
112+
// Groups section should disappear
113+
await expect(page.locator("button[data-group]").first()).not.toBeVisible();
114+
});
115+
});

src/analyzer/index.test.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -261,12 +261,19 @@ describe("cloudGroup", () => {
261261
expect(cloudGroup("apps/web/")).toBe("web");
262262
});
263263

264-
it("returns first segment for non-source dirs", () => {
265-
expect(cloudGroup("convex/agents/")).toBe("convex");
266-
expect(cloudGroup("e2e/tests/")).toBe("e2e");
264+
it("uses two-level grouping for non-source dirs with subdirs", () => {
265+
expect(cloudGroup("convex/agents/")).toBe("convex/agents");
266+
expect(cloudGroup("convex/auth/")).toBe("convex/auth");
267+
expect(cloudGroup("e2e/tests/")).toBe("e2e/tests");
267268
expect(cloudGroup("scripts/")).toBe("scripts");
268269
});
269270

271+
it("uses two-level grouping for deep source-dir paths", () => {
272+
expect(cloudGroup("src/app/(dashboard)/")).toBe("app/(dashboard)");
273+
expect(cloudGroup("src/components/ui/")).toBe("components/ui");
274+
expect(cloudGroup("app/api/graph/")).toBe("api/graph");
275+
});
276+
270277
it("returns root for empty or dot paths", () => {
271278
expect(cloudGroup("")).toBe("root");
272279
expect(cloudGroup(".")).toBe("root");
@@ -341,13 +348,13 @@ describe("computeGroups", () => {
341348
expect(result.groups).toEqual([]);
342349
});
343350

344-
it("caps at 8 groups max", () => {
345-
const files = Array.from({ length: 12 }, (_, i) =>
351+
it("returns all groups without cap", () => {
352+
const files = Array.from({ length: 25 }, (_, i) =>
346353
makeFile(`dir${i}/file.ts`, { loc: 10 }),
347354
);
348355
const built = buildGraph(files);
349356
const result = analyzeGraph(built, files);
350-
expect(result.groups.length).toBeLessThanOrEqual(8);
357+
expect(result.groups.length).toBe(25);
351358
});
352359

353360
it("includes fanIn and fanOut per group", () => {

src/analyzer/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,11 @@ export function analyzeGraph(built: BuiltGraph, parsedFiles?: ParsedFile[]): Cod
153153
const GROUP_COLORS = [
154154
"#2563eb", "#dc2626", "#16a34a", "#9333ea", "#ea580c",
155155
"#0891b2", "#ca8a04", "#e11d48", "#4f46e5", "#059669",
156+
"#7c3aed", "#db2777", "#0d9488", "#d97706", "#6366f1",
157+
"#be123c", "#15803d", "#a855f7", "#f97316", "#0284c7",
156158
];
157159

158-
const MAX_LEGEND_GROUPS = 8;
160+
const MAX_LEGEND_GROUPS = Infinity;
159161

160162
function computeGroups(
161163
fileNodes: GraphNode[],

src/cloud-group.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ const SOURCE_DIRS = new Set(["src", "lib", "app", "packages", "apps"]);
33
export function cloudGroup(mod: string): string {
44
const parts = mod.replace(/\/$/, "").split("/").filter(Boolean);
55
if (parts.length === 0 || parts[0] === ".") return "root";
6-
if (SOURCE_DIRS.has(parts[0]) && parts.length > 1) return parts[1];
7-
return parts[0];
6+
const start = SOURCE_DIRS.has(parts[0]) ? 1 : 0;
7+
const meaningful = parts.slice(start);
8+
if (meaningful.length === 0) return parts[0];
9+
if (meaningful.length === 1) return meaningful[0];
10+
return meaningful.slice(0, 2).join("/");
811
}

0 commit comments

Comments
 (0)