@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.
@@ -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
- Refresh
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
  )}
@@ -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 key={node.id} className={`border rounded-lg bg-card overflow-hidden transition-all ${expandedNode === node.id ? "ring-2 ring-primary" : ""}`}>
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.1",
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.1",
78
- "@medicine-wheel/community-review": "^0.4.1",
79
- "@medicine-wheel/consent-lifecycle": "^0.4.1",
80
- "@medicine-wheel/data-store": "^0.4.1",
81
- "@medicine-wheel/data-store-postgres": "^0.4.1",
82
- "@medicine-wheel/fire-keeper": "^0.4.1",
83
- "@medicine-wheel/graph-viz": "^0.4.1",
84
- "@medicine-wheel/importance-unit": "^0.4.1",
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.1",
87
- "@medicine-wheel/ontology-core": "^0.4.1",
88
- "@medicine-wheel/prompt-decomposition": "^0.4.1",
89
- "@medicine-wheel/relational-index": "^0.4.1",
90
- "@medicine-wheel/relational-query": "^0.4.1",
91
- "@medicine-wheel/session-reader": "^0.4.1",
92
- "@medicine-wheel/storage-provider": "^0.4.1",
93
- "@medicine-wheel/transformation-tracker": "^0.4.1",
94
- "@medicine-wheel/ui-components": "^0.4.1",
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",