- 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
1754 lines
62 KiB
TypeScript
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("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """);
|
|
}
|
|
|
|
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} → {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>
|
|
);
|
|
}
|