"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 { useGameTick } from "@/hooks/useGameTick"; import { distanceSquared, interpolateAlong, zoneLabelPoint, 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"; import { nodeIconOption } from "@/lib/map/nodeIcons"; export interface MapClientProps { map: { 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 = { 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" | "other", 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 }, other: { color: "#94a3b8", fillColor: "#94a3b8", fillOpacity: 0.08, weight: 1 }, }; const ROAD_PATH_OPTIONS: Record = { paved: { color: "#cbd5e1", weight: 2 }, dirt: { color: "#d97706", weight: 2 }, trail: { color: "#92713a", weight: 2 }, }; const STRUCTURE_PATH_OPTIONS: Record = { 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 = { sm: 26, md: 32, lg: 38 }; const STRUCTURE_PIN_SVG_SIZES: Record = { 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(); function zoneLabelIcon(name: string): L.DivIcon { let icon = zoneLabelIconCache.get(name); if (!icon) { icon = L.divIcon({ className: "map-zone-label-icon", html: `
${escapeHtml(name)}
`, 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(); 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: `
${escapeHtml(name.toUpperCase())}
`, 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) => `${escapeHtml(name.toUpperCase())}`, ) .join(""); return L.divIcon({ className: "map-road-label-icon", html: `${texts}`, iconSize: [0, 0], iconAnchor: [0, 0], }); } const toLatLng = (p: MapPoint): [number, number] => [-p[1], p[0]]; const NODE_SVG_PATH = ''; const SHIPMENT_SVG_PATH = ''; const STRUCTURE_SVG_PATHS: Record = { complete: '', building: '', awaiting_materials: '', }; function dossierSvg(paths: string, color: string, bearingDeg?: number, size = 13): string { const rotate = bearingDeg != null ? ` transform="rotate(${bearingDeg} 12 12)"` : ""; return `${paths}`; } const nodeIconCache = new Map(); function nodeIconFor(icon: string | null | undefined): L.DivIcon { const key = icon ?? "marker"; let cached = nodeIconCache.get(key); if (!cached) { const option = nodeIconOption(key); cached = L.divIcon({ className: "map-structure-icon", html: `
${dossierSvg(option.path, "#22d3ee")}
`, iconSize: [20, 20], iconAnchor: [10, 10], }); nodeIconCache.set(key, cached); } return cached; } const shipmentIconCache = new Map(); 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: `
${dossierSvg( SHIPMENT_SVG_PATH, "#22d3ee", bucket, )}
`, iconSize: [26, 26], iconAnchor: [13, 13], }); shipmentIconCache.set(bucket, icon); } return icon; } const structureIconCache = new Map(); 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 ? `
` : ""; const ribbonHtml = ribbon ? `
${escapeHtml(ribbon.name)}
${escapeHtml(ribbon.meta)}
` : ""; icon = L.divIcon({ className: "map-structure-icon", html: `
${dossierSvg( STRUCTURE_SVG_PATHS[statusKey] ?? STRUCTURE_SVG_PATHS.complete, meta.color, undefined, STRUCTURE_PIN_SVG_SIZES[tier], )}${bar}${ribbonHtml}
`, 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(null); useMapEvents({ mousemove: (event) => { setPosition([Math.round(event.latlng.lng), Math.round(-event.latlng.lat)]); }, mouseout: () => setPosition(null), }); return (
{position ? `X ${position[0]} m, Y ${position[1]} m` : "\u00a0"}
); } /** * 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(); function snapPreviewIcon(kind: SnapKind): L.DivIcon { let icon = snapPreviewIconCache.get(kind); if (!icon) { icon = L.divIcon({ className: "map-snap-icon", html: `
`, 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(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 ( ); } const vertexHandleIcon = L.divIcon({ className: "map-vertex-handle-icon", html: '
', iconSize: [12, 12], iconAnchor: [6, 6], }); const midpointHandleIcon = L.divIcon({ className: "map-midpoint-handle-icon", html: '
', 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) => ( { 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) => ( 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 (
{label} {refLabel}
); } function DossierFooter({ cells }: { cells: { label: string; value: string }[] }) { return (
{cells.map((cell) => (
{cell.value}
{cell.label}
))}
); } 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 (
{structure.name}
{(structure.blueprintName || structure.factionName) && (
{structure.blueprintName ?? "Unknown blueprint"} {structure.factionName ? ` // ${structure.factionName}` : ""}
)}
{status.label} {pct != null ? ` // ${pct}%` : ""} {building && Number.isFinite(completesMs) ? ` // Est. completion by ${friendlyCompletion(completesMs, nowMs)}` : ""}
{structure.constructionStatus === "awaiting_materials" && structure.materials.length > 0 && (
Needs:{" "} {structure.materials .map((material) => `${material.resourceName} x${material.amount ?? 0}`) .join(", ")}
)}
0) ? [] : [ { label: "Compound", value: `${structure.compoundChildren} building${structure.compoundChildren === 1 ? "" : "s"}`, }, ]), ]} />
); } /** * 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(); 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(); const placed: { left: number; right: number; top: number; bottom: number }[] = []; const winners = new Set(); 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 if ((structure.compoundChildren ?? 0) > 0) { ribbonMeta = `Compound: ${structure.compoundChildren} building${structure.compoundChildren === 1 ? "" : "s"}`; } 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 ( {tier === "lg" ? ( ribbon ? null : (
{structure.name}
{ribbonMeta}
) ) : showLabel ? ( {structure.name} ) : (
{structure.name}
{ribbonMeta}
)}
); })} ); }); 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) => ( ))} ); }); const ShipmentLine = memo(function ShipmentLine({ path }: { path: MapPoint[] }) { const positions = useMemo(() => splinePolyline(path).map(toLatLng), [path]); return ; }); /** * 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 => entry !== null); }, [shipments, nowMs]); return ( <> {active.map(({ shipment, path, position }) => (
{shipment.name ?? `Shipment #${shipment.id}`}
{shipment.originName} → {shipment.destinationName} {shipment.vehicleName ? ` // ${shipment.vehicleName}` : ""}
{shipment.status.replace("_", " ")} {formatEta(shipment.estimatedArrival, nowMs) ? ` // ${formatEta(shipment.estimatedArrival, nowMs)}` : ""}
))} ); } export function MapClient({ map, canEdit, canPlace }: MapClientProps) { const selectedMapId = map.id; const [features, setFeatures] = useState(null); const [error, setError] = useState(null); const [toggles, setToggles] = useState({ 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(null); const [previewId, setPreviewId] = useState(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( () => (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(() => { 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]); 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 (
{mapConfig && mapConfig.basemapMode === "tiles" && mapConfig.tileUrlTemplate && ( )} {mapConfig && mapConfig.basemapMode === "image" && mapConfig.basemapImageUrl && ( )} {mapConfig && mapConfig.basemapMode === "image" && !mapConfig.basemapImageUrl && ( )} {features && toggles.zones && ( <> {zonePolys.map(({ zone, positions, options }) => (
{zone.name ?? `Zone #${zone.id}`}
{zone.resources.length > 0 && (
Resources: {zone.resources.map((entry) => entry.name).join(", ")}
)} {zone.description && (
{zone.description}
)}
{zone.name ?? `Zone #${zone.id}`}
{zone.type} zone
{zone.resources.length > 0 && (
Resources: {zone.resources.map((entry) => entry.name).join(", ")}
)} {zone.description && (
{zone.description}
)} {canEdit && mode === "edit" && (
)}
))} )} {features && toggles.roads && ( <> {roadLines.map(({ road, positions, options }) => (
{road.name ?? `Road #${road.id}`}
{road.description && (
{road.description}
)}
{road.name ?? `Road #${road.id}`}
{road.surface}, speed x{road.speedMultiplier}
{road.description && (
{road.description}
)} {canEdit && mode === "edit" && (
)}
))} )} {features && toggles.roads && ( ({ 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 ? ( ) : null, )} )} {features && toggles.nodes && ( <> {nodeDots.map(({ node, position }) => (
{node.name}
{node.description && (
{node.description}
)}
{node.name}
{node.resources.length > 0 ? (
Resources: {node.resources.map((entry) => entry.name).join(", ")}
) : null} {node.description && (
{node.description}
)} {node.richness != null && node.resources.length > 0 && (
Richness {node.richness}
)} {canEdit && mode === "edit" && (
)}
))} )} {features && toggles.ranges && ( <> {structureDots.flatMap(({ structure, position, ranges }) => ranges.map((requirement, index) => ( )), )} )} {features && toggles.shipments && } {features && toggles.structures && ( )} {mapConfig && ( )} {mode === "place" && } {mode === "edit" && authoring.kind != null && ( )} {mode === "edit" && authoring.kind != null && authoring.draftPoints.length > 0 && ( )} {mode === "edit" && authoring.kind === "road" && authoring.draftPoints.length > 0 && ( )} {mode === "edit" && authoring.kind === "zone" && authoring.draftPoints.length > 0 && ( )} {mode === "edit" && authoring.kind === "node" && authoring.draftPoints.length > 0 && ( )}
PTF OPS // Map Control REF {selectedMapId != null ? String(selectedMapId).padStart(3, "0") : "---"}
{layerToggleKeys.map(({ key, label }) => ( ))} {canPlace && ( )} {canEdit && ( )}
{mode === "edit" && }
{error && (
{error}
)} {features && mapConfig && ( { if (!open) setPendingPoint(null); }} mapConfig={mapConfig} point={pendingPoint ?? [0, 0]} zones={features.zones} nodes={features.nodes} structures={features.structures} onPlaced={refetchFeatures} /> )} {previewId != null && ( setPreviewId(null)} /> )}
); }