import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'; const MOON_VISUAL_YAW_DEG = 180; function normalizeLonDeg(lon) { return ((((lon + 180) % 360) + 360) % 360) - 180; } function applyClippingPlanes(object3d, planes) { if (!object3d) return; object3d.traverse((child) => { const mat = child?.material; if (!mat) return; const mats = Array.isArray(mat) ? mat : [mat]; for (const m of mats) { if (!m) continue; m.clippingPlanes = planes; m.clipIntersection = false; m.needsUpdate = true; } }); } function parseCsvLine(line) { const out = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (inQuotes) { if (ch === '"') { if (line[i + 1] === '"') { current += '"'; i++; } else { inQuotes = false; } } else { current += ch; } continue; } if (ch === '"') { inQuotes = true; continue; } if (ch === ',') { out.push(current); current = ''; continue; } current += ch; } out.push(current); return out; } function parseSingleRowCsv(text) { const lines = String(text || '') .replace(/^\uFEFF/, '') .split(/\r?\n/) .map((l) => l.trim()) .filter(Boolean); if (lines.length < 2) throw new Error('CSV missing rows'); const keys = parseCsvLine(lines[0]).map((s) => s.trim()); const values = parseCsvLine(lines[1]); const row = {}; for (let i = 0; i < keys.length; i++) row[keys[i]] = (values[i] ?? '').trim(); return row; } function parseMultiRowCsv(text) { const lines = String(text || '') .replace(/^\uFEFF/, '') .split(/\r?\n/) .map((l) => l.trim()) .filter(Boolean); if (lines.length < 2) return []; const keys = parseCsvLine(lines[0]).map((s) => s.trim()); const rows = []; for (let i = 1; i < lines.length; i++) { const values = parseCsvLine(lines[i]); const row = {}; for (let k = 0; k < keys.length; k++) row[keys[k]] = (values[k] ?? '').trim(); rows.push(row); } return rows; } function parseFirstNumber(s) { const m = String(s || '').match(/[-+]?\d+(?:\.\d+)?/); if (!m) return null; const n = parseFloat(m[0]); return Number.isFinite(n) ? n : null; } let scene, camera, renderer, controls, moon, moonVisual, moonTexture, impacts = []; let impactsData = []; let impactsGroup; let gridGroup; let gridVisible = false; let raycaster, mouse; let hoveredImpact = null; let moonSurfaceObjects = []; let coordTooltipEl = null; let loadingEl = null; let loadingTextEl = null; let loadingBarEl = null; let _pointerDown = null; let impactAnimationEnabled = true; let magRMin = 6; let magRMax = 12; let magIMin = 6; let magIMax = 12; let magAvgMin = 6; let magAvgMax = 12; let durationMin = 0.01; let durationMax = 0.20; const _tmpMoonCenter = new THREE.Vector3(); const _tmpCamPos = new THREE.Vector3(); const _tmpToCam = new THREE.Vector3(); const _tmpToMarker = new THREE.Vector3(); const _tmpMarkerWorld = new THREE.Vector3(); const _tmpViewDir = new THREE.Vector3(); const _gridClipPlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0); function updateDurationRange(data) { let localMin = Number.POSITIVE_INFINITY; let localMax = Number.NEGATIVE_INFINITY; for (const item of Array.isArray(data) ? data : []) { const d = item?.duration; if (!Number.isFinite(d)) continue; localMin = Math.min(localMin, d); localMax = Math.max(localMax, d); } if (Number.isFinite(localMin) && Number.isFinite(localMax) && localMin < localMax) { durationMin = localMin; durationMax = localMax; } } function getAverageMag(magR, magI) { const r = Number.isFinite(magR) ? magR : null; const i = Number.isFinite(magI) ? magI : null; if (r != null && i != null) return (r + i) / 2; if (r != null) return r; if (i != null) return i; return null; } function updateMagnitudeRanges(data) { let localRMin = Number.POSITIVE_INFINITY; let localRMax = Number.NEGATIVE_INFINITY; let localIMin = Number.POSITIVE_INFINITY; let localIMax = Number.NEGATIVE_INFINITY; let localAvgMin = Number.POSITIVE_INFINITY; let localAvgMax = Number.NEGATIVE_INFINITY; for (const item of Array.isArray(data) ? data : []) { const r = item?.magR; const i = item?.magI; if (Number.isFinite(r)) { localRMin = Math.min(localRMin, r); localRMax = Math.max(localRMax, r); } if (Number.isFinite(i)) { localIMin = Math.min(localIMin, i); localIMax = Math.max(localIMax, i); } const avg = getAverageMag(r, i); if (Number.isFinite(avg)) { localAvgMin = Math.min(localAvgMin, avg); localAvgMax = Math.max(localAvgMax, avg); } } if (Number.isFinite(localRMin) && Number.isFinite(localRMax) && localRMin < localRMax) { magRMin = localRMin; magRMax = localRMax; } if (Number.isFinite(localIMin) && Number.isFinite(localIMax) && localIMin < localIMax) { magIMin = localIMin; magIMax = localIMax; } if (Number.isFinite(localAvgMin) && Number.isFinite(localAvgMax) && localAvgMin < localAvgMax) { magAvgMin = localAvgMin; magAvgMax = localAvgMax; } } async function loadEventImpact(eventId) { try { return await loadEventImpactFromCsv(eventId); } catch (e) { return await loadEventImpactFromHtml(eventId); } } async function loadEventImpactFromCsv(eventId) { const csvPath = `data/event-${eventId}/event-${eventId}.csv`; const res = await fetch(csvPath, { cache: 'no-store' }); if (!res.ok) { throw new Error(`Event ${eventId}: failed to fetch ${csvPath} (${res.status})`); } const csvText = await res.text(); const row = parseSingleRowCsv(csvText); const latitude = parseFloat(row.lunar_lat_deg); const longitude = parseFloat(row.lunar_long_deg); if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { throw new Error(`Event ${eventId}: failed to parse lunar lat/lon from CSV`); } const magRText = (row.mag_r || '').trim(); const magIText = (row.mag_i || '').trim(); const durationText = (row.duration_sec || '').trim(); const magR = parseFirstNumber(magRText); const magI = parseFirstNumber(magIText); const duration = parseFirstNumber(durationText); return { id: eventId, csvId: (row.id || '').trim(), dateTime: row.ut_date && row.ut_time ? `${row.ut_date} ${row.ut_time}` : '', date: (row.ut_date || '').trim(), time: (row.ut_time || '').trim(), duration: Number.isFinite(duration) ? duration : null, magR: Number.isFinite(magR) ? magR : null, magI: Number.isFinite(magI) ? magI : null, durationText: durationText || null, magRText: magRText || null, magIText: magIText || null, peakMagR: Number.isFinite(magR) ? magR : null, peakMagI: Number.isFinite(magI) ? magI : null, observer: 'NELIOTA', classification: '', latitude, longitude, airmassText: (row.airmass || '').trim(), altitudeDegText: (row.altitude_deg || '').trim(), azimuthDegText: (row.azimuth_deg || '').trim(), numberOfCamerasText: (row.number_of_cameras || '').trim(), mediaSizeMbText: (row.media_size_mb || '').trim(), size: 4 }; } async function loadAllImpactsFromCombinedCsv() { const res = await fetch('data/events-all.csv', { cache: 'no-store' }); if (!res.ok) throw new Error(`Failed to fetch data/events-all.csv (${res.status})`); const csvText = await res.text(); const rows = parseMultiRowCsv(csvText); const impacts = []; for (const row of rows) { const eventId = parseInt(row.event_num, 10); if (!Number.isFinite(eventId)) continue; const latitude = parseFloat(row.lunar_lat_deg); const longitude = parseFloat(row.lunar_long_deg); if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) continue; const magRText = (row.mag_r || '').trim(); const magIText = (row.mag_i || '').trim(); const durationText = (row.duration_sec || '').trim(); const magR = parseFirstNumber(magRText); const magI = parseFirstNumber(magIText); const duration = parseFirstNumber(durationText); impacts.push({ id: eventId, csvId: (row.id || '').trim(), dateTime: row.ut_date && row.ut_time ? `${row.ut_date} ${row.ut_time}` : '', date: (row.ut_date || '').trim(), time: (row.ut_time || '').trim(), duration: Number.isFinite(duration) ? duration : null, magR: Number.isFinite(magR) ? magR : null, magI: Number.isFinite(magI) ? magI : null, durationText: durationText || null, magRText: magRText || null, magIText: magIText || null, peakMagR: Number.isFinite(magR) ? magR : null, peakMagI: Number.isFinite(magI) ? magI : null, observer: 'NELIOTA', classification: '', latitude, longitude, airmassText: (row.airmass || '').trim(), altitudeDegText: (row.altitude_deg || '').trim(), azimuthDegText: (row.azimuth_deg || '').trim(), numberOfCamerasText: (row.number_of_cameras || '').trim(), mediaSizeMbText: (row.media_size_mb || '').trim(), size: 4 }); } return impacts; } async function loadEventImpactFromHtml(eventId) { const path = `data/event-${eventId}/event-${eventId}.html`; const res = await fetch(path, { cache: 'no-store' }); if (!res.ok) { throw new Error(`Event ${eventId}: failed to fetch ${path} (${res.status})`); } const htmlText = await res.text(); const doc = new DOMParser().parseFromString(htmlText, 'text/html'); const extractNumberFromHtml = (label) => { const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const re = new RegExp(`\\s*\\s*${escaped}\\s*:?\\s*<\\/b>\\s*<\\/td>\\s*\\s*([-+]?\\d*\\.?\\d+)`, 'i'); const m = htmlText.match(re); return m ? parseFloat(m[1]) : null; }; const getValueForLabelFromHtml = (label) => { const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const re = new RegExp(`\\s*\\s*${escaped}\\s*:?\\s*<\\/b>\\s*<\\/td>\\s*([\\s\\S]*?)<\\/td>`, 'i'); const m = htmlText.match(re); if (!m) return ''; return m[1] .replace(/<[^>]*>/g, ' ') .replace(/ /gi, ' ') .replace(/±/gi, '±') .replace(/\s+/g, ' ') .trim(); }; const getValueForLabel = (label) => { const tds = Array.from(doc.querySelectorAll('td')); const normalizedLabel = label.toLowerCase(); for (let i = 0; i < tds.length - 1; i++) { const left = (tds[i].textContent || '').replace(/\s+/g, ' ').trim().toLowerCase(); if (!left) continue; if (left.includes(normalizedLabel)) { return (tds[i + 1].textContent || '').replace(/\s+/g, ' ').trim(); } } return ''; }; const dateRaw = getValueForLabel('ut date') || getValueForLabelFromHtml('UT Date (DD/MM/YYYY)'); const timeRaw = getValueForLabel('ut time') || getValueForLabelFromHtml('UT Time'); const magRRaw = getValueForLabel('r (mag)') || getValueForLabelFromHtml('R (mag)'); const magIRaw = getValueForLabel('i (mag)') || getValueForLabelFromHtml('I (mag)'); const lonRaw = getValueForLabel('lunar long') || getValueForLabelFromHtml('Lunar Long (deg)'); const latRaw = getValueForLabel('lunar lat') || getValueForLabelFromHtml('Lunar Lat (deg)'); const durationRaw = getValueForLabel('duration (sec)') || getValueForLabelFromHtml('Duration (sec)'); const observerRaw = getValueForLabel('observer') || getValueForLabelFromHtml('Observer'); const peakMagRRaw = getValueForLabel('peak mag r') || getValueForLabelFromHtml('Peak mag R') || magRRaw; const peakMagIRaw = getValueForLabel('peak mag i') || getValueForLabelFromHtml('Peak mag I') || magIRaw; const pageTitleText = (doc.querySelector('#pageTitle')?.textContent || '').replace(/\s+/g, ' ').trim(); let latitude = parseFloat(latRaw); let longitude = parseFloat(lonRaw); if (!Number.isFinite(latitude)) { const latFromHtml = extractNumberFromHtml('Lunar Lat (deg)'); if (Number.isFinite(latFromHtml)) latitude = latFromHtml; } if (!Number.isFinite(longitude)) { const lonFromHtml = extractNumberFromHtml('Lunar Long (deg)'); if (Number.isFinite(lonFromHtml)) longitude = lonFromHtml; } const durationText = (durationRaw || '').trim(); const magRText = (magRRaw || '').trim(); const magIText = (magIRaw || '').trim(); const duration = parseFloat(durationText); const magR = parseFloat(magRText); const magI = parseFloat(magIText); const peakMagR = parseFloat(peakMagRRaw); const peakMagI = parseFloat(peakMagIRaw); if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { throw new Error(`Event ${eventId}: failed to parse lunar lat/lon`); } const classification = (() => { const t = pageTitleText.toLowerCase(); if (t.includes('neo')) return 'NEO'; if (pageTitleText) return pageTitleText; return ''; })(); return { id: eventId, csvId: '', dateTime: dateRaw && timeRaw ? `${dateRaw} ${timeRaw}` : '', date: dateRaw ? dateRaw.trim() : '', time: timeRaw ? timeRaw.trim() : '', duration: Number.isFinite(duration) ? duration : null, magR: Number.isFinite(magR) ? magR : null, magI: Number.isFinite(magI) ? magI : null, durationText: durationText || null, magRText: magRText || null, magIText: magIText || null, peakMagR: Number.isFinite(peakMagR) ? peakMagR : (Number.isFinite(magR) ? magR : null), peakMagI: Number.isFinite(peakMagI) ? peakMagI : (Number.isFinite(magI) ? magI : null), observer: observerRaw ? observerRaw.trim() : 'NELIOTA', classification, latitude, longitude, airmassText: '', altitudeDegText: '', azimuthDegText: '', numberOfCamerasText: '', mediaSizeMbText: '', size: 4 }; } async function loadLocalEvents(eventIds) { const results = await Promise.all( (Array.isArray(eventIds) ? eventIds : []).map(async (id) => { try { return await loadEventImpact(id); } catch (e) { console.error(e); return null; } }) ); return results.filter(Boolean); } async function loadNeliotaImpacts() { const res = await fetch('NELIOTA_LIFs_2017-2023.xml', { cache: 'no-store' }); const xmlText = await res.text(); const doc = new DOMParser().parseFromString(xmlText, 'application/xml'); const rows = Array.from(doc.getElementsByTagName('TR')); const impacts = []; let localMagRMin = Number.POSITIVE_INFINITY; let localMagRMax = Number.NEGATIVE_INFINITY; let localMagIMin = Number.POSITIVE_INFINITY; let localMagIMax = Number.NEGATIVE_INFINITY; let localDurationMin = Number.POSITIVE_INFINITY; let localDurationMax = Number.NEGATIVE_INFINITY; let localMagAvgMin = Number.POSITIVE_INFINITY; let localMagAvgMax = Number.NEGATIVE_INFINITY; for (let i = 0; i < rows.length; i++) { const tds = Array.from(rows[i].getElementsByTagName('TD')).map((td) => td.textContent?.trim() ?? ''); if (tds.length < 6) continue; const dateTimeRaw = tds[0]; const durationRaw = tds[1]; const magRRaw = tds[2]; const magIRaw = tds[3]; const latRaw = tds[4]; const lonRaw = tds[5]; const duration = parseFloat(durationRaw); const magR = parseFloat(magRRaw); const magI = parseFloat(magIRaw); const latitude = parseFloat(latRaw); const longitude = parseFloat(lonRaw); if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) continue; if (Number.isFinite(magR)) { localMagRMin = Math.min(localMagRMin, magR); localMagRMax = Math.max(localMagRMax, magR); } if (Number.isFinite(magI)) { localMagIMin = Math.min(localMagIMin, magI); localMagIMax = Math.max(localMagIMax, magI); } const magAvg = getAverageMag(magR, magI); if (Number.isFinite(magAvg)) { localMagAvgMin = Math.min(localMagAvgMin, magAvg); localMagAvgMax = Math.max(localMagAvgMax, magAvg); } if (Number.isFinite(duration)) { localDurationMin = Math.min(localDurationMin, duration); localDurationMax = Math.max(localDurationMax, duration); } impacts.push({ id: i + 1, dateTime: dateTimeRaw, duration: Number.isFinite(duration) ? duration : null, magR: Number.isFinite(magR) ? magR : null, magI: Number.isFinite(magI) ? magI : null, latitude, longitude, size: 4 }); } if (Number.isFinite(localMagRMin) && Number.isFinite(localMagRMax) && localMagRMin < localMagRMax) { magRMin = localMagRMin; magRMax = localMagRMax; } if (Number.isFinite(localMagIMin) && Number.isFinite(localMagIMax) && localMagIMin < localMagIMax) { magIMin = localMagIMin; magIMax = localMagIMax; } if (Number.isFinite(localMagAvgMin) && Number.isFinite(localMagAvgMax) && localMagAvgMin < localMagAvgMax) { magAvgMin = localMagAvgMin; magAvgMax = localMagAvgMax; } if (Number.isFinite(localDurationMin) && Number.isFinite(localDurationMax) && localDurationMin < localDurationMax) { durationMin = localDurationMin; durationMax = localDurationMax; } return impacts; } function init() { THREE.Cache.enabled = true; scene = new THREE.Scene(); scene.background = new THREE.Color(0x000011); camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000); camera.position.z = 5; const canvasEl = document.getElementById('canvas'); if (!canvasEl) return; renderer = new THREE.WebGLRenderer({ canvas: canvasEl, antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.2; renderer.localClippingEnabled = true; controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.05; controls.minDistance = 2; controls.maxDistance = 15; const ambientLight = new THREE.AmbientLight(0xffffff, 1.0); scene.add(ambientLight); const hemisphereLight = new THREE.HemisphereLight(0xffffff, 0x1a2233, 0.9); scene.add(hemisphereLight); const keyLight = new THREE.DirectionalLight(0xffffff, 3.0); keyLight.position.set(8, 5, 8); scene.add(keyLight); const fillLight = new THREE.DirectionalLight(0xffffff, 1.0); fillLight.position.set(-6, 2, -8); scene.add(fillLight); const cameraLight = new THREE.PointLight(0xffffff, 1.2, 100); camera.add(cameraLight); scene.add(camera); raycaster = new THREE.Raycaster(); mouse = new THREE.Vector2(); coordTooltipEl = document.getElementById('coord-tooltip'); loadingEl = document.getElementById('loading'); loadingTextEl = document.getElementById('loading-text'); loadingBarEl = document.getElementById('loading-bar'); createMoon(); addStars(); loadAllImpactsFromCombinedCsv() .then((data) => { impactsData = data; updateMagnitudeRanges(data); updateDurationRange(data); createImpacts(data); }) .catch(() => { impactsData = []; createImpacts([]); }); window.addEventListener('resize', onWindowResize); renderer.domElement.addEventListener('pointermove', onPointerMove, { passive: true }); renderer.domElement.addEventListener('pointerdown', onPointerDown, { passive: true }); renderer.domElement.addEventListener('pointerup', onPointerUp, { passive: true }); setupGridToggle(); setupImpactAnimationToggle(); setupHelpMenu(); setupInfoCollapse(); animate(); } function setLoadingVisible(visible) { if (!loadingEl) return; loadingEl.style.display = visible ? 'flex' : 'none'; } function setLoadingProgress(pct, text) { if (loadingTextEl && typeof text === 'string') loadingTextEl.textContent = text; if (!loadingBarEl) return; if (Number.isFinite(pct)) { loadingBarEl.classList.remove('indeterminate'); const v = THREE.MathUtils.clamp(pct, 0, 100); loadingBarEl.style.width = `${v}%`; return; } loadingBarEl.classList.add('indeterminate'); loadingBarEl.style.width = '40%'; } function createMoon() { const desiredRadius = 1.5; moon = new THREE.Group(); scene.add(moon); moonVisual = new THREE.Group(); moonVisual.rotation.y = THREE.MathUtils.degToRad(MOON_VISUAL_YAW_DEG); moon.add(moonVisual); impactsGroup = new THREE.Group(); moon.add(impactsGroup); gridGroup = createLatLonGrid(desiredRadius + 0.02); gridGroup.visible = gridVisible; moon.add(gridGroup); applyClippingPlanes(gridGroup, [_gridClipPlane]); const geometry = new THREE.SphereGeometry(desiredRadius, 256, 256); const textureLoader = new THREE.TextureLoader(); const material = new THREE.MeshPhongMaterial({ color: 0xcccccc, shininess: 5, specular: 0x333333 }); moonTexture = textureLoader.load( 'textures/moon_1024.jpg', (texture) => { const maxAnisotropy = renderer && renderer.capabilities && typeof renderer.capabilities.getMaxAnisotropy === 'function' ? renderer.capabilities.getMaxAnisotropy() : 1; texture.anisotropy = maxAnisotropy; texture.minFilter = THREE.LinearMipmapLinearFilter; texture.magFilter = THREE.LinearFilter; texture.generateMipmaps = true; texture.colorSpace = THREE.SRGBColorSpace; material.map = texture; material.color.set(0xffffff); material.needsUpdate = true; }, undefined, () => { material.color.set(0x999999); } ); const fallbackMoon = new THREE.Mesh(geometry, material); moonVisual.add(fallbackMoon); moonSurfaceObjects = [fallbackMoon]; const isMobileDevice = (() => { const ua = navigator.userAgent || ''; const coarse = typeof window.matchMedia === 'function' && window.matchMedia('(pointer: coarse)').matches; const smallScreen = Math.min(window.innerWidth || 9999, window.innerHeight || 9999) <= 820; return coarse || /Android|iPhone|iPad|iPod|Mobile/i.test(ua) || smallScreen; })(); const modelUrls = ['models/moon.glb']; const dracoLoader = new DRACOLoader(); dracoLoader.setDecoderPath('https://unpkg.com/three@0.160.0/examples/jsm/libs/draco/'); const gltfLoader = new GLTFLoader(); gltfLoader.setDRACOLoader(dracoLoader); let glbInProgress = false; let glbCurrentUrl = ''; let glbLastPct = 0; const loadMoonGlb = (urls, urlIndex = 0, attempt = 0) => { const url = urls[urlIndex]; if (!url) return; if (glbInProgress && glbCurrentUrl === url) return; glbInProgress = true; glbCurrentUrl = url; glbLastPct = 0; setLoadingVisible(true); setLoadingProgress(null, 'Loading Moon Model…'); gltfLoader.load( url, (gltf) => { const object = gltf.scene; const surfaceMeshes = []; object.traverse((child) => { if (!child.isMesh || !child.material) return; surfaceMeshes.push(child); const materials = Array.isArray(child.material) ? child.material : [child.material]; for (const m of materials) { m.side = THREE.FrontSide; if ('metalness' in m) m.metalness = 0; if ('roughness' in m) m.roughness = 1; if (m.map) { m.map.colorSpace = THREE.SRGBColorSpace; const maxAnisotropy = renderer && renderer.capabilities && typeof renderer.capabilities.getMaxAnisotropy === 'function' ? renderer.capabilities.getMaxAnisotropy() : 1; m.map.anisotropy = maxAnisotropy; } m.needsUpdate = true; } }); const box = new THREE.Box3().setFromObject(object); const size = new THREE.Vector3(); box.getSize(size); const radius = Math.max(size.x, size.y, size.z) / 2; if (Number.isFinite(radius) && radius > 0) { const scale = desiredRadius / radius; object.scale.setScalar(scale); } const centeredBox = new THREE.Box3().setFromObject(object); const center = new THREE.Vector3(); centeredBox.getCenter(center); object.position.sub(center); moonVisual.remove(fallbackMoon); moonVisual.add(object); moonSurfaceObjects = surfaceMeshes; glbInProgress = false; glbLastPct = 100; setLoadingVisible(false); }, (xhr) => { if (xhr && xhr.total) { const pct = Math.round((xhr.loaded / xhr.total) * 100); if (Number.isFinite(pct)) { glbLastPct = pct; setLoadingProgress(pct, `Loading… ${pct}%`); } } }, (err) => { glbInProgress = false; const isLikelyDecodeOrMemoryFailure = glbLastPct >= 99; const maxAttempts = isLikelyDecodeOrMemoryFailure ? 1 : 3; const nextAttempt = attempt + 1; if (nextAttempt < maxAttempts) { setLoadingProgress(0, `Retrying… (${nextAttempt}/${maxAttempts - 1})`); window.setTimeout(() => loadMoonGlb(urls, urlIndex, nextAttempt), 400 * nextAttempt); return; } if (isLikelyDecodeOrMemoryFailure) { setLoadingProgress(0, 'Model too large for this device. Using fallback.'); } else { setLoadingProgress(0, 'Failed to load Moon model. Using fallback.'); } window.setTimeout(() => setLoadingVisible(false), 900); loadMoonGlb(urls, urlIndex + 1, 0); } ); }; loadMoonGlb(modelUrls, 0, 0); } function addStars() { const starsGeometry = new THREE.BufferGeometry(); const starsMaterial = new THREE.PointsMaterial({ color: 0xffffff, size: 0.01 }); const starsVertices = []; for (let i = 0; i < 5000; i++) { const x = (Math.random() - 0.5) * 100; const y = (Math.random() - 0.5) * 100; const z = (Math.random() - 0.5) * 100; starsVertices.push(x, y, z); } starsGeometry.setAttribute('position', new THREE.Float32BufferAttribute(starsVertices, 3)); const starField = new THREE.Points(starsGeometry, starsMaterial); scene.add(starField); } function latLonToVector3(lat, lon, radius) { const latRad = THREE.MathUtils.degToRad(lat); const lonRad = THREE.MathUtils.degToRad(lon); const cosLat = Math.cos(latRad); const sinLat = Math.sin(latRad); const cosLon = Math.cos(lonRad); const sinLon = Math.sin(lonRad); const x = radius * cosLat * sinLon; const y = radius * sinLat; const z = radius * cosLat * cosLon; return new THREE.Vector3(x, y, z); } function createLatLonGrid(radius) { const group = new THREE.Group(); const material = new THREE.LineBasicMaterial({ color: 0x88ccff, transparent: true, opacity: 0.6, depthTest: false, depthWrite: false }); const createLabelSprite = (text) => { const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1)); const fontSize = 36; const padding = 18; const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); if (!ctx) return null; ctx.font = `600 ${fontSize}px Segoe UI, Arial, sans-serif`; const metrics = ctx.measureText(text); const textWidth = Math.ceil(metrics.width); canvas.width = Math.ceil((textWidth + padding * 2) * dpr); canvas.height = Math.ceil((fontSize + padding * 2) * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = 'rgba(0, 0, 0, 0.55)'; ctx.strokeStyle = 'rgba(255, 255, 255, 0.20)'; ctx.lineWidth = 2; const w = (textWidth + padding * 2); const h = (fontSize + padding * 2); const r = 10; ctx.beginPath(); ctx.moveTo(r, 0); ctx.lineTo(w - r, 0); ctx.quadraticCurveTo(w, 0, w, r); ctx.lineTo(w, h - r); ctx.quadraticCurveTo(w, h, w - r, h); ctx.lineTo(r, h); ctx.quadraticCurveTo(0, h, 0, h - r); ctx.lineTo(0, r); ctx.quadraticCurveTo(0, 0, r, 0); ctx.closePath(); ctx.fill(); ctx.stroke(); ctx.fillStyle = 'rgba(255, 255, 255, 0.95)'; ctx.textBaseline = 'middle'; ctx.textAlign = 'center'; ctx.fillText(text, w / 2, h / 2 + 1); const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; texture.generateMipmaps = false; texture.minFilter = THREE.LinearFilter; texture.magFilter = THREE.LinearFilter; const spriteMaterial = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false, depthWrite: false }); const sprite = new THREE.Sprite(spriteMaterial); const aspect = canvas.width / canvas.height; const height = radius * 0.085; sprite.scale.set(height * aspect, height, 1); sprite.renderOrder = 11; sprite.frustumCulled = false; return sprite; }; const segmentsAround = 256; const segmentsMeridian = 128; const stepDeg = 10; const addLine = (points) => { const geometry = new THREE.BufferGeometry().setFromPoints(points); const line = new THREE.Line(geometry, material); line.frustumCulled = false; line.renderOrder = 10; group.add(line); }; for (let lat = -80; lat <= 80; lat += stepDeg) { const points = []; for (let i = 0; i <= segmentsAround; i++) { const lon = (i / segmentsAround) * 360 - 180; points.push(latLonToVector3(lat, lon, radius)); } addLine(points); } for (let lon = -180; lon < 180; lon += stepDeg) { const points = []; const lonStd = lon; for (let i = 0; i <= segmentsMeridian; i++) { const lat = (i / segmentsMeridian) * 180 - 90; points.push(latLonToVector3(lat, lonStd, radius)); } addLine(points); } const labelRadius = radius + 0.035; for (let lat = -80; lat <= 80; lat += stepDeg) { if (lat === 0) continue; const sprite = createLabelSprite(`${lat}°`); if (!sprite) continue; sprite.position.copy(latLonToVector3(lat, 0, labelRadius)); group.add(sprite); } for (let lon = -180; lon < 180; lon += stepDeg) { if (lon === 0) continue; const lonLabel = lon === -180 ? 180 : lon; const sprite = createLabelSprite(`${lonLabel}°`); if (!sprite) continue; sprite.position.copy(latLonToVector3(0, lon, labelRadius)); group.add(sprite); } group.frustumCulled = false; return group; } function setupGridToggle() { const btn = document.getElementById('grid-toggle'); if (!btn) return; const updateLabel = () => { btn.textContent = gridVisible ? 'Hide Lat/Lon Grid' : 'Show Lat/Lon Grid'; }; updateLabel(); btn.addEventListener('click', () => { if (!impactAnimationEnabled) return; gridVisible = !gridVisible; if (gridGroup) gridGroup.visible = gridVisible; updateLabel(); }); } function setupImpactAnimationToggle() { const btn = document.getElementById('impact-anim-toggle'); if (!btn) return; const updateLabel = () => { btn.textContent = impactAnimationEnabled ? 'Hide Overlays' : 'Show Overlays'; }; const clearActiveExplosions = () => { impacts.forEach((item) => { if (item.explosion && item.explosion.group) { (impactsGroup ?? scene).remove(item.explosion.group); } item.explosion = null; item.explosionActive = false; if (item.glow) item.glow.scale.set(1, 1, 1); }); }; updateLabel(); if (impactsGroup) impactsGroup.visible = impactAnimationEnabled; const gridBtn = document.getElementById('grid-toggle'); if (gridBtn) gridBtn.disabled = !impactAnimationEnabled; btn.addEventListener('click', () => { impactAnimationEnabled = !impactAnimationEnabled; if (!impactAnimationEnabled) { clearActiveExplosions(); hoveredImpact = null; document.body.style.cursor = 'default'; gridVisible = false; if (gridGroup) gridGroup.visible = false; if (coordTooltipEl) coordTooltipEl.style.display = 'none'; const detailsDiv = document.getElementById('details'); if (detailsDiv) { detailsDiv.classList.remove('visible'); detailsDiv.classList.remove('bump'); detailsDiv.style.display = 'none'; } } if (impactsGroup) impactsGroup.visible = impactAnimationEnabled; if (impactAnimationEnabled && impactsGroup && impactsGroup.children.length === 0 && impactsData && impactsData.length) { createImpacts(impactsData); } if (gridBtn) { gridBtn.textContent = 'Show Lat/Lon Grid'; gridBtn.disabled = !impactAnimationEnabled; } updateLabel(); }); } function magnitudeToStrength(mag, minMag, maxMag) { if (!Number.isFinite(mag)) return 0.5; if (minMag === maxMag) return 0.5; return THREE.MathUtils.clamp((maxMag - mag) / (maxMag - minMag), 0, 1); } function durationToStrength(durationSec, minSec, maxSec) { if (!Number.isFinite(durationSec)) return 0.5; if (minSec === maxSec) return 0.5; return THREE.MathUtils.clamp((durationSec - minSec) / (maxSec - minSec), 0, 1); } function colorFromAverageMagnitude(magR, magI) { const avg = getAverageMag(magR, magI); const yellow = new THREE.Color(0xffd84a); const red = new THREE.Color(0xff2b2b); if (!Number.isFinite(avg) || magAvgMin === magAvgMax) return yellow.clone(); const u = THREE.MathUtils.clamp((avg - magAvgMin) / (magAvgMax - magAvgMin), 0, 1); return yellow.lerp(red, u); } function sizeFromDuration(durationSec) { const t = durationToStrength(durationSec, durationMin, durationMax); return THREE.MathUtils.lerp(2.5, 10, t); } let impactParticleTexture = null; function getImpactParticleTexture() { if (impactParticleTexture) return impactParticleTexture; const canvas = document.createElement('canvas'); canvas.width = 96; canvas.height = 96; const ctx = canvas.getContext('2d'); const gradient = ctx.createRadialGradient(48, 48, 0, 48, 48, 48); gradient.addColorStop(0, 'rgba(255,255,255,1)'); gradient.addColorStop(0.25, 'rgba(255,255,255,0.85)'); gradient.addColorStop(0.55, 'rgba(255,255,255,0.25)'); gradient.addColorStop(1, 'rgba(255,255,255,0)'); ctx.fillStyle = gradient; ctx.fillRect(0, 0, 96, 96); impactParticleTexture = new THREE.CanvasTexture(canvas); impactParticleTexture.colorSpace = THREE.SRGBColorSpace; return impactParticleTexture; } function createExplosion(position, impact) { const particles = []; const baseSize = Number.isFinite(impact?.size) ? impact.size : sizeFromDuration(impact?.duration); const strengthR = magnitudeToStrength(impact?.magR, magRMin, magRMax); const particleCount = Math.round(9 + strengthR * 26); const particleScale = baseSize * THREE.MathUtils.lerp(0.0016, 0.0038, strengthR); const velocityScalar = baseSize * THREE.MathUtils.lerp(0.0035, 0.009, strengthR); const drag = THREE.MathUtils.lerp(0.90, 0.95, 1 - strengthR); const baseColor = new THREE.Color(0x2f66ff); const normal = position.clone().normalize(); const group = new THREE.Group(); group.position.copy(position); const ringInner = baseSize * THREE.MathUtils.lerp(0.0020, 0.0034, strengthR); const ringOuter = baseSize * THREE.MathUtils.lerp(0.0046, 0.0078, strengthR); const ringMaterial = new THREE.MeshBasicMaterial({ color: baseColor.getHex(), transparent: true, opacity: 0.55, side: THREE.DoubleSide, depthTest: false, depthWrite: false, blending: THREE.AdditiveBlending }); const ring = new THREE.Mesh(new THREE.RingGeometry(ringInner, ringOuter, 64), ringMaterial); ring.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), normal); ring.renderOrder = 42; group.add(ring); const flashMaterial = new THREE.SpriteMaterial({ map: getImpactParticleTexture(), color: baseColor.clone().lerp(new THREE.Color(0xffffff), 0.35).getHex(), transparent: true, opacity: 0.9, depthTest: false, depthWrite: false, blending: THREE.AdditiveBlending }); const flash = new THREE.Sprite(flashMaterial); const flashScale = baseSize * THREE.MathUtils.lerp(0.005, 0.011, strengthR); flash.scale.set(flashScale, flashScale, 1); flash.renderOrder = 43; group.add(flash); const particleTexture = getImpactParticleTexture(); const baseAxis = new THREE.Vector3(0, 0, 1); const particleAxisRotation = new THREE.Quaternion().setFromUnitVectors(baseAxis, normal); for (let i = 0; i < particleCount; i++) { const color = baseColor.clone(); color.offsetHSL((Math.random() - 0.5) * 0.07, 0, (Math.random() - 0.5) * 0.10); const material = new THREE.SpriteMaterial({ map: particleTexture, color: color.getHex(), transparent: true, opacity: 1, depthTest: false, depthWrite: false, blending: THREE.AdditiveBlending }); const sprite = new THREE.Sprite(material); const scale = particleScale * THREE.MathUtils.lerp(0.55, 1.25, Math.random()); sprite.scale.set(scale, scale, 1); sprite.position.set(0, 0, 0); sprite.renderOrder = 41; const dir = new THREE.Vector3( (Math.random() - 0.5), (Math.random() - 0.5), (Math.random() - 0.2) ).normalize(); dir.applyQuaternion(particleAxisRotation); dir.addScaledVector(normal, THREE.MathUtils.lerp(1.4, 2.2, strengthR)).normalize(); const velocity = dir.multiplyScalar(velocityScalar * THREE.MathUtils.lerp(0.65, 1.35, Math.random())); const life = THREE.MathUtils.lerp(0.55, 1.0, Math.random()); group.add(sprite); particles.push({ sprite, velocity, life, drag, baseScale: scale }); } (impactsGroup ?? scene).add(group); return { group, particles, ring, flash, age: 0, ringDuration: THREE.MathUtils.lerp(4.60, 6.20, strengthR) }; } function createImpacts(data) { const moonRadius = 1.5; impacts.forEach((item) => { (impactsGroup ?? scene).remove(item.glow); (impactsGroup ?? scene).remove(item.marker); if (item.hit) (impactsGroup ?? scene).remove(item.hit); }); impacts = []; (Array.isArray(data) ? data : []).forEach(impact => { const lon = impact.longitude; if (!Number.isFinite(lon)) return; if (Math.abs(lon) > 90) return; const position = latLonToVector3(impact.latitude, lon, moonRadius + 0.003); impact.size = sizeFromDuration(impact.duration); const impactColor = colorFromAverageMagnitude(impact.magR, impact.magI); const markerGeometry = new THREE.SphereGeometry(impact.size * 0.004, 16, 16); const markerMaterial = new THREE.MeshBasicMaterial({ color: impactColor.getHex(), transparent: true, opacity: 0.95, depthTest: false, depthWrite: false }); const marker = new THREE.Mesh(markerGeometry, markerMaterial); marker.position.copy(position); marker.userData = impact; marker.renderOrder = 30; const glowGeometry = new THREE.SphereGeometry(impact.size * 0.008, 16, 16); const glowColor = impactColor.clone().lerp(new THREE.Color(0xffffff), 0.15); const glowMaterial = new THREE.MeshBasicMaterial({ color: glowColor.getHex(), transparent: true, opacity: 0.55, depthTest: false, depthWrite: false }); const glow = new THREE.Mesh(glowGeometry, glowMaterial); glow.position.copy(position); glow.renderOrder = 29; const hitGeometry = new THREE.SphereGeometry(Math.max(impact.size * 0.006, 0.06), 12, 12); const hitMaterial = new THREE.MeshBasicMaterial({ transparent: true, opacity: 0, depthTest: false, depthWrite: false }); const hit = new THREE.Mesh(hitGeometry, hitMaterial); hit.position.copy(position); hit.userData = impact; hit.renderOrder = 31; (impactsGroup ?? scene).add(glow); (impactsGroup ?? scene).add(marker); (impactsGroup ?? scene).add(hit); impacts.push({ marker, glow, hit, impact, position, explosion: null, explosionActive: false }); }); } function animate() { requestAnimationFrame(animate); const hasMoon = !!moon; if (hasMoon && camera) { moon.getWorldPosition(_tmpMoonCenter); camera.getWorldPosition(_tmpCamPos); _tmpToCam.copy(_tmpCamPos).sub(_tmpMoonCenter); if (_tmpToCam.lengthSq() > 0) _tmpToCam.normalize(); _gridClipPlane.normal.copy(_tmpToCam); _gridClipPlane.constant = -_gridClipPlane.normal.dot(_tmpMoonCenter); } impacts.forEach((item, index) => { if (hasMoon && item.marker) { item.marker.getWorldPosition(_tmpMarkerWorld); _tmpToMarker.copy(_tmpMarkerWorld).sub(_tmpMoonCenter); const markerLenSq = _tmpToMarker.lengthSq(); if (markerLenSq > 0) _tmpToMarker.multiplyScalar(1 / Math.sqrt(markerLenSq)); _tmpViewDir.copy(_tmpCamPos).sub(_tmpMarkerWorld); const viewLenSq = _tmpViewDir.lengthSq(); if (viewLenSq > 0) _tmpViewDir.multiplyScalar(1 / Math.sqrt(viewLenSq)); const isFrontFacing = markerLenSq > 0 && viewLenSq > 0 && _tmpToMarker.dot(_tmpViewDir) > 0.002; item.marker.visible = isFrontFacing; item.glow.visible = isFrontFacing; if (item.hit) item.hit.visible = isFrontFacing; if (!isFrontFacing) { if (item.explosion && item.explosion.group) { (impactsGroup ?? scene).remove(item.explosion.group); } item.explosion = null; item.explosionActive = false; return; } } if (!impactAnimationEnabled) { item.glow.scale.set(1, 1, 1); item.marker.scale.set(1, 1, 1); if (item.hit) item.hit.scale.set(1, 1, 1); if (item.explosion && item.explosion.group) { (impactsGroup ?? scene).remove(item.explosion.group); } item.explosion = null; item.explosionActive = false; return; } const scale = 1 + Math.sin(Date.now() * 0.0014 + index * 0.7) * 0.28; item.glow.scale.set(scale, scale, scale); item.marker.scale.set(scale, scale, scale); if (item.hit) item.hit.scale.set(scale, scale, scale); if (item.explosion) { const explosion = item.explosion; const dt = 0.007; explosion.age += dt; const ringT = explosion.ringDuration > 0 ? THREE.MathUtils.clamp(explosion.age / explosion.ringDuration, 0, 1) : 1; if (explosion.ring) { const ringScale = 1 + ringT * 1.0; explosion.ring.scale.set(ringScale, ringScale, 1); explosion.ring.material.opacity = (1 - ringT) * 0.55; } if (explosion.flash) { const flashT = THREE.MathUtils.clamp(explosion.age / 1.60, 0, 1); explosion.flash.material.opacity = (1 - flashT) * 0.9; } explosion.particles.forEach((p) => { p.sprite.position.add(p.velocity); p.velocity.multiplyScalar(p.drag ?? 0.94); p.life -= dt * 0.2625; if (p.life < 0) p.life = 0; p.sprite.material.opacity = p.life; const s = p.baseScale * (0.45 + p.life * 0.75); p.sprite.scale.set(s, s, 1); p.sprite.material.rotation += 0.12; }); explosion.particles = explosion.particles.filter((p) => { if (p.life <= 0) { explosion.group.remove(p.sprite); return false; } return true; }); if (explosion.particles.length === 0 && ringT >= 1) { (impactsGroup ?? scene).remove(explosion.group); item.explosion = null; item.explosionActive = false; } } }); controls.update(); renderer.render(scene, camera); } function onWindowResize() { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); } function setMouseFromPointerEvent(event) { if (!renderer || !renderer.domElement) return false; const rect = renderer.domElement.getBoundingClientRect(); if (!rect.width || !rect.height) return false; mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; mouse.y = -(((event.clientY - rect.top) / rect.height) * 2 - 1); return true; } function onPointerMove(event) { if (!impactAnimationEnabled) { if (coordTooltipEl) coordTooltipEl.style.display = 'none'; hoveredImpact = null; document.body.style.cursor = 'default'; return; } if (event.pointerType && event.pointerType !== 'mouse') { if (coordTooltipEl) coordTooltipEl.style.display = 'none'; return; } if (!setMouseFromPointerEvent(event)) return; raycaster.setFromCamera(mouse, camera); let newHoveredImpact = null; if (impactsGroup && impactsGroup.visible) { const hits = impacts .map((item) => item.hit ?? item.marker) .filter((obj) => obj && obj.visible !== false); const intersects = raycaster.intersectObjects(hits); if (intersects.length > 0) { const hitObj = intersects[0].object; newHoveredImpact = impacts.find((item) => item.hit === hitObj || item.marker === hitObj) ?? null; } } else { hoveredImpact = null; document.body.style.cursor = 'default'; } if (newHoveredImpact !== hoveredImpact) { if (newHoveredImpact && !newHoveredImpact.explosionActive) { newHoveredImpact.explosion = createExplosion(newHoveredImpact.position, newHoveredImpact.impact); newHoveredImpact.explosionActive = true; } hoveredImpact = newHoveredImpact; document.body.style.cursor = hoveredImpact ? 'pointer' : 'default'; const detailsDiv = document.getElementById('details'); const detailsOpen = !!detailsDiv && detailsDiv.style.display !== 'none' && detailsDiv.classList.contains('visible'); if (detailsOpen && hoveredImpact && hoveredImpact.impact) { showDetails(hoveredImpact.impact); } } if (coordTooltipEl && hoveredImpact && hoveredImpact.impact) { const rect = renderer.domElement.getBoundingClientRect(); coordTooltipEl.style.left = `${event.clientX - rect.left}px`; coordTooltipEl.style.top = `${event.clientY - rect.top}px`; coordTooltipEl.textContent = `Lat: ${hoveredImpact.impact.latitude.toFixed(1)}°, Lon: ${hoveredImpact.impact.longitude.toFixed(1)}°`; coordTooltipEl.style.display = 'block'; return; } if (coordTooltipEl) coordTooltipEl.style.display = 'none'; } function onPointerDown(event) { _pointerDown = { x: event.clientX, y: event.clientY, pointerId: event.pointerId, time: performance.now() }; } function onPointerUp(event) { if (!_pointerDown || _pointerDown.pointerId !== event.pointerId) return; const dx = event.clientX - _pointerDown.x; const dy = event.clientY - _pointerDown.y; const movedSq = dx * dx + dy * dy; _pointerDown = null; if (movedSq > 36) return; if (!impactAnimationEnabled || !impactsGroup || !impactsGroup.visible) return; if (!setMouseFromPointerEvent(event)) return; raycaster.setFromCamera(mouse, camera); const hits = impacts.map((item) => item.hit ?? item.marker).filter(Boolean); const intersects = raycaster.intersectObjects(hits); if (intersects.length === 0) return; const impact = intersects[0].object.userData; showDetails(impact); const clicked = impacts.find((item) => item.hit === intersects[0].object || item.marker === intersects[0].object); if (clicked && !clicked.explosionActive) { clicked.explosion = createExplosion(clicked.position, clicked.impact); clicked.explosionActive = true; } } function setupHelpMenu() { const helpEl = document.getElementById('help'); const helpToggle = document.getElementById('help-toggle'); const helpClose = document.getElementById('help-close'); const helpContent = document.getElementById('help-content'); if (!helpEl || !helpToggle || !helpClose || !helpContent) return; const show = () => { helpEl.style.display = 'flex'; }; const hide = () => { helpEl.style.display = 'none'; }; helpToggle.addEventListener('click', (e) => { e.stopPropagation(); show(); }); helpClose.addEventListener('click', (e) => { e.stopPropagation(); hide(); }); helpEl.addEventListener('click', () => { hide(); }); helpContent.addEventListener('click', (e) => { e.stopPropagation(); }); window.addEventListener('keydown', (e) => { if (e.key === 'Escape') hide(); }); } function setupInfoCollapse() { const infoEl = document.getElementById('info'); const collapseBtn = document.getElementById('info-collapse'); if (!infoEl || !collapseBtn) return; const setCollapsed = (collapsed) => { if (collapsed) { infoEl.classList.add('collapsed'); collapseBtn.textContent = '+'; } else { infoEl.classList.remove('collapsed'); collapseBtn.textContent = '−'; } }; collapseBtn.addEventListener('click', (e) => { e.stopPropagation(); setCollapsed(!infoEl.classList.contains('collapsed')); }); } function showDetails(impact) { const detailsDiv = document.getElementById('details'); document.getElementById('details-title').textContent = `Impact #${impact.id}`; const csvIdEl = document.getElementById('details-csv-id'); if (csvIdEl) csvIdEl.textContent = impact.csvId ? `ID: ${impact.csvId}` : ''; document.getElementById('details-date').textContent = `UT Date: ${(impact.date ?? '').trim()}`; document.getElementById('details-time').textContent = `UT Time: ${(impact.time ?? '').trim()}`; const lon = impact.longitude; document.getElementById('details-coords').textContent = `Coordinates: ${impact.latitude.toFixed(1)}° Lat, ${lon.toFixed(1)}° Lon`; document.getElementById('details-duration').textContent = impact.durationText ? `Duration (sec): ${impact.durationText}` : (impact.duration != null ? `Duration (sec): ${impact.duration}` : ''); const rText = impact.magRText ?? (impact.magR != null ? String(impact.magR) : ''); const iText = impact.magIText ?? (impact.magI != null ? String(impact.magI) : ''); document.getElementById('details-magnitude').textContent = `R (mag): ${rText}, I (mag): ${iText}`; const airmassEl = document.getElementById('details-airmass'); if (airmassEl) airmassEl.textContent = impact.airmassText ? `Airmass: ${impact.airmassText}` : ''; const altEl = document.getElementById('details-altitude'); if (altEl) altEl.textContent = impact.altitudeDegText ? `Altitude (deg): ${impact.altitudeDegText}` : ''; const azEl = document.getElementById('details-azimuth'); if (azEl) azEl.textContent = impact.azimuthDegText ? `Azimuth (deg): ${impact.azimuthDegText}` : ''; const observerEl = document.getElementById('details-observer'); if (observerEl) observerEl.textContent = ''; const peakEl = document.getElementById('details-peak'); if (peakEl) peakEl.textContent = ''; const clsEl = document.getElementById('details-classification'); if (clsEl) clsEl.textContent = ''; const wasHidden = detailsDiv.style.display === 'none'; detailsDiv.style.display = 'block'; if (wasHidden) { detailsDiv.classList.remove('visible'); detailsDiv.classList.remove('bump'); requestAnimationFrame(() => { detailsDiv.classList.add('visible'); }); } else { detailsDiv.classList.add('visible'); detailsDiv.classList.remove('bump'); void detailsDiv.offsetWidth; detailsDiv.classList.add('bump'); } } { const closeBtn = document.getElementById('close-details'); if (closeBtn) { closeBtn.addEventListener('click', () => { const detailsDiv = document.getElementById('details'); if (!detailsDiv) return; detailsDiv.classList.remove('visible'); detailsDiv.classList.remove('bump'); window.setTimeout(() => { if (!detailsDiv.classList.contains('visible')) detailsDiv.style.display = 'none'; }, 220); }); } } init();