Skip to content

Commit 645ab24

Browse files
committed
Enhance graph management and visualization features
1 parent 50c681b commit 645ab24

File tree

12 files changed

+663
-49
lines changed

12 files changed

+663
-49
lines changed

bun.lock

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

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
"scripts": {
1010
"dev": "bun --hot src/index.tsx",
1111
"start": "NODE_ENV=production bun src/index.tsx",
12-
"build": "bun run build.ts"
12+
"build": "bun run build.ts",
13+
"test": "bun test"
1314
},
1415
"dependencies": {
1516
"3d-force-graph": "^1.79.1",
@@ -32,6 +33,7 @@
3233
"graphology-layout-forceatlas2": "^0.10.1",
3334
"js-yaml": "^4.1.1",
3435
"lucide-react": "^0.475.0",
36+
"lz-string": "^1.5.0",
3537
"radix-ui": "^1.4.3",
3638
"react": "^19",
3739
"react-dom": "^19",

src/App.tsx

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useEffect } from "react";
12
import { ForceGraph3DComponent } from "./components/ForceGraph3D";
23
import { CosmographGraph } from "./components/CosmographGraph";
34
import { SigmaGraph } from "./components/SigmaGraph";
@@ -9,12 +10,45 @@ import { SettingsFAB } from "./components/SettingsFAB";
910
import { SettingsPanel } from "./components/SettingsPanel";
1011
import { ErrorBoundary } from "./components/ErrorBoundary";
1112
import { useGraphStore } from "./store/graphStore";
13+
import { decode, stripSimulationState } from "./lib/graphHash";
1214
import "./index.css";
1315

