Skip to content

Commit fa05781

Browse files
committed
Integrate color management for graph visualizations and add legend overlays
1 parent 645ab24 commit fa05781

17 files changed

+508
-49
lines changed

bun.lock

Lines changed: 7 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"class-variance-authority": "^0.7.1",
2929
"clsx": "^2.1.1",
3030
"cmdk": "^1.1.1",
31+
"color-convert": "^3.1.3",
3132
"graphology": "^0.26.0",
3233
"graphology-layout": "^0.6.1",
3334
"graphology-layout-forceatlas2": "^0.10.1",

src/App.tsx

Lines changed: 50 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,24 @@ import { GraphSelector } from "./components/GraphSelector";
99
import { SettingsFAB } from "./components/SettingsFAB";
1010
import { SettingsPanel } from "./components/SettingsPanel";
1111
import { ErrorBoundary } from "./components/ErrorBoundary";
12+
import { GroupLegendOverlay } from "./components/GroupLegendOverlay";
13+
import { LinkLegendOverlay } from "./components/LinkLegendOverlay";
14+
import { ColorMapProvider } from "./context/ColorMapContext";
15+
import { useLegendData } from "./hooks/useLegendData";
1216
import { useGraphStore } from "./store/graphStore";
1317
import { decode, stripSimulationState } from "./lib/graphHash";
1418
import "./index.css";
1519

