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:
+170
-21
@@ -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();
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user