// 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 uses the OS tooltip with a long delay. We render our own div * so it shows the moment the cursor enters a pin/icon. Driven by a single * pointer listener on #overlay that walks up to find any [data-tooltip]. */ function ensureTooltip() { let el = document.getElementById("custom-tooltip"); if (el) return el; el = document.createElement("div"); el.id = "custom-tooltip"; el.className = "custom-tooltip"; document.body.appendChild(el); return el; } function hookTooltip() { const tip = ensureTooltip(); const host = $("canvas-host"); let activeText = ""; host.addEventListener("pointermove", (e) => { const target = e.target.closest("[data-tooltip]"); if (!target) { tip.classList.remove("show"); activeText = ""; return; } const text = target.dataset.tooltip; if (!text) { tip.classList.remove("show"); return; } if (text !== activeText) { tip.textContent = text; activeText = text; } // Position near cursor, but keep on-screen const pad = 14; let x = e.clientX + pad; let y = e.clientY + pad; const w = tip.offsetWidth, h = tip.offsetHeight; if (x + w + pad > window.innerWidth) x = e.clientX - w - pad; if (y + h + pad > window.innerHeight) y = e.clientY - h - pad; tip.style.left = x + "px"; tip.style.top = y + "px"; tip.classList.add("show"); }); host.addEventListener("pointerleave", () => { tip.classList.remove("show"); activeText = ""; }); } function hookCanvasPanZoom() { const host = $("canvas-host"); host.addEventListener("wheel", (e) => { e.preventDefault(); const rect = host.getBoundingClientRect(); zoomBy(e.deltaY < 0 ? 1.15 : 1 / 1.15, e.clientX - rect.left, e.clientY - rect.top); }, { passive: false }); host.addEventListener("pointerdown", (e) => { if (e.target.closest("g.waypoint, g.pull")) return; const onSvg = e.target.closest("#overlay"); const isPanIntent = e.button === 1 || e.shiftKey || e.button === 2 || !onSvg; if (!isPanIntent) return; e.preventDefault(); state.pan = { x0: e.clientX, y0: e.clientY, tx0: state.view.tx, ty0: state.view.ty }; host.classList.add("panning"); host.setPointerCapture(e.pointerId); }); host.addEventListener("pointermove", (e) => { if (!state.pan) return; state.view.tx = state.pan.tx0 + (e.clientX - state.pan.x0); state.view.ty = state.pan.ty0 + (e.clientY - state.pan.y0); applyView(); }); host.addEventListener("pointerup", () => { state.pan = null; host.classList.remove("panning"); }); host.addEventListener("contextmenu", (e) => { if (state.pan) e.preventDefault(); }); $("zoom-in").addEventListener("click", () => zoomBy(1.25)); $("zoom-out").addEventListener("click", () => zoomBy(1 / 1.25)); $("zoom-reset").addEventListener("click", fitToScreen); window.addEventListener("resize", () => fitToScreen()); } /* --- history / share / export ------------------------------------------- */ function pushHistory() { state.history.push(JSON.stringify({ routes: state.routes, pulls: state.pulls, notes: state.notes, texts: state.texts, })); if (state.history.length > 200) state.history.shift(); } function undo() { state.history.pop(); const last = state.history[state.history.length - 1]; if (last) { const s = JSON.parse(last); state.routes = s.routes; state.pulls = s.pulls; state.notes = s.notes || {}; state.texts = s.texts || {}; } else { state.routes = {}; state.pulls = {}; state.notes = {}; state.texts = {}; } renderOverlay(); renderInfoPane(); updateHash(); } function clearCurrent() { const key = currentKey(); const r = (state.routes[key] || []).length; const p = (state.pulls[key] || []).length; const n = (state.notes[key] || []).length; const t = (state.texts[key] || []).length; const total = r + p + n + t; if (!total) return; const summary = [ r ? `${r} waypoint${r === 1 ? "" : "s"}` : null, p ? `${p} pull marker${p === 1 ? "" : "s"}` : null, n ? `${n} note${n === 1 ? "" : "s"}` : null, t ? `${t} label${t === 1 ? "" : "s"}` : null, ].filter(Boolean).join(", "); if (!confirm(`Clear ${summary} on this floor? This can be undone with Undo.`)) return; delete state.routes[key]; delete state.pulls[key]; delete state.notes[key]; delete state.texts[key]; pushHistory(); renderOverlay(); renderInfoPane(); updateHash(); } /* ----- URL hash share format ----- * v1 (legacy): base64url of JSON with full {x,y} objects, no prefix. * v2 (current): "~" + base64url of deflate-raw'd JSON. The waypoint * arrays are flattened to [x1,y1,x2,y2,...] (ints) before compression to * shave another ~30% before deflate runs. * * On a typical 30-pull RFK route, v1 produced ~3500 char URLs; v2 lands * around 400-700 chars. */ const HASH_PREFIX = "~"; function flattenPins(map) { const out = {}; for (const k of Object.keys(map || {})) { const flat = []; for (const p of map[k]) flat.push(Math.round(p.x), Math.round(p.y)); out[k] = flat; } return out; } function inflatePins(map) { const out = {}; for (const k of Object.keys(map || {})) { const flat = map[k]; const arr = []; if (Array.isArray(flat) && flat.length && typeof flat[0] === "object") { // Backward compat: legacy {x,y} objects, just round into ints. for (const p of flat) arr.push({ x: Math.round(p.x), y: Math.round(p.y) }); } else { for (let i = 0; i + 1 < flat.length; i += 2) arr.push({ x: flat[i], y: flat[i + 1] }); } out[k] = arr; } return out; } async function encodePayload(p) { const json = JSON.stringify(p); if (typeof CompressionStream === "undefined") { return base64UrlEncodeStr(json); } const stream = new Blob([json]).stream().pipeThrough(new CompressionStream("deflate-raw")); const buf = new Uint8Array(await new Response(stream).arrayBuffer()); return HASH_PREFIX + base64UrlEncodeBytes(buf); } async function decodePayload(s) { if (s.startsWith(HASH_PREFIX) && typeof DecompressionStream !== "undefined") { const buf = base64UrlDecodeBytes(s.slice(1)); const stream = new Blob([buf]).stream().pipeThrough(new DecompressionStream("deflate-raw")); return JSON.parse(await new Response(stream).text()); } // Legacy: plain base64url JSON return JSON.parse(base64UrlDecodeStr(s)); } function base64UrlEncodeStr(s) { return btoa(unescape(encodeURIComponent(s))) .replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_"); } function base64UrlDecodeStr(s) { s = s.replace(/-/g, "+").replace(/_/g, "/"); while (s.length % 4) s += "="; return decodeURIComponent(escape(atob(s))); } function base64UrlEncodeBytes(bytes) { let bin = ""; for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); return btoa(bin).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_"); } function base64UrlDecodeBytes(s) { s = s.replace(/-/g, "+").replace(/_/g, "/"); while (s.length % 4) s += "="; const bin = atob(s); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } function flattenNotes(map) { // Notes carry a text payload, so they can't use the int-pair shortcut. // Round coords to ints; keep text verbatim. const out = {}; for (const k of Object.keys(map || {})) { out[k] = map[k].map((n) => [Math.round(n.x), Math.round(n.y), n.text || ""]); } return out; } function inflateNotes(map) { const out = {}; for (const k of Object.keys(map || {})) { out[k] = map[k].map((n) => ({ x: n[0], y: n[1], text: n[2] || "" })); } return out; } async function updateHash() { if (!state.current) return; const payload = { d: state.current.id, f: state.floorIndex, r: flattenPins(state.routes), p: flattenPins(state.pulls), n: flattenNotes(state.notes), // hover-only (i) notes t: flattenNotes(state.texts), // always-visible labels }; const encoded = await encodePayload(payload); const hash = "#" + encoded; if (location.hash !== hash) { history.replaceState(null, "", location.pathname + location.search + hash); } } async function loadFromHash() { const h = location.hash.slice(1); if (!h) return; try { const p = await decodePayload(h); if (!p.d) return; const d = state.dungeons.find((x) => x.id === p.d); if (!d) return; state.current = d; state.floorIndex = p.f || 0; state.routes = inflatePins(p.r); state.pulls = inflatePins(p.p); state.notes = inflateNotes(p.n); state.texts = inflateNotes(p.t); renderViewer(); } catch (e) { console.warn("hash parse failed", e); } } async function shareUrl() { await updateHash(); navigator.clipboard?.writeText(location.href); toast("Link copied"); } const EXPORT_VERSION = 1; function exportJson() { const d = state.current; if (!d) return; const payload = { version: EXPORT_VERSION, dungeon: { id: d.id, name: d.name }, floors: d.maps.map((m, i) => { const k = `${d.id}::${i}`; return { index: i, label: m.label, route: state.routes[k] || [], pulls: state.pulls[k] || [], notes: state.notes[k] || [], texts: state.texts[k] || [], }; }), }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${d.id}-route.json`; a.click(); URL.revokeObjectURL(url); } function importJson() { const input = document.createElement("input"); input.type = "file"; input.accept = "application/json,.json"; input.onchange = async () => { const file = input.files?.[0]; if (!file) return; let payload; try { const text = await file.text(); payload = JSON.parse(text); } catch (e) { alert("Could not parse JSON: " + e.message); return; } if (!payload || !payload.dungeon || !payload.dungeon.id || !Array.isArray(payload.floors)) { alert("Not a recognized route file (missing dungeon/floors)."); return; } const d = state.dungeons.find((x) => x.id === payload.dungeon.id); if (!d) { alert(`Dungeon "${payload.dungeon.id}" is not in this build's data set.`); return; } // Replace this dungeon's data on every floor present in the file. const cleanPin = (p) => ({ x: Math.round(p.x), y: Math.round(p.y) }); const cleanNote = (n) => ({ x: Math.round(n.x), y: Math.round(n.y), text: String(n.text ?? ""), }); for (const f of payload.floors) { const i = Number.isInteger(f.index) ? f.index : 0; const k = `${d.id}::${i}`; state.routes[k] = (f.route || []).filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y)).map(cleanPin); state.pulls[k] = (f.pulls || []).filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y)).map(cleanPin); state.notes[k] = (f.notes || []).filter((n) => n && Number.isFinite(n.x) && Number.isFinite(n.y)).map(cleanNote); state.texts[k] = (f.texts || []).filter((n) => n && Number.isFinite(n.x) && Number.isFinite(n.y)).map(cleanNote); } state.current = d; state.floorIndex = 0; pushHistory(); renderDungeonList(); renderViewer(); updateHash(); toast(`Imported ${payload.floors.length} floor${payload.floors.length === 1 ? "" : "s"} of ${d.name}`); }; input.click(); } function toast(msg) { let t = document.querySelector(".toast"); if (!t) { t = document.createElement("div"); t.className = "toast"; document.body.appendChild(t); } t.textContent = msg; t.classList.add("show"); clearTimeout(toast._h); toast._h = setTimeout(() => t.classList.remove("show"), 1500); } function setTool(tool) { state.tool = tool; for (const t of ["route", "pull", "note", "text"]) { const btn = $(`tool-${t}`); if (btn) btn.classList.toggle("active", tool === t); } } function hookEvents() { $("search").addEventListener("input", renderDungeonList); const ef = $("expansion-filter"); if (ef) ef.addEventListener("change", renderDungeonList); $("undo").addEventListener("click", undo); $("clear").addEventListener("click", clearCurrent); $("share").addEventListener("click", shareUrl); $("export").addEventListener("click", exportJson); const importBtn = $("import"); if (importBtn) importBtn.addEventListener("click", importJson); // Layer toggles — repaint overlay on change. Persisted in state.show. for (const layer of ["enemies", "packs", "patrols", "icons"]) { const cb = $(`layer-${layer}`); if (!cb) continue; cb.checked = state.show[layer]; cb.addEventListener("change", () => { state.show[layer] = cb.checked; renderOverlay(); }); } $("tool-route").addEventListener("click", () => setTool("route")); $("tool-pull").addEventListener("click", () => setTool("pull")); const noteBtn = $("tool-note"); if (noteBtn) noteBtn.addEventListener("click", () => setTool("note")); const textBtn = $("tool-text"); if (textBtn) textBtn.addEventListener("click", () => setTool("text")); document.addEventListener("keydown", (e) => { if ((e.metaKey || e.ctrlKey) && e.key === "z") { e.preventDefault(); undo(); } }); window.addEventListener("hashchange", loadFromHash); hookCanvasPanZoom(); hookTooltip(); } init().catch((e) => { console.error(e); document.body.innerHTML = `<pre style="color:#d63b3b;padding:20px">init failed: ${e.message}</pre>`; });