From fcf220b9619f71688f4b9932a74a593b0f3352a8 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Wed, 16 Sep 2026 23:14:12 -0400 Subject: [PATCH] feat(map): /map world map with authoring, placement, and feature APIs Add the Leaflet map page and client (markers, labels, layer toggles, shipment interpolation), the placement and authoring server actions, the map-features and map-import API routes, and the map styles including leaflet pane isolation so portals stack correctly. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/app/(frontend)/logistics/map/actions.ts | 498 ++++++ src/app/(frontend)/map/MapLoader.tsx | 20 + src/app/(frontend)/map/page.tsx | 50 + src/app/(frontend)/styles.css | 221 +++ src/app/api/map-features/route.ts | 260 ++++ src/app/api/map-import/route.ts | 243 +++ .../frontend/map/AuthoringToolbar.tsx | 778 ++++++++++ src/components/frontend/map/MapClient.tsx | 1352 +++++++++++++++++ .../frontend/map/PlacementDialog.tsx | 321 ++++ .../frontend/map/StructureDetailDrawer.tsx | 245 +++ 10 files changed, 3988 insertions(+) create mode 100644 src/app/(frontend)/logistics/map/actions.ts create mode 100644 src/app/(frontend)/map/MapLoader.tsx create mode 100644 src/app/(frontend)/map/page.tsx create mode 100644 src/app/api/map-features/route.ts create mode 100644 src/app/api/map-import/route.ts create mode 100644 src/components/frontend/map/AuthoringToolbar.tsx create mode 100644 src/components/frontend/map/MapClient.tsx create mode 100644 src/components/frontend/map/PlacementDialog.tsx create mode 100644 src/components/frontend/map/StructureDetailDrawer.tsx diff --git a/src/app/(frontend)/logistics/map/actions.ts b/src/app/(frontend)/logistics/map/actions.ts new file mode 100644 index 0000000..066d653 --- /dev/null +++ b/src/app/(frontend)/logistics/map/actions.ts @@ -0,0 +1,498 @@ +"use server"; + +import config from "@payload-config"; +import { getPayload } from "payload"; +import type { GameStructure } from "@/payload-types"; +import { hasPermission } from "@/utils/access-control/hasPermission"; +import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification"; +import { emitGameEvent } from "@/utils/event-log/emit"; +import { EventTypes } from "@/utils/event-log/eventTypes"; +import { + validatePlacement, + type BlueprintTerrain, + type NodeInput, + type StructureInput, + type ZoneInput, +} from "@/lib/map/placement"; +import { maybeStartConstruction } from "@/lib/map/construction"; +import { isPointArray, validatePosition, type MapPoint } from "@/lib/map/points"; +import { validateHexColor } from "@/lib/map/style"; + +interface ActionResult { + success: boolean; + error?: string; + data?: unknown; +} + +async function authenticate() { + const payloadConfig = await config; + const payload = await getPayload({ config: payloadConfig }); + const { headers } = await import("next/headers"); + const hdrs = await headers(); + const { user } = await payload.auth({ + headers: hdrs, + canSetHeaders: false, + }); + + if (!user) { + throw new Error("Unauthorized"); + } + + return { payload, user }; +} + +export async function placeStructure(params: { + mapId: number; + blueprintId: number; + name: string; + factionId?: number | null; + x: number; + y: number; +}): Promise { + try { + const { payload, user } = await authenticate(); + + if (!(await hasLogisticsQualification(payload, user))) { + return { + success: false, + error: "Logistics qualification required to place structures.", + }; + } + + const { mapId, blueprintId, name, x, y } = params; + + if (!Number.isFinite(x) || !Number.isFinite(y)) { + return { success: false, error: "Valid coordinates are required." }; + } + + if (!name || name.trim().length === 0) { + return { success: false, error: "A display name is required." }; + } + + const map = await payload + .findByID({ collection: "maps", id: mapId, depth: 0 }) + .catch(() => null); + if (!map) { + return { success: false, error: "Map not found." }; + } + if (!map.isActive) { + return { success: false, error: "This map is not active." }; + } + + const worldW = map.worldSizeWidth ?? 8192; + const worldH = map.worldSizeHeight ?? 8192; + if (x < 0 || x > worldW || y < 0 || y > worldH) { + return { success: false, error: "Site is outside the map bounds." }; + } + + const blueprint = await payload + .findByID({ collection: "structures", id: blueprintId, depth: 1 }) + .catch(() => null); + if (!blueprint) { + return { success: false, error: "Structure blueprint not found." }; + } + + if (params.factionId != null) { + const faction = await payload + .findByID({ collection: "factions", id: params.factionId, depth: 0 }) + .catch(() => null); + if (!faction) { + return { success: false, error: "Faction not found." }; + } + } + + const [zonesRes, nodesRes, structuresRes] = await Promise.all([ + payload.find({ + collection: "map-zones", + where: { map: { equals: mapId } }, + depth: 0, + limit: 5000, + }), + payload.find({ + collection: "resource-nodes", + where: { map: { equals: mapId } }, + depth: 0, + limit: 5000, + }), + payload.find({ + collection: "game-structures", + where: { and: [{ map: { equals: mapId } }, { coordinates: { exists: true } }] }, + depth: 0, + limit: 1000, + }), + ]); + + const zones: ZoneInput[] = zonesRes.docs.map((zone) => ({ + id: zone.id, + name: zone.name ?? null, + type: zone.type, + resources: ((zone.resources ?? []) as number[]) ?? [], + points: (zone.points ?? []) as MapPoint[], + })); + const nodes: NodeInput[] = nodesRes.docs.map((node) => ({ + id: node.id, + resources: ((node.resources ?? []) as number[]) ?? [], + position: (node.position ?? [0, 0]) as MapPoint, + })); + const structures: StructureInput[] = structuresRes.docs + .map((doc) => { + const structure = doc as unknown as GameStructure; + return { + id: structure.id, + type: typeof structure.type === "object" ? structure.type.id : structure.type, + coordinates: structure.coordinates as MapPoint | null, + }; + }) + .filter((structure): structure is StructureInput & { coordinates: MapPoint } => + isPointArray(structure.coordinates, 1), + ); + + const point: MapPoint = [x, y]; + const terrain = (blueprint.terrainType ?? "land") as BlueprintTerrain; + const validation = validatePlacement({ + zones, + nodes, + structures, + blueprintTerrain: terrain, + resourcesInRange: (blueprint.resourcesInRange ?? []).map((requirement) => ({ + resource: + typeof requirement.resource === "object" + ? requirement.resource?.id + : requirement.resource, + range: requirement.range ?? null, + })), + structuresInRange: (blueprint.structuresInRange ?? []).map((requirement) => ({ + structure: + typeof requirement.structure === "object" + ? requirement.structure?.id + : requirement.structure, + range: requirement.range ?? null, + })), + point, + }); + + if (!validation.allowed) { + return { success: false, error: validation.error ?? "Site is not valid for placement." }; + } + + const structure = await payload.create({ + collection: "game-structures", + data: { + name: name.trim(), + type: blueprintId, + faction: params.factionId ?? null, + map: mapId, + coordinates: point, + constructionStatus: "awaiting_materials", + }, + }); + + // Blueprints without materials start building immediately (the helper + // no-ops while any material is still missing). + try { + await maybeStartConstruction(payload, structure.id); + } catch { + // The site starts on the next delivery instead. + } + + await emitGameEvent(payload, { + type: EventTypes.StructurePlaced, + message: `Construction site "${name.trim()}" (${blueprint.name}) placed at ${Math.round(x)}, ${Math.round(y)} on ${map.name}`, + actor: user.id, + structure: structure.id, + targetCollection: "game-structures", + targetId: structure.id, + data: { + mapId, + blueprintId, + blueprintName: blueprint.name, + point, + constructionDurationMinutes: blueprint.constructionDurationMinutes, + }, + }); + + return { success: true, data: structure }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + if (message === "Unauthorized") { + return { success: false, error: "You must be logged in." }; + } + return { success: false, error: message }; + } +} + +export interface MapRoadInput { + id?: number | null; + mapId: number; + name?: string | null; + description?: string | null; + surface: "paved" | "dirt" | "trail"; + speedMultiplier: number; + strokeColor?: string | null; + strokeWeight?: number | null; + strokeStyle?: "solid" | "dashed" | "dotted" | null; + labelVisible?: boolean | null; + points: MapPoint[]; +} + +export interface MapZoneInput { + id?: number | null; + mapId: number; + name?: string | null; + description?: string | null; + type: "water" | "land"; + resourceIds?: number[]; + fillColor?: string | null; + strokeColor?: string | null; + fillOpacity?: number | null; + strokeWeight?: number | null; + labelVisible?: boolean | null; + points: MapPoint[]; +} + +export interface MapNodeInput { + id?: number | null; + mapId: number; + name: string; + description?: string | null; + resourceIds: number[]; + position: MapPoint; + richness?: number | null; +} + +async function requireMapPermission( + payload: Awaited>, + user: { id: number | string }, + permission: "maps:create" | "maps:update" | "maps:delete", +): Promise { + return hasPermission(payload, user, permission); +} + +function relationshipMapId(value: unknown): number | null { + if (typeof value === "number") return value; + if (value && typeof value === "object" && "id" in value) { + return (value as { id: number }).id; + } + return null; +} + +type MapFeatureCollection = "map-roads" | "map-zones" | "resource-nodes"; + +/** When editing, the feature must exist and belong to the map being edited. */ +async function existingFeatureOnMap( + payload: Awaited>, + collection: MapFeatureCollection, + id: number, + mapId: number, + label: string, +): Promise<{ ok: true } | { ok: false; error: string }> { + const existing = await payload.findByID({ collection, id, depth: 0 }).catch(() => null); + if (!existing) return { ok: false, error: `${label} not found.` }; + if (relationshipMapId(existing.map) !== mapId) { + return { ok: false, error: `This ${label.toLowerCase()} belongs to a different map.` }; + } + return { ok: true }; +} + +export async function saveMapRoad(input: MapRoadInput): Promise { + try { + const { payload, user } = await authenticate(); + const editing = input.id != null; + if (!(await requireMapPermission(payload, user, editing ? "maps:update" : "maps:create"))) { + return { success: false, error: "Map configuration permission required." }; + } + if (!isPointArray(input.points, 2)) { + return { success: false, error: "A road requires at least two points." }; + } + if (input.speedMultiplier <= 0) { + return { success: false, error: "Speed multiplier must be greater than zero." }; + } + const colorCheck = validateHexColor(input.strokeColor); + if (colorCheck !== true) return { success: false, error: colorCheck }; + const map = await payload + .findByID({ collection: "maps", id: input.mapId, depth: 0 }) + .catch(() => null); + if (!map) return { success: false, error: "Map not found." }; + + const roadData = { + name: input.name?.trim() || null, + description: input.description?.trim() || null, + surface: input.surface, + speedMultiplier: input.speedMultiplier, + strokeColor: input.strokeColor?.trim() || null, + strokeWeight: input.strokeWeight ?? null, + strokeStyle: input.strokeStyle ?? null, + labelVisible: input.labelVisible ?? null, + points: input.points, + }; + + if (editing) { + const existing = await existingFeatureOnMap( + payload, + "map-roads", + input.id!, + input.mapId, + "Road", + ); + if (!existing.ok) return { success: false, error: existing.error }; + const road = await payload.update({ + collection: "map-roads", + id: input.id!, + data: roadData, + }); + return { success: true, data: road }; + } + + const road = await payload.create({ + collection: "map-roads", + data: { ...roadData, map: input.mapId }, + }); + return { success: true, data: road }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + if (message === "Unauthorized") return { success: false, error: "You must be logged in." }; + return { success: false, error: message }; + } +} + +export async function saveMapZone(input: MapZoneInput): Promise { + try { + const { payload, user } = await authenticate(); + const editing = input.id != null; + if (!(await requireMapPermission(payload, user, editing ? "maps:update" : "maps:create"))) { + return { success: false, error: "Map configuration permission required." }; + } + if (!isPointArray(input.points, 3)) { + return { success: false, error: "A zone requires at least three points." }; + } + for (const color of [input.fillColor, input.strokeColor]) { + const colorCheck = validateHexColor(color); + if (colorCheck !== true) return { success: false, error: colorCheck }; + } + const map = await payload + .findByID({ collection: "maps", id: input.mapId, depth: 0 }) + .catch(() => null); + if (!map) return { success: false, error: "Map not found." }; + + const zoneData = { + name: input.name?.trim() || null, + description: input.description?.trim() || null, + type: input.type, + resources: input.resourceIds ?? [], + fillColor: input.fillColor?.trim() || null, + strokeColor: input.strokeColor?.trim() || null, + fillOpacity: input.fillOpacity ?? null, + strokeWeight: input.strokeWeight ?? null, + labelVisible: input.labelVisible ?? null, + points: input.points, + }; + + if (editing) { + const existing = await existingFeatureOnMap( + payload, + "map-zones", + input.id!, + input.mapId, + "Zone", + ); + if (!existing.ok) return { success: false, error: existing.error }; + const zone = await payload.update({ + collection: "map-zones", + id: input.id!, + data: zoneData, + }); + return { success: true, data: zone }; + } + + const zone = await payload.create({ + collection: "map-zones", + data: { ...zoneData, map: input.mapId }, + }); + return { success: true, data: zone }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + if (message === "Unauthorized") return { success: false, error: "You must be logged in." }; + return { success: false, error: message }; + } +} + +export async function saveMapNode(input: MapNodeInput): Promise { + try { + const { payload, user } = await authenticate(); + const editing = input.id != null; + if (!(await requireMapPermission(payload, user, editing ? "maps:update" : "maps:create"))) { + return { success: false, error: "Map configuration permission required." }; + } + if (validatePosition(input.position) !== true) { + return { success: false, error: "A node requires a single [x, y] position." }; + } + const resourceIds = [...new Set(input.resourceIds)]; + if (resourceIds.length === 0) { + return { success: false, error: "A node requires at least one resource." }; + } + const resources = await payload.find({ + collection: "resources", + where: { id: { in: resourceIds } }, + depth: 0, + limit: resourceIds.length, + }); + if (resources.docs.length !== resourceIds.length) { + return { success: false, error: "One or more resources not found." }; + } + + const nodeData = { + name: input.name.trim(), + description: input.description?.trim() || null, + resources: resourceIds, + position: input.position, + richness: input.richness ?? 100, + }; + + if (editing) { + const existing = await existingFeatureOnMap( + payload, + "resource-nodes", + input.id!, + input.mapId, + "Node", + ); + if (!existing.ok) return { success: false, error: existing.error }; + const node = await payload.update({ + collection: "resource-nodes", + id: input.id!, + data: nodeData, + }); + return { success: true, data: node }; + } + + const node = await payload.create({ + collection: "resource-nodes", + data: { ...nodeData, map: input.mapId }, + }); + return { success: true, data: node }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + if (message === "Unauthorized") return { success: false, error: "You must be logged in." }; + return { success: false, error: message }; + } +} + +export async function deleteMapFeature( + kind: "road" | "zone" | "node", + id: number, +): Promise { + try { + const { payload, user } = await authenticate(); + if (!(await requireMapPermission(payload, user, "maps:delete"))) { + return { success: false, error: "Map configuration permission required." }; + } + const collection = + kind === "road" ? "map-roads" : kind === "zone" ? "map-zones" : "resource-nodes"; + await payload.delete({ collection, id, overrideAccess: false }); + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + if (message === "Unauthorized") return { success: false, error: "You must be logged in." }; + return { success: false, error: message }; + } +} diff --git a/src/app/(frontend)/map/MapLoader.tsx b/src/app/(frontend)/map/MapLoader.tsx new file mode 100644 index 0000000..5c139b9 --- /dev/null +++ b/src/app/(frontend)/map/MapLoader.tsx @@ -0,0 +1,20 @@ +"use client"; + +import dynamic from "next/dynamic"; +import type { MapClientProps } from "@/components/frontend/map/MapClient"; + +const MapClient = dynamic( + () => import("@/components/frontend/map/MapClient").then((mod) => mod.MapClient), + { + ssr: false, + loading: () => ( +
+ Loading map... +
+ ), + }, +); + +export function MapLoader(props: MapClientProps) { + return ; +} diff --git a/src/app/(frontend)/map/page.tsx b/src/app/(frontend)/map/page.tsx new file mode 100644 index 0000000..49a77ed --- /dev/null +++ b/src/app/(frontend)/map/page.tsx @@ -0,0 +1,50 @@ +import config from "@payload-config"; +import { getPayload } from "payload"; +import { headers } from "next/headers"; +import type { Metadata } from "next"; +import { Map as MapIcon } from "lucide-react"; +import { hasPermission } from "@/utils/access-control/hasPermission"; +import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification"; +import { MapLoader } from "./MapLoader"; + +export const metadata: Metadata = { + title: "World Map: Polaris Task Force", +}; + +export default async function MapPage() { + const payload = await getPayload({ config: await config }); + const { user } = await payload.auth({ headers: await headers(), canSetHeaders: false }); + + const [mapsRes, canEdit, canPlace] = await Promise.all([ + payload.find({ + collection: "maps", + where: { isActive: { equals: true } }, + sort: "name", + limit: 100, + depth: 0, + overrideAccess: true, + }), + user ? hasPermission(payload, user, "maps:update") : Promise.resolve(false), + user ? hasLogisticsQualification(payload, user) : Promise.resolve(false), + ]); + + const maps = mapsRes.docs.map((map) => ({ id: map.id, name: map.name })); + + return ( +
+
+
+ +

