ketatlas 0.1.0

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE.md +10 -0
  4. package/README.md +130 -0
  5. package/assets/INTER-LICENSE +93 -0
  6. package/assets/KETJS-LICENSE +21 -0
  7. package/assets/LUCIDE-LICENSE +43 -0
  8. package/assets/design-system.lock.json +56 -0
  9. package/assets/inter-latin-wght-normal.woff2 +0 -0
  10. package/assets/inter-vietnamese-wght-normal.woff2 +0 -0
  11. package/bin/audit.js +116 -0
  12. package/bin/ketatlas.js +148 -0
  13. package/bin/server.js +93 -0
  14. package/bin/viewer.js +20 -0
  15. package/docs/architecture.md +57 -0
  16. package/docs/authoring.md +49 -0
  17. package/docs/cli.md +70 -0
  18. package/docs/configuration.md +65 -0
  19. package/docs/integration.md +108 -0
  20. package/docs/migration.md +35 -0
  21. package/docs/releasing.md +40 -0
  22. package/package.json +59 -0
  23. package/schema.json +187 -0
  24. package/src/config.js +196 -0
  25. package/src/icons.js +23 -0
  26. package/src/index.d.ts +106 -0
  27. package/src/index.js +679 -0
  28. package/src/layout.js +103 -0
  29. package/src/template.js +38 -0
  30. package/styles/design-system.css +2838 -0
  31. package/styles/design-system.document.css +2838 -0
  32. package/styles/fonts.css +17 -0
  33. package/styles/ketatlas.css +992 -0
  34. package/templates/basic/README.md +14 -0
  35. package/templates/basic/atlas.json +45 -0
  36. package/templates/basic/ketatlas.schema.json +187 -0
  37. package/templates/basic/screens/done.html +18 -0
  38. package/templates/basic/screens/screen.css +48 -0
  39. package/templates/basic/screens/welcome.html +21 -0
  40. package/templates/basic/styles/LICENSE +21 -0
  41. package/templates/basic/styles/design-system.css +2838 -0
  42. package/templates/process/README.md +14 -0
  43. package/templates/process/atlas.json +83 -0
  44. package/templates/process/ketatlas.schema.json +187 -0
  45. package/templates/web/README.md +14 -0
  46. package/templates/web/atlas.json +77 -0
  47. package/templates/web/ketatlas.schema.json +187 -0
  48. package/templates/web/screens/demo.css +255 -0
  49. package/templates/web/screens/portal.html +41 -0
  50. package/templates/web/styles/LICENSE +21 -0
  51. package/templates/web/styles/design-system.css +2838 -0
