Files
mplus-routes/web/app.js
T

617 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 = `<span class="swatch"></span><span>${escapeHtml(b.name)}</span>`;
bossUl.appendChild(li);
}
if (!m.bosses?.length) {
const li = document.createElement("li");
li.innerHTML = `<span style="color:var(--text-dim)">no boss data</span>`;
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 = `
<span class="swatch"></span>
<span>#${i + 1} (${p.x.toFixed(1)}, ${p.y.toFixed(1)})</span>
<button title="Remove">×</button>
`;
li.querySelector("button").onclick = () => removePoint("route", i);
wpUl.appendChild(li);
});
if (!wps.length) {
const li = document.createElement("li");
li.innerHTML = `<span style="color:var(--text-dim)">click map to add</span>`;
wpUl.appendChild(li);
}
}
function escapeHtml(s) {
return s.replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 = `<pre style="color:#d63b3b;padding:20px">init failed: ${e.message}</pre>`;
});