Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e1bd268
[Jamie] Add collapsible node-type legend to Graph Explorer 2D view
fayekelmith Aug 27, 2026
e508582
Generated with Hive: Import React in GraphLegend to fix unit test fai…
fayekelmith Aug 27, 2026
eebb80a
Merge branch 'master' into swarm/swarm-change-3eec23fb
tomsmith8 Aug 27, 2026
5b2f06f
Merge branch 'master' into swarm/swarm-change-3eec23fb
tomsmith8 Aug 27, 2026
fdb7cf8
Merge branch 'master' into swarm/swarm-change-3eec23fb
tomsmith8 Aug 27, 2026
ed24725
Merge branch 'master' into swarm/swarm-change-3eec23fb
tomsmith8 Aug 27, 2026
82257d8
Merge branch 'master' into swarm/swarm-change-3eec23fb
tomsmith8 Aug 27, 2026
55ff646
Merge branch 'master' into swarm/swarm-change-3eec23fb
tomsmith8 Aug 27, 2026
f282f0b
Merge branch 'master' into swarm/swarm-change-3eec23fb
tomsmith8 Aug 27, 2026
81375dc
Merge branch 'master' into swarm/swarm-change-3eec23fb
tomsmith8 Aug 27, 2026
75c9e90
Merge branch 'master' into swarm/swarm-change-3eec23fb
tomsmith8 Aug 27, 2026
38bd1d4
Merge branch 'master' into swarm/swarm-change-3eec23fb
tomsmith8 Aug 27, 2026
d124791
Merge branch 'master' into swarm/swarm-change-3eec23fb
tomsmith8 Aug 27, 2026
ffe09bf
Merge branch 'master' into swarm/swarm-change-3eec23fb
tomsmith8 Aug 28, 2026
a7bd13c
Merge branch 'master' into swarm/swarm-change-3eec23fb
tomsmith8 Aug 28, 2026
0c55c16
Merge branch 'master' into swarm/swarm-change-3eec23fb
tomsmith8 Aug 28, 2026
e8e95dd
Merge branch 'master' into swarm/swarm-change-3eec23fb
tomsmith8 Aug 30, 2026
9b5b56c
Merge branch 'master' into swarm/swarm-change-3eec23fb
tomsmith8 Aug 30, 2026
e12edf1
Merge branch 'master' into swarm/swarm-change-3eec23fb
tomsmith8 Aug 31, 2026
fde8ede
Merge branch 'master' into swarm/swarm-change-3eec23fb
Evanfeenstra Aug 31, 2026
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
83 changes: 83 additions & 0 deletions src/__tests__/unit/components/graph-explorer/GraphLegend.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// @vitest-environment jsdom
import React from "react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, test, expect } from "vitest";
import { GraphLegend } from "@/components/graph-explorer/GraphLegend";
import { getNodeColor } from "@/components/graph/graphUtils";

describe("GraphLegend", () => {
test("renders nothing for an empty node list", () => {
const { container } = render(<GraphLegend nodes={[]} />);
expect(container).toBeEmptyDOMElement();
});

test("aggregates per-type counts and sorts descending", () => {
render(
<GraphLegend
nodes={[
{ type: "File" },
{ type: "Function" },
{ type: "File" },
{ type: "Function" },
{ type: "File" },
]}
/>
);

const fileEntry = screen.getByTestId("graph-legend-entry-File");
const functionEntry = screen.getByTestId("graph-legend-entry-Function");

expect(within(fileEntry).getByText("3")).toBeInTheDocument();
expect(within(functionEntry).getByText("2")).toBeInTheDocument();

// "File" (count 3) should come before "Function" (count 2) in the DOM.
const entries = screen.getAllByTestId(/^graph-legend-entry-/);
expect(entries[0]).toBe(fileEntry);
expect(entries[1]).toBe(functionEntry);
});

test("toggle button collapses and expands the list, updating aria-expanded", async () => {
const user = userEvent.setup();
render(<GraphLegend nodes={[{ type: "File" }]} />);

const toggle = screen.getByTestId("graph-legend-toggle");
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(screen.getByTestId("graph-legend-entry-File")).toBeInTheDocument();

await user.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByTestId("graph-legend-entry-File")).not.toBeInTheDocument();

await user.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(screen.getByTestId("graph-legend-entry-File")).toBeInTheDocument();
});

test("swatch color matches getNodeColor for a listed and an unlisted type", () => {
const colorMap = { File: "#123456" };
render(
<GraphLegend nodes={[{ type: "File" }, { type: "Mystery" }]} colorMap={colorMap} />
);

const fileEntry = screen.getByTestId("graph-legend-entry-File");
const mysteryEntry = screen.getByTestId("graph-legend-entry-Mystery");

const fileSwatch = fileEntry.querySelector("span[style]") as HTMLElement;
const mysterySwatch = mysteryEntry.querySelector("span[style]") as HTMLElement;

expect(fileSwatch.style.backgroundColor).toBe(rgbFromHex(getNodeColor("File", colorMap)));
expect(mysterySwatch.style.backgroundColor).toBe(
rgbFromHex(getNodeColor("Mystery", colorMap))
);
});
});

