diff --git a/src/__tests__/unit/components/graph-explorer/GraphLegend.test.tsx b/src/__tests__/unit/components/graph-explorer/GraphLegend.test.tsx
new file mode 100644
index 0000000000..6d663455df
--- /dev/null
+++ b/src/__tests__/unit/components/graph-explorer/GraphLegend.test.tsx
@@ -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();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ test("aggregates per-type counts and sorts descending", () => {
+ render(
+
+ );
+
+ 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();
+
+ 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(
+
+ );
+
+ 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})`;
+}
diff --git a/src/components/graph-explorer/Graph2DView.tsx b/src/components/graph-explorer/Graph2DView.tsx
index e0da39600d..67c5c95a2d 100644
--- a/src/components/graph-explorer/Graph2DView.tsx
+++ b/src/components/graph-explorer/Graph2DView.tsx
@@ -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";
@@ -71,18 +72,21 @@ export function Graph2DView({
);
return (
-
+
{size && nodes.length > 0 && (
-
+ <>
+
+
+ >
)}
);
diff --git a/src/components/graph-explorer/GraphLegend.tsx b/src/components/graph-explorer/GraphLegend.tsx
new file mode 100644
index 0000000000..95e7ac35db
--- /dev/null
+++ b/src/components/graph-explorer/GraphLegend.tsx
@@ -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
[];
+ colorMap?: Record;
+}) {
+ const [expanded, setExpanded] = useState(true);
+
+ const counts = new Map();
+ 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 (
+
+
+ {expanded && (
+
+ {entries.map(([type, count]) => (
+ -
+
+ {type}
+ {count}
+
+ ))}
+
+ )}
+
+ );
+}