diff --git a/changelog.json b/changelog.json
index b4f53ba..dfcb77c 100644
--- a/changelog.json
+++ b/changelog.json
@@ -1,4 +1,14 @@
[
+ {
+ "version": "0.27.17",
+ "date": "",
+ "en": [
+ "[NEW] Satellites. A new tab (Tools → Satellites) tracks the amateur birds: a map with each satellite's footprint and the selected one's path over the ground, the next passes with their maximum elevation, and — for the satellite you are on — the azimuth, the elevation and the Doppler-corrected downlink and uplink. Orbital elements come from Celestrak (with a mirror behind it) and are kept on disk, so the tab is full the moment it opens even with no internet; elements for a bird no feed carries yet can be pasted in and survive every refresh. The shipped frequency list covers the FM and linear satellites and QO-100, and lives in a file you can correct yourself when a transponder is switched."
+ ],
+ "fr": [
+ "[NOUVEAU] Satellites. Un nouvel onglet (Outils → Satellites) suit les satellites amateurs : une carte avec l'empreinte de chacun et la trace au sol de celui qui est sélectionné, les prochains passages avec leur élévation maximale, et — pour le satellite en cours — l'azimut, l'élévation et les fréquences de descente et de montée corrigées de l'effet Doppler. Les éléments orbitaux viennent de Celestrak (avec un miroir derrière) et sont conservés sur disque : l'onglet est rempli dès son ouverture, même sans internet. Les éléments d'un satellite qu'aucun flux ne diffuse encore peuvent être collés à la main et survivent à chaque mise à jour. La liste de fréquences fournie couvre les satellites FM et linéaires ainsi que QO-100, dans un fichier que vous pouvez corriger vous-même quand un transpondeur change de mode."
+ ]
+ },
{
"version": "0.27.16",
"date": "",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 6a2d1e5..e1811c1 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -80,6 +80,7 @@ import { ConfirmDialog } from '@/components/ConfirmDialog';
import { SettingsModal } from '@/components/SettingsModal';
import { FTMapPanel } from '@/components/FTMapPanel';
import { DXpeditionsPanel } from '@/components/DXpeditionsPanel';
+import { SatellitePanel } from '@/components/SatellitePanel';
import { FirstRunModal } from '@/components/FirstRunModal';
import { QSOEditModal } from '@/components/QSOEditModal';
import { BandMap } from '@/components/BandMap';
@@ -1354,6 +1355,17 @@ export default function App() {
}
const [ftmapTabOpen, setFtmapTabOpen] = useState(() => localStorage.getItem('opslog.ftmapTab') === '1');
const [dxpedTabOpen, setDxpedTabOpen] = useState(() => localStorage.getItem('opslog.dxpedTab') === '1');
+ const [satTabOpen, setSatTabOpen] = useState(() => localStorage.getItem('opslog.satTab') === '1');
+ function openSatTab() {
+ setSatTabOpen(true);
+ writeUiPref('opslog.satTab', '1');
+ setActiveTab('sat');
+ }
+ function closeSatTab() {
+ setSatTabOpen(false);
+ writeUiPref('opslog.satTab', '0');
+ setActiveTab((t) => (t === 'sat' ? 'recent' : t));
+ }
function openDxpedTab() {
setDxpedTabOpen(true);
writeUiPref('opslog.dxpedTab', '1');
@@ -5210,6 +5222,7 @@ export default function App() {
{ name: 'tools', label: t('menu.tools'), items: [
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
{ type: 'item', label: t('dxp.tab'), action: 'tools.dxped' },
+ { type: 'item', label: t('sat.tab'), action: 'tools.sat' },
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' },
{ type: 'item', label: t('station.title'), action: 'tools.station' },
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
@@ -5267,6 +5280,7 @@ export default function App() {
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break;
case 'tools.dxped': openDxpedTab(); break;
+ case 'tools.sat': openSatTab(); break;
case 'tools.decodes': openDecodesTab(); break;
case 'tools.ftmap': openFtmapTab(); break;
case 'tools.grids': openGridsTab(); break;
@@ -8160,6 +8174,21 @@ export default function App() {
)}
+ {satTabOpen && (
+
+ {t('sat.tab')}
+ { e.stopPropagation(); }}
+ onClick={(e) => { e.stopPropagation(); closeSatTab(); }}
+ >
+
+
+
+ )}
{ftmapTabOpen && (
{t('ftmap.tab')}
@@ -8808,6 +8837,18 @@ export default function App() {
)}
)}
+ {satTabOpen && (
+
+ {/* Mounted only while it is the visible tab: the panel polls the
+ tuning once a second, and there is no reason to compute an
+ orbit for a tab nobody is looking at. */}
+ {activeTab === 'sat' && (
+
+
+
+ )}
+
+ )}
{ftmapTabOpen && (
{activeTab === 'ftmap' && (
diff --git a/frontend/src/components/SatellitePanel.tsx b/frontend/src/components/SatellitePanel.tsx
new file mode 100644
index 0000000..2bd9f1c
--- /dev/null
+++ b/frontend/src/components/SatellitePanel.tsx
@@ -0,0 +1,462 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import L from 'leaflet';
+import 'leaflet/dist/leaflet.css';
+import { RefreshCw, Star, Satellite as SatIcon, ClipboardPaste } from 'lucide-react';
+import {
+ GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
+ GetSatelliteGroundTrack, GetSatelliteTLEInfo, RefreshSatelliteTLE, AddSatelliteElements,
+ GetSatSettings, SaveSatSettings,
+} from '../../wailsjs/go/main/App';
+import { EventsOn } from '../../wailsjs/runtime/runtime';
+import { Button } from '@/components/ui/button';
+import { gridToLatLon, splitAtAntimeridian } from '@/lib/maidenhead';
+import { BASEMAPS, type BasemapKey } from '@/components/MainMap';
+import { loadMapView, saveMapView } from '@/lib/mapView';
+import { cn } from '@/lib/utils';
+import { useI18n } from '@/lib/i18n';
+
+// Satellites — where the birds are, and what to do with the radio.
+//
+// The tab answers three questions at once because on a pass there is no time to
+// go looking for any of them: where the satellite is (the map), when the next
+// one comes (the table), and what to tune (the readout). Everything is computed
+// in Go from the same element set, so the dial and the map can never tell
+// different stories.
+
+type Bird = {
+ name: string; norad: number; geostationary: boolean; favorite: boolean;
+ has_elements: boolean; element_name: string; epoch_age_h: number;
+ transponders?: {
+ label: string; mode: string; down_lo: number; down_hi: number;
+ up_lo: number; up_hi: number; inverting: boolean; ctcss: number; linear: boolean;
+ }[];
+};
+type Position = {
+ name: string; lat: number; lon: number; alt_km: number; footprint_km: number;
+ az: number; el: number; range_km: number; range_rate: number;
+};
+type Pass = {
+ name: string; aos: string; los: string; aos_az: number; los_az: number;
+ max_el: number; max_el_az: number; max_el_at: string; duration_s: number;
+};
+type Tuning = {
+ name: string; transponder: string; mode: string;
+ nominal_down: number; nominal_up: number; down_hz: number; up_hz: number;
+ ctcss: number; inverting: boolean;
+ az: number; el: number; range_km: number; range_rate: number; visible: boolean;
+};
+
+const MAP_VIEW_SAT = 'opslog.satMapView';
+
+// Above the horizon is green, below is grey. Nothing subtler: on a pass the one
+// thing an operator needs to read from across the room is whether the bird is
+// up.
+const upColour = (up: boolean) => (up ? 'var(--success)' : 'var(--muted-foreground)');
+
+const fmtHz = (hz: number) => {
+ if (!hz) return '—';
+ const mhz = hz / 1e6;
+ // Six decimals: a linear transponder is tuned to the hundred hertz, and the
+ // Doppler correction moves the last three digits every second.
+ return mhz.toFixed(6).replace(/(\d)(?=(\d{3})+\.)/g, '$1 ');
+};
+const fmtDeg = (d: number) => `${d.toFixed(1)}°`;
+const hhmm = (iso: string) => {
+ const d = new Date(iso);
+ return Number.isNaN(d.getTime()) ? '—' : d.toISOString().slice(11, 16);
+};
+const inMin = (iso: string) => Math.round((Date.parse(iso) - Date.now()) / 60000);
+
+export function SatellitePanel({ myGrid }: { myGrid: string }) {
+ const { t } = useI18n();
+ const [birds, setBirds] = useState([]);
+ const [sel, setSel] = useState(() => localStorage.getItem('opslog.satSelected') || '');
+ const [tpIdx, setTpIdx] = useState(0);
+ const [positions, setPositions] = useState([]);
+ const [passes, setPasses] = useState([]);
+ const [tuning, setTuning] = useState(null);
+ const [tle, setTle] = useState<{ count: number; age_h: number; stale: boolean; custom: number } | null>(null);
+ const [busy, setBusy] = useState(false);
+ const [err, setErr] = useState('');
+ const [favs, setFavs] = useState([]);
+
+ const bird = useMemo(() => birds.find((b) => b.name === sel) ?? null, [birds, sel]);
+ const tp = bird?.transponders?.[tpIdx] ?? null;
+
+ // ── Data ─────────────────────────────────────────────────────────────────
+
+ const loadBirds = useCallback(async () => {
+ try {
+ const list: Bird[] = (await GetSatelliteBirds()) as any;
+ setBirds(list ?? []);
+ setSel((cur) => {
+ if (cur && (list ?? []).some((b) => b.name === cur)) return cur;
+ // Nothing chosen yet: the first bird we can both find and tune.
+ const first = (list ?? []).find((b) => b.has_elements && (b.transponders?.length ?? 0) > 0);
+ return first?.name ?? cur;
+ });
+ } catch (e: any) { setErr(String(e?.message ?? e)); }
+ }, []);
+
+ const loadSettings = useCallback(async () => {
+ try {
+ const s: any = await GetSatSettings();
+ setFavs(s?.favorites ?? []);
+ } catch { /* favourites are a convenience; the list works without them */ }
+ }, []);
+
+ const loadTle = useCallback(async () => {
+ try { setTle((await GetSatelliteTLEInfo()) as any); } catch { /* shown as unknown */ }
+ }, []);
+
+ const loadPasses = useCallback(async () => {
+ try {
+ setPasses(((await GetSatellitePasses([], 0)) as any) ?? []);
+ setErr('');
+ } catch (e: any) { setErr(String(e?.message ?? e)); }
+ }, []);
+
+ useEffect(() => { loadBirds(); loadSettings(); loadTle(); loadPasses(); }, [loadBirds, loadSettings, loadTle, loadPasses]);
+ useEffect(() => EventsOn('sat:tle', () => { loadTle(); loadBirds(); loadPasses(); }), [loadTle, loadBirds, loadPasses]);
+ useEffect(() => { if (sel) localStorage.setItem('opslog.satSelected', sel); }, [sel]);
+ useEffect(() => { setTpIdx(0); }, [sel]);
+
+ // The map's satellites, every five seconds: a low orbit moves about a third of
+ // a degree of longitude in that time, which is a pixel or two at this zoom.
+ useEffect(() => {
+ let live = true;
+ const tick = async () => {
+ try {
+ const p: Position[] = ((await GetSatellitePositions([])) as any) ?? [];
+ if (live) setPositions(p);
+ } catch { /* a missing locator is already reported by the passes call */ }
+ };
+ tick();
+ const id = window.setInterval(tick, 5000);
+ return () => { live = false; window.clearInterval(id); };
+ }, []);
+
+ // The readout, every second: this is the number an operator types into a
+ // radio, and a Doppler correction on 70 cm moves by a few tens of hertz a
+ // second at the middle of a pass.
+ useEffect(() => {
+ if (!sel) { setTuning(null); return; }
+ let live = true;
+ const tick = async () => {
+ try {
+ const tn: Tuning = (await GetSatelliteTuning(sel, tpIdx, 0)) as any;
+ if (live) setTuning(tn);
+ } catch { if (live) setTuning(null); }
+ };
+ tick();
+ const id = window.setInterval(tick, 1000);
+ return () => { live = false; window.clearInterval(id); };
+ }, [sel, tpIdx]);
+
+ // Passes are cheap but not free, and they change slowly.
+ useEffect(() => {
+ const id = window.setInterval(loadPasses, 5 * 60_000);
+ return () => window.clearInterval(id);
+ }, [loadPasses]);
+
+ const refreshTle = async () => {
+ setBusy(true); setErr('');
+ try {
+ setTle((await RefreshSatelliteTLE()) as any);
+ await loadBirds(); await loadPasses();
+ } catch (e: any) { setErr(String(e?.message ?? e)); }
+ setBusy(false);
+ };
+
+ const pasteElements = async () => {
+ const text = window.prompt(t('sat.pastePrompt'));
+ if (!text) return;
+ try {
+ const n: number = (await AddSatelliteElements(text)) as any;
+ await loadBirds(); await loadPasses();
+ setErr(n > 0 ? '' : t('sat.pasteNone'));
+ } catch (e: any) { setErr(String(e?.message ?? e)); }
+ };
+
+ const toggleFav = async (name: string) => {
+ const next = favs.includes(name) ? favs.filter((f) => f !== name) : [...favs, name];
+ setFavs(next);
+ try {
+ const s: any = await GetSatSettings();
+ await SaveSatSettings({ ...s, favorites: next });
+ await loadBirds(); await loadPasses();
+ } catch (e: any) { setErr(String(e?.message ?? e)); }
+ };
+
+ // ── Map ──────────────────────────────────────────────────────────────────
+
+ const divRef = useRef(null);
+ const mapRef = useRef(null);
+ const layerRef = useRef(null);
+ const baseRef = useRef(null);
+ const labelsRef = useRef(null);
+ const [basemap, setBasemap] = useState(() =>
+ (localStorage.getItem('opslog.satMapBase') as BasemapKey) || 'light');
+ const saved = useRef(loadMapView(MAP_VIEW_SAT));
+ const [track, setTrack] = useState([]);
+
+ const home = useMemo(() => gridToLatLon(myGrid), [myGrid]);
+
+ useEffect(() => {
+ if (!divRef.current || mapRef.current) return;
+ const m = L.map(divRef.current, {
+ zoomControl: true, attributionControl: true,
+ worldCopyJump: false, preferCanvas: true,
+ // Opened on the station, not on the Atlantic: the passes that matter are
+ // the ones over the operator's own head.
+ center: saved.current ? [saved.current.lat, saved.current.lon] : [home?.lat ?? 25, home?.lon ?? 0],
+ zoom: saved.current ? saved.current.zoom : 3,
+ minZoom: 2,
+ });
+ m.on('moveend', () => {
+ const c = m.getCenter();
+ saveMapView(MAP_VIEW_SAT, c.lat, c.lng, m.getZoom());
+ });
+ mapRef.current = m;
+ layerRef.current = L.layerGroup().addTo(m);
+ const ro = new ResizeObserver(() => m.invalidateSize({ animate: false }));
+ ro.observe(divRef.current);
+ const settle = window.setTimeout(() => m.invalidateSize({ animate: false }), 100);
+ return () => {
+ window.clearTimeout(settle);
+ ro.disconnect();
+ m.remove();
+ mapRef.current = null;
+ layerRef.current = null;
+ };
+ }, [home?.lat, home?.lon]);
+
+ useEffect(() => {
+ const m = mapRef.current;
+ if (!m) return;
+ baseRef.current?.remove(); labelsRef.current?.remove();
+ const bm = BASEMAPS[basemap];
+ const opts: L.TileLayerOptions = {
+ maxNativeZoom: bm.maxNativeZoom, noWrap: true,
+ bounds: L.latLngBounds(L.latLng(-85.0511, -180), L.latLng(85.0511, 180)),
+ };
+ baseRef.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
+ if (bm.labelsUrl) labelsRef.current = L.tileLayer(bm.labelsUrl, opts).addTo(m);
+ localStorage.setItem('opslog.satMapBase', basemap);
+ }, [basemap]);
+
+ // The selected bird's path over the ground, redrawn when the selection
+ // changes and every couple of minutes as it walks off the front of it.
+ useEffect(() => {
+ if (!sel) { setTrack([]); return; }
+ let live = true;
+ const load = async () => {
+ try {
+ const pts: Position[] = ((await GetSatelliteGroundTrack(sel, 100)) as any) ?? [];
+ if (live) setTrack(pts);
+ } catch { if (live) setTrack([]); }
+ };
+ load();
+ const id = window.setInterval(load, 120_000);
+ return () => { live = false; window.clearInterval(id); };
+ }, [sel]);
+
+ useEffect(() => {
+ const layer = layerRef.current;
+ if (!layer) return;
+ layer.clearLayers();
+ if (home) {
+ L.circleMarker([home.lat, home.lon], {
+ radius: 5, color: '#fff', weight: 2, fillColor: '#e11d48', fillOpacity: 1,
+ }).bindTooltip(myGrid, { direction: 'top' }).addTo(layer);
+ }
+ if (track.length > 1) {
+ const pts = splitAtAntimeridian(track.map((p) => [p.lat, p.lon] as [number, number]));
+ L.polyline(pts as L.LatLngExpression[][], {
+ color: 'var(--info)', weight: 1.2, opacity: 0.7, dashArray: '4 4', smoothFactor: 0,
+ }).addTo(layer);
+ }
+ for (const p of positions) {
+ const chosen = p.name === sel;
+ const up = p.el > 0;
+ const colour = chosen ? '#22c55e' : up ? '#f59e0b' : '#9ca3af';
+ // 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,
+ fillColor: colour, fillOpacity: chosen ? 0.1 : 0.05,
+ }).addTo(layer);
+ L.circleMarker([p.lat, p.lon], {
+ radius: chosen ? 6 : 4, color: '#fff', weight: 1,
+ fillColor: colour, fillOpacity: 1,
+ })
+ .bindTooltip(`${p.name} · ${fmtDeg(p.el)} · ${Math.round(p.alt_km)} km`, { direction: 'top' })
+ .on('click', () => setSel(p.name))
+ .addTo(layer);
+ }
+ }, [positions, track, home?.lat, home?.lon, myGrid, sel]);
+
+ // ── Render ───────────────────────────────────────────────────────────────
+
+ const tleLabel = !tle ? '—'
+ : tle.count === 0 ? t('sat.tleNone')
+ : t('sat.tleAge', { n: tle.count, h: Math.round(tle.age_h) });
+
+ return (
+
+ {/* Header: what to track, and what we know about the elements. */}
+
+ );
+}
+
+// The corrected frequency large, the nominal one small underneath. Showing only
+// one of them leaves an operator unable to tell a Doppler correction from a
+// mistuned transponder.
+function FreqRow({ label, hz, nominal }: { label: string; hz: number; nominal: number }) {
+ const shift = hz && nominal ? hz - nominal : 0;
+ return (
+
+ );
+}
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx
index 6dd1441..3cf19e8 100644
--- a/frontend/src/lib/i18n.tsx
+++ b/frontend/src/lib/i18n.tsx
@@ -576,6 +576,12 @@ const en: Dict = {
'aud.testPtt': 'Test PTT', 'aud.pttPort': 'PTT COM port', 'aud.pickPort': 'Pick a COM port', 'aud.selectPort': '— select —', 'aud.refresh': 'Refresh',
'aud.msgPlaceholder': 'Message {n} label (CQ, report, 73…)', 'aud.holdRec': '● Hold to rec', 'aud.recordingNow': '● Recording…', 'aud.play': '▶ Play', 'aud.stop': '■ Stop',
'aud.errPttTest': 'PTT test: ', 'aud.errRecord': 'Record: ', 'aud.errSave': 'Save: ', 'aud.errPlay': 'Play: ',
+ 'sat.tab': 'Satellites', 'sat.title': 'Satellites', 'sat.az': 'Azimuth', 'sat.el': 'Elevation', 'sat.down': 'Down', 'sat.up': 'Up',
+ 'sat.nextPasses': 'Next passes', 'sat.noPasses': 'No pass above your minimum elevation in the window — or your locator is not set.', 'sat.now': 'now',
+ 'sat.refreshTle': 'Elements', 'sat.tleAge': '{n} satellites · {h} h old', 'sat.tleNone': 'no elements yet — fetch them', 'sat.tleCustom': '{n} of your own',
+ 'sat.noElements': 'no elements', 'sat.inverting': 'inverting', 'sat.geo': 'geostationary', 'sat.favTip': 'Track this satellite by default',
+ 'sat.paste': 'Paste…', 'sat.pasteTip': 'Paste elements for a satellite no feed carries yet. They are kept in their own file and survive every refresh.',
+ 'sat.pastePrompt': 'Paste the elements (name, then the two lines):', 'sat.pasteNone': 'Nothing usable in that text.',
};
const fr: Dict = {
@@ -1115,6 +1121,12 @@ const fr: Dict = {
'aud.testPtt': 'Tester le PTT', 'aud.pttPort': 'Port COM du PTT', 'aud.pickPort': 'Choisir un port COM', 'aud.selectPort': '— choisir —', 'aud.refresh': 'Actualiser',
'aud.msgPlaceholder': 'Libellé du message {n} (CQ, report, 73…)', 'aud.holdRec': '● Maintenir pour enreg.', 'aud.recordingNow': '● Enregistrement…', 'aud.play': '▶ Lire', 'aud.stop': '■ Arrêter',
'aud.errPttTest': 'Test PTT : ', 'aud.errRecord': 'Enregistrement : ', 'aud.errSave': 'Sauvegarde : ', 'aud.errPlay': 'Lecture : ',
+ 'sat.tab': 'Satellites', 'sat.title': 'Satellites', 'sat.az': 'Azimut', 'sat.el': 'Élévation', 'sat.down': 'Desc.', 'sat.up': 'Mont.',
+ 'sat.nextPasses': 'Prochains passages', 'sat.noPasses': 'Aucun passage au-dessus de votre élévation minimale sur la fenêtre — ou votre locator n’est pas renseigné.', 'sat.now': 'en cours',
+ 'sat.refreshTle': 'Éléments', 'sat.tleAge': '{n} satellites · {h} h', 'sat.tleNone': 'aucun élément — récupérez-les', 'sat.tleCustom': 'dont {n} à vous',
+ 'sat.noElements': 'sans éléments', 'sat.inverting': 'inverseur', 'sat.geo': 'géostationnaire', 'sat.favTip': 'Suivre ce satellite par défaut',
+ 'sat.paste': 'Coller…', 'sat.pasteTip': 'Collez les éléments d’un satellite qu’aucun flux ne diffuse encore. Ils sont conservés dans leur propre fichier et survivent à chaque mise à jour.',
+ 'sat.pastePrompt': 'Collez les éléments (nom, puis les deux lignes) :', 'sat.pasteNone': 'Rien d’utilisable dans ce texte.',
};
const dicts: Record = { en, fr };
diff --git a/frontend/src/lib/uiPref.ts b/frontend/src/lib/uiPref.ts
index b3795a6..39c80c9 100644
--- a/frontend/src/lib/uiPref.ts
+++ b/frontend/src/lib/uiPref.ts
@@ -31,7 +31,7 @@ const PORTABLE_KEYS = [
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
// The same, for the FT decodes map and the grid-square map: a view an
// operator set up is theirs, and it should follow the folder like the rest.
- 'opslog.ftMapView', 'opslog.gridMapView',
+ 'opslog.ftMapView', 'opslog.gridMapView', 'opslog.satMapView',
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
'opslog.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)