mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-06 08:53:41 +02:00
web: display sats in view (#523)
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
<svg
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns:ns1="http://sozi.baierouge.fr"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
id="svg7158"
|
||||
viewBox="0 0 453.54 453.54"
|
||||
version="1.1"
|
||||
>
|
||||
<title
|
||||
id="title7193"
|
||||
>Satellite Icon</title
|
||||
>
|
||||
<g
|
||||
id="layer1"
|
||||
transform="translate(-174.99 -97.13)"
|
||||
>
|
||||
<path
|
||||
id="path7145"
|
||||
style="fill:#000000"
|
||||
d="m287.44 286.58c8.8583-0.0756 18.161 1.2414 27.843 4.3505 23.639 7.5916 49.437 23.77 72.544 46.876 22.316 22.316 38.195 47.044 46.114 70.042 3.5848 10.411 5.3257 20.665 5.3294 30.127 34.464-34.381 74.719-74.501 74.719-74.501 8.8046-8.8046 10.662-25.016 4.0244-45.788-6.6374-20.772-21.622-44.897-43.287-66.562-21.664-21.665-45.789-36.649-66.561-43.287-20.772-6.6373-36.984-4.7803-45.788 4.0241a7.3368 7.3368 0 0 1 -0.32655 0.32655c0.00015-0.00038-0.15572-0.0378-0.32656 0.10658a7.3368 7.3368 0 0 1 -0.10733 0.10809c-0.0755 0.0755-0.23434 0.23735-0.3258 0.32655a7.3368 7.3368 0 0 1 -0.10734 0.10734c-0.0227 0.0227-0.0755 0.0907-0.10734 0.10734-0.27741 0.27062-0.46261 0.46942-0.87004 0.87005-1.3246 1.3035-3.2358 3.2497-5.7641 5.7642-5.0603 5.0256-12.407 12.42-21.317 21.317-11.973 11.959-29.332 29.327-45.679 45.68zm-40.639-122.78-33.338-33.338 33.338-33.338 33.338 33.338zm38.467 38.467-33.338-33.338 33.338-33.338 33.338 33.338zm38.467 38.467-33.338-33.338 33.338-33.338 33.338 33.338zm-115.4-38.467-33.338-33.338 33.338-33.338 33.338 33.338zm38.467 38.467-33.338-33.338 33.338-33.338 33.338 33.338zm38.467 38.467-33.338-33.338 33.338-33.338 33.338 33.338zm233 156.07-33.338-33.338 33.338-33.338 33.338 33.338zm38.467 38.467-33.338-33.338 33.338-33.338 33.338 33.338zm38.467 38.467-33.338-33.338 33.338-33.338 33.338 33.338zm-115.4-38.467-33.338-33.338 33.338-33.338 33.338 33.338zm38.467 38.467-33.338-33.338 33.338-33.338 33.338 33.338zm38.467 38.467-33.338-33.338 33.338-33.338 33.338 33.338zm-26.89-249.86c14.671-18.593 23.601-32.737 23.601-32.737 6.0975-9.8498 8.7897-20.878 6.5255-33.498-2.2639-12.621-9.7854-27.187-25.667-43.069-15.989-15.989-29.977-23.759-42.199-26.103-12.222-2.3439-23.154 0.29026-34.586 6.9608a8.7573 8.7573 0 0 1 -0.10733 0.10734c-0.90581 0.51174-0.23131 0.0975-0.4354 0.21694-0.20486 0.11566-0.55106 0.2948-0.97959 0.54275-0.85462 0.49889-2.117 1.2198-3.8063 2.284-3.3786 2.1287-8.3084 5.4327-14.574 10.006-3.793 2.7685-8.1932 6.2168-12.943 10.115 20.824 8.7749 42.483 23.558 62.211 43.287 19.646 19.646 34.196 41.165 42.96 61.885zm-243.08 138.23c15.825 15.825 27.262 20.215 35.673 20.012 8.2898-0.19956 16.009-4.9974 24.362-13.269 0.18141-0.20258 0.17082-0.17235 0.32654-0.32579 0.67351-0.65991 1.1912-1.0953 1.5228-1.3049 0.81396-0.51552 1.1331-0.63722 1.4148-0.75877 0.5639-0.24415 0.87103-0.43313 1.196-0.54349 0.65234-0.22073 1.3406-0.40517 2.1753-0.65311 1.67-0.49361 3.9356-1.1132 6.6343-1.8491 5.3972-1.4715 12.536-3.2413 19.686-5.1116 12.5-3.2709 22.067-5.7697 25.015-6.5254 5.2003-6.2337 6.9486-16.641 2.8278-30.888-4.3795-15.141-14.954-33.009-30.779-48.834s-33.693-26.4-48.834-30.779c-14.246-4.121-24.654-2.3723-30.888 2.8278-0.39307 1.4287-0.92984 3.0175-1.5226 5.2205-1.4259 5.3024-3.3261 12.432-5.2202 19.577-3.793 14.289-7.613 28.713-7.613 28.713a9.2981 9.2981 0 0 1 -2.3919 4.1331c-8.4974 8.4974-13.392 16.277-13.595 24.689-0.20259 8.4119 4.1869 19.848 20.012 35.673z"
|
||||
/>
|
||||
</g
|
||||
>
|
||||
<metadata
|
||||
>
|
||||
<rdf:RDF
|
||||
>
|
||||
<cc:Work
|
||||
>
|
||||
<dc:format
|
||||
>image/svg+xml</dc:format
|
||||
>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage"
|
||||
/>
|
||||
<cc:license
|
||||
rdf:resource="http://creativecommons.org/licenses/publicdomain/"
|
||||
/>
|
||||
<dc:publisher
|
||||
>
|
||||
<cc:Agent
|
||||
rdf:about="http://openclipart.org/"
|
||||
>
|
||||
<dc:title
|
||||
>Openclipart</dc:title
|
||||
>
|
||||
</cc:Agent
|
||||
>
|
||||
</dc:publisher
|
||||
>
|
||||
</cc:Work
|
||||
>
|
||||
<cc:License
|
||||
rdf:about="http://creativecommons.org/licenses/publicdomain/"
|
||||
>
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#Reproduction"
|
||||
/>
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#Distribution"
|
||||
/>
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#DerivativeWorks"
|
||||
/>
|
||||
</cc:License
|
||||
>
|
||||
</rdf:RDF
|
||||
>
|
||||
</metadata
|
||||
>
|
||||
</svg
|
||||
>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -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' })],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(`<span class="mono">${escapeHtml(nodeIdValue)}</span>`);
|
||||
}
|
||||
const satelliteLine = renderSatsInViewBadge(overlayInfo);
|
||||
if (satelliteLine) {
|
||||
shortParts.push(satelliteLine);
|
||||
}
|
||||
if (shortParts.length) {
|
||||
lines.push(shortParts.join(' '));
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 [
|
||||
'<span class="short-info-sats" aria-label="Satellites in view">',
|
||||
`<span class="short-info-sats__icon" aria-hidden="true">` +
|
||||
`<img src="${ICON_PATH}" alt="" width="14" height="14" loading="lazy" decoding="async" class="short-info-sats__glyph">` +
|
||||
`</span>`,
|
||||
`<span class="short-info-sats__count">${count}</span>`,
|
||||
'</span>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
export const __testUtils = {
|
||||
toPositiveInteger,
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user