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 @@ + + + + Satellite Icon + + + + + + + image/svg+xml + + + + + Openclipart + + + + + + + + + + + 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(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;