// Map-side cargo-route arrows. Each `ReportRouteEntry` becomes a // short arrow from the source planet to its destination, drawn as // three `LinePrim` segments — one shaft and two arrowhead wings — // styled per load type so the four cargo kinds are // distinguishable at a glance. The stroke colours come from the // active `Theme` (dark or light); the alpha and width are fixed. // // Geometry uses `torusShortestDelta` so an arrow that crosses the // torus seam takes the wrap, not the long way round, matching the // engine's reach test (`util.ShortDistance`, // `pkg/util/map.go.deltas`). import type { GameReport, ReportPlanet } from "../api/game-state"; import type { CargoLoadType } from "../sync/order-types"; import { torusShortestDelta } from "./math"; import { DARK_THEME, type LinePrim, type PrimitiveID, type Style, type Theme } from "./world"; /** * routeStylesByLoadType builds the per-load-type stroke styles for the * active theme. A single `Style` object is shared by every line of a * given load type within one call so the renderer can dedupe them. */ function routeStylesByLoadType(theme: Theme): Record { return { COL: { strokeColor: theme.routeCol, strokeAlpha: 0.95, strokeWidthPx: 0.6 }, CAP: { strokeColor: theme.routeCap, strokeAlpha: 0.95, strokeWidthPx: 0.6 }, MAT: { strokeColor: theme.routeMat, strokeAlpha: 0.95, strokeWidthPx: 0.6 }, EMP: { strokeColor: theme.routeEmp, strokeAlpha: 0.85, strokeWidthPx: 0.4 }, }; } /** Per-load-type priority. Higher wins hit-test ties; planets sit * at 1..4 (`state-binding.ts.priorityFor`), so route arrows always * lose to planet primitives. The internal ordering follows the * engine's COL > CAP > MAT > EMP preference so when two arrows * overlap exactly, the higher-priority cargo wins the click. */ const PRIORITY_BY_LOAD_TYPE: Record = { COL: 8, CAP: 7, MAT: 6, EMP: 5, }; const LOAD_TYPE_INDEX: Record = { COL: 0, CAP: 1, MAT: 2, EMP: 3, }; /** High-bit prefix on every cargo-route line id so it cannot * collide with a planet number (planets use uint64 numbers ≪ * 2^31). The renderer's hit-test treats ids opaquely; the * inspector never resolves a planet by a line id, so the prefix * is internal-only. */ export const ROUTE_LINE_ID_PREFIX = 0x80000000; const SHAFT_OFFSET = 0; const WING_LEFT_OFFSET = 1; const WING_RIGHT_OFFSET = 2; /** Arrowhead size in world units. Picked so the head is visible * at default zoom but does not eat the destination planet glyph. */ const HEAD_LENGTH_WORLD = 6; /** Half-angle of the arrowhead opening, in radians (~25°). */ const HEAD_HALF_ANGLE = (25 * Math.PI) / 180; /** * buildCargoRouteLines emits one `LinePrim` per shaft + two per * arrowhead wing for every (source, loadType, destination) entry * in `report.routes`. Skips routes whose source or destination is * not present in the planet list (e.g. a destination newly * unidentified after a turn cutoff). Pure: relies only on the * report; no DOM access; no Pixi calls. * * `opts.skipPlanets` (Phase 29) is an optional set of planet numbers * whose routes — outgoing or incoming — should be filtered out so the * arrows do not point at hidden glyphs. Empty / undefined means no * extra filtering, preserving the pre-Phase-29 contract. * * `theme` supplies the per-load-type stroke colours and defaults to * `DARK_THEME`. */ export function buildCargoRouteLines( report: GameReport, opts?: { skipPlanets?: ReadonlySet }, theme: Theme = DARK_THEME, ): LinePrim[] { if (report.routes.length === 0) return []; const skip = opts?.skipPlanets; const styleByLoadType = routeStylesByLoadType(theme); const planetById = new Map(); for (const planet of report.planets) { planetById.set(planet.number, planet); } const lines: LinePrim[] = []; for (const route of report.routes) { if (skip !== undefined && skip.has(route.sourcePlanetNumber)) continue; const source = planetById.get(route.sourcePlanetNumber); if (source === undefined) continue; for (const entry of route.entries) { if (skip !== undefined && skip.has(entry.destinationPlanetNumber)) { continue; } const dest = planetById.get(entry.destinationPlanetNumber); if (dest === undefined) continue; const dx = torusShortestDelta(source.x, dest.x, report.mapWidth); const dy = torusShortestDelta(source.y, dest.y, report.mapHeight); const length = Math.hypot(dx, dy); if (length === 0) continue; const headX = source.x + dx; const headY = source.y + dy; const ux = dx / length; const uy = dy / length; const cosA = Math.cos(HEAD_HALF_ANGLE); const sinA = Math.sin(HEAD_HALF_ANGLE); const leftX = headX - HEAD_LENGTH_WORLD * (ux * cosA + uy * sinA); const leftY = headY - HEAD_LENGTH_WORLD * (uy * cosA - ux * sinA); const rightX = headX - HEAD_LENGTH_WORLD * (ux * cosA - uy * sinA); const rightY = headY - HEAD_LENGTH_WORLD * (uy * cosA + ux * sinA); const baseId = routeLineBaseId( route.sourcePlanetNumber, entry.loadType, ); const style = styleByLoadType[entry.loadType]; const priority = PRIORITY_BY_LOAD_TYPE[entry.loadType]; lines.push({ kind: "line", id: baseId + SHAFT_OFFSET, priority, style, hitSlopPx: 0, x1: source.x, y1: source.y, x2: headX, y2: headY, }); lines.push({ kind: "line", id: baseId + WING_LEFT_OFFSET, priority, style, hitSlopPx: 0, x1: headX, y1: headY, x2: leftX, y2: leftY, }); lines.push({ kind: "line", id: baseId + WING_RIGHT_OFFSET, priority, style, hitSlopPx: 0, x1: headX, y1: headY, x2: rightX, y2: rightY, }); } } return lines; } /** Unique numeric id for a route's three line primitives. The * three segments occupy `baseId + 0..2`. Encoded as * `prefix | (source << 8) | (loadTypeIndex << 4)` so a planet * number up to 2^23 and the four load-type slots fit without * collision. */ function routeLineBaseId( sourcePlanetNumber: number, loadType: CargoLoadType, ): PrimitiveID { return ( ROUTE_LINE_ID_PREFIX | ((sourcePlanetNumber & 0x7fffff) << 8) | (LOAD_TYPE_INDEX[loadType] << 4) ); }