feat(missions): add a per-mission planning map with Arma markers
- New mission-map-markers collection (permission-gated, removed with its mission via a Missions beforeDelete hook) plus an opt-in classification.planningMapEnabled flag on missions. - The mission page gains a planning section: place and edit Arma markers (icons, ellipses, rectangles, polylines) with debounced saves, an SQF copy export, and an Arma grid readout. Everyone who can read the mission sees the plan once enabled; editing requires missions:update or mission ownership. - Base layers come from GET /api/map-features?layers=base, which skips the structure, node and shipment queries; marker icons render tinted by color exactly like in-game. - Includes the hand-written migration, the missionMap service lib, server actions emitting mission:map-marker-* events, and integration plus e2e coverage.
This commit is contained in:
parent
54e9a787cb
commit
1f230c0868
27 changed files with 3669 additions and 30 deletions
|
|
@ -41,6 +41,8 @@ import {
|
||||||
getUserAttendance,
|
getUserAttendance,
|
||||||
isMissionPast,
|
isMissionPast,
|
||||||
} from "@/lib/attendance";
|
} from "@/lib/attendance";
|
||||||
|
import { MissionPlanningMap } from "@/components/frontend/intelligence/missionMap/MissionPlanningMap";
|
||||||
|
import { listMissionMarkers } from "@/lib/intelligence/missionMap";
|
||||||
|
|
||||||
interface MissionDetailPageProps {
|
interface MissionDetailPageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
|
|
@ -114,7 +116,7 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
|
||||||
mission.createdBy !== null
|
mission.createdBy !== null
|
||||||
? (mission.createdBy as User)
|
? (mission.createdBy as User)
|
||||||
: null;
|
: 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 durationHours = Math.floor(mission.classification.estimatedDuration / 60);
|
||||||
const durationMins = 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 }));
|
sideOpCampaigns = campaignsRes.docs.map((c) => ({ id: c.id, name: c.name }));
|
||||||
sideOpModLists = modListsRes.docs.map((l) => ({ id: l.id, name: l.modlistName }));
|
sideOpModLists = modListsRes.docs.map((l) => ({ id: l.id, name: l.modlistName }));
|
||||||
const currentCampaignId =
|
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.id
|
||||||
: mission.classification.campaign;
|
: mission.classification.campaign;
|
||||||
if (
|
if (currentCampaignId != null && !sideOpCampaigns.some((c) => c.id === currentCampaignId)) {
|
||||||
currentCampaignId != null &&
|
|
||||||
!sideOpCampaigns.some((c) => c.id === currentCampaignId)
|
|
||||||
) {
|
|
||||||
sideOpCampaigns = [
|
sideOpCampaigns = [
|
||||||
...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 providedRoles = mission.missionRoles?.providedRoles ?? [];
|
||||||
const signupGate = roleSignupGate(mission);
|
const signupGate = roleSignupGate(mission);
|
||||||
const signupState = await getMissionRoleSignupState(payload, mission.id, currentUser?.id ?? null);
|
const signupState = await getMissionRoleSignupState(payload, mission.id, currentUser?.id ?? null);
|
||||||
|
|
@ -413,6 +421,21 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="p-5 flex flex-col gap-6 max-w-7xl mx-auto w-full">
|
<div className="p-5 flex flex-col gap-6 max-w-7xl mx-auto w-full">
|
||||||
|
{showPlanningMap && map && (
|
||||||
|
<MissionPlanningMap
|
||||||
|
missionId={mission.id}
|
||||||
|
missionLabel={mission.codeName || mission.name}
|
||||||
|
enabled={planningMapEnabled}
|
||||||
|
canEdit={canEditPlan}
|
||||||
|
map={{
|
||||||
|
id: map.id,
|
||||||
|
name: map.name,
|
||||||
|
worldWidth: Number(map.worldSizeWidth ?? 8192),
|
||||||
|
worldHeight: Number(map.worldSizeHeight ?? 8192),
|
||||||
|
}}
|
||||||
|
initialMarkers={planMarkers}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{/* Attendance sits right under the hero on stacked (below-xl) layouts */}
|
{/* Attendance sits right under the hero on stacked (below-xl) layouts */}
|
||||||
<div className="xl:hidden">{attendanceCard}</div>
|
<div className="xl:hidden">{attendanceCard}</div>
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||||
|
|
|
||||||
169
src/app/(frontend)/intelligence/missions/mapActions.ts
Normal file
169
src/app/(frontend)/intelligence/missions/mapActions.ts
Normal file
|
|
@ -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<T = undefined> {
|
||||||
|
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<ActionResult> {
|
||||||
|
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<ActionResult<MissionMarker[]>> {
|
||||||
|
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<ActionResult<MissionMarker>> {
|
||||||
|
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<ActionResult> {
|
||||||
|
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<ActionResult<{ removed: number }>> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3621,3 +3621,90 @@
|
||||||
.book-view-body [style*="text-align"] {
|
.book-view-body [style*="text-align"] {
|
||||||
text-align: left !important;
|
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%;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,15 +69,24 @@ export async function GET(req: NextRequest) {
|
||||||
const worldW = Number(mapDoc.worldSizeWidth ?? 8192);
|
const worldW = Number(mapDoc.worldSizeWidth ?? 8192);
|
||||||
const worldH = Number(mapDoc.worldSizeHeight ?? 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([
|
const [structuresRes, nodesRes, roadsRes, zonesRes, shipmentsRes] = await Promise.all([
|
||||||
payload.find({
|
baseOnly
|
||||||
|
? none
|
||||||
|
: payload.find({
|
||||||
collection: "game-structures",
|
collection: "game-structures",
|
||||||
where: { map: { equals: mapId } },
|
where: { map: { equals: mapId } },
|
||||||
depth: 2,
|
depth: 2,
|
||||||
limit: 1000,
|
limit: 1000,
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
}),
|
}),
|
||||||
payload.find({
|
baseOnly
|
||||||
|
? none
|
||||||
|
: payload.find({
|
||||||
collection: "resource-nodes",
|
collection: "resource-nodes",
|
||||||
where: { map: { equals: mapId } },
|
where: { map: { equals: mapId } },
|
||||||
depth: 1,
|
depth: 1,
|
||||||
|
|
@ -98,7 +107,9 @@ export async function GET(req: NextRequest) {
|
||||||
limit: 5000,
|
limit: 5000,
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
}),
|
}),
|
||||||
payload.find({
|
baseOnly
|
||||||
|
? none
|
||||||
|
: payload.find({
|
||||||
collection: "shipments",
|
collection: "shipments",
|
||||||
where: {
|
where: {
|
||||||
and: [{ map: { equals: mapId } }, { status: { in: ["dispatched", "in_transit"] } }],
|
and: [{ map: { equals: mapId } }, { status: { in: ["dispatched", "in_transit"] } }],
|
||||||
|
|
|
||||||
111
src/collections/intelligence/MissionMapMarkers.ts
Normal file
111
src/collections/intelligence/MissionMapMarkers.ts
Normal file
|
|
@ -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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -89,6 +89,29 @@ export const Missions: CollectionConfig = {
|
||||||
useAsTitle: "name",
|
useAsTitle: "name",
|
||||||
},
|
},
|
||||||
hooks: {
|
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: [
|
beforeChange: [
|
||||||
// Loop-safe by construction: this mutates the incoming data of the very
|
// 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
|
// operation it runs in, so it never triggers a second write. It only
|
||||||
|
|
@ -339,6 +362,16 @@ export const Missions: CollectionConfig = {
|
||||||
relationTo: "maps",
|
relationTo: "maps",
|
||||||
required: true,
|
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",
|
name: "missionType",
|
||||||
type: "select",
|
type: "select",
|
||||||
|
|
|
||||||
|
|
@ -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<MissionMarker["shape"], string> = {
|
||||||
|
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 (
|
||||||
|
<Label className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
|
||||||
|
{children}
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<MarkerStyle> & { 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 (
|
||||||
|
<div className="flex flex-col gap-4 p-3">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<FieldLabel>Tool</FieldLabel>
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
value={tool}
|
||||||
|
onValueChange={(value) => value && onToolChange(value as PlanTool)}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{TOOLS.map((entry) => (
|
||||||
|
<ToggleGroupItem
|
||||||
|
key={entry.value}
|
||||||
|
value={entry.value}
|
||||||
|
aria-label={entry.label}
|
||||||
|
title={entry.label}
|
||||||
|
>
|
||||||
|
<entry.icon />
|
||||||
|
</ToggleGroupItem>
|
||||||
|
))}
|
||||||
|
</ToggleGroup>
|
||||||
|
<p className="text-xs leading-snug text-muted-foreground">{toolInfo.hint}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between border-t border-white/10 pt-3">
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-widest text-white/70">
|
||||||
|
{selected
|
||||||
|
? `Selected // ${SHAPE_LABELS[selected.shape]}`
|
||||||
|
: shape
|
||||||
|
? `New ${SHAPE_LABELS[shape].toLowerCase()}`
|
||||||
|
: "No selection"}
|
||||||
|
</span>
|
||||||
|
{selected && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-red-400 hover:text-red-300"
|
||||||
|
onClick={onDeleteSelected}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-3.5" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!shape && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Pick a placement tool, or select a marker on the map to edit it.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isIcon && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<FieldLabel>Type</FieldLabel>
|
||||||
|
<MarkerTypePicker
|
||||||
|
value={markerType}
|
||||||
|
color={color}
|
||||||
|
onChange={(value) => apply({ markerType: value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{shape && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<FieldLabel>Color</FieldLabel>
|
||||||
|
<div className="grid grid-cols-9 gap-1">
|
||||||
|
{ARMA_MARKER_COLORS.map((entry) => {
|
||||||
|
const swatch = rgbaToHex(
|
||||||
|
resolveMarkerRgba(entry.className, isIcon ? markerType : null),
|
||||||
|
);
|
||||||
|
const active = entry.className.toLowerCase() === color.toLowerCase();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={entry.className}
|
||||||
|
type="button"
|
||||||
|
title={entry.name}
|
||||||
|
aria-label={entry.name}
|
||||||
|
aria-pressed={active}
|
||||||
|
onClick={() => apply({ color: entry.className })}
|
||||||
|
className={cn(
|
||||||
|
"relative aspect-square border border-white/20",
|
||||||
|
active && "ring-2 ring-cyan-400 ring-offset-1 ring-offset-background",
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: swatch }}
|
||||||
|
>
|
||||||
|
{entry.className === "Default" && (
|
||||||
|
<span className="absolute inset-0 flex items-center justify-center font-mono text-[8px] font-bold text-white mix-blend-difference">
|
||||||
|
D
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{ARMA_MARKER_COLORS.find(
|
||||||
|
(entry) => entry.className.toLowerCase() === color.toLowerCase(),
|
||||||
|
)?.name ?? color}
|
||||||
|
{isIcon && color === "Default" && markerTypeOf(markerType)
|
||||||
|
? " (marker's own color)"
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isArea && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<FieldLabel>Brush</FieldLabel>
|
||||||
|
<Select value={brush} onValueChange={(value) => apply({ brush: value })}>
|
||||||
|
<SelectTrigger className="h-8 w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ARMA_MARKER_BRUSHES.map((entry) => (
|
||||||
|
<SelectItem key={entry.className} value={entry.className}>
|
||||||
|
{entry.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{shape && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<FieldLabel>Text</FieldLabel>
|
||||||
|
<Input
|
||||||
|
value={text}
|
||||||
|
maxLength={MAX_MARKER_TEXT}
|
||||||
|
placeholder={isIcon ? "Label shown beside the icon" : "Shown on hover"}
|
||||||
|
onChange={(event) => apply({ text: event.target.value })}
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isIcon && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<FieldLabel>Scale</FieldLabel>
|
||||||
|
<span className="font-mono text-xs tabular-nums text-white/70">
|
||||||
|
{iconScale.toFixed(2)}x
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
min={MIN_ICON_SCALE}
|
||||||
|
max={Math.min(4, MAX_ICON_SCALE)}
|
||||||
|
step={0.25}
|
||||||
|
value={[iconScale]}
|
||||||
|
onValueChange={([value]) => apply({ iconScale: value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isArea && selected && (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<FieldLabel>Width (m)</FieldLabel>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={MIN_AREA_SIZE * 2}
|
||||||
|
max={MAX_AREA_SIZE * 2}
|
||||||
|
value={Math.round(selected.size[0] * 2)}
|
||||||
|
onChange={(event) =>
|
||||||
|
apply({
|
||||||
|
size: [
|
||||||
|
clampNumber(event.target.value, MIN_AREA_SIZE * 2, MAX_AREA_SIZE * 2, 2) / 2,
|
||||||
|
selected.size[1],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<FieldLabel>Height (m)</FieldLabel>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={MIN_AREA_SIZE * 2}
|
||||||
|
max={MAX_AREA_SIZE * 2}
|
||||||
|
value={Math.round(selected.size[1] * 2)}
|
||||||
|
onChange={(event) =>
|
||||||
|
apply({
|
||||||
|
size: [
|
||||||
|
selected.size[0],
|
||||||
|
clampNumber(event.target.value, MIN_AREA_SIZE * 2, MAX_AREA_SIZE * 2, 2) / 2,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(isIcon || isArea) && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<FieldLabel>Direction</FieldLabel>
|
||||||
|
<span className="font-mono text-xs tabular-nums text-white/70">
|
||||||
|
{Math.round(direction)}°
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
min={0}
|
||||||
|
max={359}
|
||||||
|
step={1}
|
||||||
|
value={[direction]}
|
||||||
|
onValueChange={([value]) => apply({ direction: value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{shape && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<FieldLabel>Opacity</FieldLabel>
|
||||||
|
<span className="font-mono text-xs tabular-nums text-white/70">
|
||||||
|
{Math.round(alpha * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
min={0.1}
|
||||||
|
max={1}
|
||||||
|
step={0.05}
|
||||||
|
value={[alpha]}
|
||||||
|
onValueChange={([value]) => apply({ alpha: value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLine && !selected && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Lines take the color, text and opacity above.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 <span className={cn("block border border-dashed border-white/30", className)} />;
|
||||||
|
const hex = rgbaToHex(resolveMarkerRgba(colorClass, typeClass));
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn("block", className)}
|
||||||
|
style={armaIconReactStyle(type.iconUrl, hex)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Popover
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
setOpen(next);
|
||||||
|
if (!next) setQuery("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-10 w-full justify-start gap-2 px-2"
|
||||||
|
aria-label="Marker type"
|
||||||
|
>
|
||||||
|
<span className="flex size-7 shrink-0 items-center justify-center rounded-sm bg-white/90">
|
||||||
|
<MarkerGlyph typeClass={value} colorClass={color} className="size-6" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-left text-sm">
|
||||||
|
{selected?.name ?? value}
|
||||||
|
</span>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-60" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-80 p-0" align="start">
|
||||||
|
<div className="border-b p-2">
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
placeholder="Search markers..."
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-80 overflow-y-auto p-2">
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<p className="py-6 text-center text-xs text-muted-foreground">No markers match.</p>
|
||||||
|
)}
|
||||||
|
{filtered.map((group) => (
|
||||||
|
<div key={group.category.className} className="mb-2 last:mb-0">
|
||||||
|
<div className="mb-1 font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
|
||||||
|
{group.category.name}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-1">
|
||||||
|
{group.types.map((type) => (
|
||||||
|
<button
|
||||||
|
key={type.className}
|
||||||
|
type="button"
|
||||||
|
title={`${type.name} (${type.className})`}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(type.className);
|
||||||
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex aspect-square items-center justify-center rounded-sm border bg-white/90 p-1 transition-colors hover:border-cyan-400",
|
||||||
|
type.className === selected?.className
|
||||||
|
? "border-cyan-400 ring-1 ring-cyan-400"
|
||||||
|
: "border-transparent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MarkerGlyph
|
||||||
|
typeClass={type.className}
|
||||||
|
colorClass={color}
|
||||||
|
className="size-full"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<string, L.PathOptions> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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: '<span class="mission-map-handle-dot"></span>',
|
||||||
|
iconSize: [12, 12],
|
||||||
|
iconAnchor: [6, 6],
|
||||||
|
});
|
||||||
|
|
||||||
|
const moveHandleIcon = L.divIcon({
|
||||||
|
className: "mission-map-handle",
|
||||||
|
html: '<span class="mission-map-handle-dot mission-map-handle-move"></span>',
|
||||||
|
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<number>;
|
||||||
|
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<MapPoint | null>(null);
|
||||||
|
const [dragStart, setDragStartState] = useState<MapPoint | null>(null);
|
||||||
|
const dragStartRef = useRef<MapPoint | null>(null);
|
||||||
|
const setDragStart = useCallback((next: MapPoint | null) => {
|
||||||
|
dragStartRef.current = next;
|
||||||
|
setDragStartState(next);
|
||||||
|
}, []);
|
||||||
|
const [linePoints, setLinePointsState] = useState<MapPoint[]>([]);
|
||||||
|
// 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<MapPoint[]>([]);
|
||||||
|
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 && <Polygon positions={areaDraft} pathOptions={DRAFT_PATH} interactive={false} />}
|
||||||
|
{lineDraft && <Polyline positions={lineDraft} pathOptions={DRAFT_PATH} interactive={false} />}
|
||||||
|
{lineTool &&
|
||||||
|
linePoints.map((point, index) => (
|
||||||
|
<Marker
|
||||||
|
key={`draft-${index}`}
|
||||||
|
position={toLatLng(point)}
|
||||||
|
icon={handleIcon}
|
||||||
|
interactive={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<L.PathOptions>(
|
||||||
|
() => ({
|
||||||
|
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<L.PathOptions>(
|
||||||
|
() => ({ color: hex, weight: 1.25, opacity: marker.alpha, interactive: false }),
|
||||||
|
[hex, marker.alpha],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Polygon
|
||||||
|
positions={positions}
|
||||||
|
pathOptions={pathOptions}
|
||||||
|
eventHandlers={{ click: () => onLayerClick(marker.id) }}
|
||||||
|
>
|
||||||
|
{marker.text && (
|
||||||
|
<Tooltip direction="top" opacity={1} className="map-tooltip-dossier" sticky>
|
||||||
|
{marker.text}
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Polygon>
|
||||||
|
{hatches.length > 0 && <Polyline positions={hatches} pathOptions={hatchOptions} />}
|
||||||
|
{selected && <Polygon positions={positions} pathOptions={SELECTED_OUTLINE} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<>
|
||||||
|
<Marker
|
||||||
|
position={toLatLng(marker.position)}
|
||||||
|
icon={moveHandleIcon}
|
||||||
|
draggable
|
||||||
|
eventHandlers={{
|
||||||
|
drag: (event) =>
|
||||||
|
onChange(
|
||||||
|
{ ...marker, position: fromLatLng((event.target as L.Marker).getLatLng()) },
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
dragend: (event) =>
|
||||||
|
onChange(
|
||||||
|
{ ...marker, position: fromLatLng((event.target as L.Marker).getLatLng()) },
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Marker
|
||||||
|
position={toLatLng(corner)}
|
||||||
|
icon={handleIcon}
|
||||||
|
draggable
|
||||||
|
eventHandlers={{
|
||||||
|
drag: (event) => 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) => (
|
||||||
|
<Marker
|
||||||
|
key={`vertex-${index}`}
|
||||||
|
position={toLatLng(point)}
|
||||||
|
icon={handleIcon}
|
||||||
|
draggable
|
||||||
|
eventHandlers={{
|
||||||
|
drag: (event) => 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<MapFeaturesResponse | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="flex size-full items-center justify-center text-sm text-red-400">{error}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MapContainer
|
||||||
|
key={`${mapId}-${worldW}x${worldH}`}
|
||||||
|
center={center}
|
||||||
|
zoom={mapConfig?.defaultZoom ?? 2}
|
||||||
|
minZoom={mapConfig?.minZoom ?? 0}
|
||||||
|
maxZoom={mapConfig?.maxZoom ?? 12}
|
||||||
|
maxBounds={L.latLngBounds(L.latLng(-worldH - 2000, -2000), L.latLng(2000, worldW + 2000))}
|
||||||
|
maxBoundsViscosity={1}
|
||||||
|
zoomSnap={0.25}
|
||||||
|
crs={mapCrs}
|
||||||
|
className="size-full"
|
||||||
|
preferCanvas
|
||||||
|
attributionControl={false}
|
||||||
|
>
|
||||||
|
{mapConfig && <DefaultView center={center} zoom={mapConfig.defaultZoom} />}
|
||||||
|
{mapConfig?.basemapMode === "tiles" && mapConfig.tileUrlTemplate && (
|
||||||
|
<TileLayer
|
||||||
|
url={mapConfig.tileUrlTemplate}
|
||||||
|
minZoom={mapConfig.minZoom}
|
||||||
|
maxZoom={mapConfig.maxZoom}
|
||||||
|
bounds={worldBounds}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{mapConfig?.basemapMode === "image" && mapConfig.basemapImageUrl && (
|
||||||
|
<ImageOverlay url={mapConfig.basemapImageUrl} bounds={worldBounds} />
|
||||||
|
)}
|
||||||
|
{mapConfig &&
|
||||||
|
!(
|
||||||
|
(mapConfig.basemapMode === "image" && mapConfig.basemapImageUrl) ||
|
||||||
|
(mapConfig.basemapMode === "tiles" && mapConfig.tileUrlTemplate)
|
||||||
|
) && <Rectangle bounds={worldBounds} pathOptions={BASEMAP_FALLBACK} interactive={false} />}
|
||||||
|
|
||||||
|
{showZones &&
|
||||||
|
zonePolys.map(({ zone, positions, options }) => (
|
||||||
|
<Polygon key={`zone-${zone.id}`} positions={positions} pathOptions={options} />
|
||||||
|
))}
|
||||||
|
{showRoads &&
|
||||||
|
roadLines.map(({ road, positions, options }) => (
|
||||||
|
<Polyline key={`road-${road.id}`} positions={positions} pathOptions={options} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{markers.map((marker) => {
|
||||||
|
if (marker.shape === "rectangle" || marker.shape === "ellipse") {
|
||||||
|
return (
|
||||||
|
<AreaMarker
|
||||||
|
key={`area-${marker.id}`}
|
||||||
|
marker={marker}
|
||||||
|
selected={marker.id === selectedId}
|
||||||
|
worldSize={worldSize}
|
||||||
|
onLayerClick={handleLayerClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (marker.shape === "polyline") {
|
||||||
|
const hex = rgbaToHex(resolveMarkerRgba(marker.color));
|
||||||
|
return (
|
||||||
|
<Fragment key={`line-${marker.id}`}>
|
||||||
|
<Polyline
|
||||||
|
positions={marker.points.map(toLatLng)}
|
||||||
|
pathOptions={{ color: hex, weight: 3, opacity: marker.alpha }}
|
||||||
|
eventHandlers={{ click: () => handleLayerClick(marker.id) }}
|
||||||
|
>
|
||||||
|
{marker.text && (
|
||||||
|
<Tooltip direction="top" opacity={1} className="map-tooltip-dossier" sticky>
|
||||||
|
{marker.text}
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Polyline>
|
||||||
|
{marker.id === selectedId && (
|
||||||
|
<Polyline
|
||||||
|
positions={marker.points.map(toLatLng)}
|
||||||
|
pathOptions={{
|
||||||
|
...SELECTED_OUTLINE,
|
||||||
|
weight: 7,
|
||||||
|
opacity: 0.35,
|
||||||
|
dashArray: undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Icons render after areas/lines so they stay on top (marker pane). */}
|
||||||
|
{markers
|
||||||
|
.filter((marker) => marker.shape === "icon")
|
||||||
|
.map((marker) => (
|
||||||
|
<Marker
|
||||||
|
key={`icon-${marker.id}`}
|
||||||
|
position={toLatLng(marker.position)}
|
||||||
|
icon={markerDivIcon(marker, marker.id === selectedId)}
|
||||||
|
draggable={canEdit && tool === "select"}
|
||||||
|
eventHandlers={{
|
||||||
|
click: () => 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") && (
|
||||||
|
<AreaHandles marker={selected} onChange={onChange} />
|
||||||
|
)}
|
||||||
|
{editingSelected && selected.shape === "polyline" && (
|
||||||
|
<LineHandles marker={selected} onChange={onChange} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PlanInteractions
|
||||||
|
tool={tool}
|
||||||
|
canEdit={canEdit}
|
||||||
|
style={style}
|
||||||
|
worldSize={worldSize}
|
||||||
|
layerClickAt={layerClickAt}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onCreate={onCreate}
|
||||||
|
onCursor={onCursor}
|
||||||
|
/>
|
||||||
|
</MapContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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: () => (
|
||||||
|
<div className="flex size-full items-center justify-center font-mono text-xs uppercase tracking-widest text-white/50">
|
||||||
|
Loading map...
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export function MissionMapLoader(props: MissionMapCanvasProps) {
|
||||||
|
return <MissionMapCanvas {...props} />;
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<>
|
||||||
|
<span aria-hidden className={`${base} left-2 top-2 border-l border-t`} />
|
||||||
|
<span aria-hidden className={`${base} right-2 top-2 border-r border-t`} />
|
||||||
|
<span aria-hidden className={`${base} bottom-2 left-2 border-b border-l`} />
|
||||||
|
<span aria-hidden className={`${base} bottom-2 right-2 border-b border-r`} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReadoutCell({ value, label }: { value: string; label: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-1 items-baseline justify-center gap-1.5 border-r border-white/15 px-2 last:border-r-0">
|
||||||
|
<span className="truncate font-mono text-xs tabular-nums text-white/80">{value}</span>
|
||||||
|
<span className="text-[9px] uppercase tracking-widest text-white/50">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<MissionMarker[]>(initialMarkers);
|
||||||
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
|
const [tool, setTool] = useState<PlanTool>("select");
|
||||||
|
const [style, setStyle] = useState<MarkerStyle>(DEFAULT_STYLE);
|
||||||
|
const [cursor, setCursor] = useState<MapPoint | null>(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<number, ReturnType<typeof setTimeout>>());
|
||||||
|
const inflight = useRef(new Map<number, Promise<unknown>>());
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-dashed border-white/15 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<MapIcon className="size-4 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Planning map</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Show {map.name} on this page and plan the operation with Arma 3 map markers. Markers
|
||||||
|
only appear on this mission.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={() => void toggleEnabled(true)} disabled={toggling}>
|
||||||
|
Enable planning map
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label="Mission planning map"
|
||||||
|
className="group relative overflow-hidden rounded-none border border-white/15 bg-black/40"
|
||||||
|
data-tour="mission-planning-map"
|
||||||
|
>
|
||||||
|
{/* Classification strip */}
|
||||||
|
<div className="flex items-center justify-between gap-3 border-b border-white/15 bg-black/50 px-3 py-1.5 backdrop-blur-sm">
|
||||||
|
<span className="truncate font-mono text-[9px] uppercase tracking-widest text-white/60">
|
||||||
|
Planning map // {missionLabel}
|
||||||
|
</span>
|
||||||
|
<div className="flex shrink-0 items-center gap-3">
|
||||||
|
<label className="flex items-center gap-1.5">
|
||||||
|
<Switch size="sm" checked={showRoads} onCheckedChange={setShowRoads} id="plan-roads" />
|
||||||
|
<Label
|
||||||
|
htmlFor="plan-roads"
|
||||||
|
className="font-mono text-[9px] uppercase tracking-widest text-white/60"
|
||||||
|
>
|
||||||
|
Roads
|
||||||
|
</Label>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1.5">
|
||||||
|
<Switch size="sm" checked={showZones} onCheckedChange={setShowZones} id="plan-zones" />
|
||||||
|
<Label
|
||||||
|
htmlFor="plan-zones"
|
||||||
|
className="font-mono text-[9px] uppercase tracking-widest text-white/60"
|
||||||
|
>
|
||||||
|
Zones
|
||||||
|
</Label>
|
||||||
|
</label>
|
||||||
|
<span className="hidden font-mono text-[9px] uppercase tracking-widest text-white/60 sm:inline">
|
||||||
|
{map.name} // REF {ref}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col lg:h-[620px] lg:flex-row">
|
||||||
|
<div
|
||||||
|
className="map-host relative h-[480px] min-w-0 flex-1 lg:h-full"
|
||||||
|
data-mode={canEdit && tool !== "select" ? "crosshair" : undefined}
|
||||||
|
>
|
||||||
|
<Corners />
|
||||||
|
<MissionMapLoader
|
||||||
|
mapId={map.id}
|
||||||
|
markers={markers}
|
||||||
|
selectedId={selectedId}
|
||||||
|
tool={canEdit ? tool : "select"}
|
||||||
|
style={style}
|
||||||
|
canEdit={canEdit}
|
||||||
|
showRoads={showRoads}
|
||||||
|
showZones={showZones}
|
||||||
|
onSelect={setSelectedId}
|
||||||
|
onCreate={(data) => void handleCreate(data)}
|
||||||
|
onChange={handleChange}
|
||||||
|
onCursor={setCursor}
|
||||||
|
onLoaded={handleLoaded}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
|
<aside className="flex w-full flex-col border-t border-white/15 bg-background/80 lg:h-full lg:w-72 lg:border-l lg:border-t-0">
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
<MarkerToolPanel
|
||||||
|
tool={tool}
|
||||||
|
onToolChange={(next) => {
|
||||||
|
setTool(next);
|
||||||
|
if (next !== "select") setSelectedId(null);
|
||||||
|
}}
|
||||||
|
style={style}
|
||||||
|
onStyleChange={setStyle}
|
||||||
|
selected={selected}
|
||||||
|
onSelectedChange={handlePanelChange}
|
||||||
|
onDeleteSelected={() => selectedId != null && void handleDelete(selectedId)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 border-t border-white/15 p-3">
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" disabled={markers.length === 0}>
|
||||||
|
<EraserIcon />
|
||||||
|
Clear all
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Clear the planning map?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This removes all {markers.length} markers from this mission. It cannot be
|
||||||
|
undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => void clearAll()}>
|
||||||
|
Clear markers
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void toggleEnabled(false)}
|
||||||
|
disabled={toggling}
|
||||||
|
title="Hide the planning map from the mission page (markers are kept)"
|
||||||
|
>
|
||||||
|
<PowerOffIcon />
|
||||||
|
Disable
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Readout footer */}
|
||||||
|
<div className="flex items-center border-t border-white/15 bg-black/60 py-1.5 backdrop-blur-sm">
|
||||||
|
<ReadoutCell value={String(counts.icons)} label="Icons" />
|
||||||
|
<ReadoutCell value={String(counts.areas)} label="Areas" />
|
||||||
|
<ReadoutCell value={String(counts.lines)} label="Lines" />
|
||||||
|
<ReadoutCell value={cursor ? armaGridRef(cursor, world.height) : "--- ---"} label="Grid" />
|
||||||
|
<ReadoutCell
|
||||||
|
value={`${(Math.max(world.width, world.height) / 1000).toFixed(1)} km`}
|
||||||
|
label="World"
|
||||||
|
/>
|
||||||
|
<div className="flex shrink-0 justify-center px-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 font-mono text-[10px] uppercase tracking-widest text-white/70"
|
||||||
|
onClick={() => void exportSqf()}
|
||||||
|
disabled={markers.length === 0}
|
||||||
|
title="Copy a createMarker script that recreates this plan in Arma 3"
|
||||||
|
>
|
||||||
|
<ClipboardCopyIcon className="size-3" />
|
||||||
|
SQF
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/components/frontend/intelligence/missionMap/iconStyle.ts
Normal file
54
src/components/frontend/intelligence/missionMap/iconStyle.ts
Normal file
|
|
@ -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<string, string> {
|
||||||
|
// 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<string, string> = {};
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
@ -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, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = new Map<string, L.DivIcon>();
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
? `<span class="arma-marker-glyph${type.shadow ? " arma-marker-shadow" : ""}" style="width:${width}px;height:${height}px;transform:rotate(${marker.direction}deg)"><span style="${armaIconStyle(type.iconUrl, hex)};display:block;width:100%;height:100%"></span></span>`
|
||||||
|
: `<span class="arma-marker-glyph${type ? "" : " arma-marker-missing"}" style="width:${width}px;height:${height}px"></span>`;
|
||||||
|
// Arma draws the text to the right of the icon, in the marker color.
|
||||||
|
const text = marker.text
|
||||||
|
? `<span class="arma-marker-text ${textHaloClass(hex)}" style="color:${hex};left:${Math.round(width / 2) + 3}px">${escapeHtml(marker.text)}</span>`
|
||||||
|
: "";
|
||||||
|
const icon = L.divIcon({
|
||||||
|
className: `arma-marker${selected ? " arma-marker-selected" : ""}`,
|
||||||
|
html: `<div class="arma-marker-body" style="opacity:${marker.alpha}">${glyph}${text}</div>`,
|
||||||
|
iconSize: [width, height],
|
||||||
|
iconAnchor: [width / 2, height / 2],
|
||||||
|
});
|
||||||
|
cache.set(key, icon);
|
||||||
|
return icon;
|
||||||
|
}
|
||||||
12
src/components/frontend/intelligence/missionMap/types.ts
Normal file
12
src/components/frontend/intelligence/missionMap/types.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
63
src/components/ui/slider.tsx
Normal file
63
src/components/ui/slider.tsx
Normal file
|
|
@ -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<typeof SliderPrimitive.Root>) {
|
||||||
|
const _values = React.useMemo(
|
||||||
|
() =>
|
||||||
|
Array.isArray(value)
|
||||||
|
? value
|
||||||
|
: Array.isArray(defaultValue)
|
||||||
|
? defaultValue
|
||||||
|
: [min, max],
|
||||||
|
[value, defaultValue, min, max]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
data-slot="slider"
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
value={value}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Track
|
||||||
|
data-slot="slider-track"
|
||||||
|
className={cn(
|
||||||
|
"relative grow overflow-hidden rounded-full bg-muted data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Range
|
||||||
|
data-slot="slider-range"
|
||||||
|
className={cn(
|
||||||
|
"absolute bg-primary data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
{Array.from({ length: _values.length }, (_, index) => (
|
||||||
|
<SliderPrimitive.Thumb
|
||||||
|
data-slot="slider-thumb"
|
||||||
|
key={index}
|
||||||
|
className="block size-4 shrink-0 rounded-full border border-primary bg-white shadow-sm ring-ring/50 transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SliderPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Slider }
|
||||||
73
src/components/ui/toggle-group.tsx
Normal file
73
src/components/ui/toggle-group.tsx
Normal file
|
|
@ -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<typeof toggleVariants>
|
||||||
|
>({
|
||||||
|
size: "default",
|
||||||
|
variant: "default",
|
||||||
|
})
|
||||||
|
|
||||||
|
function ToggleGroup({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
||||||
|
VariantProps<typeof toggleVariants>) {
|
||||||
|
return (
|
||||||
|
<ToggleGroupPrimitive.Root
|
||||||
|
data-slot="toggle-group"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||||
|
{children}
|
||||||
|
</ToggleGroupContext.Provider>
|
||||||
|
</ToggleGroupPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleGroupItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
|
||||||
|
VariantProps<typeof toggleVariants>) {
|
||||||
|
const context = React.useContext(ToggleGroupContext)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToggleGroupPrimitive.Item
|
||||||
|
data-slot="toggle-group-item"
|
||||||
|
data-variant={context.variant || variant}
|
||||||
|
data-size={context.size || size}
|
||||||
|
className={cn(
|
||||||
|
toggleVariants({
|
||||||
|
variant: context.variant || variant,
|
||||||
|
size: context.size || size,
|
||||||
|
}),
|
||||||
|
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ToggleGroupPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ToggleGroup, ToggleGroupItem }
|
||||||
47
src/components/ui/toggle.tsx
Normal file
47
src/components/ui/toggle.tsx
Normal file
|
|
@ -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<typeof TogglePrimitive.Root> &
|
||||||
|
VariantProps<typeof toggleVariants>) {
|
||||||
|
return (
|
||||||
|
<TogglePrimitive.Root
|
||||||
|
data-slot="toggle"
|
||||||
|
className={cn(toggleVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toggle, toggleVariants }
|
||||||
169
src/lib/intelligence/missionMap.ts
Normal file
169
src/lib/intelligence/missionMap.ts
Normal file
|
|
@ -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<Mission, "createdBy">,
|
||||||
|
): Promise<boolean> {
|
||||||
|
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<Mission | null> {
|
||||||
|
if (!Number.isInteger(missionId) || missionId <= 0) return null;
|
||||||
|
return payload
|
||||||
|
.findByID({
|
||||||
|
collection: "missions",
|
||||||
|
id: missionId,
|
||||||
|
depth: 1,
|
||||||
|
user: user as Parameters<Payload["findByID"]>[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<MissionMarker[]> {
|
||||||
|
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<MissionMarker> {
|
||||||
|
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<MissionMarker> {
|
||||||
|
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<void> {
|
||||||
|
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<number> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
70
src/migrations/20261003_190000_add_mission_map_markers.ts
Normal file
70
src/migrations/20261003_190000_add_mission_map_markers.ts
Normal file
|
|
@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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";`)
|
||||||
|
}
|
||||||
|
|
@ -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_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_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_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 = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -618,4 +619,9 @@ export const migrations = [
|
||||||
down: migration_20261003_060154_change_ai_model_to_ornith.down,
|
down: migration_20261003_060154_change_ai_model_to_ornith.down,
|
||||||
name: '20261003_060154_change_ai_model_to_ornith'
|
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'
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -269,6 +269,11 @@ export const enum_roles_permissions = pgEnum("enum_roles_permissions", [
|
||||||
"mission-role-signups:update",
|
"mission-role-signups:update",
|
||||||
"mission-role-signups:delete",
|
"mission-role-signups:delete",
|
||||||
"admin:mission-role-signups:manage",
|
"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:create",
|
||||||
"campaigns:read",
|
"campaigns:read",
|
||||||
"campaigns:update",
|
"campaigns:update",
|
||||||
|
|
@ -634,6 +639,12 @@ export const enum_mission_attendances_response = pgEnum("enum_mission_attendance
|
||||||
"no",
|
"no",
|
||||||
"tentative",
|
"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", [
|
export const enum_campaigns_status = pgEnum("enum_campaigns_status", [
|
||||||
"concept",
|
"concept",
|
||||||
"inProgress",
|
"inProgress",
|
||||||
|
|
@ -675,6 +686,7 @@ export const enum_assets_asset_type = pgEnum("enum_assets_asset_type", [
|
||||||
"optic",
|
"optic",
|
||||||
"muzzle",
|
"muzzle",
|
||||||
"bipod",
|
"bipod",
|
||||||
|
"accessory",
|
||||||
"uniform",
|
"uniform",
|
||||||
"vest",
|
"vest",
|
||||||
"backpack",
|
"backpack",
|
||||||
|
|
@ -3093,6 +3105,9 @@ export const missions = pgTable(
|
||||||
.references(() => maps.id, {
|
.references(() => maps.id, {
|
||||||
onDelete: "set null",
|
onDelete: "set null",
|
||||||
}),
|
}),
|
||||||
|
classification_planningMapEnabled: boolean("classification_planning_map_enabled").default(
|
||||||
|
false,
|
||||||
|
),
|
||||||
classification_missionType: enum_missions_classification_mission_type(
|
classification_missionType: enum_missions_classification_mission_type(
|
||||||
"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(
|
export const campaigns = pgTable(
|
||||||
"campaigns",
|
"campaigns",
|
||||||
{
|
{
|
||||||
|
|
@ -7001,6 +7054,10 @@ export const payload_mcp_api_keys = pgTable(
|
||||||
missionRoleSignups_create: boolean("mission_role_signups_create").default(false),
|
missionRoleSignups_create: boolean("mission_role_signups_create").default(false),
|
||||||
missionRoleSignups_update: boolean("mission_role_signups_update").default(false),
|
missionRoleSignups_update: boolean("mission_role_signups_update").default(false),
|
||||||
missionRoleSignups_delete: boolean("mission_role_signups_delete").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_find: boolean("campaigns_find").default(false),
|
||||||
campaigns_create: boolean("campaigns_create").default(false),
|
campaigns_create: boolean("campaigns_create").default(false),
|
||||||
campaigns_update: boolean("campaigns_update").default(false),
|
campaigns_update: boolean("campaigns_update").default(false),
|
||||||
|
|
@ -7288,6 +7345,7 @@ export const payload_locked_documents_rels = pgTable(
|
||||||
missionsID: integer("missions_id"),
|
missionsID: integer("missions_id"),
|
||||||
"mission-attendancesID": integer("mission_attendances_id"),
|
"mission-attendancesID": integer("mission_attendances_id"),
|
||||||
"mission-role-signupsID": integer("mission_role_signups_id"),
|
"mission-role-signupsID": integer("mission_role_signups_id"),
|
||||||
|
"mission-map-markersID": integer("mission_map_markers_id"),
|
||||||
campaignsID: integer("campaigns_id"),
|
campaignsID: integer("campaigns_id"),
|
||||||
factionsID: integer("factions_id"),
|
factionsID: integer("factions_id"),
|
||||||
technologiesID: integer("technologies_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(
|
index("payload_locked_documents_rels_mission_role_signups_id_idx").on(
|
||||||
columns["mission-role-signupsID"],
|
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_campaigns_id_idx").on(columns.campaignsID),
|
||||||
index("payload_locked_documents_rels_factions_id_idx").on(columns.factionsID),
|
index("payload_locked_documents_rels_factions_id_idx").on(columns.factionsID),
|
||||||
index("payload_locked_documents_rels_technologies_id_idx").on(columns.technologiesID),
|
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],
|
foreignColumns: [mission_role_signups.id],
|
||||||
name: "payload_locked_documents_rels_mission_role_signups_fk",
|
name: "payload_locked_documents_rels_mission_role_signups_fk",
|
||||||
}).onDelete("cascade"),
|
}).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({
|
foreignKey({
|
||||||
columns: [columns["campaignsID"]],
|
columns: [columns["campaignsID"]],
|
||||||
foreignColumns: [campaigns.id],
|
foreignColumns: [campaigns.id],
|
||||||
|
|
@ -9023,6 +9089,18 @@ export const relations_mission_role_signups = relations(mission_role_signups, ({
|
||||||
relationName: "user",
|
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 }) => ({
|
export const relations_campaigns = relations(campaigns, ({ one }) => ({
|
||||||
coverImage: one(media, {
|
coverImage: one(media, {
|
||||||
fields: [campaigns.coverImage],
|
fields: [campaigns.coverImage],
|
||||||
|
|
@ -10756,6 +10834,11 @@ export const relations_payload_locked_documents_rels = relations(
|
||||||
references: [mission_role_signups.id],
|
references: [mission_role_signups.id],
|
||||||
relationName: "mission-role-signups",
|
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, {
|
campaignsID: one(campaigns, {
|
||||||
fields: [payload_locked_documents_rels.campaignsID],
|
fields: [payload_locked_documents_rels.campaignsID],
|
||||||
references: [campaigns.id],
|
references: [campaigns.id],
|
||||||
|
|
@ -11197,6 +11280,7 @@ type DatabaseSchema = {
|
||||||
enum_missions_ownership_and_status_visibility: typeof enum_missions_ownership_and_status_visibility;
|
enum_missions_ownership_and_status_visibility: typeof enum_missions_ownership_and_status_visibility;
|
||||||
enum_missions_civilian_presence: typeof enum_missions_civilian_presence;
|
enum_missions_civilian_presence: typeof enum_missions_civilian_presence;
|
||||||
enum_mission_attendances_response: typeof enum_mission_attendances_response;
|
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_status: typeof enum_campaigns_status;
|
||||||
enum_campaigns_campaign_mode: typeof enum_campaigns_campaign_mode;
|
enum_campaigns_campaign_mode: typeof enum_campaigns_campaign_mode;
|
||||||
enum_factions_type: typeof enum_factions_type;
|
enum_factions_type: typeof enum_factions_type;
|
||||||
|
|
@ -11337,6 +11421,7 @@ type DatabaseSchema = {
|
||||||
missions_rels: typeof missions_rels;
|
missions_rels: typeof missions_rels;
|
||||||
mission_attendances: typeof mission_attendances;
|
mission_attendances: typeof mission_attendances;
|
||||||
mission_role_signups: typeof mission_role_signups;
|
mission_role_signups: typeof mission_role_signups;
|
||||||
|
mission_map_markers: typeof mission_map_markers;
|
||||||
campaigns: typeof campaigns;
|
campaigns: typeof campaigns;
|
||||||
factions_relationships: typeof factions_relationships;
|
factions_relationships: typeof factions_relationships;
|
||||||
factions: typeof factions;
|
factions: typeof factions;
|
||||||
|
|
@ -11529,6 +11614,7 @@ type DatabaseSchema = {
|
||||||
relations_missions: typeof relations_missions;
|
relations_missions: typeof relations_missions;
|
||||||
relations_mission_attendances: typeof relations_mission_attendances;
|
relations_mission_attendances: typeof relations_mission_attendances;
|
||||||
relations_mission_role_signups: typeof relations_mission_role_signups;
|
relations_mission_role_signups: typeof relations_mission_role_signups;
|
||||||
|
relations_mission_map_markers: typeof relations_mission_map_markers;
|
||||||
relations_campaigns: typeof relations_campaigns;
|
relations_campaigns: typeof relations_campaigns;
|
||||||
relations_factions_relationships: typeof relations_factions_relationships;
|
relations_factions_relationships: typeof relations_factions_relationships;
|
||||||
relations_factions_rels: typeof relations_factions_rels;
|
relations_factions_rels: typeof relations_factions_rels;
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ export interface Config {
|
||||||
missions: Mission;
|
missions: Mission;
|
||||||
'mission-attendances': MissionAttendance;
|
'mission-attendances': MissionAttendance;
|
||||||
'mission-role-signups': MissionRoleSignup;
|
'mission-role-signups': MissionRoleSignup;
|
||||||
|
'mission-map-markers': MissionMapMarker;
|
||||||
campaigns: Campaign;
|
campaigns: Campaign;
|
||||||
factions: Faction;
|
factions: Faction;
|
||||||
technologies: Technology;
|
technologies: Technology;
|
||||||
|
|
@ -190,6 +191,7 @@ export interface Config {
|
||||||
missions: MissionsSelect<false> | MissionsSelect<true>;
|
missions: MissionsSelect<false> | MissionsSelect<true>;
|
||||||
'mission-attendances': MissionAttendancesSelect<false> | MissionAttendancesSelect<true>;
|
'mission-attendances': MissionAttendancesSelect<false> | MissionAttendancesSelect<true>;
|
||||||
'mission-role-signups': MissionRoleSignupsSelect<false> | MissionRoleSignupsSelect<true>;
|
'mission-role-signups': MissionRoleSignupsSelect<false> | MissionRoleSignupsSelect<true>;
|
||||||
|
'mission-map-markers': MissionMapMarkersSelect<false> | MissionMapMarkersSelect<true>;
|
||||||
campaigns: CampaignsSelect<false> | CampaignsSelect<true>;
|
campaigns: CampaignsSelect<false> | CampaignsSelect<true>;
|
||||||
factions: FactionsSelect<false> | FactionsSelect<true>;
|
factions: FactionsSelect<false> | FactionsSelect<true>;
|
||||||
technologies: TechnologiesSelect<false> | TechnologiesSelect<true>;
|
technologies: TechnologiesSelect<false> | TechnologiesSelect<true>;
|
||||||
|
|
@ -646,6 +648,11 @@ export interface Role {
|
||||||
| 'mission-role-signups:update'
|
| 'mission-role-signups:update'
|
||||||
| 'mission-role-signups:delete'
|
| 'mission-role-signups:delete'
|
||||||
| 'admin:mission-role-signups:manage'
|
| '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:create'
|
||||||
| 'campaigns:read'
|
| 'campaigns:read'
|
||||||
| 'campaigns:update'
|
| 'campaigns:update'
|
||||||
|
|
@ -2983,6 +2990,10 @@ export interface Mission {
|
||||||
coverImage?: (number | null) | Media;
|
coverImage?: (number | null) | Media;
|
||||||
classification: {
|
classification: {
|
||||||
map: number | Map;
|
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';
|
missionType: 'Zeus' | 'PvE' | 'PvP' | 'PvPvE' | 'Training' | 'Special';
|
||||||
campaign?: (number | null) | Campaign;
|
campaign?: (number | null) | Campaign;
|
||||||
/**
|
/**
|
||||||
|
|
@ -3385,6 +3396,70 @@ export interface MissionRoleSignup {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: 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
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "shipments".
|
* via the `definition` "shipments".
|
||||||
|
|
@ -5837,6 +5912,24 @@ export interface PayloadMcpApiKey {
|
||||||
*/
|
*/
|
||||||
delete?: boolean | null;
|
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?: {
|
campaigns?: {
|
||||||
/**
|
/**
|
||||||
* Allow clients to find campaigns.
|
* Allow clients to find campaigns.
|
||||||
|
|
@ -6908,6 +7001,10 @@ export interface PayloadLockedDocument {
|
||||||
relationTo: 'mission-role-signups';
|
relationTo: 'mission-role-signups';
|
||||||
value: number | MissionRoleSignup;
|
value: number | MissionRoleSignup;
|
||||||
} | null)
|
} | null)
|
||||||
|
| ({
|
||||||
|
relationTo: 'mission-map-markers';
|
||||||
|
value: number | MissionMapMarker;
|
||||||
|
} | null)
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'campaigns';
|
relationTo: 'campaigns';
|
||||||
value: number | Campaign;
|
value: number | Campaign;
|
||||||
|
|
@ -7885,6 +7982,7 @@ export interface MissionsSelect<T extends boolean = true> {
|
||||||
| T
|
| T
|
||||||
| {
|
| {
|
||||||
map?: T;
|
map?: T;
|
||||||
|
planningMapEnabled?: T;
|
||||||
missionType?: T;
|
missionType?: T;
|
||||||
campaign?: T;
|
campaign?: T;
|
||||||
customScripts?: T;
|
customScripts?: T;
|
||||||
|
|
@ -8014,6 +8112,27 @@ export interface MissionRoleSignupsSelect<T extends boolean = true> {
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "mission-map-markers_select".
|
||||||
|
*/
|
||||||
|
export interface MissionMapMarkersSelect<T extends boolean = true> {
|
||||||
|
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
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "campaigns_select".
|
* via the `definition` "campaigns_select".
|
||||||
|
|
@ -9748,6 +9867,14 @@ export interface PayloadMcpApiKeysSelect<T extends boolean = true> {
|
||||||
update?: T;
|
update?: T;
|
||||||
delete?: T;
|
delete?: T;
|
||||||
};
|
};
|
||||||
|
missionMapMarkers?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
find?: T;
|
||||||
|
create?: T;
|
||||||
|
update?: T;
|
||||||
|
delete?: T;
|
||||||
|
};
|
||||||
campaigns?:
|
campaigns?:
|
||||||
| T
|
| T
|
||||||
| {
|
| {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { Profiles } from "@/collections/users/Profiles";
|
||||||
import { Missions } from "@/collections/intelligence/Missions";
|
import { Missions } from "@/collections/intelligence/Missions";
|
||||||
import { MissionAttendances } from "@/collections/intelligence/MissionAttendances";
|
import { MissionAttendances } from "@/collections/intelligence/MissionAttendances";
|
||||||
import { MissionRoleSignups } from "@/collections/intelligence/MissionRoleSignups";
|
import { MissionRoleSignups } from "@/collections/intelligence/MissionRoleSignups";
|
||||||
|
import { MissionMapMarkers } from "@/collections/intelligence/MissionMapMarkers";
|
||||||
import { Maps } from "@/collections/world/Maps";
|
import { Maps } from "@/collections/world/Maps";
|
||||||
import { MapRoads } from "@/collections/world/MapRoads";
|
import { MapRoads } from "@/collections/world/MapRoads";
|
||||||
import { MapZones } from "@/collections/world/MapZones";
|
import { MapZones } from "@/collections/world/MapZones";
|
||||||
|
|
@ -290,6 +291,7 @@ const collections = [
|
||||||
Missions,
|
Missions,
|
||||||
MissionAttendances,
|
MissionAttendances,
|
||||||
MissionRoleSignups,
|
MissionRoleSignups,
|
||||||
|
MissionMapMarkers,
|
||||||
Campaigns,
|
Campaigns,
|
||||||
Factions,
|
Factions,
|
||||||
Technologies,
|
Technologies,
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,16 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
|
||||||
{ value: "admin:mission-role-signups:manage", label: "Manage Admin Page" },
|
{ 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",
|
group: "Campaigns",
|
||||||
permissions: [
|
permissions: [
|
||||||
|
|
@ -808,6 +818,11 @@ export const ALL_PERMISSION_VALUES = PERMISSION_GROUPS.flatMap((g) =>
|
||||||
"mission-role-signups:update",
|
"mission-role-signups:update",
|
||||||
"mission-role-signups:delete",
|
"mission-role-signups:delete",
|
||||||
"admin:mission-role-signups:manage",
|
"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:create",
|
||||||
"campaigns:read",
|
"campaigns:read",
|
||||||
"campaigns:update",
|
"campaigns:update",
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,11 @@ export const EventTypes = {
|
||||||
MissionUpdate: "mission:update",
|
MissionUpdate: "mission:update",
|
||||||
MissionCancel: "mission:cancel",
|
MissionCancel: "mission:cancel",
|
||||||
MissionMoveCampaign: "mission:move-campaign",
|
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
|
// Combat
|
||||||
PlayerKill: "combat:kill",
|
PlayerKill: "combat:kill",
|
||||||
|
|
|
||||||
172
tests/e2e/mission-planning-map.e2e.spec.ts
Normal file
172
tests/e2e/mission-planning-map.e2e.spec.ts
Normal file
|
|
@ -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 <body> 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
427
tests/int/mission-map.int.spec.ts
Normal file
427
tests/int/mission-map.int.spec.ts
Normal file
|
|
@ -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<User> => {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue