@medicine-wheel/app 0.10.0 → 0.12.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/app/api/nodes/[id]/route.ts +29 -1
- package/app/graph/page.tsx +81 -6
- package/package.json +24 -24
|
@@ -70,7 +70,35 @@ export async function PATCH(request: Request, context: RouteContext) {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
const store = await createProvider();
|
|
73
|
-
|
|
73
|
+
|
|
74
|
+
// `metadata` MERGES. It used to replace, and that cost 76 episode nodes
|
|
75
|
+
// their `kind`, `contract`, `schema_version`, `root`, `relative_path`,
|
|
76
|
+
// `parent_id`, `status` and `source_issue` on 2026-09-08 — a migration sent
|
|
77
|
+
// `{metadata: {occurred_at}}` intending to add one key, got 200 OK seventy-six
|
|
78
|
+
// times, and silently emptied the rest. `chronicle_episode` fell from 85 to 9.
|
|
79
|
+
//
|
|
80
|
+
// Replace-on-PATCH is a landmine because every caller reasonably assumes
|
|
81
|
+
// otherwise: PATCH means "change these fields", and a nested object that
|
|
82
|
+
// silently drops its siblings breaks that everywhere it is used. A caller
|
|
83
|
+
// that genuinely wants to clear a key now says so with `null`.
|
|
84
|
+
let patch = parsed.data;
|
|
85
|
+
if (patch.metadata) {
|
|
86
|
+
const existing = await store.getNode(id);
|
|
87
|
+
if (existing) {
|
|
88
|
+
const merged: Record<string, unknown> = {
|
|
89
|
+
...(existing.metadata ?? {}),
|
|
90
|
+
...patch.metadata,
|
|
91
|
+
};
|
|
92
|
+
// Explicit null removes a key — the only way to shrink metadata, and it
|
|
93
|
+
// has to be asked for rather than being the accidental default.
|
|
94
|
+
for (const [key, value] of Object.entries(patch.metadata)) {
|
|
95
|
+
if (value === null) delete merged[key];
|
|
96
|
+
}
|
|
97
|
+
patch = { ...patch, metadata: merged };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const node = await store.updateNode(id, patch);
|
|
74
102
|
return NextResponse.json({ success: true, node, provider: detectProvider() });
|
|
75
103
|
} catch (error: unknown) {
|
|
76
104
|
if (error instanceof NodeNotFoundError) return notFound(error.nodeId);
|
package/app/graph/page.tsx
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import { type RelationalNode, type RelationalEdge, DIRECTION_COLORS } from "@/lib/types";
|
|
24
24
|
import { cn } from "@/lib/utils";
|
|
25
25
|
import {
|
|
26
|
+
applyLineageLayout,
|
|
26
27
|
applyWheelLayout,
|
|
27
28
|
buildGraphData,
|
|
28
29
|
type MWGraphData,
|
|
@@ -74,11 +75,17 @@ const GRAPH_COMPONENT_LINKS = [
|
|
|
74
75
|
{ href: "/accountability", label: "Accountability", icon: ShieldCheck },
|
|
75
76
|
];
|
|
76
77
|
|
|
77
|
-
function graphNodePositions(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
78
|
+
function graphNodePositions(
|
|
79
|
+
data: MWGraphData,
|
|
80
|
+
mode: "wheel" | "lineage" = "wheel",
|
|
81
|
+
): MWGraphNodePositions {
|
|
82
|
+
const laidOut =
|
|
83
|
+
mode === "lineage"
|
|
84
|
+
? applyLineageLayout({ nodes: data.nodes.map((node) => ({ ...node })), links: data.links })
|
|
85
|
+
: applyWheelLayout({
|
|
86
|
+
nodes: data.nodes.map((node) => ({ ...node })),
|
|
87
|
+
links: data.links,
|
|
88
|
+
});
|
|
82
89
|
const positions: MWGraphNodePositions = {};
|
|
83
90
|
|
|
84
91
|
for (const node of laidOut.nodes) {
|
|
@@ -119,6 +126,18 @@ export default function GraphPage() {
|
|
|
119
126
|
rootName: string | null;
|
|
120
127
|
} | null>(null);
|
|
121
128
|
const [storeTotal, setStoreTotal] = useState<number | null>(null);
|
|
129
|
+
/**
|
|
130
|
+
* The wheel answers "what direction is this?" and cannot answer "what came
|
|
131
|
+
* before this?" — a circle has no before. Lineage lays x as time and y as
|
|
132
|
+
* direction, which is only honest because episodes carry `occurred_at`:
|
|
133
|
+
* `created_at` is when the wheel LEARNED of a node, and 34 of 76 episodes
|
|
134
|
+
* disagreed with it, episode 001 by four months.
|
|
135
|
+
*/
|
|
136
|
+
const [layoutMode, setLayoutMode] = useState<"wheel" | "lineage">("wheel");
|
|
137
|
+
const [lineageMeta, setLineageMeta] = useState<{
|
|
138
|
+
span: { from: string; to: string } | null;
|
|
139
|
+
undated: number;
|
|
140
|
+
} | null>(null);
|
|
122
141
|
const [highlightDirection, setHighlightDirection] = useState<DirectionParam | undefined>(undefined);
|
|
123
142
|
const [radialSnap, setRadialSnap] = useState<"off" | "ring" | "sector">("off");
|
|
124
143
|
const [loading, setLoading] = useState(true);
|
|
@@ -352,13 +371,35 @@ export default function GraphPage() {
|
|
|
352
371
|
useEffect(() => {
|
|
353
372
|
if (!layoutsHydrated || graph.nodes.length === 0) return;
|
|
354
373
|
|
|
374
|
+
// Choosing lineage re-seeds unconditionally: it is a different question
|
|
375
|
+
// about the same graph, not a nudge to the current arrangement, and keeping
|
|
376
|
+
// a saved wheel disposition would leave the beings where the circle put
|
|
377
|
+
// them while claiming to show time.
|
|
378
|
+
if (layoutMode === "lineage") {
|
|
379
|
+
const laid = applyLineageLayout({
|
|
380
|
+
nodes: graph.nodes.map((n) => ({ ...n })),
|
|
381
|
+
links: graph.links,
|
|
382
|
+
});
|
|
383
|
+
const positions: MWGraphNodePositions = {};
|
|
384
|
+
for (const n of laid.nodes) {
|
|
385
|
+
if (typeof n.x === "number" && typeof n.y === "number") {
|
|
386
|
+
positions[n.id] = { x: n.x, y: n.y };
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
setLineageMeta({ span: laid.span, undated: laid.undated.length });
|
|
390
|
+
saveLayoutStore(upsertCurrentGraphLayout(layoutStoreRef.current, positions));
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
setLineageMeta(null);
|
|
395
|
+
|
|
355
396
|
const activeLayout = getActiveGraphLayout(layoutStoreRef.current);
|
|
356
397
|
if (Object.keys(activeLayout.positions).length > 0) return;
|
|
357
398
|
|
|
358
399
|
saveLayoutStore(
|
|
359
400
|
upsertCurrentGraphLayout(layoutStoreRef.current, graphNodePositions(graph)),
|
|
360
401
|
);
|
|
361
|
-
}, [graph, layoutsHydrated, saveLayoutStore]);
|
|
402
|
+
}, [graph, layoutsHydrated, saveLayoutStore, layoutMode]);
|
|
362
403
|
|
|
363
404
|
const ceremoniedCount = useMemo(
|
|
364
405
|
() => graph.links.filter((l) => l.ceremonyHonored).length,
|
|
@@ -572,6 +613,17 @@ export default function GraphPage() {
|
|
|
572
613
|
>
|
|
573
614
|
Snap {radialSnap === "off" ? "OFF" : radialSnap.toUpperCase()}
|
|
574
615
|
</button>
|
|
616
|
+
<button
|
|
617
|
+
onClick={() => setLayoutMode(layoutMode === "wheel" ? "lineage" : "wheel")}
|
|
618
|
+
className={`min-h-11 px-3 py-1.5 rounded text-sm ${layoutMode === "lineage" ? "bg-yellow-400/20 text-yellow-200" : "bg-white/5 hover:bg-white/10"}`}
|
|
619
|
+
title={
|
|
620
|
+
layoutMode === "wheel"
|
|
621
|
+
? "Lay the beings out by when they happened, left to right"
|
|
622
|
+
: "Return to the four-direction wheel"
|
|
623
|
+
}
|
|
624
|
+
>
|
|
625
|
+
{layoutMode === "wheel" ? "Wheel" : "Lineage"}
|
|
626
|
+
</button>
|
|
575
627
|
<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">
|
|
576
628
|
<RefreshCw className="h-4 w-4" /> Refresh
|
|
577
629
|
</button>
|
|
@@ -636,6 +688,29 @@ export default function GraphPage() {
|
|
|
636
688
|
</div>
|
|
637
689
|
)}
|
|
638
690
|
|
|
691
|
+
{layoutMode === "lineage" && lineageMeta && (
|
|
692
|
+
<div className="mb-3 rounded-xl border border-yellow-400/30 bg-yellow-400/5 px-3 py-2 text-sm">
|
|
693
|
+
<span className="text-gray-300">
|
|
694
|
+
Laid out by when things happened
|
|
695
|
+
{lineageMeta.span && (
|
|
696
|
+
<>
|
|
697
|
+
{" — "}
|
|
698
|
+
<span className="text-yellow-300">{lineageMeta.span.from}</span> to{" "}
|
|
699
|
+
<span className="text-yellow-300">{lineageMeta.span.to}</span>
|
|
700
|
+
</>
|
|
701
|
+
)}
|
|
702
|
+
. Left to right is time; the bands are the four directions.
|
|
703
|
+
</span>
|
|
704
|
+
{lineageMeta.undated > 0 && (
|
|
705
|
+
<p className="mt-1 text-xs text-gray-500">
|
|
706
|
+
{lineageMeta.undated} of {graph.nodes.length} carry no{" "}
|
|
707
|
+
<code>occurred_at</code> and are placed by when the wheel recorded them —
|
|
708
|
+
registration order, not history.
|
|
709
|
+
</p>
|
|
710
|
+
)}
|
|
711
|
+
</div>
|
|
712
|
+
)}
|
|
713
|
+
|
|
639
714
|
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
|
|
640
715
|
<div className="relative flex-1 rounded-xl border border-white/10 overflow-hidden">
|
|
641
716
|
{!loading && graph.nodes.length > 0 && (
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@medicine-wheel/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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.12.0",
|
|
89
|
+
"@medicine-wheel/ceremony-protocol": "^0.12.0",
|
|
90
|
+
"@medicine-wheel/community-review": "^0.12.0",
|
|
91
|
+
"@medicine-wheel/consent-lifecycle": "^0.12.0",
|
|
92
|
+
"@medicine-wheel/creative-orientation": "^0.12.0",
|
|
93
|
+
"@medicine-wheel/data-store": "^0.12.0",
|
|
94
|
+
"@medicine-wheel/data-store-postgres": "^0.12.0",
|
|
95
|
+
"@medicine-wheel/fire-keeper": "^0.12.0",
|
|
96
|
+
"@medicine-wheel/github-ceremony": "^0.12.0",
|
|
97
|
+
"@medicine-wheel/graph-viz": "^0.12.0",
|
|
98
|
+
"@medicine-wheel/importance-unit": "^0.12.0",
|
|
99
|
+
"@medicine-wheel/mcp": "^4.12.0",
|
|
100
|
+
"@medicine-wheel/narrative-cluster": "^0.12.0",
|
|
101
|
+
"@medicine-wheel/narrative-engine": "^0.12.0",
|
|
102
|
+
"@medicine-wheel/ontology-core": "^0.12.0",
|
|
103
|
+
"@medicine-wheel/perception-layer": "^0.12.0",
|
|
104
|
+
"@medicine-wheel/prompt-decomposition": "^0.12.0",
|
|
105
|
+
"@medicine-wheel/relational-index": "^0.12.0",
|
|
106
|
+
"@medicine-wheel/relational-query": "^0.12.0",
|
|
107
|
+
"@medicine-wheel/session-reader": "^0.12.0",
|
|
108
|
+
"@medicine-wheel/storage-provider": "^0.12.0",
|
|
109
|
+
"@medicine-wheel/transformation-tracker": "^0.12.0",
|
|
110
|
+
"@medicine-wheel/ui-components": "^0.12.0",
|
|
111
111
|
"@neondatabase/serverless": "^0.10.0",
|
|
112
112
|
"@xyflow/react": "^12.3.0",
|
|
113
113
|
"clsx": "^2.1.1",
|