20+
function LegendOverlays() {
21+
const { groups, linkTypes } = useLegendData();
22+
return (
23+
<>
24+
<GroupLegendOverlay groups={groups} />
25+
<LinkLegendOverlay linkTypes={linkTypes} />
26+
</>
27+
);
28+
}
29+
1630
function useHashGraphSync() {
1731
const setSharedGraph = useGraphStore((state) => state.setSharedGraph);
1832

@@ -56,37 +70,42 @@ export function App() {
5670

5771
return (
5872
<ErrorBoundary>
59-
<div className="fixed inset-0 w-full h-full bg-background overflow-hidden">
60-
{/* Visualization - Fullscreen (3D, Cosmo, or 2D based on mode) */}
61-
{/* Key includes currentGraphId to force remount on graph change */}
62-
{visualizationMode === "3d" ? (
63-
<ForceGraph3DComponent key={`3d-${graphKey}`} />
64-
) : visualizationMode === "cosmo" ? (
65-
<CosmographGraph key={`cosmo-${graphKey}`} />
66-
) : (
67-
<SigmaGraph key={`2d-${graphKey}`} />
68-
)}
69-
70-
{/* Graph Selector - Top left */}
71-
<GraphSelector />
72-
73-
{/* Search Overlay - Top right */}
74-
<SearchOverlay />
75-
76-
{/* Node Info Overlay - Shows below search */}
77-
<NodeInfoOverlay onClose={handleCloseNodeInfo} />
78-
79-
{/* Visualization Mode Toggle - Bottom center */}
80-
<VisualizationToggle />
81-
82-
{/* Settings FAB and Panel - Bottom left (only in 3D mode) */}
83-
{visualizationMode === "3d" && (
84-
<>
85-
<SettingsFAB />
86-
<SettingsPanel />
87-
</>
88-
)}
89-
</div>
73+
<ColorMapProvider>
74+
<div className="fixed inset-0 w-full h-full bg-background overflow-hidden">
75+
{/* Visualization - Fullscreen (3D, Cosmo, or 2D based on mode) */}
76+
{/* Key includes currentGraphId to force remount on graph change */}
77+
{visualizationMode === "3d" ? (
78+
<ForceGraph3DComponent key={`3d-${graphKey}`} />
79+
) : visualizationMode === "cosmo" ? (
80+
<CosmographGraph key={`cosmo-${graphKey}`} />
81+
) : (
82+
<SigmaGraph key={`2d-${graphKey}`} />
83+
)}
84+
85+
{/* Graph Selector - Top left */}
86+
<GraphSelector />
87+
88+
{/* Search Overlay - Top right */}
89+
<SearchOverlay />
90+
91+
{/* Node Info Overlay - Shows below search */}
92+
<NodeInfoOverlay onClose={handleCloseNodeInfo} />
93+
94+
{/* Legend overlays - Bottom left (groups), Bottom right (link types) */}
95+
<LegendOverlays />
96+
97+
{/* Visualization Mode Toggle - Bottom center */}
98+
<VisualizationToggle />
99+
100+
{/* Settings FAB and Panel - Bottom left (only in 3D mode) */}
101+
{visualizationMode === "3d" && (
102+
<>
103+
<SettingsFAB />
104+
<SettingsPanel />
105+
</>
106+
)}
107+
</div>
108+
</ColorMapProvider>
90109
</ErrorBoundary>
91110
);
92111
}

src/components/CosmographGraph.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import { useMemo } from "react";
22
import { Cosmograph } from "@cosmograph/react";
33
import type { CosmographConfig } from "@cosmograph/react";
4+
import { useColorMap } from "@/context/ColorMapContext";
45
import { useGraphStore } from "@/store/graphStore";
5-
import { getGroupColor } from "@/lib/groupColors";
66
import type { GraphNode } from "@/store/graphStore";
77

88
// Cosmograph has no link/edge label API; only point labels are supported.
99
// We pass raw points/links with pointIndexBy and link index columns (like the official Basic usage example).
1010

1111
export function CosmographGraph() {
12+
const { getGroupColor, getLinkColor } = useColorMap();
1213
const graphData = useGraphStore((state) => state.sharedGraph?.graph ?? state.graphData);
1314
const searchQuery = useGraphStore((state) => state.searchQuery);
1415
const setSelectedNode = useGraphStore((state) => state.setSelectedNode);
@@ -59,6 +60,7 @@ export function CosmographGraph() {
5960
target: string;
6061
sourceidx: number;
6162
targetidx: number;
63+
label: string;
6264
}> = filteredLinks.map((link) => {
6365
const sourceId = getLinkId(link.source)!;
6466
const targetId = getLinkId(link.target)!;
@@ -67,6 +69,7 @@ export function CosmographGraph() {
6769
target: targetId,
6870
sourceidx: idToIndex.get(sourceId)!,
6971
targetidx: idToIndex.get(targetId)!,
72+
label: link.label != null && String(link.label).trim() !== "" ? String(link.label).trim() : "untyped",
7073
};
7174
});
7275

@@ -90,7 +93,10 @@ export function CosmographGraph() {
9093
backgroundColor: "#0a0a0a",
9194
pointColorBy: "group",
9295
pointColorStrategy: "direct",
93-
pointColorByFn: (value: unknown) => getGroupColor(Number(value)),
96+
pointColorByFn: (value: unknown) => getGroupColor(Number(value) ?? 1),
97+
linkColorBy: "label",
98+
linkColorStrategy: "direct",
99+
linkColorByFn: (value: unknown) => getLinkColor(value as string),
94100
pointLabelBy: "name",
95101
showLabels: true,
96102
showTopLabels: true,
@@ -109,7 +115,7 @@ export function CosmographGraph() {
109115
}
110116
},
111117
}),
112-
[points, links, nodeById, setSelectedNode]
118+
[points, links, nodeById, setSelectedNode, getGroupColor, getLinkColor]
113119
);
114120

115121
return (

src/components/ForceGraph3D.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,22 @@ import type { ForceGraphInstance } from "3d-force-graph";
44
import * as THREE from "three";
55
import { CSS2DRenderer, CSS2DObject } from "three/examples/jsm/renderers/CSS2DRenderer.js";
66
import { Text } from "troika-three-text";
7-
import { getGroupColor } from "@/lib/groupColors";
7+
import { useColorMap } from "@/context/ColorMapContext";
88
import { useGraphStore } from "@/store/graphStore";
99
import { useSettingsStore } from "@/store/settingsStore";
1010

1111
/** LOD threshold: only show edge labels when camera distance to midpoint is below this */
1212
const EDGE_LABEL_LOD_THRESHOLD = 500;
1313

14+
function hexToNumber(hex: string): number {
15+
const s = hex.startsWith("#") ? hex.slice(1) : hex;
16+
return parseInt(s, 16);
17+
}
18+
1419
export function ForceGraph3DComponent() {
1520
const containerRef = useRef<HTMLDivElement>(null);
1621
const graphRef = useRef<ForceGraphInstance | null>(null);
17-
22+
const { getGroupColor, getLinkColor } = useColorMap();
1823
const graphData = useGraphStore((state) => state.sharedGraph?.graph ?? state.graphData);
1924
const searchQuery = useGraphStore((state) => state.searchQuery);
2025
const setSelectedNode = useGraphStore((state) => state.setSelectedNode);
@@ -42,12 +47,13 @@ export function ForceGraph3DComponent() {
4247
const nodeEl = document.createElement('div');
4348
nodeEl.textContent = node.name || node.id;
4449
nodeEl.className = 'node-label-3d';
45-
nodeEl.style.color = node.color || '#4a9eff';
50+
nodeEl.style.color = getGroupColor(node.group);
4651
nodeEl.style.fontSize = `${settings.nodeLabelFontSize}px`;
4752

4853
return new CSS2DObject(nodeEl);
4954
})
5055
.nodeThreeObjectExtend(true)
56+
.linkColor((link: any) => getLinkColor(link.label))
5157
.linkWidth(settings.linkWidth)
5258
.linkOpacity(settings.linkOpacity)
5359
.linkDirectionalParticles(0) // Disable animated particles
@@ -123,7 +129,7 @@ export function ForceGraph3DComponent() {
123129
graphRef.current._destructor();
124130
}
125131
};
126-
}, [graphData, setSelectedNode, settings, incrementor]);
132+
}, [graphData, setSelectedNode, settings, incrementor, getGroupColor, getLinkColor]);
127133

