@medicine-wheel/app 0.17.2 → 0.17.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.
package/README.md CHANGED
@@ -95,7 +95,7 @@ Ceremony lifecycle protocol — manages ceremony state, four-phase transitions (
95
95
  - **Dependencies:** `@medicine-wheel/ontology-core`
96
96
 
97
97
  ### [@medicine-wheel/fire-keeper](src/fire-keeper)
98
- Fire Keeper coordination agent — tends the ceremony fire, ensures relational integrity through gating conditions, permission tier escalation, and maintains Wilson alignment as an active agent that evaluates, gates, routes, and escalates.
98
+ Fire Keeper coordination agent — tends the ceremony fire through gating conditions (consent, ceremony phase, trajectory confidence), permission tier escalation, and human decision points; it evaluates, gates, routes, and escalates. It does not measure relational accountability; people judge that (#155).
99
99
 
100
100
  - **Dependencies:** `@medicine-wheel/ontology-core`, `@medicine-wheel/ceremony-protocol`
101
101
 
@@ -130,7 +130,7 @@ Four-source epistemic dimensional indexing — Land, Dream, Code, Vision travers
130
130
  - **Dependencies:** `@medicine-wheel/ontology-core`
131
131
 
132
132
  ### [@medicine-wheel/transformation-tracker](src/transformation-tracker)
133
- Research transformation tracking — Wilson validity criterion: "If research doesn't change you, you haven't done it right." Tracks researcher growth, community impact, relational shifts, reciprocity balance, and seven-generation sustainability.
133
+ Research transformation tracking — built on Wilson's line that research which does not change you has not been done right (2008, p. 135, through Wulff 2010); the validity check is ours. Tracks researcher growth, community impact, relational shifts, reciprocity balance, and seven-generation sustainability.
134
134
 
135
135
  - **Dependencies:** `@medicine-wheel/ontology-core`, `@medicine-wheel/ceremony-protocol`, `zod`
136
136
 
@@ -12,14 +12,15 @@
12
12
  * that filtered by episode long before any page asked them
13
13
  * (`components/episode-holdings.tsx`). What is open, from
14
14
  * `?kind=attention&parent_id=<id>`. What it relates to, from
15
- * `/api/nodes/[id]/web`, which walks the traversal that shipped in
16
- * `relational-query` and went unimported. Ref jgwill/medicine-wheel#153.
15
+ * `/api/nodes/[id]/web`: its lineage first — each relation to another episode
16
+ * read from this episode's end ("continues from", "continued by") with the
17
+ * words that say why it holds — then everything else it touches
18
+ * (`lib/episode-lineage.ts`). Ref jgwill/medicine-wheel#153, #156.
17
19
  *
18
- * The relations panel is where the wheel's current honesty shows. 101 of 106
19
- * containment links live only in `metadata.parent_id` and were never written as
20
- * edges, so most episodes will show a parent here that has no relation behind
21
- * it. The panel names that rather than rendering an empty box, because an empty
22
- * box reads as "this episode touches nothing" when the truth is "the edge was
20
+ * Containment links that live only in `metadata.parent_id` were never written
21
+ * as edges, so an episode can show a parent with no relation behind it. The
22
+ * page names that rather than rendering an empty box, because an empty box
23
+ * reads as "this episode touches nothing" when the truth is "the edge was
23
24
  * never written".
24
25
  */
25
26
 
@@ -29,6 +30,13 @@ import Link from "next/link";
29
30
  import type { RelationalNode, RelationalEdge } from "@/lib/types";
30
31
  import { EpisodeFilesPanel } from "@/components/episode-files-panel";
31
32
  import { EpisodeHoldings } from "@/components/episode-holdings";
33
+ import {
34
+ chronicleKey,
35
+ episodeFolderOfNode,
36
+ relationsOf,
37
+ type EpisodeRelation,
38
+ type LineageEdge,
39
+ } from "@/lib/episode-lineage";
32
40
 
33
41
  // `description` lives on the store's node, not on ontology-core's — the same
34
42
  // widening `app/nodes/page.tsx` declares as `NodeRecord`. Named EpisodeNode
@@ -50,31 +58,39 @@ function metaString(node: EpisodeNode | null, key: string): string | null {
50
58
  return typeof value === "string" && value ? value : null;
51
59
  }
52
60
 
53
- /**
54
- * Where an episode sits in the chronicle: its folder's date, then its number —
55
- * the order the folders keep on disk. Not `created_at`, which records when the
56
- * wheel learned of an episode, and not the number alone, which the chronicle
57
- * does not hand out in date order (episode 1000 follows episode 550 by two days).
58
- */
59
- function chronicleKey(node: EpisodeNode): string {
60
- const match = (episodeFolderOf(node) ?? "").match(/^(\d{4}-\d{2}-\d{2})-episode-(\d+)/i);
61
- return match ? `${match[1]}-${match[2].padStart(6, "0")}` : (node.created_at ?? "");
62
- }
63
-
64
- /**
65
- * The episode's folder name — what the wheel's other records file it under.
66
- *
67
- * From the id when it is `chronicle:<folder>`. A few early episodes were
68
- * registered under other ids (`node:knowledge:…`, a bare uuid); their node
69
- * still carries the artefact reference, so the folder comes from
70
- * `metadata.relative_path` when `metadata.root` names the chronicle.
71
- */
72
- function episodeFolderOf(node: EpisodeNode): string | null {
73
- if (node.id.startsWith("chronicle:")) return node.id.slice("chronicle:".length) || null;
74
- const relative = metaString(node, "relative_path");
75
- if (metaString(node, "root") !== "MIADI_CHRONICLE_ROOT" || !relative) return null;
76
- const folder = relative.split("/")[0];
77
- return folder && folder !== relative ? folder : null;
61
+ /** One relation, as read from this episode, with the words that say why it holds. */
62
+ function RelationRow({
63
+ relation,
64
+ node,
65
+ episode = false,
66
+ }: {
67
+ relation: EpisodeRelation;
68
+ node?: EpisodeNode;
69
+ episode?: boolean;
70
+ }) {
71
+ const name = node?.name ?? relation.other;
72
+ return (
73
+ <li className="p-3 border rounded-lg bg-card">
74
+ <p className="text-sm">
75
+ <span className="text-muted-foreground">{relation.label}</span>{" "}
76
+ {episode ? (
77
+ <Link href={`/episodes/${encodeURIComponent(relation.other)}`} className="font-medium hover:underline">
78
+ {name}
79
+ </Link>
80
+ ) : (
81
+ <span className="font-medium">{name}</span>
82
+ )}
83
+ </p>
84
+ {!episode && (
85
+ <span className="font-mono text-xs text-muted-foreground block break-all">{relation.other}</span>
86
+ )}
87
+ {relation.descriptions.map((said) => (
88
+ <p key={said} className="text-sm text-muted-foreground mt-1">
89
+ {said}
90
+ </p>
91
+ ))}
92
+ </li>
93
+ );
78
94
  }
79
95
 
80
96
  function EpisodeLink({ node, label }: { node: EpisodeNode | null; label: string }) {
@@ -140,10 +156,18 @@ export default function EpisodePage({ params }: { params: Promise<{ id: string }
140
156
  }, [load]);
141
157
 
142
158
  const parentId = metaString(episode, "parent_id");
143
- const folder = episode ? episodeFolderOf(episode) : null;
159
+ const folder = episode ? episodeFolderOfNode(episode) : null;
144
160
  const neighbours = (web?.nodes ?? []).filter((n) => n.id !== id);
145
161
  const parentHasEdge = Boolean(parentId && neighbours.some((n) => n.id === parentId));
146
162
 
163
+ // Each relation read from this episode's end, with the words that say why it
164
+ // holds; episodes first (the lineage), then everything else it touches.
165
+ const nodeById = new Map(neighbours.map((n) => [n.id, n]));
166
+ const relations = relationsOf(id, (web?.edges ?? []) as LineageEdge[]);
167
+ const isEpisodeId = (other: string) => nodeById.get(other)?.metadata?.kind === "chronicle_episode";
168
+ const lineage = relations.filter((r) => isEpisodeId(r.other));
169
+ const elsewhere = relations.filter((r) => !isEpisodeId(r.other));
170
+
147
171
  if (loading) {
148
172
  return <div className="p-6 max-w-4xl mx-auto text-sm text-muted-foreground">Loading…</div>;
149
173
  }
@@ -231,7 +255,24 @@ export default function EpisodePage({ params }: { params: Promise<{ id: string }
231
255
 
232
256
  <section>
233
257
  <h2 className="text-sm font-semibold mb-2">
234
- Relations {neighbours.length > 0 && `(${neighbours.length})`}
258
+ Lineage {lineage.length > 0 && `(${lineage.length})`}
259
+ </h2>
260
+ {lineage.length === 0 ? (
261
+ <p className="text-sm text-muted-foreground">
262
+ No relation to another episode has been woven yet.
263
+ </p>
264
+ ) : (
265
+ <ul className="space-y-2">
266
+ {lineage.map((r) => (
267
+ <RelationRow key={`${r.other}-${r.label}`} relation={r} node={nodeById.get(r.other)} episode />
268
+ ))}
269
+ </ul>
270
+ )}
271
+ </section>
272
+
273
+ <section>
274
+ <h2 className="text-sm font-semibold mb-2">
275
+ Also related {elsewhere.length > 0 && `(${elsewhere.length})`}
235
276
  </h2>
236
277
 
237
278
  {parentId && !parentHasEdge && (
@@ -242,37 +283,15 @@ export default function EpisodePage({ params }: { params: Promise<{ id: string }
242
283
  </p>
243
284
  )}
244
285
 
245
- {neighbours.length === 0 ? (
286
+ {elsewhere.length === 0 ? (
246
287
  <p className="text-sm text-muted-foreground">
247
- No relation reaches this episode. {parentId ? "See the note above." : ""}
288
+ Nothing else is related to this episode. {parentId && !parentHasEdge ? "See the note above." : ""}
248
289
  </p>
249
290
  ) : (
250
291
  <ul className="space-y-2">
251
- {neighbours.map((n) => {
252
- const edge = (web?.edges ?? []).find(
253
- (e) =>
254
- (e.from_id === id && e.to_id === n.id) ||
255
- (e.to_id === id && e.from_id === n.id),
256
- );
257
- return (
258
- <li
259
- key={n.id}
260
- className="p-3 border rounded-lg bg-card flex items-baseline justify-between gap-3 flex-wrap"
261
- >
262
- <span className="min-w-0">
263
- <span className="text-sm font-medium">{n.name}</span>
264
- <span className="font-mono text-xs text-muted-foreground block break-all">
265
- {n.id}
266
- </span>
267
- </span>
268
- {edge && (
269
- <span className="mw-badge shrink-0" title="relationship_type">
270
- {edge.from_id === id ? "→" : "←"} {edge.relationship_type}
271
- </span>
272
- )}
273
- </li>
274
- );
275
- })}
292
+ {elsewhere.map((r) => (
293
+ <RelationRow key={`${r.other}-${r.label}`} relation={r} node={nodeById.get(r.other)} />
294
+ ))}
276
295
  </ul>
277
296
  )}
278
297
 
@@ -21,6 +21,10 @@
21
21
  import { useCallback, useEffect, useMemo, useState } from "react";
22
22
  import Link from "next/link";
23
23
  import type { RelationalNode } from "@/lib/types";
24
+ import { EpisodeLineageWeb } from "@/components/episode-lineage-web";
25
+ import { chronicleKey, episodeDateOf, lineageEdges, type LineageEdge } from "@/lib/episode-lineage";
26
+
27
+ type View = "list" | "lineage";
24
28
 
25
29
  // `description` lives on the store's node, not on ontology-core's — the same
26
30
  // widening `app/nodes/page.tsx` declares as `NodeRecord`.
@@ -63,6 +67,24 @@ export default function EpisodesPage() {
63
67
  const [loading, setLoading] = useState(true);
64
68
  const [error, setError] = useState<string | null>(null);
65
69
  const [query, setQuery] = useState("");
70
+ const [edges, setEdges] = useState<LineageEdge[]>([]);
71
+ const [edgesFailed, setEdgesFailed] = useState(false);
72
+ const [view, setView] = useState<View>("list");
73
+
74
+ // `?view=lineage` opens on the lineage, so it can be linked to. Read once on
75
+ // mount from the location rather than through useSearchParams, which would
76
+ // need a Suspense boundary around the whole page.
77
+ useEffect(() => {
78
+ if (new URLSearchParams(window.location.search).get("view") === "lineage") setView("lineage");
79
+ }, []);
80
+
81
+ const choose = useCallback((next: View) => {
82
+ setView(next);
83
+ const url = new URL(window.location.href);
84
+ if (next === "lineage") url.searchParams.set("view", "lineage");
85
+ else url.searchParams.delete("view");
86
+ window.history.replaceState(null, "", url);
87
+ }, []);
66
88
 
67
89
  const load = useCallback(async () => {
68
90
  setLoading(true);
@@ -70,9 +92,10 @@ export default function EpisodesPage() {
70
92
  // limit=all, not the provider's 100-row default. The chronicle holds more
71
93
  // episodes than a default page, and a list that silently ends is worse
72
94
  // than one that refuses to load.
73
- const [episodesRes, attentionRes] = await Promise.all([
95
+ const [episodesRes, attentionRes, edgesRes] = await Promise.all([
74
96
  fetch("/api/nodes?kind=chronicle_episode&limit=all"),
75
97
  fetch("/api/nodes?kind=attention&limit=all"),
98
+ fetch("/api/edges?limit=all").catch(() => null),
76
99
  ]);
77
100
 
78
101
  if (!episodesRes.ok) throw new Error(`Episodes: ${episodesRes.status}`);
@@ -90,6 +113,18 @@ export default function EpisodesPage() {
90
113
 
91
114
  setEpisodes([...list].sort((a, b) => sortKey(b).localeCompare(sortKey(a))));
92
115
  setAttentionCounts(counts);
116
+
117
+ // The relations are a second answer: if they do not come, the list still does.
118
+ const ids = new Set(list.map((e) => e.id));
119
+ if (edgesRes?.ok) {
120
+ const body = await edgesRes.json();
121
+ const all: LineageEdge[] = Array.isArray(body) ? body : (body.edges ?? []);
122
+ setEdges(lineageEdges(all, (id) => ids.has(id)));
123
+ setEdgesFailed(false);
124
+ } else {
125
+ setEdges([]);
126
+ setEdgesFailed(true);
127
+ }
93
128
  setError(null);
94
129
  } catch (e) {
95
130
  setError(e instanceof Error ? e.message : String(e));
@@ -113,6 +148,30 @@ export default function EpisodesPage() {
113
148
  );
114
149
  }, [episodes, query]);
115
150
 
151
+ /** Relations to other episodes, per episode. */
152
+ const relationCounts = useMemo(() => {
153
+ const counts: Record<string, number> = {};
154
+ for (const edge of edges) {
155
+ counts[edge.from_id] = (counts[edge.from_id] ?? 0) + 1;
156
+ counts[edge.to_id] = (counts[edge.to_id] ?? 0) + 1;
157
+ }
158
+ return counts;
159
+ }, [edges]);
160
+
161
+ /** The lineage reads oldest first, in the folders' own order. */
162
+ const chronological = useMemo(
163
+ () =>
164
+ [...episodes]
165
+ .sort((a, b) => chronicleKey(a).localeCompare(chronicleKey(b)))
166
+ .map((e) => ({ id: e.id, name: e.name, direction: e.direction, date: episodeDateOf(e) ?? occurredAt(e)?.slice(0, 10) })),
167
+ [episodes],
168
+ );
169
+
170
+ const matching = useMemo(
171
+ () => (query.trim() ? new Set(visible.map((e) => e.id)) : null),
172
+ [query, visible],
173
+ );
174
+
116
175
  /** How many rows are ordered by registration rather than by when they happened. */
117
176
  const undatedCount = useMemo(
118
177
  () => episodes.filter((e) => !occurredAt(e)).length,
@@ -128,16 +187,41 @@ export default function EpisodesPage() {
128
187
  {loading ? "Reading the chronicle…" : `${episodes.length} episodes in the wheel`}
129
188
  </p>
130
189
  </div>
131
- <input
132
- value={query}
133
- onChange={(e) => setQuery(e.target.value)}
134
- placeholder="Filter by name or id"
135
- className="mw-input max-w-xs"
136
- aria-label="Filter episodes"
137
- />
190
+ <div className="flex items-center gap-2 flex-wrap">
191
+ <div className="flex gap-1" role="group" aria-label="View">
192
+ {(["list", "lineage"] as const).map((v) => (
193
+ <button
194
+ key={v}
195
+ onClick={() => choose(v)}
196
+ aria-pressed={view === v}
197
+ className={`mw-btn ${view === v ? "" : "mw-btn--ghost"}`}
198
+ >
199
+ {v === "list" ? "List" : "Lineage"}
200
+ </button>
201
+ ))}
202
+ </div>
203
+ <input
204
+ value={query}
205
+ onChange={(e) => setQuery(e.target.value)}
206
+ placeholder="Filter by name or id"
207
+ className="mw-input max-w-xs"
208
+ aria-label="Filter episodes"
209
+ />
210
+ </div>
138
211
  </div>
139
212
 
140
- {undatedCount > 0 && !loading && (
213
+ {view === "lineage" && !loading && episodes.length > 0 && (
214
+ <div className="mb-6">
215
+ {edgesFailed && (
216
+ <p className="mb-2 text-xs text-muted-foreground">
217
+ The wheel did not answer for its relations, so the lineage shows episodes without arcs.
218
+ </p>
219
+ )}
220
+ <EpisodeLineageWeb episodes={chronological} edges={edges} matches={matching} />
221
+ </div>
222
+ )}
223
+
224
+ {view === "list" && undatedCount > 0 && !loading && (
141
225
  <p className="mb-4 text-xs text-muted-foreground border rounded-lg p-3 bg-card">
142
226
  {undatedCount} of {episodes.length} episodes have no <code>occurred_at</code>, so they
143
227
  are ordered by when the wheel recorded them rather than by when they happened. That is
@@ -163,10 +247,12 @@ export default function EpisodesPage() {
163
247
  </p>
164
248
  )}
165
249
 
250
+ {view === "list" && (
166
251
  <ul className="space-y-2">
167
252
  {visible.map((episode) => {
168
253
  const number = episodeNumber(episode.id);
169
254
  const open = attentionCounts[episode.id] ?? 0;
255
+ const related = relationCounts[episode.id] ?? 0;
170
256
  const dated = occurredAt(episode);
171
257
  return (
172
258
  <li key={episode.id}>
@@ -184,6 +270,11 @@ export default function EpisodesPage() {
184
270
  <span className="font-medium truncate">{episode.name}</span>
185
271
  </div>
186
272
  <div className="flex items-center gap-2 shrink-0">
273
+ {related > 0 && (
274
+ <span className="mw-badge" title={`${related} relations to other episodes`}>
275
+ {related} related
276
+ </span>
277
+ )}
187
278
  {open > 0 && (
188
279
  <span className="mw-badge" title={`${open} open attention items`}>
189
280
  {open} attention
@@ -213,8 +304,9 @@ export default function EpisodesPage() {
213
304
  );
214
305
  })}
215
306
  </ul>
307
+ )}
216
308
 
217
- {!loading && visible.length === 0 && episodes.length > 0 && (
309
+ {view === "list" && !loading && visible.length === 0 && episodes.length > 0 && (
218
310
  <p className="text-sm text-muted-foreground">Nothing matches “{query}”.</p>
219
311
  )}
220
312
  </div>
@@ -0,0 +1,247 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The chronicle as a lineage: every episode on a spine read left to right, in
5
+ * its direction's band, with the relations between episodes drawn as arcs.
6
+ *
7
+ * Miadi's chronicle opens on this picture (`LineageWeb`), and it answers what a
8
+ * list cannot: which episodes carry a thread forward, which stand alone, where
9
+ * the work keeps returning. The wheel already holds every relation it draws.
10
+ *
11
+ * Tap an episode to see its relations and the words that say why each holds;
12
+ * tap it again, or its name, to open it. Touch first — a phone has no hover.
13
+ * Ref jgwill/medicine-wheel#156.
14
+ */
15
+
16
+ import { useEffect, useMemo, useRef, useState } from "react";
17
+ import Link from "next/link";
18
+ import { useRouter } from "next/navigation";
19
+ import { isContinuation, relationsOf, type LineageEdge } from "@/lib/episode-lineage";
20
+
21
+ export interface LineageEpisode {
22
+ id: string;
23
+ name: string;
24
+ direction?: string;
25
+ /** `YYYY-MM-DD`, when known: the axis is labelled by month. */
26
+ date?: string;
27
+ }
28
+
29
+ const BANDS = ["east", "south", "west", "north"] as const;
30
+ const BAND_ICON: Record<string, string> = { east: "🌅", south: "🔥", west: "🌊", north: "❄️" };
31
+ const BAND_H = 64;
32
+ const TOP = 28;
33
+ const AXIS = 26;
34
+ const STEP = 15;
35
+ const MARGIN_L = 72;
36
+ const MARGIN_R = 24;
37
+ const H = TOP + BAND_H * BANDS.length + AXIS;
38
+
39
+ function bandOf(direction?: string): number {
40
+ const at = BANDS.indexOf((direction ?? "") as (typeof BANDS)[number]);
41
+ return at === -1 ? 1.5 : at;
42
+ }
43
+
44
+ export function EpisodeLineageWeb({
45
+ episodes,
46
+ edges,
47
+ matches,
48
+ }: {
49
+ /** In chronicle order, oldest first. */
50
+ episodes: LineageEpisode[];
51
+ /** Episode↔episode relations only. */
52
+ edges: LineageEdge[];
53
+ /** When a filter is typed: the episodes it matches. The rest fade. */
54
+ matches?: Set<string> | null;
55
+ }) {
56
+ const router = useRouter();
57
+ const scroller = useRef<HTMLDivElement>(null);
58
+ const [selected, setSelected] = useState<string | null>(null);
59
+ const [hovered, setHovered] = useState<string | null>(null);
60
+
61
+ const layout = useMemo(() => {
62
+ const position = new Map<string, { x: number; y: number }>();
63
+ episodes.forEach((episode, i) => {
64
+ const x = MARGIN_L + i * STEP;
65
+ // A small stagger so neighbours in one band do not sit on one line.
66
+ const y = TOP + (bandOf(episode.direction) + 0.5) * BAND_H + ((i % 3) - 1) * 13;
67
+ position.set(episode.id, { x, y });
68
+ });
69
+ const width = Math.max(MARGIN_L + (episodes.length - 1) * STEP + MARGIN_R, 360);
70
+
71
+ const months: Array<{ x: number; label: string }> = [];
72
+ let last = "";
73
+ episodes.forEach((episode, i) => {
74
+ const month = episode.date?.slice(0, 7) ?? "";
75
+ if (month && month !== last) {
76
+ months.push({ x: MARGIN_L + i * STEP, label: month });
77
+ last = month;
78
+ }
79
+ });
80
+
81
+ const degree = new Map<string, number>();
82
+ for (const edge of edges) {
83
+ degree.set(edge.from_id, (degree.get(edge.from_id) ?? 0) + 1);
84
+ degree.set(edge.to_id, (degree.get(edge.to_id) ?? 0) + 1);
85
+ }
86
+ return { position, width, months, degree };
87
+ }, [episodes, edges]);
88
+
89
+ // The newest episodes are where the work is: open scrolled to the right.
90
+ useEffect(() => {
91
+ const el = scroller.current;
92
+ if (el) el.scrollLeft = el.scrollWidth;
93
+ }, [layout.width]);
94
+
95
+ const focus = hovered ?? selected;
96
+ const near = useMemo(() => {
97
+ if (!focus) return null;
98
+ const set = new Set([focus]);
99
+ for (const edge of edges) {
100
+ if (edge.from_id === focus) set.add(edge.to_id);
101
+ if (edge.to_id === focus) set.add(edge.from_id);
102
+ }
103
+ return set;
104
+ }, [focus, edges]);
105
+
106
+ const byId = useMemo(() => new Map(episodes.map((e) => [e.id, e])), [episodes]);
107
+ const chosen = selected ? byId.get(selected) : undefined;
108
+ const chosenRelations = useMemo(
109
+ () => (selected ? relationsOf(selected, edges) : []),
110
+ [selected, edges],
111
+ );
112
+
113
+ function open(id: string) {
114
+ router.push(`/episodes/${encodeURIComponent(id)}`);
115
+ }
116
+
117
+ function tap(id: string) {
118
+ if (selected === id) open(id);
119
+ else setSelected(id);
120
+ }
121
+
122
+ const faded = (id: string) =>
123
+ (near !== null && !near.has(id)) || (matches != null && !matches.has(id));
124
+
125
+ return (
126
+ <div className="space-y-3">
127
+ <div ref={scroller} className="overflow-x-auto border rounded-lg bg-card">
128
+ <svg
129
+ width={layout.width}
130
+ height={H}
131
+ viewBox={`0 0 ${layout.width} ${H}`}
132
+ role="img"
133
+ aria-label={`${episodes.length} episodes in chronicle order, with ${edges.length} relations between them`}
134
+ onClick={(event) => {
135
+ if (event.target === event.currentTarget) setSelected(null);
136
+ }}
137
+ >
138
+ {BANDS.map((band, i) => (
139
+ <g key={band}>
140
+ <line
141
+ x1={0}
142
+ x2={layout.width}
143
+ y1={TOP + (i + 1) * BAND_H}
144
+ y2={TOP + (i + 1) * BAND_H}
145
+ stroke="var(--mw-border)"
146
+ strokeWidth={1}
147
+ opacity={0.5}
148
+ />
149
+ <text x={8} y={TOP + i * BAND_H + 16} fontSize={11} fill="var(--mw-muted)">
150
+ {BAND_ICON[band]} {band}
151
+ </text>
152
+ </g>
153
+ ))}
154
+
155
+ {layout.months.map((m) => (
156
+ <text key={m.label} x={m.x} y={H - 8} fontSize={10} fill="var(--mw-muted)">
157
+ {m.label}
158
+ </text>
159
+ ))}
160
+
161
+ {edges.map((edge) => {
162
+ const a = layout.position.get(edge.from_id);
163
+ const b = layout.position.get(edge.to_id);
164
+ if (!a || !b) return null;
165
+ const lit = focus !== null && (edge.from_id === focus || edge.to_id === focus);
166
+ const lift = Math.min(110, Math.abs(b.x - a.x) * 0.3) + 18;
167
+ const top = Math.max(6, Math.min(a.y, b.y) - lift);
168
+ const continuation = isContinuation(edge);
169
+ return (
170
+ <path
171
+ key={edge.id}
172
+ d={`M ${a.x} ${a.y} Q ${(a.x + b.x) / 2} ${top} ${b.x} ${b.y}`}
173
+ fill="none"
174
+ stroke={lit ? "var(--mw-primary)" : "var(--mw-muted)"}
175
+ strokeWidth={lit ? 2 : 1}
176
+ strokeDasharray={continuation ? undefined : "3 3"}
177
+ opacity={lit ? 1 : focus ? 0.12 : continuation ? 0.55 : 0.3}
178
+ />
179
+ );
180
+ })}
181
+
182
+ {episodes.map((episode) => {
183
+ const p = layout.position.get(episode.id)!;
184
+ const related = (layout.degree.get(episode.id) ?? 0) > 0;
185
+ const isSelected = episode.id === selected;
186
+ return (
187
+ <g
188
+ key={episode.id}
189
+ onClick={() => tap(episode.id)}
190
+ onMouseEnter={() => setHovered(episode.id)}
191
+ onMouseLeave={() => setHovered(null)}
192
+ style={{ cursor: "pointer" }}
193
+ opacity={faded(episode.id) ? 0.2 : 1}
194
+ >
195
+ <title>{episode.name}</title>
196
+ {/* A target a finger can hit, larger than the dot it holds. */}
197
+ <circle cx={p.x} cy={p.y} r={9} fill="transparent" />
198
+ <circle
199
+ cx={p.x}
200
+ cy={p.y}
201
+ r={related ? 4.5 : 3}
202
+ fill={`var(--mw-${episode.direction ?? "west"}-ink, var(--mw-muted))`}
203
+ stroke={isSelected ? "var(--mw-primary)" : "none"}
204
+ strokeWidth={isSelected ? 2.5 : 0}
205
+ />
206
+ </g>
207
+ );
208
+ })}
209
+ </svg>
210
+ </div>
211
+
212
+ {chosen ? (
213
+ <div className="p-3 border rounded-lg bg-card space-y-2">
214
+ <div className="flex items-baseline justify-between gap-3 flex-wrap">
215
+ <Link href={`/episodes/${encodeURIComponent(chosen.id)}`} className="text-sm font-medium hover:underline">
216
+ {chosen.name}
217
+ </Link>
218
+ <span className="text-xs text-muted-foreground">{chosen.date}</span>
219
+ </div>
220
+ {chosenRelations.length === 0 ? (
221
+ <p className="text-xs text-muted-foreground">No relation to another episode has been woven yet.</p>
222
+ ) : (
223
+ <ul className="space-y-1">
224
+ {chosenRelations.map((r) => (
225
+ <li key={`${r.other}-${r.label}`} className="text-sm">
226
+ <span className="text-muted-foreground">{r.label}</span>{" "}
227
+ <button className="hover:underline text-left" onClick={() => setSelected(r.other)}>
228
+ {byId.get(r.other)?.name ?? r.other}
229
+ </button>
230
+ {r.descriptions[0] && (
231
+ <p className="text-xs text-muted-foreground pl-3">{r.descriptions[0]}</p>
232
+ )}
233
+ </li>
234
+ ))}
235
+ </ul>
236
+ )}
237
+ </div>
238
+ ) : (
239
+ <p className="text-xs text-muted-foreground">
240
+ Each dot is an episode, oldest on the left, in its direction&apos;s band. Solid arcs carry
241
+ a thread forward (continues from); dashed arcs relate two episodes. Tap a dot to see why
242
+ they are related; tap it again to open it.
243
+ </p>
244
+ )}
245
+ </div>
246
+ );
247
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * An episode's lineage, read from the relations the wheel already holds.
3
+ *
4
+ * Miadi's chronicle writes every episode↔episode relation to the wheel as an
5
+ * edge, with the words that say why it holds in `description` (jgwill/Miadi#702,
6
+ * jgwill/medicine-wheel#150). The wheel kept them, and its Episodes tab listed a
7
+ * neighbour's name and a raw `relationship_type` — never the words, and never
8
+ * which way the relation runs. This module turns an edge into the sentence a
9
+ * reader expects from where they stand: on episode 549, `549 continues_from 045`
10
+ * reads "continues from 045"; on episode 045, the same edge reads "continued by
11
+ * 549".
12
+ *
13
+ * Pure functions, no fetching: the episode page and the lineage web read the
14
+ * same answer. Ref jgwill/medicine-wheel#156.
15
+ */
16
+
17
+ export interface LineageEdge {
18
+ id: string;
19
+ from_id: string;
20
+ to_id: string;
21
+ relationship_type: string;
22
+ description?: string;
23
+ }
24
+
25
+ export interface EpisodeRelation {
26
+ /** The node on the other end. */
27
+ other: string;
28
+ /** The relation as read from this episode: "continues from", "continued by", "relates to"… */
29
+ label: string;
30
+ /** Lineage relations sort first, in this order; everything else after them. */
31
+ rank: number;
32
+ /** The words that say why it holds — one per edge that said something. */
33
+ descriptions: string[];
34
+ /** The edges this relation was read from (two when both directions were woven). */
35
+ edgeIds: string[];
36
+ }
37
+
38
+ /**
39
+ * Relations with a direction: [as read from `from_id`, as read from `to_id`].
40
+ * Both spellings appear on the chronicle wheel (`continues_from` from Miadi's
41
+ * lineage door, `continues-from` from an earlier writer), so both are named.
42
+ */
43
+ const DIRECTED: Record<string, [string, string]> = {
44
+ continues_from: ["continues from", "continued by"],
45
+ "continues-from": ["continues from", "continued by"],
46
+ continued_by: ["continued by", "continues from"],
47
+ "continued-by": ["continued by", "continues from"],
48
+ descends_from: ["descends from", "gave rise to"],
49
+ "descends-from": ["descends from", "gave rise to"],
50
+ born_from: ["born from", "gave birth to"],
51
+ };
52
+
53
+ /** Relations that read the same from either end. */
54
+ const SYMMETRIC = new Set(["relates_to", "relates-to", "speaks-with", "speaks_with"]);
55
+
56
+ const RANK: Record<string, number> = {
57
+ "continues from": 0,
58
+ "descends from": 0,
59
+ "born from": 0,
60
+ "continued by": 1,
61
+ "gave rise to": 1,
62
+ "gave birth to": 1,
63
+ "relates to": 2,
64
+ };
65
+
66
+ function words(type: string): string {
67
+ return type.replace(/[_-]+/g, " ").trim().toLowerCase();
68
+ }
69
+
70
+ /** One edge, read from `episodeId`'s end. Null when the edge does not touch it, or loops on it. */
71
+ export function readEdge(episodeId: string, edge: LineageEdge): { other: string; label: string } | null {
72
+ const outgoing = edge.from_id === episodeId;
73
+ const incoming = edge.to_id === episodeId;
74
+ if (outgoing === incoming) return null;
75
+ const other = outgoing ? edge.to_id : edge.from_id;
76
+ const type = edge.relationship_type ?? "";
77
+ const directed = DIRECTED[type.toLowerCase()];
78
+ if (directed) return { other, label: outgoing ? directed[0] : directed[1] };
79
+ if (SYMMETRIC.has(type.toLowerCase())) return { other, label: words(type) };
80
+ // A type the chronicle has not named a reverse for keeps its own words, and
81
+ // an arrow says which way it was written.
82
+ return { other, label: `${outgoing ? "→" : "←"} ${words(type) || "related"}` };
83
+ }
84
+
85
+ /**
86
+ * Every relation touching an episode, one per (other node, reading).
87
+ *
88
+ * Miadi's lineage door can weave a relation both ways — `A relates_to B` and
89
+ * `B relates_to A`, each with its own words. Read from A, those are the same
90
+ * relation said twice, so they merge and keep both sets of words. Two different
91
+ * readings of the same pair (A continues from B, and also relates to it) stay
92
+ * two relations.
93
+ */
94
+ export function relationsOf(episodeId: string, edges: LineageEdge[]): EpisodeRelation[] {
95
+ const byKey = new Map<string, EpisodeRelation>();
96
+ for (const edge of edges) {
97
+ const read = readEdge(episodeId, edge);
98
+ if (!read) continue;
99
+ const key = `${read.other}\u0000${read.label}`;
100
+ const relation =
101
+ byKey.get(key) ??
102
+ { other: read.other, label: read.label, rank: RANK[read.label] ?? 3, descriptions: [], edgeIds: [] };
103
+ relation.edgeIds.push(edge.id);
104
+ const said = edge.description?.trim();
105
+ if (said && !relation.descriptions.includes(said)) relation.descriptions.push(said);
106
+ byKey.set(key, relation);
107
+ }
108
+ return [...byKey.values()].sort((a, b) => a.rank - b.rank || a.other.localeCompare(b.other));
109
+ }
110
+
111
+ /** Episode↔episode edges only — the lineage, without the chronicle root every episode belongs to. */
112
+ export function lineageEdges(edges: LineageEdge[], isEpisode: (id: string) => boolean): LineageEdge[] {
113
+ return edges.filter(
114
+ (edge) => edge.from_id !== edge.to_id && isEpisode(edge.from_id) && isEpisode(edge.to_id),
115
+ );
116
+ }
117
+
118
+ /** Whether a reading runs along time (continues, descends) rather than across it (relates). */
119
+ export function isContinuation(edge: LineageEdge): boolean {
120
+ return Boolean(DIRECTED[(edge.relationship_type ?? "").toLowerCase()]);
121
+ }
122
+
123
+ // ── Where an episode sits in the chronicle ──────────────────────────────────
124
+
125
+ /** The fields of a wheel node these helpers read. */
126
+ export interface ChronicleNode {
127
+ id: string;
128
+ created_at?: string;
129
+ metadata?: Record<string, unknown>;
130
+ }
131
+
132
+ const FOLDER = /^(\d{4}-\d{2}-\d{2})-episode-(\d+)/i;
133
+
134
+ /**
135
+ * The episode's folder name — what the wheel's other records file it under.
136
+ *
137
+ * From the id when it is `chronicle:<folder>`. A few early episodes were
138
+ * registered under other ids (`node:knowledge:…`, a bare uuid); their node
139
+ * still carries the artefact reference, so the folder comes from
140
+ * `metadata.relative_path` when `metadata.root` names the chronicle.
141
+ */
142
+ export function episodeFolderOfNode(node: ChronicleNode): string | null {
143
+ if (node.id.startsWith("chronicle:")) return node.id.slice("chronicle:".length) || null;
144
+ const root = node.metadata?.root;
145
+ const relative = node.metadata?.relative_path;
146
+ if (root !== "MIADI_CHRONICLE_ROOT" || typeof relative !== "string") return null;
147
+ const folder = relative.split("/")[0];
148
+ return folder && folder !== relative ? folder : null;
149
+ }
150
+
151
+ /**
152
+ * Where an episode sits in the chronicle: its folder's date, then its number —
153
+ * the order the folders keep on disk. Not `created_at`, which records when the
154
+ * wheel learned of an episode, and not the number alone, which the chronicle
155
+ * does not hand out in date order (episode 1000 follows episode 550 by two days).
156
+ */
157
+ export function chronicleKey(node: ChronicleNode): string {
158
+ const match = (episodeFolderOfNode(node) ?? "").match(FOLDER);
159
+ return match ? `${match[1]}-${match[2].padStart(6, "0")}` : (node.created_at ?? "");
160
+ }
161
+
162
+ /** The day the episode's folder is dated, when it has one. */
163
+ export function episodeDateOf(node: ChronicleNode): string | undefined {
164
+ return (episodeFolderOfNode(node) ?? "").match(FOLDER)?.[1];
165
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medicine-wheel/app",
3
- "version": "0.17.2",
3
+ "version": "0.17.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",
@@ -91,32 +91,32 @@
91
91
  "release:major": "npm run version:major && npm run publish:all && npm run release:commit"
92
92
  },
93
93
  "dependencies": {
94
- "@medicine-wheel/ceremonial-diary": "^0.17.2",
95
- "@medicine-wheel/ceremony-protocol": "^0.17.2",
96
- "@medicine-wheel/community-identity": "^0.17.2",
97
- "@medicine-wheel/community-review": "^0.17.2",
98
- "@medicine-wheel/consent-lifecycle": "^0.17.2",
99
- "@medicine-wheel/creative-orientation": "^0.17.2",
100
- "@medicine-wheel/data-store": "^0.17.2",
101
- "@medicine-wheel/data-store-postgres": "^0.17.2",
102
- "@medicine-wheel/fire-keeper": "^0.17.2",
103
- "@medicine-wheel/github-ceremony": "^0.17.2",
104
- "@medicine-wheel/graph-viz": "^0.17.2",
105
- "@medicine-wheel/honcho": "^0.17.2",
106
- "@medicine-wheel/importance-unit": "^0.17.2",
107
- "@medicine-wheel/mcp": "^4.17.2",
108
- "@medicine-wheel/memory": "^0.17.2",
109
- "@medicine-wheel/narrative-cluster": "^0.17.2",
110
- "@medicine-wheel/narrative-engine": "^0.17.2",
111
- "@medicine-wheel/ontology-core": "^0.17.2",
112
- "@medicine-wheel/perception-layer": "^0.17.2",
113
- "@medicine-wheel/prompt-decomposition": "^0.17.2",
114
- "@medicine-wheel/relational-index": "^0.17.2",
115
- "@medicine-wheel/relational-query": "^0.17.2",
116
- "@medicine-wheel/session-reader": "^0.17.2",
117
- "@medicine-wheel/storage-provider": "^0.17.2",
118
- "@medicine-wheel/transformation-tracker": "^0.17.2",
119
- "@medicine-wheel/ui-components": "^0.17.2",
94
+ "@medicine-wheel/ceremonial-diary": "^0.17.3",
95
+ "@medicine-wheel/ceremony-protocol": "^0.17.3",
96
+ "@medicine-wheel/community-identity": "^0.17.3",
97
+ "@medicine-wheel/community-review": "^0.17.3",
98
+ "@medicine-wheel/consent-lifecycle": "^0.17.3",
99
+ "@medicine-wheel/creative-orientation": "^0.17.3",
100
+ "@medicine-wheel/data-store": "^0.17.3",
101
+ "@medicine-wheel/data-store-postgres": "^0.17.3",
102
+ "@medicine-wheel/fire-keeper": "^0.17.3",
103
+ "@medicine-wheel/github-ceremony": "^0.17.3",
104
+ "@medicine-wheel/graph-viz": "^0.17.3",
105
+ "@medicine-wheel/honcho": "^0.17.3",
106
+ "@medicine-wheel/importance-unit": "^0.17.3",
107
+ "@medicine-wheel/mcp": "^4.17.3",
108
+ "@medicine-wheel/memory": "^0.17.3",
109
+ "@medicine-wheel/narrative-cluster": "^0.17.3",
110
+ "@medicine-wheel/narrative-engine": "^0.17.3",
111
+ "@medicine-wheel/ontology-core": "^0.17.3",
112
+ "@medicine-wheel/perception-layer": "^0.17.3",
113
+ "@medicine-wheel/prompt-decomposition": "^0.17.3",
114
+ "@medicine-wheel/relational-index": "^0.17.3",
115
+ "@medicine-wheel/relational-query": "^0.17.3",
116
+ "@medicine-wheel/session-reader": "^0.17.3",
117
+ "@medicine-wheel/storage-provider": "^0.17.3",
118
+ "@medicine-wheel/transformation-tracker": "^0.17.3",
119
+ "@medicine-wheel/ui-components": "^0.17.3",
120
120
  "@miadi/episode-vessel": "^0.2.1",
121
121
  "@neondatabase/serverless": "^0.10.0",
122
122
  "@xyflow/react": "^12.3.0",