From 39eaa66f6cbd0cbbeb67a1822f07719bd5a20a6f Mon Sep 17 00:00:00 2001 From: Michael R Wheeley Date: Mon, 30 Mar 2026 11:26:11 -0700 Subject: [PATCH 01/12] calc and display range, range rate and doppler factor if satellite above horizon --- src/hooks/useSatellites.js | 22 ++++++++++++++++------ src/plugins/layers/useSatelliteLayer.js | 17 ++++++++++++++--- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/hooks/useSatellites.js b/src/hooks/useSatellites.js index e8ab1b59..7ba1b82e 100644 --- a/src/hooks/useSatellites.js +++ b/src/hooks/useSatellites.js @@ -69,15 +69,23 @@ export const useSatellites = (observerLocation) => { const alt = positionGd.height; // Calculate look angles - const lookAngles = satellite.ecfToLookAngles( - observerGd, - satellite.eciToEcf(positionAndVelocity.position, gmst), - ); - + const positionEcf = satellite.eciToEcf(positionAndVelocity.position, gmst); + const lookAngles = satellite.ecfToLookAngles(observerGd, positionEcf); const azimuth = satellite.radiansToDegrees(lookAngles.azimuth); const elevation = satellite.radiansToDegrees(lookAngles.elevation); const rangeSat = lookAngles.rangeSat; + // Calculate range-rate and doppler factor, only if satellite is above horizon + let dopplerFactor = 0; + let rangeRate = 0; + if (elevation > 0) { + const observerEcf = satellite.geodeticToEcf(observerGd); + const velocityEcf = satellite.eciToEcf(positionAndVelocity.velocity, gmst); + dopplerFactor = satellite.dopplerFactor(observerEcf, positionEcf, velocityEcf); + const c = 299792.458; // Speed of light [km/s] + rangeRate = (1 - dopplerFactor) * c; // [km/s] + } + // Calculate speed from ECI velocity vector (km/s) let speedKmH = 0; if (positionAndVelocity.velocity) { @@ -117,7 +125,9 @@ export const useSatellites = (observerLocation) => { azimuth: Math.round(azimuth), elevation: Math.round(elevation), range: Math.round(rangeSat), - visible: elevation > 0, + rangeRate, + dopplerFactor, + isVisible: elevation > 0, isPopular: tle.priority <= 2, track, footprintRadius: Math.round(footprintRadius), diff --git a/src/plugins/layers/useSatelliteLayer.js b/src/plugins/layers/useSatelliteLayer.js index 96900ddc..c5f6bf36 100644 --- a/src/plugins/layers/useSatelliteLayer.js +++ b/src/plugins/layers/useSatelliteLayer.js @@ -240,7 +240,7 @@ export const useLayer = ({ map, enabled, satellites, setSatellites, opacity, con `
` + activeSats .map((sat) => { - const isVisible = sat.visible === true; + const isVisible = sat.isVisible === true; const isMetric = allUnits.dist === 'metric'; const distanceUnitsStr = isMetric ? 'km' : 'miles'; @@ -266,7 +266,18 @@ export const useLayer = ({ map, enabled, satellites, setSatellites, opacity, con ${t('station.settings.satellites.longitude')}:${sat.lon.toFixed(2)}° ${t('station.settings.satellites.speed')}:${speedStr} ${t('station.settings.satellites.altitude')}:${altitudeStr} - ${t('station.settings.satellites.azimuth_elevation')}:${Math.round(sat.azimuth)}° / ${Math.round(sat.elevation)}° + ${t('station.settings.satellites.azimuth_elevation')}:${sat.azimuth}° / ${sat.elevation}° + + ${ + isVisible + ? ` + range:${sat.range} km + range rate:${sat.rangeRate.toFixed(2)} km/s + doppler factor:${sat.dopplerFactor.toFixed(7)} + ` + : `` + } + ${t('station.settings.satellites.mode')}:${sat.mode || 'N/A'} ${sat.downlink ? `${t('station.settings.satellites.downlink')}:${sat.downlink}` : ''} ${sat.uplink ? `${t('station.settings.satellites.uplink')}:${sat.uplink}` : ''} @@ -308,7 +319,7 @@ export const useLayer = ({ map, enabled, satellites, setSatellites, opacity, con const EARTH_RADIUS = 6371; const centralAngle = Math.acos(EARTH_RADIUS / (EARTH_RADIUS + sat.alt)); const footprintRadiusMeters = centralAngle * EARTH_RADIUS * 1000; - const footColor = sat.visible === true ? '#00ff00' : '#00ffff'; + const footColor = sat.isVisible === true ? '#00ff00' : '#00ffff'; replicatePoint(sat.lat, sat.lon).forEach((pos) => { window.L.circle(pos, { From c25e074debaf00ff2311198bba92f9645f12ed26 Mon Sep 17 00:00:00 2001 From: Michael R Wheeley Date: Mon, 30 Mar 2026 14:24:23 -0700 Subject: [PATCH 02/12] US units corrections --- README.md | 2 ++ src/hooks/useSatellites.js | 20 ++++++++++++-------- src/plugins/layers/useSatelliteLayer.js | 11 ++++++----- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 32ba8a99..d33c7e26 100644 --- a/README.md +++ b/README.md @@ -429,6 +429,8 @@ Real-time tracking of amateur radio satellites with orbital visualization on the - Satellite positions as colored markers on the map, updated every 5 seconds - Orbital track lines showing each satellite's path over the next pass - Satellite name, altitude, and coordinates in the popup +- When the satellite is visible shows its range, range-rate (positive approaching, negative receding), and dopper factor +- (doppler factor is uplink/downlnk frequency multiplier when satellite approaching, and frequency divisor when satellite receding) **How to use it:** diff --git a/src/hooks/useSatellites.js b/src/hooks/useSatellites.js index 7ba1b82e..848733bf 100644 --- a/src/hooks/useSatellites.js +++ b/src/hooks/useSatellites.js @@ -6,6 +6,11 @@ import { useState, useEffect, useCallback } from 'react'; import * as satellite from 'satellite.js'; +function round(value, decimals) { + const factor = Math.pow(10, decimals); + return Math.round(value * factor) / factor; +} + export const useSatellites = (observerLocation) => { const [data, setData] = useState([]); const [loading, setLoading] = useState(true); @@ -112,7 +117,6 @@ export const useSatellites = (observerLocation) => { } // Calculate footprint radius (visibility circle) - // Formula: radius = Earth_radius * arccos(Earth_radius / (Earth_radius + altitude)) const earthRadius = 6371; // km const footprintRadius = earthRadius * Math.acos(earthRadius / (earthRadius + alt)); @@ -120,13 +124,13 @@ export const useSatellites = (observerLocation) => { name: tle.name || name, lat, lon, - alt: Math.round(alt), - speedKmH: Math.round(speedKmH), - azimuth: Math.round(azimuth), - elevation: Math.round(elevation), - range: Math.round(rangeSat), - rangeRate, - dopplerFactor, + alt: round(alt, 1), + speedKmH: round(speedKmH, 1), + azimuth: round(azimuth, 0), + elevation: round(elevation, 0), + range: round(rangeSat, 1), + rangeRate: round(rangeRate, 3), + dopplerFactor: round(dopplerFactor, 9), isVisible: elevation > 0, isPopular: tle.priority <= 2, track, diff --git a/src/plugins/layers/useSatelliteLayer.js b/src/plugins/layers/useSatelliteLayer.js index c5f6bf36..e8bd7b96 100644 --- a/src/plugins/layers/useSatelliteLayer.js +++ b/src/plugins/layers/useSatelliteLayer.js @@ -245,13 +245,14 @@ export const useLayer = ({ map, enabled, satellites, setSatellites, opacity, con const isMetric = allUnits.dist === 'metric'; const distanceUnitsStr = isMetric ? 'km' : 'miles'; const speedUnitsStr = isMetric ? 'km/h' : 'mph'; + const rangeRateUnitsStr = isMetric ? 'km/s' : 'miles/s'; const km_to_miles_factor = 0.621371; - let speed = isMetric ? Math.round(sat.speedKmH || 0) : Math.round((sat.speedKmH || 0) * km_to_miles_factor); + let speed = Math.round((sat.speedKmH || 0) * (isMetric ? 1 : km_to_miles_factor)); let speedStr = `${speed.toLocaleString()} ${speedUnitsStr}`; speedStr = `${sat.speedKmH ? speedStr : 'N/A'}`; - let altitude = isMetric ? Math.round(sat.alt) : Math.round(sat.alt * km_to_miles_factor); + let altitude = Math.round(sat.alt * (isMetric ? 1 : km_to_miles_factor)); let altitudeStr = `${altitude.toLocaleString()} ${distanceUnitsStr}`; return ` @@ -271,9 +272,9 @@ export const useLayer = ({ map, enabled, satellites, setSatellites, opacity, con ${ isVisible ? ` - range:${sat.range} km - range rate:${sat.rangeRate.toFixed(2)} km/s - doppler factor:${sat.dopplerFactor.toFixed(7)} + Range:${(sat.range * (isMetric ? 1 : km_to_miles_factor)).toFixed(0)} ${distanceUnitsStr} + Range Rate:${(sat.rangeRate * (isMetric ? 1 : km_to_miles_factor)).toFixed(2)} ${rangeRateUnitsStr} + Doppler Factor:${sat.dopplerFactor.toFixed(7)} ` : `` } From fe4525aaf6393b12d4f266c5d98343451945b97d Mon Sep 17 00:00:00 2001 From: Michael R Wheeley Date: Mon, 30 Mar 2026 15:05:57 -0700 Subject: [PATCH 03/12] language keys for new satellite words, all languages --- src/lang/ca.json | 3 +++ src/lang/de.json | 3 +++ src/lang/en.json | 3 +++ src/lang/es.json | 3 +++ src/lang/fr.json | 3 +++ src/lang/it.json | 3 +++ src/lang/ja.json | 3 +++ src/lang/ka.json | 3 +++ src/lang/ko.json | 3 +++ src/lang/ms.json | 3 +++ src/lang/nl.json | 3 +++ src/lang/pt.json | 3 +++ src/lang/ru.json | 3 +++ src/lang/sl.json | 3 +++ src/lang/th.json | 3 +++ src/lang/zh.json | 3 +++ src/plugins/layers/useSatelliteLayer.js | 6 +++--- 17 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/lang/ca.json b/src/lang/ca.json index c8b8ecc4..edd727c1 100644 --- a/src/lang/ca.json +++ b/src/lang/ca.json @@ -393,6 +393,7 @@ "station.settings.satellites.belowHorizon": "Sota l’horitzó", "station.settings.satellites.clear": "Netejar", "station.settings.satellites.clearFootprints": "CLEAR ALL FOOTPRINTS", + "station.settings.satellites.dopplerFactor": "Doppler Factor", "station.settings.satellites.downlink": "Downlink", "station.settings.satellites.dragTitle": "Drag title to move", "station.settings.satellites.latitude": "Latitude", @@ -400,6 +401,8 @@ "station.settings.satellites.mode": "Mode", "station.settings.satellites.name": "SATELLITE", "station.settings.satellites.name_plural": "SATELLITES", + "station.settings.satellites.range": "Range", + "station.settings.satellites.rangeRate": "Range Rate", "station.settings.satellites.selectAll": "Seleccionar-ho tot", "station.settings.satellites.selectedCount": "{{count}} satèl·lit(s) seleccionat(s)", "station.settings.satellites.showAll": "Mostrant tots els satèl·lits (sense filtre)", diff --git a/src/lang/de.json b/src/lang/de.json index 119aed1a..0514ba2e 100644 --- a/src/lang/de.json +++ b/src/lang/de.json @@ -393,6 +393,7 @@ "station.settings.satellites.belowHorizon": "Unter dem Horizont", "station.settings.satellites.clear": "Löschen", "station.settings.satellites.clearFootprints": "CLEAR ALL FOOTPRINTS", + "station.settings.satellites.dopplerFactor": "Doppler Factor", "station.settings.satellites.downlink": "Downlink", "station.settings.satellites.dragTitle": "Drag title to move", "station.settings.satellites.latitude": "Latitude", @@ -400,6 +401,8 @@ "station.settings.satellites.mode": "Mode", "station.settings.satellites.name": "SATELLITE", "station.settings.satellites.name_plural": "SATELLITES", + "station.settings.satellites.range": "Range", + "station.settings.satellites.rangeRate": "Range Rate", "station.settings.satellites.selectAll": "Alle auswählen", "station.settings.satellites.selectedCount": "{{count}} Satellit(en) ausgewählt", "station.settings.satellites.showAll": "Alle Satelliten werden angezeigt (kein Filter)", diff --git a/src/lang/en.json b/src/lang/en.json index e48be247..8ba4d1a7 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -393,6 +393,7 @@ "station.settings.satellites.belowHorizon": "Below Horizon", "station.settings.satellites.clear": "Clear", "station.settings.satellites.clearFootprints": "CLEAR ALL FOOTPRINTS", + "station.settings.satellites.dopplerFactor": "Doppler Factor", "station.settings.satellites.downlink": "Downlink", "station.settings.satellites.dragTitle": "Drag title to move", "station.settings.satellites.latitude": "Latitude", @@ -400,6 +401,8 @@ "station.settings.satellites.mode": "Mode", "station.settings.satellites.name": "SATELLITE", "station.settings.satellites.name_plural": "SATELLITES", + "station.settings.satellites.range": "Range", + "station.settings.satellites.rangeRate": "Range Rate", "station.settings.satellites.selectAll": "Select All", "station.settings.satellites.selectedCount": "{{count}} satellite(s) selected", "station.settings.satellites.showAll": "Showing all satellites (no filter)", diff --git a/src/lang/es.json b/src/lang/es.json index ee2313ce..10b4860f 100644 --- a/src/lang/es.json +++ b/src/lang/es.json @@ -393,6 +393,7 @@ "station.settings.satellites.belowHorizon": "Bajo el horizonte", "station.settings.satellites.clear": "Limpiar", "station.settings.satellites.clearFootprints": "CLEAR ALL FOOTPRINTS", + "station.settings.satellites.dopplerFactor": "Doppler Factor", "station.settings.satellites.downlink": "Downlink", "station.settings.satellites.dragTitle": "Drag title to move", "station.settings.satellites.latitude": "Latitude", @@ -400,6 +401,8 @@ "station.settings.satellites.mode": "Mode", "station.settings.satellites.name": "SATELLITE", "station.settings.satellites.name_plural": "SATELLITES", + "station.settings.satellites.range": "Range", + "station.settings.satellites.rangeRate": "Range Rate", "station.settings.satellites.selectAll": "Seleccionar todo", "station.settings.satellites.selectedCount": "{{count}} satélite(s) seleccionado(s)", "station.settings.satellites.showAll": "Mostrando todos los satélites (sin filtro)", diff --git a/src/lang/fr.json b/src/lang/fr.json index f8b065b5..93339aa6 100644 --- a/src/lang/fr.json +++ b/src/lang/fr.json @@ -393,6 +393,7 @@ "station.settings.satellites.belowHorizon": "Sous l'horizon", "station.settings.satellites.clear": "Effacer", "station.settings.satellites.clearFootprints": "CLEAR ALL FOOTPRINTS", + "station.settings.satellites.dopplerFactor": "Doppler Factor", "station.settings.satellites.downlink": "Downlink", "station.settings.satellites.dragTitle": "Drag title to move", "station.settings.satellites.latitude": "Latitude", @@ -400,6 +401,8 @@ "station.settings.satellites.mode": "Mode", "station.settings.satellites.name": "SATELLITE", "station.settings.satellites.name_plural": "SATELLITES", + "station.settings.satellites.range": "Range", + "station.settings.satellites.rangeRate": "Range Rate", "station.settings.satellites.selectAll": "Tout sélectionner", "station.settings.satellites.selectedCount": "{{count}} satellite(s) sélectionné(s)", "station.settings.satellites.showAll": "Tous les satellites affichés (aucun filtre)", diff --git a/src/lang/it.json b/src/lang/it.json index fc3d8eb5..abd83807 100644 --- a/src/lang/it.json +++ b/src/lang/it.json @@ -393,6 +393,7 @@ "station.settings.satellites.belowHorizon": "Sotto l'orizzonte", "station.settings.satellites.clear": "Pulisci", "station.settings.satellites.clearFootprints": "CLEAR ALL FOOTPRINTS", + "station.settings.satellites.dopplerFactor": "Doppler Factor", "station.settings.satellites.downlink": "Downlink", "station.settings.satellites.dragTitle": "Drag title to move", "station.settings.satellites.latitude": "Latitude", @@ -400,6 +401,8 @@ "station.settings.satellites.mode": "Mode", "station.settings.satellites.name": "SATELLITE", "station.settings.satellites.name_plural": "SATELLITES", + "station.settings.satellites.range": "Range", + "station.settings.satellites.rangeRate": "Range Rate", "station.settings.satellites.selectAll": "Seleziona tutto", "station.settings.satellites.selectedCount": "{{count}} satellite(i) selezionato(i)", "station.settings.satellites.showAll": "Mostra tutti i satelliti (nessun filtro)", diff --git a/src/lang/ja.json b/src/lang/ja.json index 9df59643..2235d8b7 100644 --- a/src/lang/ja.json +++ b/src/lang/ja.json @@ -393,6 +393,7 @@ "station.settings.satellites.belowHorizon": "地平線下", "station.settings.satellites.clear": "クリア", "station.settings.satellites.clearFootprints": "CLEAR ALL FOOTPRINTS", + "station.settings.satellites.dopplerFactor": "Doppler Factor", "station.settings.satellites.downlink": "Downlink", "station.settings.satellites.dragTitle": "Drag title to move", "station.settings.satellites.latitude": "Latitude", @@ -400,6 +401,8 @@ "station.settings.satellites.mode": "Mode", "station.settings.satellites.name": "SATELLITE", "station.settings.satellites.name_plural": "SATELLITES", + "station.settings.satellites.range": "Range", + "station.settings.satellites.rangeRate": "Range Rate", "station.settings.satellites.selectAll": "全選択", "station.settings.satellites.selectedCount": "{{count}}個の衛星を選択", "station.settings.satellites.showAll": "全衛星を表示中 (フィルタなし)", diff --git a/src/lang/ka.json b/src/lang/ka.json index a75bcab6..a6a2219c 100644 --- a/src/lang/ka.json +++ b/src/lang/ka.json @@ -393,6 +393,7 @@ "station.settings.satellites.belowHorizon": "ჰორიზონტის ქვემოთ", "station.settings.satellites.clear": "გასუფთავება", "station.settings.satellites.clearFootprints": "CLEAR ALL FOOTPRINTS", + "station.settings.satellites.dopplerFactor": "Doppler Factor", "station.settings.satellites.downlink": "Downlink", "station.settings.satellites.dragTitle": "Drag title to move", "station.settings.satellites.latitude": "Latitude", @@ -400,6 +401,8 @@ "station.settings.satellites.mode": "Mode", "station.settings.satellites.name": "SATELLITE", "station.settings.satellites.name_plural": "SATELLITES", + "station.settings.satellites.range": "Range", + "station.settings.satellites.rangeRate": "Range Rate", "station.settings.satellites.selectAll": "ყველას არჩევა", "station.settings.satellites.selectedCount": "არჩეულია {{count}} თანამგზავრი", "station.settings.satellites.showAll": "ნაჩვენებია ყველა თანამგზავრი (ფილტრის გარეშე)", diff --git a/src/lang/ko.json b/src/lang/ko.json index eb200b7e..3813c619 100644 --- a/src/lang/ko.json +++ b/src/lang/ko.json @@ -393,6 +393,7 @@ "station.settings.satellites.belowHorizon": "수평선 아래", "station.settings.satellites.clear": "지우기", "station.settings.satellites.clearFootprints": "CLEAR ALL FOOTPRINTS", + "station.settings.satellites.dopplerFactor": "Doppler Factor", "station.settings.satellites.downlink": "Downlink", "station.settings.satellites.dragTitle": "Drag title to move", "station.settings.satellites.latitude": "Latitude", @@ -400,6 +401,8 @@ "station.settings.satellites.mode": "Mode", "station.settings.satellites.name": "SATELLITE", "station.settings.satellites.name_plural": "SATELLITES", + "station.settings.satellites.range": "Range", + "station.settings.satellites.rangeRate": "Range Rate", "station.settings.satellites.selectAll": "전체 선택", "station.settings.satellites.selectedCount": "{{count}}개 위성 선택됨", "station.settings.satellites.showAll": "모든 위성 표시 중 (필터 없음)", diff --git a/src/lang/ms.json b/src/lang/ms.json index 29095c74..71cc67f2 100644 --- a/src/lang/ms.json +++ b/src/lang/ms.json @@ -393,6 +393,7 @@ "station.settings.satellites.belowHorizon": "Di bawah ufuk", "station.settings.satellites.clear": "Kosongkan", "station.settings.satellites.clearFootprints": "CLEAR ALL FOOTPRINTS", + "station.settings.satellites.dopplerFactor": "Doppler Factor", "station.settings.satellites.downlink": "Downlink", "station.settings.satellites.dragTitle": "Drag title to move", "station.settings.satellites.latitude": "Latitude", @@ -400,6 +401,8 @@ "station.settings.satellites.mode": "Mode", "station.settings.satellites.name": "SATELLITE", "station.settings.satellites.name_plural": "SATELLITES", + "station.settings.satellites.range": "Range", + "station.settings.satellites.rangeRate": "Range Rate", "station.settings.satellites.selectAll": "Pilih Semua", "station.settings.satellites.selectedCount": "{{count}} satelit dipilih", "station.settings.satellites.showAll": "Menunjukkan semua satelit", diff --git a/src/lang/nl.json b/src/lang/nl.json index 2c797589..d64d3938 100644 --- a/src/lang/nl.json +++ b/src/lang/nl.json @@ -393,6 +393,7 @@ "station.settings.satellites.belowHorizon": "Onder de horizon", "station.settings.satellites.clear": "Wissen", "station.settings.satellites.clearFootprints": "CLEAR ALL FOOTPRINTS", + "station.settings.satellites.dopplerFactor": "Doppler Factor", "station.settings.satellites.downlink": "Downlink", "station.settings.satellites.dragTitle": "Drag title to move", "station.settings.satellites.latitude": "Latitude", @@ -400,6 +401,8 @@ "station.settings.satellites.mode": "Mode", "station.settings.satellites.name": "SATELLITE", "station.settings.satellites.name_plural": "SATELLITES", + "station.settings.satellites.range": "Range", + "station.settings.satellites.rangeRate": "Range Rate", "station.settings.satellites.selectAll": "Alles selecteren", "station.settings.satellites.selectedCount": "{{count}} satelliet(en) geselecteerd", "station.settings.satellites.showAll": "Alle satellieten worden getoond (geen filter)", diff --git a/src/lang/pt.json b/src/lang/pt.json index a3394a09..16da5cba 100644 --- a/src/lang/pt.json +++ b/src/lang/pt.json @@ -393,6 +393,7 @@ "station.settings.satellites.belowHorizon": "Abaixo do horizonte", "station.settings.satellites.clear": "Limpar", "station.settings.satellites.clearFootprints": "CLEAR ALL FOOTPRINTS", + "station.settings.satellites.dopplerFactor": "Doppler Factor", "station.settings.satellites.downlink": "Downlink", "station.settings.satellites.dragTitle": "Drag title to move", "station.settings.satellites.latitude": "Latitude", @@ -400,6 +401,8 @@ "station.settings.satellites.mode": "Mode", "station.settings.satellites.name": "SATELLITE", "station.settings.satellites.name_plural": "SATELLITES", + "station.settings.satellites.range": "Range", + "station.settings.satellites.rangeRate": "Range Rate", "station.settings.satellites.selectAll": "Selecionar tudo", "station.settings.satellites.selectedCount": "{{count}} sat?lite(s) selecionado(s)", "station.settings.satellites.showAll": "Mostrando todos os sat?lites (sem filtro)", diff --git a/src/lang/ru.json b/src/lang/ru.json index d8357938..5d245be2 100644 --- a/src/lang/ru.json +++ b/src/lang/ru.json @@ -393,6 +393,7 @@ "station.settings.satellites.belowHorizon": "За горизонтом", "station.settings.satellites.clear": "Очистить", "station.settings.satellites.clearFootprints": "CLEAR ALL FOOTPRINTS", + "station.settings.satellites.dopplerFactor": "Doppler Factor", "station.settings.satellites.downlink": "Downlink", "station.settings.satellites.dragTitle": "Drag title to move", "station.settings.satellites.latitude": "Latitude", @@ -400,6 +401,8 @@ "station.settings.satellites.mode": "Mode", "station.settings.satellites.name": "SATELLITE", "station.settings.satellites.name_plural": "SATELLITES", + "station.settings.satellites.range": "Range", + "station.settings.satellites.rangeRate": "Range Rate", "station.settings.satellites.selectAll": "Выбрать все", "station.settings.satellites.selectedCount": "Выбрано спутников: {{count}}", "station.settings.satellites.showAll": "Показаны все спутники (без фильтра)", diff --git a/src/lang/sl.json b/src/lang/sl.json index f60acbbd..df7145af 100644 --- a/src/lang/sl.json +++ b/src/lang/sl.json @@ -393,6 +393,7 @@ "station.settings.satellites.belowHorizon": "Pod obzorjem", "station.settings.satellites.clear": "Po?isti", "station.settings.satellites.clearFootprints": "CLEAR ALL FOOTPRINTS", + "station.settings.satellites.dopplerFactor": "Doppler Factor", "station.settings.satellites.downlink": "Downlink", "station.settings.satellites.dragTitle": "Drag title to move", "station.settings.satellites.latitude": "Latitude", @@ -400,6 +401,8 @@ "station.settings.satellites.mode": "Mode", "station.settings.satellites.name": "SATELLITE", "station.settings.satellites.name_plural": "SATELLITES", + "station.settings.satellites.range": "Range", + "station.settings.satellites.rangeRate": "Range Rate", "station.settings.satellites.selectAll": "Izberi vse", "station.settings.satellites.selectedCount": "Izbranih satelitov: {{count}}", "station.settings.satellites.showAll": "Prikazani vsi sateliti (brez filtra)", diff --git a/src/lang/th.json b/src/lang/th.json index afc564ff..6a60e0c3 100644 --- a/src/lang/th.json +++ b/src/lang/th.json @@ -393,6 +393,7 @@ "station.settings.satellites.belowHorizon": "ต่ำกว่าขอบฟ้า", "station.settings.satellites.clear": "ล้างการเลือก", "station.settings.satellites.clearFootprints": "ล้างรอยแผนที่ทั้งหมด", + "station.settings.satellites.dopplerFactor": "ตัวคูณดอปเปลอร์", "station.settings.satellites.downlink": "ดาวน์ลิงก์", "station.settings.satellites.dragTitle": "ลากชื่อเรื่องเพื่อย้าย", "station.settings.satellites.latitude": "ละติจูด", @@ -400,6 +401,8 @@ "station.settings.satellites.mode": "โหมด", "station.settings.satellites.name": "ดาวเทียม", "station.settings.satellites.name_plural": "ดาวเทียม", + "station.settings.satellites.range": "ระยะทาง", + "station.settings.satellites.rangeRate": "อัตราการของระยะทาง", "station.settings.satellites.selectAll": "เลือกทั้งหมด", "station.settings.satellites.selectedCount": "ดาวเทียม {{count}} ที่ได้รับการคัดเลือก", "station.settings.satellites.showAll": "แสดงดาวเทียมทั้งหมด (ปิดตัวกรอง)", diff --git a/src/lang/zh.json b/src/lang/zh.json index a98ca07a..a3893faf 100644 --- a/src/lang/zh.json +++ b/src/lang/zh.json @@ -393,6 +393,7 @@ "station.settings.satellites.belowHorizon": "地平线下", "station.settings.satellites.clear": "清空", "station.settings.satellites.clearFootprints": "CLEAR ALL FOOTPRINTS", + "station.settings.satellites.dopplerFactor": "Doppler Factor", "station.settings.satellites.downlink": "Downlink", "station.settings.satellites.dragTitle": "Drag title to move", "station.settings.satellites.latitude": "Latitude", @@ -400,6 +401,8 @@ "station.settings.satellites.mode": "Mode", "station.settings.satellites.name": "SATELLITE", "station.settings.satellites.name_plural": "SATELLITES", + "station.settings.satellites.range": "Range", + "station.settings.satellites.rangeRate": "Range Rate", "station.settings.satellites.selectAll": "全选", "station.settings.satellites.selectedCount": "已选择 {{count}} 颗卫星", "station.settings.satellites.showAll": "显示所有卫星 (无过滤)", diff --git a/src/plugins/layers/useSatelliteLayer.js b/src/plugins/layers/useSatelliteLayer.js index e8bd7b96..1df58a5a 100644 --- a/src/plugins/layers/useSatelliteLayer.js +++ b/src/plugins/layers/useSatelliteLayer.js @@ -272,9 +272,9 @@ export const useLayer = ({ map, enabled, satellites, setSatellites, opacity, con ${ isVisible ? ` - Range:${(sat.range * (isMetric ? 1 : km_to_miles_factor)).toFixed(0)} ${distanceUnitsStr} - Range Rate:${(sat.rangeRate * (isMetric ? 1 : km_to_miles_factor)).toFixed(2)} ${rangeRateUnitsStr} - Doppler Factor:${sat.dopplerFactor.toFixed(7)} + ${t('station.settings.satellites.range')}:${(sat.range * (isMetric ? 1 : km_to_miles_factor)).toFixed(0)} ${distanceUnitsStr} + ${t('station.settings.satellites.rangeRate')}:${(sat.rangeRate * (isMetric ? 1 : km_to_miles_factor)).toFixed(2)} ${rangeRateUnitsStr} + ${t('station.settings.satellites.dopplerFactor')}:${sat.dopplerFactor.toFixed(7)} ` : `` } From ab4e8e42841f130f90a75b4f95cf99fbbf9a8be4 Mon Sep 17 00:00:00 2001 From: Michael R Wheeley Date: Wed, 1 Apr 2026 21:54:22 -0700 Subject: [PATCH 04/12] update to satellite.js version 6.0.0 to fix dopplerFactor --- README.md | 5 +++-- package-lock.json | 8 ++++---- package.json | 2 +- public/index-monolithic.html | 2 +- src/hooks/useSatellites.js | 25 ++++++++++++++----------- 5 files changed, 23 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index d33c7e26..3198f7c3 100644 --- a/README.md +++ b/README.md @@ -429,8 +429,9 @@ Real-time tracking of amateur radio satellites with orbital visualization on the - Satellite positions as colored markers on the map, updated every 5 seconds - Orbital track lines showing each satellite's path over the next pass - Satellite name, altitude, and coordinates in the popup -- When the satellite is visible shows its range, range-rate (positive approaching, negative receding), and dopper factor -- (doppler factor is uplink/downlnk frequency multiplier when satellite approaching, and frequency divisor when satellite receding) +- When the satellite is visible popup shows range, range-rate, and doppler factor +- (negative range rate means the satellite is approaching, positive means it is receding (moving away)) +- (doppler factor is uplink/downlink frequency multiplier to account for frequency shift due to relative motion) **How to use it:** diff --git a/package-lock.json b/package-lock.json index e8148b55..31a3d8b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "papaparse": "^5.5.3", "react-colorful": "^5.6.1", "react-i18next": "^16.5.4", - "satellite.js": "^5.0.0", + "satellite.js": "^6.0.0", "ws": "^8.14.2" }, "devDependencies": { @@ -8293,9 +8293,9 @@ } }, "node_modules/satellite.js": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/satellite.js/-/satellite.js-5.0.0.tgz", - "integrity": "sha512-ie3yiJ2LJAJIhVUKdYhgp7V0btXKAMImDjRnuaNfJGl8rjwP2HwVIh4HLFcpiXYEiYwXc5fqh5+yZqCe6KIwWw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/satellite.js/-/satellite.js-6.0.0.tgz", + "integrity": "sha512-A1OTZpIDgzCj1SYJZYs3aISsBuhE0jAZG3n6Pymxq5FFsScNSLIgabu8a1w0Wu56Mf1eoL+pcK4tXgaPNXtdSw==", "license": "MIT" }, "node_modules/sax": { diff --git a/package.json b/package.json index 6de843be..fd230181 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "papaparse": "^5.5.3", "react-colorful": "^5.6.1", "react-i18next": "^16.5.4", - "satellite.js": "^5.0.0", + "satellite.js": "^6.0.0", "ws": "^8.14.2" }, "devDependencies": { diff --git a/public/index-monolithic.html b/public/index-monolithic.html index b4b775c0..ced3d72b 100644 --- a/public/index-monolithic.html +++ b/public/index-monolithic.html @@ -109,7 +109,7 @@ - + diff --git a/src/hooks/useSatellites.js b/src/hooks/useSatellites.js index 848733bf..778bca7e 100644 --- a/src/hooks/useSatellites.js +++ b/src/hooks/useSatellites.js @@ -44,6 +44,7 @@ export const useSatellites = (observerLocation) => { try { const now = new Date(); + const gmst = satellite.gstime(now); const positions = []; // Observer position in radians @@ -62,11 +63,12 @@ export const useSatellites = (observerLocation) => { try { const satrec = satellite.twoline2satrec(line1, line2); const positionAndVelocity = satellite.propagate(satrec, now); + const positionEci = positionAndVelocity.position; + const velocityEci = positionAndVelocity.velocity; - if (!positionAndVelocity.position) return; + if (!positionEci) return; - const gmst = satellite.gstime(now); - const positionGd = satellite.eciToGeodetic(positionAndVelocity.position, gmst); + const positionGd = satellite.eciToGeodetic(positionEci, gmst); // Convert to degrees const lat = satellite.degreesLat(positionGd.latitude); @@ -74,28 +76,28 @@ export const useSatellites = (observerLocation) => { const alt = positionGd.height; // Calculate look angles - const positionEcf = satellite.eciToEcf(positionAndVelocity.position, gmst); + const positionEcf = satellite.eciToEcf(positionEci, gmst); const lookAngles = satellite.ecfToLookAngles(observerGd, positionEcf); const azimuth = satellite.radiansToDegrees(lookAngles.azimuth); const elevation = satellite.radiansToDegrees(lookAngles.elevation); const rangeSat = lookAngles.rangeSat; // Calculate range-rate and doppler factor, only if satellite is above horizon - let dopplerFactor = 0; + let dopplerFactor = 1; let rangeRate = 0; if (elevation > 0) { const observerEcf = satellite.geodeticToEcf(observerGd); - const velocityEcf = satellite.eciToEcf(positionAndVelocity.velocity, gmst); + const velocityEcf = satellite.eciToEcf(velocityEci, gmst); dopplerFactor = satellite.dopplerFactor(observerEcf, positionEcf, velocityEcf); const c = 299792.458; // Speed of light [km/s] rangeRate = (1 - dopplerFactor) * c; // [km/s] } - // Calculate speed from ECI velocity vector (km/s) + // Calculate speed from ECI velocity vector [km/s] let speedKmH = 0; - if (positionAndVelocity.velocity) { - const v = positionAndVelocity.velocity; - speedKmH = Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z) * 3600; // km/s → km/h + if (velocityEci) { + const v = velocityEci; + speedKmH = Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z) * 3600; // [km/s] → [km/h] } // Calculate orbit track (past 45 min and future 45 min = 90 min total) @@ -117,7 +119,8 @@ export const useSatellites = (observerLocation) => { } // Calculate footprint radius (visibility circle) - const earthRadius = 6371; // km + // Formula: radius = Earth_radius * arccos(Earth_radius / (Earth_radius + altitude)) + const earthRadius = 6371; // [km] const footprintRadius = earthRadius * Math.acos(earthRadius / (earthRadius + alt)); positions.push({ From c7aaafd95d99f8f9555c29420599ab1ad5fef1c8 Mon Sep 17 00:00:00 2001 From: ceotjoe Date: Fri, 3 Apr 2026 00:23:39 +0200 Subject: [PATCH 05/12] fix: K/SFI index shows '--' for zero and band conditions can show stale N0NBH data Two bugs reported in propagation display: 1. Kp index (and SSN/SFI fallback) treated numeric zero as absent because the server used || null when resolving the current value from history. A quiet geomagnetic day (Kp=0) caused kp.current to be set to null, which propagated to the header and layout displays as '--'. Fixed by switching to ?? null / ?? '--' throughout (server route + Header, SpaceWeatherPanel, ClassicLayout, useSpaceWeather). Also tightened the !result.sfi.current / !result.ssn.current guards to == null so a genuine zero is never treated as missing. 2. When hamqsl.com (N0NBH) was temporarily unreachable the server returned stale cached data indefinitely. The error path never updated the cache timestamp so every subsequent request re-tried, failed, and served the same old data with no age bound. Added N0NBH_MAX_STALE_TTL (4 h): beyond that the endpoint returns 503 rather than misleading clients. Normal and error-fallback responses now include fetchedAt and stale:true when serving fallback data. useBandConditions passes these through extras; BandConditionsPanel and PropagationPanel show a stale badge in their headers when N0NBH data could not be refreshed. Co-Authored-By: Claude Sonnet 4.6 --- server/routes/space-weather.js | 31 ++++++++++++++++------- src/components/BandConditionsPanel.jsx | 28 +++++++++++++++++++-- src/components/Header.jsx | 2 +- src/components/PropagationPanel.jsx | 25 +++++++++++++++++- src/components/SpaceWeatherPanel.jsx | 4 +-- src/hooks/useBandConditions.js | 2 ++ src/hooks/useSpaceWeather.js | 2 +- src/layouts/ClassicLayout.jsx | 35 ++++++++++++++++++++++---- 8 files changed, 108 insertions(+), 21 deletions(-) diff --git a/server/routes/space-weather.js b/server/routes/space-weather.js index 32e33fba..e0bd2e12 100644 --- a/server/routes/space-weather.js +++ b/server/routes/space-weather.js @@ -20,6 +20,10 @@ module.exports = function (app, ctx) { // N0NBH / HamQSL cache let n0nbhCache = { data: null, timestamp: 0 }; const N0NBH_CACHE_TTL = 60 * 60 * 1000; + // Maximum age of stale error-fallback data. N0NBH updates every ~3 hours; + // beyond 4 hours the data is definitively out of date and we should stop + // serving it rather than silently mislead clients. + const N0NBH_MAX_STALE_TTL = 4 * 60 * 60 * 1000; // Parse N0NBH solarxml.php XML into clean JSON function parseN0NBHxml(xml) { @@ -165,7 +169,7 @@ module.exports = function (app, ctx) { } // SFI current fallback: N0NBH - if (!result.sfi.current && n0nbhCache.data?.solarData?.solarFlux) { + if (result.sfi.current == null && n0nbhCache.data?.solarData?.solarFlux) { const flux = parseInt(n0nbhCache.data.solarData.solarFlux); if (flux > 0) result.sfi.current = flux; } @@ -179,8 +183,8 @@ module.exports = function (app, ctx) { date: d.time_tag || d.date, value: Math.round(d.flux || d.value || 0), })); - if (!result.sfi.current) { - result.sfi.current = result.sfi.history[result.sfi.history.length - 1]?.value || null; + if (result.sfi.current == null) { + result.sfi.current = result.sfi.history[result.sfi.history.length - 1]?.value ?? null; } } } @@ -194,7 +198,7 @@ module.exports = function (app, ctx) { time: d[0], value: parseFloat(d[1]) || 0, })); - result.kp.current = result.kp.history[result.kp.history.length - 1]?.value || null; + result.kp.current = result.kp.history[result.kp.history.length - 1]?.value ?? null; } } @@ -224,8 +228,8 @@ module.exports = function (app, ctx) { date: `${d['time-tag'] || d.time_tag || ''}`, value: Math.round(d.ssn || d['ISES SSN'] || 0), })); - if (!result.ssn.current) { - result.ssn.current = result.ssn.history[result.ssn.history.length - 1]?.value || null; + if (result.ssn.current == null) { + result.ssn.current = result.ssn.history[result.ssn.history.length - 1]?.value ?? null; } } } @@ -570,10 +574,18 @@ module.exports = function (app, ctx) { const parsed = parseN0NBHxml(xml); n0nbhCache = { data: parsed, timestamp: Date.now() }; - res.json(parsed); + res.json({ ...parsed, fetchedAt: n0nbhCache.timestamp }); } catch (error) { logErrorOnce('N0NBH', error.message); - if (n0nbhCache.data) return res.json(n0nbhCache.data); + if (n0nbhCache.data) { + const age = Date.now() - n0nbhCache.timestamp; + if (age > N0NBH_MAX_STALE_TTL) { + // Cache is too old to be useful; tell the client so it can show a + // meaningful error rather than silently displaying stale conditions. + return res.status(503).json({ error: 'N0NBH data unavailable and cached data is too stale' }); + } + return res.json({ ...n0nbhCache.data, fetchedAt: n0nbhCache.timestamp, stale: true }); + } res.status(500).json({ error: 'Failed to fetch N0NBH data' }); } }); @@ -599,7 +611,8 @@ module.exports = function (app, ctx) { try { const response = await fetch('https://www.hamqsl.com/solarxml.php'); const xml = await response.text(); - n0nbhCache = { data: parseN0NBHxml(xml), timestamp: Date.now() }; + const ts = Date.now(); + n0nbhCache = { data: { ...parseN0NBHxml(xml), fetchedAt: ts }, timestamp: ts }; logInfo('[Startup] N0NBH solar data pre-warmed'); } catch (e) { logWarn('[Startup] N0NBH pre-warm failed:', e.message); diff --git a/src/components/BandConditionsPanel.jsx b/src/components/BandConditionsPanel.jsx index 17caae95..54d3c549 100644 --- a/src/components/BandConditionsPanel.jsx +++ b/src/components/BandConditionsPanel.jsx @@ -4,7 +4,7 @@ */ import { useTranslation } from 'react-i18next'; -export const BandConditionsPanel = ({ data, loading }) => { +export const BandConditionsPanel = ({ data, loading, extras }) => { const { t } = useTranslation(); const getConditionStyle = (condition) => { switch (condition) { @@ -19,9 +19,33 @@ export const BandConditionsPanel = ({ data, loading }) => { } }; + // Show a staleness badge when the server is serving error-fallback data. + // fetchedAt is the Unix ms timestamp of the last successful N0NBH fetch. + const staleMinutes = + extras?.stale && extras?.fetchedAt != null ? Math.round((Date.now() - extras.fetchedAt) / 60_000) : null; + return (
-
{t('band.conditions')}
+
+ {t('band.conditions')} + {staleMinutes != null && ( + + ⚠ stale ({staleMinutes}m) + + )} +
{loading ? (
diff --git a/src/components/Header.jsx b/src/components/Header.jsx index 1f812ea6..d4b2a78f 100644 --- a/src/components/Header.jsx +++ b/src/components/Header.jsx @@ -188,7 +188,7 @@ export const Header = ({
SFI - {solarIndices?.data?.sfi?.current || spaceWeather?.data?.solarFlux || '--'} + {solarIndices?.data?.sfi?.current ?? spaceWeather?.data?.solarFlux ?? '--'}
diff --git a/src/components/PropagationPanel.jsx b/src/components/PropagationPanel.jsx index dd4a8bd2..3cd92c45 100644 --- a/src/components/PropagationPanel.jsx +++ b/src/components/PropagationPanel.jsx @@ -209,8 +209,31 @@ export const PropagationPanel = ({ return (
- + {viewMode === 'bands' ? t('band.conditions') : viewMode === 'health' ? '📶 Band Health' : '⌇ VOACAP'} + {viewMode === 'bands' && + bandConditions?.extras?.stale && + bandConditions.extras.fetchedAt != null && + (() => { + const mins = Math.round((Date.now() - bandConditions.extras.fetchedAt) / 60_000); + return ( + + ⚠ stale ({mins}m) + + ); + })()} {!forcedMode && ( diff --git a/src/components/SpaceWeatherPanel.jsx b/src/components/SpaceWeatherPanel.jsx index c263551b..06e76fc1 100644 --- a/src/components/SpaceWeatherPanel.jsx +++ b/src/components/SpaceWeatherPanel.jsx @@ -38,7 +38,7 @@ export const SpaceWeatherPanel = ({ data, loading }) => { fontFamily: 'Orbitron, monospace', }} > - {data?.solarFlux || '--'} + {data?.solarFlux ?? '--'}
@@ -51,7 +51,7 @@ export const SpaceWeatherPanel = ({ data, loading }) => { fontFamily: 'Orbitron, monospace', }} > - {data?.kIndex || '--'} + {data?.kIndex ?? '--'}
diff --git a/src/hooks/useBandConditions.js b/src/hooks/useBandConditions.js index e9c45019..2ba76cec 100644 --- a/src/hooks/useBandConditions.js +++ b/src/hooks/useBandConditions.js @@ -93,6 +93,8 @@ export const useBandConditions = () => { signalNoise: n0nbh.signalNoise, muf: n0nbh.solarData?.muf, updated: n0nbh.updated, + fetchedAt: n0nbh.fetchedAt ?? null, + stale: n0nbh.stale ?? false, source: 'N0NBH', }); } catch (err) { diff --git a/src/hooks/useSpaceWeather.js b/src/hooks/useSpaceWeather.js index af6051cb..446341ac 100644 --- a/src/hooks/useSpaceWeather.js +++ b/src/hooks/useSpaceWeather.js @@ -28,7 +28,7 @@ export const useSpaceWeather = () => { } if (kIndexRes.status === 'fulfilled' && kIndexRes.value.ok) { const d = await kIndexRes.value.json(); - if (d?.length > 1) kIndex = d[d.length - 1][1] || '--'; + if (d?.length > 1) kIndex = d[d.length - 1][1] ?? '--'; } if (sunspotRes.status === 'fulfilled' && sunspotRes.value.ok) { const d = await sunspotRes.value.json(); diff --git a/src/layouts/ClassicLayout.jsx b/src/layouts/ClassicLayout.jsx index 037aba91..84a47607 100644 --- a/src/layouts/ClassicLayout.jsx +++ b/src/layouts/ClassicLayout.jsx @@ -746,12 +746,12 @@ export default function ClassicLayout(props) {
A - {bandConditions?.extras?.aIndex || '--'} + {bandConditions?.extras?.aIndex ?? '--'}
SFI - {solarIndices?.data?.sfi?.current || spaceWeather?.data?.solarFlux || '--'} + {solarIndices?.data?.sfi?.current ?? spaceWeather?.data?.solarFlux ?? '--'}
@@ -1118,7 +1118,7 @@ export default function ClassicLayout(props) {
SFI
- {solarIndices?.data?.sfi?.current || spaceWeather?.data?.solarFlux || '--'} + {solarIndices?.data?.sfi?.current ?? spaceWeather?.data?.solarFlux ?? '--'}
{/* SSN */} @@ -1294,7 +1294,7 @@ export default function ClassicLayout(props) { {t('app.solar.sfiShort')} - {solarIndices?.data?.sfi?.current || spaceWeather?.data?.solarFlux || '--'} + {solarIndices?.data?.sfi?.current ?? spaceWeather?.data?.solarFlux ?? '--'} @@ -1532,9 +1532,34 @@ export default function ClassicLayout(props) { marginBottom: '6px', textTransform: 'uppercase', letterSpacing: '0.5px', + display: 'flex', + alignItems: 'center', + gap: '6px', }} > {t('band.conditions')} + {(() => { + if (!bandConditions?.extras?.stale || bandConditions.extras.fetchedAt == null) return null; + const mins = Math.round((Date.now() - bandConditions.extras.fetchedAt) / 60_000); + return ( + + ⚠ stale ({mins}m) + + ); + })()}
{t('app.solar.sfiShort')} - {solarIndices?.data?.sfi?.current || spaceWeather?.data?.solarFlux || '--'} + {solarIndices?.data?.sfi?.current ?? spaceWeather?.data?.solarFlux ?? '--'} From 4ce841375520cf19d87e127ccdedabd08fb16cd0 Mon Sep 17 00:00:00 2001 From: ceotjoe Date: Fri, 3 Apr 2026 00:34:08 +0200 Subject: [PATCH 06/12] fix: NOAA K-index API format change causes kp to read as 0 NOAA changed noaa-planetary-k-index.json and noaa-planetary-k-index-forecast.json from array-of-arrays to array-of-objects. The old parser read d[0]/d[1] on each row; with objects those are undefined, so parseFloat(undefined) goes to NaN then 0 for every entry, making kp.current always 0 regardless of actual conditions. Both the server /api/solar-indices handler and the browser-side useSpaceWeather hook detect the format (Array.isArray check) and read the correct field (d.Kp for history, d.kp for forecast) in object format, with fallback to old array indexing. Guard history values with Number.isFinite() instead of bare || 0. Co-Authored-By: Claude Sonnet 4.6 --- server/routes/space-weather.js | 24 +++++++++++++++--------- src/hooks/useSpaceWeather.js | 6 +++++- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/server/routes/space-weather.js b/server/routes/space-weather.js index e0bd2e12..94833bea 100644 --- a/server/routes/space-weather.js +++ b/server/routes/space-weather.js @@ -190,25 +190,31 @@ module.exports = function (app, ctx) { } // Kp history + // NOAA changed from array-of-arrays [[header],[time,Kp,...],...] to + // array-of-objects [{time_tag,Kp,...},...] — support both formats. if (kIndexRes.status === 'fulfilled' && kIndexRes.value.ok) { const data = await kIndexRes.value.json(); - if (data?.length > 1) { - const recent = data.slice(1).slice(-24); + if (data?.length) { + const isObj = !Array.isArray(data[0]); + const rows = isObj ? data : data.slice(1); // old format has a header row + const recent = rows.slice(-24); result.kp.history = recent.map((d) => ({ - time: d[0], - value: parseFloat(d[1]) || 0, + time: isObj ? d.time_tag : d[0], + value: Number.isFinite(isObj ? d.Kp : parseFloat(d[1])) ? (isObj ? d.Kp : parseFloat(d[1])) : 0, })); result.kp.current = result.kp.history[result.kp.history.length - 1]?.value ?? null; } } - // Kp forecast + // Kp forecast — same format change; forecast uses lowercase 'kp' field. if (kForecastRes.status === 'fulfilled' && kForecastRes.value.ok) { const data = await kForecastRes.value.json(); - if (data?.length > 1) { - result.kp.forecast = data.slice(1).map((d) => ({ - time: d[0], - value: parseFloat(d[1]) || 0, + if (data?.length) { + const isObj = !Array.isArray(data[0]); + const rows = isObj ? data : data.slice(1); + result.kp.forecast = rows.map((d) => ({ + time: isObj ? d.time_tag : d[0], + value: Number.isFinite(isObj ? d.kp : parseFloat(d[1])) ? (isObj ? d.kp : parseFloat(d[1])) : 0, })); } } diff --git a/src/hooks/useSpaceWeather.js b/src/hooks/useSpaceWeather.js index 446341ac..89aee46e 100644 --- a/src/hooks/useSpaceWeather.js +++ b/src/hooks/useSpaceWeather.js @@ -28,7 +28,11 @@ export const useSpaceWeather = () => { } if (kIndexRes.status === 'fulfilled' && kIndexRes.value.ok) { const d = await kIndexRes.value.json(); - if (d?.length > 1) kIndex = d[d.length - 1][1] ?? '--'; + // NOAA changed from array-of-arrays to array-of-objects — support both. + if (d?.length) { + const last = d[d.length - 1]; + kIndex = (Array.isArray(last) ? last[1] : last?.Kp) ?? '--'; + } } if (sunspotRes.status === 'fulfilled' && sunspotRes.value.ok) { const d = await sunspotRes.value.json(); From f02fc127fa54f197b182fd590dcc83c53390b308 Mon Sep 17 00:00:00 2001 From: ceotjoe Date: Fri, 3 Apr 2026 00:38:02 +0200 Subject: [PATCH 07/12] fix: propagation getSolarData() uses same broken NOAA K-index array parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getSolarData() in propagation.js fetched noaa-planetary-k-index.json independently and parsed it with the old array-of-arrays accessor d[n][1]. NOAA now returns objects, so d[last][1] was undefined, parseInt gave NaN, and the NaN || 2 fallback permanently locked kIndex at 2 — matching neither the actual conditions nor the value shown in the header. Apply the same format-detection fix (Array.isArray check, read d.Kp for object format) used in the space-weather route. Switch to parseFloat so fractional Kp values (e.g. 4.67) are preserved rather than truncated. Co-Authored-By: Claude Sonnet 4.6 --- server/routes/propagation.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/server/routes/propagation.js b/server/routes/propagation.js index 68565b21..13a3cd71 100644 --- a/server/routes/propagation.js +++ b/server/routes/propagation.js @@ -504,7 +504,13 @@ module.exports = function (app, ctx) { } if (kRes.status === 'fulfilled' && kRes.value.ok) { const data = await kRes.value.json(); - if (data?.length > 1) kIndex = parseInt(data[data.length - 1][1]) || 2; + // NOAA changed from array-of-arrays to array-of-objects — support both. + if (data?.length) { + const last = data[data.length - 1]; + const raw = Array.isArray(last) ? last[1] : last?.Kp; + const parsed = parseFloat(raw); + if (Number.isFinite(parsed)) kIndex = parsed; + } } ssn = Math.max(0, Math.round((sfi - 67) / 0.97)); } catch (e) { From dface24e09a6196205dd242ad7570e360a7b848b Mon Sep 17 00:00:00 2001 From: ceotjoe Date: Fri, 3 Apr 2026 00:41:59 +0200 Subject: [PATCH 08/12] =?UTF-8?q?fix:=20f107=5Fcm=5Fflux.json=20is=20not?= =?UTF-8?q?=20chronologically=20sorted=20=E2=80=94=20always=20find=20lates?= =?UTF-8?q?t=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NOAA's f107_cm_flux.json array does not guarantee chronological order; the last element in the array can be months old (observed: last entry was 2026-02-20 with flux=112 while the actual current value from 2026-04-02 was flux=140 at array index 0). getSolarData() in propagation.js was using data[last].flux and therefore reporting stale SFI to the panel footer. Fix: reduce over the array to find the entry with the maximum time_tag. space-weather.js was building the SFI history chart with data.slice(-30) on the unsorted array, giving a chart with out-of-order and non-recent readings. Fix: sort a copy by time_tag before slicing. Co-Authored-By: Claude Sonnet 4.6 --- server/routes/propagation.js | 7 ++++++- server/routes/space-weather.js | 5 ++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/server/routes/propagation.js b/server/routes/propagation.js index 13a3cd71..bffcd506 100644 --- a/server/routes/propagation.js +++ b/server/routes/propagation.js @@ -500,7 +500,12 @@ module.exports = function (app, ctx) { ]); if (fluxRes.status === 'fulfilled' && fluxRes.value.ok) { const data = await fluxRes.value.json(); - if (data?.length) sfi = Math.round(data[data.length - 1].flux || 150); + // f107_cm_flux.json is not sorted chronologically — find the entry with + // the latest time_tag rather than assuming the last element is current. + if (data?.length) { + const latest = data.reduce((best, d) => (d.time_tag > (best?.time_tag ?? '') ? d : best), null); + if (latest?.flux != null) sfi = Math.round(latest.flux ?? 150); + } } if (kRes.status === 'fulfilled' && kRes.value.ok) { const data = await kRes.value.json(); diff --git a/server/routes/space-weather.js b/server/routes/space-weather.js index 94833bea..aa4526fc 100644 --- a/server/routes/space-weather.js +++ b/server/routes/space-weather.js @@ -178,7 +178,10 @@ module.exports = function (app, ctx) { if (fluxRes.status === 'fulfilled' && fluxRes.value.ok) { const data = await fluxRes.value.json(); if (data?.length) { - const recent = data.slice(-30); + // f107_cm_flux.json is not chronologically sorted — sort before slicing + // so history shows the correct 30 most-recent readings in order. + const sorted = [...data].sort((a, b) => (a.time_tag > b.time_tag ? 1 : -1)); + const recent = sorted.slice(-30); result.sfi.history = recent.map((d) => ({ date: d.time_tag || d.date, value: Math.round(d.flux || d.value || 0), From adee1bba6bae6ac4a585c602df90d86f9485f0bf Mon Sep 17 00:00:00 2001 From: ceotjoe Date: Fri, 3 Apr 2026 01:06:00 +0200 Subject: [PATCH 09/12] i18n: localize stale band-conditions badge in all 16 languages The N0NBH stale-data badge added in the previous commit had hardcoded English strings. Add band.conditions.stale.label and band.conditions.stale.tooltip keys to all 16 language files and replace the hardcoded literals in BandConditionsPanel, PropagationPanel, and ClassicLayout with t() calls using {{mins}} interpolation. Co-Authored-By: Claude Sonnet 4.6 --- src/components/BandConditionsPanel.jsx | 4 ++-- src/components/PropagationPanel.jsx | 4 ++-- src/lang/ca.json | 18 ++++++++++-------- src/lang/de.json | 18 ++++++++++-------- src/lang/en.json | 18 ++++++++++-------- src/lang/es.json | 18 ++++++++++-------- src/lang/fr.json | 18 ++++++++++-------- src/lang/it.json | 18 ++++++++++-------- src/lang/ja.json | 18 ++++++++++-------- src/lang/ka.json | 18 ++++++++++-------- src/lang/ko.json | 18 ++++++++++-------- src/lang/ms.json | 18 ++++++++++-------- src/lang/nl.json | 18 ++++++++++-------- src/lang/pt.json | 18 ++++++++++-------- src/lang/ru.json | 18 ++++++++++-------- src/lang/sl.json | 18 ++++++++++-------- src/lang/th.json | 18 ++++++++++-------- src/lang/zh.json | 18 ++++++++++-------- src/layouts/ClassicLayout.jsx | 4 ++-- 19 files changed, 166 insertions(+), 134 deletions(-) diff --git a/src/components/BandConditionsPanel.jsx b/src/components/BandConditionsPanel.jsx index 54d3c549..9e21aa07 100644 --- a/src/components/BandConditionsPanel.jsx +++ b/src/components/BandConditionsPanel.jsx @@ -30,7 +30,7 @@ export const BandConditionsPanel = ({ data, loading, extras }) => { {t('band.conditions')} {staleMinutes != null && ( { cursor: 'default', }} > - ⚠ stale ({staleMinutes}m) + {t('band.conditions.stale.label', { mins: staleMinutes })} )}
diff --git a/src/components/PropagationPanel.jsx b/src/components/PropagationPanel.jsx index 3cd92c45..dc3b8457 100644 --- a/src/components/PropagationPanel.jsx +++ b/src/components/PropagationPanel.jsx @@ -218,7 +218,7 @@ export const PropagationPanel = ({ const mins = Math.round((Date.now() - bandConditions.extras.fetchedAt) / 60_000); return ( - ⚠ stale ({mins}m) + {t('band.conditions.stale.label', { mins })} ); })()} diff --git a/src/lang/ca.json b/src/lang/ca.json index c8b8ecc4..a449cbaf 100644 --- a/src/lang/ca.json +++ b/src/lang/ca.json @@ -6,24 +6,24 @@ "app.dxCluster.title": "Clúster DX", "app.dxLocation.beamDir": "Direcció del feix:", "app.dxLocation.deTitle": "📍 DE - LA TEVA UBICACIÓ", + "app.dxLocation.dxTitle": "📍 DX - OBJECTIU", "app.dxLocation.dxccClearTitle": "Clear DXCC input", "app.dxLocation.dxccPlaceholder": "Select DXCC entity", "app.dxLocation.dxccTitle": "Select a DXCC entity to move the DX target", "app.dxLocation.dxccTitleLocked": "Unlock DX position to select a DXCC entity", "app.dxLocation.dxccToggleTitle": "Show or hide the DXCC selector", - "app.dxLocation.dxTitle": "📍 DX - OBJECTIU", "app.dxLocation.gridInputTitle": "Escriviu un locator Maidenhead (p. ex. JN58sm), premeu Intro", "app.dxLocation.gridInputTitleLocked": "Desbloquegeu la posició DX per introduir un locator manualment", "app.dxLocation.lp": "LP:", "app.dxLocation.sp": "SP:", "app.dxLock.clickToSet": "Clica el mapa per definir DX", - "app.dxLock.locked": "🔒 DX bloquejat", - "app.dxLock.lockedShort": "DX bloquejat", "app.dxLock.lockShort": "Bloquejar posició DX", "app.dxLock.lockTooltip": "Bloquejar posició DX (evitar clics al mapa)", - "app.dxLock.unlocked": "🔓 DX desbloquejat", + "app.dxLock.locked": "🔒 DX bloquejat", + "app.dxLock.lockedShort": "DX bloquejat", "app.dxLock.unlockShort": "Desbloquejar posició DX", "app.dxLock.unlockTooltip": "Desbloquejar posició DX (permetre clics al mapa)", + "app.dxLock.unlocked": "🔓 DX desbloquejat", "app.dxNews.decreaseTextSize": "Decrease text size", "app.dxNews.increaseTextSize": "Increase text size", "app.dxNews.pauseTooltip": "Clica per pausar", @@ -69,8 +69,8 @@ "app.spaceWeather.bz": "Bz", "app.spaceWeather.kp": "Kp", "app.spaceWeather.xray": "Raigs X", - "app.time.local": "Local", "app.time.locShort": "LOC", + "app.time.local": "Local", "app.time.toggleFormat": "Clica per format {{format}}", "app.time.utc": "UTC", "app.units.mhz": "MHz", @@ -87,6 +87,8 @@ "aprsPanel.disabled.rfAfter": "al connector rig-bridge — no cal canvi al .env.", "aprsPanel.disabled.rfBefore": "Per rebre només spots RF locals, activeu el connector", "aprsPanel.disabled.title": "APRS no activat", + "aprsPanel.groupTab.all": "Tots ({{count}})", + "aprsPanel.groupTab.watchlist": "★ Llista de seguiment", "aprsPanel.groups.addButton": "+ Afegir", "aprsPanel.groups.callsignPlaceholder": "Indicatiu...", "aprsPanel.groups.createButton": "+ Crear", @@ -98,8 +100,6 @@ "aprsPanel.groups.title": "Grups de llista de seguiment", "aprsPanel.groupsButton": "👥 Grups", "aprsPanel.groupsButtonTitle": "Gestionar grups de llista de seguiment", - "aprsPanel.groupTab.all": "Tots ({{count}})", - "aprsPanel.groupTab.watchlist": "★ Llista de seguiment", "aprsPanel.loading": "Carregant...", "aprsPanel.mapOff": "OFF", "aprsPanel.mapOn": "ON", @@ -117,6 +117,8 @@ "band.conditions.fair": "REGULAR", "band.conditions.good": "BONA", "band.conditions.poor": "DOLENTA", + "band.conditions.stale.label": "⚠ antic ({{mins}}m)", + "band.conditions.stale.tooltip": "Les dades de N0NBH no s'han pogut actualitzar — mostrant dades de fa {{mins}} minuts", "cancel": "Cancel·lar", "contest.panel.calendar": "Calendari de Concursos WA7BNM", "contest.panel.live": "🔴 {{liveCount}} EN VIU", @@ -125,8 +127,8 @@ "contest.panel.time.live.minutes": "{{minutes}}m restants", "contest.panel.time.startsIn": "Comença en {{hours}}h", "contest.panel.title": "⊛ CONCURSOS", - "dxClusterPanel.filtersButton": "Filtres", "dxClusterPanel.filterTooltip": "Filtrar spots DX per banda, mode o continent", + "dxClusterPanel.filtersButton": "Filtres", "dxClusterPanel.live": "EN VIU", "dxClusterPanel.mapToggleHide": "Amagar spots DX al mapa", "dxClusterPanel.mapToggleOff": "OFF", diff --git a/src/lang/de.json b/src/lang/de.json index 119aed1a..b2c99a15 100644 --- a/src/lang/de.json +++ b/src/lang/de.json @@ -6,24 +6,24 @@ "app.dxCluster.title": "DX-Cluster", "app.dxLocation.beamDir": "Strahlrichtung:", "app.dxLocation.deTitle": "📍 DE - IHR STANDORT", + "app.dxLocation.dxTitle": "📍 DX - ZIEL", "app.dxLocation.dxccClearTitle": "DXCC-Eingabe löschen", "app.dxLocation.dxccPlaceholder": "DXCC-Eintrag wählen", "app.dxLocation.dxccTitle": "DXCC-Eintrag auswählen, um das DX-Ziel zu verschieben", "app.dxLocation.dxccTitleLocked": "DX-Position entsperren, um einen DXCC-Eintrag zu wählen", "app.dxLocation.dxccToggleTitle": "DXCC-Auswahl ein- oder ausblenden", - "app.dxLocation.dxTitle": "📍 DX - ZIEL", "app.dxLocation.gridInputTitle": "Maidenhead-Locator eingeben (z.B. JN58sm), Enter drücken", "app.dxLocation.gridInputTitleLocked": "DX-Position entsperren, um einen Locator einzugeben", "app.dxLocation.lp": "LP:", "app.dxLocation.sp": "SP:", "app.dxLock.clickToSet": "Karte klicken, um DX zu setzen", - "app.dxLock.locked": "🔒 DX gesperrt", - "app.dxLock.lockedShort": "DX gesperrt", "app.dxLock.lockShort": "DX-Position sperren", "app.dxLock.lockTooltip": "DX-Position sperren (Klicks auf Karte verhindern)", - "app.dxLock.unlocked": "🔓 DX entsperrt", + "app.dxLock.locked": "🔒 DX gesperrt", + "app.dxLock.lockedShort": "DX gesperrt", "app.dxLock.unlockShort": "DX-Position entsperren", "app.dxLock.unlockTooltip": "DX-Position entsperren (Klicks auf Karte erlauben)", + "app.dxLock.unlocked": "🔓 DX entsperrt", "app.dxNews.decreaseTextSize": "Decrease text size", "app.dxNews.increaseTextSize": "Increase text size", "app.dxNews.pauseTooltip": "Klicken zum Anhalten", @@ -69,8 +69,8 @@ "app.spaceWeather.bz": "Bz", "app.spaceWeather.kp": "Kp", "app.spaceWeather.xray": "Röntgen", - "app.time.local": "Lokal", "app.time.locShort": "LOC", + "app.time.local": "Lokal", "app.time.toggleFormat": "Klicken für {{format}}-Format", "app.time.utc": "UTC", "app.units.mhz": "MHz", @@ -87,6 +87,8 @@ "aprsPanel.disabled.rfAfter": "Plugin in rig-bridge — keine .env-Änderung erforderlich.", "aprsPanel.disabled.rfBefore": "Für lokale RF-Spots aktivieren Sie das", "aprsPanel.disabled.title": "APRS nicht aktiviert", + "aprsPanel.groupTab.all": "Alle ({{count}})", + "aprsPanel.groupTab.watchlist": "★ Beobachtungsliste", "aprsPanel.groups.addButton": "+ Hinzufügen", "aprsPanel.groups.callsignPlaceholder": "Rufzeichen...", "aprsPanel.groups.createButton": "+ Erstellen", @@ -98,8 +100,6 @@ "aprsPanel.groups.title": "Beobachtungslisten-Gruppen", "aprsPanel.groupsButton": "👥 Gruppen", "aprsPanel.groupsButtonTitle": "Beobachtungslisten-Gruppen verwalten", - "aprsPanel.groupTab.all": "Alle ({{count}})", - "aprsPanel.groupTab.watchlist": "★ Beobachtungsliste", "aprsPanel.loading": "Laden...", "aprsPanel.mapOff": "AUS", "aprsPanel.mapOn": "EIN", @@ -117,6 +117,8 @@ "band.conditions.fair": "MÄSSIG", "band.conditions.good": "GUT", "band.conditions.poor": "SCHLECHT", + "band.conditions.stale.label": "⚠ veraltet ({{mins}}m)", + "band.conditions.stale.tooltip": "N0NBH-Daten konnten nicht aktualisiert werden — Daten von vor {{mins}} Minuten", "cancel": "Abbrechen", "contest.panel.calendar": "WA7BNM Contestkalender", "contest.panel.live": "🔴 {{liveCount}} LIVE", @@ -125,8 +127,8 @@ "contest.panel.time.live.minutes": "noch {{minutes}}m", "contest.panel.time.startsIn": "Startet in {{hours}}h", "contest.panel.title": "⊛ CONTESTS", - "dxClusterPanel.filtersButton": "Filter", "dxClusterPanel.filterTooltip": "DX-Spots nach Band, Modus oder Kontinent filtern", + "dxClusterPanel.filtersButton": "Filter", "dxClusterPanel.live": "LIVE", "dxClusterPanel.mapToggleHide": "DX-Spots auf der Karte ausblenden", "dxClusterPanel.mapToggleOff": "AUS", diff --git a/src/lang/en.json b/src/lang/en.json index e48be247..93f7c175 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -6,24 +6,24 @@ "app.dxCluster.title": "DX Cluster", "app.dxLocation.beamDir": "Beam Dir:", "app.dxLocation.deTitle": "📍 DE - YOUR LOCATION", + "app.dxLocation.dxTitle": "📍 DX - TARGET", "app.dxLocation.dxccClearTitle": "Clear DXCC input", "app.dxLocation.dxccPlaceholder": "Select DXCC entity", "app.dxLocation.dxccTitle": "Select a DXCC entity to move the DX target", "app.dxLocation.dxccTitleLocked": "Unlock DX position to select a DXCC entity", "app.dxLocation.dxccToggleTitle": "Show or hide the DXCC selector", - "app.dxLocation.dxTitle": "📍 DX - TARGET", "app.dxLocation.gridInputTitle": "Type a Maidenhead locator (e.g. JN58sm), press Enter", "app.dxLocation.gridInputTitleLocked": "Unlock DX position to enter a locator manually", "app.dxLocation.lp": "LP:", "app.dxLocation.sp": "SP:", "app.dxLock.clickToSet": "Click map to set DX", - "app.dxLock.locked": "🔒 DX Locked", - "app.dxLock.lockedShort": "DX locked", "app.dxLock.lockShort": "Lock DX position", "app.dxLock.lockTooltip": "Lock DX position (prevent map clicks)", - "app.dxLock.unlocked": "🔓 DX Unlocked", + "app.dxLock.locked": "🔒 DX Locked", + "app.dxLock.lockedShort": "DX locked", "app.dxLock.unlockShort": "Unlock DX position", "app.dxLock.unlockTooltip": "Unlock DX position (allow map clicks)", + "app.dxLock.unlocked": "🔓 DX Unlocked", "app.dxNews.decreaseTextSize": "Decrease text size", "app.dxNews.increaseTextSize": "Increase text size", "app.dxNews.pauseTooltip": "Click to pause scrolling", @@ -69,8 +69,8 @@ "app.spaceWeather.bz": "Bz", "app.spaceWeather.kp": "Kp", "app.spaceWeather.xray": "X-Ray", - "app.time.local": "Local", "app.time.locShort": "LOC", + "app.time.local": "Local", "app.time.toggleFormat": "Click for {{format}} format", "app.time.utc": "UTC", "app.units.mhz": "MHz", @@ -87,6 +87,8 @@ "aprsPanel.disabled.rfAfter": "plugin in rig-bridge — no .env change required.", "aprsPanel.disabled.rfBefore": "To receive local RF spots only, enable the", "aprsPanel.disabled.title": "APRS Not Enabled", + "aprsPanel.groupTab.all": "All ({{count}})", + "aprsPanel.groupTab.watchlist": "★ Watchlist", "aprsPanel.groups.addButton": "+ Add", "aprsPanel.groups.callsignPlaceholder": "Callsign...", "aprsPanel.groups.createButton": "+ Create", @@ -98,8 +100,6 @@ "aprsPanel.groups.title": "Watchlist Groups", "aprsPanel.groupsButton": "👥 Groups", "aprsPanel.groupsButtonTitle": "Manage watchlist groups", - "aprsPanel.groupTab.all": "All ({{count}})", - "aprsPanel.groupTab.watchlist": "★ Watchlist", "aprsPanel.loading": "Loading...", "aprsPanel.mapOff": "OFF", "aprsPanel.mapOn": "ON", @@ -117,6 +117,8 @@ "band.conditions.fair": "FAIR", "band.conditions.good": "GOOD", "band.conditions.poor": "POOR", + "band.conditions.stale.label": "⚠ stale ({{mins}}m)", + "band.conditions.stale.tooltip": "N0NBH data could not be refreshed — showing data from {{mins}} minutes ago", "cancel": "Cancel", "contest.panel.calendar": "WA7BNM Contest Calendar", "contest.panel.live": "🔴 {{liveCount}} LIVE", @@ -125,8 +127,8 @@ "contest.panel.time.live.minutes": "{{minutes}}m left", "contest.panel.time.startsIn": "Starts in {{hours}}H", "contest.panel.title": "⊛ CONTESTS", - "dxClusterPanel.filtersButton": "Filters", "dxClusterPanel.filterTooltip": "Filter DX spots by band, mode, or continent", + "dxClusterPanel.filtersButton": "Filters", "dxClusterPanel.live": "LIVE", "dxClusterPanel.mapToggleHide": "Hide DX spots on map", "dxClusterPanel.mapToggleOff": "OFF", diff --git a/src/lang/es.json b/src/lang/es.json index ee2313ce..c71592a6 100644 --- a/src/lang/es.json +++ b/src/lang/es.json @@ -6,24 +6,24 @@ "app.dxCluster.title": "Cluster DX", "app.dxLocation.beamDir": "Dirección del haz:", "app.dxLocation.deTitle": "📍 DE - TU UBICACIÓN", + "app.dxLocation.dxTitle": "📍 DX - OBJETIVO", "app.dxLocation.dxccClearTitle": "Clear DXCC input", "app.dxLocation.dxccPlaceholder": "Select DXCC entity", "app.dxLocation.dxccTitle": "Select a DXCC entity to move the DX target", "app.dxLocation.dxccTitleLocked": "Unlock DX position to select a DXCC entity", "app.dxLocation.dxccToggleTitle": "Show or hide the DXCC selector", - "app.dxLocation.dxTitle": "📍 DX - OBJETIVO", "app.dxLocation.gridInputTitle": "Introduzca un localizador Maidenhead (p. ej. JN58sm), pulse Intro", "app.dxLocation.gridInputTitleLocked": "Desbloquear posición DX para introducir un localizador manualmente", "app.dxLocation.lp": "LP:", "app.dxLocation.sp": "SP:", "app.dxLock.clickToSet": "Haz clic en el mapa para definir DX", - "app.dxLock.locked": "🔒 DX bloqueado", - "app.dxLock.lockedShort": "DX bloqueado", "app.dxLock.lockShort": "Bloquear posición DX", "app.dxLock.lockTooltip": "Bloquear posición DX (evitar clics en el mapa)", - "app.dxLock.unlocked": "🔓 DX desbloqueado", + "app.dxLock.locked": "🔒 DX bloqueado", + "app.dxLock.lockedShort": "DX bloqueado", "app.dxLock.unlockShort": "Desbloquear posición DX", "app.dxLock.unlockTooltip": "Desbloquear posición DX (permitir clics en el mapa)", + "app.dxLock.unlocked": "🔓 DX desbloqueado", "app.dxNews.decreaseTextSize": "Decrease text size", "app.dxNews.increaseTextSize": "Increase text size", "app.dxNews.pauseTooltip": "Clic para pausar", @@ -69,8 +69,8 @@ "app.spaceWeather.bz": "Bz", "app.spaceWeather.kp": "Kp", "app.spaceWeather.xray": "Rayos X", - "app.time.local": "Local", "app.time.locShort": "LOC", + "app.time.local": "Local", "app.time.toggleFormat": "Haz clic para formato {{format}}", "app.time.utc": "UTC", "app.units.mhz": "MHz", @@ -87,6 +87,8 @@ "aprsPanel.disabled.rfAfter": "plugin en rig-bridge — no se requiere cambio en .env.", "aprsPanel.disabled.rfBefore": "Para recibir solo spots RF locales, active el", "aprsPanel.disabled.title": "APRS no activado", + "aprsPanel.groupTab.all": "Todos ({{count}})", + "aprsPanel.groupTab.watchlist": "★ Lista de seguimiento", "aprsPanel.groups.addButton": "+ Añadir", "aprsPanel.groups.callsignPlaceholder": "Indicativo...", "aprsPanel.groups.createButton": "+ Crear", @@ -98,8 +100,6 @@ "aprsPanel.groups.title": "Grupos de lista de seguimiento", "aprsPanel.groupsButton": "👥 Grupos", "aprsPanel.groupsButtonTitle": "Gestionar grupos de lista de seguimiento", - "aprsPanel.groupTab.all": "Todos ({{count}})", - "aprsPanel.groupTab.watchlist": "★ Lista de seguimiento", "aprsPanel.loading": "Cargando...", "aprsPanel.mapOff": "OFF", "aprsPanel.mapOn": "ON", @@ -117,6 +117,8 @@ "band.conditions.fair": "REGULAR", "band.conditions.good": "BUENA", "band.conditions.poor": "MALA", + "band.conditions.stale.label": "⚠ antiguo ({{mins}}m)", + "band.conditions.stale.tooltip": "Los datos de N0NBH no se pudieron actualizar — mostrando datos de hace {{mins}} minutos", "cancel": "Cancelar", "contest.panel.calendar": "Calendario de Concursos WA7BNM", "contest.panel.live": "🔴 {{liveCount}} EN VIVO", @@ -125,8 +127,8 @@ "contest.panel.time.live.minutes": "{{minutes}}m left", "contest.panel.time.startsIn": "Starts in {{hours}}h", "contest.panel.title": "⊛ CONCURSOS", - "dxClusterPanel.filtersButton": "Filtros", "dxClusterPanel.filterTooltip": "Filtrar spots DX por banda, modo o continente", + "dxClusterPanel.filtersButton": "Filtros", "dxClusterPanel.live": "EN VIVO", "dxClusterPanel.mapToggleHide": "Ocultar spots DX en el mapa", "dxClusterPanel.mapToggleOff": "OFF", diff --git a/src/lang/fr.json b/src/lang/fr.json index f8b065b5..c392ea13 100644 --- a/src/lang/fr.json +++ b/src/lang/fr.json @@ -6,24 +6,24 @@ "app.dxCluster.title": "Cluster DX", "app.dxLocation.beamDir": "Direction du faisceau :", "app.dxLocation.deTitle": "📍 DE - VOTRE POSITION", + "app.dxLocation.dxTitle": "📍 DX - CIBLE", "app.dxLocation.dxccClearTitle": "Clear DXCC input", "app.dxLocation.dxccPlaceholder": "Select DXCC entity", "app.dxLocation.dxccTitle": "Select a DXCC entity to move the DX target", "app.dxLocation.dxccTitleLocked": "Unlock DX position to select a DXCC entity", "app.dxLocation.dxccToggleTitle": "Show or hide the DXCC selector", - "app.dxLocation.dxTitle": "📍 DX - CIBLE", "app.dxLocation.gridInputTitle": "Saisissez un locator Maidenhead (ex. JN58sm), appuyez sur Entrée", "app.dxLocation.gridInputTitleLocked": "Déverrouillez la position DX pour saisir un locator manuellement", "app.dxLocation.lp": "LP :", "app.dxLocation.sp": "SP :", "app.dxLock.clickToSet": "Cliquez sur la carte pour définir le DX", - "app.dxLock.locked": "🔒 DX verrouillé", - "app.dxLock.lockedShort": "DX verrouillé", "app.dxLock.lockShort": "Verrouiller la position DX", "app.dxLock.lockTooltip": "Verrouiller la position DX (empêcher les clics sur la carte)", - "app.dxLock.unlocked": "🔓 DX déverrouillé", + "app.dxLock.locked": "🔒 DX verrouillé", + "app.dxLock.lockedShort": "DX verrouillé", "app.dxLock.unlockShort": "Déverrouiller la position DX", "app.dxLock.unlockTooltip": "Déverrouiller la position DX (autoriser les clics sur la carte)", + "app.dxLock.unlocked": "🔓 DX déverrouillé", "app.dxNews.decreaseTextSize": "Decrease text size", "app.dxNews.increaseTextSize": "Increase text size", "app.dxNews.pauseTooltip": "Cliquer pour mettre en pause", @@ -69,8 +69,8 @@ "app.spaceWeather.bz": "Bz", "app.spaceWeather.kp": "Kp", "app.spaceWeather.xray": "Rayons X", - "app.time.local": "Local", "app.time.locShort": "LOC", + "app.time.local": "Local", "app.time.toggleFormat": "Cliquez pour le format {{format}}", "app.time.utc": "UTC", "app.units.mhz": "MHz", @@ -87,6 +87,8 @@ "aprsPanel.disabled.rfAfter": "dans rig-bridge — aucune modification de .env requise.", "aprsPanel.disabled.rfBefore": "Pour recevoir uniquement des spots RF locaux, activez le plugin", "aprsPanel.disabled.title": "APRS non activé", + "aprsPanel.groupTab.all": "Tous ({{count}})", + "aprsPanel.groupTab.watchlist": "★ Liste de surveillance", "aprsPanel.groups.addButton": "+ Ajouter", "aprsPanel.groups.callsignPlaceholder": "Indicatif...", "aprsPanel.groups.createButton": "+ Créer", @@ -98,8 +100,6 @@ "aprsPanel.groups.title": "Groupes de liste de surveillance", "aprsPanel.groupsButton": "👥 Groupes", "aprsPanel.groupsButtonTitle": "Gérer les groupes de liste de surveillance", - "aprsPanel.groupTab.all": "Tous ({{count}})", - "aprsPanel.groupTab.watchlist": "★ Liste de surveillance", "aprsPanel.loading": "Chargement...", "aprsPanel.mapOff": "OFF", "aprsPanel.mapOn": "ON", @@ -117,6 +117,8 @@ "band.conditions.fair": "MOYENNE", "band.conditions.good": "BONNE", "band.conditions.poor": "MAUVAISE", + "band.conditions.stale.label": "⚠ périmé ({{mins}}m)", + "band.conditions.stale.tooltip": "Les données N0NBH n'ont pas pu être actualisées — affichage des données d'il y a {{mins}} minutes", "cancel": "Annuler", "contest.panel.calendar": "Calendrier des concours WA7BNM", "contest.panel.live": "🔴 {{liveCount}} EN DIRECT", @@ -125,8 +127,8 @@ "contest.panel.time.live.minutes": "{{minutes}}m left", "contest.panel.time.startsIn": "Starts in {{hours}}h", "contest.panel.title": "⊛ CONCOURS", - "dxClusterPanel.filtersButton": "Filtres", "dxClusterPanel.filterTooltip": "Filtrer les spots DX par bande, mode ou continent", + "dxClusterPanel.filtersButton": "Filtres", "dxClusterPanel.live": "EN DIRECT", "dxClusterPanel.mapToggleHide": "Masquer les spots DX sur la carte", "dxClusterPanel.mapToggleOff": "OFF", diff --git a/src/lang/it.json b/src/lang/it.json index fc3d8eb5..b20cc26d 100644 --- a/src/lang/it.json +++ b/src/lang/it.json @@ -6,24 +6,24 @@ "app.dxCluster.title": "Cluster DX", "app.dxLocation.beamDir": "Direzione fascio:", "app.dxLocation.deTitle": "📍 DE - LA TUA POSIZIONE", + "app.dxLocation.dxTitle": "📍 DX - OBIETTIVO", "app.dxLocation.dxccClearTitle": "Clear DXCC input", "app.dxLocation.dxccPlaceholder": "Select DXCC entity", "app.dxLocation.dxccTitle": "Select a DXCC entity to move the DX target", "app.dxLocation.dxccTitleLocked": "Unlock DX position to select a DXCC entity", "app.dxLocation.dxccToggleTitle": "Show or hide the DXCC selector", - "app.dxLocation.dxTitle": "📍 DX - OBIETTIVO", "app.dxLocation.gridInputTitle": "Inserire un locatore Maidenhead (es. JN58sm), premere Invio", "app.dxLocation.gridInputTitleLocked": "Sbloccare la posizione DX per inserire un locatore manualmente", "app.dxLocation.lp": "LP:", "app.dxLocation.sp": "SP:", "app.dxLock.clickToSet": "Clicca sulla mappa per impostare DX", - "app.dxLock.locked": "🔒 DX bloccato", - "app.dxLock.lockedShort": "DX bloccato", "app.dxLock.lockShort": "Blocca posizione DX", "app.dxLock.lockTooltip": "Blocca posizione DX (impedisci clic sulla mappa)", - "app.dxLock.unlocked": "🔓 DX sbloccato", + "app.dxLock.locked": "🔒 DX bloccato", + "app.dxLock.lockedShort": "DX bloccato", "app.dxLock.unlockShort": "Sblocca posizione DX", "app.dxLock.unlockTooltip": "Sblocca posizione DX (consenti clic sulla mappa)", + "app.dxLock.unlocked": "🔓 DX sbloccato", "app.dxNews.decreaseTextSize": "Decrease text size", "app.dxNews.increaseTextSize": "Increase text size", "app.dxNews.pauseTooltip": "Clicca per mettere in pausa", @@ -69,8 +69,8 @@ "app.spaceWeather.bz": "Bz", "app.spaceWeather.kp": "Kp", "app.spaceWeather.xray": "Raggi X", - "app.time.local": "Locale", "app.time.locShort": "LOC", + "app.time.local": "Locale", "app.time.toggleFormat": "Clicca per formato {{format}}", "app.time.utc": "UTC", "app.units.mhz": "MHz", @@ -87,6 +87,8 @@ "aprsPanel.disabled.rfAfter": "plugin in rig-bridge — nessuna modifica a .env richiesta.", "aprsPanel.disabled.rfBefore": "Per ricevere solo spot RF locali, abilita il", "aprsPanel.disabled.title": "APRS non abilitato", + "aprsPanel.groupTab.all": "Tutti ({{count}})", + "aprsPanel.groupTab.watchlist": "★ Lista di controllo", "aprsPanel.groups.addButton": "+ Aggiungi", "aprsPanel.groups.callsignPlaceholder": "Nominativo...", "aprsPanel.groups.createButton": "+ Crea", @@ -98,8 +100,6 @@ "aprsPanel.groups.title": "Gruppi lista di controllo", "aprsPanel.groupsButton": "👥 Gruppi", "aprsPanel.groupsButtonTitle": "Gestisci gruppi lista di controllo", - "aprsPanel.groupTab.all": "Tutti ({{count}})", - "aprsPanel.groupTab.watchlist": "★ Lista di controllo", "aprsPanel.loading": "Caricamento...", "aprsPanel.mapOff": "OFF", "aprsPanel.mapOn": "ON", @@ -117,6 +117,8 @@ "band.conditions.fair": "DISCRETO", "band.conditions.good": "BUONO", "band.conditions.poor": "SCARSO", + "band.conditions.stale.label": "⚠ obsoleto ({{mins}}m)", + "band.conditions.stale.tooltip": "I dati N0NBH non hanno potuto essere aggiornati — dati risalenti a {{mins}} minuti fa", "cancel": "Annulla", "contest.panel.calendar": "Calendario Contest WA7BNM", "contest.panel.live": "🔴 {{liveCount}} LIVE", @@ -125,8 +127,8 @@ "contest.panel.time.live.minutes": "{{minutes}}m left", "contest.panel.time.startsIn": "Starts in {{hours}}h", "contest.panel.title": "⊛ CONTEST", - "dxClusterPanel.filtersButton": "Filtri", "dxClusterPanel.filterTooltip": "Filtra gli spot DX per banda, modo o continente", + "dxClusterPanel.filtersButton": "Filtri", "dxClusterPanel.live": "LIVE", "dxClusterPanel.mapToggleHide": "Nascondi gli spot DX sulla mappa", "dxClusterPanel.mapToggleOff": "OFF", diff --git a/src/lang/ja.json b/src/lang/ja.json index 9df59643..eb23457e 100644 --- a/src/lang/ja.json +++ b/src/lang/ja.json @@ -6,24 +6,24 @@ "app.dxCluster.title": "DX クラスター", "app.dxLocation.beamDir": "ビーム方向:", "app.dxLocation.deTitle": "📍 DE - あなたの位置", + "app.dxLocation.dxTitle": "📍 DX - ターゲット", "app.dxLocation.dxccClearTitle": "Clear DXCC input", "app.dxLocation.dxccPlaceholder": "Select DXCC entity", "app.dxLocation.dxccTitle": "Select a DXCC entity to move the DX target", "app.dxLocation.dxccTitleLocked": "Unlock DX position to select a DXCC entity", "app.dxLocation.dxccToggleTitle": "Show or hide the DXCC selector", - "app.dxLocation.dxTitle": "📍 DX - ターゲット", "app.dxLocation.gridInputTitle": "メイデンヘッドロケーターを入力(例:JN58sm)、Enterを押す", "app.dxLocation.gridInputTitleLocked": "手動でロケーターを入力するにはDX位置のロックを解除してください", "app.dxLocation.lp": "LP:", "app.dxLocation.sp": "SP:", "app.dxLock.clickToSet": "マップをクリックして DX 設定", - "app.dxLock.locked": "🔒 DX ロック中", - "app.dxLock.lockedShort": "DX ロック", "app.dxLock.lockShort": "DX ロック", "app.dxLock.lockTooltip": "DX 位置をロック(マップクリック不可)", - "app.dxLock.unlocked": "🔓 DX ロック解除", + "app.dxLock.locked": "🔒 DX ロック中", + "app.dxLock.lockedShort": "DX ロック", "app.dxLock.unlockShort": "DX ロック解除", "app.dxLock.unlockTooltip": "DX 位置を解除(マップクリック可)", + "app.dxLock.unlocked": "🔓 DX ロック解除", "app.dxNews.decreaseTextSize": "Decrease text size", "app.dxNews.increaseTextSize": "Increase text size", "app.dxNews.pauseTooltip": "クリックして一時停止", @@ -69,8 +69,8 @@ "app.spaceWeather.bz": "Bz", "app.spaceWeather.kp": "Kp", "app.spaceWeather.xray": "X線", - "app.time.local": "ローカル", "app.time.locShort": "現地", + "app.time.local": "ローカル", "app.time.toggleFormat": "{{format}} 形式に切替", "app.time.utc": "UTC", "app.units.mhz": "MHz", @@ -87,6 +87,8 @@ "aprsPanel.disabled.rfAfter": "プラグインを rig-bridge で有効にしてください — .env の変更は不要です。", "aprsPanel.disabled.rfBefore": "ローカル RF スポットのみを受信するには、", "aprsPanel.disabled.title": "APRS が無効です", + "aprsPanel.groupTab.all": "すべて ({{count}})", + "aprsPanel.groupTab.watchlist": "★ ウォッチリスト", "aprsPanel.groups.addButton": "+ 追加", "aprsPanel.groups.callsignPlaceholder": "コールサイン...", "aprsPanel.groups.createButton": "+ 作成", @@ -98,8 +100,6 @@ "aprsPanel.groups.title": "ウォッチリストグループ", "aprsPanel.groupsButton": "👥 グループ", "aprsPanel.groupsButtonTitle": "ウォッチリストグループを管理", - "aprsPanel.groupTab.all": "すべて ({{count}})", - "aprsPanel.groupTab.watchlist": "★ ウォッチリスト", "aprsPanel.loading": "読み込み中...", "aprsPanel.mapOff": "OFF", "aprsPanel.mapOn": "ON", @@ -117,6 +117,8 @@ "band.conditions.fair": "普通", "band.conditions.good": "良好", "band.conditions.poor": "不良", + "band.conditions.stale.label": "⚠ 古いデータ ({{mins}}m)", + "band.conditions.stale.tooltip": "N0NBHデータを更新できませんでした — {{mins}}分前のデータを表示中", "cancel": "キャンセル", "contest.panel.calendar": "WA7BNM コンテストカレンダー", "contest.panel.live": "🔴 {{liveCount}} 件開催中", @@ -125,8 +127,8 @@ "contest.panel.time.live.minutes": "{{minutes}}m left", "contest.panel.time.startsIn": "Starts in {{hours}}h", "contest.panel.title": "⊛ コンテスト", - "dxClusterPanel.filtersButton": "フィルター", "dxClusterPanel.filterTooltip": "バンド・モード・大陸でフィルター", + "dxClusterPanel.filtersButton": "フィルター", "dxClusterPanel.live": "ライブ", "dxClusterPanel.mapToggleHide": "マップ上の DX スポットを非表示", "dxClusterPanel.mapToggleOff": "OFF", diff --git a/src/lang/ka.json b/src/lang/ka.json index a75bcab6..a5c7ac1d 100644 --- a/src/lang/ka.json +++ b/src/lang/ka.json @@ -6,24 +6,24 @@ "app.dxCluster.title": "DX კლასტერი", "app.dxLocation.beamDir": "მიმართულება:", "app.dxLocation.deTitle": "📍 DE - თქვენი მდებარეობა", + "app.dxLocation.dxTitle": "📍 DX - სამიზნე", "app.dxLocation.dxccClearTitle": "Clear DXCC input", "app.dxLocation.dxccPlaceholder": "Select DXCC entity", "app.dxLocation.dxccTitle": "Select a DXCC entity to move the DX target", "app.dxLocation.dxccTitleLocked": "Unlock DX position to select a DXCC entity", "app.dxLocation.dxccToggleTitle": "Show or hide the DXCC selector", - "app.dxLocation.dxTitle": "📍 DX - სამიზნე", "app.dxLocation.gridInputTitle": "შეიყვანეთ Maidenhead ლოკატორი (მაგ. JN58sm), დააჭირეთ Enter", "app.dxLocation.gridInputTitleLocked": "განბლოკეთ DX პოზიცია ლოკატორის ხელით შესაყვანად", "app.dxLocation.lp": "გრძელი:", "app.dxLocation.sp": "მოკლე:", "app.dxLock.clickToSet": "დააწკაპუნეთ რუკაზე DX-ის დასაყენებლად", - "app.dxLock.locked": "🔒 DX დაბლოკილია", - "app.dxLock.lockedShort": "DX დაბლოკილია", "app.dxLock.lockShort": "DX-ის დაბლოკვა", "app.dxLock.lockTooltip": "DX-ის დაბლოკვა (რუკაზე დაწკაპუნების აკრძალვა)", - "app.dxLock.unlocked": "🔓 DX განბლოკილია", + "app.dxLock.locked": "🔒 DX დაბლოკილია", + "app.dxLock.lockedShort": "DX დაბლოკილია", "app.dxLock.unlockShort": "DX-ის განბლოკვა", "app.dxLock.unlockTooltip": "DX-ის განბლოკვა (რუკაზე დაწკაპუნების დაშვება)", + "app.dxLock.unlocked": "🔓 DX განბლოკილია", "app.dxNews.decreaseTextSize": "Decrease text size", "app.dxNews.increaseTextSize": "Increase text size", "app.dxNews.pauseTooltip": "დააწკაპუნეთ გადახვევის შესაჩერებლად", @@ -69,8 +69,8 @@ "app.spaceWeather.bz": "Bz", "app.spaceWeather.kp": "Kp", "app.spaceWeather.xray": "რენტგენი", - "app.time.local": "ადგილობრივი", "app.time.locShort": "ადგ", + "app.time.local": "ადგილობრივი", "app.time.toggleFormat": "დააწკაპუნეთ {{format}} ფორმატისთვის", "app.time.utc": "UTC", "app.units.mhz": "MHz", @@ -87,6 +87,8 @@ "aprsPanel.disabled.rfAfter": "მოდული rig-bridge-ში — .env-ის ცვლილება საჭირო არ არის.", "aprsPanel.disabled.rfBefore": "მხოლოდ ლოკალური RF სპოტების მიღებისთვის ჩართეთ", "aprsPanel.disabled.title": "APRS გათიშულია", + "aprsPanel.groupTab.all": "ყველა ({{count}})", + "aprsPanel.groupTab.watchlist": "★ სამეთვალყურეო სია", "aprsPanel.groups.addButton": "+ დამატება", "aprsPanel.groups.callsignPlaceholder": "სიგნალი...", "aprsPanel.groups.createButton": "+ შექმნა", @@ -98,8 +100,6 @@ "aprsPanel.groups.title": "სამეთვალყურეო სიის ჯგუფები", "aprsPanel.groupsButton": "👥 ჯგუფები", "aprsPanel.groupsButtonTitle": "სამეთვალყურეო სიის ჯგუფების მართვა", - "aprsPanel.groupTab.all": "ყველა ({{count}})", - "aprsPanel.groupTab.watchlist": "★ სამეთვალყურეო სია", "aprsPanel.loading": "იტვირთება...", "aprsPanel.mapOff": "OFF", "aprsPanel.mapOn": "ON", @@ -117,6 +117,8 @@ "band.conditions.fair": "საშუალო", "band.conditions.good": "კარგი", "band.conditions.poor": "ცუდი", + "band.conditions.stale.label": "⚠ მოძველებული ({{mins}}მ)", + "band.conditions.stale.tooltip": "N0NBH-ის მონაცემების განახლება ვერ მოხერხდა — ნაჩვენებია {{mins}} წუთის წინანდელი მონაცემები", "cancel": "გაუქმება", "contest.panel.calendar": "WA7BNM კონტესტების კალენდარი", "contest.panel.live": "🔴 {{liveCount}} ეთერში", @@ -125,8 +127,8 @@ "contest.panel.time.live.minutes": "დარჩა {{minutes}}წთ", "contest.panel.time.startsIn": "იწყება {{hours}}სთ-ში", "contest.panel.title": "⊛ კონტესტები", - "dxClusterPanel.filtersButton": "ფილტრები", "dxClusterPanel.filterTooltip": "DX სპოტების ფილტრი დიაპაზონით, მოდით ან კონტინენტით", + "dxClusterPanel.filtersButton": "ფილტრები", "dxClusterPanel.live": "ეთერი", "dxClusterPanel.mapToggleHide": "DX სპოტების დამალვა რუკაზე", "dxClusterPanel.mapToggleOff": "გამორთ.", diff --git a/src/lang/ko.json b/src/lang/ko.json index eb200b7e..a3d8eb85 100644 --- a/src/lang/ko.json +++ b/src/lang/ko.json @@ -6,24 +6,24 @@ "app.dxCluster.title": "DX 클러스터", "app.dxLocation.beamDir": "방향:", "app.dxLocation.deTitle": "📍 DE – 내 위치", + "app.dxLocation.dxTitle": "📍 DX – 대상", "app.dxLocation.dxccClearTitle": "Clear DXCC input", "app.dxLocation.dxccPlaceholder": "Select DXCC entity", "app.dxLocation.dxccTitle": "Select a DXCC entity to move the DX target", "app.dxLocation.dxccTitleLocked": "Unlock DX position to select a DXCC entity", "app.dxLocation.dxccToggleTitle": "Show or hide the DXCC selector", - "app.dxLocation.dxTitle": "📍 DX – 대상", "app.dxLocation.gridInputTitle": "메이든헤드 로케이터 입력 (예: JN58sm), Enter 누르기", "app.dxLocation.gridInputTitleLocked": "로케이터를 수동으로 입력하려면 DX 위치 잠금 해제", "app.dxLocation.lp": "LP:", "app.dxLocation.sp": "SP:", "app.dxLock.clickToSet": "지도를 클릭하여 DX 설정", - "app.dxLock.locked": "🔒 DX 잠김", - "app.dxLock.lockedShort": "DX 잠김", "app.dxLock.lockShort": "DX 위치 잠금", "app.dxLock.lockTooltip": "DX 위치 잠금 (지도 클릭 방지)", - "app.dxLock.unlocked": "🔓 DX 잠금 해제", + "app.dxLock.locked": "🔒 DX 잠김", + "app.dxLock.lockedShort": "DX 잠김", "app.dxLock.unlockShort": "DX 위치 잠금 해제", "app.dxLock.unlockTooltip": "DX 위치 잠금 해제 (지도 클릭 허용)", + "app.dxLock.unlocked": "🔓 DX 잠금 해제", "app.dxNews.decreaseTextSize": "Decrease text size", "app.dxNews.increaseTextSize": "Increase text size", "app.dxNews.pauseTooltip": "클릭하여 일시 중지", @@ -69,8 +69,8 @@ "app.spaceWeather.bz": "Bz", "app.spaceWeather.kp": "Kp", "app.spaceWeather.xray": "X선", - "app.time.local": "현지", "app.time.locShort": "LOC", + "app.time.local": "현지", "app.time.toggleFormat": "{{format}} 형식으로 전환", "app.time.utc": "UTC", "app.units.mhz": "MHz", @@ -87,6 +87,8 @@ "aprsPanel.disabled.rfAfter": "플러그인을 rig-bridge에서 활성화하세요 — .env 변경 불필요.", "aprsPanel.disabled.rfBefore": "로컬 RF 스팟만 수신하려면", "aprsPanel.disabled.title": "APRS 비활성화", + "aprsPanel.groupTab.all": "전체 ({{count}})", + "aprsPanel.groupTab.watchlist": "★ 관심 목록", "aprsPanel.groups.addButton": "+ 추가", "aprsPanel.groups.callsignPlaceholder": "콜사인...", "aprsPanel.groups.createButton": "+ 생성", @@ -98,8 +100,6 @@ "aprsPanel.groups.title": "관심 목록 그룹", "aprsPanel.groupsButton": "👥 그룹", "aprsPanel.groupsButtonTitle": "관심 목록 그룹 관리", - "aprsPanel.groupTab.all": "전체 ({{count}})", - "aprsPanel.groupTab.watchlist": "★ 관심 목록", "aprsPanel.loading": "불러오는 중...", "aprsPanel.mapOff": "OFF", "aprsPanel.mapOn": "ON", @@ -117,6 +117,8 @@ "band.conditions.fair": "보통", "band.conditions.good": "좋음", "band.conditions.poor": "나쁨", + "band.conditions.stale.label": "⚠ 오래된 데이터 ({{mins}}m)", + "band.conditions.stale.tooltip": "N0NBH 데이터를 새로 고칠 수 없습니다 — {{mins}}분 전 데이터를 표시 중", "cancel": "취소", "contest.panel.calendar": "WA7BNM 콘테스트 캘린더", "contest.panel.live": "🔴 {{liveCount}}개 진행 중", @@ -125,8 +127,8 @@ "contest.panel.time.live.minutes": "{{minutes}}m left", "contest.panel.time.startsIn": "Starts in {{hours}}h", "contest.panel.title": "⊛ 콘테스트", - "dxClusterPanel.filtersButton": "필터", "dxClusterPanel.filterTooltip": "밴드, 모드 또는 대륙별로 DX 스팟 필터", + "dxClusterPanel.filtersButton": "필터", "dxClusterPanel.live": "실시간", "dxClusterPanel.mapToggleHide": "지도에서 DX 스팟 숨기기", "dxClusterPanel.mapToggleOff": "끔", diff --git a/src/lang/ms.json b/src/lang/ms.json index 29095c74..08fda99b 100644 --- a/src/lang/ms.json +++ b/src/lang/ms.json @@ -6,24 +6,24 @@ "app.dxCluster.title": "Kluster DX", "app.dxLocation.beamDir": "Arah Pancaran:", "app.dxLocation.deTitle": "📍 DE - LOKASI ANDA", + "app.dxLocation.dxTitle": "📍 DX - SASARAN", "app.dxLocation.dxccClearTitle": "Kosongkan input DXCC", "app.dxLocation.dxccPlaceholder": "Pilih entiti DXCC", "app.dxLocation.dxccTitle": "Pilih entiti DXCC untuk mengalihkan sasaran DX", "app.dxLocation.dxccTitleLocked": "Buka kunci kedudukan DX untuk memilih entiti DXCC", "app.dxLocation.dxccToggleTitle": "Tunjuk atau sembunyi pemilih DXCC", - "app.dxLocation.dxTitle": "📍 DX - SASARAN", "app.dxLocation.gridInputTitle": "Taip lokator Maidenhead (cth. JN58sm), tekan Enter", "app.dxLocation.gridInputTitleLocked": "Buka kunci kedudukan DX untuk memasukkan lokator secara manual", "app.dxLocation.lp": "LP:", "app.dxLocation.sp": "SP:", "app.dxLock.clickToSet": "Klik peta untuk tetapkan DX", - "app.dxLock.locked": "🔒 DX Dikunci", - "app.dxLock.lockedShort": "DX dikunci", "app.dxLock.lockShort": "Kunci DX", "app.dxLock.lockTooltip": "Kunci posisi DX (halang klik peta)", - "app.dxLock.unlocked": "🔓 DX Dibuka", + "app.dxLock.locked": "🔒 DX Dikunci", + "app.dxLock.lockedShort": "DX dikunci", "app.dxLock.unlockShort": "Buka kunci DX", "app.dxLock.unlockTooltip": "Buka kunci posisi DX (benarkan klik peta)", + "app.dxLock.unlocked": "🔓 DX Dibuka", "app.dxNews.decreaseTextSize": "Kecilkan saiz teks", "app.dxNews.increaseTextSize": "Besarkan saiz teks", "app.dxNews.pauseTooltip": "Klik untuk jeda tatalan", @@ -69,8 +69,8 @@ "app.spaceWeather.bz": "Bz", "app.spaceWeather.kp": "Kp", "app.spaceWeather.xray": "Sinar-X", - "app.time.local": "Tempatan", "app.time.locShort": "LOC", + "app.time.local": "Tempatan", "app.time.toggleFormat": "Klik untuk format {{format}}", "app.time.utc": "UTC", "app.units.mhz": "MHz", @@ -87,6 +87,8 @@ "aprsPanel.disabled.rfAfter": "plugin dalam rig-bridge — tiada perubahan .env diperlukan.", "aprsPanel.disabled.rfBefore": "Untuk menerima spot RF tempatan sahaja, aktifkan plugin", "aprsPanel.disabled.title": "APRS Tidak Diaktifkan", + "aprsPanel.groupTab.all": "Semua ({{count}})", + "aprsPanel.groupTab.watchlist": "★ Senarai Pantau", "aprsPanel.groups.addButton": "+ Tambah", "aprsPanel.groups.callsignPlaceholder": "Callsign...", "aprsPanel.groups.createButton": "+ Cipta", @@ -98,8 +100,6 @@ "aprsPanel.groups.title": "Kumpulan Senarai Pantau", "aprsPanel.groupsButton": "👥 Kumpulan", "aprsPanel.groupsButtonTitle": "Urus kumpulan senarai pantau", - "aprsPanel.groupTab.all": "Semua ({{count}})", - "aprsPanel.groupTab.watchlist": "★ Senarai Pantau", "aprsPanel.loading": "Memuatkan...", "aprsPanel.mapOff": "OFF", "aprsPanel.mapOn": "ON", @@ -117,6 +117,8 @@ "band.conditions.fair": "SEDERHANA", "band.conditions.good": "BAIK", "band.conditions.poor": "LEMAH", + "band.conditions.stale.label": "⚠ lapuk ({{mins}}m)", + "band.conditions.stale.tooltip": "Data N0NBH tidak dapat dimuat semula — menunjukkan data dari {{mins}} minit lalu", "cancel": "Batal", "contest.panel.calendar": "Kalendar Peraduan WA7BNM", "contest.panel.live": "🔴 {{liveCount}} LANGSUNG", @@ -125,8 +127,8 @@ "contest.panel.time.live.minutes": "tinggal {{minutes}}m", "contest.panel.time.startsIn": "Bermula dalam {{hours}}j", "contest.panel.title": "⊛ PERADUAN", - "dxClusterPanel.filtersButton": "Penapis", "dxClusterPanel.filterTooltip": "Tapis spot DX mengikut jalur, mod, atau benua", + "dxClusterPanel.filtersButton": "Penapis", "dxClusterPanel.live": "LANGSUNG", "dxClusterPanel.mapToggleHide": "Sembunyi spot DX pada peta", "dxClusterPanel.mapToggleOff": "OFF", diff --git a/src/lang/nl.json b/src/lang/nl.json index 2c797589..50587ca2 100644 --- a/src/lang/nl.json +++ b/src/lang/nl.json @@ -6,24 +6,24 @@ "app.dxCluster.title": "DX-cluster", "app.dxLocation.beamDir": "Straalrichting:", "app.dxLocation.deTitle": "📍 DE - JOUW LOCATIE", + "app.dxLocation.dxTitle": "📍 DX - DOEL", "app.dxLocation.dxccClearTitle": "Clear DXCC input", "app.dxLocation.dxccPlaceholder": "Select DXCC entity", "app.dxLocation.dxccTitle": "Select a DXCC entity to move the DX target", "app.dxLocation.dxccTitleLocked": "Unlock DX position to select a DXCC entity", "app.dxLocation.dxccToggleTitle": "Show or hide the DXCC selector", - "app.dxLocation.dxTitle": "📍 DX - DOEL", "app.dxLocation.gridInputTitle": "Voer een Maidenhead-locator in (bijv. JN58sm), druk op Enter", "app.dxLocation.gridInputTitleLocked": "Ontgrendel DX-positie om een locator handmatig in te voeren", "app.dxLocation.lp": "LP:", "app.dxLocation.sp": "SP:", "app.dxLock.clickToSet": "Klik op de kaart om DX in te stellen", - "app.dxLock.locked": "DX vergrendeld", - "app.dxLock.lockedShort": "DX vergrendeld", "app.dxLock.lockShort": "DX-positie vergrendelen", "app.dxLock.lockTooltip": "DX-positie vergrendelen (klik op kaart voorkomen)", - "app.dxLock.unlocked": "DX ontgrendeld", + "app.dxLock.locked": "DX vergrendeld", + "app.dxLock.lockedShort": "DX vergrendeld", "app.dxLock.unlockShort": "DX-positie ontgrendelen", "app.dxLock.unlockTooltip": "DX-positie ontgrendelen (klik op kaart toestaan)", + "app.dxLock.unlocked": "DX ontgrendeld", "app.dxNews.decreaseTextSize": "Decrease text size", "app.dxNews.increaseTextSize": "Increase text size", "app.dxNews.pauseTooltip": "Klik om te pauzeren", @@ -69,8 +69,8 @@ "app.spaceWeather.bz": "Bz", "app.spaceWeather.kp": "Kp", "app.spaceWeather.xray": "Röntgen", - "app.time.local": "Lokaal", "app.time.locShort": "LOC", + "app.time.local": "Lokaal", "app.time.toggleFormat": "Klik voor {{format}}-indeling", "app.time.utc": "UTC", "app.units.mhz": "MHz", @@ -87,6 +87,8 @@ "aprsPanel.disabled.rfAfter": "plugin in rig-bridge — geen .env-wijziging vereist.", "aprsPanel.disabled.rfBefore": "Activeer voor alleen lokale RF-spots de", "aprsPanel.disabled.title": "APRS niet ingeschakeld", + "aprsPanel.groupTab.all": "Alle ({{count}})", + "aprsPanel.groupTab.watchlist": "★ Volglijst", "aprsPanel.groups.addButton": "+ Toevoegen", "aprsPanel.groups.callsignPlaceholder": "Roepnaam...", "aprsPanel.groups.createButton": "+ Aanmaken", @@ -98,8 +100,6 @@ "aprsPanel.groups.title": "Volglijst-groepen", "aprsPanel.groupsButton": "👥 Groepen", "aprsPanel.groupsButtonTitle": "Volglijst-groepen beheren", - "aprsPanel.groupTab.all": "Alle ({{count}})", - "aprsPanel.groupTab.watchlist": "★ Volglijst", "aprsPanel.loading": "Laden...", "aprsPanel.mapOff": "UIT", "aprsPanel.mapOn": "AAN", @@ -117,6 +117,8 @@ "band.conditions.fair": "MATIG", "band.conditions.good": "GOED", "band.conditions.poor": "SLECHT", + "band.conditions.stale.label": "⚠ verouderd ({{mins}}m)", + "band.conditions.stale.tooltip": "N0NBH-gegevens konden niet worden vernieuwd — gegevens van {{mins}} minuten geleden", "cancel": "Annuleer", "contest.panel.calendar": "WA7BNM Contestkalender", "contest.panel.live": "🔴 {{liveCount}} LIVE", @@ -125,8 +127,8 @@ "contest.panel.time.live.minutes": "{{minutes}}m left", "contest.panel.time.startsIn": "Starts in {{hours}}h", "contest.panel.title": "⊛ CONTESTS", - "dxClusterPanel.filtersButton": "Filters", "dxClusterPanel.filterTooltip": "DX-spots filteren op band, mode of continent", + "dxClusterPanel.filtersButton": "Filters", "dxClusterPanel.live": "LIVE", "dxClusterPanel.mapToggleHide": "DX-spots op de kaart verbergen", "dxClusterPanel.mapToggleOff": "UIT", diff --git a/src/lang/pt.json b/src/lang/pt.json index a3394a09..e2cea330 100644 --- a/src/lang/pt.json +++ b/src/lang/pt.json @@ -6,24 +6,24 @@ "app.dxCluster.title": "Cluster DX", "app.dxLocation.beamDir": "Direção do feixe:", "app.dxLocation.deTitle": "📍 DE - SUA LOCALIZAÇÃO", + "app.dxLocation.dxTitle": "📍 DX - ALVO", "app.dxLocation.dxccClearTitle": "Clear DXCC input", "app.dxLocation.dxccPlaceholder": "Select DXCC entity", "app.dxLocation.dxccTitle": "Select a DXCC entity to move the DX target", "app.dxLocation.dxccTitleLocked": "Unlock DX position to select a DXCC entity", "app.dxLocation.dxccToggleTitle": "Show or hide the DXCC selector", - "app.dxLocation.dxTitle": "📍 DX - ALVO", "app.dxLocation.gridInputTitle": "Digite um localizador Maidenhead (ex. JN58sm), pressione Enter", "app.dxLocation.gridInputTitleLocked": "Desbloqueie a posição DX para inserir um localizador manualmente", "app.dxLocation.lp": "LP:", "app.dxLocation.sp": "SP:", "app.dxLock.clickToSet": "Clique no mapa para definir DX", - "app.dxLock.locked": "🔒 DX bloqueado", - "app.dxLock.lockedShort": "DX bloqueado", "app.dxLock.lockShort": "Bloquear posição DX", "app.dxLock.lockTooltip": "Bloquear posição DX (evitar cliques no mapa)", - "app.dxLock.unlocked": "🔓 DX desbloqueado", + "app.dxLock.locked": "🔒 DX bloqueado", + "app.dxLock.lockedShort": "DX bloqueado", "app.dxLock.unlockShort": "Desbloquear posição DX", "app.dxLock.unlockTooltip": "Desbloquear posição DX (permitir cliques no mapa)", + "app.dxLock.unlocked": "🔓 DX desbloqueado", "app.dxNews.decreaseTextSize": "Decrease text size", "app.dxNews.increaseTextSize": "Increase text size", "app.dxNews.pauseTooltip": "Clique para pausar", @@ -69,8 +69,8 @@ "app.spaceWeather.bz": "Bz", "app.spaceWeather.kp": "Kp", "app.spaceWeather.xray": "Raios X", - "app.time.local": "Local", "app.time.locShort": "LOC", + "app.time.local": "Local", "app.time.toggleFormat": "Clique para formato {{format}}", "app.time.utc": "UTC", "app.units.mhz": "MHz", @@ -87,6 +87,8 @@ "aprsPanel.disabled.rfAfter": "plugin no rig-bridge — nenhuma alteração em .env necessária.", "aprsPanel.disabled.rfBefore": "Para receber apenas spots RF locais, ative o", "aprsPanel.disabled.title": "APRS não habilitado", + "aprsPanel.groupTab.all": "Todos ({{count}})", + "aprsPanel.groupTab.watchlist": "★ Lista de observação", "aprsPanel.groups.addButton": "+ Adicionar", "aprsPanel.groups.callsignPlaceholder": "Indicativo...", "aprsPanel.groups.createButton": "+ Criar", @@ -98,8 +100,6 @@ "aprsPanel.groups.title": "Grupos de lista de observação", "aprsPanel.groupsButton": "👥 Grupos", "aprsPanel.groupsButtonTitle": "Gerenciar grupos de lista de observação", - "aprsPanel.groupTab.all": "Todos ({{count}})", - "aprsPanel.groupTab.watchlist": "★ Lista de observação", "aprsPanel.loading": "Carregando...", "aprsPanel.mapOff": "OFF", "aprsPanel.mapOn": "ON", @@ -117,6 +117,8 @@ "band.conditions.fair": "REGULAR", "band.conditions.good": "BOM", "band.conditions.poor": "FRACO", + "band.conditions.stale.label": "⚠ desatualizado ({{mins}}m)", + "band.conditions.stale.tooltip": "Os dados do N0NBH não puderam ser atualizados — exibindo dados de {{mins}} minutos atrás", "cancel": "Cancelar", "contest.panel.calendar": "Calendário de Concursos WA7BNM", "contest.panel.live": "🔴 {{liveCount}} AO VIVO", @@ -125,8 +127,8 @@ "contest.panel.time.live.minutes": "{{minutes}}m left", "contest.panel.time.startsIn": "Starts in {{hours}}h", "contest.panel.title": "⊛ CONCURSOS", - "dxClusterPanel.filtersButton": "Filtros", "dxClusterPanel.filterTooltip": "Filtrar spots DX por banda, modo ou continente", + "dxClusterPanel.filtersButton": "Filtros", "dxClusterPanel.live": "AO VIVO", "dxClusterPanel.mapToggleHide": "Ocultar spots DX no mapa", "dxClusterPanel.mapToggleOff": "OFF", diff --git a/src/lang/ru.json b/src/lang/ru.json index d8357938..8b92900e 100644 --- a/src/lang/ru.json +++ b/src/lang/ru.json @@ -6,24 +6,24 @@ "app.dxCluster.title": "DX кластер", "app.dxLocation.beamDir": "Направление:", "app.dxLocation.deTitle": "📍 DE - ВАШЕ РАСПОЛОЖЕНИЕ", + "app.dxLocation.dxTitle": "📍 DX - ЦЕЛЬ", "app.dxLocation.dxccClearTitle": "Clear DXCC input", "app.dxLocation.dxccPlaceholder": "Select DXCC entity", "app.dxLocation.dxccTitle": "Select a DXCC entity to move the DX target", "app.dxLocation.dxccTitleLocked": "Unlock DX position to select a DXCC entity", "app.dxLocation.dxccToggleTitle": "Show or hide the DXCC selector", - "app.dxLocation.dxTitle": "📍 DX - ЦЕЛЬ", "app.dxLocation.gridInputTitle": "Введите локатор Maidenhead (напр. JN58sm), нажмите Enter", "app.dxLocation.gridInputTitleLocked": "Разблокируйте позицию DX для ввода локатора вручную", "app.dxLocation.lp": "ДП:", "app.dxLocation.sp": "КП:", "app.dxLock.clickToSet": "Нажмите на карту для установки DX", - "app.dxLock.locked": "🔒 DX заблокирован", - "app.dxLock.lockedShort": "DX заблокирован", "app.dxLock.lockShort": "Заблокировать DX", "app.dxLock.lockTooltip": "Заблокировать DX (запретить клики по карте)", - "app.dxLock.unlocked": "🔓 DX разблокирован", + "app.dxLock.locked": "🔒 DX заблокирован", + "app.dxLock.lockedShort": "DX заблокирован", "app.dxLock.unlockShort": "Разблокировать DX", "app.dxLock.unlockTooltip": "Разблокировать DX (разрешить клики по карте)", + "app.dxLock.unlocked": "🔓 DX разблокирован", "app.dxNews.decreaseTextSize": "Decrease text size", "app.dxNews.increaseTextSize": "Increase text size", "app.dxNews.pauseTooltip": "Нажмите для паузы прокрутки", @@ -69,8 +69,8 @@ "app.spaceWeather.bz": "Bz", "app.spaceWeather.kp": "Kp", "app.spaceWeather.xray": "Рентген", - "app.time.local": "Местное", "app.time.locShort": "МЕСТ", + "app.time.local": "Местное", "app.time.toggleFormat": "Нажмите для {{format}} формата", "app.time.utc": "UTC", "app.units.mhz": "МГц", @@ -87,6 +87,8 @@ "aprsPanel.disabled.rfAfter": "плагин в rig-bridge — изменение .env не требуется.", "aprsPanel.disabled.rfBefore": "Для приёма только локальных RF-споттов включите", "aprsPanel.disabled.title": "APRS не включён", + "aprsPanel.groupTab.all": "Все ({{count}})", + "aprsPanel.groupTab.watchlist": "★ Список наблюдения", "aprsPanel.groups.addButton": "+ Добавить", "aprsPanel.groups.callsignPlaceholder": "Позывной...", "aprsPanel.groups.createButton": "+ Создать", @@ -98,8 +100,6 @@ "aprsPanel.groups.title": "Группы списка наблюдения", "aprsPanel.groupsButton": "👥 Группы", "aprsPanel.groupsButtonTitle": "Управление группами списка наблюдения", - "aprsPanel.groupTab.all": "Все ({{count}})", - "aprsPanel.groupTab.watchlist": "★ Список наблюдения", "aprsPanel.loading": "Загрузка...", "aprsPanel.mapOff": "ВЫКЛ", "aprsPanel.mapOn": "ВКЛ", @@ -117,6 +117,8 @@ "band.conditions.fair": "СРЕДНЕ", "band.conditions.good": "ХОРОШО", "band.conditions.poor": "ПЛОХО", + "band.conditions.stale.label": "⚠ устарело ({{mins}}м)", + "band.conditions.stale.tooltip": "Данные N0NBH не удалось обновить — отображаются данные {{mins}}-минутной давности", "cancel": "Отмена", "contest.panel.calendar": "Календарь контестов WA7BNM", "contest.panel.live": "🔴 {{liveCount}} В ЭФИРЕ", @@ -125,8 +127,8 @@ "contest.panel.time.live.minutes": "осталось {{minutes}}м", "contest.panel.time.startsIn": "Начало через {{hours}}ч", "contest.panel.title": "⊛ КОНТЕСТЫ", - "dxClusterPanel.filtersButton": "Фильтры", "dxClusterPanel.filterTooltip": "Фильтр DX-спотов по диапазону, виду излучения или континенту", + "dxClusterPanel.filtersButton": "Фильтры", "dxClusterPanel.live": "ЭФИР", "dxClusterPanel.mapToggleHide": "Скрыть DX-споты на карте", "dxClusterPanel.mapToggleOff": "ВЫКЛ", diff --git a/src/lang/sl.json b/src/lang/sl.json index f60acbbd..f378ce9d 100644 --- a/src/lang/sl.json +++ b/src/lang/sl.json @@ -6,24 +6,24 @@ "app.dxCluster.title": "DX cluster", "app.dxLocation.beamDir": "Smer žarka:", "app.dxLocation.deTitle": "📍 DE - VAŠA LOKACIJA", + "app.dxLocation.dxTitle": "📍 DX - CILJ", "app.dxLocation.dxccClearTitle": "Clear DXCC input", "app.dxLocation.dxccPlaceholder": "Select DXCC entity", "app.dxLocation.dxccTitle": "Select a DXCC entity to move the DX target", "app.dxLocation.dxccTitleLocked": "Unlock DX position to select a DXCC entity", "app.dxLocation.dxccToggleTitle": "Show or hide the DXCC selector", - "app.dxLocation.dxTitle": "📍 DX - CILJ", "app.dxLocation.gridInputTitle": "Vnesite Maidenhead lokator (npr. JN58sm), pritisnite Enter", "app.dxLocation.gridInputTitleLocked": "Odklenite položaj DX za ročni vnos lokatorja", "app.dxLocation.lp": "LP:", "app.dxLocation.sp": "SP:", "app.dxLock.clickToSet": "Kliknite na zemljevid za nastavitev DX", - "app.dxLock.locked": "🔒 DX zaklenjen", - "app.dxLock.lockedShort": "DX zaklenjen", "app.dxLock.lockShort": "Zakleni DX položaj", "app.dxLock.lockTooltip": "Zakleni DX položaj (prepreči klike na zemljevid)", - "app.dxLock.unlocked": "🔓 DX odklenjen", + "app.dxLock.locked": "🔒 DX zaklenjen", + "app.dxLock.lockedShort": "DX zaklenjen", "app.dxLock.unlockShort": "Odkleni DX položaj", "app.dxLock.unlockTooltip": "Odkleni DX položaj (dovoli klike na zemljevid)", + "app.dxLock.unlocked": "🔓 DX odklenjen", "app.dxNews.decreaseTextSize": "Decrease text size", "app.dxNews.increaseTextSize": "Increase text size", "app.dxNews.pauseTooltip": "Kliknite za premor", @@ -69,8 +69,8 @@ "app.spaceWeather.bz": "Bz", "app.spaceWeather.kp": "Kp", "app.spaceWeather.xray": "Rentgen", - "app.time.local": "Lokalno", "app.time.locShort": "LOC", + "app.time.local": "Lokalno", "app.time.toggleFormat": "Kliknite za format {{format}}", "app.time.utc": "UTC", "app.units.mhz": "MHz", @@ -87,6 +87,8 @@ "aprsPanel.disabled.rfAfter": "vtičnik v rig-bridge — sprememba .env ni potrebna.", "aprsPanel.disabled.rfBefore": "Za prejemanje samo lokalnih RF spotov omogočite", "aprsPanel.disabled.title": "APRS ni omogočen", + "aprsPanel.groupTab.all": "Vsi ({{count}})", + "aprsPanel.groupTab.watchlist": "★ Opazovalni seznam", "aprsPanel.groups.addButton": "+ Dodaj", "aprsPanel.groups.callsignPlaceholder": "Klicni znak...", "aprsPanel.groups.createButton": "+ Ustvari", @@ -98,8 +100,6 @@ "aprsPanel.groups.title": "Skupine opazovalnega seznama", "aprsPanel.groupsButton": "👥 Skupine", "aprsPanel.groupsButtonTitle": "Upravljanje skupin opazovalnega seznama", - "aprsPanel.groupTab.all": "Vsi ({{count}})", - "aprsPanel.groupTab.watchlist": "★ Opazovalni seznam", "aprsPanel.loading": "Nalaganje...", "aprsPanel.mapOff": "IZKLOP", "aprsPanel.mapOn": "VKLOP", @@ -117,6 +117,8 @@ "band.conditions.fair": "ZMERNO", "band.conditions.good": "DOBRO", "band.conditions.poor": "SLABO", + "band.conditions.stale.label": "⚠ zastarelo ({{mins}}m)", + "band.conditions.stale.tooltip": "Podatkov N0NBH ni bilo mogoče osvežiti — prikazujem podatke izpred {{mins}} minut", "cancel": "Prekliči", "contest.panel.calendar": "WA7BNM Koledar tekmovanj", "contest.panel.live": "🔴 {{liveCount}} V ŽIVO", @@ -125,8 +127,8 @@ "contest.panel.time.live.minutes": "{{minutes}}m left", "contest.panel.time.startsIn": "Starts in {{hours}}h", "contest.panel.title": "⊛ TEKMOVANJA", - "dxClusterPanel.filtersButton": "Filtri", "dxClusterPanel.filterTooltip": "Filtriraj DX spote po pasu, načinu ali celini", + "dxClusterPanel.filtersButton": "Filtri", "dxClusterPanel.live": "V ŽIVO", "dxClusterPanel.mapToggleHide": "Skrij DX spote na zemljevidu", "dxClusterPanel.mapToggleOff": "IZKLOP", diff --git a/src/lang/th.json b/src/lang/th.json index afc564ff..1ab7fbc2 100644 --- a/src/lang/th.json +++ b/src/lang/th.json @@ -6,24 +6,24 @@ "app.dxCluster.title": "DX Cluster", "app.dxLocation.beamDir": "ทิศทาง:", "app.dxLocation.deTitle": "📍 DE - ตำแหน่งของฉัน", + "app.dxLocation.dxTitle": "📍 DX - สถานที่เป้าหมาย", "app.dxLocation.dxccClearTitle": "Clear DXCC input", "app.dxLocation.dxccPlaceholder": "Select DXCC entity", "app.dxLocation.dxccTitle": "Select a DXCC entity to move the DX target", "app.dxLocation.dxccTitleLocked": "Unlock DX position to select a DXCC entity", "app.dxLocation.dxccToggleTitle": "Show or hide the DXCC selector", - "app.dxLocation.dxTitle": "📍 DX - สถานที่เป้าหมาย", "app.dxLocation.gridInputTitle": "เขียนตัวระบุ Maidenhead locator (e.g. JN58sm), กด Enter", "app.dxLocation.gridInputTitleLocked": "ปลดล็อกตำแหน่ง DX เพื่อป้อนตัวระบุด้วยตนเอง", "app.dxLocation.lp": "LP:", "app.dxLocation.sp": "SP:", "app.dxLock.clickToSet": "คลิกที่แผนที่เพื่อตั้ง DX", - "app.dxLock.locked": "🔒 DX Locked", - "app.dxLock.lockedShort": "DX locked", "app.dxLock.lockShort": "Lock DX position", "app.dxLock.lockTooltip": "Lock DX position (prevent map clicks)", - "app.dxLock.unlocked": "🔓 DX Unlocked", + "app.dxLock.locked": "🔒 DX Locked", + "app.dxLock.lockedShort": "DX locked", "app.dxLock.unlockShort": "Unlock DX position", "app.dxLock.unlockTooltip": "Unlock DX position (allow map clicks)", + "app.dxLock.unlocked": "🔓 DX Unlocked", "app.dxNews.decreaseTextSize": "ลดขนาดข้อความ", "app.dxNews.increaseTextSize": "เพิ่มขนาดข้อความ", "app.dxNews.pauseTooltip": "คลิกเพื่อหยุดการเลื่อน", @@ -69,8 +69,8 @@ "app.spaceWeather.bz": "Bz", "app.spaceWeather.kp": "Kp", "app.spaceWeather.xray": "X-Ray", - "app.time.local": "Local", "app.time.locShort": "LOC", + "app.time.local": "Local", "app.time.toggleFormat": "Click for {{format}} format", "app.time.utc": "UTC", "app.units.mhz": "MHz", @@ -87,6 +87,8 @@ "aprsPanel.disabled.rfAfter": "ปลั๊กอินใน rig-bridge — ไม่จำเป็นต้องเปลี่ยน .env", "aprsPanel.disabled.rfBefore": "หากคุณต้องการรับสัญญาณ RF เฉพาะในพื้นที่ท้องถิ่น เปิดใช้", "aprsPanel.disabled.title": "ยังไม่เปิดใช้งาน APRS", + "aprsPanel.groupTab.all": "ทั้งหมด ({{count}})", + "aprsPanel.groupTab.watchlist": "★ รายการเฝ้าติดตาม", "aprsPanel.groups.addButton": "+ เพิ่ม", "aprsPanel.groups.callsignPlaceholder": "สัญญาณเรียกขาน...", "aprsPanel.groups.createButton": "+ สร้าง", @@ -98,8 +100,6 @@ "aprsPanel.groups.title": "กลุ่มรายการเฝ้าติดตาม", "aprsPanel.groupsButton": "👥 กลุ่ม", "aprsPanel.groupsButtonTitle": "จัดการกลุ่มรายการเฝ้าติดตาม", - "aprsPanel.groupTab.all": "ทั้งหมด ({{count}})", - "aprsPanel.groupTab.watchlist": "★ รายการเฝ้าติดตาม", "aprsPanel.loading": "กำลังโหลด...", "aprsPanel.mapOff": "ปิด", "aprsPanel.mapOn": "เปิด", @@ -117,6 +117,8 @@ "band.conditions.fair": "FAIR", "band.conditions.good": "GOOD", "band.conditions.poor": "POOR", + "band.conditions.stale.label": "⚠ ข้อมูลเก่า ({{mins}}m)", + "band.conditions.stale.tooltip": "ไม่สามารถรีเฟรชข้อมูล N0NBH — แสดงข้อมูลเมื่อ {{mins}} นาทีที่แล้ว", "cancel": "ยกเลิก", "contest.panel.calendar": "WA7BNM Contest Calendar", "contest.panel.live": "🔴 {{liveCount}} LIVE", @@ -125,8 +127,8 @@ "contest.panel.time.live.minutes": "{{minutes}}m left", "contest.panel.time.startsIn": "Starts in {{hours}}H", "contest.panel.title": "⊛ CONTESTS", - "dxClusterPanel.filtersButton": "Filters", "dxClusterPanel.filterTooltip": "Filter DX spots by band, mode, or continent", + "dxClusterPanel.filtersButton": "Filters", "dxClusterPanel.live": "LIVE", "dxClusterPanel.mapToggleHide": "Hide DX spots on map", "dxClusterPanel.mapToggleOff": "OFF", diff --git a/src/lang/zh.json b/src/lang/zh.json index a98ca07a..51686f5b 100644 --- a/src/lang/zh.json +++ b/src/lang/zh.json @@ -6,24 +6,24 @@ "app.dxCluster.title": "DX 集群", "app.dxLocation.beamDir": "波束方向:", "app.dxLocation.deTitle": "📍 DE - 您的位置", + "app.dxLocation.dxTitle": "📍 DX - 目标位置", "app.dxLocation.dxccClearTitle": "Clear DXCC input", "app.dxLocation.dxccPlaceholder": "Select DXCC entity", "app.dxLocation.dxccTitle": "Select a DXCC entity to move the DX target", "app.dxLocation.dxccTitleLocked": "Unlock DX position to select a DXCC entity", "app.dxLocation.dxccToggleTitle": "Show or hide the DXCC selector", - "app.dxLocation.dxTitle": "📍 DX - 目标位置", "app.dxLocation.gridInputTitle": "输入梅登黑德网格(如 JN58sm),按回车确认", "app.dxLocation.gridInputTitleLocked": "解锁DX位置以手动输入网格坐标", "app.dxLocation.lp": "长径:", "app.dxLocation.sp": "短径:", "app.dxLock.clickToSet": "点击地图设置 DX", - "app.dxLock.locked": "🔒 DX 已锁定", - "app.dxLock.lockedShort": "DX 已锁定", "app.dxLock.lockShort": "锁定 DX 位置", "app.dxLock.lockTooltip": "锁定 DX 位置 (防止点击地图)", - "app.dxLock.unlocked": "🔓 DX 已解锁", + "app.dxLock.locked": "🔒 DX 已锁定", + "app.dxLock.lockedShort": "DX 已锁定", "app.dxLock.unlockShort": "解锁 DX 位置", "app.dxLock.unlockTooltip": "解锁 DX 位置 (允许点击地图)", + "app.dxLock.unlocked": "🔓 DX 已解锁", "app.dxNews.decreaseTextSize": "Decrease text size", "app.dxNews.increaseTextSize": "Increase text size", "app.dxNews.pauseTooltip": "点击暂停滚动", @@ -69,8 +69,8 @@ "app.spaceWeather.bz": "Bz 场", "app.spaceWeather.kp": "Kp 指数", "app.spaceWeather.xray": "X射线", - "app.time.local": "本地时间", "app.time.locShort": "LOC", + "app.time.local": "本地时间", "app.time.toggleFormat": "点击切换 {{format}} 格式", "app.time.utc": "UTC", "app.units.mhz": "MHz", @@ -87,6 +87,8 @@ "aprsPanel.disabled.rfAfter": "插件在 rig-bridge 中 — 无需修改 .env。", "aprsPanel.disabled.rfBefore": "仅接收本地 RF 标记,请在 rig-bridge 中启用", "aprsPanel.disabled.title": "APRS 未启用", + "aprsPanel.groupTab.all": "全部 ({{count}})", + "aprsPanel.groupTab.watchlist": "★ 监视列表", "aprsPanel.groups.addButton": "+ 添加", "aprsPanel.groups.callsignPlaceholder": "呼号...", "aprsPanel.groups.createButton": "+ 创建", @@ -98,8 +100,6 @@ "aprsPanel.groups.title": "监视列表分组", "aprsPanel.groupsButton": "👥 分组", "aprsPanel.groupsButtonTitle": "管理监视列表分组", - "aprsPanel.groupTab.all": "全部 ({{count}})", - "aprsPanel.groupTab.watchlist": "★ 监视列表", "aprsPanel.loading": "加载中...", "aprsPanel.mapOff": "关", "aprsPanel.mapOn": "开", @@ -117,6 +117,8 @@ "band.conditions.fair": "一般", "band.conditions.good": "优良", "band.conditions.poor": "较差", + "band.conditions.stale.label": "⚠ 数据过旧 ({{mins}}m)", + "band.conditions.stale.tooltip": "无法刷新 N0NBH 数据 — 显示 {{mins}} 分钟前的数据", "cancel": "取消", "contest.panel.calendar": "WA7BNM 竞赛日历", "contest.panel.live": "🔴 {{liveCount}} 正在进行", @@ -125,8 +127,8 @@ "contest.panel.time.live.minutes": "剩余 {{minutes}}分", "contest.panel.time.startsIn": "{{hours}}小时后开始", "contest.panel.title": "⊛ 竞赛", - "dxClusterPanel.filtersButton": "筛选", "dxClusterPanel.filterTooltip": "按频段、模式或大洲筛选 DX 监测", + "dxClusterPanel.filtersButton": "筛选", "dxClusterPanel.live": "实时", "dxClusterPanel.mapToggleHide": "在地图上隐藏 DX 点", "dxClusterPanel.mapToggleOff": "关闭", diff --git a/src/layouts/ClassicLayout.jsx b/src/layouts/ClassicLayout.jsx index 84a47607..dcf2242b 100644 --- a/src/layouts/ClassicLayout.jsx +++ b/src/layouts/ClassicLayout.jsx @@ -1543,7 +1543,7 @@ export default function ClassicLayout(props) { const mins = Math.round((Date.now() - bandConditions.extras.fetchedAt) / 60_000); return ( - ⚠ stale ({mins}m) + {t('band.conditions.stale.label', { mins })} ); })()} From 697f5f27c03e503ce21ba3b3c752f4065f5537f6 Mon Sep 17 00:00:00 2001 From: accius Date: Fri, 3 Apr 2026 09:35:36 -0400 Subject: [PATCH 10/12] fix(privacy): remove IP collection, GeoIP lookups, and country tracking from health check (#866) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop collecting, storing, and transmitting visitor IP addresses to third parties. IPs are now only held as SHA-256 hashes in memory for dedup counting — never persisted to disk or sent externally. Removes ip-api.com integration, country statistics, and IP display from the dashboard and JSON health endpoint. Co-Authored-By: Claude Opus 4.6 (1M context) --- server.js | 4 - server/routes/admin.js | 108 +-------------- server/services/visitor-stats.js | 223 ++++++++----------------------- 3 files changed, 64 insertions(+), 271 deletions(-) diff --git a/server.js b/server.js index 61f884c2..06059d24 100644 --- a/server.js +++ b/server.js @@ -150,10 +150,6 @@ const visitorStatsService = createVisitorStatsService(ctx); Object.assign(ctx, { visitorStats: visitorStatsService.visitorStats, sessionTracker: visitorStatsService.sessionTracker, - geoIPCache: visitorStatsService.geoIPCache, - geoIPQueue: visitorStatsService.geoIPQueue, - todayIPSet: visitorStatsService.todayIPSet, - allTimeIPSet: visitorStatsService.allTimeIPSet, saveVisitorStats: visitorStatsService.saveVisitorStats, rolloverVisitorStats: visitorStatsService.rolloverVisitorStats, STATS_FILE: visitorStatsService.STATS_FILE, diff --git a/server/routes/admin.js b/server/routes/admin.js index 7bd63679..50296547 100644 --- a/server/routes/admin.js +++ b/server/routes/admin.js @@ -24,10 +24,6 @@ module.exports = function (app, ctx) { API_WRITE_KEY, visitorStats, sessionTracker, - geoIPCache, - geoIPQueue, - todayIPSet, - allTimeIPSet, saveVisitorStats, STATS_FILE, rolloverVisitorStats, @@ -64,15 +60,15 @@ module.exports = function (app, ctx) { const avg = visitorStats.history.length > 0 ? Math.round(visitorStats.history.reduce((sum, d) => sum + d.uniqueVisitors, 0) / visitorStats.history.length) - : visitorStats.uniqueIPsToday.length; + : visitorStats.uniqueVisitorsToday; // Get last 14 days for the chart const chartData = [...visitorStats.history].slice(-14); // Add today if we have data - if (visitorStats.uniqueIPsToday.length > 0) { + if (visitorStats.uniqueVisitorsToday > 0) { chartData.push({ date: visitorStats.today, - uniqueVisitors: visitorStats.uniqueIPsToday.length, + uniqueVisitors: visitorStats.uniqueVisitorsToday, totalRequests: visitorStats.totalRequestsToday, }); } @@ -445,7 +441,7 @@ module.exports = function (app, ctx) {
👥
-
${visitorStats.uniqueIPsToday.length}
+
${visitorStats.uniqueVisitorsToday}
Visitors Today
@@ -546,7 +542,6 @@ module.exports = function (app, ctx) { # - IP Session Duration Requests @@ -557,7 +552,6 @@ module.exports = function (app, ctx) { (s, i) => ` ${i + 1} - ${s.ip} ${s.durationFormatted} ${s.requests} @@ -616,74 +610,6 @@ module.exports = function (app, ctx) {
- ${(() => { - // Country statistics section - const allTimeCountries = Object.entries(visitorStats.countryStats || {}).sort((a, b) => b[1] - a[1]); - const todayCountries = Object.entries(visitorStats.countryStatsToday || {}).sort((a, b) => b[1] - a[1]); - const totalResolved = allTimeCountries.reduce((s, [, v]) => s + v, 0); - - if (allTimeCountries.length === 0 && geoIPQueue.size === 0) return ''; - - // Country code to flag emoji - const flag = (cc) => { - try { - return String.fromCodePoint(...[...cc.toUpperCase()].map((c) => 0x1f1e5 + c.charCodeAt(0) - 64)); - } catch { - return '🏳'; - } - }; - - const maxCount = allTimeCountries[0]?.[1] || 1; - - return ` -
-
- 🌍 Visitor Countries - ${geoIPCache.size} resolved, ${geoIPQueue.size} pending -
- - ${ - todayCountries.length > 0 - ? ` -
-
Today
-
- ${todayCountries - .map( - ([cc, count]) => ` - - ${flag(cc)} ${cc} ${count} - - `, - ) - .join('')} -
-
` - : '' - } - -
All-Time (${allTimeCountries.length} countries, ${totalResolved} visitors resolved)
-
- ${allTimeCountries - .slice(0, 40) - .map(([cc, count]) => { - const pct = Math.round((count / totalResolved) * 100); - const barWidth = Math.max(2, (count / maxCount) * 100); - return ` -
- ${flag(cc)} - ${cc} -
-
-
- ${count} - ${pct}% -
`; - }) - .join('')} -
-
`; - })()}
@@ -834,7 +760,7 @@ module.exports = function (app, ctx) { const avg = visitorStats.history.length > 0 ? Math.round(visitorStats.history.reduce((sum, d) => sum + d.uniqueVisitors, 0) / visitorStats.history.length) - : visitorStats.uniqueIPsToday.length; + : visitorStats.uniqueVisitorsToday; // Get endpoint monitoring stats const apiStats = endpointStats.getStats(); @@ -861,37 +787,17 @@ module.exports = function (app, ctx) { visitors: { today: { date: visitorStats.today, - uniqueVisitors: visitorStats.uniqueIPsToday.length, + uniqueVisitors: visitorStats.uniqueVisitorsToday, totalRequests: visitorStats.totalRequestsToday, - countries: Object.entries(visitorStats.countryStatsToday || {}) - .sort((a, b) => b[1] - a[1]) - .reduce((o, [k, v]) => { - o[k] = v; - return o; - }, {}), }, allTime: { since: visitorStats.serverFirstStarted, uniqueVisitors: visitorStats.allTimeVisitors, totalRequests: visitorStats.allTimeRequests, deployments: visitorStats.deploymentCount, - countries: Object.entries(visitorStats.countryStats || {}) - .sort((a, b) => b[1] - a[1]) - .reduce((o, [k, v]) => { - o[k] = v; - return o; - }, {}), - }, - geoIP: { - resolved: geoIPCache.size, - pending: geoIPQueue.size, - coverage: - visitorStats.allTimeVisitors > 0 - ? `${Math.round((geoIPCache.size / visitorStats.allTimeVisitors) * 100)}%` - : '0%', }, dailyAverage: avg, - history: visitorStats.history.slice(-30), // Last 30 days + history: visitorStats.history.slice(-30), }, apiTraffic: { monitoringStarted: new Date(endpointStats.startTime).toISOString(), diff --git a/server/services/visitor-stats.js b/server/services/visitor-stats.js index f1e99d2b..e68babc9 100644 --- a/server/services/visitor-stats.js +++ b/server/services/visitor-stats.js @@ -1,6 +1,7 @@ /** - * Visitor tracking, GeoIP resolution, and session tracking service. + * Visitor tracking and session tracking service. * Persistent visitor stats that survive server restarts. + * Privacy: No raw IPs are stored to disk or sent to third parties. */ const fs = require('fs'); @@ -9,11 +10,11 @@ const { formatDuration } = require('../utils/helpers'); /** * Initialize and return the visitor stats service. - * @param {object} ctx - Shared context (fetch, logDebug, logInfo, logWarn, logErrorOnce, ROOT_DIR) - * @returns {object} { visitorStats, sessionTracker, visitorMiddleware, geoIPCache, todayIPSet, allTimeIPSet } + * @param {object} ctx - Shared context (logInfo, ROOT_DIR) + * @returns {object} { visitorStats, sessionTracker, visitorMiddleware, saveVisitorStats, rolloverVisitorStats, formatDuration, STATS_FILE } */ function createVisitorStatsService(ctx) { - const { fetch, logDebug, logInfo, logWarn, logErrorOnce, ROOT_DIR } = ctx; + const { logInfo, ROOT_DIR } = ctx; // Determine best location for stats file with write permission check function getStatsFilePath() { @@ -51,11 +52,10 @@ function createVisitorStatsService(ctx) { function loadVisitorStats() { const defaults = { today: new Date().toISOString().slice(0, 10), - uniqueIPsToday: [], + uniqueVisitorsToday: 0, totalRequestsToday: 0, allTimeVisitors: 0, allTimeRequests: 0, - allTimeUniqueIPs: [], serverFirstStarted: new Date().toISOString(), lastDeployment: new Date().toISOString(), deploymentCount: 1, @@ -80,17 +80,18 @@ function createVisitorStatsService(ctx) { `[Stats] 🚀 Deployment #${(data.deploymentCount || 0) + 1} (first: ${data.serverFirstStarted || 'unknown'})`, ); + const isSameDay = data.today === new Date().toISOString().slice(0, 10); + return { today: new Date().toISOString().slice(0, 10), - uniqueIPsToday: data.today === new Date().toISOString().slice(0, 10) ? data.uniqueIPsToday || [] : [], - totalRequestsToday: data.today === new Date().toISOString().slice(0, 10) ? data.totalRequestsToday || 0 : 0, + uniqueVisitorsToday: isSameDay ? data.uniqueVisitorsToday || (data.uniqueIPsToday || []).length || 0 : 0, + totalRequestsToday: isSameDay ? data.totalRequestsToday || 0 : 0, allTimeVisitors: data.allTimeVisitors || 0, allTimeRequests: data.allTimeRequests || 0, - allTimeUniqueIPs: [...new Set([...(data.allTimeUniqueIPs || []), ...Object.keys(data.geoIPCache || {})])], serverFirstStarted: data.serverFirstStarted || defaults.serverFirstStarted, lastDeployment: new Date().toISOString(), deploymentCount: (data.deploymentCount || 0) + 1, - history: data.history || [], + history: (data.history || []).map(({ countries, ...rest }) => rest), lastSaved: data.lastSaved, }; } @@ -102,9 +103,9 @@ function createVisitorStatsService(ctx) { return defaults; } - // Save stats to disk + // Save stats to disk (no PII — only aggregate counts) let saveErrorCount = 0; - function saveVisitorStats(includeGeoCache = false) { + function saveVisitorStats() { if (!STATS_FILE) return; try { @@ -115,8 +116,6 @@ function createVisitorStatsService(ctx) { const data = { ...visitorStats, - allTimeUniqueIPs: undefined, - geoIPCache: includeGeoCache ? Object.fromEntries(geoIPCache) : undefined, lastSaved: new Date().toISOString(), }; @@ -125,7 +124,7 @@ function createVisitorStatsService(ctx) { saveErrorCount = 0; if (Math.random() < 0.1) { console.log( - `[Stats] Saved - ${visitorStats.allTimeVisitors} all-time visitors, ${visitorStats.uniqueIPsToday.length} today`, + `[Stats] Saved - ${visitorStats.allTimeVisitors} all-time visitors, ${visitorStats.uniqueVisitorsToday} today`, ); } } catch (err) { @@ -142,120 +141,22 @@ function createVisitorStatsService(ctx) { // Initialize stats const visitorStats = loadVisitorStats(); - // Convert today's IPs to a Set for fast lookup - const todayIPSet = new Set(visitorStats.uniqueIPsToday); - const allTimeIPSet = new Set(visitorStats.allTimeUniqueIPs); - const MAX_TRACKED_IPS = 10000; - - // Free the array - visitorStats.allTimeUniqueIPs = []; - - // GEO-IP COUNTRY RESOLUTION - if (!visitorStats.countryStats) visitorStats.countryStats = {}; - if (!visitorStats.countryStatsToday) visitorStats.countryStatsToday = {}; - if (!visitorStats.geoIPCache) visitorStats.geoIPCache = {}; - - const geoIPCache = new Map(Object.entries(visitorStats.geoIPCache)); - delete visitorStats.geoIPCache; - const geoIPQueue = new Set(); - let geoIPLastBatch = 0; - const GEOIP_BATCH_INTERVAL = 30 * 1000; - const GEOIP_BATCH_SIZE = 100; - - // Queue any existing IPs that haven't been resolved yet - for (const ip of allTimeIPSet) { - if (!geoIPCache.has(ip) && ip !== 'unknown' && !ip.startsWith('127.') && !ip.startsWith('::')) { - geoIPQueue.add(ip); - } - } - if (geoIPQueue.size > 0) { - logInfo(`[GeoIP] Queued ${geoIPQueue.size} unresolved IPs from history for batch lookup`); - } - - function queueGeoIPLookup(ip) { - if (!ip || ip === 'unknown' || ip.startsWith('127.') || ip.startsWith('::1') || ip === '0.0.0.0') return; - if (geoIPCache.has(ip)) return; - geoIPQueue.add(ip); - } - - function recordCountry(ip, countryCode) { - if (!countryCode || countryCode === 'Unknown') return; - if (geoIPCache.size < MAX_TRACKED_IPS || geoIPCache.has(ip)) { - geoIPCache.set(ip, countryCode); - } - visitorStats.countryStats[countryCode] = (visitorStats.countryStats[countryCode] || 0) + 1; - if (todayIPSet.has(ip)) { - visitorStats.countryStatsToday[countryCode] = (visitorStats.countryStatsToday[countryCode] || 0) + 1; - } - } - - async function resolveGeoIPBatch() { - if (geoIPQueue.size === 0) return; - const now = Date.now(); - if (now - geoIPLastBatch < GEOIP_BATCH_INTERVAL) return; - geoIPLastBatch = now; - - const batch = []; - for (const ip of geoIPQueue) { - batch.push(ip); - if (batch.length >= GEOIP_BATCH_SIZE) break; - } - batch.forEach((ip) => geoIPQueue.delete(ip)); - - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 10000); - - const response = await fetch('http://ip-api.com/batch?fields=query,countryCode,status', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify( - batch.map((ip) => ({ - query: ip, - fields: 'query,countryCode,status', - })), - ), - signal: controller.signal, - }); - clearTimeout(timeout); - - if (response.status === 429) { - batch.forEach((ip) => geoIPQueue.add(ip)); - logWarn('[GeoIP] Rate limited by ip-api.com, will retry later'); - geoIPLastBatch = now + 60000; - return; - } - - if (!response.ok) { - batch.forEach((ip) => geoIPQueue.add(ip)); - logWarn(`[GeoIP] Batch lookup failed: HTTP ${response.status}`); - return; - } - - const results = await response.json(); - let resolved = 0; - - for (const entry of results) { - if (entry.status === 'success' && entry.countryCode) { - recordCountry(entry.query, entry.countryCode); - resolved++; - } - } + // In-memory sets for dedup (never persisted to disk) + const crypto = require('crypto'); + const todayIPHashes = new Set(); + const allTimeIPHashes = new Set(); + const MAX_TRACKED_HASHES = 10000; - if (resolved > 0) { - logDebug(`[GeoIP] Resolved ${resolved}/${batch.length} IPs (${geoIPQueue.size} remaining)`); - } - } catch (err) { - batch.forEach((ip) => geoIPQueue.add(ip)); - if (err.name !== 'AbortError') { - logErrorOnce('GeoIP', `Batch lookup error: ${err.message}`); - } - } + function hashIP(ip) { + return crypto.createHash('sha256').update(ip).digest('hex').slice(0, 16); } - // Run GeoIP batch resolver every 30 seconds - setInterval(resolveGeoIPBatch, GEOIP_BATCH_INTERVAL); - setTimeout(resolveGeoIPBatch, 5000); + // Strip legacy fields that may have been loaded from old stats files + delete visitorStats.countryStats; + delete visitorStats.countryStatsToday; + delete visitorStats.geoIPCache; + delete visitorStats.uniqueIPsToday; + delete visitorStats.allTimeUniqueIPs; // Save immediately on startup if (STATS_FILE) { @@ -269,12 +170,11 @@ function createVisitorStatsService(ctx) { function rolloverVisitorStats() { const now = new Date().toISOString().slice(0, 10); if (now !== visitorStats.today) { - if (visitorStats.uniqueIPsToday.length > 0 || visitorStats.totalRequestsToday > 0) { + if (visitorStats.uniqueVisitorsToday > 0 || visitorStats.totalRequestsToday > 0) { visitorStats.history.push({ date: visitorStats.today, - uniqueVisitors: visitorStats.uniqueIPsToday.length, + uniqueVisitors: visitorStats.uniqueVisitorsToday, totalRequests: visitorStats.totalRequestsToday, - countries: { ...visitorStats.countryStatsToday }, }); } if (visitorStats.history.length > 90) { @@ -285,14 +185,13 @@ function createVisitorStatsService(ctx) { ? Math.round(visitorStats.history.reduce((sum, d) => sum + d.uniqueVisitors, 0) / visitorStats.history.length) : 0; console.log( - `[Stats] Daily rollover for ${visitorStats.today}: ${visitorStats.uniqueIPsToday.length} unique, ${visitorStats.totalRequestsToday} requests | All-time: ${visitorStats.allTimeVisitors} visitors | ${visitorStats.history.length}-day avg: ${avg}/day`, + `[Stats] Daily rollover for ${visitorStats.today}: ${visitorStats.uniqueVisitorsToday} unique, ${visitorStats.totalRequestsToday} requests | All-time: ${visitorStats.allTimeVisitors} visitors | ${visitorStats.history.length}-day avg: ${avg}/day`, ); visitorStats.today = now; - visitorStats.uniqueIPsToday = []; + visitorStats.uniqueVisitorsToday = 0; visitorStats.totalRequestsToday = 0; - visitorStats.countryStatsToday = {}; - todayIPSet.clear(); + todayIPHashes.clear(); saveVisitorStats(); } } @@ -302,23 +201,23 @@ function createVisitorStatsService(ctx) { const SESSION_CLEANUP_INTERVAL = 60 * 1000; const sessionTracker = { - activeSessions: new Map(), + activeSessions: new Map(), // keyed by hashed IP (in-memory only) completedSessions: [], peakConcurrent: 0, peakConcurrentTime: null, - touch(ip, userAgent) { + touch(ip) { + const key = hashIP(ip); const now = Date.now(); - if (this.activeSessions.has(ip)) { - const session = this.activeSessions.get(ip); + if (this.activeSessions.has(key)) { + const session = this.activeSessions.get(key); session.lastSeen = now; session.requests++; } else { - this.activeSessions.set(ip, { + this.activeSessions.set(key, { firstSeen: now, lastSeen: now, requests: 1, - userAgent: (userAgent || '').slice(0, 100), }); } const current = this.activeSessions.size; @@ -331,9 +230,9 @@ function createVisitorStatsService(ctx) { cleanup() { const now = Date.now(); const expired = []; - for (const [ip, session] of this.activeSessions) { + for (const [key, session] of this.activeSessions) { if (now - session.lastSeen > SESSION_TIMEOUT) { - expired.push(ip); + expired.push(key); const duration = session.lastSeen - session.firstSeen; if (duration > 10000) { this.completedSessions.push({ @@ -344,7 +243,7 @@ function createVisitorStatsService(ctx) { } } } - expired.forEach((ip) => this.activeSessions.delete(ip)); + expired.forEach((key) => this.activeSessions.delete(key)); if (this.completedSessions.length > 1000) { this.completedSessions = this.completedSessions.slice(-1000); } @@ -429,12 +328,11 @@ function createVisitorStatsService(ctx) { } const activeList = []; - for (const [ip, session] of this.activeSessions) { + for (const [, session] of this.activeSessions) { activeList.push({ duration: now - session.firstSeen, durationFormatted: formatDuration(now - session.firstSeen), requests: session.requests, - ip: ip.includes('.') ? ip.substring(0, ip.lastIndexOf('.') + 1) + 'x' : ip, }); } activeList.sort((a, b) => b.duration - a.duration); @@ -462,39 +360,36 @@ function createVisitorStatsService(ctx) { // Periodic cleanup of stale sessions setInterval(() => sessionTracker.cleanup(), SESSION_CLEANUP_INTERVAL); - // Visitor tracking middleware + // Visitor tracking middleware (privacy-safe: only hashed IPs held in memory, never persisted) function visitorMiddleware(req, res, next) { rolloverVisitorStats(); - const sessionIp = req.ip || req.connection?.remoteAddress || 'unknown'; + const rawIp = req.ip || req.connection?.remoteAddress || 'unknown'; if (req.path !== '/api/health' && !req.path.startsWith('/assets/')) { - sessionTracker.touch(sessionIp, req.headers['user-agent']); + sessionTracker.touch(rawIp); } const countableRoutes = ['/', '/index.html', '/api/config']; if (countableRoutes.includes(req.path)) { - const ip = req.ip || req.connection?.remoteAddress || 'unknown'; + const ipHash = hashIP(rawIp); - const isNewToday = !todayIPSet.has(ip); + const isNewToday = !todayIPHashes.has(ipHash); if (isNewToday) { - todayIPSet.add(ip); - visitorStats.uniqueIPsToday.push(ip); + todayIPHashes.add(ipHash); + visitorStats.uniqueVisitorsToday++; } visitorStats.totalRequestsToday++; visitorStats.allTimeRequests++; - const isNewAllTime = !allTimeIPSet.has(ip); + const isNewAllTime = !allTimeIPHashes.has(ipHash); if (isNewAllTime) { - if (allTimeIPSet.size < MAX_TRACKED_IPS) { - allTimeIPSet.add(ip); + if (allTimeIPHashes.size < MAX_TRACKED_HASHES) { + allTimeIPHashes.add(ipHash); } visitorStats.allTimeVisitors++; - queueGeoIPLookup(ip); logInfo( - `[Stats] New visitor (#${visitorStats.uniqueIPsToday.length} today, #${visitorStats.allTimeVisitors} all-time) from ${ip.includes('.') ? ip.substring(0, ip.lastIndexOf('.') + 1) + 'x' : ip}`, + `[Stats] New visitor (#${visitorStats.uniqueVisitorsToday} today, #${visitorStats.allTimeVisitors} all-time)`, ); - } else if (isNewToday) { - queueGeoIPLookup(ip); } } @@ -505,18 +400,18 @@ function createVisitorStatsService(ctx) { setInterval( () => { rolloverVisitorStats(); - if (visitorStats.uniqueIPsToday.length > 0 || visitorStats.allTimeVisitors > 0) { + if (visitorStats.uniqueVisitorsToday > 0 || visitorStats.allTimeVisitors > 0) { const avg = visitorStats.history.length > 0 ? Math.round( visitorStats.history.reduce((sum, d) => sum + d.uniqueVisitors, 0) / visitorStats.history.length, ) - : visitorStats.uniqueIPsToday.length; + : visitorStats.uniqueVisitorsToday; console.log( - `[Stats] Hourly: ${visitorStats.uniqueIPsToday.length} unique today, ${visitorStats.totalRequestsToday} requests | All-time: ${visitorStats.allTimeVisitors} visitors | Avg: ${avg}/day`, + `[Stats] Hourly: ${visitorStats.uniqueVisitorsToday} unique today, ${visitorStats.totalRequestsToday} requests | All-time: ${visitorStats.allTimeVisitors} visitors | Avg: ${avg}/day`, ); } - saveVisitorStats(true); + saveVisitorStats(); }, 60 * 60 * 1000, ); @@ -549,10 +444,6 @@ function createVisitorStatsService(ctx) { visitorMiddleware, saveVisitorStats, rolloverVisitorStats, - geoIPCache, - geoIPQueue, - todayIPSet, - allTimeIPSet, formatDuration, STATS_FILE, }; From 08be5c7320c987263b5b722655b0b756907d4683 Mon Sep 17 00:00:00 2001 From: accius Date: Fri, 3 Apr 2026 09:47:35 -0400 Subject: [PATCH 11/12] feat(privacy): add presence opt-out toggle and privacy notice (#866) Add a "Share Presence" toggle in Station settings so users can hide their callsign from the Active Users map layer. Toggling off stops heartbeats and sends an immediate leave beacon. Add a Privacy section to the Community tab documenting the app's data practices: no cookies, no tracking, hashed visitor stats, opt-in presence, and local-only browser storage. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/App.jsx | 2 +- src/components/SettingsPanel.jsx | 124 +++++++++++++++++++++++++++++++ src/hooks/app/usePresence.js | 8 +- src/lang/en.json | 5 ++ src/utils/config.js | 1 + 5 files changed, 136 insertions(+), 4 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 74dca8ee..6e23e5f3 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -231,7 +231,7 @@ const App = () => { }, [updateInProgress, t]); // Report presence to active users layer (runs for all configured users) - usePresence({ callsign: config.callsign, locator: config.locator }); + usePresence({ callsign: config.callsign, locator: config.locator, sharePresence: config.sharePresence !== false }); // Location & map state const { dxLocation, dxLocked, handleToggleDxLock, handleDXChange } = useDXLocation(config.defaultDX); diff --git a/src/components/SettingsPanel.jsx b/src/components/SettingsPanel.jsx index fccd37ac..ef63adb3 100644 --- a/src/components/SettingsPanel.jsx +++ b/src/components/SettingsPanel.jsx @@ -59,6 +59,7 @@ export const SettingsPanel = ({ const [udpDxCluster, setUdpDxCluster] = useState(config?.udpDxCluster || { host: '', port: 12060 }); const [lowMemoryMode, setLowMemoryMode] = useState(config?.lowMemoryMode || false); const [preventSleep, setPreventSleep] = useState(config?.preventSleep || false); + const [sharePresence, setSharePresence] = useState(config?.sharePresence !== false); const [displaySchedule, setDisplaySchedule] = useState( config?.displaySchedule || { enabled: false, sleepTime: '23:00', wakeTime: '07:00' }, ); @@ -186,6 +187,7 @@ export const SettingsPanel = ({ setUdpDxCluster(config.udpDxCluster || { host: '', port: 12060 }); setLowMemoryMode(config.lowMemoryMode || false); setPreventSleep(config.preventSleep || false); + setSharePresence(config.sharePresence !== false); setDistUnits(config.allUnits?.dist || config.units || 'imperial'); setTempUnits(config.allUnits?.temp || config.units || 'imperial'); setPressUnits(config.allUnits?.press || config.units || 'imperial'); @@ -428,6 +430,7 @@ export const SettingsPanel = ({ udpDxCluster, lowMemoryMode, preventSleep, + sharePresence, displaySchedule, // units, allUnits: { dist: distUnits, temp: tempUnits, press: pressUnits }, @@ -1864,6 +1867,69 @@ export const SettingsPanel = ({
+ {/* Active Users Presence */} +
+ +
+ + +
+
+ {t( + sharePresence + ? 'station.settings.sharePresence.describe.on' + : 'station.settings.sharePresence.describe.off', + )} +
+
+ {/* Display Schedule */}
+ + {/* Privacy Notice */} +
+
+ Privacy +
+
+

+ No Cookies or Tracking +
+ OpenHamClock does not set any HTTP cookies. There are no analytics services, tracking pixels, ad + networks, or telemetry. All vendor libraries (maps, fonts) are self-hosted. +

+

+ Browser Storage +
+ Your settings (callsign, theme, filters, layout) are saved to your browser's localStorage. This data + stays on your device and is never shared with third parties. Clearing your browser data removes it. +

+

+ Visitor Statistics +
+ The server counts unique visitors using anonymized, one-way hashed identifiers. No IP addresses are + stored to disk or sent to third parties. Only aggregate counts are retained. +

+

+ Active Users Layer +
+ If enabled, your callsign and grid square (rounded to ~1km) are shared with other operators on the + map. You can opt out in Station settings without affecting other features. Your presence is + automatically removed when you close the tab or disable the setting. +

+

+ Third-Party APIs +
+ Weather data is fetched from Open-Meteo and NOAA directly from your browser. No personal data beyond + your configured coordinates is sent. API keys you provide are stored locally in your browser only. +

+

+ Settings Sync +
+ If the server operator has enabled settings sync, your preferences may be synced to the server for + cross-device use. This is off by default and does not include profile data. +

+
+
)} diff --git a/src/hooks/app/usePresence.js b/src/hooks/app/usePresence.js index e3e1cade..6735ca4b 100644 --- a/src/hooks/app/usePresence.js +++ b/src/hooks/app/usePresence.js @@ -9,7 +9,7 @@ import { apiFetch } from '../../utils/apiFetch'; const HEARTBEAT_INTERVAL = 2 * 60 * 1000; // 2 minutes -export default function usePresence({ callsign, locator }) { +export default function usePresence({ callsign, locator, sharePresence = true }) { const locationRef = useRef(null); // Parse locator to lat/lon @@ -36,7 +36,7 @@ export default function usePresence({ callsign, locator }) { // Send heartbeat useEffect(() => { - if (!callsign || callsign === 'N0CALL' || !locationRef.current) return; + if (!sharePresence || !callsign || callsign === 'N0CALL' || !locationRef.current) return; const sendHeartbeat = async () => { if (!locationRef.current) return; @@ -70,6 +70,8 @@ export default function usePresence({ callsign, locator }) { return () => { clearInterval(interval); window.removeEventListener('beforeunload', handleUnload); + // Remove presence immediately when stopping (toggle off or unmount) + navigator.sendBeacon('/api/presence/leave', JSON.stringify({ callsign })); }; - }, [callsign, locator]); + }, [callsign, locator, sharePresence]); } diff --git a/src/lang/en.json b/src/lang/en.json index e48be247..0c89e0a6 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -367,6 +367,11 @@ "station.settings.preventSleep.status.error": "Could not acquire wake lock (try disabling Low Power Mode)", "station.settings.preventSleep.status.insecure": "Requires HTTPS — not available on http://", "station.settings.preventSleep.status.unsupported": "Not supported by this browser", + "station.settings.sharePresence": "Active Users Layer", + "station.settings.sharePresence.off": "Hidden", + "station.settings.sharePresence.on": "Visible", + "station.settings.sharePresence.describe.off": "Your callsign is not shared — you won't appear on the Active Users map layer for other operators.", + "station.settings.sharePresence.describe.on": "Your callsign and grid square are shared on the Active Users map layer so other operators can see you.", "station.settings.rigControl.apiToken": "API Token", "station.settings.rigControl.apiToken.hint": "Required when rig-bridge has authentication enabled (new installs). Find it at http://localhost:5555.", "station.settings.rigControl.apiToken.placeholder": "Paste token from rig-bridge setup UI", diff --git a/src/utils/config.js b/src/utils/config.js index 1eb88178..ac5a62be 100644 --- a/src/utils/config.js +++ b/src/utils/config.js @@ -37,6 +37,7 @@ export const DEFAULT_CONFIG = { swapHeaderClocks: false, // false = UTC first, true = Local first showMutualReception: true, // Show gold star on PSK spots with mutual reception preventSleep: false, // Keep screen awake while app is open (tablet/kiosk mode) + sharePresence: true, // Share callsign on the Active Users map layer displaySchedule: { enabled: false, sleepTime: '23:00', wakeTime: '07:00' }, // Scheduled display on/off showSatellites: true, showPota: true, From 67e42d15f414caa9752df67a70febe0cecc83efa Mon Sep 17 00:00:00 2001 From: accius Date: Fri, 3 Apr 2026 10:28:04 -0400 Subject: [PATCH 12/12] fix(stats): count unique visitors on all trackable requests, not just 3 routes Visitor counting was limited to '/', '/index.html', '/api/config' while session tracking covered all non-health/asset requests. This caused concurrent sessions to far exceed reported unique visitors. Now both use the same scope so the numbers stay consistent. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/services/visitor-stats.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/server/services/visitor-stats.js b/server/services/visitor-stats.js index e68babc9..e7f66de7 100644 --- a/server/services/visitor-stats.js +++ b/server/services/visitor-stats.js @@ -365,12 +365,11 @@ function createVisitorStatsService(ctx) { rolloverVisitorStats(); const rawIp = req.ip || req.connection?.remoteAddress || 'unknown'; - if (req.path !== '/api/health' && !req.path.startsWith('/assets/')) { + const isTrackable = req.path !== '/api/health' && !req.path.startsWith('/assets/'); + + if (isTrackable) { sessionTracker.touch(rawIp); - } - const countableRoutes = ['/', '/index.html', '/api/config']; - if (countableRoutes.includes(req.path)) { const ipHash = hashIP(rawIp); const isNewToday = !todayIPHashes.has(ipHash);