diff --git a/README.md b/README.md index 3198f7c3..d9178da0 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,44 @@ 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 diff --git a/src/components/WorldMap.jsx b/src/components/WorldMap.jsx index 7facc252..06557cd5 100644 --- a/src/components/WorldMap.jsx +++ b/src/components/WorldMap.jsx @@ -161,6 +161,12 @@ export const WorldMap = ({ const aprsMarkersRef = useRef([]); 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); @@ -171,6 +177,130 @@ 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. + // 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, realMarker, glowColor) => { + 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, + }); + } + + // 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); + }, + [bindSpotClick], + ); + const handleAzimuthalMapReady = useCallback((map) => { azimuthalMapRef.current = map; setAzimuthalMapReady(!!map); @@ -744,6 +874,24 @@ 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 = () => { + touchPendingRef.current = null; + 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; @@ -1224,7 +1372,10 @@ 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; + const dxCircle = L.circleMarker([lat, lon], { radius: isHovered ? 12 : 6, fillColor: isHovered ? '#ffffff' : color, @@ -1232,15 +1383,47 @@ export const WorldMap = ({ weight: isHovered ? 3 : 1.5, opacity: 1, fillOpacity: isHovered ? 1 : 0.9, - interactive: !!onSpotClick, + 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], { + 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 () { + if (pinnedPopupRef.current.marker !== this) 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) { - dxCircle.on('click', () => onSpotClick(path)); + if (!isTouchDeviceRef.current) { + bindSpotClick(dxCircle, () => onSpotClick(path)); + } else { + addTouchGhost( + 'circle', + [lat, lon], + dxPopupHtml, + () => onSpotClick(path), + dxPathsMarkersRef, + dxCircle, + color, + ); + } } if (isHovered) dxCircle.bringToFront(); @@ -1258,12 +1441,35 @@ export const WorldMap = ({ replicatePoint(path.dxLat, path.dxLon).forEach(([lat, lon]) => { const label = L.marker([lat, lon], { icon: labelIcon, - interactive: !!onSpotClick, + interactive: !isTouchDeviceRef.current, zIndexOffset: isHovered ? 10000 : 0, - }).addTo(map); + }) + .bindPopup(dxPopupHtml) + .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)); + if (!isTouchDeviceRef.current) { + bindSpotClick(label, () => onSpotClick(path)); + } else { + addTouchGhost( + 'icon', + [lat, lon], + dxPopupHtml, + () => onSpotClick(path), + dxPathsMarkersRef, + label, + color, + ); + } } dxPathsMarkersRef.current.push(label); @@ -1366,22 +1572,43 @@ 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) + 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) { - marker.on('click', () => onSpotClick(spot)); + if (!isTouchDeviceRef.current) { + bindSpotClick(marker, () => onSpotClick(spot)); + } else { + addTouchGhost( + 'icon', + [lat, lon], + spotPopupHtml, + () => onSpotClick(spot), + markersRef, + marker, + mapDefaults.color, + ); + } } markersRef.current.push(marker); @@ -1397,8 +1624,36 @@ export const WorldMap = ({ replicatePoint(spot.lat, spot.lon).forEach(([lat, lon]) => { const label = L.marker([lat, lon], { icon: labelIcon, - interactive: false, - }).addTo(map); + interactive: !isTouchDeviceRef.current, + }) + .bindPopup(spotPopupHtml) + .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) { + if (!isTouchDeviceRef.current) { + bindSpotClick(label, () => onSpotClick(spot)); + } else { + addTouchGhost( + 'icon', + [lat, lon], + spotPopupHtml, + () => onSpotClick(spot), + markersRef, + label, + mapDefaults.color, + ); + } + } + markersRef.current.push(label); }); } @@ -1591,8 +1846,15 @@ 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; + if (isRx) { // Diamond marker for RX marker = L.marker([rLat, rLon], { @@ -1608,6 +1870,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 @@ -1618,21 +1881,58 @@ 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) { + // 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 () { + if (pinnedPopupRef.current.marker !== this) 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) { - marker.on('click', () => onSpotClick(spot)); + if (!isTouchDeviceRef.current) { + bindSpotClick(marker, () => onSpotClick(spot)); + } else { + const ghostType = isRx ? 'icon' : 'circle'; + addTouchGhost( + ghostType, + [rLat, rLon], + pskPopupHtml, + () => onSpotClick(spot), + pskMarkersRef, + marker, + bandColor, + ); + } } pskMarkersRef.current.push(marker); @@ -1716,6 +2016,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({ @@ -1730,18 +2035,34 @@ 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) + 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 () { + if (pinnedPopupRef.current.marker !== this) this.closePopup(); + if (this._icon) this._icon.style.filter = ''; + }) .addTo(map); if (onSpotClick) { - diamond.on('click', () => onSpotClick(spot)); + if (!isTouchDeviceRef.current) { + bindSpotClick(diamond, () => onSpotClick(spot)); + } else { + addTouchGhost( + 'icon', + [rLat, rLon], + wsjtxPopupHtml, + () => onSpotClick(spot), + wsjtxMarkersRef, + diamond, + bandColor, + ); + } } wsjtxMarkersRef.current.push(diamond);