ui/phase-16: cargo routes inspector + map pick foundation
Add per-planet cargo routes (COL/CAP/MAT/EMP) to the inspector with a renderer-driven destination picker (faded out-of-reach planets, cursor-line anchor, hover-highlight) and per-route arrows on the map. The pick-mode primitives are exposed via `MapPickService` so ship-group dispatch in Phase 19/20 can reuse the same surface. Pass A — generic map foundation: - hit-test now sizes the click zone to `pointRadiusPx + slopPx` so the visible disc is always part of the target. - `RendererHandle` gains `onPointerMove`, `onHoverChange`, `setPickMode`, `getPickState`, `getPrimitiveAlpha`, `setExtraPrimitives`, `getPrimitives`. The click dispatcher is centralised: pick-mode swallows clicks atomically so the standard selection consumers do not race against teardown. - `MapPickService` (`lib/map-pick.svelte.ts`) wraps the renderer contract in a promise-shaped `pick(...)`. The in-game shell layout owns the service so sidebar and bottom-sheet inspectors see the same instance. - Debug-surface registry exposes `getMapPrimitives`, `getMapPickState`, `getMapCamera` to e2e specs without spawning a separate debug page after navigation. Pass B — cargo-route feature: - `CargoLoadType`, `setCargoRoute`, `removeCargoRoute` typed variants with `(source, loadType)` collapse rule on the order draft; round-trip through the FBS encoder/decoder. - `GameReport` decodes `routes` and the local player's drive tech for the inline reach formula (40 × drive). `applyOrderOverlay` upserts/drops route entries for valid/submitting/applied commands. - `lib/inspectors/planet/cargo-routes.svelte` renders the four-slot section. `Add` / `Edit` call `MapPickService.pick`, `Remove` emits `removeCargoRoute`. - `map/cargo-routes.ts` builds shaft + arrowhead primitives per cargo type; the map view pushes them through `setExtraPrimitives` so the renderer never re-inits Pixi on route mutations (Pixi 8 doesn't support that on a reused canvas). Docs: - `docs/cargo-routes-ux.md` covers engine semantics + UI map. - `docs/renderer.md` documents pick mode and the debug surface. - `docs/calc-bridge.md` records the Phase 16 reach waiver. - `PLAN.md` rewrites Phase 16 to reflect the foundation + feature split and the decisions baked in (map-driven picker, inline reach, optimistic overlay via `setExtraPrimitives`). Tests: - `tests/map-pick-mode.test.ts` — pure overlay-spec helper. - `tests/map-cargo-routes.test.ts` — `buildCargoRouteLines`. - `tests/inspector-planet-cargo-routes.test.ts` — slot rendering, picker invocation, collapse, cancel, remove. - Extensions to `order-draft`, `submit`, `order-load`, `order-overlay`, `state-binding`, `inspector-planet`, `inspector-overlay`, `game-shell-sidebar`, `game-shell-header`. - `tests/e2e/cargo-routes.spec.ts` — Playwright happy path: add COL, add CAP, remove COL, asserting both the inspector and the arrow count via `__galaxyDebug.getMapPrimitives()`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
// Map pick-mode contract: a generic "pick a destination on the map"
|
||||
// interaction the inspector triggers and the renderer drives. Phase
|
||||
// 16 adds the cargo-route picker on top of this; later phases
|
||||
// (19/20) drive ship-group dispatch through the same surface.
|
||||
//
|
||||
// The renderer-facing API lives on `RendererHandle.setPickMode`
|
||||
// (see `render.ts`); this module owns the option / handle types and
|
||||
// the pure overlay-draw helper that translates the pick state into a
|
||||
// drawing spec the renderer can lift straight onto a Pixi `Graphics`.
|
||||
// Keeping the math here means the lifecycle (dim / cursor line /
|
||||
// hover outline / click+Escape resolution) can be tested without
|
||||
// booting a Pixi `Application`.
|
||||
|
||||
import { DEFAULT_POINT_RADIUS_PX, type PointPrim, type PrimitiveID } from "./world";
|
||||
|
||||
/**
|
||||
* PickModeOptions configures a pick-mode session. The caller is
|
||||
* responsible for computing `reachableIds` from the current report
|
||||
* (e.g. cargo routes apply the `40 * driveTech` rule before opening
|
||||
* the picker). The renderer never validates reach itself — it only
|
||||
* dims primitives whose id is missing from this set.
|
||||
*/
|
||||
export interface PickModeOptions {
|
||||
/** Numeric id of the source planet primitive. Stays full-alpha
|
||||
* during the session and anchors the cursor line. */
|
||||
readonly sourcePrimitiveId: PrimitiveID;
|
||||
/** World coordinates of the source. Pre-computed so the renderer
|
||||
* can draw the anchor ring and the line endpoint without
|
||||
* crawling the primitive list. */
|
||||
readonly sourceX: number;
|
||||
readonly sourceY: number;
|
||||
/** Ids whose primitives stay full-alpha and accept clicks. */
|
||||
readonly reachableIds: ReadonlySet<PrimitiveID>;
|
||||
/** Resolution callback. Fires with the chosen primitive id on a
|
||||
* successful pick, or `null` when the player cancels via Escape
|
||||
* or the imperative `cancel()` handle. */
|
||||
readonly onPick: (id: PrimitiveID | null) => void;
|
||||
}
|
||||
|
||||
export interface PickModeHandle {
|
||||
/**
|
||||
* cancel terminates the session immediately and resolves
|
||||
* `onPick(null)`. Idempotent — repeated calls are no-ops.
|
||||
*/
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* PickOverlaySpec is the pure description the renderer paints onto
|
||||
* its overlay graphic each frame. Keeps the lifecycle logic
|
||||
* Pixi-free so it can be exercised by Vitest.
|
||||
*/
|
||||
export interface PickOverlaySpec {
|
||||
/** Highlight ring around the source planet (slightly outside the
|
||||
* visible disc). */
|
||||
readonly anchor: {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly radius: number;
|
||||
};
|
||||
/** Line from source to current cursor; `null` while the cursor
|
||||
* is off-canvas. */
|
||||
readonly line: {
|
||||
readonly x1: number;
|
||||
readonly y1: number;
|
||||
readonly x2: number;
|
||||
readonly y2: number;
|
||||
} | null;
|
||||
/** Outline circle around the hovered reachable planet; `null`
|
||||
* when the hover is empty or aimed at a non-reachable primitive. */
|
||||
readonly hoverOutline: {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly radius: number;
|
||||
} | null;
|
||||
/** Ids to dim (alpha 0.3). Everything not in `reachableIds` and
|
||||
* not the source. */
|
||||
readonly dimmedIds: ReadonlySet<PrimitiveID>;
|
||||
}
|
||||
|
||||
/** Anchor / hover outline padding in world units (the rings sit
|
||||
* outside the visible disc so the planet stays clearly visible). */
|
||||
export const ANCHOR_PADDING_WORLD = 6;
|
||||
export const HOVER_PADDING_WORLD = 4;
|
||||
|
||||
/**
|
||||
* computePickOverlay produces a `PickOverlaySpec` for the current
|
||||
* pick state. Pure: no DOM access, no Pixi calls. Callers prepare
|
||||
* `pointPrimitivesById` from the active world before invoking.
|
||||
*/
|
||||
export function computePickOverlay(
|
||||
options: PickModeOptions,
|
||||
cursorWorld: { x: number; y: number } | null,
|
||||
hoveredId: PrimitiveID | null,
|
||||
pointPrimitivesById: ReadonlyMap<PrimitiveID, PointPrim>,
|
||||
allPrimitiveIds: Iterable<PrimitiveID>,
|
||||
): PickOverlaySpec {
|
||||
const sourcePrim = pointPrimitivesById.get(options.sourcePrimitiveId);
|
||||
const sourceRadius =
|
||||
(sourcePrim?.style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX) +
|
||||
ANCHOR_PADDING_WORLD;
|
||||
|
||||
const dimmed = new Set<PrimitiveID>();
|
||||
for (const id of allPrimitiveIds) {
|
||||
if (id === options.sourcePrimitiveId) continue;
|
||||
if (options.reachableIds.has(id)) continue;
|
||||
dimmed.add(id);
|
||||
}
|
||||
|
||||
const line =
|
||||
cursorWorld === null
|
||||
? null
|
||||
: {
|
||||
x1: options.sourceX,
|
||||
y1: options.sourceY,
|
||||
x2: cursorWorld.x,
|
||||
y2: cursorWorld.y,
|
||||
};
|
||||
|
||||
let hoverOutline: PickOverlaySpec["hoverOutline"] = null;
|
||||
if (
|
||||
hoveredId !== null &&
|
||||
hoveredId !== options.sourcePrimitiveId &&
|
||||
options.reachableIds.has(hoveredId)
|
||||
) {
|
||||
const target = pointPrimitivesById.get(hoveredId);
|
||||
if (target !== undefined) {
|
||||
hoverOutline = {
|
||||
x: target.x,
|
||||
y: target.y,
|
||||
radius:
|
||||
(target.style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX) +
|
||||
HOVER_PADDING_WORLD,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
anchor: {
|
||||
x: options.sourceX,
|
||||
y: options.sourceY,
|
||||
radius: sourceRadius,
|
||||
},
|
||||
line,
|
||||
hoverOutline,
|
||||
dimmedIds: dimmed,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PICK_OVERLAY_STYLE captures the colours / widths the renderer
|
||||
* applies to each spec channel. Exported so tests and future themes
|
||||
* can read the same values.
|
||||
*/
|
||||
export const PICK_OVERLAY_STYLE = {
|
||||
anchor: { color: 0xffe082, alpha: 0.9, width: 2 },
|
||||
line: { color: 0xffe082, alpha: 0.5, width: 1 },
|
||||
hover: { color: 0xffe082, alpha: 1, width: 2 },
|
||||
dimAlpha: 0.3,
|
||||
} as const;
|
||||
Reference in New Issue
Block a user