128134
// Handle search filtering
129135
useEffect(() => {
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { useState } from "react";
2+
import { Button } from "@/components/ui/button";
3+
import { Palette, ChevronUp } from "lucide-react";
4+
import { cn } from "@/lib/utils";
5+
import { OverlayList } from "@/components/OverlayList";
6+
import type { GroupLegendItem } from "@/hooks/useLegendData";
7+
8+
interface GroupLegendOverlayProps {
9+
groups: GroupLegendItem[];
10+
}
11+
12+
export function GroupLegendOverlay({ groups }: GroupLegendOverlayProps) {
13+
const [isExpanded, setIsExpanded] = useState(false);
14+
15+
return (
16+
<>
17+
{!isExpanded && (
18+
<div className="fixed bottom-6 left-6 z-40">
19+
<Button
20+
variant="secondary"
21+
size="icon"
22+
onClick={() => setIsExpanded(true)}
23+
className={cn(
24+
"h-10 w-10 rounded-full shadow-lg",
25+
"backdrop-blur-md bg-background/80 border border-border/50",
26+
"hover:bg-background/90 transition-all duration-200",
27+
"hover:scale-110"
28+
)}
29+
>
30+
<Palette className="h-4 w-4" />
31+
</Button>
32+
</div>
33+
)}
34+
35+
{isExpanded && (
36+
<div className="fixed bottom-6 left-6 z-40 w-56">
37+
<div
38+
className={cn(
39+
"rounded-lg shadow-2xl p-4",
40+
"backdrop-blur-md bg-background/95 border border-border/50",
41+
"animate-in fade-in slide-in-from-bottom-2 duration-200"
42+
)}
43+
>
44+
<div className="flex items-center justify-between mb-3">
45+
<h3 className="text-sm font-semibold">Node Groups</h3>
46+
<Button
47+
variant="ghost"
48+
size="icon"
49+
onClick={() => setIsExpanded(false)}
50+
className="h-7 w-7 shrink-0"
51+
>
52+
<ChevronUp className="h-4 w-4" />
53+
</Button>
54+
</div>
55+
<p className="text-xs text-muted-foreground mb-2">
56+
{groups.length} group{groups.length !== 1 ? "s" : ""}
57+
</p>
58+
<OverlayList
59+
items={groups}
60+
getItemKey={(g) => String(g.id)}
61+
renderItem={(g) => (
62+
<div className="flex items-center gap-2 py-1 text-sm text-muted-foreground">
63+
<div
64+
className="w-3 h-3 rounded-full shrink-0"
65+
style={{ backgroundColor: g.color }}
66+
/>
67+
<span className="truncate flex-1">{g.label}</span>
68+
<span className="text-xs tabular-nums">{g.count}</span>
69+
</div>
70+
)}
71+
getSearchKey={(g) => g.label}
72+
searchPlaceholder="Filter groups..."
73+
maxHeight={200}
74+
searchThreshold={10}
75+
/>
76+
</div>
77+
</div>
78+
)}
79+
</>
80+
);
81+
}

0 commit comments

Comments
 (0)