// Ascension M+ Route Planner — vanilla JS, single-file SPA. const SVG_NS = "http://www.w3.org/2000/svg"; const state = { dungeons: [], current: null, // selected dungeon floorIndex: 0, // index into dungeon.maps tool: "route", // "route" | "pull" // routes/pulls keyed by `${dungeonId}::${floorIndex}` routes: {}, // { key: [{x,y}] } pulls: {}, // { key: [{x,y,label?}] } history: [], drag: null, // { kind, key, idx } view: { scale: 1, tx: 0, ty: 0 }, // canvas pan/zoom pan: null, // active pan drag {x0,y0,tx0,ty0} }; const $ = (id) => document.getElementById(id); async function init() { const r = await fetch("assets/dungeons.json", { cache: "no-cache" }); const data = await r.json(); state.dungeons = data.dungeons; populateExpansionFilter(); renderDungeonList(); hookEvents(); loadFromHash(); if (!state.current && state.dungeons.length) { selectDungeon(state.dungeons[0].id); } } function populateExpansionFilter() { const sel = $("expansion-filter"); const exps = [...new Set(state.dungeons.map((d) => d.expansion))].sort(); for (const e of exps) { const opt = document.createElement("option"); opt.value = e; opt.textContent = labelExpansion(e); sel.appendChild(opt); } } function labelExpansion(e) { return ({ OriginalWoW: "Classic", BurningCrusade: "Burning Crusade", WrathoftheLichKing: "Wrath of the Lich King", Other: "Other / Unmapped", })[e] || e; } function renderDungeonList() { const ul = $("dungeon-list"); const q = $("search").value.trim().toLowerCase(); const expFilter = $("expansion-filter").value; ul.innerHTML = ""; const filtered = state.dungeons.filter((d) => { if (expFilter && d.expansion !== expFilter) return false; if (!q) return true; return ( d.name.toLowerCase().includes(q) || (d.acronym || "").toLowerCase().includes(q) || d.id.toLowerCase().includes(q) ); }); let lastExp = null; for (const d of filtered) { if (d.expansion !== lastExp) { const h = document.createElement("li"); h.className = "group-header"; h.textContent = labelExpansion(d.expansion); ul.appendChild(h); lastExp = d.expansion; } const li = document.createElement("li"); li.dataset.id = d.id; li.dataset.expansion = d.expansion; if (state.current && d.id === state.current.id && d.expansion === state.current.expansion) { 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, d.expansion); ul.appendChild(li); } } function selectDungeon(id, expansion) { const d = state.dungeons.find( (x) => x.id === id && (!expansion || x.expansion === expansion) ); if (!d) return; state.current = d; state.floorIndex = 0; renderDungeonList(); renderViewer(); updateHash(); } function currentKey() { return state.current ? `${state.current.expansion}::${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 meta = [d.acronym, d.levelRange && `Lv ${d.levelRange}`, d.location, d.playerLimit && `${d.playerLimit} players`] .filter(Boolean) .join(" · "); $("dungeon-meta").textContent = meta; // Floor tabs const tabs = $("floor-tabs"); tabs.innerHTML = ""; if (d.maps.length > 1) { d.maps.forEach((m, i) => { const b = document.createElement("button"); b.textContent = humanizeLabel(m.label); if (i === state.floorIndex) b.classList.add("active"); b.onclick = () => { state.floorIndex = i; renderViewer(); updateHash(); }; tabs.appendChild(b); }); } // Map image const m = currentMap(); 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(/^CFR/, "") .replace(/^HC/, "") .replace(/^CoT/, "CoT ") .replace(/^TempestKeep/, "TK ") .replace(/^SM/, "SM ") .replace(/^Auch/, "Auch ") .replace(/^FH/, "") .replace(/^Ulduar([A-Z])/, "Ulduar $1") .replace(/^IcecrownCitadel([A-Z])/, "ICC $1") .replace(/([a-z])([A-Z])/g, "$1 $2") .trim(); } function renderOverlay() { const svg = $("overlay"); svg.innerHTML = ""; const m = currentMap(); if (!m) return; // SVG works in image-pixel space, so circles stay circular regardless of // the map's aspect ratio. Coords on disk are 0-100 percent (AtlasLoot // convention) and converted at render/click time. 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] || []; const px = (p) => ({ x: (p.x / 100) * m.width, y: (p.y / 100) * m.height }); // route line first (under pins) if (wps.length > 1) { const path = document.createElementNS(SVG_NS, "polyline"); path.setAttribute("class", "route-line"); path.setAttribute( "points", wps.map((p) => { const q = px(p); return `${q.x},${q.y}`; }).join(" ") ); svg.appendChild(path); } // boss pins for (const b of m.bosses || []) { const q = px(b); const g = document.createElementNS(SVG_NS, "g"); g.setAttribute("class", "boss-pin"); g.setAttribute("transform", `translate(${q.x},${q.y})`); const c = document.createElementNS(SVG_NS, "circle"); c.setAttribute("r", 32); g.appendChild(c); const t = document.createElementNS(SVG_NS, "title"); t.textContent = b.name; g.appendChild(t); svg.appendChild(g); } // pull markers pulls.forEach((p, i) => { const q = px(p); svg.appendChild(makePin(q.x, q.y, i + 1, "pull", "pull", i)); }); // user waypoints (on top) wps.forEach((p, i) => { const q = px(p); svg.appendChild(makePin(q.x, q.y, i + 1, "waypoint", "route", i)); }); svg.onclick = onCanvasClick; } function makePin(x, y, label, cssClass, kind, idx) { // x/y here are SVG pixel coords (already converted from percent). 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", 36); g.appendChild(c); const t = document.createElementNS(SVG_NS, "text"); t.setAttribute("y", 14); t.setAttribute("font-size", 36); t.textContent = label; g.appendChild(t); g.addEventListener("pointerdown", (e) => { e.stopPropagation(); state.drag = { kind, idx }; g.classList.add("dragging"); g.setPointerCapture(e.pointerId); }); g.addEventListener("pointermove", (e) => { if (!state.drag || state.drag.idx !== idx || state.drag.kind !== kind) return; const m = currentMap(); const pt = svgPointFromEvent(e); // percent (0-100) const arr = kind === "route" ? state.routes[currentKey()] : state.pulls[currentKey()]; arr[idx] = { ...arr[idx], x: pt.x, y: pt.y }; const px = (pt.x / 100) * m.width; const py = (pt.y / 100) * m.height; g.setAttribute("transform", `translate(${px},${py})`); if (kind === "route") { const polyline = document.querySelector("#overlay .route-line"); if (polyline) { polyline.setAttribute( "points", state.routes[currentKey()] .map((p) => `${(p.x / 100) * m.width},${(p.y / 100) * m.height}`) .join(" ") ); } } }); g.addEventListener("pointerup", () => { if (!state.drag) return; state.drag = null; g.classList.remove("dragging"); pushHistory(); renderInfoPane(); updateHash(); }); g.addEventListener("contextmenu", (e) => { e.preventDefault(); 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({ x: pt.x, y: pt.y }); } else if (state.tool === "pull") { state.pulls[key] = state.pulls[key] || []; state.pulls[key].push({ x: pt.x, y: pt.y }); } pushHistory(); renderOverlay(); renderInfoPane(); updateHash(); } function svgPointFromEvent(e) { // Convert client coords → 0-100% of map (same coord space as AtlasLoot) const svg = $("overlay"); const rect = svg.getBoundingClientRect(); const x = ((e.clientX - rect.left) / rect.width) * 100; const y = ((e.clientY - rect.top) / rect.height) * 100; return { x: clamp(x), y: clamp(y) }; } function clamp(v) { return Math.max(0, Math.min(100, +v.toFixed(2))); } /* --- pan / zoom ---------------------------------------------------------- */ function applyView() { const stage = $("canvas-stage"); 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); // Center on host: stage origin is at host center (50/50 in CSS), so // shift by -half-image-size * scale to center the image visually. 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(); // Anchor defaults to host center 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(); } function hookCanvasPanZoom() { const host = $("canvas-host"); host.addEventListener("wheel", (e) => { e.preventDefault(); const rect = host.getBoundingClientRect(); const ax = e.clientX - rect.left; const ay = e.clientY - rect.top; const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15; zoomBy(factor, ax, ay); }, { passive: false }); host.addEventListener("pointerdown", (e) => { // Skip if click is on a pin (those handle their own pointerdown). if (e.target.closest("g.waypoint, g.pull, g.boss-pin")) return; // Skip on SVG clicks (those are handled by onCanvasClick to drop pins). // Use middle mouse, shift-drag, or right-drag for pan; plain left-click on // empty area also pans if it falls outside the SVG. 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) => { // suppress the menu when shift-right-clicking to allow pan if (state.pan) e.preventDefault(); }); // Buttons $("zoom-in").addEventListener("click", () => zoomBy(1.25)); $("zoom-out").addEventListener("click", () => zoomBy(1 / 1.25)); $("zoom-reset").addEventListener("click", fitToScreen); window.addEventListener("resize", () => { // Refit on first resize, but keep user zoom afterwards. if (!state._userZoomed) fitToScreen(); }); } 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(); } function renderInfoPane() { const m = currentMap(); const bossUl = $("boss-list"); bossUl.innerHTML = ""; if (m) { for (const b of m.bosses || []) { const li = document.createElement("li"); li.innerHTML = `${escapeHtml(b.name)}`; bossUl.appendChild(li); } if (!m.bosses?.length) { 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} (${p.x.toFixed(1)}, ${p.y.toFixed(1)}) `; 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]) ); } function pushHistory() { state.history.push(JSON.stringify({ routes: state.routes, pulls: state.pulls })); 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; } else { state.routes = {}; state.pulls = {}; } renderOverlay(); renderInfoPane(); updateHash(); } function clearCurrent() { const key = currentKey(); delete state.routes[key]; delete state.pulls[key]; pushHistory(); renderOverlay(); renderInfoPane(); updateHash(); } /* --- URL hash sharing ---------------------------------------------------- */ function updateHash() { if (!state.current) return; const payload = { d: `${state.current.expansion}/${state.current.id}`, f: state.floorIndex, r: state.routes, p: state.pulls, }; const json = JSON.stringify(payload); const minified = compressForUrl(json); const newHash = `#${minified}`; if (location.hash !== newHash) { history.replaceState(null, "", location.pathname + location.search + newHash); } } function loadFromHash() { const h = location.hash.slice(1); if (!h) return; try { const json = decompressFromUrl(h); const p = JSON.parse(json); if (p.d) { const [expansion, id] = p.d.split("/"); const d = state.dungeons.find((x) => x.id === id && x.expansion === expansion); if (d) { state.current = d; state.floorIndex = p.f || 0; state.routes = p.r || {}; state.pulls = p.p || {}; renderViewer(); } } } catch (e) { console.warn("hash parse failed", e); } } // Tiny base64-of-utf8 — small payloads make for OK-length URLs. function compressForUrl(s) { return btoa(unescape(encodeURIComponent(s))).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_"); } function decompressFromUrl(s) { s = s.replace(/-/g, "+").replace(/_/g, "/"); while (s.length % 4) s += "="; return decodeURIComponent(escape(atob(s))); } function shareUrl() { updateHash(); navigator.clipboard?.writeText(location.href); toast("Link copied"); } function exportJson() { const d = state.current; if (!d) return; const payload = { dungeon: { id: d.id, expansion: d.expansion, name: d.name }, floors: d.maps.map((m, i) => ({ label: m.label, route: state.routes[`${d.expansion}::${d.id}::${i}`] || [], pulls: state.pulls[`${d.expansion}::${d.id}::${i}`] || [], })), }; 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 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); } /* --- events -------------------------------------------------------------- */ function hookEvents() { $("search").addEventListener("input", renderDungeonList); $("expansion-filter").addEventListener("change", renderDungeonList); $("undo").addEventListener("click", undo); $("clear").addEventListener("click", clearCurrent); $("share").addEventListener("click", shareUrl); $("export").addEventListener("click", exportJson); $("tool-route").addEventListener("click", () => setTool("route")); $("tool-pull").addEventListener("click", () => setTool("pull")); document.addEventListener("keydown", (e) => { if ((e.metaKey || e.ctrlKey) && e.key === "z") { e.preventDefault(); undo(); } }); window.addEventListener("hashchange", loadFromHash); hookCanvasPanZoom(); } function setTool(t) { state.tool = t; $("tool-route").classList.toggle("active", t === "route"); $("tool-pull").classList.toggle("active", t === "pull"); } init().catch((e) => { console.error(e); document.body.innerHTML = `
init failed: ${e.message}`;
});