@cr8rcho/alkahest 0.1.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/README.ko.md +208 -0
- package/README.md +208 -0
- package/dist/assets/dashboard.html +647 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +68 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/hook.d.ts +1 -0
- package/dist/commands/hook.js +64 -0
- package/dist/commands/hook.js.map +1 -0
- package/dist/commands/login.d.ts +11 -0
- package/dist/commands/login.js +27 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/mcp.d.ts +6 -0
- package/dist/commands/mcp.js +13 -0
- package/dist/commands/mcp.js.map +1 -0
- package/dist/commands/publish.d.ts +19 -0
- package/dist/commands/publish.js +27 -0
- package/dist/commands/publish.js.map +1 -0
- package/dist/commands/scan.d.ts +11 -0
- package/dist/commands/scan.js +29 -0
- package/dist/commands/scan.js.map +1 -0
- package/dist/commands/update.d.ts +15 -0
- package/dist/commands/update.js +41 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/commands/view.d.ts +6 -0
- package/dist/commands/view.js +21 -0
- package/dist/commands/view.js.map +1 -0
- package/dist/core/adapters/angular.d.ts +2 -0
- package/dist/core/adapters/angular.js +305 -0
- package/dist/core/adapters/angular.js.map +1 -0
- package/dist/core/adapters/astro.d.ts +3 -0
- package/dist/core/adapters/astro.js +140 -0
- package/dist/core/adapters/astro.js.map +1 -0
- package/dist/core/adapters/compose.d.ts +2 -0
- package/dist/core/adapters/compose.js +195 -0
- package/dist/core/adapters/compose.js.map +1 -0
- package/dist/core/adapters/django.d.ts +2 -0
- package/dist/core/adapters/django.js +314 -0
- package/dist/core/adapters/django.js.map +1 -0
- package/dist/core/adapters/expo-router.d.ts +2 -0
- package/dist/core/adapters/expo-router.js +60 -0
- package/dist/core/adapters/expo-router.js.map +1 -0
- package/dist/core/adapters/flask.d.ts +2 -0
- package/dist/core/adapters/flask.js +249 -0
- package/dist/core/adapters/flask.js.map +1 -0
- package/dist/core/adapters/flutter.d.ts +2 -0
- package/dist/core/adapters/flutter.js +232 -0
- package/dist/core/adapters/flutter.js.map +1 -0
- package/dist/core/adapters/index.d.ts +19 -0
- package/dist/core/adapters/index.js +59 -0
- package/dist/core/adapters/index.js.map +1 -0
- package/dist/core/adapters/next-app.d.ts +2 -0
- package/dist/core/adapters/next-app.js +62 -0
- package/dist/core/adapters/next-app.js.map +1 -0
- package/dist/core/adapters/next-pages.d.ts +2 -0
- package/dist/core/adapters/next-pages.js +70 -0
- package/dist/core/adapters/next-pages.js.map +1 -0
- package/dist/core/adapters/nuxt.d.ts +2 -0
- package/dist/core/adapters/nuxt.js +59 -0
- package/dist/core/adapters/nuxt.js.map +1 -0
- package/dist/core/adapters/rails.d.ts +2 -0
- package/dist/core/adapters/rails.js +275 -0
- package/dist/core/adapters/rails.js.map +1 -0
- package/dist/core/adapters/react-jsx.d.ts +68 -0
- package/dist/core/adapters/react-jsx.js +355 -0
- package/dist/core/adapters/react-jsx.js.map +1 -0
- package/dist/core/adapters/react-navigation.d.ts +2 -0
- package/dist/core/adapters/react-navigation.js +153 -0
- package/dist/core/adapters/react-navigation.js.map +1 -0
- package/dist/core/adapters/react-router.d.ts +2 -0
- package/dist/core/adapters/react-router.js +249 -0
- package/dist/core/adapters/react-router.js.map +1 -0
- package/dist/core/adapters/remix.d.ts +2 -0
- package/dist/core/adapters/remix.js +109 -0
- package/dist/core/adapters/remix.js.map +1 -0
- package/dist/core/adapters/static-html.d.ts +2 -0
- package/dist/core/adapters/static-html.js +157 -0
- package/dist/core/adapters/static-html.js.map +1 -0
- package/dist/core/adapters/sveltekit.d.ts +2 -0
- package/dist/core/adapters/sveltekit.js +151 -0
- package/dist/core/adapters/sveltekit.js.map +1 -0
- package/dist/core/adapters/swiftui.d.ts +2 -0
- package/dist/core/adapters/swiftui.js +216 -0
- package/dist/core/adapters/swiftui.js.map +1 -0
- package/dist/core/adapters/types.d.ts +65 -0
- package/dist/core/adapters/types.js +2 -0
- package/dist/core/adapters/types.js.map +1 -0
- package/dist/core/adapters/uikit.d.ts +2 -0
- package/dist/core/adapters/uikit.js +178 -0
- package/dist/core/adapters/uikit.js.map +1 -0
- package/dist/core/adapters/vue-router.d.ts +2 -0
- package/dist/core/adapters/vue-router.js +224 -0
- package/dist/core/adapters/vue-router.js.map +1 -0
- package/dist/core/adapters/vue-sfc.d.ts +28 -0
- package/dist/core/adapters/vue-sfc.js +132 -0
- package/dist/core/adapters/vue-sfc.js.map +1 -0
- package/dist/core/credentials.d.ts +24 -0
- package/dist/core/credentials.js +30 -0
- package/dist/core/credentials.js.map +1 -0
- package/dist/core/dashboard.d.ts +9 -0
- package/dist/core/dashboard.js +20 -0
- package/dist/core/dashboard.js.map +1 -0
- package/dist/core/emit.d.ts +7 -0
- package/dist/core/emit.js +23 -0
- package/dist/core/emit.js.map +1 -0
- package/dist/core/hash.d.ts +2 -0
- package/dist/core/hash.js +6 -0
- package/dist/core/hash.js.map +1 -0
- package/dist/core/pipeline.d.ts +25 -0
- package/dist/core/pipeline.js +122 -0
- package/dist/core/pipeline.js.map +1 -0
- package/dist/core/publish.d.ts +31 -0
- package/dist/core/publish.js +87 -0
- package/dist/core/publish.js.map +1 -0
- package/dist/core/resolve.d.ts +42 -0
- package/dist/core/resolve.js +128 -0
- package/dist/core/resolve.js.map +1 -0
- package/dist/core/serve.d.ts +5 -0
- package/dist/core/serve.js +52 -0
- package/dist/core/serve.js.map +1 -0
- package/dist/core/types.d.ts +117 -0
- package/dist/core/types.js +9 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/version.d.ts +23 -0
- package/dist/core/version.js +102 -0
- package/dist/core/version.js.map +1 -0
- package/dist/mcp/server.d.ts +7 -0
- package/dist/mcp/server.js +224 -0
- package/dist/mcp/server.js.map +1 -0
- package/package.json +57 -0
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" data-theme="light">
|
|
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
|
+
<style>
|
|
8
|
+
/* Minimal palette — light by default, swapped via [data-theme="dark"] (style F) */
|
|
9
|
+
:root {
|
|
10
|
+
--bg: #ffffff;
|
|
11
|
+
--panel: #f7f7f8;
|
|
12
|
+
--panel-soft: #ffffff;
|
|
13
|
+
--line: #e4e4e7;
|
|
14
|
+
--ink: #18181b;
|
|
15
|
+
--muted: #71717a;
|
|
16
|
+
--node-fill: #ffffff; /* screen */
|
|
17
|
+
--node-stroke: #c4c4cc;
|
|
18
|
+
--node-ink: #27272a;
|
|
19
|
+
--res-fill: #f4f4f5; /* resource (F: light gray fill) */
|
|
20
|
+
--entry: #16a34a;
|
|
21
|
+
--accent: #2563eb;
|
|
22
|
+
--edge: #a1a1aa;
|
|
23
|
+
}
|
|
24
|
+
html[data-theme="dark"] {
|
|
25
|
+
--bg: #0d1117;
|
|
26
|
+
--panel: #161b22;
|
|
27
|
+
--panel-soft: #1c2128;
|
|
28
|
+
--line: #2a2f37;
|
|
29
|
+
--ink: #e6edf3;
|
|
30
|
+
--muted: #8b949e;
|
|
31
|
+
--node-fill: #1c2128;
|
|
32
|
+
--node-stroke: #444c56;
|
|
33
|
+
--node-ink: #e6edf3;
|
|
34
|
+
--res-fill: #30404f;
|
|
35
|
+
--entry: #3fb950;
|
|
36
|
+
--accent: #58a6ff;
|
|
37
|
+
--edge: #484f58;
|
|
38
|
+
}
|
|
39
|
+
* { box-sizing: border-box; }
|
|
40
|
+
html, body { margin: 0; height: 100%; }
|
|
41
|
+
body {
|
|
42
|
+
font: 13px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
43
|
+
background: var(--bg);
|
|
44
|
+
color: var(--ink);
|
|
45
|
+
display: flex;
|
|
46
|
+
flex-direction: column;
|
|
47
|
+
height: 100vh;
|
|
48
|
+
}
|
|
49
|
+
.toolbar {
|
|
50
|
+
display: flex;
|
|
51
|
+
align-items: center;
|
|
52
|
+
flex-wrap: wrap;
|
|
53
|
+
column-gap: 16px;
|
|
54
|
+
row-gap: 6px;
|
|
55
|
+
padding: 8px 14px;
|
|
56
|
+
border-bottom: 1px solid var(--line);
|
|
57
|
+
background: var(--panel);
|
|
58
|
+
flex: 0 0 auto;
|
|
59
|
+
}
|
|
60
|
+
.theme-toggle { cursor: pointer; border: 1px solid var(--line); background: var(--bg); color: var(--ink); border-radius: 7px; padding: 4px 9px; font-size: 12px; }
|
|
61
|
+
.brand { font-weight: 700; letter-spacing: 0.3px; white-space: nowrap; }
|
|
62
|
+
.brand .sub { color: var(--muted); font-weight: 400; margin-left: 6px; }
|
|
63
|
+
.counts { color: var(--muted); white-space: nowrap; }
|
|
64
|
+
.toolbar label { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; user-select: none; white-space: nowrap; }
|
|
65
|
+
.legend { margin-left: auto; display: flex; flex-wrap: wrap; gap: 6px 14px; color: var(--muted); align-items: center; }
|
|
66
|
+
.legend span { white-space: nowrap; }
|
|
67
|
+
.legend .swatch { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 5px; vertical-align: -1px; }
|
|
68
|
+
.legend .edge { display: inline-block; width: 22px; border-top: 2px solid; margin-right: 5px; vertical-align: 3px; }
|
|
69
|
+
main { flex: 1 1 auto; display: flex; min-height: 0; position: relative; }
|
|
70
|
+
#graph { flex: 1 1 auto; min-width: 0; cursor: grab; touch-action: none; }
|
|
71
|
+
#graph:active { cursor: grabbing; }
|
|
72
|
+
.panel-close { display: none; }
|
|
73
|
+
#panel {
|
|
74
|
+
flex: 0 0 360px;
|
|
75
|
+
border-left: 1px solid var(--line);
|
|
76
|
+
background: var(--panel);
|
|
77
|
+
padding: 16px;
|
|
78
|
+
overflow-y: auto;
|
|
79
|
+
-webkit-overflow-scrolling: touch;
|
|
80
|
+
}
|
|
81
|
+
#panel h2 { margin: 0 0 2px; font-size: 16px; }
|
|
82
|
+
#panel .route { color: var(--accent); font-family: ui-monospace, monospace; font-size: 12px; margin-bottom: 12px; word-break: break-all; }
|
|
83
|
+
#panel .src { color: var(--muted); font-family: ui-monospace, monospace; font-size: 11px; margin-bottom: 14px; word-break: break-all; }
|
|
84
|
+
#panel section { margin-bottom: 16px; }
|
|
85
|
+
#panel h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; color: var(--muted); margin: 0 0 7px; }
|
|
86
|
+
#panel ul { list-style: none; margin: 0; padding: 0; }
|
|
87
|
+
#panel li { padding: 5px 8px; border: 1px solid var(--line); border-radius: 6px; margin-bottom: 5px; background: var(--panel-soft); }
|
|
88
|
+
#panel li.clickable { cursor: pointer; }
|
|
89
|
+
#panel li.clickable:hover { border-color: var(--accent); }
|
|
90
|
+
#panel .tag { font-family: ui-monospace, monospace; font-size: 10px; color: var(--muted); }
|
|
91
|
+
#panel .lineno { float: right; color: var(--muted); font-family: ui-monospace, monospace; font-size: 10px; }
|
|
92
|
+
#panel .empty { color: var(--muted); font-style: italic; }
|
|
93
|
+
#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; }
|
|
94
|
+
#panel .prd p { margin: 4px 0; }
|
|
95
|
+
#panel .prd ul.md { list-style: none; margin: 4px 0; padding: 0; }
|
|
96
|
+
#panel .prd ul.md li { border: none; background: none; padding: 1px 0; margin: 0; }
|
|
97
|
+
#panel .prd code { font-family: ui-monospace, monospace; font-size: 11px; background: var(--panel-soft); padding: 0 3px; border-radius: 3px; }
|
|
98
|
+
.hint { color: var(--muted); }
|
|
99
|
+
.node text { font-size: 11px; fill: var(--node-ink); pointer-events: none; paint-order: stroke; stroke: var(--bg); stroke-width: 3px; }
|
|
100
|
+
.node circle, .node rect, .node path { cursor: pointer; stroke: var(--node-stroke); stroke-width: 1.5px; }
|
|
101
|
+
.node.selected circle, .node.selected rect, .node.selected path { stroke: var(--accent); stroke-width: 2.5px; }
|
|
102
|
+
.node.dim { opacity: 0.2; }
|
|
103
|
+
/* Hover preview (only when nothing is selected): highlight connected edges/neighbors by color only (keep width) */
|
|
104
|
+
.node.hl circle, .node.hl rect, .node.hl path { stroke: var(--ink); }
|
|
105
|
+
.edge { opacity: 0.9; }
|
|
106
|
+
.edge.dim { opacity: 0.08; }
|
|
107
|
+
.edge.hl { stroke: var(--ink) !important; opacity: 1; }
|
|
108
|
+
|
|
109
|
+
/* ---- Mobile: on narrow screens, render the panel as a bottom sheet ---- */
|
|
110
|
+
@media (max-width: 640px) {
|
|
111
|
+
body { height: 100dvh; }
|
|
112
|
+
.toolbar { column-gap: 12px; padding: 8px 12px; font-size: 12px; }
|
|
113
|
+
.brand .sub { display: none; }
|
|
114
|
+
.legend { width: 100%; margin-left: 0; justify-content: flex-start; font-size: 11px; }
|
|
115
|
+
#panel {
|
|
116
|
+
position: absolute;
|
|
117
|
+
left: 0; right: 0; bottom: 0;
|
|
118
|
+
flex: none;
|
|
119
|
+
width: 100%;
|
|
120
|
+
max-height: 62%;
|
|
121
|
+
border-left: none;
|
|
122
|
+
border-top: 1px solid var(--line);
|
|
123
|
+
border-radius: 14px 14px 0 0;
|
|
124
|
+
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.45);
|
|
125
|
+
transform: translateY(101%);
|
|
126
|
+
transition: transform 0.22s ease;
|
|
127
|
+
padding-top: 30px;
|
|
128
|
+
}
|
|
129
|
+
body.panel-open #panel { transform: translateY(0); }
|
|
130
|
+
.panel-close {
|
|
131
|
+
display: block;
|
|
132
|
+
position: absolute;
|
|
133
|
+
top: 6px; right: 10px;
|
|
134
|
+
width: 32px; height: 32px;
|
|
135
|
+
line-height: 30px; text-align: center;
|
|
136
|
+
font-size: 20px; color: var(--muted);
|
|
137
|
+
background: none; border: none; cursor: pointer;
|
|
138
|
+
}
|
|
139
|
+
/* sheet handle */
|
|
140
|
+
#panel::before {
|
|
141
|
+
content: ""; position: absolute; top: 9px; left: 50%;
|
|
142
|
+
width: 38px; height: 4px; margin-left: -19px;
|
|
143
|
+
background: var(--line); border-radius: 2px;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
</style>
|
|
147
|
+
</head>
|
|
148
|
+
<body>
|
|
149
|
+
<header class="toolbar">
|
|
150
|
+
<div class="brand">Alkahest<span class="sub">Product Map</span></div>
|
|
151
|
+
<div class="counts" id="counts"></div>
|
|
152
|
+
<label><input type="checkbox" id="t-trans" checked /> Navigate</label>
|
|
153
|
+
<label><input type="checkbox" id="t-calls" checked /> Call</label>
|
|
154
|
+
<div class="legend">
|
|
155
|
+
<span style="font-weight:700">▶ Start</span>
|
|
156
|
+
<span><span class="swatch" style="background: var(--node-fill); border: 1.5px solid var(--node-stroke)"></span>Screen</span>
|
|
157
|
+
<span><span class="swatch" style="background: var(--res-fill); border: 1.5px solid var(--node-stroke); border-radius: 2px"></span>Resource</span>
|
|
158
|
+
<span><span class="edge" style="border-color: var(--edge)"></span>Navigate</span>
|
|
159
|
+
<span><span class="edge" style="border-top-style: dotted; border-color: var(--edge)"></span>Contains</span>
|
|
160
|
+
<span><span class="edge" style="border-top-style: dashed; border-color: var(--edge)"></span>Call</span>
|
|
161
|
+
</div>
|
|
162
|
+
<button class="theme-toggle" id="fit-btn" aria-label="Fit to view">⤢ Fit</button>
|
|
163
|
+
<button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">🌗</button>
|
|
164
|
+
</header>
|
|
165
|
+
<main>
|
|
166
|
+
<svg id="graph">
|
|
167
|
+
<defs>
|
|
168
|
+
<!-- single arrow (shared by transitions and calls). userSpaceOnUse: fixed size regardless of line width -->
|
|
169
|
+
<!-- open V-shaped arrow (instead of a filled triangle) — blends into the line so it stands out less -->
|
|
170
|
+
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" markerUnits="userSpaceOnUse" orient="auto">
|
|
171
|
+
<path d="M1,1 L8,5 L1,9" fill="none" stroke="var(--edge)" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" />
|
|
172
|
+
</marker>
|
|
173
|
+
</defs>
|
|
174
|
+
<g id="viewport">
|
|
175
|
+
<g id="edges"></g>
|
|
176
|
+
<g id="nodes"></g>
|
|
177
|
+
</g>
|
|
178
|
+
</svg>
|
|
179
|
+
<aside id="panel">
|
|
180
|
+
<button class="panel-close" id="panel-close" aria-label="Close">×</button>
|
|
181
|
+
<div id="panel-body">
|
|
182
|
+
<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>
|
|
183
|
+
</div>
|
|
184
|
+
</aside>
|
|
185
|
+
</main>
|
|
186
|
+
|
|
187
|
+
<script id="alkahest-data" type="application/json">/*__ALKAHEST_MAP__*/</script>
|
|
188
|
+
<script type="module">
|
|
189
|
+
// The map is either inlined (self-contained local index.html) or fetched at
|
|
190
|
+
// runtime (hosted viewer). Same dashboard code renders both.
|
|
191
|
+
let MAP;
|
|
192
|
+
try {
|
|
193
|
+
MAP = await loadMap();
|
|
194
|
+
} catch (err) {
|
|
195
|
+
document.body.innerHTML =
|
|
196
|
+
'<div style="font:14px system-ui;padding:40px;color:#b00">Failed to load the product map: ' +
|
|
197
|
+
(err && err.message ? err.message : err) +
|
|
198
|
+
"</div>";
|
|
199
|
+
throw err;
|
|
200
|
+
}
|
|
201
|
+
async function loadMap() {
|
|
202
|
+
const raw = document.getElementById("alkahest-data").textContent.trim();
|
|
203
|
+
if (raw && raw[0] === "{") return JSON.parse(raw); // self-contained (local)
|
|
204
|
+
const url = mapUrl();
|
|
205
|
+
const res = await fetch(url, { cache: "no-store" });
|
|
206
|
+
if (!res.ok) throw new Error("HTTP " + res.status + " for " + url);
|
|
207
|
+
return res.json();
|
|
208
|
+
}
|
|
209
|
+
// Resolve where to fetch the map from, in priority order:
|
|
210
|
+
// 1. ?src=<url> explicit override
|
|
211
|
+
// 2. <meta name="alkahest:map-base"> + /p/{slug} hosted viewer
|
|
212
|
+
// 3. ./map.json sibling file (plain static hosting)
|
|
213
|
+
function mapUrl() {
|
|
214
|
+
const explicit = new URLSearchParams(location.search).get("src");
|
|
215
|
+
if (explicit) return explicit;
|
|
216
|
+
const base = document.querySelector('meta[name="alkahest:map-base"]')?.content;
|
|
217
|
+
const slug = (location.pathname.match(/\/p\/([^/]+)/) || [])[1];
|
|
218
|
+
if (base && slug) return base.replace(/\/+$/, "") + "/" + slug + "/map.json";
|
|
219
|
+
return "./map.json";
|
|
220
|
+
}
|
|
221
|
+
const SVGNS = "http://www.w3.org/2000/svg";
|
|
222
|
+
|
|
223
|
+
// ---- indexes ----
|
|
224
|
+
const screensById = new Map(MAP.screens.map((s) => [s.id, s]));
|
|
225
|
+
const resourcesById = new Map(MAP.resources.map((r) => [r.id, r]));
|
|
226
|
+
|
|
227
|
+
// ---- node/edge graph model ----
|
|
228
|
+
const nodes = [];
|
|
229
|
+
const nodeByKey = new Map();
|
|
230
|
+
function addNode(n) { nodes.push(n); nodeByKey.set(n.key, n); return n; }
|
|
231
|
+
for (const s of MAP.screens) addNode({ key: "s:" + s.id, type: "screen", id: s.id, label: s.title || s.route, r: 10 });
|
|
232
|
+
for (const r of MAP.resources) addNode({ key: "r:" + r.id, type: "resource", id: r.id, label: r.label, kind: r.kind, r: 8 });
|
|
233
|
+
|
|
234
|
+
const edges = [];
|
|
235
|
+
for (const t of MAP.transitions) {
|
|
236
|
+
if (t.to == null) continue;
|
|
237
|
+
const target = nodeByKey.get("s:" + t.to);
|
|
238
|
+
if (!target) continue; // external URLs/unresolved aren't drawn in the graph (shown in the panel)
|
|
239
|
+
// rel: "contains" (structural containment) vs "navigate" (user navigation). Both are the "transition" layer.
|
|
240
|
+
edges.push({ source: nodeByKey.get("s:" + t.from), target, kind: "transition", rel: t.kind || "navigate" });
|
|
241
|
+
}
|
|
242
|
+
for (const c of MAP.calls) {
|
|
243
|
+
if (c.to == null) continue;
|
|
244
|
+
const target = nodeByKey.get("r:" + c.to);
|
|
245
|
+
if (!target) continue;
|
|
246
|
+
edges.push({ source: nodeByKey.get("s:" + c.from), target, kind: "call", rel: "call" });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
{
|
|
250
|
+
const navCount = MAP.transitions.filter((t) => (t.kind || "navigate") === "navigate").length;
|
|
251
|
+
const containCount = MAP.transitions.length - navCount;
|
|
252
|
+
document.getElementById("counts").textContent =
|
|
253
|
+
MAP.screens.length + " screens · " + MAP.resources.length + " resources · " +
|
|
254
|
+
navCount + " navigate" + (containCount ? " · " + containCount + " contains" : "") +
|
|
255
|
+
" · " + MAP.calls.length + " calls";
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ====== force-directed layout (reference approach) ======
|
|
259
|
+
// Force is natural for a tangled screen graph. Depth is not enforced.
|
|
260
|
+
// Deterministic: a fixed-seed PRNG instead of Math.random → identical layout every time.
|
|
261
|
+
const svg = document.getElementById("graph");
|
|
262
|
+
let W = (svg.clientWidth || 800), H = (svg.clientHeight || 600);
|
|
263
|
+
|
|
264
|
+
// Seeded PRNG (mulberry32) — seeded by node count for the same result every time.
|
|
265
|
+
let _seed = 0x9e3779b9 ^ nodes.length;
|
|
266
|
+
function rng() {
|
|
267
|
+
_seed |= 0; _seed = (_seed + 0x6D2B79F5) | 0;
|
|
268
|
+
let t = Math.imul(_seed ^ (_seed >>> 15), 1 | _seed);
|
|
269
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
270
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Entry points (if any, weakly pinned near the center — to hint at the start point)
|
|
274
|
+
const entryKeys = new Set(MAP.screens.filter((s) => s.isEntry).map((s) => "s:" + s.id));
|
|
275
|
+
|
|
276
|
+
// Neighbor (connection) adjacency — for springs
|
|
277
|
+
const adj = new Map(nodes.map((n) => [n.key, []]));
|
|
278
|
+
for (const e of edges) { adj.get(e.source.key).push(e.target); adj.get(e.target.key).push(e.source); }
|
|
279
|
+
|
|
280
|
+
// Initial placement: circular + seeded jitter (the starting point force will spread out)
|
|
281
|
+
const cx0 = W / 2, cy0 = H / 2, R0 = Math.min(W, H) * 0.42;
|
|
282
|
+
nodes.forEach((n, i) => {
|
|
283
|
+
const a = (i / nodes.length) * Math.PI * 2;
|
|
284
|
+
n.x = cx0 + Math.cos(a) * R0 * (0.6 + rng() * 0.5);
|
|
285
|
+
n.y = cy0 + Math.sin(a) * R0 * (0.6 + rng() * 0.5);
|
|
286
|
+
n.vx = 0; n.vy = 0;
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// ---- interaction state (step references dragging) ----
|
|
290
|
+
let dragging = null, panStart = null, selected = null;
|
|
291
|
+
|
|
292
|
+
// ---- force simulation ----
|
|
293
|
+
// Gravity applies only during initial settling (gathers nodes into view). After settled it's 0 →
|
|
294
|
+
// a node moved by dragging isn't pulled toward the center and stays near where it was dropped.
|
|
295
|
+
let alpha = 1, settled = false;
|
|
296
|
+
const REPULSION = 16000, SPRING = 0.025, SPRING_LEN = 180, GRAVITY = 0.012, DAMP = 0.82;
|
|
297
|
+
const REPULSE_RANGE = 320, REPULSE_RANGE2 = REPULSE_RANGE * REPULSE_RANGE;
|
|
298
|
+
function step() {
|
|
299
|
+
const grav = settled ? 0 : GRAVITY; // gravity OFF after settling
|
|
300
|
+
// Repulsion (pairwise) — ignored beyond a fixed distance (REPULSE_RANGE). Avoids a "bulldozer" that pushes distant nodes.
|
|
301
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
302
|
+
for (let j = i + 1; j < nodes.length; j++) {
|
|
303
|
+
const a = nodes[i], b = nodes[j];
|
|
304
|
+
let dx = a.x - b.x, dy = a.y - b.y;
|
|
305
|
+
let d2 = dx * dx + dy * dy || 1;
|
|
306
|
+
if (d2 > REPULSE_RANGE2) continue;
|
|
307
|
+
const f = REPULSION / d2, d = Math.sqrt(d2);
|
|
308
|
+
const fx = (dx / d) * f, fy = (dy / d) * f;
|
|
309
|
+
a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// Springs (edges)
|
|
313
|
+
for (const e of edges) {
|
|
314
|
+
const a = e.source, b = e.target;
|
|
315
|
+
let dx = b.x - a.x, dy = b.y - a.y;
|
|
316
|
+
const d = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
317
|
+
const f = (d - SPRING_LEN) * SPRING;
|
|
318
|
+
const fx = (dx / d) * f, fy = (dy / d) * f;
|
|
319
|
+
a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy;
|
|
320
|
+
}
|
|
321
|
+
for (const n of nodes) {
|
|
322
|
+
if (n === dragging) { n.vx = 0; n.vy = 0; continue; } // dragged node: no velocity accumulation (no bounce on release)
|
|
323
|
+
if (grav) { n.vx += (cx0 - n.x) * grav; n.vy += (cy0 - n.y) * grav; }
|
|
324
|
+
n.vx *= DAMP; n.vy *= DAMP;
|
|
325
|
+
n.x += n.vx * alpha; n.y += n.vy * alpha;
|
|
326
|
+
}
|
|
327
|
+
alpha *= 0.985;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
// ---- render ----
|
|
332
|
+
const gEdges = document.getElementById("edges");
|
|
333
|
+
const gNodes = document.getElementById("nodes");
|
|
334
|
+
|
|
335
|
+
// (no depth bands in the force layout — there is no column concept)
|
|
336
|
+
// Index for fanning out multiple edges that share the same (source,target) pair
|
|
337
|
+
const pairCount = new Map();
|
|
338
|
+
for (const e of edges) {
|
|
339
|
+
const key = e.source.key + "|" + e.target.key;
|
|
340
|
+
e.pairKey = key;
|
|
341
|
+
e.pairIdx = pairCount.get(key) || 0;
|
|
342
|
+
pairCount.set(key, e.pairIdx + 1);
|
|
343
|
+
}
|
|
344
|
+
const edgeEls = edges.map((e) => {
|
|
345
|
+
const pa = document.createElementNS(SVGNS, "path");
|
|
346
|
+
pa.setAttribute("class", "edge " + e.kind);
|
|
347
|
+
pa.setAttribute("stroke", "var(--edge)");
|
|
348
|
+
pa.setAttribute("stroke-width", "1.4");
|
|
349
|
+
pa.setAttribute("fill", "none");
|
|
350
|
+
if (e.kind === "call") {
|
|
351
|
+
pa.setAttribute("stroke-dasharray", "4 4");
|
|
352
|
+
pa.setAttribute("marker-end", "url(#arrow)");
|
|
353
|
+
} else if (e.rel === "contains") {
|
|
354
|
+
pa.setAttribute("stroke-dasharray", "2 3");
|
|
355
|
+
} else {
|
|
356
|
+
pa.setAttribute("marker-end", "url(#arrow)");
|
|
357
|
+
}
|
|
358
|
+
gEdges.appendChild(pa);
|
|
359
|
+
e.el = pa;
|
|
360
|
+
return pa;
|
|
361
|
+
});
|
|
362
|
+
const nodeEls = nodes.map((n) => {
|
|
363
|
+
const g = document.createElementNS(SVGNS, "g");
|
|
364
|
+
g.setAttribute("class", "node");
|
|
365
|
+
// Style F: screen=white circle / resource=light gray square. Start points get a ▶ prefix on the label.
|
|
366
|
+
const isEntry = n.type === "screen" && entryKeys.has(n.key);
|
|
367
|
+
let shape;
|
|
368
|
+
if (n.type === "screen") {
|
|
369
|
+
shape = document.createElementNS(SVGNS, "circle");
|
|
370
|
+
shape.setAttribute("r", n.r);
|
|
371
|
+
shape.setAttribute("fill", "var(--node-fill)");
|
|
372
|
+
} else {
|
|
373
|
+
shape = document.createElementNS(SVGNS, "rect");
|
|
374
|
+
shape.setAttribute("width", n.r * 2); shape.setAttribute("height", n.r * 2);
|
|
375
|
+
shape.setAttribute("x", -n.r); shape.setAttribute("y", -n.r);
|
|
376
|
+
shape.setAttribute("rx", 3);
|
|
377
|
+
shape.setAttribute("fill", "var(--res-fill)");
|
|
378
|
+
}
|
|
379
|
+
const txt = document.createElementNS(SVGNS, "text");
|
|
380
|
+
txt.setAttribute("x", n.r + 6); txt.setAttribute("y", 4);
|
|
381
|
+
if (isEntry) txt.setAttribute("font-weight", "700");
|
|
382
|
+
const baseLabel = n.label.length > 26 ? n.label.slice(0, 25) + "…" : n.label;
|
|
383
|
+
txt.textContent = isEntry ? "▶ " + baseLabel : baseLabel;
|
|
384
|
+
g.appendChild(shape); g.appendChild(txt);
|
|
385
|
+
g.addEventListener("pointerdown", (ev) => onNodeDown(ev, n));
|
|
386
|
+
g.addEventListener("pointerenter", () => hoverOn(n));
|
|
387
|
+
g.addEventListener("pointerleave", hoverOff);
|
|
388
|
+
gNodes.appendChild(g);
|
|
389
|
+
n.el = g;
|
|
390
|
+
return g;
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
function paint() {
|
|
394
|
+
for (const e of edges) {
|
|
395
|
+
// Straight edge (trimmed at node edges) so the arrow isn't hidden behind the node.
|
|
396
|
+
const a = e.source, b = e.target;
|
|
397
|
+
const dx = b.x - a.x, dy = b.y - a.y, len = Math.hypot(dx, dy) || 1;
|
|
398
|
+
const ux = dx / len, uy = dy / len;
|
|
399
|
+
const padA = (a.r || 10) + 2, padB = (b.r || 10) + 2;
|
|
400
|
+
const x1 = a.x + ux * padA, y1 = a.y + uy * padA;
|
|
401
|
+
const x2 = b.x - ux * padB, y2 = b.y - uy * padB;
|
|
402
|
+
e.el.setAttribute("d", "M" + x1 + "," + y1 + " L" + x2 + "," + y2);
|
|
403
|
+
}
|
|
404
|
+
for (const n of nodes) n.el.setAttribute("transform", "translate(" + n.x + "," + n.y + ")");
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ---- simulation loop: stop once settled, then fit to view ----
|
|
408
|
+
// While dragging, keep alpha high → neighbors actively follow the grabbed node.
|
|
409
|
+
let fitted = false;
|
|
410
|
+
function loop() {
|
|
411
|
+
if (dragging) {
|
|
412
|
+
// While dragging: simulate only weakly (alpha 0.12) → neighbors follow smoothly but don't bounce on release.
|
|
413
|
+
alpha = 0.12; step(); paint();
|
|
414
|
+
} else if (alpha > 0.02) {
|
|
415
|
+
step(); paint();
|
|
416
|
+
} else if (!fitted) {
|
|
417
|
+
fitted = true; settled = true; paint(); fitToView();
|
|
418
|
+
}
|
|
419
|
+
requestAnimationFrame(loop);
|
|
420
|
+
}
|
|
421
|
+
// The loop is started at the end of the script (after fitToView/tx are declared) — to avoid TDZ.
|
|
422
|
+
|
|
423
|
+
// ---- layer toggles ----
|
|
424
|
+
const tTrans = document.getElementById("t-trans"), tCalls = document.getElementById("t-calls");
|
|
425
|
+
function applyLayers() {
|
|
426
|
+
for (const e of edges) e.el.style.display =
|
|
427
|
+
(e.kind === "transition" && !tTrans.checked) || (e.kind === "call" && !tCalls.checked) ? "none" : "";
|
|
428
|
+
for (const n of nodes) if (n.type === "resource") n.el.style.display = tCalls.checked ? "" : "none";
|
|
429
|
+
}
|
|
430
|
+
tTrans.onchange = tCalls.onchange = applyLayers;
|
|
431
|
+
|
|
432
|
+
// ---- selection/highlight ---- (selected is declared above)
|
|
433
|
+
function select(n) {
|
|
434
|
+
hoverOff(); // clear hover highlight
|
|
435
|
+
selected = n;
|
|
436
|
+
const connected = new Set([n.key]);
|
|
437
|
+
for (const e of edges) {
|
|
438
|
+
if (e.source === n) connected.add(e.target.key);
|
|
439
|
+
if (e.target === n) connected.add(e.source.key);
|
|
440
|
+
}
|
|
441
|
+
for (const m of nodes) {
|
|
442
|
+
m.el.classList.toggle("dim", !connected.has(m.key));
|
|
443
|
+
m.el.classList.toggle("selected", m === n); // accent ring on the selected node
|
|
444
|
+
}
|
|
445
|
+
for (const e of edges) e.el.classList.toggle("dim", e.source !== n && e.target !== n);
|
|
446
|
+
document.body.classList.add("panel-open");
|
|
447
|
+
renderPanel(n);
|
|
448
|
+
}
|
|
449
|
+
function clearSelection() {
|
|
450
|
+
selected = null;
|
|
451
|
+
for (const m of nodes) { m.el.classList.remove("dim"); m.el.classList.remove("selected"); }
|
|
452
|
+
for (const e of edges) e.el.classList.remove("dim");
|
|
453
|
+
panel.innerHTML = '<p class="hint">Click a node to see its features, transitions, and calls.</p>';
|
|
454
|
+
document.body.classList.remove("panel-open");
|
|
455
|
+
}
|
|
456
|
+
// ---- hover preview: only when nothing is selected and not dragging, highlight connected edges/neighbors ----
|
|
457
|
+
let hovered = null;
|
|
458
|
+
function hoverOn(n) {
|
|
459
|
+
if (selected || dragging) return;
|
|
460
|
+
hovered = n;
|
|
461
|
+
for (const e of edges) {
|
|
462
|
+
const on = e.source === n || e.target === n;
|
|
463
|
+
e.el.classList.toggle("hl", on);
|
|
464
|
+
if (on) { e.source.el.classList.add("hl"); e.target.el.classList.add("hl"); }
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
function hoverOff() {
|
|
468
|
+
if (!hovered) return;
|
|
469
|
+
hovered = null;
|
|
470
|
+
for (const e of edges) e.el.classList.remove("hl");
|
|
471
|
+
for (const m of nodes) m.el.classList.remove("hl");
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Tapping empty svg space → clearSelection is handled in the pointerup (tap) handler.
|
|
475
|
+
document.getElementById("panel-close").addEventListener("click", clearSelection);
|
|
476
|
+
|
|
477
|
+
// ---- theme toggle (light by default ↔ dark) ----
|
|
478
|
+
document.getElementById("theme-toggle").addEventListener("click", () => {
|
|
479
|
+
const h = document.documentElement;
|
|
480
|
+
h.setAttribute("data-theme", h.getAttribute("data-theme") === "dark" ? "light" : "dark");
|
|
481
|
+
});
|
|
482
|
+
// ---- fit-to-view button ----
|
|
483
|
+
document.getElementById("fit-btn").addEventListener("click", fitToView);
|
|
484
|
+
|
|
485
|
+
// ---- panel ----
|
|
486
|
+
const panel = document.getElementById("panel-body"); // content container (close button/handle are preserved)
|
|
487
|
+
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]));
|
|
488
|
+
function li(inner, cls) { return "<li" + (cls ? ' class="' + cls + '"' : "") + ">" + inner + "</li>"; }
|
|
489
|
+
function listOr(items, emptyMsg) { return items.length ? "<ul>" + items.join("") + "</ul>" : '<p class="empty">' + emptyMsg + "</p>"; }
|
|
490
|
+
|
|
491
|
+
// Minimal markdown → HTML for PRD (headings, list/checkbox, bold/code, paragraphs). No deps.
|
|
492
|
+
function mdToHtml(md) {
|
|
493
|
+
const inline = (t) => esc(t)
|
|
494
|
+
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
|
495
|
+
.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
496
|
+
const out = [];
|
|
497
|
+
let list = null; // "ul" | null
|
|
498
|
+
const closeList = () => { if (list) { out.push("</" + list + ">"); list = null; } };
|
|
499
|
+
for (const raw of md.split("\n")) {
|
|
500
|
+
const line = raw.trimEnd();
|
|
501
|
+
let m;
|
|
502
|
+
if (!line.trim()) { closeList(); continue; }
|
|
503
|
+
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 + ">"); }
|
|
504
|
+
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>"); }
|
|
505
|
+
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>"); }
|
|
506
|
+
else { closeList(); out.push("<p>" + inline(line) + "</p>"); }
|
|
507
|
+
}
|
|
508
|
+
closeList();
|
|
509
|
+
return out.join("");
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function renderPanel(n) {
|
|
513
|
+
if (n.type === "screen") return renderScreen(screensById.get(n.id));
|
|
514
|
+
return renderResource(resourcesById.get(n.id));
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function renderScreen(s) {
|
|
518
|
+
const out = MAP.transitions.filter((t) => t.from === s.id);
|
|
519
|
+
const inc = MAP.transitions.filter((t) => t.to === s.id);
|
|
520
|
+
const calls = MAP.calls.filter((c) => c.from === s.id);
|
|
521
|
+
const feats = s.features.map((f) =>
|
|
522
|
+
li('<span class="tag">[' + f.kind + "]</span> " + esc(f.label) + (f.loc ? '<span class="lineno">L' + f.loc.line + "</span>" : "")));
|
|
523
|
+
const navItem = (t) => {
|
|
524
|
+
const label = t.to ? (screensById.has(t.to) ? esc(screensById.get(t.to).title || t.to) : esc(t.to)) : esc(t.rawTarget || "unresolved");
|
|
525
|
+
const cls = t.to && screensById.has(t.to) ? "clickable" : "";
|
|
526
|
+
const key = t.to && screensById.has(t.to) ? ' data-sel="s:' + esc(t.to) + '"' : "";
|
|
527
|
+
return "<li" + (cls ? ' class="' + cls + '"' : "") + key + '><span class="tag">' + esc(t.trigger) + "</span> → " + label +
|
|
528
|
+
'<span class="lineno">L' + t.loc.line + "</span></li>";
|
|
529
|
+
};
|
|
530
|
+
const callItem = (c) => {
|
|
531
|
+
const label = c.to ? esc(c.to) : esc(c.rawTarget || "unresolved");
|
|
532
|
+
const cls = c.to ? "clickable" : "";
|
|
533
|
+
const key = c.to ? ' data-sel="r:' + esc(c.to) + '"' : "";
|
|
534
|
+
return "<li" + (cls ? ' class="' + cls + '"' : "") + key + '><span class="tag">' + esc(c.trigger) + "</span> " + label +
|
|
535
|
+
'<span class="lineno">L' + c.loc.line + "</span></li>";
|
|
536
|
+
};
|
|
537
|
+
panel.innerHTML =
|
|
538
|
+
"<h2>" + esc(s.title) + "</h2>" +
|
|
539
|
+
'<div class="route">' + esc(s.route) + "</div>" +
|
|
540
|
+
'<div class="src">' + esc(s.sourceFile) + "</div>" +
|
|
541
|
+
(s.summary ? "<section><h3>Summary</h3><p>" + esc(s.summary) + "</p></section>" : "") +
|
|
542
|
+
(s.prd ? '<section class="prd"><h3>PRD</h3>' + mdToHtml(s.prd) + "</section>" : "") +
|
|
543
|
+
"<section><h3>Features (" + s.features.length + ")</h3>" + listOr(feats, "No UI features extracted") + "</section>" +
|
|
544
|
+
"<section><h3>Outgoing (" + out.length + ")</h3>" + listOr(out.map(navItem), "None") + "</section>" +
|
|
545
|
+
"<section><h3>Calls (" + calls.length + ")</h3>" + listOr(calls.map(callItem), "None") + "</section>" +
|
|
546
|
+
"<section><h3>Incoming (" + inc.length + ")</h3>" + listOr(inc.map(navItem), "None") + "</section>";
|
|
547
|
+
wirePanelLinks();
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function renderResource(r) {
|
|
551
|
+
const callers = MAP.calls.filter((c) => c.to === r.id);
|
|
552
|
+
const items = callers.map((c) => {
|
|
553
|
+
const s = screensById.get(c.from);
|
|
554
|
+
return '<li class="clickable" data-sel="s:' + esc(c.from) + '"><span class="tag">' + esc(c.trigger) + "</span> " +
|
|
555
|
+
esc(s ? s.title : c.from) + '<span class="lineno">L' + c.loc.line + "</span></li>";
|
|
556
|
+
});
|
|
557
|
+
panel.innerHTML =
|
|
558
|
+
"<h2>" + esc(r.label) + "</h2>" +
|
|
559
|
+
'<div class="route">' + esc(r.kind) + (r.method ? " · " + esc(r.method) : "") + "</div>" +
|
|
560
|
+
'<div class="src">' + esc(r.path || "") + "</div>" +
|
|
561
|
+
"<section><h3>Called by screens (" + callers.length + ")</h3>" + listOr(items, "None") + "</section>";
|
|
562
|
+
wirePanelLinks();
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function wirePanelLinks() {
|
|
566
|
+
panel.querySelectorAll("[data-sel]").forEach((el) => {
|
|
567
|
+
el.addEventListener("click", () => { const n = nodeByKey.get(el.getAttribute("data-sel")); if (n) select(n); });
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ---- pan / zoom / drag ----
|
|
572
|
+
const viewport = document.getElementById("viewport");
|
|
573
|
+
let tx = 0, ty = 0, k = 1;
|
|
574
|
+
function applyTransform() { viewport.setAttribute("transform", "translate(" + tx + " " + ty + ") scale(" + k + ")"); }
|
|
575
|
+
function toWorld(cx, cy) { const rect = svg.getBoundingClientRect(); return { x: (cx - rect.left - tx) / k, y: (cy - rect.top - ty) / k }; }
|
|
576
|
+
|
|
577
|
+
// Fit the whole graph into view (40px margin, max 1.3x)
|
|
578
|
+
function fitToView() {
|
|
579
|
+
if (!nodes.length) return;
|
|
580
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
581
|
+
for (const n of nodes) {
|
|
582
|
+
const r = (n.r || 10) + 60; // room for the label
|
|
583
|
+
minX = Math.min(minX, n.x - r); maxX = Math.max(maxX, n.x + r);
|
|
584
|
+
minY = Math.min(minY, n.y - r); maxY = Math.max(maxY, n.y + r);
|
|
585
|
+
}
|
|
586
|
+
const w = svg.clientWidth || 800, h = svg.clientHeight || 600, pad = 40;
|
|
587
|
+
const gw = maxX - minX || 1, gh = maxY - minY || 1;
|
|
588
|
+
k = Math.min((w - pad * 2) / gw, (h - pad * 2) / gh, 1.3);
|
|
589
|
+
tx = pad + (w - pad * 2 - gw * k) / 2 - minX * k;
|
|
590
|
+
ty = pad + (h - pad * 2 - gh * k) / 2 - minY * k;
|
|
591
|
+
applyTransform();
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// dragging, panStart are declared above.
|
|
595
|
+
// Tap vs drag: record only the pointerdown position, and promote to a drag once movement exceeds the threshold (4px).
|
|
596
|
+
const DRAG_THRESHOLD = 4;
|
|
597
|
+
let down = null; // {node|null, x, y, moved, pointerId}
|
|
598
|
+
|
|
599
|
+
function onNodeDown(ev, n) {
|
|
600
|
+
ev.stopPropagation();
|
|
601
|
+
down = { node: n, x: ev.clientX, y: ev.clientY, moved: false, pointerId: ev.pointerId };
|
|
602
|
+
svg.setPointerCapture(ev.pointerId);
|
|
603
|
+
}
|
|
604
|
+
svg.addEventListener("pointerdown", (ev) => {
|
|
605
|
+
if (down) return; // already started on a node
|
|
606
|
+
down = { node: null, x: ev.clientX, y: ev.clientY, moved: false, pointerId: ev.pointerId };
|
|
607
|
+
panStart = { x: ev.clientX - tx, y: ev.clientY - ty };
|
|
608
|
+
svg.setPointerCapture(ev.pointerId);
|
|
609
|
+
});
|
|
610
|
+
svg.addEventListener("pointermove", (ev) => {
|
|
611
|
+
if (!down) return;
|
|
612
|
+
if (!down.moved && Math.hypot(ev.clientX - down.x, ev.clientY - down.y) > DRAG_THRESHOLD) {
|
|
613
|
+
down.moved = true;
|
|
614
|
+
if (down.node) { dragging = down.node; fitted = true; } // promote to drag (alpha is managed by loop)
|
|
615
|
+
}
|
|
616
|
+
if (!down.moved) return;
|
|
617
|
+
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
|
|
618
|
+
else if (panStart) { tx = ev.clientX - panStart.x; ty = ev.clientY - panStart.y; applyTransform(); }
|
|
619
|
+
});
|
|
620
|
+
svg.addEventListener("pointerup", () => {
|
|
621
|
+
if (down && !down.moved) { // didn't move = tap
|
|
622
|
+
if (down.node) select(down.node);
|
|
623
|
+
else clearSelection();
|
|
624
|
+
} else if (dragging) {
|
|
625
|
+
// On release: briefly settle with a low alpha → no bounce, settles near where it was dropped.
|
|
626
|
+
alpha = 0.06;
|
|
627
|
+
}
|
|
628
|
+
dragging = null; panStart = null; down = null;
|
|
629
|
+
});
|
|
630
|
+
svg.addEventListener("wheel", (ev) => {
|
|
631
|
+
ev.preventDefault();
|
|
632
|
+
const rect = svg.getBoundingClientRect();
|
|
633
|
+
const mx = ev.clientX - rect.left, my = ev.clientY - rect.top;
|
|
634
|
+
const factor = ev.deltaY < 0 ? 1.1 : 1 / 1.1;
|
|
635
|
+
const nk = Math.min(4, Math.max(0.2, k * factor));
|
|
636
|
+
tx = mx - (mx - tx) * (nk / k); ty = my - (my - ty) * (nk / k); k = nk;
|
|
637
|
+
applyTransform();
|
|
638
|
+
}, { passive: false });
|
|
639
|
+
|
|
640
|
+
window.addEventListener("resize", () => { W = (svg.clientWidth || 800); H = (svg.clientHeight || 600); fitToView(); });
|
|
641
|
+
applyLayers();
|
|
642
|
+
|
|
643
|
+
// ---- start the simulation (auto fit-to-view after force settles) ----
|
|
644
|
+
loop();
|
|
645
|
+
</script>
|
|
646
|
+
</body>
|
|
647
|
+
</html>
|
package/dist/cli.d.ts
ADDED