initial commit: classic-only M+ planner with upreza-derived maps
This commit is contained in:
+616
@@ -0,0 +1,616 @@
|
||||
// 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) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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>`;
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Ascension M+ Route Planner</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<aside class="sidebar">
|
||||
<header class="brand">
|
||||
<h1>M+ Routes</h1>
|
||||
<p class="sub">Ascension dungeon planner</p>
|
||||
</header>
|
||||
<div class="filters">
|
||||
<input id="search" type="search" placeholder="Search dungeons…" autocomplete="off">
|
||||
<select id="expansion-filter">
|
||||
<option value="">All expansions</option>
|
||||
</select>
|
||||
</div>
|
||||
<ul id="dungeon-list" class="dungeon-list"></ul>
|
||||
</aside>
|
||||
|
||||
<main class="viewer">
|
||||
<header class="viewer-header">
|
||||
<div class="title-block">
|
||||
<h2 id="dungeon-name">Pick a dungeon</h2>
|
||||
<p id="dungeon-meta" class="meta"></p>
|
||||
</div>
|
||||
<div id="floor-tabs" class="floor-tabs"></div>
|
||||
<div class="toolbar">
|
||||
<button id="tool-route" class="tool active" title="Click map to add waypoints">Route</button>
|
||||
<button id="tool-pull" class="tool" title="Click map to drop pull markers">Pull</button>
|
||||
<button id="undo" title="Undo (⌘Z)">Undo</button>
|
||||
<button id="clear" title="Clear current floor">Clear</button>
|
||||
<button id="share">Share</button>
|
||||
<button id="export">Export JSON</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="canvas-wrap">
|
||||
<div id="canvas-host" class="canvas-host">
|
||||
<div id="canvas-stage" class="canvas-stage">
|
||||
<img id="map-img" alt="">
|
||||
<svg id="overlay" xmlns="http://www.w3.org/2000/svg"></svg>
|
||||
</div>
|
||||
<div class="zoom-controls">
|
||||
<button id="zoom-in" title="Zoom in">+</button>
|
||||
<button id="zoom-out" title="Zoom out">−</button>
|
||||
<button id="zoom-reset" title="Fit to screen">⊡</button>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="info-pane">
|
||||
<h3>Bosses</h3>
|
||||
<ul id="boss-list" class="boss-list"></ul>
|
||||
<h3>Waypoints</h3>
|
||||
<ol id="waypoint-list" class="waypoint-list"></ol>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="app.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
+386
@@ -0,0 +1,386 @@
|
||||
/* Ascension M+ Route Planner — dark, gold-accented, no frameworks. */
|
||||
|
||||
:root {
|
||||
--bg: #0e0d10;
|
||||
--panel: #17151b;
|
||||
--panel-2: #1f1c25;
|
||||
--line: #2e2932;
|
||||
--text: #d8d2c4;
|
||||
--text-dim: #8d8576;
|
||||
--accent: #d4a44a; /* WoW gold */
|
||||
--accent-2: #f0c674;
|
||||
--boss: #d63b3b; /* boss pin */
|
||||
--waypoint: #6ea8ff; /* route point */
|
||||
--pull: #6ad17b; /* pull marker */
|
||||
--route: #f0c674;
|
||||
--shadow: 0 1px 0 rgba(0,0,0,.4), 0 6px 18px rgba(0,0,0,.35);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 14px/1.45 -apple-system, "SF Pro Text", "Inter", "Segoe UI", system-ui, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
}
|
||||
|
||||
/* --- sidebar -------------------------------------------------------------- */
|
||||
|
||||
.sidebar {
|
||||
background: var(--panel);
|
||||
border-right: 1px solid var(--line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
padding: 18px 20px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.brand h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
letter-spacing: .04em;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.brand .sub {
|
||||
margin: 2px 0 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.filters {
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.filters input,
|
||||
.filters select {
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
.filters input:focus,
|
||||
.filters select:focus { border-color: var(--accent); }
|
||||
|
||||
.dungeon-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 6px 0;
|
||||
overflow-y: auto;
|
||||
flex: 1 1 auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--line) transparent;
|
||||
}
|
||||
.dungeon-list li {
|
||||
padding: 7px 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
border-left: 3px solid transparent;
|
||||
font-size: 13px;
|
||||
}
|
||||
.dungeon-list li:hover { background: var(--panel-2); }
|
||||
.dungeon-list li.active {
|
||||
background: var(--panel-2);
|
||||
border-left-color: var(--accent);
|
||||
color: var(--accent-2);
|
||||
}
|
||||
.dungeon-list li .acronym {
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.dungeon-list .group-header {
|
||||
padding: 12px 16px 4px;
|
||||
color: var(--text-dim);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .12em;
|
||||
cursor: default;
|
||||
}
|
||||
.dungeon-list .group-header:hover { background: transparent; }
|
||||
|
||||
/* --- main viewer ---------------------------------------------------------- */
|
||||
|
||||
.viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.viewer-header {
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.title-block { flex: 0 1 auto; min-width: 200px; }
|
||||
.title-block h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: var(--accent-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
.title-block .meta {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.floor-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.floor-tabs button {
|
||||
background: var(--panel);
|
||||
color: var(--text-dim);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.floor-tabs button:hover { color: var(--text); }
|
||||
.floor-tabs button.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-2);
|
||||
background: var(--panel-2);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.toolbar button {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--text);
|
||||
border-radius: 4px;
|
||||
padding: 5px 12px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toolbar button:hover { border-color: var(--accent); color: var(--accent-2); }
|
||||
.toolbar .tool.active {
|
||||
background: var(--accent);
|
||||
color: #1a1208;
|
||||
border-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* --- canvas / overlay ----------------------------------------------------- */
|
||||
|
||||
.canvas-wrap {
|
||||
flex: 1 1 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 240px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.canvas-host {
|
||||
position: relative;
|
||||
background: #050507;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,.015) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,.015) 1px, transparent 1px);
|
||||
background-size: 32px 32px;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
cursor: grab;
|
||||
}
|
||||
.canvas-host.panning { cursor: grabbing; }
|
||||
|
||||
/* The pan/zoom transform is applied to this wrapper. Image and SVG share it. */
|
||||
.canvas-stage {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform-origin: 0 0;
|
||||
/* set by JS: transform: translate(...) scale(...) */
|
||||
will-change: transform;
|
||||
}
|
||||
.canvas-stage img {
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
.canvas-stage svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: auto;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.zoom-controls {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
z-index: 5;
|
||||
}
|
||||
.zoom-controls button {
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--text);
|
||||
border-radius: 4px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.zoom-controls button:hover { border-color: var(--accent); color: var(--accent-2); }
|
||||
|
||||
/* boss pins (non-interactive markers from AtlasLoot data) */
|
||||
.boss-pin circle {
|
||||
fill: var(--boss);
|
||||
stroke: #200;
|
||||
stroke-width: 6;
|
||||
}
|
||||
|
||||
/* user-placed waypoints */
|
||||
.waypoint circle {
|
||||
fill: var(--waypoint);
|
||||
stroke: #002;
|
||||
stroke-width: 6;
|
||||
cursor: grab;
|
||||
}
|
||||
.waypoint.dragging circle { cursor: grabbing; }
|
||||
|
||||
.pull circle {
|
||||
fill: var(--pull);
|
||||
stroke: #020;
|
||||
stroke-width: 6;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
/* labels for all pin types */
|
||||
.boss-pin text,
|
||||
.waypoint text,
|
||||
.pull text {
|
||||
fill: #fff;
|
||||
font-weight: 700;
|
||||
font-family: system-ui, sans-serif;
|
||||
text-anchor: middle;
|
||||
paint-order: stroke;
|
||||
stroke: #000;
|
||||
stroke-width: 6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.route-line {
|
||||
fill: none;
|
||||
stroke: var(--route);
|
||||
stroke-width: 8;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-dasharray: 16 12;
|
||||
opacity: .85;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* --- info pane ----------------------------------------------------------- */
|
||||
|
||||
.info-pane {
|
||||
border-left: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
padding: 14px 16px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.info-pane h3 {
|
||||
margin: 14px 0 6px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .12em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.info-pane h3:first-child { margin-top: 0; }
|
||||
|
||||
.boss-list, .waypoint-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.boss-list li,
|
||||
.waypoint-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.boss-list li:hover,
|
||||
.waypoint-list li:hover { background: var(--panel-2); }
|
||||
.boss-list .swatch,
|
||||
.waypoint-list .swatch {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.boss-list .swatch { background: var(--boss); }
|
||||
.waypoint-list .swatch { background: var(--waypoint); }
|
||||
.waypoint-list li button {
|
||||
margin-left: auto;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
.waypoint-list li button:hover { color: var(--boss); }
|
||||
|
||||
/* --- toast ---------------------------------------------------------------- */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 18px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--panel-2);
|
||||
color: var(--accent-2);
|
||||
border: 1px solid var(--accent);
|
||||
padding: 8px 18px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
box-shadow: var(--shadow);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity .2s ease;
|
||||
z-index: 99;
|
||||
}
|
||||
.toast.show { opacity: 1; }
|
||||
Reference in New Issue
Block a user