From 60eab15fffbaa0d5db9d197b466401b96fc40185 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 8 Apr 2026 12:28:09 +0200 Subject: [PATCH] feat: scroll-wheel zoom and click-to-select in radar - Replace range dropdown with smooth scroll-wheel zoom on canvas - Click dot on canvas to select it (white ring + name label) - Click row in entity list to select on canvas - Click again to deselect - Selected row highlighted with blue accent in list - Auto-scrolls list to keep selected row visible Co-Authored-By: Claude Opus 4.6 (1M context) --- static/script.js | 108 ++++++++++++++++++++++++++++++++--------------- static/style.css | 23 +++++++--- 2 files changed, 93 insertions(+), 38 deletions(-) diff --git a/static/script.js b/static/script.js index 7c83d272..3e5fc474 100644 --- a/static/script.js +++ b/static/script.js @@ -3624,26 +3624,15 @@ function showRadarWindow(name) { const controls = document.createElement('div'); controls.className = 'radar-controls'; - const rangeLabel = document.createElement('label'); - rangeLabel.textContent = 'Range: '; - const rangeSelect = document.createElement('select'); - rangeSelect.className = 'radar-range-select'; - [ - { value: '0.2', label: 'Close (~50m)' }, - { value: '0.5', label: 'Medium (~120m)' }, - { value: '1.0', label: 'Far (~240m)' }, - { value: '2.0', label: 'Very Far (~480m)' }, - ].forEach(opt => { - const o = document.createElement('option'); - o.value = opt.value; - o.textContent = opt.label; - if (opt.value === '0.5') o.selected = true; - rangeSelect.appendChild(o); - }); - rangeLabel.appendChild(rangeSelect); - controls.appendChild(rangeLabel); + const rangeDisplay = document.createElement('span'); + rangeDisplay.className = 'radar-range-display'; + rangeDisplay.textContent = 'Range: ~120m'; + controls.appendChild(rangeDisplay); - // Compass is always heading-up (player facing direction = up) + const zoomHint = document.createElement('span'); + zoomHint.className = 'radar-zoom-hint'; + zoomHint.textContent = 'Scroll to zoom'; + controls.appendChild(zoomHint); content.appendChild(controls); @@ -3671,7 +3660,35 @@ function showRadarWindow(name) { // Store refs on the window win._radarCanvas = canvas; win._radarListBody = listBody; - win._radarRangeSelect = rangeSelect; + win._radarRange = RADAR_DEFAULT_RANGE; + win._radarRangeDisplay = rangeDisplay; + win._radarSelectedId = null; + win._radarLastObjects = []; // cache for click hit-testing + + // Scroll-wheel zoom on canvas + canvas.addEventListener('wheel', (e) => { + e.preventDefault(); + const factor = e.deltaY > 0 ? 1.25 : 0.8; + win._radarRange = Math.max(0.02, Math.min(5.0, win._radarRange * factor)); + const meters = Math.round(win._radarRange * 240); + win._radarRangeDisplay.textContent = `Range: ~${meters}m`; + }); + + // Click canvas to select nearest object + canvas.addEventListener('click', (e) => { + const rect = canvas.getBoundingClientRect(); + const mx = (e.clientX - rect.left) * (canvas.width / rect.width); + const my = (e.clientY - rect.top) * (canvas.height / rect.height); + // Find closest rendered dot + let closest = null; + let closestDist = 20; // max pixel distance to select + (win._radarLastObjects || []).forEach(obj => { + if (obj._px === undefined) return; + const d = Math.sqrt((mx - obj._px) ** 2 + (my - obj._py) ** 2); + if (d < closestDist) { closestDist = d; closest = obj; } + }); + win._radarSelectedId = closest ? closest.id : null; + }); // Send start_radar command if (socket && socket.readyState === WebSocket.OPEN) { @@ -3686,7 +3703,8 @@ function updateRadarWindow(msg) { const canvas = win._radarCanvas; const listBody = win._radarListBody; - const range = parseFloat(win._radarRangeSelect.value) || RADAR_DEFAULT_RANGE; + const range = win._radarRange || RADAR_DEFAULT_RANGE; + const selectedId = win._radarSelectedId; const objects = msg.objects || []; const playerEW = msg.player_ew; @@ -3757,40 +3775,54 @@ function updateRadarWindow(msg) { // Rotate world by heading so player facing = up on canvas const rotAngle = headingRad; - // Draw objects + // Draw objects and cache positions for click hit-testing + const cosA = Math.cos(rotAngle); + const sinA = Math.sin(rotAngle); objects.forEach(obj => { const dEW = obj.ew - playerEW; const dNS = obj.ns - playerNS; - // Rotate world by negative heading so player facing = up - const cosA = Math.cos(rotAngle); - const sinA = Math.sin(rotAngle); const dx = dEW * cosA - dNS * sinA; const dy = -(dEW * sinA + dNS * cosA); const px = cx + dx * scale; const py = cy + dy * scale; + // Store pixel position for click hit-testing + obj._px = px; + obj._py = py; + // Clip to circle const distFromCenter = Math.sqrt((px - cx) ** 2 + (py - cy) ** 2); if (distFromCenter > cx - 4) return; const color = RADAR_COLORS[obj.object_class] || '#999'; - const dotSize = (obj.object_class === 'Monster' || obj.object_class === 'Player') ? 4 : 3; + const isSelected = obj.id === selectedId; + const dotSize = isSelected ? 6 : ((obj.object_class === 'Monster' || obj.object_class === 'Player') ? 4 : 3); + + // Selection ring + if (isSelected) { + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(px, py, dotSize + 3, 0, Math.PI * 2); + ctx.stroke(); + } ctx.fillStyle = color; ctx.beginPath(); ctx.arc(px, py, dotSize, 0, Math.PI * 2); ctx.fill(); - // Label for players and portals - if (obj.object_class === 'Player' || obj.object_class === 'Portal') { - ctx.fillStyle = color; + // Label for players, portals, and selected object + if (obj.object_class === 'Player' || obj.object_class === 'Portal' || isSelected) { + ctx.fillStyle = isSelected ? '#fff' : color; ctx.font = '9px monospace'; ctx.textAlign = 'left'; ctx.fillText(obj.name, px + 6, py + 3); } }); + win._radarLastObjects = objects; // Player dot (center) ctx.fillStyle = '#ffcc00'; @@ -3818,11 +3850,21 @@ function updateRadarWindow(msg) { }); withDist.sort((a, b) => a.distMeters - b.distMeters); - // Rebuild list (simple approach — runs 1/sec so fine) + // Rebuild list with selection support listBody.innerHTML = ''; + let selectedRow = null; withDist.forEach(obj => { const row = document.createElement('div'); row.className = 'radar-entity-row'; + if (obj.id === selectedId) { + row.classList.add('radar-entity-selected'); + selectedRow = row; + } + + // Click row to select on canvas + row.addEventListener('click', () => { + win._radarSelectedId = (win._radarSelectedId === obj.id) ? null : obj.id; + }); const colorDot = document.createElement('span'); colorDot.className = 're-color'; @@ -3832,9 +3874,6 @@ function updateRadarWindow(msg) { const nameSpan = document.createElement('span'); nameSpan.className = 're-name'; nameSpan.textContent = obj.name; - if (obj.health_pct !== null && obj.health_pct !== undefined) { - nameSpan.textContent += ` (${obj.health_pct}%)`; - } row.appendChild(nameSpan); const typeSpan = document.createElement('span'); @@ -3856,6 +3895,9 @@ function updateRadarWindow(msg) { listBody.appendChild(row); }); + + // Scroll selected row into view + if (selectedRow) selectedRow.scrollIntoView({ block: 'nearest' }); } function compassDir(angleDeg) { diff --git a/static/style.css b/static/style.css index 16dddd4f..1ea39e6a 100644 --- a/static/style.css +++ b/static/style.css @@ -2533,14 +2533,17 @@ table.ts-allegiance td:first-child { color: #ccc; } -.radar-range-select { - background: #222; - color: #ccc; - border: 1px solid #444; - padding: 2px 4px; +.radar-range-display { + color: #aaa; font-size: 0.75rem; } +.radar-zoom-hint { + color: #555; + font-size: 0.7rem; + font-style: italic; +} + .radar-canvas { display: block; margin: 0 auto; @@ -2578,6 +2581,16 @@ table.ts-allegiance td:first-child { .radar-entity-row:hover { background: #1a1a2a; + cursor: pointer; +} + +.radar-entity-selected { + background: #1a2a3a; + border-left: 2px solid #4488ff; +} + +.radar-entity-selected:hover { + background: #1a2a3a; } .re-color {