1
0
Fork 0

Compare commits

...

5 commits

Author SHA1 Message Date
308a522764 v0.2.41 - searchable site dropdowns, map ribbons and vertex snapping, political zone types 2026-09-17 16:28:45 -04:00
be12d36014 fix(map): widen MapZoneInput type union for political zones 2026-09-17 16:28:38 -04:00
947a5c1bbf feat(ui): searchable combobox across data-driven dropdowns
- new ui/combobox primitive (popover plus cmdk): search input, scrolling
  list, trigger-width popover, per-option disabled and hint lines
- converts every data-driven single-value select site-wide (blueprints,
  factions, accounts, assignments, NPCs, structures, locker items, seats,
  missions, ranks, resources, mod lists, event targets, sprints)
- long multi-select checkbox menus (event log types, kanban labels) gain
  search inputs following the resource picker pattern
- static short enum selects stay as plain selects
2026-09-17 16:28:17 -04:00
2bc61fe211 feat(map): zoom-tiered pins with info ribbons, vertex editing UI, attribution
- structure pins step 26px to 32px to 38px as you zoom in; at the close tier
  an info ribbon extends from the pin (name plus one status line: blueprint
  purpose, minute-bucketed build ETA, or first needed material)
- ribbons claim screen-space rectangles greedily by cluster priority so they
  never overlap other pins or ribbons; losers fall back to hover tooltips
- hovered structure jumps above all markers via a leaflet icon z-index rule
- authoring clicks and draft-vertex drags snap to existing geometry
  (vertices plus road/zone edges, closing segments included) with a live
  snap preview; draggable handles, right-click delete, midpoint insert
- distant roads render one straight midpoint label, close roads curve text
  along the path
- structure popover footer shows a live ETA countdown, status line shows a
  friendly estimated completion timestamp
- attribution control credited to the unit and game, restyled to match
2026-09-17 16:28:05 -04:00
cc0a7facd0 feat(map): political zone types, road label tri-state, authoring snapping lib
- map-zones type gains territory and country border (informational political
  boundaries that never gate placement, default fills amber 0.1 / violet 0.06)
- map-roads labelVisible is tri-state: unset means auto, where paved roads
  always label and dirt/trail only label at 4 m/px or finer (roadLabelShown)
- migration clears the old implicit label default on non-paved roads
- new pure snapping lib: findSnap (vertices own their neighborhood, edges must
  be twice as close), closestPointOnSegment, segmentMidpoints
- tests for label angles, tri-state rule, snapping, political zones
2026-09-17 16:27:55 -04:00
39 changed files with 1969 additions and 816 deletions

View file

@ -198,13 +198,13 @@ Labor/staffing simulation for structures. Collections in `src/collections/game/`
Leaflet world map at `/map` (top-level sidebar entry, whole unit views it). Full docs: `docs/map-system.md`. Leaflet world map at `/map` (top-level sidebar entry, whole unit views it). Full docs: `docs/map-system.md`.
- **Collections** (`src/collections/world/`): `maps` (redesigned: `worldSizeWidth`/`worldSizeHeight` meters, `basemapMode` select `image`/`tiles`, `basemapImage` upload, `tileUrlTemplate`, zoom defaults, `isActive`), `map-roads` (name, description, map rel, surface select, `speedMultiplier`, strokeColor/strokeWeight/strokeStyle/labelVisible, `points` JSON array of [x, y] meters), `map-zones` (name, description, type select water/land, fillColor/strokeColor/fillOpacity/strokeWeight/labelVisible, polygon points JSON), `resource-nodes` (`resources` hasMany rel, `position` JSON [x, y], richness, name, description). Feature collections read: any logged-in user; write: `maps:*` permissions. Admin-panel visibility needs `<slug>:read` + `admin:<slug>:manage` ("Map Features" permission group). - **Collections** (`src/collections/world/`): `maps` (redesigned: `worldSizeWidth`/`worldSizeHeight` meters, `basemapMode` select `image`/`tiles`, `basemapImage` upload, `tileUrlTemplate`, zoom defaults, `isActive`), `map-roads` (name, description, map rel, surface select, `speedMultiplier`, strokeColor/strokeWeight/strokeStyle/labelVisible (tri-state: unset = auto, paved labels always, dirt/trail only at close zoom via `roadLabelShown` in style.ts, 4 m/px threshold), `points` JSON array of [x, y] meters), `map-zones` (name, description, type select water/land/territory/border (territory/border are informational political boundaries that never gate placement; default fills amber 0.1 / violet 0.06), fillColor/strokeColor/fillOpacity/strokeWeight/labelVisible, polygon points JSON), `resource-nodes` (`resources` hasMany rel, `position` JSON [x, y], richness, name, description). Feature collections read: any logged-in user; write: `maps:*` permissions. Admin-panel visibility needs `<slug>:read` + `admin:<slug>:manage` ("Map Features" permission group).
- **Coordinates**: real meters, per-map Cartesian grid, origin top-left, y runs south (Arma convention). JSON geometry for map features; `game-structures.coordinates` keeps its PostGIS point but stores meters (`validate: () => true` overrides Payload's geographic lng/lat validation; meters are not degrees). - **Coordinates**: real meters, per-map Cartesian grid, origin top-left, y runs south (Arma convention). JSON geometry for map features; `game-structures.coordinates` keeps its PostGIS point but stores meters (`validate: () => true` overrides Payload's geographic lng/lat validation; meters are not degrees).
- **Routing**: `src/lib/map/routing.ts` (pure) builds a graph from road polylines (1 m vertex epsilon; shared vertex = junction), Dijkstra weighted by `length / speedMultiplier`; origin/destination snap to the nearest vertex within 1000 m; no connected route refuses ground shipments with a clear error. Air/sea (`vehicles.transportMode`) go straight-line. Distance = geometric route length in meters; `routePath` JSON snapshot on shipments (auto-return reverses it). Speed math converts meters to km once in `calculateTransitTime`; fuel stays `meters x rate`. - **Routing**: `src/lib/map/routing.ts` (pure) builds a graph from road polylines (1 m vertex epsilon; shared vertex = junction), Dijkstra weighted by `length / speedMultiplier`; origin/destination snap to the nearest vertex within 1000 m; no connected route refuses ground shipments with a clear error. Air/sea (`vehicles.transportMode`) go straight-line. Distance = geometric route length in meters; `routePath` JSON snapshot on shipments (auto-return reverses it). Speed math converts meters to km once in `calculateTransitTime`; fuel stays `meters x rate`.
- **Placement**: `src/lib/map/placement.ts` (pure: terrain point-in-polygon, resourcesInRange vs multi-resource nodes OR resource-bearing zones at the site, structuresInRange) + `placeStructure` action (`src/app/(frontend)/logistics/map/actions.ts`, hasLogisticsQualification gate, emits `structure:placed`). Map UI placement mode for logistics-qualified users (click to place, dialog with blueprint/name/faction, client pre-validation + server enforcement). Water zones block land blueprints; water blueprints need a water zone; no zones = everything allowed. - **Placement**: `src/lib/map/placement.ts` (pure: terrain point-in-polygon, resourcesInRange vs multi-resource nodes OR resource-bearing zones at the site, structuresInRange) + `placeStructure` action (`src/app/(frontend)/logistics/map/actions.ts`, hasLogisticsQualification gate, emits `structure:placed`). Map UI placement mode for logistics-qualified users (click to place, dialog with blueprint/name/faction, client pre-validation + server enforcement). Water zones block land blueprints; water blueprints need a water zone; no zones = everything allowed.
- **Construction**: `src/lib/map/construction.ts`. Construction runs on timestamps: `maybeStartConstruction(payload, siteId, now)` is called at delivery time (shipment `completeShipment`, the structures `addResource`/`transferResource` actions via `addResourceInternal`, and `placeStructure` for materialless blueprints) and, when every blueprint material is stored, consumes them via `consumeStorage`, sets `constructionStartedAt`/`constructionCompletesAt` from the blueprint's `constructionDurationMinutes` (minutes, replacing the old tick-based `constructionTime`), and flips `awaiting_materials` to `building`. The game tick's `processConstructionTick` is a sweeper only: building sites past `constructionCompletesAt` flip to `complete` + `structure:construction-complete` (idempotent). Default constructionStatus on game-structures is `complete` (backward compat). ConstructionPanel on the structure detail page shows required vs delivered + a timestamp-derived progress bar; map markers interpolate progress from the timestamps. - **Construction**: `src/lib/map/construction.ts`. Construction runs on timestamps: `maybeStartConstruction(payload, siteId, now)` is called at delivery time (shipment `completeShipment`, the structures `addResource`/`transferResource` actions via `addResourceInternal`, and `placeStructure` for materialless blueprints) and, when every blueprint material is stored, consumes them via `consumeStorage`, sets `constructionStartedAt`/`constructionCompletesAt` from the blueprint's `constructionDurationMinutes` (minutes, replacing the old tick-based `constructionTime`), and flips `awaiting_materials` to `building`. The game tick's `processConstructionTick` is a sweeper only: building sites past `constructionCompletesAt` flip to `complete` + `structure:construction-complete` (idempotent). Default constructionStatus on game-structures is `complete` (backward compat). ConstructionPanel on the structure detail page shows required vs delivered + a timestamp-derived progress bar; map markers interpolate progress from the timestamps.
- **UI**: `/map` (`src/app/(frontend)/map/page.tsx` + `MapLoader` ssr:false + `src/components/frontend/map/MapClient.tsx`): CRS.Simple with y-flip (latlng = [-y, x]), image (ImageOverlay) or XYZ (TileLayer) basemap, map switcher, layer toggles (structures/nodes/roads/zones/shipments/ranges), structure markers as status-colored pins (emerald building icon = operational, blue hammer = building, amber package = awaiting materials, with a progress bar under construction sites), popovers linking to `/logistics/structures/[id]`, shipment markers interpolated along `routePath` from `dispatchedAt`/`estimatedArrival`, refreshed by `useGameTick`. Feature data served by `GET /api/map-features?mapId=` (view surface, logged-in). Map labels: zone names render at the polygon centroid, road names render as curved SVG textPath labels following a Catmull-Rom smoothed road line every 400 screen px (max 12/road, flipped to a reversed path when the direction would render upside down or straight down, so vertical labels read bottom-to-top; dark casing stroke for contrast), structure names render as persistent labels with cluster priority (`labelVisible` + `labelPriority` on game-structures; winner = highest `labelPriority`, then stored mass, then id; cluster radius 56 screen px, so zooming in reveals suppressed labels). Road/zone stroke + fill colors, widths, dash styles, and opacities are editable in the authoring toolbar (`strokeColor`/`strokeWeight`/`strokeStyle` on roads; `fillColor`/`strokeColor`/`fillOpacity`/`strokeWeight` on zones; hex validated by `validateHexColor` in `src/lib/map/style.ts`, empty = per-type defaults). Pure label/styling math lives in `src/lib/map/style.ts` (`metersPerPx`, `roadLabelPoints`, `polygonCentroid`, `dashArrayFor`). - **UI**: `/map` (`src/app/(frontend)/map/page.tsx` + `MapLoader` ssr:false + `src/components/frontend/map/MapClient.tsx`): CRS.Simple with y-flip (latlng = [-y, x]), image (ImageOverlay) or XYZ (TileLayer) basemap, map switcher, layer toggles (structures/nodes/roads/zones/shipments/ranges), structure markers as status-colored pins (emerald building icon = operational, blue hammer = building, amber package = awaiting materials, with a progress bar under construction sites), popovers linking to `/logistics/structures/[id]`, shipment markers interpolated along `routePath` from `dispatchedAt`/`estimatedArrival`, refreshed by `useGameTick`. Feature data served by `GET /api/map-features?mapId=` (view surface, logged-in). Map labels: zone names render at the polygon centroid, road names render as curved SVG textPath labels following a Catmull-Rom smoothed road line every 400 screen px (max 12/road, flipped to a reversed path when the direction would render upside down or straight down, so vertical labels read bottom-to-top; dark casing stroke for contrast), structure names render as persistent labels with cluster priority (`labelVisible` + `labelPriority` on game-structures; winner = highest `labelPriority`, then stored mass, then id; cluster radius 56 screen px, so zooming in reveals suppressed labels). Road/zone stroke + fill colors, widths, dash styles, and opacities are editable in the authoring toolbar (`strokeColor`/`strokeWeight`/`strokeStyle` on roads; `fillColor`/`strokeColor`/`fillOpacity`/`strokeWeight` on zones; hex validated by `validateHexColor` in `src/lib/map/style.ts`, empty = per-type defaults). Pure label/styling math lives in `src/lib/map/style.ts` (`metersPerPx`, `roadLabelPoints`, `polygonCentroid`, `dashArrayFor`, `roadLabelShown`, `labelScreenAngle`; roads under 600 screen px render one straight midpoint label instead of curved text, since glyphs fold on tiny wiggly paths).
- **Authoring**: edit mode on `/map` (maps:*-gated) draws roads/zones/nodes, edits existing features (popup Edit loads geometry + attributes into the authoring toolbar; Save passes the feature id and requires `maps:update`), and deletes features (server actions in the same actions.ts); `POST /api/map-import` imports a GeoJSON FeatureCollection (LineString to roads, Polygon to zones, Point to nodes; [x, y] positions used directly as map meters). Mapping table in `docs/map-system.md`. - **Authoring**: edit mode on `/map` (maps:*-gated) draws roads/zones/nodes, edits existing features (popup Edit loads geometry + attributes into the authoring toolbar; Save passes the feature id and requires `maps:update`), and deletes features (server actions in the same actions.ts). Drafts get vertex editing: draggable square handles per draft vertex (drag-end snaps), right-click deletes (min 2 road / 3 zone points), cyan midpoint handles insert a vertex; clicks and drags snap within 12 screen px to feature vertices and zone/road edges (closing segments included; vertices beat edges unless the edge is 2x closer) via pure `src/lib/map/snapping.ts` (`findSnap`, `closestPointOnSegment`, `segmentMidpoints`; `AuthoringSnapLayer` + `DraftHandles` in MapClient). `POST /api/map-import` imports a GeoJSON FeatureCollection (LineString to roads, Polygon to zones, Point to nodes; [x, y] positions used directly as map meters). Mapping table in `docs/map-system.md`.
- **GameRules**: `proximityThreshold` now means meters (default 1000; migration backfilled rows still at the old default 10). Both call sites (shipments/actions.ts, structures/actions.ts transferResource) use meters. - **GameRules**: `proximityThreshold` now means meters (default 1000; migration backfilled rows still at the old default 10). Both call sites (shipments/actions.ts, structures/actions.ts transferResource) use meters.
- **Tests**: `tests/int/map-system.int.spec.ts` (routing graph/snap/refusals, cross-map refusal, ground route + path snapshot, air straight-line, proximity, terrain/range validation, placeStructure happy/water/range cases, construction tick lifecycle, permission gating, label/styling helpers + save-action styling validation). Gotcha: Payload applies select `filterOptions` server-side, so test fixtures must use `approvalStatus: "in_progress"`. - **Tests**: `tests/int/map-system.int.spec.ts` (routing graph/snap/refusals, cross-map refusal, ground route + path snapshot, air straight-line, proximity, terrain/range validation, placeStructure happy/water/range cases, construction tick lifecycle, permission gating, label/styling helpers + save-action styling validation). Gotcha: Payload applies select `filterOptions` server-side, so test fixtures must use `approvalStatus: "in_progress"`.

View file