World Map

+
+
+ PTF Unit Command // Tactical Grid + + {maps.length} Active Grid{maps.length === 1 ? "" : "s"} + +
+
+ +
+ ); +} diff --git a/src/app/(frontend)/styles.css b/src/app/(frontend)/styles.css index f7241bd..f1ee6a3 100644 --- a/src/app/(frontend)/styles.css +++ b/src/app/(frontend)/styles.css @@ -3208,3 +3208,224 @@ animation: none; } } + +/* World map: dossier marker family (structure / node / shipment pins). */ + +/* World map: give the Leaflet container its own stacking context so its panes + (z-index 200-1000, applied in the root stacking context otherwise) stop + painting over Radix dropdown portals (body-level, z-50) on the map page. */ +.leaflet-container { + isolation: isolate; +} + +/* Map placement/authoring modes: crosshair cursor while a click mode is + active (MapContainer className is mount-only, so this lives on CSS). */ +.map-host[data-mode="crosshair"] .leaflet-container { + cursor: crosshair; +} + +/* World map: structure / construction-site markers (status-colored pin with an + inline SVG icon and a progress bar for sites under construction). The same + pin base is shared by the node and shipment badges. */ +.map-structure-icon { + background: transparent; + border: none; +} + +.map-structure-pin { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 2px; + background: rgb(8 10 14 / 90%); + border: 1.5px solid rgb(255 255 255 / 25%); + box-shadow: + 0 0 0 1.5px rgb(0 0 0 / 65%), + 0 1px 4px rgb(0 0 0 / 60%); +} + +.map-structure-pin.map-pin-sm { + width: 20px; + height: 20px; + border-radius: 1.5px; +} + +.map-structure-progress { + position: absolute; + left: 3px; + right: 3px; + bottom: -6px; + height: 3px; + overflow: hidden; + border-radius: 2px; + background: rgb(255 255 255 / 30%); +} + +.map-structure-progress > div { + height: 100%; + background: #ffffff; +} + +/* World map: dossier-style Leaflet chrome. Tooltips and popups are Leaflet DOM + (not shadcn), so they get the tactical card treatment here: dark glass, + hairline borders, squared corners, mono uppercase labels. */ +.leaflet-container .map-tooltip-dossier.leaflet-tooltip { + background: rgb(0 0 0 / 82%); + backdrop-filter: blur(6px); + border: 1px solid rgb(255 255 255 / 25%); + border-radius: 0; + box-shadow: 0 2px 8px rgb(0 0 0 / 65%); + color: rgb(255 255 255 / 85%); + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", + monospace; + font-size: 10px; + letter-spacing: 0.16em; + padding: 4px 8px; + text-transform: uppercase; +} + +.leaflet-container .map-tooltip-dossier.leaflet-tooltip-top::before { + border-top-color: rgb(255 255 255 / 25%); +} + +.leaflet-container .map-tooltip-dossier.leaflet-tooltip-bottom::before { + border-bottom-color: rgb(255 255 255 / 25%); +} + +.leaflet-container .map-tooltip-dossier.leaflet-tooltip-left::before { + border-left-color: rgb(255 255 255 / 25%); +} + +.leaflet-container .map-tooltip-dossier.leaflet-tooltip-right::before { + border-right-color: rgb(255 255 255 / 25%); +} + +.leaflet-container .leaflet-popup-content-wrapper { + background: rgb(8 10 14 / 92%); + backdrop-filter: blur(8px); + border: 1px solid rgb(255 255 255 / 20%); + border-radius: 0; + box-shadow: 0 4px 16px rgb(0 0 0 / 65%); + color: rgb(255 255 255 / 90%); +} + +.leaflet-container .leaflet-popup-content { + margin: 10px 12px; + line-height: 1.45; +} + +.leaflet-container .leaflet-popup-tip { + background: rgb(8 10 14 / 92%); + box-shadow: none; +} + +.leaflet-container a.leaflet-popup-close-button { + color: rgb(255 255 255 / 50%); +} + +.leaflet-container a.leaflet-popup-close-button:hover { + color: #ffffff; +} + +/* Dossier popups (structure/shipment cards): zero wrapper padding so the + classification strip and readout footer run edge to edge. */ +.leaflet-container .map-popup-dossier .leaflet-popup-content { + margin: 0; + width: 100% !important; +} + +/* World map: persistent map labels (structures, zones, roads) in the dossier + style: mono uppercase, tracked, dark halo instead of a box. */ +.leaflet-container .map-label.leaflet-tooltip { + background: rgb(8 10 14 / 62%); + border: none; + box-shadow: none; + border-radius: 1px; + padding: 1px 5px; + color: rgb(255 255 255 / 92%); + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", + monospace; + font-size: 10px; + letter-spacing: 0.16em; + text-transform: uppercase; + text-shadow: + 0 0 3px rgb(0 0 0 / 95%), + 0 0 6px rgb(0 0 0 / 85%); + white-space: nowrap; +} + +.leaflet-container .map-label.leaflet-tooltip::before { + display: none; +} + +.map-zone-label-icon, +.map-road-label-icon { + background: transparent; + border: none; +} + +.map-zone-label { + color: rgb(255 255 255 / 90%); + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", + monospace; + font-size: 10px; + letter-spacing: 0.2em; + text-transform: uppercase; + white-space: nowrap; + /* Size to the text itself: inside the 0x0 divIcon container a block div + resolves to 0 width, which makes the percentage centering transform a + no-op and leaves the label hanging off the road. */ + width: max-content; + background: rgb(8 10 14 / 62%); + padding: 1px 5px; + border-radius: 1px; + text-shadow: + 0 0 3px rgb(0 0 0 / 95%), + 0 0 7px rgb(0 0 0 / 90%), + 0 0 2px rgb(0 0 0 / 90%); + pointer-events: none; + transform: translate(-50%, -50%); +} + +.map-road-label-svg { + overflow: visible; +} + +.map-road-label-path { + fill: none; + stroke: none; +} + +.map-road-label-text { + fill: rgb(255 255 255 / 94%); + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", + monospace; + font-size: 11px; + letter-spacing: 0.2em; + /* Dark casing behind the glyphs (painted under the fill) for contrast on + any terrain. */ + stroke: rgb(0 0 0 / 82%); + stroke-width: 4px; + paint-order: stroke; + stroke-linejoin: round; +} + +.leaflet-container .map-zone-label-icon, +.leaflet-container .map-road-label-icon { + pointer-events: none; +} + +/* Radix floating panels (Select/DropdownMenu/Popover/Tooltip) portal to + and would otherwise paint below the map page's high-z overlays (authoring + toolbar z-[1000], structure drawer z-[1200]). Pin every popper wrapper above + them. */ +[data-radix-popper-content-wrapper] { + z-index: 1300 !important; +} diff --git a/src/app/api/map-features/route.ts b/src/app/api/map-features/route.ts new file mode 100644 index 0000000..c347b3c --- /dev/null +++ b/src/app/api/map-features/route.ts @@ -0,0 +1,260 @@ +import { NextRequest, NextResponse } from "next/server"; +import config from "@payload-config"; +import { getPayload } from "payload"; +import type { GameStructure, Map as MapDoc, Shipment } from "@/payload-types"; +import { isPointArray, type MapPoint } from "@/lib/map/points"; +import type { MapFeaturesResponse, StructureFeature } from "@/lib/map/types"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +/** + * PostGIS point fields read back as a FLAT [x, y] tuple (not an array of + * tuples like the JSON feature geometry). + */ +function pointOf(coordinates: unknown): MapPoint | null { + if (!Array.isArray(coordinates) || coordinates.length !== 2) return null; + const x = coordinates[0]; + const y = coordinates[1]; + if ( + typeof x !== "number" || + typeof y !== "number" || + !Number.isFinite(x) || + !Number.isFinite(y) + ) { + return null; + } + return [x, y]; +} + +function relationshipId(value: unknown): number | null { + if (typeof value === "number") return value; + if (value && typeof value === "object" && "id" in value) return (value as { id: number }).id; + return null; +} + +/** + * View-surface feature feed for the map page (settled decision 8): any + * logged-in user may read map features here, while maps/road/zone/node + * configuration stays maps:*-gated in the Payload admin panel. + */ +export async function GET(req: NextRequest) { + const payload = await getPayload({ config: await config }); + const { user } = await payload.auth({ headers: req.headers, canSetHeaders: false }); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const mapId = Number(req.nextUrl.searchParams.get("mapId")); + if (!Number.isInteger(mapId) || mapId <= 0) { + return NextResponse.json({ error: "mapId is required" }, { status: 400 }); + } + + const map = await payload + .findByID({ collection: "maps", id: mapId, depth: 1, overrideAccess: true }) + .catch(() => null); + if (!map) { + return NextResponse.json({ error: "Map not found" }, { status: 404 }); + } + + const mapDoc = map as unknown as MapDoc & { + basemapImage: { url?: string | null } | number | null; + }; + const worldW = Number(mapDoc.worldSizeWidth ?? 8192); + const worldH = Number(mapDoc.worldSizeHeight ?? 8192); + + const [structuresRes, nodesRes, roadsRes, zonesRes, shipmentsRes] = await Promise.all([ + payload.find({ + collection: "game-structures", + where: { map: { equals: mapId } }, + depth: 2, + limit: 1000, + overrideAccess: true, + }), + payload.find({ + collection: "resource-nodes", + where: { map: { equals: mapId } }, + depth: 1, + limit: 5000, + overrideAccess: true, + }), + payload.find({ + collection: "map-roads", + where: { map: { equals: mapId } }, + depth: 0, + limit: 5000, + overrideAccess: true, + }), + payload.find({ + collection: "map-zones", + where: { map: { equals: mapId } }, + depth: 1, + limit: 5000, + overrideAccess: true, + }), + payload.find({ + collection: "shipments", + where: { + and: [{ map: { equals: mapId } }, { status: { in: ["dispatched", "in_transit"] } }], + }, + depth: 2, + limit: 200, + sort: "-dispatchedAt", + overrideAccess: true, + }), + ]); + + const structures: StructureFeature[] = []; + for (const doc of structuresRes.docs) { + const structure = doc as unknown as GameStructure & { type: { id: number; name: string } }; + const point = pointOf(structure.coordinates); + if (!point) continue; + const blueprint = typeof structure.type === "object" ? structure.type : null; + const faction = + structure.faction && typeof structure.faction === "object" ? structure.faction : null; + + const storageEntries = [ + ...(structure.storedResources ?? []), + ...((structure as GameStructure).voidStorage ?? []), + ]; + let storageUsedKg = 0; + for (const entry of storageEntries) { + const resource = typeof entry.resource === "object" ? entry.resource : null; + storageUsedKg += (resource?.massPerUnit ?? 0) * entry.amount; + } + + structures.push({ + id: structure.id, + name: structure.name, + point, + constructionStatus: structure.constructionStatus ?? "complete", + constructionStartedAt: structure.constructionStartedAt ?? null, + constructionCompletesAt: structure.constructionCompletesAt ?? null, + constructionDurationMinutes: blueprint?.constructionDurationMinutes ?? null, + storageUsedKg, + storageCapacityKg: blueprint?.maxCapacityMass ?? null, + labelVisible: structure.labelVisible ?? null, + labelPriority: structure.labelPriority ?? null, + blueprintId: blueprint?.id ?? null, + blueprintName: blueprint?.name ?? null, + blueprintTerrain: (blueprint?.terrainType as "land" | "water" | undefined) ?? null, + materials: (blueprint?.materials ?? []).map((material) => ({ + resource: relationshipId(material.resource) ?? 0, + resourceName: + material.resource && typeof material.resource === "object" + ? material.resource.name + : "Resource", + amount: material.amount ?? 0, + })), + resourcesInRange: (blueprint?.resourcesInRange ?? []).map((requirement) => ({ + resource: relationshipId(requirement.resource), + structure: null, + range: requirement.range ?? null, + })), + structuresInRange: (blueprint?.structuresInRange ?? []).map((requirement) => ({ + resource: null, + structure: relationshipId(requirement.structure), + range: requirement.range ?? null, + })), + factionName: faction && "name" in faction ? String(faction.name) : null, + }); + } + + const relEntries = (value: unknown): { id: number; name: string }[] => + (Array.isArray(value) ? value : []).map((entry) => + typeof entry === "object" && entry !== null && "id" in entry + ? { id: entry.id as number, name: String((entry as { name?: string }).name ?? "Resource") } + : { id: entry as number, name: "Resource" }, + ); + + const nodes = nodesRes.docs.map((node) => ({ + id: node.id, + name: node.name, + point: pointOf(node.position) ?? [0, 0], + description: node.description ?? null, + resources: relEntries(node.resources), + richness: node.richness ?? null, + })); + + const roads = roadsRes.docs.map((road) => ({ + id: road.id, + name: road.name ?? null, + description: road.description ?? null, + surface: road.surface, + speedMultiplier: road.speedMultiplier, + strokeColor: road.strokeColor ?? null, + strokeWeight: road.strokeWeight ?? null, + strokeStyle: road.strokeStyle ?? null, + labelVisible: road.labelVisible ?? null, + points: isPointArray(road.points, 2) ? (road.points as MapPoint[]) : [], + })); + + const zones = zonesRes.docs.map((zone) => ({ + id: zone.id, + name: zone.name ?? null, + description: zone.description ?? null, + type: zone.type, + resources: relEntries(zone.resources), + fillColor: zone.fillColor ?? null, + strokeColor: zone.strokeColor ?? null, + fillOpacity: zone.fillOpacity ?? null, + strokeWeight: zone.strokeWeight ?? null, + labelVisible: zone.labelVisible ?? null, + points: isPointArray(zone.points, 3) ? (zone.points as MapPoint[]) : [], + })); + + const shipments = shipmentsRes.docs.map((doc) => { + const shipment = doc as unknown as Shipment & { + origin: GameStructure; + destination: GameStructure; + transportVehicle: { name: string } | number | null; + }; + const vehicle = + shipment.transportVehicle && typeof shipment.transportVehicle === "object" + ? shipment.transportVehicle + : null; + const vehicleType = + vehicle && typeof (vehicle as { type?: unknown }).type === "object" + ? ((vehicle as { type: { name?: string } }).type.name ?? null) + : null; + return { + id: shipment.id, + name: shipment.name ?? null, + status: shipment.status, + routePath: isPointArray(shipment.routePath, 2) ? (shipment.routePath as MapPoint[]) : null, + originName: shipment.origin?.name ?? null, + originPoint: pointOf(shipment.origin?.coordinates), + destinationName: shipment.destination?.name ?? null, + destinationPoint: pointOf(shipment.destination?.coordinates), + dispatchedAt: shipment.dispatchedAt ?? null, + estimatedArrival: shipment.estimatedArrival ?? null, + vehicleName: vehicleType, + }; + }); + + const response: MapFeaturesResponse = { + map: { + id: mapDoc.id, + name: mapDoc.name, + worldSizeWidth: worldW, + worldSizeHeight: worldH, + basemapMode: mapDoc.basemapMode, + basemapImageUrl: + mapDoc.basemapImage && typeof mapDoc.basemapImage === "object" + ? (mapDoc.basemapImage.url ?? null) + : null, + tileUrlTemplate: mapDoc.tileUrlTemplate ?? null, + minZoom: mapDoc.minZoom ?? 0, + maxZoom: mapDoc.maxZoom ?? 12, + defaultZoom: mapDoc.defaultZoom ?? 2, + defaultCenter: [mapDoc.defaultCenterX ?? worldW / 2, mapDoc.defaultCenterY ?? worldH / 2], + }, + structures, + nodes, + roads, + zones, + shipments, + }; + + return NextResponse.json(response); +} diff --git a/src/app/api/map-import/route.ts b/src/app/api/map-import/route.ts new file mode 100644 index 0000000..7ecac01 --- /dev/null +++ b/src/app/api/map-import/route.ts @@ -0,0 +1,243 @@ +import { NextRequest, NextResponse } from "next/server"; +import config from "@payload-config"; +import { getPayload } from "payload"; +import { hasPermission } from "@/utils/access-control/hasPermission"; +import { isPointArray, type MapPoint } from "@/lib/map/points"; +import { validateHexColor } from "@/lib/map/style"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +interface GeoJsonPosition { + 0: number; + 1: number; + length: number; +} + +interface GeoJsonFeature { + type?: string; + geometry?: { + type?: string; + coordinates?: unknown; + } | null; + properties?: Record | null; +} + +interface GeoJsonCollection { + type?: string; + features?: GeoJsonFeature[]; +} + +const ROAD_SURFACES = new Set(["paved", "dirt", "trail"]); + +function toMapPoint(position: unknown): MapPoint | null { + const p = position as GeoJsonPosition | undefined; + if (!p || !Array.isArray(p) || p.length < 2) return null; + const x = Number(p[0]); + const y = Number(p[1]); + if (!Number.isFinite(x) || !Number.isFinite(y)) return null; + return [x, y]; +} + +function propString(props: Record, key: string): string | null { + const value = props[key]; + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function propNumber(props: Record, key: string): number | null { + const value = props[key]; + const num = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN; + return Number.isFinite(num) ? num : null; +} + +function propHexColor(props: Record, key: string): string | null { + const value = propString(props, key); + return validateHexColor(value) === true ? value : null; +} + +function propStrokeStyle( + props: Record, + key: string, +): "solid" | "dashed" | "dotted" | null { + const value = propString(props, key); + return value === "dashed" || value === "dotted" || value === "solid" ? value : null; +} + +/** + * GeoJSON FeatureCollection import for road/zone/node authoring (JOSM-style + * workflows). See docs/map-system.md for the full mapping table. + * + * Mapping: + * - LineString -> map-roads (props: name, description, surface, speedMultiplier, + * strokeColor #rrggbb, strokeWeight, strokeStyle solid|dashed|dotted) + * - Polygon -> map-zones (props: name, description, type "water"|"land"; outer ring only; + * fillColor, strokeColor #rrggbb, fillOpacity, strokeWeight) + * - Point -> resource-nodes (props: name, resource [resource ID] or + * resources [array of IDs], resourceName/resourceNames, richness) + * + * GeoJSON positions are interpreted directly as map meters: [x, y] = + * [east, south] on the map grid (same convention as the authoring UI). + */ +export async function POST(req: NextRequest) { + const payload = await getPayload({ config: await config }); + const { user } = await payload.auth({ headers: req.headers, canSetHeaders: false }); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (!(await hasPermission(payload, user, "maps:create"))) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + let body: (GeoJsonCollection & { mapId?: number }) | null = null; + try { + body = (await req.json()) as GeoJsonCollection & { mapId?: number }; + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const mapId = Number(body?.mapId); + if (!Number.isInteger(mapId) || mapId <= 0) { + return NextResponse.json({ error: "mapId is required" }, { status: 400 }); + } + if (body?.type !== "FeatureCollection" || !Array.isArray(body.features)) { + return NextResponse.json( + { error: "Body must be a GeoJSON FeatureCollection with a mapId field" }, + { status: 400 }, + ); + } + + const map = await payload + .findByID({ collection: "maps", id: mapId, depth: 0, overrideAccess: true }) + .catch(() => null); + if (!map) { + return NextResponse.json({ error: "Map not found" }, { status: 404 }); + } + + const created = { roads: 0, zones: 0, nodes: 0 }; + const errors: string[] = []; + + for (let index = 0; index < body.features.length; index++) { + const feature = body.features[index]; + const label = feature.properties?.name ?? `feature ${index}`; + const props = feature.properties ?? {}; + + try { + const geometryType = feature.geometry?.type; + const rawCoordinates = feature.geometry?.coordinates; + + if (geometryType === "LineString" && isPointArray(rawCoordinates, 2)) { + const surface = propString(props, "surface"); + await payload.create({ + collection: "map-roads", + data: { + map: mapId, + name: propString(props, "name"), + description: propString(props, "description"), + surface: + surface && ROAD_SURFACES.has(surface) + ? (surface as "paved" | "dirt" | "trail") + : "paved", + speedMultiplier: propNumber(props, "speedMultiplier") ?? 1, + strokeColor: propHexColor(props, "strokeColor"), + strokeWeight: propNumber(props, "strokeWeight"), + strokeStyle: propStrokeStyle(props, "strokeStyle"), + points: (rawCoordinates as GeoJsonPosition[]).map(toMapPoint) as MapPoint[], + }, + overrideAccess: true, + }); + created.roads++; + continue; + } + + if (geometryType === "Polygon" && Array.isArray(rawCoordinates)) { + const ring = (rawCoordinates as unknown[][])[0]; + if (isPointArray(ring, 3)) { + const zoneType = propString(props, "type"); + const zoneResourceNames = (propString(props, "resources") ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + let zoneResourceIds: number[] = []; + if (zoneResourceNames.length > 0) { + const found = await payload.find({ + collection: "resources", + where: { name: { in: zoneResourceNames } }, + depth: 0, + limit: 100, + overrideAccess: true, + }); + zoneResourceIds = found.docs.map((doc) => doc.id); + } + await payload.create({ + collection: "map-zones", + data: { + map: mapId, + name: propString(props, "name"), + description: propString(props, "description"), + type: zoneType === "land" ? "land" : "water", + resources: zoneResourceIds, + fillColor: propHexColor(props, "fillColor"), + strokeColor: propHexColor(props, "strokeColor"), + fillOpacity: propNumber(props, "fillOpacity"), + strokeWeight: propNumber(props, "strokeWeight"), + points: (ring as GeoJsonPosition[]).map(toMapPoint) as MapPoint[], + }, + overrideAccess: true, + }); + created.zones++; + continue; + } + } + + if (geometryType === "Point" && Array.isArray(rawCoordinates)) { + const position = toMapPoint(rawCoordinates); + if (!position) throw new Error("invalid Point coordinates"); + const resourceIds: number[] = []; + const resourceNames: string[] = []; + if (Array.isArray(props["resources"])) { + for (const entry of props["resources"]) { + if (typeof entry === "number") resourceIds.push(entry); + else if (typeof entry === "string") resourceNames.push(entry); + } + } + const singleId = propNumber(props, "resource"); + if (singleId != null) resourceIds.push(singleId); + const singleName = propString(props, "resourceName"); + if (singleName) resourceNames.push(singleName); + if (resourceNames.length > 0) { + const found = await payload.find({ + collection: "resources", + where: { name: { in: resourceNames } }, + depth: 0, + limit: 100, + overrideAccess: true, + }); + resourceIds.push(...found.docs.map((doc) => doc.id)); + } + const uniqueResourceIds = [...new Set(resourceIds)]; + if (uniqueResourceIds.length === 0) + throw new Error("missing resource (ID) or resourceName property"); + const name = propString(props, "name") ?? `Node ${position[0]}, ${position[1]}`; + await payload.create({ + collection: "resource-nodes", + data: { + map: mapId, + name, + resources: uniqueResourceIds, + position, + richness: propNumber(props, "richness") ?? 100, + }, + overrideAccess: true, + }); + created.nodes++; + continue; + } + + errors.push(`${label}: unsupported or malformed geometry "${String(geometryType)}"`); + } catch (error) { + errors.push(`${label}: ${error instanceof Error ? error.message : "import failed"}`); + } + } + + return NextResponse.json({ ok: errors.length === 0, created, errors }); +} diff --git a/src/components/frontend/map/AuthoringToolbar.tsx b/src/components/frontend/map/AuthoringToolbar.tsx new file mode 100644 index 0000000..24d2022 --- /dev/null +++ b/src/components/frontend/map/AuthoringToolbar.tsx @@ -0,0 +1,778 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + deleteMapFeature, + saveMapNode, + saveMapRoad, + saveMapZone, +} from "@/app/(frontend)/logistics/map/actions"; +import type { MapPoint } from "@/lib/map/points"; +import type { NodeFeature, RoadFeature, ZoneFeature } from "@/lib/map/types"; + +export type AuthoringKind = "road" | "zone" | "node" | null; + +export interface EditingFeature { + kind: Exclude; + id: number; + label: string; +} + +export interface MapAuthoring { + kind: AuthoringKind; + draftPoints: MapPoint[]; + canSave: boolean; + saving: boolean; + editing: EditingFeature | null; + fields: { + roadName: string; + setRoadName: (value: string) => void; + roadDescription: string; + setRoadDescription: (value: string) => void; + surface: "paved" | "dirt" | "trail"; + setSurface: (value: "paved" | "dirt" | "trail") => void; + speedMultiplier: string; + setSpeedMultiplier: (value: string) => void; + strokeColor: string; + setStrokeColor: (value: string) => void; + strokeWeight: string; + setStrokeWeight: (value: string) => void; + strokeStyle: "solid" | "dashed" | "dotted"; + setStrokeStyle: (value: "solid" | "dashed" | "dotted") => void; + labelVisible: boolean; + setLabelVisible: (value: boolean) => void; + zoneName: string; + setZoneName: (value: string) => void; + zoneDescription: string; + setZoneDescription: (value: string) => void; + zoneType: "water" | "land"; + setZoneType: (value: "water" | "land") => void; + fillColor: string; + setFillColor: (value: string) => void; + zoneStrokeColor: string; + setZoneStrokeColor: (value: string) => void; + fillOpacity: string; + setFillOpacity: (value: string) => void; + zoneStrokeWeight: string; + setZoneStrokeWeight: (value: string) => void; + nodeName: string; + setNodeName: (value: string) => void; + nodeDescription: string; + setNodeDescription: (value: string) => void; + resourceIds: number[]; + toggleResourceId: (id: number) => void; + zoneResourceIds: number[]; + toggleZoneResourceId: (id: number) => void; + richness: string; + setRichness: (value: string) => void; + }; + setKind: (kind: AuthoringKind) => void; + pushPoint: (point: MapPoint) => void; + clearDraft: () => void; + save: () => Promise; + removeFeature: (target: "road" | "zone" | "node", id: number) => Promise; + startEditingRoad: (road: RoadFeature) => void; + startEditingZone: (zone: ZoneFeature) => void; + startEditingNode: (node: NodeFeature) => void; +} + +export function useMapAuthoring(options: { mapId: number; onSaved: () => void }): MapAuthoring { + const { mapId, onSaved } = options; + const [kind, setKindState] = useState(null); + const [draftPoints, setDraftPoints] = useState([]); + const [roadName, setRoadName] = useState(""); + const [roadDescription, setRoadDescription] = useState(""); + const [surface, setSurface] = useState<"paved" | "dirt" | "trail">("paved"); + const [speedMultiplier, setSpeedMultiplier] = useState("1"); + const [strokeColor, setStrokeColor] = useState(""); + const [strokeWeight, setStrokeWeight] = useState(""); + const [strokeStyle, setStrokeStyle] = useState<"solid" | "dashed" | "dotted">("solid"); + const [labelVisible, setLabelVisible] = useState(true); + const [zoneName, setZoneName] = useState(""); + const [zoneDescription, setZoneDescription] = useState(""); + const [zoneType, setZoneType] = useState<"water" | "land">("water"); + const [fillColor, setFillColor] = useState(""); + const [zoneStrokeColor, setZoneStrokeColor] = useState(""); + const [fillOpacity, setFillOpacity] = useState(""); + const [zoneStrokeWeight, setZoneStrokeWeight] = useState(""); + const [nodeName, setNodeName] = useState(""); + const [nodeDescription, setNodeDescription] = useState(""); + const [resourceIds, setResourceIds] = useState([]); + const [zoneResourceIds, setZoneResourceIds] = useState([]); + const [richness, setRichness] = useState("100"); + + const toggleResourceId = useCallback((id: number) => { + setResourceIds((current) => + current.includes(id) ? current.filter((entry) => entry !== id) : [...current, id], + ); + }, []); + + const toggleZoneResourceId = useCallback((id: number) => { + setZoneResourceIds((current) => + current.includes(id) ? current.filter((entry) => entry !== id) : [...current, id], + ); + }, []); + const [saving, setSaving] = useState(false); + const [editing, setEditing] = useState(null); + + const pushPoint = useCallback((point: MapPoint) => { + setDraftPoints((current) => [...current, point]); + }, []); + + const clearDraft = useCallback(() => setDraftPoints([]), []); + + const setKind = useCallback((next: AuthoringKind) => { + setKindState(next); + setDraftPoints([]); + setEditing(null); + }, []); + + const startEditingRoad = useCallback((road: RoadFeature) => { + setKindState("road"); + setDraftPoints(road.points.map((point) => [point[0], point[1]])); + setRoadName(road.name ?? ""); + setRoadDescription(road.description ?? ""); + setSurface(road.surface); + setSpeedMultiplier(String(road.speedMultiplier)); + setStrokeColor(road.strokeColor ?? ""); + setStrokeWeight(road.strokeWeight != null ? String(road.strokeWeight) : ""); + setStrokeStyle(road.strokeStyle ?? "solid"); + setLabelVisible(road.labelVisible !== false); + setEditing({ kind: "road", id: road.id, label: road.name ?? `Road #${road.id}` }); + }, []); + + const startEditingZone = useCallback((zone: ZoneFeature) => { + setKindState("zone"); + setDraftPoints(zone.points.map((point) => [point[0], point[1]])); + setZoneName(zone.name ?? ""); + setZoneDescription(zone.description ?? ""); + setZoneType(zone.type); + setZoneResourceIds(zone.resources.map((entry) => entry.id)); + setFillColor(zone.fillColor ?? ""); + setZoneStrokeColor(zone.strokeColor ?? ""); + setFillOpacity(zone.fillOpacity != null ? String(zone.fillOpacity) : ""); + setZoneStrokeWeight(zone.strokeWeight != null ? String(zone.strokeWeight) : ""); + setLabelVisible(zone.labelVisible !== false); + setEditing({ kind: "zone", id: zone.id, label: zone.name ?? `Zone #${zone.id}` }); + }, []); + + const startEditingNode = useCallback((node: NodeFeature) => { + setKindState("node"); + setDraftPoints([[node.point[0], node.point[1]]]); + setNodeName(node.name); + setNodeDescription(node.description ?? ""); + setResourceIds(node.resources.map((entry) => entry.id)); + setRichness(String(node.richness ?? 100)); + setEditing({ kind: "node", id: node.id, label: node.name }); + }, []); + + const removeFeature = useCallback( + async (target: "road" | "zone" | "node", id: number) => { + const result = await deleteMapFeature(target, id); + if (result.success) { + if (editing && editing.id === id && editing.kind === target) setEditing(null); + toast.success("Feature deleted."); + onSaved(); + } else { + toast.error(result.error ?? "Delete failed."); + } + }, + [editing, onSaved], + ); + + const save = useCallback(async () => { + if (kind == null) return; + setSaving(true); + try { + if (kind === "road") { + const result = await saveMapRoad({ + id: editing?.kind === "road" ? editing.id : undefined, + mapId, + name: roadName.trim() || null, + description: roadDescription.trim() || null, + surface, + speedMultiplier: Number(speedMultiplier) || 1, + strokeColor: strokeColor.trim() || null, + strokeWeight: strokeWeight.trim() ? Number(strokeWeight) : null, + strokeStyle, + labelVisible, + points: draftPoints, + }); + if (!result.success) throw new Error(result.error ?? "Save failed."); + toast.success(editing?.kind === "road" ? "Road updated." : "Road saved."); + } else if (kind === "zone") { + const result = await saveMapZone({ + id: editing?.kind === "zone" ? editing.id : undefined, + mapId, + name: zoneName.trim() || null, + description: zoneDescription.trim() || null, + type: zoneType, + resourceIds: zoneResourceIds, + fillColor: fillColor.trim() || null, + strokeColor: zoneStrokeColor.trim() || null, + fillOpacity: fillOpacity.trim() ? Number(fillOpacity) : null, + strokeWeight: zoneStrokeWeight.trim() ? Number(zoneStrokeWeight) : null, + labelVisible, + points: draftPoints, + }); + if (!result.success) throw new Error(result.error ?? "Save failed."); + toast.success(editing?.kind === "zone" ? "Zone updated." : "Zone saved."); + } else { + const result = await saveMapNode({ + id: editing?.kind === "node" ? editing.id : undefined, + mapId, + name: nodeName.trim(), + description: nodeDescription.trim() || null, + resourceIds, + position: draftPoints[0], + richness: Number(richness) || 100, + }); + if (!result.success) throw new Error(result.error ?? "Save failed."); + toast.success(editing?.kind === "node" ? "Resource node updated." : "Resource node saved."); + } + setKindState(null); + setDraftPoints([]); + setEditing(null); + setRoadName(""); + setRoadDescription(""); + setZoneName(""); + setZoneDescription(""); + setNodeName(""); + setNodeDescription(""); + setResourceIds([]); + setZoneResourceIds([]); + setStrokeColor(""); + setStrokeWeight(""); + setStrokeStyle("solid"); + setFillColor(""); + setZoneStrokeColor(""); + setFillOpacity(""); + setZoneStrokeWeight(""); + setLabelVisible(true); + onSaved(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Save failed."); + } finally { + setSaving(false); + } + }, [ + kind, + editing, + mapId, + roadName, + roadDescription, + surface, + speedMultiplier, + strokeColor, + strokeWeight, + strokeStyle, + labelVisible, + draftPoints, + zoneName, + zoneDescription, + zoneType, + fillColor, + zoneStrokeColor, + fillOpacity, + zoneStrokeWeight, + nodeName, + nodeDescription, + resourceIds, + zoneResourceIds, + richness, + onSaved, + ]); + + const roadReady = kind !== "road" || draftPoints.length >= 2; + const zoneReady = kind !== "zone" || draftPoints.length >= 3; + const nodeReady = + kind !== "node" || + (draftPoints.length === 1 && resourceIds.length > 0 && nodeName.trim().length > 0); + const canSave = kind != null && roadReady && zoneReady && nodeReady && !saving; + + return { + kind, + draftPoints, + canSave, + saving, + editing, + fields: { + roadName, + setRoadName, + roadDescription, + setRoadDescription, + surface, + setSurface, + speedMultiplier, + setSpeedMultiplier, + strokeColor, + setStrokeColor, + strokeWeight, + setStrokeWeight, + strokeStyle, + setStrokeStyle, + labelVisible, + setLabelVisible, + zoneName, + setZoneName, + zoneDescription, + setZoneDescription, + zoneType, + setZoneType, + fillColor, + setFillColor, + zoneStrokeColor, + setZoneStrokeColor, + fillOpacity, + setFillOpacity, + zoneStrokeWeight, + setZoneStrokeWeight, + nodeName, + setNodeName, + nodeDescription, + setNodeDescription, + resourceIds, + toggleResourceId, + zoneResourceIds, + toggleZoneResourceId, + richness, + setRichness, + }, + setKind, + pushPoint, + clearDraft, + save, + removeFeature, + startEditingRoad, + startEditingZone, + startEditingNode, + }; +} + +function useResourceOptions(): { id: number; name: string }[] { + const [options, setOptions] = useState<{ id: number; name: string }[]>([]); + useEffect(() => { + void (async () => { + const response = await fetch("/api/resources?limit=500&depth=0&sort=name", { + cache: "no-store", + }); + if (response.ok) { + const body = (await response.json()) as { docs: { id: number; name: string }[] }; + setOptions(body.docs); + } + })(); + }, []); + return options; +} + +function ResourcePicker({ + label, + selected, + onToggle, + resources, +}: { + label: string; + selected: number[]; + onToggle: (id: number) => void; + resources: { id: number; name: string }[]; +}) { + const [query, setQuery] = useState(""); + const [open, setOpen] = useState(false); + const needle = query.trim().toLowerCase(); + const filtered = needle + ? resources.filter((resource) => resource.name.toLowerCase().includes(needle)) + : resources; + const summary = + selected.length === 0 + ? label + : selected.length <= 2 + ? resources + .filter((resource) => selected.includes(resource.id)) + .map((resource) => resource.name) + .join(", ") || label + : `${selected.length} selected`; + + return ( +
+ + + + + + +
+ setQuery(event.target.value)} + placeholder="Search resources..." + className="h-7 rounded-none text-xs" + /> +
+
+ {filtered.map((resource) => ( + onToggle(resource.id)} + onSelect={(event) => event.preventDefault()} + className="text-xs" + > + {resource.name} + + ))} + {filtered.length === 0 && ( +
No matches
+ )} +
+
+
+
+ ); +} + +export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring; mapId: number }) { + const resources = useResourceOptions(); + const { kind, fields } = authoring; + + return ( +
+
+ + GM Authoring // Feature Editor + + + REF {String(mapId).padStart(3, "0")} + +
+
+
+ {(["road", "zone", "node"] as const).map((target) => ( + + ))} + + {authoring.draftPoints.length} point{authoring.draftPoints.length === 1 ? "" : "s"} + {kind === "road" && authoring.draftPoints.length < 2 && " (need 2+)"} + {kind === "zone" && authoring.draftPoints.length < 3 && " (need 3+)"} + {kind === "node" && authoring.draftPoints.length === 0 && " (click to set)"} + + + +
+ + {authoring.editing && ( +
+ + Editing {authoring.editing.kind}:{" "} + {authoring.editing.label} + {kind === "road" && " (click the map to add points to the route)"} + {kind === "zone" && " (click the map to add vertices to the polygon)"} + {kind === "node" && " (click the map to move the node)"} + + +
+ )} + + {kind === "road" && ( + <> +
+
+ + fields.setRoadName(event.target.value)} + placeholder="Road name" + className="h-8 w-40 text-xs" + /> +
+
+ + +
+
+ + fields.setSpeedMultiplier(event.target.value)} + className="h-8 w-16 text-xs" + /> +
+
+ + fields.setStrokeColor(event.target.value)} + placeholder="#cbd5e1" + className="h-8 w-24 text-xs" + /> +
+
+ + fields.setStrokeWeight(event.target.value)} + placeholder="2" + className="h-8 w-14 text-xs" + /> +
+
+ + +
+
+ +
+
+ +