Files
mplus-routes/web/app.js
T
florian.berthold 71bef7b8ff instant-hover tooltip + always-visible note labels
- replaced SVG <title> tooltips (which use the OS dwell-delay) with an instant custom tooltip rendered in #custom-tooltip
- note pins now also render their text inline next to the icon, so a route can be read at a glance without hovering each pin (truncated at 80 chars in the SVG; full text still in the tooltip)
2026-04-25 23:01:43 +02:00

929 lines
29 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.
// Renders keystone.guru-derived data (enemies, packs, patrols, icons) on
// stitched z=4 dungeon tiles. All overlays live in image-pixel space; the
// canvas-stage CSS transform handles pan/zoom uniformly.
const SVG_NS = "http://www.w3.org/2000/svg";
const state = {
dungeons: [],
current: null,
floorIndex: 0,
tool: "route", // "route" | "pull" | "note"
routes: {}, // { key: [{x,y}] } image-pixel coords
pulls: {}, // { key: [{x,y}] }
notes: {}, // { key: [{x,y,text}] }
history: [],
drag: null,
view: { scale: 1, tx: 0, ty: 0 },
pan: null,
show: { enemies: true, packs: true, patrols: true, icons: true },
};
const $ = (id) => document.getElementById(id);
async function init() {
const r = await fetch("assets/dungeons.json", { cache: "no-cache" });
state.dungeons = (await r.json()).dungeons;
populateExpansionFilter();
renderDungeonList();
hookEvents();
loadFromHash();
if (!state.current && state.dungeons.length) {
selectDungeon(state.dungeons[0].id);
}
}
function populateExpansionFilter() {
const sel = $("expansion-filter");
sel.innerHTML = '<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();
ul.innerHTML = "";
for (const d of state.dungeons.filter((d) =>
!q || d.name.toLowerCase().includes(q) || (d.acronym || "").toLowerCase().includes(q)
)) {
const li = document.createElement("li");
li.dataset.id = d.id;
if (state.current && d.id === state.current.id) li.classList.add("active");
const name = document.createElement("span");
name.textContent = d.name;
const acr = document.createElement("span");
acr.className = "acronym";
acr.textContent = d.acronym || "";
li.append(name, acr);
li.onclick = () => selectDungeon(d.id);
ul.appendChild(li);
}
}
function selectDungeon(id) {
const d = state.dungeons.find((x) => x.id === id);
if (!d) return;
state.current = d;
state.floorIndex = 0;
renderDungeonList();
renderViewer();
updateHash();
}
function currentKey() {
return state.current ? `${state.current.id}::${state.floorIndex}` : null;
}
function currentMap() {
return state.current?.maps[state.floorIndex] || null;
}
function renderViewer() {
const d = state.current;
if (!d) return;
$("dungeon-name").textContent = d.name;
const m = currentMap();
const meta = [
d.acronym, d.levelRange && `Lv ${d.levelRange}`,
m && `${m.enemies?.length ?? 0} enemies`,
m && `${m.packs?.length ?? 0} packs`,
].filter(Boolean).join(" · ");
$("dungeon-meta").textContent = meta;
const tabs = $("floor-tabs");
tabs.innerHTML = "";
if (d.maps.length > 1) {
d.maps.forEach((mp, i) => {
const b = document.createElement("button");
b.textContent = humanizeLabel(mp.label);
if (i === state.floorIndex) b.classList.add("active");
b.onclick = () => { state.floorIndex = i; renderViewer(); updateHash(); };
tabs.appendChild(b);
});
}
const img = $("map-img");
if (m) {
img.src = "assets/" + m.image;
img.width = m.width;
img.height = m.height;
const svg = $("overlay");
svg.setAttribute("width", m.width);
svg.setAttribute("height", m.height);
img.onload = () => { fitToScreen(); renderOverlay(); };
if (img.complete) fitToScreen();
} else {
img.removeAttribute("src");
}
renderOverlay();
renderInfoPane();
}
function humanizeLabel(s) {
return s
.replace(/_floor(\d+)$/, " · floor $1")
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
/* --- overlay rendering --------------------------------------------------- */
function renderOverlay() {
const svg = $("overlay");
svg.innerHTML = "";
const m = currentMap();
if (!m) return;
svg.setAttribute("viewBox", `0 0 ${m.width} ${m.height}`);
svg.setAttribute("preserveAspectRatio", "none");
const key = currentKey();
const wps = state.routes[key] || [];
const pulls = state.pulls[key] || [];
// Pack polygons (under enemies)
if (state.show.packs && m.packs) {
for (const p of m.packs) {
if (p.vertices.length < 3) continue;
const poly = document.createElementNS(SVG_NS, "polygon");
poly.setAttribute("class", "pack");
poly.setAttribute("points", p.vertices.map(([x, y]) => `${x},${y}`).join(" "));
poly.setAttribute("fill", p.color);
poly.setAttribute("fill-opacity", "0.18");
poly.setAttribute("stroke", p.color);
poly.setAttribute("stroke-width", "4");
poly.setAttribute("stroke-opacity", "0.65");
svg.appendChild(poly);
}
}
// Patrol polylines
if (state.show.patrols && m.patrols) {
for (const pa of m.patrols) {
if (pa.vertices.length < 2) continue;
const line = document.createElementNS(SVG_NS, "polyline");
line.setAttribute("class", "patrol");
line.setAttribute("points", pa.vertices.map(([x, y]) => `${x},${y}`).join(" "));
line.setAttribute("fill", "none");
line.setAttribute("stroke", pa.color);
line.setAttribute("stroke-width", String((pa.weight || 3) * 4));
line.setAttribute("stroke-dasharray", "12 8");
line.setAttribute("opacity", "0.7");
svg.appendChild(line);
}
}
// Enemies (smallest pins first; bosses on top)
if (state.show.enemies && m.enemies) {
const ranked = [...m.enemies].sort(
(a, b) => (a.classification || 0) - (b.classification || 0)
);
for (const e of ranked) {
svg.appendChild(makeEnemyPin(e));
}
}
// Map icons (skull, comment, door, etc.)
if (state.show.icons && m.icons) {
for (const ic of m.icons) {
svg.appendChild(makeIconMarker(ic));
}
}
// Route polyline
if (wps.length > 1) {
const path = document.createElementNS(SVG_NS, "polyline");
path.setAttribute("class", "route-line");
path.setAttribute("points", wps.map((p) => `${p.x},${p.y}`).join(" "));
svg.appendChild(path);
}
// Pull markers + waypoints (above everything)
pulls.forEach((p, i) => svg.appendChild(makeUserPin(p.x, p.y, i + 1, "pull", "pull", i)));
wps.forEach((p, i) => svg.appendChild(makeUserPin(p.x, p.y, i + 1, "waypoint", "route", i)));
// User notes (info icons with hover tooltip)
const notes = state.notes[key] || [];
notes.forEach((n, i) => svg.appendChild(makeNotePin(n, i)));
svg.onclick = onCanvasClick;
}
function makeNotePin(note, idx) {
const g = document.createElementNS(SVG_NS, "g");
g.setAttribute("class", "note");
g.setAttribute("transform", `translate(${note.x},${note.y})`);
// Yellow info pin with "i" glyph
const c = document.createElementNS(SVG_NS, "circle");
c.setAttribute("r", 22);
c.setAttribute("fill", "#f0c674");
c.setAttribute("stroke", "#1a1208");
c.setAttribute("stroke-width", "3");
g.appendChild(c);
const i = document.createElementNS(SVG_NS, "text");
i.setAttribute("y", 9);
i.setAttribute("font-size", "26");
i.setAttribute("text-anchor", "middle");
i.setAttribute("fill", "#1a1208");
i.setAttribute("font-weight", "900");
i.setAttribute("font-style", "italic");
i.setAttribute("font-family", "Georgia, serif");
i.textContent = "i";
g.appendChild(i);
// Always-visible label: text rendered next to the pin in image-pixel
// space, with a dark stroke for readability over the parchment.
if (note.text) {
const label = document.createElementNS(SVG_NS, "text");
label.setAttribute("class", "note-label");
label.setAttribute("x", 30); // sits to the right of the circle
label.setAttribute("y", 10);
label.setAttribute("font-size", "28");
label.setAttribute("font-family", "system-ui, sans-serif");
label.setAttribute("font-weight", "600");
label.setAttribute("fill", "#fff7d6");
label.setAttribute("stroke", "#1a1208");
label.setAttribute("stroke-width", "5");
label.setAttribute("paint-order", "stroke");
// Truncate very long text on the SVG side to ~80 chars so it doesn't
// overflow the map. The full text is still in the data tooltip.
const truncated = note.text.length > 80 ? note.text.slice(0, 77) + "..." : note.text;
label.textContent = truncated;
g.appendChild(label);
}
// Custom-tooltip data (instant, used by ensureTooltip).
g.dataset.tooltip = note.text || "(empty)";
// drag-to-move + right-click delete + double-click to edit text
g.addEventListener("pointerdown", (e) => {
e.stopPropagation();
state.drag = { kind: "note", idx };
g.classList.add("dragging");
g.setPointerCapture(e.pointerId);
});
g.addEventListener("pointermove", (e) => {
if (!state.drag || state.drag.kind !== "note" || state.drag.idx !== idx) return;
const pt = svgPointFromEvent(e);
const arr = state.notes[currentKey()];
arr[idx].x = pt.x; arr[idx].y = pt.y;
g.setAttribute("transform", `translate(${pt.x},${pt.y})`);
});
g.addEventListener("pointerup", () => {
if (!state.drag) return;
state.drag = null;
g.classList.remove("dragging");
pushHistory();
updateHash();
});
g.addEventListener("contextmenu", (e) => {
e.preventDefault();
removeNote(idx);
});
g.addEventListener("dblclick", (e) => {
e.preventDefault();
e.stopPropagation();
const txt = prompt("Edit note text", note.text || "");
if (txt !== null) {
state.notes[currentKey()][idx].text = txt;
pushHistory();
renderOverlay();
updateHash();
}
});
return g;
}
function removeNote(idx) {
const key = currentKey();
const arr = state.notes[key];
if (!arr) return;
arr.splice(idx, 1);
pushHistory();
renderOverlay();
updateHash();
}
// kg classification IDs:
// 1 = minor (NPCs, ambient mobs)
// 2 = standard trash
// 3 = named boss
// 4 = final / "raid" boss
// 5 = rare elite (silver-dragon)
const CLASS_RADIUS = { 1: 14, 2: 18, 3: 28, 4: 38, 5: 24 };
const CLASS_FILL = {
1: "#9aa1aa", 2: "#d6d6dc", 3: "#d63b3b", 4: "#ffd83a", 5: "#bfd6f0",
};
function isBossLike(cls) {
return cls === 3 || cls === 4 || cls === 5;
}
function makeEnemyPin(e) {
const cls = e.classification || 1;
const r = CLASS_RADIUS[cls] ?? 14;
const fill = CLASS_FILL[cls] ?? "#9aa1aa";
const g = document.createElementNS(SVG_NS, "g");
g.setAttribute("class", `enemy enemy-c${cls}`);
g.setAttribute("transform", `translate(${e.pos[0]},${e.pos[1]})`);
const c = document.createElementNS(SVG_NS, "circle");
c.setAttribute("r", r);
c.setAttribute("fill", fill);
c.setAttribute("stroke", cls === 5 ? "#3b6db0" : "#000");
c.setAttribute("stroke-width", cls === 5 ? "4" : "3");
g.appendChild(c);
if (isBossLike(cls)) {
const t = document.createElementNS(SVG_NS, "text");
t.setAttribute("y", r * 0.35);
t.setAttribute("font-size", String(Math.round(r * 1.6)));
t.setAttribute("text-anchor", "middle");
t.setAttribute("fill", cls === 5 ? "#1a3360" : "#1a0000");
t.setAttribute("font-weight", "900");
t.textContent = "☠";
g.appendChild(t);
}
const tag = cls === 5 ? " (rare)" : (e.skippable ? " (skippable)" : "");
g.dataset.tooltip = e.name + tag;
return g;
}
function 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) {
g.dataset.tooltip = ic.comment;
} else if (ic.type) {
g.dataset.tooltip = ic.type;
}
return g;
}
function makeUserPin(x, y, label, cssClass, kind, idx) {
const g = document.createElementNS(SVG_NS, "g");
g.setAttribute("class", cssClass);
g.setAttribute("transform", `translate(${x},${y})`);
const c = document.createElementNS(SVG_NS, "circle");
c.setAttribute("r", 26);
g.appendChild(c);
const t = document.createElementNS(SVG_NS, "text");
t.setAttribute("y", 9);
t.setAttribute("font-size", "26");
t.textContent = label;
g.appendChild(t);
g.addEventListener("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 pt = svgPointFromEvent(e);
const arr = kind === "route" ? state.routes[currentKey()] : state.pulls[currentKey()];
arr[idx] = { x: pt.x, y: pt.y };
g.setAttribute("transform", `translate(${pt.x},${pt.y})`);
if (kind === "route") {
const polyline = document.querySelector("#overlay .route-line");
if (polyline) {
polyline.setAttribute(
"points",
state.routes[currentKey()].map((p) => `${p.x},${p.y}`).join(" ")
);
}
}
});
g.addEventListener("pointerup", () => {
if (!state.drag) return;
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(pt);
} else if (state.tool === "pull") {
state.pulls[key] = state.pulls[key] || [];
state.pulls[key].push(pt);
} else if (state.tool === "note") {
const text = prompt("Note text:", "");
if (text === null) return; // cancel
state.notes[key] = state.notes[key] || [];
state.notes[key].push({ x: pt.x, y: pt.y, text: text || "" });
}
pushHistory();
renderOverlay();
renderInfoPane();
updateHash();
}
function svgPointFromEvent(e) {
// Convert client coords → image-pixel coords (the SVG's own coord system,
// which matches the underlying webp's natural dimensions). Integer
// pixels are plenty for routing — sub-pixel precision just bloats URLs.
const m = currentMap();
const svg = $("overlay");
const rect = svg.getBoundingClientRect();
const x = Math.round(((e.clientX - rect.left) / rect.width) * m.width);
const y = Math.round(((e.clientY - rect.top) / rect.height) * m.height);
return { x, y };
}
function removePoint(kind, idx) {
const key = currentKey();
const arr = kind === "route" ? state.routes[key] : state.pulls[key];
if (!arr) return;
arr.splice(idx, 1);
pushHistory();
renderOverlay();
renderInfoPane();
updateHash();
}
/* --- info pane ----------------------------------------------------------- */
function renderInfoPane() {
const m = currentMap();
const bossUl = $("boss-list");
bossUl.innerHTML = "";
if (m) {
const bossLike = (m.enemies || []).filter((e) => isBossLike(e.classification || 0));
// Sort by classification desc (final → boss → rare), name asc
bossLike.sort((a, b) =>
(b.classification || 0) - (a.classification || 0) || a.name.localeCompare(b.name)
);
// De-dupe by name (rare + non-rare variants share names)
const seen = new Set();
for (const e of bossLike) {
if (seen.has(e.name)) continue;
seen.add(e.name);
const cls = e.classification || 0;
const tag = cls === 5 ? "rare" : cls === 4 ? "final" : "";
const li = document.createElement("li");
const swatchCls = cls === 5 ? "rare" : "boss";
li.innerHTML = `<span class="swatch ${swatchCls}"></span><span>${escapeHtml(e.name)}</span>`
+ (tag ? ` <span class="tag">${tag}</span>` : "");
bossUl.appendChild(li);
}
if (!seen.size) {
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} (${Math.round(p.x)}, ${Math.round(p.y)})</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])
);
}
/* --- pan / zoom ---------------------------------------------------------- */
function applyView() {
$("canvas-stage").style.transform =
`translate(${state.view.tx}px, ${state.view.ty}px) scale(${state.view.scale})`;
}
function fitToScreen() {
const m = currentMap();
const host = $("canvas-host");
if (!m || !host) return;
const pad = 24;
const sx = (host.clientWidth - pad * 2) / m.width;
const sy = (host.clientHeight - pad * 2) / m.height;
const s = Math.min(sx, sy);
state.view.scale = s;
state.view.tx = -(m.width * s) / 2;
state.view.ty = -(m.height * s) / 2;
applyView();
}
function zoomBy(factor, anchorX, anchorY) {
const host = $("canvas-host");
const rect = host.getBoundingClientRect();
const ax = anchorX ?? rect.width / 2;
const ay = anchorY ?? rect.height / 2;
const cx = rect.width / 2 + state.view.tx;
const cy = rect.height / 2 + state.view.ty;
const dx = ax - cx; const dy = ay - cy;
const before = state.view.scale;
const after = Math.max(0.05, Math.min(20, before * factor));
const ratio = after / before;
state.view.scale = after;
state.view.tx -= dx * (ratio - 1);
state.view.ty -= dy * (ratio - 1);
applyView();
}
/* --- instant tooltip ------------------------------------------------------
* SVG <title> uses the OS tooltip with a long delay. We render our own div
* so it shows the moment the cursor enters a pin/icon. Driven by a single
* pointer listener on #overlay that walks up to find any [data-tooltip].
*/
function ensureTooltip() {
let el = document.getElementById("custom-tooltip");
if (el) return el;
el = document.createElement("div");
el.id = "custom-tooltip";
el.className = "custom-tooltip";
document.body.appendChild(el);
return el;
}
function hookTooltip() {
const tip = ensureTooltip();
const host = $("canvas-host");
let activeText = "";
host.addEventListener("pointermove", (e) => {
const target = e.target.closest("[data-tooltip]");
if (!target) {
tip.classList.remove("show");
activeText = "";
return;
}
const text = target.dataset.tooltip;
if (!text) {
tip.classList.remove("show");
return;
}
if (text !== activeText) {
tip.textContent = text;
activeText = text;
}
// Position near cursor, but keep on-screen
const pad = 14;
let x = e.clientX + pad;
let y = e.clientY + pad;
const w = tip.offsetWidth, h = tip.offsetHeight;
if (x + w + pad > window.innerWidth) x = e.clientX - w - pad;
if (y + h + pad > window.innerHeight) y = e.clientY - h - pad;
tip.style.left = x + "px";
tip.style.top = y + "px";
tip.classList.add("show");
});
host.addEventListener("pointerleave", () => {
tip.classList.remove("show");
activeText = "";
});
}
function hookCanvasPanZoom() {
const host = $("canvas-host");
host.addEventListener("wheel", (e) => {
e.preventDefault();
const rect = host.getBoundingClientRect();
zoomBy(e.deltaY < 0 ? 1.15 : 1 / 1.15, e.clientX - rect.left, e.clientY - rect.top);
}, { passive: false });
host.addEventListener("pointerdown", (e) => {
if (e.target.closest("g.waypoint, g.pull")) return;
const onSvg = e.target.closest("#overlay");
const isPanIntent = e.button === 1 || e.shiftKey || e.button === 2 || !onSvg;
if (!isPanIntent) return;
e.preventDefault();
state.pan = { x0: e.clientX, y0: e.clientY, tx0: state.view.tx, ty0: state.view.ty };
host.classList.add("panning");
host.setPointerCapture(e.pointerId);
});
host.addEventListener("pointermove", (e) => {
if (!state.pan) return;
state.view.tx = state.pan.tx0 + (e.clientX - state.pan.x0);
state.view.ty = state.pan.ty0 + (e.clientY - state.pan.y0);
applyView();
});
host.addEventListener("pointerup", () => {
state.pan = null;
host.classList.remove("panning");
});
host.addEventListener("contextmenu", (e) => {
if (state.pan) e.preventDefault();
});
$("zoom-in").addEventListener("click", () => zoomBy(1.25));
$("zoom-out").addEventListener("click", () => zoomBy(1 / 1.25));
$("zoom-reset").addEventListener("click", fitToScreen);
window.addEventListener("resize", () => fitToScreen());
}
/* --- history / share / export ------------------------------------------- */
function pushHistory() {
state.history.push(JSON.stringify({
routes: state.routes, pulls: state.pulls, notes: state.notes,
}));
if (state.history.length > 200) state.history.shift();
}
function undo() {
state.history.pop();
const last = state.history[state.history.length - 1];
if (last) {
const s = JSON.parse(last);
state.routes = s.routes;
state.pulls = s.pulls;
state.notes = s.notes || {};
} else {
state.routes = {};
state.pulls = {};
state.notes = {};
}
renderOverlay();
renderInfoPane();
updateHash();
}
function clearCurrent() {
const key = currentKey();
const r = (state.routes[key] || []).length;
const p = (state.pulls[key] || []).length;
const n = (state.notes[key] || []).length;
const total = r + p + n;
if (!total) return;
const summary = [
r ? `${r} waypoint${r === 1 ? "" : "s"}` : null,
p ? `${p} pull marker${p === 1 ? "" : "s"}` : null,
n ? `${n} note${n === 1 ? "" : "s"}` : null,
].filter(Boolean).join(", ");
if (!confirm(`Clear ${summary} on this floor? This can be undone with Undo.`)) return;
delete state.routes[key];
delete state.pulls[key];
delete state.notes[key];
pushHistory();
renderOverlay();
renderInfoPane();
updateHash();
}
/* ----- URL hash share format -----
* v1 (legacy): base64url of JSON with full {x,y} objects, no prefix.
* v2 (current): "~" + base64url of deflate-raw'd JSON. The waypoint
* arrays are flattened to [x1,y1,x2,y2,...] (ints) before compression to
* shave another ~30% before deflate runs.
*
* On a typical 30-pull RFK route, v1 produced ~3500 char URLs; v2 lands
* around 400-700 chars.
*/
const HASH_PREFIX = "~";
function flattenPins(map) {
const out = {};
for (const k of Object.keys(map || {})) {
const flat = [];
for (const p of map[k]) flat.push(Math.round(p.x), Math.round(p.y));
out[k] = flat;
}
return out;
}
function inflatePins(map) {
const out = {};
for (const k of Object.keys(map || {})) {
const flat = map[k];
const arr = [];
if (Array.isArray(flat) && flat.length && typeof flat[0] === "object") {
// Backward compat: legacy {x,y} objects, just round into ints.
for (const p of flat) arr.push({ x: Math.round(p.x), y: Math.round(p.y) });
} else {
for (let i = 0; i + 1 < flat.length; i += 2) arr.push({ x: flat[i], y: flat[i + 1] });
}
out[k] = arr;
}
return out;
}
async function encodePayload(p) {
const json = JSON.stringify(p);
if (typeof CompressionStream === "undefined") {
return base64UrlEncodeStr(json);
}
const stream = new Blob([json]).stream().pipeThrough(new CompressionStream("deflate-raw"));
const buf = new Uint8Array(await new Response(stream).arrayBuffer());
return HASH_PREFIX + base64UrlEncodeBytes(buf);
}
async function decodePayload(s) {
if (s.startsWith(HASH_PREFIX) && typeof DecompressionStream !== "undefined") {
const buf = base64UrlDecodeBytes(s.slice(1));
const stream = new Blob([buf]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
return JSON.parse(await new Response(stream).text());
}
// Legacy: plain base64url JSON
return JSON.parse(base64UrlDecodeStr(s));
}
function base64UrlEncodeStr(s) {
return btoa(unescape(encodeURIComponent(s)))
.replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
}
function base64UrlDecodeStr(s) {
s = s.replace(/-/g, "+").replace(/_/g, "/");
while (s.length % 4) s += "=";
return decodeURIComponent(escape(atob(s)));
}
function base64UrlEncodeBytes(bytes) {
let bin = "";
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
}
function base64UrlDecodeBytes(s) {
s = s.replace(/-/g, "+").replace(/_/g, "/");
while (s.length % 4) s += "=";
const bin = atob(s);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function flattenNotes(map) {
// Notes carry a text payload, so they can't use the int-pair shortcut.
// Round coords to ints; keep text verbatim.
const out = {};
for (const k of Object.keys(map || {})) {
out[k] = map[k].map((n) => [Math.round(n.x), Math.round(n.y), n.text || ""]);
}
return out;
}
function inflateNotes(map) {
const out = {};
for (const k of Object.keys(map || {})) {
out[k] = map[k].map((n) => ({ x: n[0], y: n[1], text: n[2] || "" }));
}
return out;
}
async function updateHash() {
if (!state.current) return;
const payload = {
d: state.current.id,
f: state.floorIndex,
r: flattenPins(state.routes),
p: flattenPins(state.pulls),
n: flattenNotes(state.notes),
};
const encoded = await encodePayload(payload);
const hash = "#" + encoded;
if (location.hash !== hash) {
history.replaceState(null, "", location.pathname + location.search + hash);
}
}
async function loadFromHash() {
const h = location.hash.slice(1);
if (!h) return;
try {
const p = await decodePayload(h);
if (!p.d) return;
const d = state.dungeons.find((x) => x.id === p.d);
if (!d) return;
state.current = d;
state.floorIndex = p.f || 0;
state.routes = inflatePins(p.r);
state.pulls = inflatePins(p.p);
state.notes = inflateNotes(p.n);
renderViewer();
} catch (e) {
console.warn("hash parse failed", e);
}
}
async function shareUrl() {
await updateHash();
navigator.clipboard?.writeText(location.href);
toast("Link copied");
}
function exportJson() {
const d = state.current;
if (!d) return;
const payload = {
dungeon: { id: d.id, name: d.name },
floors: d.maps.map((m, i) => ({
label: m.label,
route: state.routes[`${d.id}::${i}`] || [],
pulls: state.pulls[`${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);
}
function setTool(tool) {
state.tool = tool;
for (const t of ["route", "pull", "note"]) {
const btn = $(`tool-${t}`);
if (btn) btn.classList.toggle("active", tool === t);
}
}
function hookEvents() {
$("search").addEventListener("input", renderDungeonList);
const ef = $("expansion-filter"); if (ef) ef.addEventListener("change", renderDungeonList);
$("undo").addEventListener("click", undo);
$("clear").addEventListener("click", clearCurrent);
$("share").addEventListener("click", shareUrl);
$("export").addEventListener("click", exportJson);
$("tool-route").addEventListener("click", () => setTool("route"));
$("tool-pull").addEventListener("click", () => setTool("pull"));
const noteBtn = $("tool-note");
if (noteBtn) noteBtn.addEventListener("click", () => setTool("note"));
document.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "z") {
e.preventDefault();
undo();
}
});
window.addEventListener("hashchange", loadFromHash);
hookCanvasPanZoom();
hookTooltip();
}
init().catch((e) => {
console.error(e);
document.body.innerHTML = `<pre style="color:#d63b3b;padding:20px">init failed: ${e.message}</pre>`;
});