@medicine-wheel/app 0.17.6 โ†’ 0.17.7

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.
@@ -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,19 +24,14 @@ 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({
@@ -50,58 +43,27 @@ export function EpisodeLineageWeb({
50
43
  episodes: LineageEpisode[];
51
44
  /** Episodeโ†”episode relations only. */
52
45
  edges: LineageEdge[];
53
- /** When a filter is typed: the episodes it matches. The rest fade. */
46
+ /** When a filter is typed: the episodes it matches. Their relatives stay, as rings. */
54
47
  matches?: Set<string> | null;
55
48
  }) {
56
49
  const router = useRouter();
57
- const scroller = useRef<HTMLDivElement>(null);
58
50
  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
51
 
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]);
52
+ const webEpisodes = useMemo(
53
+ () => episodes.map((e) => ({ id: e.id, label: markOf(e.id), title: e.name, direction: e.direction, date: e.date })),
54
+ [episodes],
55
+ );
56
+ const webEdges = useMemo<LineageWebEdge[]>(
57
+ () =>
58
+ edges.map((edge) => ({
59
+ id: edge.id,
60
+ from: edge.from_id,
61
+ to: edge.to_id,
62
+ kind: isContinuation(edge) ? "continuation" : "relation",
63
+ words: edge.description,
64
+ })),
65
+ [edges],
66
+ );
105
67
 
106
68
  const byId = useMemo(() => new Map(episodes.map((e) => [e.id, e])), [episodes]);
107
69
  const chosen = selected ? byId.get(selected) : undefined;
@@ -110,103 +72,20 @@ export function EpisodeLineageWeb({
110
72
  [selected, edges],
111
73
  );
112
74
 
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
75
  return (
126
76
  <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>
77
+ <div className="border rounded-lg bg-card p-2">
78
+ <LineageWeb
79
+ episodes={webEpisodes}
80
+ edges={webEdges}
81
+ selected={selected}
82
+ onSelect={setSelected}
83
+ onOpen={(id) => router.push(`/episodes/${encodeURIComponent(id)}`)}
84
+ matches={matches}
85
+ layout={LAYOUT}
86
+ bandLabels={BAND_LABELS}
87
+ startAt="end"
88
+ />
210
89
  </div>
211
90
 
212
91
  {chosen ? (
@@ -237,9 +116,10 @@ export function EpisodeLineageWeb({
237
116
  </div>
238
117
  ) : (
239
118
  <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.
119
+ Each dot is an episode, oldest on the left, in its direction&apos;s band. Solid arcs above carry
120
+ a thread forward (continues from); dashed arcs below relate two episodes. Tap a dot to see why
121
+ they are related; tap it again to open it. A filter keeps what it found and, as rings, the
122
+ episodes related to it.
243
123
  </p>
244
124
  )}
245
125
  </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.7",
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.7",
95
+ "@medicine-wheel/ceremony-protocol": "^0.17.7",
96
+ "@medicine-wheel/community-identity": "^0.17.7",
97
+ "@medicine-wheel/community-review": "^0.17.7",
98
+ "@medicine-wheel/consent-lifecycle": "^0.17.7",
99
+ "@medicine-wheel/creative-orientation": "^0.17.7",
100
+ "@medicine-wheel/data-store": "^0.17.7",
101
+ "@medicine-wheel/data-store-postgres": "^0.17.7",
102
+ "@medicine-wheel/fire-keeper": "^0.17.7",
103
+ "@medicine-wheel/github-ceremony": "^0.17.7",
104
+ "@medicine-wheel/graph-viz": "^0.17.7",
105
+ "@medicine-wheel/honcho": "^0.17.7",
106
+ "@medicine-wheel/importance-unit": "^0.17.7",
107
+ "@medicine-wheel/mcp": "^4.17.7",
108
+ "@medicine-wheel/memory": "^0.17.7",
109
+ "@medicine-wheel/narrative-cluster": "^0.17.7",
110
+ "@medicine-wheel/narrative-engine": "^0.17.7",
111
+ "@medicine-wheel/ontology-core": "^0.17.7",
112
+ "@medicine-wheel/perception-layer": "^0.17.7",
113
+ "@medicine-wheel/prompt-decomposition": "^0.17.7",
114
+ "@medicine-wheel/relational-index": "^0.17.7",
115
+ "@medicine-wheel/relational-query": "^0.17.7",
116
+ "@medicine-wheel/session-reader": "^0.17.7",
117
+ "@medicine-wheel/storage-provider": "^0.17.7",
118
+ "@medicine-wheel/transformation-tracker": "^0.17.7",
119
+ "@medicine-wheel/ui-components": "^0.17.7",
120
120
  "@miadi/episode-vessel": "^0.2.1",
121
121
  "@neondatabase/serverless": "^0.10.0",
122
122
  "@xyflow/react": "^12.3.0",