16+
function useHashGraphSync() {
17+
const setSharedGraph = useGraphStore((state) => state.setSharedGraph);
18+
19+
useEffect(() => {
20+
const applyHash = () => {
21+
const hash = window.location.hash.slice(1);
22+
if (!hash) {
23+
setSharedGraph(null);
24+
return;
25+
}
26+
try {
27+
const { graph, metadata } = decode(hash);
28+
const cleanGraph = stripSimulationState(graph);
29+
setSharedGraph({
30+
graph: JSON.parse(JSON.stringify(cleanGraph)),
31+
metadata,
32+
});
33+
} catch (err) {
34+
console.warn("Failed to decode graph from URL hash:", err);
35+
setSharedGraph(null);
36+
}
37+
};
38+
39+
applyHash();
40+
window.addEventListener("hashchange", applyHash);
41+
return () => window.removeEventListener("hashchange", applyHash);
42+
}, [setSharedGraph]);
43+
}
44+
1445
export function App() {
46+
useHashGraphSync();
1547
const visualizationMode = useGraphStore((state) => state.visualizationMode);
1648
const currentGraphId = useGraphStore((state) => state.currentGraphId);
49+
const sharedGraph = useGraphStore((state) => state.sharedGraph);
1750
const setSelectedNode = useGraphStore((state) => state.setSelectedNode);
51+
const graphKey = sharedGraph ? "url" : currentGraphId;
1852

1953
const handleCloseNodeInfo = () => {
2054
setSelectedNode(null);
@@ -26,11 +60,11 @@ export function App() {
2660
{/* Visualization - Fullscreen (3D, Cosmo, or 2D based on mode) */}
2761
{/* Key includes currentGraphId to force remount on graph change */}
2862
{visualizationMode === "3d" ? (
29-
<ForceGraph3DComponent key={`3d-${currentGraphId}`} />
63+
<ForceGraph3DComponent key={`3d-${graphKey}`} />
3064
) : visualizationMode === "cosmo" ? (
31-
<CosmographGraph key={`cosmo-${currentGraphId}`} />
65+
<CosmographGraph key={`cosmo-${graphKey}`} />
3266
) : (
33-
<SigmaGraph key={`2d-${currentGraphId}`} />
67+
<SigmaGraph key={`2d-${graphKey}`} />
3468
)}
3569

3670
{/* Graph Selector - Top left */}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import NiceModal, { useModal } from "@ebay/nice-modal-react";
2+
import {
3+
Dialog,
4+
DialogContent,
5+
DialogHeader,
6+
DialogTitle,
7+
DialogFooter,
8+
DialogDescription,
9+
} from "@/components/ui/dialog";
10+
import { Button } from "@/components/ui/button";
11+
import type { GraphFile } from "@/store/graphStore";
12+
13+
export interface ConfirmDeleteGraphModalProps {
14+
graph: GraphFile;
15+
onConfirm: () => void;
16+
}
17+
18+
export const ConfirmDeleteGraphModal = NiceModal.create<ConfirmDeleteGraphModalProps>(() => {
19+
const modal = useModal();
20+
const { graph, onConfirm } = modal.args ?? { graph: null, onConfirm: () => {} };
21+
22+
const handleConfirm = () => {
23+
onConfirm();
24+
modal.hide();
25+
setTimeout(() => modal.remove(), 200);
26+
};
27+
28+
const handleOpenChange = (open: boolean) => {
29+
if (!open) {
30+
modal.hide();
31+
setTimeout(() => modal.remove(), 200);
32+
}
33+
};
34+
35+
if (!graph) return null;
36+
37+
return (
38+
<Dialog open={modal.visible} onOpenChange={handleOpenChange}>
39+
<DialogContent showCloseButton={true}>
40+
<DialogHeader>
41+
<DialogTitle>Delete graph</DialogTitle>
42+
<DialogDescription>
43+
Remove &quot;{graph.metadata?.name ?? "Unknown"}&quot; from local storage? This cannot be undone.
44+
</DialogDescription>
45+
</DialogHeader>
46+
<DialogFooter className="gap-2 sm:gap-0">
47+
<Button variant="outline" onClick={() => handleOpenChange(false)}>
48+
Cancel
49+
</Button>
50+
<Button variant="destructive" onClick={handleConfirm}>
51+
Delete
52+
</Button>
53+
</DialogFooter>
54+
</DialogContent>
55+
</Dialog>
56+
);
57+
});

src/components/CosmographGraph.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type { GraphNode } from "@/store/graphStore";
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 graphData = useGraphStore((state) => state.graphData);
12+
const graphData = useGraphStore((state) => state.sharedGraph?.graph ?? state.graphData);
1313
const searchQuery = useGraphStore((state) => state.searchQuery);
1414
const setSelectedNode = useGraphStore((state) => state.setSelectedNode);
1515

src/components/ForceGraph3D.tsx

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export function ForceGraph3DComponent() {
1515
const containerRef = useRef<HTMLDivElement>(null);
1616
const graphRef = useRef<ForceGraphInstance | null>(null);
1717

18-
const graphData = useGraphStore((state) => state.graphData);
18+
const graphData = useGraphStore((state) => state.sharedGraph?.graph ?? state.graphData);
1919
const searchQuery = useGraphStore((state) => state.searchQuery);
2020
const setSelectedNode = useGraphStore((state) => state.setSelectedNode);
2121
const settings = useSettingsStore((state) => state.settings);
@@ -24,11 +24,14 @@ export function ForceGraph3DComponent() {
2424
useEffect(() => {
2525
if (!containerRef.current) return;
2626

27+
// Pass a clone so the library mutates it instead of the store (keeps sharedGraph/graphData pristine)
28+
const dataForLibrary = JSON.parse(JSON.stringify(graphData));
29+
2730
// Initialize the force graph with CSS2DRenderer for HTML labels
2831
const graph = ForceGraph3D({
2932
extraRenderers: [new CSS2DRenderer()]
3033
})(containerRef.current)
31-
.graphData(graphData)
34+
.graphData(dataForLibrary)
3235
.nodeLabel("name")
3336
.nodeColor((node: any) => getGroupColor(node.group))
3437
.nodeOpacity(settings.nodeOpacity)
@@ -64,7 +67,7 @@ export function ForceGraph3DComponent() {
6467
return troikaText;
6568
})
6669
.linkThreeObjectExtend(true)
67-
.linkPositionUpdate((obj: THREE.Object3D, { start, end }: any, link: any) => {
70+
.linkPositionUpdate((obj: THREE.Object3D & { sync: () => void, fontSize: number }, { start, end }: any, link: any) => {
6871
// Midpoint
6972
const mid = new THREE.Vector3(
7073
(start.x + end.x) / 2,
@@ -84,6 +87,7 @@ export function ForceGraph3DComponent() {
8487
const distance = mid.distanceTo(camera.position);
8588
obj.visible = distance < EDGE_LABEL_LOD_THRESHOLD;
8689

90+
8791
// Optional: scale font size inversely with distance for depth cue
8892
if (obj.visible && typeof obj.sync === "function") {
8993
const baseSize = settings.linkLabelFontSize * 0.25;
@@ -128,8 +132,8 @@ export function ForceGraph3DComponent() {
128132
const query = searchQuery.toLowerCase();
129133

130134
if (!query) {
131-
// Reset all nodes to visible
132-
graphRef.current.graphData(graphData);
135+
// Reset all nodes to visible (pass clone so library does not mutate store)
136+
graphRef.current.graphData(JSON.parse(JSON.stringify(graphData)));
133137
return;
134138
}
135139

@@ -143,14 +147,13 @@ export function ForceGraph3DComponent() {
143147
// Filter links to only show connections between visible nodes
144148
const filteredLinks = graphData.links.filter(
145149
(link: any) =>
146-
filteredNodeIds.has(link.source.id || link.source) &&
147-
filteredNodeIds.has(link.target.id || link.target)
150+
filteredNodeIds.has(link.source?.id ?? link.source) &&
151+
filteredNodeIds.has(link.target?.id ?? link.target)
148152
);
149153

150-
graphRef.current.graphData({
151-
nodes: filteredNodes,
152-
links: filteredLinks,
153-
});
154+
graphRef.current.graphData(
155+
JSON.parse(JSON.stringify({ nodes: filteredNodes, links: filteredLinks }))
156+
);
154157
}, [searchQuery, graphData]);
155158

156159
return (

0 commit comments

Comments
 (0)