113 lines
13 KiB
TypeScript
113 lines
13 KiB
TypeScript
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<InteractionAction>;
|
||
camera: Camera;
|
||
setCamera: Dispatch<SetStateAction<Camera>>;
|
||
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<string, string> = { '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<DraftPoint | null>(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<HTMLDivElement>): 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<HTMLDivElement>) => {
|
||
const rect = event.currentTarget.getBoundingClientRect(); return { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
||
};
|
||
function down(event: ReactPointerEvent<HTMLDivElement>) {
|
||
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<HTMLDivElement>) {
|
||
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<HTMLDivElement>) {
|
||
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<HTMLDivElement>) { 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 <section className="operational-map" data-testid="operational-map" data-camera={`${camera.x.toFixed(2)}:${camera.y.toFixed(2)}:${camera.zoom.toFixed(2)}`} aria-label={`Offline tactical map · ${semantic.toLowerCase()} zoom`} style={{ cursor: MODES[interaction.mode].cursor }} onPointerDown={down} onPointerMove={move} onPointerUp={up} onPointerCancel={() => { pointer.current = null; setMarquee(null); }} onWheel={wheel} onContextMenu={event => { event.preventDefault(); onContext(toMap(event as unknown as ReactPointerEvent<HTMLDivElement>)); }}>
|
||
<div className="map-grid"/>
|
||
<div className="map-world" style={{ transform: `translate(calc(${camera.x}% * ${camera.zoom}), calc(${camera.y}% * ${camera.zoom})) scale(${camera.zoom})` }}>
|
||
<div className="map-raster"/>
|
||
<svg className="map-geometry" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
||
<defs><pattern id="task-hatch" width="2" height="2" patternUnits="userSpaceOnUse" patternTransform="rotate(35)"><line x1="0" y1="0" x2="0" y2="2" stroke="#45c8ef" strokeOpacity=".22" strokeWidth=".5"/></pattern><pattern id="danger-hatch" width="2" height="2" patternUnits="userSpaceOnUse" patternTransform="rotate(35)"><line x1="0" y1="0" x2="0" y2="2" stroke="#ef5b61" strokeOpacity=".42" strokeWidth=".6"/></pattern></defs>
|
||
<polygon points="24,20 43,15 56,28 52,47 35,51 20,38" fill="url(#task-hatch)" stroke="#45c8ef" strokeWidth=".28" strokeDasharray="1.2 1"/>
|
||
<polygon points="72,22 83,25 80,38 69,34" fill="url(#danger-hatch)" stroke="#ef5b61" strokeWidth=".25" strokeDasharray="1 1"/>
|
||
<path d="M29 25 C34 31 38 28 42 38 S48 42 51 31" fill="none" stroke="#55c77a" strokeWidth=".28" strokeDasharray="1 1"/>
|
||
<path d="M49 46 Q63 45 75 58" fill="none" stroke="#e9b949" strokeWidth=".32" strokeDasharray="1.5 1.2"/>
|
||
{allDrafts.map(draft => <g key={draft.id} className={`draft-shape ${draft.phase} ${draft.valid ? '' : 'invalid'}`} data-map-entity>
|
||
{isPolygon(draft) ? <polygon points={points(draft)} /> : draft.mode === 'circle' && draft.points.length > 1 ? <circle cx={draft.points[0].x} cy={draft.points[0].y} r={Math.hypot(draft.points[1].x - draft.points[0].x, draft.points[1].y - draft.points[0].y)} /> : <path d={path(draft)} />}
|
||
{draft.points.map((point, index) => <g key={index}><circle className="draft-handle" cx={point.x} cy={point.y} r=".75"/><text x={point.x} y={point.y + .27}>{index + 1}</text></g>)}
|
||
</g>)}
|
||
{interaction.draft?.mode === 'formation' && interaction.draft.points.length > 1 && Array.from({ length: Math.min(12, selection.length) }, (_, index) => { const anchor = interaction.draft!.points[0]; const end = interaction.draft!.points[1]; const angle = Math.atan2(end.y - anchor.y, end.x - anchor.x); const row = Math.floor(index / 4); const col = index % 4 - 1.5; const x = anchor.x + Math.cos(angle) * row * 3 - Math.sin(angle) * col * 2.5; const y = anchor.y + Math.sin(angle) * row * 3 + Math.cos(angle) * col * 2.5; return <g key={index} className="formation-slot"><circle cx={x} cy={y} r="1.1"/><text x={x} y={y + .3}>{index + 1}</text></g>; })}
|
||
</svg>
|
||
<span className="map-label task-label" style={{ left: '29%', top: '22%' }}><b>ALPHA SEARCH</b><small>82% coverage</small></span>
|
||
<span className="map-label constraint-label" style={{ left: '70%', top: '31%' }}><b>R-210B</b><small>keep out</small></span>
|
||
{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 <button key={group.id} data-map-entity className={`group-map-marker ${vehicles.some(v => selection.includes(v.id)) ? 'selected' : ''}`} style={{ left: `${x}%`, top: `${y}%`, '--group': groupColors[group.id] } as React.CSSProperties} onPointerDown={event => event.stopPropagation()} onClick={event => selectVehicles(vehicles.map(v => v.id), event.shiftKey)}><i>{group.name[0]}</i><span><b>{group.name}</b><small>{group.count} units · {group.progress}%</small></span></button>; }) : Object.values(state.vehicles).map(vehicle => <button key={vehicle.id} data-map-entity aria-label={`${vehicle.name} ${vehicle.health}`} className={`vehicle-map-marker ${selection.includes(vehicle.id) ? 'selected' : ''} ${vehicle.health} ${vehicle.freshnessMs > 1500 ? 'stale' : ''}`} style={{ left: `${vehicle.position.x}%`, top: `${vehicle.position.y}%`, transform: `translate(-50%,-50%) rotate(${vehicle.heading}deg)`, '--group': groupColors[vehicle.groupId] } as React.CSSProperties} onPointerDown={event => event.stopPropagation()} onClick={event => selectVehicles([vehicle.id], event.shiftKey)}><Icon name="aircraft"/><span>{vehicle.name}</span></button>)}
|
||
</div>
|
||
{marquee && <div className="selection-marquee" style={{ left: Math.min(marquee.x1, marquee.x2), top: Math.min(marquee.y1, marquee.y2), width: Math.abs(marquee.x2 - marquee.x1), height: Math.abs(marquee.y2 - marquee.y1) }}/>}
|
||
<div className="map-mode-hud"><span>{semantic}</span><b>{MODES[interaction.mode].label}</b><small>{interaction.draft ? interaction.draft.message : MODES[interaction.mode].pointer}</small></div>
|
||
<div className="map-scale"><i/><span>{Math.round(5 / camera.zoom)} km</span></div>
|
||
<div className="map-zoom-controls"><button aria-label="Zoom in" onClick={() => setCamera(value => ({ ...value, zoom: Math.min(4.2, value.zoom * 1.25) }))}>+</button><button aria-label="Zoom out" onClick={() => setCamera(value => ({ ...value, zoom: Math.max(.72, value.zoom / 1.25) }))}>−</button></div>
|
||
<button className="minimap" aria-label="Tactical overview; click to frame mission" onClick={() => setCamera({ x: 0, y: 0, zoom: .92 })}>
|
||
<span className="mini-coast"/><span className="mini-task"/><span className="mini-alert"/>
|
||
<span className="mini-viewport" style={{ width: `${Math.min(82, 76 / camera.zoom)}%`, height: `${Math.min(82, 64 / camera.zoom)}%`, left: `${12 - camera.x / 4}%`, top: `${18 - camera.y / 4}%` }}/><small>TACTICAL OVERVIEW</small>
|
||
</button>
|
||
{cursorPoint && interaction.mode !== 'select' && <span className="coordinate-readout">{(41.02 + cursorPoint.y * .0014).toFixed(5)}°N {(28.98 + cursorPoint.x * .0019).toFixed(5)}°E</span>}
|
||
</section>;
|
||
}
|