@medicine-wheel/app 0.17.6 → 0.17.8

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.
@@ -30,9 +30,12 @@ import Link from "next/link";
30
30
  import type { RelationalNode, RelationalEdge } from "@/lib/types";
31
31
  import { EpisodeFilesPanel } from "@/components/episode-files-panel";
32
32
  import { EpisodeHoldings } from "@/components/episode-holdings";
33
+ import { EpisodeLineageWeb, type LineageEpisode } from "@/components/episode-lineage-web";
33
34
  import {
34
35
  chronicleKey,
36
+ episodeDateOf,
35
37
  episodeFolderOfNode,
38
+ lineageEdges,
36
39
  relationsOf,
37
40
  type EpisodeRelation,
38
41
  type LineageEdge,
@@ -113,6 +116,8 @@ export default function EpisodePage({ params }: { params: Promise<{ id: string }
113
116
  const [episode, setEpisode] = useState<EpisodeNode | null>(null);
114
117
  const [attention, setAttention] = useState<EpisodeNode[]>([]);
115
118
  const [web, setWeb] = useState<{ nodes: EpisodeNode[]; edges: RelationalEdge[] } | null>(null);
119
+ // The whole chronicle's lineage, for the picture: two relations away needs more than this episode's web.
120
+ const [chronicle, setChronicle] = useState<{ episodes: LineageEpisode[]; edges: LineageEdge[] } | null>(null);
116
121
  const [before, setBefore] = useState<EpisodeNode | null>(null);
117
122
  const [after, setAfter] = useState<EpisodeNode | null>(null);
118
123
  const [loading, setLoading] = useState(true);
@@ -122,10 +127,11 @@ export default function EpisodePage({ params }: { params: Promise<{ id: string }
122
127
  setLoading(true);
123
128
  try {
124
129
  const encoded = encodeURIComponent(id);
125
- const [nodeRes, attentionRes, webRes] = await Promise.all([
130
+ const [nodeRes, attentionRes, webRes, edgesRes] = await Promise.all([
126
131
  fetch(`/api/nodes?kind=chronicle_episode&limit=all`),
127
132
  fetch(`/api/nodes?kind=attention&parent_id=${encoded}&limit=all`),
128
133
  fetch(`/api/nodes/${encoded}/web?depth=1`),
134
+ fetch(`/api/edges?limit=all`).catch(() => null),
129
135
  ]);
130
136
 
131
137
  if (!nodeRes.ok) throw new Error(`Episodes: ${nodeRes.status}`);
@@ -138,6 +144,19 @@ export default function EpisodePage({ params }: { params: Promise<{ id: string }
138
144
  setBefore(at > 0 ? ordered[at - 1] : null);
139
145
  setAfter(at >= 0 && at < ordered.length - 1 ? ordered[at + 1] : null);
140
146
 
147
+ // The picture is a second answer: without the edges, the lists below still stand.
148
+ if (edgesRes?.ok) {
149
+ const body = await edgesRes.json();
150
+ const allEdges: LineageEdge[] = Array.isArray(body) ? body : (body.edges ?? []);
151
+ const ids = new Set(all.map((n) => n.id));
152
+ setChronicle({
153
+ episodes: ordered.map((n) => ({ id: n.id, name: n.name, direction: n.direction, date: episodeDateOf(n) })),
154
+ edges: lineageEdges(allEdges, (other) => ids.has(other)),
155
+ });
156
+ } else {
157
+ setChronicle(null);
158
+ }
159
+
141
160
  setAttention(attentionRes.ok ? ((await attentionRes.json()).nodes ?? []) : []);
142
161
 
143
162
  // A 404 here means the node is not in the wheel at all — distinct from a
@@ -257,6 +276,11 @@ export default function EpisodePage({ params }: { params: Promise<{ id: string }
257
276
  <h2 className="text-sm font-semibold mb-2">
258
277
  Lineage {lineage.length > 0 && `(${lineage.length})`}
259
278
  </h2>
279
+ {lineage.length > 0 && chronicle && (
280
+ <div className="mb-3">
281
+ <EpisodeLineageWeb episodes={chronicle.episodes} edges={chronicle.edges} around={id} panel={false} />
282
+ </div>
283
+ )}
260
284
  {lineage.length === 0 ? (
261
285
  <p className="text-sm text-muted-foreground">
262
286
  No relation to another episode has been woven yet.
@@ -4,18 +4,16 @@
4
4
  * The chronicle as a lineage: every episode on a spine read left to right, in
5
5
  * its direction's band, with the relations between episodes drawn as arcs.
6
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.
7
+ * The web itself is `LineageWeb` from `@medicine-wheel/graph-viz`, the one the
8
+ * Miadi chronicle draws too (#162). This file adapts the wheel's edges to it
9
+ * and shows, under it, the selected episode's relations and the words that say
10
+ * why each holds. A tap selects, a second tap opens. Ref jgwill/medicine-wheel#156.
14
11
  */
15
12
 
16
- import { useEffect, useMemo, useRef, useState } from "react";
13
+ import { useMemo, useState } from "react";
17
14
  import Link from "next/link";
18
15
  import { useRouter } from "next/navigation";
16
+ import { LineageWeb, type LineageWebEdge, type LineageWebLayoutConfig } from "@medicine-wheel/graph-viz";
19
17
  import { isContinuation, relationsOf, type LineageEdge } from "@/lib/episode-lineage";
20
18
 
21
19
  export interface LineageEpisode {
@@ -26,82 +24,52 @@ export interface LineageEpisode {
26
24
  date?: string;
27
25
  }
28
26
 
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;
27
+ const BAND_LABELS = { east: "🌅 east", south: "🔥 south", west: "🌊 west", north: "❄️ north" };
28
+ // The wheel's page is narrower than the chronicle's: closer bands, less room for arcs.
29
+ const LAYOUT: Partial<LineageWebLayoutConfig> = { headroom: 90, bandGap: 84, footroom: 84, minStep: 15, minWidth: 360 };
38
30
 
39
- function bandOf(direction?: string): number {
40
- const at = BANDS.indexOf((direction ?? "") as (typeof BANDS)[number]);
41
- return at === -1 ? 1.5 : at;
31
+ /** `E044` from a `chronicle:<date>-episode-044-…` id; the dot's chip shows the name under it. */
32
+ function markOf(id: string): string {
33
+ const n = /episode-(\d+)/i.exec(id)?.[1];
34
+ return n ? `E${n.padStart(3, "0")}` : "·";
42
35
  }
43
36
 
44
37
  export function EpisodeLineageWeb({
45
38
  episodes,
46
39
  edges,
47
40
  matches,
41
+ around,
42
+ panel = true,
48
43
  }: {
49
44
  /** In chronicle order, oldest first. */
50
45
  episodes: LineageEpisode[];
51
46
  /** Episode↔episode relations only. */
52
47
  edges: LineageEdge[];
53
- /** When a filter is typed: the episodes it matches. The rest fade. */
48
+ /** When a filter is typed: the episodes it matches. Their relatives stay, as rings. */
54
49
  matches?: Set<string> | null;
50
+ /** Open centred on this episode and its relatives (an episode's own page). */
51
+ around?: string;
52
+ /** The selected episode's relations under the web. Off where the page lists them already. */
53
+ panel?: boolean;
55
54
  }) {
56
55
  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]);
56
+ const [selected, setSelected] = useState<string | null>(around ?? null);
88
57
 
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]);
58
+ const webEpisodes = useMemo(
59
+ () => episodes.map((e) => ({ id: e.id, label: markOf(e.id), title: e.name, direction: e.direction, date: e.date })),
60
+ [episodes],
61
+ );
62
+ const webEdges = useMemo<LineageWebEdge[]>(
63
+ () =>
64
+ edges.map((edge) => ({
65
+ id: edge.id,
66
+ from: edge.from_id,
67
+ to: edge.to_id,
68
+ kind: isContinuation(edge) ? "continuation" : "relation",
69
+ words: edge.description,
70
+ })),
71
+ [edges],
72
+ );
105
73
 
106
74
  const byId = useMemo(() => new Map(episodes.map((e) => [e.id, e])), [episodes]);
107
75
  const chosen = selected ? byId.get(selected) : undefined;
@@ -110,106 +78,24 @@ export function EpisodeLineageWeb({
110
78
  [selected, edges],
111
79
  );
112
80
 
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
81
  return (
126
82
  <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>
83
+ <div className="border rounded-lg bg-card p-2">
84
+ <LineageWeb
85
+ episodes={webEpisodes}
86
+ edges={webEdges}
87
+ selected={selected}
88
+ onSelect={setSelected}
89
+ onOpen={(id) => router.push(`/episodes/${encodeURIComponent(id)}`)}
90
+ matches={matches}
91
+ layout={LAYOUT}
92
+ bandLabels={BAND_LABELS}
93
+ startAt={around ? "start" : "end"}
94
+ defaultView={around ? { scope: "around" } : undefined}
95
+ />
210
96
  </div>
211
97
 
212
- {chosen ? (
98
+ {!panel ? null : chosen ? (
213
99
  <div className="p-3 border rounded-lg bg-card space-y-2">
214
100
  <div className="flex items-baseline justify-between gap-3 flex-wrap">
215
101
  <Link href={`/episodes/${encodeURIComponent(chosen.id)}`} className="text-sm font-medium hover:underline">
@@ -237,9 +123,10 @@ export function EpisodeLineageWeb({
237
123
  </div>
238
124
  ) : (
239
125
  <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.
126
+ Each dot is an episode, oldest on the left, in its direction&apos;s band. Solid arcs above carry
127
+ a thread forward (continues from); dashed arcs below relate two episodes. Tap a dot to see why
128
+ they are related; tap it again to open it. A filter keeps what it found and, as rings, the
129
+ episodes related to it.
243
130
  </p>
244
131
  )}
245
132
  </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medicine-wheel/app",
3
- "version": "0.17.6",
3
+ "version": "0.17.8",
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.6",
95
- "@medicine-wheel/ceremony-protocol": "^0.17.6",
96
- "@medicine-wheel/community-identity": "^0.17.6",
97
- "@medicine-wheel/community-review": "^0.17.6",
98
- "@medicine-wheel/consent-lifecycle": "^0.17.6",
99
- "@medicine-wheel/creative-orientation": "^0.17.6",
100
- "@medicine-wheel/data-store": "^0.17.6",
101
- "@medicine-wheel/data-store-postgres": "^0.17.6",
102
- "@medicine-wheel/fire-keeper": "^0.17.6",
103
- "@medicine-wheel/github-ceremony": "^0.17.6",
104
- "@medicine-wheel/graph-viz": "^0.17.6",
105
- "@medicine-wheel/honcho": "^0.17.6",
106
- "@medicine-wheel/importance-unit": "^0.17.6",
107
- "@medicine-wheel/mcp": "^4.17.6",
108
- "@medicine-wheel/memory": "^0.17.6",
109
- "@medicine-wheel/narrative-cluster": "^0.17.6",
110
- "@medicine-wheel/narrative-engine": "^0.17.6",
111
- "@medicine-wheel/ontology-core": "^0.17.6",
112
- "@medicine-wheel/perception-layer": "^0.17.6",
113
- "@medicine-wheel/prompt-decomposition": "^0.17.6",
114
- "@medicine-wheel/relational-index": "^0.17.6",
115
- "@medicine-wheel/relational-query": "^0.17.6",
116
- "@medicine-wheel/session-reader": "^0.17.6",
117
- "@medicine-wheel/storage-provider": "^0.17.6",
118
- "@medicine-wheel/transformation-tracker": "^0.17.6",
119
- "@medicine-wheel/ui-components": "^0.17.6",
94
+ "@medicine-wheel/ceremonial-diary": "^0.17.8",
95
+ "@medicine-wheel/ceremony-protocol": "^0.17.8",
96
+ "@medicine-wheel/community-identity": "^0.17.8",
97
+ "@medicine-wheel/community-review": "^0.17.8",
98
+ "@medicine-wheel/consent-lifecycle": "^0.17.8",
99
+ "@medicine-wheel/creative-orientation": "^0.17.8",
100
+ "@medicine-wheel/data-store": "^0.17.8",
101
+ "@medicine-wheel/data-store-postgres": "^0.17.8",
102
+ "@medicine-wheel/fire-keeper": "^0.17.8",
103
+ "@medicine-wheel/github-ceremony": "^0.17.8",
104
+ "@medicine-wheel/graph-viz": "^0.17.8",
105
+ "@medicine-wheel/honcho": "^0.17.8",
106
+ "@medicine-wheel/importance-unit": "^0.17.8",
107
+ "@medicine-wheel/mcp": "^4.17.8",
108
+ "@medicine-wheel/memory": "^0.17.8",
109
+ "@medicine-wheel/narrative-cluster": "^0.17.8",
110
+ "@medicine-wheel/narrative-engine": "^0.17.8",
111
+ "@medicine-wheel/ontology-core": "^0.17.8",
112
+ "@medicine-wheel/perception-layer": "^0.17.8",
113
+ "@medicine-wheel/prompt-decomposition": "^0.17.8",
114
+ "@medicine-wheel/relational-index": "^0.17.8",
115
+ "@medicine-wheel/relational-query": "^0.17.8",
116
+ "@medicine-wheel/session-reader": "^0.17.8",
117
+ "@medicine-wheel/storage-provider": "^0.17.8",
118
+ "@medicine-wheel/transformation-tracker": "^0.17.8",
119
+ "@medicine-wheel/ui-components": "^0.17.8",
120
120
  "@miadi/episode-vessel": "^0.2.1",
121
121
  "@neondatabase/serverless": "^0.10.0",
122
122
  "@xyflow/react": "^12.3.0",