@medicine-wheel/app 0.4.1 → 0.4.3

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.
@@ -12,7 +12,9 @@ export async function GET(request: Request) {
12
12
 
13
13
  if (direction) {
14
14
  ceremonies = ceremonies.filter((c) => c.direction === direction);
15
- } else if (type) {
15
+ }
16
+
17
+ if (type) {
16
18
  ceremonies = ceremonies.filter((c) => c.type === type);
17
19
  }
18
20
 
@@ -1,9 +1,10 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useState, Suspense, useMemo } from "react";
3
+ import { useCallback, useEffect, useState, Suspense, useMemo } from "react";
4
4
  import type { FormEvent } from "react";
5
5
  import { useSearchParams } from "next/navigation";
6
6
  import { type CeremonyLog, DIRECTION_COLORS, CEREMONY_ICONS, type DirectionName, type CeremonyType } from "@/lib/types";
7
+ import { extractCeremonyLogs } from "@/lib/ceremony-response";
7
8
  import { toast } from "sonner";
8
9
 
9
10
  function CeremoniesContent() {
@@ -14,16 +15,25 @@ function CeremoniesContent() {
14
15
  const [showForm, setShowForm] = useState(false);
15
16
  const [expandedId, setExpandedId] = useState<string | null>(null);
16
17
 
17
- useEffect(() => {
18
+ const loadCeremonies = useCallback(async () => {
18
19
  const params = new URLSearchParams();
19
20
  if (filterDir !== "all") params.set("direction", filterDir);
20
21
  if (filterType !== "all") params.set("type", filterType);
21
- fetch(`/api/ceremonies?${params}`)
22
- .then((r) => r.json())
23
- .then((data) => setCeremonies(Array.isArray(data) ? data : []))
24
- .catch(() => setCeremonies([]));
22
+
23
+ try {
24
+ const res = await fetch(`/api/ceremonies?${params}`);
25
+ if (!res.ok) throw new Error("Failed to load ceremonies");
26
+ const data = await res.json();
27
+ setCeremonies(extractCeremonyLogs(data));
28
+ } catch {
29
+ setCeremonies([]);
30
+ }
25
31
  }, [filterDir, filterType]);
26
32
 
33
+ useEffect(() => {
34
+ loadCeremonies();
35
+ }, [loadCeremonies]);
36
+
27
37
  async function logCeremony(e: FormEvent<HTMLFormElement>) {
28
38
  e.preventDefault();
29
39
  const form = new FormData(e.currentTarget);
@@ -39,8 +49,7 @@ function CeremoniesContent() {
39
49
  if (res.ok) {
40
50
  toast.success("Ceremony logged");
41
51
  setShowForm(false);
42
- const data = await fetch("/api/ceremonies").then((r) => r.json());
43
- setCeremonies(Array.isArray(data) ? data : []);
52
+ await loadCeremonies();
44
53
  } else { toast.error("Failed to log ceremony"); }
45
54
  }
46
55
 
@@ -2,14 +2,44 @@
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
+ import type { ChangeEvent } from "react";
6
7
  import dynamic from "next/dynamic";
8
+ import Link from "next/link";
9
+ import { useRouter } from "next/navigation";
10
+ import {
11
+ BookOpen,
12
+ CircleDot,
13
+ Flame,
14
+ GitFork,
15
+ RefreshCw,
16
+ Route,
17
+ Save,
18
+ ShieldCheck,
19
+ } from "lucide-react";
7
20
  import { type RelationalNode, type RelationalEdge, DIRECTION_COLORS } from "@/lib/types";
8
21
  import {
22
+ applyWheelLayout,
9
23
  buildGraphData,
10
24
  type MWGraphData,
11
25
  type MWGraphNode,
26
+ type MWGraphNodePositions,
12
27
  } from "@medicine-wheel/graph-viz";
28
+ import {
29
+ CURRENT_GRAPH_LAYOUT_ID,
30
+ getActiveGraphLayout,
31
+ loadStoredGraphLayoutStore,
32
+ persistGraphLayoutStore,
33
+ saveNamedGraphLayout,
34
+ selectGraphLayout,
35
+ upsertCurrentGraphLayout,
36
+ type GraphLayoutStore,
37
+ } from "@/lib/graph-layout-storage";
38
+ import {
39
+ DEFAULT_GRAPH_ANIMATION_ENABLED,
40
+ loadStoredGraphAnimationPreference,
41
+ persistGraphAnimationPreference,
42
+ } from "@/lib/graph-animation-storage";
13
43
  import { toast } from "sonner";
14
44
 
15
45
  // React Flow touches `window`/`document`, so the interactive renderer is
@@ -29,11 +59,64 @@ const MedicineWheelFlowGraph = dynamic(
29
59
  },
30
60
  );
31
61
 
62
+ const GRAPH_COMPONENT_LINKS = [
63
+ { href: "/nodes", label: "Nodes", icon: CircleDot },
64
+ { href: "/relations", label: "Relations", icon: GitFork },
65
+ { href: "/ceremonies", label: "Ceremonies", icon: Flame },
66
+ { href: "/narrative", label: "Narrative", icon: BookOpen },
67
+ { href: "/narrative/beats", label: "Beats", icon: Route },
68
+ { href: "/accountability", label: "Accountability", icon: ShieldCheck },
69
+ ];
70
+
71
+ function graphNodePositions(data: MWGraphData): MWGraphNodePositions {
72
+ const laidOut = applyWheelLayout({
73
+ nodes: data.nodes.map((node) => ({ ...node })),
74
+ links: data.links,
75
+ });
76
+ const positions: MWGraphNodePositions = {};
77
+
78
+ for (const node of laidOut.nodes) {
79
+ if (typeof node.x === "number" && typeof node.y === "number") {
80
+ positions[node.id] = { x: node.x, y: node.y };
81
+ }
82
+ }
83
+
84
+ return positions;
85
+ }
86
+
32
87
  export default function GraphPage() {
88
+ const router = useRouter();
33
89
  const [graph, setGraph] = useState<MWGraphData>({ nodes: [], links: [] });
34
90
  const [selectedNode, setSelectedNode] = useState<MWGraphNode | null>(null);
35
91
  const [loading, setLoading] = useState(true);
36
92
  const [showLabels, setShowLabels] = useState(true);
93
+ const [animationsEnabled, setAnimationsEnabled] = useState(
94
+ DEFAULT_GRAPH_ANIMATION_ENABLED,
95
+ );
96
+ const [layoutName, setLayoutName] = useState("");
97
+ const [layoutsHydrated, setLayoutsHydrated] = useState(false);
98
+ const [layoutStore, setLayoutStore] = useState<GraphLayoutStore>(() =>
99
+ loadStoredGraphLayoutStore(null),
100
+ );
101
+ const layoutStoreRef = useRef(layoutStore);
102
+
103
+ const saveLayoutStore = useCallback(
104
+ (
105
+ nextStore: GraphLayoutStore,
106
+ options: { notifyOnFailure?: boolean } = {},
107
+ ) => {
108
+ layoutStoreRef.current = nextStore;
109
+ setLayoutStore(nextStore);
110
+
111
+ const persisted = persistGraphLayoutStore(nextStore);
112
+ if (!persisted && options.notifyOnFailure) {
113
+ toast.error("Could not save graph disposition");
114
+ }
115
+
116
+ return persisted;
117
+ },
118
+ [],
119
+ );
37
120
 
38
121
  const loadData = useCallback(async () => {
39
122
  try {
@@ -58,6 +141,32 @@ export default function GraphPage() {
58
141
  loadData();
59
142
  }, [loadData]);
60
143
 
144
+ useEffect(() => {
145
+ const storedLayouts = loadStoredGraphLayoutStore();
146
+ layoutStoreRef.current = storedLayouts;
147
+ setLayoutStore(storedLayouts);
148
+ setLayoutsHydrated(true);
149
+ }, []);
150
+
151
+ useEffect(() => {
152
+ setAnimationsEnabled(loadStoredGraphAnimationPreference());
153
+ }, []);
154
+
155
+ useEffect(() => {
156
+ layoutStoreRef.current = layoutStore;
157
+ }, [layoutStore]);
158
+
159
+ useEffect(() => {
160
+ if (!layoutsHydrated || graph.nodes.length === 0) return;
161
+
162
+ const activeLayout = getActiveGraphLayout(layoutStoreRef.current);
163
+ if (Object.keys(activeLayout.positions).length > 0) return;
164
+
165
+ saveLayoutStore(
166
+ upsertCurrentGraphLayout(layoutStoreRef.current, graphNodePositions(graph)),
167
+ );
168
+ }, [graph, layoutsHydrated, saveLayoutStore]);
169
+
61
170
  const ceremoniedCount = useMemo(
62
171
  () => graph.links.filter((l) => l.ceremonyHonored).length,
63
172
  [graph.links],
@@ -66,6 +175,70 @@ export default function GraphPage() {
66
175
  () => new Set(graph.nodes.map((n) => n.direction).filter(Boolean)).size,
67
176
  [graph.nodes],
68
177
  );
178
+ const activeLayout = useMemo(() => getActiveGraphLayout(layoutStore), [layoutStore]);
179
+ const savedLayouts = useMemo(
180
+ () => layoutStore.layouts.filter((layout) => layout.id !== CURRENT_GRAPH_LAYOUT_ID),
181
+ [layoutStore.layouts],
182
+ );
183
+ const rememberedPositionCount = useMemo(
184
+ () => Object.keys(activeLayout.positions).length,
185
+ [activeLayout.positions],
186
+ );
187
+
188
+ const handleNodePositionsChange = useCallback(
189
+ (positions: MWGraphNodePositions) => {
190
+ saveLayoutStore(upsertCurrentGraphLayout(layoutStoreRef.current, positions));
191
+ },
192
+ [saveLayoutStore],
193
+ );
194
+
195
+ const saveNamedLayout = useCallback(() => {
196
+ const name = layoutName.trim();
197
+ if (!name) {
198
+ toast.error("Name the disposition first");
199
+ return;
200
+ }
201
+
202
+ const positions = getActiveGraphLayout(layoutStoreRef.current).positions;
203
+ if (Object.keys(positions).length === 0) {
204
+ toast.error("Move a node before saving");
205
+ return;
206
+ }
207
+
208
+ const nextStore = saveNamedGraphLayout(layoutStoreRef.current, name, positions);
209
+ const persisted = saveLayoutStore(nextStore, { notifyOnFailure: true });
210
+ if (persisted) toast.success(`Saved ${name}`);
211
+ setLayoutName("");
212
+ }, [layoutName, saveLayoutStore]);
213
+
214
+ const loadNamedLayout = useCallback(
215
+ (layoutId: string) => {
216
+ const nextStore = selectGraphLayout(layoutStoreRef.current, layoutId);
217
+ const active = getActiveGraphLayout(nextStore);
218
+ const persisted = saveLayoutStore(nextStore, { notifyOnFailure: true });
219
+ if (persisted) toast.success(`Loaded ${active.name}`);
220
+ },
221
+ [saveLayoutStore],
222
+ );
223
+
224
+ const navigateToNode = useCallback(
225
+ (node: MWGraphNode) => {
226
+ router.push(`/nodes?node=${encodeURIComponent(node.id)}`);
227
+ },
228
+ [router],
229
+ );
230
+
231
+ const handleAnimationsEnabledChange = useCallback(
232
+ (event: ChangeEvent<HTMLInputElement>) => {
233
+ const enabled = event.target.checked;
234
+ setAnimationsEnabled(enabled);
235
+
236
+ if (!persistGraphAnimationPreference(enabled)) {
237
+ toast.error("Could not save graph animation preference");
238
+ }
239
+ },
240
+ [],
241
+ );
69
242
 
70
243
  return (
71
244
  <div className="min-h-screen bg-[#0a0a1a] text-white p-6">
@@ -86,14 +259,26 @@ export default function GraphPage() {
86
259
  >
87
260
  Labels {showLabels ? "ON" : "OFF"}
88
261
  </button>
89
- <button onClick={loadData} className="px-3 py-1.5 rounded text-sm bg-white/5 hover:bg-white/10">
90
- Refresh
262
+ <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">
263
+ <RefreshCw className="h-4 w-4" /> Refresh
91
264
  </button>
92
265
  </div>
93
266
  </div>
94
267
 
95
268
  <div className="flex gap-6">
96
- <div className="flex-1 rounded-xl border border-white/10 overflow-hidden">
269
+ <div className="relative flex-1 rounded-xl border border-white/10 overflow-hidden">
270
+ {!loading && graph.nodes.length > 0 && (
271
+ <label className="absolute right-3 top-3 z-10 inline-flex items-center gap-2 rounded-md border border-white/10 bg-[#0a0a1a]/80 px-2 py-1 text-[11px] text-gray-300 shadow-sm backdrop-blur">
272
+ <input
273
+ type="checkbox"
274
+ checked={animationsEnabled}
275
+ onChange={handleAnimationsEnabledChange}
276
+ className="h-3 w-3 accent-yellow-400"
277
+ aria-label="Animate graph edges"
278
+ />
279
+ <span>Animation</span>
280
+ </label>
281
+ )}
97
282
  {loading ? (
98
283
  <div className="flex items-center justify-center h-[600px] text-gray-500">Loading graph data...</div>
99
284
  ) : graph.nodes.length === 0 ? (
@@ -108,12 +293,81 @@ export default function GraphPage() {
108
293
  height={600}
109
294
  darkMode
110
295
  showNodeLabels={showLabels}
296
+ animationsEnabled={animationsEnabled}
297
+ nodePositions={activeLayout.positions}
111
298
  onNodeClick={(node) => setSelectedNode(node)}
299
+ onNodeDoubleClick={navigateToNode}
300
+ onNodePositionsChange={handleNodePositionsChange}
112
301
  />
113
302
  )}
114
303
  </div>
115
304
 
116
305
  <div className="w-72 space-y-4">
306
+ <div className="rounded-lg border border-white/10 p-4">
307
+ <h3 className="text-sm font-semibold text-gray-400 mb-3">Dispositions</h3>
308
+ <div className="space-y-3">
309
+ <select
310
+ value={layoutStore.activeLayoutId}
311
+ onChange={(event) => loadNamedLayout(event.target.value)}
312
+ className="w-full rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm text-white"
313
+ aria-label="Graph disposition"
314
+ >
315
+ <option className="bg-[#101020]" value={CURRENT_GRAPH_LAYOUT_ID}>
316
+ Last positioning
317
+ </option>
318
+ {savedLayouts.map((layout) => (
319
+ <option className="bg-[#101020]" key={layout.id} value={layout.id}>
320
+ {layout.name}
321
+ </option>
322
+ ))}
323
+ </select>
324
+
325
+ <div className="flex gap-2">
326
+ <input
327
+ value={layoutName}
328
+ onChange={(event) => setLayoutName(event.target.value)}
329
+ onKeyDown={(event) => {
330
+ if (event.key === "Enter") saveNamedLayout();
331
+ }}
332
+ placeholder="Name disposition"
333
+ 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"
334
+ />
335
+ <button
336
+ onClick={saveNamedLayout}
337
+ className="inline-flex h-9 w-9 items-center justify-center rounded-md bg-white/10 text-white hover:bg-white/15"
338
+ aria-label="Save named disposition"
339
+ title="Save named disposition"
340
+ >
341
+ <Save className="h-4 w-4" />
342
+ </button>
343
+ </div>
344
+
345
+ <p className="text-xs text-gray-500">
346
+ {rememberedPositionCount} positions remembered
347
+ </p>
348
+ </div>
349
+ </div>
350
+
351
+ <div className="rounded-lg border border-white/10 p-4">
352
+ <h3 className="text-sm font-semibold text-gray-400 mb-3">Related</h3>
353
+ <div className="grid grid-cols-2 gap-2">
354
+ {GRAPH_COMPONENT_LINKS.map((item) => {
355
+ const Icon = item.icon;
356
+
357
+ return (
358
+ <Link
359
+ key={item.href}
360
+ href={item.href}
361
+ 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"
362
+ >
363
+ <Icon className="h-4 w-4 shrink-0 text-gray-400" />
364
+ <span className="truncate">{item.label}</span>
365
+ </Link>
366
+ );
367
+ })}
368
+ </div>
369
+ </div>
370
+
117
371
  <div className="rounded-xl border border-white/10 p-4">
118
372
  <h3 className="text-sm font-semibold text-gray-400 mb-3">Directions</h3>
119
373
  {(["east", "south", "west", "north"] as const).map((dir) => (
@@ -132,6 +386,12 @@ export default function GraphPage() {
132
386
  <div><span className="text-xs text-gray-500">Type</span><p className="capitalize">{selectedNode.type}</p></div>
133
387
  {selectedNode.direction && <div><span className="text-xs text-gray-500">Direction</span><p className="capitalize">{selectedNode.direction}</p></div>}
134
388
  <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>
389
+ <Link
390
+ href={`/nodes?node=${encodeURIComponent(selectedNode.id)}`}
391
+ 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"
392
+ >
393
+ <CircleDot className="h-4 w-4" /> Open node
394
+ </Link>
135
395
  </div>
136
396
  </div>
137
397
  )}
@@ -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,16 @@
1
+ import type { CeremonyLog } from "@/lib/types";
2
+
3
+ interface CeremonyListResponse {
4
+ ceremonies?: unknown;
5
+ }
6
+
7
+ export function extractCeremonyLogs(response: unknown): CeremonyLog[] {
8
+ if (Array.isArray(response)) return response as CeremonyLog[];
9
+
10
+ if (!response || typeof response !== "object") return [];
11
+
12
+ const candidate = response as CeremonyListResponse;
13
+ return Array.isArray(candidate.ceremonies)
14
+ ? (candidate.ceremonies as CeremonyLog[])
15
+ : [];
16
+ }
@@ -0,0 +1,84 @@
1
+ export const GRAPH_ANIMATION_STORAGE_VERSION = 1;
2
+ export const GRAPH_ANIMATION_STORAGE_KEY = "medicine-wheel:graph-animation:v1";
3
+ export const DEFAULT_GRAPH_ANIMATION_ENABLED = true;
4
+
5
+ interface StorageLike {
6
+ getItem(key: string): string | null;
7
+ setItem(key: string, value: string): void;
8
+ }
9
+
10
+ interface GraphAnimationPreference {
11
+ version: typeof GRAPH_ANIMATION_STORAGE_VERSION;
12
+ animationsEnabled: boolean;
13
+ }
14
+
15
+ function safeStorage(): StorageLike | null {
16
+ if (typeof window === "undefined") return null;
17
+
18
+ try {
19
+ return window.localStorage;
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ function serializeGraphAnimationPreference(
26
+ animationsEnabled: boolean,
27
+ ): string {
28
+ const value: GraphAnimationPreference = {
29
+ version: GRAPH_ANIMATION_STORAGE_VERSION,
30
+ animationsEnabled,
31
+ };
32
+
33
+ return JSON.stringify(value);
34
+ }
35
+
36
+ export function parseGraphAnimationPreference(raw: string | null): boolean {
37
+ if (!raw) return DEFAULT_GRAPH_ANIMATION_ENABLED;
38
+
39
+ try {
40
+ const parsed = JSON.parse(raw) as Partial<GraphAnimationPreference>;
41
+
42
+ if (
43
+ parsed.version === GRAPH_ANIMATION_STORAGE_VERSION &&
44
+ typeof parsed.animationsEnabled === "boolean"
45
+ ) {
46
+ return parsed.animationsEnabled;
47
+ }
48
+ } catch {
49
+ return DEFAULT_GRAPH_ANIMATION_ENABLED;
50
+ }
51
+
52
+ return DEFAULT_GRAPH_ANIMATION_ENABLED;
53
+ }
54
+
55
+ export function loadStoredGraphAnimationPreference(
56
+ storage: StorageLike | null = safeStorage(),
57
+ ): boolean {
58
+ if (!storage) return DEFAULT_GRAPH_ANIMATION_ENABLED;
59
+
60
+ try {
61
+ return parseGraphAnimationPreference(
62
+ storage.getItem(GRAPH_ANIMATION_STORAGE_KEY),
63
+ );
64
+ } catch {
65
+ return DEFAULT_GRAPH_ANIMATION_ENABLED;
66
+ }
67
+ }
68
+
69
+ export function persistGraphAnimationPreference(
70
+ animationsEnabled: boolean,
71
+ storage: StorageLike | null = safeStorage(),
72
+ ): boolean {
73
+ if (!storage) return false;
74
+
75
+ try {
76
+ storage.setItem(
77
+ GRAPH_ANIMATION_STORAGE_KEY,
78
+ serializeGraphAnimationPreference(animationsEnabled),
79
+ );
80
+ return true;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
@@ -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.3",
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",
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",
77
+ "@medicine-wheel/ceremony-protocol": "^0.4.3",
78
+ "@medicine-wheel/community-review": "^0.4.3",
79
+ "@medicine-wheel/consent-lifecycle": "^0.4.3",
80
+ "@medicine-wheel/data-store": "^0.4.3",
81
+ "@medicine-wheel/data-store-postgres": "^0.4.3",
82
+ "@medicine-wheel/fire-keeper": "^0.4.3",
83
+ "@medicine-wheel/graph-viz": "^0.4.3",
84
+ "@medicine-wheel/importance-unit": "^0.4.3",
85
+ "@medicine-wheel/mcp": "^4.4.2",
86
+ "@medicine-wheel/narrative-engine": "^0.4.3",
87
+ "@medicine-wheel/ontology-core": "^0.4.3",
88
+ "@medicine-wheel/prompt-decomposition": "^0.4.3",
89
+ "@medicine-wheel/relational-index": "^0.4.3",
90
+ "@medicine-wheel/relational-query": "^0.4.3",
91
+ "@medicine-wheel/session-reader": "^0.4.3",
92
+ "@medicine-wheel/storage-provider": "^0.4.3",
93
+ "@medicine-wheel/transformation-tracker": "^0.4.3",
94
+ "@medicine-wheel/ui-components": "^0.4.3",
95
95
  "@neondatabase/serverless": "^0.10.0",
96
96
  "clsx": "^2.1.1",
97
97
  "lucide-react": "^0.475.0",