// Phase 19 ship-group → World primitive translation. Sits next to // `state-binding.ts` so the latter can stay focused on planets while // the more involved group geometry (in-hyperspace interpolation, // incoming-trajectory lines) lives here. // // Position rules: // - On-planet local / other groups (origin === null, range === null) // are NOT rendered on the map. Stationed groups would otherwise // pile up next to every populated planet and turn the canvas // into noise; the planet inspector lists them instead // (see `lib/inspectors/planet/ship-groups.svelte`). // - In-hyperspace local / other groups (origin / range set) — // interpolated along the origin → destination line at `range` // world units from the destination. The line is the wrap-aware // shortest path on a torus. // - Incoming groups — origin and destination are always present; // emit a dashed red trajectory line from origin to a wrap-aware // destination plus a clickable point at the interpolated // position (range = the `distance` field). // - Unidentified groups — drawn at the absolute (x, y) the radar // reports. // // Torus-shortest deltas come from `map/math.torusShortestDelta`. The // canonical Go-side equivalent is `pkg/calc.ShortestDelta`; the TS // helper duplicates the formula because the renderer's hot path // avoids the WASM boundary cost. Both implementations agree on the // half-circumference tie-break. // // PrimitiveIDs are partitioned via large per-variant offsets so they // never collide with planet ids (which run in `[0, planetCount)`). import type { GameReport, ReportLocalShipGroup, ReportOtherShipGroup, ReportPlanet, ReportUnidentifiedShipGroup, } from "../api/game-state"; import type { ShipGroupRef } from "../lib/selection.svelte"; import { torusShortestDelta } from "./math"; import { DARK_THEME, type LinePrim, type PointPrim, type PrimitiveID, type Style, type Theme, } from "./world"; /** * SHIP_GROUP_ID_OFFSETS partitions the primitive-id namespace so a * hit on a ship-group primitive is unambiguous: the offset alone * disambiguates the variant and `id - offset` recovers the index * (or, for `local`, lookup happens via the parallel hitLookup map * since UUID strings cannot fit in a numeric primitive id). */ export const SHIP_GROUP_ID_OFFSETS = { local: 100_000_000, localLine: 150_000_000, other: 200_000_000, incoming: 300_000_000, incomingLine: 350_000_000, unidentified: 400_000_000, } as const; // shipGroupStyles builds the per-variant `Style` objects for the // active theme. Only the colours are theme-driven; the alpha, radius, // and dash spacing are fixed emphasis values. The in-space track // reuses the own-group colour and the incoming trajectory line reuses // the incoming colour so each pair reads as one entity. function shipGroupStyles(theme: Theme): { local: Style; localLine: Style; other: Style; incoming: Style; incomingLine: Style; unidentified: Style; } { return { local: { fillColor: theme.shipLocal, fillAlpha: 0.95, pointRadiusPx: 3 }, localLine: { strokeColor: theme.shipLocal, strokeAlpha: 0.7, strokeWidthPx: 1, strokeDashPx: 4, }, other: { fillColor: theme.shipOther, fillAlpha: 0.9, pointRadiusPx: 3 }, incoming: { fillColor: theme.shipIncoming, fillAlpha: 1, pointRadiusPx: 4, }, incomingLine: { strokeColor: theme.shipIncoming, strokeAlpha: 0.85, strokeWidthPx: 1, strokeDashPx: 4, }, unidentified: { fillColor: theme.shipUnidentified, fillAlpha: 0.65, pointRadiusPx: 3, }, }; } // Priority order inside `hit-test`: ship groups outrank planets so a // hyperspace group landing on top of an unidentified planet is // selectable. The trajectory line itself is given the lowest priority // so a click on the dashed segment never "wins" over the clickable // point at the interpolated position. const PRIORITY_LOCAL = 5; const PRIORITY_LOCAL_LINE = 0; const PRIORITY_OTHER = 5; const PRIORITY_INCOMING_POINT = 6; const PRIORITY_INCOMING_LINE = 0; const PRIORITY_UNIDENTIFIED = 4; /** * ShipGroupCategory tags every emitted primitive with the toggleable * surface it belongs to. The Phase 29 hide-set machinery in * `lib/active-view/map.svelte` looks these up via `categories` to * decide whether to hide the primitive when the matching `MapToggles` * flag is `false`. */ export type ShipGroupCategory = | "hyperspaceGroup" | "incomingGroup" | "unidentifiedGroup"; export interface ShipGroupPrimitives { primitives: (PointPrim | LinePrim)[]; lookup: Map; categories: Map; /** * planetDependents maps a planet number to the set of primitive * ids that should hide together with that planet. In Phase 29 the * hide-by-id machinery cascades planet visibility onto in-space * and incoming groups flying *to* the planet (their points + the * trajectory / track lines). Unidentified groups have no planet * anchor and therefore contribute nothing here. */ planetDependents: Map>; } function addDependent( planetDependents: Map>, planetNumber: number, primitiveId: PrimitiveID, ): void { let set = planetDependents.get(planetNumber); if (set === undefined) { set = new Set(); planetDependents.set(planetNumber, set); } set.add(primitiveId); } export function shipGroupsToPrimitives( report: GameReport, theme: Theme = DARK_THEME, ): ShipGroupPrimitives { const styles = shipGroupStyles(theme); const primitives: (PointPrim | LinePrim)[] = []; const lookup = new Map(); const categories = new Map(); const planetDependents = new Map>(); const planetIndex = new Map(); for (const planet of report.planets) { planetIndex.set(planet.number, planet); } const w = report.mapWidth; const h = report.mapHeight; for (let i = 0; i < report.localShipGroups.length; i++) { const group = report.localShipGroups[i]!; const pos = computeInSpacePosition(group, planetIndex, w, h); if (pos === null) continue; const id = SHIP_GROUP_ID_OFFSETS.local + i; primitives.push(makePoint(id, pos.x, pos.y, PRIORITY_LOCAL, styles.local)); lookup.set(id, { variant: "local", id: group.id }); categories.set(id, "hyperspaceGroup"); addDependent(planetDependents, group.destination, id); // Yellow dashed track from the origin planet to the destination // planet. The colour matches the in-space group point so the // player can read both as one entity at a glance. Wrap-aware // like the incoming-line: we unwrap `destination` relative to // `origin`, drawing the segment in a single tile, and PixiJS // repeats the world in torus mode. const origin = planetIndex.get(group.origin!); const destination = planetIndex.get(group.destination); if (origin !== undefined && destination !== undefined) { const dx = torusShortestDelta(origin.x, destination.x, w); const dy = torusShortestDelta(origin.y, destination.y, h); const lineId = SHIP_GROUP_ID_OFFSETS.localLine + i; primitives.push({ kind: "line", id: lineId, priority: PRIORITY_LOCAL_LINE, style: styles.localLine, hitSlopPx: 0, x1: origin.x, y1: origin.y, x2: origin.x + dx, y2: origin.y + dy, }); categories.set(lineId, "hyperspaceGroup"); addDependent(planetDependents, group.destination, lineId); } } for (let i = 0; i < report.otherShipGroups.length; i++) { const group = report.otherShipGroups[i]!; const pos = computeInSpacePosition(group, planetIndex, w, h); if (pos === null) continue; const id = SHIP_GROUP_ID_OFFSETS.other + i; primitives.push(makePoint(id, pos.x, pos.y, PRIORITY_OTHER, styles.other)); lookup.set(id, { variant: "other", index: i }); categories.set(id, "hyperspaceGroup"); addDependent(planetDependents, group.destination, id); } for (let i = 0; i < report.incomingShipGroups.length; i++) { const group = report.incomingShipGroups[i]!; const origin = planetIndex.get(group.origin); const destination = planetIndex.get(group.destination); if (origin === undefined || destination === undefined) continue; // Unwrap the destination relative to origin so the line crosses // the torus seam when that is the shorter path. Renderer-side // we draw the segment in a single tile; in torus mode PixiJS // repeats the world so the line still appears continuous on // the visible side of the seam. const dx = torusShortestDelta(origin.x, destination.x, w); const dy = torusShortestDelta(origin.y, destination.y, h); const destX = origin.x + dx; const destY = origin.y + dy; const lineId = SHIP_GROUP_ID_OFFSETS.incomingLine + i; primitives.push({ kind: "line", id: lineId, priority: PRIORITY_INCOMING_LINE, style: styles.incomingLine, hitSlopPx: 0, x1: origin.x, y1: origin.y, x2: destX, y2: destY, }); categories.set(lineId, "incomingGroup"); addDependent(planetDependents, group.destination, lineId); const pos = interpolateAlongLine(destX, destY, origin.x, origin.y, group.distance); const pointId = SHIP_GROUP_ID_OFFSETS.incoming + i; primitives.push( makePoint( pointId, pos.x, pos.y, PRIORITY_INCOMING_POINT, styles.incoming, /*hitSlopPx*/ 4, ), ); lookup.set(pointId, { variant: "incoming", index: i }); categories.set(pointId, "incomingGroup"); addDependent(planetDependents, group.destination, pointId); } for (let i = 0; i < report.unidentifiedShipGroups.length; i++) { const group: ReportUnidentifiedShipGroup = report.unidentifiedShipGroups[i]!; const id = SHIP_GROUP_ID_OFFSETS.unidentified + i; primitives.push( makePoint( id, group.x, group.y, PRIORITY_UNIDENTIFIED, styles.unidentified, ), ); lookup.set(id, { variant: "unidentified", index: i }); categories.set(id, "unidentifiedGroup"); } return { primitives, lookup, categories, planetDependents }; } /** * computeInSpacePosition returns the renderer-side (x, y) of a local * or foreign group that is currently in hyperspace. On-planet groups * (origin === null || range === null) are intentionally skipped so the * map does not pile dozens of primitives onto every populated planet * — the planet inspector lists them instead. Returns null when either * the group is on-planet, or the origin / destination planets are * not visible to the local player. * * Exported so the active-view map can centre the camera on an * in-space group when the F8-10 tables raise a `selection.focus` * request for one. */ export function computeInSpacePosition( group: ReportLocalShipGroup | ReportOtherShipGroup, planetIndex: Map, mapWidth: number, mapHeight: number, ): { x: number; y: number } | null { if (group.origin === null || group.range === null) return null; const destination = planetIndex.get(group.destination); if (destination === undefined) return null; const origin = planetIndex.get(group.origin); if (origin === undefined) return null; const dx = torusShortestDelta(destination.x, origin.x, mapWidth); const dy = torusShortestDelta(destination.y, origin.y, mapHeight); const total = Math.hypot(dx, dy); if (total === 0 || group.range <= 0) { return { x: destination.x, y: destination.y }; } const t = Math.min(1, group.range / total); return { x: destination.x + t * dx, y: destination.y + t * dy }; } /** * interpolateAlongLine returns the point that sits `range` world * units away from `(dx, dy)` toward `(ox, oy)`. The total path length * is the Euclidean distance between the two anchors; the position is * `dest + (range / total) × (origin - dest)`. When the anchors are * coincident or `range` is zero the result is the destination, which * is fine for the ship-group rendering — a degenerate group still * gets a click target on the destination planet. */ function interpolateAlongLine( dx: number, dy: number, ox: number, oy: number, range: number, ): { x: number; y: number } { const ddx = ox - dx; const ddy = oy - dy; const total = Math.hypot(ddx, ddy); if (total === 0 || range <= 0) return { x: dx, y: dy }; const t = Math.min(1, range / total); return { x: dx + t * ddx, y: dy + t * ddy }; } function makePoint( id: PrimitiveID, x: number, y: number, priority: number, style: Style, hitSlopPx = 0, ): PointPrim { return { kind: "point", id, priority, style, hitSlopPx, x, y }; }