Files
kaptan/ground-suite/src/app/OperationalMap.tsx
T
Onur Özkan 6e15063502 save
Signed-off-by: Onur Özkan <work@onurozkan.dev>
2026-08-17 18:51:40 +03:00

113 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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&nbsp; {(28.98 + cursorPoint.x * .0019).toFixed(5)}°E</span>}
</section>;
}