diff --git a/src/app/(frontend)/intelligence/missions/[id]/page.tsx b/src/app/(frontend)/intelligence/missions/[id]/page.tsx index 81b1342..af1704c 100644 --- a/src/app/(frontend)/intelligence/missions/[id]/page.tsx +++ b/src/app/(frontend)/intelligence/missions/[id]/page.tsx @@ -41,6 +41,8 @@ import { getUserAttendance, isMissionPast, } from "@/lib/attendance"; +import { MissionPlanningMap } from "@/components/frontend/intelligence/missionMap/MissionPlanningMap"; +import { listMissionMarkers } from "@/lib/intelligence/missionMap"; interface MissionDetailPageProps { params: Promise<{ id: string }>; @@ -114,7 +116,7 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro mission.createdBy !== null ? (mission.createdBy as User) : null; - const hostLabel = host ? (host.displayName || host.username) : null; + const hostLabel = host ? host.displayName || host.username : null; const durationHours = Math.floor(mission.classification.estimatedDuration / 60); const durationMins = mission.classification.estimatedDuration % 60; @@ -197,13 +199,11 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro sideOpCampaigns = campaignsRes.docs.map((c) => ({ id: c.id, name: c.name })); sideOpModLists = modListsRes.docs.map((l) => ({ id: l.id, name: l.modlistName })); const currentCampaignId = - typeof mission.classification.campaign === "object" && mission.classification.campaign !== null + typeof mission.classification.campaign === "object" && + mission.classification.campaign !== null ? mission.classification.campaign.id : mission.classification.campaign; - if ( - currentCampaignId != null && - !sideOpCampaigns.some((c) => c.id === currentCampaignId) - ) { + if (currentCampaignId != null && !sideOpCampaigns.some((c) => c.id === currentCampaignId)) { sideOpCampaigns = [ ...sideOpCampaigns, { @@ -261,6 +261,14 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro /> ); + // Planning map: visible to everyone who can see the mission once enabled; + // editors (missions:update or the creator) also get the opt-in control. + const canEditPlan = canControlSideOp; + const planningMapEnabled = mission.classification.planningMapEnabled === true; + const planMarkers = + map && planningMapEnabled ? await listMissionMarkers(payload, mission.id) : []; + const showPlanningMap = map !== null && (planningMapEnabled || canEditPlan); + const providedRoles = mission.missionRoles?.providedRoles ?? []; const signupGate = roleSignupGate(mission); const signupState = await getMissionRoleSignupState(payload, mission.id, currentUser?.id ?? null); @@ -413,6 +421,21 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
+ {showPlanningMap && map && ( + + )} {/* Attendance sits right under the hero on stacked (below-xl) layouts */}
{attendanceCard}
diff --git a/src/app/(frontend)/intelligence/missions/mapActions.ts b/src/app/(frontend)/intelligence/missions/mapActions.ts new file mode 100644 index 0000000..9170f69 --- /dev/null +++ b/src/app/(frontend)/intelligence/missions/mapActions.ts @@ -0,0 +1,169 @@ +"use server"; + +import config from "@payload-config"; +import { getPayload } from "payload"; +import { revalidatePath } from "next/cache"; +import { + canEditMissionPlan, + clearMissionMarkers, + createMissionMarker, + deleteMissionMarker, + findVisibleMission, + listMissionMarkers, + updateMissionMarker, +} from "@/lib/intelligence/missionMap"; +import type { MissionMarker, MissionMarkerData } from "@/lib/arma-markers/missionMarkers"; +import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; +import { emitGameEvent } from "@/utils/event-log/emit"; +import { EventTypes } from "@/utils/event-log/eventTypes"; + +export interface ActionResult { + success: boolean; + error?: string; + data?: T; +} + +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 || !isPayloadUser(user)) { + throw new Error("Unauthorized"); + } + + return { payload, user }; +} + +function failure(error: unknown): { success: false; error: string } { + const message = error instanceof Error ? error.message : "Unknown error"; + return message === "Unauthorized" + ? { success: false, error: "You must be logged in." } + : { success: false, error: message }; +} + +/** Visible mission + editor gate shared by every mutation below. */ +async function editableMission(missionId: number) { + const { payload, user } = await authenticate(); + const mission = await findVisibleMission(payload, user, missionId); + if (!mission) throw new Error("Mission not found."); + if (!(await canEditMissionPlan(payload, user, mission))) { + throw new Error("You do not have permission to edit this mission's planning map."); + } + return { payload, user, mission }; +} + +function markerLabel(marker: MissionMarker): string { + const base = marker.shape === "icon" ? (marker.markerType ?? "icon") : marker.shape; + return marker.text ? `${base} "${marker.text}"` : base; +} + +export async function setMissionPlanningMap(input: { + missionId: number; + enabled: boolean; +}): Promise { + try { + const { payload, user, mission } = await editableMission(input.missionId); + const enabled = Boolean(input.enabled); + await payload.update({ + collection: "missions", + id: mission.id, + data: { classification: { planningMapEnabled: enabled } }, + overrideAccess: true, + }); + await emitGameEvent(payload, { + type: EventTypes.MissionPlanningMapToggle, + message: `${enabled ? "Enabled" : "Disabled"} the planning map for ${mission.name}`, + actor: user.id as number, + targetCollection: "missions", + targetId: mission.id, + data: { enabled }, + }); + revalidatePath(`/intelligence/missions/${mission.id}`); + return { success: true }; + } catch (error) { + return failure(error); + } +} + +/** Re-read markers (any user who can see the mission). */ +export async function getMissionMapMarkers(input: { + missionId: number; +}): Promise> { + try { + const { payload, user } = await authenticate(); + const mission = await findVisibleMission(payload, user, input.missionId); + if (!mission) throw new Error("Mission not found."); + return { success: true, data: await listMissionMarkers(payload, mission.id) }; + } catch (error) { + return failure(error); + } +} + +export async function saveMissionMapMarker(input: { + missionId: number; + id?: number | null; + marker: MissionMarkerData; +}): Promise> { + try { + const { payload, user, mission } = await editableMission(input.missionId); + const isUpdate = typeof input.id === "number"; + const marker = isUpdate + ? await updateMissionMarker(payload, mission, input.id as number, input.marker) + : await createMissionMarker(payload, mission, input.marker, user.id as number); + await emitGameEvent(payload, { + type: isUpdate ? EventTypes.MissionMapMarkerUpdate : EventTypes.MissionMapMarkerCreate, + message: `${isUpdate ? "Updated" : "Placed"} planning marker ${markerLabel(marker)} on ${mission.name}`, + actor: user.id as number, + targetCollection: "missions", + targetId: mission.id, + data: { markerId: marker.id, shape: marker.shape }, + }); + return { success: true, data: marker }; + } catch (error) { + return failure(error); + } +} + +export async function deleteMissionMapMarker(input: { + missionId: number; + id: number; +}): Promise { + try { + const { payload, user, mission } = await editableMission(input.missionId); + await deleteMissionMarker(payload, mission.id, input.id); + await emitGameEvent(payload, { + type: EventTypes.MissionMapMarkerDelete, + message: `Removed a planning marker from ${mission.name}`, + actor: user.id as number, + targetCollection: "missions", + targetId: mission.id, + data: { markerId: input.id }, + }); + return { success: true }; + } catch (error) { + return failure(error); + } +} + +export async function clearMissionMapMarkers(input: { + missionId: number; +}): Promise> { + try { + const { payload, user, mission } = await editableMission(input.missionId); + const removed = await clearMissionMarkers(payload, mission.id); + await emitGameEvent(payload, { + type: EventTypes.MissionMapMarkersClear, + message: `Cleared ${removed} planning marker${removed === 1 ? "" : "s"} from ${mission.name}`, + actor: user.id as number, + targetCollection: "missions", + targetId: mission.id, + data: { removed }, + }); + return { success: true, data: { removed } }; + } catch (error) { + return failure(error); + } +} diff --git a/src/app/(frontend)/styles.css b/src/app/(frontend)/styles.css index 718ee72..4809c71 100644 --- a/src/app/(frontend)/styles.css +++ b/src/app/(frontend)/styles.css @@ -3621,3 +3621,90 @@ .book-view-body [style*="text-align"] { text-align: left !important; } + +/* Mission planning map: Arma 3 marker icons (tinted textures + side text), + edit handles. Leaflet divIcons; sizes come from inline styles. */ +.leaflet-container .arma-marker { + background: transparent; + border: none; +} + +.arma-marker-body { + position: relative; + width: 100%; + height: 100%; +} + +.arma-marker-glyph { + position: absolute; + inset: 0; + display: block; +} + +.arma-marker-shadow { + filter: drop-shadow(1px 1px 0 rgb(0 0 0 / 55%)); +} + +.arma-marker-missing { + border: 1px dashed rgb(255 255 255 / 50%); +} + +.arma-marker-text { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: max-content; + max-width: 320px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif); + font-size: 12px; + font-weight: 600; + line-height: 1; + cursor: pointer; +} + +.arma-marker-halo-light { + text-shadow: + 0 0 2px rgb(255 255 255 / 90%), + 0 0 3px rgb(255 255 255 / 70%); +} + +.arma-marker-halo-dark { + text-shadow: + 0 0 2px rgb(0 0 0 / 90%), + 0 0 3px rgb(0 0 0 / 70%); +} + +.arma-marker-selected .arma-marker-glyph { + outline: 1px dashed #22d3ee; + outline-offset: 3px; +} + +.leaflet-container .mission-map-handle { + background: transparent; + border: none; +} + +.mission-map-handle-dot { + display: block; + width: 100%; + height: 100%; + border: 1.5px solid #22d3ee; + background: rgb(8 10 14 / 80%); + cursor: grab; +} + +.mission-map-handle-move { + border-radius: 9999px; + background: #22d3ee; + cursor: move; +} + +/* Marker palette swatches (outside Leaflet). */ +.arma-swatch-icon { + display: block; + width: 100%; + height: 100%; +} diff --git a/src/app/api/map-features/route.ts b/src/app/api/map-features/route.ts index 1d32731..170049c 100644 --- a/src/app/api/map-features/route.ts +++ b/src/app/api/map-features/route.ts @@ -69,21 +69,30 @@ export async function GET(req: NextRequest) { const worldW = Number(mapDoc.worldSizeWidth ?? 8192); const worldH = Number(mapDoc.worldSizeHeight ?? 8192); + // layers=base: basemap + roads + zones only (mission planning maps). Skips + // the logistics queries (structures/nodes/shipments) entirely. + const baseOnly = req.nextUrl.searchParams.get("layers") === "base"; + const none = { docs: [] as never[] }; + 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, - }), + baseOnly + ? none + : payload.find({ + collection: "game-structures", + where: { map: { equals: mapId } }, + depth: 2, + limit: 1000, + overrideAccess: true, + }), + baseOnly + ? none + : payload.find({ + collection: "resource-nodes", + where: { map: { equals: mapId } }, + depth: 1, + limit: 5000, + overrideAccess: true, + }), payload.find({ collection: "map-roads", where: { map: { equals: mapId } }, @@ -98,16 +107,18 @@ export async function GET(req: NextRequest) { 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, - }), + baseOnly + ? none + : 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[] = []; diff --git a/src/collections/intelligence/MissionMapMarkers.ts b/src/collections/intelligence/MissionMapMarkers.ts new file mode 100644 index 0000000..3f8ee29 --- /dev/null +++ b/src/collections/intelligence/MissionMapMarkers.ts @@ -0,0 +1,111 @@ +import type { CollectionConfig } from "payload"; +import { hasPermission } from "@/utils/access-control/hasPermission"; +import { MISSION_MARKER_SHAPES } from "@/lib/arma-markers/missionMarkers"; + +/** + * Pre-planning markers on a mission's planning map (Arma 3 marker model: + * icon / rectangle / ellipse / polyline + CfgMarkers type, CfgMarkerColors + * color, CfgMarkerBrushes brush). Markers belong to exactly one mission and + * never appear on the shared world map. + * + * All frontend reads/writes flow through `src/lib/intelligence/missionMap.ts` + * (overrideAccess) behind mission-visibility and mission-editor checks, so the + * collection-level access here only governs REST/admin-panel use. + */ +export const MissionMapMarkers: CollectionConfig = { + slug: "mission-map-markers", + admin: { + group: "Intelligence", + useAsTitle: "text", + defaultColumns: ["mission", "shape", "markerType", "color", "text", "createdBy"], + description: + "Planning-map markers for individual missions. Normally edited on the mission page, not here.", + }, + access: { + read: async ({ req }) => hasPermission(req.payload, req.user, "mission-map-markers:read"), + create: async ({ req }) => hasPermission(req.payload, req.user, "mission-map-markers:create"), + update: async ({ req }) => hasPermission(req.payload, req.user, "mission-map-markers:update"), + delete: async ({ req }) => hasPermission(req.payload, req.user, "mission-map-markers:delete"), + }, + fields: [ + { + name: "mission", + type: "relationship", + relationTo: "missions", + required: true, + index: true, + }, + { + name: "shape", + type: "select", + required: true, + defaultValue: "icon", + options: MISSION_MARKER_SHAPES.map((value) => ({ label: value, value })), + }, + { + name: "markerType", + type: "text", + admin: { description: "CfgMarkers class name (icon markers), e.g. b_inf, mil_objective." }, + }, + { + name: "color", + type: "text", + required: true, + defaultValue: "Default", + admin: { description: "CfgMarkerColors class name, e.g. ColorRed, ColorWEST." }, + }, + { + name: "brush", + type: "text", + admin: { description: "CfgMarkerBrushes class name (area markers), e.g. Solid, Grid." }, + }, + { + name: "text", + type: "text", + maxLength: 200, + }, + { + name: "position", + type: "json", + required: true, + admin: { + description: "[x, y] in map meters (origin top-left, y south). First vertex for lines.", + }, + }, + { + name: "sizeA", + type: "number", + defaultValue: 1, + admin: { description: "Area half-width in meters, or icon scale." }, + }, + { + name: "sizeB", + type: "number", + defaultValue: 1, + admin: { description: "Area half-height in meters, or icon scale." }, + }, + { + name: "direction", + type: "number", + defaultValue: 0, + admin: { description: "Degrees clockwise from north." }, + }, + { + name: "alpha", + type: "number", + defaultValue: 1, + min: 0, + max: 1, + }, + { + name: "points", + type: "json", + admin: { description: "Line vertices [[x, y], ...] in map meters (line markers only)." }, + }, + { + name: "createdBy", + type: "relationship", + relationTo: "users", + }, + ], +}; diff --git a/src/collections/intelligence/Missions.ts b/src/collections/intelligence/Missions.ts index 4ff34cb..1b65cc6 100644 --- a/src/collections/intelligence/Missions.ts +++ b/src/collections/intelligence/Missions.ts @@ -89,6 +89,29 @@ export const Missions: CollectionConfig = { useAsTitle: "name", }, hooks: { + beforeDelete: [ + // Planning-map markers belong to exactly one mission. Their FK is + // NOT NULL (required), so the default ON DELETE SET NULL would make the + // mission delete fail; remove them first, inside the same request. + async ({ id, req }) => { + const markers = await req.payload.find({ + collection: "mission-map-markers", + where: { mission: { equals: id } }, + depth: 0, + pagination: false, + overrideAccess: true, + req, + }); + for (const marker of markers.docs) { + await req.payload.delete({ + collection: "mission-map-markers", + id: marker.id, + overrideAccess: true, + req, + }); + } + }, + ], beforeChange: [ // Loop-safe by construction: this mutates the incoming data of the very // operation it runs in, so it never triggers a second write. It only @@ -339,6 +362,16 @@ export const Missions: CollectionConfig = { relationTo: "maps", required: true, }, + { + name: "planningMapEnabled", + type: "checkbox", + label: "Planning Map", + defaultValue: false, + admin: { + description: + "Show this mission's map on the mission page so mission editors can place Arma 3 planning markers that only appear for this mission.", + }, + }, { name: "missionType", type: "select", diff --git a/src/components/frontend/intelligence/missionMap/MarkerToolPanel.tsx b/src/components/frontend/intelligence/missionMap/MarkerToolPanel.tsx new file mode 100644 index 0000000..be7eb55 --- /dev/null +++ b/src/components/frontend/intelligence/missionMap/MarkerToolPanel.tsx @@ -0,0 +1,398 @@ +"use client"; + +import { + CircleIcon, + MousePointer2Icon, + MapPinIcon, + SplineIcon, + SquareIcon, + Trash2Icon, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Slider } from "@/components/ui/slider"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + ARMA_MARKER_BRUSHES, + ARMA_MARKER_COLORS, + markerTypeOf, + resolveMarkerRgba, + rgbaToHex, +} from "@/lib/arma-markers"; +import { + MAX_AREA_SIZE, + MAX_ICON_SCALE, + MAX_MARKER_TEXT, + MIN_AREA_SIZE, + MIN_ICON_SCALE, + type MissionMarker, +} from "@/lib/arma-markers/missionMarkers"; +import { cn } from "@/lib/utils"; +import { MarkerTypePicker } from "./MarkerTypePicker"; +import type { MarkerStyle, PlanTool } from "./types"; + +const TOOLS: { value: PlanTool; label: string; icon: typeof MapPinIcon; hint: string }[] = [ + { + value: "select", + label: "Select", + icon: MousePointer2Icon, + hint: "Click a marker to edit it. Drag icons and handles to move or resize. Delete removes.", + }, + { value: "icon", label: "Icon", icon: MapPinIcon, hint: "Click the map to place the icon." }, + { + value: "rectangle", + label: "Rectangle", + icon: SquareIcon, + hint: "Click and drag corner to corner. A plain click places a default-size area.", + }, + { + value: "ellipse", + label: "Ellipse", + icon: CircleIcon, + hint: "Click and drag corner to corner. A plain click places a default-size area.", + }, + { + value: "polyline", + label: "Line", + icon: SplineIcon, + hint: "Click to add points. Double-click or Enter finishes, Backspace undoes a point, Escape cancels.", + }, +]; + +const SHAPE_LABELS: Record = { + icon: "Icon", + rectangle: "Rectangle", + ellipse: "Ellipse", + polyline: "Line", +}; + +function clampNumber(value: string, min: number, max: number, fallback: number): number { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + return Math.min(max, Math.max(min, parsed)); +} + +function FieldLabel({ children }: { children: React.ReactNode }) { + return ( + + ); +} + +export interface MarkerToolPanelProps { + tool: PlanTool; + onToolChange: (tool: PlanTool) => void; + style: MarkerStyle; + onStyleChange: (style: MarkerStyle) => void; + selected: MissionMarker | null; + onSelectedChange: (marker: MissionMarker) => void; + onDeleteSelected: () => void; +} + +/** + * Tool picker + property editor. With a marker selected the fields edit that + * marker; otherwise they set the defaults for the next marker placed. + */ +export function MarkerToolPanel({ + tool, + onToolChange, + style, + onStyleChange, + selected, + onSelectedChange, + onDeleteSelected, +}: MarkerToolPanelProps) { + const toolInfo = TOOLS.find((entry) => entry.value === tool) ?? TOOLS[0]; + + // What the property fields currently describe. + const shape: MissionMarker["shape"] | null = selected + ? selected.shape + : tool === "select" + ? null + : tool; + const isIcon = shape === "icon"; + const isArea = shape === "rectangle" || shape === "ellipse"; + const isLine = shape === "polyline"; + + const color = selected?.color ?? style.color; + const markerType = selected?.markerType ?? style.markerType; + const text = selected?.text ?? style.text; + const alpha = selected?.alpha ?? style.alpha; + const direction = selected?.direction ?? style.direction; + const brush = selected?.brush ?? style.brush; + const iconScale = selected?.shape === "icon" ? selected.size[0] : style.iconScale; + + const apply = (patch: Partial & { size?: [number, number] }) => { + if (selected) { + onSelectedChange({ + ...selected, + ...(patch.color !== undefined && { color: patch.color }), + ...(patch.markerType !== undefined && + selected.shape === "icon" && { + markerType: patch.markerType, + }), + ...(patch.text !== undefined && { text: patch.text }), + ...(patch.alpha !== undefined && { alpha: patch.alpha }), + ...(patch.direction !== undefined && { direction: patch.direction }), + ...(patch.brush !== undefined && { brush: patch.brush }), + ...(patch.iconScale !== undefined && + selected.shape === "icon" && { size: [patch.iconScale, patch.iconScale] }), + ...(patch.size !== undefined && { size: patch.size }), + }); + return; + } + const { size: _size, ...rest } = patch; + onStyleChange({ ...style, ...rest }); + }; + + return ( +
+
+ Tool + value && onToolChange(value as PlanTool)} + className="w-full" + > + {TOOLS.map((entry) => ( + + + + ))} + +

{toolInfo.hint}

+
+ +
+ + {selected + ? `Selected // ${SHAPE_LABELS[selected.shape]}` + : shape + ? `New ${SHAPE_LABELS[shape].toLowerCase()}` + : "No selection"} + + {selected && ( + + )} +
+ + {!shape && ( +

+ Pick a placement tool, or select a marker on the map to edit it. +

+ )} + + {isIcon && ( +
+ Type + apply({ markerType: value })} + /> +
+ )} + + {shape && ( +
+ Color +
+ {ARMA_MARKER_COLORS.map((entry) => { + const swatch = rgbaToHex( + resolveMarkerRgba(entry.className, isIcon ? markerType : null), + ); + const active = entry.className.toLowerCase() === color.toLowerCase(); + return ( + + ); + })} +
+ + {ARMA_MARKER_COLORS.find( + (entry) => entry.className.toLowerCase() === color.toLowerCase(), + )?.name ?? color} + {isIcon && color === "Default" && markerTypeOf(markerType) + ? " (marker's own color)" + : ""} + +
+ )} + + {isArea && ( +
+ Brush + +
+ )} + + {shape && ( +
+ Text + apply({ text: event.target.value })} + className="h-8" + /> +
+ )} + + {isIcon && ( +
+
+ Scale + + {iconScale.toFixed(2)}x + +
+ apply({ iconScale: value })} + /> +
+ )} + + {isArea && selected && ( +
+
+ Width (m) + + apply({ + size: [ + clampNumber(event.target.value, MIN_AREA_SIZE * 2, MAX_AREA_SIZE * 2, 2) / 2, + selected.size[1], + ], + }) + } + className="h-8" + /> +
+
+ Height (m) + + apply({ + size: [ + selected.size[0], + clampNumber(event.target.value, MIN_AREA_SIZE * 2, MAX_AREA_SIZE * 2, 2) / 2, + ], + }) + } + className="h-8" + /> +
+
+ )} + + {(isIcon || isArea) && ( +
+
+ Direction + + {Math.round(direction)}° + +
+ apply({ direction: value })} + /> +
+ )} + + {shape && ( +
+
+ Opacity + + {Math.round(alpha * 100)}% + +
+ apply({ alpha: value })} + /> +
+ )} + + {isLine && !selected && ( +

+ Lines take the color, text and opacity above. +

+ )} +
+ ); +} diff --git a/src/components/frontend/intelligence/missionMap/MarkerTypePicker.tsx b/src/components/frontend/intelligence/missionMap/MarkerTypePicker.tsx new file mode 100644 index 0000000..74b1def --- /dev/null +++ b/src/components/frontend/intelligence/missionMap/MarkerTypePicker.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { ChevronDownIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + markerTypeOf, + markerTypesByCategory, + resolveMarkerRgba, + rgbaToHex, +} from "@/lib/arma-markers"; +import { cn } from "@/lib/utils"; +import { armaIconReactStyle } from "./iconStyle"; + +function MarkerGlyph({ + typeClass, + colorClass, + className, +}: { + typeClass: string; + colorClass: string; + className?: string; +}) { + const type = markerTypeOf(typeClass); + if (!type) + return ; + const hex = rgbaToHex(resolveMarkerRgba(colorClass, typeClass)); + return ( + + ); +} + +export { MarkerGlyph }; + +export function MarkerTypePicker({ + value, + color, + onChange, +}: { + value: string; + color: string; + onChange: (className: string) => void; +}) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const groups = useMemo(() => markerTypesByCategory(), []); + const selected = markerTypeOf(value); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return groups; + return groups + .map((group) => ({ + category: group.category, + types: group.types.filter( + (type) => + type.name.toLowerCase().includes(q) || + type.className.toLowerCase().includes(q) || + group.category.name.toLowerCase().includes(q), + ), + })) + .filter((group) => group.types.length > 0); + }, [groups, query]); + + return ( + { + setOpen(next); + if (!next) setQuery(""); + }} + > + + + + +
+ setQuery(event.target.value)} + className="h-8" + /> +
+
+ {filtered.length === 0 && ( +

No markers match.

+ )} + {filtered.map((group) => ( +
+
+ {group.category.name} +
+
+ {group.types.map((type) => ( + + ))} +
+
+ ))} +
+
+
+ ); +} diff --git a/src/components/frontend/intelligence/missionMap/MissionMapCanvas.tsx b/src/components/frontend/intelligence/missionMap/MissionMapCanvas.tsx new file mode 100644 index 0000000..0bb73be --- /dev/null +++ b/src/components/frontend/intelligence/missionMap/MissionMapCanvas.tsx @@ -0,0 +1,765 @@ +"use client"; + +import "leaflet/dist/leaflet.css"; +import L from "leaflet"; +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + ImageOverlay, + MapContainer, + Marker, + Polygon, + Polyline, + Rectangle, + TileLayer, + Tooltip, + useMap, + useMapEvents, +} from "react-leaflet"; +import type { MapPoint } from "@/lib/map/points"; +import { dashArrayFor, metersPerPx, splinePolyline } from "@/lib/map/style"; +import type { MapFeaturesResponse } from "@/lib/map/types"; +import { markerBrushOf, resolveMarkerRgba, rgbaToHex } from "@/lib/arma-markers"; +import { + areaOutline, + hatchAngles, + hatchSegments, + type MissionMarker, + type MissionMarkerData, +} from "@/lib/arma-markers/missionMarkers"; +import { markerDivIcon } from "./markerIcon"; +import type { MarkerStyle, PlanTool } from "./types"; + +export interface MissionMapCanvasProps { + mapId: number; + markers: MissionMarker[]; + selectedId: number | null; + tool: PlanTool; + style: MarkerStyle; + canEdit: boolean; + showRoads: boolean; + showZones: boolean; + onSelect: (id: number | null) => void; + onCreate: (data: MissionMarkerData) => void; + /** persist=false for live drag previews, true on drag end. */ + onChange: (marker: MissionMarker, persist: boolean) => void; + onCursor: (point: MapPoint | null) => void; + onLoaded: (info: { worldWidth: number; worldHeight: number; hasBasemap: boolean }) => void; +} + +const toLatLng = (p: MapPoint): [number, number] => [-p[1], p[0]]; +const fromLatLng = (latlng: L.LatLng): MapPoint => [ + Math.round(latlng.lng * 100) / 100, + Math.round(-latlng.lat * 100) / 100, +]; + +const ZONE_PATH_OPTIONS: Record = { + water: { color: "#38bdf8", fillColor: "#38bdf8", fillOpacity: 0.2, weight: 1 }, + land: { color: "#4ade80", fillColor: "#4ade80", fillOpacity: 0.06, weight: 1 }, + territory: { color: "#fbbf24", fillColor: "#fbbf24", fillOpacity: 0.08, weight: 1 }, + border: { color: "#a78bfa", fillColor: "#a78bfa", fillOpacity: 0.05, weight: 1 }, + other: { color: "#94a3b8", fillColor: "#94a3b8", fillOpacity: 0.06, weight: 1 }, +}; + +const ROAD_COLORS: Record = { + paved: "#cbd5e1", + dirt: "#d97706", + trail: "#92713a", +}; + +const BASEMAP_FALLBACK: L.PathOptions = { + color: "#334155", + fillColor: "#0f172a", + fillOpacity: 1, + weight: 1, +}; + +const DRAFT_PATH: L.PathOptions = { color: "#22d3ee", weight: 2, dashArray: "6 4", fill: false }; +const SELECTED_OUTLINE: L.PathOptions = { + color: "#22d3ee", + weight: 1.5, + dashArray: "4 4", + fill: false, + interactive: false, +}; + +/** Arma draws hatch brushes in screen space; ~9 px between lines. */ +const HATCH_SPACING_PX = 9; +/** Click-drag shorter than this (screen px) places a default-size area. */ +const MIN_DRAG_PX = 6; +const DEFAULT_AREA_PX = 40; + +const handleIcon = L.divIcon({ + className: "mission-map-handle", + html: '', + iconSize: [12, 12], + iconAnchor: [6, 6], +}); + +const moveHandleIcon = L.divIcon({ + className: "mission-map-handle", + html: '', + iconSize: [14, 14], + iconAnchor: [7, 7], +}); + +function DefaultView({ center, zoom }: { center: [number, number]; zoom: number }) { + const map = useMap(); + const applied = useRef(false); + useEffect(() => { + if (applied.current) return; + applied.current = true; + map.setView(center, Math.max(zoom, map.getMinZoom()), { animate: false }); + }, [map, center, zoom]); + return null; +} + +/** Tracks the zoom level so hatch density can stay constant on screen. */ +function useZoom(): number { + const map = useMap(); + const [zoom, setZoom] = useState(() => map.getZoom()); + useMapEvents({ zoomend: () => setZoom(map.getZoom()) }); + return zoom; +} + +interface InteractionProps { + tool: PlanTool; + canEdit: boolean; + style: MarkerStyle; + worldSize: number; + layerClickAt: React.RefObject; + onSelect: (id: number | null) => void; + onCreate: (data: MissionMarkerData) => void; + onCursor: (point: MapPoint | null) => void; +} + +/** + * Tool interactions: click-to-place icons, click-drag areas (corner to + * corner), click-by-click lines (double-click or Enter finishes, Backspace + * removes the last point, Escape cancels), and click-empty-map to deselect. + */ +function PlanInteractions({ + tool, + canEdit, + style, + worldSize, + layerClickAt, + onSelect, + onCreate, + onCursor, +}: InteractionProps) { + const map = useMap(); + const [cursor, setCursor] = useState(null); + const [dragStart, setDragStartState] = useState(null); + const dragStartRef = useRef(null); + const setDragStart = useCallback((next: MapPoint | null) => { + dragStartRef.current = next; + setDragStartState(next); + }, []); + const [linePoints, setLinePointsState] = useState([]); + // Click / dblclick fire back to back faster than React re-renders, so the + // handlers read and write the vertices through a ref (state only drives + // the draft preview). + const linePointsRef = useRef([]); + const setLinePoints = useCallback((next: MapPoint[]) => { + linePointsRef.current = next; + setLinePointsState(next); + }, []); + const areaTool = canEdit && (tool === "rectangle" || tool === "ellipse"); + const lineTool = canEdit && tool === "polyline"; + + // Area tools draw with click-drag, so the map must not pan meanwhile; the + // line tool finishes on double-click, so that must not zoom. + useEffect(() => { + if (areaTool) map.dragging.disable(); + else map.dragging.enable(); + if (lineTool) map.doubleClickZoom.disable(); + else map.doubleClickZoom.enable(); + return () => { + map.dragging.enable(); + map.doubleClickZoom.enable(); + }; + }, [map, areaTool, lineTool]); + + useEffect(() => { + setLinePoints([]); + setDragStart(null); + }, [tool, setLinePoints, setDragStart]); + + const finishLine = useCallback( + (points: MapPoint[]) => { + if (points.length >= 2) { + onCreate({ + shape: "polyline", + markerType: null, + color: style.color, + brush: null, + text: style.text, + position: points[0], + size: [1, 1], + direction: 0, + alpha: style.alpha, + points, + }); + } + setLinePoints([]); + }, + [onCreate, style, setLinePoints], + ); + + useEffect(() => { + if (!lineTool) return; + const onKey = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null; + if (target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return; + const points = linePointsRef.current; + if (event.key === "Enter") { + event.preventDefault(); + finishLine(points); + } else if (event.key === "Escape") { + setLinePoints([]); + } else if (event.key === "Backspace" && points.length > 0) { + event.preventDefault(); + event.stopPropagation(); + setLinePoints(points.slice(0, -1)); + } + }; + window.addEventListener("keydown", onKey, true); + return () => window.removeEventListener("keydown", onKey, true); + }, [lineTool, finishLine, setLinePoints]); + + const pxToMeters = useCallback( + (px: number) => px * metersPerPx(map.getZoom(), worldSize), + [map, worldSize], + ); + + useMapEvents({ + mousemove: (event) => { + const point = fromLatLng(event.latlng); + setCursor(point); + onCursor(point); + }, + mouseout: () => { + setCursor(null); + onCursor(null); + }, + mousedown: (event) => { + if (!areaTool || event.originalEvent.button !== 0) return; + setDragStart(fromLatLng(event.latlng)); + }, + mouseup: (event) => { + const dragStart = dragStartRef.current; + if (!areaTool || !dragStart) return; + const end = fromLatLng(event.latlng); + const startPx = map.latLngToContainerPoint(toLatLng(dragStart)); + const endPx = map.latLngToContainerPoint(event.latlng); + const dragged = startPx.distanceTo(endPx) >= MIN_DRAG_PX; + const fallback = Math.max(5, Math.round(pxToMeters(DEFAULT_AREA_PX))); + const center: MapPoint = dragged + ? [(dragStart[0] + end[0]) / 2, (dragStart[1] + end[1]) / 2] + : dragStart; + const size: [number, number] = dragged + ? [ + Math.max(1, Math.abs(end[0] - dragStart[0]) / 2), + Math.max(1, Math.abs(end[1] - dragStart[1]) / 2), + ] + : [fallback, fallback]; + setDragStart(null); + onCreate({ + shape: tool as "rectangle" | "ellipse", + markerType: null, + color: style.color, + brush: style.brush, + text: style.text, + position: center, + size, + direction: 0, + alpha: style.alpha, + points: [], + }); + }, + click: (event) => { + // Layer clicks (markers/areas/lines) bubble here too; ignore those. + if (Date.now() - (layerClickAt.current ?? 0) < 80) return; + const point = fromLatLng(event.latlng); + if (canEdit && tool === "icon") { + onCreate({ + shape: "icon", + markerType: style.markerType, + color: style.color, + brush: null, + text: style.text, + position: point, + size: [style.iconScale, style.iconScale], + direction: style.direction, + alpha: style.alpha, + points: [], + }); + return; + } + if (lineTool) { + // The second click of a double-click lands on the previous vertex; + // skip it so finishing a line never leaves a zero-length segment. + const current = linePointsRef.current; + const last = current[current.length - 1]; + if ( + last && + map.latLngToContainerPoint(toLatLng(last)).distanceTo(event.containerPoint) < 4 + ) { + return; + } + setLinePoints([...current, point]); + return; + } + if (!areaTool) onSelect(null); + }, + dblclick: () => { + if (lineTool) finishLine(linePointsRef.current); + }, + }); + + const areaDraft = useMemo(() => { + if (!dragStart || !cursor || !areaTool) return null; + const center: MapPoint = [(dragStart[0] + cursor[0]) / 2, (dragStart[1] + cursor[1]) / 2]; + const size: [number, number] = [ + Math.max(1, Math.abs(cursor[0] - dragStart[0]) / 2), + Math.max(1, Math.abs(cursor[1] - dragStart[1]) / 2), + ]; + return areaOutline(tool as "rectangle" | "ellipse", center, size, 0).map(toLatLng); + }, [dragStart, cursor, areaTool, tool]); + + const lineDraft = useMemo(() => { + if (!lineTool || linePoints.length === 0) return null; + const points = cursor ? [...linePoints, cursor] : linePoints; + return points.map(toLatLng); + }, [lineTool, linePoints, cursor]); + + return ( + <> + {areaDraft && } + {lineDraft && } + {lineTool && + linePoints.map((point, index) => ( + + ))} + + ); +} + +interface AreaProps { + marker: MissionMarker; + selected: boolean; + worldSize: number; + onLayerClick: (id: number) => void; +} + +function AreaMarker({ marker, selected, worldSize, onLayerClick }: AreaProps) { + const zoom = useZoom(); + const shape = marker.shape as "rectangle" | "ellipse"; + const hex = rgbaToHex(resolveMarkerRgba(marker.color)); + const brush = markerBrushOf(marker.brush); + const pattern = brush?.pattern ?? "solid"; + + const ring = useMemo( + () => areaOutline(shape, marker.position, marker.size, marker.direction), + [shape, marker.position, marker.size, marker.direction], + ); + const positions = useMemo(() => ring.map(toLatLng), [ring]); + + const spacing = HATCH_SPACING_PX * metersPerPx(zoom, worldSize); + const hatches = useMemo(() => { + const angles = hatchAngles(pattern); + return angles.flatMap((angle) => + hatchSegments(ring, angle + marker.direction, spacing).map((segment) => + segment.map(toLatLng), + ), + ); + }, [pattern, ring, marker.direction, spacing]); + + const solid = pattern === "solid"; + const fullSolid = brush?.className.toLowerCase() === "solidfull"; + const pathOptions = useMemo( + () => ({ + color: hex, + weight: brush?.drawBorder ? 2 : 0, + opacity: marker.alpha, + stroke: Boolean(brush?.drawBorder), + fill: true, + fillColor: hex, + // Hatched/border brushes keep a near-invisible fill so the whole area + // stays clickable. + fillOpacity: solid ? (fullSolid ? 0.85 : 0.45) * marker.alpha : 0.01, + }), + [hex, brush?.drawBorder, marker.alpha, solid, fullSolid], + ); + const hatchOptions = useMemo( + () => ({ color: hex, weight: 1.25, opacity: marker.alpha, interactive: false }), + [hex, marker.alpha], + ); + + return ( + <> + onLayerClick(marker.id) }} + > + {marker.text && ( + + {marker.text} + + )} + + {hatches.length > 0 && } + {selected && } + + ); +} + +/** Inverse of the marker rotation: map offset to local (east, north). */ +function localExtent(center: MapPoint, point: MapPoint, directionDeg: number): [number, number] { + const east = point[0] - center[0]; + const north = center[1] - point[1]; + const rad = (directionDeg * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + return [Math.abs(east * cos - north * sin), Math.abs(east * sin + north * cos)]; +} + +function AreaHandles({ + marker, + onChange, +}: { + marker: MissionMarker; + onChange: (marker: MissionMarker, persist: boolean) => void; +}) { + // Resize grip sits on the rotated north-east corner (for ellipses: the + // corner of the bounding box, which is where a rectangle grip would be). + const corner = areaOutline("rectangle", marker.position, marker.size, marker.direction)[1]; + const resized = (latlng: L.LatLng): MissionMarker => { + const [a, b] = localExtent(marker.position, fromLatLng(latlng), marker.direction); + return { ...marker, size: [Math.max(1, Math.round(a)), Math.max(1, Math.round(b))] }; + }; + return ( + <> + + onChange( + { ...marker, position: fromLatLng((event.target as L.Marker).getLatLng()) }, + false, + ), + dragend: (event) => + onChange( + { ...marker, position: fromLatLng((event.target as L.Marker).getLatLng()) }, + true, + ), + }} + /> + onChange(resized((event.target as L.Marker).getLatLng()), false), + dragend: (event) => onChange(resized((event.target as L.Marker).getLatLng()), true), + }} + /> + + ); +} + +function LineHandles({ + marker, + onChange, +}: { + marker: MissionMarker; + onChange: (marker: MissionMarker, persist: boolean) => void; +}) { + const moved = (index: number, latlng: L.LatLng): MissionMarker => { + const points = marker.points.map((point, i) => (i === index ? fromLatLng(latlng) : point)); + return { ...marker, points, position: points[0] }; + }; + return ( + <> + {marker.points.map((point, index) => ( + onChange(moved(index, (event.target as L.Marker).getLatLng()), false), + dragend: (event) => + onChange(moved(index, (event.target as L.Marker).getLatLng()), true), + contextmenu: () => { + if (marker.points.length <= 2) return; + const points = marker.points.filter((_, i) => i !== index); + onChange({ ...marker, points, position: points[0] }, true); + }, + }} + /> + ))} + + ); +} + +export function MissionMapCanvas({ + mapId, + markers, + selectedId, + tool, + style, + canEdit, + showRoads, + showZones, + onSelect, + onCreate, + onChange, + onCursor, + onLoaded, +}: MissionMapCanvasProps) { + const [features, setFeatures] = useState(null); + const [error, setError] = useState(null); + const layerClickAt = useRef(0); + + useEffect(() => { + let cancelled = false; + fetch(`/api/map-features?mapId=${mapId}&layers=base`, { cache: "no-store" }) + .then(async (response) => { + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { error?: string } | null; + throw new Error(body?.error ?? `Request failed (${response.status})`); + } + return (await response.json()) as MapFeaturesResponse; + }) + .then((body) => { + if (cancelled) return; + setFeatures(body); + onLoaded({ + worldWidth: body.map.worldSizeWidth, + worldHeight: body.map.worldSizeHeight, + hasBasemap: Boolean( + (body.map.basemapMode === "image" && body.map.basemapImageUrl) || + (body.map.basemapMode === "tiles" && body.map.tileUrlTemplate), + ), + }); + }) + .catch((e: unknown) => { + if (!cancelled) setError(e instanceof Error ? e.message : "Failed to load the map."); + }); + return () => { + cancelled = true; + }; + }, [mapId, onLoaded]); + + const mapConfig = features?.map ?? null; + const worldW = mapConfig?.worldSizeWidth ?? 8192; + const worldH = mapConfig?.worldSizeHeight ?? 8192; + const worldSize = Math.max(worldW, worldH); + + const mapCrs = useMemo( + () => + L.extend({}, L.CRS.Simple, { + transformation: new L.Transformation(256 / worldSize, 0, -256 / worldSize, 0), + }) as L.CRS, + [worldSize], + ); + const worldBounds = useMemo( + () => L.latLngBounds(L.latLng(-worldH, 0), L.latLng(0, worldW)), + [worldW, worldH], + ); + const center = useMemo<[number, number]>( + () => + mapConfig + ? [-mapConfig.defaultCenter[1], mapConfig.defaultCenter[0]] + : [-worldH / 2, worldW / 2], + [mapConfig, worldW, worldH], + ); + + const roadLines = useMemo( + () => + (features?.roads ?? []).map((road) => ({ + road, + positions: splinePolyline(road.points).map(toLatLng), + options: { + color: road.strokeColor || ROAD_COLORS[road.surface] || "#cbd5e1", + weight: road.strokeWeight ?? 2, + dashArray: dashArrayFor(road.strokeStyle), + opacity: 0.85, + interactive: false, + } satisfies L.PathOptions, + })), + [features], + ); + const zonePolys = useMemo( + () => + (features?.zones ?? []).map((zone) => { + const defaults = ZONE_PATH_OPTIONS[zone.type] ?? ZONE_PATH_OPTIONS.other; + return { + zone, + positions: zone.points.map(toLatLng), + options: { + color: zone.strokeColor || defaults.color, + fillColor: zone.fillColor || defaults.fillColor, + fillOpacity: zone.fillOpacity ?? defaults.fillOpacity, + weight: zone.strokeWeight ?? 1, + interactive: false, + } satisfies L.PathOptions, + }; + }), + [features], + ); + + const handleLayerClick = useCallback( + (id: number) => { + layerClickAt.current = Date.now(); + // Placement tools keep placing on top of existing markers. + if (tool === "select" || !canEdit) onSelect(id); + }, + [tool, canEdit, onSelect], + ); + + const selected = markers.find((marker) => marker.id === selectedId) ?? null; + const editingSelected = canEdit && tool === "select" && selected; + + if (error) { + return ( +
{error}
+ ); + } + + return ( + + {mapConfig && } + {mapConfig?.basemapMode === "tiles" && mapConfig.tileUrlTemplate && ( + + )} + {mapConfig?.basemapMode === "image" && mapConfig.basemapImageUrl && ( + + )} + {mapConfig && + !( + (mapConfig.basemapMode === "image" && mapConfig.basemapImageUrl) || + (mapConfig.basemapMode === "tiles" && mapConfig.tileUrlTemplate) + ) && } + + {showZones && + zonePolys.map(({ zone, positions, options }) => ( + + ))} + {showRoads && + roadLines.map(({ road, positions, options }) => ( + + ))} + + {markers.map((marker) => { + if (marker.shape === "rectangle" || marker.shape === "ellipse") { + return ( + + ); + } + if (marker.shape === "polyline") { + const hex = rgbaToHex(resolveMarkerRgba(marker.color)); + return ( + + handleLayerClick(marker.id) }} + > + {marker.text && ( + + {marker.text} + + )} + + {marker.id === selectedId && ( + + )} + + ); + } + return null; + })} + + {/* Icons render after areas/lines so they stay on top (marker pane). */} + {markers + .filter((marker) => marker.shape === "icon") + .map((marker) => ( + handleLayerClick(marker.id), + dragstart: () => onSelect(marker.id), + dragend: (event) => + onChange( + { ...marker, position: fromLatLng((event.target as L.Marker).getLatLng()) }, + true, + ), + }} + /> + ))} + + {editingSelected && (selected.shape === "rectangle" || selected.shape === "ellipse") && ( + + )} + {editingSelected && selected.shape === "polyline" && ( + + )} + + + + ); +} diff --git a/src/components/frontend/intelligence/missionMap/MissionMapLoader.tsx b/src/components/frontend/intelligence/missionMap/MissionMapLoader.tsx new file mode 100644 index 0000000..598e337 --- /dev/null +++ b/src/components/frontend/intelligence/missionMap/MissionMapLoader.tsx @@ -0,0 +1,20 @@ +"use client"; + +import dynamic from "next/dynamic"; +import type { MissionMapCanvasProps } from "./MissionMapCanvas"; + +const MissionMapCanvas = dynamic( + () => import("./MissionMapCanvas").then((mod) => mod.MissionMapCanvas), + { + ssr: false, + loading: () => ( +
+ Loading map... +
+ ), + }, +); + +export function MissionMapLoader(props: MissionMapCanvasProps) { + return ; +} diff --git a/src/components/frontend/intelligence/missionMap/MissionPlanningMap.tsx b/src/components/frontend/intelligence/missionMap/MissionPlanningMap.tsx new file mode 100644 index 0000000..4e71b47 --- /dev/null +++ b/src/components/frontend/intelligence/missionMap/MissionPlanningMap.tsx @@ -0,0 +1,493 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { ClipboardCopyIcon, EraserIcon, MapIcon, PowerOffIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { + DEFAULT_MARKER_BRUSH, + DEFAULT_MARKER_COLOR, + DEFAULT_MARKER_TYPE, +} from "@/lib/arma-markers"; +import { + armaGridRef, + markersToSqf, + normalizeMarkerInput, + type MissionMarker, + type MissionMarkerData, +} from "@/lib/arma-markers/missionMarkers"; +import type { MapPoint } from "@/lib/map/points"; +import { + clearMissionMapMarkers, + deleteMissionMapMarker, + getMissionMapMarkers, + saveMissionMapMarker, + setMissionPlanningMap, +} from "@/app/(frontend)/intelligence/missions/mapActions"; +import { MissionMapLoader } from "./MissionMapLoader"; +import { MarkerToolPanel } from "./MarkerToolPanel"; +import type { MarkerStyle, PlanTool } from "./types"; + +export interface MissionPlanningMapProps { + missionId: number; + missionLabel: string; + enabled: boolean; + canEdit: boolean; + map: { id: number; name: string; worldWidth: number; worldHeight: number }; + initialMarkers: MissionMarker[]; +} + +const SAVE_DEBOUNCE_MS = 400; +const POLL_MS = 30_000; + +const DEFAULT_STYLE: MarkerStyle = { + markerType: DEFAULT_MARKER_TYPE, + color: DEFAULT_MARKER_COLOR, + brush: DEFAULT_MARKER_BRUSH, + text: "", + iconScale: 1, + direction: 0, + alpha: 1, +}; + +function toData(marker: MissionMarker): MissionMarkerData { + const { id: _id, ...data } = marker; + return data; +} + +function Corners() { + const base = + "pointer-events-none absolute z-[999] size-3 border-white/25 transition-colors group-hover:border-white/60"; + return ( + <> + + + + + + ); +} + +function ReadoutCell({ value, label }: { value: string; label: string }) { + return ( +
+ {value} + {label} +
+ ); +} + +/** + * Mission planning map (tactical dossier surface): the mission's world map + * with an Arma 3 marker layer that belongs to this mission only. Everyone who + * can see the mission sees the plan; mission editors place and edit markers. + */ +export function MissionPlanningMap({ + missionId, + missionLabel, + enabled, + canEdit, + map, + initialMarkers, +}: MissionPlanningMapProps) { + const router = useRouter(); + const [markers, setMarkers] = useState(initialMarkers); + const [selectedId, setSelectedId] = useState(null); + const [tool, setTool] = useState("select"); + const [style, setStyle] = useState(DEFAULT_STYLE); + const [cursor, setCursor] = useState(null); + const [showRoads, setShowRoads] = useState(true); + const [showZones, setShowZones] = useState(true); + const [world, setWorld] = useState({ width: map.worldWidth, height: map.worldHeight }); + const [toggling, setToggling] = useState(false); + + const timers = useRef(new Map>()); + const inflight = useRef(new Map>()); + const busy = useRef(0); + + const refresh = useCallback(async () => { + const result = await getMissionMapMarkers({ missionId }); + if (result.success && result.data) { + const next = result.data; + setMarkers(next); + setSelectedId((id) => (id != null && next.some((marker) => marker.id === id) ? id : null)); + } + }, [missionId]); + + // Background refresh picks up other editors' changes, but never while a + // local edit is pending (it would snap the marker back mid-edit). + useEffect(() => { + if (!enabled) return; + const timer = setInterval(() => { + if ( + busy.current === 0 && + timers.current.size === 0 && + document.visibilityState === "visible" + ) { + void refresh(); + } + }, POLL_MS); + return () => clearInterval(timer); + }, [enabled, refresh]); + + useEffect(() => { + const pending = timers.current; + return () => pending.forEach((timer) => clearTimeout(timer)); + }, []); + + const persist = useCallback( + (marker: MissionMarker, immediate: boolean) => { + const existing = timers.current.get(marker.id); + if (existing) clearTimeout(existing); + const run = async () => { + timers.current.delete(marker.id); + // Saves for one marker run strictly in order (last edit wins). + const previous = inflight.current.get(marker.id) ?? Promise.resolve(); + busy.current += 1; + const next = previous.then(async () => { + const result = await saveMissionMapMarker({ + missionId, + id: marker.id, + marker: toData(marker), + }); + if (!result.success) { + toast.error(result.error ?? "Failed to save marker."); + await refresh(); + } + }); + inflight.current.set(marker.id, next); + await next.finally(() => { + busy.current -= 1; + if (inflight.current.get(marker.id) === next) inflight.current.delete(marker.id); + }); + }; + if (immediate) void run(); + else + timers.current.set( + marker.id, + setTimeout(() => void run(), SAVE_DEBOUNCE_MS), + ); + }, + [missionId, refresh], + ); + + const handleChange = useCallback( + (marker: MissionMarker, commit: boolean) => { + setMarkers((list) => list.map((entry) => (entry.id === marker.id ? marker : entry))); + if (commit) persist(marker, true); + }, + [persist], + ); + + /** Panel edits: live update + debounced save (sliders fire continuously). */ + const handlePanelChange = useCallback( + (marker: MissionMarker) => { + const check = normalizeMarkerInput(marker, world); + if (!check.ok) { + toast.error(check.error); + return; + } + setMarkers((list) => list.map((entry) => (entry.id === marker.id ? marker : entry))); + persist(marker, false); + }, + [persist, world], + ); + + const handleCreate = useCallback( + async (data: MissionMarkerData) => { + const check = normalizeMarkerInput(data, world); + if (!check.ok) { + toast.error(check.error); + return; + } + busy.current += 1; + try { + const result = await saveMissionMapMarker({ missionId, marker: check.data }); + if (!result.success || !result.data) { + toast.error(result.error ?? "Failed to place marker."); + return; + } + const created = result.data; + setMarkers((list) => [...list, created]); + setSelectedId(created.id); + // A label belongs to one marker; the next placement starts blank. + setStyle((current) => (current.text ? { ...current, text: "" } : current)); + } finally { + busy.current -= 1; + } + }, + [missionId, world], + ); + + const handleDelete = useCallback( + async (id: number) => { + const timer = timers.current.get(id); + if (timer) clearTimeout(timer); + timers.current.delete(id); + setMarkers((list) => list.filter((marker) => marker.id !== id)); + setSelectedId((current) => (current === id ? null : current)); + busy.current += 1; + try { + await inflight.current.get(id); + const result = await deleteMissionMapMarker({ missionId, id }); + if (!result.success) { + toast.error(result.error ?? "Failed to delete marker."); + await refresh(); + } + } finally { + busy.current -= 1; + } + }, + [missionId, refresh], + ); + + // Delete / Escape shortcuts (ignored while typing in a field). + useEffect(() => { + if (!enabled) return; + const onKey = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null; + if ( + target && + (/^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName) || target.isContentEditable) + ) { + return; + } + if (event.key === "Escape") setSelectedId(null); + if (canEdit && selectedId != null && (event.key === "Delete" || event.key === "Backspace")) { + if (tool === "polyline") return; + event.preventDefault(); + void handleDelete(selectedId); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [enabled, canEdit, selectedId, tool, handleDelete]); + + const handleLoaded = useCallback( + (info: { worldWidth: number; worldHeight: number }) => + setWorld({ width: info.worldWidth, height: info.worldHeight }), + [], + ); + + const toggleEnabled = async (next: boolean) => { + setToggling(true); + const result = await setMissionPlanningMap({ missionId, enabled: next }); + setToggling(false); + if (!result.success) { + toast.error(result.error ?? "Failed to update the planning map."); + return; + } + toast.success(next ? "Planning map enabled." : "Planning map hidden."); + router.refresh(); + }; + + const exportSqf = async () => { + try { + await navigator.clipboard.writeText(markersToSqf(markers, world.height)); + toast.success(`Copied ${markers.length} markers as SQF.`, { + description: "Paste into the debug console or an init script.", + }); + } catch { + toast.error("Clipboard access was blocked by the browser."); + } + }; + + const clearAll = async () => { + const result = await clearMissionMapMarkers({ missionId }); + if (!result.success) { + toast.error(result.error ?? "Failed to clear markers."); + return; + } + setMarkers([]); + setSelectedId(null); + toast.success(`Removed ${result.data?.removed ?? 0} markers.`); + }; + + const counts = useMemo( + () => ({ + icons: markers.filter((marker) => marker.shape === "icon").length, + areas: markers.filter((marker) => marker.shape === "rectangle" || marker.shape === "ellipse") + .length, + lines: markers.filter((marker) => marker.shape === "polyline").length, + }), + [markers], + ); + const selected = markers.find((marker) => marker.id === selectedId) ?? null; + const ref = String((missionId * 37) % 1000).padStart(3, "0"); + + if (!enabled) { + if (!canEdit) return null; + return ( +
+
+ +
+

Planning map

+

+ Show {map.name} on this page and plan the operation with Arma 3 map markers. Markers + only appear on this mission. +

+
+
+ +
+ ); + } + + return ( +
+ {/* Classification strip */} +
+ + Planning map // {missionLabel} + +
+ + + + {map.name} // REF {ref} + +
+
+ +
+
+ + void handleCreate(data)} + onChange={handleChange} + onCursor={setCursor} + onLoaded={handleLoaded} + /> +
+ + {canEdit && ( + + )} +
+ + {/* Readout footer */} +
+ + + + + +
+ +
+
+
+ ); +} diff --git a/src/components/frontend/intelligence/missionMap/iconStyle.ts b/src/components/frontend/intelligence/missionMap/iconStyle.ts new file mode 100644 index 0000000..c3cf720 --- /dev/null +++ b/src/components/frontend/intelligence/missionMap/iconStyle.ts @@ -0,0 +1,54 @@ +import type React from "react"; + +/** + * Style that renders an Arma marker texture exactly like the game: the marker + * color MULTIPLIES the texture (white NATO/military glyphs take the color, + * full-color flags stay as-is under "Default"/white), and the texture's own + * alpha masks the result. Keys are CSS property names (kebab-case). + */ +export function armaIconCss(iconUrl: string, hex: string): Record { + // Single quotes: this also lands inside double-quoted style attributes in + // divIcon HTML, where a double quote would end the attribute. + const url = `url('${iconUrl}')`; + return { + "background-image": url, + "background-color": hex, + "background-blend-mode": "multiply", + "background-size": "100% 100%", + "-webkit-mask-image": url, + "mask-image": url, + "-webkit-mask-size": "100% 100%", + "mask-size": "100% 100%", + }; +} + +/** Inline style string form of armaIconCss (for divIcon HTML). */ +export function armaIconStyle(iconUrl: string, hex: string): string { + return Object.entries(armaIconCss(iconUrl, hex)) + .map(([prop, value]) => `${prop}:${value}`) + .join(";"); +} + +/** React style object form of armaIconCss. */ +export function armaIconReactStyle(iconUrl: string, hex: string): React.CSSProperties { + const style: Record = {}; + for (const [prop, value] of Object.entries(armaIconCss(iconUrl, hex))) { + const camel = prop + .replace(/^-webkit-/, "Webkit-") + .replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); + style[camel] = value; + } + return style as React.CSSProperties; +} + +/** + * Halo class for marker text: Arma's default black text vanishes on dark + * basemaps, so dark colors get a light halo and light colors a dark one. + */ +export function textHaloClass(hex: string): string { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; + return luminance < 0.35 ? "arma-marker-halo-light" : "arma-marker-halo-dark"; +} diff --git a/src/components/frontend/intelligence/missionMap/markerIcon.ts b/src/components/frontend/intelligence/missionMap/markerIcon.ts new file mode 100644 index 0000000..bbba612 --- /dev/null +++ b/src/components/frontend/intelligence/missionMap/markerIcon.ts @@ -0,0 +1,55 @@ +import L from "leaflet"; +import { markerTypeOf, resolveMarkerRgba, rgbaToHex } from "@/lib/arma-markers"; +import type { MissionMarker } from "@/lib/arma-markers/missionMarkers"; +import { armaIconStyle, textHaloClass } from "./iconStyle"; + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +const cache = new Map(); + +/** Leaflet divIcon for an icon marker (cached per visual state). */ +export function markerDivIcon(marker: MissionMarker, selected: boolean): L.DivIcon { + const type = markerTypeOf(marker.markerType); + const hex = rgbaToHex(resolveMarkerRgba(marker.color, marker.markerType)); + const base = type?.size ?? 32; + // Size-0 types (the text-only "Empty" marker) keep a small hit box. + const width = Math.max(8, Math.round(base * marker.size[0])); + const height = Math.max(8, Math.round(base * marker.size[1])); + const showGlyph = base > 0; + const key = [ + type?.iconUrl ?? "none", + hex, + width, + height, + marker.direction, + marker.alpha, + marker.text, + selected ? 1 : 0, + type?.shadow ? 1 : 0, + ].join("|"); + const cached = cache.get(key); + if (cached) return cached; + + const glyph = + type && showGlyph + ? `` + : ``; + // Arma draws the text to the right of the icon, in the marker color. + const text = marker.text + ? `${escapeHtml(marker.text)}` + : ""; + const icon = L.divIcon({ + className: `arma-marker${selected ? " arma-marker-selected" : ""}`, + html: `
${glyph}${text}
`, + iconSize: [width, height], + iconAnchor: [width / 2, height / 2], + }); + cache.set(key, icon); + return icon; +} diff --git a/src/components/frontend/intelligence/missionMap/types.ts b/src/components/frontend/intelligence/missionMap/types.ts new file mode 100644 index 0000000..141c1a7 --- /dev/null +++ b/src/components/frontend/intelligence/missionMap/types.ts @@ -0,0 +1,12 @@ +export type PlanTool = "select" | "icon" | "rectangle" | "ellipse" | "polyline"; + +/** Defaults applied to newly placed markers (and edited on the selection). */ +export interface MarkerStyle { + markerType: string; + color: string; + brush: string; + text: string; + iconScale: number; + direction: number; + alpha: number; +} diff --git a/src/components/ui/slider.tsx b/src/components/ui/slider.tsx new file mode 100644 index 0000000..46ebc4b --- /dev/null +++ b/src/components/ui/slider.tsx @@ -0,0 +1,63 @@ +"use client" + +import * as React from "react" +import { Slider as SliderPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Slider({ + className, + defaultValue, + value, + min = 0, + max = 100, + ...props +}: React.ComponentProps) { + const _values = React.useMemo( + () => + Array.isArray(value) + ? value + : Array.isArray(defaultValue) + ? defaultValue + : [min, max], + [value, defaultValue, min, max] + ) + + return ( + + + + + {Array.from({ length: _values.length }, (_, index) => ( + + ))} + + ) +} + +export { Slider } diff --git a/src/components/ui/toggle-group.tsx b/src/components/ui/toggle-group.tsx new file mode 100644 index 0000000..a524c2a --- /dev/null +++ b/src/components/ui/toggle-group.tsx @@ -0,0 +1,73 @@ +"use client" + +import * as React from "react" +import { type VariantProps } from "class-variance-authority" +import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { toggleVariants } from "@/components/ui/toggle" + +const ToggleGroupContext = React.createContext< + VariantProps +>({ + size: "default", + variant: "default", +}) + +function ToggleGroup({ + className, + variant, + size, + children, + ...props +}: React.ComponentProps & + VariantProps) { + return ( + + + {children} + + + ) +} + +function ToggleGroupItem({ + className, + children, + variant, + size, + ...props +}: React.ComponentProps & + VariantProps) { + const context = React.useContext(ToggleGroupContext) + + return ( + + {children} + + ) +} + +export { ToggleGroup, ToggleGroupItem } diff --git a/src/components/ui/toggle.tsx b/src/components/ui/toggle.tsx new file mode 100644 index 0000000..3cbffee --- /dev/null +++ b/src/components/ui/toggle.tsx @@ -0,0 +1,47 @@ +"use client" + +import * as React from "react" +import { Toggle as TogglePrimitive } from "radix-ui" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const toggleVariants = cva( + "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-transparent", + outline: + "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground", + }, + size: { + default: "h-9 min-w-9 px-2", + sm: "h-8 min-w-8 px-1.5", + lg: "h-10 min-w-10 px-2.5", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Toggle({ + className, + variant, + size, + ...props +}: React.ComponentProps & + VariantProps) { + return ( + + ) +} + +export { Toggle, toggleVariants } diff --git a/src/lib/intelligence/missionMap.ts b/src/lib/intelligence/missionMap.ts new file mode 100644 index 0000000..507ae6b --- /dev/null +++ b/src/lib/intelligence/missionMap.ts @@ -0,0 +1,169 @@ +/** + * Mission planning map service: editor rules + marker CRUD. + * + * Visibility: anyone who can read the mission (checked by the caller via a + * user-scoped `findByID`) can read its markers. Editing: `missions:update` + * holders or the mission's creator (`createdBy`, the Friday side-op host). + * All writes run with overrideAccess after those checks. + */ +import type { Payload } from "payload"; +import type { Mission, MissionMapMarker as MissionMapMarkerDoc } from "@/payload-types"; +import { hasPermission } from "@/utils/access-control/hasPermission"; +import { + MAX_MARKERS_PER_MISSION, + markerFromDoc, + markerToDocFields, + normalizeMarkerInput, + type MissionMarker, + type WorldSize, +} from "@/lib/arma-markers/missionMarkers"; + +type UserRef = { id: number | string } | null | undefined; + +function relId(value: unknown): number | null { + if (typeof value === "number") return value; + if (value && typeof value === "object" && "id" in value) { + const id = (value as { id: unknown }).id; + return typeof id === "number" ? id : null; + } + return null; +} + +export async function canEditMissionPlan( + payload: Payload, + user: UserRef, + mission: Pick, +): Promise { + if (!user) return false; + if (relId(mission.createdBy) === Number(user.id)) return true; + return hasPermission(payload, user, "missions:update"); +} + +/** + * Load a mission the user is allowed to see (null when hidden or missing). + * depth 1 so the map relation carries its world size. + */ +export async function findVisibleMission( + payload: Payload, + user: UserRef, + missionId: number, +): Promise { + if (!Number.isInteger(missionId) || missionId <= 0) return null; + return payload + .findByID({ + collection: "missions", + id: missionId, + depth: 1, + user: user as Parameters[0]["user"], + overrideAccess: false, + }) + .catch(() => null); +} + +export function missionWorldSize(mission: Mission): WorldSize { + const map = typeof mission.classification.map === "object" ? mission.classification.map : null; + return { + width: Number(map?.worldSizeWidth ?? 8192), + height: Number(map?.worldSizeHeight ?? 8192), + }; +} + +export async function listMissionMarkers( + payload: Payload, + missionId: number, +): Promise { + const result = await payload.find({ + collection: "mission-map-markers", + where: { mission: { equals: missionId } }, + sort: "id", + depth: 0, + limit: MAX_MARKERS_PER_MISSION, + pagination: false, + overrideAccess: true, + }); + return result.docs + .map((doc) => markerFromDoc(doc as MissionMapMarkerDoc)) + .filter((marker): marker is MissionMarker => marker !== null); +} + +export async function createMissionMarker( + payload: Payload, + mission: Mission, + input: unknown, + actorId: number, +): Promise { + const normalized = normalizeMarkerInput(input, missionWorldSize(mission)); + if (!normalized.ok) throw new Error(normalized.error); + + const { totalDocs } = await payload.count({ + collection: "mission-map-markers", + where: { mission: { equals: mission.id } }, + overrideAccess: true, + }); + if (totalDocs >= MAX_MARKERS_PER_MISSION) { + throw new Error(`A mission can have at most ${MAX_MARKERS_PER_MISSION} markers.`); + } + + const doc = await payload.create({ + collection: "mission-map-markers", + data: { ...markerToDocFields(normalized.data), mission: mission.id, createdBy: actorId }, + overrideAccess: true, + }); + const marker = markerFromDoc(doc as MissionMapMarkerDoc); + if (!marker) throw new Error("Failed to save marker."); + return marker; +} + +async function findMissionMarkerDoc(payload: Payload, missionId: number, markerId: number) { + const doc = await payload + .findByID({ collection: "mission-map-markers", id: markerId, depth: 0, overrideAccess: true }) + .catch(() => null); + if (!doc || relId(doc.mission) !== missionId) throw new Error("Marker not found."); + return doc; +} + +export async function updateMissionMarker( + payload: Payload, + mission: Mission, + markerId: number, + input: unknown, +): Promise { + await findMissionMarkerDoc(payload, mission.id, markerId); + const normalized = normalizeMarkerInput(input, missionWorldSize(mission)); + if (!normalized.ok) throw new Error(normalized.error); + const doc = await payload.update({ + collection: "mission-map-markers", + id: markerId, + data: markerToDocFields(normalized.data), + overrideAccess: true, + }); + const marker = markerFromDoc(doc as MissionMapMarkerDoc); + if (!marker) throw new Error("Failed to save marker."); + return marker; +} + +export async function deleteMissionMarker( + payload: Payload, + missionId: number, + markerId: number, +): Promise { + await findMissionMarkerDoc(payload, missionId, markerId); + await payload.delete({ collection: "mission-map-markers", id: markerId, overrideAccess: true }); +} + +/** Per-doc deletes: bulk delete-by-where is unreliable on this adapter. */ +export async function clearMissionMarkers(payload: Payload, missionId: number): Promise { + const result = await payload.find({ + collection: "mission-map-markers", + where: { mission: { equals: missionId } }, + depth: 0, + limit: MAX_MARKERS_PER_MISSION, + pagination: false, + overrideAccess: true, + select: {}, + }); + for (const doc of result.docs) { + await payload.delete({ collection: "mission-map-markers", id: doc.id, overrideAccess: true }); + } + return result.docs.length; +} diff --git a/src/migrations/20261003_190000_add_mission_map_markers.ts b/src/migrations/20261003_190000_add_mission_map_markers.ts new file mode 100644 index 0000000..539a39c --- /dev/null +++ b/src/migrations/20261003_190000_add_mission_map_markers.ts @@ -0,0 +1,70 @@ +import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' + +/** + * Mission planning maps: `mission-map-markers` collection (Arma 3 marker + * model per mission), the `classification.planningMapEnabled` opt-in on + * missions, and the collection's RBAC permission values. Hand-written in the + * add_mission_role_signups style (isolated to this feature's footprint). + */ +export async function up({ db }: MigrateUpArgs): Promise { + await db.execute(sql` + ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'mission-map-markers:create' BEFORE 'campaigns:create'; + ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'mission-map-markers:read' BEFORE 'campaigns:create'; + ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'mission-map-markers:update' BEFORE 'campaigns:create'; + ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'mission-map-markers:delete' BEFORE 'campaigns:create'; + ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:mission-map-markers:manage' BEFORE 'campaigns:create'; + CREATE TYPE "public"."enum_mission_map_markers_shape" AS ENUM('icon', 'rectangle', 'ellipse', 'polyline'); + CREATE TABLE "mission_map_markers" ( + "id" serial PRIMARY KEY NOT NULL, + "mission_id" integer NOT NULL, + "shape" "enum_mission_map_markers_shape" DEFAULT 'icon' NOT NULL, + "marker_type" varchar, + "color" varchar DEFAULT 'Default' NOT NULL, + "brush" varchar, + "text" varchar, + "position" jsonb NOT NULL, + "size_a" numeric DEFAULT 1, + "size_b" numeric DEFAULT 1, + "direction" numeric DEFAULT 0, + "alpha" numeric DEFAULT 1, + "points" jsonb, + "created_by_id" integer, + "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL + ); + + ALTER TABLE "missions" ADD COLUMN "classification_planning_map_enabled" boolean DEFAULT false; + ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "mission_map_markers_find" boolean DEFAULT false; + ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "mission_map_markers_create" boolean DEFAULT false; + ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "mission_map_markers_update" boolean DEFAULT false; + ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "mission_map_markers_delete" boolean DEFAULT false; + ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "mission_map_markers_id" integer; + ALTER TABLE "mission_map_markers" ADD CONSTRAINT "mission_map_markers_mission_id_missions_id_fk" FOREIGN KEY ("mission_id") REFERENCES "public"."missions"("id") ON DELETE set null ON UPDATE no action; + ALTER TABLE "mission_map_markers" ADD CONSTRAINT "mission_map_markers_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action; + CREATE INDEX "mission_map_markers_mission_idx" ON "mission_map_markers" USING btree ("mission_id"); + CREATE INDEX "mission_map_markers_created_by_idx" ON "mission_map_markers" USING btree ("created_by_id"); + CREATE INDEX "mission_map_markers_updated_at_idx" ON "mission_map_markers" USING btree ("updated_at"); + CREATE INDEX "mission_map_markers_created_at_idx" ON "mission_map_markers" USING btree ("created_at"); + ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_mission_map_markers_fk" FOREIGN KEY ("mission_map_markers_id") REFERENCES "public"."mission_map_markers"("id") ON DELETE cascade ON UPDATE no action; + CREATE INDEX "payload_locked_documents_rels_mission_map_markers_id_idx" ON "payload_locked_documents_rels" USING btree ("mission_map_markers_id");`) +} + +/** + * Drops the markers table and columns. The permission enum values are left in + * place (Postgres cannot drop enum values without rebuilding the type, and an + * unused value is harmless); role rows holding them are removed. + */ +export async function down({ db }: MigrateDownArgs): Promise { + await db.execute(sql` + DELETE FROM "roles_permissions" WHERE "value"::text LIKE '%mission-map-markers%'; + ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT IF EXISTS "payload_locked_documents_rels_mission_map_markers_fk"; + DROP INDEX IF EXISTS "payload_locked_documents_rels_mission_map_markers_id_idx"; + ALTER TABLE "payload_locked_documents_rels" DROP COLUMN IF EXISTS "mission_map_markers_id"; + ALTER TABLE "payload_mcp_api_keys" DROP COLUMN IF EXISTS "mission_map_markers_find"; + ALTER TABLE "payload_mcp_api_keys" DROP COLUMN IF EXISTS "mission_map_markers_create"; + ALTER TABLE "payload_mcp_api_keys" DROP COLUMN IF EXISTS "mission_map_markers_update"; + ALTER TABLE "payload_mcp_api_keys" DROP COLUMN IF EXISTS "mission_map_markers_delete"; + ALTER TABLE "missions" DROP COLUMN IF EXISTS "classification_planning_map_enabled"; + DROP TABLE IF EXISTS "mission_map_markers" CASCADE; + DROP TYPE IF EXISTS "public"."enum_mission_map_markers_shape";`) +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index ba77386..a6cc389 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -101,6 +101,7 @@ import * as migration_20261001_000400_add_reset_owned_sequences_function from '. import * as migration_20261002_031917_add_promotion_nominations_and_leadership_transfers from './20261002_031917_add_promotion_nominations_and_leadership_transfers'; import * as migration_20261002_180000_add_asset_type_accessory from './20261002_180000_add_asset_type_accessory'; import * as migration_20261003_060154_change_ai_model_to_ornith from './20261003_060154_change_ai_model_to_ornith'; +import * as migration_20261003_190000_add_mission_map_markers from './20261003_190000_add_mission_map_markers'; export const migrations = [ { @@ -618,4 +619,9 @@ export const migrations = [ down: migration_20261003_060154_change_ai_model_to_ornith.down, name: '20261003_060154_change_ai_model_to_ornith' }, + { + up: migration_20261003_190000_add_mission_map_markers.up, + down: migration_20261003_190000_add_mission_map_markers.down, + name: '20261003_190000_add_mission_map_markers' + }, ]; diff --git a/src/payload-generated-schema.ts b/src/payload-generated-schema.ts index 7949562..c63914c 100644 --- a/src/payload-generated-schema.ts +++ b/src/payload-generated-schema.ts @@ -269,6 +269,11 @@ export const enum_roles_permissions = pgEnum("enum_roles_permissions", [ "mission-role-signups:update", "mission-role-signups:delete", "admin:mission-role-signups:manage", + "mission-map-markers:create", + "mission-map-markers:read", + "mission-map-markers:update", + "mission-map-markers:delete", + "admin:mission-map-markers:manage", "campaigns:create", "campaigns:read", "campaigns:update", @@ -634,6 +639,12 @@ export const enum_mission_attendances_response = pgEnum("enum_mission_attendance "no", "tentative", ]); +export const enum_mission_map_markers_shape = pgEnum("enum_mission_map_markers_shape", [ + "icon", + "rectangle", + "ellipse", + "polyline", +]); export const enum_campaigns_status = pgEnum("enum_campaigns_status", [ "concept", "inProgress", @@ -675,6 +686,7 @@ export const enum_assets_asset_type = pgEnum("enum_assets_asset_type", [ "optic", "muzzle", "bipod", + "accessory", "uniform", "vest", "backpack", @@ -3093,6 +3105,9 @@ export const missions = pgTable( .references(() => maps.id, { onDelete: "set null", }), + classification_planningMapEnabled: boolean("classification_planning_map_enabled").default( + false, + ), classification_missionType: enum_missions_classification_mission_type( "classification_mission_type", ) @@ -3275,6 +3290,44 @@ export const mission_role_signups = pgTable( ], ); +export const mission_map_markers = pgTable( + "mission_map_markers", + { + id: serial("id").primaryKey(), + mission: integer("mission_id") + .notNull() + .references(() => missions.id, { + onDelete: "set null", + }), + shape: enum_mission_map_markers_shape("shape").notNull().default("icon"), + markerType: varchar("marker_type"), + color: varchar("color").notNull().default("Default"), + brush: varchar("brush"), + text: varchar("text"), + position: jsonb("position").notNull(), + sizeA: numeric("size_a", { mode: "number" }).default(1), + sizeB: numeric("size_b", { mode: "number" }).default(1), + direction: numeric("direction", { mode: "number" }).default(0), + alpha: numeric("alpha", { mode: "number" }).default(1), + points: jsonb("points"), + createdBy: integer("created_by_id").references(() => users.id, { + onDelete: "set null", + }), + updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 }) + .defaultNow() + .notNull(), + createdAt: timestamp("created_at", { mode: "string", withTimezone: true, precision: 3 }) + .defaultNow() + .notNull(), + }, + (columns) => [ + index("mission_map_markers_mission_idx").on(columns.mission), + index("mission_map_markers_created_by_idx").on(columns.createdBy), + index("mission_map_markers_updated_at_idx").on(columns.updatedAt), + index("mission_map_markers_created_at_idx").on(columns.createdAt), + ], +); + export const campaigns = pgTable( "campaigns", { @@ -7001,6 +7054,10 @@ export const payload_mcp_api_keys = pgTable( missionRoleSignups_create: boolean("mission_role_signups_create").default(false), missionRoleSignups_update: boolean("mission_role_signups_update").default(false), missionRoleSignups_delete: boolean("mission_role_signups_delete").default(false), + missionMapMarkers_find: boolean("mission_map_markers_find").default(false), + missionMapMarkers_create: boolean("mission_map_markers_create").default(false), + missionMapMarkers_update: boolean("mission_map_markers_update").default(false), + missionMapMarkers_delete: boolean("mission_map_markers_delete").default(false), campaigns_find: boolean("campaigns_find").default(false), campaigns_create: boolean("campaigns_create").default(false), campaigns_update: boolean("campaigns_update").default(false), @@ -7288,6 +7345,7 @@ export const payload_locked_documents_rels = pgTable( missionsID: integer("missions_id"), "mission-attendancesID": integer("mission_attendances_id"), "mission-role-signupsID": integer("mission_role_signups_id"), + "mission-map-markersID": integer("mission_map_markers_id"), campaignsID: integer("campaigns_id"), factionsID: integer("factions_id"), technologiesID: integer("technologies_id"), @@ -7393,6 +7451,9 @@ export const payload_locked_documents_rels = pgTable( index("payload_locked_documents_rels_mission_role_signups_id_idx").on( columns["mission-role-signupsID"], ), + index("payload_locked_documents_rels_mission_map_markers_id_idx").on( + columns["mission-map-markersID"], + ), index("payload_locked_documents_rels_campaigns_id_idx").on(columns.campaignsID), index("payload_locked_documents_rels_factions_id_idx").on(columns.factionsID), index("payload_locked_documents_rels_technologies_id_idx").on(columns.technologiesID), @@ -7620,6 +7681,11 @@ export const payload_locked_documents_rels = pgTable( foreignColumns: [mission_role_signups.id], name: "payload_locked_documents_rels_mission_role_signups_fk", }).onDelete("cascade"), + foreignKey({ + columns: [columns["mission-map-markersID"]], + foreignColumns: [mission_map_markers.id], + name: "payload_locked_documents_rels_mission_map_markers_fk", + }).onDelete("cascade"), foreignKey({ columns: [columns["campaignsID"]], foreignColumns: [campaigns.id], @@ -9023,6 +9089,18 @@ export const relations_mission_role_signups = relations(mission_role_signups, ({ relationName: "user", }), })); +export const relations_mission_map_markers = relations(mission_map_markers, ({ one }) => ({ + mission: one(missions, { + fields: [mission_map_markers.mission], + references: [missions.id], + relationName: "mission", + }), + createdBy: one(users, { + fields: [mission_map_markers.createdBy], + references: [users.id], + relationName: "createdBy", + }), +})); export const relations_campaigns = relations(campaigns, ({ one }) => ({ coverImage: one(media, { fields: [campaigns.coverImage], @@ -10756,6 +10834,11 @@ export const relations_payload_locked_documents_rels = relations( references: [mission_role_signups.id], relationName: "mission-role-signups", }), + "mission-map-markersID": one(mission_map_markers, { + fields: [payload_locked_documents_rels["mission-map-markersID"]], + references: [mission_map_markers.id], + relationName: "mission-map-markers", + }), campaignsID: one(campaigns, { fields: [payload_locked_documents_rels.campaignsID], references: [campaigns.id], @@ -11197,6 +11280,7 @@ type DatabaseSchema = { enum_missions_ownership_and_status_visibility: typeof enum_missions_ownership_and_status_visibility; enum_missions_civilian_presence: typeof enum_missions_civilian_presence; enum_mission_attendances_response: typeof enum_mission_attendances_response; + enum_mission_map_markers_shape: typeof enum_mission_map_markers_shape; enum_campaigns_status: typeof enum_campaigns_status; enum_campaigns_campaign_mode: typeof enum_campaigns_campaign_mode; enum_factions_type: typeof enum_factions_type; @@ -11337,6 +11421,7 @@ type DatabaseSchema = { missions_rels: typeof missions_rels; mission_attendances: typeof mission_attendances; mission_role_signups: typeof mission_role_signups; + mission_map_markers: typeof mission_map_markers; campaigns: typeof campaigns; factions_relationships: typeof factions_relationships; factions: typeof factions; @@ -11529,6 +11614,7 @@ type DatabaseSchema = { relations_missions: typeof relations_missions; relations_mission_attendances: typeof relations_mission_attendances; relations_mission_role_signups: typeof relations_mission_role_signups; + relations_mission_map_markers: typeof relations_mission_map_markers; relations_campaigns: typeof relations_campaigns; relations_factions_relationships: typeof relations_factions_relationships; relations_factions_rels: typeof relations_factions_rels; diff --git a/src/payload-types.ts b/src/payload-types.ts index 5881c60..484ac28 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -94,6 +94,7 @@ export interface Config { missions: Mission; 'mission-attendances': MissionAttendance; 'mission-role-signups': MissionRoleSignup; + 'mission-map-markers': MissionMapMarker; campaigns: Campaign; factions: Faction; technologies: Technology; @@ -190,6 +191,7 @@ export interface Config { missions: MissionsSelect | MissionsSelect; 'mission-attendances': MissionAttendancesSelect | MissionAttendancesSelect; 'mission-role-signups': MissionRoleSignupsSelect | MissionRoleSignupsSelect; + 'mission-map-markers': MissionMapMarkersSelect | MissionMapMarkersSelect; campaigns: CampaignsSelect | CampaignsSelect; factions: FactionsSelect | FactionsSelect; technologies: TechnologiesSelect | TechnologiesSelect; @@ -646,6 +648,11 @@ export interface Role { | 'mission-role-signups:update' | 'mission-role-signups:delete' | 'admin:mission-role-signups:manage' + | 'mission-map-markers:create' + | 'mission-map-markers:read' + | 'mission-map-markers:update' + | 'mission-map-markers:delete' + | 'admin:mission-map-markers:manage' | 'campaigns:create' | 'campaigns:read' | 'campaigns:update' @@ -2983,6 +2990,10 @@ export interface Mission { coverImage?: (number | null) | Media; classification: { map: number | Map; + /** + * Show this mission's map on the mission page so mission editors can place Arma 3 planning markers that only appear for this mission. + */ + planningMapEnabled?: boolean | null; missionType: 'Zeus' | 'PvE' | 'PvP' | 'PvPvE' | 'Training' | 'Special'; campaign?: (number | null) | Campaign; /** @@ -3385,6 +3396,70 @@ export interface MissionRoleSignup { updatedAt: string; createdAt: string; } +/** + * Planning-map markers for individual missions. Normally edited on the mission page, not here. + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "mission-map-markers". + */ +export interface MissionMapMarker { + id: number; + mission: number | Mission; + shape: 'icon' | 'rectangle' | 'ellipse' | 'polyline'; + /** + * CfgMarkers class name (icon markers), e.g. b_inf, mil_objective. + */ + markerType?: string | null; + /** + * CfgMarkerColors class name, e.g. ColorRed, ColorWEST. + */ + color: string; + /** + * CfgMarkerBrushes class name (area markers), e.g. Solid, Grid. + */ + brush?: string | null; + text?: string | null; + /** + * [x, y] in map meters (origin top-left, y south). First vertex for lines. + */ + position: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + /** + * Area half-width in meters, or icon scale. + */ + sizeA?: number | null; + /** + * Area half-height in meters, or icon scale. + */ + sizeB?: number | null; + /** + * Degrees clockwise from north. + */ + direction?: number | null; + alpha?: number | null; + /** + * Line vertices [[x, y], ...] in map meters (line markers only). + */ + points?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + createdBy?: (number | null) | User; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "shipments". @@ -5837,6 +5912,24 @@ export interface PayloadMcpApiKey { */ delete?: boolean | null; }; + missionMapMarkers?: { + /** + * Allow clients to find mission-map-markers. + */ + find?: boolean | null; + /** + * Allow clients to create mission-map-markers. + */ + create?: boolean | null; + /** + * Allow clients to update mission-map-markers. + */ + update?: boolean | null; + /** + * Allow clients to delete mission-map-markers. + */ + delete?: boolean | null; + }; campaigns?: { /** * Allow clients to find campaigns. @@ -6908,6 +7001,10 @@ export interface PayloadLockedDocument { relationTo: 'mission-role-signups'; value: number | MissionRoleSignup; } | null) + | ({ + relationTo: 'mission-map-markers'; + value: number | MissionMapMarker; + } | null) | ({ relationTo: 'campaigns'; value: number | Campaign; @@ -7885,6 +7982,7 @@ export interface MissionsSelect { | T | { map?: T; + planningMapEnabled?: T; missionType?: T; campaign?: T; customScripts?: T; @@ -8014,6 +8112,27 @@ export interface MissionRoleSignupsSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "mission-map-markers_select". + */ +export interface MissionMapMarkersSelect { + mission?: T; + shape?: T; + markerType?: T; + color?: T; + brush?: T; + text?: T; + position?: T; + sizeA?: T; + sizeB?: T; + direction?: T; + alpha?: T; + points?: T; + createdBy?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "campaigns_select". @@ -9748,6 +9867,14 @@ export interface PayloadMcpApiKeysSelect { update?: T; delete?: T; }; + missionMapMarkers?: + | T + | { + find?: T; + create?: T; + update?: T; + delete?: T; + }; campaigns?: | T | { diff --git a/src/payload.config.ts b/src/payload.config.ts index fbc6226..090450b 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -17,6 +17,7 @@ import { Profiles } from "@/collections/users/Profiles"; import { Missions } from "@/collections/intelligence/Missions"; import { MissionAttendances } from "@/collections/intelligence/MissionAttendances"; import { MissionRoleSignups } from "@/collections/intelligence/MissionRoleSignups"; +import { MissionMapMarkers } from "@/collections/intelligence/MissionMapMarkers"; import { Maps } from "@/collections/world/Maps"; import { MapRoads } from "@/collections/world/MapRoads"; import { MapZones } from "@/collections/world/MapZones"; @@ -290,6 +291,7 @@ const collections = [ Missions, MissionAttendances, MissionRoleSignups, + MissionMapMarkers, Campaigns, Factions, Technologies, diff --git a/src/permissions/index.ts b/src/permissions/index.ts index 20db6bc..4bcb231 100644 --- a/src/permissions/index.ts +++ b/src/permissions/index.ts @@ -195,6 +195,16 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [ { value: "admin:mission-role-signups:manage", label: "Manage Admin Page" }, ], }, + { + group: "Mission Map Markers", + permissions: [ + { value: "mission-map-markers:create", label: "Create" }, + { value: "mission-map-markers:read", label: "Read" }, + { value: "mission-map-markers:update", label: "Update" }, + { value: "mission-map-markers:delete", label: "Delete" }, + { value: "admin:mission-map-markers:manage", label: "Manage Admin Page" }, + ], + }, { group: "Campaigns", permissions: [ @@ -808,6 +818,11 @@ export const ALL_PERMISSION_VALUES = PERMISSION_GROUPS.flatMap((g) => "mission-role-signups:update", "mission-role-signups:delete", "admin:mission-role-signups:manage", + "mission-map-markers:create", + "mission-map-markers:read", + "mission-map-markers:update", + "mission-map-markers:delete", + "admin:mission-map-markers:manage", "campaigns:create", "campaigns:read", "campaigns:update", diff --git a/src/utils/event-log/eventTypes.ts b/src/utils/event-log/eventTypes.ts index 0c57140..4af80d9 100644 --- a/src/utils/event-log/eventTypes.ts +++ b/src/utils/event-log/eventTypes.ts @@ -158,6 +158,11 @@ export const EventTypes = { MissionUpdate: "mission:update", MissionCancel: "mission:cancel", MissionMoveCampaign: "mission:move-campaign", + MissionPlanningMapToggle: "mission:planning-map-toggle", + MissionMapMarkerCreate: "mission:map-marker-create", + MissionMapMarkerUpdate: "mission:map-marker-update", + MissionMapMarkerDelete: "mission:map-marker-delete", + MissionMapMarkersClear: "mission:map-markers-clear", // Combat PlayerKill: "combat:kill", diff --git a/tests/e2e/mission-planning-map.e2e.spec.ts b/tests/e2e/mission-planning-map.e2e.spec.ts new file mode 100644 index 0000000..6928865 --- /dev/null +++ b/tests/e2e/mission-planning-map.e2e.spec.ts @@ -0,0 +1,172 @@ +import { expect, test, type Page } from "@playwright/test"; + +/** + * Mission planning map: an editor enables the map on a mission, places an + * Arma icon marker (with text), a hatched rectangle, and a line, edits the + * selection from the panel, and everything survives a reload. Seeds its own + * map + mission through cookie-authed REST (needs the test DB's superuser dev). + */ +test.describe("Mission planning map", () => { + let mapId: number | null = null; + let missionId: number | null = null; + + async function login(page: Page) { + const res = await page.request.post("/api/users/login", { + data: { username: "dev", password: "Test123" }, + }); + expect(res.ok(), `login failed: ${res.status()}`).toBe(true); + } + + async function markers(page: Page) { + const res = await page.request.get( + `/api/mission-map-markers?where[mission][equals]=${missionId}&depth=0&limit=100&sort=id`, + ); + expect(res.ok()).toBe(true); + return ((await res.json()).docs ?? []) as { + id: number; + shape: string; + markerType: string | null; + color: string; + brush: string | null; + text: string | null; + points: number[][] | null; + }[]; + } + + test.afterAll(async ({ browser }) => { + const page = await browser.newPage(); + await login(page); + if (missionId) await page.request.delete(`/api/missions/${missionId}`); + if (mapId) await page.request.delete(`/api/maps/${mapId}`); + await page.close(); + }); + + test("enable, place icon/area/line, edit, persist", async ({ page }) => { + await login(page); + // Tours would overlay the page; opt this browser out before any page runs. + await page.addInitScript(() => window.localStorage.setItem("ptf:tours:opted-out", "1")); + + const mapRes = await page.request.post("/api/maps", { + data: { + name: "E2E Planning Map", + worldSizeWidth: 8192, + worldSizeHeight: 8192, + basemapMode: "image", + isActive: true, + }, + }); + expect(mapRes.ok(), await mapRes.text()).toBe(true); + mapId = (await mapRes.json()).doc.id; + + const meRes = await page.request.get("/api/users/me"); + const meId = (await meRes.json()).user.id as number; + const missionRes = await page.request.post("/api/missions", { + data: { + name: "E2E Planning Op", + codeName: "E2E-PLAN", + summary: "Planning map e2e mission", + operationType: "main", + classification: { + map: mapId, + missionType: "PvE", + startDateTime: new Date(Date.now() + 3 * 86_400_000).toISOString(), + estimatedDuration: 60, + }, + ownershipAndStatus: { authors: [meId], status: "Scheduled", visibility: "unit" }, + briefing: [], + gameDetails: { serverDetails: { serverIp: "127.0.0.1", serverPort: 2302 } }, + missionRoles: { maxPlayers: 32, providedRoles: [] }, + }, + }); + expect(missionRes.ok(), await missionRes.text()).toBe(true); + missionId = (await missionRes.json()).doc.id; + + await page.goto(`/intelligence/missions/${missionId}`); + await page.getByRole("button", { name: "Enable planning map" }).click(); + + const section = page.locator("section[aria-label='Mission planning map']"); + await expect(section).toBeVisible(); + const map = section.locator(".leaflet-container"); + await expect(map).toBeVisible(); + await page.waitForTimeout(1000); + // Panel edits can scroll the page, so measure the map before every use. + const at = async (fx: number, fy: number) => { + await map.scrollIntoViewIfNeeded(); + const box = (await map.boundingBox())!; + return { x: box.x + box.width * fx, y: box.y + box.height * fy }; + }; + + // 1. NATO icon (OPFOR armor), red, with text. + await section.getByRole("radio", { name: "Icon" }).click(); + await section.getByRole("button", { name: "Marker type" }).click(); + await page.getByPlaceholder("Search markers...").fill("o_armor"); + await page.locator("button[title*='(o_armor)']").first().click(); + await section.getByRole("button", { name: "Red", exact: true }).click(); + await section.getByPlaceholder("Label shown beside the icon").fill("ENEMY ARMOR"); + let p = await at(0.55, 0.35); + await page.mouse.click(p.x, p.y); + await expect(section.locator(".arma-marker-text", { hasText: "ENEMY ARMOR" })).toBeVisible(); + + // 2. Rectangle with the Grid brush, drawn corner to corner. + await section.getByRole("radio", { name: "Rectangle" }).click(); + await section.getByRole("combobox").click(); + await page.getByRole("option", { name: "Grid", exact: true }).click(); + // Radix Select blocks pointer events on until it finishes closing. + await expect(page.locator("body")).not.toHaveCSS("pointer-events", "none"); + const a = await at(0.3, 0.5); + const b = await at(0.5, 0.7); + await page.mouse.move(a.x, a.y); + await page.mouse.down(); + await page.mouse.move(b.x, b.y, { steps: 8 }); + await page.mouse.up(); + await expect(section.getByText("Selected // Rectangle")).toBeVisible(); + + // 3. Line: click, click, double-click. + await section.getByRole("radio", { name: "Line" }).click(); + for (const [fx, fy] of [ + [0.2, 0.3], + [0.35, 0.25], + ]) { + p = await at(fx, fy); + await page.mouse.click(p.x, p.y); + await page.waitForTimeout(150); + } + p = await at(0.45, 0.35); + await page.mouse.dblclick(p.x, p.y); + await expect(section.getByText("Selected // Line")).toBeVisible(); + + await expect + .poll(async () => (await markers(page)).map((m) => m.shape).sort()) + .toEqual(["icon", "polyline", "rectangle"]); + const saved = await markers(page); + const icon = saved.find((m) => m.shape === "icon")!; + expect(icon).toMatchObject({ markerType: "o_armor", color: "ColorRed", text: "ENEMY ARMOR" }); + expect(saved.find((m) => m.shape === "rectangle")?.brush).toBe("Grid"); + expect(saved.find((m) => m.shape === "polyline")?.points).toHaveLength(3); + + // 4. Select the icon and edit its text from the panel (debounced save). + await section.getByRole("radio", { name: "Select" }).click(); + await section.locator(".arma-marker-text", { hasText: "ENEMY ARMOR" }).click(); + await expect(section.getByText("Selected // Icon")).toBeVisible(); + await section.getByPlaceholder("Label shown beside the icon").fill("T-72 PLT"); + await expect + .poll(async () => (await markers(page)).find((m) => m.id === icon.id)?.text) + .toBe("T-72 PLT"); + + await section.screenshot({ path: "test-results/mission-planning-map.png" }); + + // 5. Reload: the plan persists and the readout counts it. + await page.reload(); + const reloaded = page.locator("section[aria-label='Mission planning map']"); + await expect(reloaded.locator(".arma-marker-text", { hasText: "T-72 PLT" })).toBeVisible(); + const footer = reloaded.locator("div.border-t.bg-black\\/60"); + await expect(footer).toContainText("1Icons"); + await expect(footer).toContainText("1Areas"); + await expect(footer).toContainText("1Lines"); + + // 6. Delete key removes the selected marker. + await reloaded.locator(".arma-marker-text", { hasText: "T-72 PLT" }).click(); + await page.keyboard.press("Delete"); + await expect.poll(async () => (await markers(page)).length).toBe(2); + }); +}); diff --git a/tests/int/mission-map.int.spec.ts b/tests/int/mission-map.int.spec.ts new file mode 100644 index 0000000..e242555 --- /dev/null +++ b/tests/int/mission-map.int.spec.ts @@ -0,0 +1,427 @@ +import { getPayload, Payload } from "payload"; +import config from "@/payload.config"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { Map as MissionMapDoc, Mission, User } from "@/payload-types"; +import { + ARMA_MARKER_BRUSHES, + ARMA_MARKER_COLORS, + ARMA_MARKER_TYPES, + markerTypeOf, + markerTypesByCategory, + resolveMarkerRgba, + rgbaToHex, +} from "@/lib/arma-markers"; +import { + areaOutline, + armaGridRef, + hatchSegments, + markerFromDoc, + markersToSqf, + normalizeMarkerInput, + toArmaPosition, + type MissionMarker, +} from "@/lib/arma-markers/missionMarkers"; +import { + canEditMissionPlan, + clearMissionMarkers, + createMissionMarker, + deleteMissionMarker, + findVisibleMission, + listMissionMarkers, + updateMissionMarker, +} from "@/lib/intelligence/missionMap"; +import { existsSync } from "fs"; +import { join } from "path"; + +const WORLD = { width: 8192, height: 8192 }; + +describe("Arma marker catalog", () => { + it("contains the core vanilla classes with resolved names", () => { + expect(markerTypeOf("b_inf")?.name).toBe("Infantry"); + expect(markerTypeOf("B_INF")?.className).toBe("b_inf"); + expect(markerTypeOf("mil_dot")).not.toBeNull(); + expect(markerTypeOf("hd_dot")).not.toBeNull(); + expect(ARMA_MARKER_COLORS.some((c) => c.className === "ColorRed")).toBe(true); + expect(ARMA_MARKER_BRUSHES.some((b) => b.className === "Grid")).toBe(true); + }); + + it("ships an icon file for every marker type", () => { + const missing = ARMA_MARKER_TYPES.filter( + (type) => !existsSync(join(process.cwd(), "public", type.iconUrl)), + ); + expect(missing.map((type) => type.className)).toEqual([]); + }); + + it("groups every type under a category", () => { + const grouped = markerTypesByCategory().reduce((sum, group) => sum + group.types.length, 0); + expect(grouped).toBe(ARMA_MARKER_TYPES.length); + }); + + it("resolves Default to the type's own (side) color", () => { + expect(rgbaToHex(resolveMarkerRgba("Default", "b_inf"))).toBe(rgbaToHex([0, 0.3, 0.6, 1])); + expect(rgbaToHex(resolveMarkerRgba("ColorRed", "b_inf"))).toBe("#e60000"); + expect(rgbaToHex(resolveMarkerRgba("Default", null))).toBe("#000000"); + }); +}); + +describe("Mission marker validation", () => { + it("accepts and normalizes an icon", () => { + const result = normalizeMarkerInput( + { + shape: "icon", + markerType: "b_inf", + color: "ColorRed", + text: " Alpha\nSquad ", + position: [100.456, 200], + size: [20, 20], + direction: -90, + alpha: 3, + }, + WORLD, + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.text).toBe("Alpha Squad"); + expect(result.data.position).toEqual([100.46, 200]); + expect(result.data.size).toEqual([8, 8]); + expect(result.data.direction).toBe(270); + expect(result.data.alpha).toBe(1); + expect(result.data.brush).toBeNull(); + }); + + it("rejects unknown classes, bad shapes and off-world positions", () => { + expect( + normalizeMarkerInput({ shape: "icon", markerType: "nope", position: [1, 1] }, WORLD), + ).toMatchObject({ ok: false }); + expect( + normalizeMarkerInput( + { shape: "icon", markerType: "b_inf", color: "ColorPlaid", position: [1, 1] }, + WORLD, + ), + ).toMatchObject({ ok: false }); + expect(normalizeMarkerInput({ shape: "star", position: [1, 1] }, WORLD)).toMatchObject({ + ok: false, + }); + expect( + normalizeMarkerInput({ shape: "icon", markerType: "b_inf", position: [99999, 1] }, WORLD), + ).toMatchObject({ ok: false }); + expect( + normalizeMarkerInput({ shape: "rectangle", position: [10, 10], size: [0.1, 5] }, WORLD), + ).toMatchObject({ ok: false }); + expect(normalizeMarkerInput({ shape: "polyline", points: [[1, 1]] }, WORLD)).toMatchObject({ + ok: false, + }); + }); + + it("defaults area brush and derives line position", () => { + const area = normalizeMarkerInput({ shape: "ellipse", position: [10, 10], size: [50] }, WORLD); + expect(area.ok && area.data.brush).toBe("Solid"); + expect(area.ok && area.data.size).toEqual([50, 50]); + const line = normalizeMarkerInput( + { + shape: "polyline", + points: [ + [5, 6], + [7, 8], + ], + }, + WORLD, + ); + expect(line.ok && line.data.position).toEqual([5, 6]); + }); + + it("drops corrupt stored rows instead of throwing", () => { + expect(markerFromDoc({ id: 1, shape: "icon", position: "bad" })).toBeNull(); + expect( + markerFromDoc({ id: 2, shape: "polyline", position: [1, 1], points: [[1, 1]] }), + ).toBeNull(); + expect(markerFromDoc({ id: 3, shape: "icon", position: [1, 2] })?.position).toEqual([1, 2]); + }); +}); + +describe("Mission marker geometry and export", () => { + it("rotates area outlines clockwise from north", () => { + // 100 m east-west half-width, 10 m north-south, rotated 90 deg: now long + // north-south. y runs south on the map. + const ring = areaOutline("rectangle", [1000, 1000], [100, 10], 90); + const xs = ring.map((p) => Math.round(p[0])); + const ys = ring.map((p) => Math.round(p[1])); + expect(Math.max(...xs) - Math.min(...xs)).toBe(20); + expect(Math.max(...ys) - Math.min(...ys)).toBe(200); + expect(areaOutline("ellipse", [0, 0], [10, 10], 0, 32)).toHaveLength(32); + }); + + it("clips hatch lines to the area", () => { + const square = areaOutline("rectangle", [50, 50], [50, 50], 0); + const lines = hatchSegments(square, 0, 10); + expect(lines).toHaveLength(10); + for (const [a, b] of lines) { + expect(a[0]).toBeCloseTo(0, 5); + expect(b[0]).toBeCloseTo(100, 5); + expect(a[1]).toBe(b[1]); + } + expect(hatchSegments(square, 0, 0)).toEqual([]); + }); + + it("flips y for Arma positions and grid refs", () => { + expect(toArmaPosition([100, 8000], 8192)).toEqual([100, 192]); + expect(armaGridRef([4567, 8192 - 1234], 8192)).toBe("045 012"); + expect(armaGridRef([4567, 8192 - 1234], 8192, 8)).toBe("0456 0123"); + }); + + it("exports a createMarker script", () => { + const markers: MissionMarker[] = [ + { + id: 7, + shape: "icon", + markerType: "b_inf", + color: "ColorBLUFOR", + brush: null, + text: 'Say "hi"', + position: [100, 8092], + size: [1, 1], + direction: 45, + alpha: 1, + points: [], + }, + { + id: 8, + shape: "polyline", + markerType: null, + color: "ColorRed", + brush: null, + text: "", + position: [0, 0], + size: [1, 1], + direction: 0, + alpha: 0.5, + points: [ + [0, 8192], + [10, 8182], + ], + }, + ]; + const sqf = markersToSqf(markers, 8192); + expect(sqf).toContain('_m = createMarker ["ptf_plan_7", [100, 100]];'); + expect(sqf).toContain('_m setMarkerType "b_inf";'); + expect(sqf).toContain('_m setMarkerText "Say ""hi""";'); + expect(sqf).toContain("_m setMarkerDir 45;"); + expect(sqf).toContain('_m setMarkerShape "POLYLINE";'); + expect(sqf).toContain("_m setMarkerPolyline [0, 0, 10, 10];"); + expect(sqf).toContain("_m setMarkerAlpha 0.5;"); + }); +}); + +describe("Mission planning map service", () => { + let payload: Payload; + const RUN = `mmap-${Date.now().toString(36)}`; + let mapId: number; + let mission: Mission; + let creator: User; + let editor: User; + let outsider: User; + let roleId: number; + const userIds: number[] = []; + + const makeUser = async (username: string, roleDocs: number[] = []): Promise => { + const user = (await payload.create({ + collection: "users", + data: { + username, + discordUsername: username, + displayName: "MISSION MAP TEST", + steamId: `7656119${Math.floor(Math.random() * 1e9)}`, + password: "Test123", + roles: ["user"], + roleDocs, + }, + overrideAccess: true, + depth: 0, + })) as unknown as User; + userIds.push(user.id); + return user; + }; + + const makeMission = async (label: string, createdBy: number | null) => + (await payload.create({ + collection: "missions", + data: { + name: `${RUN} ${label}`, + codeName: `${RUN}-${label}`, + summary: "Planning map test mission", + operationType: "main", + createdBy, + classification: { + map: mapId, + missionType: "PvE", + startDateTime: new Date(Date.now() + 2 * 86_400_000).toISOString(), + estimatedDuration: 60, + planningMapEnabled: true, + }, + ownershipAndStatus: { authors: [creator.id], status: "Scheduled", visibility: "unit" }, + briefing: [], + gameDetails: { serverDetails: { serverIp: "127.0.0.1", serverPort: 2302 } }, + missionRoles: { maxPlayers: 32, providedRoles: [] }, + }, + overrideAccess: true, + depth: 1, + })) as unknown as Mission; + + beforeAll(async () => { + payload = await getPayload({ config: await config }); + const map = (await payload.create({ + collection: "maps", + data: { + name: `${RUN} Map`, + worldSizeWidth: 4096, + worldSizeHeight: 4096, + basemapMode: "image", + }, + overrideAccess: true, + depth: 0, + })) as unknown as MissionMapDoc; + mapId = map.id; + + const role = await payload.create({ + collection: "roles", + data: { name: `${RUN}-planner`, slug: `${RUN}-planner`, permissions: ["missions:update"] }, + overrideAccess: true, + depth: 0, + }); + roleId = role.id; + + creator = await makeUser(`${RUN}-creator`); + editor = await makeUser(`${RUN}-editor`, [roleId]); + outsider = await makeUser(`${RUN}-outsider`); + mission = await makeMission("Op", creator.id); + }); + + afterAll(async () => { + if (!payload) return; + if (mission) + await payload + .delete({ collection: "missions", id: mission.id, overrideAccess: true }) + .catch(() => {}); + for (const id of userIds) { + for (const collection of ["bank-accounts", "profiles"] as const) { + const field = collection === "bank-accounts" ? "ownerUser" : "user"; + const docs = await payload + .find({ collection, where: { [field]: { equals: id } }, depth: 0, overrideAccess: true }) + .catch(() => null); + for (const doc of docs?.docs ?? []) { + await payload.delete({ collection, id: doc.id, overrideAccess: true }).catch(() => {}); + } + } + await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {}); + } + await payload.delete({ collection: "roles", id: roleId, overrideAccess: true }).catch(() => {}); + await payload.delete({ collection: "maps", id: mapId, overrideAccess: true }).catch(() => {}); + }); + + it("lets the creator and missions:update holders edit, nobody else", async () => { + expect(await canEditMissionPlan(payload, creator, mission)).toBe(true); + expect(await canEditMissionPlan(payload, editor, mission)).toBe(true); + expect(await canEditMissionPlan(payload, outsider, mission)).toBe(false); + expect(await canEditMissionPlan(payload, null, mission)).toBe(false); + }); + + it("finds unit-visible missions with the world size", async () => { + const visible = await findVisibleMission(payload, outsider, mission.id); + expect(visible?.id).toBe(mission.id); + expect(visible?.classification.planningMapEnabled).toBe(true); + expect(await findVisibleMission(payload, outsider, -1)).toBeNull(); + }); + + it("creates, updates, lists and deletes markers within one mission", async () => { + const icon = await createMissionMarker( + payload, + mission, + { + shape: "icon", + markerType: "o_armor", + color: "Default", + text: "Tank", + position: [500, 600], + }, + creator.id, + ); + expect(icon.markerType).toBe("o_armor"); + + const area = await createMissionMarker( + payload, + mission, + { + shape: "rectangle", + brush: "Grid", + color: "ColorRed", + position: [1000, 1000], + size: [200, 50], + direction: 30, + }, + editor.id, + ); + const moved = await updateMissionMarker(payload, mission, area.id, { + ...area, + position: [1100, 900], + }); + expect(moved.position).toEqual([1100, 900]); + expect(moved.size).toEqual([200, 50]); + expect(moved.direction).toBe(30); + + // Outside the 4096 m world (+2 km margin): rejected. + await expect( + updateMissionMarker(payload, mission, area.id, { ...area, position: [9000, 9000] }), + ).rejects.toThrow("on the map"); + + const listed = await listMissionMarkers(payload, mission.id); + expect(listed.map((marker) => marker.id)).toEqual([icon.id, area.id]); + + // A marker id from another mission cannot be touched through this one. + const other = await makeMission("Other", null); + try { + await expect(deleteMissionMarker(payload, other.id, icon.id)).rejects.toThrow( + "Marker not found", + ); + } finally { + await payload.delete({ collection: "missions", id: other.id, overrideAccess: true }); + } + + await deleteMissionMarker(payload, mission.id, icon.id); + expect((await listMissionMarkers(payload, mission.id)).map((m) => m.id)).toEqual([area.id]); + expect(await clearMissionMarkers(payload, mission.id)).toBe(1); + expect(await listMissionMarkers(payload, mission.id)).toEqual([]); + }); + + it("removes a mission's markers when the mission is deleted", async () => { + const doomed = await makeMission("Doomed", creator.id); + const marker = await createMissionMarker( + payload, + doomed, + { + shape: "polyline", + color: "ColorBlack", + points: [ + [1, 1], + [50, 50], + ], + }, + creator.id, + ); + await payload.delete({ collection: "missions", id: doomed.id, overrideAccess: true }); + const left = await payload + .findByID({ collection: "mission-map-markers", id: marker.id, overrideAccess: true }) + .catch(() => null); + expect(left).toBeNull(); + }); + + it("keeps the collection closed to plain users over REST/local access", async () => { + await expect( + payload.find({ + collection: "mission-map-markers", + user: outsider, + overrideAccess: false, + }), + ).rejects.toThrow(); + }); +});