/** jsdom normalizes inline hex colors to rgb() when read back from style. */
function rgbFromHex(hex: string): string {
const value = hex.replace("#", "");
const r = parseInt(value.substring(0, 2), 16);
const g = parseInt(value.substring(2, 4), 16);
const b = parseInt(value.substring(4, 6), 16);
return `rgb(${r}, ${g}, ${b})`;
}
26 changes: 15 additions & 11 deletions src/components/graph-explorer/Graph2DView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import React, { useEffect, useMemo, useRef, useState } from "react";
import { GraphVisualization } from "@/components/graph/GraphVisualization";
import { GraphLegend } from "./GraphLegend";
import type { GraphEdge, GraphNode } from "@/components/graph/graphUtils";
import { GRAPH_EXPLORER_COLORS } from "./nodeColors";
import { LEGAL_NODE_ICONS, resolveEdgeStyle } from "./legalGraphStyles";
Expand Down Expand Up @@ -71,18 +72,21 @@ export function Graph2DView({
);

return (
<div ref={containerRef} className="h-full w-full" data-testid="graph-2d-view">
<div ref={containerRef} className="relative h-full w-full" data-testid="graph-2d-view">
{size && nodes.length > 0 && (
<GraphVisualization
nodes={nodes}
edges={edges}
width={size.width}
height={size.height}
colorMap={GRAPH_EXPLORER_COLORS}
onNodeClick={handleNodeClick}
iconMap={LEGAL_NODE_ICONS}
edgeStyleFn={resolveEdgeStyle}
/>
<>
<GraphVisualization
nodes={nodes}
edges={edges}
width={size.width}
height={size.height}
colorMap={GRAPH_EXPLORER_COLORS}
onNodeClick={handleNodeClick}
iconMap={LEGAL_NODE_ICONS}
edgeStyleFn={resolveEdgeStyle}
/>
<GraphLegend nodes={nodes} colorMap={GRAPH_EXPLORER_COLORS} />
</>
)}
</div>
);
Expand Down
74 changes: 74 additions & 0 deletions src/components/graph-explorer/GraphLegend.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"use client";

import React, { useState } from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { getNodeColor, type GraphNode } from "@/components/graph/graphUtils";

/**
* Small overlay explaining what the 2D graph's node colors mean.
*
* Renders nothing when there are no nodes to summarize, so it never occupies
* layout space (or shows an empty card) before the graph has data.
*/
export function GraphLegend({
nodes,
colorMap,
}: {
nodes: Pick<GraphNode, "type">[];
colorMap?: Record<string, string>;
}) {
const [expanded, setExpanded] = useState(true);

const counts = new Map<string, number>();
for (const node of nodes) {
counts.set(node.type, (counts.get(node.type) ?? 0) + 1);
}

const entries = Array.from(counts.entries()).sort((a, b) => b[1] - a[1]);

if (entries.length === 0) {
return null;
}

return (
<Card className="absolute bottom-4 right-4 max-w-[220px] max-h-[60%] overflow-y-auto p-2 gap-1">
<Button
type="button"
variant="ghost"
size="sm"
aria-expanded={expanded}
data-testid="graph-legend-toggle"
className="flex w-full items-center justify-between px-2"
onClick={() => setExpanded((prev) => !prev)}
>
<span className="text-xs font-medium">Legend</span>
{expanded ? (
<ChevronDown className="size-3.5" />
) : (
<ChevronUp className="size-3.5" />
)}
</Button>
{expanded && (
<ul className="flex flex-col gap-1 px-2 pb-1">
{entries.map(([type, count]) => (
<li
key={type}
data-testid={`graph-legend-entry-${type}`}
className="flex items-center gap-2 text-xs"
>
<span
className="inline-block size-2.5 shrink-0 rounded-full"
style={{ backgroundColor: getNodeColor(type, colorMap) }}
/>
<span className="flex-1 truncate">{type}</span>
<Badge variant="secondary">{count}</Badge>
</li>
))}
</ul>
)}
</Card>
);
}
Loading