package/src/index.js ADDED
@@ -0,0 +1,679 @@
1
+ import { normalizeAtlas, safeURL } from "./config.js";
2
+ import { layoutAtlas, edgePath } from "./layout.js";
3
+ import { template, escapeHTML as e } from "./template.js";
4
+ import { icons } from "./icons.js";
5
+ export { validateAtlas, AtlasValidationError } from "./config.js";
6
+
7
+ /** Mount an isolated viewer. Await atlas.ready before measuring or focusing it. */
8
+ export function createAtlas(container, input, options = {}) {
9
+ if (!(container instanceof HTMLElement))
10
+ throw new TypeError("createAtlas requires an HTML element.");
11
+ const config = normalizeAtlas(input, options.baseURL || document.baseURI);
12
+ const maxPreviews = options.maxPreviews ?? 24,
13
+ previewThreshold = options.previewThreshold ?? 0.3;
14
+ if (!Number.isInteger(maxPreviews) || maxPreviews < 1 || maxPreviews > 64)
15
+ throw new RangeError("maxPreviews must be 1–64.");
16
+ if (!Number.isFinite(previewThreshold) || previewThreshold < 0 || previewThreshold > 2.2)
17
+ throw new RangeError("previewThreshold must be 0–2.2.");
18
+ if (options.theme !== undefined && !["light", "dark"].includes(options.theme))
19
+ throw new TypeError("theme must be light or dark.");
20
+ const sandbox = options.sandbox ?? "allow-scripts allow-forms";
21
+ if (
22
+ typeof sandbox !== "string" ||
23
+ sandbox
24
+ .split(/\s+/)
25
+ .filter(Boolean)
26
+ .some(
27
+ (t) =>
28
+ ![
29
+ "allow-scripts",
30
+ "allow-forms",
31
+ "allow-same-origin",
32
+ "allow-modals",
33
+ "allow-downloads",
34
+ "allow-popups",
35
+ "allow-popups-to-escape-sandbox",
36
+ ].includes(t),
37
+ )
38
+ )
39
+ throw new TypeError("Unsupported sandbox permission.");
40
+ const assetBaseURL = options.assetBaseURL
41
+ ? safeURL(options.assetBaseURL, document.baseURI)
42
+ : new URL("../", import.meta.url).href;
43
+ if (!assetBaseURL.endsWith("/")) {
44
+ throw new TypeError("assetBaseURL must end with a slash.");
45
+ }
46
+ const host = document.createElement("div");
47
+ host.className = "ketatlas";
48
+ host.dataset.theme = options.theme || "light";
49
+ host.lang = "en";
50
+ const root = host.attachShadow({ mode: "open" });
51
+ host.style.visibility = "hidden";
52
+ container.append(host);
53
+ const stylesheet = (file, target) =>
54
+ new Promise((resolve, reject) => {
55
+ const link = document.createElement("link");
56
+ link.rel = "stylesheet";
57
+ link.href = new URL(file, assetBaseURL).href;
58
+ link.onload = resolve;
59
+ link.onerror = () => reject(new Error(`Cannot load KetAtlas stylesheet: ${link.href}`));
60
+ target.append(link);
61
+ });
62
+ const stylesReady = Promise.all([
63
+ stylesheet("styles/design-system.css", root),
64
+ stylesheet("styles/ketatlas.css", root),
65
+ ]);
66
+ const fontURL = new URL("styles/fonts.css", assetBaseURL).href;
67
+ if (![...document.querySelectorAll('link[rel="stylesheet"]')].some((el) => el.href === fontURL)) {
68
+ const link = document.createElement("link");
69
+ link.rel = "stylesheet";
70
+ link.href = fontURL;
71
+ document.head.append(link);
72
+ }
73
+ const content = document.createElement("div");
74
+ content.innerHTML = template(config);
75
+ root.append(...content.childNodes);
76
+ const $ = (id) => root.getElementById(id),
77
+ shell = root.querySelector(".flow-page"),
78
+ icon = (name) => icons[name] || icons.circle;
79
+ const normalize = (t) =>
80
+ String(t)
81
+ .toLocaleLowerCase("en")
82
+ .normalize("NFD")
83
+ .replace(/[\u0300-\u036f]/g, "")
84
+ .replaceAll("đ", "d");
85
+ const { flows, nodes, nodeByKey, flowById, width: maxWidth, height } = layoutAtlas(config);
86
+ const screens = new Map(config.screens.map((s) => [s.id, s]));
87
+ const viewport = $("map-viewport"),
88
+ world = $("map-world"),
89
+ pointers = new Map();
90
+ let current = flows[0],
91
+ selected = null,
92
+ view = { x: 0, y: 0, z: 0.8 },
93
+ frameRequest = 0,
94
+ drag = null,
95
+ suppressClick = false,
96
+ minimapGeometry = null,
97
+ pinch = null,
98
+ destroyed = false;
99
+ const tone = (kind) => ({ primary: "main", conditional: "branch", recovery: "recovery" })[kind];
100
+ const emit = (type, detail) =>
101
+ host.dispatchEvent(
102
+ new CustomEvent(`ketatlas:${type}`, { detail, bubbles: true, composed: true }),
103
+ );
104
+ const createFrame = (n) => {
105
+ const el = document.createElement("iframe");
106
+ el.setAttribute("sandbox", sandbox);
107
+ el.referrerPolicy = "no-referrer";
108
+ el.src = n.url;
109
+ el.addEventListener("error", () => emit("previewerror", { nodeId: n.id, url: n.url }));
110
+ return el;
111
+ };
112
+ world.style.width = maxWidth + "px";
113
+ world.style.height = height + "px";
114
+ $("map-total").textContent = `${flows.length} flows · ${screens.size} screens`;
115
+ const groups = [...new Set(flows.map((f) => f.group))];
116
+ function sidebar() {
117
+ const q = normalize($("flow-search").value);
118
+ $("flow-list").innerHTML =
119
+ groups
120
+ .map((group) => {
121
+ const matches = flows.filter(
122
+ (f) =>
123
+ f.group === group &&
124
+ normalize(
125
+ `${f.title} ${f.group} ${f.nodes.map((n) => `${n.title} ${n.screenId || ""}`).join(" ")}`,
126
+ ).includes(q),
127
+ );
128
+ return matches.length
129
+ ? `<section><h2 class="flow-group-title">${e(group)}</h2>${matches.map((f) => `<button class="flow-link ${f === current ? "active" : ""}" data-flow="${f.id}" ${f === current ? 'aria-current="true"' : ""}><span class="flow-number">${String(f.index + 1).padStart(2, "0")}</span><span><strong>${e(f.title)}</strong><small>${f.nodes.length} steps${f.edges.some((a) => a.kind === "recovery") ? " · Recovery branch" : ""}</small></span></button>`).join("")}</section>`
130
+ : "";
131
+ })
132
+ .join("") || '<p class="mock-filter-empty">No matching workflows.</p>';
133
+ }
134
+ const defs = `<defs>${[
135
+ ["main", "--kv-accent"],
136
+ ["branch", "--kv-text-muted"],
137
+ ["recovery", "--kv-warning"],
138
+ ]
139
+ .map(
140
+ ([tone, color]) =>
141
+ `<marker id="arrow-${tone}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" style="fill:var(${color});stroke:none"></path></marker>`,
142
+ )
143
+ .join("")}</defs>`;
144
+ $("map-edges").setAttribute("viewBox", `0 0 ${maxWidth} ${height}`);
145
+ $("map-edges").innerHTML =
146
+ defs +
147
+ flows
148
+ .map((f) =>
149
+ f.edges
150
+ .map((a, i) => {
151
+ const from = nodeByKey.get(`${f.id}:${a.from}`),
152
+ to = nodeByKey.get(`${f.id}:${a.to}`),
153
+ path = edgePath(from, to, i);
154
+ return `<g class="flow-edge ${tone(a.kind)}" data-from="${from.uid}" data-to="${to.uid}"><path d="${path.d}" marker-end="url(#arrow-${tone(a.kind)})"></path><foreignObject x="${path.label[0] - 80}" y="${path.label[1] - 25}" width="160" height="50"><div xmlns="http://www.w3.org/1999/xhtml" class="edge-label-wrap"><span class="edge-label">${e(a.label)}</span></div></foreignObject></g>`;
155
+ })
156
+ .join(""),
157
+ )
158
+ .join("");
159
+ const labels = document.createElementNS("http://www.w3.org/2000/svg", "g");
160
+ labels.classList.add("edge-label-layer");
161
+ for (const el of $("map-edges").querySelectorAll(".flow-edge")) {
162
+ const label = document.createElementNS("http://www.w3.org/2000/svg", "g");
163
+ label.setAttribute("class", el.getAttribute("class"));
164
+ label.dataset.from = el.dataset.from;
165
+ label.dataset.to = el.dataset.to;
166
+ label.append(el.querySelector("foreignObject"));
167
+ labels.append(label);
168
+ }
169
+ $("map-edges").append(labels);
170
+ $("map-lanes").innerHTML = flows
171
+ .map(
172
+ (f) =>
173
+ `<section class="flow-lane" data-lane="${f.id}" style="left:80px;top:${f.y}px;width:${f.width - 160}px"><div class="flow-lane-head"><span class="lane-number">${String(f.index + 1).padStart(2, "0")}</span><h2>${e(f.title)}</h2></div><p>${e(f.description)}</p></section>`,
174
+ )
175
+ .join("");
176
+ $("map-nodes").innerHTML = nodes
177
+ .map((n) => {
178
+ const f = flowById.get(n.flowId),
179
+ badge =
180
+ n.key === f.start
181
+ ? `<span class="node-badge">${icon("circle-dot")}Start here</span>`
182
+ : f.ends.includes(n.key)
183
+ ? `<span class="node-badge destination">${icon("circle-check")}Outcome</span>`
184
+ : "";
185
+ return `<article class="flow-node ${n.kind !== "screen" ? "external" : ""}" data-node="${n.uid}" data-screen-id="${n.screenId || ""}" data-flow-id="${f.id}" style="left:${n.x}px;top:${n.y}px;height:${n.height}px;--node-width:${n.width}px;--preview-width:${n.previewWidth}px;--preview-height:${n.previewHeight}px;--screen-width:${n.screen?.viewport.width || 0}px;--screen-height:${n.screen?.viewport.height || 0}px;--preview-scale:${n.screen ? n.previewWidth / n.screen.viewport.width : 1}" tabindex="0" aria-label="${e(n.title)}${n.screenId ? " · " + n.screenId : ""}">${badge}${n.kind !== "screen" ? `<span class="external-icon">${icon(n.kind === "external" ? "arrow-up-right" : "file-text")}</span><small>${n.kind === "external" ? "EXTERNAL STEP" : "PROCESS STEP"}</small><h3>${e(n.title)}</h3><p>${e(n.description)}</p>` : `<header class="node-head"><small>${n.screenId}${n.url !== n.screen?.url ? " · Variant" : ""}</small><h3>${e(n.title)}</h3></header><div class="node-preview"><div class="node-placeholder"><span></span><span></span><span></span><p>Zoom in to see<br>the live screen</p></div></div><footer class="node-foot"><span>HTML · ${e(n.screen.badge || "Preview")}</span><button data-open="${n.uid}" aria-label="Open ${e(n.title)}">Open screen ${icon("arrow-up-right")}</button></footer>`}</article>`;
186
+ })
187
+ .join("");
188
+ for (const n of nodes) n.element = viewport.querySelector(`[data-node="${n.uid}"]`);
189
+ function setCurrent(f, updateUrl = true) {
190
+ if (!f) return;
191
+ current = f;
192
+ $("current-flow-title").textContent = f.title;
193
+ $("current-flow-description").textContent = f.description;
194
+ $("flow-position").textContent =
195
+ `FLOW ${String(f.index + 1).padStart(2, "0")} / ${flows.length} · ${f.group.toLocaleUpperCase("en")}`;
196
+ sidebar();
197
+ emit("flowchange", { flowId: f.id });
198
+ if (updateUrl && options.syncUrl) {
199
+ const url = new URL(location.href);
200
+ url.searchParams.set("flow", f.id);
201
+ url.searchParams.delete("screen");
202
+ history.replaceState(history.state, "", url);
203
+ }
204
+ }
205
+ function visibleFrames() {
206
+ const width = viewport.clientWidth,
207
+ height = viewport.clientHeight;
208
+ const candidates = nodes
209
+ .filter((n) => n.screenId)
210
+ .map((n) => ({ n, left: n.x * view.z + view.x, top: n.y * view.z + view.y }))
211
+ .filter(
212
+ ({ n, left, top }) =>
213
+ view.z >= previewThreshold &&
214
+ left + n.width * view.z > -150 &&
215
+ left < width + 150 &&
216
+ top + n.height * view.z > -150 &&
217
+ top < height + 150,
218
+ )
219
+ .sort(
220
+ (a, b) =>
221
+ Math.hypot(a.left - width / 2, a.top - height / 2) -
222
+ Math.hypot(b.left - width / 2, b.top - height / 2),
223
+ )
224
+ .slice(0, maxPreviews);
225
+ const wanted = new Set(candidates.map(({ n }) => n.uid));
226
+ for (const n of nodes) {
227
+ const preview = n.element.querySelector(".node-preview");
228
+ if (!preview) continue;
229
+ const frame = preview.querySelector("iframe");
230
+ if (wanted.has(n.uid) && !frame) {
231
+ const el = createFrame(n);
232
+ el.title = `HTML ${n.screenId} · ${n.title}`;
233
+ el.tabIndex = -1;
234
+ el.setAttribute("aria-hidden", "true");
235
+ preview.append(el);
236
+ } else if (!wanted.has(n.uid) && frame) frame.remove();
237
+ }
238
+ }
239
+ function minimap() {
240
+ const f = current,
241
+ scale = Math.min(152 / f.width, 92 / f.height),
242
+ ox = (168 - f.width * scale) / 2,
243
+ oy = (114 - f.height * scale) / 2;
244
+ minimapGeometry = { scale, ox, oy };
245
+ const x = (-view.x / view.z) * scale + ox,
246
+ y = (-view.y / view.z - f.y) * scale + oy,
247
+ w = (viewport.clientWidth / view.z) * scale,
248
+ h = (viewport.clientHeight / view.z) * scale;
249
+ $("minimap").innerHTML =
250
+ `<defs><clipPath id="mini-clip"><rect x="${ox}" y="${oy}" width="${f.width * scale}" height="${f.height * scale}"></rect></clipPath></defs>${f.nodes.map((n) => `<rect class="minimap-node ${n.key === f.start ? "start" : ""}" x="${n.x * scale + ox}" y="${(n.y - f.y) * scale + oy}" width="${n.width * scale}" height="${n.height * scale}" rx="1"></rect>`).join("")}<rect class="minimap-window" x="${x}" y="${y}" width="${w}" height="${h}" clip-path="url(#mini-clip)"></rect>`;
251
+ }
252
+ function paint() {
253
+ frameRequest = 0;
254
+ if (destroyed) return;
255
+ world.style.transform = `translate(${view.x}px,${view.y}px) scale(${view.z})`;
256
+ viewport.style.backgroundSize = `${Math.max(20, 24 * view.z)}px ${Math.max(20, 24 * view.z)}px`;
257
+ viewport.style.backgroundPosition = `${view.x}px ${view.y}px`;
258
+ $("zoom-reset").textContent = Math.round(view.z * 100) + "%";
259
+ viewport.dataset.zoom = String(view.z);
260
+ viewport.dataset.panX = String(view.x);
261
+ viewport.dataset.panY = String(view.y);
262
+ visibleFrames();
263
+ minimap();
264
+ emit("viewportchange", { ...view });
265
+ }
266
+ function schedule() {
267
+ if (!destroyed && !frameRequest) frameRequest = requestAnimationFrame(paint);
268
+ }
269
+ function zoomTo(z, cx = viewport.clientWidth / 2, cy = viewport.clientHeight / 2) {
270
+ z = Math.max(0.18, Math.min(2.2, z));
271
+ const wx = (cx - view.x) / view.z,
272
+ wy = (cy - view.y) / view.z;
273
+ view = { x: cx - wx * z, y: cy - wy * z, z };
274
+ schedule();
275
+ }
276
+ function focusNode(n, resetZoom = false) {
277
+ if (resetZoom) view.z = viewport.clientWidth < 700 ? 0.68 : 0.8;
278
+ view.x = Math.min(64, viewport.clientWidth * 0.08) - n.x * view.z;
279
+ view.y = (viewport.clientWidth < 700 ? 166 : 218) - n.y * view.z;
280
+ schedule();
281
+ }
282
+ function fitFlow() {
283
+ const f = current,
284
+ top = viewport.clientWidth < 700 ? 146 : 185,
285
+ bottom = 80,
286
+ availableW = viewport.clientWidth - 64,
287
+ availableH = viewport.clientHeight - top - bottom,
288
+ z = Math.max(0.18, Math.min(1.1, availableW / f.width, availableH / f.height));
289
+ view = {
290
+ z,
291
+ x: (viewport.clientWidth - f.width * z) / 2,
292
+ y: top + (availableH - f.height * z) / 2 - f.y * z,
293
+ };
294
+ schedule();
295
+ }
296
+ function chooseFlow(id) {
297
+ const f = flowById.get(id);
298
+ if (!f) return;
299
+ clearSelection();
300
+ setCurrent(f);
301
+ const query = normalize($("flow-search").value),
302
+ match = query
303
+ ? f.nodes.find((n) => normalize(`${n.screenId || ""} ${n.title}`).includes(query))
304
+ : null;
305
+ focusNode(match || nodeByKey.get(`${f.id}:${f.start}`), true);
306
+ shell.classList.remove("sidebar-open");
307
+ $("map-menu").setAttribute("aria-expanded", "false");
308
+ }
309
+ function clearSelection() {
310
+ selected = null;
311
+ world.classList.remove("has-selection");
312
+ root
313
+ .querySelectorAll(".flow-node.selected,.flow-node.related-node")
314
+ .forEach((el) => el.classList.remove("selected", "related-node"));
315
+ root.querySelectorAll(".flow-edge.related").forEach((el) => el.classList.remove("related"));
316
+ $("node-details").hidden = true;
317
+ }
318
+ function selectNode(n) {
319
+ clearSelection();
320
+ selected = n;
321
+ setCurrent(flowById.get(n.flowId));
322
+ n.element.classList.add("selected");
323
+ world.classList.add("has-selection");
324
+ const f = current,
325
+ edges = f.edges.filter((a) => a.from === n.key),
326
+ connected = new Set();
327
+ for (const el of root.querySelectorAll(".flow-edge"))
328
+ if (el.dataset.from === n.uid || el.dataset.to === n.uid) {
329
+ el.classList.add("related");
330
+ connected.add(el.dataset.from);
331
+ connected.add(el.dataset.to);
332
+ }
333
+ for (const key of connected) nodeByKey.get(key)?.element.classList.add("related-node");
334
+ $("node-details").innerHTML =
335
+ `<div class="detail-head"><div class="grow"><small>${e(n.screenId || "Process step")}</small><h3>${e(n.title)}</h3></div><button class="icon-button" data-clear-selection aria-label="Clear selection">${icon("x")}</button></div><h4>Next steps</h4>${
336
+ edges.length
337
+ ? edges
338
+ .map((a) => {
339
+ const to = nodeByKey.get(`${f.id}:${a.to}`);
340
+ return `<button class="detail-edge" data-focus="${to.uid}"><strong>${e(a.label)} →</strong><span>${e(to.title)}${to.screenId ? " · " + to.screenId : ""}</span></button>`;
341
+ })
342
+ .join("")
343
+ : "<p>This is the end of this flow.</p>"
344
+ }${n.screenId ? `<button data-open="${n.uid}" data-ui="action" data-variant="primary">Try this screen</button>` : `<p>${e(n.description)}</p>${n.url ? `<a data-ui="action" data-variant="secondary" href="${e(n.url)}" target="_blank" rel="noopener noreferrer">Open reference ${icon("arrow-up-right")}</a>` : ""}`}`;
345
+ $("node-details").hidden = false;
346
+ emit("select", { flowId: n.flowId, nodeId: n.id });
347
+ minimap();
348
+ }
349
+ let previewNode = null;
350
+ function resizePreview() {
351
+ if (!previewNode || !$("screen-dialog").open) return;
352
+ const v = previewNode.screen.viewport,
353
+ dialog = $("screen-dialog"),
354
+ frame = $("dialog-preview").querySelector("iframe");
355
+ const scale = Math.min(1, dialog.clientWidth / v.width);
356
+ $("dialog-preview").style.width = v.width * scale + "px";
357
+ $("dialog-preview").style.height = v.height * scale + "px";
358
+ frame.style.width = v.width + "px";
359
+ frame.style.height = v.height + "px";
360
+ frame.style.transform = `scale(${scale})`;
361
+ frame.style.transformOrigin = "top left";
362
+ }
363
+ function openScreen(n) {
364
+ if (!n.screenId) {
365
+ selectNode(n);
366
+ return;
367
+ }
368
+ previewNode = n;
369
+ $("dialog-screen-id").textContent = n.screenId;
370
+ $("dialog-title").textContent = n.title;
371
+ $("dialog-open").href = n.url;
372
+ const frame = createFrame(n);
373
+ frame.title = `Try ${n.title}`;
374
+ $("dialog-preview").replaceChildren(frame);
375
+ const dialog = $("screen-dialog");
376
+ dialog.style.setProperty("--dialog-width", n.screen.viewport.width + "px");
377
+ dialog.showModal();
378
+ resizePreview();
379
+ emit("previewopen", { flowId: n.flowId, nodeId: n.id, screenId: n.screenId });
380
+ }
381
+ function point(event) {
382
+ const r = viewport.getBoundingClientRect();
383
+ return { x: event.clientX - r.left, y: event.clientY - r.top };
384
+ }
385
+ function syncCurrentFromPan() {
386
+ const worldY = (viewport.clientHeight / 2 - view.y) / view.z,
387
+ closest = flows.reduce((a, b) =>
388
+ Math.abs(a.y + a.height / 2 - worldY) < Math.abs(b.y + b.height / 2 - worldY) ? a : b,
389
+ );
390
+ if (closest !== current && !selected) setCurrent(closest);
391
+ }
392
+ viewport.addEventListener("pointerdown", (event) => {
393
+ if (
394
+ event.button !== 0 ||
395
+ event.target.closest("button,a,.map-context,.node-details,.map-minimap,.map-bottom")
396
+ )
397
+ return;
398
+ event.preventDefault();
399
+ viewport.focus({ preventScroll: true });
400
+ (event.target.closest("[data-node]") || viewport).setPointerCapture(event.pointerId);
401
+ pointers.set(event.pointerId, point(event));
402
+ if (pointers.size === 2) {
403
+ const [a, b] = [...pointers.values()];
404
+ pinch = {
405
+ distance: Math.hypot(a.x - b.x, a.y - b.y),
406
+ z: view.z,
407
+ worldX: ((a.x + b.x) / 2 - view.x) / view.z,
408
+ worldY: ((a.y + b.y) / 2 - view.y) / view.z,
409
+ };
410
+ drag = null;
411
+ } else drag = { id: event.pointerId, start: point(event), x: view.x, y: view.y, moved: false };
412
+ });
413
+ viewport.addEventListener("pointermove", (event) => {
414
+ if (!pointers.has(event.pointerId)) return;
415
+ pointers.set(event.pointerId, point(event));
416
+ if (pointers.size >= 2 && pinch) {
417
+ const [a, b] = [...pointers.values()],
418
+ z = Math.max(
419
+ 0.18,
420
+ Math.min(2.2, (pinch.z * Math.hypot(a.x - b.x, a.y - b.y)) / Math.max(1, pinch.distance)),
421
+ );
422
+ view = { z, x: (a.x + b.x) / 2 - pinch.worldX * z, y: (a.y + b.y) / 2 - pinch.worldY * z };
423
+ suppressClick = true;
424
+ schedule();
425
+ return;
426
+ }
427
+ if (!drag || drag.id !== event.pointerId) return;
428
+ const p = point(event),
429
+ dx = p.x - drag.start.x,
430
+ dy = p.y - drag.start.y;
431
+ if (Math.hypot(dx, dy) > 4) drag.moved = true;
432
+ if (drag.moved) {
433
+ viewport.classList.add("dragging");
434
+ view.x = drag.x + dx;
435
+ view.y = drag.y + dy;
436
+ schedule();
437
+ }
438
+ });
439
+ function pointerEnd(event) {
440
+ if (drag?.moved) suppressClick = true;
441
+ pointers.delete(event.pointerId);
442
+ pinch = null;
443
+ drag = null;
444
+ if (pointers.size === 1) {
445
+ const [id, position] = [...pointers.entries()][0];
446
+ drag = { id, start: position, x: view.x, y: view.y, moved: false };
447
+ }
448
+ viewport.classList.remove("dragging");
449
+ if (event.target.hasPointerCapture?.(event.pointerId))
450
+ event.target.releasePointerCapture(event.pointerId);
451
+ syncCurrentFromPan();
452
+ schedule();
453
+ setTimeout(() => {
454
+ suppressClick = false;
455
+ }, 0);
456
+ }
457
+ viewport.addEventListener("pointerup", pointerEnd);
458
+ viewport.addEventListener("pointercancel", pointerEnd);
459
+ viewport.addEventListener(
460
+ "wheel",
461
+ (event) => {
462
+ if (event.target.closest(".node-details,.map-minimap,.map-zoom")) return;
463
+ event.preventDefault();
464
+ if (event.ctrlKey || event.metaKey) {
465
+ const p = point(event);
466
+ zoomTo(view.z * Math.exp(-event.deltaY * 0.002), p.x, p.y);
467
+ } else {
468
+ view.x -= event.deltaX;
469
+ view.y -= event.deltaY;
470
+ syncCurrentFromPan();
471
+ schedule();
472
+ }
473
+ },
474
+ { passive: false },
475
+ );
476
+ viewport.addEventListener("keydown", (event) => {
477
+ if (event.target.closest("button,a,input") || $("screen-dialog").open) return;
478
+ const step = event.shiftKey ? 180 : 80;
479
+ if (event.key === "ArrowLeft") view.x += step;
480
+ else if (event.key === "ArrowRight") view.x -= step;
481
+ else if (event.key === "ArrowUp") view.y += step;
482
+ else if (event.key === "ArrowDown") view.y -= step;
483
+ else if (["+", "="].includes(event.key)) {
484
+ zoomTo(view.z * 1.2);
485
+ event.preventDefault();
486
+ return;
487
+ } else if (event.key === "-") {
488
+ zoomTo(view.z / 1.2);
489
+ event.preventDefault();
490
+ return;
491
+ } else if (event.key.toLowerCase() === "f") {
492
+ fitFlow();
493
+ event.preventDefault();
494
+ return;
495
+ } else if (event.key === "0") {
496
+ focusNode(nodeByKey.get(`${current.id}:${current.start}`), true);
497
+ event.preventDefault();
498
+ return;
499
+ } else if (event.key === "Escape") {
500
+ clearSelection();
501
+ return;
502
+ } else if (event.key === "Enter" && event.target.dataset.node) {
503
+ openScreen(nodeByKey.get(event.target.dataset.node));
504
+ return;
505
+ } else return;
506
+ event.preventDefault();
507
+ syncCurrentFromPan();
508
+ schedule();
509
+ });
510
+ root.addEventListener("click", (event) => {
511
+ const b = event.target.closest("button");
512
+ if (b?.dataset.flow) {
513
+ chooseFlow(b.dataset.flow);
514
+ return;
515
+ }
516
+ if (b?.dataset.open) {
517
+ openScreen(nodeByKey.get(b.dataset.open));
518
+ return;
519
+ }
520
+ if (b?.dataset.focus) {
521
+ const n = nodeByKey.get(b.dataset.focus);
522
+ focusNode(n);
523
+ selectNode(n);
524
+ return;
525
+ }
526
+ if (b?.hasAttribute("data-clear-selection")) {
527
+ clearSelection();
528
+ return;
529
+ }
530
+ const n = event.target.closest("[data-node]");
531
+ if (n && !suppressClick) {
532
+ selectNode(nodeByKey.get(n.dataset.node));
533
+ return;
534
+ }
535
+ if (event.target === viewport || event.target === world) clearSelection();
536
+ });
537
+ viewport.addEventListener("dblclick", (event) => {
538
+ const n = event.target.closest("[data-node]");
539
+ if (n && !event.target.closest("button")) openScreen(nodeByKey.get(n.dataset.node));
540
+ });
541
+ $("flow-search").addEventListener("input", sidebar);
542
+ $("zoom-in").onclick = () => zoomTo(view.z * 1.2);
543
+ $("zoom-out").onclick = () => zoomTo(view.z / 1.2);
544
+ $("zoom-reset").onclick = () => zoomTo(1);
545
+ $("zoom-fit").onclick = fitFlow;
546
+ $("minimap-fit").onclick = fitFlow;
547
+ $("flow-start").onclick = () => {
548
+ clearSelection();
549
+ focusNode(nodeByKey.get(`${current.id}:${current.start}`), true);
550
+ };
551
+ $("minimap").addEventListener("pointerdown", (event) => {
552
+ event.stopPropagation();
553
+ const rect = $("minimap").getBoundingClientRect(),
554
+ x = ((event.clientX - rect.left) * 168) / rect.width,
555
+ y = ((event.clientY - rect.top) * 114) / rect.height;
556
+ view.x = viewport.clientWidth / 2 - ((x - minimapGeometry.ox) / minimapGeometry.scale) * view.z;
557
+ view.y =
558
+ viewport.clientHeight / 2 -
559
+ ((y - minimapGeometry.oy) / minimapGeometry.scale + current.y) * view.z;
560
+ schedule();
561
+ });
562
+ $("map-menu").onclick = () => {
563
+ const open = shell.classList.toggle("sidebar-open");
564
+ $("map-menu").setAttribute("aria-expanded", String(open));
565
+ };
566
+ $("map-help").onclick = () => $("help-dialog").showModal();
567
+ $("close-help").onclick = () => $("help-dialog").close();
568
+ $("close-screen").onclick = () => $("screen-dialog").close();
569
+ $("screen-dialog").addEventListener("close", () => {
570
+ previewNode = null;
571
+ $("dialog-preview").replaceChildren();
572
+ });
573
+ for (const [id, name] of [
574
+ ["map-menu", "layout-grid"],
575
+ ["map-help", "circle-help"],
576
+ ["zoom-in", "plus"],
577
+ ["zoom-out", "minus"],
578
+ ["zoom-fit", "expand"],
579
+ ["close-screen", "x"],
580
+ ])
581
+ $(id).innerHTML = icon(name);
582
+ const observer = new ResizeObserver(() => {
583
+ schedule();
584
+ resizePreview();
585
+ });
586
+ observer.observe(viewport);
587
+ observer.observe($("screen-dialog"));
588
+ const query = new URLSearchParams(options.syncUrl ? location.search : ""),
589
+ requested = query.get("screen"),
590
+ initialNode = requested ? nodes.find((n) => n.screenId === requested) : null;
591
+ current = initialNode
592
+ ? flowById.get(initialNode.flowId)
593
+ : flowById.get(options.initialFlow || query.get("flow")) || flows[0];
594
+ setCurrent(current);
595
+ focusNode(initialNode || nodeByKey.get(`${current.id}:${current.start}`), true);
596
+ paint();
597
+ const ready = stylesReady.then(() => {
598
+ if (destroyed) return;
599
+ host.style.visibility = "";
600
+ focusNode(initialNode || nodeByKey.get(`${current.id}:${current.start}`), true);
601
+ paint();
602
+ });
603
+ const requireNode = (flowId, nodeId) => {
604
+ const n = nodeByKey.get(`${flowId}:${nodeId}`);
605
+ if (!n) throw new RangeError(`Unknown node: ${flowId}/${nodeId}`);
606
+ return n;
607
+ };
608
+ const active = () => {
609
+ if (destroyed) throw new Error("This atlas has been destroyed.");
610
+ };
611
+ return {
612
+ element: host,
613
+ ready,
614
+ goToFlow(id) {
615
+ active();
616
+ if (!flowById.has(id)) throw new RangeError(`Unknown flow: ${id}`);
617
+ $("flow-search").value = "";
618
+ chooseFlow(id);
619
+ },
620
+ focusNode(flowId, nodeId) {
621
+ active();
622
+ const n = requireNode(flowId, nodeId);
623
+ setCurrent(flowById.get(flowId));
624
+ focusNode(n, true);
625
+ selectNode(n);
626
+ },
627
+ openNode(flowId, nodeId) {
628
+ active();
629
+ openScreen(requireNode(flowId, nodeId));
630
+ },
631
+ fit() {
632
+ active();
633
+ fitFlow();
634
+ },
635
+ zoomTo(value) {
636
+ active();
637
+ if (!Number.isFinite(value)) throw new TypeError("Zoom must be a finite number.");
638
+ zoomTo(value);
639
+ },
640
+ getState() {
641
+ return {
642
+ flowId: current.id,
643
+ selectedNodeId: selected?.id ?? null,
644
+ zoom: view.z,
645
+ pan: { x: view.x, y: view.y },
646
+ flowCount: flows.length,
647
+ nodeCount: nodes.length,
648
+ screenCount: screens.size,
649
+ destroyed,
650
+ };
651
+ },
652
+ destroy() {
653
+ if (destroyed) return;
654
+ destroyed = true;
655
+ observer.disconnect();
656
+ cancelAnimationFrame(frameRequest);
657
+ for (const dialog of root.querySelectorAll("dialog[open]")) dialog.close();
658
+ host.remove();
659
+ },
660
+ };
661
+ }
662
+ /** Load relative screen URLs against the JSON file, not the embedding page. */
663
+ export async function loadAtlas(container, url, options = {}) {
664
+ const absolute = safeURL(String(url), document.baseURI);
665
+ const response = await fetch(absolute, { signal: options.signal });
666
+ if (!response.ok) throw new Error(`Cannot load atlas: HTTP ${response.status} (${absolute})`);
667
+ const config = await response.json();
668
+ const atlas = createAtlas(container, config, {
669
+ ...options,
670
+ baseURL: options.baseURL || response.url || absolute,
671
+ });
672
+ try {
673
+ await atlas.ready;
674
+ return atlas;
675
+ } catch (error) {
676
+ atlas.destroy();
677
+ throw error;
678
+ }
679
+ }