This commit is contained in:
Rainer Leit
2026-09-25 17:02:24 +03:00
parent cc43ed8dc8
commit 9597629951
2149 changed files with 460234 additions and 1770 deletions
+271
View File
@@ -0,0 +1,271 @@
// 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 = `<span style="color:${isOcean ? '#4af' : '#6b3'}">●</span>`;
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} <b>${isOcean ? 'Ocean' : 'Land'} → ${target}</b> <span style="color:#fa0">(pending)</span> · ${action} to undo`);
} else {
lines.push(`${dot} <b>${isOcean ? 'Ocean' : 'Land'}</b> plate · ${action} to ${isOcean ? 'raise land' : 'flood'}`);
}
// Elevation
const elev = d.r_elevation[region];
const elevKm = elevToHeightKm(elev).toFixed(1);
lines.push(`<span class="hi-label">Elev</span> ${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(`<span class="hi-label">Coord</span> ${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(`<span class="hi-label">SST</span> ${tS.toFixed(0)}°C / ${tW.toFixed(0)}°C`);
} else {
lines.push(`<span class="hi-label">Temp</span> ${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(`<span class="hi-label">Precip</span> ${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(`<span class="hi-label">Clima</span> <span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:${hex};vertical-align:middle;margin-right:4px"></span>${kc.code} — ${kc.name}`);
}
}
}
}
return lines.join('<br>');
}
/** 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';
}
}
});
}