@medicine-wheel/app 0.4.13 → 0.5.1
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/accountability/layout.tsx +7 -0
- package/app/accountability/page.tsx +7 -7
- package/app/api/edges/route.ts +198 -15
- package/app/api/nodes/[id]/route.ts +101 -0
- package/app/api/nodes/route.ts +39 -12
- package/app/ceremonies/layout.tsx +7 -0
- package/app/ceremonies/page.tsx +17 -14
- package/app/globals.css +78 -36
- package/app/graph/medicine-wheel-flow.css +157 -0
- package/app/graph/page.tsx +152 -1
- package/app/layout.tsx +32 -3
- package/app/narrative/beats/layout.tsx +7 -0
- package/app/narrative/beats/page.tsx +17 -14
- package/app/narrative/cycles/layout.tsx +7 -0
- package/app/narrative/cycles/page.tsx +13 -10
- package/app/narrative/layout.tsx +9 -0
- package/app/narrative/page.tsx +11 -8
- package/app/nodes/page.tsx +256 -82
- package/app/page.tsx +144 -61
- package/app/relations/page.tsx +338 -157
- package/components/direction-panel.tsx +21 -9
- package/components/navigation.tsx +24 -31
- package/components/workspaces-panel.tsx +10 -10
- package/next.config.mjs +18 -0
- package/package.json +25 -21
- package/public/favicon.svg +14 -0
- package/public/fonts/Stereohead.otf +0 -0
package/app/page.tsx
CHANGED
|
@@ -1,103 +1,181 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useState } from "react";
|
|
4
|
-
import { DIRECTIONS,
|
|
4
|
+
import { DIRECTIONS, type DirectionName } from "@/lib/types";
|
|
5
5
|
import { DirectionPanel } from "@/components/direction-panel";
|
|
6
6
|
|
|
7
|
+
/** Presentation colors from tokens.css — West's canonical color is invisible on dark ground. */
|
|
8
|
+
const DIR_VAR: Record<DirectionName, string> = {
|
|
9
|
+
east: "var(--mw-east)",
|
|
10
|
+
south: "var(--mw-south)",
|
|
11
|
+
west: "var(--mw-west)",
|
|
12
|
+
north: "var(--mw-north)",
|
|
13
|
+
};
|
|
14
|
+
const DIR_INK: Record<DirectionName, string> = {
|
|
15
|
+
east: "var(--mw-east-ink)",
|
|
16
|
+
south: "var(--mw-south-ink)",
|
|
17
|
+
west: "var(--mw-west-ink)",
|
|
18
|
+
north: "var(--mw-north-ink)",
|
|
19
|
+
};
|
|
20
|
+
/** Text drawn ON a filled quadrant — dark on light fills, light on dark fills. */
|
|
21
|
+
const QUADRANT_TEXT: Record<DirectionName, { main: string; sub: string }> = {
|
|
22
|
+
east: { main: "#1a1408", sub: "#3d3210" },
|
|
23
|
+
south: { main: "#fff5f4", sub: "#ffd9d6" },
|
|
24
|
+
west: { main: "#f4f6fa", sub: "#dbe2ee" },
|
|
25
|
+
north: { main: "#1c1a14", sub: "#4a463a" },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const R = 200;
|
|
29
|
+
const CX = 250;
|
|
30
|
+
const CY = 250;
|
|
31
|
+
const toRad = (a: number) => (a * Math.PI) / 180;
|
|
32
|
+
|
|
33
|
+
function quadrantPath(startAngle: number, endAngle: number) {
|
|
34
|
+
const x1 = CX + R * Math.cos(toRad(startAngle));
|
|
35
|
+
const y1 = CY + R * Math.sin(toRad(startAngle));
|
|
36
|
+
const x2 = CX + R * Math.cos(toRad(endAngle));
|
|
37
|
+
const y2 = CY + R * Math.sin(toRad(endAngle));
|
|
38
|
+
return `M ${CX} ${CY} L ${x1} ${y1} A ${R} ${R} 0 0 1 ${x2} ${y2} Z`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const QUADRANTS: { dir: DirectionName; start: number; end: number }[] = [
|
|
42
|
+
{ dir: "east", start: -45, end: 45 },
|
|
43
|
+
{ dir: "south", start: 45, end: 135 },
|
|
44
|
+
{ dir: "west", start: 135, end: 225 },
|
|
45
|
+
{ dir: "north", start: 225, end: 315 },
|
|
46
|
+
];
|
|
47
|
+
|
|
7
48
|
export default function HomePage() {
|
|
8
49
|
const [selected, setSelected] = useState<DirectionName | null>(null);
|
|
9
50
|
const [hovered, setHovered] = useState<DirectionName | null>(null);
|
|
10
51
|
|
|
11
52
|
const selectedDir = selected ? DIRECTIONS.find((d) => d.name === selected) : null;
|
|
12
53
|
|
|
13
|
-
const
|
|
14
|
-
const CX = 250;
|
|
15
|
-
const CY = 250;
|
|
16
|
-
|
|
17
|
-
function quadrantPath(startAngle: number, endAngle: number) {
|
|
18
|
-
const toRad = (a: number) => (a * Math.PI) / 180;
|
|
19
|
-
const x1 = CX + R * Math.cos(toRad(startAngle));
|
|
20
|
-
const y1 = CY + R * Math.sin(toRad(startAngle));
|
|
21
|
-
const x2 = CX + R * Math.cos(toRad(endAngle));
|
|
22
|
-
const y2 = CY + R * Math.sin(toRad(endAngle));
|
|
23
|
-
return `M ${CX} ${CY} L ${x1} ${y1} A ${R} ${R} 0 0 1 ${x2} ${y2} Z`;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const quadrants: { dir: DirectionName; start: number; end: number }[] = [
|
|
27
|
-
{ dir: "east", start: -45, end: 45 },
|
|
28
|
-
{ dir: "south", start: 45, end: 135 },
|
|
29
|
-
{ dir: "west", start: 135, end: 225 },
|
|
30
|
-
{ dir: "north", start: 225, end: 315 },
|
|
31
|
-
];
|
|
54
|
+
const toggle = (dir: DirectionName) => setSelected(selected === dir ? null : dir);
|
|
32
55
|
|
|
33
56
|
return (
|
|
34
57
|
<div className="flex flex-col items-center gap-8 p-6">
|
|
35
58
|
<div className="text-center max-w-2xl">
|
|
36
|
-
<h1 className="
|
|
59
|
+
<h1 className="mw-display mb-2">Medicine Wheel</h1>
|
|
37
60
|
<p className="text-muted-foreground">
|
|
38
|
-
Navigate the Four Directions —
|
|
61
|
+
Navigate the Four Directions — select a direction to explore its teachings,
|
|
62
|
+
ceremonies, and relational obligations.
|
|
39
63
|
</p>
|
|
40
64
|
</div>
|
|
41
65
|
|
|
42
66
|
<div className="relative">
|
|
43
|
-
<svg
|
|
44
|
-
|
|
67
|
+
<svg
|
|
68
|
+
viewBox="0 0 500 500"
|
|
69
|
+
className="w-full max-w-[500px] mw-breathe"
|
|
70
|
+
aria-label="Medicine Wheel — four directions"
|
|
71
|
+
role="group"
|
|
72
|
+
>
|
|
73
|
+
{/* Concentric rings + four-direction cross — the wheel made visible */}
|
|
74
|
+
<g aria-hidden="true">
|
|
75
|
+
<circle cx={CX} cy={CY} r={222} fill="none" stroke="var(--mw-border)" strokeWidth={1} opacity={0.6} />
|
|
76
|
+
<circle cx={CX} cy={CY} r={140} fill="none" stroke="var(--mw-fg)" strokeWidth={0.5} opacity={0.12} />
|
|
77
|
+
<circle cx={CX} cy={CY} r={80} fill="none" stroke="var(--mw-fg)" strokeWidth={0.5} opacity={0.12} />
|
|
78
|
+
{QUADRANTS.map(({ dir, start }) => {
|
|
79
|
+
const x = CX + 222 * Math.cos(toRad(start));
|
|
80
|
+
const y = CY + 222 * Math.sin(toRad(start));
|
|
81
|
+
return (
|
|
82
|
+
<line
|
|
83
|
+
key={`cross-${dir}`}
|
|
84
|
+
x1={CX} y1={CY} x2={x} y2={y}
|
|
85
|
+
stroke="var(--mw-border)" strokeWidth={1} opacity={0.7}
|
|
86
|
+
/>
|
|
87
|
+
);
|
|
88
|
+
})}
|
|
89
|
+
</g>
|
|
90
|
+
|
|
91
|
+
{QUADRANTS.map(({ dir, start, end }) => {
|
|
45
92
|
const dirData = DIRECTIONS.find((d) => d.name === dir)!;
|
|
46
93
|
const isHovered = hovered === dir;
|
|
47
94
|
const isSelected = selected === dir;
|
|
48
|
-
const
|
|
95
|
+
const active = isHovered || isSelected;
|
|
49
96
|
const midAngle = (start + end) / 2;
|
|
50
|
-
const labelR = R * 0.
|
|
97
|
+
const labelR = R * 0.62;
|
|
51
98
|
const lx = CX + labelR * Math.cos(toRad(midAngle));
|
|
52
99
|
const ly = CY + labelR * Math.sin(toRad(midAngle));
|
|
100
|
+
// Lift: the hovered quadrant drifts outward along its own direction
|
|
101
|
+
const dx = 6 * Math.cos(toRad(midAngle));
|
|
102
|
+
const dy = 6 * Math.sin(toRad(midAngle));
|
|
103
|
+
const text = QUADRANT_TEXT[dir];
|
|
53
104
|
|
|
54
105
|
return (
|
|
55
|
-
<g
|
|
106
|
+
<g
|
|
107
|
+
key={dir}
|
|
108
|
+
style={{
|
|
109
|
+
transform: active ? `translate(${dx}px, ${dy}px)` : undefined,
|
|
110
|
+
transition: "transform var(--duration-default) var(--ease-out)",
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
56
113
|
<path
|
|
57
114
|
d={quadrantPath(start, end)}
|
|
58
|
-
fill={
|
|
59
|
-
stroke="var(--
|
|
115
|
+
fill={DIR_VAR[dir]}
|
|
116
|
+
stroke="var(--mw-bg)"
|
|
60
117
|
strokeWidth={2}
|
|
61
|
-
opacity={
|
|
62
|
-
className="cursor-pointer
|
|
63
|
-
style={{
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
118
|
+
opacity={active ? 0.95 : 0.72}
|
|
119
|
+
className="cursor-pointer"
|
|
120
|
+
style={{ transition: "opacity var(--duration-default) ease" }}
|
|
121
|
+
role="button"
|
|
122
|
+
tabIndex={0}
|
|
123
|
+
aria-label={`${dirData.ojibwe} — ${dir}: ${dirData.season}, ${dirData.medicine.join(", ")}`}
|
|
124
|
+
onClick={() => toggle(dir)}
|
|
125
|
+
onKeyDown={(e) => {
|
|
126
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
127
|
+
e.preventDefault();
|
|
128
|
+
toggle(dir);
|
|
129
|
+
}
|
|
67
130
|
}}
|
|
68
|
-
onClick={() => setSelected(selected === dir ? null : dir)}
|
|
69
131
|
onMouseEnter={() => setHovered(dir)}
|
|
70
132
|
onMouseLeave={() => setHovered(null)}
|
|
133
|
+
onFocus={() => setHovered(dir)}
|
|
134
|
+
onBlur={() => setHovered(null)}
|
|
71
135
|
/>
|
|
72
136
|
<text
|
|
73
137
|
x={lx}
|
|
74
|
-
y={ly -
|
|
138
|
+
y={ly - 12}
|
|
75
139
|
textAnchor="middle"
|
|
76
|
-
className="pointer-events-none text-
|
|
77
|
-
style={{ fill:
|
|
140
|
+
className="pointer-events-none text-base font-semibold"
|
|
141
|
+
style={{ fill: text.main }}
|
|
78
142
|
>
|
|
79
143
|
{dirData.ojibwe}
|
|
80
144
|
</text>
|
|
81
145
|
<text
|
|
82
146
|
x={lx}
|
|
83
|
-
y={ly +
|
|
147
|
+
y={ly + 6}
|
|
148
|
+
textAnchor="middle"
|
|
149
|
+
className="pointer-events-none text-xs capitalize"
|
|
150
|
+
style={{ fill: text.sub }}
|
|
151
|
+
>
|
|
152
|
+
{dir}
|
|
153
|
+
</text>
|
|
154
|
+
{/* Reveal on hover/select: season and medicines */}
|
|
155
|
+
<text
|
|
156
|
+
x={lx}
|
|
157
|
+
y={ly + 24}
|
|
84
158
|
textAnchor="middle"
|
|
85
159
|
className="pointer-events-none text-xs"
|
|
86
|
-
style={{
|
|
160
|
+
style={{
|
|
161
|
+
fill: text.sub,
|
|
162
|
+
opacity: active ? 1 : 0,
|
|
163
|
+
transition: "opacity var(--duration-default) ease",
|
|
164
|
+
}}
|
|
87
165
|
>
|
|
88
|
-
{dirData.season} · {dirData.medicine
|
|
166
|
+
{dirData.season} · {dirData.medicine.join(" · ")}
|
|
89
167
|
</text>
|
|
90
168
|
</g>
|
|
91
169
|
);
|
|
92
170
|
})}
|
|
93
171
|
|
|
94
|
-
{/* Center
|
|
172
|
+
{/* Center — balance */}
|
|
95
173
|
<circle
|
|
96
174
|
cx={CX}
|
|
97
175
|
cy={CY}
|
|
98
|
-
r={
|
|
99
|
-
fill="var(--
|
|
100
|
-
stroke="var(--
|
|
176
|
+
r={32}
|
|
177
|
+
fill="var(--mw-bg)"
|
|
178
|
+
stroke="var(--mw-border)"
|
|
101
179
|
strokeWidth={2}
|
|
102
180
|
className="animate-pulse-center"
|
|
103
181
|
/>
|
|
@@ -112,36 +190,41 @@ export default function HomePage() {
|
|
|
112
190
|
</svg>
|
|
113
191
|
</div>
|
|
114
192
|
|
|
115
|
-
{/* Direction cards
|
|
193
|
+
{/* Direction cards */}
|
|
116
194
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 w-full max-w-4xl">
|
|
117
195
|
{DIRECTIONS.map((dir) => (
|
|
118
196
|
<button
|
|
119
197
|
key={dir.name}
|
|
120
|
-
onClick={() =>
|
|
121
|
-
className={`p-4
|
|
122
|
-
selected === dir.name ? "
|
|
198
|
+
onClick={() => toggle(dir.name)}
|
|
199
|
+
className={`mw-dir-card p-4 text-left ${
|
|
200
|
+
selected === dir.name ? "mw-dir-card--selected" : ""
|
|
123
201
|
}`}
|
|
124
|
-
style={
|
|
202
|
+
style={
|
|
203
|
+
{
|
|
204
|
+
"--dir": DIR_VAR[dir.name],
|
|
205
|
+
borderColor: `var(--mw-${dir.name}-border)`,
|
|
206
|
+
} as React.CSSProperties
|
|
207
|
+
}
|
|
208
|
+
aria-pressed={selected === dir.name}
|
|
125
209
|
>
|
|
126
|
-
<div className="flex items-center gap-2 mb-
|
|
210
|
+
<div className="flex items-center gap-2 mb-1">
|
|
127
211
|
<span
|
|
128
|
-
className="w-3 h-3 rounded-full"
|
|
129
|
-
style={{ backgroundColor:
|
|
212
|
+
className="w-3 h-3 rounded-full shrink-0"
|
|
213
|
+
style={{ backgroundColor: DIR_VAR[dir.name] }}
|
|
130
214
|
/>
|
|
131
215
|
<span className="font-semibold capitalize">{dir.name}</span>
|
|
132
216
|
</div>
|
|
133
|
-
<p className="
|
|
134
|
-
|
|
217
|
+
<p className="mw-ojibwe" style={{ color: DIR_INK[dir.name], opacity: 1 }}>
|
|
218
|
+
{dir.ojibwe}
|
|
219
|
+
</p>
|
|
220
|
+
<p className="text-xs text-muted-foreground mt-1">{dir.season}</p>
|
|
221
|
+
<p className="text-xs text-muted-foreground">{dir.medicine.join(" · ")}</p>
|
|
135
222
|
</button>
|
|
136
223
|
))}
|
|
137
224
|
</div>
|
|
138
225
|
|
|
139
|
-
{/* Direction Panel */}
|
|
140
226
|
{selectedDir && (
|
|
141
|
-
<DirectionPanel
|
|
142
|
-
direction={selectedDir}
|
|
143
|
-
onClose={() => setSelected(null)}
|
|
144
|
-
/>
|
|
227
|
+
<DirectionPanel direction={selectedDir} onClose={() => setSelected(null)} />
|
|
145
228
|
)}
|
|
146
229
|
</div>
|
|
147
230
|
);
|