// Ascension M+ Route Planner — vanilla JS, single-file SPA. // Renders keystone.guru-derived data (enemies, packs, patrols, icons) on // stitched z=4 dungeon tiles. All overlays live in image-pixel space; the // canvas-stage CSS transform handles pan/zoom uniformly. const SVG_NS = "http://www.w3.org/2000/svg"; const state = { dungeons: [], current: null, floorIndex: 0, tool: "route", // "route" | "pull" | "note" | "text" routes: {}, // { key: [{x,y}] } image-pixel coords pulls: {}, // { key: [{x,y}] } notes: {}, // { key: [{x,y,text}] } — `i` pin, hover-only texts: {}, // { key: [{x,y,text}] } — always-visible label, no icon history: [], drag: null, view: { scale: 1, tx: 0, ty: 0 }, pan: null, show: { enemies: true, packs: true, patrols: true, icons: true }, }; const $ = (id) => document.getElementById(id); async function init() { const r = await fetch("assets/dungeons.json", { cache: "no-cache" }); state.dungeons = (await r.json()).dungeons; populateExpansionFilter(); renderDungeonList(); hookEvents(); loadFromHash(); if (!state.current && state.dungeons.length) { selectDungeon(state.dungeons[0].id); } } function populateExpansionFilter() { const sel = $("expansion-filter"); sel.innerHTML = ''; // Classic-only deployment — keep the dropdown as a no-op for now. } function renderDungeonList() { const ul = $("dungeon-list"); const q = $("search").value.trim().toLowerCase(); ul.innerHTML = ""; for (const d of state.dungeons.filter((d) => !q || d.name.toLowerCase().includes(q) || (d.acronym || "").toLowerCase().includes(q) )) { const li = document.createElement("li"); li.dataset.id = d.id; if (state.current && d.id === state.current.id) li.classList.add("active"); const name = document.createElement("span"); name.textContent = d.name; const acr = document.createElement("span"); acr.className = "acronym"; acr.textContent = d.acronym || ""; li.append(name, acr); li.onclick = () => selectDungeon(d.id); ul.appendChild(li); } } function selectDungeon(id) { const d = state.dungeons.find((x) => x.id === id); if (!d) return; state.current = d; state.floorIndex = 0; renderDungeonList(); renderViewer(); updateHash(); } function currentKey() { return state.current ? `${state.current.id}::${state.floorIndex}` : null; } function currentMap() { return state.current?.maps[state.floorIndex] || null; } function renderViewer() { const d = state.current; if (!d) return; $("dungeon-name").textContent = d.name; const m = currentMap(); const meta = [ d.acronym, d.levelRange && `Lv ${d.levelRange}`, m && `${m.enemies?.length ?? 0} enemies`, m && `${m.packs?.length ?? 0} packs`, ].filter(Boolean).join(" · "); $("dungeon-meta").textContent = meta; const tabs = $("floor-tabs"); tabs.innerHTML = ""; if (d.maps.length > 1) { d.maps.forEach((mp, i) => { const b = document.createElement("button"); b.textContent = humanizeLabel(mp.label); if (i === state.floorIndex) b.classList.add("active"); b.onclick = () => { state.floorIndex = i; renderViewer(); updateHash(); }; tabs.appendChild(b); }); } const img = $("map-img"); if (m) { img.src = "assets/" + m.image; img.width = m.width; img.height = m.height; const svg = $("overlay"); svg.setAttribute("width", m.width); svg.setAttribute("height", m.height); img.onload = () => { fitToScreen(); renderOverlay(); }; if (img.complete) fitToScreen(); } else { img.removeAttribute("src"); } renderOverlay(); renderInfoPane(); } function humanizeLabel(s) { return s .replace(/_floor(\d+)$/, " · floor $1") .replace(/_/g, " ") .replace(/\b\w/g, (c) => c.toUpperCase()); } /* --- overlay rendering --------------------------------------------------- */ function renderOverlay() { const svg = $("overlay"); svg.innerHTML = ""; const m = currentMap(); if (!m) return; svg.setAttribute("viewBox", `0 0 ${m.width} ${m.height}`); svg.setAttribute("preserveAspectRatio", "none"); const key = currentKey(); const wps = state.routes[key] || []; const pulls = state.pulls[key] || []; // Pack polygons (under enemies) if (state.show.packs && m.packs) { for (const p of m.packs) { if (p.vertices.length < 3) continue; const poly = document.createElementNS(SVG_NS, "polygon"); poly.setAttribute("class", "pack"); poly.setAttribute("points", p.vertices.map(([x, y]) => `${x},${y}`).join(" ")); poly.setAttribute("fill", p.color); poly.setAttribute("fill-opacity", "0.18"); poly.setAttribute("stroke", p.color); poly.setAttribute("stroke-width", "4"); poly.setAttribute("stroke-opacity", "0.65"); svg.appendChild(poly); } } // Patrol polylines if (state.show.patrols && m.patrols) { for (const pa of m.patrols) { if (pa.vertices.length < 2) continue; const line = document.createElementNS(SVG_NS, "polyline"); line.setAttribute("class", "patrol"); line.setAttribute("points", pa.vertices.map(([x, y]) => `${x},${y}`).join(" ")); line.setAttribute("fill", "none"); line.setAttribute("stroke", pa.color); line.setAttribute("stroke-width", String((pa.weight || 3) * 4)); line.setAttribute("stroke-dasharray", "12 8"); line.setAttribute("opacity", "0.7"); svg.appendChild(line); } } // Enemies (smallest pins first; bosses on top) if (state.show.enemies && m.enemies) { const ranked = [...m.enemies].sort( (a, b) => (a.classification || 0) - (b.classification || 0) ); for (const e of ranked) { svg.appendChild(makeEnemyPin(e)); } } // Map icons (skull, comment, door, etc.) if (state.show.icons && m.icons) { for (const ic of m.icons) { svg.appendChild(makeIconMarker(ic)); } } // AtlasLoot-derived extras: render only ones that match the current // floor. Single-floor dungeons have kg_floor_id null on every extra, // so they all render on floor 0. if (state.current?.extras) { const curFloor = m.kg_floor_id; for (const ex of state.current.extras) { // If the extra has no floor assignment, show only on floor 0. // If it does, show on the matching floor only. const fid = ex.kg_floor_id; const matches = fid == null ? state.floorIndex === 0 : fid === curFloor; if (!matches) continue; svg.appendChild(makeExtraPin(ex)); } } // Route polyline if (wps.length > 1) { const path = document.createElementNS(SVG_NS, "polyline"); path.setAttribute("class", "route-line"); path.setAttribute("points", wps.map((p) => `${p.x},${p.y}`).join(" ")); svg.appendChild(path); } // Pull markers + waypoints (above everything) pulls.forEach((p, i) => svg.appendChild(makeUserPin(p.x, p.y, i + 1, "pull", "pull", i))); wps.forEach((p, i) => svg.appendChild(makeUserPin(p.x, p.y, i + 1, "waypoint", "route", i))); // User notes (info icons with hover tooltip) const notes = state.notes[key] || []; notes.forEach((n, i) => svg.appendChild(makeNotePin(n, i))); // Always-visible freetext labels const texts = state.texts[key] || []; texts.forEach((it, i) => svg.appendChild(makeTextLabel(it, i))); svg.onclick = onCanvasClick; } function makeNotePin(note, idx) { const g = document.createElementNS(SVG_NS, "g"); g.setAttribute("class", "note"); g.setAttribute("transform", `translate(${note.x},${note.y})`); // Yellow info pin with "i" glyph const c = document.createElementNS(SVG_NS, "circle"); c.setAttribute("r", 22); c.setAttribute("fill", "#f0c674"); c.setAttribute("stroke", "#1a1208"); c.setAttribute("stroke-width", "3"); g.appendChild(c); const i = document.createElementNS(SVG_NS, "text"); i.setAttribute("y", 9); i.setAttribute("font-size", "26"); i.setAttribute("text-anchor", "middle"); i.setAttribute("fill", "#1a1208"); i.setAttribute("font-weight", "900"); i.setAttribute("font-style", "italic"); i.setAttribute("font-family", "Georgia, serif"); i.textContent = "i"; g.appendChild(i); // Custom-tooltip data — text only shows on hover for the (i) note. g.dataset.tooltip = note.text || "(empty)"; // drag-to-move + right-click delete + double-click to edit text // Click on a pin must NOT bubble to the SVG (which would add a waypoint // / pull / etc. at the same spot, depending on the active tool). g.addEventListener("click", (e) => e.stopPropagation()); g.addEventListener("pointerdown", (e) => { e.stopPropagation(); state.drag = { kind: "note", idx, moved: false, downX: e.clientX, downY: e.clientY }; g.classList.add("dragging"); g.setPointerCapture(e.pointerId); }); g.addEventListener("pointermove", (e) => { if (!state.drag || state.drag.kind !== "note" || state.drag.idx !== idx) return; if (Math.hypot(e.clientX - state.drag.downX, e.clientY - state.drag.downY) > 3) { state.drag.moved = true; } const pt = svgPointFromEvent(e); const arr = state.notes[currentKey()]; arr[idx].x = pt.x; arr[idx].y = pt.y; g.setAttribute("transform", `translate(${pt.x},${pt.y})`); }); g.addEventListener("pointerup", () => { if (!state.drag) return; const moved = state.drag.moved; state.drag = null; g.classList.remove("dragging"); if (moved) { pushHistory(); updateHash(); } }); g.addEventListener("contextmenu", (e) => { e.preventDefault(); e.stopPropagation(); removeNote(idx); }); // Double-click → edit. Works regardless of active tool because the // pin captures click + dblclick before the canvas click handler runs. g.addEventListener("dblclick", (e) => { e.preventDefault(); e.stopPropagation(); editNote(idx); }); return g; } function editNote(idx) { const cur = state.notes[currentKey()]?.[idx]; if (!cur) return; const txt = prompt("Edit note text:", cur.text || ""); if (txt === null) return; cur.text = txt; pushHistory(); renderOverlay(); updateHash(); } function removeNote(idx) { const key = currentKey(); const arr = state.notes[key]; if (!arr) return; arr.splice(idx, 1); pushHistory(); renderOverlay(); updateHash(); } /* --- always-visible freetext labels -------------------------------------- */ function makeTextLabel(item, idx) { const g = document.createElementNS(SVG_NS, "g"); g.setAttribute("class", "freetext"); g.setAttribute("transform", `translate(${item.x},${item.y})`); const text = item.text || ""; // Background plate so light/dark areas don't both kill readability. // We measure after first paint via getBBox; provide an initial backdrop // via stroke on the text itself (paint-order: stroke). const t = document.createElementNS(SVG_NS, "text"); t.setAttribute("class", "freetext-label"); t.setAttribute("font-size", "32"); t.setAttribute("font-family", "system-ui, sans-serif"); t.setAttribute("font-weight", "700"); t.setAttribute("fill", "#fff7d6"); t.setAttribute("stroke", "#1a1208"); t.setAttribute("stroke-width", "6"); t.setAttribute("paint-order", "stroke"); t.setAttribute("alignment-baseline", "middle"); t.setAttribute("dominant-baseline", "middle"); t.setAttribute("y", 8); t.textContent = text || "(empty)"; g.appendChild(t); // Drag handle — invisible rect over the text bounding region. We can't // size it without measurement, so use a roomy default (will be replaced // by getBBox after insertion). const hit = document.createElementNS(SVG_NS, "rect"); hit.setAttribute("class", "freetext-hit"); hit.setAttribute("x", -4); hit.setAttribute("y", -22); hit.setAttribute("width", Math.max(40, text.length * 16)); hit.setAttribute("height", 44); hit.setAttribute("fill", "transparent"); g.appendChild(hit); // Stop click propagating so the canvas's tool handler doesn't fire when // the user is interacting with this label (otherwise editing in Route // mode would also drop a waypoint underneath). g.addEventListener("click", (e) => e.stopPropagation()); g.addEventListener("pointerdown", (e) => { e.stopPropagation(); state.drag = { kind: "text", idx, moved: false, downX: e.clientX, downY: e.clientY }; g.classList.add("dragging"); g.setPointerCapture(e.pointerId); }); g.addEventListener("pointermove", (e) => { if (!state.drag || state.drag.kind !== "text" || state.drag.idx !== idx) return; if (Math.hypot(e.clientX - state.drag.downX, e.clientY - state.drag.downY) > 3) { state.drag.moved = true; } const pt = svgPointFromEvent(e); const arr = state.texts[currentKey()]; arr[idx].x = pt.x; arr[idx].y = pt.y; g.setAttribute("transform", `translate(${pt.x},${pt.y})`); }); g.addEventListener("pointerup", () => { if (!state.drag) return; const moved = state.drag.moved; state.drag = null; g.classList.remove("dragging"); if (moved) { pushHistory(); updateHash(); } }); g.addEventListener("contextmenu", (e) => { e.preventDefault(); e.stopPropagation(); removeText(idx); }); g.addEventListener("dblclick", (e) => { e.preventDefault(); e.stopPropagation(); editText(idx); }); return g; } function removeText(idx) { const key = currentKey(); const arr = state.texts[key]; if (!arr) return; arr.splice(idx, 1); pushHistory(); renderOverlay(); updateHash(); } function editText(idx) { const cur = state.texts[currentKey()]?.[idx]; if (!cur) return; const txt = prompt("Edit label:", cur.text || ""); if (txt === null) return; cur.text = txt; pushHistory(); renderOverlay(); updateHash(); } // kg classification IDs: // 1 = minor (NPCs, ambient mobs) // 2 = standard trash // 3 = named boss // 4 = final / "raid" boss // 5 = rare elite (silver-dragon) const CLASS_RADIUS = { 1: 14, 2: 18, 3: 28, 4: 38, 5: 24 }; const CLASS_FILL = { 1: "#9aa1aa", 2: "#d6d6dc", 3: "#d63b3b", 4: "#ffd83a", 5: "#bfd6f0", }; function isBossLike(cls) { return cls === 3 || cls === 4 || cls === 5; } function makeEnemyPin(e) { const cls = e.classification || 1; const r = CLASS_RADIUS[cls] ?? 14; const fill = CLASS_FILL[cls] ?? "#9aa1aa"; const g = document.createElementNS(SVG_NS, "g"); g.setAttribute("class", `enemy enemy-c${cls}`); g.setAttribute("transform", `translate(${e.pos[0]},${e.pos[1]})`); const c = document.createElementNS(SVG_NS, "circle"); c.setAttribute("r", r); c.setAttribute("fill", fill); c.setAttribute("stroke", cls === 5 ? "#3b6db0" : "#000"); c.setAttribute("stroke-width", cls === 5 ? "4" : "3"); g.appendChild(c); if (isBossLike(cls)) { const t = document.createElementNS(SVG_NS, "text"); t.setAttribute("y", r * 0.35); t.setAttribute("font-size", String(Math.round(r * 1.6))); t.setAttribute("text-anchor", "middle"); t.setAttribute("fill", cls === 5 ? "#1a3360" : "#1a0000"); t.setAttribute("font-weight", "900"); t.textContent = "☠"; g.appendChild(t); } const tag = cls === 5 ? " (rare)" : (e.skippable ? " (skippable)" : ""); g.dataset.tooltip = e.name + tag; return g; } function makeExtraPin(ex) { // Silver-blue ring with skull glyph for rares; muted square for non-rare // interactives (postboxes, summon spots, etc.). Strip the trailing // "(Rare)" / "(Rare, Wanders)" tag from the displayed tooltip — we // surface "rare" via the visual treatment. const g = document.createElementNS(SVG_NS, "g"); g.setAttribute("class", ex.rare ? "extra rare" : "extra"); g.setAttribute("transform", `translate(${ex.pos[0]},${ex.pos[1]})`); if (ex.rare) { const c = document.createElementNS(SVG_NS, "circle"); c.setAttribute("r", 26); c.setAttribute("fill", "#bfd6f0"); c.setAttribute("stroke", "#3b6db0"); c.setAttribute("stroke-width", "5"); g.appendChild(c); const t = document.createElementNS(SVG_NS, "text"); t.setAttribute("y", 11); t.setAttribute("font-size", "30"); t.setAttribute("text-anchor", "middle"); t.setAttribute("fill", "#1a3360"); t.setAttribute("font-weight", "900"); t.textContent = "☠"; g.appendChild(t); } else { const r = document.createElementNS(SVG_NS, "rect"); r.setAttribute("x", -10); r.setAttribute("y", -10); r.setAttribute("width", 20); r.setAttribute("height", 20); r.setAttribute("fill", "#7e8290"); r.setAttribute("stroke", "#1a1208"); r.setAttribute("stroke-width", "2"); g.appendChild(r); } g.dataset.tooltip = ex.name + (ex.rare ? "" : ""); return g; } function makeIconMarker(ic) { const g = document.createElementNS(SVG_NS, "g"); g.setAttribute("class", `icon icon-${ic.type}`); g.setAttribute("transform", `translate(${ic.pos[0]},${ic.pos[1]})`); if (ic.type === "comment") { const c = document.createElementNS(SVG_NS, "circle"); c.setAttribute("r", 14); c.setAttribute("fill", "#5993D2"); c.setAttribute("stroke", "#000"); c.setAttribute("stroke-width", "2"); g.appendChild(c); const t = document.createElementNS(SVG_NS, "text"); t.setAttribute("y", 6); t.setAttribute("font-size", "20"); t.setAttribute("text-anchor", "middle"); t.setAttribute("fill", "#fff"); t.setAttribute("font-weight", "700"); t.textContent = "i"; g.appendChild(t); } else if (ic.type === "door" || ic.type === "door_locked") { const r = document.createElementNS(SVG_NS, "rect"); r.setAttribute("x", -10); r.setAttribute("y", -14); r.setAttribute("width", 20); r.setAttribute("height", 28); r.setAttribute("fill", ic.type === "door_locked" ? "#7a4a1a" : "#B58A3F"); r.setAttribute("stroke", "#000"); r.setAttribute("stroke-width", "2"); g.appendChild(r); } else if (ic.type === "start") { // Green entry-flag triangle const p = document.createElementNS(SVG_NS, "polygon"); p.setAttribute("points", "-12,-14 14,0 -12,14"); p.setAttribute("fill", "#6ad17b"); p.setAttribute("stroke", "#0a2a12"); p.setAttribute("stroke-width", "2"); g.appendChild(p); } else if (ic.type === "graveyard") { // Tombstone: rounded-top rect with a cross const r = document.createElementNS(SVG_NS, "rect"); r.setAttribute("x", -12); r.setAttribute("y", -14); r.setAttribute("width", 24); r.setAttribute("height", 28); r.setAttribute("rx", 10); r.setAttribute("ry", 10); r.setAttribute("fill", "#9aa1aa"); r.setAttribute("stroke", "#000"); r.setAttribute("stroke-width", "2"); g.appendChild(r); const t = document.createElementNS(SVG_NS, "text"); t.setAttribute("y", 6); t.setAttribute("font-size", "20"); t.setAttribute("text-anchor", "middle"); t.setAttribute("fill", "#1a1208"); t.setAttribute("font-weight", "900"); t.textContent = "✝"; g.appendChild(t); } else if (ic.type === "dot_yellow") { const c = document.createElementNS(SVG_NS, "circle"); c.setAttribute("r", 10); c.setAttribute("fill", "#f0c674"); c.setAttribute("stroke", "#1a1208"); c.setAttribute("stroke-width", "2"); g.appendChild(c); } else if (ic.type === "gateway") { const c = document.createElementNS(SVG_NS, "circle"); c.setAttribute("r", 14); c.setAttribute("fill", "#9b59b6"); c.setAttribute("stroke", "#000"); c.setAttribute("stroke-width", "2"); g.appendChild(c); } if (ic.comment) { g.dataset.tooltip = ic.comment; } else if (ic.type) { g.dataset.tooltip = ic.type; } return g; } function makeUserPin(x, y, label, cssClass, kind, idx) { const g = document.createElementNS(SVG_NS, "g"); g.setAttribute("class", cssClass); g.setAttribute("transform", `translate(${x},${y})`); const c = document.createElementNS(SVG_NS, "circle"); c.setAttribute("r", 26); g.appendChild(c); const t = document.createElementNS(SVG_NS, "text"); t.setAttribute("y", 9); t.setAttribute("font-size", "26"); t.textContent = label; g.appendChild(t); g.addEventListener("click", (e) => e.stopPropagation()); g.addEventListener("pointerdown", (e) => { e.stopPropagation(); state.drag = { kind, idx, moved: false, downX: e.clientX, downY: e.clientY }; g.classList.add("dragging"); g.setPointerCapture(e.pointerId); }); g.addEventListener("pointermove", (e) => { if (!state.drag || state.drag.idx !== idx || state.drag.kind !== kind) return; if (Math.hypot(e.clientX - state.drag.downX, e.clientY - state.drag.downY) > 3) { state.drag.moved = true; } const pt = svgPointFromEvent(e); const arr = kind === "route" ? state.routes[currentKey()] : state.pulls[currentKey()]; arr[idx] = { x: pt.x, y: pt.y }; g.setAttribute("transform", `translate(${pt.x},${pt.y})`); if (kind === "route") { const polyline = document.querySelector("#overlay .route-line"); if (polyline) { polyline.setAttribute( "points", state.routes[currentKey()].map((p) => `${p.x},${p.y}`).join(" ") ); } } }); g.addEventListener("pointerup", () => { if (!state.drag) return; const moved = state.drag.moved; state.drag = null; g.classList.remove("dragging"); if (moved) { pushHistory(); renderInfoPane(); updateHash(); } }); g.addEventListener("contextmenu", (e) => { e.preventDefault(); e.stopPropagation(); removePoint(kind, idx); }); return g; } function onCanvasClick(e) { if (state.drag) return; const pt = svgPointFromEvent(e); const key = currentKey(); if (state.tool === "route") { state.routes[key] = state.routes[key] || []; state.routes[key].push(pt); } else if (state.tool === "pull") { state.pulls[key] = state.pulls[key] || []; state.pulls[key].push(pt); } else if (state.tool === "note") { const text = prompt("Note text (shown on hover):", ""); if (text === null) return; // cancel state.notes[key] = state.notes[key] || []; state.notes[key].push({ x: pt.x, y: pt.y, text: text || "" }); } else if (state.tool === "text") { const text = prompt("Label text (always visible):", ""); if (text === null) return; // cancel state.texts[key] = state.texts[key] || []; state.texts[key].push({ x: pt.x, y: pt.y, text: text || "" }); } pushHistory(); renderOverlay(); renderInfoPane(); updateHash(); } function svgPointFromEvent(e) { // Convert client coords → image-pixel coords (the SVG's own coord system, // which matches the underlying webp's natural dimensions). Integer // pixels are plenty for routing — sub-pixel precision just bloats URLs. const m = currentMap(); const svg = $("overlay"); const rect = svg.getBoundingClientRect(); const x = Math.round(((e.clientX - rect.left) / rect.width) * m.width); const y = Math.round(((e.clientY - rect.top) / rect.height) * m.height); return { x, y }; } function removePoint(kind, idx) { const key = currentKey(); const arr = kind === "route" ? state.routes[key] : state.pulls[key]; if (!arr) return; arr.splice(idx, 1); pushHistory(); renderOverlay(); renderInfoPane(); updateHash(); } /* --- info pane ----------------------------------------------------------- */ function renderInfoPane() { const m = currentMap(); const bossUl = $("boss-list"); bossUl.innerHTML = ""; if (m) { const bossLike = (m.enemies || []).filter((e) => isBossLike(e.classification || 0)); // Sort by classification desc (final → boss → rare), name asc bossLike.sort((a, b) => (b.classification || 0) - (a.classification || 0) || a.name.localeCompare(b.name) ); // De-dupe by name (rare + non-rare variants share names) const seen = new Set(); for (const e of bossLike) { if (seen.has(e.name)) continue; seen.add(e.name); const cls = e.classification || 0; const tag = cls === 5 ? "rare" : cls === 4 ? "final" : ""; const li = document.createElement("li"); const swatchCls = cls === 5 ? "rare" : "boss"; li.innerHTML = `${escapeHtml(e.name)}` + (tag ? ` ${tag}` : ""); bossUl.appendChild(li); } if (!seen.size) { const li = document.createElement("li"); li.innerHTML = `no boss data`; bossUl.appendChild(li); } } const wpUl = $("waypoint-list"); wpUl.innerHTML = ""; const wps = state.routes[currentKey()] || []; wps.forEach((p, i) => { const li = document.createElement("li"); li.innerHTML = ` #${i + 1} (${Math.round(p.x)}, ${Math.round(p.y)}) `; li.querySelector("button").onclick = () => removePoint("route", i); wpUl.appendChild(li); }); if (!wps.length) { const li = document.createElement("li"); li.innerHTML = `click map to add`; wpUl.appendChild(li); } } function escapeHtml(s) { return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]) ); } /* --- pan / zoom ---------------------------------------------------------- */ function applyView() { $("canvas-stage").style.transform = `translate(${state.view.tx}px, ${state.view.ty}px) scale(${state.view.scale})`; } function fitToScreen() { const m = currentMap(); const host = $("canvas-host"); if (!m || !host) return; const pad = 24; const sx = (host.clientWidth - pad * 2) / m.width; const sy = (host.clientHeight - pad * 2) / m.height; const s = Math.min(sx, sy); state.view.scale = s; state.view.tx = -(m.width * s) / 2; state.view.ty = -(m.height * s) / 2; applyView(); } function zoomBy(factor, anchorX, anchorY) { const host = $("canvas-host"); const rect = host.getBoundingClientRect(); const ax = anchorX ?? rect.width / 2; const ay = anchorY ?? rect.height / 2; const cx = rect.width / 2 + state.view.tx; const cy = rect.height / 2 + state.view.ty; const dx = ax - cx; const dy = ay - cy; const before = state.view.scale; const after = Math.max(0.05, Math.min(20, before * factor)); const ratio = after / before; state.view.scale = after; state.view.tx -= dx * (ratio - 1); state.view.ty -= dy * (ratio - 1); applyView(); } /* --- instant tooltip ------------------------------------------------------ * SVG
init failed: ${e.message}`;
});