Compare commits
5 commits
9ca85fce9b
...
308a522764
| Author | SHA1 | Date | |
|---|---|---|---|
| 308a522764 | |||
| be12d36014 | |||
| 947a5c1bbf | |||
| 2bc61fe211 | |||
| cc0a7facd0 |
39 changed files with 1969 additions and 816 deletions
|
|
@ -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"`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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).",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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")}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
122
src/components/ui/combobox.tsx
Normal file
122
src/components/ui/combobox.tsx
Normal 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 };
|
||||||
|
|
@ -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
95
src/lib/map/snapping.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
29
src/migrations/20260917_112258_add_zone_political_types.ts
Normal file
29
src/migrations/20260917_112258_add_zone_political_types.ts
Normal 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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue