- New mission-map-markers collection (permission-gated, removed with its mission via a Missions beforeDelete hook) plus an opt-in classification.planningMapEnabled flag on missions. - The mission page gains a planning section: place and edit Arma markers (icons, ellipses, rectangles, polylines) with debounced saves, an SQF copy export, and an Arma grid readout. Everyone who can read the mission sees the plan once enabled; editing requires missions:update or mission ownership. - Base layers come from GET /api/map-features?layers=base, which skips the structure, node and shipment queries; marker icons render tinted by color exactly like in-game. - Includes the hand-written migration, the missionMap service lib, server actions emitting mission:map-marker-* events, and integration plus e2e coverage.
765 lines
24 KiB
TypeScript
765 lines
24 KiB
TypeScript
"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<string, L.PathOptions> = {
|
|
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<string, string> = {
|
|
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: '<span class="mission-map-handle-dot"></span>',
|
|
iconSize: [12, 12],
|
|
iconAnchor: [6, 6],
|
|
});
|
|
|
|
const moveHandleIcon = L.divIcon({
|
|
className: "mission-map-handle",
|
|
html: '<span class="mission-map-handle-dot mission-map-handle-move"></span>',
|
|
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<number>;
|
|
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<MapPoint | null>(null);
|
|
const [dragStart, setDragStartState] = useState<MapPoint | null>(null);
|
|
const dragStartRef = useRef<MapPoint | null>(null);
|
|
const setDragStart = useCallback((next: MapPoint | null) => {
|
|
dragStartRef.current = next;
|
|
setDragStartState(next);
|
|
}, []);
|
|
const [linePoints, setLinePointsState] = useState<MapPoint[]>([]);
|
|
// 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<MapPoint[]>([]);
|
|
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 && <Polygon positions={areaDraft} pathOptions={DRAFT_PATH} interactive={false} />}
|
|
{lineDraft && <Polyline positions={lineDraft} pathOptions={DRAFT_PATH} interactive={false} />}
|
|
{lineTool &&
|
|
linePoints.map((point, index) => (
|
|
<Marker
|
|
key={`draft-${index}`}
|
|
position={toLatLng(point)}
|
|
icon={handleIcon}
|
|
interactive={false}
|
|
/>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
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<L.PathOptions>(
|
|
() => ({
|
|
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<L.PathOptions>(
|
|
() => ({ color: hex, weight: 1.25, opacity: marker.alpha, interactive: false }),
|
|
[hex, marker.alpha],
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<Polygon
|
|
positions={positions}
|
|
pathOptions={pathOptions}
|
|
eventHandlers={{ click: () => onLayerClick(marker.id) }}
|
|
>
|
|
{marker.text && (
|
|
<Tooltip direction="top" opacity={1} className="map-tooltip-dossier" sticky>
|
|
{marker.text}
|
|
</Tooltip>
|
|
)}
|
|
</Polygon>
|
|
{hatches.length > 0 && <Polyline positions={hatches} pathOptions={hatchOptions} />}
|
|
{selected && <Polygon positions={positions} pathOptions={SELECTED_OUTLINE} />}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** 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 (
|
|
<>
|
|
<Marker
|
|
position={toLatLng(marker.position)}
|
|
icon={moveHandleIcon}
|
|
draggable
|
|
eventHandlers={{
|
|
drag: (event) =>
|
|
onChange(
|
|
{ ...marker, position: fromLatLng((event.target as L.Marker).getLatLng()) },
|
|
false,
|
|
),
|
|
dragend: (event) =>
|
|
onChange(
|
|
{ ...marker, position: fromLatLng((event.target as L.Marker).getLatLng()) },
|
|
true,
|
|
),
|
|
}}
|
|
/>
|
|
<Marker
|
|
position={toLatLng(corner)}
|
|
icon={handleIcon}
|
|
draggable
|
|
eventHandlers={{
|
|
drag: (event) => 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) => (
|
|
<Marker
|
|
key={`vertex-${index}`}
|
|
position={toLatLng(point)}
|
|
icon={handleIcon}
|
|
draggable
|
|
eventHandlers={{
|
|
drag: (event) => 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<MapFeaturesResponse | null>(null);
|
|
const [error, setError] = useState<string | null>(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 (
|
|
<div className="flex size-full items-center justify-center text-sm text-red-400">{error}</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<MapContainer
|
|
key={`${mapId}-${worldW}x${worldH}`}
|
|
center={center}
|
|
zoom={mapConfig?.defaultZoom ?? 2}
|
|
minZoom={mapConfig?.minZoom ?? 0}
|
|
maxZoom={mapConfig?.maxZoom ?? 12}
|
|
maxBounds={L.latLngBounds(L.latLng(-worldH - 2000, -2000), L.latLng(2000, worldW + 2000))}
|
|
maxBoundsViscosity={1}
|
|
zoomSnap={0.25}
|
|
crs={mapCrs}
|
|
className="size-full"
|
|
preferCanvas
|
|
attributionControl={false}
|
|
>
|
|
{mapConfig && <DefaultView center={center} zoom={mapConfig.defaultZoom} />}
|
|
{mapConfig?.basemapMode === "tiles" && mapConfig.tileUrlTemplate && (
|
|
<TileLayer
|
|
url={mapConfig.tileUrlTemplate}
|
|
minZoom={mapConfig.minZoom}
|
|
maxZoom={mapConfig.maxZoom}
|
|
bounds={worldBounds}
|
|
/>
|
|
)}
|
|
{mapConfig?.basemapMode === "image" && mapConfig.basemapImageUrl && (
|
|
<ImageOverlay url={mapConfig.basemapImageUrl} bounds={worldBounds} />
|
|
)}
|
|
{mapConfig &&
|
|
!(
|
|
(mapConfig.basemapMode === "image" && mapConfig.basemapImageUrl) ||
|
|
(mapConfig.basemapMode === "tiles" && mapConfig.tileUrlTemplate)
|
|
) && <Rectangle bounds={worldBounds} pathOptions={BASEMAP_FALLBACK} interactive={false} />}
|
|
|
|
{showZones &&
|
|
zonePolys.map(({ zone, positions, options }) => (
|
|
<Polygon key={`zone-${zone.id}`} positions={positions} pathOptions={options} />
|
|
))}
|
|
{showRoads &&
|
|
roadLines.map(({ road, positions, options }) => (
|
|
<Polyline key={`road-${road.id}`} positions={positions} pathOptions={options} />
|
|
))}
|
|
|
|
{markers.map((marker) => {
|
|
if (marker.shape === "rectangle" || marker.shape === "ellipse") {
|
|
return (
|
|
<AreaMarker
|
|
key={`area-${marker.id}`}
|
|
marker={marker}
|
|
selected={marker.id === selectedId}
|
|
worldSize={worldSize}
|
|
onLayerClick={handleLayerClick}
|
|
/>
|
|
);
|
|
}
|
|
if (marker.shape === "polyline") {
|
|
const hex = rgbaToHex(resolveMarkerRgba(marker.color));
|
|
return (
|
|
<Fragment key={`line-${marker.id}`}>
|
|
<Polyline
|
|
positions={marker.points.map(toLatLng)}
|
|
pathOptions={{ color: hex, weight: 3, opacity: marker.alpha }}
|
|
eventHandlers={{ click: () => handleLayerClick(marker.id) }}
|
|
>
|
|
{marker.text && (
|
|
<Tooltip direction="top" opacity={1} className="map-tooltip-dossier" sticky>
|
|
{marker.text}
|
|
</Tooltip>
|
|
)}
|
|
</Polyline>
|
|
{marker.id === selectedId && (
|
|
<Polyline
|
|
positions={marker.points.map(toLatLng)}
|
|
pathOptions={{
|
|
...SELECTED_OUTLINE,
|
|
weight: 7,
|
|
opacity: 0.35,
|
|
dashArray: undefined,
|
|
}}
|
|
/>
|
|
)}
|
|
</Fragment>
|
|
);
|
|
}
|
|
return null;
|
|
})}
|
|
|
|
{/* Icons render after areas/lines so they stay on top (marker pane). */}
|
|
{markers
|
|
.filter((marker) => marker.shape === "icon")
|
|
.map((marker) => (
|
|
<Marker
|
|
key={`icon-${marker.id}`}
|
|
position={toLatLng(marker.position)}
|
|
icon={markerDivIcon(marker, marker.id === selectedId)}
|
|
draggable={canEdit && tool === "select"}
|
|
eventHandlers={{
|
|
click: () => 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") && (
|
|
<AreaHandles marker={selected} onChange={onChange} />
|
|
)}
|
|
{editingSelected && selected.shape === "polyline" && (
|
|
<LineHandles marker={selected} onChange={onChange} />
|
|
)}
|
|
|
|
<PlanInteractions
|
|
tool={tool}
|
|
canEdit={canEdit}
|
|
style={style}
|
|
worldSize={worldSize}
|
|
layerClickAt={layerClickAt}
|
|
onSelect={onSelect}
|
|
onCreate={onCreate}
|
|
onCursor={onCursor}
|
|
/>
|
|
</MapContainer>
|
|
);
|
|
}
|