"use client"; import "leaflet/dist/leaflet.css"; import L from "leaflet"; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ImageOverlay, MapContainer, Marker, Polygon, Polyline, Rectangle, TileLayer, Tooltip, useMap, useMapEvents, } from "react-leaflet"; import type { MapPoint } from "@/lib/map/points"; import { dashArrayFor, metersPerPx, splinePolyline } from "@/lib/map/style"; import type { MapFeaturesResponse } from "@/lib/map/types"; import { markerBrushOf, resolveMarkerRgba, rgbaToHex } from "@/lib/arma-markers"; import { areaOutline, hatchAngles, hatchSegments, type MissionMarker, type MissionMarkerData, } from "@/lib/arma-markers/missionMarkers"; import { markerDivIcon } from "./markerIcon"; import type { MarkerStyle, PlanTool } from "./types"; export interface MissionMapCanvasProps { mapId: number; markers: MissionMarker[]; selectedId: number | null; tool: PlanTool; style: MarkerStyle; canEdit: boolean; showRoads: boolean; showZones: boolean; onSelect: (id: number | null) => void; onCreate: (data: MissionMarkerData) => void; /** persist=false for live drag previews, true on drag end. */ onChange: (marker: MissionMarker, persist: boolean) => void; onCursor: (point: MapPoint | null) => void; onLoaded: (info: { worldWidth: number; worldHeight: number; hasBasemap: boolean }) => void; } const toLatLng = (p: MapPoint): [number, number] => [-p[1], p[0]]; const fromLatLng = (latlng: L.LatLng): MapPoint => [ Math.round(latlng.lng * 100) / 100, Math.round(-latlng.lat * 100) / 100, ]; const ZONE_PATH_OPTIONS: Record = { water: { color: "#38bdf8", fillColor: "#38bdf8", fillOpacity: 0.2, weight: 1 }, land: { color: "#4ade80", fillColor: "#4ade80", fillOpacity: 0.06, weight: 1 }, territory: { color: "#fbbf24", fillColor: "#fbbf24", fillOpacity: 0.08, weight: 1 }, border: { color: "#a78bfa", fillColor: "#a78bfa", fillOpacity: 0.05, weight: 1 }, other: { color: "#94a3b8", fillColor: "#94a3b8", fillOpacity: 0.06, weight: 1 }, }; const ROAD_COLORS: Record = { paved: "#cbd5e1", dirt: "#d97706", trail: "#92713a", }; const BASEMAP_FALLBACK: L.PathOptions = { color: "#334155", fillColor: "#0f172a", fillOpacity: 1, weight: 1, }; const DRAFT_PATH: L.PathOptions = { color: "#22d3ee", weight: 2, dashArray: "6 4", fill: false }; const SELECTED_OUTLINE: L.PathOptions = { color: "#22d3ee", weight: 1.5, dashArray: "4 4", fill: false, interactive: false, }; /** Arma draws hatch brushes in screen space; ~9 px between lines. */ const HATCH_SPACING_PX = 9; /** Click-drag shorter than this (screen px) places a default-size area. */ const MIN_DRAG_PX = 6; const DEFAULT_AREA_PX = 40; const handleIcon = L.divIcon({ className: "mission-map-handle", html: '', iconSize: [12, 12], iconAnchor: [6, 6], }); const moveHandleIcon = L.divIcon({ className: "mission-map-handle", html: '', iconSize: [14, 14], iconAnchor: [7, 7], }); function DefaultView({ center, zoom }: { center: [number, number]; zoom: number }) { const map = useMap(); const applied = useRef(false); useEffect(() => { if (applied.current) return; applied.current = true; map.setView(center, Math.max(zoom, map.getMinZoom()), { animate: false }); }, [map, center, zoom]); return null; } /** Tracks the zoom level so hatch density can stay constant on screen. */ function useZoom(): number { const map = useMap(); const [zoom, setZoom] = useState(() => map.getZoom()); useMapEvents({ zoomend: () => setZoom(map.getZoom()) }); return zoom; } interface InteractionProps { tool: PlanTool; canEdit: boolean; style: MarkerStyle; worldSize: number; layerClickAt: React.RefObject; onSelect: (id: number | null) => void; onCreate: (data: MissionMarkerData) => void; onCursor: (point: MapPoint | null) => void; } /** * Tool interactions: click-to-place icons, click-drag areas (corner to * corner), click-by-click lines (double-click or Enter finishes, Backspace * removes the last point, Escape cancels), and click-empty-map to deselect. */ function PlanInteractions({ tool, canEdit, style, worldSize, layerClickAt, onSelect, onCreate, onCursor, }: InteractionProps) { const map = useMap(); const [cursor, setCursor] = useState(null); const [dragStart, setDragStartState] = useState(null); const dragStartRef = useRef(null); const setDragStart = useCallback((next: MapPoint | null) => { dragStartRef.current = next; setDragStartState(next); }, []); const [linePoints, setLinePointsState] = useState([]); // Click / dblclick fire back to back faster than React re-renders, so the // handlers read and write the vertices through a ref (state only drives // the draft preview). const linePointsRef = useRef([]); const setLinePoints = useCallback((next: MapPoint[]) => { linePointsRef.current = next; setLinePointsState(next); }, []); const areaTool = canEdit && (tool === "rectangle" || tool === "ellipse"); const lineTool = canEdit && tool === "polyline"; // Area tools draw with click-drag, so the map must not pan meanwhile; the // line tool finishes on double-click, so that must not zoom. useEffect(() => { if (areaTool) map.dragging.disable(); else map.dragging.enable(); if (lineTool) map.doubleClickZoom.disable(); else map.doubleClickZoom.enable(); return () => { map.dragging.enable(); map.doubleClickZoom.enable(); }; }, [map, areaTool, lineTool]); useEffect(() => { setLinePoints([]); setDragStart(null); }, [tool, setLinePoints, setDragStart]); const finishLine = useCallback( (points: MapPoint[]) => { if (points.length >= 2) { onCreate({ shape: "polyline", markerType: null, color: style.color, brush: null, text: style.text, position: points[0], size: [1, 1], direction: 0, alpha: style.alpha, points, }); } setLinePoints([]); }, [onCreate, style, setLinePoints], ); useEffect(() => { if (!lineTool) return; const onKey = (event: KeyboardEvent) => { const target = event.target as HTMLElement | null; if (target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return; const points = linePointsRef.current; if (event.key === "Enter") { event.preventDefault(); finishLine(points); } else if (event.key === "Escape") { setLinePoints([]); } else if (event.key === "Backspace" && points.length > 0) { event.preventDefault(); event.stopPropagation(); setLinePoints(points.slice(0, -1)); } }; window.addEventListener("keydown", onKey, true); return () => window.removeEventListener("keydown", onKey, true); }, [lineTool, finishLine, setLinePoints]); const pxToMeters = useCallback( (px: number) => px * metersPerPx(map.getZoom(), worldSize), [map, worldSize], ); useMapEvents({ mousemove: (event) => { const point = fromLatLng(event.latlng); setCursor(point); onCursor(point); }, mouseout: () => { setCursor(null); onCursor(null); }, mousedown: (event) => { if (!areaTool || event.originalEvent.button !== 0) return; setDragStart(fromLatLng(event.latlng)); }, mouseup: (event) => { const dragStart = dragStartRef.current; if (!areaTool || !dragStart) return; const end = fromLatLng(event.latlng); const startPx = map.latLngToContainerPoint(toLatLng(dragStart)); const endPx = map.latLngToContainerPoint(event.latlng); const dragged = startPx.distanceTo(endPx) >= MIN_DRAG_PX; const fallback = Math.max(5, Math.round(pxToMeters(DEFAULT_AREA_PX))); const center: MapPoint = dragged ? [(dragStart[0] + end[0]) / 2, (dragStart[1] + end[1]) / 2] : dragStart; const size: [number, number] = dragged ? [ Math.max(1, Math.abs(end[0] - dragStart[0]) / 2), Math.max(1, Math.abs(end[1] - dragStart[1]) / 2), ] : [fallback, fallback]; setDragStart(null); onCreate({ shape: tool as "rectangle" | "ellipse", markerType: null, color: style.color, brush: style.brush, text: style.text, position: center, size, direction: 0, alpha: style.alpha, points: [], }); }, click: (event) => { // Layer clicks (markers/areas/lines) bubble here too; ignore those. if (Date.now() - (layerClickAt.current ?? 0) < 80) return; const point = fromLatLng(event.latlng); if (canEdit && tool === "icon") { onCreate({ shape: "icon", markerType: style.markerType, color: style.color, brush: null, text: style.text, position: point, size: [style.iconScale, style.iconScale], direction: style.direction, alpha: style.alpha, points: [], }); return; } if (lineTool) { // The second click of a double-click lands on the previous vertex; // skip it so finishing a line never leaves a zero-length segment. const current = linePointsRef.current; const last = current[current.length - 1]; if ( last && map.latLngToContainerPoint(toLatLng(last)).distanceTo(event.containerPoint) < 4 ) { return; } setLinePoints([...current, point]); return; } if (!areaTool) onSelect(null); }, dblclick: () => { if (lineTool) finishLine(linePointsRef.current); }, }); const areaDraft = useMemo(() => { if (!dragStart || !cursor || !areaTool) return null; const center: MapPoint = [(dragStart[0] + cursor[0]) / 2, (dragStart[1] + cursor[1]) / 2]; const size: [number, number] = [ Math.max(1, Math.abs(cursor[0] - dragStart[0]) / 2), Math.max(1, Math.abs(cursor[1] - dragStart[1]) / 2), ]; return areaOutline(tool as "rectangle" | "ellipse", center, size, 0).map(toLatLng); }, [dragStart, cursor, areaTool, tool]); const lineDraft = useMemo(() => { if (!lineTool || linePoints.length === 0) return null; const points = cursor ? [...linePoints, cursor] : linePoints; return points.map(toLatLng); }, [lineTool, linePoints, cursor]); return ( <> {areaDraft && } {lineDraft && } {lineTool && linePoints.map((point, index) => ( ))} ); } interface AreaProps { marker: MissionMarker; selected: boolean; worldSize: number; onLayerClick: (id: number) => void; } function AreaMarker({ marker, selected, worldSize, onLayerClick }: AreaProps) { const zoom = useZoom(); const shape = marker.shape as "rectangle" | "ellipse"; const hex = rgbaToHex(resolveMarkerRgba(marker.color)); const brush = markerBrushOf(marker.brush); const pattern = brush?.pattern ?? "solid"; const ring = useMemo( () => areaOutline(shape, marker.position, marker.size, marker.direction), [shape, marker.position, marker.size, marker.direction], ); const positions = useMemo(() => ring.map(toLatLng), [ring]); const spacing = HATCH_SPACING_PX * metersPerPx(zoom, worldSize); const hatches = useMemo(() => { const angles = hatchAngles(pattern); return angles.flatMap((angle) => hatchSegments(ring, angle + marker.direction, spacing).map((segment) => segment.map(toLatLng), ), ); }, [pattern, ring, marker.direction, spacing]); const solid = pattern === "solid"; const fullSolid = brush?.className.toLowerCase() === "solidfull"; const pathOptions = useMemo( () => ({ color: hex, weight: brush?.drawBorder ? 2 : 0, opacity: marker.alpha, stroke: Boolean(brush?.drawBorder), fill: true, fillColor: hex, // Hatched/border brushes keep a near-invisible fill so the whole area // stays clickable. fillOpacity: solid ? (fullSolid ? 0.85 : 0.45) * marker.alpha : 0.01, }), [hex, brush?.drawBorder, marker.alpha, solid, fullSolid], ); const hatchOptions = useMemo( () => ({ color: hex, weight: 1.25, opacity: marker.alpha, interactive: false }), [hex, marker.alpha], ); return ( <> onLayerClick(marker.id) }} > {marker.text && ( {marker.text} )} {hatches.length > 0 && } {selected && } ); } /** Inverse of the marker rotation: map offset to local (east, north). */ function localExtent(center: MapPoint, point: MapPoint, directionDeg: number): [number, number] { const east = point[0] - center[0]; const north = center[1] - point[1]; const rad = (directionDeg * Math.PI) / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); return [Math.abs(east * cos - north * sin), Math.abs(east * sin + north * cos)]; } function AreaHandles({ marker, onChange, }: { marker: MissionMarker; onChange: (marker: MissionMarker, persist: boolean) => void; }) { // Resize grip sits on the rotated north-east corner (for ellipses: the // corner of the bounding box, which is where a rectangle grip would be). const corner = areaOutline("rectangle", marker.position, marker.size, marker.direction)[1]; const resized = (latlng: L.LatLng): MissionMarker => { const [a, b] = localExtent(marker.position, fromLatLng(latlng), marker.direction); return { ...marker, size: [Math.max(1, Math.round(a)), Math.max(1, Math.round(b))] }; }; return ( <> onChange( { ...marker, position: fromLatLng((event.target as L.Marker).getLatLng()) }, false, ), dragend: (event) => onChange( { ...marker, position: fromLatLng((event.target as L.Marker).getLatLng()) }, true, ), }} /> onChange(resized((event.target as L.Marker).getLatLng()), false), dragend: (event) => onChange(resized((event.target as L.Marker).getLatLng()), true), }} /> ); } function LineHandles({ marker, onChange, }: { marker: MissionMarker; onChange: (marker: MissionMarker, persist: boolean) => void; }) { const moved = (index: number, latlng: L.LatLng): MissionMarker => { const points = marker.points.map((point, i) => (i === index ? fromLatLng(latlng) : point)); return { ...marker, points, position: points[0] }; }; return ( <> {marker.points.map((point, index) => ( onChange(moved(index, (event.target as L.Marker).getLatLng()), false), dragend: (event) => onChange(moved(index, (event.target as L.Marker).getLatLng()), true), contextmenu: () => { if (marker.points.length <= 2) return; const points = marker.points.filter((_, i) => i !== index); onChange({ ...marker, points, position: points[0] }, true); }, }} /> ))} ); } export function MissionMapCanvas({ mapId, markers, selectedId, tool, style, canEdit, showRoads, showZones, onSelect, onCreate, onChange, onCursor, onLoaded, }: MissionMapCanvasProps) { const [features, setFeatures] = useState(null); const [error, setError] = useState(null); const layerClickAt = useRef(0); useEffect(() => { let cancelled = false; fetch(`/api/map-features?mapId=${mapId}&layers=base`, { cache: "no-store" }) .then(async (response) => { if (!response.ok) { const body = (await response.json().catch(() => null)) as { error?: string } | null; throw new Error(body?.error ?? `Request failed (${response.status})`); } return (await response.json()) as MapFeaturesResponse; }) .then((body) => { if (cancelled) return; setFeatures(body); onLoaded({ worldWidth: body.map.worldSizeWidth, worldHeight: body.map.worldSizeHeight, hasBasemap: Boolean( (body.map.basemapMode === "image" && body.map.basemapImageUrl) || (body.map.basemapMode === "tiles" && body.map.tileUrlTemplate), ), }); }) .catch((e: unknown) => { if (!cancelled) setError(e instanceof Error ? e.message : "Failed to load the map."); }); return () => { cancelled = true; }; }, [mapId, onLoaded]); const mapConfig = features?.map ?? null; const worldW = mapConfig?.worldSizeWidth ?? 8192; const worldH = mapConfig?.worldSizeHeight ?? 8192; const worldSize = Math.max(worldW, worldH); const mapCrs = useMemo( () => L.extend({}, L.CRS.Simple, { transformation: new L.Transformation(256 / worldSize, 0, -256 / worldSize, 0), }) as L.CRS, [worldSize], ); const worldBounds = useMemo( () => L.latLngBounds(L.latLng(-worldH, 0), L.latLng(0, worldW)), [worldW, worldH], ); const center = useMemo<[number, number]>( () => mapConfig ? [-mapConfig.defaultCenter[1], mapConfig.defaultCenter[0]] : [-worldH / 2, worldW / 2], [mapConfig, worldW, worldH], ); const roadLines = useMemo( () => (features?.roads ?? []).map((road) => ({ road, positions: splinePolyline(road.points).map(toLatLng), options: { color: road.strokeColor || ROAD_COLORS[road.surface] || "#cbd5e1", weight: road.strokeWeight ?? 2, dashArray: dashArrayFor(road.strokeStyle), opacity: 0.85, interactive: false, } satisfies L.PathOptions, })), [features], ); const zonePolys = useMemo( () => (features?.zones ?? []).map((zone) => { const defaults = ZONE_PATH_OPTIONS[zone.type] ?? ZONE_PATH_OPTIONS.other; return { zone, positions: zone.points.map(toLatLng), options: { color: zone.strokeColor || defaults.color, fillColor: zone.fillColor || defaults.fillColor, fillOpacity: zone.fillOpacity ?? defaults.fillOpacity, weight: zone.strokeWeight ?? 1, interactive: false, } satisfies L.PathOptions, }; }), [features], ); const handleLayerClick = useCallback( (id: number) => { layerClickAt.current = Date.now(); // Placement tools keep placing on top of existing markers. if (tool === "select" || !canEdit) onSelect(id); }, [tool, canEdit, onSelect], ); const selected = markers.find((marker) => marker.id === selectedId) ?? null; const editingSelected = canEdit && tool === "select" && selected; if (error) { return (
{error}
); } return ( {mapConfig && } {mapConfig?.basemapMode === "tiles" && mapConfig.tileUrlTemplate && ( )} {mapConfig?.basemapMode === "image" && mapConfig.basemapImageUrl && ( )} {mapConfig && !( (mapConfig.basemapMode === "image" && mapConfig.basemapImageUrl) || (mapConfig.basemapMode === "tiles" && mapConfig.tileUrlTemplate) ) && } {showZones && zonePolys.map(({ zone, positions, options }) => ( ))} {showRoads && roadLines.map(({ road, positions, options }) => ( ))} {markers.map((marker) => { if (marker.shape === "rectangle" || marker.shape === "ellipse") { return ( ); } if (marker.shape === "polyline") { const hex = rgbaToHex(resolveMarkerRgba(marker.color)); return ( handleLayerClick(marker.id) }} > {marker.text && ( {marker.text} )} {marker.id === selectedId && ( )} ); } return null; })} {/* Icons render after areas/lines so they stay on top (marker pane). */} {markers .filter((marker) => marker.shape === "icon") .map((marker) => ( handleLayerClick(marker.id), dragstart: () => onSelect(marker.id), dragend: (event) => onChange( { ...marker, position: fromLatLng((event.target as L.Marker).getLatLng()) }, true, ), }} /> ))} {editingSelected && (selected.shape === "rectangle" || selected.shape === "ellipse") && ( )} {editingSelected && selected.shape === "polyline" && ( )} ); }