diff --git a/README.md b/README.md index 32ba8a99..3198f7c3 100644 --- a/README.md +++ b/README.md @@ -429,6 +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 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/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/routes/propagation.js b/server/routes/propagation.js index 68565b21..bffcd506 100644 --- a/server/routes/propagation.js +++ b/server/routes/propagation.js @@ -500,11 +500,22 @@ 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(); - 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) { diff --git a/server/routes/space-weather.js b/server/routes/space-weather.js index 32e33fba..aa4526fc 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; } @@ -174,37 +178,46 @@ 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), })); - 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; } } } // 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; + 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, })); } } @@ -224,8 +237,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 +583,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 +620,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/server/services/visitor-stats.js b/server/services/visitor-stats.js index f1e99d2b..e7f66de7 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,35 @@ 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'; - if (req.path !== '/api/health' && !req.path.startsWith('/assets/')) { - sessionTracker.touch(sessionIp, req.headers['user-agent']); - } + const rawIp = req.ip || req.connection?.remoteAddress || 'unknown'; + const isTrackable = req.path !== '/api/health' && !req.path.startsWith('/assets/'); - const countableRoutes = ['/', '/index.html', '/api/config']; - if (countableRoutes.includes(req.path)) { - const ip = req.ip || req.connection?.remoteAddress || 'unknown'; + if (isTrackable) { + sessionTracker.touch(rawIp); - const isNewToday = !todayIPSet.has(ip); + const ipHash = hashIP(rawIp); + + 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 +399,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 +443,6 @@ function createVisitorStatsService(ctx) { visitorMiddleware, saveVisitorStats, rolloverVisitorStats, - geoIPCache, - geoIPQueue, - todayIPSet, - allTimeIPSet, formatDuration, STATS_FILE, }; 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/BandConditionsPanel.jsx b/src/components/BandConditionsPanel.jsx index 17caae95..9e21aa07 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 && ( + + {t('band.conditions.stale.label', { mins: staleMinutes })} + + )} +
{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..dc3b8457 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 ( + + {t('band.conditions.stale.label', { mins })} + + ); + })()} {!forcedMode && ( 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/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/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/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/useSatellites.js b/src/hooks/useSatellites.js index e8ab1b59..778bca7e 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); @@ -39,6 +44,7 @@ export const useSatellites = (observerLocation) => { try { const now = new Date(); + const gmst = satellite.gstime(now); const positions = []; // Observer position in radians @@ -57,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); @@ -69,20 +76,28 @@ 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(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 speed from ECI velocity vector (km/s) + // Calculate range-rate and doppler factor, only if satellite is above horizon + let dopplerFactor = 1; + let rangeRate = 0; + if (elevation > 0) { + const observerEcf = satellite.geodeticToEcf(observerGd); + 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] 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) @@ -105,19 +120,21 @@ export const useSatellites = (observerLocation) => { // Calculate footprint radius (visibility circle) // Formula: radius = Earth_radius * arccos(Earth_radius / (Earth_radius + altitude)) - const earthRadius = 6371; // km + const earthRadius = 6371; // [km] const footprintRadius = earthRadius * Math.acos(earthRadius / (earthRadius + alt)); positions.push({ 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), - visible: elevation > 0, + 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, footprintRadius: Math.round(footprintRadius), diff --git a/src/hooks/useSpaceWeather.js b/src/hooks/useSpaceWeather.js index af6051cb..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(); diff --git a/src/lang/ca.json b/src/lang/ca.json index c8b8ecc4..de848243 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", @@ -393,6 +395,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 +403,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..015c5e1d 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", @@ -393,6 +395,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 +403,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..b2647344 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", @@ -367,6 +369,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", @@ -393,6 +400,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 +408,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..9c850925 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", @@ -393,6 +395,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 +403,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..383a8298 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", @@ -393,6 +395,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 +403,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..d209e0bc 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", @@ -393,6 +395,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 +403,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..cb808a0a 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", @@ -393,6 +395,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 +403,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..a4991a99 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": "გამორთ.", @@ -393,6 +395,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 +403,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..f569b7a4 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": "끔", @@ -393,6 +395,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 +403,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..5b992c5c 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", @@ -393,6 +395,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 +403,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..c3b067f6 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", @@ -393,6 +395,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 +403,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..53691cd3 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", @@ -393,6 +395,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 +403,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..49b8aba2 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": "ВЫКЛ", @@ -393,6 +395,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 +403,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..986c81f7 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", @@ -393,6 +395,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 +403,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..a99e9b11 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", @@ -393,6 +395,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 +403,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..a7b434d9 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": "关闭", @@ -393,6 +395,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 +403,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/layouts/ClassicLayout.jsx b/src/layouts/ClassicLayout.jsx index 037aba91..dcf2242b 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 ( + + {t('band.conditions.stale.label', { mins })} + + ); + })()}
{t('app.solar.sfiShort')} - {solarIndices?.data?.sfi?.current || spaceWeather?.data?.solarFlux || '--'} + {solarIndices?.data?.sfi?.current ?? spaceWeather?.data?.solarFlux ?? '--'} diff --git a/src/plugins/layers/useSatelliteLayer.js b/src/plugins/layers/useSatelliteLayer.js index 96900ddc..1df58a5a 100644 --- a/src/plugins/layers/useSatelliteLayer.js +++ b/src/plugins/layers/useSatelliteLayer.js @@ -240,18 +240,19 @@ 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'; 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 ` @@ -266,7 +267,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 + ? ` + ${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)} + ` + : `` + } + ${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 +320,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, { 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,