// 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 { torusShortestDelta } from "./math"; import { displayPointRadiusWorld, 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; /** 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; } /** Anchor / hover outline padding. F8-12 / #5 retired the anchor * ring from the picker overlay, so `ANCHOR_PADDING_WORLD` is now * dead — kept exported for legacy test coverage that asserts the * spec stays shaped the same way. `HOVER_PADDING_PX` is the * screen-pixel gap the picker hover-ring leaves between the * destination disc edge and the stroke; it matches the regular * planet outline (`OUTLINE_RADIUS_PADDING_PX` in `render.ts`) so * "selection" and "pick hover" outlines feel identical at every * zoom. */ export const ANCHOR_PADDING_WORLD = 6; export const HOVER_PADDING_PX = 1; /** * 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. * * `world` enables torus-wrap geometry for the cursor line: in torus * mode the renderer paints the overlay graphic into every torus copy * (`render.ts.openPickMode`), so the line endpoint is computed as * `(sourceX + torusShortestDelta(...), sourceY + ...)` — extending * beyond `[0, W) × [0, H)` when the short path crosses the seam. With * 9-copy replication the user sees a short line in whatever copy is * under the cursor. Pass `null` (the default) for no-wrap mode, where * the line goes straight from source to canonical cursor. * * `anchor` and `hoverOutline` stay at the canonical source / target * coordinates regardless of mode: the copy with the offset that puts * them under the user's view gets rendered by the renderer's * per-copy graphics, the others fall off-screen and consume nothing. */ export function computePickOverlay( options: PickModeOptions, cursorWorld: { x: number; y: number } | null, hoveredId: PrimitiveID | null, pointPrimitivesById: ReadonlyMap, allPrimitiveIds: Iterable, world: { width: number; height: number } | null = null, cameraScale: number = 1, scaleRef: number = 1, ): PickOverlaySpec { const sourcePrim = pointPrimitivesById.get(options.sourcePrimitiveId); const sourceVisibleRadius = sourcePrim === undefined ? 0 : displayPointRadiusWorld(sourcePrim.style, cameraScale, scaleRef); const sourceRadius = sourceVisibleRadius + ANCHOR_PADDING_WORLD; const dimmed = new Set(); for (const id of allPrimitiveIds) { if (id === options.sourcePrimitiveId) continue; if (options.reachableIds.has(id)) continue; dimmed.add(id); } let line: PickOverlaySpec["line"] = null; if (cursorWorld !== null) { if (world !== null) { const dx = torusShortestDelta( options.sourceX, cursorWorld.x, world.width, ); const dy = torusShortestDelta( options.sourceY, cursorWorld.y, world.height, ); line = { x1: options.sourceX, y1: options.sourceY, x2: options.sourceX + dx, y2: options.sourceY + dy, }; } else { line = { 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) { const targetRadius = displayPointRadiusWorld( target.style, cameraScale, scaleRef, ); const paddingWorld = cameraScale > 0 ? HOVER_PADDING_PX / cameraScale : 0; hoverOutline = { x: target.x, y: target.y, radius: targetRadius + paddingWorld, }; } } return { anchor: { x: options.sourceX, y: options.sourceY, radius: sourceRadius, }, line, hoverOutline, dimmedIds: dimmed, }; } /** * PICK_OVERLAY_STYLE captures the per-channel alphas and widths the * renderer applies to the pick overlay, plus the dim alpha for * non-reachable primitives. The colours themselves — the highlight * colour shared by the anchor / line / hover channels and the dim * multiply tint — come from the active `Theme` (`pickHighlight`, * `pickDimTint`) so the overlay tracks the light / dark palette. * * `dimAlpha` and `Theme.pickDimTint` are applied together to * non-reachable primitives during a pick session: the alpha drops * their brightness while the tint collapses the colour identity * (planet kind) into a single muted shade, so the disabled set reads * as obviously inert against the map background. */ export const PICK_OVERLAY_STYLE = { anchor: { alpha: 0.9, widthPx: 2 }, // F8-12 / #5: cursor line uses the same screen-pixel thickness as // a regular cargo-route shaft (0.6 px), and the hover ring around // the destination matches the planet-outline stroke (1.5 px). The // renderer divides by `cameraScale` before drawing so the values // stay constant on screen at any zoom. line: { alpha: 0.95, widthPx: 0.6 }, hover: { alpha: 0.95, widthPx: 1.5 }, dimAlpha: 0.35, } as const;