@medicine-wheel/app 0.6.3 → 0.7.0
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/README.md +30 -0
- package/app/accountability/page.tsx +2 -2
- package/app/api/ceremonies/[id]/route.ts +24 -0
- package/app/api/ceremonies/route.ts +14 -1
- package/app/api/edges/route.ts +20 -2
- package/app/api/nodes/[id]/web/route.ts +287 -0
- package/app/api/nodes/route.ts +44 -11
- package/app/episodes/[id]/page.tsx +223 -0
- package/app/episodes/page.tsx +222 -0
- package/app/graph/page.tsx +328 -30
- package/app/nodes/page.tsx +5 -2
- package/app/relations/page.tsx +1 -1
- package/components/navigation.tsx +1 -0
- package/components/workspaces-panel.tsx +7 -9
- package/lib/api-paging.ts +49 -0
- package/lib/ceremony-response.ts +21 -0
- package/package.json +24 -24
package/app/graph/page.tsx
CHANGED
|
@@ -17,8 +17,11 @@ import {
|
|
|
17
17
|
Route,
|
|
18
18
|
Save,
|
|
19
19
|
ShieldCheck,
|
|
20
|
+
SlidersHorizontal,
|
|
21
|
+
X,
|
|
20
22
|
} from "lucide-react";
|
|
21
23
|
import { type RelationalNode, type RelationalEdge, DIRECTION_COLORS } from "@/lib/types";
|
|
24
|
+
import { cn } from "@/lib/utils";
|
|
22
25
|
import {
|
|
23
26
|
applyWheelLayout,
|
|
24
27
|
buildGraphData,
|
|
@@ -95,10 +98,34 @@ export default function GraphPage() {
|
|
|
95
98
|
const [graph, setGraph] = useState<MWGraphData>({ nodes: [], links: [] });
|
|
96
99
|
const [selectedNode, setSelectedNode] = useState<MWGraphNode | null>(null);
|
|
97
100
|
const [focusedNodeId, setFocusedNodeId] = useState<string | undefined>(undefined);
|
|
101
|
+
/**
|
|
102
|
+
* Scope is not focus.
|
|
103
|
+
*
|
|
104
|
+
* `focusedNodeId` zooms the viewport at a node and leaves all 205 mounted —
|
|
105
|
+
* useful, and not an answer to "I cannot navigate", because the field is still
|
|
106
|
+
* there behind the zoom. `scopeId` replaces the graph with one node's
|
|
107
|
+
* neighbourhood, so the canvas holds only what was asked for. Two names
|
|
108
|
+
* because they are two questions; collapsing them would make the control
|
|
109
|
+
* silently mean different things depending on how the page was entered.
|
|
110
|
+
*/
|
|
111
|
+
const [scopeId, setScopeId] = useState<string | null>(null);
|
|
112
|
+
// 1, not 2. With the chronicle root at degree 82, depth 2 from any episode
|
|
113
|
+
// used to return the whole corpus — see the route's DEFAULT_DEPTH.
|
|
114
|
+
const [scopeDepth, setScopeDepth] = useState(1);
|
|
115
|
+
const [scopeMeta, setScopeMeta] = useState<{
|
|
116
|
+
count: number;
|
|
117
|
+
truncated: boolean;
|
|
118
|
+
hubs: { id: string; name: string; degree: number; reason: string; unexpanded: number }[];
|
|
119
|
+
rootName: string | null;
|
|
120
|
+
} | null>(null);
|
|
121
|
+
const [storeTotal, setStoreTotal] = useState<number | null>(null);
|
|
98
122
|
const [highlightDirection, setHighlightDirection] = useState<DirectionParam | undefined>(undefined);
|
|
99
123
|
const [radialSnap, setRadialSnap] = useState<"off" | "ring" | "sector">("off");
|
|
100
124
|
const [loading, setLoading] = useState(true);
|
|
101
|
-
|
|
125
|
+
// `null` means "nobody has chosen" — the default then follows the crowding
|
|
126
|
+
// (see labelsAutoSuppressed). Once the user touches the toggle their choice
|
|
127
|
+
// sticks, including across a scope change.
|
|
128
|
+
const [showLabels, setShowLabels] = useState<boolean | null>(null);
|
|
102
129
|
const [animationsEnabled, setAnimationsEnabled] = useState(
|
|
103
130
|
DEFAULT_GRAPH_ANIMATION_ENABLED,
|
|
104
131
|
);
|
|
@@ -109,6 +136,40 @@ export default function GraphPage() {
|
|
|
109
136
|
);
|
|
110
137
|
const layoutStoreRef = useRef(layoutStore);
|
|
111
138
|
|
|
139
|
+
// Mobile presentation: the side panel becomes a slide-up sheet so the
|
|
140
|
+
// canvas can own the screen, and the canvas height fills the viewport.
|
|
141
|
+
const [isMobile, setIsMobile] = useState(false);
|
|
142
|
+
const [sheetOpen, setSheetOpen] = useState(false);
|
|
143
|
+
const [graphHeight, setGraphHeight] = useState<number | string>(600);
|
|
144
|
+
const selectedPanelRef = useRef<HTMLDivElement>(null);
|
|
145
|
+
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
const mq = window.matchMedia("(max-width: 1023px)");
|
|
148
|
+
const apply = () => {
|
|
149
|
+
setIsMobile(mq.matches);
|
|
150
|
+
setGraphHeight(
|
|
151
|
+
mq.matches ? Math.max(360, Math.round(window.innerHeight * 0.66)) : 600,
|
|
152
|
+
);
|
|
153
|
+
};
|
|
154
|
+
apply();
|
|
155
|
+
mq.addEventListener("change", apply);
|
|
156
|
+
window.addEventListener("resize", apply);
|
|
157
|
+
return () => {
|
|
158
|
+
mq.removeEventListener("change", apply);
|
|
159
|
+
window.removeEventListener("resize", apply);
|
|
160
|
+
};
|
|
161
|
+
}, []);
|
|
162
|
+
|
|
163
|
+
// When a being is tapped on mobile the sheet opens; bring its details into
|
|
164
|
+
// view once the slide-up transition settles.
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
if (!isMobile || !sheetOpen || !selectedNode) return;
|
|
167
|
+
const t = window.setTimeout(() => {
|
|
168
|
+
selectedPanelRef.current?.scrollIntoView({ block: "start", behavior: "smooth" });
|
|
169
|
+
}, 320);
|
|
170
|
+
return () => window.clearTimeout(t);
|
|
171
|
+
}, [isMobile, sheetOpen, selectedNode]);
|
|
172
|
+
|
|
112
173
|
const saveLayoutStore = useCallback(
|
|
113
174
|
(
|
|
114
175
|
nextStore: GraphLayoutStore,
|
|
@@ -129,22 +190,54 @@ export default function GraphPage() {
|
|
|
129
190
|
|
|
130
191
|
const loadData = useCallback(async () => {
|
|
131
192
|
try {
|
|
132
|
-
|
|
193
|
+
// Scoped and whole-wheel are different questions, not the same question
|
|
194
|
+
// with a filter. `focusedNodeId` already existed and only zooms the
|
|
195
|
+
// viewport — every one of the 205 nodes stays mounted, so "focus" was
|
|
196
|
+
// never a way to look at less. Scope replaces the data.
|
|
197
|
+
if (scopeId) {
|
|
198
|
+
const res = await fetch(
|
|
199
|
+
`/api/nodes/${encodeURIComponent(scopeId)}/web?depth=${scopeDepth}`,
|
|
200
|
+
);
|
|
201
|
+
if (!res.ok) {
|
|
202
|
+
toast.error(
|
|
203
|
+
res.status === 404
|
|
204
|
+
? "That node is not in the wheel."
|
|
205
|
+
: "Could not read that neighbourhood.",
|
|
206
|
+
);
|
|
207
|
+
setScopeId(null);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const web = await res.json();
|
|
211
|
+
setGraph(buildGraphData(web.nodes ?? [], web.edges ?? []));
|
|
212
|
+
setScopeMeta({
|
|
213
|
+
count: web.count ?? 0,
|
|
214
|
+
truncated: Boolean(web.truncated),
|
|
215
|
+
hubs: web.hubs ?? [],
|
|
216
|
+
rootName: web.root?.name ?? null,
|
|
217
|
+
});
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const [nodesRes, edgesRes] = await Promise.all([fetch("/api/nodes?limit=all"), fetch("/api/edges?limit=all")]);
|
|
133
222
|
const nodesResponse = await nodesRes.json();
|
|
134
223
|
const edgesData: RelationalEdge[] = await edgesRes.json();
|
|
135
224
|
|
|
136
|
-
// API returns { nodes: [...], provider: '...', count: N }
|
|
225
|
+
// API returns { nodes: [...], provider: '...', count: N, total, truncated }
|
|
137
226
|
const nodesData: RelationalNode[] = Array.isArray(nodesResponse)
|
|
138
227
|
? nodesResponse
|
|
139
228
|
: nodesResponse.nodes || [];
|
|
140
229
|
|
|
141
230
|
setGraph(buildGraphData(nodesData, edgesData));
|
|
231
|
+
setScopeMeta(null);
|
|
232
|
+
setStoreTotal(
|
|
233
|
+
Array.isArray(nodesResponse) ? nodesData.length : (nodesResponse.total ?? nodesData.length),
|
|
234
|
+
);
|
|
142
235
|
} catch {
|
|
143
236
|
toast.error("Failed to load graph data");
|
|
144
237
|
} finally {
|
|
145
238
|
setLoading(false);
|
|
146
239
|
}
|
|
147
|
-
}, []);
|
|
240
|
+
}, [scopeId, scopeDepth]);
|
|
148
241
|
|
|
149
242
|
useEffect(() => {
|
|
150
243
|
loadData();
|
|
@@ -167,12 +260,31 @@ export default function GraphPage() {
|
|
|
167
260
|
const params = new URLSearchParams(window.location.search);
|
|
168
261
|
const node = params.get("node");
|
|
169
262
|
if (node) setFocusedNodeId(node);
|
|
263
|
+
// ?scope=<id> arrives from an episode page's "Open in graph" and means
|
|
264
|
+
// "show me this and its neighbours", not "zoom at this".
|
|
265
|
+
const scope = params.get("scope");
|
|
266
|
+
if (scope) {
|
|
267
|
+
setScopeId(scope);
|
|
268
|
+
setFocusedNodeId(scope);
|
|
269
|
+
}
|
|
170
270
|
const direction = params.get("direction");
|
|
171
271
|
if (direction && (DIRECTION_NAMES as readonly string[]).includes(direction)) {
|
|
172
272
|
setHighlightDirection(direction as DirectionParam);
|
|
173
273
|
}
|
|
174
274
|
}, []);
|
|
175
275
|
|
|
276
|
+
// Escape leaves the neighbourhood and returns the whole wheel. It is the only
|
|
277
|
+
// way out that needs no aiming, which matters when the reason you scoped was
|
|
278
|
+
// that you could not find anything.
|
|
279
|
+
useEffect(() => {
|
|
280
|
+
if (!scopeId) return;
|
|
281
|
+
const onKey = (event: KeyboardEvent) => {
|
|
282
|
+
if (event.key === "Escape") setScopeId(null);
|
|
283
|
+
};
|
|
284
|
+
window.addEventListener("keydown", onKey);
|
|
285
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
286
|
+
}, [scopeId]);
|
|
287
|
+
|
|
176
288
|
// Surface the deep-linked node in the side panel once data arrives.
|
|
177
289
|
useEffect(() => {
|
|
178
290
|
if (!focusedNodeId || graph.nodes.length === 0) return;
|
|
@@ -180,10 +292,58 @@ export default function GraphPage() {
|
|
|
180
292
|
if (node) setSelectedNode(node);
|
|
181
293
|
}, [focusedNodeId, graph.nodes]);
|
|
182
294
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
295
|
+
/**
|
|
296
|
+
* The node you asked about must be findable in the picture.
|
|
297
|
+
*
|
|
298
|
+
* Before this, arriving from an episode page put your episode somewhere in a
|
|
299
|
+
* field of identical dots — the graph knew which node you meant (it zoomed at
|
|
300
|
+
* it) and gave you no way to see which one it was once you panned. Marking is
|
|
301
|
+
* done here rather than in `graph-viz` because `MedicineWheelNode` already
|
|
302
|
+
* reads `size`, `color` and `opacity` off each node; the renderer needs no
|
|
303
|
+
* new concept, only better data.
|
|
304
|
+
*
|
|
305
|
+
* Hubs are dimmed rather than hidden. The chronicle root genuinely is in your
|
|
306
|
+
* neighbourhood — every episode belongs to it — but it is context, not the
|
|
307
|
+
* subject, and drawing it at full weight makes the one true container compete
|
|
308
|
+
* with the thing you were looking at.
|
|
309
|
+
*/
|
|
310
|
+
const graphData = useMemo<MWGraphData>(() => {
|
|
311
|
+
const hubIds = new Set((scopeMeta?.hubs ?? []).map((h) => h.id));
|
|
312
|
+
const marked =
|
|
313
|
+
scopeId || hubIds.size > 0
|
|
314
|
+
? {
|
|
315
|
+
...graph,
|
|
316
|
+
nodes: graph.nodes.map((n) => {
|
|
317
|
+
if (n.id === scopeId) {
|
|
318
|
+
return {
|
|
319
|
+
...n,
|
|
320
|
+
size: (n.size ?? 8) * 1.9,
|
|
321
|
+
color: "#ffd75e",
|
|
322
|
+
opacity: 1,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
if (hubIds.has(n.id)) {
|
|
326
|
+
return { ...n, size: (n.size ?? 8) * 1.25, opacity: 0.45 };
|
|
327
|
+
}
|
|
328
|
+
return n;
|
|
329
|
+
}),
|
|
330
|
+
}
|
|
331
|
+
: graph;
|
|
332
|
+
|
|
333
|
+
return focusedNodeId ? { ...marked, focusedNodeId } : marked;
|
|
334
|
+
}, [graph, focusedNodeId, scopeId, scopeMeta]);
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Labels are legible up to a point and then they are texture.
|
|
338
|
+
*
|
|
339
|
+
* At 83 nodes crushed into one quadrant the labels stacked into unreadable
|
|
340
|
+
* bands — worse than absent, because they cost the whole canvas its clarity
|
|
341
|
+
* while conveying nothing. Off past the threshold, and the existing toggle
|
|
342
|
+
* still wins: this sets the default, it does not take the control away.
|
|
343
|
+
*/
|
|
344
|
+
const LABEL_LEGIBLE_UP_TO = 35;
|
|
345
|
+
const labelsAutoSuppressed = graph.nodes.length > LABEL_LEGIBLE_UP_TO;
|
|
346
|
+
const labelsVisible = showLabels ?? !labelsAutoSuppressed;
|
|
187
347
|
|
|
188
348
|
useEffect(() => {
|
|
189
349
|
layoutStoreRef.current = layoutStore;
|
|
@@ -380,40 +540,103 @@ export default function GraphPage() {
|
|
|
380
540
|
);
|
|
381
541
|
|
|
382
542
|
return (
|
|
383
|
-
<div className="min-h-screen bg-[#0a0a1a] text-white p-6">
|
|
543
|
+
<div className="min-h-screen bg-[#0a0a1a] text-white p-3 sm:p-6">
|
|
384
544
|
<div className="max-w-7xl mx-auto">
|
|
385
|
-
<div className="flex items-center justify-between
|
|
545
|
+
<div className="flex flex-col gap-3 mb-4 sm:mb-6 sm:flex-row sm:items-center sm:justify-between">
|
|
386
546
|
<div>
|
|
387
|
-
<h1 className="text-2xl font-bold flex items-center gap-2">
|
|
388
|
-
<span className="text-3xl">🔮</span> Medicine Wheel Graph
|
|
547
|
+
<h1 className="text-xl sm:text-2xl font-bold flex items-center gap-2">
|
|
548
|
+
<span className="text-2xl sm:text-3xl">🔮</span> Medicine Wheel Graph
|
|
389
549
|
</h1>
|
|
390
|
-
<p className="text-gray-400 text-sm mt-1">
|
|
391
|
-
Interactive relational web — drag, pan,
|
|
550
|
+
<p className="text-gray-400 text-xs sm:text-sm mt-1">
|
|
551
|
+
Interactive relational web — tap a being, drag, pan, and pinch to zoom
|
|
392
552
|
</p>
|
|
393
553
|
</div>
|
|
394
|
-
<div className="flex gap-2">
|
|
554
|
+
<div className="flex flex-wrap gap-2">
|
|
395
555
|
<button
|
|
396
|
-
onClick={() => setShowLabels(!
|
|
397
|
-
className={`px-3 py-1.5 rounded text-sm ${
|
|
556
|
+
onClick={() => setShowLabels(!labelsVisible)}
|
|
557
|
+
className={`min-h-11 px-3 py-1.5 rounded text-sm ${labelsVisible ? "bg-white/10" : "bg-white/5"}`}
|
|
558
|
+
title={
|
|
559
|
+
showLabels === null && labelsAutoSuppressed
|
|
560
|
+
? `Off by default above ${LABEL_LEGIBLE_UP_TO} nodes — they overlap into bands`
|
|
561
|
+
: undefined
|
|
562
|
+
}
|
|
398
563
|
>
|
|
399
|
-
Labels {
|
|
564
|
+
Labels {labelsVisible ? "ON" : "OFF"}
|
|
400
565
|
</button>
|
|
401
566
|
<button
|
|
402
567
|
onClick={() =>
|
|
403
568
|
setRadialSnap((s) => (s === "off" ? "ring" : s === "ring" ? "sector" : "off"))
|
|
404
569
|
}
|
|
405
|
-
className={`px-3 py-1.5 rounded text-sm ${radialSnap !== "off" ? "bg-white/10" : "bg-white/5"}`}
|
|
570
|
+
className={`min-h-11 px-3 py-1.5 rounded text-sm ${radialSnap !== "off" ? "bg-white/10" : "bg-white/5"}`}
|
|
406
571
|
title="Radial snapping: the wheel guides dragged beings — ring keeps them in their band, sector also inside their quadrant"
|
|
407
572
|
>
|
|
408
573
|
Snap {radialSnap === "off" ? "OFF" : radialSnap.toUpperCase()}
|
|
409
574
|
</button>
|
|
410
|
-
<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">
|
|
575
|
+
<button onClick={loadData} className="min-h-11 px-3 py-1.5 rounded text-sm bg-white/5 hover:bg-white/10 inline-flex items-center gap-2">
|
|
411
576
|
<RefreshCw className="h-4 w-4" /> Refresh
|
|
412
577
|
</button>
|
|
413
578
|
</div>
|
|
414
579
|
</div>
|
|
415
580
|
|
|
416
|
-
|
|
581
|
+
{scopeId && (
|
|
582
|
+
<div className="mb-3 rounded-xl border border-yellow-400/30 bg-yellow-400/5 px-3 py-2 text-sm">
|
|
583
|
+
{/* min-w-0 on the growing child, so a long chronicle id truncates
|
|
584
|
+
instead of pushing the depth control and the exit off-screen —
|
|
585
|
+
the banner clipped its own warning at this width before. */}
|
|
586
|
+
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
|
587
|
+
<span className="min-w-0 flex-1 text-gray-300">
|
|
588
|
+
<strong className="text-yellow-300">
|
|
589
|
+
{scopeMeta?.count ?? graph.nodes.length}
|
|
590
|
+
</strong>{" "}
|
|
591
|
+
nodes, {scopeDepth} {scopeDepth === 1 ? "hop" : "hops"} from{" "}
|
|
592
|
+
<span className="text-gray-100">{scopeMeta?.rootName ?? scopeId}</span>
|
|
593
|
+
</span>
|
|
594
|
+
<label className="inline-flex shrink-0 items-center gap-1.5 text-xs text-gray-400">
|
|
595
|
+
Depth
|
|
596
|
+
<select
|
|
597
|
+
value={scopeDepth}
|
|
598
|
+
onChange={(e) => setScopeDepth(Number(e.target.value))}
|
|
599
|
+
className="rounded bg-white/5 px-1.5 py-0.5 text-gray-200"
|
|
600
|
+
aria-label="Neighbourhood depth"
|
|
601
|
+
>
|
|
602
|
+
{[1, 2, 3, 4, 5, 6].map((d) => (
|
|
603
|
+
<option key={d} value={d}>
|
|
604
|
+
{d}
|
|
605
|
+
</option>
|
|
606
|
+
))}
|
|
607
|
+
</select>
|
|
608
|
+
</label>
|
|
609
|
+
<button
|
|
610
|
+
onClick={() => setScopeId(null)}
|
|
611
|
+
className="min-h-8 shrink-0 rounded bg-white/5 px-2 py-1 text-xs hover:bg-white/10 inline-flex items-center gap-1.5"
|
|
612
|
+
>
|
|
613
|
+
<X className="h-3.5 w-3.5" /> Whole wheel
|
|
614
|
+
<span className="text-gray-500">(Esc)</span>
|
|
615
|
+
</button>
|
|
616
|
+
</div>
|
|
617
|
+
|
|
618
|
+
{/* Say what was held back. A neighbourhood that quietly stopped at a
|
|
619
|
+
container is the same class of lie as a list that quietly
|
|
620
|
+
stopped at 100 rows. */}
|
|
621
|
+
{(scopeMeta?.hubs?.length ?? 0) > 0 && (
|
|
622
|
+
<p className="mt-1.5 text-xs text-gray-500">
|
|
623
|
+
{scopeMeta!.hubs.map((h) => (
|
|
624
|
+
<span key={h.id} className="mr-3 inline-block">
|
|
625
|
+
<span className="text-gray-400">{h.name}</span> holds {h.degree} relations —
|
|
626
|
+
shown, not expanded{h.unexpanded > 0 ? ` (${h.unexpanded} beyond it)` : ""}
|
|
627
|
+
</span>
|
|
628
|
+
))}
|
|
629
|
+
</p>
|
|
630
|
+
)}
|
|
631
|
+
{scopeMeta?.truncated && (
|
|
632
|
+
<p className="mt-1 text-xs text-gray-500">
|
|
633
|
+
stopped at the depth limit — there is more further out
|
|
634
|
+
</p>
|
|
635
|
+
)}
|
|
636
|
+
</div>
|
|
637
|
+
)}
|
|
638
|
+
|
|
639
|
+
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
|
|
417
640
|
<div className="relative flex-1 rounded-xl border border-white/10 overflow-hidden">
|
|
418
641
|
{!loading && graph.nodes.length > 0 && (
|
|
419
642
|
<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">
|
|
@@ -428,9 +651,9 @@ export default function GraphPage() {
|
|
|
428
651
|
</label>
|
|
429
652
|
)}
|
|
430
653
|
{loading ? (
|
|
431
|
-
<div className="flex items-center justify-center
|
|
654
|
+
<div className="flex items-center justify-center text-gray-500" style={{ height: graphHeight }}>Loading graph data...</div>
|
|
432
655
|
) : graph.nodes.length === 0 ? (
|
|
433
|
-
<div className="flex flex-col items-center justify-center
|
|
656
|
+
<div className="flex flex-col items-center justify-center text-gray-500" style={{ height: graphHeight }}>
|
|
434
657
|
<span className="text-4xl mb-3">🌀</span>
|
|
435
658
|
<p>No relational nodes yet.</p>
|
|
436
659
|
<p className="text-sm mt-1">Create nodes via the Nodes page.</p>
|
|
@@ -438,12 +661,16 @@ export default function GraphPage() {
|
|
|
438
661
|
) : (
|
|
439
662
|
<MedicineWheelFlowGraph
|
|
440
663
|
data={graphData}
|
|
441
|
-
height={
|
|
664
|
+
height={graphHeight}
|
|
442
665
|
darkMode
|
|
443
|
-
showNodeLabels={
|
|
666
|
+
showNodeLabels={labelsVisible}
|
|
667
|
+
showMiniMap={!isMobile}
|
|
444
668
|
animationsEnabled={animationsEnabled}
|
|
445
669
|
nodePositions={activeLayout.positions}
|
|
446
|
-
onNodeClick={(node) =>
|
|
670
|
+
onNodeClick={(node) => {
|
|
671
|
+
setSelectedNode(node);
|
|
672
|
+
if (isMobile) setSheetOpen(true);
|
|
673
|
+
}}
|
|
447
674
|
onNodeDoubleClick={navigateToNode}
|
|
448
675
|
onNodePositionsChange={handleNodePositionsChange}
|
|
449
676
|
enableConnections
|
|
@@ -458,7 +685,53 @@ export default function GraphPage() {
|
|
|
458
685
|
)}
|
|
459
686
|
</div>
|
|
460
687
|
|
|
461
|
-
|
|
688
|
+
{/* Mobile: floating toggle opens the panels as a slide-up sheet so
|
|
689
|
+
the canvas keeps the whole screen. */}
|
|
690
|
+
{isMobile && !sheetOpen && (
|
|
691
|
+
<button
|
|
692
|
+
onClick={() => setSheetOpen(true)}
|
|
693
|
+
className="fixed bottom-4 right-4 z-30 inline-flex min-h-11 max-w-[70vw] items-center gap-2 rounded-full border border-white/15 bg-[#12122a]/95 px-4 py-2 text-sm font-medium text-white shadow-lg backdrop-blur"
|
|
694
|
+
aria-label="Open graph panels"
|
|
695
|
+
>
|
|
696
|
+
<SlidersHorizontal className="h-4 w-4 shrink-0" />
|
|
697
|
+
<span className="truncate">{selectedNode ? selectedNode.label : "Panels"}</span>
|
|
698
|
+
</button>
|
|
699
|
+
)}
|
|
700
|
+
|
|
701
|
+
{/* Mobile: dim backdrop behind the open sheet. */}
|
|
702
|
+
{isMobile && sheetOpen && (
|
|
703
|
+
<div
|
|
704
|
+
onClick={() => setSheetOpen(false)}
|
|
705
|
+
className="fixed inset-0 z-40 bg-black/60"
|
|
706
|
+
aria-hidden="true"
|
|
707
|
+
/>
|
|
708
|
+
)}
|
|
709
|
+
|
|
710
|
+
<div
|
|
711
|
+
className={cn(
|
|
712
|
+
isMobile
|
|
713
|
+
? cn(
|
|
714
|
+
"fixed inset-x-0 bottom-0 z-50 max-h-[82vh] overflow-y-auto rounded-t-2xl border-t border-white/10 bg-[#0a0a1a] px-4 pb-8 shadow-2xl transition-transform duration-300 ease-out",
|
|
715
|
+
sheetOpen ? "translate-y-0" : "translate-y-full",
|
|
716
|
+
)
|
|
717
|
+
: "w-72",
|
|
718
|
+
)}
|
|
719
|
+
aria-hidden={isMobile && !sheetOpen ? "true" : undefined}
|
|
720
|
+
>
|
|
721
|
+
{isMobile && (
|
|
722
|
+
<div className="sticky top-0 z-10 -mx-4 mb-3 flex items-center justify-between gap-2 border-b border-white/10 bg-[#0a0a1a] px-4 pb-3 pt-3">
|
|
723
|
+
<span className="text-sm font-semibold text-gray-300">Graph panels</span>
|
|
724
|
+
<button
|
|
725
|
+
onClick={() => setSheetOpen(false)}
|
|
726
|
+
aria-label="Close panels"
|
|
727
|
+
className="inline-flex h-11 w-11 items-center justify-center rounded-md bg-white/10 text-white hover:bg-white/15"
|
|
728
|
+
>
|
|
729
|
+
<X className="h-5 w-5" />
|
|
730
|
+
</button>
|
|
731
|
+
</div>
|
|
732
|
+
)}
|
|
733
|
+
|
|
734
|
+
<div className="space-y-4">
|
|
462
735
|
<div className="rounded-lg border border-white/10 p-4">
|
|
463
736
|
<h3 className="text-sm font-semibold text-gray-400 mb-3">Dispositions</h3>
|
|
464
737
|
<div className="space-y-3">
|
|
@@ -539,13 +812,21 @@ export default function GraphPage() {
|
|
|
539
812
|
</div>
|
|
540
813
|
|
|
541
814
|
{selectedNode && (
|
|
542
|
-
<div className="rounded-xl border border-white/10 p-4">
|
|
815
|
+
<div ref={selectedPanelRef} className="rounded-xl border border-white/10 p-4 scroll-mt-16">
|
|
543
816
|
<h3 className="text-sm font-semibold text-gray-400 mb-2">Selected Node</h3>
|
|
544
817
|
<div className="space-y-2">
|
|
545
818
|
<div><span className="text-xs text-gray-500">Name</span><p className="font-medium">{selectedNode.label}</p></div>
|
|
546
819
|
<div><span className="text-xs text-gray-500">Type</span><p className="capitalize">{selectedNode.type}</p></div>
|
|
547
820
|
{selectedNode.direction && <div><span className="text-xs text-gray-500">Direction</span><p className="capitalize">{selectedNode.direction}</p></div>}
|
|
548
821
|
<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>
|
|
822
|
+
{scopeId !== selectedNode.id && (
|
|
823
|
+
<button
|
|
824
|
+
onClick={() => setScopeId(selectedNode.id)}
|
|
825
|
+
className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-yellow-400/15 px-3 py-2 text-sm text-yellow-200 hover:bg-yellow-400/25"
|
|
826
|
+
>
|
|
827
|
+
<Route className="h-4 w-4" /> Show only its neighbourhood
|
|
828
|
+
</button>
|
|
829
|
+
)}
|
|
549
830
|
<Link
|
|
550
831
|
href={`/nodes?node=${encodeURIComponent(selectedNode.id)}`}
|
|
551
832
|
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"
|
|
@@ -557,14 +838,31 @@ export default function GraphPage() {
|
|
|
557
838
|
)}
|
|
558
839
|
|
|
559
840
|
<div className="rounded-xl border border-white/10 p-4">
|
|
560
|
-
<h3 className="text-sm font-semibold text-gray-400 mb-3">
|
|
841
|
+
<h3 className="text-sm font-semibold text-gray-400 mb-3">
|
|
842
|
+
Graph Stats
|
|
843
|
+
{/* This panel read "100 Nodes / 100 Relations" against a store of
|
|
844
|
+
205 and 191 until 2026-09-03, because the page fetched without
|
|
845
|
+
a limit and took the provider's default page in silence. A
|
|
846
|
+
count with nothing to compare it to cannot be doubted, which
|
|
847
|
+
is what made it a lie rather than a number. */}
|
|
848
|
+
{scopeId && <span className="ml-2 font-normal text-yellow-300/80">scoped</span>}
|
|
849
|
+
</h3>
|
|
561
850
|
<div className="grid grid-cols-2 gap-3 text-center">
|
|
562
|
-
<div
|
|
851
|
+
<div>
|
|
852
|
+
<p className="text-2xl font-bold">{graph.nodes.length}</p>
|
|
853
|
+
<p className="text-xs text-gray-500">
|
|
854
|
+
Nodes
|
|
855
|
+
{storeTotal !== null && graph.nodes.length < storeTotal && (
|
|
856
|
+
<span className="text-gray-600"> of {storeTotal}</span>
|
|
857
|
+
)}
|
|
858
|
+
</p>
|
|
859
|
+
</div>
|
|
563
860
|
<div><p className="text-2xl font-bold">{graph.links.length}</p><p className="text-xs text-gray-500">Relations</p></div>
|
|
564
861
|
<div><p className="text-2xl font-bold">{ceremoniedCount}</p><p className="text-xs text-gray-500">Ceremonied</p></div>
|
|
565
862
|
<div><p className="text-2xl font-bold">{directionCount}</p><p className="text-xs text-gray-500">Directions</p></div>
|
|
566
863
|
</div>
|
|
567
864
|
</div>
|
|
865
|
+
</div>
|
|
568
866
|
</div>
|
|
569
867
|
</div>
|
|
570
868
|
</div>
|
package/app/nodes/page.tsx
CHANGED
|
@@ -50,13 +50,16 @@ export default function NodesPage() {
|
|
|
50
50
|
const loadNodes = useCallback(async () => {
|
|
51
51
|
setLoading(true);
|
|
52
52
|
try {
|
|
53
|
-
|
|
53
|
+
// The list is client-side paginated below, so it needs the whole store.
|
|
54
|
+
// Without this the route applies its 100-row default and the table silently
|
|
55
|
+
// ends at 100 of 205 with no page control to reveal the rest.
|
|
56
|
+
const params = new URLSearchParams({ limit: "all" });
|
|
54
57
|
if (filterType !== "all") params.set("type", filterType);
|
|
55
58
|
if (filterDirection !== "all") params.set("direction", filterDirection);
|
|
56
59
|
|
|
57
60
|
const [nodesRes, edgesRes] = await Promise.all([
|
|
58
61
|
fetch(`/api/nodes?${params.toString()}`),
|
|
59
|
-
fetch("/api/edges"),
|
|
62
|
+
fetch("/api/edges?limit=all"),
|
|
60
63
|
]);
|
|
61
64
|
const nodesResponse = await nodesRes.json();
|
|
62
65
|
const edgesData: RelationalEdge[] = edgesRes.ok ? await edgesRes.json() : [];
|
package/app/relations/page.tsx
CHANGED
|
@@ -64,7 +64,7 @@ export default function RelationsPage() {
|
|
|
64
64
|
const loadData = useCallback(async () => {
|
|
65
65
|
setLoading(true);
|
|
66
66
|
try {
|
|
67
|
-
const [nodesRes, edgesRes] = await Promise.all([fetch("/api/nodes"), fetch("/api/edges")]);
|
|
67
|
+
const [nodesRes, edgesRes] = await Promise.all([fetch("/api/nodes?limit=all"), fetch("/api/edges?limit=all")]);
|
|
68
68
|
const nodesResponse = await nodesRes.json();
|
|
69
69
|
const edgesData = await edgesRes.json();
|
|
70
70
|
const nodesData: NodeRecord[] = Array.isArray(nodesResponse) ? nodesResponse : (nodesResponse.nodes || []);
|
|
@@ -13,6 +13,7 @@ import { WorkspacesPanel, WORKSPACES, type Workspace } from "@/components/worksp
|
|
|
13
13
|
const NAV_ITEMS = [
|
|
14
14
|
{ href: "/", label: "Wheel", color: "var(--mw-east)" },
|
|
15
15
|
{ href: "/graph", label: "Graph", color: "var(--mw-primary)" },
|
|
16
|
+
{ href: "/episodes", label: "Episodes", color: "var(--mw-node-ancestor)" },
|
|
16
17
|
{ href: "/nodes", label: "Nodes", color: "var(--mw-node-land)" },
|
|
17
18
|
{ href: "/relations", label: "Relations", color: "var(--mw-node-future)" },
|
|
18
19
|
{ href: "/ceremonies", label: "Ceremonies", color: "var(--mw-south)" },
|
|
@@ -47,22 +47,20 @@ export function WorkspacesPanel({
|
|
|
47
47
|
return () => window.removeEventListener("keydown", onKey);
|
|
48
48
|
}, [open, onClose]);
|
|
49
49
|
|
|
50
|
+
// Robustness: when closed, render nothing at all so the panel can never
|
|
51
|
+
// occupy layout space — not even if the CSS bundle that hides it (transform)
|
|
52
|
+
// fails to load. This is what keeps the switcher discreet on first paint.
|
|
53
|
+
if (!open) return null;
|
|
54
|
+
|
|
50
55
|
return (
|
|
51
56
|
<>
|
|
52
57
|
<div
|
|
53
|
-
aria-hidden={!open}
|
|
54
58
|
onClick={onClose}
|
|
55
|
-
className=
|
|
56
|
-
"fixed inset-0 z-40 bg-background/60 backdrop-blur-sm transition-opacity",
|
|
57
|
-
open ? "opacity-100" : "pointer-events-none opacity-0"
|
|
58
|
-
)}
|
|
59
|
+
className="fixed inset-0 z-40 bg-background/60 backdrop-blur-sm"
|
|
59
60
|
/>
|
|
60
61
|
<aside
|
|
61
62
|
aria-label="Workspaces"
|
|
62
|
-
className=
|
|
63
|
-
"fixed left-0 top-0 z-50 h-full w-[340px] border-r border-border bg-card transition-transform",
|
|
64
|
-
open ? "translate-x-0" : "-translate-x-full"
|
|
65
|
-
)}
|
|
63
|
+
className="fixed left-0 top-0 z-50 h-full w-[340px] max-w-[85vw] border-r border-border bg-card"
|
|
66
64
|
>
|
|
67
65
|
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
|
68
66
|
<div className="mw-h2">Workspaces</div>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared paging contract for the wheel's read routes.
|
|
3
|
+
*
|
|
4
|
+
* The provider's `getAllNodes()` / `getAllEdges()` default to 100 rows. A bare
|
|
5
|
+
* `GET /api/nodes` therefore returned 100 of the store's 205 nodes, and the graph
|
|
6
|
+
* page — which sends no parameters — drew that window while its own panel read
|
|
7
|
+
* "100 Nodes / 100 Relations". Of the 100 it drew, 53 had no drawable edge,
|
|
8
|
+
* because 27 of the 100 delivered edges pointed at nodes that were never sent.
|
|
9
|
+
*
|
|
10
|
+
* Nothing was broken. Every layer did what it was told. The lie was that no layer
|
|
11
|
+
* ever said how much it had left out.
|
|
12
|
+
*
|
|
13
|
+
* So both routes accept the same `limit`, and both report enough for a caller to
|
|
14
|
+
* know it holds a page: `?limit=all` for the whole store, `?limit=<n>` for n.
|
|
15
|
+
* This lives in `lib/` rather than in either route because a second hand-written
|
|
16
|
+
* copy of a validation rule is how the two drift apart — the same defect this
|
|
17
|
+
* repo just removed from `PersonRole`.
|
|
18
|
+
*/
|
|
19
|
+
import { NextResponse } from "next/server";
|
|
20
|
+
|
|
21
|
+
/** The provider's own default page size, restated so a route can report honestly. */
|
|
22
|
+
export const DEFAULT_PAGE_SIZE = 100;
|
|
23
|
+
|
|
24
|
+
/** Sentinel meaning "the whole store", distinct from "no limit given". */
|
|
25
|
+
export const LIMIT_ALL = "all";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns the requested page size, `null` for "everything", or a 400 response.
|
|
29
|
+
*
|
|
30
|
+
* An unparseable limit is refused rather than coerced. Falling back to the
|
|
31
|
+
* default on bad input would reintroduce the silent truncation this parameter
|
|
32
|
+
* exists to close, and would do it precisely when a caller had tried to ask.
|
|
33
|
+
*/
|
|
34
|
+
export function parseLimit(raw: string | null): number | null | NextResponse {
|
|
35
|
+
if (raw === null || raw === "") return DEFAULT_PAGE_SIZE;
|
|
36
|
+
if (raw === LIMIT_ALL) return null;
|
|
37
|
+
|
|
38
|
+
const parsed = Number(raw);
|
|
39
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
40
|
+
return NextResponse.json(
|
|
41
|
+
{
|
|
42
|
+
error: `Invalid limit: ${raw} — nothing was returned.`,
|
|
43
|
+
hint: `limit must be a positive integer, or "${LIMIT_ALL}" for the whole store.`,
|
|
44
|
+
},
|
|
45
|
+
{ status: 400 },
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return parsed;
|
|
49
|
+
}
|
package/lib/ceremony-response.ts
CHANGED
|
@@ -50,6 +50,27 @@ export function normalizeCeremonyLog(value: unknown): CeremonyLog | null {
|
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
export function ceremonyEpisodePath(value: unknown): string | undefined {
|
|
54
|
+
if (!value || typeof value !== "object") return undefined;
|
|
55
|
+
const researchContext = (value as Record<string, unknown>).research_context;
|
|
56
|
+
if (typeof researchContext !== "string") return undefined;
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const parsed = JSON.parse(researchContext) as Record<string, unknown> | null;
|
|
60
|
+
if (!parsed || typeof parsed !== "object") return undefined;
|
|
61
|
+
const episodePath = parsed.episode_path ?? parsed.episodePath;
|
|
62
|
+
return typeof episodePath === "string" && episodePath.length > 0
|
|
63
|
+
? episodePath
|
|
64
|
+
: undefined;
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function ceremonyBelongsToEpisode(value: unknown, episodePath: string): boolean {
|
|
71
|
+
return ceremonyEpisodePath(value) === episodePath;
|
|
72
|
+
}
|
|
73
|
+
|
|
53
74
|
export function extractCeremonyLogs(response: unknown): CeremonyLog[] {
|
|
54
75
|
const candidate = Array.isArray(response)
|
|
55
76
|
? response
|