import { useEffect, useRef, useState } from 'react'; import type { Dispatch, PointerEvent as ReactPointerEvent, SetStateAction, WheelEvent as ReactWheelEvent } from 'react'; import type { MissionState } from '../domain/model'; import { MODES, type DraftGeometry, type DraftPoint, type InteractionAction, type InteractionState } from '../interaction/controller'; import { Icon } from './Icon'; export type Camera = { x: number; y: number; zoom: number }; type Props = { state: MissionState; selection: string[]; interaction: InteractionState; dispatch: Dispatch; camera: Camera; setCamera: Dispatch>; selectVehicles: (ids: string[], additive?: boolean) => void; onContext: (point: DraftPoint) => void; }; const points = (draft: DraftGeometry) => draft.points.map(point => `${point.x},${point.y}`).join(' '); const path = (draft: DraftGeometry) => draft.points.map((point, index) => `${index ? 'L' : 'M'} ${point.x} ${point.y}`).join(' '); const isPolygon = (draft: DraftGeometry) => ['polygon', 'task-area', 'measure-area', 'geofence-include', 'geofence-exclude'].includes(draft.mode); const groupColors: Record = { 'group:alpha': '#45c8ef', 'group:bravo': '#6ed779', 'group:charlie': '#8ea9ff', 'group:delta': '#e8b94b', 'group:reserve': '#a879e2' }; const insidePolygon = (point: DraftPoint, polygon: DraftPoint[]) => { let inside = false; for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { const a = polygon[i]; const b = polygon[j]; if ((a.y > point.y) !== (b.y > point.y) && point.x < (b.x - a.x) * (point.y - a.y) / ((b.y - a.y) || .0001) + a.x) inside = !inside; } return inside; }; export function OperationalMap({ state, selection, interaction, dispatch, camera, setCamera, selectVehicles, onContext }: Props) { const pointer = useRef<{ id: number; x: number; y: number; mapX: number; mapY: number; kind: 'pan' | 'marquee' | 'draw' } | null>(null); const spaceHeld = useRef(false); const [marquee, setMarquee] = useState<{ x1: number; y1: number; x2: number; y2: number } | null>(null); const [cursorPoint, setCursorPoint] = useState(null); useEffect(() => { const down = (event: KeyboardEvent) => { if (event.code === 'Space' && !(event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement)) spaceHeld.current = true; }; const up = (event: KeyboardEvent) => { if (event.code === 'Space') spaceHeld.current = false; }; window.addEventListener('keydown', down); window.addEventListener('keyup', up); return () => { window.removeEventListener('keydown', down); window.removeEventListener('keyup', up); }; }, []); const toMap = (event: ReactPointerEvent): DraftPoint => { const rect = event.currentTarget.getBoundingClientRect(); return { x: Math.max(0, Math.min(100, ((event.clientX - rect.left) / rect.width - .5) * 100 / camera.zoom + 50 - camera.x)), y: Math.max(0, Math.min(100, ((event.clientY - rect.top) / rect.height - .5) * 100 / camera.zoom + 50 - camera.y)) }; }; const toScreen = (event: ReactPointerEvent) => { const rect = event.currentTarget.getBoundingClientRect(); return { x: event.clientX - rect.left, y: event.clientY - rect.top }; }; function down(event: ReactPointerEvent) { if ((event.target as HTMLElement).closest('button,[data-map-entity]')) return; const map = toMap(event); const screen = toScreen(event); const temporaryPan = event.button === 1 || event.buttons === 4 || spaceHeld.current; const kind = interaction.mode === 'pan' || temporaryPan ? 'pan' : ['select', 'select-additive', 'marquee'].includes(interaction.mode) ? 'marquee' : 'draw'; pointer.current = { id: event.pointerId, x: screen.x, y: screen.y, mapX: map.x, mapY: map.y, kind }; event.currentTarget.setPointerCapture(event.pointerId); if (kind === 'marquee') setMarquee({ x1: screen.x, y1: screen.y, x2: screen.x, y2: screen.y }); if (kind === 'draw') dispatch({ type: 'point', point: map, targetIds: [...selection] }); } function move(event: ReactPointerEvent) { const map = toMap(event); setCursorPoint(map); if (!pointer.current) return; const screen = toScreen(event); if (pointer.current.kind === 'pan') { const dx = (screen.x - pointer.current.x) / event.currentTarget.clientWidth * 100 / camera.zoom; const dy = (screen.y - pointer.current.y) / event.currentTarget.clientHeight * 100 / camera.zoom; setCamera(value => ({ ...value, x: value.x + dx, y: value.y + dy })); pointer.current.x = screen.x; pointer.current.y = screen.y; } else if (pointer.current.kind === 'marquee') setMarquee(value => value ? { ...value, x2: screen.x, y2: screen.y } : null); else if (['freehand', 'lasso'].includes(interaction.mode)) dispatch({ type: 'freehand', point: map }); } function up(event: ReactPointerEvent) { if (!pointer.current) return; if (pointer.current.kind === 'marquee' && marquee) { const rect = event.currentTarget.getBoundingClientRect(); const x1 = ((Math.min(marquee.x1, marquee.x2) / rect.width - .5) * 100 / camera.zoom + 50 - camera.x); const x2 = ((Math.max(marquee.x1, marquee.x2) / rect.width - .5) * 100 / camera.zoom + 50 - camera.x); const y1 = ((Math.min(marquee.y1, marquee.y2) / rect.height - .5) * 100 / camera.zoom + 50 - camera.y); const y2 = ((Math.max(marquee.y1, marquee.y2) / rect.height - .5) * 100 / camera.zoom + 50 - camera.y); const moved = Math.abs(marquee.x2 - marquee.x1) + Math.abs(marquee.y2 - marquee.y1) > 8; const additive = event.shiftKey || interaction.mode === 'select-additive'; if (moved) selectVehicles(Object.values(state.vehicles).filter(vehicle => vehicle.position.x >= x1 && vehicle.position.x <= x2 && vehicle.position.y >= y1 && vehicle.position.y <= y2).map(vehicle => vehicle.id), additive); else if (!additive) selectVehicles([]); } if (pointer.current.kind === 'draw' && ['circle', 'formation', 'move-intent', 'measure-distance', 'measure-bearing'].includes(interaction.mode)) dispatch({ type: 'point', point: toMap(event), targetIds: [...selection] }); if (pointer.current.kind === 'draw' && interaction.mode === 'lasso') { const polygon = [...(interaction.draft?.points ?? []), toMap(event)]; selectVehicles(Object.values(state.vehicles).filter(vehicle => insidePolygon(vehicle.position, polygon)).map(vehicle => vehicle.id), event.shiftKey); dispatch({ type: 'discard-draft' }); } else if (pointer.current.kind === 'draw' && interaction.mode === 'freehand') dispatch({ type: 'complete' }); pointer.current = null; setMarquee(null); } function wheel(event: ReactWheelEvent) { event.preventDefault(); setCamera(value => ({ ...value, zoom: Math.max(.72, Math.min(4.2, value.zoom * Math.exp(-event.deltaY * .0012))) })); } const semantic = camera.zoom < 1.05 ? 'STRATEGIC' : camera.zoom < 1.75 ? 'OPERATIONAL' : camera.zoom < 2.8 ? 'TACTICAL' : 'ENGINEERING'; const allDrafts = [...interaction.artifacts, ...(interaction.draft ? [interaction.draft] : [])]; return
{ pointer.current = null; setMarquee(null); }} onWheel={wheel} onContextMenu={event => { event.preventDefault(); onContext(toMap(event as unknown as ReactPointerEvent)); }}>
ALPHA SEARCH82% coverage R-210Bkeep out {semantic === 'STRATEGIC' ? Object.values(state.groups).map(group => { const vehicles = Object.values(state.vehicles).filter(vehicle => vehicle.groupId === group.id); const x = vehicles.reduce((sum, item) => sum + item.position.x, 0) / vehicles.length; const y = vehicles.reduce((sum, item) => sum + item.position.y, 0) / vehicles.length; return ; }) : Object.values(state.vehicles).map(vehicle => )}
{marquee &&
}
{semantic}{MODES[interaction.mode].label}{interaction.draft ? interaction.draft.message : MODES[interaction.mode].pointer}
{Math.round(5 / camera.zoom)} km
{cursorPoint && interaction.mode !== 'select' && {(41.02 + cursorPoint.y * .0014).toFixed(5)}°N  {(28.98 + cursorPoint.x * .0019).toFixed(5)}°E}
; }