From a8e2b9d98de6982fd873b64a0b663c666e5805b3 Mon Sep 17 00:00:00 2001 From: Smittix Date: Sat, 21 Feb 2026 14:51:45 +0000 Subject: [PATCH] Shrink hit areas and spread overlapping radar dots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hit area: was Math.max(dotSize * 2, 15) — up to 24px radius around a 4px dot. Now the CSS hover-flicker is fixed the large hit area is unnecessary and was the reason dots activated when merely nearby. Changed to dotSize + 4 (proportional, 4px padding around the visual circle). Overlap spread: compute all band positions first, then run an iterative push-apart pass (spreadOverlappingDots) that nudges any two dots whose arc gap is smaller than 2 * maxHitArea + 2px apart. Positions within a band are stable across renders (same hash angle, same band = same output before spreading) so dots don't shuffle on every update. Z-order: sort visible devices by rssi_current ascending before rendering so the strongest signal lands last in SVG order and receives clicks when dots stack. Co-Authored-By: Claude Sonnet 4.6 --- static/js/components/proximity-radar.js | 66 +++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/static/js/components/proximity-radar.js b/static/js/components/proximity-radar.js index 1b7d733..c08f338 100644 --- a/static/js/components/proximity-radar.js +++ b/static/js/components/proximity-radar.js @@ -218,14 +218,28 @@ const ProximityRadar = (function() { } }); + // Sort weakest signal first so strongest renders on top (SVG z-order) + visibleDevices.sort((a, b) => (a.rssi_current || -100) - (b.rssi_current || -100)); + + // Compute all positions upfront so we can spread overlapping dots + const posMap = new Map(); visibleDevices.forEach(device => { - const { x, y } = calculateDevicePosition(device, center, maxRadius); + posMap.set(device.device_key, calculateDevicePosition(device, center, maxRadius)); + }); + + // Spread dots that land too close together within the same band. + // minGapPx = diameter of largest possible hit area + 2px breathing room. + const maxHitArea = CONFIG.dotMaxSize + 4; + spreadOverlappingDots(Array.from(posMap.values()), center, maxHitArea * 2 + 2); + + visibleDevices.forEach(device => { + const { x, y } = posMap.get(device.device_key); const confidence = device.distance_confidence || 0.5; const dotSize = CONFIG.dotMinSize + (CONFIG.dotMaxSize - CONFIG.dotMinSize) * confidence; const color = getBandColor(device.proximity_band); const isNew = device.age_seconds < 5; const isSelected = !!(selectedDeviceKey && device.device_key === selectedDeviceKey); - const hitAreaSize = Math.max(dotSize * 2, 15); + const hitAreaSize = dotSize + 4; const key = device.device_key; const existing = devicesGroup.querySelector( @@ -393,7 +407,53 @@ const ProximityRadar = (function() { const x = center + Math.sin(angle) * radius; const y = center - Math.cos(angle) * radius; - return { x, y, radius }; + return { x, y, angle, radius }; + } + + /** + * Spread dots within the same band that land too close together. + * Groups entries by radius, sorts by angle, then nudges neighbours + * apart until the arc gap between any two dots is at least minGapPx. + * Positions are updated in-place on the entry objects. + */ + function spreadOverlappingDots(entries, center, minGapPx) { + const groups = new Map(); + entries.forEach(e => { + const key = Math.round(e.radius); + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(e); + }); + + groups.forEach((group, r) => { + if (group.length < 2 || r < 1) return; + const minSep = minGapPx / r; // radians + + group.sort((a, b) => a.angle - b.angle); + + // Iterative push-apart (up to 8 passes) + for (let iter = 0; iter < 8; iter++) { + let moved = false; + for (let i = 0; i < group.length; i++) { + const j = (i + 1) % group.length; + let gap = group[j].angle - group[i].angle; + if (gap < 0) gap += 2 * Math.PI; + if (gap < minSep) { + const push = (minSep - gap) / 2; + group[i].angle -= push; + group[j].angle += push; + moved = true; + } + } + if (!moved) break; + } + + // Normalise angles back to [0, 2π) and recompute x/y + group.forEach(e => { + e.angle = ((e.angle % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI); + e.x = center + Math.sin(e.angle) * r; + e.y = center - Math.cos(e.angle) * r; + }); + }); } /**