@cr8rcho/alkahest 0.1.59 → 0.1.61

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.
@@ -1,806 +0,0 @@
1
- <!doctype html>
2
- <html lang="en" data-theme="dark">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>Alkahest — Product Map</title>
7
- <!-- Inter (UI) + Geist Mono (code/targets), matching the hosted viewer. Degrades to the
8
- system stack offline (the fallbacks below). -->
9
- <link rel="preconnect" href="https://fonts.googleapis.com" />
10
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
11
- <link href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;500&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
12
- <style>
13
- /* Linear palette — monotone base + indigo brand accent, dark by default (matches the hosted
14
- viewer). --accent is monotone emphasis (route text, hover borders); --primary is the indigo
15
- brand (#6366f1) used for the selection stroke — one mid-tone value for both themes. */
16
- :root {
17
- --bg: #ffffff;
18
- --panel: #f4f5f6;
19
- --panel-soft: #ffffff;
20
- --line: #e8e9eb;
21
- --ink: #08090a;
22
- --muted: #62666d;
23
- --node-fill: #ffffff; /* screen */
24
- --node-stroke: #d0d2d6;
25
- --node-ink: #08090a;
26
- --res-fill: #f0f1f2; /* resource */
27
- --entry: #27a644;
28
- --accent: #08090a;
29
- --primary: #6366f1;
30
- --edge: #c4c6cc;
31
- }
32
- html[data-theme="dark"] {
33
- --bg: #08090a;
34
- --panel: #161718;
35
- --panel-soft: #1c1d1f;
36
- --line: #23252a;
37
- --ink: #f7f8f8;
38
- --muted: #8a8f98;
39
- --node-fill: #1c1d1f;
40
- --node-stroke: #383b3f;
41
- --node-ink: #f7f8f8;
42
- --res-fill: #26282c;
43
- --entry: #27a644;
44
- --accent: #f7f8f8;
45
- --primary: #6366f1;
46
- --edge: #44474d;
47
- }
48
- * { box-sizing: border-box; }
49
- html, body { margin: 0; height: 100%; }
50
- body {
51
- font: 13px/1.5 "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
52
- -webkit-font-smoothing: antialiased;
53
- -moz-osx-font-smoothing: grayscale;
54
- background: var(--bg);
55
- color: var(--ink);
56
- display: flex;
57
- flex-direction: column;
58
- height: 100vh;
59
- }
60
- .toolbar {
61
- display: flex;
62
- align-items: center;
63
- flex-wrap: wrap;
64
- column-gap: 16px;
65
- row-gap: 6px;
66
- padding: 8px 14px;
67
- border-bottom: 1px solid var(--line);
68
- background: var(--panel);
69
- flex: 0 0 auto;
70
- position: relative; /* anchor the Display dropdown above the graph */
71
- z-index: 10;
72
- }
73
- /* All three toolbar buttons (Display, Fit, theme) share one chip style on --panel. */
74
- .tbtn { display: inline-flex; align-items: center; justify-content: center; height: 32px; border-radius: 8px; background: var(--panel); border: 1px solid var(--line); color: var(--ink); cursor: pointer; padding: 0; }
75
- .tbtn:hover { border-color: var(--accent); }
76
- .tbtn svg { width: 16px; height: 16px; }
77
- #fit-btn, #theme-toggle { width: 32px; }
78
- [data-theme="dark"] #theme-toggle .i-sun { display: block; } [data-theme="dark"] #theme-toggle .i-moon { display: none; }
79
- [data-theme="light"] #theme-toggle .i-sun { display: none; } [data-theme="light"] #theme-toggle .i-moon { display: block; }
80
- /* Action cluster pinned to the right: Display · Fit · theme */
81
- .toolbar-actions { margin-left: auto; display: inline-flex; align-items: center; gap: 8px; }
82
- /* Brand matches the web service header (account/landing): 13px / 600 / -0.025em */
83
- .brand { font-size: 13px; font-weight: 600; letter-spacing: -0.025em; white-space: nowrap; }
84
- .brand .sub { color: var(--muted); font-weight: 400; font-size: 13px; letter-spacing: 0; margin-left: 6px; }
85
- .counts { color: var(--muted); white-space: nowrap; }
86
- /* compact counts (mobile only): node-shape icons + number — ○ screen / ▢ resource, matching the legend */
87
- .counts-min { display: none; }
88
- .counts-min .cg { display: inline-flex; align-items: center; gap: 5px; }
89
- .counts-min .cg + .cg { margin-left: 11px; }
90
- .counts-min .nd { width: 9px; height: 9px; border: 1.5px solid var(--node-stroke); background: var(--node-fill); flex: 0 0 auto; }
91
- .counts-min .nd.dot { border-radius: 50%; }
92
- .counts-min .nd.sq { border-radius: 2px; background: var(--res-fill); }
93
-
94
- /* ---- Display menu: edge filters + legend behind one button. Dropdown on desktop, bottom sheet on mobile. ---- */
95
- .display-menu { position: relative; }
96
- .display-btn { gap: 7px; padding: 0 11px; }
97
- .display-label { font-size: 12px; }
98
- .display-menu.open .display-btn { border-color: var(--accent); color: var(--accent); }
99
- .display-backdrop { display: none; }
100
- .display-pop {
101
- position: absolute; top: calc(100% + 8px); right: 0; z-index: 30;
102
- min-width: 210px; background: var(--panel-soft); border: 1px solid var(--line);
103
- border-radius: 11px; padding: 12px 14px; box-shadow: 0 14px 36px rgba(0, 0, 0, 0.28);
104
- display: none;
105
- }
106
- .display-menu.open .display-pop { display: block; }
107
- .display-pop h3 { font-size: 10px; text-transform: uppercase; letter-spacing: 0.6px; color: var(--muted); margin: 0 0 8px; }
108
- .display-pop h3.grp { margin-top: 13px; }
109
- .disp-row { display: flex; align-items: center; gap: 8px; padding: 5px 0; cursor: pointer; white-space: nowrap; user-select: none; }
110
- .disp-legend { display: flex; flex-direction: column; gap: 6px; color: var(--muted); }
111
- .disp-legend > span { white-space: nowrap; }
112
- .disp-row .sw, .disp-legend .sw { display: inline-block; width: 10px; height: 10px; border-radius: 50%; border: 1.5px solid var(--node-stroke); vertical-align: -1px; }
113
- .disp-legend .sw { margin-right: 6px; }
114
- .sw.res { border-radius: 2px; }
115
- .disp-row .lkey, .disp-legend .lkey { display: inline-block; width: 22px; border-top: 2px solid var(--edge); vertical-align: 3px; }
116
- .disp-legend .lkey { margin-right: 6px; }
117
- .lkey.dot { border-top-style: dotted; } .lkey.dash { border-top-style: dashed; }
118
- main { flex: 1 1 auto; display: flex; min-height: 0; position: relative; }
119
- #graph { flex: 1 1 auto; min-width: 0; cursor: grab; touch-action: none; }
120
- #graph:active { cursor: grabbing; }
121
- .panel-close { display: none; }
122
- #panel {
123
- flex: 0 0 360px;
124
- border-left: 1px solid var(--line);
125
- background: var(--panel);
126
- padding: 16px;
127
- overflow-y: auto;
128
- -webkit-overflow-scrolling: touch;
129
- }
130
- #panel h2 { margin: 0 0 2px; font-size: 16px; }
131
- #panel .route { color: var(--accent); font-family: "Geist Mono", ui-monospace, monospace; font-size: 12px; margin-bottom: 12px; word-break: break-all; }
132
- #panel .src { color: var(--muted); font-family: "Geist Mono", ui-monospace, monospace; font-size: 11px; margin-bottom: 14px; word-break: break-all; }
133
- #panel section { margin-bottom: 16px; }
134
- #panel h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; color: var(--muted); margin: 0 0 7px; }
135
- #panel ul { list-style: none; margin: 0; padding: 0; }
136
- #panel li { padding: 5px 8px; border: 1px solid var(--line); border-radius: 6px; margin-bottom: 5px; background: var(--panel-soft); }
137
- #panel li.clickable { cursor: pointer; }
138
- #panel li.clickable:hover { border-color: var(--accent); }
139
- #panel .tag { font-family: "Geist Mono", ui-monospace, monospace; font-size: 10px; color: var(--muted); }
140
- #panel .lineno { float: right; color: var(--muted); font-family: "Geist Mono", ui-monospace, monospace; font-size: 10px; }
141
- #panel .empty { color: var(--muted); font-style: italic; }
142
- #panel .prd h4, #panel .prd h5, #panel .prd h6 { margin: 12px 0 4px; font-size: 12px; font-weight: 700; color: var(--ink); text-transform: none; letter-spacing: 0; }
143
- #panel .prd p { margin: 4px 0; }
144
- #panel .prd ul.md { list-style: none; margin: 4px 0; padding: 0; }
145
- #panel .prd ul.md li { border: none; background: none; padding: 1px 0; margin: 0; }
146
- #panel .prd code { font-family: "Geist Mono", ui-monospace, monospace; font-size: 11px; background: var(--panel-soft); padding: 0 3px; border-radius: 3px; }
147
- .hint { color: var(--muted); }
148
- .node text { font-size: 11px; fill: var(--node-ink); pointer-events: none; paint-order: stroke; stroke: var(--bg); stroke-width: 3px; }
149
- .node circle, .node rect, .node path { cursor: pointer; stroke: var(--node-stroke); stroke-width: 1.5px; }
150
- .node.selected circle, .node.selected rect, .node.selected path { stroke: var(--primary); stroke-width: 2.5px; }
151
- .node.dim { opacity: 0.2; }
152
- /* Hover preview (only when nothing is selected): highlight connected edges/neighbors by color only (keep width) */
153
- .node.hl circle, .node.hl rect, .node.hl path { stroke: var(--ink); }
154
- .edge { opacity: 0.9; }
155
- .edge.dim { opacity: 0.08; }
156
- .edge.hl { stroke: var(--ink) !important; opacity: 1; }
157
-
158
- /* ---- Mobile: on narrow screens, render the panel as a bottom sheet ---- */
159
- @media (max-width: 640px) {
160
- body { height: 100dvh; }
161
- .toolbar { column-gap: 10px; padding: 8px 12px; font-size: 12px; flex-wrap: nowrap; }
162
- .brand .sub { display: none; }
163
- .counts-full { display: none; }
164
- .counts-min { display: inline-flex; align-items: center; }
165
- .display-label { display: none; } /* Display collapses to icon-only */
166
- .display-btn { width: 32px; padding: 0; }
167
- /* Display popover → bottom sheet, mirroring the #panel idiom */
168
- .display-menu.open .display-backdrop { display: block; position: fixed; inset: 0; background: rgba(0, 0, 0, 0.35); z-index: 40; }
169
- .display-pop {
170
- position: fixed; left: 0; right: 0; bottom: 0; top: auto;
171
- min-width: 0; width: 100%;
172
- border-radius: 16px 16px 0 0; border-left: none; border-right: none; border-bottom: none;
173
- box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.45);
174
- padding: 26px 18px 22px; z-index: 41;
175
- display: block; /* always present; slid off-screen until open */
176
- transform: translateY(101%);
177
- /* visibility:hidden when closed so the upward box-shadow doesn't bleed a band
178
- at the bottom of the screen; delayed off so the slide-out still animates. */
179
- visibility: hidden;
180
- transition: transform 0.24s ease, visibility 0s linear 0.24s;
181
- }
182
- .display-menu.open .display-pop { transform: translateY(0); visibility: visible; transition: transform 0.24s ease; }
183
- .display-pop::before { content: ""; position: absolute; top: 9px; left: 50%; width: 40px; height: 4px; margin-left: -20px; background: var(--line); border-radius: 2px; }
184
- .disp-row { padding: 9px 0; font-size: 14px; }
185
- .disp-legend { font-size: 13px; gap: 8px; }
186
- #panel {
187
- position: absolute;
188
- left: 0; right: 0; bottom: 0;
189
- flex: none;
190
- width: 100%;
191
- max-height: 62%;
192
- border-left: none;
193
- border-top: 1px solid var(--line);
194
- border-radius: 14px 14px 0 0;
195
- box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.45);
196
- transform: translateY(101%);
197
- /* hidden when closed so the upward shadow doesn't peek at the screen bottom */
198
- visibility: hidden;
199
- transition: transform 0.22s ease, visibility 0s linear 0.22s;
200
- padding-top: 30px;
201
- }
202
- body.panel-open #panel { transform: translateY(0); visibility: visible; transition: transform 0.22s ease; }
203
- .panel-close {
204
- display: block;
205
- position: absolute;
206
- top: 6px; right: 10px;
207
- width: 32px; height: 32px;
208
- line-height: 30px; text-align: center;
209
- font-size: 20px; color: var(--muted);
210
- background: none; border: none; cursor: pointer;
211
- }
212
- /* sheet handle */
213
- #panel::before {
214
- content: ""; position: absolute; top: 9px; left: 50%;
215
- width: 38px; height: 4px; margin-left: -19px;
216
- background: var(--line); border-radius: 2px;
217
- }
218
- }
219
- </style>
220
- </head>
221
- <body>
222
- <header class="toolbar">
223
- <div class="brand">Alkahest<span class="sub">Product Map</span></div>
224
- <div class="counts" id="counts"><span class="counts-full"></span><span class="counts-min"></span></div>
225
- <div class="toolbar-actions">
226
- <div class="display-menu" id="display-menu">
227
- <button class="tbtn display-btn" id="display-btn" aria-haspopup="true" aria-expanded="false" aria-label="Display options" title="Display options"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="4" y1="8" x2="20" y2="8"/><circle cx="9" cy="8" r="2.4" fill="var(--panel)"/><line x1="4" y1="16" x2="20" y2="16"/><circle cx="15" cy="16" r="2.4" fill="var(--panel)"/></svg><span class="display-label">Display</span></button>
228
- <div class="display-backdrop" id="display-backdrop"></div>
229
- <div class="display-pop" id="display-pop" role="menu">
230
- <h3>Show</h3>
231
- <label class="disp-row"><input type="checkbox" id="t-trans" checked /><span class="lkey"></span>Navigate</label>
232
- <label class="disp-row"><input type="checkbox" id="t-calls" checked /><span class="lkey dash"></span>Call</label>
233
- <h3 class="grp">Legend</h3>
234
- <div class="disp-legend">
235
- <span style="font-weight:700">▶ Start</span>
236
- <span><span class="sw"></span>Screen</span>
237
- <span><span class="sw res"></span>Resource</span>
238
- <span><span class="lkey"></span>Navigate</span>
239
- <span><span class="lkey dot"></span>Contains</span>
240
- <span><span class="lkey dash"></span>Call</span>
241
- </div>
242
- </div>
243
- </div>
244
- <button class="tbtn" id="fit-btn" aria-label="Fit to view" title="Fit to view"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3M16 3h3a2 2 0 0 1 2 2v3M8 21H5a2 2 0 0 1-2-2v-3M16 21h3a2 2 0 0 0 2-2v-3"/></svg></button>
245
- <button class="tbtn" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme"><svg class="i-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg><svg class="i-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg></button>
246
- </div>
247
- </header>
248
- <main>
249
- <svg id="graph">
250
- <defs>
251
- <!-- single arrow (shared by transitions and calls). userSpaceOnUse: fixed size regardless of line width -->
252
- <!-- open V-shaped arrow (instead of a filled triangle) — blends into the line so it stands out less -->
253
- <marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" markerUnits="userSpaceOnUse" orient="auto">
254
- <path d="M1,1 L8,5 L1,9" fill="none" stroke="var(--edge)" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" />
255
- </marker>
256
- </defs>
257
- <!-- Screen-space hit target so empty areas fire pointer events (SVG's default
258
- visiblePainted ignores unpainted regions, which on touch killed pan/pinch
259
- anywhere but on a node). transparent fill is hittable; "none" is not. Sits
260
- before #viewport so nodes paint on top and keep their own events. -->
261
- <rect id="bg" x="0" y="0" width="100%" height="100%" fill="transparent" />
262
- <g id="viewport">
263
- <g id="edges"></g>
264
- <g id="nodes"></g>
265
- </g>
266
- </svg>
267
- <aside id="panel">
268
- <button class="panel-close" id="panel-close" aria-label="Close">×</button>
269
- <div id="panel-body">
270
- <p class="hint">Click a node to see its features, transitions, and calls.<br /><br />Drag to move a node, wheel/pinch to zoom, drag empty space to pan.</p>
271
- </div>
272
- </aside>
273
- </main>
274
-
275
- <script id="alkahest-data" type="application/json">/*__ALKAHEST_MAP__*/</script>
276
- <script type="module">
277
- // The map is either inlined (self-contained local index.html) or fetched at
278
- // runtime (hosted viewer). Same dashboard code renders both.
279
- let MAP;
280
- try {
281
- MAP = await loadMap();
282
- } catch (err) {
283
- document.body.innerHTML =
284
- '<div style="font:14px system-ui;padding:40px;color:#b00">Failed to load the product map: ' +
285
- (err && err.message ? err.message : err) +
286
- "</div>";
287
- throw err;
288
- }
289
- async function loadMap() {
290
- const raw = document.getElementById("alkahest-data").textContent.trim();
291
- if (raw && raw[0] === "{") return JSON.parse(raw); // self-contained (local)
292
- const url = mapUrl();
293
- const res = await fetch(url, { cache: "no-store" });
294
- if (!res.ok) throw new Error("HTTP " + res.status + " for " + url);
295
- return res.json();
296
- }
297
- // Resolve where to fetch the map from, in priority order:
298
- // 1. ?src=<url> explicit override
299
- // 2. <meta name="alkahest:map-base"> + /p/{slug} hosted viewer
300
- // 3. ./map.json sibling file (plain static hosting)
301
- function mapUrl() {
302
- const explicit = new URLSearchParams(location.search).get("src");
303
- if (explicit) return explicit;
304
- const base = document.querySelector('meta[name="alkahest:map-base"]')?.content;
305
- const slug = (location.pathname.match(/\/p\/([^/]+)/) || [])[1];
306
- if (base && slug) return base.replace(/\/+$/, "") + "/" + slug + "/map.json";
307
- return "./map.json";
308
- }
309
- const SVGNS = "http://www.w3.org/2000/svg";
310
-
311
- // ---- indexes ----
312
- const screensById = new Map(MAP.screens.map((s) => [s.id, s]));
313
- const resourcesById = new Map(MAP.resources.map((r) => [r.id, r]));
314
-
315
- // ---- node/edge graph model ----
316
- const nodes = [];
317
- const nodeByKey = new Map();
318
- function addNode(n) { nodes.push(n); nodeByKey.set(n.key, n); return n; }
319
- for (const s of MAP.screens) addNode({ key: "s:" + s.id, type: "screen", id: s.id, label: s.title || s.route, r: 10 });
320
- for (const r of MAP.resources) addNode({ key: "r:" + r.id, type: "resource", id: r.id, label: r.label, kind: r.kind, r: 8 });
321
-
322
- const edges = [];
323
- for (const t of MAP.transitions) {
324
- if (t.to == null) continue;
325
- const target = nodeByKey.get("s:" + t.to);
326
- if (!target) continue; // external URLs/unresolved aren't drawn in the graph (shown in the panel)
327
- // rel: "contains" (structural containment) vs "navigate" (user navigation). Both are the "transition" layer.
328
- edges.push({ source: nodeByKey.get("s:" + t.from), target, kind: "transition", rel: t.kind || "navigate" });
329
- }
330
- for (const c of MAP.calls) {
331
- if (c.to == null) continue;
332
- const target = nodeByKey.get("r:" + c.to);
333
- if (!target) continue;
334
- edges.push({ source: nodeByKey.get("s:" + c.from), target, kind: "call", rel: "call" });
335
- }
336
-
337
- {
338
- const navCount = MAP.transitions.filter((t) => (t.kind || "navigate") === "navigate").length;
339
- const containCount = MAP.transitions.length - navCount;
340
- const countsEl = document.getElementById("counts");
341
- countsEl.querySelector(".counts-full").textContent =
342
- MAP.screens.length + " screens · " + MAP.resources.length + " resources · " +
343
- navCount + " navigate" + (containCount ? " · " + containCount + " contains" : "") +
344
- " · " + MAP.calls.length + " calls";
345
- // compact form for the mobile toolbar: ○{screens} ▢{resources} (node-shape icons)
346
- countsEl.querySelector(".counts-min").innerHTML =
347
- '<span class="cg"><span class="nd dot"></span>' + MAP.screens.length + "</span>" +
348
- '<span class="cg"><span class="nd sq"></span>' + MAP.resources.length + "</span>";
349
- }
350
-
351
- // ====== force-directed layout (reference approach) ======
352
- // Force is natural for a tangled screen graph. Depth is not enforced.
353
- // Deterministic: a fixed-seed PRNG instead of Math.random → identical layout every time.
354
- const svg = document.getElementById("graph");
355
- let W = (svg.clientWidth || 800), H = (svg.clientHeight || 600);
356
-
357
- // Seeded PRNG (mulberry32) — seeded by node count for the same result every time.
358
- let _seed = 0x9e3779b9 ^ nodes.length;
359
- function rng() {
360
- _seed |= 0; _seed = (_seed + 0x6D2B79F5) | 0;
361
- let t = Math.imul(_seed ^ (_seed >>> 15), 1 | _seed);
362
- t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
363
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
364
- }
365
-
366
- // Entry points (if any, weakly pinned near the center — to hint at the start point)
367
- const entryKeys = new Set(MAP.screens.filter((s) => s.isEntry).map((s) => "s:" + s.id));
368
-
369
- // Neighbor (connection) adjacency — for springs
370
- const adj = new Map(nodes.map((n) => [n.key, []]));
371
- for (const e of edges) { adj.get(e.source.key).push(e.target); adj.get(e.target.key).push(e.source); }
372
-
373
- // Initial placement: circular + seeded jitter (the starting point force will spread out)
374
- const cx0 = W / 2, cy0 = H / 2, R0 = Math.min(W, H) * 0.42;
375
- nodes.forEach((n, i) => {
376
- const a = (i / nodes.length) * Math.PI * 2;
377
- n.x = cx0 + Math.cos(a) * R0 * (0.6 + rng() * 0.5);
378
- n.y = cy0 + Math.sin(a) * R0 * (0.6 + rng() * 0.5);
379
- n.vx = 0; n.vy = 0;
380
- });
381
-
382
- // ---- interaction state (step references dragging) ----
383
- let dragging = null, panStart = null, selected = null;
384
-
385
- // ---- force simulation ----
386
- // Gravity applies only during initial settling (gathers nodes into view). After settled it's 0 →
387
- // a node moved by dragging isn't pulled toward the center and stays near where it was dropped.
388
- let alpha = 1, settled = false;
389
- const REPULSION = 16000, SPRING = 0.025, SPRING_LEN = 180, GRAVITY = 0.012, DAMP = 0.82;
390
- const REPULSE_RANGE = 320, REPULSE_RANGE2 = REPULSE_RANGE * REPULSE_RANGE;
391
- function step() {
392
- const grav = settled ? 0 : GRAVITY; // gravity OFF after settling
393
- // Repulsion (pairwise) — ignored beyond a fixed distance (REPULSE_RANGE). Avoids a "bulldozer" that pushes distant nodes.
394
- for (let i = 0; i < nodes.length; i++) {
395
- for (let j = i + 1; j < nodes.length; j++) {
396
- const a = nodes[i], b = nodes[j];
397
- let dx = a.x - b.x, dy = a.y - b.y;
398
- let d2 = dx * dx + dy * dy || 1;
399
- if (d2 > REPULSE_RANGE2) continue;
400
- const f = REPULSION / d2, d = Math.sqrt(d2);
401
- const fx = (dx / d) * f, fy = (dy / d) * f;
402
- a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy;
403
- }
404
- }
405
- // Springs (edges)
406
- for (const e of edges) {
407
- const a = e.source, b = e.target;
408
- let dx = b.x - a.x, dy = b.y - a.y;
409
- const d = Math.sqrt(dx * dx + dy * dy) || 1;
410
- const f = (d - SPRING_LEN) * SPRING;
411
- const fx = (dx / d) * f, fy = (dy / d) * f;
412
- a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy;
413
- }
414
- for (const n of nodes) {
415
- if (n === dragging) { n.vx = 0; n.vy = 0; continue; } // dragged node: no velocity accumulation (no bounce on release)
416
- if (grav) { n.vx += (cx0 - n.x) * grav; n.vy += (cy0 - n.y) * grav; }
417
- n.vx *= DAMP; n.vy *= DAMP;
418
- n.x += n.vx * alpha; n.y += n.vy * alpha;
419
- }
420
- alpha *= 0.985;
421
- }
422
-
423
-
424
- // ---- render ----
425
- const gEdges = document.getElementById("edges");
426
- const gNodes = document.getElementById("nodes");
427
-
428
- // (no depth bands in the force layout — there is no column concept)
429
- // Index for fanning out multiple edges that share the same (source,target) pair
430
- const pairCount = new Map();
431
- for (const e of edges) {
432
- const key = e.source.key + "|" + e.target.key;
433
- e.pairKey = key;
434
- e.pairIdx = pairCount.get(key) || 0;
435
- pairCount.set(key, e.pairIdx + 1);
436
- }
437
- const edgeEls = edges.map((e) => {
438
- const pa = document.createElementNS(SVGNS, "path");
439
- pa.setAttribute("class", "edge " + e.kind);
440
- pa.setAttribute("stroke", "var(--edge)");
441
- pa.setAttribute("stroke-width", "1.4");
442
- pa.setAttribute("fill", "none");
443
- if (e.kind === "call") {
444
- pa.setAttribute("stroke-dasharray", "4 4");
445
- pa.setAttribute("marker-end", "url(#arrow)");
446
- } else if (e.rel === "contains") {
447
- pa.setAttribute("stroke-dasharray", "2 3");
448
- } else {
449
- pa.setAttribute("marker-end", "url(#arrow)");
450
- }
451
- gEdges.appendChild(pa);
452
- e.el = pa;
453
- return pa;
454
- });
455
- const nodeEls = nodes.map((n) => {
456
- const g = document.createElementNS(SVGNS, "g");
457
- g.setAttribute("class", "node");
458
- // Style F: screen=white circle / resource=light gray square. Start points get a ▶ prefix on the label.
459
- const isEntry = n.type === "screen" && entryKeys.has(n.key);
460
- let shape;
461
- if (n.type === "screen") {
462
- shape = document.createElementNS(SVGNS, "circle");
463
- shape.setAttribute("r", n.r);
464
- shape.setAttribute("fill", "var(--node-fill)");
465
- } else {
466
- shape = document.createElementNS(SVGNS, "rect");
467
- shape.setAttribute("width", n.r * 2); shape.setAttribute("height", n.r * 2);
468
- shape.setAttribute("x", -n.r); shape.setAttribute("y", -n.r);
469
- shape.setAttribute("rx", 3);
470
- shape.setAttribute("fill", "var(--res-fill)");
471
- }
472
- const txt = document.createElementNS(SVGNS, "text");
473
- txt.setAttribute("x", n.r + 6); txt.setAttribute("y", 4);
474
- if (isEntry) txt.setAttribute("font-weight", "700");
475
- const baseLabel = n.label.length > 26 ? n.label.slice(0, 25) + "…" : n.label;
476
- txt.textContent = isEntry ? "▶ " + baseLabel : baseLabel;
477
- g.appendChild(shape); g.appendChild(txt);
478
- g.addEventListener("pointerdown", (ev) => onNodeDown(ev, n));
479
- g.addEventListener("pointerenter", () => hoverOn(n));
480
- g.addEventListener("pointerleave", hoverOff);
481
- gNodes.appendChild(g);
482
- n.el = g;
483
- return g;
484
- });
485
-
486
- function paint() {
487
- for (const e of edges) {
488
- // Straight edge (trimmed at node edges) so the arrow isn't hidden behind the node.
489
- const a = e.source, b = e.target;
490
- const dx = b.x - a.x, dy = b.y - a.y, len = Math.hypot(dx, dy) || 1;
491
- const ux = dx / len, uy = dy / len;
492
- const padA = (a.r || 10) + 2, padB = (b.r || 10) + 2;
493
- const x1 = a.x + ux * padA, y1 = a.y + uy * padA;
494
- const x2 = b.x - ux * padB, y2 = b.y - uy * padB;
495
- e.el.setAttribute("d", "M" + x1 + "," + y1 + " L" + x2 + "," + y2);
496
- }
497
- for (const n of nodes) n.el.setAttribute("transform", "translate(" + n.x + "," + n.y + ")");
498
- }
499
-
500
- // ---- simulation loop: stop once settled, then fit to view ----
501
- // While dragging, keep alpha high → neighbors actively follow the grabbed node.
502
- let fitted = false;
503
- function loop() {
504
- if (dragging) {
505
- // While dragging: simulate only weakly (alpha 0.12) → neighbors follow smoothly but don't bounce on release.
506
- alpha = 0.12; step(); paint();
507
- } else if (alpha > 0.02) {
508
- step(); paint();
509
- } else if (!fitted) {
510
- fitted = true; settled = true; paint(); fitToView();
511
- }
512
- requestAnimationFrame(loop);
513
- }
514
- // The loop is started at the end of the script (after fitToView/tx are declared) — to avoid TDZ.
515
-
516
- // ---- layer toggles ----
517
- const tTrans = document.getElementById("t-trans"), tCalls = document.getElementById("t-calls");
518
- function applyLayers() {
519
- for (const e of edges) e.el.style.display =
520
- (e.kind === "transition" && !tTrans.checked) || (e.kind === "call" && !tCalls.checked) ? "none" : "";
521
- for (const n of nodes) if (n.type === "resource") n.el.style.display = tCalls.checked ? "" : "none";
522
- }
523
- tTrans.onchange = tCalls.onchange = applyLayers;
524
-
525
- // ---- selection/highlight ---- (selected is declared above)
526
- function select(n) {
527
- hoverOff(); // clear hover highlight
528
- selected = n;
529
- const connected = new Set([n.key]);
530
- for (const e of edges) {
531
- if (e.source === n) connected.add(e.target.key);
532
- if (e.target === n) connected.add(e.source.key);
533
- }
534
- for (const m of nodes) {
535
- m.el.classList.toggle("dim", !connected.has(m.key));
536
- m.el.classList.toggle("selected", m === n); // accent ring on the selected node
537
- }
538
- for (const e of edges) e.el.classList.toggle("dim", e.source !== n && e.target !== n);
539
- document.body.classList.add("panel-open");
540
- renderPanel(n);
541
- }
542
- function clearSelection() {
543
- selected = null;
544
- for (const m of nodes) { m.el.classList.remove("dim"); m.el.classList.remove("selected"); }
545
- for (const e of edges) e.el.classList.remove("dim");
546
- panel.innerHTML = '<p class="hint">Click a node to see its features, transitions, and calls.</p>';
547
- document.body.classList.remove("panel-open");
548
- }
549
- // ---- hover preview: only when nothing is selected and not dragging, highlight connected edges/neighbors ----
550
- let hovered = null;
551
- function hoverOn(n) {
552
- if (selected || dragging) return;
553
- hovered = n;
554
- for (const e of edges) {
555
- const on = e.source === n || e.target === n;
556
- e.el.classList.toggle("hl", on);
557
- if (on) { e.source.el.classList.add("hl"); e.target.el.classList.add("hl"); }
558
- }
559
- }
560
- function hoverOff() {
561
- if (!hovered) return;
562
- hovered = null;
563
- for (const e of edges) e.el.classList.remove("hl");
564
- for (const m of nodes) m.el.classList.remove("hl");
565
- }
566
-
567
- // Tapping empty svg space → clearSelection is handled in the pointerup (tap) handler.
568
- document.getElementById("panel-close").addEventListener("click", clearSelection);
569
-
570
- // ---- theme toggle (light by default ↔ dark) ----
571
- document.getElementById("theme-toggle").addEventListener("click", () => {
572
- const h = document.documentElement;
573
- h.setAttribute("data-theme", h.getAttribute("data-theme") === "dark" ? "light" : "dark");
574
- });
575
- // ---- fit-to-view button ----
576
- document.getElementById("fit-btn").addEventListener("click", fitToView);
577
-
578
- // ---- Display menu (edge filters + legend): dropdown on desktop, bottom sheet on mobile ----
579
- {
580
- const menu = document.getElementById("display-menu");
581
- const btn = document.getElementById("display-btn");
582
- const close = () => { menu.classList.remove("open"); btn.setAttribute("aria-expanded", "false"); };
583
- btn.addEventListener("click", (e) => {
584
- e.stopPropagation();
585
- const open = menu.classList.toggle("open");
586
- btn.setAttribute("aria-expanded", open ? "true" : "false");
587
- });
588
- document.getElementById("display-backdrop").addEventListener("click", close);
589
- document.addEventListener("click", (e) => { if (!menu.contains(e.target)) close(); });
590
- document.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); });
591
- }
592
-
593
- // ---- panel ----
594
- const panel = document.getElementById("panel-body"); // content container (close button/handle are preserved)
595
- const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
596
- function li(inner, cls) { return "<li" + (cls ? ' class="' + cls + '"' : "") + ">" + inner + "</li>"; }
597
- function listOr(items, emptyMsg) { return items.length ? "<ul>" + items.join("") + "</ul>" : '<p class="empty">' + emptyMsg + "</p>"; }
598
-
599
- // Minimal markdown → HTML for PRD (headings, list/checkbox, bold/code, paragraphs). No deps.
600
- function mdToHtml(md) {
601
- const inline = (t) => esc(t)
602
- .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
603
- .replace(/`([^`]+)`/g, "<code>$1</code>");
604
- const out = [];
605
- let list = null; // "ul" | null
606
- const closeList = () => { if (list) { out.push("</" + list + ">"); list = null; } };
607
- for (const raw of md.split("\n")) {
608
- const line = raw.trimEnd();
609
- let m;
610
- if (!line.trim()) { closeList(); continue; }
611
- if ((m = line.match(/^(#{1,4})\s+(.*)$/))) { closeList(); const lv = Math.min(m[1].length + 2, 6); out.push("<h" + lv + ">" + inline(m[2]) + "</h" + lv + ">"); }
612
- else if ((m = line.match(/^\s*[-*]\s+\[([ xX])\]\s+(.*)$/))) { if (list !== "ul") { closeList(); out.push('<ul class="md">'); list = "ul"; } out.push("<li>" + (m[1].trim() ? "☑ " : "☐ ") + inline(m[2]) + "</li>"); }
613
- else if ((m = line.match(/^\s*[-*]\s+(.*)$/))) { if (list !== "ul") { closeList(); out.push('<ul class="md">'); list = "ul"; } out.push("<li>" + inline(m[1]) + "</li>"); }
614
- else { closeList(); out.push("<p>" + inline(line) + "</p>"); }
615
- }
616
- closeList();
617
- return out.join("");
618
- }
619
-
620
- function renderPanel(n) {
621
- if (n.type === "screen") return renderScreen(screensById.get(n.id));
622
- return renderResource(resourcesById.get(n.id));
623
- }
624
-
625
- function renderScreen(s) {
626
- const out = MAP.transitions.filter((t) => t.from === s.id);
627
- const inc = MAP.transitions.filter((t) => t.to === s.id);
628
- const calls = MAP.calls.filter((c) => c.from === s.id);
629
- const feats = s.features.map((f) =>
630
- li('<span class="tag">[' + f.kind + "]</span> " + esc(f.label) + (f.loc ? '<span class="lineno">L' + f.loc.line + "</span>" : "")));
631
- const navItem = (t) => {
632
- const label = t.to ? (screensById.has(t.to) ? esc(screensById.get(t.to).title || t.to) : esc(t.to)) : esc(t.rawTarget || "unresolved");
633
- const cls = t.to && screensById.has(t.to) ? "clickable" : "";
634
- const key = t.to && screensById.has(t.to) ? ' data-sel="s:' + esc(t.to) + '"' : "";
635
- return "<li" + (cls ? ' class="' + cls + '"' : "") + key + '><span class="tag">' + esc(t.trigger) + "</span> → " + label +
636
- '<span class="lineno">L' + t.loc.line + "</span></li>";
637
- };
638
- const callItem = (c) => {
639
- const label = c.to ? esc(c.to) : esc(c.rawTarget || "unresolved");
640
- const cls = c.to ? "clickable" : "";
641
- const key = c.to ? ' data-sel="r:' + esc(c.to) + '"' : "";
642
- return "<li" + (cls ? ' class="' + cls + '"' : "") + key + '><span class="tag">' + esc(c.trigger) + "</span> " + label +
643
- '<span class="lineno">L' + c.loc.line + "</span></li>";
644
- };
645
- panel.innerHTML =
646
- "<h2>" + esc(s.title) + "</h2>" +
647
- '<div class="route">' + esc(s.route) + "</div>" +
648
- '<div class="src">' + esc(s.sourceFile) + "</div>" +
649
- (s.summary ? "<section><h3>Summary</h3><p>" + esc(s.summary) + "</p></section>" : "") +
650
- (s.prd ? '<section class="prd"><h3>PRD</h3>' + mdToHtml(s.prd) + "</section>" : "") +
651
- "<section><h3>Features (" + s.features.length + ")</h3>" + listOr(feats, "No UI features extracted") + "</section>" +
652
- "<section><h3>Outgoing (" + out.length + ")</h3>" + listOr(out.map(navItem), "None") + "</section>" +
653
- "<section><h3>Calls (" + calls.length + ")</h3>" + listOr(calls.map(callItem), "None") + "</section>" +
654
- "<section><h3>Incoming (" + inc.length + ")</h3>" + listOr(inc.map(navItem), "None") + "</section>";
655
- wirePanelLinks();
656
- }
657
-
658
- function renderResource(r) {
659
- const callers = MAP.calls.filter((c) => c.to === r.id);
660
- const items = callers.map((c) => {
661
- const s = screensById.get(c.from);
662
- return '<li class="clickable" data-sel="s:' + esc(c.from) + '"><span class="tag">' + esc(c.trigger) + "</span> " +
663
- esc(s ? s.title : c.from) + '<span class="lineno">L' + c.loc.line + "</span></li>";
664
- });
665
- panel.innerHTML =
666
- "<h2>" + esc(r.label) + "</h2>" +
667
- '<div class="route">' + esc(r.kind) + (r.method ? " · " + esc(r.method) : "") + "</div>" +
668
- '<div class="src">' + esc(r.path || "") + "</div>" +
669
- "<section><h3>Called by screens (" + callers.length + ")</h3>" + listOr(items, "None") + "</section>";
670
- wirePanelLinks();
671
- }
672
-
673
- function wirePanelLinks() {
674
- panel.querySelectorAll("[data-sel]").forEach((el) => {
675
- el.addEventListener("click", () => { const n = nodeByKey.get(el.getAttribute("data-sel")); if (n) select(n); });
676
- });
677
- }
678
-
679
- // ---- pan / zoom / drag ----
680
- const viewport = document.getElementById("viewport");
681
- let tx = 0, ty = 0, k = 1;
682
- function applyTransform() { viewport.setAttribute("transform", "translate(" + tx + " " + ty + ") scale(" + k + ")"); }
683
- function toWorld(cx, cy) { const rect = svg.getBoundingClientRect(); return { x: (cx - rect.left - tx) / k, y: (cy - rect.top - ty) / k }; }
684
-
685
- // Fit the whole graph into view (40px margin, max 1.3x)
686
- function fitToView() {
687
- if (!nodes.length) return;
688
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
689
- for (const n of nodes) {
690
- const r = (n.r || 10) + 60; // room for the label
691
- minX = Math.min(minX, n.x - r); maxX = Math.max(maxX, n.x + r);
692
- minY = Math.min(minY, n.y - r); maxY = Math.max(maxY, n.y + r);
693
- }
694
- const w = svg.clientWidth || 800, h = svg.clientHeight || 600, pad = 40;
695
- const gw = maxX - minX || 1, gh = maxY - minY || 1;
696
- k = Math.min((w - pad * 2) / gw, (h - pad * 2) / gh, 1.3);
697
- tx = pad + (w - pad * 2 - gw * k) / 2 - minX * k;
698
- ty = pad + (h - pad * 2 - gh * k) / 2 - minY * k;
699
- applyTransform();
700
- }
701
-
702
- // dragging, panStart are declared above.
703
- // Tap vs drag: record only the pointerdown position, and promote to a drag once movement exceeds the threshold (4px).
704
- const DRAG_THRESHOLD = 4;
705
- let down = null; // {node|null, x, y, moved, pointerId}
706
-
707
- // Active pointers for multi-touch. Two pointers down → pinch-to-zoom.
708
- const pointers = new Map(); // pointerId -> {x, y}
709
- let pinch = null; // { dist, midX, midY } for the running gesture
710
-
711
- function startPinch() {
712
- const pts = [...pointers.values()];
713
- const dx = pts[0].x - pts[1].x, dy = pts[0].y - pts[1].y;
714
- const rect = svg.getBoundingClientRect();
715
- pinch = {
716
- dist: Math.hypot(dx, dy) || 1,
717
- midX: (pts[0].x + pts[1].x) / 2 - rect.left,
718
- midY: (pts[0].y + pts[1].y) / 2 - rect.top,
719
- };
720
- // a pinch is purely a zoom — abandon any in-progress tap / pan / node drag
721
- dragging = null; panStart = null; down = null;
722
- }
723
-
724
- function onNodeDown(ev, n) {
725
- ev.stopPropagation();
726
- pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
727
- svg.setPointerCapture(ev.pointerId);
728
- if (pointers.size === 2) { startPinch(); return; }
729
- down = { node: n, x: ev.clientX, y: ev.clientY, moved: false, pointerId: ev.pointerId };
730
- }
731
- svg.addEventListener("pointerdown", (ev) => {
732
- pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
733
- svg.setPointerCapture(ev.pointerId);
734
- if (pointers.size === 2) { startPinch(); return; }
735
- if (down) return; // already started on a node
736
- down = { node: null, x: ev.clientX, y: ev.clientY, moved: false, pointerId: ev.pointerId };
737
- panStart = { x: ev.clientX - tx, y: ev.clientY - ty };
738
- });
739
- svg.addEventListener("pointermove", (ev) => {
740
- if (pointers.has(ev.pointerId)) pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
741
- if (pinch && pointers.size >= 2) {
742
- const pts = [...pointers.values()];
743
- const dx = pts[0].x - pts[1].x, dy = pts[0].y - pts[1].y;
744
- const dist = Math.hypot(dx, dy) || 1;
745
- const rect = svg.getBoundingClientRect();
746
- const mx = (pts[0].x + pts[1].x) / 2 - rect.left, my = (pts[0].y + pts[1].y) / 2 - rect.top;
747
- // pan by how far the two-finger midpoint moved, then zoom around that midpoint
748
- tx += mx - pinch.midX; ty += my - pinch.midY;
749
- const nk = Math.min(4, Math.max(0.2, k * (dist / pinch.dist)));
750
- tx = mx - (mx - tx) * (nk / k); ty = my - (my - ty) * (nk / k); k = nk;
751
- pinch.dist = dist; pinch.midX = mx; pinch.midY = my;
752
- applyTransform();
753
- return;
754
- }
755
- if (!down) return;
756
- if (!down.moved && Math.hypot(ev.clientX - down.x, ev.clientY - down.y) > DRAG_THRESHOLD) {
757
- down.moved = true;
758
- if (down.node) { dragging = down.node; fitted = true; } // promote to drag (alpha is managed by loop)
759
- }
760
- if (!down.moved) return;
761
- if (dragging) { const w = toWorld(ev.clientX, ev.clientY); dragging.x = w.x; dragging.y = w.y; dragging.vx = 0; dragging.vy = 0; } // free 2D since it's force-based
762
- else if (panStart) { tx = ev.clientX - panStart.x; ty = ev.clientY - panStart.y; applyTransform(); }
763
- });
764
- function onPointerUp(ev) {
765
- pointers.delete(ev.pointerId);
766
- if (pinch) {
767
- if (pointers.size < 2) {
768
- pinch = null;
769
- if (pointers.size === 1) {
770
- // one finger left after a pinch → resume panning from there (no jump)
771
- const [id, p] = [...pointers.entries()][0];
772
- down = { node: null, x: p.x, y: p.y, moved: true, pointerId: id };
773
- panStart = { x: p.x - tx, y: p.y - ty };
774
- }
775
- }
776
- return; // a pinch gesture never counts as a tap
777
- }
778
- if (ev.type !== "pointercancel" && down && !down.moved) { // didn't move = tap
779
- if (down.node) select(down.node);
780
- else clearSelection();
781
- } else if (dragging) {
782
- // On release: briefly settle with a low alpha → no bounce, settles near where it was dropped.
783
- alpha = 0.06;
784
- }
785
- dragging = null; panStart = null; down = null;
786
- }
787
- svg.addEventListener("pointerup", onPointerUp);
788
- svg.addEventListener("pointercancel", onPointerUp);
789
- svg.addEventListener("wheel", (ev) => {
790
- ev.preventDefault();
791
- const rect = svg.getBoundingClientRect();
792
- const mx = ev.clientX - rect.left, my = ev.clientY - rect.top;
793
- const factor = ev.deltaY < 0 ? 1.1 : 1 / 1.1;
794
- const nk = Math.min(4, Math.max(0.2, k * factor));
795
- tx = mx - (mx - tx) * (nk / k); ty = my - (my - ty) * (nk / k); k = nk;
796
- applyTransform();
797
- }, { passive: false });
798
-
799
- window.addEventListener("resize", () => { W = (svg.clientWidth || 800); H = (svg.clientHeight || 600); fitToView(); });
800
- applyLayers();
801
-
802
- // ---- start the simulation (auto fit-to-view after force settles) ----
803
- loop();
804
- </script>
805
- </body>
806
- </html>