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);