PageSourceSearch

https://orbitalradar.com/operatorPanel.js

js orbitalradar.com collected 2026-09-24 18:25:03 UTC 42,855 bytes, 1,169 lines download raw bytes

1// operatorPanel.js
2// Operator selection panel for choosing visible satellite operators, supports select/reset/search.
3import { ORBIT_TYPE_LABELS } from './ui.js';
4
5// ── Object type breakdown from server (SATCAT OBJECT_TYPE) ──
6// Prefers window.typeBreakdown (set by main.js from same API), falls back to own fetch
7let _typeBreakdown = null; // { payload, rocketBody, debris, unknown }
8
9async function _fetchTypeBreakdown() {
10  // If main.js already fetched it, use that
11  if (window.typeBreakdown) {
12    _typeBreakdown = window.typeBreakdown;
13    return;
14  }
15  try {
16    const res = await fetch('/api/stats/tracking');
17    if (!res.ok) return;
18    const data = await res.json();
19    if (data.typeBreakdown) {
20      _typeBreakdown = data.typeBreakdown;
21    }
22  } catch { /* silent — will retry on next interval */ }
23}
24// Fetch on load (slight delay to let main.js set window.typeBreakdown first)
25setTimeout(_fetchTypeBreakdown, 3000);
26setInterval(_fetchTypeBreakdown, 10 * 60 * 1000);
27
28// --- Operator panel styles are now in style.css (no JS injection needed) ---
29function injectOperatorPanelStyles() { /* styles in style.css */ }
30
31export default (function() {
32  let panel, toggleBtn, operatorList = [], selected = new Set(), onChange;
33
34
35// --- SEARCH NORMALIZER & ALIAS DB (UPDATED BLOCK) ---
36function normalize(s) {
37  let out = (s || "").toLowerCase();
38
39  // Canonical replacements to unify common variants
40  out = out
41    .replace(/\bunited states of america\b/g, "usa")
42    .replace(/\bu\.?\s*s\.?\s*a\.?\b/g, "usa")
43    .replace(/\bunited states\b/g, "us")
44    .replace(/\bu\.?\s*s\.?\b/g, "us")
45    .replace(/\bdepartment of defense\b/g, "dod")
46    .replace(/\bministry of defence\b/g, "mod")
47    .replace(/\bpeople'?s? liberation army\b/g, "pla")
48    .replace(/\bnational aeronautics and space administration\b/g, "nasa")
49    .replace(/\bindian space research organisation\b/g, "isro")
50    .replace(/\bchina national space administration\b/g, "cnsa");
51
52  // Strip accents, punctuation, collapse spaces
53  out = out
54    .normalize("NFD").replace(/[\u0300-\u036f]/g, "")
55    .replace(/[^a-z0-9\s]/g, " ")
56    .replace(/\s+/g, " ")
57    .trim();
58
59  return out;
60}
61
62// Aliases map: alias/keyword => array of canonical operator names in your list.
63// TIP: keep operator strings EXACTLY as they appear in your operatorList/allOps
64const OPERATOR_ALIASES_RAW = {
65  // ===== GPS / NAVSTAR (GNSS - USA) =====
66  "gps": [
67    "US Space Force",
68    "United States Space Force",
69    "USAF",
70    "United States Air Force",
71    "United States Department of Defense",
72    "Department of Defense",
73    "DoD",
74    "Navstar",
75    "Navstar GPS"
76  ],
77  "navstar": [
78    "US Space Force",
79    "United States Space Force",
80    "USAF",
81    "United States Air Force",
82    "United States Department of Defense",
83    "Department of Defense",
84    "DoD",
85    "Navstar GPS"
86  ],
87  "usg": [
88    "United States Air Force",
89    "US Space Force",
90    "United States Department of Defense",
91    "Department of Defense",
92    "DoD",
93    "National Reconnaissance Office",
94    "National Aeronautics and Space Administration"
95  ],
96  "dod": [
97    "United States Department of Defense",
98    "Department of Defense",
99    "DoD",
100    "US Space Force",
101    "United States Space Force",
102    "USAF",
103    "United States Air Force"
104  ],
105  "usaf": ["USAF", "United States Air Force"],
106  "ussf": ["US Space Force", "United States Space Force"],
107
108  "gnss": ["United States Air Force", "US Space Force", "European Union", "European Space Agency", "Roscosmos", "Russian Federation", "CNSA", "China National Space Administration", "BeiDou", "ISRO", "Indian Space Research Organisation", "QZSS", "Cabinet Office Japan"],
109  "navigation": ["United States Air Force", "US Space Force", "European Union", "European Space Agency", "Roscosmos", "Russian Federation", "CNSA", "China National Space Administration", "ISRO", "Indian Space Research Organisation", "Cabinet Office Japan"],
110  "satnav": ["United States Air Force", "US Space Force", "European Union", "European Space Agency", "Roscosmos", "Russian Federation", "CNSA", "China National Space Administration", "ISRO", "Indian Space Research Organisation", "Cabinet Office Japan"],
111
112  "galileo": ["European Union", "European Space Agency", "EUSPA", "European Union Agency for the Space Programme"],
113  "euspa": ["European Union", "European Space Agency", "EUSPA", "European Union Agency for the Space Programme"],
114  "glonass": ["Roscosmos", "Russian Federation"],
115  "beidou": ["CNSA", "China National Space Administration"],
116  "bds": ["CNSA", "China National Space Administration"],
117  "compass": ["CNSA", "China National Space Administration"],
118  "irnss": ["ISRO", "Indian Space Research Organisation"],
119  "navic": ["ISRO", "Indian Space Research Organisation"],
120  "qzss": ["Cabinet Office Japan"],
121
122  // SBAS (augmentation)
123  "waas": ["Federal Aviation Administration", "NOAA", "United States Department of Transportation"],
124  "egnos": ["European Union", "European Space Agency", "EUSPA", "European Union Agency for the Space Programme"],
125  "msas": ["Japan Meteorological Agency", "Cabinet Office Japan"],
126  "gagan": ["ISRO", "Indian Space Research Organisation"],
127  "sdcm": ["Roscosmos", "Russian Federation"],
128  "kass": ["Korea Aerospace Research Institute"],
129
130  // ===== LEO mega-constellations / broadband =====
131  "starlink": ["SpaceX"],
132  "spacex": ["SpaceX"],
133  "oneweb": ["OneWeb"],
134
135  // ✅ Kuiper: keep this STRICT so we don't accidentally catch "AMAZONAS" (GEO)
136"kuiper": [
137  "Amazon LEO",
138  "Amazon LEO Systems",
139  "Amazon Kuiper",
140  "Amazon Kuiper Systems LLC",
141  "Project Kuiper"
142],
143"amazon leo": [
144  "Amazon LEO",
145  "Amazon LEO Systems",
146  "Amazon Kuiper",
147  "Amazon Kuiper Systems LLC",
148  "Project Kuiper"
149],
150// ✅ Keep "amazon" STRICT so we don't accidentally catch "AMAZONAS" (GEO)
151"amazon": [
152  "Amazon LEO",
153  "Amazon LEO Systems",
154  "Amazon Kuiper",
155  "Amazon Kuiper Systems LLC",
156  "Project Kuiper"
157],
158
159  "o3b": ["SES"],
160  "mpower": ["SES"],
161
162  // MSS / L-band, satphone, narrowband
163  "iridium": ["Iridium"],
164  "globalstar": ["Globalstar"],
165  "orbcomm": ["ORBCOMM"],
166  "thuraya": ["Thuraya"],
167  "yahsat": ["Yahsat"],
168
169  // GEO FSS/VHTS operators
170  "inmarsat": ["Inmarsat"],
171  "intelsat": ["Intelsat"],
172  "ses": ["SES"],
173  "eutelsat": ["Eutelsat"],
174  "viasat": ["Viasat"],
175  "hughes": ["Hughes Network Systems"],
176  "jupiter": ["Hughes Network Systems"],
177  "telesat": ["Telesat"],
178  "optus": ["Optus"],
179  "arabsat": ["Arabsat"],
180  "nilesat": ["Nilesat"],
181  "hispasat": ["Hispasat"],
182  "hellas": ["Hellas Sat"],
183  "azercosmos": ["Azercosmos"],
184  "turksat": ["Turksat"],
185  "chinasat": ["China Satcom", "ChinaSat"],
186  "apstar": ["APT Satellite"],
187  "jcsat": ["SKY Perfect JSAT"],
188  "superbird": ["SKY Perfect JSAT"],
189  "bsat": ["Broadcasting Satellite System Corporation"],
190  "asiasat": ["AsiaSat"],
191  "kacific": ["Kacific"],
192
193  // ===== Earth Observation (EO), imaging, SAR =====
194  "eo": ["European Space Agency", "CNES", "DLR", "JAXA", "ISRO", "CNSA", "USGS", "NOAA", "Planet", "Maxar", "BlackSky", "Satellogic", "ICEYE", "Capella Space", "GHGSat", "Spire"],
195  "earth observation": ["European Space Agency", "CNES", "DLR", "JAXA", "ISRO", "CNSA", "USGS", "NOAA", "Planet", "Maxar", "BlackSky", "Satellogic", "ICEYE", "Capella Space", "GHGSat", "Spire"],
196  "imaging": ["Maxar", "Planet", "BlackSky", "Satellogic", "Spire", "ICEYE", "Capella Space"],
197  "worldview": ["Maxar"],
198  "geoeye": ["Maxar"],
199  "skysat": ["Planet"],
200  "dove": ["Planet"],
201  "flock": ["Planet"],
202  "sar": ["ICEYE", "Capella Space"],
203  "capella": ["Capella Space"],
204  "iceye": ["ICEYE"],
205  "blacksky": ["BlackSky"],
206  "satellogic": ["Satellogic"],
207  "spire": ["Spire Global"],
208  "ghgsat": ["GHGSat"],
209  "copernicus": ["European Space Agency", "European Union"],
210  "sentinel": ["European Space Agency", "European Union"],
211  "landsat": ["USGS", "NASA"],
212  "modis": ["NASA", "NOAA"],
213
214  // Weather
215  "weather": ["NOAA", "EUMETSAT", "JMA", "CMA", "KMA", "ISRO"],
216  "goes": ["NOAA"],
217  "noaa": ["NOAA"],
218  "meteosat": ["EUMETSAT"],
219  "metop": ["EUMETSAT"],
220  "himawari": ["JMA"],
221  "fy": ["CMA"], "fengyun": ["CMA"],
222  "insat": ["ISRO", "Indian Space Research Organisation"],
223  "gsat": ["ISRO", "Indian Space Research Organisation"],
224
225  // Science & agencies (broad catch)
226  "esa": ["European Space Agency"],
227  "cnsa": ["CNSA", "China National Space Administration"],
228  "cnes": ["CNES"],
229  "dlr": ["DLR"],
230  "jaxa": ["JAXA"],
231  "roscosmos": ["Roscosmos", "Russian Federation"],
232  "nasa": ["National Aeronautics and Space Administration"],
233  "usgs": ["USGS"],
234
235  // Defence / intel (broad)
236  "nro": ["National Reconnaissance Office"],
237  "dod usa": ["United States Air Force", "US Space Force", "National Reconnaissance Office"],
238  "military": ["United States Air Force", "US Space Force", "National Reconnaissance Office", "Roscosmos", "Russian Federation", "Ministry of Defence (UK)", "People's Liberation Army", "CNSA", "China National Space Administration"],
239
240  // Misc popular programs
241  "skynet": ["UK Ministry of Defence", "Ministry of Defence (UK)"],
242  "sicral": ["Italian Ministry of Defence"],
243  "aeolus": ["European Space Agency"],
244  "ocean sat": ["ISRO", "Indian Space Research Organisation"],
245  "hubble": ["National Aeronautics and Space Administration", "European Space Agency"]
246};
247
248
249// Build a robust reverse alias index that tolerates name variants
250function buildAliasIndex(allOps) {
251  const list = Array.isArray(allOps) ? allOps : [];
252
253  // Support list of strings OR objects with .name
254  const names = list
255    .map(o => (typeof o === 'string' ? o : (o && o.name) ? o.name : ''))
256    .filter(Boolean);
257
258  const normNames = names.map(n => ({ raw: n, norm: normalize(n) }));
259
260  // Helper: does opName ~ target by equality OR token containment?
261  function matchesTarget(opNorm, targetRaw) {
262    const t = normalize(targetRaw);
263    if (!t) return false;
264    if (opNorm === t) return true;
265    // token containment: every word in t must exist in opNorm
266    const words = t.split(' ');
267    return words.every(w => opNorm.includes(w));
268  }
269
270  const reverse = new Map(); // alias_normalized -> Set(visibleOperatorName)
271
272  for (const [alias, targets] of Object.entries(OPERATOR_ALIASES_RAW)) {
273    const aliasKey = normalize(alias);
274    for (const target of targets) {
275      for (const { raw, norm } of normNames) {
276        if (matchesTarget(norm, target)) {
277          if (!reverse.has(aliasKey)) reverse.set(aliasKey, new Set());
278          reverse.get(aliasKey).add(raw);
279        }
280      }
281    }
282  }
283  return reverse;
284}
285
286
287function buildPanel(topOps, allOps) {
288  if (!Array.isArray(topOps)) topOps = [];
289  if (!Array.isArray(allOps)) allOps = [];
290
291  injectOperatorPanelStyles();
292
293  operatorList = allOps;
294
295  panel = document.getElementById('operator-panel');
296  if (!panel) {
297    panel = document.createElement('div');
298    panel.id = 'operator-panel';
299    panel.classList.add('panel-main');
300    document.body.appendChild(panel);
301  } else {
302    panel.classList.add('panel-main');
303  }
304
305panel.innerHTML = `
306  <div class="op-header">
307    <span>Operators</span>
308    <button id="op-close-btn" title="Close panel">✕</button>
309  </div>
310
311  <div id="op-impact" class="op-impact"></div>
312
313  <div class="op-search-wrap">
314    <input type="text" id="op-search" placeholder="Search operators…" />
315  </div>
316  <div id="op-search-count" class="op-search-count"></div>
317
318  <div class="op-actions">
319    <button id="op-clear-filter" class="op-action-clear" title="Clear operator filter (show all)">Clear filter</button>
320  </div>
321
322  <div id="op-presets" class="op-presets"></div>
323  <div class="op-hint">Tip: Shift+Click adds/removes operators. Press <span class="op-hint-key">ESC</span> to clear search.</div>
324
325
326  <div id="op-top-list"></div>
327  <div id="op-list"></div>
328  <div class="op-intersite-links" style="margin-top:14px;padding:10px 8px 4px;border-top:1px solid rgba(255,224,96,0.1);">
329    <a href="/satellites-by-operator" class="op-site-link" style="display:block;color:#60eafe;font-size:12.5px;text-decoration:none;margin-bottom:6px;font-family:'JetBrains Mono',monospace;">📊 Full Operator Directory →</a>
330    <a href="/satellites-by-country" class="op-site-link" style="display:block;color:#60eafe;font-size:12.5px;text-decoration:none;margin-bottom:6px;font-family:'JetBrains Mono',monospace;">🌍 Satellites by Country →</a>
331    <a href="/how-many-satellites-in-orbit" class="op-site-link" style="display:block;color:#60eafe;font-size:12.5px;text-decoration:none;font-family:'JetBrains Mono',monospace;">🛰️ How Many Satellites in Orbit? →</a>
332  </div>
333`;
334
335
336  panel.style.display = '';
337
338  // ---- Close button ----
339  panel.querySelector('#op-close-btn').addEventListener('click', () => {
340    panel.classList.remove('visible');
341    const tb = document.getElementById('show-operator-panel-btn');
342    if (tb) tb.classList.remove('glow');
343  });
344
345  // ---- Top operators (now interactive) ----
346  const topList = panel.querySelector('#op-top-list');
347  topList.innerHTML = `
348    <div class="top-operators">Top 7 Operators</div>
349    <div class="top-operator-list"></div>
350  `;
351
352  const topOperatorList = topList.querySelector('.top-operator-list');
353
354  topOps.forEach(op => {
355    const btn = document.createElement('button');
356    btn.type = 'button';
357    btn.className = 'top-operator op-chip';
358    btn.setAttribute('data-operator', op.name);
359    btn.title = 'Click: Solo • Shift+Click: Add/Remove';
360
361    // Mini donut ring showing fleet share
362    const totalSats = topOps.reduce((s, o) => s + (o.count || 0), 0) || 1;
363    const share = Math.max(0.04, Math.min(1, (op.count || 0) / totalSats));
364    const r = 16, stroke = 3.5;
365    const circ = 2 * Math.PI * r;
366    const dashLen = share * circ;
367    const donutSvg = `<svg class="op-donut-ring" width="38" height="38" viewBox="0 0 38 38">
368      <circle cx="19" cy="19" r="${r}" fill="none" stroke="rgba(255,255,255,0.08)" stroke-width="${stroke}"/>
369      <circle cx="19" cy="19" r="${r}" fill="none" stroke="#60eafe" stroke-width="${stroke}"
370        stroke-dasharray="${dashLen.toFixed(1)} ${(circ - dashLen).toFixed(1)}"
371        stroke-dashoffset="${(circ * 0.25).toFixed(1)}" stroke-linecap="round"
372        style="filter:drop-shadow(0 0 3px rgba(96,234,254,0.4));transition:stroke-dasharray 0.5s ease;"/>
373      <text x="19" y="20.5" text-anchor="middle" fill="#ffe060" font-family="Orbitron,JetBrains Mono,monospace" font-size="7.5" font-weight="800">${Math.round(share * 100)}%</text>
374    </svg>`;
375
376    btn.innerHTML = `
377      ${donutSvg}
378      <span class="top-operator-name">${op.name}</span>
379      <span class="badge">${op.count}</span>
380    `;
381
382    btn.addEventListener('click', (ev) => {
383      const name = op.name;
384      if (!name) return;
385
386      if (ev.shiftKey) {
387        if (selected.has(name)) selected.delete(name);
388        else selected.add(name);
389      } else {
390        if (selected.size === 1 && selected.has(name)) {
391          selected.clear(); // clicking same solo clears back to ALL
392        } else {
393          selected.clear();
394          selected.add(name);
395        }
396      }
397
398      renderList();
399      renderImpactStrip();
400      updateUrlSelectedOperators(Array.from(selected));
401      onChange(Array.from(selected));
402    });
403
404    btn.addEventListener('mouseenter', () => showIntelCard(op.name, btn));
405    btn.addEventListener('mouseleave', hideIntelCard);
406
407    topOperatorList.appendChild(btn);
408  });
409
410  // ---- Presets ----
411  const presetsEl = panel.querySelector('#op-presets');
412  if (presetsEl) {
413    const PRESETS = [
414      { key: 'starlink', label: 'Starlink' },
415      { key: 'oneweb', label: 'OneWeb' },
416     { key: 'kuiper', label: 'Amazon LEO' },
417      { key: 'gnss', label: 'GNSS' },
418      { key: 'weather', label: 'Weather' },
419      { key: 'eo', label: 'Earth Obs' },
420    ];
421
422    presetsEl.innerHTML = PRESETS.map(p =>
423      `<button type="button" class="op-preset-btn" data-preset="${p.key}">${p.label}</button>`
424    ).join('');
425
426    presetsEl.querySelectorAll('.op-preset-btn').forEach(btn => {
427   btn.addEventListener('click', (ev) => {
428  const preset = btn.getAttribute('data-preset');
429
430  const ops = getPresetOperators(preset, operatorList);
431  if (ev.shiftKey) ops.forEach(o => selected.add(o));
432  else {
433    selected.clear();
434    ops.forEach(o => selected.add(o));
435  }
436
437  renderList();
438  renderImpactStrip();
439  updateUrlSelectedOperators(Array.from(selected));
440  onChange(Array.from(selected));
441});
442
443    });
444  }
445
446  // ---- Search ----
447  const searchInput = panel.querySelector('#op-search');
448  searchInput.oninput = filterList;
449  
450  // ESC clears search and restores collapse state snapshot
451searchInput.addEventListener('keydown', (ev) => {
452  if (ev.key !== 'Escape') return;
453  if (!searchInput.value) return;
454  ev.preventDefault();
455  searchInput.value = '';
456  filterList({ target: { value: '' } });
457});
458
459
460  // Buttons
461panel.querySelector('#op-clear-filter').onclick = resetAll;
462
463
464  // Intel card singleton
465  ensureIntelCard();
466
467  renderList();
468  renderImpactStrip();
469}
470
471let OPERATOR_ALIAS_INDEX = new Map(); // filled in init()
472
473// --- Intel Card (hover tooltip) ---
474let _opIntelCardEl = null;
475let _opIntelHideTimer = null;
476
477function ensureIntelCard() {
478  if (_opIntelCardEl && document.body.contains(_opIntelCardEl)) return;
479
480  const el = document.createElement('div');
481  el.id = 'operatorIntelCard';
482  el.style.display = 'none';
483  el.style.position = 'fixed';
484  el.style.zIndex = '3000';
485  el.style.width = '330px';
486  el.style.maxWidth = '88vw';
487  el.style.pointerEvents = 'auto';
488
489  // Theme-consistent styling (matches Orbital Radar HUD)
490  el.style.background = 'linear-gradient(120deg, rgba(24,25,41,0.96) 88%, rgba(96,234,254,0.10) 100%)';
491  el.style.border = '1.5px solid rgba(255,224,96,0.55)';
492  el.style.borderRadius = '14px';
493  el.style.boxShadow = '0 10px 34px rgba(0,0,0,0.55), 0 0 0 1px rgba(96,234,254,0.10) inset';
494  el.style.backdropFilter = 'blur(6px)';
495
496  el.innerHTML = `
497    <div style="display:flex;align-items:flex-start;justify-content:space-between;gap:10px;padding:12px 12px 8px 12px;">
498      <div style="min-width:0;">
499        <div id="opIntelTitle" style="font-family:Orbitron,JetBrains Mono,monospace;font-weight:900;letter-spacing:1px;color:#ffe060;font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
500          Operator Intel
501        </div>
502        <div id="opIntelSub" style="margin-top:4px;font-family:JetBrains Mono,monospace;font-size:12px;color:rgba(255,255,255,0.70);">
503          Hover an operator to preview fleet + orbits
504        </div>
505      </div>
506      <button id="opIntelClose" title="Close" style="border:none;background:transparent;color:#ffe060;font-size:18px;line-height:1;cursor:pointer;padding:2px 6px;">×</button>
507    </div>
508
509    <div style="padding:0 12px 12px 12px;">
510      <div id="opIntelStats" style="display:grid;grid-template-columns:1fr;gap:10px;"></div>
511      <div id="opIntelHint" style="margin-top:10px;font-family:JetBrains Mono,monospace;font-size:11.5px;color:rgba(255,224,96,0.72);letter-spacing:0.4px;">
512        Tip: hover top operators for fast intel.
513      </div>
514    </div>
515  `;
516
517  // Don’t hide when moving from the button into the card
518  el.addEventListener('mouseenter', () => {
519    if (_opIntelHideTimer) clearTimeout(_opIntelHideTimer);
520    _opIntelHideTimer = null;
521  });
522  el.addEventListener('mouseleave', () => hideIntelCard());
523
524  el.querySelector('#opIntelClose').onclick = () => {
525    el.style.display = 'none';
526  };
527
528  document.body.appendChild(el);
529  _opIntelCardEl = el;
530}
531
532function showIntelCard(operatorName, anchorEl) {
533  ensureIntelCard();
534  if (!_opIntelCardEl) return;
535
536  if (_opIntelHideTimer) clearTimeout(_opIntelHideTimer);
537  _opIntelHideTimer = null;
538
539  const op = operatorName || 'Unknown';
540
541  // Fleet count (what’s currently in Cesium, not total catalog)
542  const byProvider = window.cesiumSatellitesByProvider || {};
543  const fleet = Array.isArray(byProvider[op]) ? byProvider[op].length : 0;
544
545  // Orbit breakdown if available
546  const orbitCountsByProvider = window.operatorOrbitCountsByProvider || {};
547  const orbit = orbitCountsByProvider[op] || {};
548  const LEO = Number(orbit.LEO || 0);
549  const MEO = Number(orbit.MEO || 0);
550  const GEO = Number(orbit.GEO || 0);
551  const HEO = Number(orbit.HEO || 0);
552  const totalOrbit = LEO + MEO + GEO + HEO;
553
554  const titleEl = _opIntelCardEl.querySelector('#opIntelTitle');
555  const subEl = _opIntelCardEl.querySelector('#opIntelSub');
556  const statsEl = _opIntelCardEl.querySelector('#opIntelStats');
557
558  titleEl.textContent = op;
559  subEl.textContent = `${fleet.toLocaleString()} objects currently rendered`;
560
561  const pct = (n) => totalOrbit ? Math.round((n / totalOrbit) * 100) : 0;
562
563  // Build orbit chips (only show non-zero, but still render an empty state)
564  const orbitRows = [
565    { k: 'LEO', v: LEO },
566    { k: 'MEO', v: MEO },
567    { k: 'GEO', v: GEO },
568    { k: 'HEO', v: HEO },
569  ].filter(x => x.v > 0);
570
571  const orbitHtml = orbitRows.length ? orbitRows.map(x => `
572    <div style="display:flex;align-items:center;justify-content:space-between;gap:10px;">
573      <div style="font-family:JetBrains Mono,monospace;font-size:12px;color:rgba(255,255,255,0.85);">${x.k}</div>
574      <div style="display:flex;align-items:center;gap:10px;">
575        <div style="width:120px;height:8px;border-radius:999px;background:rgba(255,255,255,0.10);overflow:hidden;">
576          <div style="height:100%;width:${pct(x.v)}%;background:rgba(96,234,254,0.85);box-shadow:0 0 10px rgba(96,234,254,0.25);"></div>
577        </div>
578        <div style="font-family:JetBrains Mono,monospace;font-size:12px;color:#ffe060;min-width:58px;text-align:right;">
579          ${x.v.toLocaleString()} (${pct(x.v)}%)
580        </div>
581      </div>
582    </div>
583  `).join('') : `
584    <div style="font-family:JetBrains Mono,monospace;font-size:12px;color:rgba(255,255,255,0.55);">
585      Orbit breakdown unavailable for this operator.
586    </div>
587  `;
588
589  statsEl.innerHTML = `
590    <div style="padding:10px 10px;border-radius:12px;border:1px solid rgba(255,224,96,0.18);background:rgba(10,14,28,0.55);">
591      <div style="font-family:Orbitron,JetBrains Mono,monospace;font-weight:900;letter-spacing:0.9px;color:#60eafe;font-size:12px;margin-bottom:8px;">
592        ORBIT DISTRIBUTION
593      </div>
594      <div style="display:flex;flex-direction:column;gap:8px;">
595        ${orbitHtml}
596      </div>
597    </div>
598  `;
599
600  // Position the card near the hovered operator, biased to the LEFT of the panel
601  const cardW = 330;
602  const margin = 12;
603
604  const a = anchorEl?.getBoundingClientRect?.() || { top: 160, left: window.innerWidth - 360, right: window.innerWidth - 40, width: 320, height: 28 };
605  const p = panel?.getBoundingClientRect?.() || { left: window.innerWidth - 420, right: window.innerWidth - 20, top: 110, bottom: window.innerHeight - 20 };
606
607  let left = (p.left - cardW - margin);
608  if (left < 8) left = (p.right + margin); // fallback to right side if no room
609
610  let top = Math.min(Math.max(a.top - 10, 10), window.innerHeight - 240);
611  _opIntelCardEl.style.left = `${Math.round(left)}px`;
612  _opIntelCardEl.style.top = `${Math.round(top)}px`;
613  _opIntelCardEl.style.display = 'block';
614}
615
616function hideIntelCard() {
617  if (!_opIntelCardEl) return;
618  if (_opIntelHideTimer) clearTimeout(_opIntelHideTimer);
619
620  _opIntelHideTimer = setTimeout(() => {
621    if (_opIntelCardEl) _opIntelCardEl.style.display = 'none';
622  }, 120);
623}
624
625
626
627// --- URL share + presets + impact helpers ---
628function getUrlSelectedOperators(allOps) {
629  try {
630    const url = new URL(window.location.href);
631    const ops = (url.searchParams.get('ops') || '').trim();
632    if (!ops) return null;
633
634    const decoded = ops.split(',').map(s =>
634 decodeURIComponent(s.trim())).filter(Boolean);
635    const valid = decoded.filter(op => allOps.includes(op));
636    return valid.length ? valid : null;
637  } catch (e) {
638    return null;
639  }
640}
641
642function updateUrlSelectedOperators(selectedOps) {
643  try {
644    const url = new URL(window.location.href);
645    if (!selectedOps || !selectedOps.length) {
646      url.searchParams.delete('ops');
647    } else {
648      const sorted = [...selectedOps].sort((a, b) => a.localeCompare(b));
649      url.searchParams.set('ops', sorted.map(s => encodeURIComponent(s)).join(','));
650    }
651    window.history.replaceState({}, '', url.toString());
652  } catch (e) {}
653}
654
655function getPresetOperators(presetKey, allOps) {
656  const key = String(presetKey || '').toLowerCase().trim();
657  const out = new Set();
658
659const aliasKeys = {
660  starlink: ['starlink', 'spacex'],
661  oneweb: ['oneweb'],
662kuiper: ['amazon leo', 'kuiper'],
663  'guowang-qianfan': ['qianfan', 'spacesail', 'guowang', 'china satnet', 'hulianwang'],
664  qianfan: ['qianfan', 'spacesail'],
665  guowang: ['guowang', 'china satnet', 'hulianwang'],
666  china: ['qianfan', 'spacesail', 'guowang', 'china satnet', 'hulianwang', 'beidou', 'gaofen', 'yaogan', 'jilin', 'zhongxing', 'chinasat', 'apstar', 'casc', 'cnsa', 'fengyun', 'shijian', 'shiyan', 'tianhui', 'yunhai', 'haiyang', 'tianlian', 'tiantong', 'geespace', 'geely', 'galaxy space', 'centispace', 'piesat'],
667  gnss: ['gnss', 'gps', 'galileo', 'glonass', 'beidou', 'navic', 'qzss'],
668  weather: ['weather', 'noaa', 'eumetsat', 'jma', 'cma', 'kma'],
669  eo: ['eo', 'earth observation', 'imaging', 'sar', 'copernicus', 'sentinel', 'landsat', 'planet', 'maxar', 'blacksky', 'iceye', 'capella', 'spire'],
670  gps: ['gps', 'navstar'],
671  galileo: ['galileo'],
672  beidou: ['beidou', 'compass'],
673};
674
675
676  const tryAliases = (aliases) => {
677    (aliases || []).forEach(a => {
678      const aNorm = normalize(a);
679      for (const [aliasKey, targets] of OPERATOR_ALIAS_INDEX.entries()) {
680        if (aliasKey === aNorm || aliasKey.startsWith(aNorm) || aNorm.startsWith(aliasKey)) {
681          targets.forEach(t => out.add(t));
682        }
683      }
684    });
685  };
686
687  if (aliasKeys[key]) tryAliases(aliasKeys[key]);
688
689  // fallback (keeps presets working even if alias index is imperfect)
690  if (key === 'starlink') allOps.filter(o => /spacex|starlink/i.test(o)).forEach(o => out.add(o));
691  if (key === 'oneweb') allOps.filter(o => /oneweb/i.test(o)).forEach(o => out.add(o));
692  if (key === 'kuiper') {
693  allOps
694    .filter(o => /\bkuiper\b|\bproject kuiper\b|\bamazon leo\b/i.test(o))
695    .forEach(o => out.add(o));
696}
697  if (key === 'gps') allOps.filter(o => /navstar|\bgps\b/i.test(o)).forEach(o => out.add(o));
698  if (key === 'beidou') allOps.filter(o => /beidou|compass/i.test(o)).forEach(o => out.add(o));
699  if (key === 'guowang-qianfan') allOps.filter(o => /qianfan|spacesail|guowang|satnet|hulianwang/i.test(o)).forEach(o => out.add(o));
700  if (key === 'qianfan') allOps.filter(o => /qianfan|spacesail/i.test(o)).forEach(o => out.add(o));
701  if (key === 'guowang') allOps.filter(o => /guowang|satnet|hulianwang/i.test(o)).forEach(o => out.add(o));
702  // 'china' preset (/china hub globe CTA): every Chinese operator / name-family
703  // bucket the panel derives — mirrors the server-side programme matchers.
704  if (key === 'china') allOps.filter(o => /qianfan|spacesail|guowang|satnet|hulianwang|beidou|gaofen|yaogan|jilin|zhongxing|chinasat|apstar|\bcasc\b|cnsa|fengyun|shijian|shiyan|tianhui|yunhai|haiyang|tianlian|tiantong|geespace|geely|galaxy space|centispace|piesat|china/i.test(o)).forEach(o => out.add(o));
705
706
707  return Array.from(out).filter(o => allOps.includes(o));
708}
709
710function computeImpact(selectedOps) {
711  const sel = (selectedOps && selectedOps.length) ? new Set(selectedOps) : null;
712
713  const byProvider = window.cesiumSatellitesByProvider || {};
714  const orbitCountsByProvider = window.operatorOrbitCountsByProvider || null;
715
716  let satsVisible = 0;
717  let satsTotal = 0;
718
719  const orbitMix = { LEO: 0, MEO: 0, GEO: 0, HEO: 0 };
720
721  for (const [provider, arr] of Object.entries(byProvider)) {
722    const n = Array.isArray(arr) ? arr.length : 0;
723    satsTotal += n;
724
725    const included = !sel || sel.has(provider);
726    if (!included) continue;
727
728    satsVisible += n;
729
730    if (orbitCountsByProvider && orbitCountsByProvider[provider]) {
731      const oc = orbitCountsByProvider[provider];
732      for (const k of Object.keys(orbitMix)) orbitMix[k] += (oc[k] || 0);
733    } else {
734      const topOrbit = (window.operatorOrbitMap && window.operatorOrbitMap[provider]) ? window.operatorOrbitMap[provider] : 'LEO';
735      orbitMix[topOrbit] = (orbitMix[topOrbit] || 0) + n;
736    }
737  }
738
739  // ── Use OBJECT_TYPE breakdown from server (SATCAT classification) ──
740  // This properly counts PAY vs DEB vs R/B from the authoritative SATCAT data
741  let debrisCount = 0;
742  let rocketBodyCount = 0;
743  const satOnlyVisible = satsVisible;
744
745  if (_typeBreakdown) {
746    debrisCount = (_typeBreakdown.debris || 0);
747    rocketBodyCount = (_typeBreakdown.rocketBody || 0);
748    if (window.trackingTotalFromDB > 0) {
749      satsTotal = window.trackingTotalFromDB;
750      satsVisible = satsTotal; // all are visible when no filter
751    }
752  } else if (window.trackingTotalFromDB > 0) {
753    // Fallback: old subtraction method if type breakdown not yet loaded
754    satsTotal = window.trackingTotalFromDB;
755    const totalSatCount = Object.values(byProvider).reduce((s, a) => s + (Array.isArray(a) ? a.length : 0), 0);
756    debrisCount = Math.max(0, satsTotal - totalSatCount);
757    satsVisible += debrisCount;
758  }
759  const pct = satsTotal ? Math.round((satsVisible / satsTotal) * 100) : 0;
760  return { satsVisible, satsTotal, pct, orbitMix, debrisCount, rocketBodyCount, satOnlyVisible };
761}
762
763let _lastImpactVisible = -1;
764
765function animateCountUp(el, target, duration = 600) {
766  const start = parseInt(el.textContent.replace(/,/g, ''), 10) || 0;
767  if (start === target) return;
768  el.classList.add('op-counting');
769  const startTime = performance.now();
770  function tick(now) {
771    const t = Math.min(1, (now - startTime) / duration);
772    const eased = 1 - Math.pow(1 - t, 3); // ease-out cubic
773    const cur = Math.round(start + (target - start) * eased);
774    el.textContent = cur.toLocaleString();
775    if (t < 1) requestAnimationFrame(tick);
776    else {
777      el.classList.remove('op-counting');
778      el.classList.add('op-glow');
779      setTimeout(() => el.classList.remove('op-glow'), 650);
780    }
781  }
782  requestAnimationFrame(tick);
783}
784
785function renderImpactStrip() {
786  const el = panel && panel.querySelector('#op-impact');
787  if (!el) return;
788
789  const selectedOps = Array.from(selected || []);
790  const impact = computeImpact(selectedOps);
791
792  const totalMix = (impact.orbitMix.LEO + impact.orbitMix.MEO + impact.orbitMix.GEO + impact.orbitMix.HEO) || 0;
793  const barSeg = (k) => {
794    const v = impact.orbitMix[k] || 0;
795    const w = totalMix ? Math.max(2, Math.round((v / totalMix) * 100)) : 0;
796    return `<span class="op-impact-bar-seg op-impact-${k.toLowerCase()}" style="width:${w}%" title="${k}: ${v.toLocaleString()}"></span>`;
797  };
798
799  // Build type breakdown line (satellites · rocket bodies · debris)
800  const hasBrk = _typeBreakdown && (impact.debrisCount > 0 || impact.rocketBodyCount > 0);
801  let debrisLine = '';
802  if (hasBrk) {
803    const payCount = (_typeBreakdown.payload || 0) + (_typeBreakdown.unknown || 0);
804    const parts = [
805      `<span class="op-brk-sats">${payCount.toLocaleString()} satellites</span>`
806    ];
807    if (impact.rocketBodyCount > 0) {
808      parts.push(`<span class="op-brk-sep">·</span><span class="op-brk-rb">${impact.rocketBodyCount.toLocaleString()} rocket bodies</span>`);
809    }
810    if (impact.debrisCount > 0) {
811      parts.push(`<span class="op-brk-sep">·</span><span class="op-brk-debris">${impact.debrisCount.toLocaleString()} debris</span>`);
812    }
813    debrisLine = `<div class="op-impact-breakdown">${parts.join('')}</div>`;
814  } else if (impact.debrisCount > 0) {
815    // Fallback: old style if type breakdown not loaded
816    debrisLine = `<div class="op-impact-breakdown">
817        <span class="op-brk-sats">${impact.satOnlyVisible.toLocaleString()} satellites</span>
818        <span class="op-brk-sep">·</span>
819        <span class="op-brk-debris">${impact.debrisCount.toLocaleString()} debris</span>
820      </div>`;
821  }
822
823  el.innerHTML = `
824    <div class="op-impact-row">
825      <span class="op-impact-label">Tracking</span>
826      <span class="op-impact-value" id="op-impact-count">${_lastImpactVisible >= 0 ? _lastImpactVisible.toLocaleString() : impact.satsVisible.toLocaleString()}</span>
827      <span class="op-impact-sub">of ${impact.satsTotal.toLocaleString()} objects</span>
828    </div>
829    ${debrisLine}
830
831    <div class="op-impact-mix">
832      <div class="op-impact-mix-label">Orbit mix</div>
833      <div class="op-impact-bar">
834        ${barSeg('LEO')}${barSeg('MEO')}${barSeg('GEO')}${barSeg('HEO')}
835      </div>
836      <div class="op-impact-legend">
837        <span><i class="op-dot leo"></i>LEO</span>
838        <span><i class="op-dot meo"></i>MEO</span>
839        <span><i class="op-dot geo"></i>GEO</span>
840        <span><i class="op-dot heo"></i>HEO</span>
841      </div>
842    </div>
843  `;
844
845  // Animate the count
846  const countEl = el.querySelector('#op-impact-count');
847  if (countEl && impact.satsVisible !== _lastImpactVisible) {
848    animateCountUp(countEl, impact.satsVisible);
849  }
850  _lastImpactVisible = impact.satsVisible;
851
852  // Keep badge & chip glow in sync
853  updateFilterBadge();
854  updateChipGlow();
855}
856
857
858
859// --- Active filter badge on sidebar button ---
860function updateFilterBadge() {
861  const btn = document.getElementById('show-operator-panel-btn');
862  if (!btn) return;
863  // Ensure button can host the badge
864  if (getComputedStyle(btn).position === 'static') btn.style.position = 'relative';
865  let badge = btn.querySelector('.op-filter-badge');
866  if (selected.size > 0) {
867    if (!badge) {
868      badge = document.createElement('span');
869      badge.className = 'op-filter-badge';
870      btn.appendChild(badge);
871    }
872    badge.textContent = String(selected.size);
873  } else if (badge) {
874    badge.remove();
875  }
876}
877
878// --- Top operator chip selected glow ---
879function updateChipGlow() {
880  if (!panel) return;
881  panel.querySelectorAll('.top-operator.op-chip').forEach(btn => {
882    const name = btn.getAttribute('data-operator');
883    if (name && selected.has(name)) {
884      btn.classList.add('op-selected');
885    } else {
886      btn.classList.remove('op-selected');
887    }
888  });
889}
890
891function renderList() {
892  const container = panel.querySelector('#op-list');
893  container.innerHTML = '';
894
895  // Always render these orbit buckets (even if empty)
896  const ORBIT_ORDER = ['LEO', 'MEO', 'GEO', 'HEO'];
897
898  // Build grouped operators (default empty arrays so headings always render)
899  const grouped = Object.fromEntries(ORBIT_ORDER.map(k => [k, []]));
900
901  operatorList.forEach(op => {
902    const orbit = window.operatorOrbitMap?.[op] || 'LEO'; // Fallback LEO
903    const key = grouped[orbit] ? orbit : 'LEO';           // Safety fallback
904    grouped[key].push(op);
905  });
906
907  // Render groups in fixed order
908  ORBIT_ORDER.forEach(orbit => {
909    const section = document.createElement('div');
910    section.className = 'op-group';
911
912    // Compute counts for this orbit group
913    const opsInGroup = grouped[orbit] || [];
914    const opCount = opsInGroup.length;
915    const byProvider = window.cesiumSatellitesByProvider || {};
916    const orbitObjCount = opsInGroup.reduce((sum, opName) => {
917      const arr = byProvider[opName];
918      return sum + (Array.isArray(arr) ? arr.length : 0);
919    }, 0);
920
921    section.innerHTML = `
922      <div class="op-group-header" data-orbit="${orbit}">
923        <span>${orbit} — ${ORBIT_TYPE_LABELS[orbit] || orbit}<span class="op-group-stats">${opCount} operators · ${orbitObjCount.toLocaleString()} objects</span></span>
924        <button class="op-group-toggle" data-orbit="${orbit}">▼</button>
925      </div>
926      <div class="op-group-list" data-orbit="${orbit}"></div>
927    `;
928
929    const list = section.querySelector('.op-group-list');
930    const ops = grouped[orbit] || [];
931
932    if (!ops.length) {
933      const empty = document.createElement('div');
934      empty.style.cssText = 'padding:6px 10px;color:rgba(255,255,255,0.55);font-size:13px;';
935      empty.textContent = `No operators currently classified as ${orbit}.`;
936      list.appendChild(empty);
937    } else {
938      ops.forEach(op => {
939        const checked = selected.has(op) ? 'checked' : '';
940        const item = document.createElement('label');
941        item.style.display = 'block';
942        item.setAttribute('data-operator', op.toLowerCase());
943        item.innerHTML = `
944          <input type="checkbox" value="${op}" ${checked} />
945          ${op}
946        `;
947        item.querySelector('input').onchange = onCheckbox;
948        list.appendChild(item);
949      });
950    }
951
952    container.appendChild(section);
953
954    // Collapse by default except LEO
955    if (orbit !== 'LEO') list.style.display = 'none';
956    section.querySelector('.op-group-toggle').onclick = function() {
957      list.style.display = list.style.display === 'none' ? '' : 'none';
958    };
959  });
960
961  // OPTIONAL: re-apply current search term after rendering
962  const searchTerm = panel.querySelector('#op-search').value.toLowerCase();
963  if (searchTerm) filterList({ target: { value: searchTerm } });
964}
965
966
967let _searchWasActive = false;
968let _collapseSnapshot = {};
969
970function filterList(e) {
971  const raw = (e && e.target && e.target.value) || "";
972  const query = normalize(raw);
973  const terms = query.split(" ").filter(Boolean);
974
975  const groupLists = Array.from(panel.querySelectorAll('.op-group-list'));
976  const groupHeaders = Array.from(panel.querySelectorAll('.op-group-header'));
977
978  if (terms.length && !_searchWasActive) {
979    _searchWasActive = true;
980    _collapseSnapshot = {};
981    groupLists.forEach(gl => {
982      const orbit = gl.getAttribute('data-orbit');
983      _collapseSnapshot[orbit] = (gl.style.display === 'none') ? 'none' : '';
984    });
985  }
986
987  if (!terms.length) {
988    panel.querySelectorAll('#op-list label').forEach(lbl => lbl.style.display = '');
989
990    if (_searchWasActive) {
991      groupLists.forEach(gl => {
992        const orbit = gl.getAttribute('data-orbit');
993        if (orbit && orbit in _collapseSnapshot) gl.style.display = _collapseSnapshot[orbit];
994      });
995
996      groupHeaders.forEach(h => {
997        const badge = h.querySelector('.op-group-match');
998        if (badge) badge.remove();
999      });
1000
1001      _searchWasActive = false;
1002      _collapseSnapshot = {};
1003    }
1004    updateSearchCount();
1005    return;
1006  }
1007
1008  panel.querySelectorAll('#op-list label').forEach(lbl => {
1009    const opName = lbl.querySelector('.op-item-name')?.textContent?.trim() || lbl.textContent.trim();
1010    const opNorm = normalize(opName);
1011    const tokens = new Set(opNorm.split(" "));
1012
1013    const matchesAll = terms.every(t =>
1014      opNorm.includes(t) ||
1015      Array.from(tokens).some(tok => tok.startsWith(t)) ||
1016      (() => {
1017        for (const [aliasKey, targets] of OPERATOR_ALIAS_INDEX.entries()) {
1018          if (aliasKey === t || aliasKey.startsWith(t)) {
1019            if (targets.has(opName)) return true;
1020          }
1021        }
1022        return false;
1023      })()
1024    );
1025
1026    lbl.style.display = matchesAll ? '' : 'none';
1027  });
1028
1029  groupLists.forEach(gl => {
1030    const orbit = gl.getAttribute('data-orbit');
1031    const visibleCount = Array.from(gl.querySelectorAll('label')).filter(l => l.style.display !== 'none').length;
1032
1033    const header = panel.querySelector(`.op-group-header[data-orbit="${orbit}"]`);
1034    if (header) {
1035      let badge = header.querySelector('.op-group-match');
1036      if (!badge) {
1037        badge = document.createElement('span');
1038        badge.className = 'op-group-match';
1039        header.appendChild(badge);
1040      }
1041      badge.textContent = String(visibleCount);
1042    }
1043
1044    gl.style.display = visibleCount > 0 ? '' : 'none';
1045  });
1046
1047  // Update search results count
1048  updateSearchCount();
1049}
1050
1051function updateSearchCount() {
1052  const countEl = panel && panel.querySelector('#op-search-count');
1053  if (!countEl) return;
1054  const searchVal = (panel.querySelector('#op-search')?.value || '').trim();
1055  if (!searchVal) {
1056    countEl.textContent = '';
1057    return;
1058  }
1059  const allLabels = panel.querySelectorAll('#op-list label');
1060  const visible = Array.from(allLabels).filter(l => l.style.display !== 'none').length;
1061  const total = allLabels.length;
1062  countEl.textContent = `${visible} of ${total} operators match`;
1063}
1064
1065
1066 function onCheckbox(e) {
1067  const val = e.target.value;
1068  if (e.target.checked) selected.add(val);
1069  else selected.delete(val);
1070
1071  renderImpactStrip();
1072  updateUrlSelectedOperators(Array.from(selected));
1073  onChange(Array.from(selected));
1074}
1075
1076function selectAll() {
1077  operatorList.forEach(op => selected.add(op));
1078  renderList();
1079  renderImpactStrip();
1080  updateUrlSelectedOperators(Array.from(selected));
1081  onChange(Array.from(selected));
1082}
1083
1084function resetAll() {
1085  selected.clear();
1086  renderList();
1087  renderImpactStrip();
1088  updateUrlSelectedOperators([]);
1089  onChange([]);
1090}
1091
1092// --- Public helpers for legend / external UI ---
1093function setSelectedOperators(nextSelected) {
1094  const arr = Array.isArray(nextSelected) ? nextSelected : [];
1095  const valid = arr.filter(o => operatorList.includes(o));
1096  selected = new Set(valid);
1097  renderList();
1098  renderImpactStrip();
1099  updateUrlSelectedOperators(Array.from(selected));
1100  onChange(Array.from(selected));
1101}
1102
1103function applyPreset(presetKey) {
1104  const ops = getPresetOperators(presetKey, operatorList);
1105  setSelectedOperators(ops);
1106  return ops;
1107}
1108
1109function isPresetActive(presetKey) {
1110  const ops = getPresetOperators(presetKey, operatorList);
1111  if (!ops || !ops.length) return false;
1112  if (selected.size !== ops.length) return false;
1113  return ops.every(o => selected.has(o));
1114}
1115
1116function togglePreset(presetKey) {
1117  if (isPresetActive(presetKey)) {
1118    resetAll();
1119    return false;
1120  }
1121  applyPreset(presetKey);
1122  return true;
1123}
1124
1125
1126
1127  // No need for show/hide as public methods anymore (handled by toggle)
1128
1129function init({ allOps, topOps, initialSelected }, changeCb) {
1130  onChange = changeCb;
1131
1132  operatorList = Array.isArray(allOps) ? allOps : [];     // ← guard
1133  OPERATOR_ALIAS_INDEX = buildAliasIndex(operatorList);   // ← pass guarded list
1134const urlSelected = getUrlSelectedOperators(operatorList);
1135selected = new Set((urlSelected && urlSelected.length) ? urlSelected : (initialSelected || []));
1136buildPanel(topOps, operatorList);
1137updateUrlSelectedOperators(Array.from(selected));
1138
1139
1140    // Poll for toggle button until available
1141    const interval = setInterval(() => {
1142      toggleBtn = document.getElementById('show-operator-panel-btn');
1143      if (toggleBtn) {
1144        toggleBtn.onclick = function () {
1145          if (panel.classList.contains('visible')) {
1146            panel.classList.remove('visible');
1147            toggleBtn.classList.remove('glow');
1148          } else {
1149            if (typeof window.closeAllPanels === 'function') window.closeAllPanels();
1150            panel.classList.add('visible');
1151            toggleBtn.classList.add('glow');
1152          }
1153        };
1154        clearInterval(interval);
1155      }
1156    }, 100);
1157  }
1158
1159  return {
1160  init,
1161  setSelectedOperators,
1162  applyPreset,
1163  togglePreset,
1164  isPresetActive
1165};
1166
1167})();
1168
1169// Click-outside-to-close now handled by unified handler in filterNav.js

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.