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. */} +
+ + + {(bird?.transponders?.length ?? 0) > 1 && ( + + )} + {bird && ( + + )} +
+ + {tleLabel}{tle?.custom ? ` · ${t('sat.tleCustom', { n: tle.custom })}` : ''} + + + + +
+ + {err &&
{err}
} + +
+ {/* The map. */} +
+
+
+ + {/* The readout and the pass list. */} +
+
+
+ {bird?.name ?? '—'} + {tp?.label ?? ''} +
+ +
+ + +
+ +
+ + +
+ +
+ {tuning && tuning.range_km > 0 && {Math.round(tuning.range_km)} km} + {tuning && {tuning.range_rate > 0 ? '+' : ''}{tuning.range_rate.toFixed(2)} km/s} + {!!tp?.ctcss && CTCSS {tp.ctcss.toFixed(1)}} + {tp?.inverting && {t('sat.inverting')}} + {bird?.geostationary && {t('sat.geo')}} +
+
+ +
+
+ {t('sat.nextPasses')} +
+
+ {passes.length === 0 && ( +
{t('sat.noPasses')}
+ )} + {passes.map((p, i) => { + const mins = inMin(p.aos); + const now = mins <= 0 && Date.parse(p.los) > Date.now(); + return ( + + ); + })} +
+
+
+
+
+ ); +} + +function Readout({ label, value, colour }: { label: string; value: string; colour?: string }) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +// 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 ( +
+ {label} + {fmtHz(hz)} + {!!shift && ( + + {shift > 0 ? '+' : '−'}{Math.abs(Math.round(shift))} Hz + + )} +
+ ); +} 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)