@medicine-wheel/app 0.6.4 → 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/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 +224 -14
- package/app/nodes/page.tsx +5 -2
- package/app/relations/page.tsx +1 -1
- package/components/navigation.tsx +1 -0
- package/lib/api-paging.ts +49 -0
- package/package.json +24 -24
package/app/graph/page.tsx
CHANGED
|
@@ -98,10 +98,34 @@ export default function GraphPage() {
|
|
|
98
98
|
const [graph, setGraph] = useState<MWGraphData>({ nodes: [], links: [] });
|
|
99
99
|
const [selectedNode, setSelectedNode] = useState<MWGraphNode | null>(null);
|
|
100
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);
|
|
101
122
|
const [highlightDirection, setHighlightDirection] = useState<DirectionParam | undefined>(undefined);
|
|
102
123
|
const [radialSnap, setRadialSnap] = useState<"off" | "ring" | "sector">("off");
|
|
103
124
|
const [loading, setLoading] = useState(true);
|
|
104
|
-
|
|
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);
|
|
105
129
|
const [animationsEnabled, setAnimationsEnabled] = useState(
|
|
106
130
|
DEFAULT_GRAPH_ANIMATION_ENABLED,
|
|
107
131
|
);
|
|
@@ -166,22 +190,54 @@ export default function GraphPage() {
|
|
|
166
190
|
|
|
167
191
|
const loadData = useCallback(async () => {
|
|
168
192
|
try {
|
|
169
|
-
|
|
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")]);
|
|
170
222
|
const nodesResponse = await nodesRes.json();
|
|
171
223
|
const edgesData: RelationalEdge[] = await edgesRes.json();
|
|
172
224
|
|
|
173
|
-
// API returns { nodes: [...], provider: '...', count: N }
|
|
225
|
+
// API returns { nodes: [...], provider: '...', count: N, total, truncated }
|
|
174
226
|
const nodesData: RelationalNode[] = Array.isArray(nodesResponse)
|
|
175
227
|
? nodesResponse
|
|
176
228
|
: nodesResponse.nodes || [];
|
|
177
229
|
|
|
178
230
|
setGraph(buildGraphData(nodesData, edgesData));
|
|
231
|
+
setScopeMeta(null);
|
|
232
|
+
setStoreTotal(
|
|
233
|
+
Array.isArray(nodesResponse) ? nodesData.length : (nodesResponse.total ?? nodesData.length),
|
|
234
|
+
);
|
|
179
235
|
} catch {
|
|
180
236
|
toast.error("Failed to load graph data");
|
|
181
237
|
} finally {
|
|
182
238
|
setLoading(false);
|
|
183
239
|
}
|
|
184
|
-
}, []);
|
|
240
|
+
}, [scopeId, scopeDepth]);
|
|
185
241
|
|
|
186
242
|
useEffect(() => {
|
|
187
243
|
loadData();
|
|
@@ -204,12 +260,31 @@ export default function GraphPage() {
|
|
|
204
260
|
const params = new URLSearchParams(window.location.search);
|
|
205
261
|
const node = params.get("node");
|
|
206
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
|
+
}
|
|
207
270
|
const direction = params.get("direction");
|
|
208
271
|
if (direction && (DIRECTION_NAMES as readonly string[]).includes(direction)) {
|
|
209
272
|
setHighlightDirection(direction as DirectionParam);
|
|
210
273
|
}
|
|
211
274
|
}, []);
|
|
212
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
|
+
|
|
213
288
|
// Surface the deep-linked node in the side panel once data arrives.
|
|
214
289
|
useEffect(() => {
|
|
215
290
|
if (!focusedNodeId || graph.nodes.length === 0) return;
|
|
@@ -217,10 +292,58 @@ export default function GraphPage() {
|
|
|
217
292
|
if (node) setSelectedNode(node);
|
|
218
293
|
}, [focusedNodeId, graph.nodes]);
|
|
219
294
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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;
|
|
224
347
|
|
|
225
348
|
useEffect(() => {
|
|
226
349
|
layoutStoreRef.current = layoutStore;
|
|
@@ -430,10 +553,15 @@ export default function GraphPage() {
|
|
|
430
553
|
</div>
|
|
431
554
|
<div className="flex flex-wrap gap-2">
|
|
432
555
|
<button
|
|
433
|
-
onClick={() => setShowLabels(!
|
|
434
|
-
className={`min-h-11 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
|
+
}
|
|
435
563
|
>
|
|
436
|
-
Labels {
|
|
564
|
+
Labels {labelsVisible ? "ON" : "OFF"}
|
|
437
565
|
</button>
|
|
438
566
|
<button
|
|
439
567
|
onClick={() =>
|
|
@@ -450,6 +578,64 @@ export default function GraphPage() {
|
|
|
450
578
|
</div>
|
|
451
579
|
</div>
|
|
452
580
|
|
|
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
|
+
|
|
453
639
|
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
|
|
454
640
|
<div className="relative flex-1 rounded-xl border border-white/10 overflow-hidden">
|
|
455
641
|
{!loading && graph.nodes.length > 0 && (
|
|
@@ -477,7 +663,7 @@ export default function GraphPage() {
|
|
|
477
663
|
data={graphData}
|
|
478
664
|
height={graphHeight}
|
|
479
665
|
darkMode
|
|
480
|
-
showNodeLabels={
|
|
666
|
+
showNodeLabels={labelsVisible}
|
|
481
667
|
showMiniMap={!isMobile}
|
|
482
668
|
animationsEnabled={animationsEnabled}
|
|
483
669
|
nodePositions={activeLayout.positions}
|
|
@@ -633,6 +819,14 @@ export default function GraphPage() {
|
|
|
633
819
|
<div><span className="text-xs text-gray-500">Type</span><p className="capitalize">{selectedNode.type}</p></div>
|
|
634
820
|
{selectedNode.direction && <div><span className="text-xs text-gray-500">Direction</span><p className="capitalize">{selectedNode.direction}</p></div>}
|
|
635
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
|
+
)}
|
|
636
830
|
<Link
|
|
637
831
|
href={`/nodes?node=${encodeURIComponent(selectedNode.id)}`}
|
|
638
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"
|
|
@@ -644,9 +838,25 @@ export default function GraphPage() {
|
|
|
644
838
|
)}
|
|
645
839
|
|
|
646
840
|
<div className="rounded-xl border border-white/10 p-4">
|
|
647
|
-
<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>
|
|
648
850
|
<div className="grid grid-cols-2 gap-3 text-center">
|
|
649
|
-
<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>
|
|
650
860
|
<div><p className="text-2xl font-bold">{graph.links.length}</p><p className="text-xs text-gray-500">Relations</p></div>
|
|
651
861
|
<div><p className="text-2xl font-bold">{ceremoniedCount}</p><p className="text-xs text-gray-500">Ceremonied</p></div>
|
|
652
862
|
<div><p className="text-2xl font-bold">{directionCount}</p><p className="text-xs text-gray-500">Directions</p></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)" },
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@medicine-wheel/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
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",
|
|
@@ -85,29 +85,29 @@
|
|
|
85
85
|
"release:major": "npm run version:major && npm run publish:all && npm run release:commit"
|
|
86
86
|
},
|
|
87
87
|
"dependencies": {
|
|
88
|
-
"@medicine-wheel/ceremonial-diary": "^0.
|
|
89
|
-
"@medicine-wheel/ceremony-protocol": "^0.
|
|
90
|
-
"@medicine-wheel/community-review": "^0.
|
|
91
|
-
"@medicine-wheel/consent-lifecycle": "^0.
|
|
92
|
-
"@medicine-wheel/creative-orientation": "^0.
|
|
93
|
-
"@medicine-wheel/data-store": "^0.
|
|
94
|
-
"@medicine-wheel/data-store-postgres": "^0.
|
|
95
|
-
"@medicine-wheel/fire-keeper": "^0.
|
|
96
|
-
"@medicine-wheel/github-ceremony": "^0.
|
|
97
|
-
"@medicine-wheel/graph-viz": "^0.
|
|
98
|
-
"@medicine-wheel/importance-unit": "^0.
|
|
99
|
-
"@medicine-wheel/mcp": "^4.
|
|
100
|
-
"@medicine-wheel/narrative-cluster": "^0.
|
|
101
|
-
"@medicine-wheel/narrative-engine": "^0.
|
|
102
|
-
"@medicine-wheel/ontology-core": "^0.
|
|
103
|
-
"@medicine-wheel/perception-layer": "^0.
|
|
104
|
-
"@medicine-wheel/prompt-decomposition": "^0.
|
|
105
|
-
"@medicine-wheel/relational-index": "^0.
|
|
106
|
-
"@medicine-wheel/relational-query": "^0.
|
|
107
|
-
"@medicine-wheel/session-reader": "^0.
|
|
108
|
-
"@medicine-wheel/storage-provider": "^0.
|
|
109
|
-
"@medicine-wheel/transformation-tracker": "^0.
|
|
110
|
-
"@medicine-wheel/ui-components": "^0.
|
|
88
|
+
"@medicine-wheel/ceremonial-diary": "^0.7.0",
|
|
89
|
+
"@medicine-wheel/ceremony-protocol": "^0.7.0",
|
|
90
|
+
"@medicine-wheel/community-review": "^0.7.0",
|
|
91
|
+
"@medicine-wheel/consent-lifecycle": "^0.7.0",
|
|
92
|
+
"@medicine-wheel/creative-orientation": "^0.7.0",
|
|
93
|
+
"@medicine-wheel/data-store": "^0.7.0",
|
|
94
|
+
"@medicine-wheel/data-store-postgres": "^0.7.0",
|
|
95
|
+
"@medicine-wheel/fire-keeper": "^0.7.0",
|
|
96
|
+
"@medicine-wheel/github-ceremony": "^0.7.0",
|
|
97
|
+
"@medicine-wheel/graph-viz": "^0.7.0",
|
|
98
|
+
"@medicine-wheel/importance-unit": "^0.7.0",
|
|
99
|
+
"@medicine-wheel/mcp": "^4.7.0",
|
|
100
|
+
"@medicine-wheel/narrative-cluster": "^0.7.0",
|
|
101
|
+
"@medicine-wheel/narrative-engine": "^0.7.0",
|
|
102
|
+
"@medicine-wheel/ontology-core": "^0.7.0",
|
|
103
|
+
"@medicine-wheel/perception-layer": "^0.7.0",
|
|
104
|
+
"@medicine-wheel/prompt-decomposition": "^0.7.0",
|
|
105
|
+
"@medicine-wheel/relational-index": "^0.7.0",
|
|
106
|
+
"@medicine-wheel/relational-query": "^0.7.0",
|
|
107
|
+
"@medicine-wheel/session-reader": "^0.7.0",
|
|
108
|
+
"@medicine-wheel/storage-provider": "^0.7.0",
|
|
109
|
+
"@medicine-wheel/transformation-tracker": "^0.7.0",
|
|
110
|
+
"@medicine-wheel/ui-components": "^0.7.0",
|
|
111
111
|
"@neondatabase/serverless": "^0.10.0",
|
|
112
112
|
"@xyflow/react": "^12.3.0",
|
|
113
113
|
"clsx": "^2.1.1",
|