import * as THREE from "three"; import { describeHoverLabel, getSelectionGroup } from "./viewerSelection"; import { ACTIVE_SYSTEM_DETAIL_SCALE } from "./viewerConstants"; import { DISPLAY_UNITS_PER_KILOMETER, DISPLAY_UNITS_PER_LIGHT_YEAR, KILOMETERS_PER_AU, formatAdaptiveDistanceFromKilometers, formatSystemDistance } from "./viewerMath"; import type { Selectable, SelectionGroup, WorldState, ZoomLevel } from "./viewerTypes"; export interface HoverPickResult { selection: Selectable; object: THREE.Object3D; } export function pickSelectableAtClientPosition( renderer: THREE.WebGLRenderer, raycaster: THREE.Raycaster, mouse: THREE.Vector2, camera: THREE.Camera, selectableTargets: Map, clientX: number, clientY: number, ) { const hit = pickSelectableHitAtClientPosition(renderer, raycaster, mouse, camera, selectableTargets, clientX, clientY); return hit?.selection; } export function pickSelectableHitAtClientPosition( renderer: THREE.WebGLRenderer, raycaster: THREE.Raycaster, mouse: THREE.Vector2, camera: THREE.Camera, selectableTargets: Map, clientX: number, clientY: number, ): HoverPickResult | undefined { const bounds = renderer.domElement.getBoundingClientRect(); mouse.x = ((clientX - bounds.left) / bounds.width) * 2 - 1; mouse.y = -(((clientY - bounds.top) / bounds.height) * 2 - 1); raycaster.setFromCamera(mouse, camera); const hit = raycaster.intersectObjects([...selectableTargets.keys()], false)[0]; const selection = hit ? selectableTargets.get(hit.object) : undefined; return hit && selection ? { selection, object: hit.object } : undefined; } export function updateHoverLabel(params: { dragMode?: string; hoverLabelEl: HTMLDivElement; hoverPick: HoverPickResult | undefined; activeSystemId?: string; zoomLevel: ZoomLevel; world?: WorldState; point: THREE.Vector2; camera: THREE.Camera; }) { const { dragMode, hoverLabelEl, hoverPick, activeSystemId, zoomLevel, world, point, camera, } = params; if (dragMode) { hoverLabelEl.hidden = true; return; } if (!hoverPick) { hoverLabelEl.hidden = true; return; } const { selection, object } = hoverPick; const label = describeHoverLabel(world, selection); if (!label) { hoverLabelEl.hidden = true; return; } const distance = formatHoverDistance(camera, object, selection, zoomLevel, activeSystemId); hoverLabelEl.hidden = false; hoverLabelEl.textContent = `${label}\n${distance}`; hoverLabelEl.style.left = `${point.x + 14}px`; hoverLabelEl.style.top = `${point.y + 14}px`; } function formatHoverDistance( camera: THREE.Camera, object: THREE.Object3D, selection: Selectable, zoomLevel: ZoomLevel, activeSystemId?: string, ) { const worldPosition = object.getWorldPosition(new THREE.Vector3()); const displayDistance = camera.position.distanceTo(worldPosition); if (selection.kind === "system") { return formatAdaptiveDistanceFromKilometers((displayDistance / DISPLAY_UNITS_PER_LIGHT_YEAR) * 9.4607e12); } const inActiveSystem = selection.kind === "planet" ? selection.systemId === activeSystemId : selection.kind === "ship" || selection.kind === "station" || selection.kind === "node" || selection.kind === "spatial-node" || selection.kind === "bubble" || selection.kind === "claim" || selection.kind === "construction-site"; if (inActiveSystem && activeSystemId) { const kilometers = displayDistance / (DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE); return zoomLevel === "system" ? formatSystemDistance(kilometers / KILOMETERS_PER_AU) : formatAdaptiveDistanceFromKilometers(kilometers); } return formatAdaptiveDistanceFromKilometers(displayDistance / DISPLAY_UNITS_PER_KILOMETER); } export function updateMarqueeBox( marqueeEl: HTMLDivElement, dragStart: THREE.Vector2, dragLast: THREE.Vector2, ) { const minX = Math.min(dragStart.x, dragLast.x); const minY = Math.min(dragStart.y, dragLast.y); const maxX = Math.max(dragStart.x, dragLast.x); const maxY = Math.max(dragStart.y, dragLast.y); marqueeEl.style.left = `${minX}px`; marqueeEl.style.top = `${minY}px`; marqueeEl.style.width = `${maxX - minX}px`; marqueeEl.style.height = `${maxY - minY}px`; } export function hideMarqueeBox(marqueeEl: HTMLDivElement) { marqueeEl.style.display = "none"; marqueeEl.style.width = "0"; marqueeEl.style.height = "0"; } export function completeMarqueeSelection(params: { renderer: THREE.WebGLRenderer; camera: THREE.Camera; dragStart: THREE.Vector2; dragLast: THREE.Vector2; selectableTargets: Map; }) { const { renderer, camera, dragStart, dragLast, selectableTargets, } = params; const bounds = renderer.domElement.getBoundingClientRect(); const minX = Math.min(dragStart.x, dragLast.x); const minY = Math.min(dragStart.y, dragLast.y); const maxX = Math.max(dragStart.x, dragLast.x); const maxY = Math.max(dragStart.y, dragLast.y); const grouped = new Map(); for (const [object, selectable] of selectableTargets.entries()) { if (object instanceof THREE.Sprite && !object.visible) { continue; } if (!object.visible) { continue; } const worldPosition = new THREE.Vector3(); object.getWorldPosition(worldPosition); worldPosition.project(camera); const screenX = ((worldPosition.x + 1) * 0.5) * bounds.width; const screenY = ((1 - worldPosition.y) * 0.5) * bounds.height; if (screenX < minX || screenX > maxX || screenY < minY || screenY > maxY) { continue; } const group = getSelectionGroup(selectable); const list = grouped.get(group) ?? []; if (!list.some((entry) => JSON.stringify(entry) === JSON.stringify(selectable))) { list.push(selectable); } grouped.set(group, list); } return [...grouped.entries()] .sort((left, right) => right[1].length - left[1].length)[0]?.[1] ?? []; }