f8c5157c80
- exportJson now includes per-floor notes and texts arrays alongside route/pulls; bumps a version field for forward-compat - new importJson: file picker, parses JSON, resolves dungeon by id against current build, replaces this dungeon's pins on every floor present in the file - Import button next to Export in the toolbar
1072 lines
34 KiB
JavaScript
1072 lines
34 KiB
JavaScript
// 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" | "text"
|
||
routes: {}, // { key: [{x,y}] } image-pixel coords
|
||
pulls: {}, // { key: [{x,y}] }
|
||
notes: {}, // { key: [{x,y,text}] } — `i` pin, hover-only
|
||
texts: {}, // { key: [{x,y,text}] } — always-visible label, no icon
|
||
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)));
|
||
|
||
// Always-visible freetext labels
|
||
const texts = state.texts[key] || [];
|
||
texts.forEach((it, i) => svg.appendChild(makeTextLabel(it, 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);
|
||
|
||
// Custom-tooltip data — text only shows on hover for the (i) note.
|
||
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();
|
||
}
|
||
|
||
/* --- always-visible freetext labels -------------------------------------- */
|
||
function makeTextLabel(item, idx) {
|
||
const g = document.createElementNS(SVG_NS, "g");
|
||
g.setAttribute("class", "freetext");
|
||
g.setAttribute("transform", `translate(${item.x},${item.y})`);
|
||
|
||
const text = item.text || "";
|
||
// Background plate so light/dark areas don't both kill readability.
|
||
// We measure after first paint via getBBox; provide an initial backdrop
|
||
// via stroke on the text itself (paint-order: stroke).
|
||
const t = document.createElementNS(SVG_NS, "text");
|
||
t.setAttribute("class", "freetext-label");
|
||
t.setAttribute("font-size", "32");
|
||
t.setAttribute("font-family", "system-ui, sans-serif");
|
||
t.setAttribute("font-weight", "700");
|
||
t.setAttribute("fill", "#fff7d6");
|
||
t.setAttribute("stroke", "#1a1208");
|
||
t.setAttribute("stroke-width", "6");
|
||
t.setAttribute("paint-order", "stroke");
|
||
t.setAttribute("alignment-baseline", "middle");
|
||
t.setAttribute("dominant-baseline", "middle");
|
||
t.setAttribute("y", 8);
|
||
t.textContent = text || "(empty)";
|
||
g.appendChild(t);
|
||
|
||
// Drag handle — invisible rect over the text bounding region. We can't
|
||
// size it without measurement, so use a roomy default (will be replaced
|
||
// by getBBox after insertion).
|
||
const hit = document.createElementNS(SVG_NS, "rect");
|
||
hit.setAttribute("class", "freetext-hit");
|
||
hit.setAttribute("x", -4); hit.setAttribute("y", -22);
|
||
hit.setAttribute("width", Math.max(40, text.length * 16));
|
||
hit.setAttribute("height", 44);
|
||
hit.setAttribute("fill", "transparent");
|
||
g.appendChild(hit);
|
||
|
||
// Drag / right-click delete / dbl-click edit
|
||
g.addEventListener("pointerdown", (e) => {
|
||
e.stopPropagation();
|
||
state.drag = { kind: "text", idx };
|
||
g.classList.add("dragging");
|
||
g.setPointerCapture(e.pointerId);
|
||
});
|
||
g.addEventListener("pointermove", (e) => {
|
||
if (!state.drag || state.drag.kind !== "text" || state.drag.idx !== idx) return;
|
||
const pt = svgPointFromEvent(e);
|
||
const arr = state.texts[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();
|
||
removeText(idx);
|
||
});
|
||
g.addEventListener("dblclick", (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const txt = prompt("Edit label", item.text || "");
|
||
if (txt !== null) {
|
||
state.texts[currentKey()][idx].text = txt;
|
||
pushHistory();
|
||
renderOverlay();
|
||
updateHash();
|
||
}
|
||
});
|
||
return g;
|
||
}
|
||
|
||
function removeText(idx) {
|
||
const key = currentKey();
|
||
const arr = state.texts[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 (shown on hover):", "");
|
||
if (text === null) return; // cancel
|
||
state.notes[key] = state.notes[key] || [];
|
||
state.notes[key].push({ x: pt.x, y: pt.y, text: text || "" });
|
||
} else if (state.tool === "text") {
|
||
const text = prompt("Label text (always visible):", "");
|
||
if (text === null) return; // cancel
|
||
state.texts[key] = state.texts[key] || [];
|
||
state.texts[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) =>
|
||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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, texts: state.texts,
|
||
}));
|
||
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 || {};
|
||
state.texts = s.texts || {};
|
||
} else {
|
||
state.routes = {};
|
||
state.pulls = {};
|
||
state.notes = {};
|
||
state.texts = {};
|
||
}
|
||
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 t = (state.texts[key] || []).length;
|
||
const total = r + p + n + t;
|
||
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,
|
||
t ? `${t} label${t === 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];
|
||
delete state.texts[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), // hover-only (i) notes
|
||
t: flattenNotes(state.texts), // always-visible labels
|
||
};
|
||
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);
|
||
state.texts = inflateNotes(p.t);
|
||
renderViewer();
|
||
} catch (e) {
|
||
console.warn("hash parse failed", e);
|
||
}
|
||
}
|
||
|
||
async function shareUrl() {
|
||
await updateHash();
|
||
navigator.clipboard?.writeText(location.href);
|
||
toast("Link copied");
|
||
}
|
||
|
||
const EXPORT_VERSION = 1;
|
||
|
||
function exportJson() {
|
||
const d = state.current;
|
||
if (!d) return;
|
||
const payload = {
|
||
version: EXPORT_VERSION,
|
||
dungeon: { id: d.id, name: d.name },
|
||
floors: d.maps.map((m, i) => {
|
||
const k = `${d.id}::${i}`;
|
||
return {
|
||
index: i,
|
||
label: m.label,
|
||
route: state.routes[k] || [],
|
||
pulls: state.pulls[k] || [],
|
||
notes: state.notes[k] || [],
|
||
texts: state.texts[k] || [],
|
||
};
|
||
}),
|
||
};
|
||
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 importJson() {
|
||
const input = document.createElement("input");
|
||
input.type = "file";
|
||
input.accept = "application/json,.json";
|
||
input.onchange = async () => {
|
||
const file = input.files?.[0];
|
||
if (!file) return;
|
||
let payload;
|
||
try {
|
||
const text = await file.text();
|
||
payload = JSON.parse(text);
|
||
} catch (e) {
|
||
alert("Could not parse JSON: " + e.message);
|
||
return;
|
||
}
|
||
if (!payload || !payload.dungeon || !payload.dungeon.id || !Array.isArray(payload.floors)) {
|
||
alert("Not a recognized route file (missing dungeon/floors).");
|
||
return;
|
||
}
|
||
const d = state.dungeons.find((x) => x.id === payload.dungeon.id);
|
||
if (!d) {
|
||
alert(`Dungeon "${payload.dungeon.id}" is not in this build's data set.`);
|
||
return;
|
||
}
|
||
// Replace this dungeon's data on every floor present in the file.
|
||
const cleanPin = (p) => ({ x: Math.round(p.x), y: Math.round(p.y) });
|
||
const cleanNote = (n) => ({
|
||
x: Math.round(n.x), y: Math.round(n.y), text: String(n.text ?? ""),
|
||
});
|
||
for (const f of payload.floors) {
|
||
const i = Number.isInteger(f.index) ? f.index : 0;
|
||
const k = `${d.id}::${i}`;
|
||
state.routes[k] = (f.route || []).filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y)).map(cleanPin);
|
||
state.pulls[k] = (f.pulls || []).filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y)).map(cleanPin);
|
||
state.notes[k] = (f.notes || []).filter((n) => n && Number.isFinite(n.x) && Number.isFinite(n.y)).map(cleanNote);
|
||
state.texts[k] = (f.texts || []).filter((n) => n && Number.isFinite(n.x) && Number.isFinite(n.y)).map(cleanNote);
|
||
}
|
||
state.current = d;
|
||
state.floorIndex = 0;
|
||
pushHistory();
|
||
renderDungeonList();
|
||
renderViewer();
|
||
updateHash();
|
||
toast(`Imported ${payload.floors.length} floor${payload.floors.length === 1 ? "" : "s"} of ${d.name}`);
|
||
};
|
||
input.click();
|
||
}
|
||
|
||
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", "text"]) {
|
||
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);
|
||
const importBtn = $("import");
|
||
if (importBtn) importBtn.addEventListener("click", importJson);
|
||
$("tool-route").addEventListener("click", () => setTool("route"));
|
||
$("tool-pull").addEventListener("click", () => setTool("pull"));
|
||
const noteBtn = $("tool-note");
|
||
if (noteBtn) noteBtn.addEventListener("click", () => setTool("note"));
|
||
const textBtn = $("tool-text");
|
||
if (textBtn) textBtn.addEventListener("click", () => setTool("text"));
|
||
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>`;
|
||
});
|