80ed11e3b6
Lights up three previously-stubbed table active views and tightens the
existing one:
- table-planets: 4 kind checkboxes (own / foreign / uninhabited /
unknown) + race dropdown that filters the foreign slice; row click
selects + centres the planet on the map.
- table-ship-groups: local + foreign groups in one grid, owner
checkboxes, planet dropdown (destination OR origin), class
dropdown; on-planet click focuses the destination planet, in-space
click focuses the ship group itself (camera follows interpolated
position).
- table-fleets: own fleets only with the shared planet dropdown;
on-planet click focuses the planet, in-space click centres the
camera on the interpolated fleet position without altering the
selection (no fleet variant in Selected).
- table-ship-classes: per-row Delete is disabled with a count tooltip
while at least one local ship group references the class. The
engine refuses the removal anyway; the UI pre-empts the surface.
Wires the click → map flow through a transient `SelectionStore.focus`
/ `focusPoint` channel that `map.svelte` consumes once on mount —
in-memory only, so an F5 does not re-centre.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
356 lines
12 KiB
TypeScript
356 lines
12 KiB
TypeScript
// 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<PrimitiveID, ShipGroupRef>;
|
||
categories: Map<PrimitiveID, ShipGroupCategory>;
|
||
/**
|
||
* 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<number, Set<PrimitiveID>>;
|
||
}
|
||
|
||
function addDependent(
|
||
planetDependents: Map<number, Set<PrimitiveID>>,
|
||
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<PrimitiveID, ShipGroupRef>();
|
||
const categories = new Map<PrimitiveID, ShipGroupCategory>();
|
||
const planetDependents = new Map<number, Set<PrimitiveID>>();
|
||
const planetIndex = new Map<number, ReportPlanet>();
|
||
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<number, ReportPlanet>,
|
||
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 };
|
||
}
|