From 7160d72aaea4fe6c5d0122471dc51bc0a2dbcd85 Mon Sep 17 00:00:00 2001
From: l5y <220195275+l5yth@users.noreply.github.com>
Date: Wed, 26 Nov 2025 22:01:53 +0100
Subject: [PATCH] web: display sats in view (#523)
---
web/public/assets/img/satellite-icon.svg | 79 ++++++++++++++++++
.../js/app/__tests__/node-details.test.js | 30 +++++++
.../__tests__/short-info-satellites.test.js | 47 +++++++++++
web/public/assets/js/app/main.js | 6 ++
web/public/assets/js/app/node-details.js | 3 +
.../assets/js/app/node-snapshot-normalizer.js | 1 +
.../assets/js/app/short-info-satellites.js | 82 +++++++++++++++++++
web/public/assets/styles/base.css | 33 ++++++++
8 files changed, 281 insertions(+)
create mode 100644 web/public/assets/img/satellite-icon.svg
create mode 100644 web/public/assets/js/app/__tests__/short-info-satellites.test.js
create mode 100644 web/public/assets/js/app/short-info-satellites.js
diff --git a/web/public/assets/img/satellite-icon.svg b/web/public/assets/img/satellite-icon.svg
new file mode 100644
index 0000000..557e1e0
--- /dev/null
+++ b/web/public/assets/img/satellite-icon.svg
@@ -0,0 +1,79 @@
+
+
+
diff --git a/web/public/assets/js/app/__tests__/node-details.test.js b/web/public/assets/js/app/__tests__/node-details.test.js
index 52842e2..9181f57 100644
--- a/web/public/assets/js/app/__tests__/node-details.test.js
+++ b/web/public/assets/js/app/__tests__/node-details.test.js
@@ -113,6 +113,36 @@ test('refreshNodeInformation merges telemetry metrics when the base node lacks t
});
});
+test('refreshNodeInformation surfaces sats_in_view from the latest position packet', async () => {
+ const calls = [];
+ const responses = new Map([
+ ['/api/nodes/!sat?limit=7', createResponse(200, {
+ node_id: '!sat',
+ short_name: 'SAT',
+ })],
+ ['/api/telemetry/!sat?limit=1000', createResponse(404, { error: 'not found' })],
+ ['/api/positions/!sat?limit=7', createResponse(200, [
+ { node_id: '!sat', position_time: 200, rx_time: 200, latitude: 1.1, longitude: 2.1, sats_in_view: 8 },
+ { node_id: '!sat', position_time: 100, rx_time: 100, latitude: 1, longitude: 2, sats_in_view: 3 },
+ ])],
+ ['/api/neighbors/!sat?limit=1000', createResponse(404, { error: 'not found' })],
+ ]);
+
+ const fetchImpl = async (url, options) => {
+ calls.push({ url, options });
+ return responses.get(url) ?? createResponse(404, { error: 'not found' });
+ };
+
+ const node = await refreshNodeInformation({ nodeId: '!sat' }, { fetchImpl });
+
+ assert.equal(node.satsInView, 8);
+ assert.equal(node.sats_in_view, 8);
+ assert.ok(node.position);
+ assert.equal(node.position.sats_in_view, 8);
+ assert.equal(node.rawSources.position.sats_in_view, 8);
+ assert.equal(calls.length, 4);
+});
+
test('refreshNodeInformation normalizes telemetry aliases for downstream consumers', async () => {
const responses = new Map([
['/api/nodes/!chan?limit=7', createResponse(404, { error: 'not found' })],
diff --git a/web/public/assets/js/app/__tests__/short-info-satellites.test.js b/web/public/assets/js/app/__tests__/short-info-satellites.test.js
new file mode 100644
index 0000000..6f8f047
--- /dev/null
+++ b/web/public/assets/js/app/__tests__/short-info-satellites.test.js
@@ -0,0 +1,47 @@
+/*
+ * Copyright © 2025-26 l5yth & contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { renderSatsInViewBadge, resolveSatsInView, __testUtils } from '../short-info-satellites.js';
+
+const { toPositiveInteger } = __testUtils;
+
+test('resolveSatsInView inspects aliases and nested payloads', () => {
+ assert.equal(resolveSatsInView({ sats_in_view: '3.6' }), 4);
+ assert.equal(resolveSatsInView({ position: { satsInView: 5 } }), 5);
+ assert.equal(resolveSatsInView({ rawSources: { position: { sats_in_view: 9 } } }), 9);
+ assert.equal(resolveSatsInView({ satsInView: 0 }), null);
+ assert.equal(resolveSatsInView(null), null);
+});
+
+test('renderSatsInViewBadge returns markup only for positive counts', () => {
+ const html = renderSatsInViewBadge({ satsInView: 6 });
+ assert.match(html, /short-info-sats/);
+ assert.ok(html.includes('satellite-icon.svg'));
+ assert.match(html, />6);
+
+ assert.equal(renderSatsInViewBadge({ satsInView: 0 }), '');
+ assert.equal(renderSatsInViewBadge({ position: { sats_in_view: -1 } }), '');
+});
+
+test('toPositiveInteger normalizes numeric values defensively', () => {
+ assert.equal(toPositiveInteger('7.2'), 7);
+ assert.equal(toPositiveInteger(''), null);
+ assert.equal(toPositiveInteger(-3), null);
+ assert.equal(toPositiveInteger(NaN), null);
+});
diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js
index b250232..dd3df1e 100644
--- a/web/public/assets/js/app/main.js
+++ b/web/public/assets/js/app/main.js
@@ -34,6 +34,7 @@ import {
fmtTemperature,
fmtTx,
} from './short-info-telemetry.js';
+import { renderSatsInViewBadge } from './short-info-satellites.js';
import { createMessageNodeHydrator } from './message-node-hydrator.js';
import {
extractChatMessageMetadata,
@@ -2002,6 +2003,7 @@ export function initializeApp(config) {
['latitude', source.latitude],
['longitude', source.longitude],
['altitude', source.altitude],
+ ['satsInView', source.satsInView ?? source.sats_in_view],
['positionTime', source.positionTime ?? source.position_time],
];
for (const [key, value] of numericPairs) {
@@ -2116,6 +2118,10 @@ export function initializeApp(config) {
if (nodeIdValue !== '—') {
shortParts.push(`${escapeHtml(nodeIdValue)}`);
}
+ const satelliteLine = renderSatsInViewBadge(overlayInfo);
+ if (satelliteLine) {
+ shortParts.push(satelliteLine);
+ }
if (shortParts.length) {
lines.push(shortParts.join(' '));
}
diff --git a/web/public/assets/js/app/node-details.js b/web/public/assets/js/app/node-details.js
index cea77f8..a4fc7d0 100644
--- a/web/public/assets/js/app/node-details.js
+++ b/web/public/assets/js/app/node-details.js
@@ -181,6 +181,7 @@ function mergeNodeFields(target, record) {
assignString(target, 'hwModel', extractString(record, ['hwModel', 'hw_model']));
mergeModemMetadata(target, record);
assignNumber(target, 'snr', extractNumber(record, ['snr']));
+ assignNumber(target, 'satsInView', extractNumber(record, ['sats_in_view', 'satsInView']));
assignNumber(target, 'battery', extractNumber(record, ['battery', 'battery_level', 'batteryLevel']));
assignNumber(target, 'voltage', extractNumber(record, ['voltage']));
assignNumber(target, 'uptime', extractNumber(record, ['uptime', 'uptime_seconds', 'uptimeSeconds']));
@@ -278,6 +279,8 @@ function mergePosition(target, position) {
assignString(target, 'lastSeenIso', extractString(position, ['rx_iso', 'rxIso']), { preferExisting: true });
}
}
+
+ assignNumber(target, 'satsInView', extractNumber(position, ['sats_in_view', 'satsInView']), { preferExisting: true });
}
/**
diff --git a/web/public/assets/js/app/node-snapshot-normalizer.js b/web/public/assets/js/app/node-snapshot-normalizer.js
index 3e3bed6..e0d4f85 100644
--- a/web/public/assets/js/app/node-snapshot-normalizer.js
+++ b/web/public/assets/js/app/node-snapshot-normalizer.js
@@ -74,6 +74,7 @@ const FIELD_ALIASES = Object.freeze([
{ keys: ['relative_humidity', 'relativeHumidity', 'humidity'], normalise: normalizeNumber },
{ keys: ['barometric_pressure', 'barometricPressure', 'pressure'], normalise: normalizeNumber },
{ keys: ['gas_resistance', 'gasResistance'], normalise: normalizeNumber },
+ { keys: ['sats_in_view', 'satsInView'], normalise: normalizeNumber },
{ keys: ['snr'], normalise: normalizeNumber },
{ keys: ['last_heard', 'lastHeard'], normalise: normalizeNumber },
{ keys: ['last_seen_iso', 'lastSeenIso'], normalise: normalizeString },
diff --git a/web/public/assets/js/app/short-info-satellites.js b/web/public/assets/js/app/short-info-satellites.js
new file mode 100644
index 0000000..3320389
--- /dev/null
+++ b/web/public/assets/js/app/short-info-satellites.js
@@ -0,0 +1,82 @@
+/*
+ * Copyright © 2025-26 l5yth & contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Coerce a candidate value into a positive integer satellite count.
+ *
+ * @param {*} value Raw candidate value.
+ * @returns {number|null} Rounded positive integer or ``null``.
+ */
+function toPositiveInteger(value) {
+ if (value == null || value === '') return null;
+ const num = typeof value === 'number' ? value : Number(value);
+ if (!Number.isFinite(num) || num <= 0) return null;
+ return Math.round(num);
+}
+
+/**
+ * Extract the satellite count from a node-like payload.
+ *
+ * @param {*} info Node payload potentially containing satellite metadata.
+ * @returns {number|null} Satellite count when present and positive.
+ */
+export function resolveSatsInView(info) {
+ if (!info || typeof info !== 'object') {
+ return toPositiveInteger(info);
+ }
+ const candidates = [
+ info.satsInView,
+ info.sats_in_view,
+ info.position?.satsInView,
+ info.position?.sats_in_view,
+ info.rawSources?.position?.satsInView,
+ info.rawSources?.position?.sats_in_view,
+ ];
+ for (const candidate of candidates) {
+ const count = toPositiveInteger(candidate);
+ if (count != null) {
+ return count;
+ }
+ }
+ return null;
+}
+
+const ICON_PATH = '/assets/img/satellite-icon.svg';
+
+/**
+ * Render a short-info overlay row describing visible satellites.
+ *
+ * @param {*} info Node payload providing satellite metadata.
+ * @returns {string} HTML snippet or an empty string when unavailable.
+ */
+export function renderSatsInViewBadge(info) {
+ const count = resolveSatsInView(info);
+ if (count == null) {
+ return '';
+ }
+ return [
+ '',
+ `` +
+ `
` +
+ ``,
+ `${count}`,
+ '',
+ ].join('');
+}
+
+export const __testUtils = {
+ toPositiveInteger,
+};
diff --git a/web/public/assets/styles/base.css b/web/public/assets/styles/base.css
index 29fa94a..086bf31 100644
--- a/web/public/assets/styles/base.css
+++ b/web/public/assets/styles/base.css
@@ -783,6 +783,39 @@ body.view-map .map-panel--full #map {
background: rgba(0, 0, 0, 0.08);
}
+.short-info-sats {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-weight: inherit;
+ color: inherit;
+ padding: 0;
+ margin: 0;
+ vertical-align: middle;
+}
+
+.short-info-sats__icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: currentColor;
+}
+
+.short-info-sats__glyph {
+ display: block;
+ filter: grayscale(1);
+}
+
+body.dark .short-info-sats__glyph {
+ filter: invert(1) grayscale(1);
+}
+
+.short-info-sats__count {
+ font-family: inherit;
+ font-size: inherit;
+ letter-spacing: 0.1px;
+}
+
.node-detail-overlay {
position: fixed;
inset: 0;