moon_flash / app.js
AK51's picture
Upload app.js
153cc75 verified
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(/&nbsp;/gi, ' ')
.replace(/&plusmn;/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();