switch to keystone.guru tiles + enemy data

Replaces the upreza-derived 4K dungeon textures + AtlasLoot boss-coord
overlay (which had a consistent positional offset against texture skulls)
with keystone.guru's z=4 tile pyramid stitched to 6144x4096 WebP per floor.

kg's split_floors.js gives per-dungeon enemies, packs (polygons), patrols
(polylines), and map icons calibrated to those tiles, so overlays align
pixel-perfectly. 27/29 classic dungeons now have full enemy/pack data;
ZG + Sunken Temple have maps only.

Pipeline: tools/kg_fetch.py -> tools/kg_stitch.py -> tools/kg_build_data.py.
This commit is contained in:
2026-04-25 22:11:17 +02:00
parent aa1cd9ee40
commit 18c7792935
15 changed files with 178577 additions and 10485 deletions
+279 -266
View File
@@ -1,27 +1,29 @@
// 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, // selected dungeon
floorIndex: 0, // index into dungeon.maps
current: null,
floorIndex: 0,
tool: "route", // "route" | "pull"
// routes/pulls keyed by `${dungeonId}::${floorIndex}`
routes: {}, // { key: [{x,y}] }
pulls: {}, // { key: [{x,y,label?}] }
routes: {}, // { key: [{x,y}] } image-pixel coords
pulls: {}, // { key: [{x,y}] }
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}
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" });
const data = await r.json();
state.dungeons = data.dungeons;
state.dungeons = (await r.json()).dungeons;
populateExpansionFilter();
renderDungeonList();
@@ -35,70 +37,34 @@ async function init() {
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;
sel.innerHTML = '<option value="">All</option>';
// Classic-only deployment — keep the dropdown as a no-op for now.
}
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;
}
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;
li.dataset.expansion = d.expansion;
if (state.current && d.id === state.current.id && d.expansion === state.current.expansion) {
li.classList.add("active");
}
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, d.expansion);
li.onclick = () => selectDungeon(d.id);
ul.appendChild(li);
}
}
function selectDungeon(id, expansion) {
const d = state.dungeons.find(
(x) => x.id === id && (!expansion || x.expansion === expansion)
);
function selectDungeon(id) {
const d = state.dungeons.find((x) => x.id === id);
if (!d) return;
state.current = d;
state.floorIndex = 0;
@@ -108,9 +74,8 @@ function selectDungeon(id, expansion) {
}
function currentKey() {
return state.current ? `${state.current.expansion}::${state.current.id}::${state.floorIndex}` : null;
return state.current ? `${state.current.id}::${state.floorIndex}` : null;
}
function currentMap() {
return state.current?.maps[state.floorIndex] || null;
}
@@ -119,26 +84,26 @@ 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(" · ");
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;
// Floor tabs
const tabs = $("floor-tabs");
tabs.innerHTML = "";
if (d.maps.length > 1) {
d.maps.forEach((m, i) => {
d.maps.forEach((mp, i) => {
const b = document.createElement("button");
b.textContent = humanizeLabel(m.label);
b.textContent = humanizeLabel(mp.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;
@@ -148,7 +113,7 @@ function renderViewer() {
svg.setAttribute("width", m.width);
svg.setAttribute("height", m.height);
img.onload = () => { fitToScreen(); renderOverlay(); };
if (img.complete) { fitToScreen(); }
if (img.complete) fitToScreen();
} else {
img.removeAttribute("src");
}
@@ -159,87 +124,167 @@ function renderViewer() {
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();
.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 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)
// 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));
}
}
// Route polyline
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(" ")
);
path.setAttribute("points", wps.map((p) => `${p.x},${p.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));
});
// 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)));
svg.onclick = onCanvasClick;
}
function makePin(x, y, label, cssClass, kind, idx) {
// x/y here are SVG pixel coords (already converted from percent).
const CLASS_RADIUS = { 1: 14, 2: 18, 3: 28, 4: 38 };
const CLASS_FILL = { 1: "#9aa1aa", 2: "#d6d6dc", 3: "#d63b3b", 4: "#ffd83a" };
function makeEnemyPin(e) {
const r = CLASS_RADIUS[e.classification || 1] ?? 14;
const fill = CLASS_FILL[e.classification || 1] ?? "#9aa1aa";
const g = document.createElementNS(SVG_NS, "g");
g.setAttribute("class", `enemy enemy-c${e.classification || 1}`);
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", "#000");
c.setAttribute("stroke-width", "3");
g.appendChild(c);
if ((e.classification || 1) >= 3) {
// Boss — overlay a skull glyph
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", "#1a0000");
t.setAttribute("font-weight", "900");
t.textContent = "☠";
g.appendChild(t);
}
const title = document.createElementNS(SVG_NS, "title");
title.textContent = e.name + (e.skippable ? " (skippable)" : "");
g.appendChild(title);
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") {
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", "#B58A3F");
r.setAttribute("stroke", "#000");
r.setAttribute("stroke-width", "2");
g.appendChild(r);
}
if (ic.comment) {
const title = document.createElementNS(SVG_NS, "title");
title.textContent = ic.comment;
g.appendChild(title);
}
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", 36);
c.setAttribute("r", 26);
g.appendChild(c);
const t = document.createElementNS(SVG_NS, "text");
t.setAttribute("y", 14);
t.setAttribute("font-size", 36);
t.setAttribute("y", 9);
t.setAttribute("font-size", "26");
t.textContent = label;
g.appendChild(t);
@@ -251,21 +296,16 @@ function makePin(x, y, label, cssClass, kind, idx) {
});
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 pt = svgPointFromEvent(e);
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})`);
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 / 100) * m.width},${(p.y / 100) * m.height}`)
.join(" ")
state.routes[currentKey()].map((p) => `${p.x},${p.y}`).join(" ")
);
}
}
@@ -291,10 +331,10 @@ function onCanvasClick(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.routes[key].push(pt);
} else {
state.pulls[key] = state.pulls[key] || [];
state.pulls[key].push({ x: pt.x, y: pt.y });
state.pulls[key].push(pt);
}
pushHistory();
renderOverlay();
@@ -303,110 +343,14 @@ function onCanvasClick(e) {
}
function svgPointFromEvent(e) {
// Convert client coords → 0-100% of map (same coord space as AtlasLoot)
// Convert client coords → image-pixel coords (the SVG's own coord system,
// which matches the underlying webp's natural dimensions).
const m = currentMap();
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();
});
const x = ((e.clientX - rect.left) / rect.width) * m.width;
const y = ((e.clientY - rect.top) / rect.height) * m.height;
return { x: +x.toFixed(1), y: +y.toFixed(1) };
}
function removePoint(kind, idx) {
@@ -420,17 +364,21 @@ function removePoint(kind, idx) {
updateHash();
}
/* --- info pane ----------------------------------------------------------- */
function renderInfoPane() {
const m = currentMap();
const bossUl = $("boss-list");
bossUl.innerHTML = "";
if (m) {
for (const b of m.bosses || []) {
const bosses = (m.enemies || []).filter((e) => (e.classification || 0) >= 3);
bosses.sort((a, b) => (b.classification || 0) - (a.classification || 0));
for (const e of bosses) {
const li = document.createElement("li");
li.innerHTML = `<span class="swatch"></span><span>${escapeHtml(b.name)}</span>`;
li.innerHTML = `<span class="swatch boss"></span><span>${escapeHtml(e.name)}</span>`;
bossUl.appendChild(li);
}
if (!m.bosses?.length) {
if (!bosses.length) {
const li = document.createElement("li");
li.innerHTML = `<span style="color:var(--text-dim)">no boss data</span>`;
bossUl.appendChild(li);
@@ -444,7 +392,7 @@ function renderInfoPane() {
const li = document.createElement("li");
li.innerHTML = `
<span class="swatch"></span>
<span>#${i + 1} (${p.x.toFixed(1)}, ${p.y.toFixed(1)})</span>
<span>#${i + 1} (${Math.round(p.x)}, ${Math.round(p.y)})</span>
<button title="Remove">×</button>
`;
li.querySelector("button").onclick = () => removePoint("route", i);
@@ -463,6 +411,85 @@ function escapeHtml(s) {
);
}
/* --- 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();
}
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 }));
if (state.history.length > 200) state.history.shift();
@@ -494,21 +521,12 @@ function clearCurrent() {
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);
const payload = { d: state.current.id, f: state.floorIndex, r: state.routes, p: state.pulls };
const hash = "#" + compress(JSON.stringify(payload));
if (location.hash !== hash) {
history.replaceState(null, "", location.pathname + location.search + hash);
}
}
@@ -516,11 +534,9 @@ function loadFromHash() {
const h = location.hash.slice(1);
if (!h) return;
try {
const json = decompressFromUrl(h);
const p = JSON.parse(json);
const p = JSON.parse(decompress(h));
if (p.d) {
const [expansion, id] = p.d.split("/");
const d = state.dungeons.find((x) => x.id === id && x.expansion === expansion);
const d = state.dungeons.find((x) => x.id === p.d);
if (d) {
state.current = d;
state.floorIndex = p.f || 0;
@@ -534,11 +550,10 @@ function loadFromHash() {
}
}
// Tiny base64-of-utf8 — small payloads make for OK-length URLs.
function compressForUrl(s) {
function compress(s) {
return btoa(unescape(encodeURIComponent(s))).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
}
function decompressFromUrl(s) {
function decompress(s) {
s = s.replace(/-/g, "+").replace(/_/g, "/");
while (s.length % 4) s += "=";
return decodeURIComponent(escape(atob(s)));
@@ -554,11 +569,11 @@ function exportJson() {
const d = state.current;
if (!d) return;
const payload = {
dungeon: { id: d.id, expansion: d.expansion, name: d.name },
dungeon: { id: d.id, 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}`] || [],
route: state.routes[`${d.id}::${i}`] || [],
pulls: state.pulls[`${d.id}::${i}`] || [],
})),
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
@@ -583,11 +598,15 @@ function toast(msg) {
toast._h = setTimeout(() => t.classList.remove("show"), 1500);
}
/* --- events -------------------------------------------------------------- */
function setTool(tool) {
state.tool = tool;
$("tool-route").classList.toggle("active", tool === "route");
$("tool-pull").classList.toggle("active", tool === "pull");
}
function hookEvents() {
$("search").addEventListener("input", renderDungeonList);
$("expansion-filter").addEventListener("change", renderDungeonList);
const ef = $("expansion-filter"); if (ef) ef.addEventListener("change", renderDungeonList);
$("undo").addEventListener("click", undo);
$("clear").addEventListener("click", clearCurrent);
$("share").addEventListener("click", shareUrl);
@@ -604,12 +623,6 @@ function hookEvents() {
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>`;
+177513 -2217
View File
File diff suppressed because it is too large Load Diff