1
0
Fork 0
polaris-task-force/src/components/frontend/map/MapClient.tsx
Z8MB1E 2bc61fe211 feat(map): zoom-tiered pins with info ribbons, vertex editing UI, attribution
- structure pins step 26px to 32px to 38px as you zoom in; at the close tier
  an info ribbon extends from the pin (name plus one status line: blueprint
  purpose, minute-bucketed build ETA, or first needed material)
- ribbons claim screen-space rectangles greedily by cluster priority so they
  never overlap other pins or ribbons; losers fall back to hover tooltips
- hovered structure jumps above all markers via a leaflet icon z-index rule
- authoring clicks and draft-vertex drags snap to existing geometry
  (vertices plus road/zone edges, closing segments included) with a live
  snap preview; draggable handles, right-click delete, midpoint insert
- distant roads render one straight midpoint label, close roads curve text
  along the path
- structure popover footer shows a live ETA countdown, status line shows a
  friendly estimated completion timestamp
- attribution control credited to the unit and game, restyled to match
2026-09-17 16:28:05 -04:00

1754 lines
62 KiB
TypeScript

"use client";
import "leaflet/dist/leaflet.css";
import L from "leaflet";
import { useCallback, useEffect, useMemo, useRef, useState, Fragment, memo } from "react";
import {
Circle,
CircleMarker,
ImageOverlay,
MapContainer,
Marker,
Polygon,
Polyline,
Popup,
Rectangle,
TileLayer,
Tooltip,
useMap,
useMapEvents,
} from "react-leaflet";
import Link from "next/link";
import { Hammer, Pencil, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useGameTick } from "@/hooks/useGameTick";
import { distanceSquared, interpolateAlong, polylineLength, type MapPoint } from "@/lib/map/points";
import {
findSnap,
segmentMidpoints,
type SnapKind,
type SnapMatch,
type SnapTarget,
} from "@/lib/map/snapping";
import {
dashArrayFor,
labelScreenAngle,
metersPerPx,
polygonCentroid,
roadLabelPoints,
roadLabelShown,
splinePolyline,
} from "@/lib/map/style";
import type { MapFeaturesResponse, ShipmentFeature, StructureFeature } from "@/lib/map/types";
import { PlacementDialog } from "./PlacementDialog";
import { AuthoringToolbar, useMapAuthoring } from "./AuthoringToolbar";
import { StructureDetailDrawer } from "./StructureDetailDrawer";
export interface MapClientProps {
maps: { id: number; name: string }[];
canEdit: boolean;
canPlace: boolean;
}
type LayerToggles = {
structures: boolean;
nodes: boolean;
roads: boolean;
zones: boolean;
shipments: boolean;
ranges: boolean;
};
const STRUCTURE_STATUS: Record<string, { color: string; label: string }> = {
complete: { color: "#34d399", label: "Operational" },
building: { color: "#60a5fa", label: "Building" },
awaiting_materials: { color: "#fbbf24", label: "Awaiting materials" },
};
// Layer styling is hoisted to module constants: react-leaflet re-applies
// pathOptions by reference, and a fresh object literal every render forces a
// canvas redraw that glitches mid-zoom-animation.
const ZONE_PATH_OPTIONS: Record<"water" | "land" | "territory" | "border", L.PathOptions> = {
water: { color: "#38bdf8", fillColor: "#38bdf8", fillOpacity: 0.25, weight: 1 },
land: { color: "#4ade80", fillColor: "#4ade80", fillOpacity: 0.08, weight: 1 },
territory: { color: "#fbbf24", fillColor: "#fbbf24", fillOpacity: 0.1, weight: 1 },
border: { color: "#a78bfa", fillColor: "#a78bfa", fillOpacity: 0.06, weight: 1 },
};
const ROAD_PATH_OPTIONS: Record<string, L.PathOptions> = {
paved: { color: "#cbd5e1", weight: 2 },
dirt: { color: "#d97706", weight: 2 },
trail: { color: "#92713a", weight: 2 },
};
const STRUCTURE_PATH_OPTIONS: Record<string, L.PathOptions> = {
complete: { color: "#34d399", fillColor: "#34d399", fillOpacity: 0.85, weight: 2 },
building: { color: "#60a5fa", fillColor: "#60a5fa", fillOpacity: 0.85, weight: 2 },
awaiting_materials: { color: "#fbbf24", fillColor: "#fbbf24", fillOpacity: 0.85, weight: 2 },
};
const RANGE_RESOURCE_PATH_OPTIONS: L.PathOptions = {
color: "#2dd4bf",
dashArray: "4 4",
fillOpacity: 0.03,
weight: 1,
};
const RANGE_STRUCTURE_PATH_OPTIONS: L.PathOptions = {
color: "#a78bfa",
dashArray: "4 4",
fillOpacity: 0.03,
weight: 1,
};
const SHIPMENT_LINE_PATH_OPTIONS: L.PathOptions = {
color: "#22d3ee",
weight: 2,
dashArray: "6 6",
opacity: 0.8,
};
const BASEMAP_FALLBACK_PATH_OPTIONS: L.PathOptions = {
color: "#334155",
fillColor: "#0f172a",
fillOpacity: 1,
weight: 1,
};
type StructurePinTier = "sm" | "md" | "lg";
const STRUCTURE_PIN_SIZES: Record<StructurePinTier, number> = { sm: 26, md: 32, lg: 38 };
const STRUCTURE_PIN_SVG_SIZES: Record<StructurePinTier, number> = { sm: 13, md: 16, lg: 20 };
const ROAD_LABEL_SPACING_PX = 400;
const ROAD_LABEL_MIN_SCREEN_PX = 120;
const ROAD_LABEL_CURVE_MIN_PX = 600;
const ROAD_LABELS_MAX = 12;
const ROAD_LABEL_CHAR_PX = 9;
const STRUCTURE_LABEL_CLUSTER_PX = 56;
const STRUCTURE_PIN_MID_M_PER_PX = 8;
const STRUCTURE_RIBBON_M_PER_PX = 4;
const RIBBON_HEIGHT_PX = 32;
const RIBBON_GAP_PX = 6;
const RIBBON_PIN_HALF_PX = STRUCTURE_PIN_SIZES.lg / 2;
function escapeHtml(text: string): string {
return text
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
const zoneLabelIconCache = new Map<string, L.DivIcon>();
function zoneLabelIcon(name: string): L.DivIcon {
let icon = zoneLabelIconCache.get(name);
if (!icon) {
icon = L.divIcon({
className: "map-zone-label-icon",
html: `<div class="map-zone-label">${escapeHtml(name)}</div>`,
iconSize: [0, 0],
iconAnchor: [0, 0],
});
zoneLabelIconCache.set(name, icon);
}
return icon;
}
function polylinePathD(points: MapPoint[]): string {
return points
.map((p, index) => `${index === 0 ? "M" : "L"}${p[0].toFixed(1)} ${p[1].toFixed(1)}`)
.join(" ");
}
const straightLabelIconCache = new Map<string, L.DivIcon>();
function straightLabelIcon(name: string, angle: number): L.DivIcon {
const bucket = Math.round(angle / 5) * 5;
const cacheKey = `${name}:${bucket}`;
let icon = straightLabelIconCache.get(cacheKey);
if (!icon) {
icon = L.divIcon({
className: "map-road-label-icon",
html: `<div class="map-road-label" style="transform: translate(-50%, -50%) rotate(${bucket}deg)">${escapeHtml(name.toUpperCase())}</div>`,
iconSize: [0, 0],
iconAnchor: [0, 0],
});
straightLabelIconCache.set(cacheKey, icon);
}
return icon;
}
/**
* Catmull-Rom spline through the points, emitted as cubic Beziers, so label
* glyphs turn gently through bends instead of snapping at each kink. The
* spline passes through every vertex; first/last neighbors are clamped.
*/
function smoothPathD(points: MapPoint[]): string {
if (points.length < 3) return polylinePathD(points);
const at = (index: number): MapPoint => points[Math.max(0, Math.min(points.length - 1, index))];
let d = `M${points[0][0].toFixed(1)} ${points[0][1].toFixed(1)}`;
for (let i = 0; i < points.length - 1; i++) {
const prev = at(i - 1);
const current = at(i);
const next = at(i + 1);
const after = at(i + 2);
const c1: MapPoint = [
current[0] + (next[0] - prev[0]) / 6,
current[1] + (next[1] - prev[1]) / 6,
];
const c2: MapPoint = [
next[0] - (after[0] - current[0]) / 6,
next[1] - (after[1] - current[1]) / 6,
];
d += ` C${c1[0].toFixed(1)} ${c1[1].toFixed(1)} ${c2[0].toFixed(1)} ${c2[1].toFixed(1)} ${next[0].toFixed(1)} ${next[1].toFixed(1)}`;
}
return d;
}
function roadLabelSvgIcon(
roadId: number,
name: string,
pxPoints: MapPoint[],
screenLength: number,
labels: { offsetPct: number; flipped: boolean }[],
): L.DivIcon {
const forward = smoothPathD(pxPoints);
const reversed = smoothPathD([...pxPoints].reverse());
const lengthAttr = screenLength.toFixed(1);
const texts = labels
.map(
(label) =>
`<text class="map-road-label-text"><textPath href="#rlp-${roadId}-${label.flipped ? "r" : "f"}" xlink:href="#rlp-${roadId}-${label.flipped ? "r" : "f"}" startOffset="${label.offsetPct.toFixed(2)}%" text-anchor="middle">${escapeHtml(name.toUpperCase())}</textPath></text>`,
)
.join("");
return L.divIcon({
className: "map-road-label-icon",
html: `<svg class="map-road-label-svg"><path id="rlp-${roadId}-f" class="map-road-label-path" pathLength="${lengthAttr}" d="${forward}"></path><path id="rlp-${roadId}-r" class="map-road-label-path" pathLength="${lengthAttr}" d="${reversed}"></path>${texts}</svg>`,
iconSize: [0, 0],
iconAnchor: [0, 0],
});
}
const toLatLng = (p: MapPoint): [number, number] => [-p[1], p[0]];
const NODE_SVG_PATH = '<path d="m8 3 4 8 5-5 5 15H2L8 3z"/>';
const SHIPMENT_SVG_PATH = '<polygon points="3 11 22 2 13 21 11 13 3 11"/>';
const STRUCTURE_SVG_PATHS: Record<string, string> = {
complete:
'<path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/><path d="M10 18h4"/>',
building:
'<path d="m15 12-8.373 8.373a1 1 0 1 1-3-3L12 9"/><path d="m18 15 4-4"/><path d="m21.5 11.5-1.914-1.914A2 2 0 0 1 19 8.172V7l-2.26-2.26a6 6 0 0 0-4.202-1.756L9 2.96l.92.829A6.018 6.018 0 0 1 12 8.02V10l2 2h1.172a2 2 0 0 1 1.414.586L18.5 14.5"/>',
awaiting_materials:
'<path d="M16.5 9.4 7.55 4.24"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.29 7 12 12 20.71 7"/><line x1="12" x2="12" y1="22" y2="12"/>',
};
function dossierSvg(paths: string, color: string, bearingDeg?: number, size = 13): string {
const rotate = bearingDeg != null ? ` transform="rotate(${bearingDeg} 12 12)"` : "";
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"${rotate}>${paths}</svg>`;
}
const nodeIcon = L.divIcon({
className: "map-structure-icon",
html: `<div class="map-structure-pin map-pin-sm" style="border-color:#22d3ee">${dossierSvg(NODE_SVG_PATH, "#22d3ee")}</div>`,
iconSize: [20, 20],
iconAnchor: [10, 10],
});
const shipmentIconCache = new Map<number, L.DivIcon>();
function shipmentIcon(bearing: number): L.DivIcon {
const bucket = Math.round(bearing / 5) * 5;
let icon = shipmentIconCache.get(bucket);
if (!icon) {
icon = L.divIcon({
className: "map-structure-icon",
html: `<div class="map-structure-pin" style="border-color:#22d3ee">${dossierSvg(
SHIPMENT_SVG_PATH,
"#22d3ee",
bucket,
)}</div>`,
iconSize: [26, 26],
iconAnchor: [13, 13],
});
shipmentIconCache.set(bucket, icon);
}
return icon;
}
const structureIconCache = new Map<string, L.DivIcon>();
interface StructureRibbon {
tier: StructurePinTier;
name: string;
meta: string;
}
function getStructureIcon(
statusKey: string,
progressPct: number | null,
ribbon: StructureRibbon | null,
): L.DivIcon {
const tier = ribbon?.tier ?? "sm";
const pinSize = STRUCTURE_PIN_SIZES[tier];
const cacheKey = `${statusKey}:${progressPct ?? "full"}:${tier}:${ribbon ? `${ribbon.name}|${ribbon.meta}` : ""}`;
let icon = structureIconCache.get(cacheKey);
if (!icon) {
if (structureIconCache.size > 400) structureIconCache.clear();
const meta = STRUCTURE_STATUS[statusKey] ?? STRUCTURE_STATUS.complete;
const bar =
progressPct != null
? `<div class="map-structure-progress"><div style="width:${progressPct}%"></div></div>`
: "";
const ribbonHtml = ribbon
? `<div class="map-structure-ribbon" style="border-left-color:${meta.color}"><div class="ribbon-name">${escapeHtml(ribbon.name)}</div><div class="ribbon-meta">${escapeHtml(ribbon.meta)}</div></div>`
: "";
icon = L.divIcon({
className: "map-structure-icon",
html: `<div class="map-structure-pin ${tier === "sm" ? "" : `map-pin-${tier}`}" style="border-color:${meta.color}">${dossierSvg(
STRUCTURE_SVG_PATHS[statusKey] ?? STRUCTURE_SVG_PATHS.complete,
meta.color,
undefined,
STRUCTURE_PIN_SVG_SIZES[tier],
)}${bar}${ribbonHtml}</div>`,
iconSize: [pinSize, pinSize],
iconAnchor: [pinSize / 2, pinSize / 2],
});
structureIconCache.set(cacheKey, icon);
}
return icon;
}
function formatEta(estimatedArrival: string | null, nowMs: number): string | null {
if (!estimatedArrival) return null;
const eta = new Date(estimatedArrival).getTime();
const minutes = Math.round((eta - nowMs) / 60000);
if (minutes <= 0) return "arriving";
if (minutes < 60) return `ETA ${minutes}m`;
return `ETA ${Math.floor(minutes / 60)}h ${minutes % 60}m`;
}
function MousePosition() {
const [position, setPosition] = useState<MapPoint | null>(null);
useMapEvents({
mousemove: (event) => {
setPosition([Math.round(event.latlng.lng), Math.round(-event.latlng.lat)]);
},
mouseout: () => setPosition(null),
});
return (
<div className="pointer-events-none absolute bottom-2 left-2 z-[1000] rounded border border-white/15 bg-black/70 px-2 py-1 font-mono text-[10px] text-white/80 backdrop-blur-sm">
{position ? `X ${position[0]} m, Y ${position[1]} m` : "\u00a0"}
</div>
);
}
/**
* Keeps the world at least viewport-sized: raises the map's min zoom to the
* level where the world covers the container (never lower than the
* configured floor). Below that level the max-bounds clamp fights the zoom
* animation, which reads as a glitch; preventing sub-viewport zooms removes it.
*/
function FitToViewport({
worldW,
worldH,
minZoomFloor,
}: {
worldW: number;
worldH: number;
minZoomFloor: number;
}) {
const map = useMap();
useEffect(() => {
const scaleMax = Math.max(worldW, worldH);
const fit = () => {
map.invalidateSize();
const size = map.getSize();
if (size.x === 0 || size.y === 0) return;
const needed = Math.max((size.x * scaleMax) / worldW, (size.y * scaleMax) / worldH);
const fitZoom = Math.log2(needed / 256);
const snapped = Math.ceil(fitZoom / 0.25) * 0.25;
const minZoom = Math.max(snapped, minZoomFloor);
map.setMinZoom(minZoom);
if (map.getZoom() < minZoom) {
map.setZoom(minZoom, { animate: false });
}
};
fit();
const observer = new ResizeObserver(() => fit());
observer.observe(map.getContainer());
return () => observer.disconnect();
}, [map, worldW, worldH, minZoomFloor]);
return null;
}
// Applies the map's admin-configured default view once per map selection.
// MapContainer's center/zoom props are init-only and the config arrives after
// mount, so without this the defaults never take effect.
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;
}
function ClickHandler({ onPick }: { onPick: (point: MapPoint) => void }) {
useMapEvents({
click: (event) => {
onPick([Math.round(event.latlng.lng), Math.round(-event.latlng.lat)]);
},
});
return null;
}
/** Rewrites the bottom-right attribution prefix to credit the unit and the game. */
function MapAttribution({ prefix }: { prefix: string }) {
const map = useMap();
useEffect(() => {
map.attributionControl.setPrefix(prefix);
}, [map, prefix]);
return null;
}
const SNAP_RADIUS_PX = 12;
const snapPreviewIconCache = new Map<string, L.DivIcon>();
function snapPreviewIcon(kind: SnapKind): L.DivIcon {
let icon = snapPreviewIconCache.get(kind);
if (!icon) {
icon = L.divIcon({
className: "map-snap-icon",
html: `<div class="map-snap-preview map-snap-${kind}"></div>`,
iconSize: [0, 0],
iconAnchor: [0, 0],
});
snapPreviewIconCache.set(kind, icon);
}
return icon;
}
/**
* Authoring click layer: while a road/zone/node draft is active, every map
* click snaps to existing geometry (feature vertices, zone/road edges,
* structure/node positions, already-placed draft vertices) before landing,
* and a moving cursor shows where the next click would land.
*/
function AuthoringSnapLayer({
targets,
worldSize,
onPick,
}: {
targets: SnapTarget[];
worldSize: number;
onPick: (point: MapPoint) => void;
}) {
const map = useMap();
const [preview, setPreview] = useState<SnapMatch | null>(null);
const snapAt = (event: L.LeafletMouseEvent): SnapMatch | null =>
findSnap(
[event.latlng.lng, -event.latlng.lat],
targets,
SNAP_RADIUS_PX * metersPerPx(map.getZoom(), worldSize),
);
useMapEvents({
mousemove: (event) => setPreview(snapAt(event)),
click: (event) => {
const match = snapAt(event);
if (match) {
onPick(match.point);
} else {
onPick([Math.round(event.latlng.lng), Math.round(-event.latlng.lat)]);
}
},
});
if (!preview) return null;
return (
<Marker
position={toLatLng(preview.point)}
interactive={false}
icon={snapPreviewIcon(preview.kind)}
/>
);
}
const vertexHandleIcon = L.divIcon({
className: "map-vertex-handle-icon",
html: '<div class="map-vertex-handle"></div>',
iconSize: [12, 12],
iconAnchor: [6, 6],
});
const midpointHandleIcon = L.divIcon({
className: "map-midpoint-handle-icon",
html: '<div class="map-midpoint-handle"></div>',
iconSize: [8, 8],
iconAnchor: [4, 4],
});
/**
* Draggable handles for the current draft's vertices: drag a square to move it
* (drag-end snaps to existing geometry), right-click deletes it, clicking the
* small hollow square on a segment inserts a vertex there.
*/
function DraftHandles({
points,
closed,
baseTargets,
worldSize,
onUpdate,
onInsert,
onRemove,
}: {
points: MapPoint[];
closed: boolean;
baseTargets: SnapTarget[];
worldSize: number;
onUpdate: (index: number, point: MapPoint) => void;
onInsert: (index: number, point: MapPoint) => void;
onRemove: (index: number) => void;
}) {
const map = useMap();
const midpoints = useMemo(() => segmentMidpoints(points, closed), [points, closed]);
const dragTargets = (index: number): SnapTarget[] => [
...baseTargets,
// Own vertices, minus the dragged one so a small drag cannot snap back.
{ key: "draft", vertices: points.filter((_, i) => i !== index), closed: false, edges: false },
];
return (
<>
{points.map((point, index) => (
<Marker
key={`draft-vertex-${index}`}
position={toLatLng(point)}
draggable
icon={vertexHandleIcon}
zIndexOffset={1000}
eventHandlers={{
dragend: (event) => {
const latlng = (event.target as L.Marker).getLatLng();
const raw: MapPoint = [latlng.lng, -latlng.lat];
const radius = SNAP_RADIUS_PX * metersPerPx(map.getZoom(), worldSize);
const match = findSnap(raw, dragTargets(index), radius);
onUpdate(index, match ? match.point : [Math.round(raw[0]), Math.round(raw[1])]);
},
contextmenu: () => onRemove(index),
}}
/>
))}
{midpoints.map((midpoint, index) => (
<Marker
key={`draft-midpoint-${index}-${midpoint.index}`}
position={toLatLng(midpoint.point)}
icon={midpointHandleIcon}
zIndexOffset={900}
eventHandlers={{ click: () => onInsert(midpoint.index, midpoint.point) }}
/>
))}
</>
);
}
function formatDuration(ms: number): string {
const total = Math.max(0, Math.ceil(ms / 1000));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
return `${hours}h ${String(minutes).padStart(2, "0")}m ${String(seconds).padStart(2, "0")}s`;
}
/** Footer-cell countdown: fixed-width "04H 07M 12S". */
function paddedCountdown(ms: number): string {
const total = Math.max(0, Math.ceil(ms / 1000));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
return `${String(hours).padStart(2, "0")}H ${String(minutes).padStart(2, "0")}M ${String(seconds).padStart(2, "0")}S`;
}
/**
* Friendly completion target: today (or already due) shows the time only,
* within a week the weekday, beyond that the calendar date.
*/
function friendlyCompletion(completesMs: number, nowMs: number): string {
const target = new Date(completesMs);
const time = target.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
const startOfDay = (date: Date) =>
new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
const dayDiff = Math.round(
(startOfDay(target) - startOfDay(new Date(nowMs))) / (24 * 60 * 60 * 1000),
);
if (dayDiff <= 0) return time;
if (dayDiff < 7) return `${target.toLocaleDateString([], { weekday: "long" })}, ${time}`;
return `${target.toLocaleDateString([], { month: "short", day: "numeric" })}, ${time}`;
}
/** Ribbon ETA in whole minutes ("1H 05M") so the cached icon changes at most once per minute. */
function bucketedEta(ms: number): string {
const totalMinutes = Math.max(0, Math.floor(ms / 60000));
return `${Math.floor(totalMinutes / 60)}H ${totalMinutes % 60}M`;
}
function DossierStrip({ label, refLabel }: { label: string; refLabel: string }) {
return (
<div className="flex items-center justify-between gap-6 border-b border-white/15 bg-black/50 px-3 py-1">
<span className="font-mono text-[9px] uppercase tracking-[0.2em] text-white/60">{label}</span>
<span className="font-mono text-[9px] uppercase tracking-[0.2em] text-white/60">
{refLabel}
</span>
</div>
);
}
function DossierFooter({ cells }: { cells: { label: string; value: string }[] }) {
return (
<div className="flex border-t border-white/15 bg-black/50">
{cells.map((cell) => (
<div key={cell.label} className="flex-1 border-r border-white/15 px-3 py-1 last:border-r-0">
<div className="font-mono text-xs tabular-nums text-white/80">{cell.value}</div>
<div className="font-mono text-[9px] uppercase tracking-[0.2em] text-white/50">
{cell.label}
</div>
</div>
))}
</div>
);
}
function StructurePopup({
structure,
nowMs,
onPreview,
}: {
structure: StructureFeature;
nowMs: number;
onPreview: (structureId: number) => void;
}) {
const statusKey = structure.constructionStatus ?? "complete";
const status = STRUCTURE_STATUS[statusKey] ?? STRUCTURE_STATUS.complete;
const building = statusKey === "building";
const pct = building
? Math.min(100, Math.max(0, Math.round(constructionPct(structure, nowMs))))
: null;
const completesMs = structure.constructionCompletesAt
? new Date(structure.constructionCompletesAt).getTime()
: NaN;
const remaining = building && Number.isFinite(completesMs) ? completesMs - nowMs : NaN;
const gridX = Math.round(structure.point[0]);
const gridY = Math.round(structure.point[1]);
return (
<div className="min-w-[250px] text-sm">
<DossierStrip
label="Structure Dossier"
refLabel={`REF ${String(structure.id).padStart(3, "0")}`}
/>
<div className="flex flex-col gap-1.5 px-3 py-2.5">
<div className="font-semibold text-white/95">{structure.name}</div>
{(structure.blueprintName || structure.factionName) && (
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-white/60">
{structure.blueprintName ?? "Unknown blueprint"}
{structure.factionName ? ` // ${structure.factionName}` : ""}
</div>
)}
<div
className="font-mono text-[10px] uppercase tracking-[0.14em]"
style={{ color: status.color }}
>
{status.label}
{pct != null ? ` // ${pct}%` : ""}
{building && Number.isFinite(completesMs)
? ` // Est. completion by ${friendlyCompletion(completesMs, nowMs)}`
: ""}
</div>
{structure.constructionStatus === "awaiting_materials" &&
structure.materials.length > 0 && (
<div className="text-xs text-white/60">
Needs:{" "}
{structure.materials
.map((material) => `${material.resourceName} x${material.amount ?? 0}`)
.join(", ")}
</div>
)}
</div>
<DossierFooter
cells={[
{ label: "Grid", value: `${gridX}E / ${gridY}S` },
building
? {
label: "ETA",
value: Number.isFinite(remaining) ? paddedCountdown(remaining) : "pending",
}
: {
label: "Storage",
value:
structure.storageCapacityKg != null
? `${Math.round(structure.storageUsedKg).toLocaleString()} / ${structure.storageCapacityKg.toLocaleString()} kg`
: `${Math.round(structure.storageUsedKg).toLocaleString()} kg`,
},
]}
/>
<div className="border-t border-white/15 px-3 py-2">
<div className="flex gap-1.5">
<Button
size="sm"
variant="outline"
className="h-7 flex-1 rounded-none px-2 font-mono text-[10px] uppercase tracking-[0.2em]"
onClick={() => onPreview(structure.id)}
>
Preview
</Button>
<Button
asChild
size="sm"
variant="outline"
className="h-7 flex-1 rounded-none px-2 font-mono text-[10px] uppercase tracking-[0.2em]"
>
<Link href={`/logistics/structures/${structure.id}`}>Full Page</Link>
</Button>
</div>
</div>
</div>
);
}
/**
* Continuous construction progress (0-100) derived from the site's timestamps,
* rounded to the nearest 5% so the icon cache stays small across ticks.
*/
function constructionPct(structure: StructureFeature, nowMs: number): number {
const start = structure.constructionStartedAt
? new Date(structure.constructionStartedAt).getTime()
: NaN;
const end = structure.constructionCompletesAt
? new Date(structure.constructionCompletesAt).getTime()
: NaN;
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return 0;
const ratio = (nowMs - start) / (end - start);
if (!Number.isFinite(ratio)) return 0;
return Math.round(Math.min(100, Math.max(0, ratio * 100)) / 5) * 5;
}
interface StructureDot {
structure: StructureFeature;
statusKey: string;
position: [number, number];
ranges: { kind: "resource" | "structure"; range: number }[];
}
/**
* Owns the 1-second interpolation clock for building-site markers so the rest
* of the map tree does not re-render every second (re-renders re-apply layer
* geometry, which glitches mid-zoom-animation).
*/
const StructureLayer = memo(function StructureLayer({
dots,
worldSize,
onPreview,
}: {
dots: StructureDot[];
worldSize: number;
onPreview: (structureId: number) => void;
}) {
const map = useMap();
const [nowMs, setNowMs] = useState(() => Date.now());
const [zoom, setZoom] = useState(() => map.getZoom());
useEffect(() => {
const timer = setInterval(() => setNowMs(Date.now()), 1000);
return () => clearInterval(timer);
}, []);
useMapEvents({
zoomend: () => setZoom(map.getZoom()),
});
// Persistent name labels win a cluster by labelPriority, then by stored
// mass, then by id. Losing labels reappear as the cluster spreads on zoom.
const labeledIds = useMemo(() => {
const radius = STRUCTURE_LABEL_CLUSTER_PX * metersPerPx(zoom, worldSize);
const maxSq = radius * radius;
const candidates = dots
.filter((dot) => dot.structure.labelVisible !== false)
.sort(
(a, b) =>
(b.structure.labelPriority ?? 0) - (a.structure.labelPriority ?? 0) ||
b.structure.storageUsedKg - a.structure.storageUsedKg ||
a.structure.id - b.structure.id,
);
const winners = new Set<number>();
for (const candidate of candidates) {
const suppressed = [...winners].some((winnerId) => {
const winner = dots.find((dot) => dot.structure.id === winnerId);
return (
winner != null &&
distanceSquared(winner.structure.point, candidate.structure.point) <= maxSq
);
});
if (!suppressed) winners.add(candidate.structure.id);
}
return winners;
}, [dots, zoom, worldSize]);
// Ribbon allocation at close zoom: same priority order as labels, greedy
// screen-space rectangles (pin + estimated ribbon width) so ribbons never
// cover another structure's pin or ribbon. Structures that lose the
// collision fall back to their hover tooltip.
const ribbonIds = useMemo(() => {
if (metersPerPx(zoom, worldSize) > STRUCTURE_RIBBON_M_PER_PX) return new Set<number>();
const placed: { left: number; right: number; top: number; bottom: number }[] = [];
const winners = new Set<number>();
const ordered = [...dots].sort(
(a, b) =>
(b.structure.labelPriority ?? 0) - (a.structure.labelPriority ?? 0) ||
b.structure.storageUsedKg - a.structure.storageUsedKg ||
a.structure.id - b.structure.id,
);
for (const dot of ordered) {
const point = map.latLngToLayerPoint(dot.position);
const width = Math.min(220, Math.max(110, Math.max(dot.structure.name.length, 24) * 6.2));
const left = point.x + RIBBON_PIN_HALF_PX + RIBBON_GAP_PX;
const rect = {
left,
right: left + width,
top: point.y - RIBBON_HEIGHT_PX / 2,
bottom: point.y + RIBBON_HEIGHT_PX / 2,
};
const collides = placed.some(
(other) =>
rect.left < other.right &&
rect.right > other.left &&
rect.top < other.bottom &&
rect.bottom > other.top,
);
if (collides) continue;
placed.push(rect);
winners.add(dot.structure.id);
}
return winners;
}, [dots, zoom, worldSize, map]);
return (
<>
{dots.map(({ structure, statusKey, position }) => {
const pct = statusKey === "building" ? constructionPct(structure, nowMs) : null;
const showLabel = labeledIds.has(structure.id);
const mPerPx = metersPerPx(zoom, worldSize);
const tier: StructurePinTier =
mPerPx <= STRUCTURE_RIBBON_M_PER_PX
? "lg"
: mPerPx <= STRUCTURE_PIN_MID_M_PER_PX
? "md"
: "sm";
let ribbonMeta: string;
if (statusKey === "building") {
const end = structure.constructionCompletesAt
? new Date(structure.constructionCompletesAt).getTime()
: NaN;
ribbonMeta = Number.isFinite(end)
? `ETA ${bucketedEta(Math.max(0, end - nowMs))}`
: "Building";
} else if (statusKey === "awaiting_materials") {
const first = structure.materials[0];
ribbonMeta = first
? `Needs ${first.resourceName} x${first.amount ?? 0}${structure.materials.length > 1 ? ` +${structure.materials.length - 1}` : ""}`
: "Awaiting materials";
} else {
ribbonMeta = structure.blueprintName ?? "Structure";
}
const ribbon: StructureRibbon | null =
tier === "lg" && ribbonIds.has(structure.id)
? {
tier,
name: structure.name ?? `Structure ${structure.id}`,
meta: ribbonMeta,
}
: null;
return (
<Marker
key={`structure-${structure.id}`}
position={position}
icon={getStructureIcon(statusKey, pct, ribbon)}
>
{tier === "lg" ? (
ribbon ? null : (
<Tooltip
direction="top"
offset={[0, -16]}
opacity={1}
className="map-tooltip-dossier"
>
<div className="map-tooltip-name">{structure.name}</div>
<div className="map-tooltip-meta">{ribbonMeta}</div>
</Tooltip>
)
) : showLabel ? (
<Tooltip
permanent
direction="right"
offset={[10, 0]}
opacity={1}
className="map-label"
>
{structure.name}
</Tooltip>
) : (
<Tooltip
direction="top"
offset={[0, -16]}
opacity={1}
className="map-tooltip-dossier"
>
<div className="map-tooltip-name">{structure.name}</div>
<div className="map-tooltip-meta">{ribbonMeta}</div>
</Tooltip>
)}
<Popup className="map-popup-dossier">
<StructurePopup structure={structure} nowMs={nowMs} onPreview={onPreview} />
</Popup>
</Marker>
);
})}
</>
);
});
const RoadLabelsLayer = memo(function RoadLabelsLayer({
roads,
worldSize,
}: {
roads: {
id: number;
name: string;
labelVisible: boolean | null;
surface: "paved" | "dirt" | "trail";
points: MapPoint[];
}[];
worldSize: number;
}) {
const map = useMap();
const [zoom, setZoom] = useState(() => map.getZoom());
useMapEvents({
zoomend: () => setZoom(map.getZoom()),
});
const labeledRoads = useMemo(() => {
const scale = metersPerPx(zoom, worldSize);
return roads
.filter((road) => road.name && roadLabelShown(road, scale))
.flatMap((road) => {
const anchor = map.latLngToLayerPoint(toLatLng(road.points[0]));
const pxPoints = road.points.map((point) => {
const layerPoint = map.latLngToLayerPoint(toLatLng(point));
return [layerPoint.x - anchor.x, layerPoint.y - anchor.y] as MapPoint;
});
const screenLength = polylineLength(pxPoints);
if (screenLength < ROAD_LABEL_MIN_SCREEN_PX) return [];
// Distant roads: a single straight label at the midpoint (following a
// tiny wiggly path folds the letters together). Close up: text follows
// the bends, character-aligned.
if (screenLength < ROAD_LABEL_CURVE_MIN_PX) {
const mid = interpolateAlong(road.points, 0.5);
if (!mid) return [];
return [
{
id: road.id,
name: road.name,
position: toLatLng(mid.point),
icon: straightLabelIcon(
road.name,
labelScreenAngle(road.points[0], road.points[road.points.length - 1]),
),
},
];
}
const halfText = (road.name.length * ROAD_LABEL_CHAR_PX) / 2;
// On very long roads a fixed interval would exhaust the label cap part
// way along the path; spread the capped labels over the whole road
// instead so no stretch goes unlabeled.
const interval = Math.max(ROAD_LABEL_SPACING_PX, screenLength / ROAD_LABELS_MAX);
const labels = roadLabelPoints(pxPoints, interval, ROAD_LABELS_MAX)
.map((label) => ({
...label,
distance: Math.min(Math.max(label.distance, halfText), screenLength - halfText),
}))
.map((label) => ({
offsetPct: (label.distance / screenLength) * 100,
flipped: label.flipped,
}));
if (labels.length === 0) return [];
return [
{
id: road.id,
name: road.name,
position: toLatLng(road.points[0]),
icon: roadLabelSvgIcon(road.id, road.name, pxPoints, screenLength, labels),
},
];
});
}, [map, roads, worldSize, zoom]);
return (
<>
{labeledRoads.map((road) => (
<Marker
key={`road-labels-${road.id}`}
position={road.position}
icon={road.icon}
interactive={false}
/>
))}
</>
);
});
const ShipmentLine = memo(function ShipmentLine({ path }: { path: MapPoint[] }) {
const positions = useMemo(() => splinePolyline(path).map(toLatLng), [path]);
return <Polyline positions={positions} pathOptions={SHIPMENT_LINE_PATH_OPTIONS} />;
});
/**
* Owns the 1-second interpolation clock so the rest of the map tree does not
* re-render every second (re-renders re-apply layer geometry, which glitches
* mid-zoom-animation).
*/
function ShipmentLayer({ shipments }: { shipments: ShipmentFeature[] }) {
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
const timer = setInterval(() => setNowMs(Date.now()), 1000);
return () => clearInterval(timer);
}, []);
const active = useMemo(() => {
return shipments
.map((shipment) => {
const path =
shipment.routePath && shipment.routePath.length >= 2
? shipment.routePath
: shipment.originPoint && shipment.destinationPoint
? [shipment.originPoint, shipment.destinationPoint]
: null;
if (!path) return null;
const dispatched = shipment.dispatchedAt ? new Date(shipment.dispatchedAt).getTime() : 0;
const arrival = shipment.estimatedArrival
? new Date(shipment.estimatedArrival).getTime()
: 0;
const span = arrival - dispatched;
const t = span > 0 ? Math.min(1, Math.max(0, (nowMs - dispatched) / span)) : 0;
const position = interpolateAlong(path, t);
return position ? { shipment, path, position } : null;
})
.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
}, [shipments, nowMs]);
return (
<>
{active.map(({ shipment, path, position }) => (
<Fragment key={`shipment-${shipment.id}`}>
<ShipmentLine path={path} />
<Marker position={toLatLng(position.point)} icon={shipmentIcon(position.bearing)}>
<Popup className="map-popup-dossier">
<div className="min-w-[240px] text-sm">
<DossierStrip
label="Shipment Dossier"
refLabel={`REF ${String(shipment.id).padStart(3, "0")}`}
/>
<div className="flex flex-col gap-1.5 px-3 py-2.5">
<div className="font-semibold text-white/95">
{shipment.name ?? `Shipment #${shipment.id}`}
</div>
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-white/60">
{shipment.originName} &rarr; {shipment.destinationName}
{shipment.vehicleName ? ` // ${shipment.vehicleName}` : ""}
</div>
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-cyan-300">
{shipment.status.replace("_", " ")}
{formatEta(shipment.estimatedArrival, nowMs)
? ` // ${formatEta(shipment.estimatedArrival, nowMs)}`
: ""}
</div>
</div>
<DossierFooter
cells={[
{
label: "Arrives",
value: shipment.estimatedArrival
? new Date(shipment.estimatedArrival).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
: "unknown",
},
{ label: "Vehicle", value: shipment.vehicleName ?? "unassigned" },
]}
/>
<div className="border-t border-white/15 px-3 py-2">
<Button
asChild
size="sm"
variant="outline"
className="h-7 w-full rounded-none px-2 font-mono text-[10px] uppercase tracking-[0.2em]"
>
<Link href={`/logistics/shipments/${shipment.id}`}>Open Shipment</Link>
</Button>
</div>
</div>
</Popup>
</Marker>
</Fragment>
))}
</>
);
}
export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
const [selectedMapId, setSelectedMapId] = useState<number | null>(maps[0]?.id ?? null);
const [features, setFeatures] = useState<MapFeaturesResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [toggles, setToggles] = useState<LayerToggles>({
structures: true,
nodes: true,
roads: true,
zones: true,
shipments: true,
ranges: false,
});
const fetchingRef = useRef(false);
const [mode, setMode] = useState<"view" | "place" | "edit">("view");
const [pendingPoint, setPendingPoint] = useState<MapPoint | null>(null);
const [previewId, setPreviewId] = useState<number | null>(null);
const fetchFeatures = useCallback(async (mapId: number) => {
if (fetchingRef.current) return;
fetchingRef.current = true;
try {
const response = await fetch(`/api/map-features?mapId=${mapId}`, { cache: "no-store" });
if (!response.ok) {
const body = (await response.json().catch(() => null)) as { error?: string } | null;
throw new Error(body?.error ?? `Request failed (${response.status})`);
}
const body = (await response.json()) as MapFeaturesResponse;
setFeatures(body);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load map features.");
} finally {
fetchingRef.current = false;
}
}, []);
const refetchFeatures = useCallback(() => {
if (selectedMapId != null) void fetchFeatures(selectedMapId);
}, [selectedMapId, fetchFeatures]);
const openPreview = useCallback((structureId: number) => setPreviewId(structureId), []);
const authoring = useMapAuthoring({ mapId: selectedMapId ?? 0, onSaved: refetchFeatures });
useEffect(() => {
if (selectedMapId == null) return;
void fetchFeatures(selectedMapId);
}, [selectedMapId, fetchFeatures]);
useGameTick(refetchFeatures);
const mapConfig = features?.map ?? null;
// Layer geometry is derived once per features fetch (stable references across
// re-renders so react-leaflet never re-applies geometry mid-zoom-animation).
const roadLines = useMemo(
() =>
(features?.roads ?? []).map((road) => {
const options: L.PathOptions = {
color: road.strokeColor || ROAD_PATH_OPTIONS[road.surface]?.color || "#cbd5e1",
weight: road.strokeWeight ?? ROAD_PATH_OPTIONS[road.surface]?.weight ?? 2,
dashArray: dashArrayFor(road.strokeStyle),
};
return { road, positions: splinePolyline(road.points).map(toLatLng), options };
}),
[features],
);
const zonePolys = useMemo(
() =>
(features?.zones ?? []).map((zone) => {
const defaults = ZONE_PATH_OPTIONS[zone.type];
const options: L.PathOptions = {
color: zone.strokeColor || defaults.color,
fillColor: zone.fillColor || defaults.fillColor,
fillOpacity: zone.fillOpacity ?? defaults.fillOpacity,
weight: zone.strokeWeight ?? 1,
};
return { zone, positions: zone.points.map(toLatLng), options };
}),
[features],
);
const nodeDots = useMemo(
() => (features?.nodes ?? []).map((node) => ({ node, position: toLatLng(node.point) })),
[features],
);
const structureDots = useMemo<StructureDot[]>(
() =>
(features?.structures ?? []).map((structure) => ({
structure,
statusKey: structure.constructionStatus ?? "complete",
position: toLatLng(structure.point),
ranges: [
...structure.resourcesInRange.map((requirement) => ({
kind: "resource" as const,
range: requirement.range ?? 0,
})),
...structure.structuresInRange.map((requirement) => ({
kind: "structure" as const,
range: requirement.range ?? 0,
})),
].filter((requirement) => requirement.range > 0),
})),
[features],
);
const worldW = mapConfig?.worldSizeWidth ?? 8192;
const worldH = mapConfig?.worldSizeHeight ?? 8192;
const center: [number, number] = mapConfig
? [-mapConfig.defaultCenter[1], mapConfig.defaultCenter[0]]
: [-worldH / 2, worldW / 2];
// CRS.Simple puts 1 meter on 1 px at zoom 0, which pins an 8192 m world far
// beyond any positive zoom. Scale the transformation per map so the whole
// world spans 256 px at zoom 0 (one tile, standard XYZ pyramid): zoom 0 is
// then "fully zoomed out" and tile (z, x, y) grids align with the map.
// Uniform scale (larger dimension) keeps aspect for non-square worlds.
const mapCrs = useMemo(() => {
const scaleMax = Math.max(worldW, worldH);
return L.extend({}, L.CRS.Simple, {
transformation: new L.Transformation(256 / scaleMax, 0, -256 / scaleMax, 0),
}) as L.CRS;
}, [worldW, worldH]);
const worldBounds = useMemo(
() => L.latLngBounds(L.latLng(-worldH, 0), L.latLng(0, worldW)),
[worldW, worldH],
);
const maxWorld = Math.max(worldW, worldH);
const snapTargets = useMemo<SnapTarget[]>(() => {
if (mode !== "edit" || authoring.kind == null) return [];
return [
...(features?.roads ?? []).map((road) => ({
key: `road-${road.id}`,
vertices: road.points,
closed: false,
edges: true,
})),
...(features?.zones ?? []).map((zone) => ({
key: `zone-${zone.id}`,
vertices: zone.points,
closed: true,
edges: true,
})),
...(features?.nodes ?? []).map((node) => ({
key: `node-${node.id}`,
vertices: [node.point],
closed: false,
edges: false,
})),
...(features?.structures ?? []).map((structure) => ({
key: `structure-${structure.id}`,
vertices: [structure.point],
closed: false,
edges: false,
})),
];
}, [mode, authoring.kind, features]);
if (maps.length === 0) {
return (
<div className="flex flex-1 items-center justify-center p-6">
<p className="text-sm text-muted-foreground">
No active maps yet. Maps are configured in the admin panel (World: Maps).
</p>
</div>
);
}
const toggle = (key: keyof LayerToggles) =>
setToggles((current) => ({ ...current, [key]: !current[key] }));
const layerToggleKeys: { key: keyof LayerToggles; label: string }[] = [
{ key: "structures", label: "Structures" },
{ key: "nodes", label: "Nodes" },
{ key: "roads", label: "Roads" },
{ key: "zones", label: "Zones" },
{ key: "shipments", label: "Shipments" },
{ key: "ranges", label: "Ranges" },
];
return (
<div
className="map-host group relative min-h-0 flex-1"
data-mode={mode === "place" || authoring.kind != null ? "crosshair" : undefined}
>
<span
aria-hidden
className="pointer-events-none absolute left-1 top-1 z-[999] size-3 border-l border-t border-white/25 transition-colors group-hover:border-white/60"
/>
<span
aria-hidden
className="pointer-events-none absolute right-1 top-1 z-[999] size-3 border-r border-t border-white/25 transition-colors group-hover:border-white/60"
/>
<span
aria-hidden
className="pointer-events-none absolute bottom-1 left-1 z-[999] size-3 border-b border-l border-white/25 transition-colors group-hover:border-white/60"
/>
<span
aria-hidden
className="pointer-events-none absolute bottom-1 right-1 z-[999] size-3 border-b border-r border-white/25 transition-colors group-hover:border-white/60"
/>
<MapContainer
key={`${selectedMapId ?? "map"}-${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
>
{mapConfig && mapConfig.basemapMode === "tiles" && mapConfig.tileUrlTemplate && (
<TileLayer
url={mapConfig.tileUrlTemplate}
minZoom={mapConfig.minZoom}
maxZoom={mapConfig.maxZoom}
bounds={worldBounds}
/>
)}
{mapConfig && mapConfig.basemapMode === "image" && mapConfig.basemapImageUrl && (
<ImageOverlay url={mapConfig.basemapImageUrl} bounds={worldBounds} />
)}
{mapConfig && mapConfig.basemapMode === "image" && !mapConfig.basemapImageUrl && (
<Rectangle bounds={worldBounds} pathOptions={BASEMAP_FALLBACK_PATH_OPTIONS} />
)}
{features && toggles.zones && (
<>
{zonePolys.map(({ zone, positions, options }) => (
<Polygon key={`zone-${zone.id}`} positions={positions} pathOptions={options}>
<Tooltip direction="top" opacity={1} className="map-tooltip-dossier" sticky>
<div>
<div>{zone.name ?? `Zone #${zone.id}`}</div>
{zone.resources.length > 0 && (
<div className="normal-case tracking-normal text-white/60">
Resources: {zone.resources.map((entry) => entry.name).join(", ")}
</div>
)}
{zone.description && (
<div className="normal-case tracking-normal text-white/60">
{zone.description}
</div>
)}
</div>
</Tooltip>
<Popup>
<div className="text-sm">
<div className="font-semibold">{zone.name ?? `Zone #${zone.id}`}</div>
<div className="text-xs capitalize text-white/60">{zone.type} zone</div>
{zone.resources.length > 0 && (
<div className="text-xs text-white/60">
Resources: {zone.resources.map((entry) => entry.name).join(", ")}
</div>
)}
{zone.description && (
<div className="mt-1 text-xs text-white/70">{zone.description}</div>
)}
{canEdit && mode === "edit" && (
<div className="mt-2 flex gap-1.5">
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
onClick={() => authoring.startEditingZone(zone)}
>
Edit
</Button>
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
onClick={() => void authoring.removeFeature("zone", zone.id)}
>
Delete
</Button>
</div>
)}
</div>
</Popup>
</Polygon>
))}
</>
)}
{features && toggles.roads && (
<>
{roadLines.map(({ road, positions, options }) => (
<Polyline key={`road-${road.id}`} positions={positions} pathOptions={options}>
<Tooltip
direction="top"
offset={[0, -6]}
opacity={1}
className="map-tooltip-dossier"
>
<div>
<div>{road.name ?? `Road #${road.id}`}</div>
{road.description && (
<div className="normal-case tracking-normal text-white/60">
{road.description}
</div>
)}
</div>
</Tooltip>
<Popup>
<div className="text-sm">
<div className="font-semibold">{road.name ?? `Road #${road.id}`}</div>
<div className="text-xs capitalize text-white/60">
{road.surface}, speed x{road.speedMultiplier}
</div>
{road.description && (
<div className="mt-1 text-xs text-white/70">{road.description}</div>
)}
{canEdit && mode === "edit" && (
<div className="mt-2 flex gap-1.5">
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
onClick={() => authoring.startEditingRoad(road)}
>
Edit
</Button>
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
onClick={() => void authoring.removeFeature("road", road.id)}
>
Delete
</Button>
</div>
)}
</div>
</Popup>
</Polyline>
))}
</>
)}
{features && toggles.roads && (
<RoadLabelsLayer
worldSize={Math.max(worldW, worldH)}
roads={features.roads.map((road) => ({
id: road.id,
name: road.name ?? "",
labelVisible: road.labelVisible,
surface: road.surface,
points: road.points,
}))}
/>
)}
{features && toggles.zones && (
<>
{zonePolys.map(({ zone }) =>
zone.name && zone.labelVisible !== false ? (
<Marker
key={`zone-label-${zone.id}`}
position={toLatLng(polygonCentroid(zone.points))}
icon={zoneLabelIcon(zone.name)}
interactive={false}
/>
) : null,
)}
</>
)}
{features && toggles.nodes && (
<>
{nodeDots.map(({ node, position }) => (
<Marker key={`node-${node.id}`} position={position} icon={nodeIcon}>
<Tooltip
direction="top"
offset={[0, -14]}
opacity={1}
className="map-tooltip-dossier"
>
<div>
<div>{node.name}</div>
{node.description && (
<div className="normal-case tracking-normal text-white/60">
{node.description}
</div>
)}
</div>
</Tooltip>
<Popup>
<div className="text-sm">
<div className="font-semibold">{node.name}</div>
<div className="text-xs text-white/60">
{node.resources.map((entry) => entry.name).join(", ") || "No resources"}
</div>
{node.description && (
<div className="mt-1 text-xs text-white/70">{node.description}</div>
)}
{node.richness != null && (
<div className="text-xs text-white/60">Richness {node.richness}</div>
)}
{canEdit && mode === "edit" && (
<div className="mt-2 flex gap-1.5">
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
onClick={() => authoring.startEditingNode(node)}
>
Edit
</Button>
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
onClick={() => void authoring.removeFeature("node", node.id)}
>
Delete
</Button>
</div>
)}
</div>
</Popup>
</Marker>
))}
</>
)}
{features && toggles.ranges && (
<>
{structureDots.flatMap(({ structure, position, ranges }) =>
ranges.map((requirement, index) => (
<Circle
key={`range-${structure.id}-${requirement.kind}-${index}`}
center={position}
radius={requirement.range}
interactive={false}
pathOptions={
requirement.kind === "resource"
? RANGE_RESOURCE_PATH_OPTIONS
: RANGE_STRUCTURE_PATH_OPTIONS
}
/>
)),
)}
</>
)}
{features && toggles.shipments && <ShipmentLayer shipments={features.shipments} />}
{features && toggles.structures && (
<StructureLayer
dots={structureDots}
worldSize={Math.max(worldW, worldH)}
onPreview={openPreview}
/>
)}
<MousePosition />
<MapAttribution prefix="Polaris Task Force // Arma 3 // Leaflet" />
<FitToViewport worldW={worldW} worldH={worldH} minZoomFloor={mapConfig?.minZoom ?? 0} />
{mapConfig && (
<DefaultView key={mapConfig.id} center={center} zoom={mapConfig.defaultZoom ?? 2} />
)}
{mode === "place" && <ClickHandler onPick={setPendingPoint} />}
{mode === "edit" && authoring.kind != null && (
<AuthoringSnapLayer
targets={snapTargets}
worldSize={maxWorld}
onPick={authoring.pushPoint}
/>
)}
{mode === "edit" && authoring.kind != null && authoring.draftPoints.length > 0 && (
<DraftHandles
points={authoring.draftPoints}
closed={authoring.kind === "zone"}
baseTargets={snapTargets}
worldSize={maxWorld}
onUpdate={authoring.updateDraftPoint}
onInsert={authoring.insertDraftPoint}
onRemove={authoring.removeDraftPoint}
/>
)}
{mode === "edit" && authoring.kind === "road" && authoring.draftPoints.length > 0 && (
<Polyline
positions={authoring.draftPoints.map(toLatLng)}
pathOptions={{ color: "#f8fafc", weight: 2, dashArray: "4 6" }}
/>
)}
{mode === "edit" && authoring.kind === "zone" && authoring.draftPoints.length > 0 && (
<Polygon
positions={authoring.draftPoints.map(toLatLng)}
pathOptions={{
color: authoring.fields.zoneType === "water" ? "#38bdf8" : "#4ade80",
fillOpacity: 0.1,
dashArray: "4 6",
}}
/>
)}
{mode === "edit" && authoring.kind === "node" && authoring.draftPoints.length > 0 && (
<CircleMarker
center={toLatLng(authoring.draftPoints[0])}
radius={6}
pathOptions={{ color: "#22d3ee", fillColor: "#22d3ee", fillOpacity: 0.6 }}
/>
)}
</MapContainer>
<div className="pointer-events-none absolute left-2 top-2 z-[1000] flex max-w-[calc(100%-1rem)] flex-wrap items-start gap-2">
<div className="pointer-events-auto w-fit max-w-full overflow-hidden rounded-none border border-white/15 bg-black/70 backdrop-blur-sm">
<div className="flex items-center justify-between gap-8 border-b border-white/15 bg-black/50 px-3 py-1">
<span className="font-mono text-[9px] uppercase tracking-[0.2em] text-white/60">
PTF OPS // Map Control
</span>
<span className="font-mono text-[9px] uppercase tracking-[0.2em] text-white/60">
REF {selectedMapId != null ? String(selectedMapId).padStart(3, "0") : "---"}
</span>
</div>
<div className="flex flex-wrap items-center gap-2 p-2">
<Select
value={selectedMapId != null ? String(selectedMapId) : undefined}
onValueChange={(value) => {
authoring.setKind(null);
setPreviewId(null);
setSelectedMapId(Number(value));
}}
>
<SelectTrigger className="h-8 w-[180px] text-xs">
<SelectValue placeholder="Select map" />
</SelectTrigger>
<SelectContent>
{maps.map((map) => (
<SelectItem key={map.id} value={String(map.id)}>
{map.name}
</SelectItem>
))}
</SelectContent>
</Select>
{layerToggleKeys.map(({ key, label }) => (
<Button
key={key}
size="sm"
variant={toggles[key] ? "secondary" : "outline"}
className="h-8 px-2.5 text-xs"
onClick={() => toggle(key)}
>
{label}
</Button>
))}
<Button
size="sm"
variant="outline"
className="h-8 px-2.5 text-xs"
onClick={refetchFeatures}
>
<RefreshCw className="size-3.5" />
</Button>
{canPlace && (
<Button
size="sm"
variant={mode === "place" ? "secondary" : "outline"}
className="h-8 px-2.5 text-xs"
onClick={() => {
setMode((current) => (current === "place" ? "view" : "place"));
authoring.setKind(null);
setPendingPoint(null);
}}
>
<Hammer className="size-3.5" />
{mode === "place" ? "Cancel placement" : "Place structure"}
</Button>
)}
{canEdit && (
<Button
size="sm"
variant={mode === "edit" ? "secondary" : "outline"}
className="h-8 px-2.5 text-xs"
onClick={() => {
setMode((current) => (current === "edit" ? "view" : "edit"));
authoring.setKind(null);
setPendingPoint(null);
}}
>
<Pencil className="size-3.5" />
{mode === "edit" ? "Done editing" : "Edit features"}
</Button>
)}
</div>
</div>
{mode === "edit" && <AuthoringToolbar authoring={authoring} mapId={selectedMapId ?? 0} />}
</div>
{error && (
<div className="absolute right-2 top-2 z-[1000] rounded-md border border-red-500/40 bg-red-950/80 px-3 py-2 text-xs text-red-200 backdrop-blur-sm">
{error}
</div>
)}
{features && mapConfig && (
<PlacementDialog
open={pendingPoint != null}
onOpenChange={(open) => {
if (!open) setPendingPoint(null);
}}
mapConfig={mapConfig}
point={pendingPoint ?? [0, 0]}
zones={features.zones}
nodes={features.nodes}
structures={features.structures}
onPlaced={refetchFeatures}
/>
)}
{previewId != null && (
<StructureDetailDrawer structureId={previewId} onClose={() => setPreviewId(null)} />
)}
</div>
);
}