Spaces:
Running
Running
| 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(`<td>\\s*<b>\\s*${escaped}\\s*:?\\s*<\\/b>\\s*<\\/td>\\s*<td>\\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(`<td>\\s*<b>\\s*${escaped}\\s*:?\\s*<\\/b>\\s*<\\/td>\\s*<td>([\\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(); | |