rare-elite enemies + freetext notes tool + clear-confirmation

- classification 5 (rare-elite) now renders with silver-blue skull pin and shows up in the boss list with a 'rare' tag (was falling through to default trash style)
- new Note tool: click map → enter text → drops yellow info pin; hover for tooltip, double-click to edit, drag to move, right-click to remove. Notes are included in the share-URL hash and history.
- Clear button now confirms before wiping the current floor's waypoints/pulls/notes
This commit is contained in:
2026-04-25 22:59:01 +02:00
parent 920408f7d3
commit e6c457a400
3 changed files with 180 additions and 22 deletions
+170 -21
View File
@@ -9,9 +9,10 @@ const state = {
dungeons: [],
current: null,
floorIndex: 0,
tool: "route", // "route" | "pull"
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 },
@@ -204,37 +205,129 @@ function renderOverlay() {
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;
}
const CLASS_RADIUS = { 1: 14, 2: 18, 3: 28, 4: 38 };
const CLASS_FILL = { 1: "#9aa1aa", 2: "#d6d6dc", 3: "#d63b3b", 4: "#ffd83a" };
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 t = document.createElementNS(SVG_NS, "text");
t.setAttribute("y", 9);
t.setAttribute("font-size", "26");
t.setAttribute("text-anchor", "middle");
t.setAttribute("fill", "#1a1208");
t.setAttribute("font-weight", "900");
t.setAttribute("font-style", "italic");
t.setAttribute("font-family", "Georgia, serif");
t.textContent = "i";
g.appendChild(t);
const title = document.createElementNS(SVG_NS, "title");
title.textContent = note.text || "(empty)";
g.appendChild(title);
// 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 r = CLASS_RADIUS[e.classification || 1] ?? 14;
const fill = CLASS_FILL[e.classification || 1] ?? "#9aa1aa";
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${e.classification || 1}`);
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", "#000");
c.setAttribute("stroke-width", "3");
c.setAttribute("stroke", cls === 5 ? "#3b6db0" : "#000");
c.setAttribute("stroke-width", cls === 5 ? "4" : "3");
g.appendChild(c);
if ((e.classification || 1) >= 3) {
// Boss — overlay a skull glyph
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", "#1a0000");
t.setAttribute("fill", cls === 5 ? "#1a3360" : "#1a0000");
t.setAttribute("font-weight", "900");
t.textContent = "☠";
g.appendChild(t);
}
const title = document.createElementNS(SVG_NS, "title");
title.textContent = e.name + (e.skippable ? " (skippable)" : "");
const tag = cls === 5 ? " (rare)" : (e.skippable ? " (skippable)" : "");
title.textContent = e.name + tag;
g.appendChild(title);
return g;
}
@@ -332,9 +425,14 @@ function onCanvasClick(e) {
if (state.tool === "route") {
state.routes[key] = state.routes[key] || [];
state.routes[key].push(pt);
} else {
} 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();
@@ -372,14 +470,25 @@ function renderInfoPane() {
const bossUl = $("boss-list");
bossUl.innerHTML = "";
if (m) {
const bosses = (m.enemies || []).filter((e) => (e.classification || 0) >= 3);
bosses.sort((a, b) => (b.classification || 0) - (a.classification || 0));
for (const e of bosses) {
const 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");
li.innerHTML = `<span class="swatch boss"></span><span>${escapeHtml(e.name)}</span>`;
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 (!bosses.length) {
if (!seen.size) {
const li = document.createElement("li");
li.innerHTML = `<span style="color:var(--text-dim)">no boss data</span>`;
bossUl.appendChild(li);
@@ -492,7 +601,9 @@ function hookCanvasPanZoom() {
/* --- history / share / export ------------------------------------------- */
function pushHistory() {
state.history.push(JSON.stringify({ routes: state.routes, pulls: state.pulls }));
state.history.push(JSON.stringify({
routes: state.routes, pulls: state.pulls, notes: state.notes,
}));
if (state.history.length > 200) state.history.shift();
}
@@ -503,9 +614,11 @@ function undo() {
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();
@@ -514,8 +627,20 @@ function undo() {
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();
@@ -603,6 +728,24 @@ function base64UrlDecodeBytes(s) {
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 = {
@@ -610,6 +753,7 @@ async function updateHash() {
f: state.floorIndex,
r: flattenPins(state.routes),
p: flattenPins(state.pulls),
n: flattenNotes(state.notes),
};
const encoded = await encodePayload(payload);
const hash = "#" + encoded;
@@ -630,6 +774,7 @@ async function loadFromHash() {
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);
@@ -677,8 +822,10 @@ function toast(msg) {
function setTool(tool) {
state.tool = tool;
$("tool-route").classList.toggle("active", tool === "route");
$("tool-pull").classList.toggle("active", tool === "pull");
for (const t of ["route", "pull", "note"]) {
const btn = $(`tool-${t}`);
if (btn) btn.classList.toggle("active", tool === t);
}
}
function hookEvents() {
@@ -690,6 +837,8 @@ function hookEvents() {
$("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();
+1
View File
@@ -31,6 +31,7 @@
<div class="toolbar">
<button id="tool-route" class="tool active" title="Click map to add waypoints">Route</button>
<button id="tool-pull" class="tool" title="Click map to drop pull markers">Pull</button>
<button id="tool-note" class="tool" title="Click map to drop a note (info icon, hover to read; double-click to edit)">Note</button>
<button id="undo" title="Undo (⌘Z)">Undo</button>
<button id="clear" title="Clear current floor">Clear</button>
<button id="share">Share</button>
+9 -1
View File
@@ -353,7 +353,15 @@ body {
border-radius: 50%;
flex-shrink: 0;
}
.boss-list .swatch { background: var(--boss); }
.boss-list .swatch.boss { background: var(--boss); }
.boss-list .swatch.rare { background: #bfd6f0; outline: 1px solid #3b6db0; }
.boss-list .tag {
margin-left: auto;
font-size: 10px;
text-transform: uppercase;
letter-spacing: .08em;
color: var(--text-dim);
}
.waypoint-list .swatch { background: var(--waypoint); }
.waypoint-list li button {
margin-left: auto;