@ -100,10 +100,14 @@ stays display-only in the placement dialog (no research system exists).
- **New road**: click vertices along the route, at least 2; set name, - **New road**: click vertices along the route, at least 2; set name,
description, surface (paved/dirt/trail), speed multiplier, and styling description, surface (paved/dirt/trail), speed multiplier, and styling
(stroke color hex, width 1-10, dash style, name-label toggle); Save. (stroke color hex, width 1-10, dash style, name-label select: Auto/Shown/
Hidden, where Auto labels paved roads always and dirt/trail roads only at
close zoom, 4 m/px or finer); Save.
- **New zone**: click at least 3 vertices; set name, description, type - **New zone**: click at least 3 vertices; set name, description, type
(water/land), and styling (fill color, edge color, fill opacity 0-1, edge (water/land/territory/border), and styling (fill color, edge color, fill
width, name-label toggle); Save. opacity 0-1, edge width, name-label toggle); Save. Territory and Country
Border are informational political boundaries (they never gate placement;
only water does) and default to amber 0.1 / violet 0.06 fill.
- **New node**: click once to set the position; set name, description, - **New node**: click once to set the position; set name, description,
resource, richness; Save. resource, richness; Save.
- Existing roads/zones/nodes show Edit + Delete buttons in their popup while - Existing roads/zones/nodes show Edit + Delete buttons in their popup while
@ -112,6 +116,20 @@ stays display-only in the placement dialog (no research system exists).
the fields, then "Save changes" (requires `maps:update`). Renaming, retyping, the fields, then "Save changes" (requires `maps:update`). Renaming, retyping,
restyling, and description edits work the same way; the feature's map cannot restyling, and description edits work the same way; the feature's map cannot
be changed. be changed.
- **Vertex editing**: while a road/zone/node draft is active, every draft
vertex renders as a draggable square handle: drag to move it (drag-end
snaps), right-click to delete it (roads keep 2+ points, zones 3+), and the
small cyan square at the middle of each segment inserts a vertex there when
clicked.
- **Snapping**: while authoring, clicks and dragged vertices snap to existing
geometry within 12 screen px: feature vertices (roads/zones/nodes/
structures, including the draft's own other vertices for closing polygons)
and edges (nearest point on any road segment or zone side, closing segment
included) so a road can run exactly along a zone border or two zones can
share an edge without gaps or overlap. Vertices beat edges near corners (an
edge must be at least twice as close as the nearest vertex to win); a
moving square preview shows where the click will land (emerald = vertex,
cyan = edge).
### Labels and styling on the live map ### Labels and styling on the live map

View file

@ -1,6 +1,6 @@
{ {
"name": "polaris-task-force", "name": "polaris-task-force",
"version": "0.2.40", "version": "0.2.41",
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.", "description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",

View file

@ -240,7 +240,7 @@ export interface MapZoneInput {
mapId: number; mapId: number;
name?: string | null; name?: string | null;
description?: string | null; description?: string | null;
type: "water" | "land"; type: "water" | "land" | "territory" | "border";
resourceIds?: number[]; resourceIds?: number[];
fillColor?: string | null; fillColor?: string | null;
strokeColor?: string | null; strokeColor?: string | null;

View file

@ -3253,6 +3253,130 @@
border-radius: 1.5px; border-radius: 1.5px;
} }
.map-structure-pin.map-pin-md {
width: 32px;
height: 32px;
}
.map-structure-pin.map-pin-lg {
width: 38px;
height: 38px;
}
.map-structure-ribbon {
position: absolute;
left: calc(100% + 6px);
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 1px;
padding: 2px 8px 3px;
border: 1px solid rgb(255 255 255 / 15%);
border-left-width: 2px;
border-radius: 1px;
background: rgb(8 10 14 / 88%);
box-shadow: 0 1px 4px rgb(0 0 0 / 50%);
white-space: nowrap;
}
.map-structure-ribbon .ribbon-name {
font-size: 11px;
font-weight: 600;
color: rgb(255 255 255 / 95%);
max-width: 190px;
overflow: hidden;
text-overflow: ellipsis;
}
.map-structure-ribbon .ribbon-meta {
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
monospace;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: rgb(255 255 255 / 65%);
}
.map-tooltip-dossier .map-tooltip-name {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
color: rgb(255 255 255 / 95%);
}
/* Must out-rank leaflet.css, which styles the SAME selector later (it loads
in the MapClient chunk after styles.css) with a white background. */
.map-host .leaflet-container .leaflet-control-attribution {
background: rgb(8 10 14 / 92%);
color: rgb(255 255 255 / 55%);
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
monospace;
font-size: 9px;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 1px 6px;
border: 1px solid rgb(255 255 255 / 15%);
border-radius: 0;
}
.leaflet-container .leaflet-control-attribution a {
color: rgb(148 163 184 / 85%);
}
.map-tooltip-dossier .map-tooltip-meta {
margin-top: 1px;
font-size: 9px;
letter-spacing: 0.12em;
color: rgb(255 255 255 / 60%);
}
/* A hovered structure (pin or ribbon) jumps above every other marker. The
!important is required to beat Leaflet's inline z-index management. */
.map-structure-icon.leaflet-marker-icon:hover {
z-index: 1200 !important;
}
.map-vertex-handle {
width: 12px;
height: 12px;
border: 1.5px solid #f8fafc;
border-radius: 2px;
background: rgb(8 10 14 / 75%);
box-shadow: 0 0 0 1px rgb(0 0 0 / 60%);
}
.map-midpoint-handle {
width: 8px;
height: 8px;
border: 1.5px solid #22d3ee;
border-radius: 1px;
background: rgb(8 10 14 / 40%);
opacity: 0.85;
}
.map-snap-preview {
width: 12px;
height: 12px;
margin-left: -6px;
margin-top: -6px;
border-radius: 2px;
border: 1.5px solid;
pointer-events: none;
}
.map-snap-vertex {
border-color: #34d399;
background: rgb(52 211 153 / 25%);
}
.map-snap-edge {
border-color: #22d3ee;
background: rgb(34 211 238 / 25%);
}
.map-structure-progress { .map-structure-progress {
position: absolute; position: absolute;
left: 3px; left: 3px;
@ -3397,6 +3521,29 @@
overflow: visible; overflow: visible;
} }
.map-road-label {
color: rgb(255 255 255 / 94%);
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
monospace;
font-size: 11px;
letter-spacing: 0.2em;
text-transform: uppercase;
white-space: nowrap;
/* Size to the text itself: inside the 0x0 divIcon container a block div
resolves to 0 width, which makes the percentage centering transform a
no-op and leaves the label hanging off the road. */
width: max-content;
background: rgb(8 10 14 / 62%);
padding: 1px 5px;
border-radius: 1px;
text-shadow:
0 0 3px rgb(0 0 0 / 95%),
0 0 7px rgb(0 0 0 / 90%),
0 0 2px rgb(0 0 0 / 90%);
pointer-events: none;
}
.map-road-label-path { .map-road-label-path {
fill: none; fill: none;
stroke: none; stroke: none;

View file

@ -96,10 +96,10 @@ export const MapRoads: CollectionConfig = {
{ {
name: "labelVisible", name: "labelVisible",
type: "checkbox", type: "checkbox",
defaultValue: true,
label: "Show Name Label", label: "Show Name Label",
admin: { admin: {
description: "Renders the road's name along the line at zoom-dependent intervals.", description:
"Auto (unset): only paved roads show labels. Check to force on, uncheck to force off. The map authoring toolbar's Label select writes this.",
}, },
}, },
{ {

View file

@ -48,10 +48,12 @@ export const MapZones: CollectionConfig = {
options: [ options: [
{ label: "Water", value: "water" }, { label: "Water", value: "water" },
{ label: "Land", value: "land" }, { label: "Land", value: "land" },
{ label: "Territory", value: "territory" },
{ label: "Country Border", value: "border" },
], ],
admin: { admin: {
description: description:
"Water zones block land-blueprint placement. Land zones are informational; default terrain is buildable land.", "Water zones block land-blueprint placement. Land zones are informational; default terrain is buildable land. Territory and Country Border are political boundaries (informational).",
}, },
}, },
{ {
@ -76,7 +78,8 @@ export const MapZones: CollectionConfig = {
min: 0, min: 0,
max: 1, max: 1,
admin: { admin: {
description: "Fill opacity 0-1. Empty = default (0.25 water, 0.08 land).", description:
"Fill opacity 0-1. Empty = default (0.25 water, 0.08 land, 0.1 territory, 0.06 border).",
}, },
}, },
{ {
@ -114,7 +117,8 @@ export const MapZones: CollectionConfig = {
required: true, required: true,
validate: validatePolygon, validate: validatePolygon,
admin: { admin: {
description: "Polygon ring as an array of [x, y] meter tuples (need not repeat the first point).", description:
"Polygon ring as an array of [x, y] meter tuples (need not repeat the first point).",
}, },
}, },
], ],

View file

@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { toast } from "sonner"; import { toast } from "sonner";
import { ArrowLeftRightIcon, Loader2Icon } from "lucide-react"; import { ArrowLeftRightIcon, Loader2Icon } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Combobox } from "@/components/ui/combobox";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@ -16,7 +17,14 @@ import {
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { requestTransfer } from "@/app/(frontend)/transfers/actions"; import { requestTransfer } from "@/app/(frontend)/transfers/actions";
@ -199,22 +207,20 @@ export function RequestTransferDialog({
<form className="flex flex-col gap-4" onSubmit={handleSubmit}> <form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label htmlFor="transfer-source">Current assignment</Label> <Label htmlFor="transfer-source">Current assignment</Label>
<Select value={sourceId} onValueChange={setSourceId} disabled={pending}> <Combobox
<SelectTrigger id="transfer-source"> id="transfer-source"
<SelectValue placeholder="Select current assignment" /> value={sourceId}
</SelectTrigger> onValueChange={setSourceId}
<SelectContent> disabled={pending}
<SelectGroup> placeholder="Select current assignment"
{requestableAssignments.map((assignment) => ( searchPlaceholder="Search assignments..."
<SelectItem key={assignment.id} value={String(assignment.id)}> options={requestableAssignments.map((assignment) => ({
{assignment.callsign value: String(assignment.id),
label: assignment.callsign
? `${assignment.name} · ${assignment.callsign}` ? `${assignment.name} · ${assignment.callsign}`
: assignment.name} : assignment.name,
</SelectItem> }))}
))} />
</SelectGroup>
</SelectContent>
</Select>
</div> </div>
<div className="flex flex-col gap-1.5" aria-busy={loadingDestinations}> <div className="flex flex-col gap-1.5" aria-busy={loadingDestinations}>
@ -222,26 +228,20 @@ export function RequestTransferDialog({
{loadingDestinations ? ( {loadingDestinations ? (
<Skeleton className="h-9 w-full" /> <Skeleton className="h-9 w-full" />
) : destinations.length > 0 ? ( ) : destinations.length > 0 ? (
<Select <Combobox
id="transfer-destination"
value={destinationId} value={destinationId}
onValueChange={setDestinationId} onValueChange={setDestinationId}
disabled={pending} disabled={pending}
> placeholder="Select destination assignment"
<SelectTrigger id="transfer-destination"> searchPlaceholder="Search assignments..."
<SelectValue placeholder="Select destination assignment" /> options={destinations.map((assignment) => ({
</SelectTrigger> value: String(assignment.id),
<SelectContent> label: assignment.callsign
<SelectGroup>
{destinations.map((assignment) => (
<SelectItem key={assignment.id} value={String(assignment.id)}>
{assignment.callsign
? `${assignment.name} · ${assignment.callsign}` ? `${assignment.name} · ${assignment.callsign}`
: assignment.name} : assignment.name,
</SelectItem> }))}
))} />
</SelectGroup>
</SelectContent>
</Select>
) : ( ) : (
<Alert> <Alert>
<AlertDescription> <AlertDescription>
@ -271,10 +271,18 @@ export function RequestTransferDialog({
)} )}
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={() => setInternalOpen(false)} disabled={pending}> <Button
type="button"
variant="outline"
onClick={() => setInternalOpen(false)}
disabled={pending}
>
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={pending || loadingDestinations || destinations.length === 0}> <Button
type="submit"
disabled={pending || loadingDestinations || destinations.length === 0}
>
{pending && <Loader2Icon data-icon="inline-start" className="animate-spin" />} {pending && <Loader2Icon data-icon="inline-start" className="animate-spin" />}
{pending ? "Submitting..." : "Submit request"} {pending ? "Submitting..." : "Submit request"}
</Button> </Button>

View file

@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { ArrowDownLeftIcon, ArrowRightLeftIcon, ArrowUpRightIcon } from "lucide-react"; import { ArrowDownLeftIcon, ArrowRightLeftIcon, ArrowUpRightIcon } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Combobox } from "@/components/ui/combobox";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import {
@ -164,18 +165,17 @@ export function BankTransactionDialog({
{mode === "transfer" && ( {mode === "transfer" && (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label>Destination account</Label> <Label>Destination account</Label>
<Select value={toId} onValueChange={setToId}> <Combobox
<SelectTrigger> value={toId}
<SelectValue placeholder="Select account" /> onValueChange={setToId}
</SelectTrigger> placeholder="Select account"
<SelectContent> searchPlaceholder="Search accounts..."
{otherAccounts.map((a) => ( emptyMessage="No other accounts."
<SelectItem key={a.id} value={String(a.id)}> options={otherAccounts.map((a) => ({
{a.name} value: String(a.id),
</SelectItem> label: a.name,
))} }))}
</SelectContent> />
</Select>
</div> </div>
)} )}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">

View file

@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { PlusIcon } from "lucide-react"; import { PlusIcon } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Combobox } from "@/components/ui/combobox";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { import {
Select, Select,
@ -117,18 +118,16 @@ export function CreateAccountDialog({
{accountType === "faction" && ( {accountType === "faction" && (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label>Owner faction</Label> <Label>Owner faction</Label>
<Select value={ownerFactionId} onValueChange={setOwnerFactionId}> <Combobox
<SelectTrigger> value={ownerFactionId}
<SelectValue placeholder="Select faction" /> onValueChange={setOwnerFactionId}
</SelectTrigger> placeholder="Select faction"
<SelectContent> searchPlaceholder="Search factions..."
{factions.map((f) => ( options={factions.map((f) => ({
<SelectItem key={f.id} value={String(f.id)}> value: String(f.id),
{f.name} label: f.name,
</SelectItem> }))}
))} />
</SelectContent>
</Select>
</div> </div>
)} )}
{accountType === "personal" && !isManager && ( {accountType === "personal" && !isManager && (

View file

@ -15,15 +15,8 @@ import {
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Combobox } from "@/components/ui/combobox";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base"; import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
import { currencyLabel, type CurrencyConfig } from "@/lib/banking/format"; import { currencyLabel, type CurrencyConfig } from "@/lib/banking/format";
@ -353,61 +346,51 @@ export function HireStaffDialog({
{fixedNpc?.name ?? `NPC #${fixedNpcId}`} {fixedNpc?.name ?? `NPC #${fixedNpcId}`}
</p> </p>
) : ( ) : (
<Select value={npcId} onValueChange={setNpcId}> <Combobox
<SelectTrigger> value={npcId}
<SelectValue placeholder="Select an NPC" /> onValueChange={setNpcId}
</SelectTrigger> placeholder="Select an NPC"
<SelectContent> searchPlaceholder="Search NPCs..."
<SelectGroup> emptyMessage="No candidates available."
{npcs.map((npc) => ( options={npcs.map((npc) => ({
<SelectItem key={npc.id} value={String(npc.id)}> value: String(npc.id),
{npc.name} label: npc.name,
</SelectItem> }))}
))} />
</SelectGroup>
</SelectContent>
</Select>
)} )}
</div> </div>
)} )}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label>Structure</Label> <Label>Structure</Label>
<Select value={structureId} onValueChange={handleStructureChange}> <Combobox
<SelectTrigger> value={structureId}
<SelectValue placeholder="Select a structure" /> onValueChange={handleStructureChange}
</SelectTrigger> placeholder="Select a structure"
<SelectContent> searchPlaceholder="Search structures..."
<SelectGroup> options={structures.map((structure) => ({
{structures.map((structure) => ( value: String(structure.id),
<SelectItem key={structure.id} value={String(structure.id)}> label: structure.name,
{structure.name} }))}
</SelectItem> />
))}
</SelectGroup>
</SelectContent>
</Select>
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label>Position type</Label> <Label>Position type</Label>
<Select value={positionType} onValueChange={handlePositionTypeChange}> <Combobox
<SelectTrigger> value={positionType}
<SelectValue /> onValueChange={handlePositionTypeChange}
</SelectTrigger> searchPlaceholder="Search positions..."
<SelectContent> options={[
<SelectGroup> ...(fixedNpcId == null && approvedClassifications.length > 0
{fixedNpcId == null && approvedClassifications.length > 0 && ( ? [{ value: "labor", label: "Labor" }]
<SelectItem value="labor">Labor</SelectItem> : []),
)} ...approvedPositionTypes.map((position) => ({
{approvedPositionTypes.map((position) => ( value: position.value,
<SelectItem key={position.value} value={position.value}> label: position.label,
{position.label} })),
</SelectItem> ]}
))} />
</SelectGroup>
</SelectContent>
</Select>
{selectedStructure && {selectedStructure &&
approvedClassifications.length === 0 && approvedClassifications.length === 0 &&
approvedPositionTypes.length === 0 && ( approvedPositionTypes.length === 0 && (
@ -421,20 +404,15 @@ export function HireStaffDialog({
<> <>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label>Classification</Label> <Label>Classification</Label>
<Select value={laborClassification} onValueChange={handleClassificationChange}> <Combobox
<SelectTrigger> value={laborClassification}
<SelectValue /> onValueChange={handleClassificationChange}
</SelectTrigger> searchPlaceholder="Search classifications..."
<SelectContent> options={approvedClassifications.map((classification) => ({
<SelectGroup> value: String(classification.id),
{approvedClassifications.map((classification) => ( label: classification.name,
<SelectItem key={classification.id} value={String(classification.id)}> }))}
{classification.name} />
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{selectedStructure && approvedClassifications.length === 0 && ( {selectedStructure && approvedClassifications.length === 0 && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
This structure has no approved labor positions. This structure has no approved labor positions.

View file

@ -4,6 +4,7 @@ import * as React from "react";
import { ChevronLeft, ChevronRight, RotateCcw, Search } from "lucide-react"; import { ChevronLeft, ChevronRight, RotateCcw, Search } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Combobox } from "@/components/ui/combobox";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
@ -305,19 +306,20 @@ export default function EventLogTable() {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<Select value={targetCollection} onValueChange={setTargetCollection}> <Combobox
<SelectTrigger className="h-8 w-44"> className="h-8 w-44"
<SelectValue placeholder="Target" /> value={targetCollection}
</SelectTrigger> onValueChange={setTargetCollection}
<SelectContent className="max-h-72"> placeholder="Target"
<SelectItem value="all">All targets</SelectItem> searchPlaceholder="Search targets..."
{TARGET_COLLECTION_OPTIONS.map(([value, label]) => ( options={[
<SelectItem key={value} value={value}> { value: "all", label: "All targets" },
{label} ...TARGET_COLLECTION_OPTIONS.map(([value, label]) => ({
</SelectItem> value,
))} label,
</SelectContent> })),
</Select> ]}
/>
<Select value={source} onValueChange={(v) => setSource(v as SourceFilter)}> <Select value={source} onValueChange={(v) => setSource(v as SourceFilter)}>
<SelectTrigger className="h-8 w-36"> <SelectTrigger className="h-8 w-36">
@ -442,10 +444,7 @@ export default function EventLogTable() {
Showing {from.toLocaleString()}-{to.toLocaleString()} of {totalDocs.toLocaleString()} Showing {from.toLocaleString()}-{to.toLocaleString()} of {totalDocs.toLocaleString()}
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Select <Select value={String(pageSize)} onValueChange={(v) => setPageSize(Number(v))}>
value={String(pageSize)}
onValueChange={(v) => setPageSize(Number(v))}
>
<SelectTrigger className="h-8 w-28"> <SelectTrigger className="h-8 w-28">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>

View file

@ -7,6 +7,7 @@ import { useRouter } from "next/navigation";
import { toast } from "sonner"; import { toast } from "sonner";
import type { Ticket, User } from "@/payload-types"; import type { Ticket, User } from "@/payload-types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { import {
@ -264,23 +265,21 @@ export function TicketDetail({
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label htmlFor="ticket-assignee">Assignee</Label> <Label htmlFor="ticket-assignee">Assignee</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Select <Combobox
id="ticket-assignee"
className="min-w-0 flex-1"
value={assignee} value={assignee}
onValueChange={setAssignee} onValueChange={setAssignee}
disabled={controlBusy !== null} disabled={controlBusy !== null}
> searchPlaceholder="Search members..."
<SelectTrigger id="ticket-assignee" className="min-w-0 flex-1"> options={[
<SelectValue /> { value: "unassigned", label: "Unassigned" },
</SelectTrigger> ...assigneeOptions.map((option) => ({
<SelectContent> value: String(option.id),
<SelectItem value="unassigned">Unassigned</SelectItem> label: option.label,
{assigneeOptions.map((option) => ( })),
<SelectItem key={option.id} value={String(option.id)}> ]}
{option.label} />
</SelectItem>
))}
</SelectContent>
</Select>
<Button <Button
size="sm" size="sm"
onClick={() => void applyControl("assignee")} onClick={() => void applyControl("assignee")}

View file

@ -10,6 +10,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Combobox } from "@/components/ui/combobox";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@ -21,11 +22,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { import {
createFridaySideOp, createFridaySideOp,
updateFridaySideOp, updateFridaySideOp,
@ -238,7 +235,9 @@ export function CreateSideOpDialog({
} }
function modsInput() { function modsInput() {
return requiredModsId === "none" ? { requiredModsId: null } : { requiredModsId: Number(requiredModsId) }; return requiredModsId === "none"
? { requiredModsId: null }
: { requiredModsId: Number(requiredModsId) };
} }
async function handleModlistUpload(file: File) { async function handleModlistUpload(file: File) {
@ -344,7 +343,8 @@ export function CreateSideOpDialog({
estimatedDuration: Number(estimatedDuration) || 240, estimatedDuration: Number(estimatedDuration) || 240,
maxPlayers: Number(maxPlayers) || 32, maxPlayers: Number(maxPlayers) || 32,
...modsInput(), ...modsInput(),
campaignId: campaignChoice === "new" || campaignChoice === "none" ? null : Number(campaignChoice), campaignId:
campaignChoice === "new" || campaignChoice === "none" ? null : Number(campaignChoice),
newCampaignName: campaignChoice === "new" ? newCampaignName || null : null, newCampaignName: campaignChoice === "new" ? newCampaignName || null : null,
noCampaign: campaignChoice === "none", noCampaign: campaignChoice === "none",
...narrativeInput(), ...narrativeInput(),
@ -430,25 +430,20 @@ export function CreateSideOpDialog({
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<Label>Map</Label> <Label>Map</Label>
<Select <Combobox
className="w-full"
value={mapChoice} value={mapChoice}
onValueChange={(v) => { onValueChange={(v) => {
setMapChoice(v); setMapChoice(v);
if (v !== "custom" && v !== "") setMapId(v); if (v !== "custom" && v !== "") setMapId(v);
}} }}
> placeholder="Choose a map"
<SelectTrigger className="w-full"> searchPlaceholder="Search maps..."
<SelectValue placeholder="Choose a map" /> options={[
</SelectTrigger> ...maps.map((m) => ({ value: String(m.id), label: m.name })),
<SelectContent> { value: "custom", label: "+ Custom map…" },
{maps.map((m) => ( ]}
<SelectItem key={m.id} value={String(m.id)}> />
{m.name}
</SelectItem>
))}
<SelectItem value="custom">+ Custom map…</SelectItem>
</SelectContent>
</Select>
</div> </div>
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<Label>Mission type</Label> <Label>Mission type</Label>
@ -490,20 +485,22 @@ export function CreateSideOpDialog({
</div> </div>
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<Label>Campaign</Label> <Label>Campaign</Label>
<Select value={campaignChoice} onValueChange={setCampaignChoice}> <Combobox
<SelectTrigger className="w-full"> className="w-full"
<SelectValue placeholder="Choose a campaign" /> value={campaignChoice}
</SelectTrigger> onValueChange={setCampaignChoice}
<SelectContent> placeholder="Choose a campaign"
<SelectItem value="none">No campaign (one-off op)</SelectItem> searchPlaceholder="Search campaigns..."
{campaigns.map((c) => ( options={[
<SelectItem key={c.id} value={String(c.id)} disabled={c.disabled}> { value: "none", label: "No campaign (one-off op)" },
{c.name} ...campaigns.map((c) => ({
</SelectItem> value: String(c.id),
))} label: c.name,
<SelectItem value="new">+ New campaign…</SelectItem> disabled: c.disabled,
</SelectContent> })),
</Select> { value: "new", label: "+ New campaign…" },
]}
/>
</div> </div>
</div> </div>
{mapChoice === "custom" && ( {mapChoice === "custom" && (
@ -531,19 +528,17 @@ export function CreateSideOpDialog({
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<Label>Mod list</Label> <Label>Mod list</Label>
<Select value={requiredModsId} onValueChange={setRequiredModsId}> <Combobox
<SelectTrigger className="w-full"> className="w-full"
<SelectValue placeholder="No mod list (vanilla)" /> value={requiredModsId}
</SelectTrigger> onValueChange={setRequiredModsId}
<SelectContent> placeholder="No mod list (vanilla)"
<SelectItem value="none">No mod list (vanilla)</SelectItem> searchPlaceholder="Search mod lists..."
{modLists.map((l) => ( options={[
<SelectItem key={l.id} value={String(l.id)}> { value: "none", label: "No mod list (vanilla)" },
{l.name} ...modLists.map((l) => ({ value: String(l.id), label: l.name })),
</SelectItem> ]}
))} />
</SelectContent>
</Select>
<Input <Input
type="file" type="file"
accept=".html,.htm,.txt,text/html,text/plain" accept=".html,.htm,.txt,text/html,text/plain"
@ -555,8 +550,8 @@ export function CreateSideOpDialog({
}} }}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Upload the mod list file exported from the Arma 3 launcher (.html) or a .txt mod Upload the mod list file exported from the Arma 3 launcher (.html) or a .txt mod list,
list, or pick an existing one. or pick an existing one.
</p> </p>
</div> </div>

View file

@ -5,15 +5,9 @@ import { useRouter } from "next/navigation";
import { PlusIcon } from "lucide-react"; import { PlusIcon } from "lucide-react";
import type { Asset } from "@/payload-types"; import type { Asset } from "@/payload-types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -86,18 +80,17 @@ export function AddLockerItemDialog({ assets }: AddLockerItemDialogProps) {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label>Asset</Label> <Label>Asset</Label>
<Select value={assetId} onValueChange={setAssetId}> <Combobox
<SelectTrigger> value={assetId}
<SelectValue placeholder="Select an asset" /> onValueChange={setAssetId}
</SelectTrigger> placeholder="Select an asset"
<SelectContent> searchPlaceholder="Search assets..."
{assets.map((a) => ( emptyMessage="No assets in your locker."
<SelectItem key={a.id} value={String(a.id)}> options={assets.map((a) => ({
{a.name} · {getAssetTypeLabel(a.assetType)} value: String(a.id),
</SelectItem> label: `${a.name} · ${getAssetTypeLabel(a.assetType)}`,
))} }))}
</SelectContent> />
</Select>
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label>Quantity</Label> <Label>Quantity</Label>

View file

@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { Asset } from "@/payload-types"; import type { Asset } from "@/payload-types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { import {
@ -14,13 +15,6 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
getAssetTypeLabel, getAssetTypeLabel,
getAttachmentOptionsForItem, getAttachmentOptionsForItem,
@ -202,30 +196,24 @@ export function EquipmentEditorDialog({
className="grid grid-cols-[100px_1fr] items-center gap-2" className="grid grid-cols-[100px_1fr] items-center gap-2"
> >
<span className="text-xs text-muted-foreground">{category.label}</span> <span className="text-xs text-muted-foreground">{category.label}</span>
<Select <Combobox
value={installed ? String(installed.id) : "none"} value={installed ? String(installed.id) : "none"}
onValueChange={(v) => handleCategoryPick(category, v)} onValueChange={(v) => handleCategoryPick(category, v)}
disabled={busy} disabled={busy}
>
<SelectTrigger>
<SelectValue
placeholder={ placeholder={
categoryOptions.length === 0 categoryOptions.length === 0
? "No compatible items" ? "No compatible items"
: "Choose an attachment" : "Choose an attachment"
} }
searchPlaceholder="Search attachments..."
options={[
{ value: "none", label: "None" },
...categoryOptions.map((o) => ({
value: String(o.assetId),
label: `${o.asset.name} · ${o.quantity > 0 ? `${o.quantity.toLocaleString()}×` : "installed"}`,
})),
]}
/> />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{categoryOptions.map((o) => (
<SelectItem key={o.assetId} value={String(o.assetId)}>
{o.asset.name} ·{" "}
{o.quantity > 0 ? `${o.quantity.toLocaleString()}×` : "installed"}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
); );
})} })}
@ -240,28 +228,22 @@ export function EquipmentEditorDialog({
<Label className="text-sm font-semibold">Skin</Label> <Label className="text-sm font-semibold">Skin</Label>
<div className="grid grid-cols-[100px_1fr] items-center gap-2"> <div className="grid grid-cols-[100px_1fr] items-center gap-2">
<span className="text-xs text-muted-foreground">Applied</span> <span className="text-xs text-muted-foreground">Applied</span>
<Select <Combobox
value={installedSkin ? String(installedSkin.id) : "none"} value={installedSkin ? String(installedSkin.id) : "none"}
onValueChange={handleSkinPick} onValueChange={handleSkinPick}
disabled={busy} disabled={busy}
>
<SelectTrigger>
<SelectValue
placeholder={ placeholder={
mergedSkinOptions.length === 0 ? "No compatible skins" : "Choose a skin" mergedSkinOptions.length === 0 ? "No compatible skins" : "Choose a skin"
} }
searchPlaceholder="Search skins..."
options={[
{ value: "none", label: "None" },
...mergedSkinOptions.map((o) => ({
value: String(o.assetId),
label: `${o.asset.name} · ${o.quantity > 0 ? `${o.quantity.toLocaleString()}×` : "applied"}`,
})),
]}
/> />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{mergedSkinOptions.map((o) => (
<SelectItem key={o.assetId} value={String(o.assetId)}>
{o.asset.name} ·{" "}
{o.quantity > 0 ? `${o.quantity.toLocaleString()}×` : "applied"}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
{installedSkin && ( {installedSkin && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">

View file

@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { Loadout } from "@/payload-types"; import type { Loadout } from "@/payload-types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
@ -160,24 +161,21 @@ export function LoadoutEditor({
return ( return (
<div key={slot.name} className="flex flex-col gap-1.5"> <div key={slot.name} className="flex flex-col gap-1.5">
<Label>{slot.label}</Label> <Label>{slot.label}</Label>
<Select <Combobox
value={slots[slot.name] ?? ""} value={slots[slot.name] ?? ""}
onValueChange={(v) => onValueChange={(v) =>
setSlots((prev) => ({ ...prev, [slot.name]: v === "none" ? "" : v })) setSlots((prev) => ({ ...prev, [slot.name]: v === "none" ? "" : v }))
} }
> placeholder={slot.description}
<SelectTrigger> searchPlaceholder="Search items..."
<SelectValue placeholder={slot.description} /> options={[
</SelectTrigger> { value: "none", label: "Empty" },
<SelectContent> ...options.map((item) => ({
<SelectItem value="none">Empty</SelectItem> value: String(item.assetId),
{options.map((item) => ( label: item.asset.name,
<SelectItem key={item.assetId} value={String(item.assetId)}> })),
{item.asset.name} ]}
</SelectItem> />
))}
</SelectContent>
</Select>
{options.length === 0 && ( {options.length === 0 && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
No matching assets in your locker. No matching assets in your locker.

View file

@ -4,13 +4,7 @@ import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { Loadout } from "@/payload-types"; import type { Loadout } from "@/payload-types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import { Combobox } from "@/components/ui/combobox";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { setLoadoutSlot, equipLoadout } from "@/app/(frontend)/locker/actions"; import { setLoadoutSlot, equipLoadout } from "@/app/(frontend)/locker/actions";
import { import {
getLoadoutSlot, getLoadoutSlot,
@ -77,22 +71,18 @@ export function LockerCharacterPanel({ loadouts, items }: LockerCharacterPanelPr
</div> </div>
<div className="border-b border-border/50 p-2"> <div className="border-b border-border/50 p-2">
<Select <Combobox
className="h-8 rounded-sm border-border/60 bg-background/40 text-xs uppercase tracking-wide"
value={equippedLoadout ? String(equippedLoadout.id) : ""} value={equippedLoadout ? String(equippedLoadout.id) : ""}
onValueChange={changeLoadout} onValueChange={changeLoadout}
disabled={busy || loadouts.length === 0} disabled={busy || loadouts.length === 0}
> placeholder="SELECT LOADOUT"
<SelectTrigger className="h-8 rounded-sm border-border/60 bg-background/40 text-xs uppercase tracking-wide"> searchPlaceholder="Search loadouts..."
<SelectValue placeholder="SELECT LOADOUT" /> options={loadouts.map((loadout) => ({
</SelectTrigger> value: String(loadout.id),
<SelectContent> label: loadout.name,
{loadouts.map((loadout) => ( }))}
<SelectItem key={loadout.id} value={String(loadout.id)}> />
{loadout.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
<div className="p-2"> <div className="p-2">

View file

@ -4,14 +4,8 @@ import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { Asset, Loadout } from "@/payload-types"; import type { Asset, Loadout } from "@/payload-types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Wardrobe } from "./Wardrobe"; import { Wardrobe } from "./Wardrobe";
import { WardrobeSlotPicker } from "./WardrobeSlotPicker"; import { WardrobeSlotPicker } from "./WardrobeSlotPicker";
import { import {
@ -115,26 +109,20 @@ export function WardrobePanel({ userName, loadouts, equippedLoadout, items }: Wa
) : ( ) : (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Select <Combobox
className="flex-1"
value={equippedLoadout ? String(equippedLoadout.id) : ""} value={equippedLoadout ? String(equippedLoadout.id) : ""}
onValueChange={(v) => handleEquip(Number(v))} onValueChange={(v) => handleEquip(Number(v))}
disabled={busy} disabled={busy}
>
<SelectTrigger className="flex-1">
<SelectValue
placeholder={ placeholder={
equippedLoadout ? equippedLoadout.name : "Select a loadout to equip" equippedLoadout ? equippedLoadout.name : "Select a loadout to equip"
} }
searchPlaceholder="Search loadouts..."
options={loadouts.map((l) => ({
value: String(l.id),
label: l.name,
}))}
/> />
</SelectTrigger>
<SelectContent>
{loadouts.map((l) => (
<SelectItem key={l.id} value={String(l.id)}>
{l.name}
</SelectItem>
))}
</SelectContent>
</Select>
{equippedLoadout && ( {equippedLoadout && (
<Button <Button
variant="outline" variant="outline"

View file

@ -56,14 +56,16 @@ export interface MapAuthoring {
setStrokeWeight: (value: string) => void; setStrokeWeight: (value: string) => void;
strokeStyle: "solid" | "dashed" | "dotted"; strokeStyle: "solid" | "dashed" | "dotted";
setStrokeStyle: (value: "solid" | "dashed" | "dotted") => void; setStrokeStyle: (value: "solid" | "dashed" | "dotted") => void;
labelVisible: boolean; roadLabelVisible: boolean | null;
setLabelVisible: (value: boolean) => void; setRoadLabelVisible: (value: boolean | null) => void;
zoneName: string; zoneName: string;
setZoneName: (value: string) => void; setZoneName: (value: string) => void;
zoneDescription: string; zoneDescription: string;
setZoneDescription: (value: string) => void; setZoneDescription: (value: string) => void;
zoneType: "water" | "land"; zoneType: "water" | "land" | "territory" | "border";
setZoneType: (value: "water" | "land") => void; setZoneType: (value: "water" | "land" | "territory" | "border") => void;
zoneLabelVisible: boolean;
setZoneLabelVisible: (value: boolean) => void;
fillColor: string; fillColor: string;
setFillColor: (value: string) => void; setFillColor: (value: string) => void;
zoneStrokeColor: string; zoneStrokeColor: string;
@ -85,6 +87,9 @@ export interface MapAuthoring {
}; };
setKind: (kind: AuthoringKind) => void; setKind: (kind: AuthoringKind) => void;
pushPoint: (point: MapPoint) => void; pushPoint: (point: MapPoint) => void;
updateDraftPoint: (index: number, point: MapPoint) => void;
insertDraftPoint: (index: number, point: MapPoint) => void;
removeDraftPoint: (index: number) => void;
clearDraft: () => void; clearDraft: () => void;
save: () => Promise<void>; save: () => Promise<void>;
removeFeature: (target: "road" | "zone" | "node", id: number) => Promise<void>; removeFeature: (target: "road" | "zone" | "node", id: number) => Promise<void>;
@ -104,10 +109,11 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
const [strokeColor, setStrokeColor] = useState(""); const [strokeColor, setStrokeColor] = useState("");
const [strokeWeight, setStrokeWeight] = useState(""); const [strokeWeight, setStrokeWeight] = useState("");
const [strokeStyle, setStrokeStyle] = useState<"solid" | "dashed" | "dotted">("solid"); const [strokeStyle, setStrokeStyle] = useState<"solid" | "dashed" | "dotted">("solid");
const [labelVisible, setLabelVisible] = useState(true); const [roadLabelVisible, setRoadLabelVisible] = useState<boolean | null>(null);
const [zoneName, setZoneName] = useState(""); const [zoneName, setZoneName] = useState("");
const [zoneDescription, setZoneDescription] = useState(""); const [zoneDescription, setZoneDescription] = useState("");
const [zoneType, setZoneType] = useState<"water" | "land">("water"); const [zoneType, setZoneType] = useState<"water" | "land" | "territory" | "border">("water");
const [zoneLabelVisible, setZoneLabelVisible] = useState(true);
const [fillColor, setFillColor] = useState(""); const [fillColor, setFillColor] = useState("");
const [zoneStrokeColor, setZoneStrokeColor] = useState(""); const [zoneStrokeColor, setZoneStrokeColor] = useState("");
const [fillOpacity, setFillOpacity] = useState(""); const [fillOpacity, setFillOpacity] = useState("");
@ -136,6 +142,31 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
setDraftPoints((current) => [...current, point]); setDraftPoints((current) => [...current, point]);
}, []); }, []);
const updateDraftPoint = useCallback((index: number, point: MapPoint) => {
setDraftPoints((current) => current.map((existing, i) => (i === index ? point : existing)));
}, []);
const insertDraftPoint = useCallback((index: number, point: MapPoint) => {
setDraftPoints((current) => {
if (index < 0 || index > current.length) return current;
return [...current.slice(0, index), point, ...current.slice(index)];
});
}, []);
const removeDraftPoint = useCallback(
(index: number) => {
setDraftPoints((current) => {
const minimum = kind === "zone" ? 3 : kind === "road" ? 2 : 1;
if (current.length <= minimum) {
toast.error(`A ${kind ?? "feature"} needs at least ${minimum} points.`);
return current;
}
return current.filter((_, i) => i !== index);
});
},
[kind],
);
const clearDraft = useCallback(() => setDraftPoints([]), []); const clearDraft = useCallback(() => setDraftPoints([]), []);
const setKind = useCallback((next: AuthoringKind) => { const setKind = useCallback((next: AuthoringKind) => {
@ -154,7 +185,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
setStrokeColor(road.strokeColor ?? ""); setStrokeColor(road.strokeColor ?? "");
setStrokeWeight(road.strokeWeight != null ? String(road.strokeWeight) : ""); setStrokeWeight(road.strokeWeight != null ? String(road.strokeWeight) : "");
setStrokeStyle(road.strokeStyle ?? "solid"); setStrokeStyle(road.strokeStyle ?? "solid");
setLabelVisible(road.labelVisible !== false); setRoadLabelVisible(road.labelVisible);
setEditing({ kind: "road", id: road.id, label: road.name ?? `Road #${road.id}` }); setEditing({ kind: "road", id: road.id, label: road.name ?? `Road #${road.id}` });
}, []); }, []);
@ -169,7 +200,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
setZoneStrokeColor(zone.strokeColor ?? ""); setZoneStrokeColor(zone.strokeColor ?? "");
setFillOpacity(zone.fillOpacity != null ? String(zone.fillOpacity) : ""); setFillOpacity(zone.fillOpacity != null ? String(zone.fillOpacity) : "");
setZoneStrokeWeight(zone.strokeWeight != null ? String(zone.strokeWeight) : ""); setZoneStrokeWeight(zone.strokeWeight != null ? String(zone.strokeWeight) : "");
setLabelVisible(zone.labelVisible !== false); setZoneLabelVisible(zone.labelVisible !== false);
setEditing({ kind: "zone", id: zone.id, label: zone.name ?? `Zone #${zone.id}` }); setEditing({ kind: "zone", id: zone.id, label: zone.name ?? `Zone #${zone.id}` });
}, []); }, []);
@ -212,7 +243,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
strokeColor: strokeColor.trim() || null, strokeColor: strokeColor.trim() || null,
strokeWeight: strokeWeight.trim() ? Number(strokeWeight) : null, strokeWeight: strokeWeight.trim() ? Number(strokeWeight) : null,
strokeStyle, strokeStyle,
labelVisible, labelVisible: roadLabelVisible,
points: draftPoints, points: draftPoints,
}); });
if (!result.success) throw new Error(result.error ?? "Save failed."); if (!result.success) throw new Error(result.error ?? "Save failed.");
@ -229,7 +260,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
strokeColor: zoneStrokeColor.trim() || null, strokeColor: zoneStrokeColor.trim() || null,
fillOpacity: fillOpacity.trim() ? Number(fillOpacity) : null, fillOpacity: fillOpacity.trim() ? Number(fillOpacity) : null,
strokeWeight: zoneStrokeWeight.trim() ? Number(zoneStrokeWeight) : null, strokeWeight: zoneStrokeWeight.trim() ? Number(zoneStrokeWeight) : null,
labelVisible, labelVisible: zoneLabelVisible,
points: draftPoints, points: draftPoints,
}); });
if (!result.success) throw new Error(result.error ?? "Save failed."); if (!result.success) throw new Error(result.error ?? "Save failed.");
@ -265,7 +296,8 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
setZoneStrokeColor(""); setZoneStrokeColor("");
setFillOpacity(""); setFillOpacity("");
setZoneStrokeWeight(""); setZoneStrokeWeight("");
setLabelVisible(true); setRoadLabelVisible(null);
setZoneLabelVisible(true);
onSaved(); onSaved();
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "Save failed."); toast.error(error instanceof Error ? error.message : "Save failed.");
@ -283,11 +315,12 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
strokeColor, strokeColor,
strokeWeight, strokeWeight,
strokeStyle, strokeStyle,
labelVisible, roadLabelVisible,
draftPoints, draftPoints,
zoneName, zoneName,
zoneDescription, zoneDescription,
zoneType, zoneType,
zoneLabelVisible,
fillColor, fillColor,
zoneStrokeColor, zoneStrokeColor,
fillOpacity, fillOpacity,
@ -328,14 +361,16 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
setStrokeWeight, setStrokeWeight,
strokeStyle, strokeStyle,
setStrokeStyle, setStrokeStyle,
labelVisible, roadLabelVisible,
setLabelVisible, setRoadLabelVisible,
zoneName, zoneName,
setZoneName, setZoneName,
zoneDescription, zoneDescription,
setZoneDescription, setZoneDescription,
zoneType, zoneType,
setZoneType, setZoneType,
zoneLabelVisible,
setZoneLabelVisible,
fillColor, fillColor,
setFillColor, setFillColor,
zoneStrokeColor, zoneStrokeColor,
@ -357,6 +392,9 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
}, },
setKind, setKind,
pushPoint, pushPoint,
updateDraftPoint,
insertDraftPoint,
removeDraftPoint,
clearDraft, clearDraft,
save, save,
removeFeature, removeFeature,
@ -491,6 +529,11 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
{kind === "zone" && authoring.draftPoints.length < 3 && " (need 3+)"} {kind === "zone" && authoring.draftPoints.length < 3 && " (need 3+)"}
{kind === "node" && authoring.draftPoints.length === 0 && " (click to set)"} {kind === "node" && authoring.draftPoints.length === 0 && " (click to set)"}
</span> </span>
{kind != null && (
<span className="text-[10px] uppercase tracking-wide text-white/40">
drag squares to move, right-click to delete, cyan squares insert
</span>
)}
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@ -621,14 +664,32 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
className="w-full rounded-none border border-white/20 bg-black/40 px-2 py-1 text-xs text-white placeholder:text-white/30" className="w-full rounded-none border border-white/20 bg-black/40 px-2 py-1 text-xs text-white placeholder:text-white/30"
/> />
</div> </div>
<label className="flex items-center gap-1.5 pb-1 text-[10px] uppercase tracking-wide text-white/60"> <div className="flex flex-col gap-1">
<input <Label className="text-[10px] uppercase text-white/60">Label</Label>
type="checkbox" <Select
checked={fields.labelVisible} value={
onChange={(event) => fields.setLabelVisible(event.target.checked)} fields.roadLabelVisible == null
/> ? "auto"
Label : fields.roadLabelVisible
</label> ? "shown"
: "hidden"
}
onValueChange={(value) =>
fields.setRoadLabelVisible(
value === "shown" ? true : value === "hidden" ? false : null,
)
}
>
<SelectTrigger className="h-8 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="shown">Shown</SelectItem>
<SelectItem value="hidden">Hidden</SelectItem>
</SelectContent>
</Select>
</div>
</div> </div>
</> </>
)} )}
@ -649,7 +710,9 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
<Label className="text-[10px] uppercase text-white/60">Type</Label> <Label className="text-[10px] uppercase text-white/60">Type</Label>
<Select <Select
value={fields.zoneType} value={fields.zoneType}
onValueChange={(value) => fields.setZoneType(value as "water" | "land")} onValueChange={(value) =>
fields.setZoneType(value as "water" | "land" | "territory" | "border")
}
> >
<SelectTrigger className="h-8 w-24 text-xs"> <SelectTrigger className="h-8 w-24 text-xs">
<SelectValue /> <SelectValue />
@ -657,6 +720,8 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
<SelectContent> <SelectContent>
<SelectItem value="water">Water</SelectItem> <SelectItem value="water">Water</SelectItem>
<SelectItem value="land">Land</SelectItem> <SelectItem value="land">Land</SelectItem>
<SelectItem value="territory">Territory</SelectItem>
<SelectItem value="border">Country Border</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -706,8 +771,8 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
<label className="flex items-center gap-1.5 pb-1 text-[10px] uppercase tracking-wide text-white/60"> <label className="flex items-center gap-1.5 pb-1 text-[10px] uppercase tracking-wide text-white/60">
<input <input
type="checkbox" type="checkbox"
checked={fields.labelVisible} checked={fields.zoneLabelVisible}
onChange={(event) => fields.setLabelVisible(event.target.checked)} onChange={(event) => fields.setZoneLabelVisible(event.target.checked)}
/> />
Label Label
</label> </label>

View file

@ -30,11 +30,20 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useGameTick } from "@/hooks/useGameTick"; import { useGameTick } from "@/hooks/useGameTick";
import { distanceSquared, interpolateAlong, polylineLength, type MapPoint } from "@/lib/map/points"; import { distanceSquared, interpolateAlong, polylineLength, type MapPoint } from "@/lib/map/points";
import {
findSnap,
segmentMidpoints,
type SnapKind,
type SnapMatch,
type SnapTarget,
} from "@/lib/map/snapping";
import { import {
dashArrayFor, dashArrayFor,
labelScreenAngle,
metersPerPx, metersPerPx,
polygonCentroid, polygonCentroid,
roadLabelPoints, roadLabelPoints,
roadLabelShown,
splinePolyline, splinePolyline,
} from "@/lib/map/style"; } from "@/lib/map/style";
import type { MapFeaturesResponse, ShipmentFeature, StructureFeature } from "@/lib/map/types"; import type { MapFeaturesResponse, ShipmentFeature, StructureFeature } from "@/lib/map/types";
@ -66,9 +75,11 @@ const STRUCTURE_STATUS: Record<string, { color: string; label: string }> = {
// Layer styling is hoisted to module constants: react-leaflet re-applies // Layer styling is hoisted to module constants: react-leaflet re-applies
// pathOptions by reference, and a fresh object literal every render forces a // pathOptions by reference, and a fresh object literal every render forces a
// canvas redraw that glitches mid-zoom-animation. // canvas redraw that glitches mid-zoom-animation.
const ZONE_PATH_OPTIONS: Record<"water" | "land", L.PathOptions> = { const ZONE_PATH_OPTIONS: Record<"water" | "land" | "territory" | "border", L.PathOptions> = {
water: { color: "#38bdf8", fillColor: "#38bdf8", fillOpacity: 0.25, weight: 1 }, water: { color: "#38bdf8", fillColor: "#38bdf8", fillOpacity: 0.25, weight: 1 },
land: { color: "#4ade80", fillColor: "#4ade80", fillOpacity: 0.08, weight: 1 }, land: { color: "#4ade80", fillColor: "#4ade80", fillOpacity: 0.08, weight: 1 },
territory: { color: "#fbbf24", fillColor: "#fbbf24", fillOpacity: 0.1, weight: 1 },
border: { color: "#a78bfa", fillColor: "#a78bfa", fillOpacity: 0.06, weight: 1 },
}; };
const ROAD_PATH_OPTIONS: Record<string, L.PathOptions> = { const ROAD_PATH_OPTIONS: Record<string, L.PathOptions> = {
@ -111,11 +122,22 @@ const BASEMAP_FALLBACK_PATH_OPTIONS: L.PathOptions = {
weight: 1, weight: 1,
}; };
type StructurePinTier = "sm" | "md" | "lg";
const STRUCTURE_PIN_SIZES: Record<StructurePinTier, number> = { sm: 26, md: 32, lg: 38 };
const STRUCTURE_PIN_SVG_SIZES: Record<StructurePinTier, number> = { sm: 13, md: 16, lg: 20 };
const ROAD_LABEL_SPACING_PX = 400; const ROAD_LABEL_SPACING_PX = 400;
const ROAD_LABEL_MIN_SCREEN_PX = 120; const ROAD_LABEL_MIN_SCREEN_PX = 120;
const ROAD_LABEL_CURVE_MIN_PX = 600;
const ROAD_LABELS_MAX = 12; const ROAD_LABELS_MAX = 12;
const ROAD_LABEL_CHAR_PX = 9; const ROAD_LABEL_CHAR_PX = 9;
const STRUCTURE_LABEL_CLUSTER_PX = 56; const STRUCTURE_LABEL_CLUSTER_PX = 56;
const STRUCTURE_PIN_MID_M_PER_PX = 8;
const STRUCTURE_RIBBON_M_PER_PX = 4;
const RIBBON_HEIGHT_PX = 32;
const RIBBON_GAP_PX = 6;
const RIBBON_PIN_HALF_PX = STRUCTURE_PIN_SIZES.lg / 2;
function escapeHtml(text: string): string { function escapeHtml(text: string): string {
return text return text
@ -147,6 +169,24 @@ function polylinePathD(points: MapPoint[]): string {
.join(" "); .join(" ");
} }
const straightLabelIconCache = new Map<string, L.DivIcon>();
function straightLabelIcon(name: string, angle: number): L.DivIcon {
const bucket = Math.round(angle / 5) * 5;
const cacheKey = `${name}:${bucket}`;
let icon = straightLabelIconCache.get(cacheKey);
if (!icon) {
icon = L.divIcon({
className: "map-road-label-icon",
html: `<div class="map-road-label" style="transform: translate(-50%, -50%) rotate(${bucket}deg)">${escapeHtml(name.toUpperCase())}</div>`,
iconSize: [0, 0],
iconAnchor: [0, 0],
});
straightLabelIconCache.set(cacheKey, icon);
}
return icon;
}
/** /**
* Catmull-Rom spline through the points, emitted as cubic Beziers, so label * Catmull-Rom spline through the points, emitted as cubic Beziers, so label
* glyphs turn gently through bends instead of snapping at each kink. The * glyphs turn gently through bends instead of snapping at each kink. The
@ -213,9 +253,9 @@ const STRUCTURE_SVG_PATHS: Record<string, string> = {
'<path d="M16.5 9.4 7.55 4.24"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.29 7 12 12 20.71 7"/><line x1="12" x2="12" y1="22" y2="12"/>', '<path d="M16.5 9.4 7.55 4.24"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.29 7 12 12 20.71 7"/><line x1="12" x2="12" y1="22" y2="12"/>',
}; };
function dossierSvg(paths: string, color: string, bearingDeg?: number): string { function dossierSvg(paths: string, color: string, bearingDeg?: number, size = 13): string {
const rotate = bearingDeg != null ? ` transform="rotate(${bearingDeg} 12 12)"` : ""; const rotate = bearingDeg != null ? ` transform="rotate(${bearingDeg} 12 12)"` : "";
return `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"${rotate}>${paths}</svg>`; return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"${rotate}>${paths}</svg>`;
} }
const nodeIcon = L.divIcon({ const nodeIcon = L.divIcon({
@ -248,23 +288,41 @@ function shipmentIcon(bearing: number): L.DivIcon {
const structureIconCache = new Map<string, L.DivIcon>(); const structureIconCache = new Map<string, L.DivIcon>();
function getStructureIcon(statusKey: string, progressPct: number | null): L.DivIcon { interface StructureRibbon {
const cacheKey = `${statusKey}:${progressPct ?? "full"}`; tier: StructurePinTier;
name: string;
meta: string;
}
function getStructureIcon(
statusKey: string,
progressPct: number | null,
ribbon: StructureRibbon | null,
): L.DivIcon {
const tier = ribbon?.tier ?? "sm";
const pinSize = STRUCTURE_PIN_SIZES[tier];
const cacheKey = `${statusKey}:${progressPct ?? "full"}:${tier}:${ribbon ? `${ribbon.name}|${ribbon.meta}` : ""}`;
let icon = structureIconCache.get(cacheKey); let icon = structureIconCache.get(cacheKey);
if (!icon) { if (!icon) {
if (structureIconCache.size > 400) structureIconCache.clear();
const meta = STRUCTURE_STATUS[statusKey] ?? STRUCTURE_STATUS.complete; const meta = STRUCTURE_STATUS[statusKey] ?? STRUCTURE_STATUS.complete;
const bar = const bar =
progressPct != null progressPct != null
? `<div class="map-structure-progress"><div style="width:${progressPct}%"></div></div>` ? `<div class="map-structure-progress"><div style="width:${progressPct}%"></div></div>`
: ""; : "";
const ribbonHtml = ribbon
? `<div class="map-structure-ribbon" style="border-left-color:${meta.color}"><div class="ribbon-name">${escapeHtml(ribbon.name)}</div><div class="ribbon-meta">${escapeHtml(ribbon.meta)}</div></div>`
: "";
icon = L.divIcon({ icon = L.divIcon({
className: "map-structure-icon", className: "map-structure-icon",
html: `<div class="map-structure-pin" style="border-color:${meta.color}">${dossierSvg( html: `<div class="map-structure-pin ${tier === "sm" ? "" : `map-pin-${tier}`}" style="border-color:${meta.color}">${dossierSvg(
STRUCTURE_SVG_PATHS[statusKey] ?? STRUCTURE_SVG_PATHS.complete, STRUCTURE_SVG_PATHS[statusKey] ?? STRUCTURE_SVG_PATHS.complete,
meta.color, meta.color,
)}${bar}</div>`, undefined,
iconSize: [26, 26], STRUCTURE_PIN_SVG_SIZES[tier],
iconAnchor: [13, 13], )}${bar}${ribbonHtml}</div>`,
iconSize: [pinSize, pinSize],
iconAnchor: [pinSize / 2, pinSize / 2],
}); });
structureIconCache.set(cacheKey, icon); structureIconCache.set(cacheKey, icon);
} }
@ -357,6 +415,159 @@ function ClickHandler({ onPick }: { onPick: (point: MapPoint) => void }) {
return null; return null;
} }
/** Rewrites the bottom-right attribution prefix to credit the unit and the game. */
function MapAttribution({ prefix }: { prefix: string }) {
const map = useMap();
useEffect(() => {
map.attributionControl.setPrefix(prefix);
}, [map, prefix]);
return null;
}
const SNAP_RADIUS_PX = 12;
const snapPreviewIconCache = new Map<string, L.DivIcon>();
function snapPreviewIcon(kind: SnapKind): L.DivIcon {
let icon = snapPreviewIconCache.get(kind);
if (!icon) {
icon = L.divIcon({
className: "map-snap-icon",
html: `<div class="map-snap-preview map-snap-${kind}"></div>`,
iconSize: [0, 0],
iconAnchor: [0, 0],
});
snapPreviewIconCache.set(kind, icon);
}
return icon;
}
/**
* Authoring click layer: while a road/zone/node draft is active, every map
* click snaps to existing geometry (feature vertices, zone/road edges,
* structure/node positions, already-placed draft vertices) before landing,
* and a moving cursor shows where the next click would land.
*/
function AuthoringSnapLayer({
targets,
worldSize,
onPick,
}: {
targets: SnapTarget[];
worldSize: number;
onPick: (point: MapPoint) => void;
}) {
const map = useMap();
const [preview, setPreview] = useState<SnapMatch | null>(null);
const snapAt = (event: L.LeafletMouseEvent): SnapMatch | null =>
findSnap(
[event.latlng.lng, -event.latlng.lat],
targets,
SNAP_RADIUS_PX * metersPerPx(map.getZoom(), worldSize),
);
useMapEvents({
mousemove: (event) => setPreview(snapAt(event)),
click: (event) => {
const match = snapAt(event);
if (match) {
onPick(match.point);
} else {
onPick([Math.round(event.latlng.lng), Math.round(-event.latlng.lat)]);
}
},
});
if (!preview) return null;
return (
<Marker
position={toLatLng(preview.point)}
interactive={false}
icon={snapPreviewIcon(preview.kind)}
/>
);
}
const vertexHandleIcon = L.divIcon({
className: "map-vertex-handle-icon",
html: '<div class="map-vertex-handle"></div>',
iconSize: [12, 12],
iconAnchor: [6, 6],
});
const midpointHandleIcon = L.divIcon({
className: "map-midpoint-handle-icon",
html: '<div class="map-midpoint-handle"></div>',
iconSize: [8, 8],
iconAnchor: [4, 4],
});
/**
* Draggable handles for the current draft's vertices: drag a square to move it
* (drag-end snaps to existing geometry), right-click deletes it, clicking the
* small hollow square on a segment inserts a vertex there.
*/
function DraftHandles({
points,
closed,
baseTargets,
worldSize,
onUpdate,
onInsert,
onRemove,
}: {
points: MapPoint[];
closed: boolean;
baseTargets: SnapTarget[];
worldSize: number;
onUpdate: (index: number, point: MapPoint) => void;
onInsert: (index: number, point: MapPoint) => void;
onRemove: (index: number) => void;
}) {
const map = useMap();
const midpoints = useMemo(() => segmentMidpoints(points, closed), [points, closed]);
const dragTargets = (index: number): SnapTarget[] => [
...baseTargets,
// Own vertices, minus the dragged one so a small drag cannot snap back.
{ key: "draft", vertices: points.filter((_, i) => i !== index), closed: false, edges: false },
];
return (
<>
{points.map((point, index) => (
<Marker
key={`draft-vertex-${index}`}
position={toLatLng(point)}
draggable
icon={vertexHandleIcon}
zIndexOffset={1000}
eventHandlers={{
dragend: (event) => {
const latlng = (event.target as L.Marker).getLatLng();
const raw: MapPoint = [latlng.lng, -latlng.lat];
const radius = SNAP_RADIUS_PX * metersPerPx(map.getZoom(), worldSize);
const match = findSnap(raw, dragTargets(index), radius);
onUpdate(index, match ? match.point : [Math.round(raw[0]), Math.round(raw[1])]);
},
contextmenu: () => onRemove(index),
}}
/>
))}
{midpoints.map((midpoint, index) => (
<Marker
key={`draft-midpoint-${index}-${midpoint.index}`}
position={toLatLng(midpoint.point)}
icon={midpointHandleIcon}
zIndexOffset={900}
eventHandlers={{ click: () => onInsert(midpoint.index, midpoint.point) }}
/>
))}
</>
);
}
function formatDuration(ms: number): string { function formatDuration(ms: number): string {
const total = Math.max(0, Math.ceil(ms / 1000)); const total = Math.max(0, Math.ceil(ms / 1000));
const hours = Math.floor(total / 3600); const hours = Math.floor(total / 3600);
@ -365,6 +576,38 @@ function formatDuration(ms: number): string {
return `${hours}h ${String(minutes).padStart(2, "0")}m ${String(seconds).padStart(2, "0")}s`; return `${hours}h ${String(minutes).padStart(2, "0")}m ${String(seconds).padStart(2, "0")}s`;
} }
/** Footer-cell countdown: fixed-width "04H 07M 12S". */
function paddedCountdown(ms: number): string {
const total = Math.max(0, Math.ceil(ms / 1000));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
return `${String(hours).padStart(2, "0")}H ${String(minutes).padStart(2, "0")}M ${String(seconds).padStart(2, "0")}S`;
}
/**
* Friendly completion target: today (or already due) shows the time only,
* within a week the weekday, beyond that the calendar date.
*/
function friendlyCompletion(completesMs: number, nowMs: number): string {
const target = new Date(completesMs);
const time = target.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
const startOfDay = (date: Date) =>
new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
const dayDiff = Math.round(
(startOfDay(target) - startOfDay(new Date(nowMs))) / (24 * 60 * 60 * 1000),
);
if (dayDiff <= 0) return time;
if (dayDiff < 7) return `${target.toLocaleDateString([], { weekday: "long" })}, ${time}`;
return `${target.toLocaleDateString([], { month: "short", day: "numeric" })}, ${time}`;
}
/** Ribbon ETA in whole minutes ("1H 05M") so the cached icon changes at most once per minute. */
function bucketedEta(ms: number): string {
const totalMinutes = Math.max(0, Math.floor(ms / 60000));
return `${Math.floor(totalMinutes / 60)}H ${totalMinutes % 60}M`;
}
function DossierStrip({ label, refLabel }: { label: string; refLabel: string }) { function DossierStrip({ label, refLabel }: { label: string; refLabel: string }) {
return ( return (
<div className="flex items-center justify-between gap-6 border-b border-white/15 bg-black/50 px-3 py-1"> <div className="flex items-center justify-between gap-6 border-b border-white/15 bg-black/50 px-3 py-1">
@ -433,7 +676,9 @@ function StructurePopup({
> >
{status.label} {status.label}
{pct != null ? ` // ${pct}%` : ""} {pct != null ? ` // ${pct}%` : ""}
{Number.isFinite(remaining) ? ` // T-${formatDuration(remaining)}` : ""} {building && Number.isFinite(completesMs)
? ` // Est. completion by ${friendlyCompletion(completesMs, nowMs)}`
: ""}
</div> </div>
{structure.constructionStatus === "awaiting_materials" && {structure.constructionStatus === "awaiting_materials" &&
structure.materials.length > 0 && ( structure.materials.length > 0 && (
@ -450,13 +695,8 @@ function StructurePopup({
{ label: "Grid", value: `${gridX}E / ${gridY}S` }, { label: "Grid", value: `${gridX}E / ${gridY}S` },
building building
? { ? {
label: "Completes", label: "ETA",
value: Number.isFinite(completesMs) value: Number.isFinite(remaining) ? paddedCountdown(remaining) : "pending",
? new Date(completesMs).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
: "pending",
} }
: { : {
label: "Storage", label: "Storage",
@ -569,18 +809,99 @@ const StructureLayer = memo(function StructureLayer({
return winners; return winners;
}, [dots, zoom, worldSize]); }, [dots, zoom, worldSize]);
// Ribbon allocation at close zoom: same priority order as labels, greedy
// screen-space rectangles (pin + estimated ribbon width) so ribbons never
// cover another structure's pin or ribbon. Structures that lose the
// collision fall back to their hover tooltip.
const ribbonIds = useMemo(() => {
if (metersPerPx(zoom, worldSize) > STRUCTURE_RIBBON_M_PER_PX) return new Set<number>();
const placed: { left: number; right: number; top: number; bottom: number }[] = [];
const winners = new Set<number>();
const ordered = [...dots].sort(
(a, b) =>
(b.structure.labelPriority ?? 0) - (a.structure.labelPriority ?? 0) ||
b.structure.storageUsedKg - a.structure.storageUsedKg ||
a.structure.id - b.structure.id,
);
for (const dot of ordered) {
const point = map.latLngToLayerPoint(dot.position);
const width = Math.min(220, Math.max(110, Math.max(dot.structure.name.length, 24) * 6.2));
const left = point.x + RIBBON_PIN_HALF_PX + RIBBON_GAP_PX;
const rect = {
left,
right: left + width,
top: point.y - RIBBON_HEIGHT_PX / 2,
bottom: point.y + RIBBON_HEIGHT_PX / 2,
};
const collides = placed.some(
(other) =>
rect.left < other.right &&
rect.right > other.left &&
rect.top < other.bottom &&
rect.bottom > other.top,
);
if (collides) continue;
placed.push(rect);
winners.add(dot.structure.id);
}
return winners;
}, [dots, zoom, worldSize, map]);
return ( return (
<> <>
{dots.map(({ structure, statusKey, position }) => { {dots.map(({ structure, statusKey, position }) => {
const pct = statusKey === "building" ? constructionPct(structure, nowMs) : null; const pct = statusKey === "building" ? constructionPct(structure, nowMs) : null;
const showLabel = labeledIds.has(structure.id); const showLabel = labeledIds.has(structure.id);
const mPerPx = metersPerPx(zoom, worldSize);
const tier: StructurePinTier =
mPerPx <= STRUCTURE_RIBBON_M_PER_PX
? "lg"
: mPerPx <= STRUCTURE_PIN_MID_M_PER_PX
? "md"
: "sm";
let ribbonMeta: string;
if (statusKey === "building") {
const end = structure.constructionCompletesAt
? new Date(structure.constructionCompletesAt).getTime()
: NaN;
ribbonMeta = Number.isFinite(end)
? `ETA ${bucketedEta(Math.max(0, end - nowMs))}`
: "Building";
} else if (statusKey === "awaiting_materials") {
const first = structure.materials[0];
ribbonMeta = first
? `Needs ${first.resourceName} x${first.amount ?? 0}${structure.materials.length > 1 ? ` +${structure.materials.length - 1}` : ""}`
: "Awaiting materials";
} else {
ribbonMeta = structure.blueprintName ?? "Structure";
}
const ribbon: StructureRibbon | null =
tier === "lg" && ribbonIds.has(structure.id)
? {
tier,
name: structure.name ?? `Structure ${structure.id}`,
meta: ribbonMeta,
}
: null;
return ( return (
<Marker <Marker
key={`structure-${structure.id}`} key={`structure-${structure.id}`}
position={position} position={position}
icon={getStructureIcon(statusKey, pct)} icon={getStructureIcon(statusKey, pct, ribbon)}
> >
{showLabel ? ( {tier === "lg" ? (
ribbon ? null : (
<Tooltip
direction="top"
offset={[0, -16]}
opacity={1}
className="map-tooltip-dossier"
>
<div className="map-tooltip-name">{structure.name}</div>
<div className="map-tooltip-meta">{ribbonMeta}</div>
</Tooltip>
)
) : showLabel ? (
<Tooltip <Tooltip
permanent permanent
direction="right" direction="right"
@ -597,7 +918,8 @@ const StructureLayer = memo(function StructureLayer({
opacity={1} opacity={1}
className="map-tooltip-dossier" className="map-tooltip-dossier"
> >
{structure.name} <div className="map-tooltip-name">{structure.name}</div>
<div className="map-tooltip-meta">{ribbonMeta}</div>
</Tooltip> </Tooltip>
)} )}
<Popup className="map-popup-dossier"> <Popup className="map-popup-dossier">
@ -612,8 +934,16 @@ const StructureLayer = memo(function StructureLayer({
const RoadLabelsLayer = memo(function RoadLabelsLayer({ const RoadLabelsLayer = memo(function RoadLabelsLayer({
roads, roads,
worldSize,
}: { }: {
roads: { id: number; name: string; labelVisible: boolean | null; points: MapPoint[] }[]; roads: {
id: number;
name: string;
labelVisible: boolean | null;
surface: "paved" | "dirt" | "trail";
points: MapPoint[];
}[];
worldSize: number;
}) { }) {
const map = useMap(); const map = useMap();
const [zoom, setZoom] = useState(() => map.getZoom()); const [zoom, setZoom] = useState(() => map.getZoom());
@ -623,8 +953,9 @@ const RoadLabelsLayer = memo(function RoadLabelsLayer({
}); });
const labeledRoads = useMemo(() => { const labeledRoads = useMemo(() => {
const scale = metersPerPx(zoom, worldSize);
return roads return roads
.filter((road) => road.name && road.labelVisible !== false) .filter((road) => road.name && roadLabelShown(road, scale))
.flatMap((road) => { .flatMap((road) => {
const anchor = map.latLngToLayerPoint(toLatLng(road.points[0])); const anchor = map.latLngToLayerPoint(toLatLng(road.points[0]));
const pxPoints = road.points.map((point) => { const pxPoints = road.points.map((point) => {
@ -634,6 +965,25 @@ const RoadLabelsLayer = memo(function RoadLabelsLayer({
const screenLength = polylineLength(pxPoints); const screenLength = polylineLength(pxPoints);
if (screenLength < ROAD_LABEL_MIN_SCREEN_PX) return []; if (screenLength < ROAD_LABEL_MIN_SCREEN_PX) return [];
// Distant roads: a single straight label at the midpoint (following a
// tiny wiggly path folds the letters together). Close up: text follows
// the bends, character-aligned.
if (screenLength < ROAD_LABEL_CURVE_MIN_PX) {
const mid = interpolateAlong(road.points, 0.5);
if (!mid) return [];
return [
{
id: road.id,
name: road.name,
position: toLatLng(mid.point),
icon: straightLabelIcon(
road.name,
labelScreenAngle(road.points[0], road.points[road.points.length - 1]),
),
},
];
}
const halfText = (road.name.length * ROAD_LABEL_CHAR_PX) / 2; const halfText = (road.name.length * ROAD_LABEL_CHAR_PX) / 2;
// On very long roads a fixed interval would exhaust the label cap part // On very long roads a fixed interval would exhaust the label cap part
// way along the path; spread the capped labels over the whole road // way along the path; spread the capped labels over the whole road
@ -659,7 +1009,7 @@ const RoadLabelsLayer = memo(function RoadLabelsLayer({
}, },
]; ];
}); });
}, [map, roads, zoom]); }, [map, roads, worldSize, zoom]);
return ( return (
<> <>
@ -845,11 +1195,11 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
const zonePolys = useMemo( const zonePolys = useMemo(
() => () =>
(features?.zones ?? []).map((zone) => { (features?.zones ?? []).map((zone) => {
const fallbackColor = zone.type === "water" ? "#38bdf8" : "#4ade80"; const defaults = ZONE_PATH_OPTIONS[zone.type];
const options: L.PathOptions = { const options: L.PathOptions = {
color: zone.strokeColor || fallbackColor, color: zone.strokeColor || defaults.color,
fillColor: zone.fillColor || fallbackColor, fillColor: zone.fillColor || defaults.fillColor,
fillOpacity: zone.fillOpacity ?? (zone.type === "water" ? 0.25 : 0.08), fillOpacity: zone.fillOpacity ?? defaults.fillOpacity,
weight: zone.strokeWeight ?? 1, weight: zone.strokeWeight ?? 1,
}; };
return { zone, positions: zone.points.map(toLatLng), options }; return { zone, positions: zone.points.map(toLatLng), options };
@ -903,6 +1253,37 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
[worldW, worldH], [worldW, worldH],
); );
const maxWorld = Math.max(worldW, worldH);
const snapTargets = useMemo<SnapTarget[]>(() => {
if (mode !== "edit" || authoring.kind == null) return [];
return [
...(features?.roads ?? []).map((road) => ({
key: `road-${road.id}`,
vertices: road.points,
closed: false,
edges: true,
})),
...(features?.zones ?? []).map((zone) => ({
key: `zone-${zone.id}`,
vertices: zone.points,
closed: true,
edges: true,
})),
...(features?.nodes ?? []).map((node) => ({
key: `node-${node.id}`,
vertices: [node.point],
closed: false,
edges: false,
})),
...(features?.structures ?? []).map((structure) => ({
key: `structure-${structure.id}`,
vertices: [structure.point],
closed: false,
edges: false,
})),
];
}, [mode, authoring.kind, features]);
if (maps.length === 0) { if (maps.length === 0) {
return ( return (
<div className="flex flex-1 items-center justify-center p-6"> <div className="flex flex-1 items-center justify-center p-6">
@ -1089,10 +1470,12 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
{features && toggles.roads && ( {features && toggles.roads && (
<RoadLabelsLayer <RoadLabelsLayer
worldSize={Math.max(worldW, worldH)}
roads={features.roads.map((road) => ({ roads={features.roads.map((road) => ({
id: road.id, id: road.id,
name: road.name ?? "", name: road.name ?? "",
labelVisible: road.labelVisible, labelVisible: road.labelVisible,
surface: road.surface,
points: road.points, points: road.points,
}))} }))}
/> />
@ -1202,12 +1585,31 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
)} )}
<MousePosition /> <MousePosition />
<MapAttribution prefix="Polaris Task Force // Arma 3 // Leaflet" />
<FitToViewport worldW={worldW} worldH={worldH} minZoomFloor={mapConfig?.minZoom ?? 0} /> <FitToViewport worldW={worldW} worldH={worldH} minZoomFloor={mapConfig?.minZoom ?? 0} />
{mapConfig && ( {mapConfig && (
<DefaultView key={mapConfig.id} center={center} zoom={mapConfig.defaultZoom ?? 2} /> <DefaultView key={mapConfig.id} center={center} zoom={mapConfig.defaultZoom ?? 2} />
)} )}
{mode === "place" && <ClickHandler onPick={setPendingPoint} />} {mode === "place" && <ClickHandler onPick={setPendingPoint} />}
{mode === "edit" && authoring.kind != null && <ClickHandler onPick={authoring.pushPoint} />} {mode === "edit" && authoring.kind != null && (
<AuthoringSnapLayer
targets={snapTargets}
worldSize={maxWorld}
onPick={authoring.pushPoint}
/>
)}
{mode === "edit" && authoring.kind != null && authoring.draftPoints.length > 0 && (
<DraftHandles
points={authoring.draftPoints}
closed={authoring.kind === "zone"}
baseTargets={snapTargets}
worldSize={maxWorld}
onUpdate={authoring.updateDraftPoint}
onInsert={authoring.insertDraftPoint}
onRemove={authoring.removeDraftPoint}
/>
)}
{mode === "edit" && authoring.kind === "road" && authoring.draftPoints.length > 0 && ( {mode === "edit" && authoring.kind === "road" && authoring.draftPoints.length > 0 && (
<Polyline <Polyline

View file

@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -13,15 +14,13 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { placeStructure } from "@/app/(frontend)/logistics/map/actions"; import { placeStructure } from "@/app/(frontend)/logistics/map/actions";
import { validatePlacement, type NodeInput, type StructureInput, type ZoneInput } from "@/lib/map/placement"; import {
validatePlacement,
type NodeInput,
type StructureInput,
type ZoneInput,
} from "@/lib/map/placement";
import type { MapPoint } from "@/lib/map/points"; import type { MapPoint } from "@/lib/map/points";
import type { MapConfig, NodeFeature, StructureFeature, ZoneFeature } from "@/lib/map/types"; import type { MapConfig, NodeFeature, StructureFeature, ZoneFeature } from "@/lib/map/types";
@ -86,9 +85,18 @@ export function PlacementDialog({
terrainType?: string | null; terrainType?: string | null;
constructionDurationMinutes?: number | null; constructionDurationMinutes?: number | null;
requiredTech?: { name?: string }[] | number[]; requiredTech?: { name?: string }[] | number[];
materials?: { resource?: { id: number; name?: string } | number; amount?: number | null }[]; materials?: {
resourcesInRange?: { resource?: { id: number } | number | null; range?: number | null }[]; resource?: { id: number; name?: string } | number;
structuresInRange?: { structure?: { id: number } | number | null; range?: number | null }[]; amount?: number | null;
}[];
resourcesInRange?: {
resource?: { id: number } | number | null;
range?: number | null;
}[];
structuresInRange?: {
structure?: { id: number } | number | null;
range?: number | null;
}[];
}[]; }[];
}; };
setBlueprints( setBlueprints(
@ -98,7 +106,9 @@ export function PlacementDialog({
terrainType: (doc.terrainType as "land" | "water" | null | undefined) ?? "land", terrainType: (doc.terrainType as "land" | "water" | null | undefined) ?? "land",
constructionDurationMinutes: doc.constructionDurationMinutes ?? null, constructionDurationMinutes: doc.constructionDurationMinutes ?? null,
requiredTech: (doc.requiredTech ?? []) requiredTech: (doc.requiredTech ?? [])
.filter((tech): tech is { name?: string } => typeof tech === "object" && tech !== null) .filter(
(tech): tech is { name?: string } => typeof tech === "object" && tech !== null,
)
.map((tech) => tech.name ?? "Unknown"), .map((tech) => tech.name ?? "Unknown"),
materials: (doc.materials ?? []).map((material) => ({ materials: (doc.materials ?? []).map((material) => ({
resource: resource:
@ -235,18 +245,18 @@ export function PlacementDialog({
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label htmlFor="place-blueprint">Blueprint</Label> <Label htmlFor="place-blueprint">Blueprint</Label>
<Select value={blueprintId} onValueChange={setBlueprintId}> <Combobox
<SelectTrigger id="place-blueprint" className="w-full"> id="place-blueprint"
<SelectValue placeholder="Select blueprint" /> value={blueprintId}
</SelectTrigger> onValueChange={setBlueprintId}
<SelectContent> placeholder="Select blueprint"
{blueprints.map((option) => ( searchPlaceholder="Search blueprints..."
<SelectItem key={option.id} value={String(option.id)}> emptyMessage="No blueprints available."
{option.name} options={blueprints.map((option) => ({
</SelectItem> value: String(option.id),
))} label: option.name,
</SelectContent> }))}
</Select> />
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
@ -261,19 +271,20 @@ export function PlacementDialog({
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label htmlFor="place-faction">Faction (optional)</Label> <Label htmlFor="place-faction">Faction (optional)</Label>
<Select value={factionId} onValueChange={setFactionId}> <Combobox
<SelectTrigger id="place-faction" className="w-full"> id="place-faction"
<SelectValue /> value={factionId}
</SelectTrigger> onValueChange={setFactionId}
<SelectContent> searchPlaceholder="Search factions..."
<SelectItem value="none">No faction</SelectItem> emptyMessage="No factions available."
{factions.map((faction) => ( options={[
<SelectItem key={faction.id} value={String(faction.id)}> { value: "none", label: "No faction" },
{faction.name} ...factions.map((faction) => ({
</SelectItem> value: String(faction.id),
))} label: faction.name,
</SelectContent> })),
</Select> ]}
/>
</div> </div>
{blueprint && ( {blueprint && (

View file

@ -5,17 +5,11 @@ import { useRouter } from "next/navigation";
import type { Asset, LockerStorage } from "@/payload-types"; import type { Asset, LockerStorage } from "@/payload-types";
import { PlusIcon } from "lucide-react"; import { PlusIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Combobox } from "@/components/ui/combobox";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -196,28 +190,21 @@ export function CreateListingDialog({
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label>Item</Label> <Label>Item</Label>
<Select <Combobox
value={assetId} value={assetId}
onValueChange={(v) => { onValueChange={(v) => {
setAssetId(v); setAssetId(v);
setQuantity("1"); setQuantity("1");
}} }}
> placeholder="Select an item from your locker"
<SelectTrigger> searchPlaceholder="Search items..."
<SelectValue placeholder="Select an item from your locker" /> emptyMessage="Nothing is approved for trading yet."
</SelectTrigger> options={sellable.map((s) => ({
<SelectContent> value: String(s.assetId),
{sellable.map((s) => ( label: `${s.name} (${s.quantity.toLocaleString()}×)`,
<SelectItem disabled: !s.tradeable || !s.isLive,
key={s.assetId} }))}
value={String(s.assetId)} />
disabled={!s.tradeable || !s.isLive}
>
{s.name} ({s.quantity.toLocaleString()}×)
</SelectItem>
))}
</SelectContent>
</Select>
{sellable.length === 0 && ( {sellable.length === 0 && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{hasLockerItems {hasLockerItems

View file

@ -10,6 +10,7 @@ import {
setPokerHoleQueue, setPokerHoleQueue,
} from "@/app/(frontend)/minigames/poker/actions"; } from "@/app/(frontend)/minigames/poker/actions";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -65,11 +66,7 @@ function cardLabel(card: Card): string {
* what lets a single pick land; the previous null-propagating version made * what lets a single pick land; the previous null-propagating version made
* first picks no-ops and hole-card queuing impossible. * first picks no-ops and hole-card queuing impossible.
*/ */
export function applyCardPick( export function applyCardPick(value: Card | null, part: "rank" | "suit", next: string): Card {
value: Card | null,
part: "rank" | "suit",
next: string,
): Card {
if (part === "rank") { if (part === "rank") {
return { rank: Number(next) as Rank, suit: value?.suit ?? "s" }; return { rank: Number(next) as Rank, suit: value?.suit ?? "s" };
} }
@ -91,18 +88,17 @@ function CardPicker({
return ( return (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="w-10 text-xs text-muted-foreground">{label}</span> <span className="w-10 text-xs text-muted-foreground">{label}</span>
<Select value={value ? String(value.rank) : ""} onValueChange={setRank}> <Combobox
<SelectTrigger className="h-8 w-20 text-xs"> className="h-8 w-20 text-xs"
<SelectValue placeholder="Rank" /> value={value ? String(value.rank) : ""}
</SelectTrigger> onValueChange={setRank}
<SelectContent> placeholder="Rank"
{RANK_OPTIONS.map((option) => ( searchPlaceholder="Rank..."
<SelectItem key={option.value} value={String(option.value)}> options={RANK_OPTIONS.map((option) => ({
{option.label} value: String(option.value),
</SelectItem> label: option.label,
))} }))}
</SelectContent> />
</Select>
<Select value={value?.suit ?? ""} onValueChange={setSuit}> <Select value={value?.suit ?? ""} onValueChange={setSuit}>
<SelectTrigger className="h-8 w-24 text-xs"> <SelectTrigger className="h-8 w-24 text-xs">
<SelectValue placeholder="Suit" /> <SelectValue placeholder="Suit" />
@ -258,20 +254,19 @@ export function PokerDevPanel({
<section className="flex flex-col gap-2 border border-border p-3"> <section className="flex flex-col gap-2 border border-border p-3">
<h3 className="text-xs font-semibold uppercase tracking-wider">Next hand hole cards</h3> <h3 className="text-xs font-semibold uppercase tracking-wider">Next hand hole cards</h3>
<Select value={seatId} onValueChange={setSeatId}> <Combobox
<SelectTrigger className="h-8 text-xs"> className="h-8 text-xs"
<SelectValue placeholder="Player" /> value={seatId}
</SelectTrigger> onValueChange={setSeatId}
<SelectContent> placeholder="Player"
{view.players searchPlaceholder="Search players..."
options={view.players
.filter((player) => !player.eliminated) .filter((player) => !player.eliminated)
.map((player) => ( .map((player) => ({
<SelectItem key={player.seatId} value={String(player.seatId)}> value: String(player.seatId),
{player.name} label: player.name,
</SelectItem> }))}
))} />
</SelectContent>
</Select>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<CardPicker label="Card 1" value={holeA} onChange={setHoleA} /> <CardPicker label="Card 1" value={holeA} onChange={setHoleA} />
<CardPicker label="Card 2" value={holeB} onChange={setHoleB} /> <CardPicker label="Card 2" value={holeB} onChange={setHoleB} />
@ -289,20 +284,19 @@ export function PokerDevPanel({
<section className="flex flex-col gap-2 border border-border p-3"> <section className="flex flex-col gap-2 border border-border p-3">
<h3 className="text-xs font-semibold uppercase tracking-wider">Stack adjustment</h3> <h3 className="text-xs font-semibold uppercase tracking-wider">Stack adjustment</h3>
<Select value={adjustSeat} onValueChange={setAdjustSeat}> <Combobox
<SelectTrigger className="h-8 text-xs"> className="h-8 text-xs"
<SelectValue placeholder="Player" /> value={adjustSeat}
</SelectTrigger> onValueChange={setAdjustSeat}
<SelectContent> placeholder="Player"
{view.players searchPlaceholder="Search players..."
options={view.players
.filter((player) => !player.eliminated) .filter((player) => !player.eliminated)
.map((player) => ( .map((player) => ({
<SelectItem key={player.seatId} value={String(player.seatId)}> value: String(player.seatId),
{player.name} ({player.stack.toLocaleString()}) label: `${player.name} (${player.stack.toLocaleString()})`,
</SelectItem> }))}
))} />
</SelectContent>
</Select>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
type="number" type="number"

View file

@ -12,6 +12,7 @@ import {
} from "@/lib/evaluations/levels"; } from "@/lib/evaluations/levels";
import { submitEvaluation } from "@/app/(frontend)/profile/[username]/actions"; import { submitEvaluation } from "@/app/(frontend)/profile/[username]/actions";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -134,20 +135,20 @@ export function LeadershipEvaluationDialog({
<form className="flex flex-col gap-4" onSubmit={handleSubmit} aria-busy={pending}> <form className="flex flex-col gap-4" onSubmit={handleSubmit} aria-busy={pending}>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label htmlFor="evaluation-mission">Mission</Label> <Label htmlFor="evaluation-mission">Mission</Label>
<Select value={missionId} onValueChange={setMissionId} disabled={pending}> <Combobox
<SelectTrigger id="evaluation-mission" aria-invalid={error !== null}> id="evaluation-mission"
<SelectValue placeholder="Select a completed mission" /> aria-invalid={error !== null}
</SelectTrigger> value={missionId}
<SelectContent> onValueChange={setMissionId}
<SelectGroup> disabled={pending}
{missions.map((mission) => ( placeholder="Select a completed mission"
<SelectItem key={mission.id} value={String(mission.id)}> searchPlaceholder="Search missions..."
{mission.name} · {formatMissionDate(mission.startDateTime)} emptyMessage="No completed missions to evaluate."
</SelectItem> options={missions.map((mission) => ({
))} value: String(mission.id),
</SelectGroup> label: `${mission.name} · ${formatMissionDate(mission.startDateTime)}`,
</SelectContent> }))}
</Select> />
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">

View file

@ -3,6 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -11,13 +12,6 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { promoteMember } from "@/app/(frontend)/profile/[username]/actions"; import { promoteMember } from "@/app/(frontend)/profile/[username]/actions";
import { ArrowUpIcon, Loader2Icon, MedalIcon } from "lucide-react"; import { ArrowUpIcon, Loader2Icon, MedalIcon } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@ -97,21 +91,16 @@ export function PromoteButton({ username, currentRankName, targetRanks }: Promot
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Select <Combobox
value={selectedRankId != null ? String(selectedRankId) : undefined} value={selectedRankId != null ? String(selectedRankId) : undefined}
onValueChange={(v) => setRankId(Number(v))} onValueChange={(v) => setRankId(Number(v))}
> placeholder="Select a rank"
<SelectTrigger> searchPlaceholder="Search ranks..."
<SelectValue placeholder="Select a rank" /> options={targetRanks.map((r) => ({
</SelectTrigger> value: String(r.id),
<SelectContent> label: r.name,
{targetRanks.map((r) => ( }))}
<SelectItem key={r.id} value={String(r.id)}> />
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
{error && <p className="text-sm text-red-500">{error}</p>} {error && <p className="text-sm text-red-500">{error}</p>}
</div> </div>
<DialogFooter> <DialogFooter>

View file

@ -1,8 +1,11 @@
"use client"; "use client";
import { useState } from "react";
import { TagsIcon } from "lucide-react"; import { TagsIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import { Input } from "@/components/ui/input";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuCheckboxItem, DropdownMenuCheckboxItem,
@ -11,15 +14,6 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { KanbanFiltersProps, SprintFilter } from "./KanbanBoard"; import type { KanbanFiltersProps, SprintFilter } from "./KanbanBoard";
function sprintFilterValue(value: string): SprintFilter { function sprintFilterValue(value: string): SprintFilter {
@ -29,7 +23,9 @@ function sprintFilterValue(value: string): SprintFilter {
} }
function activeSprints(sprints: KanbanFiltersProps["sprints"]) { function activeSprints(sprints: KanbanFiltersProps["sprints"]) {
const active = sprints.filter((sprint) => ["planned", "active", "in_progress"].includes(sprint.status)); const active = sprints.filter((sprint) =>
["planned", "active", "in_progress"].includes(sprint.status),
);
return active.length > 0 ? active : sprints; return active.length > 0 ? active : sprints;
} }
@ -43,6 +39,12 @@ export function KanbanFilters({
}: KanbanFiltersProps) { }: KanbanFiltersProps) {
const selectedLabelCount = selectedLabelIds.length; const selectedLabelCount = selectedLabelIds.length;
const sprintOptions = activeSprints(sprints); const sprintOptions = activeSprints(sprints);
const [labelQuery, setLabelQuery] = useState("");
const [labelsOpen, setLabelsOpen] = useState(false);
const labelNeedle = labelQuery.trim().toLowerCase();
const visibleLabels = labelNeedle
? labels.filter((label) => label.name.toLowerCase().includes(labelNeedle))
: labels;
const sprintLabel = const sprintLabel =
sprintFilter === "all" sprintFilter === "all"
? "All sprints" ? "All sprints"
@ -50,27 +52,23 @@ export function KanbanFilters({
return ( return (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Select <Combobox
value={String(sprintFilter)} value={String(sprintFilter)}
onValueChange={(value) => onSprintFilterChange(sprintFilterValue(value))} onValueChange={(value) => onSprintFilterChange(sprintFilterValue(value))}
> className="w-[190px]"
<SelectTrigger className="w-[190px]" aria-label="Filter by sprint"> aria-label="Filter by sprint"
<SelectValue placeholder={sprintLabel ?? "Select sprint"} /> placeholder={sprintLabel ?? "Select sprint"}
</SelectTrigger> searchPlaceholder="Search sprints..."
<SelectContent> options={[
<SelectGroup> { value: "all", label: "All sprints" },
<SelectLabel>Sprint</SelectLabel> ...sprintOptions.map((sprint) => ({
<SelectItem value="all">All sprints</SelectItem> value: String(sprint.id),
{sprintOptions.map((sprint) => ( label: sprint.name,
<SelectItem key={sprint.id} value={String(sprint.id)}> })),
{sprint.name} ]}
</SelectItem> />
))}
</SelectGroup>
</SelectContent>
</Select>
<DropdownMenu> <DropdownMenu open={labelsOpen} onOpenChange={setLabelsOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" aria-label="Filter by label"> <Button variant="outline" size="sm" aria-label="Filter by label">
<TagsIcon data-icon="inline-start" /> <TagsIcon data-icon="inline-start" />
@ -81,10 +79,22 @@ export function KanbanFilters({
<DropdownMenuContent align="start" className="w-56"> <DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel>Filter by label</DropdownMenuLabel> <DropdownMenuLabel>Filter by label</DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<div className="px-2 pb-1.5">
<Input
value={labelQuery}
onChange={(event) => setLabelQuery(event.target.value)}
placeholder="Search labels..."
className="h-7 text-xs"
/>
</div>
{labels.length === 0 ? ( {labels.length === 0 ? (
<span className="block px-2 py-1.5 text-sm text-muted-foreground">No labels yet</span> <span className="block px-2 py-1.5 text-sm text-muted-foreground">No labels yet</span>
) : visibleLabels.length === 0 ? (
<span className="block px-2 py-1.5 text-sm text-muted-foreground">
No matching labels
</span>
) : ( ) : (
labels.map((label) => ( visibleLabels.map((label) => (
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
key={label.id} key={label.id}
checked={selectedLabelIds.includes(label.id)} checked={selectedLabelIds.includes(label.id)}

View file

@ -11,6 +11,7 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
interface LogEntry { interface LogEntry {
id: number; id: number;
@ -37,6 +38,8 @@ export function EventLedger({
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedTypes, setSelectedTypes] = useState<Set<string>>(new Set()); const [selectedTypes, setSelectedTypes] = useState<Set<string>>(new Set());
const [currentUserId, setCurrentUserId] = useState<number | null>(null); const [currentUserId, setCurrentUserId] = useState<number | null>(null);
const [typeQuery, setTypeQuery] = useState("");
const [filterMenuOpen, setFilterMenuOpen] = useState(false);
const initialDone = useRef(false); const initialDone = useRef(false);
const eventTypes = useMemo(() => { const eventTypes = useMemo(() => {
@ -44,6 +47,11 @@ export function EventLedger({
return Array.from(set).sort(); return Array.from(set).sort();
}, [entries]); }, [entries]);
const typeNeedle = typeQuery.trim().toLowerCase();
const visibleTypes = typeNeedle
? eventTypes.filter((t) => formatType(t).toLowerCase().includes(typeNeedle))
: eventTypes;
const filtered = useMemo( const filtered = useMemo(
() => (selectedTypes.size === 0 ? entries : entries.filter((e) => selectedTypes.has(e.type))), () => (selectedTypes.size === 0 ? entries : entries.filter((e) => selectedTypes.has(e.type))),
[entries, selectedTypes], [entries, selectedTypes],
@ -138,7 +146,13 @@ export function EventLedger({
</ItemHeader> </ItemHeader>
<ItemContent> <ItemContent>
{eventTypes.length > 1 && ( {eventTypes.length > 1 && (
<DropdownMenu> <DropdownMenu
open={filterMenuOpen}
onOpenChange={(next) => {
setFilterMenuOpen(next);
if (!next) setTypeQuery("");
}}
>
<span className="text-xs text-muted-foreground">Filter event logs...</span> <span className="text-xs text-muted-foreground">Filter event logs...</span>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
@ -148,22 +162,39 @@ export function EventLedger({
{dropdownLabel} {dropdownLabel}
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start"> <DropdownMenuContent align="start" className="w-60">
<div className="p-1.5">
<Input
value={typeQuery}
onChange={(event) => setTypeQuery(event.target.value)}
placeholder="Search event types..."
className="h-7 rounded-none text-xs"
/>
</div>
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
checked={selectedTypes.size === 0} checked={selectedTypes.size === 0}
onCheckedChange={() => setSelectedTypes(new Set())} onCheckedChange={() => setSelectedTypes(new Set())}
onSelect={(event) => event.preventDefault()}
> >
All All
</DropdownMenuCheckboxItem> </DropdownMenuCheckboxItem>
{eventTypes.map((t) => ( <div className="max-h-64 overflow-y-auto">
{visibleTypes.map((t) => (
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
key={t} key={t}
checked={selectedTypes.has(t)} checked={selectedTypes.has(t)}
onCheckedChange={() => toggleType(t)} onCheckedChange={() => toggleType(t)}
onSelect={(event) => event.preventDefault()}
> >
{formatType(t)} {formatType(t)}
</DropdownMenuCheckboxItem> </DropdownMenuCheckboxItem>
))} ))}
{visibleTypes.length === 0 && (
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
No matches
</div>
)}
</div>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} )}

View file

@ -9,6 +9,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Combobox } from "@/components/ui/combobox";
import { import {
Select, Select,
SelectContent, SelectContent,
@ -508,21 +509,20 @@ export function ManageStorageDialog({
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Transport Vehicle</Label> <Label>Transport Vehicle</Label>
{availableVehicles.length > 0 ? ( {availableVehicles.length > 0 ? (
<Select value={shipmentVehicleId} onValueChange={setShipmentVehicleId}> <Combobox
<SelectTrigger> value={shipmentVehicleId}
<SelectValue placeholder="Select a vehicle..." /> onValueChange={setShipmentVehicleId}
</SelectTrigger> placeholder="Select a vehicle..."
<SelectContent> searchPlaceholder="Search vehicles..."
{availableVehicles.map((v) => { emptyMessage="No vehicles with range."
options={availableVehicles.map((v) => {
const vType = typeof v.type === "object" ? v.type : null; const vType = typeof v.type === "object" ? v.type : null;
return ( return {
<SelectItem key={v.id} value={String(v.id)}> value: String(v.id),
{v.name} ({vType?.name ?? "Unknown"}), {v.currentFuel?.toFixed(0)} fuel label: `${v.name} (${vType?.name ?? "Unknown"}), ${v.currentFuel?.toFixed(0)} fuel`,
</SelectItem> };
);
})} })}
</SelectContent> />
</Select>
) : ( ) : (
<div className="flex items-center gap-2 p-3 rounded-md border border-amber-500/30 bg-amber-500/5 text-sm text-amber-400"> <div className="flex items-center gap-2 p-3 rounded-md border border-amber-500/30 bg-amber-500/5 text-sm text-amber-400">
<TruckIcon className="size-4 shrink-0" /> <TruckIcon className="size-4 shrink-0" />
@ -684,7 +684,7 @@ export function ManageStorageDialog({
<TabsContent value="deposit" className="flex flex-col gap-4 pt-4"> <TabsContent value="deposit" className="flex flex-col gap-4 pt-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Resource</Label> <Label>Resource</Label>
<Select <Combobox
value={depositResourceId} value={depositResourceId}
onValueChange={(val) => { onValueChange={(val) => {
setDepositResourceId(val); setDepositResourceId(val);
@ -692,29 +692,20 @@ export function ManageStorageDialog({
clampAmount(depositAmount, getDepositLimitInfoFor(val)?.max), clampAmount(depositAmount, getDepositLimitInfoFor(val)?.max),
); );
}} }}
> placeholder="Select a resource..."
<SelectTrigger> searchPlaceholder="Search resources..."
<SelectValue placeholder="Select a resource..." /> emptyMessage="Nothing may be deposited here."
</SelectTrigger> options={allResources
<SelectContent>
{allResources
.filter((r) => isResourceAllowed(r.id)) .filter((r) => isResourceAllowed(r.id))
.map((resource) => { .map((resource) => {
const cap = getAllowedDepositCap(resource.id); const cap = getAllowedDepositCap(resource.id);
return ( return {
<SelectItem key={resource.id} value={String(resource.id)}> value: String(resource.id),
{resource.name} label: resource.name,
{cap !== undefined && ( hint: cap !== undefined ? `${cap.toLocaleString()} max` : undefined,
<span className="text-muted-foreground"> };
{" "}
({cap.toLocaleString()} max)
</span>
)}
</SelectItem>
);
})} })}
</SelectContent> />
</Select>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Amount</Label> <Label>Amount</Label>
@ -754,30 +745,27 @@ export function ManageStorageDialog({
<TabsContent value="withdraw" className="flex flex-col gap-4 pt-4"> <TabsContent value="withdraw" className="flex flex-col gap-4 pt-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Resource</Label> <Label>Resource</Label>
<Select <Combobox
value={withdrawResourceId} value={withdrawResourceId}
onValueChange={(val) => { onValueChange={(val) => {
setWithdrawResourceId(val); setWithdrawResourceId(val);
const stored = findStoredResource(val); const stored = findStoredResource(val);
setWithdrawAmount(clampAmount(withdrawAmount, stored?.amount)); setWithdrawAmount(clampAmount(withdrawAmount, stored?.amount));
}} }}
> placeholder="Select a resource..."
<SelectTrigger> searchPlaceholder="Search resources..."
<SelectValue placeholder="Select a resource..." /> emptyMessage="Nothing stored yet."
</SelectTrigger> options={storedResources
<SelectContent> .map((entry) => {
{storedResources.map((entry) => {
const resource = entry.resource as Resource; const resource = entry.resource as Resource;
if (!resource || typeof resource === "number") return null; if (!resource || typeof resource === "number") return null;
return ( return {
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}> value: String(resource.id),
{resource.name} ({entry.amount.toLocaleString()}{" "} label: `${resource.name} (${entry.amount.toLocaleString()} ${pluralize(entry.amount, resource.unitOfMeasure)})`,
{pluralize(entry.amount, resource.unitOfMeasure)}) };
</SelectItem> })
); .filter((option) => option !== null)}
})} />
</SelectContent>
</Select>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Amount</Label> <Label>Amount</Label>
@ -817,51 +805,43 @@ export function ManageStorageDialog({
<TabsContent value="transfer" className="flex flex-col gap-4 pt-4"> <TabsContent value="transfer" className="flex flex-col gap-4 pt-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Destination</Label> <Label>Destination</Label>
<Select <Combobox
value={transferToId} value={transferToId}
onValueChange={(val) => { onValueChange={(val) => {
setTransferToId(val); setTransferToId(val);
setTransferAmount(clampAmount(transferAmount, getTransferMaxFor(val))); setTransferAmount(clampAmount(transferAmount, getTransferMaxFor(val)));
}} }}
> placeholder="Select a structure..."
<SelectTrigger> searchPlaceholder="Search structures..."
<SelectValue placeholder="Select a structure..." /> options={allStructures.map((s) => ({
</SelectTrigger> value: String(s.id),
<SelectContent> label: s.name,
{allStructures.map((s) => ( }))}
<SelectItem key={s.id} value={String(s.id)}> />
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Resource</Label> <Label>Resource</Label>
<Select <Combobox
value={transferResourceId} value={transferResourceId}
onValueChange={(val) => { onValueChange={(val) => {
setTransferResourceId(val); setTransferResourceId(val);
const stored = findStoredResource(val); const stored = findStoredResource(val);
setTransferAmount(clampAmount(transferAmount, stored?.amount)); setTransferAmount(clampAmount(transferAmount, stored?.amount));
}} }}
> placeholder="Select a resource..."
<SelectTrigger> searchPlaceholder="Search resources..."
<SelectValue placeholder="Select a resource..." /> emptyMessage="Nothing stored yet."
</SelectTrigger> options={storedResources
<SelectContent> .map((entry) => {
{storedResources.map((entry) => {
const resource = entry.resource as Resource; const resource = entry.resource as Resource;
if (!resource || typeof resource === "number") return null; if (!resource || typeof resource === "number") return null;
return ( return {
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}> value: String(resource.id),
{resource.name} ({entry.amount.toLocaleString()}{" "} label: `${resource.name} (${entry.amount.toLocaleString()} ${pluralize(entry.amount, resource.unitOfMeasure)})`,
{pluralize(entry.amount, resource.unitOfMeasure)}) };
</SelectItem> })
); .filter((option) => option !== null)}
})} />
</SelectContent>
</Select>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Amount</Label> <Label>Amount</Label>

View file

@ -0,0 +1,122 @@
"use client";
import * as React from "react";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
export interface ComboboxOption {
value: string;
label: string;
/** Optional secondary line rendered under the label. */
hint?: string;
/** Disabled options render muted and cannot be selected. */
disabled?: boolean;
}
interface ComboboxProps extends React.ComponentPropsWithoutRef<"button"> {
value?: string;
onValueChange: (value: string) => void;
options: ComboboxOption[];
placeholder?: string;
searchPlaceholder?: string;
emptyMessage?: string;
/** Trigger sizing classes (the dropdown keeps the trigger's width). */
className?: string;
/** Popover content classes. */
contentClassName?: string;
}
/**
* Searchable single-value dropdown (Popover + cmdk), a drop-in replacement for
* ui/select wherever the item list is data-driven and can grow long. The
* popover stays as wide as the trigger and the list scrolls at 16rem.
*/
function Combobox({
value,
onValueChange,
options,
placeholder = "Select...",
searchPlaceholder = "Search...",
emptyMessage = "No matches.",
disabled = false,
className,
contentClassName,
...rest
}: ComboboxProps) {
const [open, setOpen] = React.useState(false);
const selected = options.find((option) => option.value === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
disabled={disabled}
role="combobox"
aria-expanded={open}
className={cn(
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs outline-none",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20",
"disabled:cursor-not-allowed disabled:opacity-50",
!selected && "text-muted-foreground",
className,
)}
{...rest}
>
<span className="truncate">{selected ? selected.label : placeholder}</span>
<ChevronDown className="size-4 shrink-0 opacity-50" aria-hidden />
</PopoverTrigger>
<PopoverContent
className={cn("w-[var(--radix-popover-trigger-width)] p-0", contentClassName)}
align="start"
>
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList className="max-h-64">
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={`${option.label} ${option.value}`}
disabled={option.disabled}
onSelect={() => {
onValueChange(option.value);
setOpen(false);
}}
>
<Check
className={cn(
"mr-1 size-4",
option.value === value ? "opacity-100" : "opacity-0",
)}
aria-hidden
/>
<span className="min-w-0">
<span className="block truncate">{option.label}</span>
{option.hint && (
<span className="block truncate text-xs text-muted-foreground">
{option.hint}
</span>
)}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
export { Combobox };

View file

@ -16,7 +16,7 @@ export type BlueprintTerrain = "land" | "water";
export interface ZoneInput { export interface ZoneInput {
id: number; id: number;
name?: string | null; name?: string | null;
type: "water" | "land"; type: "water" | "land" | "territory" | "border";
/** Resource ids contained in this zone; a site inside satisfies their requirements. */ /** Resource ids contained in this zone; a site inside satisfies their requirements. */
resources?: number[]; resources?: number[];
points: MapPoint[]; points: MapPoint[];
@ -112,8 +112,7 @@ export function checkResourcesInRange(
node.resources.includes(resourceId) && distanceSquared(node.position, point) <= maxSq, node.resources.includes(resourceId) && distanceSquared(node.position, point) <= maxSq,
); );
const zoneMatch = zones.some( const zoneMatch = zones.some(
(zone) => (zone) => (zone.resources ?? []).includes(resourceId) && pointInPolygon(point, zone.points),
(zone.resources ?? []).includes(resourceId) && pointInPolygon(point, zone.points),
); );
if (!nodeMatch && !zoneMatch) { if (!nodeMatch && !zoneMatch) {
return { return {

95
src/lib/map/snapping.ts Normal file
View file

@ -0,0 +1,95 @@
import { pointDistance, type MapPoint } from "./points";
export type SnapKind = "vertex" | "edge";
export interface SnapTarget {
key: string;
vertices: MapPoint[];
/** Polygon target: the closing segment (last -> first vertex) participates. */
closed: boolean;
/** Offer edge snapping (nearest point on each segment), not just vertices. */
edges: boolean;
}
export interface SnapMatch {
point: MapPoint;
kind: SnapKind;
targetKey: string;
/** Distance in meters from the cursor to the matched geometry. */
distance: number;
}
/** Nearest point to p on segment ab (projection clamped to the endpoints). */
export function closestPointOnSegment(p: MapPoint, a: MapPoint, b: MapPoint): MapPoint {
const abx = b[0] - a[0];
const aby = b[1] - a[1];
const lengthSq = abx * abx + aby * aby;
if (lengthSq === 0) return [a[0], a[1]];
const t = Math.max(0, Math.min(1, ((p[0] - a[0]) * abx + (p[1] - a[1]) * aby) / lengthSq));
return [a[0] + abx * t, a[1] + aby * t];
}
/**
* Nearest snap within radiusMeters. Vertices own their neighborhood: an edge
* only wins when it is at least twice as close as the nearest vertex (clicks
* near a corner mean the corner, not one of the edges meeting there). Returns null when
* nothing is in range.
*/
export function findSnap(
point: MapPoint,
targets: readonly SnapTarget[],
radiusMeters: number,
): SnapMatch | null {
if (radiusMeters <= 0) return null;
let bestVertex: SnapMatch | null = null;
let bestEdge: SnapMatch | null = null;
for (const target of targets) {
for (const vertex of target.vertices) {
const distance = pointDistance(point, vertex);
if (distance <= radiusMeters && (!bestVertex || distance < bestVertex.distance)) {
bestVertex = {
point: [vertex[0], vertex[1]],
kind: "vertex",
targetKey: target.key,
distance,
};
}
}
if (!target.edges) continue;
const last = target.closed ? target.vertices.length : target.vertices.length - 1;
for (let i = 0; i < last; i++) {
const a = target.vertices[i];
const b = target.vertices[(i + 1) % target.vertices.length];
const projected = closestPointOnSegment(point, a, b);
const distance = pointDistance(point, projected);
if (distance <= radiusMeters && (!bestEdge || distance < bestEdge.distance)) {
bestEdge = { point: projected, kind: "edge", targetKey: target.key, distance };
}
}
}
if (bestVertex && (!bestEdge || bestEdge.distance >= bestVertex.distance * 0.5)) {
return bestVertex;
}
return bestEdge ?? bestVertex;
}
export interface SegmentMidpoint {
/** Insert position in the point array: i + 1 (appends for the closing segment). */
index: number;
point: MapPoint;
}
/** Midpoint of every segment, for click-to-insert vertex handles. */
export function segmentMidpoints(points: MapPoint[], closed: boolean): SegmentMidpoint[] {
const midpoints: SegmentMidpoint[] = [];
if (points.length < 2) return midpoints;
const segments = closed ? points.length : points.length - 1;
for (let i = 0; i < segments; i++) {
const a = points[i];
const b = points[(i + 1) % points.length];
midpoints.push({ index: i + 1, point: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2] });
}
return midpoints;
}

View file

@ -147,3 +147,37 @@ function segmentAngleDeg(from: MapPoint, to: MapPoint): number {
if (dx === 0 && dy === 0) return 0; if (dx === 0 && dy === 0) return 0;
return (Math.atan2(dy, dx) * 180) / Math.PI; return (Math.atan2(dy, dx) * 180) / Math.PI;
} }
/**
* CSS rotation for a straight label running along the from->to direction:
* normalized into [-90, 90] (never upside down) with vertical text reading
* bottom-to-top.
*/
export function labelScreenAngle(from: MapPoint, to: MapPoint): number {
const angle = segmentAngleDeg(from, to);
if (angle > 90) return angle - 180;
if (angle < -90) return angle + 180;
if (angle === 90) return -90;
return angle;
}
/**
* Minor roads (dirt/trail) only carry a name label this close to the ground,
* in meters per pixel (zoom >= 3 on an 8192 m world).
*/
export const MINOR_ROAD_LABEL_MAX_M_PER_PX = 4;
/**
* Tri-state road label rule: an explicit checkbox value wins; unset means
* auto, where paved roads always label and dirt/trail roads label only at
* close zoom (metersPerPx at or below MINOR_ROAD_LABEL_MAX_M_PER_PX; pass
* null/undefined to evaluate the zoom-independent part).
*/
export function roadLabelShown(
road: { labelVisible?: boolean | null; surface: string },
metersPerPx?: number | null,
): boolean {
if (road.labelVisible != null) return road.labelVisible;
if (road.surface === "paved") return true;
return metersPerPx != null && metersPerPx <= MINOR_ROAD_LABEL_MAX_M_PER_PX;
}

View file

@ -67,7 +67,7 @@ export interface ZoneFeature {
id: number; id: number;
name: string | null; name: string | null;
description: string | null; description: string | null;
type: "water" | "land"; type: "water" | "land" | "territory" | "border";
resources: { id: number; name: string }[]; resources: { id: number; name: string }[];
fillColor: string | null; fillColor: string | null;
strokeColor: string | null; strokeColor: string | null;

View file

@ -0,0 +1,29 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from "@payloadcms/db-postgres";
export async function up({ db }: MigrateUpArgs): Promise<void> {
// Political-boundary zone types (Postgres 12+ allows ADD VALUE inside the
// migration's transaction as long as the new values are not used in it).
await db.execute(sql`
ALTER TYPE "enum_map_zones_type" ADD VALUE IF NOT EXISTS 'territory';
`);
await db.execute(sql`
ALTER TYPE "enum_map_zones_type" ADD VALUE IF NOT EXISTS 'border';
`);
// Road labels become tri-state: NULL = auto (paved only). Existing rows all
// carry the old column default (true), which was never a deliberate choice,
// so clear it on non-paved roads to make the paved-only default real.
await db.execute(sql`
ALTER TABLE "map_roads" ALTER COLUMN "label_visible" DROP DEFAULT;
`);
await db.execute(sql`
UPDATE "map_roads" SET "label_visible" = NULL WHERE "surface" IS DISTINCT FROM 'paved';
`);
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
// Enum values cannot be removed in Postgres; restore the old road default.
await db.execute(sql`
ALTER TABLE "map_roads" ALTER COLUMN "label_visible" SET DEFAULT true;
`);
}

View file

@ -1,399 +1,405 @@
import * as migration_20260820_004157 from './20260820_004157'; import * as migration_20260820_004157 from "./20260820_004157";
import * as migration_20260825_220000 from './20260825_220000'; import * as migration_20260825_220000 from "./20260825_220000";
import * as migration_20260826_050000_repair_shims_auxiliary_tables from './20260826_050000_repair_shims_auxiliary_tables'; import * as migration_20260826_050000_repair_shims_auxiliary_tables from "./20260826_050000_repair_shims_auxiliary_tables";
import * as migration_20260826_060000_add_game_servers_event_target from './20260826_060000_add_game_servers_event_target'; import * as migration_20260826_060000_add_game_servers_event_target from "./20260826_060000_add_game_servers_event_target";
import * as migration_20260827_005757 from './20260827_005757'; import * as migration_20260827_005757 from "./20260827_005757";
import * as migration_20260827_022334_add_eod_minesweeper_best_flags from './20260827_022334_add_eod_minesweeper_best_flags'; import * as migration_20260827_022334_add_eod_minesweeper_best_flags from "./20260827_022334_add_eod_minesweeper_best_flags";
import * as migration_20260827_213857_add_evaluations_collection from './20260827_213857_add_evaluations_collection'; import * as migration_20260827_213857_add_evaluations_collection from "./20260827_213857_add_evaluations_collection";
import * as migration_20260827_233224_add_mission_completed_at from './20260827_233224_add_mission_completed_at'; import * as migration_20260827_233224_add_mission_completed_at from "./20260827_233224_add_mission_completed_at";
import * as migration_20260828_011500_add_background_texture_preference from './20260828_011500_add_background_texture_preference'; import * as migration_20260828_011500_add_background_texture_preference from "./20260828_011500_add_background_texture_preference";
import * as migration_20260828_041615_add_campaign_mode from './20260828_041615_add_campaign_mode'; import * as migration_20260828_041615_add_campaign_mode from "./20260828_041615_add_campaign_mode";
import * as migration_20260828_044506 from './20260828_044506'; import * as migration_20260828_044506 from "./20260828_044506";
import * as migration_20260831_142503 from './20260831_142503'; import * as migration_20260831_142503 from "./20260831_142503";
import * as migration_20260831_180233_assignment_transfers from './20260831_180233_assignment_transfers'; import * as migration_20260831_180233_assignment_transfers from "./20260831_180233_assignment_transfers";
import * as migration_20260901_014006_add_assignment_transfer_event_target from './20260901_014006_add_assignment_transfer_event_target'; import * as migration_20260901_014006_add_assignment_transfer_event_target from "./20260901_014006_add_assignment_transfer_event_target";
import * as migration_20260901_213108_add_ticket_votes_and_edit_history from './20260901_213108_add_ticket_votes_and_edit_history'; import * as migration_20260901_213108_add_ticket_votes_and_edit_history from "./20260901_213108_add_ticket_votes_and_edit_history";
import * as migration_20260901_222200_add_admin_page_permissions from './20260901_222200_add_admin_page_permissions'; import * as migration_20260901_222200_add_admin_page_permissions from "./20260901_222200_add_admin_page_permissions";
import * as migration_20260902_195336_add_medal_backgrounds from './20260902_195336_add_medal_backgrounds'; import * as migration_20260902_195336_add_medal_backgrounds from "./20260902_195336_add_medal_backgrounds";
import * as migration_20260903_035544 from './20260903_035544'; import * as migration_20260903_035544 from "./20260903_035544";
import * as migration_20260903_044047 from './20260903_044047'; import * as migration_20260903_044047 from "./20260903_044047";
import * as migration_20260903_053419 from './20260903_053419'; import * as migration_20260903_053419 from "./20260903_053419";
import * as migration_20260903_060000_add_radio_mode_stats from './20260903_060000_add_radio_mode_stats'; import * as migration_20260903_060000_add_radio_mode_stats from "./20260903_060000_add_radio_mode_stats";
import * as migration_20260903_061000_add_radio_incorrect_counts from './20260903_061000_add_radio_incorrect_counts'; import * as migration_20260903_061000_add_radio_incorrect_counts from "./20260903_061000_add_radio_incorrect_counts";
import * as migration_20260903_062000_add_aim_trainer_stats from './20260903_062000_add_aim_trainer_stats'; import * as migration_20260903_062000_add_aim_trainer_stats from "./20260903_062000_add_aim_trainer_stats";
import * as migration_20260904_230838_add_wiki_collections from './20260904_230838_add_wiki_collections'; import * as migration_20260904_230838_add_wiki_collections from "./20260904_230838_add_wiki_collections";
import * as migration_20260905_183940_base_management from './20260905_183940_base_management'; import * as migration_20260905_183940_base_management from "./20260905_183940_base_management";
import * as migration_20260905_195456_labor_headcount from './20260905_195456_labor_headcount'; import * as migration_20260905_195456_labor_headcount from "./20260905_195456_labor_headcount";
import * as migration_20260905_210310_labor_classifications from './20260905_210310_labor_classifications'; import * as migration_20260905_210310_labor_classifications from "./20260905_210310_labor_classifications";
import * as migration_20260905_213824 from './20260905_213824'; import * as migration_20260905_213824 from "./20260905_213824";
import * as migration_20260905_215658 from './20260905_215658'; import * as migration_20260905_215658 from "./20260905_215658";
import * as migration_20260906_045502 from './20260906_045502'; import * as migration_20260906_045502 from "./20260906_045502";
import * as migration_20260906_050900_add_evaluation_reminders_field from './20260906_050900_add_evaluation_reminders_field'; import * as migration_20260906_050900_add_evaluation_reminders_field from "./20260906_050900_add_evaluation_reminders_field";
import * as migration_20260906_061451_add_wiki_template_css from './20260906_061451_add_wiki_template_css'; import * as migration_20260906_061451_add_wiki_template_css from "./20260906_061451_add_wiki_template_css";
import * as migration_20260906_063753_add_personal_excerpt_richtext from './20260906_063753_add_personal_excerpt_richtext'; import * as migration_20260906_063753_add_personal_excerpt_richtext from "./20260906_063753_add_personal_excerpt_richtext";
import * as migration_20260906_065405_add_wiki_template_html from './20260906_065405_add_wiki_template_html'; import * as migration_20260906_065405_add_wiki_template_html from "./20260906_065405_add_wiki_template_html";
import * as migration_20260906_100908_add_market_economy_state from './20260906_100908_add_market_economy_state'; import * as migration_20260906_100908_add_market_economy_state from "./20260906_100908_add_market_economy_state";
import * as migration_20260906_165837_add_market_state_event_target from './20260906_165837_add_market_state_event_target'; import * as migration_20260906_165837_add_market_state_event_target from "./20260906_165837_add_market_state_event_target";
import * as migration_20260906_202925_add_voice_submissions from './20260906_202925_add_voice_submissions'; import * as migration_20260906_202925_add_voice_submissions from "./20260906_202925_add_voice_submissions";
import * as migration_20260907_012326_add_missions_created_by from './20260907_012326_add_missions_created_by'; import * as migration_20260907_012326_add_missions_created_by from "./20260907_012326_add_missions_created_by";
import * as migration_20260907_013724_make_missions_campaign_optional from './20260907_013724_make_missions_campaign_optional'; import * as migration_20260907_013724_make_missions_campaign_optional from "./20260907_013724_make_missions_campaign_optional";
import * as migration_20260907_041051_add_voice_script_spoiler from './20260907_041051_add_voice_script_spoiler'; import * as migration_20260907_041051_add_voice_script_spoiler from "./20260907_041051_add_voice_script_spoiler";
import * as migration_20260907_043339_add_voice_script_description from './20260907_043339_add_voice_script_description'; import * as migration_20260907_043339_add_voice_script_description from "./20260907_043339_add_voice_script_description";
import * as migration_20260907_044934_add_missions_discord_modlist_hash from './20260907_044934_add_missions_discord_modlist_hash'; import * as migration_20260907_044934_add_missions_discord_modlist_hash from "./20260907_044934_add_missions_discord_modlist_hash";
import * as migration_20260907_083010_add_voice_submission_description from './20260907_083010_add_voice_submission_description'; import * as migration_20260907_083010_add_voice_submission_description from "./20260907_083010_add_voice_submission_description";
import * as migration_20260907_090245_add_mission_ready_reminder_fields from './20260907_090245_add_mission_ready_reminder_fields'; import * as migration_20260907_090245_add_mission_ready_reminder_fields from "./20260907_090245_add_mission_ready_reminder_fields";
import * as migration_20260907_120754_add_story_beats from './20260907_120754_add_story_beats'; import * as migration_20260907_120754_add_story_beats from "./20260907_120754_add_story_beats";
import * as migration_20260907_124024_convert_story_beats_body_to_lexical from './20260907_124024_convert_story_beats_body_to_lexical'; import * as migration_20260907_124024_convert_story_beats_body_to_lexical from "./20260907_124024_convert_story_beats_body_to_lexical";
import * as migration_20260908_152021_add_opord_recall_minigame_stats from './20260908_152021_add_opord_recall_minigame_stats'; import * as migration_20260908_152021_add_opord_recall_minigame_stats from "./20260908_152021_add_opord_recall_minigame_stats";
import * as migration_20260908_172241_add_custom_minigame_collections from './20260908_172241_add_custom_minigame_collections'; import * as migration_20260908_172241_add_custom_minigame_collections from "./20260908_172241_add_custom_minigame_collections";
import * as migration_20260908_193107_add_creator_xp_bank_field from './20260908_193107_add_creator_xp_bank_field'; import * as migration_20260908_193107_add_creator_xp_bank_field from "./20260908_193107_add_creator_xp_bank_field";
import * as migration_20260908_194435_add_expose_edge_mines_field from './20260908_194435_add_expose_edge_mines_field'; import * as migration_20260908_194435_add_expose_edge_mines_field from "./20260908_194435_add_expose_edge_mines_field";
import * as migration_20260909_153427_add_opord_recall_best_times from './20260909_153427_add_opord_recall_best_times'; import * as migration_20260909_153427_add_opord_recall_best_times from "./20260909_153427_add_opord_recall_best_times";
import * as migration_20260909_223955_add_voice_script_draft_status from './20260909_223955_add_voice_script_draft_status'; import * as migration_20260909_223955_add_voice_script_draft_status from "./20260909_223955_add_voice_script_draft_status";
import * as migration_20260910_221018_make_missions_codename_optional from './20260910_221018_make_missions_codename_optional'; import * as migration_20260910_221018_make_missions_codename_optional from "./20260910_221018_make_missions_codename_optional";
import * as migration_20260910_222518_add_dashboard_layout_preference from './20260910_222518_add_dashboard_layout_preference'; import * as migration_20260910_222518_add_dashboard_layout_preference from "./20260910_222518_add_dashboard_layout_preference";
import * as migration_20260911_044754_add_poker_minigame_stats from './20260911_044754_add_poker_minigame_stats'; import * as migration_20260911_044754_add_poker_minigame_stats from "./20260911_044754_add_poker_minigame_stats";
import * as migration_20260911_052147_add_ribbon_submissions from './20260911_052147_add_ribbon_submissions'; import * as migration_20260911_052147_add_ribbon_submissions from "./20260911_052147_add_ribbon_submissions";
import * as migration_20260911_201559_add_award_advancement_series from './20260911_201559_add_award_advancement_series'; import * as migration_20260911_201559_add_award_advancement_series from "./20260911_201559_add_award_advancement_series";
import * as migration_20260912_051752_add_rank_promotion_ceiling from './20260912_051752_add_rank_promotion_ceiling'; import * as migration_20260912_051752_add_rank_promotion_ceiling from "./20260912_051752_add_rank_promotion_ceiling";
import * as migration_20260912_071740_add_blackjack_minigame_stats from './20260912_071740_add_blackjack_minigame_stats'; import * as migration_20260912_071740_add_blackjack_minigame_stats from "./20260912_071740_add_blackjack_minigame_stats";
import * as migration_20260912_231219_add_discord_cancelled_at from './20260912_231219_add_discord_cancelled_at'; import * as migration_20260912_231219_add_discord_cancelled_at from "./20260912_231219_add_discord_cancelled_at";
import * as migration_20260914_153046_add_chip_bank from './20260914_153046_add_chip_bank'; import * as migration_20260914_153046_add_chip_bank from "./20260914_153046_add_chip_bank";
import * as migration_20260914_171412_add_respawn_tickets from './20260914_171412_add_respawn_tickets'; import * as migration_20260914_171412_add_respawn_tickets from "./20260914_171412_add_respawn_tickets";
import * as migration_20260916_083158_add_game_map_system from './20260916_083158_add_game_map_system'; import * as migration_20260916_083158_add_game_map_system from "./20260916_083158_add_game_map_system";
import * as migration_20260916_185544_construction_timestamp_model from './20260916_185544_construction_timestamp_model'; import * as migration_20260916_185544_construction_timestamp_model from "./20260916_185544_construction_timestamp_model";
import * as migration_20260916_220224_add_map_labels_and_styling from './20260916_220224_add_map_labels_and_styling'; import * as migration_20260916_220224_add_map_labels_and_styling from "./20260916_220224_add_map_labels_and_styling";
import * as migration_20260917_023028_add_node_and_zone_resources from './20260917_023028_add_node_and_zone_resources'; import * as migration_20260917_023028_add_node_and_zone_resources from "./20260917_023028_add_node_and_zone_resources";
import * as migration_20260917_112258_add_zone_political_types from "./20260917_112258_add_zone_political_types";
export const migrations = [ export const migrations = [
{ {
up: migration_20260820_004157.up, up: migration_20260820_004157.up,
down: migration_20260820_004157.down, down: migration_20260820_004157.down,
name: '20260820_004157', name: "20260820_004157",
}, },
{ {
up: migration_20260825_220000.up, up: migration_20260825_220000.up,
down: migration_20260825_220000.down, down: migration_20260825_220000.down,
name: '20260825_220000', name: "20260825_220000",
}, },
{ {
up: migration_20260826_050000_repair_shims_auxiliary_tables.up, up: migration_20260826_050000_repair_shims_auxiliary_tables.up,
down: migration_20260826_050000_repair_shims_auxiliary_tables.down, down: migration_20260826_050000_repair_shims_auxiliary_tables.down,
name: '20260826_050000_repair_shims_auxiliary_tables', name: "20260826_050000_repair_shims_auxiliary_tables",
}, },
{ {
up: migration_20260826_060000_add_game_servers_event_target.up, up: migration_20260826_060000_add_game_servers_event_target.up,
down: migration_20260826_060000_add_game_servers_event_target.down, down: migration_20260826_060000_add_game_servers_event_target.down,
name: '20260826_060000_add_game_servers_event_target', name: "20260826_060000_add_game_servers_event_target",
}, },
{ {
up: migration_20260827_005757.up, up: migration_20260827_005757.up,
down: migration_20260827_005757.down, down: migration_20260827_005757.down,
name: '20260827_005757', name: "20260827_005757",
}, },
{ {
up: migration_20260827_022334_add_eod_minesweeper_best_flags.up, up: migration_20260827_022334_add_eod_minesweeper_best_flags.up,
down: migration_20260827_022334_add_eod_minesweeper_best_flags.down, down: migration_20260827_022334_add_eod_minesweeper_best_flags.down,
name: '20260827_022334_add_eod_minesweeper_best_flags', name: "20260827_022334_add_eod_minesweeper_best_flags",
}, },
{ {
up: migration_20260827_213857_add_evaluations_collection.up, up: migration_20260827_213857_add_evaluations_collection.up,
down: migration_20260827_213857_add_evaluations_collection.down, down: migration_20260827_213857_add_evaluations_collection.down,
name: '20260827_213857_add_evaluations_collection', name: "20260827_213857_add_evaluations_collection",
}, },
{ {
up: migration_20260827_233224_add_mission_completed_at.up, up: migration_20260827_233224_add_mission_completed_at.up,
down: migration_20260827_233224_add_mission_completed_at.down, down: migration_20260827_233224_add_mission_completed_at.down,
name: '20260827_233224_add_mission_completed_at', name: "20260827_233224_add_mission_completed_at",
}, },
{ {
up: migration_20260828_011500_add_background_texture_preference.up, up: migration_20260828_011500_add_background_texture_preference.up,
down: migration_20260828_011500_add_background_texture_preference.down, down: migration_20260828_011500_add_background_texture_preference.down,
name: '20260828_011500_add_background_texture_preference', name: "20260828_011500_add_background_texture_preference",
}, },
{ {
up: migration_20260828_041615_add_campaign_mode.up, up: migration_20260828_041615_add_campaign_mode.up,
down: migration_20260828_041615_add_campaign_mode.down, down: migration_20260828_041615_add_campaign_mode.down,
name: '20260828_041615_add_campaign_mode', name: "20260828_041615_add_campaign_mode",
}, },
{ {
up: migration_20260828_044506.up, up: migration_20260828_044506.up,
down: migration_20260828_044506.down, down: migration_20260828_044506.down,
name: '20260828_044506', name: "20260828_044506",
}, },
{ {
up: migration_20260831_142503.up, up: migration_20260831_142503.up,
down: migration_20260831_142503.down, down: migration_20260831_142503.down,
name: '20260831_142503', name: "20260831_142503",
}, },
{ {
up: migration_20260831_180233_assignment_transfers.up, up: migration_20260831_180233_assignment_transfers.up,
down: migration_20260831_180233_assignment_transfers.down, down: migration_20260831_180233_assignment_transfers.down,
name: '20260831_180233_assignment_transfers', name: "20260831_180233_assignment_transfers",
}, },
{ {
up: migration_20260901_014006_add_assignment_transfer_event_target.up, up: migration_20260901_014006_add_assignment_transfer_event_target.up,
down: migration_20260901_014006_add_assignment_transfer_event_target.down, down: migration_20260901_014006_add_assignment_transfer_event_target.down,
name: '20260901_014006_add_assignment_transfer_event_target', name: "20260901_014006_add_assignment_transfer_event_target",
}, },
{ {
up: migration_20260901_213108_add_ticket_votes_and_edit_history.up, up: migration_20260901_213108_add_ticket_votes_and_edit_history.up,
down: migration_20260901_213108_add_ticket_votes_and_edit_history.down, down: migration_20260901_213108_add_ticket_votes_and_edit_history.down,
name: '20260901_213108_add_ticket_votes_and_edit_history', name: "20260901_213108_add_ticket_votes_and_edit_history",
}, },
{ {
up: migration_20260901_222200_add_admin_page_permissions.up, up: migration_20260901_222200_add_admin_page_permissions.up,
down: migration_20260901_222200_add_admin_page_permissions.down, down: migration_20260901_222200_add_admin_page_permissions.down,
name: '20260901_222200_add_admin_page_permissions', name: "20260901_222200_add_admin_page_permissions",
}, },
{ {
up: migration_20260902_195336_add_medal_backgrounds.up, up: migration_20260902_195336_add_medal_backgrounds.up,
down: migration_20260902_195336_add_medal_backgrounds.down, down: migration_20260902_195336_add_medal_backgrounds.down,
name: '20260902_195336_add_medal_backgrounds', name: "20260902_195336_add_medal_backgrounds",
}, },
{ {
up: migration_20260903_035544.up, up: migration_20260903_035544.up,
down: migration_20260903_035544.down, down: migration_20260903_035544.down,
name: '20260903_035544', name: "20260903_035544",
}, },
{ {
up: migration_20260903_044047.up, up: migration_20260903_044047.up,
down: migration_20260903_044047.down, down: migration_20260903_044047.down,
name: '20260903_044047', name: "20260903_044047",
}, },
{ {
up: migration_20260903_053419.up, up: migration_20260903_053419.up,
down: migration_20260903_053419.down, down: migration_20260903_053419.down,
name: '20260903_053419', name: "20260903_053419",
}, },
{ {
up: migration_20260903_060000_add_radio_mode_stats.up, up: migration_20260903_060000_add_radio_mode_stats.up,
down: migration_20260903_060000_add_radio_mode_stats.down, down: migration_20260903_060000_add_radio_mode_stats.down,
name: '20260903_060000_add_radio_mode_stats', name: "20260903_060000_add_radio_mode_stats",
}, },
{ {
up: migration_20260903_061000_add_radio_incorrect_counts.up, up: migration_20260903_061000_add_radio_incorrect_counts.up,
down: migration_20260903_061000_add_radio_incorrect_counts.down, down: migration_20260903_061000_add_radio_incorrect_counts.down,
name: '20260903_061000_add_radio_incorrect_counts', name: "20260903_061000_add_radio_incorrect_counts",
}, },
{ {
up: migration_20260903_062000_add_aim_trainer_stats.up, up: migration_20260903_062000_add_aim_trainer_stats.up,
down: migration_20260903_062000_add_aim_trainer_stats.down, down: migration_20260903_062000_add_aim_trainer_stats.down,
name: '20260903_062000_add_aim_trainer_stats', name: "20260903_062000_add_aim_trainer_stats",
}, },
{ {
up: migration_20260904_230838_add_wiki_collections.up, up: migration_20260904_230838_add_wiki_collections.up,
down: migration_20260904_230838_add_wiki_collections.down, down: migration_20260904_230838_add_wiki_collections.down,
name: '20260904_230838_add_wiki_collections', name: "20260904_230838_add_wiki_collections",
}, },
{ {
up: migration_20260905_183940_base_management.up, up: migration_20260905_183940_base_management.up,
down: migration_20260905_183940_base_management.down, down: migration_20260905_183940_base_management.down,
name: '20260905_183940_base_management', name: "20260905_183940_base_management",
}, },
{ {
up: migration_20260905_195456_labor_headcount.up, up: migration_20260905_195456_labor_headcount.up,
down: migration_20260905_195456_labor_headcount.down, down: migration_20260905_195456_labor_headcount.down,
name: '20260905_195456_labor_headcount', name: "20260905_195456_labor_headcount",
}, },
{ {
up: migration_20260905_210310_labor_classifications.up, up: migration_20260905_210310_labor_classifications.up,
down: migration_20260905_210310_labor_classifications.down, down: migration_20260905_210310_labor_classifications.down,
name: '20260905_210310_labor_classifications', name: "20260905_210310_labor_classifications",
}, },
{ {
up: migration_20260905_213824.up, up: migration_20260905_213824.up,
down: migration_20260905_213824.down, down: migration_20260905_213824.down,
name: '20260905_213824', name: "20260905_213824",
}, },
{ {
up: migration_20260905_215658.up, up: migration_20260905_215658.up,
down: migration_20260905_215658.down, down: migration_20260905_215658.down,
name: '20260905_215658', name: "20260905_215658",
}, },
{ {
up: migration_20260906_045502.up, up: migration_20260906_045502.up,
down: migration_20260906_045502.down, down: migration_20260906_045502.down,
name: '20260906_045502', name: "20260906_045502",
}, },
{ {
up: migration_20260906_050900_add_evaluation_reminders_field.up, up: migration_20260906_050900_add_evaluation_reminders_field.up,
down: migration_20260906_050900_add_evaluation_reminders_field.down, down: migration_20260906_050900_add_evaluation_reminders_field.down,
name: '20260906_050900_add_evaluation_reminders_field', name: "20260906_050900_add_evaluation_reminders_field",
}, },
{ {
up: migration_20260906_061451_add_wiki_template_css.up, up: migration_20260906_061451_add_wiki_template_css.up,
down: migration_20260906_061451_add_wiki_template_css.down, down: migration_20260906_061451_add_wiki_template_css.down,
name: '20260906_061451_add_wiki_template_css', name: "20260906_061451_add_wiki_template_css",
}, },
{ {
up: migration_20260906_063753_add_personal_excerpt_richtext.up, up: migration_20260906_063753_add_personal_excerpt_richtext.up,
down: migration_20260906_063753_add_personal_excerpt_richtext.down, down: migration_20260906_063753_add_personal_excerpt_richtext.down,
name: '20260906_063753_add_personal_excerpt_richtext', name: "20260906_063753_add_personal_excerpt_richtext",
}, },
{ {
up: migration_20260906_065405_add_wiki_template_html.up, up: migration_20260906_065405_add_wiki_template_html.up,
down: migration_20260906_065405_add_wiki_template_html.down, down: migration_20260906_065405_add_wiki_template_html.down,
name: '20260906_065405_add_wiki_template_html', name: "20260906_065405_add_wiki_template_html",
}, },
{ {
up: migration_20260906_100908_add_market_economy_state.up, up: migration_20260906_100908_add_market_economy_state.up,
down: migration_20260906_100908_add_market_economy_state.down, down: migration_20260906_100908_add_market_economy_state.down,
name: '20260906_100908_add_market_economy_state', name: "20260906_100908_add_market_economy_state",
}, },
{ {
up: migration_20260906_165837_add_market_state_event_target.up, up: migration_20260906_165837_add_market_state_event_target.up,
down: migration_20260906_165837_add_market_state_event_target.down, down: migration_20260906_165837_add_market_state_event_target.down,
name: '20260906_165837_add_market_state_event_target', name: "20260906_165837_add_market_state_event_target",
}, },
{ {
up: migration_20260906_202925_add_voice_submissions.up, up: migration_20260906_202925_add_voice_submissions.up,
down: migration_20260906_202925_add_voice_submissions.down, down: migration_20260906_202925_add_voice_submissions.down,
name: '20260906_202925_add_voice_submissions', name: "20260906_202925_add_voice_submissions",
}, },
{ {
up: migration_20260907_012326_add_missions_created_by.up, up: migration_20260907_012326_add_missions_created_by.up,
down: migration_20260907_012326_add_missions_created_by.down, down: migration_20260907_012326_add_missions_created_by.down,
name: '20260907_012326_add_missions_created_by', name: "20260907_012326_add_missions_created_by",
}, },
{ {
up: migration_20260907_013724_make_missions_campaign_optional.up, up: migration_20260907_013724_make_missions_campaign_optional.up,
down: migration_20260907_013724_make_missions_campaign_optional.down, down: migration_20260907_013724_make_missions_campaign_optional.down,
name: '20260907_013724_make_missions_campaign_optional', name: "20260907_013724_make_missions_campaign_optional",
}, },
{ {
up: migration_20260907_041051_add_voice_script_spoiler.up, up: migration_20260907_041051_add_voice_script_spoiler.up,
down: migration_20260907_041051_add_voice_script_spoiler.down, down: migration_20260907_041051_add_voice_script_spoiler.down,
name: '20260907_041051_add_voice_script_spoiler', name: "20260907_041051_add_voice_script_spoiler",
}, },
{ {
up: migration_20260907_043339_add_voice_script_description.up, up: migration_20260907_043339_add_voice_script_description.up,
down: migration_20260907_043339_add_voice_script_description.down, down: migration_20260907_043339_add_voice_script_description.down,
name: '20260907_043339_add_voice_script_description', name: "20260907_043339_add_voice_script_description",
}, },
{ {
up: migration_20260907_044934_add_missions_discord_modlist_hash.up, up: migration_20260907_044934_add_missions_discord_modlist_hash.up,
down: migration_20260907_044934_add_missions_discord_modlist_hash.down, down: migration_20260907_044934_add_missions_discord_modlist_hash.down,
name: '20260907_044934_add_missions_discord_modlist_hash', name: "20260907_044934_add_missions_discord_modlist_hash",
}, },
{ {
up: migration_20260907_083010_add_voice_submission_description.up, up: migration_20260907_083010_add_voice_submission_description.up,
down: migration_20260907_083010_add_voice_submission_description.down, down: migration_20260907_083010_add_voice_submission_description.down,
name: '20260907_083010_add_voice_submission_description', name: "20260907_083010_add_voice_submission_description",
}, },
{ {
up: migration_20260907_090245_add_mission_ready_reminder_fields.up, up: migration_20260907_090245_add_mission_ready_reminder_fields.up,
down: migration_20260907_090245_add_mission_ready_reminder_fields.down, down: migration_20260907_090245_add_mission_ready_reminder_fields.down,
name: '20260907_090245_add_mission_ready_reminder_fields', name: "20260907_090245_add_mission_ready_reminder_fields",
}, },
{ {
up: migration_20260907_120754_add_story_beats.up, up: migration_20260907_120754_add_story_beats.up,
down: migration_20260907_120754_add_story_beats.down, down: migration_20260907_120754_add_story_beats.down,
name: '20260907_120754_add_story_beats', name: "20260907_120754_add_story_beats",
}, },
{ {
up: migration_20260907_124024_convert_story_beats_body_to_lexical.up, up: migration_20260907_124024_convert_story_beats_body_to_lexical.up,
down: migration_20260907_124024_convert_story_beats_body_to_lexical.down, down: migration_20260907_124024_convert_story_beats_body_to_lexical.down,
name: '20260907_124024_convert_story_beats_body_to_lexical', name: "20260907_124024_convert_story_beats_body_to_lexical",
}, },
{ {
up: migration_20260908_152021_add_opord_recall_minigame_stats.up, up: migration_20260908_152021_add_opord_recall_minigame_stats.up,
down: migration_20260908_152021_add_opord_recall_minigame_stats.down, down: migration_20260908_152021_add_opord_recall_minigame_stats.down,
name: '20260908_152021_add_opord_recall_minigame_stats', name: "20260908_152021_add_opord_recall_minigame_stats",
}, },
{ {
up: migration_20260908_172241_add_custom_minigame_collections.up, up: migration_20260908_172241_add_custom_minigame_collections.up,
down: migration_20260908_172241_add_custom_minigame_collections.down, down: migration_20260908_172241_add_custom_minigame_collections.down,
name: '20260908_172241_add_custom_minigame_collections', name: "20260908_172241_add_custom_minigame_collections",
}, },
{ {
up: migration_20260908_193107_add_creator_xp_bank_field.up, up: migration_20260908_193107_add_creator_xp_bank_field.up,
down: migration_20260908_193107_add_creator_xp_bank_field.down, down: migration_20260908_193107_add_creator_xp_bank_field.down,
name: '20260908_193107_add_creator_xp_bank_field', name: "20260908_193107_add_creator_xp_bank_field",
}, },
{ {
up: migration_20260908_194435_add_expose_edge_mines_field.up, up: migration_20260908_194435_add_expose_edge_mines_field.up,
down: migration_20260908_194435_add_expose_edge_mines_field.down, down: migration_20260908_194435_add_expose_edge_mines_field.down,
name: '20260908_194435_add_expose_edge_mines_field', name: "20260908_194435_add_expose_edge_mines_field",
}, },
{ {
up: migration_20260909_153427_add_opord_recall_best_times.up, up: migration_20260909_153427_add_opord_recall_best_times.up,
down: migration_20260909_153427_add_opord_recall_best_times.down, down: migration_20260909_153427_add_opord_recall_best_times.down,
name: '20260909_153427_add_opord_recall_best_times', name: "20260909_153427_add_opord_recall_best_times",
}, },
{ {
up: migration_20260909_223955_add_voice_script_draft_status.up, up: migration_20260909_223955_add_voice_script_draft_status.up,
down: migration_20260909_223955_add_voice_script_draft_status.down, down: migration_20260909_223955_add_voice_script_draft_status.down,
name: '20260909_223955_add_voice_script_draft_status', name: "20260909_223955_add_voice_script_draft_status",
}, },
{ {
up: migration_20260910_221018_make_missions_codename_optional.up, up: migration_20260910_221018_make_missions_codename_optional.up,
down: migration_20260910_221018_make_missions_codename_optional.down, down: migration_20260910_221018_make_missions_codename_optional.down,
name: '20260910_221018_make_missions_codename_optional', name: "20260910_221018_make_missions_codename_optional",
}, },
{ {
up: migration_20260910_222518_add_dashboard_layout_preference.up, up: migration_20260910_222518_add_dashboard_layout_preference.up,
down: migration_20260910_222518_add_dashboard_layout_preference.down, down: migration_20260910_222518_add_dashboard_layout_preference.down,
name: '20260910_222518_add_dashboard_layout_preference', name: "20260910_222518_add_dashboard_layout_preference",
}, },
{ {
up: migration_20260911_044754_add_poker_minigame_stats.up, up: migration_20260911_044754_add_poker_minigame_stats.up,
down: migration_20260911_044754_add_poker_minigame_stats.down, down: migration_20260911_044754_add_poker_minigame_stats.down,
name: '20260911_044754_add_poker_minigame_stats', name: "20260911_044754_add_poker_minigame_stats",
}, },
{ {
up: migration_20260911_052147_add_ribbon_submissions.up, up: migration_20260911_052147_add_ribbon_submissions.up,
down: migration_20260911_052147_add_ribbon_submissions.down, down: migration_20260911_052147_add_ribbon_submissions.down,
name: '20260911_052147_add_ribbon_submissions', name: "20260911_052147_add_ribbon_submissions",
}, },
{ {
up: migration_20260911_201559_add_award_advancement_series.up, up: migration_20260911_201559_add_award_advancement_series.up,
down: migration_20260911_201559_add_award_advancement_series.down, down: migration_20260911_201559_add_award_advancement_series.down,
name: '20260911_201559_add_award_advancement_series', name: "20260911_201559_add_award_advancement_series",
}, },
{ {
up: migration_20260912_051752_add_rank_promotion_ceiling.up, up: migration_20260912_051752_add_rank_promotion_ceiling.up,
down: migration_20260912_051752_add_rank_promotion_ceiling.down, down: migration_20260912_051752_add_rank_promotion_ceiling.down,
name: '20260912_051752_add_rank_promotion_ceiling', name: "20260912_051752_add_rank_promotion_ceiling",
}, },
{ {
up: migration_20260912_071740_add_blackjack_minigame_stats.up, up: migration_20260912_071740_add_blackjack_minigame_stats.up,
down: migration_20260912_071740_add_blackjack_minigame_stats.down, down: migration_20260912_071740_add_blackjack_minigame_stats.down,
name: '20260912_071740_add_blackjack_minigame_stats', name: "20260912_071740_add_blackjack_minigame_stats",
}, },
{ {
up: migration_20260912_231219_add_discord_cancelled_at.up, up: migration_20260912_231219_add_discord_cancelled_at.up,
down: migration_20260912_231219_add_discord_cancelled_at.down, down: migration_20260912_231219_add_discord_cancelled_at.down,
name: '20260912_231219_add_discord_cancelled_at', name: "20260912_231219_add_discord_cancelled_at",
}, },
{ {
up: migration_20260914_153046_add_chip_bank.up, up: migration_20260914_153046_add_chip_bank.up,
down: migration_20260914_153046_add_chip_bank.down, down: migration_20260914_153046_add_chip_bank.down,
name: '20260914_153046_add_chip_bank', name: "20260914_153046_add_chip_bank",
}, },
{ {
up: migration_20260914_171412_add_respawn_tickets.up, up: migration_20260914_171412_add_respawn_tickets.up,
down: migration_20260914_171412_add_respawn_tickets.down, down: migration_20260914_171412_add_respawn_tickets.down,
name: '20260914_171412_add_respawn_tickets', name: "20260914_171412_add_respawn_tickets",
}, },
{ {
up: migration_20260916_083158_add_game_map_system.up, up: migration_20260916_083158_add_game_map_system.up,
down: migration_20260916_083158_add_game_map_system.down, down: migration_20260916_083158_add_game_map_system.down,
name: '20260916_083158_add_game_map_system', name: "20260916_083158_add_game_map_system",
}, },
{ {
up: migration_20260916_185544_construction_timestamp_model.up, up: migration_20260916_185544_construction_timestamp_model.up,
down: migration_20260916_185544_construction_timestamp_model.down, down: migration_20260916_185544_construction_timestamp_model.down,
name: '20260916_185544_construction_timestamp_model', name: "20260916_185544_construction_timestamp_model",
}, },
{ {
up: migration_20260916_220224_add_map_labels_and_styling.up, up: migration_20260916_220224_add_map_labels_and_styling.up,
down: migration_20260916_220224_add_map_labels_and_styling.down, down: migration_20260916_220224_add_map_labels_and_styling.down,
name: '20260916_220224_add_map_labels_and_styling', name: "20260916_220224_add_map_labels_and_styling",
}, },
{ {
up: migration_20260917_023028_add_node_and_zone_resources.up, up: migration_20260917_023028_add_node_and_zone_resources.up,
down: migration_20260917_023028_add_node_and_zone_resources.down, down: migration_20260917_023028_add_node_and_zone_resources.down,
name: '20260917_023028_add_node_and_zone_resources' name: "20260917_023028_add_node_and_zone_resources",
},
{
up: migration_20260917_112258_add_zone_political_types.up,
down: migration_20260917_112258_add_zone_political_types.down,
name: "20260917_112258_add_zone_political_types",
}, },
]; ];

View file

@ -828,7 +828,17 @@ export const enum_releases_status = pgEnum("enum_releases_status", [
]); ]);
export const enum_maps_basemap_mode = pgEnum("enum_maps_basemap_mode", ["image", "tiles"]); export const enum_maps_basemap_mode = pgEnum("enum_maps_basemap_mode", ["image", "tiles"]);
export const enum_map_roads_surface = pgEnum("enum_map_roads_surface", ["paved", "dirt", "trail"]); export const enum_map_roads_surface = pgEnum("enum_map_roads_surface", ["paved", "dirt", "trail"]);
export const enum_map_zones_type = pgEnum("enum_map_zones_type", ["water", "land"]); export const enum_map_roads_stroke_style = pgEnum("enum_map_roads_stroke_style", [
"solid",
"dashed",
"dotted",
]);
export const enum_map_zones_type = pgEnum("enum_map_zones_type", [
"water",
"land",
"territory",
"border",
]);
export const enum_narrative_events_triggers_type = pgEnum("enum_narrative_events_triggers_type", [ export const enum_narrative_events_triggers_type = pgEnum("enum_narrative_events_triggers_type", [
"manual", "manual",
"mission_complete", "mission_complete",
@ -1066,6 +1076,8 @@ export const game_structures = pgTable(
}), }),
maintenanceCost: numeric("maintenance_cost", { mode: "number" }).default(0), maintenanceCost: numeric("maintenance_cost", { mode: "number" }).default(0),
coordinates: geometryColumn("coordinates"), coordinates: geometryColumn("coordinates"),
labelVisible: boolean("label_visible").default(true),
labelPriority: numeric("label_priority", { mode: "number" }).default(0),
updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 }) updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 })
.defaultNow() .defaultNow()
.notNull(), .notNull(),
@ -4787,6 +4799,7 @@ export const map_roads = pgTable(
{ {
id: serial("id").primaryKey(), id: serial("id").primaryKey(),
name: varchar("name"), name: varchar("name"),
description: varchar("description"),
map: integer("map_id") map: integer("map_id")
.notNull() .notNull()
.references(() => maps.id, { .references(() => maps.id, {
@ -4794,6 +4807,10 @@ export const map_roads = pgTable(
}), }),
surface: enum_map_roads_surface("surface").notNull().default("paved"), surface: enum_map_roads_surface("surface").notNull().default("paved"),
speedMultiplier: numeric("speed_multiplier", { mode: "number" }).notNull().default(1), speedMultiplier: numeric("speed_multiplier", { mode: "number" }).notNull().default(1),
strokeColor: varchar("stroke_color"),
strokeWeight: numeric("stroke_weight", { mode: "number" }).default(2),
strokeStyle: enum_map_roads_stroke_style("stroke_style").default("solid"),
labelVisible: boolean("label_visible"),
points: jsonb("points").notNull(), points: jsonb("points").notNull(),
updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 }) updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 })
.defaultNow() .defaultNow()
@ -4814,12 +4831,18 @@ export const map_zones = pgTable(
{ {
id: serial("id").primaryKey(), id: serial("id").primaryKey(),
name: varchar("name"), name: varchar("name"),
description: varchar("description"),
map: integer("map_id") map: integer("map_id")
.notNull() .notNull()
.references(() => maps.id, { .references(() => maps.id, {
onDelete: "set null", onDelete: "set null",
}), }),
type: enum_map_zones_type("type").notNull(), type: enum_map_zones_type("type").notNull(),
fillColor: varchar("fill_color"),
strokeColor: varchar("stroke_color"),
fillOpacity: numeric("fill_opacity", { mode: "number" }),
strokeWeight: numeric("stroke_weight", { mode: "number" }).default(1),
labelVisible: boolean("label_visible").default(true),
points: jsonb("points").notNull(), points: jsonb("points").notNull(),
updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 }) updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 })
.defaultNow() .defaultNow()
@ -4835,21 +4858,44 @@ export const map_zones = pgTable(
], ],
); );
export const map_zones_rels = pgTable(
"map_zones_rels",
{
id: serial("id").primaryKey(),
order: integer("order"),
parent: integer("parent_id").notNull(),
path: varchar("path").notNull(),
resourcesID: integer("resources_id"),
},
(columns) => [
index("map_zones_rels_order_idx").on(columns.order),
index("map_zones_rels_parent_idx").on(columns.parent),
index("map_zones_rels_path_idx").on(columns.path),
index("map_zones_rels_resources_id_idx").on(columns.resourcesID),
foreignKey({
columns: [columns["parent"]],
foreignColumns: [map_zones.id],
name: "map_zones_rels_parent_fk",
}).onDelete("cascade"),
foreignKey({
columns: [columns["resourcesID"]],
foreignColumns: [resources.id],
name: "map_zones_rels_resources_fk",
}).onDelete("cascade"),
],
);
export const resource_nodes = pgTable( export const resource_nodes = pgTable(
"resource_nodes", "resource_nodes",
{ {
id: serial("id").primaryKey(), id: serial("id").primaryKey(),
name: varchar("name").notNull(), name: varchar("name").notNull(),
description: varchar("description"),
map: integer("map_id") map: integer("map_id")
.notNull() .notNull()
.references(() => maps.id, { .references(() => maps.id, {
onDelete: "set null", onDelete: "set null",
}), }),
resource: integer("resource_id")
.notNull()
.references(() => resources.id, {
onDelete: "set null",
}),
position: jsonb("position").notNull(), position: jsonb("position").notNull(),
richness: numeric("richness", { mode: "number" }).default(100), richness: numeric("richness", { mode: "number" }).default(100),
updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 }) updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 })
@ -4861,12 +4907,38 @@ export const resource_nodes = pgTable(
}, },
(columns) => [ (columns) => [
index("resource_nodes_map_idx").on(columns.map), index("resource_nodes_map_idx").on(columns.map),
index("resource_nodes_resource_idx").on(columns.resource),
index("resource_nodes_updated_at_idx").on(columns.updatedAt), index("resource_nodes_updated_at_idx").on(columns.updatedAt),
index("resource_nodes_created_at_idx").on(columns.createdAt), index("resource_nodes_created_at_idx").on(columns.createdAt),
], ],
); );
export const resource_nodes_rels = pgTable(
"resource_nodes_rels",
{
id: serial("id").primaryKey(),
order: integer("order"),
parent: integer("parent_id").notNull(),
path: varchar("path").notNull(),
resourcesID: integer("resources_id"),
},
(columns) => [
index("resource_nodes_rels_order_idx").on(columns.order),
index("resource_nodes_rels_parent_idx").on(columns.parent),
index("resource_nodes_rels_path_idx").on(columns.path),
index("resource_nodes_rels_resources_id_idx").on(columns.resourcesID),
foreignKey({
columns: [columns["parent"]],
foreignColumns: [resource_nodes.id],
name: "resource_nodes_rels_parent_fk",
}).onDelete("cascade"),
foreignKey({
columns: [columns["resourcesID"]],
foreignColumns: [resources.id],
name: "resource_nodes_rels_resources_fk",
}).onDelete("cascade"),
],
);
export const narrative_events = pgTable( export const narrative_events = pgTable(
"narrative_events", "narrative_events",
{ {
@ -8072,23 +8144,48 @@ export const relations_map_roads = relations(map_roads, ({ one }) => ({
relationName: "map", relationName: "map",
}), }),
})); }));
export const relations_map_zones = relations(map_zones, ({ one }) => ({ export const relations_map_zones_rels = relations(map_zones_rels, ({ one }) => ({
parent: one(map_zones, {
fields: [map_zones_rels.parent],
references: [map_zones.id],
relationName: "_rels",
}),
resourcesID: one(resources, {
fields: [map_zones_rels.resourcesID],
references: [resources.id],
relationName: "resources",
}),
}));
export const relations_map_zones = relations(map_zones, ({ one, many }) => ({
map: one(maps, { map: one(maps, {
fields: [map_zones.map], fields: [map_zones.map],
references: [maps.id], references: [maps.id],
relationName: "map", relationName: "map",
}), }),
_rels: many(map_zones_rels, {
relationName: "_rels",
}),
})); }));
export const relations_resource_nodes = relations(resource_nodes, ({ one }) => ({ export const relations_resource_nodes_rels = relations(resource_nodes_rels, ({ one }) => ({
parent: one(resource_nodes, {
fields: [resource_nodes_rels.parent],
references: [resource_nodes.id],
relationName: "_rels",
}),
resourcesID: one(resources, {
fields: [resource_nodes_rels.resourcesID],
references: [resources.id],
relationName: "resources",
}),
}));
export const relations_resource_nodes = relations(resource_nodes, ({ one, many }) => ({
map: one(maps, { map: one(maps, {
fields: [resource_nodes.map], fields: [resource_nodes.map],
references: [maps.id], references: [maps.id],
relationName: "map", relationName: "map",
}), }),
resource: one(resources, { _rels: many(resource_nodes_rels, {
fields: [resource_nodes.resource], relationName: "_rels",
references: [resources.id],
relationName: "resource",
}), }),
})); }));
export const relations_narrative_events = relations(narrative_events, () => ({})); export const relations_narrative_events = relations(narrative_events, () => ({}));
@ -8757,6 +8854,7 @@ type DatabaseSchema = {
enum_releases_status: typeof enum_releases_status; enum_releases_status: typeof enum_releases_status;
enum_maps_basemap_mode: typeof enum_maps_basemap_mode; enum_maps_basemap_mode: typeof enum_maps_basemap_mode;
enum_map_roads_surface: typeof enum_map_roads_surface; enum_map_roads_surface: typeof enum_map_roads_surface;
enum_map_roads_stroke_style: typeof enum_map_roads_stroke_style;
enum_map_zones_type: typeof enum_map_zones_type; enum_map_zones_type: typeof enum_map_zones_type;
enum_narrative_events_triggers_type: typeof enum_narrative_events_triggers_type; enum_narrative_events_triggers_type: typeof enum_narrative_events_triggers_type;
enum_story_beats_status: typeof enum_story_beats_status; enum_story_beats_status: typeof enum_story_beats_status;
@ -8887,7 +8985,9 @@ type DatabaseSchema = {
maps: typeof maps; maps: typeof maps;
map_roads: typeof map_roads; map_roads: typeof map_roads;
map_zones: typeof map_zones; map_zones: typeof map_zones;
map_zones_rels: typeof map_zones_rels;
resource_nodes: typeof resource_nodes; resource_nodes: typeof resource_nodes;
resource_nodes_rels: typeof resource_nodes_rels;
narrative_events: typeof narrative_events; narrative_events: typeof narrative_events;
story_beats: typeof story_beats; story_beats: typeof story_beats;
story_beat_states: typeof story_beat_states; story_beat_states: typeof story_beat_states;
@ -9032,7 +9132,9 @@ type DatabaseSchema = {
relations_labels: typeof relations_labels; relations_labels: typeof relations_labels;
relations_maps: typeof relations_maps; relations_maps: typeof relations_maps;
relations_map_roads: typeof relations_map_roads; relations_map_roads: typeof relations_map_roads;
relations_map_zones_rels: typeof relations_map_zones_rels;
relations_map_zones: typeof relations_map_zones; relations_map_zones: typeof relations_map_zones;
relations_resource_nodes_rels: typeof relations_resource_nodes_rels;
relations_resource_nodes: typeof relations_resource_nodes; relations_resource_nodes: typeof relations_resource_nodes;
relations_narrative_events: typeof relations_narrative_events; relations_narrative_events: typeof relations_narrative_events;
relations_story_beats: typeof relations_story_beats; relations_story_beats: typeof relations_story_beats;

View file

@ -3791,7 +3791,7 @@ export interface MapRoad {
strokeWeight?: number | null; strokeWeight?: number | null;
strokeStyle?: ('solid' | 'dashed' | 'dotted') | null; strokeStyle?: ('solid' | 'dashed' | 'dotted') | null;
/** /**
* Renders the road's name along the line at zoom-dependent intervals. * Auto (unset): only paved roads show labels. Check to force on, uncheck to force off. The map authoring toolbar's Label select writes this.
*/ */
labelVisible?: boolean | null; labelVisible?: boolean | null;
/** /**
@ -3825,9 +3825,9 @@ export interface MapZone {
description?: string | null; description?: string | null;
map: number | Map; map: number | Map;
/** /**
* Water zones block land-blueprint placement. Land zones are informational; default terrain is buildable land. * Water zones block land-blueprint placement. Land zones are informational; default terrain is buildable land. Territory and Country Border are political boundaries (informational).
*/ */
type: 'water' | 'land'; type: 'water' | 'land' | 'territory' | 'border';
/** /**
* Polygon fill as hex (e.g. #38bdf8). Empty = default color for the type. * Polygon fill as hex (e.g. #38bdf8). Empty = default color for the type.
*/ */
@ -3837,7 +3837,7 @@ export interface MapZone {
*/ */
strokeColor?: string | null; strokeColor?: string | null;
/** /**
* Fill opacity 0-1. Empty = default (0.25 water, 0.08 land). * Fill opacity 0-1. Empty = default (0.25 water, 0.08 land, 0.1 territory, 0.06 border).
*/ */
fillOpacity?: number | null; fillOpacity?: number | null;
/** /**

View file

@ -14,12 +14,20 @@ import type {
} from "@/payload-types"; } from "@/payload-types";
import { buildRoadGraph, findRoute, type RoadInput } from "@/lib/map/routing"; import { buildRoadGraph, findRoute, type RoadInput } from "@/lib/map/routing";
import { import {
labelScreenAngle,
roadLabelShown,
metersPerPx, metersPerPx,
polygonCentroid, polygonCentroid,
roadLabelPoints, roadLabelPoints,
splinePolyline, splinePolyline,
validateHexColor, validateHexColor,
} from "@/lib/map/style"; } from "@/lib/map/style";
import {
closestPointOnSegment,
findSnap,
segmentMidpoints,
type SnapTarget,
} from "@/lib/map/snapping";
import { import {
checkResourcesInRange, checkResourcesInRange,
checkStructuresInRange, checkStructuresInRange,
@ -505,6 +513,28 @@ describe("Game Map System", () => {
expect(short).toEqual(raw.slice(0, 2)); expect(short).toEqual(raw.slice(0, 2));
}); });
it("derives upright straight-label angles with vertical text reading up", () => {
expect(labelScreenAngle([0, 0], [1000, 0])).toBe(0);
expect(labelScreenAngle([0, 0], [-1000, 0])).toBe(0);
expect(labelScreenAngle([0, 0], [0, 1000])).toBe(-90);
expect(labelScreenAngle([0, 0], [0, -1000])).toBe(-90);
expect(labelScreenAngle([0, 0], [1000, 1000])).toBe(45);
expect(labelScreenAngle([0, 0], [-1000, 1000])).toBe(-45);
});
it("defaults road labels to paved roads, minor roads at close zoom, with explicit override", () => {
expect(roadLabelShown({ labelVisible: null, surface: "paved" }, 32)).toBe(true);
expect(roadLabelShown({ labelVisible: null, surface: "dirt" }, 32)).toBe(false);
expect(roadLabelShown({ labelVisible: null, surface: "trail" }, 32)).toBe(false);
expect(roadLabelShown({ labelVisible: null, surface: "dirt" }, 4)).toBe(true);
expect(roadLabelShown({ labelVisible: null, surface: "dirt" }, 3.9)).toBe(true);
expect(roadLabelShown({ labelVisible: null, surface: "trail" }, 4)).toBe(true);
expect(roadLabelShown({ labelVisible: true, surface: "dirt" }, 32)).toBe(true);
expect(roadLabelShown({ labelVisible: false, surface: "paved" }, 4)).toBe(false);
expect(roadLabelShown({ labelVisible: null, surface: "paved" })).toBe(true);
expect(roadLabelShown({ labelVisible: null, surface: "dirt" })).toBe(false);
});
it("computes polygon centroids inside the ring", () => { it("computes polygon centroids inside the ring", () => {
const centroid = polygonCentroid([ const centroid = polygonCentroid([
[0, 0], [0, 0],
@ -523,6 +553,82 @@ describe("Game Map System", () => {
}); });
}); });
describe("authoring snapping (pure)", () => {
it("projects to segments and clamps to endpoints", () => {
expect(closestPointOnSegment([500, 500], [0, 0], [1000, 0])).toEqual([500, 0]);
expect(closestPointOnSegment([-50, -50], [0, 0], [1000, 0])).toEqual([0, 0]);
expect(closestPointOnSegment([2000, 50], [0, 0], [1000, 0])).toEqual([1000, 0]);
expect(closestPointOnSegment([10, 10], [10, 10], [10, 10])).toEqual([10, 10]);
});
it("snaps to the nearest vertex, preferring vertices over edges on ties", () => {
const zone: SnapTarget = {
key: "zone-1",
vertices: [
[0, 0],
[1000, 0],
[1000, 1000],
[0, 1000],
],
closed: true,
edges: true,
};
const corner = findSnap([30, 20], [zone], 100);
expect(corner?.kind).toBe("vertex");
expect(corner?.point).toEqual([0, 0]);
// On the middle of an edge (>= 10% closer than any vertex) the edge wins.
const edge = findSnap([500, 30], [zone], 100);
expect(edge?.kind).toBe("edge");
expect(edge?.point).toEqual([500, 0]);
});
it("includes the closing segment of closed targets and respects the radius", () => {
const zone: SnapTarget = {
key: "zone-1",
vertices: [
[0, 0],
[1000, 0],
[1000, 1000],
[0, 1000],
],
closed: true,
edges: true,
};
const closing = findSnap([30, 500], [zone], 100);
expect(closing?.kind).toBe("edge");
expect(closing?.point).toEqual([0, 500]);
expect(findSnap([30, 500], [zone], 10)).toBeNull();
});
it("builds segment midpoints with insert indices for open and closed drafts", () => {
const open = segmentMidpoints(
[
[0, 0],
[100, 0],
[100, 100],
],
false,
);
expect(open).toEqual([
{ index: 1, point: [50, 0] },
{ index: 2, point: [100, 50] },
]);
const closed = segmentMidpoints(
[
[0, 0],
[100, 0],
[100, 100],
],
true,
);
expect(closed).toHaveLength(3);
expect(closed[2]).toEqual({ index: 3, point: [50, 50] });
});
});
describe("road routing (pure)", () => { describe("road routing (pure)", () => {
it("connects roads that share a vertex", () => { it("connects roads that share a vertex", () => {
const roads: RoadInput[] = [ const roads: RoadInput[] = [
@ -1083,6 +1189,98 @@ describe("Game Map System", () => {
expect(outside.success).toBe(false); expect(outside.success).toBe(false);
expect(outside.error).toContain("resource node"); expect(outside.error).toContain("resource node");
}); });
it("accepts political-boundary zone types and leaves placement unaffected", async () => {
const territory = await payload.create({
collection: "map-zones",
data: {
map: mapA.id,
name: `${RUN} Territory`,
type: "territory",
points: [
[0, 0],
[1000, 0],
[1000, 1000],
[0, 1000],
],
},
overrideAccess: true,
depth: 0,
});
createdIds.zones.push(territory.id);
expect(territory.type).toBe("territory");
const border = await payload.create({
collection: "map-zones",
data: {
map: mapA.id,
name: `${RUN} Border`,
type: "border",
points: [
[4000, 6000],
[5000, 6000],
[5000, 7000],
[4000, 7000],
],
},
overrideAccess: true,
depth: 0,
});
createdIds.zones.push(border.id);
expect(border.type).toBe("border");
// Political zones are informational: a land blueprint inside one places
// normally (only water zones gate placement).
authSpy.mockResolvedValue({ user: logisticsUser });
const inside = await placeStructure({
mapId: mapA.id,
blueprintId: depotBlueprint.id,
name: `${RUN} Territory Site`,
x: 500,
y: 500,
});
expect(inside.success).toBe(true);
createdIds.gameStructures.push((inside.data as GameStructure).id);
});
it("stores road labels as tri-state with paved-only auto default", async () => {
const dirt = await payload.create({
collection: "map-roads",
data: {
map: mapA.id,
name: `${RUN} Dirt Track`,
surface: "dirt",
speedMultiplier: 1,
points: [
[0, 9000],
[1000, 9000],
],
},
overrideAccess: true,
depth: 0,
});
createdIds.roads.push(dirt.id);
expect(dirt.labelVisible).toBeNull();
const paved = await payload.create({
collection: "map-roads",
data: {
map: mapA.id,
name: `${RUN} Paved Way`,
surface: "paved",
speedMultiplier: 1,
labelVisible: false,
points: [
[0, 9500],
[1000, 9500],
],
},
overrideAccess: true,
depth: 0,
});
createdIds.roads.push(paved.id);
expect(paved.labelVisible).toBe(false);
});
}); });
describe("construction timestamps", () => { describe("construction timestamps", () => {