From e6c457a400cdc6edcd1d2085a5b45a91edb7d247 Mon Sep 17 00:00:00 2001 From: Florian Date: Sat, 25 Apr 2026 22:59:01 +0200 Subject: [PATCH] rare-elite enemies + freetext notes tool + clear-confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- web/app.js | 191 +++++++++++++++++++++++++++++++++++++++++++------ web/index.html | 1 + web/style.css | 10 ++- 3 files changed, 180 insertions(+), 22 deletions(-) diff --git a/web/app.js b/web/app.js index d6fa35d..5bf737b 100644 --- a/web/app.js +++ b/web/app.js @@ -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 = `${escapeHtml(e.name)}`; + const swatchCls = cls === 5 ? "rare" : "boss"; + li.innerHTML = `${escapeHtml(e.name)}` + + (tag ? ` ${tag}` : ""); bossUl.appendChild(li); } - if (!bosses.length) { + if (!seen.size) { const li = document.createElement("li"); li.innerHTML = `no boss data`; 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(); diff --git a/web/index.html b/web/index.html index 32af4d5..3e31dd8 100644 --- a/web/index.html +++ b/web/index.html @@ -31,6 +31,7 @@
+ diff --git a/web/style.css b/web/style.css index a167112..66b4b3a 100644 --- a/web/style.css +++ b/web/style.css @@ -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;