PageSourceSearch

https://spaceethics.vercel.app/src/js/ui/infobox.js

js spaceethics.vercel.app collected 2026-10-03 07:47:02 UTC 35,707 bytes, 845 lines download raw bytes

1// ui/infobox.js — tooltip + multi-selection infoboxes with SVG connectors
2//
3// Desktop: click → ring + infobox on screen edge + dashed connector line
4// Mobile:  tap   → ring + name label near ring + "Object Info" CTA above nav
5//                  CTA tap → centered infobox; second CTA tap → close infobox
6
7import { THREE } from '../modules/sim.js';
8import { isMobile } from '../utils.js';
9import { findLifecycle, getActivePhase, REGISTRY } from '../modules/lifecycleRegistry.js';
10import { setHcocatSelection } from '../modules/hcocat.js';
11import { FIELD } from '../types/dataCard.js';
12import { matchSpacecraftModel, mountSpacecraftPlayer } from './spacecraftPlayer.js';
13import { fetchSatCat } from '../service/fetchGCAT.js';
14import { jumpToDate } from './controls.js';
15import { fadeFlash } from '../modules/animationBus.js';
16import { getDateMs } from '../modules/simClock.js';
17
18// ─── Iframe info overlay ─────────────────────────────────────────────────────
19const _iframeOverlay = document.createElement('div');
20Object.assign(_iframeOverlay.style, {
21  position: 'fixed', inset: '0', zIndex: '2000',
22  background: 'rgba(0,0,0,0.85)',
23  display: 'none',
24  alignItems: 'center', justifyContent: 'center',
25  flexDirection: 'column',
26});
27document.body.appendChild(_iframeOverlay);
28
29function _showIframe(url, title) {
30  _iframeOverlay.innerHTML = '';
31  _iframeOverlay.style.display = 'flex';
32
33  // Container
34  const box = document.createElement('div');
35  Object.assign(box.style, {
36    background: '#1a1a2e', borderRadius: '10px',
37    width: 'min(900px, 94vw)', height: 'min(680px, 88vh)',
38    display: 'flex', flexDirection: 'column',
39    overflow: 'hidden', boxShadow: '0 8px 40px rgba(0,0,0,0.8)',
40    position: 'relative',
41  });
42
43  // Header
44  const hdr = document.createElement('div');
45  hdr.style.cssText =
46    'display:flex;align-items:center;padding:10px 14px;gap:10px;' +
47    'background:rgba(255,255,255,0.06);border-bottom:1px solid rgba(255,255,255,0.1);';
48
49  const titleEl = document.createElement('span');
50  titleEl.textContent = title || url;
51  titleEl.style.cssText = 'flex:1;font-size:13px;color:#ccc;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
52
53  const newTabBtn = document.createElement('a');
54  newTabBtn.href = url; newTabBtn.target = '_blank';
55  newTabBtn.textContent = '↗ Open in new tab';
56  newTabBtn.style.cssText = 'color:#7ab;font-size:12px;text-decoration:none;white-space:nowrap;flex-shrink:0;';
57
58  const closeBtn = document.createElement('button');
59  closeBtn.textContent = '✕';
60  closeBtn.style.cssText =
61    'background:none;border:none;color:#aaa;font-size:18px;cursor:pointer;padding:0 4px;flex-shrink:0;';
62  closeBtn.addEventListener('click', () => { _iframeOverlay.style.display = 'none'; });
63
64  hdr.append(titleEl, newTabBtn, closeBtn);
65
66  // Iframe
67  const iframe = document.createElement('iframe');
68  iframe.src = url;
69  iframe.style.cssText = 'flex:1;border:none;background:#111;';
70  iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups');
71
72  // Loading indicator — hidden on load, replaced with fallback on timeout
73  const loading = document.createElement('div');
74  loading.style.cssText =
75    'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);' +
76    'color:#888;font-size:14px;text-align:center;line-height:1.8;pointer-events:none;';
77  loading.innerHTML = 'Loading…';
78
79  // Some sites block embedding (X-Frame-Options / CSP). After 5 s without a
80  // successful load, show a "can't embed" message with a direct link.
81  const _fallbackTimer = setTimeout(() => {
82    if (loading.style.display === 'none') return; // already loaded
83    loading.style.pointerEvents = 'auto';
84    loading.innerHTML =
85      `<div style="color:#aaa;margin-bottom:10px;">This page can't be shown here<br>(blocked by the source site)</div>` +
86      `<a href="${url}" target="_blank" rel="noopener"
87          style="color:#7ab;font-size:13px;text-decoration:underline;">Open in new tab ↗</a>`;
88  }, 5000);
89
90  iframe.addEventListener('load', () => {
91    clearTimeout(_fallbackTimer);
92    // Try to detect a blank/error document (X-Frame-Options silent block)
93    try {
94      const title = iframe.contentDocument?.title ?? '';
95      if (title === '' || /error|blocked|denied/i.test(title)) throw new Error('blocked');
96      loading.style.display = 'none';
97    } catch (_) {
98      // Cross-origin or blocked — show fallback immediately
99      clearTimeout(_fallbackTimer);
100      loading.style.pointerEvents = 'auto';
101      loading.innerHTML =
102        `<div style="color:#aaa;margin-bottom:10px;">This page can't be shown here<br>(blocked by the source site)</div>` +
103        `<a href="${url}" target="_blank" rel="noopener"
104            style="color:#7ab;font-size:13px;text-decoration:underline;">Open in new tab ↗</a>`;
105    }
106  });
107
108  box.append(hdr, iframe, loading);
109  _iframeOverlay.appendChild(box);
110}
111
112// Close on backdrop click
113_iframeOverlay.addEventListener('click', e => {
114  if (e.target === _iframeOverlay) _iframeOverlay.style.display = 'none';
115});
116
117const ACCENT     = 'rgba(100, 160, 240, 0.8)';
118
119// Returns a CSS colour string from ud._accentColor (0xRRGGBB integer) or ACCENT.
120function _accentCss(ud) {
121  const hex = ud?._accentColor;
122  if (typeof hex === 'number') {
123    const r = (hex >> 16) & 0xff;
124    const g = (hex >>  8) & 0xff;
125    const b =  hex        & 0xff;
126    return `rgba(${r},${g},${b},0.85)`;
127  }
128  return ACCENT;
129}
130const BOX_WIDTH  = 280;
131const RIGHT_PAD  = 16;
132const LEFT_PAD   = 16;
133const TOP_START  = 16;
134const BOX_GAP    = 10;
135const TOOLTIP_W  = 240;
136const BOT_MARGIN = 90;
137
138// ─── Hover tooltip (desktop only) ────────────────────────────────────────────
139const _tooltip = document.createElement('div');
140_tooltip.className = 'se-tooltip';
141Object.assign(_tooltip.style, {
142  position     : 'fixed',
143  pointerEvents: 'none',
144  zIndex       : '1001',
145  background   : 'rgba(8,14,30,0.72)',
146  color        : '#c8d8e8',
147  padding      : '3px 7px',
148  borderRadius : '3px',
149  fontSize     : '11px',
150  whiteSpace   : 'nowrap',
151  display      : 'none',
152  maxWidth     : '240px',
153  overflow     : 'hidden',
154  textOverflow : 'ellipsis',
155  boxShadow    : '0 1px 6px rgba(0,0,0,0.35)',
156  border       : '1px solid rgba(255,255,255,0.05)',
157});
158document.body.appendChild(_tooltip);
159
160export function showTooltip(name, distAU, cx, cy, extra) {
161  const dist = _fmtDist(distAU);
162  _tooltip.innerHTML = extra
163    ? `${name}  (${dist})<br><span style="font-size:10px;opacity:0.65">${extra}</span>`
164    : `${name}  (${dist})`;
165  let tx = cx + 16, ty = cy - 26;
166  if (tx + TOOLTIP_W > window.innerWidth - 8) tx = cx - TOOLTIP_W - 16;
167  if (ty < 8) ty = cy + 16;
168  _tooltip.style.left    = `${tx}px`;
169  _tooltip.style.top     = `${ty}px`;
170  _tooltip.style.display = 'block';
171}
172
173export function hideTooltip() {
174  _tooltip.style.display = 'none';
175}
176
177// ─── SVG overlay (connector lines) ───────────────────────────────────────────
178const _svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
179Object.assign(_svg.style, {
180  position: 'fixed', top: '0', left: '0',
181  width: '100%', height: '100%',
182  pointerEvents: 'none', zIndex: '998',
183  overflow: 'visible',
184});
185document.body.appendChild(_svg);
186
187// ─── Mobile singletons ────────────────────────────────────────────────────────
188// Name label near ring (mobile)
189const _mobileLabel = document.createElement('div');
190Object.assign(_mobileLabel.style, {
191  position     : 'fixed',
192  pointerEvents: 'none',
193  zIndex       : '1001',
194  background   : 'rgba(128,128,128,0.82)',
195  color        : '#fff',
196  padding      : '3px 8px',
197  borderRadius : '4px',
198  fontSize     : '13px',
199  fontWeight   : 'bold',
200  display      : 'none',
201  whiteSpace   : 'nowrap',
202  boxShadow    : '0 1px 6px rgba(0,0,0,0.4)',
203});
204document.body.appendChild(_mobileLabel);
205
206// "Object Info" CTA above nav bar (mobile)
207const _mobileCta = document.createElement('button');
208_mobileCta.className = 'se-mobile-cta ui-interface';
209_mobileCta.textContent = 'ⓘ  Object Info';
210Object.assign(_mobileCta.style, {
211  position    : 'fixed',
212  bottom      : '70px',
213  left        : '50%',
214  transform   : 'translateX(-50%)',
215  zIndex      : '1001',
216  display     : 'none',
217  background  : 'rgba(80,130,230,0.92)',
218  color       : '#fff',
219  border      : 'none',
220  borderRadius: '20px',
221  padding     : '9px 24px',
222  fontSize    : '14px',
223  fontWeight  : 'bold',
224  cursor      : 'pointer',
225  boxShadow   : '0 2px 12px rgba(0,0,0,0.45)',
226  whiteSpace  : 'nowrap',
227});
228document.body.appendChild(_mobileCta);
229
230let _mobileEntry   = null;
231let _mobileBoxOpen = false;
232
233_mobileCta.addEventListener('click', () => {
234  if (!_mobileEntry) return;
235  _mobileBoxOpen ? _closeMobileBox() : _openMobileBox(_mobileEntry);
236});
237
238function _openMobileBox(entry) {
239  const { boxEl } = _buildBoxEl(entry.ud, entry.dist, entry.id, entry.lifecycle?.phases);
240  Object.assign(boxEl.style, {
241    left     : '50%',
242    right    : 'auto',
243    top      : '50%',
244    transform: 'translate(-50%, -50%)',
245    width    : `min(calc(100vw - 32px), ${BOX_WIDTH}px)`,
246    zIndex   : '1002',
247  });
248  document.body.appendChild(boxEl);
249  entry.boxEl = boxEl;
250  _mobileBoxOpen = true;
251  boxEl.querySelector('.se-infobox-close').addEventListener('click', e => {
252    e.stopPropagation();
253    _closeMobileBox();
254  });
255  boxEl.addEventListener('pointerdown', e => e.stopPropagation());
256}
257
258function _closeMobileBox() {
259  if (_mobileEntry?.boxEl) { _mobileEntry.boxEl.remove(); _mobileEntry.boxEl = null; }
260  _mobileBoxOpen = false;
261}
262
263// ─── Selection pool ───────────────────────────────────────────────────────────
264// Each entry: { id, ud, dist, hitRef, ringEl, boxEl, lineEl, pinned, col }
265const _pool = [];
266
267function _maxSelections() {
268  if (isMobile()) return 1;
269  // Use 200px estimate per box (conservative — prevents premature overflow)
270  const rightAvail = window.innerHeight - TOP_START - BOT_MARGIN;
271  const leftAvail  = window.innerHeight - _leftColTop() - BOT_MARGIN;
272  return Math.max(2, Math.floor(rightAvail / 200)) + Math.max(1, Math.floor(leftAvail / 200));
273}
274
275// Left column starts below .sim-info (speed/dist panel at top-left)
276function _leftColTop() {
277  const el = document.querySelector('.sim-info');
278  return el ? Math.ceil(el.getBoundingClientRect().bottom) + 12 : TOP_START;
279}
280
281function _fmtDist(distAU) {
282  if (distAU == null) return '';
283  return distAU < 7000
284    ? `${distAU.toFixed(2)} AU`
285    : `${(distAU / 63241.16).toFixed(2)} LY`;
286}
287
288function _selId(hitRef) {
289  return hitRef.object.uuid + '.' + (hitRef.index ?? 'mesh');
290}
291
292// ─── Lifecycle timeline HTML ──────────────────────────────────────────────────
293const _REGISTRY_ICON = {
294  [REGISTRY.HELIOCENTRIC] : '☄',
295  [REGISTRY.LANDING]      : '●',
296  [REGISTRY.LAUNCH_SITE]  : '⊕',
297  [REGISTRY.ORBITAL]      : '◎',
298  [REGISTRY.ORBITER]      : '◎',
299};
300
301function _lifecycleHTML(phases) {
302  if (!phases?.length) return '';
303  const fmt = ms => {
304    if (!ms) return '?';
305    const d = new Date(ms);
306    return d.toISOString().slice(0, 10);
307  };
308  const rows = phases.map(p => {
309    const dateStr = fmt(p.startMs);
310    const dateEl  = p.startMs
311      ? `<span class="se-lc-date" data-ms="${p.startMs}" style="font-size:11px;color:#7ac8ff;min-width:78px;flex-shrink:0;cursor:pointer;text-decoration:underline dotted;text-underline-offset:2px;" title="Jump to this date">${dateStr}</span>`
312      : `<span style="font-size:11px;color:#bbb;min-width:78px;flex-shrink:0">?</span>`;
313    return `<div style="display:flex;gap:6px;align-items:baseline;padding:2px 0;">
314       <span style="opacity:0.6;min-width:14px;text-align:center">${_REGISTRY_ICON[p.registry] ?? '·'}</span>
315       ${dateEl}
316       <span style="font-size:11px;color:#ccc">${p.displayName ?? p._rawName}</span>
317     </div>`;
318  }).join('');
319  return `<div style="margin-top:8px;padding-top:6px;border-top:1px solid rgba(255,255,255,0.12);">
320    <div style="font-size:10px;color:#888;margin-bottom:3px;letter-spacing:.05em">LIFECYCLE</div>
321    ${rows}
322  </div>`;
323}
324
325// ─── DataCard renderer ────────────────────────────────────────────────────────
326// Renders a DataCard into the existing infobox chrome, called from _buildBoxEl
327// when ud._card is present. Falls back to the legacy ud-field path otherwise.
328function _cardFieldsHTML(card) {
329  if (!card.fields?.length && !card.subtitle) return '';
330  let html = '';
331  if (card.subtitle) {
332    html += `<div class="se-infobox-subtitle" style="font-size:12px;opacity:.7;margin:2px 0 6px;">${card.subtitle}</div>`;
333  }
334  if (card.fields?.length) {
335    html += '<table class="se-card-fields" style="width:100%;border-collapse:collapse;font-size:12px;">';
336    for (const f of card.fields) {
337      const valHtml = f.type === FIELD.URL
338        ? `<a href="${f.value}" target="_blank" rel="noopener" style="color:#7ab;text-decoration:none;">${f.value}</a>`
339        : f.type === FIELD.TAG
340          ? `<span style="display:inline-block;background:rgba(100,160,240,.25);border:1px solid rgba(100,160,240,.5);border-radius:3px;padding:0 5px;font-size:11px;">${f.value}</span>`
341          : String(f.value ?? '');
342      html += `<tr>
343        <td style="color:#8090a0;padding:2px 8px 2px 0;white-space:nowrap;vertical-align:top;">${f.key || ''}</td>
344        <td style="color:#c0cdd8;padding:2px 0;word-break:break-word;">${valHtml}</td>
345      </tr>`;
346    }
347    html += '</table>';
348  }
349  // Source + license footer
350  if (card.source || card.license) {
351    html += `<div style="margin-top:7px;padding-top:5px;border-top:1px solid rgba(255,255,255,0.07);font-size:10px;color:#6070a0;display:flex;gap:6px;align-items:center;flex-wrap:wrap;">`;
352    if (card.source)  html += `<span title="Data source">${card.source}</span>`;
353    if (card.source && card.license) html += `<span style="opacity:.4">·</span>`;
354    if (card.license) html += `<span title="License">${card.license}</span>`;
355    html += `</div>`;
356  }
357  return html;
358}
359
360function _cardPhasesHTML(card) {
361  if (!card.children?.length) return '';
362  const rows = card.children.map(ph => {
363    const dateStr = ph.startMs ? new Date(ph.startMs).toISOString().slice(0, 10) : '';
364    const msAttr  = ph.startMs ? ` data-ms="${ph.startMs}"` : '';
365    // Format meta fields inline for orbitals (exoplanets, hcocat phases)
366    let metaStr = '';
367    if (ph.meta) {
368      const m = ph.meta;
369      const parts = [];
370      if (m.period  != null) parts.push(`${m.period < 10 ? m.period.toFixed(2) : m.period.toFixed(1)}d`);
371      if (m.sma     != null) parts.push(`${m.sma.toFixed(2)} AU`);
372      if (m.radRJup != null) parts.push(`${(m.radRJup * 11.2).toFixed(1)} R⊕`);
373      if (m.massMJup!= null) parts.push(`${(m.massMJup * 317.8).toFixed(0)} M⊕`);
374      if (m.a_AU    != null) parts.push(`a=${m.a_AU.toFixed(2)} AU`);
375      if (m.e       != null) parts.push(`e=${m.e.toFixed(3)}`);
376      if (m.inc     != null) parts.push(`i=${m.inc.toFixed(1)}°`);
377      if (parts.length) metaStr = `<span style="font-size:10px;color:#6080a0;margin-left:6px;">${parts.join(' · ')}</span>`;
378    }
379    return `<div class="se-lc-phase" style="padding:3px 0;border-bottom:1px solid rgba(255,255,255,0.06);">
380      ${dateStr ? `<span class="se-lc-date" ${msAttr} style="color:#7ab;cursor:pointer;font-size:11px;">${dateStr}</span>` : ''}
381      <span style="font-size:12px;">${ph.label || ph.phaseType || ''}</span>${metaStr}
382    </div>`;
383  }).join('');
384  return `<div class="se-card-phases" style="margin-top:8px;">${rows}</div>`;
385}
386
387// ─── Build box element (shared by desktop add + mobile lazy open) ─────────────
388// Returns { boxEl, cleanupPlayer } — cleanupPlayer() disposes the 3D canvas if present.
389function _buildBoxEl(ud, dist, id, lifecycle) {
390  // DataCard path (forward-compatible): use ud._card if present
391  const card   = ud._card ?? null;
392  const name   = card ? card.label
393    : (ud.nameSet && ud.nameSet !== 'DefaultName' ? ud.nameSet : 'Unknown');
394  const text   = card ? ''
395    : (ud.textSet && ud.textSet !== 'DefaultText' ? ud.textSet : '');
396  const url    = (card?.refURL) ?? (ud.refURL && ud.refURL !== 'DefaultText' ? ud.refURL : null);
397  const imgUrl = ud.imgURL || null;
398
399  const bodyHTML = card
400    ? `${_cardFieldsHTML(card)}${_cardPhasesHTML(card)}`
401    : `${text ? `<div class="se-infobox-text">${text}</div>` : ''}
402       ${lifecycle ? _lifecycleHTML(lifecycle) : ''}`;
403
404  const boxEl = document.createElement('div');
405  boxEl.className = 'se-infobox ui-interface';
406  // Close button: absolutely positioned top-right of the infobox
407  boxEl.innerHTML = `
408    <button class="se-infobox-close" title="Close" style="
409      position:absolute; top:8px; right:10px;
410      width:22px; height:22px; border-radius:50%;
411      background:rgba(255,255,255,0.18); border:none;
412      color:#fff; font-size:16px; line-height:1;
413      cursor:pointer; display:flex; align-items:center; justify-content:center;
414      padding:0; transition:background .15s;">&times;</button>
415    <div class="se-infobox-header" style="padding-right:28px">
416      <span class="se-infobox-name">${name}</span>
417    </div>
418    ${dist ? `<div class="se-infobox-dist">Dist to Sun: ${dist}</div>` : ''}
419    ${ud.onSelect && !ud._isPlanet ? `<div class="se-infobox-flyto"><button class="se-infobox-flyto-btn">&#9654; Fly to</button></div>` : ''}
420    ${ud.onSelect &&  ud._isPlanet ? `
421      <div class="se-infobox-visit-planet">
422        <button class="se-infobox-visit-btn">🌍 Visit Planet</button>
423        <div class="se-visit-disclaimer">
424          ✦ Art &amp; imagination experience — procedurally generated fiction,
425          not a scientific simulation. No real biosignature data exists for this world.
426        </div>
427      </div>` : ''}
428    ${bodyHTML}
429    ${imgUrl ? `<div class="se-infobox-img" style="margin:6px 0;text-align:center;"><img src="${imgUrl}" alt="DSS survey image" style="max-width:180px;max-height:180px;border-radius:4px;opacity:.9;" loading="lazy" onerror="this.style.display='none'" /></div>` : ''}
430    ${url  ? `<div class="se-infobox-ref"><button class="se-infobox-moreinfo" data-url="${url}" data-title="${name}">&#128279; More on this object</button></div>` : ''}
431  `;
432  // Accent colour for the left border (matches ring and connector)
433  const _boxAccent = _accentCss(ud);
434  Object.assign(boxEl.style, {
435    position    : 'fixed',
436    zIndex      : '1000',
437    width       : `${BOX_WIDTH}px`,
438    background  : 'rgba(8,14,30,0.94)',
439    color       : '#c0cdd8',
440    padding     : '10px 14px 12px',
441    borderRadius: '7px',
442    fontSize    : '14px',
443    boxShadow   : '0 6px 28px rgba(0,0,0,0.7), inset 0 0 0 1px rgba(255,255,255,0.06)',
444    cursor      : 'default',
445    userSelect  : 'none',
446    boxSizing   : 'border-box',
447    borderLeft  : `3px solid ${_boxAccent}`,
448  });
449
450  const closeBtn = boxEl.querySelector('.se-infobox-close');
451  closeBtn.addEventListener('mouseenter', () => closeBtn.style.background = 'rgba(255,255,255,0.38)');
452  closeBtn.addEventListener('mouseleave', () => closeBtn.style.background = 'rgba(255,255,255,0.18)');
453
454  const moreBtn = boxEl.querySelector('.se-infobox-moreinfo');
455  if (moreBtn) {
456    moreBtn.addEventListener('click', () => {
457      _showIframe(moreBtn.dataset.url, moreBtn.dataset.title);
458    });
459  }
460
461  const flyBtn = boxEl.querySelector('.se-infobox-flyto-btn');
462  if (flyBtn && ud.onSelect) {
463    flyBtn.addEventListener('click', () => { ud.onSelect(); });
464  }
465
466  const visitBtn = boxEl.querySelector('.se-infobox-visit-btn');
467  if (visitBtn && ud.onSelect) {
468    visitBtn.addEventListener('click', () => { ud.onSelect(); });
469  }
470
471  // Lifecycle date click → jump simulation to that event; also re-fly camera if possible
472  boxEl.addEventListener('click', e => {
473    // Planet-link button in system infobox → open planet world viewer
474    const plink = e.target.closest('.se-planet-link');
475    if (plink && ud.onPlanetLink) {
476      ud.onPlanetLink(plink.dataset.pname);
477      return;
478    }
479
480    const t = e.target.closest('.se-lc-date');
481    if (!t) return;
482    const ms = parseInt(t.dataset.ms, 10);
483    if (!isNaN(ms)) {
484      fadeFlash(300);
485      jumpToDate(ms);
486      // Re-fly to the object at the new date (100 ms delay lets positions update for one tick)
487      if (ud.onSelect) setTimeout(() => ud.onSelect(), 100);
488    }
489  });
490
491  // ── 3D spacecraft model player (desktop only, async) ──────────────────────
492  let cleanupPlayer = null;
493  if (!isMobile()) {
494    const modelUrl = matchSpacecraftModel(ud.nameSet);
495    if (modelUrl) {
496      mountSpacecraftPlayer(boxEl, modelUrl).then(fn => {
497        cleanupPlayer = fn;
498        requestAnimationFrame(() => requestAnimationFrame(_reflow));
499      });
500    }
501  }
502
503  // ── Launch site vessel list (async, injected when ready) ──────────────────
504  if (ud._siteCode) {
505    const placeholder = document.createElement('div');
506    placeholder.style.cssText =
507      'margin-top:8px;padding-top:6px;border-top:1px solid rgba(255,255,255,0.12);' +
508      'font-size:10px;color:rgba(255,255,255,0.35);';
509    placeholder.textContent = 'Loading launches…';
510    boxEl.appendChild(placeholder);
511
512    fetchSatCat(ud._siteCode).then(vessels => {
513      if (!placeholder.parentNode) return; // box already closed
514      if (!vessels.length) { placeholder.remove(); return; }
515
516      const total = vessels.length;
517      const shown = vessels.slice(0, 200);
518
519      placeholder.innerHTML =
520        `<div style="font-size:10px;color:#888;margin-bottom:4px;letter-spacing:.05em">` +
521        `LAUNCHES — ${total.toLocaleString('en-US')} total</div>` +
522        `<div style="max-height:140px;overflow-y:auto;font-size:10px;line-height:1.65;color:#bbb;` +
523        `scrollbar-width:thin;scrollbar-color:rgba(255,255,255,0.2) transparent;">` +
524        shown.map(v => {
525          const date = v.launch ? v.launch.slice(0, 10) : '?';
526          const dot  = v.status === 'A' ? '<span style="color:#ff4444">●</span> ' : '';
527          return `<div>${dot}${v.name} <span style="color:#888">${date}</span></div>`;
528        }).join('') +
529        `</div>`;
530
531      requestAnimationFrame(() => requestAnimationFrame(_reflow));
532    });
533  }
534
535  return { boxEl, cleanupPlayer: () => { cleanupPlayer?.(); } };
536}
537
538// ─── Two-column layout: right fills first, overflow to left below sim-info ────
539function _reflow() {
540  const bottomLimit = window.innerHeight - BOT_MARGIN;
541  let rightY = TOP_START;
542  let leftY  = _leftColTop();
543
544  for (const e of _pool) {
545    if (e.pinned || !e.boxEl) continue;
546    const h = (e.boxEl.offsetHeight || 140) + BOX_GAP;
547
548    if (rightY + h - BOX_GAP <= bottomLimit) {
549      Object.assign(e.boxEl.style, { position: 'fixed', right: `${RIGHT_PAD}px`, left: 'auto', top: `${rightY}px` });
550      e.col = 'right';
551      rightY += h;
552    } else {
553      // Clamp so the box bottom never exceeds bottomLimit
554      const boxH     = e.boxEl.offsetHeight || 140;
555      const clampTop = Math.min(leftY, bottomLimit - boxH);
556      Object.assign(e.boxEl.style, { position: 'fixed', left: `${LEFT_PAD}px`, right: 'auto', top: `${clampTop}px` });
557      e.col = 'left';
558      leftY = clampTop + boxH + BOX_GAP;
559    }
560  }
561}
562
563// ─── Add a selection ─────────────────────────────────────────────────────────
564export function addSelection(ud, hitRef, distAU) {
565  const id = _selId(hitRef);
566
567  // Toggle off if already selected
568  const existing = _pool.find(e => e.id === id);
569  if (existing) { removeSelection(id); return; }
570
571  // Evict oldest if at cap
572  if (_pool.length >= _maxSelections()) removeSelection(_pool[_pool.length - 1].id);
573
574  const name = ud.nameSet && ud.nameSet !== 'DefaultName' ? ud.nameSet : 'Unknown';
575  const dist = _fmtDist(distAU);
576
577  // Notify VR info panel if active
578  if (typeof window._vrSelectionCallback === 'function') {
579    window._vrSelectionCallback(ud, distAU);
580  }
581
582  // ── Lifecycle cross-registry lookup ──────────────────────────────────────────
583  // If this object exists in multiple GCAT registries, attach lifecycle tracking
584  // so the selection ring follows it through flight → landing transitions.
585  const _lcPhases = findLifecycle(ud.nameSet ?? '');
586  const lifecycle = _lcPhases
587    ? { phases: _lcPhases, activePhase: getActivePhase(_lcPhases, getDateMs()) }
588    : null;
589
590  // Show trajectory arc for heliocentric missions on select
591  const isHeliocentric = _lcPhases?.some(p => p.registry === REGISTRY.HELIOCENTRIC)
592    ?? ud.registry === REGISTRY.HELIOCENTRIC;
593  if (isHeliocentric) setHcocatSelection(name);
594
595  // ── Ring — border colour inherits from object's accent colour ────────────────
596  const _accent = _accentCss(ud);
597  const ringEl = document.createElement('div');
598  ringEl.className = 'se-ring';
599  Object.assign(ringEl.style, {
600    position     : 'fixed',
601    pointerEvents: 'none',
602    zIndex       : '999',
603    width        : '44px',
604    height       : '44px',
605    border       : `2px solid ${_accent}`,
606    borderRadius : '50%',
607    transform    : 'translate(-50%, -50%)',
608    display      : 'none',
609    boxSizing    : 'border-box',
610    boxShadow    : `0 0 6px ${_accent}`,
611  });
612  document.body.appendChild(ringEl);
613
614  // ── Mobile path: ring + label + CTA only (no infobox yet) ────────────────────
615  if (isMobile()) {
616    // Close any open mobile box from previous selection
617    _closeMobileBox();
618    _mobileLabel.style.display = 'none';
619    _mobileCta.style.display   = 'none';
620
621    const entry = { id, ud, dist, hitRef, ringEl, boxEl: null, lineEl: null, pinned: fal
621se, col: 'right', mobile: true, lifecycle };
622    _pool.push(entry);
623    _mobileEntry = entry;
624
625    _mobileLabel.textContent   = name;
626    _mobileLabel.style.display = 'block';
627    _mobileCta.style.display   = 'block';
628    _mobileBoxOpen = false;
629    return;
630  }
631
632  // ── Desktop path: ring + infobox + SVG line ───────────────────────────────────
633  // Estimate initial column
634  const bottomLimit = window.innerHeight - BOT_MARGIN;
635  let rightY = TOP_START, leftY = _leftColTop();
636  for (const e of _pool) {
637    if (!e.pinned && e.boxEl) {
638      const h = (e.boxEl.offsetHeight || 140) + BOX_GAP;
639      if (e.col === 'left') leftY += h; else rightY += h;
640    }
641  }
642  const goLeft  = rightY + 140 > bottomLimit;
643  const initialY = goLeft ? leftY : rightY;
644
645  const { boxEl, cleanupPlayer } = _buildBoxEl(ud, dist, id, lifecycle?.phases);
646  Object.assign(boxEl.style, {
647    right: goLeft ? 'auto' : `${RIGHT_PAD}px`,
648    left : goLeft ? `${LEFT_PAD}px` : 'auto',
649    top  : `${initialY}px`,
650  });
651  document.body.appendChild(boxEl);
652
653  // SVG line — same accent colour as the ring
654  const lineEl = document.createElementNS('http://www.w3.org/2000/svg', 'line');
655  lineEl.setAttribute('stroke', _accent);
656  lineEl.setAttribute('stroke-width', '1');
657  lineEl.setAttribute('stroke-dasharray', '5 4');
658  lineEl.setAttribute('opacity', '0.65');
659  lineEl.setAttribute('visibility', 'hidden');
660  _svg.appendChild(lineEl);
661
662  const entry = { id, ud, dist, hitRef, ringEl, boxEl, lineEl, pinned: false, col: goLeft ? 'left' : 'right', lifecycle, cleanupPlayer };
663  _pool.push(entry);
664
665  boxEl.querySelector('.se-infobox-close').addEventListener('click', e => { e.stopPropagation(); removeSelection(id); });
666  _makeDraggable(boxEl, entry);
667  boxEl.addEventListener('pointerdown', e => e.stopPropagation());
668
669  requestAnimationFrame(() => requestAnimationFrame(_reflow));
670}
671
672// ─── Remove one selection ─────────────────────────────────────────────────────
673export function removeSelection(id) {
674  const idx = _pool.findIndex(e => e.id === id);
675  if (idx === -1) return;
676  const [entry] = _pool.splice(idx, 1);
677  entry.ringEl.remove();
678  if (entry.boxEl)      entry.boxEl.remove();
679  if (entry.lineEl)     entry.lineEl.remove();
680  if (entry.cleanupPlayer) entry.cleanupPlayer();
681
682  if (entry.mobile && _mobileEntry === entry) {
683    _closeMobileBox();
684    _mobileEntry = null;
685    _mobileLabel.style.display = 'none';
686    _mobileCta.style.display   = 'none';
687  }
688
689  // Hide trajectory if this was a heliocentric mission and no other helo is still selected
690  const wasHelio = entry.lifecycle?.phases?.some(p => p.registry === REGISTRY.HELIOCENTRIC)
691    ?? entry.ud?.registry === REGISTRY.HELIOCENTRIC;
692  if (wasHelio) {
693    const stillHasHelio = _pool.some(e =>
694      e.lifecycle?.phases?.some(p => p.registry === REGISTRY.HELIOCENTRIC)
695      ?? e.ud?.registry === REGISTRY.HELIOCENTRIC
696    );
697    if (!stillHasHelio) setHcocatSelection(null);
698    else {
699      // Keep showing the last remaining helio selection
700      const remaining = _pool.find(e =>
701        e.lifecycle?.phases?.some(p => p.registry === REGISTRY.HELIOCENTRIC)
702        ?? e.ud?.registry === REGISTRY.HELIOCENTRIC
703      );
704      if (remaining) setHcocatSelection(remaining.ud.nameSet);
705    }
706  }
707
708  requestAnimationFrame(() => requestAnimationFrame(_reflow));
709}
710
711// ─── Clear all ────────────────────────────────────────────────────────────────
712export function clearAllSelections() {
713  for (const e of _pool.slice()) removeSelection(e.id);
714}
715
716// ─── Per-frame update ─────────────────────────────────────────────────────────
717const _projV = new THREE.Vector3();
718
719export function updateSelectionsEachFrame(camera) {
720  // Current simulation time for lifecycle phase jumping
721  const _dateMs = getDateMs();
722
723  for (const e of _pool) {
724    // ── Lifecycle phase jump ────────────────────────────────────────────────
725    // At each frame, check if sim time has crossed a phase boundary.
726    // If so, swap hitRef to the newly-active registry's geometry (position jump).
727    if (e.lifecycle) {
728      const newPhase = getActivePhase(e.lifecycle.phases, _dateMs);
729      if (newPhase !== e.lifecycle.activePhase) {
730        e.lifecycle.activePhase = newPhase;
731      }
732      // Always try to use the active phase's hitRef (covers the case where
733      // a registry loads after the selection was created).
734      const phaseRef = e.lifecycle.activePhase?.getHitRef?.();
735      if (phaseRef?.object?.parent) {
736        e.hitRef = phaseRef;
737      }
738    }
739
740    const wp = _getWorldPos(e.hitRef);
741    if (!wp) { _hide(e); continue; }
742
743    const ndc = wp.clone().project(camera);
744    if (ndc.z > 1) { _hide(e); continue; }
745
746    const sx = (ndc.x *  0.5 + 0.5) * window.innerWidth;
747    const sy = (ndc.y * -0.5 + 0.5) * window.innerHeight;
748    if (!isFinite(sx) || !isFinite(sy)) { _hide(e); continue; }
749
750    e.ringEl.style.left    = `${sx}px`;
751    e.ringEl.style.top     = `${sy}px`;
752    e.ringEl.style.display = '';
753
754    // Mobile: update name label position near ring
755    if (e.mobile) {
756      _mobileLabel.style.left = `${Math.min(sx + 26, window.innerWidth - 180)}px`;
757      _mobileLabel.style.top  = `${Math.max(sy - 10, 4)}px`;
758      continue; // no SVG line on mobile
759    }
760
761    // Desktop: update SVG connector line
762    if (!e.boxEl || !e.lineEl) continue;
763    const rect = e.boxEl.getBoundingClientRect();
764    if (rect.width > 0) {
765      const isRight = e.pinned
766        ? (rect.left + rect.width * 0.5) > window.innerWidth * 0.5
767        : e.col !== 'left';
768      const bx = isRight ? rect.left : rect.right;
769      const by = rect.top + rect.height * 0.5;
770      e.lineEl.setAttribute('x1', sx.toFixed(1));
771      e.lineEl.setAttribute('y1', sy.toFixed(1));
772      e.lineEl.setAttribute('x2', bx.toFixed(1));
773      e.lineEl.setAttribute('y2', by.toFixed(1));
774      e.lineEl.setAttribute('visibility', 'visible');
775    }
776  }
777}
778
779function _hide(e) {
780  e.ringEl.style.display = 'none';
781  if (e.lineEl) e.lineEl.setAttribute('visibility', 'hidden');
782  if (e.mobile && _mobileEntry === e) _mobileLabel.style.display = 'none';
783}
784
785function _getWorldPos(hitRef) {
786  if (!hitRef?.object || !hitRef.object.parent) return null;
787  const obj = hitRef.object;
788  if (obj.userData._isPointCloud && hitRef.index != null) {
789    // For point-cloud entries that carry a Float64 world position (_lastPos),
790    // use it directly — the Float32 position buffer has ~3 AU precision error
791    // at interstellar distances, causing the selection circle to jump far off-star.
792    const ud = obj.userData._pointDataArray?.[hitRef.index];
793    if (ud?._lastPos) {
794      _projV.set(ud._lastPos[0], ud._lastPos[1], ud._lastPos[2]);
795    } else {
796      const attr = obj.geometry?.getAttribute('position');
797      if (!attr) return null;
798      _projV.fromBufferAttribute(attr, hitRef.index).applyMatrix4(obj.matrixWorld);
799    }
800  } else {
801    obj.getWorldPosition(_projV);
802  }
803  return _projV;
804}
805
806// ─── Drag (desktop only) ──────────────────────────────────────────────────────
807function _makeDraggable(boxEl, entry) {
808  const header = boxEl.querySelector('.se-infobox-header');
809  let startX, startY, origLeft, origTop;
810
811  header.addEventListener('pointerdown', e => {
812    if (e.target.classList.contains('se-infobox-close')) return;
813    e.preventDefault();
814    header.setPointerCapture(e.pointerId);
815    entry.pinned = true;
816    const rect = boxEl.getBoundingClientRect();
817    origLeft = rect.left; origTop = rect.top;
818    startX = e.clientX; startY = e.clientY;
819    boxEl.style.right = 'auto';
820    boxEl.style.left  = `${origLeft}px`;
821
822    const onMove = ev => {
823      boxEl.style.left = `${origLeft + (ev.clientX - startX)}px`;
824      boxEl.style.top  = `${origTop  + (ev.clientY - startY)}px`;
825    };
826    const onUp = () => {
827      header.removeEventListener('pointermove', onMove);
828      header.removeEventListener('pointerup', onUp);
829    };
830    header.addEventListener('pointermove', onMove);
831    header.addEventListener('pointerup', onUp);
832  });
833}
834
835// ─── Legacy compat ────────────────────────────────────────────────────────────
836export function showObject() {}
837export function hideInfoBox() {}
838
839// Exposed for search.js — lets search cap checkbox selection at available slots
840export function getMaxSelections() { return _maxSelections(); }
841
842// Returns the Set of active selection IDs so search.js can sync checkboxes
843export function getActiveSelectionIds() {
844  return new Set(_pool.map(e => e.id));
845}

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.