// artemis.js — Artemis Mission Tracker // True 2D orbital map: all Horizons XY coords rotated so Earth→Moon is horizontal // Earth fixed left, Moon fixed right, trajectory keeps its real shape (function () { 'use strict'; var API = 'https://api.issinfo.net'; var POLL = 30000, MET_INT = 1000; if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') API = 'http://localhost:5013'; // ===== State ===== var mode = 'IDLE'; // LIVE | HISTORICAL | IDLE var scrubbing = false; // true when user has scrubbed away from live position var missions = [], selMission = null; var liveOrion = null, liveMoon = null, lastFetch = 0; var hist = [], nowIdx = 0, scrub = 0; var playing = false, playSpd = 24, lastPF = 0; var canvas, ctx, starBuf, starBufCtx; var W = 0, H = 0, dpr = 1, pulse = 0; // View geometry (computed from data) — Earth-centered inertial frame var earthPx; // Earth screen position (center of canvas) var pxPerKm = 1; // scale: canvas pixels per km var earthR = 30, moonR = 8; var metTimer = null, pollTimer = null; // DOM refs (populated in init) var $ = function(id) { return document.getElementById(id); }; var mSel, badge, bannerEl; var metV, eDist, mDist, velV, progV; var progBar, freshDot; var tlSec, tlBar, tlHandle, tlTicks; var playBtn, scrubT; var fsBtn, fsExit; // ===== Helpers ===== function fmt(n) { return (n == null || isNaN(n)) ? '--' : Math.round(n).toLocaleString('en-US'); } function pad2(n) { return n < 10 ? '0' + n : '' + n; } function fmtMET(l, now) { if (!l) return '--:--:--'; var ms = (now || Date.now()) - new Date(l).getTime(), p = 'T+'; if (ms < 0) { ms = -ms; p = 'T-'; } var d = Math.floor(ms / 864e5); return p + d + 'd ' + pad2(Math.floor((ms%864e5)/36e5)) + ':' + pad2(Math.floor((ms%36e5)/6e4)) + ':' + pad2(Math.floor((ms%6e4)/1e3)); } function getPhase(m, now) { if (!m || !m.phases) return '--'; var days = ((now || Date.now()) - new Date(m.launch_utc).getTime()) / 864e5; var p = m.phases[0].name; for (var i = 0; i < m.phases.length; i++) if (days >= m.phases[i].start_day) p = m.phases[i].name; return p; } // Default trajectory color (cyan) var TRAJ_DEFAULT = '110,231,247'; // Get the phase color (as "r,g,b" string) for a history point index. // Falls back to cyan if no color data available. function phaseColorRGB(idx) { if (!selMission || !selMission.phases || !selMission.phases.length || !hist[idx]) return TRAJ_DEFAULT; var lMs = new Date(selMission.launch_utc).getTime(); var days = (new Date(hist[idx].t).getTime() - lMs) / 864e5; var c = null; for (var i = 0; i < selMission.phases.length; i++) { if (days >= selMission.phases[i].start_day && selMission.phases[i].color) c = selMission.phases[i].color; } if (!c) return TRAJ_DEFAULT; // Parse hex "#rrggbb" to "r,g,b" var r = parseInt(c.slice(1,3), 16), g = parseInt(c.slice(3,5), 16), b = parseInt(c.slice(5,7), 16); return r + ',' + g + ',' + b; } function getProgress(m, now) { if (!m) return 0; var l = new Date(m.launch_utc).getTime(), s = new Date(m.splashdown_utc).getTime(), t = now || Date.now(); return t <= l ? 0 : t >= s ? 100 : ((t - l) / (s - l)) * 100; } function srand(s) { var x = Math.sin(s) * 43758.5453; return x - Math.floor(x); } // ===== Coordinate System ===== // Earth-centered inertial: raw geocentric km → canvas pixels (no rotation) function kmToPx(xKm, yKm) { return { x: earthPx.x + xKm * pxPerKm, y: earthPx.y - yKm * pxPerKm // Y flipped (canvas Y grows down) }; } // Get Moon screen position at the current time function curMoonPx() { var m = curMoon(); if (!m || !m.x_km) return null; return kmToPx(m.x_km, m.y_km); } // ===== View Setup ===== // Earth at center, bounding box from ALL Orion + Moon positions function computeView() { if (hist.length < 2) { earthPx = { x: W * 0.5, y: H * 0.5 }; pxPerKm = 1; earthR = 30; moonR = 8; return; } // Find bounding box of all raw geocentric positions (Orion + Moon) var minX = 0, maxX = 0, minY = 0, maxY = 0; for (var i = 0; i < hist.length; i++) { var o = hist[i].orion, m = hist[i].moon; if (o.x_km < minX) minX = o.x_km; if (o.x_km > maxX) maxX = o.x_km; if (o.y_km < minY) minY = o.y_km; if (o.y_km > maxY) maxY = o.y_km; if (m && m.x_km != null) { if (m.x_km < minX) minX = m.x_km; if (m.x_km > maxX) maxX = m.x_km; if (m.y_km < minY) minY = m.y_km; if (m.y_km > maxY) maxY = m.y_km; } } // Ensure Earth (0,0) is included if (minX > 0) minX = 0; if (maxX < 0) maxX = 0; if (minY > 0) minY = 0; if (maxY < 0) maxY = 0; // Padding (12%) var padX = (maxX - minX) * 0.12; var padY = (maxY - minY) * 0.12; if (padY < 30000) padY = 30000; minX -= padX; maxX += padX; minY -= padY; maxY += padY; var rangeX = maxX - minX; var rangeY = maxY - minY; // Scale: fit both dimensions pxPerKm = Math.min(W / rangeX, H / rangeY); var totalW = rangeX * pxPerKm; var totalH = rangeY * pxPerKm; var offsetX = (W - totalW) / 2; var offsetY = (H - totalH) / 2; // Earth (0,0) maps to this pixel position earthPx = { x: offsetX + (-minX) * pxPerKm, y: offsetY + (maxY) * pxPerKm // Y flipped }; earthR = Math.max(W < 500 ? 12 : 20, W * 0.022); moonR = Math.max(4, earthR * 0.27); } // ===== Canvas ===== function initCanvas() { canvas = $('artemis-canvas'); ctx = canvas.getContext('2d'); doResize(); genStars(); if (typeof ResizeObserver !== 'undefined') new ResizeObserver(function() { doResize(); genStars(); }).observe(canvas.parentElement); else window.addEventListener('resize', function() { doResize(); genStars(); }); } function doResize() { var r = canvas.parentElement.getBoundingClientRect(); dpr = window.devicePixelRatio || 1; W = r.width; H = r.height; canvas.width = W * dpr; canvas.height = H * dpr; canvas.style.width = W + 'px'; canvas.style.height = H + 'px'; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } function genStars() { starBuf = document.createElement('canvas'); starBuf.width = canvas.width; starBuf.height = canvas.height; starBufCtx = starBuf.getContext('2d'); starBufCtx.setTransform(dpr, 0, 0, dpr, 0, 0); var g = starBufCtx.createLinearGradient(0, 0, 0, H); g.addColorStop(0, '#03071a'); g.addColorStop(1, '#060d26'); starBufCtx.fillStyle = g; starBufCtx.fillRect(0, 0, W, H); var n = Math.floor(W * H / 900); for (var i = 0; i < n; i++) { starBufCtx.beginPath(); starBufCtx.arc(srand(i*2+1)*W, srand(i*2+2)*H, 0.4+srand(i*5+11)*1.1, 0, Math.PI*2); starBufCtx.fillStyle = 'rgba(255,255,255,' + (0.2+srand(i*3+7)*0.6) + ')'; starBufCtx.fill(); } } // ===== Render ===== function render() { if (!ctx) return; ctx.clearRect(0, 0, W, H); if (starBuf) ctx.drawImage(starBuf, 0, 0, W, H); computeView(); drawMoonOrbit(); drawTrajectory(); drawBody('earth'); drawBody('moon'); drawOrion(); drawLabels(); pulse += 0.04; if (pulse > Math.PI * 2) pulse -= Math.PI * 2; requestAnimationFrame(render); } function drawBody(which) { var isEarth = which === 'earth'; var p = isEarth ? earthPx : curMoonPx(); if (!p) return; var r = isEarth ? earthR : moonR; if (isEarth) { // Glow var g1 = ctx.createRadialGradient(p.x, p.y, r, p.x, p.y, r*2.5); g1.addColorStop(0, 'rgba(100,180,255,0.12)'); g1.addColorStop(0.6, 'rgba(60,140,255,0.03)'); g1.addColorStop(1, 'rgba(0,0,0,0)'); ctx.beginPath(); ctx.arc(p.x, p.y, r*2.5, 0, Math.PI*2); ctx.fillStyle = g1; ctx.fill(); // Body var b = ctx.createRadialGradient(p.x-r*0.3, p.y-r*0.3, r*0.1, p.x, p.y, r); b.addColorStop(0, '#4a9eff'); b.addColorStop(0.4, '#1a6dcc'); b.addColorStop(0.8, '#0e4a8a'); b.addColorStop(1, '#0a3366'); ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI*2); ctx.fillStyle = b; ctx.fill(); // Land ctx.save(); ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI*2); ctx.clip(); ctx.fillStyle = 'rgba(34,120,60,0.45)'; blobAt(p.x-r*0.35, p.y-r*0.15, r*0.25, r*0.5); blobAt(p.x+r*0.15, p.y-r*0.1, r*0.2, r*0.45); blobAt(p.x+r*0.35, p.y-r*0.3, r*0.35, r*0.3); ctx.restore(); // Atmosphere ctx.beginPath(); ctx.arc(p.x, p.y, r+2, 0, Math.PI*2); ctx.strokeStyle = 'rgba(100,180,255,0.2)'; ctx.lineWidth = 1.5; ctx.stroke(); } else { // Moon glow var g2 = ctx.createRadialGradient(p.x, p.y, r, p.x, p.y, r*2); g2.addColorStop(0, 'rgba(200,200,210,0.08)'); g2.addColorStop(1, 'rgba(0,0,0,0)'); ctx.beginPath(); ctx.arc(p.x, p.y, r*2, 0, Math.PI*2); ctx.fillStyle = g2; ctx.fill(); // Body var b2 = ctx.createRadialGradient(p.x-r*0.25, p.y-r*0.25, r*0.1, p.x, p.y, r); b2.addColorStop(0, '#d4d4d8'); b2.addColorStop(0.5, '#a1a1aa'); b2.addColorStop(1, '#71717a'); ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI*2); ctx.fillStyle = b2; ctx.fill(); // Craters ctx.fillStyle = 'rgba(80,80,90,0.3)'; [[0.2,-0.3,0.15],[-0.3,0.1,0.12],[0.1,0.35,0.1]].forEach(function(c) { ctx.beginPath(); ctx.arc(p.x+c[0]*r, p.y+c[1]*r, c[2]*r, 0, Math.PI*2); ctx.fill(); }); } // Label — positioned to avoid Orion // Defer to drawLabels() for smart placement } function blobAt(x, y, w, h) { ctx.beginPath(); ctx.ellipse(x, y, w, h, -0.2, 0, Math.PI*2); ctx.fill(); } // Draw faint Moon orbit arc from mission start to end function drawMoonOrbit() { if (hist.length < 2) return; ctx.save(); ctx.setLineDash([6, 10]); ctx.strokeStyle = 'rgba(200,200,220,0.4)'; ctx.lineWidth = 1.5; ctx.lineJoin = 'round'; ctx.beginPath(); var first = true; for (var i = 0; i < hist.length; i++) { var m = hist[i].moon; if (!m || m.x_km == null) continue; var p = kmToPx(m.x_km, m.y_km); if (first) { ctx.moveTo(p.x, p.y); first = false; } else ctx.lineTo(p.x, p.y); } ctx.stroke(); ctx.setLineDash([]); ctx.restore(); } // Compute max |Z| across all history points for depth scaling var maxAbsZ = 1; function updateMaxZ() { maxAbsZ = 1; for (var i = 0; i < hist.length; i++) { var az = Math.abs(hist[i].orion.z_km || 0); if (az > maxAbsZ) maxAbsZ = az; } } // Map |z| to an opacity multiplier: 1.0 (z=0, in-plane) → 0.35 (max depth) function depthAlpha(z) { var t = Math.abs(z || 0) / maxAbsZ; return 1.0 - t * 0.65; } // Collect runs of consecutive segments sharing the same phase color. // Each run becomes one continuous path so round lineCaps only appear // at the start/end of the run, not at every segment boundary. function trajRuns(start, end) { var runs = [], cur = null; for (var i = start; i < end; i++) { var c = phaseColorRGB(i); if (!cur || cur.color !== c) { cur = { color: c, from: i, to: i }; runs.push(cur); } else { cur.to = i; } } return runs; } function drawTrajectory() { if (hist.length < 2) return; var divIdx = (mode === 'HISTORICAL' || scrubbing) ? Math.min(Math.round(scrub), hist.length - 1) : nowIdx; ctx.save(); ctx.lineJoin = 'bevel'; ctx.lineCap = 'butt'; // Future portion: dashed, dim, phase-colored continuous paths if (divIdx < hist.length - 1) { var fRuns = trajRuns(divIdx, hist.length - 1); ctx.setLineDash([8, 6]); ctx.lineWidth = 2; for (var fi = 0; fi < fRuns.length; fi++) { var fr = fRuns[fi]; ctx.strokeStyle = 'rgba(' + fr.color + ',0.35)'; ctx.beginPath(); var fp = kmToPx(hist[fr.from].orion.x_km, hist[fr.from].orion.y_km); ctx.moveTo(fp.x, fp.y); for (var fk = fr.from; fk <= fr.to; fk++) { var fn = kmToPx(hist[fk + 1].orion.x_km, hist[fk + 1].orion.y_km); ctx.lineTo(fn.x, fn.y); } ctx.stroke(); } ctx.setLineDash([]); } // Traveled portion: solid, bright, phase-colored continuous paths if (divIdx > 0) { var tRuns = trajRuns(0, divIdx); ctx.lineWidth = 2.5; for (var ti = 0; ti < tRuns.length; ti++) { var tr = tRuns[ti]; ctx.strokeStyle = 'rgba(' + tr.color + ',0.9)'; ctx.beginPath(); var tp = kmToPx(hist[tr.from].orion.x_km, hist[tr.from].orion.y_km); ctx.moveTo(tp.x, tp.y); for (var tk = tr.from; tk <= tr.to; tk++) { var tn = kmToPx(hist[tk + 1].orion.x_km, hist[tk + 1].orion.y_km); ctx.lineTo(tn.x, tn.y); } ctx.stroke(); } } ctx.restore(); } function drawOrion() { var orion = curOrion(); if (!orion) return; var p = kmToPx(orion.x_km, orion.y_km); var r = W < 500 ? 6 : 8; var a = (mode === 'LIVE' && !scrubbing) ? 0.5 + 0.5 * Math.sin(pulse) : 0.85; // Glow var g = ctx.createRadialGradient(p.x, p.y, r, p.x, p.y, r*5); g.addColorStop(0, 'rgba(253,230,138,'+(a*0.5)+')'); g.addColorStop(1, 'rgba(253,230,138,0)'); ctx.beginPath(); ctx.arc(p.x, p.y, r*5, 0, Math.PI*2); ctx.fillStyle = g; ctx.fill(); // Dot ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI*2); ctx.fillStyle = 'rgba(253,230,138,'+a+')'; ctx.fill(); // Core ctx.beginPath(); ctx.arc(p.x, p.y, r*0.4, 0, Math.PI*2); ctx.fillStyle = '#fff'; ctx.fill(); // Labels deferred to drawLabels() } // ===== Smart Label Placement ===== // Collects all labels, picks best offset direction for each to avoid overlaps function drawLabels() { var orion = curOrion(); var moonPx = curMoonPx(); var orionPx = orion ? kmToPx(orion.x_km, orion.y_km) : null; var fs = W < 500 ? 10 : 13; var fsSmall = W < 500 ? 9 : 11; var pad = 6; // px gap between body edge and label var lh = fs + 2; // line height // Build list of body centers with radii (obstacles) var bodies = []; bodies.push({ x: earthPx.x, y: earthPx.y, r: earthR }); if (moonPx) bodies.push({ x: moonPx.x, y: moonPx.y, r: moonR }); if (orionPx) bodies.push({ x: orionPx.x, y: orionPx.y, r: (W < 500 ? 6 : 8) }); // Candidate offsets: below, above, right, left var dirs = [ { dx: 0, dy: 1, align: 'center' }, // below { dx: 0, dy: -1, align: 'center' }, // above { dx: 1, dy: 0, align: 'left' }, // right { dx: -1, dy: 0, align: 'right' } // left ]; // Placed label bounding boxes for collision checking var placed = []; function labelBox(cx, cy, textW, textH, align) { var lx; if (align === 'center') lx = cx - textW / 2; else if (align === 'left') lx = cx; else lx = cx - textW; return { x: lx, y: cy - textH, w: textW, h: textH }; } function overlaps(box) { // Check against all body circles for (var i = 0; i < bodies.length; i++) { var b = bodies[i]; var nearX = Math.max(box.x, Math.min(b.x, box.x + box.w)); var nearY = Math.max(box.y, Math.min(b.y, box.y + box.h)); var dx = b.x - nearX, dy = b.y - nearY; if (dx * dx + dy * dy < (b.r + 4) * (b.r + 4)) return true; } // Check against already placed labels for (var j = 0; j < placed.length; j++) { var p = placed[j]; if (box.x < p.x + p.w && box.x + box.w > p.x && box.y < p.y + p.h && box.y + box.h > p.y) return true; } return false; } function bestPos(cx, cy, r, textW, textH) { for (var i = 0; i < dirs.length; i++) { var d = dirs[i]; var lx = cx + d.dx * (r + pad + (d.dx !== 0 ? 4 : 0)); var ly = cy + d.dy * (r + pad + lh * 0.5); if (d.dy === -1) ly -= textH * 0.3; if (d.dy === 1) ly += textH + 2; var box = labelBox(lx, ly, textW, textH, d.align); if (!overlaps(box) && box.x > 0 && box.x + box.w < W && box.y > 0 && box.y + box.h < H) { return { x: lx, y: ly, align: d.align, box: box }; } } // Fallback: below var fy = cy + r + pad + lh * 0.5 + 2; var fbox = labelBox(cx, fy, textW, textH, 'center'); return { x: cx, y: fy, align: 'center', box: fbox }; } ctx.save(); // --- Earth label + distance --- ctx.font = fs + 'px "IBM Plex Sans",sans-serif'; var earthText = 'Earth'; var earthW = ctx.measureText(earthText).width; var earthDistText = orion && orion.distance_earth_km ? fmt(orion.distance_earth_km) + ' km' : ''; ctx.font = fsSmall + 'px "IBM Plex Mono",monospace'; var earthDistW = earthDistText ? ctx.measureText(earthDistText).width : 0; var earthBlockW = Math.max(earthW, earthDistW); var earthBlockH = earthDistText ? lh + fsSmall + 2 : lh; var ep = bestPos(earthPx.x, earthPx.y, earthR, earthBlockW, earthBlockH); ctx.textAlign = ep.align; ctx.font = fs + 'px "IBM Plex Sans",sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.fillText(earthText, ep.x, ep.y); if (earthDistText) { ctx.font = fsSmall + 'px "IBM Plex Mono",monospace'; ctx.fillStyle = 'rgba(134,239,172,0.5)'; ctx.fillText(earthDistText, ep.x, ep.y + fsSmall + 4); } placed.push(ep.box); // --- Moon label + distance --- if (moonPx) { ctx.font = fs + 'px "IBM Plex Sans",sans-serif'; var moonText = 'Moon'; var moonW = ctx.measureText(moonText).width; var moonDistText = orion && orion.distance_moon_km ? fmt(orion.distance_moon_km) + ' km' : ''; ctx.font = fsSmall + 'px "IBM Plex Mono",monospace'; var moonDistW = moonDistText ? ctx.measureText(moonDistText).width : 0; var moonBlockW = Math.max(moonW, moonDistW); var moonBlockH = moonDistText ? lh + fsSmall + 2 : lh; var mp2 = bestPos(moonPx.x, moonPx.y, moonR, moonBlockW, moonBlockH); ctx.textAlign = mp2.align; ctx.font = fs + 'px "IBM Plex Sans",sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.fillText(moonText, mp2.x, mp2.y); if (moonDistText) { ctx.font = fsSmall + 'px "IBM Plex Mono",monospace'; ctx.fillStyle = 'rgba(196,181,253,0.5)'; ctx.fillText(moonDistText, mp2.x, mp2.y + fsSmall + 4); } placed.push(mp2.box); } // --- Orion label --- if (orionPx) { var oR = W < 500 ? 6 : 8; ctx.font = 'bold ' + fs + 'px "IBM Plex Sans",sans-serif'; var orionText = 'Orion'; var orionW = ctx.measureText(orionText).width; var op = bestPos(orionPx.x, orionPx.y, oR, orionW, lh); ctx.textAlign = op.align; ctx.fillStyle = 'rgba(253,230,138,0.9)'; ctx.fillText(orionText, op.x, op.y); placed.push(op.box); } ctx.restore(); } // ===== Data Getters ===== function curOrion() { if (mode === 'LIVE' && scrubbing && hist.length) return hist[Math.min(Math.round(scrub), hist.length-1)].orion; if (mode === 'LIVE' && liveOrion) return liveOrion; if (mode === 'HISTORICAL' && hist.length) return hist[Math.min(Math.round(scrub), hist.length-1)].orion; if (mode === 'LIVE' && hist.length && nowIdx >= 0) return hist[Math.min(nowIdx, hist.length-1)].orion; return null; } function curMoon() { if (mode === 'LIVE' && scrubbing && hist.length) return hist[Math.min(Math.round(scrub), hist.length-1)].moon; if (mode === 'LIVE' && liveMoon) return liveMoon; if (mode === 'HISTORICAL' && hist.length) return hist[Math.min(Math.round(scrub), hist.length-1)].moon; if (mode === 'LIVE' && hist.length && nowIdx >= 0) return hist[Math.min(nowIdx, hist.length-1)].moon; return null; } // ===== Data Panels ===== function updPanels(time) { var m = selMission; if (!metV) return; // DOM not ready if (!m) { metV.textContent = '--:--:--'; eDist.textContent = '-- km'; mDist.textContent = '-- km'; velV.textContent = '-- km/s'; progV.textContent = '--'; progBar.style.width = '0%'; return; } var now = time || Date.now(); if ((mode === 'HISTORICAL' || scrubbing) && hist.length) { var idx = Math.min(Math.round(scrub), hist.length-1); now = new Date(hist[idx].t).getTime(); } metV.textContent = fmtMET(m.launch_utc, now); var lMs = new Date(m.launch_utc).getTime(), sMs = new Date(m.splashdown_utc).getTime(); var day = Math.max(1, Math.ceil((now-lMs)/864e5+0.01)); progV.textContent = 'Day '+day+' of '+Math.ceil((sMs-lMs)/864e5); progBar.style.width = Math.min(100, Math.max(0, getProgress(m, now))).toFixed(1)+'%'; var o = curOrion(); if (o) { eDist.textContent = fmt(o.distance_earth_km)+' km'; mDist.textContent = o.distance_moon_km ? fmt(o.distance_moon_km)+' km' : '-- km'; velV.textContent = o.speed_km_s ? o.speed_km_s.toFixed(2)+' km/s' : '-- km/s'; } if (mode === 'LIVE' && freshDot) { if (scrubbing) { freshDot.className = 'freshness-dot amber'; } else { var age = (Date.now()-lastFetch)/1000; freshDot.className = 'freshness-dot '+(age<120?'green':age<600?'amber':'red'); } } updOverlay(now); } // ===== Mission Info Overlay ===== function updOverlay(now) { var m = selMission; var moName = $('mo-name'), moBadge = $('mo-badge'), moLaunch = $('mo-launch'); var moSplash = $('mo-splash'), moDur = $('mo-duration'), moPhase = $('mo-phase'); var moNext = $('mo-next'), moMax = $('mo-maxdist'); var moCrewSec = $('mo-crew-section'), moCrew = $('mo-crew'); if (!moName) return; if (!m) { $('artemis-side-panel').style.opacity = '0.4'; return; } $('artemis-side-panel').style.opacity = ''; moName.textContent = m.name; if (m.status === 'active') { moBadge.textContent = 'LIVE'; moBadge.className = 'sp-badge live'; } else { moBadge.textContent = m.status.toUpperCase(); moBadge.className = 'sp-badge completed'; } // Launch & splashdown formatted var ld = new Date(m.launch_utc), sd = new Date(m.splashdown_utc); moLaunch.textContent = fmtDate(ld); moSplash.textContent = fmtDate(sd); // Duration var durDays = Math.round((sd.getTime() - ld.getTime()) / 864e5); moDur.textContent = durDays + ' days'; // Phase & next phase if (!m.phases || !m.phases.length) { moPhase.textContent = '--'; moNext.textContent = '--'; } else { var days = (now - ld.getTime()) / 864e5; var curIdx = 0; for (var i = 0; i < m.phases.length; i++) if (days >= m.phases[i].start_day) curIdx = i; var curColor = m.phases[curIdx].color || '#6ee7f7'; var dot = ''; moPhase.innerHTML = dot + '' + m.phases[curIdx].name + ''; if (curIdx + 1 < m.phases.length) { var nextPhase = m.phases[curIdx + 1]; var nextColor = nextPhase.color || '#0dcaf0'; var nextDot = ''; var nextMs = ld.getTime() + nextPhase.start_day * 864e5; var remain = nextMs - now; if (remain > 0) { var rH = Math.floor(remain / 36e5), rM = Math.floor((remain % 36e5) / 6e4); var rD = Math.floor(rH / 24); rH = rH % 24; moNext.innerHTML = nextDot + '' + nextPhase.name + ' in ' + (rD > 0 ? rD + 'd ' : '') + rH + 'h ' + rM + 'm'; } else moNext.innerHTML = nextDot + '' + nextPhase.name + ''; } else moNext.textContent = 'Final phase'; } // Max distance moMax.textContent = m.max_dist_km ? fmt(m.max_dist_km) + ' km' : '--'; // Crew if (m.crewed && m.crew && m.crew.length) { moCrewSec.style.display = ''; moCrew.innerHTML = m.crew.map(function(c) { return '' + c.name + ''; }).join(''); } else moCrewSec.style.display = 'none'; // Phase legend var legSec = $('mo-legend-section'), legEl = $('mo-legend'); if (legSec && legEl && m.phases && m.phases.length) { legSec.style.display = ''; legEl.innerHTML = m.phases.map(function(p) { var c = p.color || '#6ee7f7'; return '