@@ -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('station.settings.sharePresence')}
+
+
+ setSharePresence(false)}
+ style={{
+ flex: 1,
+ padding: '10px',
+ background: !sharePresence ? 'var(--accent-amber)' : 'var(--bg-tertiary)',
+ border: `1px solid ${!sharePresence ? 'var(--accent-amber)' : 'var(--border-color)'}`,
+ borderRadius: '6px',
+ color: !sharePresence ? '#000' : 'var(--text-secondary)',
+ fontSize: '13px',
+ cursor: 'pointer',
+ fontWeight: !sharePresence ? '600' : '400',
+ }}
+ >
+ {t('station.settings.sharePresence.off')}
+
+ setSharePresence(true)}
+ style={{
+ flex: 1,
+ padding: '10px',
+ background: sharePresence ? 'var(--accent-green)' : 'var(--bg-tertiary)',
+ border: `1px solid ${sharePresence ? 'var(--accent-green)' : 'var(--border-color)'}`,
+ borderRadius: '6px',
+ color: sharePresence ? '#000' : 'var(--text-secondary)',
+ fontSize: '13px',
+ cursor: 'pointer',
+ fontWeight: sharePresence ? '600' : '400',
+ }}
+ >
+ {t('station.settings.sharePresence.on')}
+
+
+
+ {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,