e11dc1eed5
- atlasloot_extras: fit one transform per (kg_dungeon, floor_id) instead of mixing all kg bosses into one fit. Each AL extra is assigned to whichever floor's anchors it's nearest to (in AL coord space). Strat's Stonespine now correctly lands on floor 235 (Undead) instead of being hidden because the mixed-floor fit pushed it off. - new data/ascension_overrides.json: per-name position/floor patches for places where Ascension diverges from retail. Seeded with Magistrate Barthilas → moved to the southern courtyard (3498, 3300 on Undead Side) per Ascension spawns. - frontend renders extras only on their assigned floor; previously hard-coded to floor 0. - new layer-toggle checkboxes (Enemies / Packs / Patrols / Icons) in the toolbar — flip patrols off if mob routes are noise for your route.
1213 lines
39 KiB
JavaScript
1213 lines
39 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));
|
||
}
|
||
}
|
||
|
||
// AtlasLoot-derived extras: render only ones that match the current
|
||
// floor. Single-floor dungeons have kg_floor_id null on every extra,
|
||
// so they all render on floor 0.
|
||
if (state.current?.extras) {
|
||
const curFloor = m.kg_floor_id;
|
||
for (const ex of state.current.extras) {
|
||
// If the extra has no floor assignment, show only on floor 0.
|
||
// If it does, show on the matching floor only.
|
||
const fid = ex.kg_floor_id;
|
||
const matches = fid == null ? state.floorIndex === 0 : fid === curFloor;
|
||
if (!matches) continue;
|
||
svg.appendChild(makeExtraPin(ex));
|
||
}
|
||
}
|
||
|
||
// 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
|
||
// Click on a pin must NOT bubble to the SVG (which would add a waypoint
|
||
// / pull / etc. at the same spot, depending on the active tool).
|
||
g.addEventListener("click", (e) => e.stopPropagation());
|
||
g.addEventListener("pointerdown", (e) => {
|
||
e.stopPropagation();
|
||
state.drag = { kind: "note", idx, moved: false, downX: e.clientX, downY: e.clientY };
|
||
g.classList.add("dragging");
|
||
g.setPointerCapture(e.pointerId);
|
||
});
|
||
g.addEventListener("pointermove", (e) => {
|
||
if (!state.drag || state.drag.kind !== "note" || state.drag.idx !== idx) return;
|
||
if (Math.hypot(e.clientX - state.drag.downX, e.clientY - state.drag.downY) > 3) {
|
||
state.drag.moved = true;
|
||
}
|
||
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;
|
||
const moved = state.drag.moved;
|
||
state.drag = null;
|
||
g.classList.remove("dragging");
|
||
if (moved) {
|
||
pushHistory();
|
||
updateHash();
|
||
}
|
||
});
|
||
g.addEventListener("contextmenu", (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
removeNote(idx);
|
||
});
|
||
// Double-click → edit. Works regardless of active tool because the
|
||
// pin captures click + dblclick before the canvas click handler runs.
|
||
g.addEventListener("dblclick", (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
editNote(idx);
|
||
});
|
||
return g;
|
||
}
|
||
|
||
function editNote(idx) {
|
||
const cur = state.notes[currentKey()]?.[idx];
|
||
if (!cur) return;
|
||
const txt = prompt("Edit note text:", cur.text || "");
|
||
if (txt === null) return;
|
||
cur.text = txt;
|
||
pushHistory();
|
||
renderOverlay();
|
||
updateHash();
|
||
}
|
||
|
||
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);
|
||
|
||
// Stop click propagating so the canvas's tool handler doesn't fire when
|
||
// the user is interacting with this label (otherwise editing in Route
|
||
// mode would also drop a waypoint underneath).
|
||
g.addEventListener("click", (e) => e.stopPropagation());
|
||
g.addEventListener("pointerdown", (e) => {
|
||
e.stopPropagation();
|
||
state.drag = { kind: "text", idx, moved: false, downX: e.clientX, downY: e.clientY };
|
||
g.classList.add("dragging");
|
||
g.setPointerCapture(e.pointerId);
|
||
});
|
||
g.addEventListener("pointermove", (e) => {
|
||
if (!state.drag || state.drag.kind !== "text" || state.drag.idx !== idx) return;
|
||
if (Math.hypot(e.clientX - state.drag.downX, e.clientY - state.drag.downY) > 3) {
|
||
state.drag.moved = true;
|
||
}
|
||
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;
|
||
const moved = state.drag.moved;
|
||
state.drag = null;
|
||
g.classList.remove("dragging");
|
||
if (moved) {
|
||
pushHistory();
|
||
updateHash();
|
||
}
|
||
});
|
||
g.addEventListener("contextmenu", (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
removeText(idx);
|
||
});
|
||
g.addEventListener("dblclick", (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
editText(idx);
|
||
});
|
||
return g;
|
||
}
|
||
|
||
function removeText(idx) {
|
||
const key = currentKey();
|
||
const arr = state.texts[key];
|
||
if (!arr) return;
|
||
arr.splice(idx, 1);
|
||
pushHistory();
|
||
renderOverlay();
|
||
updateHash();
|
||
}
|
||
|
||
function editText(idx) {
|
||
const cur = state.texts[currentKey()]?.[idx];
|
||
if (!cur) return;
|
||
const txt = prompt("Edit label:", cur.text || "");
|
||
if (txt === null) return;
|
||
cur.text = txt;
|
||
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 makeExtraPin(ex) {
|
||
// Silver-blue ring with skull glyph for rares; muted square for non-rare
|
||
// interactives (postboxes, summon spots, etc.). Strip the trailing
|
||
// "(Rare)" / "(Rare, Wanders)" tag from the displayed tooltip — we
|
||
// surface "rare" via the visual treatment.
|
||
const g = document.createElementNS(SVG_NS, "g");
|
||
g.setAttribute("class", ex.rare ? "extra rare" : "extra");
|
||
g.setAttribute("transform", `translate(${ex.pos[0]},${ex.pos[1]})`);
|
||
if (ex.rare) {
|
||
const c = document.createElementNS(SVG_NS, "circle");
|
||
c.setAttribute("r", 26);
|
||
c.setAttribute("fill", "#bfd6f0");
|
||
c.setAttribute("stroke", "#3b6db0");
|
||
c.setAttribute("stroke-width", "5");
|
||
g.appendChild(c);
|
||
const t = document.createElementNS(SVG_NS, "text");
|
||
t.setAttribute("y", 11);
|
||
t.setAttribute("font-size", "30");
|
||
t.setAttribute("text-anchor", "middle");
|
||
t.setAttribute("fill", "#1a3360");
|
||
t.setAttribute("font-weight", "900");
|
||
t.textContent = "☠";
|
||
g.appendChild(t);
|
||
} else {
|
||
const r = document.createElementNS(SVG_NS, "rect");
|
||
r.setAttribute("x", -10); r.setAttribute("y", -10);
|
||
r.setAttribute("width", 20); r.setAttribute("height", 20);
|
||
r.setAttribute("fill", "#7e8290");
|
||
r.setAttribute("stroke", "#1a1208");
|
||
r.setAttribute("stroke-width", "2");
|
||
g.appendChild(r);
|
||
}
|
||
g.dataset.tooltip = ex.name + (ex.rare ? "" : "");
|
||
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" || ic.type === "door_locked") {
|
||
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", ic.type === "door_locked" ? "#7a4a1a" : "#B58A3F");
|
||
r.setAttribute("stroke", "#000");
|
||
r.setAttribute("stroke-width", "2");
|
||
g.appendChild(r);
|
||
} else if (ic.type === "start") {
|
||
// Green entry-flag triangle
|
||
const p = document.createElementNS(SVG_NS, "polygon");
|
||
p.setAttribute("points", "-12,-14 14,0 -12,14");
|
||
p.setAttribute("fill", "#6ad17b");
|
||
p.setAttribute("stroke", "#0a2a12");
|
||
p.setAttribute("stroke-width", "2");
|
||
g.appendChild(p);
|
||
} else if (ic.type === "graveyard") {
|
||
// Tombstone: rounded-top rect with a cross
|
||
const r = document.createElementNS(SVG_NS, "rect");
|
||
r.setAttribute("x", -12); r.setAttribute("y", -14);
|
||
r.setAttribute("width", 24); r.setAttribute("height", 28);
|
||
r.setAttribute("rx", 10); r.setAttribute("ry", 10);
|
||
r.setAttribute("fill", "#9aa1aa");
|
||
r.setAttribute("stroke", "#000");
|
||
r.setAttribute("stroke-width", "2");
|
||
g.appendChild(r);
|
||
const t = document.createElementNS(SVG_NS, "text");
|
||
t.setAttribute("y", 6);
|
||
t.setAttribute("font-size", "20");
|
||
t.setAttribute("text-anchor", "middle");
|
||
t.setAttribute("fill", "#1a1208");
|
||
t.setAttribute("font-weight", "900");
|
||
t.textContent = "✝";
|
||
g.appendChild(t);
|
||
} else if (ic.type === "dot_yellow") {
|
||
const c = document.createElementNS(SVG_NS, "circle");
|
||
c.setAttribute("r", 10);
|
||
c.setAttribute("fill", "#f0c674");
|
||
c.setAttribute("stroke", "#1a1208");
|
||
c.setAttribute("stroke-width", "2");
|
||
g.appendChild(c);
|
||
} else if (ic.type === "gateway") {
|
||
const c = document.createElementNS(SVG_NS, "circle");
|
||
c.setAttribute("r", 14);
|
||
c.setAttribute("fill", "#9b59b6");
|
||
c.setAttribute("stroke", "#000");
|
||
c.setAttribute("stroke-width", "2");
|
||
g.appendChild(c);
|
||
}
|
||
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("click", (e) => e.stopPropagation());
|
||
g.addEventListener("pointerdown", (e) => {
|
||
e.stopPropagation();
|
||
state.drag = { kind, idx, moved: false, downX: e.clientX, downY: e.clientY };
|
||
g.classList.add("dragging");
|
||
g.setPointerCapture(e.pointerId);
|
||
});
|
||
g.addEventListener("pointermove", (e) => {
|
||
if (!state.drag || state.drag.idx !== idx || state.drag.kind !== kind) return;
|
||
if (Math.hypot(e.clientX - state.drag.downX, e.clientY - state.drag.downY) > 3) {
|
||
state.drag.moved = true;
|
||
}
|
||
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;
|
||
const moved = state.drag.moved;
|
||
state.drag = null;
|
||
g.classList.remove("dragging");
|
||
if (moved) {
|
||
pushHistory();
|
||
renderInfoPane();
|
||
updateHash();
|
||
}
|
||
});
|
||
g.addEventListener("contextmenu", (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
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);
|
||
// Layer toggles — repaint overlay on change. Persisted in state.show.
|
||
for (const layer of ["enemies", "packs", "patrols", "icons"]) {
|
||
const cb = $(`layer-${layer}`);
|
||
if (!cb) continue;
|
||
cb.checked = state.show[layer];
|
||
cb.addEventListener("change", () => {
|
||
state.show[layer] = cb.checked;
|
||
renderOverlay();
|
||
});
|
||
}
|
||
$("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>`;
|
||
});
|