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;">×</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">▶ 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 & 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}">🔗 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.