feat(sat): a sky plot — the pass seen from underneath it

The map answers "where is the satellite over the earth". This answers
"where do I look", which during a pass is the question that matters.

The projection is the one every tracker uses and every operator already
reads: the centre is the zenith, the rim is the horizon, north is up. So
the radius is (90 − elevation), not the elevation — a bird overhead is a
dot in the middle, and a pass that hugs the rim is one that never rises.
Whether it comes over the roof or along the treeline is something no
amount of azimuth and elevation digits conveys, and one glance settles.

The whole pass is drawn: a dashed track with arrowheads for the direction
of travel, a hollow circle where it rises, a filled one where it sets,
and a cross where the satellite is now — green above the horizon, grey
below, because the numbers are still right down there and nothing can be
worked through the earth.

The track is fetched once a minute, not once a second: the SHAPE of a
pass does not change while it happens. Only the marker moves, and that
rides on the tuning poll that was already running.
This commit is contained in:
2026-09-07 22:50:29 +02:00
parent 37dadeda84
commit cf44b37bf4
9 changed files with 295 additions and 4 deletions
+47 -2
View File
@@ -1,10 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { Satellite as SatIcon, Radio, ArrowUp, ArrowDown, PanelRightClose, PanelRightOpen } from 'lucide-react';
import { Satellite as SatIcon, Radio, ArrowUp, ArrowDown, PanelRightClose, PanelRightOpen, Radar } from 'lucide-react';
import {
GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
GetSatelliteGroundTrack, GetSatelliteTLEInfo, GetSatelliteNextPass,
GetSatelliteGroundTrack, GetSatelliteTLEInfo, GetSatelliteNextPass, GetSatelliteSkyTrack,
StartSatelliteTracking, StopSatelliteTracking, GetSatelliteTracking,
} from '../../wailsjs/go/main/App';
import { EventsOn } from '../../wailsjs/runtime/runtime';
@@ -14,6 +14,7 @@ import { BASEMAPS, type BasemapKey } from '@/components/MainMap';
import { loadMapView, saveMapView } from '@/lib/mapView';
import { loadMapBase, saveMapBase, MAP_BASE_SAT } from '@/lib/mapBase';
import { writeUiPref } from '@/lib/uiPref';
import { SkyPlot, type SkyPoint } from '@/components/SkyPlot';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
@@ -71,6 +72,7 @@ const MAP_VIEW_SAT = 'opslog.satMapView';
const SIDE_W_KEY = 'opslog.satSideWidth';
const SIDE_SHOWN_KEY = 'opslog.satSideShown';
const SIDE_W_DEFAULT = 336, SIDE_W_MIN = 240, SIDE_W_MAX = 720;
const SKY_SHOWN_KEY = 'opslog.satSkyShown';
const fmtHz = (hz: number) => {
if (!hz) return '—';
@@ -149,6 +151,8 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
const [pass, setPass] = useState<PassInfo | null>(null);
const [tle, setTle] = useState<{ count: number; age_h: number; stale: boolean; custom: number } | null>(null);
const [tracking, setTracking] = useState<Track | null>(null);
const [sky, setSky] = useState<SkyPoint[]>([]);
const [skyShown, setSkyShown] = useState(() => localStorage.getItem(SKY_SHOWN_KEY) !== '0');
const [err, setErr] = useState('');
// A clock of its own, so every countdown on the panel ticks from one instant
// and none of them needs a round trip to Go to lose a second.
@@ -255,6 +259,24 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
return () => { live = false; window.clearInterval(id); };
}, [sel]);
// The pass drawn across the sky. Once a minute is plenty: the SHAPE of a
// pass does not change while it happens — only the marker on it moves, and
// that comes from the tuning poll a second at a time.
useEffect(() => {
if (!sel || !skyShown) { setSky([]); return; }
let live = true;
const load = async () => {
try {
const pts: SkyPoint[] = ((await GetSatelliteSkyTrack(sel, 120)) as any) ?? [];
if (live) setSky(pts);
} catch { if (live) setSky([]); }
};
load();
const id = window.setInterval(load, 60_000);
return () => { live = false; window.clearInterval(id); };
}, [sel, skyShown]);
useEffect(() => { writeUiPref(SKY_SHOWN_KEY, skyShown ? '1' : '0'); }, [skyShown]);
// Passes are cheap but not free, and they change slowly.
useEffect(() => {
const id = window.setInterval(loadPasses, 5 * 60_000);
@@ -493,6 +515,14 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
>
{Object.entries(BASEMAPS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
<Button
variant="ghost" size="sm"
className={cn('h-7 px-1.5', skyShown && 'text-success')}
onClick={() => setSkyShown((v) => !v)}
title={skyShown ? t('sat.hideSky') : t('sat.showSky')}
>
<Radar className="size-3.5" />
</Button>
{/* Put the whole window on the map. On a laptop the readout takes a
third of the screen, and there are moments — watching a footprint
cross an ocean — when the map IS the answer. */}
@@ -578,6 +608,21 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
)}
</div>
{/* The sky, seen from underneath it: the centre is straight up, the
rim is the horizon, north is at the top. One glance says whether
the pass comes over the roof or along the treeline. */}
{skyShown && (
<div className="rounded-lg border border-border bg-card p-2">
<SkyPlot
track={sky}
az={tuning?.az ?? null}
el={tuning?.el ?? null}
name={bird?.name}
visible={!!tuning?.visible}
/>
</div>
)}
{/* Where it is, right now. */}
<div className="rounded-lg border border-border bg-card p-2">
<div className="grid grid-cols-2 gap-2">
+142
View File
@@ -0,0 +1,142 @@
import { useI18n } from '@/lib/i18n';
// The sky, seen from underneath it.
//
// The map answers "where is the satellite over the earth". This answers "where
// do I look", which during a pass is the question that matters: whether the
// bird comes over the top or clips the horizon behind the house is something no
// amount of azimuth and elevation digits conveys, and one glance at a polar
// plot settles it.
//
// The projection is the one every satellite tracker uses and every operator
// already reads: the centre is the zenith, the outer circle is the horizon, and
// north is up. So the radius is (90 elevation), NOT the elevation — a
// satellite overhead is a dot in the middle, and a pass that stays near the rim
// is one that never rises.
export type SkyPoint = { at: string; az: number; el: number };
export function SkyPlot({ track, az, el, name, visible, size = 300 }: {
// The pass, sampled from rise to set. Empty draws the dial alone, which is
// still worth showing: it says where the antenna is pointing now.
track: SkyPoint[];
// Where the satellite is at this instant, or null when it is not up.
az?: number | null;
el?: number | null;
name?: string;
visible?: boolean;
size?: number;
}) {
const { t } = useI18n();
const R = size / 2 - 14; // the horizon circle
const cx = size / 2, cy = size / 2;
// Where a bearing and an elevation land on the dial.
const pt = (azDeg: number, elDeg: number): [number, number] => {
const r = R * (90 - Math.max(0, Math.min(90, elDeg))) / 90;
const a = (azDeg * Math.PI) / 180;
return [cx + r * Math.sin(a), cy - r * Math.cos(a)];
};
const rings = [15, 30, 45, 60, 75];
const path = track.length > 1
? track.map((p, i) => `${i === 0 ? 'M' : 'L'}${pt(p.az, p.el).map((v) => v.toFixed(1)).join(' ')}`).join(' ')
: '';
// Ticks every 10°, longer every 30°, so the rim reads as a compass rather
// than a plain circle.
const ticks = [];
for (let a = 0; a < 360; a += 10) {
const long = a % 30 === 0;
const rad = (a * Math.PI) / 180;
const r1 = R, r2 = R - (long ? 7 : 4);
ticks.push(
<line key={a}
x1={cx + r1 * Math.sin(rad)} y1={cy - r1 * Math.cos(rad)}
x2={cx + r2 * Math.sin(rad)} y2={cy - r2 * Math.cos(rad)}
stroke="var(--border)" strokeWidth={long ? 1.4 : 0.8} />,
);
}
const here = az != null && el != null ? pt(az, el) : null;
const start = track.length > 1 ? pt(track[0].az, track[0].el) : null;
const end = track.length > 1 ? pt(track[track.length - 1].az, track[track.length - 1].el) : null;
return (
<svg viewBox={`0 0 ${size} ${size}`} className="w-full h-auto select-none" role="img"
aria-label={t('sat.skyPlot')}>
<defs>
{/* An arrowhead on the track: a pass has a direction, and which way the
satellite is travelling decides where to point the antenna next. */}
<marker id="skyArrow" viewBox="0 0 10 10" refX="6" refY="5"
markerWidth="5" markerHeight="5" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="var(--success)" />
</marker>
</defs>
<circle cx={cx} cy={cy} r={R} fill="var(--muted)" fillOpacity={0.25}
stroke="var(--border)" strokeWidth={1.5} />
{rings.map((e) => (
<circle key={e} cx={cx} cy={cy} r={R * (90 - e) / 90}
fill="none" stroke="var(--border)" strokeWidth={0.6} strokeDasharray="3 4" />
))}
{ticks}
{/* The cardinal cross. */}
<line x1={cx} y1={cy - R} x2={cx} y2={cy + R} stroke="var(--border)" strokeWidth={0.6} />
<line x1={cx - R} y1={cy} x2={cx + R} y2={cy} stroke="var(--border)" strokeWidth={0.6} />
{([
{ lbl: 'N', x: cx, y: cy - R - 3, anchor: 'middle' },
{ lbl: 'S', x: cx, y: cy + R + 11, anchor: 'middle' },
{ lbl: 'E', x: cx + R + 4, y: cy + 4, anchor: 'start' },
{ lbl: 'W', x: cx - R - 4, y: cy + 4, anchor: 'end' },
] as const).map((c) => (
<text key={c.lbl} x={c.x} y={c.y} textAnchor={c.anchor}
fontSize={11} fontWeight={600} fill="var(--muted-foreground)">{c.lbl}</text>
))}
{/* Elevation labels along the west arm, the way a tracker draws them. */}
{[0, 30, 60].map((e) => (
<text key={e} x={cx - R * (90 - e) / 90 + 2} y={cy + 10} fontSize={8}
fill="var(--muted-foreground)" opacity={0.8}>{e}°</text>
))}
{/* The pass. */}
{path && (
<path d={path} fill="none" stroke="var(--success)" strokeWidth={1.6}
strokeDasharray="5 3" markerMid="url(#skyArrow)" markerEnd="url(#skyArrow)"
opacity={0.85} />
)}
{start && <circle cx={start[0]} cy={start[1]} r={3} fill="none" stroke="var(--success)" strokeWidth={1.4} />}
{end && <circle cx={end[0]} cy={end[1]} r={3} fill="var(--success)" opacity={0.6} />}
{/* Where it is now. Hollow and grey below the horizon: the numbers are
still right, but nothing can be worked through the earth. */}
{here && (
<g>
<line x1={here[0] - 6} y1={here[1]} x2={here[0] + 6} y2={here[1]}
stroke={visible ? 'var(--success)' : 'var(--muted-foreground)'} strokeWidth={1.4} />
<line x1={here[0]} y1={here[1] - 6} x2={here[0]} y2={here[1] + 6}
stroke={visible ? 'var(--success)' : 'var(--muted-foreground)'} strokeWidth={1.4} />
<circle cx={here[0]} cy={here[1]} r={4}
fill={visible ? 'var(--success)' : 'none'}
stroke={visible ? 'var(--background)' : 'var(--muted-foreground)'} strokeWidth={1.2} />
</g>
)}
{/* The name and the look angles, in the middle, where a tracker puts them
— big enough to read from the other side of the shack. */}
{!!name && (
<text x={cx} y={cy - R * 0.42} textAnchor="middle" fontSize={18} fontWeight={600}
fill="var(--foreground)" opacity={0.85}>{name}</text>
)}
{az != null && el != null && (
<text x={cx} y={cy - R * 0.22} textAnchor="middle" fontSize={12}
fill="var(--muted-foreground)" className="tabular-nums">
AZ {az.toFixed(1)}° EL {el.toFixed(1)}°
</text>
)}
</svg>
);
}
+4
View File
@@ -598,6 +598,8 @@ const en: Dict = {
'sat.hideSide': 'Hide the readout — all map', 'sat.showSide': 'Show the readout',
'sat.sideWidthTip': 'Drag to resize the readout. Double-click to reset it.',
'sat.thSat': 'Satellite', 'sat.thAos': 'Rise', 'sat.thLos': 'Set', 'sat.thMaxEl': 'Max', 'sat.thIn': 'In',
'sat.skyPlot': 'The pass across the sky — centre is straight up, the rim is the horizon, north is at the top',
'sat.showSky': 'Show the sky plot', 'sat.hideSky': 'Hide the sky plot',
'sec.satellite': 'Satellites',
'satset.hint': 'Where the antenna is, and the machine that points it. The satellites you follow and the frequency plan are in the Satellites tab.',
'satset.gridPlaceholder': 'e.g. JN18cx',
@@ -1190,6 +1192,8 @@ const fr: Dict = {
'sat.hideSide': 'Masquer le panneau — carte plein écran', 'sat.showSide': 'Afficher le panneau',
'sat.sideWidthTip': 'Glisser pour redimensionner le panneau. Double-clic pour le remettre par défaut.',
'sat.thSat': 'Satellite', 'sat.thAos': 'Lever', 'sat.thLos': 'Coucher', 'sat.thMaxEl': 'Max', 'sat.thIn': 'Dans',
'sat.skyPlot': 'Le passage dans le ciel — le centre est à la verticale, le bord est lhorizon, le nord en haut',
'sat.showSky': 'Afficher la vue du ciel', 'sat.hideSky': 'Masquer la vue du ciel',
'sec.satellite': 'Satellites',
'satset.hint': 'Où se trouve lantenne, et la machine qui la pointe. Les satellites suivis et le plan de fréquences sont dans longlet Satellites.',
'satset.gridPlaceholder': 'ex. JN18cx',
+1
View File
@@ -60,6 +60,7 @@ const PORTABLE_KEYS = [
'opslog.clusterSlotHighlight', // cluster/band map: colour calls not worked on this band+mode
'opslog.bandMapWidth', // docked band map: column width (px)
'opslog.satSideWidth', 'opslog.satSideShown', // Satellites tab: readout column width, and whether it is shown
'opslog.satSkyShown', // Satellites tab: the polar sky plot
'opslog.bandMapTabWidth', // Band map tab: shared card width (px)
'opslog.bandMapZoom', // band map zoom (px/kHz step) remembered per band, as one {band: index} map
'opslog.decodeColWidths', // FT decodes table: per-column widths (px), as one {col: px} map