// Plate interaction: hover info + ctrl-click to toggle land/sea. // Uses analytical ray-sphere intersection instead of Three.js mesh raycasting // for O(N) dot-product lookups rather than O(N) triangle intersection tests. import * as THREE from 'three'; import { canvas, camera, mapCamera } from './scene.js'; import { state } from './state.js'; import { updateHoverHighlight, updateMapHoverHighlight, updatePendingHighlight, updateMapPendingHighlight } from './planet-mesh.js'; import { KOPPEN_CLASSES } from './koppen.js'; import { elevToHeightKm } from './color-map.js'; const raycaster = new THREE.Raycaster(); const mouse = new THREE.Vector2(); const _inverseMatrix = new THREE.Matrix4(); const _localRay = new THREE.Ray(); /** Find nearest region to a unit-sphere direction (max dot product). */ function findNearestRegion(nx, ny, nz) { const { mesh, r_xyz, r_plate } = state.curData; const N = mesh.numRegions; let bestDot = -2, bestR = -1; for (let r = 0; r < N; r++) { const dot = nx * r_xyz[3 * r] + ny * r_xyz[3 * r + 1] + nz * r_xyz[3 * r + 2]; if (dot > bestDot) { bestDot = dot; bestR = r; } } if (bestR < 0) return null; return { region: bestR, plate: r_plate[bestR] }; } /** Globe view: analytical ray-sphere intersection → nearest region. * ~50-100x faster than Three.js mesh raycasting at high detail. */ function getHitInfoGlobe(event) { if (!state.planetMesh) return null; const rect = canvas.getBoundingClientRect(); mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; raycaster.setFromCamera(mouse, camera); // Transform ray into planet's local space (handles auto-rotation) _inverseMatrix.copy(state.planetMesh.matrixWorld).invert(); _localRay.copy(raycaster.ray).applyMatrix4(_inverseMatrix); const ox = _localRay.origin.x, oy = _localRay.origin.y, oz = _localRay.origin.z; const dx = _localRay.direction.x, dy = _localRay.direction.y, dz = _localRay.direction.z; // Ray-sphere: |O + tD|² = R² (a=1 since direction is normalised) const R = 1.08; // slightly above max elevation displacement const b = 2 * (ox * dx + oy * dy + oz * dz); const c = ox * ox + oy * oy + oz * oz - R * R; const disc = b * b - 4 * c; if (disc < 0) return null; const t = (-b - Math.sqrt(disc)) * 0.5; if (t < 0) return null; // Hit point → normalise to unit direction const hx = ox + t * dx, hy = oy + t * dy, hz = oz + t * dz; const len = Math.sqrt(hx * hx + hy * hy + hz * hz) || 1; return findNearestRegion(hx / len, hy / len, hz / len); } /** Map view: unproject mouse → map plane → inverse equirect → nearest region. */ function getHitInfoMap(event) { if (!state.mapMesh) return null; const rect = canvas.getBoundingClientRect(); mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; // Intersect ray with z=0 plane to get world coords on the map raycaster.setFromCamera(mouse, mapCamera); const o = raycaster.ray.origin, d = raycaster.ray.direction; if (Math.abs(d.z) < 1e-10) return null; const t = -o.z / d.z; const wx = o.x + t * d.x; const wy = o.y + t * d.y; // Inverse equirectangular: map coords → lon/lat → unit sphere xyz const PI = Math.PI; const sx = 2 / PI; let lon = wx / sx + (state.mapCenterLon || 0); const lat = wy / sx; if (lat < -PI / 2 || lat > PI / 2) return null; // Wrap lon back to [-PI, PI] if (lon > PI) lon -= 2 * PI; else if (lon < -PI) lon += 2 * PI; const cosLat = Math.cos(lat); return findNearestRegion( cosLat * Math.sin(lon), Math.sin(lat), cosLat * Math.cos(lon) ); } function getHitInfo(event) { if (!state.curData) return null; return state.mapMode ? getHitInfoMap(event) : getHitInfoGlobe(event); } /** Build multi-line hover HTML for a region. */ function buildHoverHTML(region, plate) { const d = state.curData; const isOcean = d.plateIsOcean.has(plate); const isPending = state.pendingToggles.has(plate); const dot = `●`; const action = state.isTouchDevice ? 'Tap' : 'Ctrl-click'; const lines = []; // Line 1: plate type + edit hint if (isPending) { const target = isOcean ? 'Land' : 'Ocean'; lines.push(`${dot} ${isOcean ? 'Ocean' : 'Land'} → ${target} (pending) · ${action} to undo`); } else { lines.push(`${dot} ${isOcean ? 'Ocean' : 'Land'} plate · ${action} to ${isOcean ? 'raise land' : 'flood'}`); } // Elevation const elev = d.r_elevation[region]; const elevKm = elevToHeightKm(elev).toFixed(1); lines.push(`Elev ${elevKm} km`); // Lat/Lon from r_xyz const x = d.r_xyz[3 * region]; const y = d.r_xyz[3 * region + 1]; const z = d.r_xyz[3 * region + 2]; const lat = Math.asin(Math.max(-1, Math.min(1, y))) * (180 / Math.PI); const lon = Math.atan2(x, z) * (180 / Math.PI); const latStr = Math.abs(lat).toFixed(1) + '°' + (lat >= 0 ? 'N' : 'S'); const lonStr = Math.abs(lon).toFixed(1) + '°' + (lon >= 0 ? 'E' : 'W'); lines.push(`Coord ${latStr}, ${lonStr}`); // Climate data (only if computed) if (state.climateComputed && d.r_temperature_summer) { const tS = -45 + Math.max(0, Math.min(1, d.r_temperature_summer[region])) * 90; const tW = -45 + Math.max(0, Math.min(1, d.r_temperature_winter[region])) * 90; if (elev <= 0) { // Ocean: show as SST lines.push(`SST ${tS.toFixed(0)}°C / ${tW.toFixed(0)}°C`); } else { lines.push(`Temp ${tS.toFixed(0)}°C / ${tW.toFixed(0)}°C`); // Precipitation (land only) if (d.r_precip_summer) { const pS = (Math.max(0, Math.min(1, d.r_precip_summer[region])) * 1000).toFixed(0); const pW = (Math.max(0, Math.min(1, d.r_precip_winter[region])) * 1000).toFixed(0); lines.push(`Precip ${pS} / ${pW} mm`); } // Köppen (land only) if (d.debugLayers && d.debugLayers.koppen) { const kIdx = d.debugLayers.koppen[region]; const kc = KOPPEN_CLASSES[kIdx]; if (kc && kc.code !== 'Ocean') { const [r, g, b] = kc.color; const hex = '#' + [r, g, b].map(v => Math.round(v * 255).toString(16).padStart(2, '0')).join(''); lines.push(`Clima ${kc.code} — ${kc.name}`); } } } } return lines.join('
'); } /** Set up hover and ctrl-click event listeners. */ export function setupEditMode() { let downInfo = null; let orbiting = false; let lastHoverTime = 0; const HOVER_INTERVAL = 50; // ms — cap hover lookups canvas.addEventListener('pointerdown', (e) => { if (!state.curData) return; const isEditTap = (e.button === 0 && e.ctrlKey) || (e.button === 0 && state.isTouchDevice && state.editMode); if (isEditTap) { // Ctrl-click or mobile edit-mode tap: plate editing const hit = getHitInfo(e); if (!hit) return; downInfo = { x: e.clientX, y: e.clientY, plate: hit.plate }; } else if (e.button === 0 || e.button === 2) { // Regular click/right-click: orbit or pan — skip hover raycasts orbiting = true; } }); canvas.addEventListener('pointerup', (e) => { orbiting = false; if (!downInfo || !state.curData || e.button !== 0) { downInfo = null; return; } const dx = e.clientX - downInfo.x; const dy = e.clientY - downInfo.y; if (dx * dx + dy * dy < 36) { const pid = downInfo.plate; // Toggle pending: add if absent, remove if present (undo) if (state.pendingToggles.has(pid)) { state.pendingToggles.delete(pid); } else { state.pendingToggles.add(pid); } // Remove hover highlight first so pending tint applies to base colors. // Hover saves its backup from pre-pending colors; if we don't strip it, // the hover restore in updateHoverHighlight wipes out the pending tint. const savedHover = state.hoveredPlate; state.hoveredPlate = -1; if (state.mapMode) updateMapHoverHighlight(); else updateHoverHighlight(); state.hoveredPlate = savedHover; // Apply pending tint to the now-clean base colors updatePendingHighlight(); updateMapPendingHighlight(); // Re-apply hover on top of pending-tinted colors if (state.mapMode) updateMapHoverHighlight(); else updateHoverHighlight(); // Update hover text to reflect pending state const hoverEl = document.getElementById('hoverInfo'); if (state.hoveredRegion >= 0 && state.curData) { hoverEl.innerHTML = buildHoverHTML(state.hoveredRegion, state.hoveredPlate); } // Notify main.js to show/hide rebuild button document.dispatchEvent(new CustomEvent('pending-edits-changed')); } downInfo = null; }); canvas.addEventListener('pointermove', (e) => { if (!state.curData) { if (state.hoveredPlate >= 0 || state.hoveredRegion >= 0) { state.hoveredPlate = -1; state.hoveredRegion = -1; document.getElementById('hoverInfo').style.display = 'none'; } return; } // Skip while orbiting/panning — no hover lookup during drag if (orbiting) return; // Throttle hover updates const now = performance.now(); if (now - lastHoverTime < HOVER_INTERVAL) return; lastHoverTime = now; const hit = getHitInfo(e); const newRegion = hit ? hit.region : -1; // Only highlight the plate when in edit mode (Ctrl held or mobile edit toggle) const inEditMode = e.ctrlKey || (state.isTouchDevice && state.editMode); const newPlate = (hit && inEditMode) ? hit.plate : -1; // Update plate highlight only when plate changes if (newPlate !== state.hoveredPlate) { state.hoveredPlate = newPlate; if (state.mapMode) updateMapHoverHighlight(); else updateHoverHighlight(); } // Update info text when region changes if (newRegion !== state.hoveredRegion) { state.hoveredRegion = newRegion; state.hoveredPlate = (hit && inEditMode) ? hit.plate : -1; const hoverEl = document.getElementById('hoverInfo'); if (newRegion >= 0) { hoverEl.innerHTML = buildHoverHTML(newRegion, hit.plate); hoverEl.style.display = 'block'; } else { hoverEl.style.display = 'none'; } } }); }