diff --git a/changelog.json b/changelog.json index 854fb2b..cceacb4 100644 --- a/changelog.json +++ b/changelog.json @@ -6,13 +6,17 @@ "Every rotator interface now lives in Settings ▸ Rotator, and the satellite page only picks one of them. EasyComm and PstRotator used to be described inside the satellite settings while the other backends were described in the rotator list, so one mast was configured twice. What you already set up is moved into the list for you and selected.", "Each rotator interface says whether it drives azimuth alone or azimuth and elevation, beside the interface itself. The satellite rotator list shows the azimuth-only ones greyed out rather than hiding them, so a rotor that cannot follow a pass says why.", "New rotator interface: ERC-M by DF9GR, the azimuth/elevation controller for a Yaesu G-5500. Over its USB COM port or the network, with its emulation set to GS-232. Untested on hardware — reports welcome.", - "EasyComm II is now an ordinary rotator interface, so it can turn the antenna from the compass and from a spot click, not only during a satellite pass." + "EasyComm II is now an ordinary rotator interface, so it can turn the antenna from the compass and from a spot click, not only during a satellite pass.", + "On the satellite map, an unselected satellite is readable: a bigger dot with a dark halo under a white ring, which shows up on a street map and on a dark ocean alike, and the ones above the horizon carry their name.", + "Hovering a satellite on the map now says what a pass is worth — elevation and azimuth, distance and whether it is closing or going away, rise and set with the countdown, and how high it will get. It no longer closes itself every five seconds while you read it." ], "fr": [ "Toutes les interfaces de rotor sont désormais dans Réglages ▸ Rotator, et la page satellite ne fait qu’en choisir une. EasyComm et PstRotator se configuraient dans les réglages satellite pendant que les autres se configuraient dans la liste des rotors : un même pylône était décrit deux fois. Ce que vous aviez réglé est déplacé dans la liste et sélectionné automatiquement.", "Chaque interface de rotor indique si elle pilote l’azimut seul ou l’azimut et l’élévation, juste à côté de l’interface. La liste des rotors de la page satellite affiche les azimut-seul en grisé plutôt que de les cacher : un rotor qui ne peut pas suivre un passage dit pourquoi.", "Nouvelle interface de rotor : ERC-M de DF9GR, le contrôleur azimut/élévation pour un Yaesu G-5500. Via son port COM USB ou le réseau, avec son émulation réglée sur GS-232. Non testé sur matériel — vos retours sont les bienvenus.", - "EasyComm II devient une interface de rotor comme les autres : elle peut tourner l’antenne depuis le compas et depuis un clic sur un spot, plus seulement pendant un passage satellite." + "EasyComm II devient une interface de rotor comme les autres : elle peut tourner l’antenne depuis le compas et depuis un clic sur un spot, plus seulement pendant un passage satellite.", + "Sur la carte satellite, un satellite non sélectionné est lisible : un point plus gros avec un halo sombre sous un anneau blanc, visible aussi bien sur une carte routière que sur un océan noir, et ceux au-dessus de l’horizon portent leur nom.", + "Le survol d’un satellite sur la carte indique désormais ce que vaut le passage — élévation et azimut, distance et si elle diminue ou augmente, lever et coucher avec le décompte, et la hauteur qu’il atteindra. L’infobulle ne se referme plus toutes les cinq secondes pendant qu’on la lit." ] }, { diff --git a/frontend/src/components/SatellitePanel.tsx b/frontend/src/components/SatellitePanel.tsx index f896cc9..83dede4 100644 --- a/frontend/src/components/SatellitePanel.tsx +++ b/frontend/src/components/SatellitePanel.tsx @@ -128,6 +128,53 @@ const MODE_COLOUR: Record = { 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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) => + `
${label}${value}
`; + const out: string[] = [`
${escapeHtml(p.name)}
`]; + + 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(`
${t('sat.tipBelow')}
`); + } + // 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(`
${t('sat.tipNoPass')}
`); + } + 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([]); const [passes, setPasses] = useState([]); + // 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(); + for (const p of passes) if (!m.has(p.name)) m.set(p.name, p); + return m; + }, [passes]); const [tuning, setTuning] = useState(null); const [pass, setPass] = useState(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(null); const layerRef = useRef(null); const baseRef = useRef(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(null); const labelsRef = useRef(null); const [basemap, setBasemap] = useState(() => 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: `${escapeHtml(p.name)}`, + 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 ─────────────────────────────────────────────────────────────── diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 8c4dd51..499c01d 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -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 l’horizon', 'sat.tipNoPass': 'aucun passage dans la fenêtre de prévision', 'sat.approaching': 'se rapproche', 'sat.receding': 's’éloigne', 'sat.below': 'sous l’horizon', '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 n’y touche plus.', diff --git a/frontend/src/style.css b/frontend/src/style.css index 27eed3a..bb55686 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -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; +}