feat(sat): the map says what a pass is worth

An unselected satellite was a four-pixel dot with a one-pixel white edge, and
the basemap decides whether that is visible at all: a pale marker vanishes into
pale terrain, a grey one into a dark ocean, and the operator can switch between
the two in one session. So: a dark halo under a white ring around a larger dot.
Two rings, because no single colour reads on both.

The ones above the horizon now carry their name. Not all of them — a dozen
labels is a map nobody can read — but the two or three an operator is choosing
between right now, which is what saves hovering each grey dot in turn to find
them.

Hovering said "name · elevation · altitude", none of which decides anything.
What decides whether to reach for the radio is how long is left, how high it
will get and where to point, so the tooltip now carries the pass: elevation and
azimuth with its compass point, distance with an arrow for closing or receding,
rise and set with a countdown and a direction, and the peak. A bird already in
view shows its SET countdown instead of its rise — that is the number that
matters at that moment. No pass in the window says so, because a blank reads as
a fault. It costs no extra prediction: the pass list on screen is indexed by
name, and the first entry for a name is its next pass.

The tooltip also had to stop closing itself. The layer is rebuilt every five
seconds as the birds move, and a rebuilt marker is a new marker, so the detail
being read disappeared mid-sentence. The map now tracks the pointer and reopens
the tooltip of the dot it is still on — that one only, so nothing hangs open
once the mouse has moved away.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
2026-09-09 11:04:26 +02:00
co-authored by Claude Opus 5
parent ca81d4fc68
commit 543550c716
4 changed files with 163 additions and 7 deletions
+114 -5
View File
@@ -128,6 +128,53 @@ const MODE_COLOUR: Record<string, string> = {
DATA: 'var(--warning)',
};
// escapeHtml, because a satellite name comes from data/satellites.json, which
// the operator edits by hand. A stray "<" there must not be able to break the
// tooltip it lands in.
const escapeHtml = (s: string) =>
s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c] as string));
// satTip is what hovering a satellite on the map says.
//
// The dot alone answered "there it is" and nothing else — the name, an
// elevation and an altitude, none of which decides anything. What decides
// whether to reach for the radio is how long is left, how high it will get and
// where to point: so the pass is here, and reading it costs a hover instead of
// selecting the bird and looking somewhere else on the screen.
function satTip(p: Position, pass: Pass | undefined, t: (k: string) => string): string {
const row = (label: string, value: string) =>
`<div class="sat-tip-row"><span>${label}</span><span>${value}</span></div>`;
const out: string[] = [`<div class="sat-tip-name">${escapeHtml(p.name)}</div>`];
if (p.el > 0) {
out.push(row(t('sat.tipEl'), `${fmtDeg(p.el)}`));
out.push(row(t('sat.tipAz'), `${fmtDeg(p.az)} ${compass(p.az)}`));
} else {
out.push(`<div class="sat-tip-note">${t('sat.tipBelow')}</div>`);
}
// Closing or opening: the sign of the range rate is the difference between a
// pass about to start being useful and one already going away.
const trend = p.range_rate < -0.05 ? ' ↓' : p.range_rate > 0.05 ? ' ↑' : '';
out.push(row(t('sat.tipRange'), fmtKm(p.range_km) + trend));
out.push(row(t('sat.tipAlt'), fmtKm(p.alt_km)));
if (pass) {
const aos = Date.parse(pass.aos), los = Date.parse(pass.los), now = Date.now();
if (now >= aos && now < los) {
out.push(row(t('sat.tipLos'), `${hhmm(pass.los)} · ${fmtCountdown(los - now)}`));
} else {
out.push(row(t('sat.tipAos'), `${hhmm(pass.aos)} · ${fmtCountdown(aos - now)} · ${compass(pass.aos_az)}`));
out.push(row(t('sat.tipLos'), `${hhmm(pass.los)} · ${compass(pass.los_az)}`));
}
out.push(row(t('sat.tipMaxEl'), `${fmtDeg(pass.max_el)} ${compass(pass.max_el_az)}`));
} else {
// No pass inside the prediction window. Worth saying: an empty space here
// reads as a bug, and "nothing in the next 24 hours" is an answer.
out.push(`<div class="sat-tip-note">${t('sat.tipNoPass')}</div>`);
}
return out.join('');
}
function ModeDot({ mode }: { mode: string }) {
const colour = MODE_COLOUR[mode];
if (!colour) return null;
@@ -147,6 +194,14 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
const [tpIdx, setTpIdx] = useState(0);
const [positions, setPositions] = useState<Position[]>([]);
const [passes, setPasses] = useState<Pass[]>([]);
// The next pass per satellite, for the map tooltips. The list is already
// ordered by AOS across every bird, so the first entry for a name is its next
// one — no second prediction run for what is already on screen.
const nextPassOf = useMemo(() => {
const m = new Map<string, Pass>();
for (const p of passes) if (!m.has(p.name)) m.set(p.name, p);
return m;
}, [passes]);
const [tuning, setTuning] = useState<Tuning | null>(null);
const [pass, setPass] = useState<PassInfo | null>(null);
const [tle, setTle] = useState<{ count: number; age_h: number; stale: boolean; custom: number } | null>(null);
@@ -315,6 +370,15 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
const mapRef = useRef<L.Map | null>(null);
const layerRef = useRef<L.LayerGroup | null>(null);
const baseRef = useRef<L.TileLayer | null>(null);
// Where the pointer is over the map, in container pixels.
//
// The satellite layer is rebuilt every five seconds as the birds move, and a
// rebuilt marker is a new marker: the tooltip the operator was reading closed
// itself, over and over, which made the hover detail useless exactly when it
// was being used. Knowing where the pointer is lets the redraw reopen the
// tooltip of the dot it is still on — and only that one, so nothing is left
// hanging open once the mouse has moved away.
const mouseRef = useRef<L.Point | null>(null);
const labelsRef = useRef<L.TileLayer | null>(null);
const [basemap, setBasemap] = useState<BasemapKey>(() => loadMapBase(MAP_BASE_SAT, 'light'));
const saved = useRef(loadMapView(MAP_VIEW_SAT));
@@ -366,6 +430,8 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
const c = m.getCenter();
saveMapView(MAP_VIEW_SAT, c.lat, c.lng, m.getZoom());
});
m.on('mousemove', (e: L.LeafletMouseEvent) => { mouseRef.current = e.containerPoint; });
m.on('mouseout', () => { mouseRef.current = null; });
mapRef.current = m;
layerRef.current = L.layerGroup().addTo(m);
const ro = new ResizeObserver(() => m.invalidateSize({ animate: false }));
@@ -430,23 +496,66 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
if (!wanted.has(p.name) && p.name !== sel) continue;
const chosen = p.name === sel;
const up = p.el > 0;
const colour = chosen ? '#22c55e' : up ? '#f59e0b' : '#9ca3af';
const colour = chosen ? '#22c55e' : up ? '#f59e0b' : '#94a3b8';
// The footprint is the honest answer to "can I hear it": everything inside
// the circle has the satellite above its horizon.
L.circle([p.lat, p.lon], {
radius: p.footprint_km * 1000,
color: colour, weight: chosen ? 1.2 : 0.8, opacity: chosen ? 0.7 : 0.35,
color: colour, weight: chosen ? 1.2 : 0.8, opacity: chosen ? 0.7 : 0.45,
fillColor: colour, fillOpacity: chosen ? 0.1 : 0.05,
}).addTo(layer);
// Two rings and not one. The map is a street map on one station and a
// dark satellite image on the next, and a single-stroke dot disappears
// into one of them — a pale marker on pale terrain, a grey one on a black
// ocean. A dark halo under a white ring reads on both, which is what an
// unselected satellite needs: it is precisely the one nobody is looking
// straight at.
const r = chosen ? 7 : up ? 6 : 5;
L.circleMarker([p.lat, p.lon], {
radius: chosen ? 6 : 4, color: '#fff', weight: 1,
radius: r + 1.5, color: '#000', weight: 2, opacity: 0.45,
fill: false, interactive: false,
}).addTo(layer);
const dot = L.circleMarker([p.lat, p.lon], {
radius: r, color: '#fff', weight: 2,
fillColor: colour, fillOpacity: 1,
})
.bindTooltip(`${p.name} · ${fmtDeg(p.el)} · ${Math.round(p.alt_km)} km`, { direction: 'top' })
.bindTooltip(satTip(p, nextPassOf.get(p.name), t), {
direction: 'top', className: 'sat-tip', offset: [0, -6],
})
.on('click', () => setSel(p.name))
.addTo(layer);
// Was the pointer on this dot before the redraw replaced it? Then put the
// tooltip back, with the numbers it has just refreshed.
const map = mapRef.current;
if (map && mouseRef.current) {
const at = map.latLngToContainerPoint([p.lat, p.lon]);
if (at.distanceTo(mouseRef.current) <= r + 3) dot.openTooltip();
}
// A name beside the ones that are UP. The map can carry a dozen birds and
// labelling them all is a map nobody can read; the two or three above the
// horizon are the ones an operator is choosing between right now, and
// hovering each grey dot in turn to find them is the work this saves.
//
// Its own non-interactive marker rather than a permanent tooltip on the
// dot: Leaflet keeps ONE tooltip per layer, so a permanent label would
// take the place of the hover detail — and the detail is the point.
if (up || chosen) {
L.marker([p.lat, p.lon], {
icon: L.divIcon({
className: 'sat-name-label',
html: `<span>${escapeHtml(p.name)}</span>`,
iconSize: [0, 0],
iconAnchor: [-(r + 5), 6],
}),
interactive: false, keyboard: false,
}).addTo(layer);
}
}
}, [positions, track, home?.lat, home?.lon, myGrid, sel, shown]);
}, [positions, track, home?.lat, home?.lon, myGrid, sel, shown, nextPassOf, t]);
// ── Render ───────────────────────────────────────────────────────────────
+6
View File
@@ -603,6 +603,9 @@ const en: Dict = {
'sat.antenna': 'Antenna', 'sat.rotCommanded': '(commanded — this controller does not report back)',
'sat.aos': 'Rises in', 'sat.los': 'Sets in', 'sat.rise': 'Rise', 'sat.peak': 'Peak', 'sat.set': 'Set',
'sat.range': 'Distance', 'sat.altitude': 'Altitude', 'sat.footprint': 'Footprint',
'sat.tipEl': 'Elevation', 'sat.tipAz': 'Azimuth', 'sat.tipRange': 'Distance', 'sat.tipAlt': 'Altitude',
'sat.tipAos': 'Rises', 'sat.tipLos': 'Sets', 'sat.tipMaxEl': 'Peak',
'sat.tipBelow': 'below the horizon', 'sat.tipNoPass': 'no pass in the prediction window',
'sat.approaching': 'approaching', 'sat.receding': 'receding', 'sat.below': 'below the horizon',
'sat.noPassSoon': 'No pass in the next day — check the elements, or your minimum elevation.',
'sat.geoHint': 'Geostationary: always there, no Doppler to correct. Point once and leave it.',
@@ -1214,6 +1217,9 @@ const fr: Dict = {
'sat.antenna': 'Antenne', 'sat.rotCommanded': '(commandé — ce contrôleur ne répond pas)',
'sat.aos': 'Lever dans', 'sat.los': 'Coucher dans', 'sat.rise': 'Lever', 'sat.peak': 'Culmination', 'sat.set': 'Coucher',
'sat.range': 'Distance', 'sat.altitude': 'Altitude', 'sat.footprint': 'Empreinte',
'sat.tipEl': 'Élévation', 'sat.tipAz': 'Azimut', 'sat.tipRange': 'Distance', 'sat.tipAlt': 'Altitude',
'sat.tipAos': 'Lever', 'sat.tipLos': 'Coucher', 'sat.tipMaxEl': 'Culmination',
'sat.tipBelow': 'sous lhorizon', 'sat.tipNoPass': 'aucun passage dans la fenêtre de prévision',
'sat.approaching': 'se rapproche', 'sat.receding': 's’éloigne', 'sat.below': 'sous lhorizon',
'sat.noPassSoon': 'Aucun passage dans les 24 h — vérifiez les éléments, ou votre élévation minimale.',
'sat.geoHint': 'Géostationnaire : toujours là, aucun Doppler à corriger. On pointe une fois et on ny touche plus.',
+37
View File
@@ -1245,3 +1245,40 @@
.leaflet-container {
background: var(--card) !important;
}
/* Satellite map tooltips. Leaflet's own are a white box with a grey border —
fine on a street map, a bright rectangle on a dark one, and always the wrong
colours for whichever theme the operator chose. These follow the theme, and
are wide enough for a pass: AOS, LOS, elevation and range each on their own
line. */
.leaflet-tooltip.sat-tip {
background: var(--popover);
color: var(--popover-foreground);
border: 1px solid var(--border);
border-radius: 0.5rem;
box-shadow: 0 4px 16px rgb(0 0 0 / 0.35);
padding: 0.4rem 0.55rem;
font-size: 11px;
line-height: 1.45;
white-space: nowrap;
}
.leaflet-tooltip.sat-tip::before { border-top-color: var(--border); }
.sat-tip-name { font-weight: 600; font-size: 12px; margin-bottom: 0.2rem; }
.sat-tip-row { display: flex; justify-content: space-between; gap: 1.25rem; }
.sat-tip-row > span:first-child { color: var(--muted-foreground); }
.sat-tip-note { color: var(--muted-foreground); font-style: italic; }
/* The name beside a satellite that is up right now. A plain div marker and
not a Leaflet tooltip, because Leaflet keeps one tooltip per layer and the
hover detail is the one worth keeping. */
.sat-name-label {
pointer-events: none;
white-space: nowrap;
font-size: 10px;
font-weight: 600;
/* Painted twice — a dark halo under a light glyph — because the label sits on
satellite imagery, on a street map and on a dark ocean in the same session,
and no single colour is readable on all three. */
color: #fff;
text-shadow: 0 0 3px #000, 0 0 3px #000, 0 1px 2px #000;
}