@medicine-wheel/app 0.4.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/graph/page.tsx +225 -3
- package/app/nodes/page.tsx +23 -1
- package/lib/graph-layout-storage.ts +265 -0
- package/package.json +18 -18
package/app/graph/page.tsx
CHANGED
|
@@ -2,14 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
import "@xyflow/react/dist/style.css";
|
|
4
4
|
|
|
5
|
-
import { useEffect, useState, useCallback, useMemo } from "react";
|
|
5
|
+
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
|
6
6
|
import dynamic from "next/dynamic";
|
|
7
|
+
import Link from "next/link";
|
|
8
|
+
import { useRouter } from "next/navigation";
|
|
9
|
+
import {
|
|
10
|
+
BookOpen,
|
|
11
|
+
CircleDot,
|
|
12
|
+
Flame,
|
|
13
|
+
GitFork,
|
|
14
|
+
RefreshCw,
|
|
15
|
+
Route,
|
|
16
|
+
Save,
|
|
17
|
+
ShieldCheck,
|
|
18
|
+
} from "lucide-react";
|
|
7
19
|
import { type RelationalNode, type RelationalEdge, DIRECTION_COLORS } from "@/lib/types";
|
|
8
20
|
import {
|
|
21
|
+
applyWheelLayout,
|
|
9
22
|
buildGraphData,
|
|
10
23
|
type MWGraphData,
|
|
11
24
|
type MWGraphNode,
|
|
25
|
+
type MWGraphNodePositions,
|
|
12
26
|
} from "@medicine-wheel/graph-viz";
|
|
27
|
+
import {
|
|
28
|
+
CURRENT_GRAPH_LAYOUT_ID,
|
|
29
|
+
getActiveGraphLayout,
|
|
30
|
+
loadStoredGraphLayoutStore,
|
|
31
|
+
persistGraphLayoutStore,
|
|
32
|
+
saveNamedGraphLayout,
|
|
33
|
+
selectGraphLayout,
|
|
34
|
+
upsertCurrentGraphLayout,
|
|
35
|
+
type GraphLayoutStore,
|
|
36
|
+
} from "@/lib/graph-layout-storage";
|
|
13
37
|
import { toast } from "sonner";
|
|
14
38
|
|
|
15
39
|
// React Flow touches `window`/`document`, so the interactive renderer is
|
|
@@ -29,11 +53,61 @@ const MedicineWheelFlowGraph = dynamic(
|
|
|
29
53
|
},
|
|
30
54
|
);
|
|
31
55
|
|
|
56
|
+
const GRAPH_COMPONENT_LINKS = [
|
|
57
|
+
{ href: "/nodes", label: "Nodes", icon: CircleDot },
|
|
58
|
+
{ href: "/relations", label: "Relations", icon: GitFork },
|
|
59
|
+
{ href: "/ceremonies", label: "Ceremonies", icon: Flame },
|
|
60
|
+
{ href: "/narrative", label: "Narrative", icon: BookOpen },
|
|
61
|
+
{ href: "/narrative/beats", label: "Beats", icon: Route },
|
|
62
|
+
{ href: "/accountability", label: "Accountability", icon: ShieldCheck },
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
function graphNodePositions(data: MWGraphData): MWGraphNodePositions {
|
|
66
|
+
const laidOut = applyWheelLayout({
|
|
67
|
+
nodes: data.nodes.map((node) => ({ ...node })),
|
|
68
|
+
links: data.links,
|
|
69
|
+
});
|
|
70
|
+
const positions: MWGraphNodePositions = {};
|
|
71
|
+
|
|
72
|
+
for (const node of laidOut.nodes) {
|
|
73
|
+
if (typeof node.x === "number" && typeof node.y === "number") {
|
|
74
|
+
positions[node.id] = { x: node.x, y: node.y };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return positions;
|
|
79
|
+
}
|
|
80
|
+
|
|
32
81
|
export default function GraphPage() {
|
|
82
|
+
const router = useRouter();
|
|
33
83
|
const [graph, setGraph] = useState<MWGraphData>({ nodes: [], links: [] });
|
|
34
84
|
const [selectedNode, setSelectedNode] = useState<MWGraphNode | null>(null);
|
|
35
85
|
const [loading, setLoading] = useState(true);
|
|
36
86
|
const [showLabels, setShowLabels] = useState(true);
|
|
87
|
+
const [layoutName, setLayoutName] = useState("");
|
|
88
|
+
const [layoutsHydrated, setLayoutsHydrated] = useState(false);
|
|
89
|
+
const [layoutStore, setLayoutStore] = useState<GraphLayoutStore>(() =>
|
|
90
|
+
loadStoredGraphLayoutStore(null),
|
|
91
|
+
);
|
|
92
|
+
const layoutStoreRef = useRef(layoutStore);
|
|
93
|
+
|
|
94
|
+
const saveLayoutStore = useCallback(
|
|
95
|
+
(
|
|
96
|
+
nextStore: GraphLayoutStore,
|
|
97
|
+
options: { notifyOnFailure?: boolean } = {},
|
|
98
|
+
) => {
|
|
99
|
+
layoutStoreRef.current = nextStore;
|
|
100
|
+
setLayoutStore(nextStore);
|
|
101
|
+
|
|
102
|
+
const persisted = persistGraphLayoutStore(nextStore);
|
|
103
|
+
if (!persisted && options.notifyOnFailure) {
|
|
104
|
+
toast.error("Could not save graph disposition");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return persisted;
|
|
108
|
+
},
|
|
109
|
+
[],
|
|
110
|
+
);
|
|
37
111
|
|
|
38
112
|
const loadData = useCallback(async () => {
|
|
39
113
|
try {
|
|
@@ -58,6 +132,28 @@ export default function GraphPage() {
|
|
|
58
132
|
loadData();
|
|
59
133
|
}, [loadData]);
|
|
60
134
|
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
const storedLayouts = loadStoredGraphLayoutStore();
|
|
137
|
+
layoutStoreRef.current = storedLayouts;
|
|
138
|
+
setLayoutStore(storedLayouts);
|
|
139
|
+
setLayoutsHydrated(true);
|
|
140
|
+
}, []);
|
|
141
|
+
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
layoutStoreRef.current = layoutStore;
|
|
144
|
+
}, [layoutStore]);
|
|
145
|
+
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
if (!layoutsHydrated || graph.nodes.length === 0) return;
|
|
148
|
+
|
|
149
|
+
const activeLayout = getActiveGraphLayout(layoutStoreRef.current);
|
|
150
|
+
if (Object.keys(activeLayout.positions).length > 0) return;
|
|
151
|
+
|
|
152
|
+
saveLayoutStore(
|
|
153
|
+
upsertCurrentGraphLayout(layoutStoreRef.current, graphNodePositions(graph)),
|
|
154
|
+
);
|
|
155
|
+
}, [graph, layoutsHydrated, saveLayoutStore]);
|
|
156
|
+
|
|
61
157
|
const ceremoniedCount = useMemo(
|
|
62
158
|
() => graph.links.filter((l) => l.ceremonyHonored).length,
|
|
63
159
|
[graph.links],
|
|
@@ -66,6 +162,58 @@ export default function GraphPage() {
|
|
|
66
162
|
() => new Set(graph.nodes.map((n) => n.direction).filter(Boolean)).size,
|
|
67
163
|
[graph.nodes],
|
|
68
164
|
);
|
|
165
|
+
const activeLayout = useMemo(() => getActiveGraphLayout(layoutStore), [layoutStore]);
|
|
166
|
+
const savedLayouts = useMemo(
|
|
167
|
+
() => layoutStore.layouts.filter((layout) => layout.id !== CURRENT_GRAPH_LAYOUT_ID),
|
|
168
|
+
[layoutStore.layouts],
|
|
169
|
+
);
|
|
170
|
+
const rememberedPositionCount = useMemo(
|
|
171
|
+
() => Object.keys(activeLayout.positions).length,
|
|
172
|
+
[activeLayout.positions],
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const handleNodePositionsChange = useCallback(
|
|
176
|
+
(positions: MWGraphNodePositions) => {
|
|
177
|
+
saveLayoutStore(upsertCurrentGraphLayout(layoutStoreRef.current, positions));
|
|
178
|
+
},
|
|
179
|
+
[saveLayoutStore],
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const saveNamedLayout = useCallback(() => {
|
|
183
|
+
const name = layoutName.trim();
|
|
184
|
+
if (!name) {
|
|
185
|
+
toast.error("Name the disposition first");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const positions = getActiveGraphLayout(layoutStoreRef.current).positions;
|
|
190
|
+
if (Object.keys(positions).length === 0) {
|
|
191
|
+
toast.error("Move a node before saving");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const nextStore = saveNamedGraphLayout(layoutStoreRef.current, name, positions);
|
|
196
|
+
const persisted = saveLayoutStore(nextStore, { notifyOnFailure: true });
|
|
197
|
+
if (persisted) toast.success(`Saved ${name}`);
|
|
198
|
+
setLayoutName("");
|
|
199
|
+
}, [layoutName, saveLayoutStore]);
|
|
200
|
+
|
|
201
|
+
const loadNamedLayout = useCallback(
|
|
202
|
+
(layoutId: string) => {
|
|
203
|
+
const nextStore = selectGraphLayout(layoutStoreRef.current, layoutId);
|
|
204
|
+
const active = getActiveGraphLayout(nextStore);
|
|
205
|
+
const persisted = saveLayoutStore(nextStore, { notifyOnFailure: true });
|
|
206
|
+
if (persisted) toast.success(`Loaded ${active.name}`);
|
|
207
|
+
},
|
|
208
|
+
[saveLayoutStore],
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const navigateToNode = useCallback(
|
|
212
|
+
(node: MWGraphNode) => {
|
|
213
|
+
router.push(`/nodes?node=${encodeURIComponent(node.id)}`);
|
|
214
|
+
},
|
|
215
|
+
[router],
|
|
216
|
+
);
|
|
69
217
|
|
|
70
218
|
return (
|
|
71
219
|
<div className="min-h-screen bg-[#0a0a1a] text-white p-6">
|
|
@@ -86,8 +234,8 @@ export default function GraphPage() {
|
|
|
86
234
|
>
|
|
87
235
|
Labels {showLabels ? "ON" : "OFF"}
|
|
88
236
|
</button>
|
|
89
|
-
<button onClick={loadData} className="px-3 py-1.5 rounded text-sm bg-white/5 hover:bg-white/10">
|
|
90
|
-
|
|
237
|
+
<button onClick={loadData} className="px-3 py-1.5 rounded text-sm bg-white/5 hover:bg-white/10 inline-flex items-center gap-2">
|
|
238
|
+
<RefreshCw className="h-4 w-4" /> Refresh
|
|
91
239
|
</button>
|
|
92
240
|
</div>
|
|
93
241
|
</div>
|
|
@@ -108,12 +256,80 @@ export default function GraphPage() {
|
|
|
108
256
|
height={600}
|
|
109
257
|
darkMode
|
|
110
258
|
showNodeLabels={showLabels}
|
|
259
|
+
nodePositions={activeLayout.positions}
|
|
111
260
|
onNodeClick={(node) => setSelectedNode(node)}
|
|
261
|
+
onNodeDoubleClick={navigateToNode}
|
|
262
|
+
onNodePositionsChange={handleNodePositionsChange}
|
|
112
263
|
/>
|
|
113
264
|
)}
|
|
114
265
|
</div>
|
|
115
266
|
|
|
116
267
|
<div className="w-72 space-y-4">
|
|
268
|
+
<div className="rounded-lg border border-white/10 p-4">
|
|
269
|
+
<h3 className="text-sm font-semibold text-gray-400 mb-3">Dispositions</h3>
|
|
270
|
+
<div className="space-y-3">
|
|
271
|
+
<select
|
|
272
|
+
value={layoutStore.activeLayoutId}
|
|
273
|
+
onChange={(event) => loadNamedLayout(event.target.value)}
|
|
274
|
+
className="w-full rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm text-white"
|
|
275
|
+
aria-label="Graph disposition"
|
|
276
|
+
>
|
|
277
|
+
<option className="bg-[#101020]" value={CURRENT_GRAPH_LAYOUT_ID}>
|
|
278
|
+
Last positioning
|
|
279
|
+
</option>
|
|
280
|
+
{savedLayouts.map((layout) => (
|
|
281
|
+
<option className="bg-[#101020]" key={layout.id} value={layout.id}>
|
|
282
|
+
{layout.name}
|
|
283
|
+
</option>
|
|
284
|
+
))}
|
|
285
|
+
</select>
|
|
286
|
+
|
|
287
|
+
<div className="flex gap-2">
|
|
288
|
+
<input
|
|
289
|
+
value={layoutName}
|
|
290
|
+
onChange={(event) => setLayoutName(event.target.value)}
|
|
291
|
+
onKeyDown={(event) => {
|
|
292
|
+
if (event.key === "Enter") saveNamedLayout();
|
|
293
|
+
}}
|
|
294
|
+
placeholder="Name disposition"
|
|
295
|
+
className="min-w-0 flex-1 rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm text-white placeholder:text-gray-500"
|
|
296
|
+
/>
|
|
297
|
+
<button
|
|
298
|
+
onClick={saveNamedLayout}
|
|
299
|
+
className="inline-flex h-9 w-9 items-center justify-center rounded-md bg-white/10 text-white hover:bg-white/15"
|
|
300
|
+
aria-label="Save named disposition"
|
|
301
|
+
title="Save named disposition"
|
|
302
|
+
>
|
|
303
|
+
<Save className="h-4 w-4" />
|
|
304
|
+
</button>
|
|
305
|
+
</div>
|
|
306
|
+
|
|
307
|
+
<p className="text-xs text-gray-500">
|
|
308
|
+
{rememberedPositionCount} positions remembered
|
|
309
|
+
</p>
|
|
310
|
+
</div>
|
|
311
|
+
</div>
|
|
312
|
+
|
|
313
|
+
<div className="rounded-lg border border-white/10 p-4">
|
|
314
|
+
<h3 className="text-sm font-semibold text-gray-400 mb-3">Related</h3>
|
|
315
|
+
<div className="grid grid-cols-2 gap-2">
|
|
316
|
+
{GRAPH_COMPONENT_LINKS.map((item) => {
|
|
317
|
+
const Icon = item.icon;
|
|
318
|
+
|
|
319
|
+
return (
|
|
320
|
+
<Link
|
|
321
|
+
key={item.href}
|
|
322
|
+
href={item.href}
|
|
323
|
+
className="inline-flex min-h-10 items-center gap-2 rounded-md bg-white/5 px-2 text-xs text-gray-200 hover:bg-white/10"
|
|
324
|
+
>
|
|
325
|
+
<Icon className="h-4 w-4 shrink-0 text-gray-400" />
|
|
326
|
+
<span className="truncate">{item.label}</span>
|
|
327
|
+
</Link>
|
|
328
|
+
);
|
|
329
|
+
})}
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
|
|
117
333
|
<div className="rounded-xl border border-white/10 p-4">
|
|
118
334
|
<h3 className="text-sm font-semibold text-gray-400 mb-3">Directions</h3>
|
|
119
335
|
{(["east", "south", "west", "north"] as const).map((dir) => (
|
|
@@ -132,6 +348,12 @@ export default function GraphPage() {
|
|
|
132
348
|
<div><span className="text-xs text-gray-500">Type</span><p className="capitalize">{selectedNode.type}</p></div>
|
|
133
349
|
{selectedNode.direction && <div><span className="text-xs text-gray-500">Direction</span><p className="capitalize">{selectedNode.direction}</p></div>}
|
|
134
350
|
<div><span className="text-xs text-gray-500">ID</span><p className="text-xs text-gray-400 font-mono break-all">{selectedNode.id}</p></div>
|
|
351
|
+
<Link
|
|
352
|
+
href={`/nodes?node=${encodeURIComponent(selectedNode.id)}`}
|
|
353
|
+
className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-white/10 px-3 py-2 text-sm text-white hover:bg-white/15"
|
|
354
|
+
>
|
|
355
|
+
<CircleDot className="h-4 w-4" /> Open node
|
|
356
|
+
</Link>
|
|
135
357
|
</div>
|
|
136
358
|
</div>
|
|
137
359
|
)}
|
package/app/nodes/page.tsx
CHANGED
|
@@ -26,6 +26,7 @@ export default function NodesPage() {
|
|
|
26
26
|
const [searchQuery, setSearchQuery] = useState("");
|
|
27
27
|
const [expandedNode, setExpandedNode] = useState<string | null>(null);
|
|
28
28
|
const [showAddNode, setShowAddNode] = useState(false);
|
|
29
|
+
const [focusedNodeId, setFocusedNodeId] = useState<string | null>(null);
|
|
29
30
|
|
|
30
31
|
const loadNodes = useCallback(async () => {
|
|
31
32
|
setLoading(true);
|
|
@@ -54,6 +55,23 @@ export default function NodesPage() {
|
|
|
54
55
|
|
|
55
56
|
useEffect(() => { loadNodes(); }, [loadNodes]);
|
|
56
57
|
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
const params = new URLSearchParams(window.location.search);
|
|
60
|
+
setFocusedNodeId(params.get("node"));
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (loading || !focusedNodeId || !nodes.some((node) => node.id === focusedNodeId)) return;
|
|
65
|
+
|
|
66
|
+
setExpandedNode(focusedNodeId);
|
|
67
|
+
window.requestAnimationFrame(() => {
|
|
68
|
+
document.getElementById(`node-${focusedNodeId}`)?.scrollIntoView({
|
|
69
|
+
block: "center",
|
|
70
|
+
behavior: "smooth",
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}, [focusedNodeId, loading, nodes]);
|
|
74
|
+
|
|
57
75
|
const filteredNodes = nodes.filter((node) => {
|
|
58
76
|
if (!searchQuery) return true;
|
|
59
77
|
const q = searchQuery.toLowerCase();
|
|
@@ -145,7 +163,11 @@ export default function NodesPage() {
|
|
|
145
163
|
) : (
|
|
146
164
|
<div className="space-y-2">
|
|
147
165
|
{filteredNodes.map((node) => (
|
|
148
|
-
<div
|
|
166
|
+
<div
|
|
167
|
+
key={node.id}
|
|
168
|
+
id={`node-${node.id}`}
|
|
169
|
+
className={`border rounded-lg bg-card overflow-hidden transition-all ${expandedNode === node.id ? "ring-2 ring-primary" : ""}`}
|
|
170
|
+
>
|
|
149
171
|
<button
|
|
150
172
|
onClick={() => setExpandedNode(expandedNode === node.id ? null : node.id)}
|
|
151
173
|
className="w-full px-4 py-3 flex items-center gap-3 text-left hover:bg-secondary/30"
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import type { MWGraphNodePositions } from "@medicine-wheel/graph-viz";
|
|
2
|
+
|
|
3
|
+
export const GRAPH_LAYOUT_STORE_VERSION = 1;
|
|
4
|
+
export const GRAPH_LAYOUT_STORAGE_KEY = "medicine-wheel:graph-layouts:v1";
|
|
5
|
+
export const CURRENT_GRAPH_LAYOUT_ID = "current";
|
|
6
|
+
|
|
7
|
+
export interface GraphLayoutDisposition {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
positions: MWGraphNodePositions;
|
|
11
|
+
updatedAt: string;
|
|
12
|
+
nodeCount: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface GraphLayoutStore {
|
|
16
|
+
version: typeof GRAPH_LAYOUT_STORE_VERSION;
|
|
17
|
+
activeLayoutId: string;
|
|
18
|
+
layouts: GraphLayoutDisposition[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface StorageLike {
|
|
22
|
+
getItem(key: string): string | null;
|
|
23
|
+
setItem(key: string, value: string): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function nowIso(): string {
|
|
27
|
+
return new Date().toISOString();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function safeStorage(): StorageLike | null {
|
|
31
|
+
if (typeof window === "undefined") return null;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
return window.localStorage;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function currentLayout(
|
|
41
|
+
positions: MWGraphNodePositions = {},
|
|
42
|
+
updatedAt: string = nowIso(),
|
|
43
|
+
): GraphLayoutDisposition {
|
|
44
|
+
return {
|
|
45
|
+
id: CURRENT_GRAPH_LAYOUT_ID,
|
|
46
|
+
name: "Last positioning",
|
|
47
|
+
positions,
|
|
48
|
+
updatedAt,
|
|
49
|
+
nodeCount: Object.keys(positions).length,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function slugLayoutName(name: string): string {
|
|
54
|
+
const slug = name
|
|
55
|
+
.trim()
|
|
56
|
+
.toLowerCase()
|
|
57
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
58
|
+
.replace(/(^-|-$)/g, "");
|
|
59
|
+
|
|
60
|
+
return slug || "layout";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function layoutIdForName(name: string): string {
|
|
64
|
+
return `layout:${slugLayoutName(name)}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function sanitizeGraphPositions(
|
|
68
|
+
positions: unknown,
|
|
69
|
+
): MWGraphNodePositions {
|
|
70
|
+
if (!positions || typeof positions !== "object" || Array.isArray(positions)) {
|
|
71
|
+
return {};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const sanitized: MWGraphNodePositions = {};
|
|
75
|
+
|
|
76
|
+
for (const [nodeId, value] of Object.entries(positions)) {
|
|
77
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
|
|
78
|
+
|
|
79
|
+
const x = (value as { x?: unknown }).x;
|
|
80
|
+
const y = (value as { y?: unknown }).y;
|
|
81
|
+
|
|
82
|
+
if (typeof x === "number" && typeof y === "number" && Number.isFinite(x) && Number.isFinite(y)) {
|
|
83
|
+
sanitized[nodeId] = {
|
|
84
|
+
x: Number(x.toFixed(2)),
|
|
85
|
+
y: Number(y.toFixed(2)),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return sanitized;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function createEmptyGraphLayoutStore(
|
|
94
|
+
updatedAt: string = nowIso(),
|
|
95
|
+
): GraphLayoutStore {
|
|
96
|
+
return {
|
|
97
|
+
version: GRAPH_LAYOUT_STORE_VERSION,
|
|
98
|
+
activeLayoutId: CURRENT_GRAPH_LAYOUT_ID,
|
|
99
|
+
layouts: [currentLayout({}, updatedAt)],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function normalizeGraphLayoutStore(value: unknown): GraphLayoutStore {
|
|
104
|
+
const empty = createEmptyGraphLayoutStore();
|
|
105
|
+
|
|
106
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
107
|
+
return empty;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const candidate = value as {
|
|
111
|
+
version?: unknown;
|
|
112
|
+
activeLayoutId?: unknown;
|
|
113
|
+
layouts?: unknown;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
if (candidate.version !== GRAPH_LAYOUT_STORE_VERSION || !Array.isArray(candidate.layouts)) {
|
|
117
|
+
return empty;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const layouts = candidate.layouts.flatMap((layout): GraphLayoutDisposition[] => {
|
|
121
|
+
if (!layout || typeof layout !== "object" || Array.isArray(layout)) return [];
|
|
122
|
+
|
|
123
|
+
const item = layout as {
|
|
124
|
+
id?: unknown;
|
|
125
|
+
name?: unknown;
|
|
126
|
+
positions?: unknown;
|
|
127
|
+
updatedAt?: unknown;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
if (typeof item.id !== "string" || typeof item.name !== "string") {
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const positions = sanitizeGraphPositions(item.positions);
|
|
135
|
+
|
|
136
|
+
return [{
|
|
137
|
+
id: item.id,
|
|
138
|
+
name: item.name,
|
|
139
|
+
positions,
|
|
140
|
+
updatedAt: typeof item.updatedAt === "string" ? item.updatedAt : empty.layouts[0].updatedAt,
|
|
141
|
+
nodeCount: Object.keys(positions).length,
|
|
142
|
+
}];
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const withoutDuplicateCurrent = layouts.filter((layout) => layout.id !== CURRENT_GRAPH_LAYOUT_ID);
|
|
146
|
+
const current = layouts.find((layout) => layout.id === CURRENT_GRAPH_LAYOUT_ID) ?? empty.layouts[0];
|
|
147
|
+
const normalizedLayouts = [current, ...withoutDuplicateCurrent];
|
|
148
|
+
const activeLayoutId =
|
|
149
|
+
typeof candidate.activeLayoutId === "string" &&
|
|
150
|
+
normalizedLayouts.some((layout) => layout.id === candidate.activeLayoutId)
|
|
151
|
+
? candidate.activeLayoutId
|
|
152
|
+
: CURRENT_GRAPH_LAYOUT_ID;
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
version: GRAPH_LAYOUT_STORE_VERSION,
|
|
156
|
+
activeLayoutId,
|
|
157
|
+
layouts: normalizedLayouts,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function parseGraphLayoutStore(raw: string | null): GraphLayoutStore {
|
|
162
|
+
if (!raw) return createEmptyGraphLayoutStore();
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
return normalizeGraphLayoutStore(JSON.parse(raw));
|
|
166
|
+
} catch {
|
|
167
|
+
return createEmptyGraphLayoutStore();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function loadStoredGraphLayoutStore(
|
|
172
|
+
storage: StorageLike | null = safeStorage(),
|
|
173
|
+
): GraphLayoutStore {
|
|
174
|
+
if (!storage) return createEmptyGraphLayoutStore();
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
return parseGraphLayoutStore(storage.getItem(GRAPH_LAYOUT_STORAGE_KEY));
|
|
178
|
+
} catch {
|
|
179
|
+
return createEmptyGraphLayoutStore();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function persistGraphLayoutStore(
|
|
184
|
+
store: GraphLayoutStore,
|
|
185
|
+
storage: StorageLike | null = safeStorage(),
|
|
186
|
+
): boolean {
|
|
187
|
+
if (!storage) return false;
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
storage.setItem(GRAPH_LAYOUT_STORAGE_KEY, JSON.stringify(normalizeGraphLayoutStore(store)));
|
|
191
|
+
return true;
|
|
192
|
+
} catch {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function getActiveGraphLayout(
|
|
198
|
+
store: GraphLayoutStore,
|
|
199
|
+
): GraphLayoutDisposition {
|
|
200
|
+
return (
|
|
201
|
+
store.layouts.find((layout) => layout.id === store.activeLayoutId) ??
|
|
202
|
+
store.layouts.find((layout) => layout.id === CURRENT_GRAPH_LAYOUT_ID) ??
|
|
203
|
+
currentLayout()
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function upsertCurrentGraphLayout(
|
|
208
|
+
store: GraphLayoutStore,
|
|
209
|
+
positions: MWGraphNodePositions,
|
|
210
|
+
updatedAt: string = nowIso(),
|
|
211
|
+
): GraphLayoutStore {
|
|
212
|
+
const current = currentLayout(sanitizeGraphPositions(positions), updatedAt);
|
|
213
|
+
const savedLayouts = store.layouts.filter((layout) => layout.id !== CURRENT_GRAPH_LAYOUT_ID);
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
version: GRAPH_LAYOUT_STORE_VERSION,
|
|
217
|
+
activeLayoutId: CURRENT_GRAPH_LAYOUT_ID,
|
|
218
|
+
layouts: [current, ...savedLayouts],
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function saveNamedGraphLayout(
|
|
223
|
+
store: GraphLayoutStore,
|
|
224
|
+
name: string,
|
|
225
|
+
positions: MWGraphNodePositions,
|
|
226
|
+
updatedAt: string = nowIso(),
|
|
227
|
+
): GraphLayoutStore {
|
|
228
|
+
const trimmedName = name.trim() || "Untitled disposition";
|
|
229
|
+
const id = layoutIdForName(trimmedName);
|
|
230
|
+
const sanitized = sanitizeGraphPositions(positions);
|
|
231
|
+
const namedLayout: GraphLayoutDisposition = {
|
|
232
|
+
id,
|
|
233
|
+
name: trimmedName,
|
|
234
|
+
positions: sanitized,
|
|
235
|
+
updatedAt,
|
|
236
|
+
nodeCount: Object.keys(sanitized).length,
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const current =
|
|
240
|
+
store.layouts.find((layout) => layout.id === CURRENT_GRAPH_LAYOUT_ID) ??
|
|
241
|
+
currentLayout(sanitized, updatedAt);
|
|
242
|
+
const savedLayouts = store.layouts.filter(
|
|
243
|
+
(layout) => layout.id !== CURRENT_GRAPH_LAYOUT_ID && layout.id !== id,
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
version: GRAPH_LAYOUT_STORE_VERSION,
|
|
248
|
+
activeLayoutId: id,
|
|
249
|
+
layouts: [current, namedLayout, ...savedLayouts],
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function selectGraphLayout(
|
|
254
|
+
store: GraphLayoutStore,
|
|
255
|
+
layoutId: string,
|
|
256
|
+
): GraphLayoutStore {
|
|
257
|
+
const activeLayoutId = store.layouts.some((layout) => layout.id === layoutId)
|
|
258
|
+
? layoutId
|
|
259
|
+
: CURRENT_GRAPH_LAYOUT_ID;
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
...store,
|
|
263
|
+
activeLayoutId,
|
|
264
|
+
};
|
|
265
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@medicine-wheel/app",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Medicine Wheel — Interactive visual layer for Indigenous relational research with Four Directions, ceremonies, and narrative arcs",
|
|
5
5
|
"bin": {
|
|
6
6
|
"mw": "dist/cli/mw.js",
|
|
@@ -74,24 +74,24 @@
|
|
|
74
74
|
"release:major": "npm run version:major && npm run publish:all && npm run release:commit"
|
|
75
75
|
},
|
|
76
76
|
"dependencies": {
|
|
77
|
-
"@medicine-wheel/ceremony-protocol": "^0.4.
|
|
78
|
-
"@medicine-wheel/community-review": "^0.4.
|
|
79
|
-
"@medicine-wheel/consent-lifecycle": "^0.4.
|
|
80
|
-
"@medicine-wheel/data-store": "^0.4.
|
|
81
|
-
"@medicine-wheel/data-store-postgres": "^0.4.
|
|
82
|
-
"@medicine-wheel/fire-keeper": "^0.4.
|
|
83
|
-
"@medicine-wheel/graph-viz": "^0.4.
|
|
84
|
-
"@medicine-wheel/importance-unit": "^0.4.
|
|
77
|
+
"@medicine-wheel/ceremony-protocol": "^0.4.2",
|
|
78
|
+
"@medicine-wheel/community-review": "^0.4.2",
|
|
79
|
+
"@medicine-wheel/consent-lifecycle": "^0.4.2",
|
|
80
|
+
"@medicine-wheel/data-store": "^0.4.2",
|
|
81
|
+
"@medicine-wheel/data-store-postgres": "^0.4.2",
|
|
82
|
+
"@medicine-wheel/fire-keeper": "^0.4.2",
|
|
83
|
+
"@medicine-wheel/graph-viz": "^0.4.2",
|
|
84
|
+
"@medicine-wheel/importance-unit": "^0.4.2",
|
|
85
85
|
"@medicine-wheel/mcp": "^4.4.1",
|
|
86
|
-
"@medicine-wheel/narrative-engine": "^0.4.
|
|
87
|
-
"@medicine-wheel/ontology-core": "^0.4.
|
|
88
|
-
"@medicine-wheel/prompt-decomposition": "^0.4.
|
|
89
|
-
"@medicine-wheel/relational-index": "^0.4.
|
|
90
|
-
"@medicine-wheel/relational-query": "^0.4.
|
|
91
|
-
"@medicine-wheel/session-reader": "^0.4.
|
|
92
|
-
"@medicine-wheel/storage-provider": "^0.4.
|
|
93
|
-
"@medicine-wheel/transformation-tracker": "^0.4.
|
|
94
|
-
"@medicine-wheel/ui-components": "^0.4.
|
|
86
|
+
"@medicine-wheel/narrative-engine": "^0.4.2",
|
|
87
|
+
"@medicine-wheel/ontology-core": "^0.4.2",
|
|
88
|
+
"@medicine-wheel/prompt-decomposition": "^0.4.2",
|
|
89
|
+
"@medicine-wheel/relational-index": "^0.4.2",
|
|
90
|
+
"@medicine-wheel/relational-query": "^0.4.2",
|
|
91
|
+
"@medicine-wheel/session-reader": "^0.4.2",
|
|
92
|
+
"@medicine-wheel/storage-provider": "^0.4.2",
|
|
93
|
+
"@medicine-wheel/transformation-tracker": "^0.4.2",
|
|
94
|
+
"@medicine-wheel/ui-components": "^0.4.2",
|
|
95
95
|
"@neondatabase/serverless": "^0.10.0",
|
|
96
96
|
"clsx": "^2.1.1",
|
|
97
97
|
"lucide-react": "^0.475.0",
|