1
0
Fork 0

feat(map): road-network routing for shipments with transport modes

Add pure routing, placement, point, and style libs plus map-system tests. Shipments gain a map relationship and routePath snapshot; ground vehicles route over the road network via Dijkstra while air and sea travel straight-line. Vehicle types declare a ground/air/sea transportMode and transit math works in meters.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-16 23:14:01 -04:00
parent 5f06039697
commit c64c9da0da
10 changed files with 2366 additions and 29 deletions

View file

@ -9,6 +9,8 @@ import { EventTypes } from "@/utils/event-log/eventTypes";
import { calculateDistance } from "@/lib/distance";
import { calculateFuelCost, calculateTransitTime, getVehicleEffectiveSpeed } from "@/lib/shipping";
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
import { buildRoadGraph, findRoute } from "@/lib/map/routing";
import { isPointArray, type MapPoint } from "@/lib/map/points";
interface ActionResult {
success: boolean;
@ -95,20 +97,6 @@ export async function createShipment(params: {
};
}
// Calculate distance
const distance = calculateDistance(originCoords, destCoords);
// Check proximity threshold
const gameRules = await payload.findGlobal({ slug: "game-rules" });
const threshold = (gameRules.proximityThreshold as number) || 10;
if (distance <= threshold) {
return {
success: false,
error: `Structures are within proximity (${distance.toFixed(1)} ≤ ${threshold}). Use instant transfer instead.`,
};
}
// Fetch vehicle
const vehicle = (await payload
.findByID({
@ -146,6 +134,69 @@ export async function createShipment(params: {
return { success: false, error: "Vehicle type not found." };
}
const originMapId = typeof origin.map === "object" ? (origin.map?.id ?? null) : (origin.map ?? null);
const destMapId =
typeof destination.map === "object" ? (destination.map?.id ?? null) : (destination.map ?? null);
if (!originMapId || !destMapId) {
return {
success: false,
error: "Both structures must be placed on a game map before shipping between them.",
};
}
if (originMapId !== destMapId) {
return {
success: false,
error: `Cross-map shipments are not supported: "${origin.name}" and "${destination.name}" are on different maps.`,
};
}
// Calculate route distance. Ground vehicles path over the road network;
// air and sea vehicles travel straight-line.
const transportMode = vehicleType.transportMode ?? "ground";
let distance: number;
let routePath: MapPoint[] | null = null;
if (transportMode === "ground") {
const roads = await payload.find({
collection: "map-roads",
where: { map: { equals: originMapId } },
limit: 5000,
depth: 0,
});
const graph = buildRoadGraph(
roads.docs.map((road) => ({
id: road.id,
points: (road.points ?? []) as MapPoint[],
speedMultiplier: road.speedMultiplier,
})),
);
const route = findRoute(graph, originCoords, destCoords);
if (!route.connected) {
return {
success: false,
error: `No connected road route between "${origin.name}" and "${destination.name}". Ground vehicles need roads joining both structures; use an air or sea vehicle instead.`,
};
}
distance = route.distanceMeters;
routePath = route.path;
} else {
distance = calculateDistance(originCoords, destCoords);
routePath = [originCoords, destCoords];
}
// Check proximity threshold
const gameRules = await payload.findGlobal({ slug: "game-rules" });
const threshold = (gameRules.proximityThreshold as number) || 1000;
if (distance <= threshold) {
return {
success: false,
error: `Structures are within proximity (${Math.round(distance)} m ≤ ${threshold} m). Use instant transfer instead.`,
};
}
// Calculate fuel cost
const consumptionRate = vehicleType.fuel?.fuelConsumptionRate ?? 1;
const fuelCost = calculateFuelCost(distance, consumptionRate);
@ -283,9 +334,7 @@ export async function createShipment(params: {
}
// Calculate transit time
const speed = getVehicleEffectiveSpeed(
vehicleType as unknown as Parameters<typeof getVehicleEffectiveSpeed>[0],
);
const speed = getVehicleEffectiveSpeed(vehicleType);
const transitTimeMs = calculateTransitTime(distance, speed);
const now = new Date();
@ -332,6 +381,8 @@ export async function createShipment(params: {
name: `SHP-${Date.now().toString(36).toUpperCase()}`,
origin: originId,
destination: destinationId,
map: originMapId,
routePath,
transportVehicle: vehicleId,
distance: Math.round(distance * 100) / 100,
cargo: cargo.map((c) => ({

View file

@ -41,6 +41,21 @@ export const Shipments: CollectionConfig = {
relationTo: "game-structures",
required: true,
},
{
name: "map",
type: "relationship",
relationTo: "maps",
admin: {
description: "The game map this shipment travels on. Cross-map shipments are not supported.",
},
},
{
name: "routePath",
type: "json",
admin: {
description: "Snapshot polyline of the route as [x, y] meter tuples, for map rendering.",
},
},
// Transport
{
name: "transportVehicle",

View file

@ -241,6 +241,21 @@ export const Vehicles: CollectionConfig = {
label: "Logistics & Grid Storage",
type: "group",
fields: [
{
name: "transportMode",
type: "select",
required: true,
defaultValue: "ground",
options: [
{ label: "Ground", value: "ground" },
{ label: "Air", value: "air" },
{ label: "Sea", value: "sea" },
],
admin: {
description:
"Ground vehicles route over the road network and refuse shipments with no connected route. Air and sea vehicles travel straight-line.",
},
},
{
label: "Storage Grid Dimensions",
type: "group",

195
src/lib/map/placement.ts Normal file
View file

@ -0,0 +1,195 @@
/**
* Structure placement validation: terrain zones, resourcesInRange,
* structuresInRange.
*
* Pure module: no Payload import, safe on client and server. The server-side
* action fetches zones/nodes/structures and passes them in; the same functions
* power the client-side pre-validation in the placement dialog.
*/
import { distanceSquared, pointInPolygon, type MapPoint } from "./points";
export { pointDistance } from "./points";
export type BlueprintTerrain = "land" | "water";
export interface ZoneInput {
id: number;
name?: string | null;
type: "water" | "land";
/** Resource ids contained in this zone; a site inside satisfies their requirements. */
resources?: number[];
points: MapPoint[];
}
export interface NodeInput {
id: number;
/** Resource ids this node yields (a node may hold several). */
resources: number[];
position: MapPoint;
}
export interface StructureInput {
id: number;
type: number;
coordinates: MapPoint;
}
export interface RangeRequirement {
resource?: number | { id: number } | null;
structure?: number | { id: number } | null;
range?: number | null;
}
export interface TerrainCheck {
allowed: boolean;
error?: string;
}
/**
* Terrain rule: default terrain is buildable land; drawn water polygons block
* land-blueprint placement; water blueprints must sit inside a water zone. A
* map with no zones accepts everything.
*/
export function checkTerrain(
zones: readonly ZoneInput[],
blueprintTerrain: BlueprintTerrain,
point: MapPoint,
): TerrainCheck {
const waterZones = zones.filter((zone) => zone.type === "water");
const containingWater = waterZones.find((zone) => pointInPolygon(point, zone.points));
if (blueprintTerrain === "land") {
if (containingWater) {
return {
allowed: false,
error: `This structure cannot be built in water (inside zone "${containingWater.name ?? `#${containingWater.id}`}").`,
};
}
return { allowed: true };
}
if (!containingWater) {
return {
allowed: false,
error:
"This structure must be placed in water, but the site is not inside a drawn water zone.",
};
}
return { allowed: true };
}
export interface RangeCheck {
allowed: boolean;
error?: string;
}
function requirementId(
value: RangeRequirement["resource"] | RangeRequirement["structure"],
): number | null {
if (typeof value === "number") return value;
return value?.id ?? null;
}
/**
* resourcesInRange: every blueprint requirement needs at least one matching
* resource node within range (meters) of the site, OR a containing zone that
* carries the resource (area deposits like oil fields: inside = satisfied).
*/
export function checkResourcesInRange(
nodes: readonly NodeInput[],
requirements: readonly RangeRequirement[],
point: MapPoint,
zones: readonly ZoneInput[] = [],
): RangeCheck {
for (const requirement of requirements) {
const resourceId = requirementId(requirement.resource);
if (resourceId === null) continue;
const range = requirement.range ?? 0;
const maxSq = range * range;
const nodeMatch = nodes.some(
(node) =>
node.resources.includes(resourceId) && distanceSquared(node.position, point) <= maxSq,
);
const zoneMatch = zones.some(
(zone) =>
(zone.resources ?? []).includes(resourceId) && pointInPolygon(point, zone.points),
);
if (!nodeMatch && !zoneMatch) {
return {
allowed: false,
error: `Placement requires a matching resource node within ${range} m of the site, or a zone containing the resource at the site.`,
};
}
}
return { allowed: true };
}
/**
* structuresInRange: every blueprint requirement needs at least one existing
* structure of the given blueprint type within range (meters) of the site.
*/
export function checkStructuresInRange(
structures: readonly StructureInput[],
requirements: readonly RangeRequirement[],
point: MapPoint,
): RangeCheck {
for (const requirement of requirements) {
const structureId = requirementId(requirement.structure);
if (structureId === null) continue;
const range = requirement.range ?? 0;
const maxSq = range * range;
const inRange = structures.some(
(structure) =>
structure.type === structureId && distanceSquared(structure.coordinates, point) <= maxSq,
);
if (!inRange) {
return {
allowed: false,
error: `Placement requires a structure of the required type within ${range} m of the site.`,
};
}
}
return { allowed: true };
}
export interface PlacementValidationInput {
zones: readonly ZoneInput[];
nodes: readonly NodeInput[];
structures: readonly StructureInput[];
blueprintTerrain: BlueprintTerrain;
resourcesInRange?: readonly RangeRequirement[];
structuresInRange?: readonly RangeRequirement[];
point: MapPoint;
}
export interface PlacementValidationResult {
allowed: boolean;
error?: string;
}
export function validatePlacement(input: PlacementValidationInput): PlacementValidationResult {
const terrain = checkTerrain(input.zones, input.blueprintTerrain, input.point);
if (!terrain.allowed) return terrain;
if (input.resourcesInRange && input.resourcesInRange.length > 0) {
const resources = checkResourcesInRange(
input.nodes,
input.resourcesInRange,
input.point,
input.zones,
);
if (!resources.allowed) return resources;
}
if (input.structuresInRange && input.structuresInRange.length > 0) {
const structures = checkStructuresInRange(
input.structures,
input.structuresInRange,
input.point,
);
if (!structures.allowed) return structures;
}
return { allowed: true };
}

142
src/lib/map/points.ts Normal file
View file

@ -0,0 +1,142 @@
/**
* Shared point/line/polygon helpers for map features.
*
* All map feature geometry is authored in real meters on a per-map Cartesian
* grid (origin top-left, x east, y south, matching Arma's world coordinates).
* Stored as JSON arrays of [x, y] tuples.
*
* Pure module: no Payload import, safe on client and server.
*/
export type MapPoint = [number, number];
/**
* Checks whether an unknown value is an array of [x, y] tuples with finite
* numbers. Optionally enforces a minimum number of points.
*/
export function isPointArray(value: unknown, minPoints = 1): value is MapPoint[] {
if (!Array.isArray(value) || value.length < minPoints) return false;
return value.every(
(point) =>
Array.isArray(point) &&
point.length === 2 &&
typeof point[0] === "number" &&
Number.isFinite(point[0]) &&
typeof point[1] === "number" &&
Number.isFinite(point[1]),
);
}
/** Field validator for polyline geometry (roads): at least 2 points. */
export function validatePolyline(value: unknown): true | string {
if (value === undefined || value === null) return "A road requires at least two points.";
if (!isPointArray(value, 2)) return "Points must be an array of [x, y] meter tuples.";
return true;
}
/** Field validator for polygon geometry (zones): at least 3 points (unclosed ring). */
export function validatePolygon(value: unknown): true | string {
if (value === undefined || value === null) return "A zone requires at least three points.";
if (!isPointArray(value, 3)) return "Points must be an array of [x, y] meter tuples.";
return true;
}
/** Field validator for a single [x, y] position. */
export function validatePosition(value: unknown): true | string {
if (!Array.isArray(value) || value.length !== 2) return "Position must be an [x, y] tuple.";
return isPointArray([value]) ? true : "Position must contain two finite numbers.";
}
/** Squared distance between two points (avoids sqrt in hot loops). */
export function distanceSquared(a: MapPoint, b: MapPoint): number {
return (b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2;
}
/** Euclidean distance in meters between two points. */
export function pointDistance(a: MapPoint, b: MapPoint): number {
return Math.sqrt(distanceSquared(a, b));
}
export interface InterpolatedPosition {
point: MapPoint;
bearing: number;
}
/**
* Position along a polyline at fraction t in [0, 1] (by geometric length),
* plus the bearing (degrees, clockwise from north) of the segment traveled.
* Returns the first point when t <= 0 and the last when t >= 1.
*/
export function interpolateAlong(points: MapPoint[], t: number): InterpolatedPosition | null {
if (points.length === 0) return null;
if (points.length === 1 || t <= 0) {
return { point: points[0], bearing: 0 };
}
if (t >= 1) {
const last = points[points.length - 1];
const prev = points[points.length - 2];
return { point: last, bearing: bearingDeg(prev, last) };
}
const total = polylineLength(points);
if (total <= 0) return { point: points[0], bearing: 0 };
let traveled = total * t;
for (let i = 1; i < points.length; i++) {
const segment = pointDistance(points[i - 1], points[i]);
if (segment <= 0) continue;
if (traveled <= segment || i === points.length - 1) {
const f = Math.min(1, traveled / segment);
const point: MapPoint = [
points[i - 1][0] + (points[i][0] - points[i - 1][0]) * f,
points[i - 1][1] + (points[i][1] - points[i - 1][1]) * f,
];
return { point, bearing: bearingDeg(points[i - 1], points[i]) };
}
traveled -= segment;
}
return { point: points[points.length - 1], bearing: 0 };
}
/** Bearing in degrees (0 = north/+y up means here -y on the map grid), clockwise. */
export function bearingDeg(from: MapPoint, to: MapPoint): number {
const dx = to[0] - from[0];
const dy = to[1] - from[1];
if (dx === 0 && dy === 0) return 0;
const angle = (Math.atan2(dx, -dy) * 180) / Math.PI;
return (angle + 360) % 360;
}
/**
* Ray-casting point-in-polygon test. The ring may be open (first vertex not
* repeated at the end); the closing edge is implied.
*/
export function pointInPolygon(point: MapPoint, polygon: MapPoint[]): boolean {
if (!Array.isArray(polygon) || polygon.length < 3) return false;
const [px, py] = point;
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const [xi, yi] = polygon[i];
const [xj, yj] = polygon[j];
const intersects =
yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi;
if (intersects) inside = !inside;
}
return inside;
}
/**
* Geometric length in meters of a polyline.
* Accepts an open polyline (roads) or a closed ring (zones, closing edge included).
*/
export function polylineLength(points: MapPoint[], closed = false): number {
if (points.length < 2) return 0;
let total = 0;
for (let i = 1; i < points.length; i++) {
total += pointDistance(points[i - 1], points[i]);
}
if (closed && points.length > 2) {
total += pointDistance(points[points.length - 1], points[0]);
}
return total;
}

253
src/lib/map/routing.ts Normal file
View file

@ -0,0 +1,253 @@
/**
* Road-network routing for ground shipments.
*
* Builds an undirected weighted graph from a map's road polylines and runs
* Dijkstra between the snapped origin and destination. Nodes are keyed on a
* 1 m grid, so two roads connect wherever they share a vertex (within the
* epsilon) - author road junctions by reusing identical endpoint coordinates
* (JOSM-style shared vertices). Polyline crossings without a shared vertex do
* NOT connect.
*
* Edge weight = segment length / speedMultiplier, so preferred (faster) roads
* win the shortest path. The reported route distance is always the geometric
* length in meters; fuel is charged on that, not on the weighted cost.
*
* Pure module: no Payload import, safe on client and server.
*/
import { distanceSquared, pointDistance, polylineLength, type MapPoint } from "./points";
/** Junction tolerance in meters: endpoints within this distance share a node. */
export const ROAD_SNAP_EPSILON_METERS = 1;
/** Maximum distance a structure may sit from the nearest road vertex and still route. */
export const DEFAULT_SNAP_RADIUS_METERS = 1000;
export interface RoadInput {
id: number;
points: MapPoint[];
speedMultiplier: number;
}
export interface GraphNode {
key: string;
position: MapPoint;
neighbors: Map<string, { to: string; weight: number; length: number }>;
}
export interface RoadGraph {
nodes: Map<string, GraphNode>;
/** Roughly the number of distinct vertices across all roads (for tests/diagnostics). */
nodeCount: number;
}
function nodeKey(position: MapPoint): string {
return `${Math.round(position[0] / ROAD_SNAP_EPSILON_METERS)}:${Math.round(
position[1] / ROAD_SNAP_EPSILON_METERS,
)}`;
}
/**
* Builds the road graph. Every vertex of every road becomes a node (keyed on
* the epsilon grid so shared vertices merge); consecutive vertices are joined
* by a weighted edge. Duplicate edges (two roads overlapping a segment) keep
* the fastest weight.
*/
export function buildRoadGraph(roads: readonly RoadInput[]): RoadGraph {
const nodes = new Map<string, GraphNode>();
const ensureNode = (position: MapPoint): GraphNode => {
const key = nodeKey(position);
let node = nodes.get(key);
if (!node) {
node = { key, position, neighbors: new Map() };
nodes.set(key, node);
}
return node;
};
for (const road of roads) {
if (!road.points || road.points.length < 2) continue;
const multiplier = road.speedMultiplier && road.speedMultiplier > 0 ? road.speedMultiplier : 1;
for (let i = 1; i < road.points.length; i++) {
const from = ensureNode(road.points[i - 1]);
const to = ensureNode(road.points[i]);
const length = pointDistance(from.position, to.position);
if (length <= 0) continue; // duplicate consecutive vertex
const weight = length / multiplier;
const forward = from.neighbors.get(to.key);
if (!forward || weight < forward.weight) {
from.neighbors.set(to.key, { to: to.key, weight, length });
}
const backward = to.neighbors.get(from.key);
if (!backward || weight < backward.weight) {
to.neighbors.set(from.key, { to: from.key, weight, length });
}
}
}
return { nodes, nodeCount: nodes.size };
}
/**
* Finds the nearest graph node to a point, within the snap radius.
* Returns null when the point is too far from the network.
*/
export function snapToNode(
graph: RoadGraph,
point: MapPoint,
snapRadiusMeters = DEFAULT_SNAP_RADIUS_METERS,
): GraphNode | null {
let best: GraphNode | null = null;
let bestDistanceSq = Infinity;
const maxSq = snapRadiusMeters * snapRadiusMeters;
for (const node of graph.nodes.values()) {
const d = distanceSquared(node.position, point);
if (d < bestDistanceSq && d <= maxSq) {
bestDistanceSq = d;
best = node;
}
}
return best;
}
export type RoutingResult =
| {
connected: true;
/** Polyline of the route from origin to destination, in meters on the map grid. */
path: MapPoint[];
/** Geometric route length in meters. */
distanceMeters: number;
/** Sum of weighted edge costs along the chosen path (diagnostics). */
weightedCost: number;
}
| {
connected: false;
path: null;
distanceMeters: null;
weightedCost: null;
};
/** Minimal binary min-heap keyed on numeric priority. */
class MinHeap {
private items: { priority: number; value: string }[] = [];
get size(): number {
return this.items.length;
}
push(priority: number, value: string): void {
this.items.push({ priority, value });
let i = this.items.length - 1;
while (i > 0) {
const parent = (i - 1) >> 1;
if (this.items[parent].priority <= this.items[i].priority) break;
[this.items[parent], this.items[i]] = [this.items[i], this.items[parent]];
i = parent;
}
}
pop(): { priority: number; value: string } | undefined {
if (this.items.length === 0) return undefined;
const top = this.items[0];
const last = this.items.pop()!;
if (this.items.length > 0) {
this.items[0] = last;
let i = 0;
for (;;) {
const left = i * 2 + 1;
const right = left + 1;
let smallest = i;
if (left < this.items.length && this.items[left].priority < this.items[smallest].priority) {
smallest = left;
}
if (
right < this.items.length &&
this.items[right].priority < this.items[smallest].priority
) {
smallest = right;
}
if (smallest === i) break;
[this.items[smallest], this.items[i]] = [this.items[i], this.items[smallest]];
i = smallest;
}
}
return top;
}
}
/**
* Dijkstra shortest path between the nodes nearest to origin and destination.
* Both ends must snap to a node within the snap radius and lie in the same
* connected component, otherwise the route is refused.
*/
export function findRoute(
graph: RoadGraph,
origin: MapPoint,
destination: MapPoint,
options?: { snapRadiusMeters?: number },
): RoutingResult {
const snapRadius = options?.snapRadiusMeters ?? DEFAULT_SNAP_RADIUS_METERS;
if (graph.nodes.size === 0) return { connected: false, path: null, distanceMeters: null, weightedCost: null };
const start = snapToNode(graph, origin, snapRadius);
const goal = snapToNode(graph, destination, snapRadius);
if (!start || !goal) {
return { connected: false, path: null, distanceMeters: null, weightedCost: null };
}
if (start.key === goal.key) {
return {
connected: true,
path: [start.position, goal.position],
distanceMeters: pointDistance(start.position, goal.position),
weightedCost: 0,
};
}
const dist = new Map<string, number>([[start.key, 0]]);
const prev = new Map<string, string>();
const settled = new Set<string>();
const heap = new MinHeap();
heap.push(0, start.key);
while (heap.size > 0) {
const current = heap.pop()!;
if (settled.has(current.value)) continue;
settled.add(current.value);
if (current.value === goal.key) break;
const node = graph.nodes.get(current.value)!;
for (const edge of node.neighbors.values()) {
if (settled.has(edge.to)) continue;
const candidate = dist.get(node.key)! + edge.weight;
if (candidate < (dist.get(edge.to) ?? Infinity)) {
dist.set(edge.to, candidate);
prev.set(edge.to, node.key);
heap.push(candidate, edge.to);
}
}
}
if (!settled.has(goal.key)) {
return { connected: false, path: null, distanceMeters: null, weightedCost: null };
}
// Reconstruct the path origin -> destination.
const path: MapPoint[] = [];
let cursor: string | undefined = goal.key;
while (cursor) {
path.unshift(graph.nodes.get(cursor)!.position);
cursor = prev.get(cursor);
}
// Snap tails: the origin/destination structures are not exactly on the
// network, prepend/append the straight stubs so the rendered route touches them.
path.unshift(origin);
path.push(destination);
const distanceMeters = polylineLength(path);
return { connected: true, path, distanceMeters, weightedCost: dist.get(goal.key)! };
}

149
src/lib/map/style.ts Normal file
View file

@ -0,0 +1,149 @@
/**
* Pure styling/label helpers for the map feature layers.
* No Payload import: safe on client and server.
*/
import { pointDistance, polylineLength, type MapPoint } from "./points";
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
/** Field validator for optional hex colors (#rrggbb). Empty = use defaults. */
export function validateHexColor(value: unknown): true | string {
if (value === undefined || value === null || value === "") return true;
return typeof value === "string" && HEX_COLOR.test(value)
? true
: "Use a hex color like #38bdf8, or leave empty for the default.";
}
/** Meters per screen pixel at the given zoom for a world scaled to 256 px at zoom 0. */
export function metersPerPx(zoom: number, worldSize: number): number {
return worldSize / (256 * Math.pow(2, zoom));
}
/** Dash array for a stroke style name. */
export function dashArrayFor(style: string | null | undefined): string | undefined {
if (style === "dashed") return "8 8";
if (style === "dotted") return "2 6";
return undefined;
}
/**
* Centroid of a polygon ring (area-weighted; falls back to the vertex average
* for degenerate rings).
*/
export function polygonCentroid(points: MapPoint[]): MapPoint {
if (points.length === 0) return [0, 0];
let area = 0;
let cx = 0;
let cy = 0;
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
const [xi, yi] = points[i];
const [xj, yj] = points[j];
const cross = xi * yj - xj * yi;
area += cross;
cx += (xj + xi) * cross;
cy += (yj + yi) * cross;
}
area /= 2;
if (Math.abs(area) < 1e-6) {
const sumX = points.reduce((total, point) => total + point[0], 0);
const sumY = points.reduce((total, point) => total + point[1], 0);
return [sumX / points.length, sumY / points.length];
}
return [cx / (6 * area), cy / (6 * area)];
}
/**
* Densifies a polyline with a Catmull-Rom spline (passing through every
* vertex) so rendered lines curve smoothly through bends instead of kinking
* at each vertex. Unit-agnostic: works in map meters or screen pixels alike.
*/
export function splinePolyline(points: MapPoint[], subdivisions = 16): MapPoint[] {
if (points.length < 3 || subdivisions < 1) return points;
const at = (index: number): MapPoint => points[Math.max(0, Math.min(points.length - 1, index))];
const out: MapPoint[] = [points[0]];
for (let i = 0; i < points.length - 1; i++) {
const p0 = at(i - 1);
const p1 = at(i);
const p2 = at(i + 1);
const p3 = at(i + 2);
for (let s = 1; s <= subdivisions; s++) {
const t = s / subdivisions;
const t2 = t * t;
const t3 = t2 * t;
out.push([
0.5 *
(2 * p1[0] +
(-p0[0] + p2[0]) * t +
(2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 +
(-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3),
0.5 *
(2 * p1[1] +
(-p0[1] + p2[1]) * t +
(2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 +
(-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3),
]);
}
}
return out;
}
export interface RoadLabelPlacement {
point: MapPoint;
/** Arc length along the input polyline at this label. */
distance: number;
/** True when the segment direction would render text upside down or reading downward (use a reversed path so vertical text reads bottom-to-top). */
flipped: boolean;
}
/**
* Label placements along a road polyline: one label roughly every
* `intervalMeters`, centered on the segment it sits on and flagged when the
* direction would render text upside down, capped at `maxLabels`.
*/
export function roadLabelPoints(
points: MapPoint[],
intervalMeters: number,
maxLabels: number,
): RoadLabelPlacement[] {
const total = polylineLength(points);
if (points.length < 2 || total <= 0 || intervalMeters <= 0) return [];
const labels: RoadLabelPlacement[] = [];
let nextAt = intervalMeters / 2;
let traveled = 0;
for (let i = 1; i < points.length && labels.length < maxLabels; i++) {
const from = points[i - 1];
const to = points[i];
const segment = pointDistance(from, to);
if (segment <= 0) continue;
const angle = segmentAngleDeg(from, to);
while (nextAt <= traveled + segment && labels.length < maxLabels) {
const f = (nextAt - traveled) / segment;
labels.push({
point: [from[0] + (to[0] - from[0]) * f, from[1] + (to[1] - from[1]) * f],
distance: nextAt,
flipped: angle > 90 || angle < -90 || angle === 90,
});
nextAt += intervalMeters;
}
traveled += segment;
}
return labels;
}
/**
* Screen-space direction of a segment in degrees (atan2(dy, dx)). Map y runs
* south like the screen's y-axis, so this is the on-screen direction. Angles
* outside [-90, 90) would render text upside down or (exactly vertical)
* reading downward.
*/
function segmentAngleDeg(from: MapPoint, to: MapPoint): number {
const dx = to[0] - from[0];
const dy = to[1] - from[1];
if (dx === 0 && dy === 0) return 0;
return (Math.atan2(dy, dx) * 180) / Math.PI;
}

101
src/lib/map/types.ts Normal file
View file

@ -0,0 +1,101 @@
import type { MapPoint } from "./points";
export interface MapConfig {
id: number;
name: string;
worldSizeWidth: number;
worldSizeHeight: number;
basemapMode: "image" | "tiles";
basemapImageUrl: string | null;
tileUrlTemplate: string | null;
minZoom: number;
maxZoom: number;
defaultZoom: number;
defaultCenter: MapPoint;
}
export interface RangeRequirementFeature {
resource: number | null;
structure: number | null;
range: number | null;
}
export interface StructureFeature {
id: number;
name: string;
point: MapPoint;
constructionStatus: "awaiting_materials" | "building" | "complete" | null;
constructionStartedAt: string | null;
constructionCompletesAt: string | null;
constructionDurationMinutes: number | null;
storageUsedKg: number;
storageCapacityKg: number | null;
labelVisible: boolean | null;
labelPriority: number | null;
blueprintId: number | null;
blueprintName: string | null;
blueprintTerrain: "land" | "water" | null;
materials: { resource: number; resourceName: string; amount: number | null }[];
resourcesInRange: RangeRequirementFeature[];
structuresInRange: RangeRequirementFeature[];
factionName: string | null;
}
export interface NodeFeature {
id: number;
name: string;
point: MapPoint;
description: string | null;
resources: { id: number; name: string }[];
richness: number | null;
}
export interface RoadFeature {
id: number;
name: string | null;
description: string | null;
surface: "paved" | "dirt" | "trail";
speedMultiplier: number;
strokeColor: string | null;
strokeWeight: number | null;
strokeStyle: "solid" | "dashed" | "dotted" | null;
labelVisible: boolean | null;
points: MapPoint[];
}
export interface ZoneFeature {
id: number;
name: string | null;
description: string | null;
type: "water" | "land";
resources: { id: number; name: string }[];
fillColor: string | null;
strokeColor: string | null;
fillOpacity: number | null;
strokeWeight: number | null;
labelVisible: boolean | null;
points: MapPoint[];
}
export interface ShipmentFeature {
id: number;
name: string | null;
status: string;
routePath: MapPoint[] | null;
originName: string | null;
originPoint: MapPoint | null;
destinationName: string | null;
destinationPoint: MapPoint | null;
dispatchedAt: string | null;
estimatedArrival: string | null;
vehicleName: string | null;
}
export interface MapFeaturesResponse {
map: MapConfig;
structures: StructureFeature[];
nodes: NodeFeature[];
roads: RoadFeature[];
zones: ZoneFeature[];
shipments: ShipmentFeature[];
}

View file

@ -1,19 +1,20 @@
/**
* Calculates the transit time in milliseconds for a shipment.
* @param distance - Euclidean distance in map units
* @param distance - Route length in meters (straight-line or road route)
* @param vehicleSpeed - Vehicle speed in km/h (from maxSpeedOnRoad, maxAirspeed, etc.)
*/
export function calculateTransitTime(
distance: number,
vehicleSpeed: number,
): number {
const hours = distance / vehicleSpeed;
const km = distance / 1000;
const hours = km / vehicleSpeed;
return hours * 60 * 60 * 1000;
}
/**
* Calculates the total fuel cost for a shipment.
* @param distance - Euclidean distance in map units
* @param distance - Route length in meters
* @param consumptionRate - Fuel consumed per unit of distance (from vehicle's fuelConsumptionRate)
*/
export function calculateFuelCost(
@ -50,22 +51,34 @@ export function calculateTicksForDuration(
}
/**
* Gets the effective speed of a vehicle, preferring road speed.
* Falls back to off-road, air, or sea speed.
* Gets the effective speed of a vehicle for its transport mode: ground
* prefers road speed then off-road, air uses airspeed, sea uses knots.
* Falls back through the remaining speeds and finally a 50 km/h default.
*/
export function getVehicleEffectiveSpeed(vehicle: {
transportMode?: "ground" | "air" | "sea" | null;
combat?: {
maxSpeedOnRoad?: number | null;
maxSpeedOffRoad?: number | null;
maxAirspeed?: number | null;
maxKnots?: number | null;
} | null;
} & {
maxSpeedOnRoad?: number | null;
maxSpeedOffRoad?: number | null;
maxAirspeed?: number | null;
maxKnots?: number | null;
}): number {
return (
vehicle.combat?.maxSpeedOnRoad ??
vehicle.combat?.maxSpeedOffRoad ??
vehicle.combat?.maxAirspeed ??
vehicle.combat?.maxKnots ??
50 // default fallback speed
);
const combat = vehicle.combat ?? vehicle;
const mode = vehicle.transportMode ?? "ground";
const byMode: Record<string, (number | null | undefined)[]> = {
ground: [combat.maxSpeedOnRoad, combat.maxSpeedOffRoad, combat.maxAirspeed, combat.maxKnots],
air: [combat.maxAirspeed, combat.maxSpeedOnRoad, combat.maxSpeedOffRoad, combat.maxKnots],
sea: [combat.maxKnots, combat.maxSpeedOffRoad, combat.maxSpeedOnRoad, combat.maxAirspeed],
};
const speeds = byMode[mode] ?? byMode.ground;
for (const speed of speeds) {
if (speed != null && speed > 0) return speed;
}
return 50; // default fallback speed
}

File diff suppressed because it is too large Load diff