From 863da5e8c4d9484a93d19e06ff64391410ab0099 Mon Sep 17 00:00:00 2001 From: ceotjoe Date: Sat, 4 Apr 2026 00:31:09 +0200 Subject: [PATCH 1/7] feat(map): show spot popup on hover, glow effect, close on mouseout - Popup opens on mouseover for all spot types (DX Cluster, POTA/SOTA/WWFF/WWBOTA, PSK Reporter, WSJT-X) and closes on mouseout - Click still tunes the rig via rig-bridge as before - DX Cluster and PSK TX circle markers get a Leaflet-native glow ring on hover (extra semi-transparent circleMarker) since CSS drop-shadow is clipped by Leaflet's SVG overlay pane - POTA/SOTA/WWFF/WWBOTA and PSK RX diamond markers use CSS drop-shadow filter (works on divIcon HTML elements) - DX Cluster circle markers set to interactive:true so hover works regardless of rig-bridge state Co-Authored-By: Claude Sonnet 4.6 --- src/components/WorldMap.jsx | 75 ++++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/src/components/WorldMap.jsx b/src/components/WorldMap.jsx index 7facc252..850a4d0f 100644 --- a/src/components/WorldMap.jsx +++ b/src/components/WorldMap.jsx @@ -1225,6 +1225,8 @@ export const WorldMap = ({ // Render circleMarker on all 3 world copies replicatePoint(path.dxLat, path.dxLon).forEach(([lat, lon]) => { + let glowCircle = null; + const dxCircle = L.circleMarker([lat, lon], { radius: isHovered ? 12 : 6, fillColor: isHovered ? '#ffffff' : color, @@ -1232,11 +1234,33 @@ export const WorldMap = ({ weight: isHovered ? 3 : 1.5, opacity: 1, fillOpacity: isHovered ? 1 : 0.9, - interactive: !!onSpotClick, + interactive: true, }) .bindPopup( `${esc(dxCall)}
${esc(path.freq)} MHz
by ${esc(path.spotter)}`, ) + .on('mouseover', function () { + this.openPopup(); + glowCircle = L.circleMarker([lat, lon], { + radius: 16, + fillColor: color, + color: color, + weight: 12, + opacity: 0.3, + fillOpacity: 0.2, + interactive: false, + }).addTo(map); + dxPathsMarkersRef.current.push(glowCircle); + }) + .on('mouseout', function () { + this.closePopup(); + if (glowCircle) { + map.removeLayer(glowCircle); + const idx = dxPathsMarkersRef.current.indexOf(glowCircle); + if (idx !== -1) dxPathsMarkersRef.current.splice(idx, 1); + glowCircle = null; + } + }) .addTo(map); if (onSpotClick) { @@ -1378,6 +1402,15 @@ export const WorldMap = ({ ${spot.name ? `${esc(spot.name)}
` : ''}${esc(spot.freq)} ${esc(spot.mode || '')} ${esc(spot.time || '')} ${spot.comments?.length > 0 ? `
(${esc(spot.comments)})` : ''}`, ) + .on('mouseover', function () { + this.openPopup(); + if (this._icon) + this._icon.style.filter = `drop-shadow(0 0 4px ${mapDefaults.color}) drop-shadow(0 0 10px ${mapDefaults.color}) drop-shadow(0 0 20px ${mapDefaults.color})`; + }) + .on('mouseout', function () { + this.closePopup(); + if (this._icon) this._icon.style.filter = ''; + }) .addTo(map); if (onSpotClick) { @@ -1593,6 +1626,8 @@ export const WorldMap = ({ // Mutual reception spots get a gold border ring replicatePoint(spotLat, spotLon).forEach(([rLat, rLon]) => { let marker; + let glowCircle = null; + if (isRx) { // Diamond marker for RX marker = L.marker([rLat, rLon], { @@ -1629,6 +1664,35 @@ export const WorldMap = ({ ${spot.snr !== null ? `SNR: ${spot.snr > 0 ? '+' : ''}${spot.snr} dB` : ''} `, ) + .on('mouseover', function () { + this.openPopup(); + if (this._path) { + // circleMarker (TX) — use a Leaflet glow ring + glowCircle = L.circleMarker([rLat, rLon], { + radius: 14, + fillColor: bandColor, + color: bandColor, + weight: 10, + opacity: 0.3, + fillOpacity: 0.2, + interactive: false, + }).addTo(map); + pskMarkersRef.current.push(glowCircle); + } else if (this._icon) { + // divIcon (RX diamond) — CSS filter works fine + this._icon.style.filter = `drop-shadow(0 0 4px ${bandColor}) drop-shadow(0 0 10px ${bandColor}) drop-shadow(0 0 20px ${bandColor})`; + } + }) + .on('mouseout', function () { + this.closePopup(); + if (glowCircle) { + map.removeLayer(glowCircle); + const idx = pskMarkersRef.current.indexOf(glowCircle); + if (idx !== -1) pskMarkersRef.current.splice(idx, 1); + glowCircle = null; + } + if (this._icon) this._icon.style.filter = ''; + }) .addTo(map); if (onSpotClick) { @@ -1738,6 +1802,15 @@ export const WorldMap = ({ ${esc(spot.mode || '')} SNR: ${spot.snr != null ? (spot.snr >= 0 ? '+' : '') + spot.snr : '?'} dB `, ) + .on('mouseover', function () { + this.openPopup(); + if (this._icon) + this._icon.style.filter = `drop-shadow(0 0 4px ${bandColor}) drop-shadow(0 0 10px ${bandColor}) drop-shadow(0 0 20px ${bandColor})`; + }) + .on('mouseout', function () { + this.closePopup(); + if (this._icon) this._icon.style.filter = ''; + }) .addTo(map); if (onSpotClick) { From f8c6c2bc90d35b7e19c1715c529fad754c04ce2e Mon Sep 17 00:00:00 2001 From: ceotjoe Date: Sat, 4 Apr 2026 01:32:08 +0200 Subject: [PATCH 2/7] docs(readme): update Quick Start with correct Node.js prerequisites Adds a Prerequisites section clarifying that Node.js v20.19+ or v22.12+ is required (not v18). Includes platform-specific install instructions for nvm, NodeSource, Volta, and Windows, plus an explicit warning for Ubuntu/Debian users not to use `apt install nodejs` which ships v18. Fixes reported issue where Ubuntu 24.04 users hit a Vite engine error after following the existing Quick Start instructions. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/README.md b/README.md index 3198f7c3..cebc123b 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,41 @@ OpenHamClock brings DX cluster spots, space weather, propagation predictions, PO ## Quick Start +### Prerequisites + +- **Node.js v20.19 or later** (v22.12+ also supported) — required by Vite and the Express + backend. The version of Node.js shipped by default in most Linux distributions + (including Ubuntu 24.04 via `apt install nodejs`) is too old and **will not work**. + + Install a current LTS release using one of these methods: + + **nvm (Linux / macOS — recommended):** + ```bash + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash + nvm install --lts + ``` + + **NodeSource (Ubuntu / Debian — system-wide):** + ```bash + curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - + sudo apt-get install -y nodejs + ``` + + **Volta (Windows / macOS / Linux):** + ```bash + curl https://get.volta.sh | bash + volta install node + ``` + + **Windows:** Download the LTS installer from [nodejs.org](https://nodejs.org/). + +- **npm v9 or later** — ships with Node 20+ by default. Verify with `npm --version`. + +> ⚠️ **Ubuntu / Debian users:** Do **not** use `apt install nodejs` — the packaged version +> is v18 which is below the minimum required. Use NodeSource or nvm as shown above. + +### Install & run + ```bash git clone https://github.com/accius/openhamclock.git cd openhamclock From db12452facd776d02862309601b8c34a2b007d3f Mon Sep 17 00:00:00 2001 From: ceotjoe Date: Sat, 4 Apr 2026 01:40:47 +0200 Subject: [PATCH 3/7] style(readme): prettier formatting on Quick Start section Co-Authored-By: Claude Sonnet 4.6 --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index cebc123b..d9178da0 100644 --- a/README.md +++ b/README.md @@ -27,18 +27,21 @@ OpenHamClock brings DX cluster spots, space weather, propagation predictions, PO Install a current LTS release using one of these methods: **nvm (Linux / macOS — recommended):** + ```bash curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash nvm install --lts ``` **NodeSource (Ubuntu / Debian — system-wide):** + ```bash curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - sudo apt-get install -y nodejs ``` **Volta (Windows / macOS / Linux):** + ```bash curl https://get.volta.sh | bash volta install node From ece3bed86de52e1709cdc1fbb9e898272c992ffb Mon Sep 17 00:00:00 2001 From: ceotjoe Date: Sat, 4 Apr 2026 09:51:41 +0200 Subject: [PATCH 4/7] feat(map): pin spot popup on click, auto-close after 20 s Clicking a spot now pins its popup open so the user can read and copy the contents without it disappearing on mouseout. The pinned popup closes when: - the user clicks anywhere else on the map - 20 seconds elapse (auto-close timer) Clicking a second spot while one is already pinned unpins the first and pins the new one immediately. Hover still opens/closes the popup transiently as before; the glow effect always follows the mouse regardless of pin state. L.DomEvent.stopPropagation prevents the map-click handler from firing in the same tick as the marker click, which would instantly unpin the popup that was just pinned. Covers all four spot types: DX Cluster, POTA/SOTA/WWFF/WWBOTA, PSK Reporter, and WSJT-X. Co-Authored-By: Claude Sonnet 4.6 --- src/components/WorldMap.jsx | 102 +++++++++++++++++++++++++++++++----- 1 file changed, 90 insertions(+), 12 deletions(-) diff --git a/src/components/WorldMap.jsx b/src/components/WorldMap.jsx index 850a4d0f..52d5696e 100644 --- a/src/components/WorldMap.jsx +++ b/src/components/WorldMap.jsx @@ -161,6 +161,7 @@ export const WorldMap = ({ const aprsMarkersRef = useRef([]); const countriesLayerRef = useRef([]); const dxLockedRef = useRef(dxLocked); + const pinnedPopupRef = useRef({ marker: null, timer: null }); const rotatorLineRef = useRef(null); const rotatorGlowRef = useRef(null); const rotatorTurnRef = useRef(onRotatorTurnRequest); @@ -744,6 +745,23 @@ export const WorldMap = ({ }; }, [leafletReady]); // leafletReady flips to true once window.L is confirmed available + // Unpin a pinned spot popup when the user clicks anywhere on the map + useEffect(() => { + const map = mapInstanceRef.current; + if (!map) return; + const handleMapClick = () => { + const pinned = pinnedPopupRef.current; + if (pinned.marker) { + pinned.marker.closePopup(); + clearTimeout(pinned.timer); + pinned.marker = null; + pinned.timer = null; + } + }; + map.on('click', handleMapClick); + return () => map.off('click', handleMapClick); + }, [leafletReady]); + // Update the value for how many scroll pixels count as a zoom level useEffect(() => { if (!mapInstanceRef.current) return; @@ -1240,7 +1258,7 @@ export const WorldMap = ({ `${esc(dxCall)}
${esc(path.freq)} MHz
by ${esc(path.spotter)}`, ) .on('mouseover', function () { - this.openPopup(); + if (pinnedPopupRef.current.marker !== this) this.openPopup(); glowCircle = L.circleMarker([lat, lon], { radius: 16, fillColor: color, @@ -1253,7 +1271,7 @@ export const WorldMap = ({ dxPathsMarkersRef.current.push(glowCircle); }) .on('mouseout', function () { - this.closePopup(); + if (pinnedPopupRef.current.marker !== this) this.closePopup(); if (glowCircle) { map.removeLayer(glowCircle); const idx = dxPathsMarkersRef.current.indexOf(glowCircle); @@ -1264,7 +1282,22 @@ export const WorldMap = ({ .addTo(map); if (onSpotClick) { - dxCircle.on('click', () => onSpotClick(path)); + dxCircle.on('click', (e) => { + L.DomEvent.stopPropagation(e); + const pinned = pinnedPopupRef.current; + if (pinned.marker) { + pinned.marker.closePopup(); + clearTimeout(pinned.timer); + } + pinned.marker = dxCircle; + dxCircle.openPopup(); + pinned.timer = setTimeout(() => { + dxCircle.closePopup(); + pinned.marker = null; + pinned.timer = null; + }, 20000); + onSpotClick(path); + }); } if (isHovered) dxCircle.bringToFront(); @@ -1403,18 +1436,33 @@ export const WorldMap = ({ ${spot.comments?.length > 0 ? `
(${esc(spot.comments)})` : ''}`, ) .on('mouseover', function () { - this.openPopup(); + if (pinnedPopupRef.current.marker !== this) this.openPopup(); if (this._icon) this._icon.style.filter = `drop-shadow(0 0 4px ${mapDefaults.color}) drop-shadow(0 0 10px ${mapDefaults.color}) drop-shadow(0 0 20px ${mapDefaults.color})`; }) .on('mouseout', function () { - this.closePopup(); + if (pinnedPopupRef.current.marker !== this) this.closePopup(); if (this._icon) this._icon.style.filter = ''; }) .addTo(map); if (onSpotClick) { - marker.on('click', () => onSpotClick(spot)); + marker.on('click', (e) => { + L.DomEvent.stopPropagation(e); + const pinned = pinnedPopupRef.current; + if (pinned.marker) { + pinned.marker.closePopup(); + clearTimeout(pinned.timer); + } + pinned.marker = marker; + marker.openPopup(); + pinned.timer = setTimeout(() => { + marker.closePopup(); + pinned.marker = null; + pinned.timer = null; + }, 20000); + onSpotClick(spot); + }); } markersRef.current.push(marker); @@ -1665,7 +1713,7 @@ export const WorldMap = ({ `, ) .on('mouseover', function () { - this.openPopup(); + if (pinnedPopupRef.current.marker !== this) this.openPopup(); if (this._path) { // circleMarker (TX) — use a Leaflet glow ring glowCircle = L.circleMarker([rLat, rLon], { @@ -1684,7 +1732,7 @@ export const WorldMap = ({ } }) .on('mouseout', function () { - this.closePopup(); + if (pinnedPopupRef.current.marker !== this) this.closePopup(); if (glowCircle) { map.removeLayer(glowCircle); const idx = pskMarkersRef.current.indexOf(glowCircle); @@ -1696,7 +1744,22 @@ export const WorldMap = ({ .addTo(map); if (onSpotClick) { - marker.on('click', () => onSpotClick(spot)); + marker.on('click', (e) => { + L.DomEvent.stopPropagation(e); + const pinned = pinnedPopupRef.current; + if (pinned.marker) { + pinned.marker.closePopup(); + clearTimeout(pinned.timer); + } + pinned.marker = marker; + marker.openPopup(); + pinned.timer = setTimeout(() => { + marker.closePopup(); + pinned.marker = null; + pinned.timer = null; + }, 20000); + onSpotClick(spot); + }); } pskMarkersRef.current.push(marker); @@ -1803,18 +1866,33 @@ export const WorldMap = ({ `, ) .on('mouseover', function () { - this.openPopup(); + if (pinnedPopupRef.current.marker !== this) this.openPopup(); if (this._icon) this._icon.style.filter = `drop-shadow(0 0 4px ${bandColor}) drop-shadow(0 0 10px ${bandColor}) drop-shadow(0 0 20px ${bandColor})`; }) .on('mouseout', function () { - this.closePopup(); + if (pinnedPopupRef.current.marker !== this) this.closePopup(); if (this._icon) this._icon.style.filter = ''; }) .addTo(map); if (onSpotClick) { - diamond.on('click', () => onSpotClick(spot)); + diamond.on('click', (e) => { + L.DomEvent.stopPropagation(e); + const pinned = pinnedPopupRef.current; + if (pinned.marker) { + pinned.marker.closePopup(); + clearTimeout(pinned.timer); + } + pinned.marker = diamond; + diamond.openPopup(); + pinned.timer = setTimeout(() => { + diamond.closePopup(); + pinned.marker = null; + pinned.timer = null; + }, 20000); + onSpotClick(spot); + }); } wsjtxMarkersRef.current.push(diamond); From 570967559e2f333ef351cfbe56ff099278c42343 Mon Sep 17 00:00:00 2001 From: ceotjoe Date: Sat, 4 Apr 2026 13:05:42 +0200 Subject: [PATCH 5/7] feat(map): hover/click on map spot labels mirrors spot marker behaviour DX Cluster labels and POTA/SOTA/WWFF/WWBOTA labels are now fully interactive: - Hovering a label shows the spot popup and a CSS drop-shadow glow (band color for DX, spot-type color for activate spots) - Moving off closes the popup and clears the glow (unless pinned) - Clicking a label pins the popup open for 20 s and tunes the rig, identical to clicking the spot marker itself DX labels previously used a Leaflet glow-ring circleMarker; switched to the same CSS drop-shadow filter used by POTA/SOTA labels since the label element is a divIcon in both cases. Co-Authored-By: Claude Sonnet 4.6 --- src/components/WorldMap.jsx | 79 ++++++++++++++++++++++++++++++++++--- 1 file changed, 74 insertions(+), 5 deletions(-) diff --git a/src/components/WorldMap.jsx b/src/components/WorldMap.jsx index 52d5696e..6c5ef4ea 100644 --- a/src/components/WorldMap.jsx +++ b/src/components/WorldMap.jsx @@ -1315,12 +1315,40 @@ export const WorldMap = ({ replicatePoint(path.dxLat, path.dxLon).forEach(([lat, lon]) => { const label = L.marker([lat, lon], { icon: labelIcon, - interactive: !!onSpotClick, + interactive: true, zIndexOffset: isHovered ? 10000 : 0, - }).addTo(map); + }) + .bindPopup( + `${esc(dxCall)}
${esc(path.freq)} MHz
by ${esc(path.spotter)}`, + ) + .on('mouseover', function () { + if (pinnedPopupRef.current.marker !== this) this.openPopup(); + if (this._icon) + this._icon.style.filter = `drop-shadow(0 0 4px ${color}) drop-shadow(0 0 10px ${color}) drop-shadow(0 0 20px ${color})`; + }) + .on('mouseout', function () { + if (pinnedPopupRef.current.marker !== this) this.closePopup(); + if (this._icon) this._icon.style.filter = ''; + }) + .addTo(map); if (onSpotClick) { - label.on('click', () => onSpotClick(path)); + label.on('click', (e) => { + L.DomEvent.stopPropagation(e); + const pinned = pinnedPopupRef.current; + if (pinned.marker) { + pinned.marker.closePopup(); + clearTimeout(pinned.timer); + } + pinned.marker = label; + label.openPopup(); + pinned.timer = setTimeout(() => { + label.closePopup(); + pinned.marker = null; + pinned.timer = null; + }, 20000); + onSpotClick(path); + }); } dxPathsMarkersRef.current.push(label); @@ -1476,10 +1504,51 @@ export const WorldMap = ({ iconAnchor: [0, -2], }); replicatePoint(spot.lat, spot.lon).forEach(([lat, lon]) => { + const grid = spot.grid6 ? spot.grid6 : spot.grid ? spot.grid : null; + const label = L.marker([lat, lon], { icon: labelIcon, - interactive: false, - }).addTo(map); + interactive: true, + }) + .bindPopup( + ` + ${mapDefaults.shape} ${mapDefaults.name} - + ${esc(spot.call)}
+ ${grid ? `${esc(grid)}
` : ''} + ${esc(spot.ref)} ${esc(spot.locationDesc || '')}
+ ${spot.name ? `${esc(spot.name)}
` : ''}${esc(spot.freq)} ${esc(spot.mode || '')} ${esc(spot.time || '')} + ${spot.comments?.length > 0 ? `
(${esc(spot.comments)})` : ''}`, + ) + .on('mouseover', function () { + if (pinnedPopupRef.current.marker !== this) this.openPopup(); + if (this._icon) + this._icon.style.filter = `drop-shadow(0 0 4px ${mapDefaults.color}) drop-shadow(0 0 10px ${mapDefaults.color}) drop-shadow(0 0 20px ${mapDefaults.color})`; + }) + .on('mouseout', function () { + if (pinnedPopupRef.current.marker !== this) this.closePopup(); + if (this._icon) this._icon.style.filter = ''; + }) + .addTo(map); + + if (onSpotClick) { + label.on('click', (e) => { + L.DomEvent.stopPropagation(e); + const pinned = pinnedPopupRef.current; + if (pinned.marker) { + pinned.marker.closePopup(); + clearTimeout(pinned.timer); + } + pinned.marker = label; + label.openPopup(); + pinned.timer = setTimeout(() => { + label.closePopup(); + pinned.marker = null; + pinned.timer = null; + }, 20000); + onSpotClick(spot); + }); + } + markersRef.current.push(label); }); } From bdf2d701388e4c5b44fc6f3354c68dc6947f2088 Mon Sep 17 00:00:00 2001 From: ceotjoe Date: Sat, 4 Apr 2026 13:50:20 +0200 Subject: [PATCH 6/7] feat(map): expand touch hit area with ghost markers for all spot types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On touch devices, small spot markers (6 px circles, 8 px diamonds) are difficult to tap precisely. A new `addTouchGhost` helper now overlays an invisible hit-area marker on every spot: a radius-22 transparent circleMarker for SVG spots (DX cluster, PSK TX) and a 44×44 transparent divIcon for icon spots (DX labels, POTA/SOTA/WWFF/WWBOTA markers and labels, PSK RX, WSJT-X). On touch devices the real marker is set to non-interactive so taps fall through to the ghost; on pointer devices the behaviour is unchanged. The ghost shares the same popup HTML and two-tap click logic as the visible marker. Co-Authored-By: Claude Sonnet 4.6 --- src/components/WorldMap.jsx | 291 +++++++++++++++++++----------------- 1 file changed, 154 insertions(+), 137 deletions(-) diff --git a/src/components/WorldMap.jsx b/src/components/WorldMap.jsx index 6c5ef4ea..5c9b3cd8 100644 --- a/src/components/WorldMap.jsx +++ b/src/components/WorldMap.jsx @@ -162,6 +162,11 @@ export const WorldMap = ({ const countriesLayerRef = useRef([]); const dxLockedRef = useRef(dxLocked); const pinnedPopupRef = useRef({ marker: null, timer: null }); + const isTouchDeviceRef = useRef( + typeof window !== 'undefined' && (window.matchMedia?.('(pointer: coarse)').matches || navigator.maxTouchPoints > 0), + ); + // Tracks which marker is waiting for a second tap on touch devices + const touchPendingRef = useRef(null); const rotatorLineRef = useRef(null); const rotatorGlowRef = useRef(null); const rotatorTurnRef = useRef(onRotatorTurnRequest); @@ -172,6 +177,94 @@ export const WorldMap = ({ const azimuthalMapRef = useRef(null); const [azimuthalMapReady, setAzimuthalMapReady] = useState(false); + // Unified spot-click handler — pointer devices pin popup + tune immediately; + // touch devices require a second tap to tune (first tap pins the popup only). + const bindSpotClick = useCallback((marker, onTune) => { + marker.on('click', (e) => { + L.DomEvent.stopPropagation(e); + const pinned = pinnedPopupRef.current; + + if (isTouchDeviceRef.current) { + if (touchPendingRef.current === marker) { + // Second tap — tune rig and close popup + if (pinned.marker) { + pinned.marker.closePopup(); + clearTimeout(pinned.timer); + pinned.marker = null; + pinned.timer = null; + } + touchPendingRef.current = null; + onTune(); + } else { + // First tap — pin popup, wait for second tap + if (pinned.marker) { + pinned.marker.closePopup(); + clearTimeout(pinned.timer); + } + touchPendingRef.current = marker; + pinned.marker = marker; + marker.openPopup(); + pinned.timer = setTimeout(() => { + marker.closePopup(); + pinned.marker = null; + pinned.timer = null; + if (touchPendingRef.current === marker) touchPendingRef.current = null; + }, 20000); + } + } else { + // Pointer device — pin popup and tune immediately + if (pinned.marker) { + pinned.marker.closePopup(); + clearTimeout(pinned.timer); + } + pinned.marker = marker; + marker.openPopup(); + pinned.timer = setTimeout(() => { + marker.closePopup(); + pinned.marker = null; + pinned.timer = null; + }, 20000); + onTune(); + } + }); + }, []); + + // On touch devices, visual spot markers are non-interactive; ghost markers with an + // expanded hit area overlay them and handle all tap events. + const addTouchGhost = useCallback( + (type, latlng, popupHtml, onTune, markersRef) => { + if (!isTouchDeviceRef.current) return; + const map = mapInstanceRef.current; + if (!map) return; + + let ghost; + if (type === 'circle') { + ghost = L.circleMarker(latlng, { + radius: 22, + fillOpacity: 0, + opacity: 0, + interactive: true, + }); + } else { + ghost = L.marker(latlng, { + icon: L.divIcon({ + className: '', + html: '
', + iconSize: [44, 44], + iconAnchor: [22, 22], + }), + interactive: true, + zIndexOffset: 1000, + }); + } + + ghost.bindPopup(popupHtml).addTo(map); + if (onTune) bindSpotClick(ghost, onTune); + markersRef.current.push(ghost); + }, + [bindSpotClick], + ); + const handleAzimuthalMapReady = useCallback((map) => { azimuthalMapRef.current = map; setAzimuthalMapReady(!!map); @@ -750,6 +843,7 @@ export const WorldMap = ({ const map = mapInstanceRef.current; if (!map) return; const handleMapClick = () => { + touchPendingRef.current = null; const pinned = pinnedPopupRef.current; if (pinned.marker) { pinned.marker.closePopup(); @@ -1242,6 +1336,7 @@ export const WorldMap = ({ }); // Render circleMarker on all 3 world copies + const dxPopupHtml = `${esc(dxCall)}
${esc(path.freq)} MHz
by ${esc(path.spotter)}`; replicatePoint(path.dxLat, path.dxLon).forEach(([lat, lon]) => { let glowCircle = null; @@ -1252,11 +1347,9 @@ export const WorldMap = ({ weight: isHovered ? 3 : 1.5, opacity: 1, fillOpacity: isHovered ? 1 : 0.9, - interactive: true, + interactive: !isTouchDeviceRef.current, }) - .bindPopup( - `${esc(dxCall)}
${esc(path.freq)} MHz
by ${esc(path.spotter)}`, - ) + .bindPopup(dxPopupHtml) .on('mouseover', function () { if (pinnedPopupRef.current.marker !== this) this.openPopup(); glowCircle = L.circleMarker([lat, lon], { @@ -1282,22 +1375,11 @@ export const WorldMap = ({ .addTo(map); if (onSpotClick) { - dxCircle.on('click', (e) => { - L.DomEvent.stopPropagation(e); - const pinned = pinnedPopupRef.current; - if (pinned.marker) { - pinned.marker.closePopup(); - clearTimeout(pinned.timer); - } - pinned.marker = dxCircle; - dxCircle.openPopup(); - pinned.timer = setTimeout(() => { - dxCircle.closePopup(); - pinned.marker = null; - pinned.timer = null; - }, 20000); - onSpotClick(path); - }); + if (!isTouchDeviceRef.current) { + bindSpotClick(dxCircle, () => onSpotClick(path)); + } else { + addTouchGhost('circle', [lat, lon], dxPopupHtml, () => onSpotClick(path), dxPathsMarkersRef); + } } if (isHovered) dxCircle.bringToFront(); @@ -1315,12 +1397,10 @@ export const WorldMap = ({ replicatePoint(path.dxLat, path.dxLon).forEach(([lat, lon]) => { const label = L.marker([lat, lon], { icon: labelIcon, - interactive: true, + interactive: !isTouchDeviceRef.current, zIndexOffset: isHovered ? 10000 : 0, }) - .bindPopup( - `${esc(dxCall)}
${esc(path.freq)} MHz
by ${esc(path.spotter)}`, - ) + .bindPopup(dxPopupHtml) .on('mouseover', function () { if (pinnedPopupRef.current.marker !== this) this.openPopup(); if (this._icon) @@ -1333,22 +1413,11 @@ export const WorldMap = ({ .addTo(map); if (onSpotClick) { - label.on('click', (e) => { - L.DomEvent.stopPropagation(e); - const pinned = pinnedPopupRef.current; - if (pinned.marker) { - pinned.marker.closePopup(); - clearTimeout(pinned.timer); - } - pinned.marker = label; - label.openPopup(); - pinned.timer = setTimeout(() => { - label.closePopup(); - pinned.marker = null; - pinned.timer = null; - }, 20000); - onSpotClick(path); - }); + if (!isTouchDeviceRef.current) { + bindSpotClick(label, () => onSpotClick(path)); + } else { + addTouchGhost('icon', [lat, lon], dxPopupHtml, () => onSpotClick(path), dxPathsMarkersRef); + } } dxPathsMarkersRef.current.push(label); @@ -1451,18 +1520,18 @@ export const WorldMap = ({ const band = normalizeBandKey(spot.band) || bandFromAnyFrequency(spot.freq); if (!bandPassesMapFilter(band)) return; - replicatePoint(spot.lat, spot.lon).forEach(([lat, lon]) => { - const grid = spot.grid6 ? spot.grid6 : spot.grid ? spot.grid : null; - const marker = L.marker([lat, lon], { icon: mapDefaults.icon }) - .bindPopup( - ` + const grid = spot.grid6 ? spot.grid6 : spot.grid ? spot.grid : null; + const spotPopupHtml = ` ${mapDefaults.shape} ${mapDefaults.name} - ${esc(spot.call)}
${grid ? `${esc(grid)}
` : ''} ${esc(spot.ref)} ${esc(spot.locationDesc || '')}
${spot.name ? `${esc(spot.name)}
` : ''}${esc(spot.freq)} ${esc(spot.mode || '')} ${esc(spot.time || '')} - ${spot.comments?.length > 0 ? `
(${esc(spot.comments)})` : ''}`, - ) + ${spot.comments?.length > 0 ? `
(${esc(spot.comments)})` : ''}`; + + replicatePoint(spot.lat, spot.lon).forEach(([lat, lon]) => { + const marker = L.marker([lat, lon], { icon: mapDefaults.icon, interactive: !isTouchDeviceRef.current }) + .bindPopup(spotPopupHtml) .on('mouseover', function () { if (pinnedPopupRef.current.marker !== this) this.openPopup(); if (this._icon) @@ -1475,22 +1544,11 @@ export const WorldMap = ({ .addTo(map); if (onSpotClick) { - marker.on('click', (e) => { - L.DomEvent.stopPropagation(e); - const pinned = pinnedPopupRef.current; - if (pinned.marker) { - pinned.marker.closePopup(); - clearTimeout(pinned.timer); - } - pinned.marker = marker; - marker.openPopup(); - pinned.timer = setTimeout(() => { - marker.closePopup(); - pinned.marker = null; - pinned.timer = null; - }, 20000); - onSpotClick(spot); - }); + if (!isTouchDeviceRef.current) { + bindSpotClick(marker, () => onSpotClick(spot)); + } else { + addTouchGhost('icon', [lat, lon], spotPopupHtml, () => onSpotClick(spot), markersRef); + } } markersRef.current.push(marker); @@ -1504,21 +1562,11 @@ export const WorldMap = ({ iconAnchor: [0, -2], }); replicatePoint(spot.lat, spot.lon).forEach(([lat, lon]) => { - const grid = spot.grid6 ? spot.grid6 : spot.grid ? spot.grid : null; - const label = L.marker([lat, lon], { icon: labelIcon, - interactive: true, + interactive: !isTouchDeviceRef.current, }) - .bindPopup( - ` - ${mapDefaults.shape} ${mapDefaults.name} - - ${esc(spot.call)}
- ${grid ? `${esc(grid)}
` : ''} - ${esc(spot.ref)} ${esc(spot.locationDesc || '')}
- ${spot.name ? `${esc(spot.name)}
` : ''}${esc(spot.freq)} ${esc(spot.mode || '')} ${esc(spot.time || '')} - ${spot.comments?.length > 0 ? `
(${esc(spot.comments)})` : ''}`, - ) + .bindPopup(spotPopupHtml) .on('mouseover', function () { if (pinnedPopupRef.current.marker !== this) this.openPopup(); if (this._icon) @@ -1531,22 +1579,11 @@ export const WorldMap = ({ .addTo(map); if (onSpotClick) { - label.on('click', (e) => { - L.DomEvent.stopPropagation(e); - const pinned = pinnedPopupRef.current; - if (pinned.marker) { - pinned.marker.closePopup(); - clearTimeout(pinned.timer); - } - pinned.marker = label; - label.openPopup(); - pinned.timer = setTimeout(() => { - label.closePopup(); - pinned.marker = null; - pinned.timer = null; - }, 20000); - onSpotClick(spot); - }); + if (!isTouchDeviceRef.current) { + bindSpotClick(label, () => onSpotClick(spot)); + } else { + addTouchGhost('icon', [lat, lon], spotPopupHtml, () => onSpotClick(spot), markersRef); + } } markersRef.current.push(label); @@ -1741,6 +1778,11 @@ export const WorldMap = ({ // TX = circle marker, RX = diamond marker (colorblind-friendly shape distinction) // Mutual reception spots get a gold border ring + const pskPopupHtml = ` + ${esc(displayCall)} ${dirLabel}${mutual ? ' ' : ''}
+ ${esc(spot.mode)} @ ${esc(freqMHz)} MHz
+ ${spot.snr !== null ? `SNR: ${spot.snr > 0 ? '+' : ''}${spot.snr} dB` : ''} + `; replicatePoint(spotLat, spotLon).forEach(([rLat, rLon]) => { let marker; let glowCircle = null; @@ -1760,6 +1802,7 @@ export const WorldMap = ({ iconSize: [mutual ? 10 : 8, mutual ? 10 : 8], iconAnchor: [mutual ? 5 : 4, mutual ? 5 : 4], }), + interactive: !isTouchDeviceRef.current, }); } else { // Circle marker for TX @@ -1770,17 +1813,12 @@ export const WorldMap = ({ weight: mutual ? 2 : 1, opacity: 0.9, fillOpacity: 0.8, + interactive: !isTouchDeviceRef.current, }); } marker - .bindPopup( - ` - ${esc(displayCall)} ${dirLabel}${mutual ? ' ' : ''}
- ${esc(spot.mode)} @ ${esc(freqMHz)} MHz
- ${spot.snr !== null ? `SNR: ${spot.snr > 0 ? '+' : ''}${spot.snr} dB` : ''} - `, - ) + .bindPopup(pskPopupHtml) .on('mouseover', function () { if (pinnedPopupRef.current.marker !== this) this.openPopup(); if (this._path) { @@ -1813,22 +1851,12 @@ export const WorldMap = ({ .addTo(map); if (onSpotClick) { - marker.on('click', (e) => { - L.DomEvent.stopPropagation(e); - const pinned = pinnedPopupRef.current; - if (pinned.marker) { - pinned.marker.closePopup(); - clearTimeout(pinned.timer); - } - pinned.marker = marker; - marker.openPopup(); - pinned.timer = setTimeout(() => { - marker.closePopup(); - pinned.marker = null; - pinned.timer = null; - }, 20000); - onSpotClick(spot); - }); + if (!isTouchDeviceRef.current) { + bindSpotClick(marker, () => onSpotClick(spot)); + } else { + const ghostType = isRx ? 'icon' : 'circle'; + addTouchGhost(ghostType, [rLat, rLon], pskPopupHtml, () => onSpotClick(spot), pskMarkersRef); + } } pskMarkersRef.current.push(marker); @@ -1912,6 +1940,11 @@ export const WorldMap = ({ } // Diamond-shaped marker — replicate across world copies + const wsjtxPopupHtml = ` + ${esc(call)} ${spot.type === 'CQ' ? 'CQ' : ''}
+ ${esc(spot.grid || '')} ${esc(spot.band || '')}${spot.gridSource === 'prefix' ? ' (est)' : spot.gridSource === 'cache' ? ' (prev)' : ''}
+ ${esc(spot.mode || '')} SNR: ${spot.snr != null ? (spot.snr >= 0 ? '+' : '') + spot.snr : '?'} dB + `; replicatePoint(spotLat, spotLon).forEach(([rLat, rLon]) => { const diamond = L.marker([rLat, rLon], { icon: L.divIcon({ @@ -1926,14 +1959,9 @@ export const WorldMap = ({ iconSize: [8, 8], iconAnchor: [4, 4], }), + interactive: !isTouchDeviceRef.current, }) - .bindPopup( - ` - ${esc(call)} ${spot.type === 'CQ' ? 'CQ' : ''}
- ${esc(spot.grid || '')} ${esc(spot.band || '')}${spot.gridSource === 'prefix' ? ' (est)' : spot.gridSource === 'cache' ? ' (prev)' : ''}
- ${esc(spot.mode || '')} SNR: ${spot.snr != null ? (spot.snr >= 0 ? '+' : '') + spot.snr : '?'} dB - `, - ) + .bindPopup(wsjtxPopupHtml) .on('mouseover', function () { if (pinnedPopupRef.current.marker !== this) this.openPopup(); if (this._icon) @@ -1946,22 +1974,11 @@ export const WorldMap = ({ .addTo(map); if (onSpotClick) { - diamond.on('click', (e) => { - L.DomEvent.stopPropagation(e); - const pinned = pinnedPopupRef.current; - if (pinned.marker) { - pinned.marker.closePopup(); - clearTimeout(pinned.timer); - } - pinned.marker = diamond; - diamond.openPopup(); - pinned.timer = setTimeout(() => { - diamond.closePopup(); - pinned.marker = null; - pinned.timer = null; - }, 20000); - onSpotClick(spot); - }); + if (!isTouchDeviceRef.current) { + bindSpotClick(diamond, () => onSpotClick(spot)); + } else { + addTouchGhost('icon', [rLat, rLon], wsjtxPopupHtml, () => onSpotClick(spot), wsjtxMarkersRef); + } } wsjtxMarkersRef.current.push(diamond); From 865d90df8cdb13df686c1f681b005c2e79ed8dc1 Mon Sep 17 00:00:00 2001 From: ceotjoe Date: Sat, 4 Apr 2026 14:04:05 +0200 Subject: [PATCH 7/7] fix(map): apply glow to visual marker on touch tap via ghost popupopen/close On touch devices ghost markers were handling taps but not triggering any glow on the underlying visual marker. The addTouchGhost helper now accepts the real marker and its band colour; it wires popupopen/popupclose events on the ghost to add/remove the glow (Leaflet glow ring for circleMarker spots, CSS drop-shadow filter for divIcon spots), matching the pointer- device hover behaviour. Co-Authored-By: Claude Sonnet 4.6 --- src/components/WorldMap.jsx | 98 ++++++++++++++++++++++++++++++++++--- 1 file changed, 91 insertions(+), 7 deletions(-) diff --git a/src/components/WorldMap.jsx b/src/components/WorldMap.jsx index 5c9b3cd8..06557cd5 100644 --- a/src/components/WorldMap.jsx +++ b/src/components/WorldMap.jsx @@ -231,8 +231,10 @@ export const WorldMap = ({ // On touch devices, visual spot markers are non-interactive; ghost markers with an // expanded hit area overlay them and handle all tap events. + // realMarker + glowColor are optional — when supplied the ghost applies a glow to + // the visual marker on popupopen and removes it on popupclose. const addTouchGhost = useCallback( - (type, latlng, popupHtml, onTune, markersRef) => { + (type, latlng, popupHtml, onTune, markersRef, realMarker, glowColor) => { if (!isTouchDeviceRef.current) return; const map = mapInstanceRef.current; if (!map) return; @@ -258,6 +260,40 @@ export const WorldMap = ({ }); } + // Apply glow to the real visual marker when the ghost's popup opens/closes + if (realMarker && glowColor) { + let glowRing = null; + ghost.on('popupopen', () => { + if (realMarker._path) { + // SVG circleMarker — use a Leaflet glow ring + glowRing = L.circleMarker(latlng, { + radius: 16, + fillColor: glowColor, + color: glowColor, + weight: 12, + opacity: 0.3, + fillOpacity: 0.2, + interactive: false, + }).addTo(map); + markersRef.current.push(glowRing); + } else if (realMarker._icon) { + // divIcon — CSS drop-shadow filter + realMarker._icon.style.filter = `drop-shadow(0 0 4px ${glowColor}) drop-shadow(0 0 10px ${glowColor}) drop-shadow(0 0 20px ${glowColor})`; + } + }); + ghost.on('popupclose', () => { + if (glowRing) { + try { + map.removeLayer(glowRing); + } catch (_) {} + const idx = markersRef.current.indexOf(glowRing); + if (idx !== -1) markersRef.current.splice(idx, 1); + glowRing = null; + } + if (realMarker._icon) realMarker._icon.style.filter = ''; + }); + } + ghost.bindPopup(popupHtml).addTo(map); if (onTune) bindSpotClick(ghost, onTune); markersRef.current.push(ghost); @@ -1378,7 +1414,15 @@ export const WorldMap = ({ if (!isTouchDeviceRef.current) { bindSpotClick(dxCircle, () => onSpotClick(path)); } else { - addTouchGhost('circle', [lat, lon], dxPopupHtml, () => onSpotClick(path), dxPathsMarkersRef); + addTouchGhost( + 'circle', + [lat, lon], + dxPopupHtml, + () => onSpotClick(path), + dxPathsMarkersRef, + dxCircle, + color, + ); } } @@ -1416,7 +1460,15 @@ export const WorldMap = ({ if (!isTouchDeviceRef.current) { bindSpotClick(label, () => onSpotClick(path)); } else { - addTouchGhost('icon', [lat, lon], dxPopupHtml, () => onSpotClick(path), dxPathsMarkersRef); + addTouchGhost( + 'icon', + [lat, lon], + dxPopupHtml, + () => onSpotClick(path), + dxPathsMarkersRef, + label, + color, + ); } } @@ -1547,7 +1599,15 @@ export const WorldMap = ({ if (!isTouchDeviceRef.current) { bindSpotClick(marker, () => onSpotClick(spot)); } else { - addTouchGhost('icon', [lat, lon], spotPopupHtml, () => onSpotClick(spot), markersRef); + addTouchGhost( + 'icon', + [lat, lon], + spotPopupHtml, + () => onSpotClick(spot), + markersRef, + marker, + mapDefaults.color, + ); } } @@ -1582,7 +1642,15 @@ export const WorldMap = ({ if (!isTouchDeviceRef.current) { bindSpotClick(label, () => onSpotClick(spot)); } else { - addTouchGhost('icon', [lat, lon], spotPopupHtml, () => onSpotClick(spot), markersRef); + addTouchGhost( + 'icon', + [lat, lon], + spotPopupHtml, + () => onSpotClick(spot), + markersRef, + label, + mapDefaults.color, + ); } } @@ -1855,7 +1923,15 @@ export const WorldMap = ({ bindSpotClick(marker, () => onSpotClick(spot)); } else { const ghostType = isRx ? 'icon' : 'circle'; - addTouchGhost(ghostType, [rLat, rLon], pskPopupHtml, () => onSpotClick(spot), pskMarkersRef); + addTouchGhost( + ghostType, + [rLat, rLon], + pskPopupHtml, + () => onSpotClick(spot), + pskMarkersRef, + marker, + bandColor, + ); } } @@ -1977,7 +2053,15 @@ export const WorldMap = ({ if (!isTouchDeviceRef.current) { bindSpotClick(diamond, () => onSpotClick(spot)); } else { - addTouchGhost('icon', [rLat, rLon], wsjtxPopupHtml, () => onSpotClick(spot), wsjtxMarkersRef); + addTouchGhost( + 'icon', + [rLat, rLon], + wsjtxPopupHtml, + () => onSpotClick(spot), + wsjtxMarkersRef, + diamond, + bandColor, + ); } }