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:
Ilia Denisov
2026-05-09 20:01:34 +02:00
parent 5fd67ed958
commit 7c8b5aeb23
43 changed files with 4559 additions and 98 deletions
+175
View File
@@ -0,0 +1,175 @@
// 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. Phase 16 ships placeholder
// colours; Phase 35 polish picks final values.
//
// 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 type { LinePrim, PrimitiveID, Style } from "./world";
export const STYLE_ROUTE_COL: Style = {
strokeColor: 0x4fc3f7,
strokeAlpha: 0.95,
strokeWidthPx: 2,
};
export const STYLE_ROUTE_CAP: Style = {
strokeColor: 0xffb74d,
strokeAlpha: 0.95,
strokeWidthPx: 2,
};
export const STYLE_ROUTE_MAT: Style = {
strokeColor: 0x81c784,
strokeAlpha: 0.95,
strokeWidthPx: 2,
};
export const STYLE_ROUTE_EMP: Style = {
strokeColor: 0x90a4ae,
strokeAlpha: 0.85,
strokeWidthPx: 1,
};
const STYLE_BY_LOAD_TYPE: Record<CargoLoadType, Style> = {
COL: STYLE_ROUTE_COL,
CAP: STYLE_ROUTE_CAP,
MAT: STYLE_ROUTE_MAT,
EMP: STYLE_ROUTE_EMP,
};
/** 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<CargoLoadType, number> = {
COL: 8,
CAP: 7,
MAT: 6,
EMP: 5,
};
const LOAD_TYPE_INDEX: Record<CargoLoadType, number> = {
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.
*/
export function buildCargoRouteLines(report: GameReport): LinePrim[] {
if (report.routes.length === 0) return [];
const planetById = new Map<number, ReportPlanet>();
for (const planet of report.planets) {
planetById.set(planet.number, planet);
}
const lines: LinePrim[] = [];
for (const route of report.routes) {
const source = planetById.get(route.sourcePlanetNumber);
if (source === undefined) continue;
for (const entry of route.entries) {
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 = STYLE_BY_LOAD_TYPE[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)
);
}
+6 -1
View File
@@ -14,6 +14,7 @@
import { distSqPointToSegment, screenToWorld, torusShortestDelta } from "./math";
import {
DEFAULT_HIT_SLOP_PX,
DEFAULT_POINT_RADIUS_PX,
KIND_ORDER,
type Camera,
type CirclePrim,
@@ -100,7 +101,11 @@ function matchPoint(
): number | null {
const { dx, dy } = torusDelta(p.x, p.y, cursor.x, cursor.y, world);
const distSq = dx * dx + dy * dy;
const r = slopWorld;
// The visible disc is `pointRadiusPx` world units; the hit zone
// is the disc plus a small ergonomic slop on top. A click on any
// painted pixel of the planet must register as a hit.
const visibleRadius = p.style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX;
const r = visibleRadius + slopWorld;
if (distSq <= r * r) return distSq;
return null;
}
+160
View File
@@ -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;
+408 -11
View File
@@ -21,18 +21,27 @@ import { Application, Container, Graphics, type Renderer, type RendererType } fr
import { Viewport as PixiViewport } from "pixi-viewport";
import { hitTest, type Hit } from "./hit-test";
import { screenToWorld } from "./math";
import { minScaleNoWrap } from "./no-wrap";
import {
computePickOverlay,
PICK_OVERLAY_STYLE,
type PickModeHandle,
type PickModeOptions,
} from "./pick-mode";
import { wrapCameraTorus } from "./torus";
import {
DARK_THEME,
DEFAULT_POINT_RADIUS_PX,
World,
type Camera,
type CirclePrim,
type LinePrim,
type PointPrim,
type Primitive,
type PrimitiveID,
type Theme,
type Viewport,
type World,
type WrapMode,
} from "./world";
@@ -58,6 +67,26 @@ export interface RendererHandle {
getViewport(): Viewport;
getBackend(): "webgl" | "webgpu" | "canvas";
hitAt(cursorPx: { x: number; y: number }): Hit | null;
/**
* setExtraPrimitives replaces the current overlay primitive layer
* with `prims`. The base world (passed to `createRenderer`) is
* preserved; only the extras layer changes. Used by the in-game
* shell to project order-overlay-driven artefacts (Phase 16
* cargo-route arrows) onto the live renderer without disposing
* and recreating the Pixi `Application` — which Pixi 8 does not
* reliably support on the same canvas.
*
* Hit-test, `getPrimitives`, and pick mode all see the union of
* base + extras after the call returns. Repeated calls
* remount-replace the extras atomically.
*/
setExtraPrimitives(prims: readonly Primitive[]): void;
/**
* getPrimitives returns the live union of base + extras. The
* order is base-first, extras-last (mirroring the draw order).
* Reads stay in sync with `setExtraPrimitives`.
*/
getPrimitives(): readonly Primitive[];
/**
* onClick subscribes `cb` to a click on the map (a pointer-down /
* pointer-up pair without enough drag to trigger pan). The cursor
@@ -70,6 +99,62 @@ export interface RendererHandle {
* click here will not race a pan gesture.
*/
onClick(cb: (cursorPx: { x: number; y: number }) => void): () => void;
/**
* onPointerMove subscribes `cb` to every pointer-move event on
* the canvas. The callback receives the cursor in canvas-local
* pixel coordinates so callers can hand it straight to `hitAt`.
* Touch drags also emit pointer-move while a finger is pressed.
* The returned function detaches the listener; idempotent.
*/
onPointerMove(cb: (cursorPx: { x: number; y: number }) => void): () => void;
/**
* onHoverChange subscribes `cb` to changes in the primitive
* currently under the cursor. The callback fires only when the
* id transitions (deduped) and is invoked with `null` when the
* cursor moves into empty space. Driven by the same pointer-move
* stream as `onPointerMove`, so subscribing to both does not
* double-cost the pointer event.
*/
onHoverChange(cb: (id: PrimitiveID | null) => void): () => void;
/**
* setPickMode opens (or, with `null`, closes) a map-driven
* destination pick. While a session is active the renderer dims
* primitives outside `reachableIds`, mounts an overlay drawing
* the source-anchor ring, the cursor line, and the
* hover-highlight ring, suppresses regular `onClick` consumers,
* and listens for Escape on `document`. The session resolves via
* `opts.onPick(id)` on a click hitting a reachable planet, or
* `opts.onPick(null)` on Escape / handle.cancel().
*
* Returns the imperative cancel handle when a session was opened
* (i.e. `opts !== null`), otherwise `null`. Calling the function
* again with `null` closes any active session and is idempotent.
*/
setPickMode(opts: PickModeOptions | null): PickModeHandle | null;
/**
* isPickModeActive reports whether a `setPickMode` session is
* currently open. The standard `onClick` path is suppressed
* while this returns `true`.
*/
isPickModeActive(): boolean;
/**
* getPickState returns a defensive snapshot of the pick-mode
* session for debugging surfaces. `sourcePrimitiveId` and
* `reachableIds` are `null` while no session is open.
*/
getPickState(): {
active: boolean;
sourcePrimitiveId: PrimitiveID | null;
reachableIds: ReadonlySet<PrimitiveID> | null;
hoveredId: PrimitiveID | null;
};
/**
* getPrimitiveAlpha returns the current rendered alpha of the
* primitive `id` (in the central tile). Used by the debug
* surface to report dimmed-state for e2e assertions. Returns 1
* for unknown ids.
*/
getPrimitiveAlpha(id: PrimitiveID): number;
resize(widthPx: number, heightPx: number): void;
dispose(): void;
}
@@ -132,10 +217,31 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
return c;
});
for (const c of copies) {
for (const p of opts.world.primitives) {
c.addChild(buildGraphics(p, theme));
// Per-id `Graphics` lookup. Each primitive lives in nine copies
// (one per torus tile); pick-mode dims them by id, so the lookup
// indexes the full set of `Graphics` instances per primitive id.
const primitiveGraphics = new Map<PrimitiveID, Graphics[]>();
const pointPrimitivesById = new Map<PrimitiveID, PointPrim>();
const allPrimitiveIds: PrimitiveID[] = [];
const extraPrimitiveIds = new Set<PrimitiveID>();
let currentWorld: World = opts.world;
const populatePrimitives = (prim: Primitive, isExtra: boolean): void => {
for (const c of copies) {
const g = buildGraphics(prim, theme);
c.addChild(g);
let list = primitiveGraphics.get(prim.id);
if (list === undefined) {
list = [];
primitiveGraphics.set(prim.id, list);
}
list.push(g);
}
allPrimitiveIds.push(prim.id);
if (prim.kind === "point") pointPrimitivesById.set(prim.id, prim);
if (isExtra) extraPrimitiveIds.add(prim.id);
};
for (const p of opts.world.primitives) {
populatePrimitives(p, false);
}
let mode: WrapMode = opts.mode;
@@ -217,6 +323,208 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
applyMode(mode);
// Pointer-move + hover plumbing. Listening on the underlying
// canvas keeps the renderer agnostic of pixi-viewport's plugin
// chain (drag/pinch can swallow Pixi-level pointer events while
// a gesture is in progress; the DOM event still fires).
const pointerMoveCallbacks = new Set<
(cursorPx: { x: number; y: number }) => void
>();
const hoverChangeCallbacks = new Set<(id: PrimitiveID | null) => void>();
let lastHoveredId: PrimitiveID | null = null;
let lastCursorPx: { x: number; y: number } | null = null;
const handlePointerMove = (event: PointerEvent): void => {
const rect = canvas.getBoundingClientRect();
const cursorPx = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
lastCursorPx = cursorPx;
for (const cb of pointerMoveCallbacks) cb(cursorPx);
const hit = hitTest(
currentWorld,
handle.getCamera(),
handle.getViewport(),
cursorPx,
mode,
);
const hoveredId = hit?.primitive.id ?? null;
if (hoveredId === lastHoveredId) return;
lastHoveredId = hoveredId;
for (const cb of hoverChangeCallbacks) cb(hoveredId);
};
const handlePointerLeave = (): void => {
lastCursorPx = null;
if (hoverChangeCallbacks.size === 0 || lastHoveredId === null) return;
lastHoveredId = null;
for (const cb of hoverChangeCallbacks) cb(null);
};
canvas.addEventListener("pointermove", handlePointerMove);
canvas.addEventListener("pointerleave", handlePointerLeave);
// Click dispatch. The renderer owns one `viewport.clicked`
// listener and fans the event out to either the pick-mode
// resolver (when a session is open) or the standard `onClick`
// subscribers — never both. Routing through one listener makes
// the gating race-proof: a pick-mode resolution + teardown runs
// in the same tick as the click, and the standard subscribers
// do not see the post-teardown state.
const clickSubscribers = new Set<
(cursorPx: { x: number; y: number }) => void
>();
// Pick-mode state. Owned by the renderer so all callers funnel
// through `setPickMode`; tests for the pure overlay math live in
// `pick-mode.ts`.
let pickModeActive = false;
let pickOptions: PickModeOptions | null = null;
let pickOverlay: Graphics | null = null;
const dimmedAlphaBackup = new Map<Graphics, number>();
const detachPickListeners: Array<() => void> = [];
const handleViewportClicked = (e: {
screen: { x: number; y: number };
}): void => {
const cursorPx = { x: e.screen.x, y: e.screen.y };
if (pickModeActive) {
const session = pickOptions;
if (session === null) return;
const hit = hitTest(
currentWorld,
handle.getCamera(),
handle.getViewport(),
cursorPx,
mode,
);
const hitId = hit?.primitive.id ?? null;
if (hitId === null) return;
if (hitId === session.sourcePrimitiveId) return;
if (!session.reachableIds.has(hitId)) return;
const cb = session.onPick;
teardownPickMode();
cb(hitId);
return;
}
for (const cb of clickSubscribers) cb(cursorPx);
};
viewport.on("clicked", handleViewportClicked);
const redrawPickOverlay = (): void => {
if (pickOverlay === null || pickOptions === null) return;
const cursorWorld =
lastCursorPx === null
? null
: screenToWorld(
lastCursorPx,
handle.getCamera(),
handle.getViewport(),
);
const spec = computePickOverlay(
pickOptions,
cursorWorld,
lastHoveredId,
pointPrimitivesById,
allPrimitiveIds,
);
const g = pickOverlay;
g.clear();
g.circle(spec.anchor.x, spec.anchor.y, spec.anchor.radius);
g.stroke({
color: PICK_OVERLAY_STYLE.anchor.color,
alpha: PICK_OVERLAY_STYLE.anchor.alpha,
width: PICK_OVERLAY_STYLE.anchor.width,
});
if (spec.line !== null) {
g.moveTo(spec.line.x1, spec.line.y1);
g.lineTo(spec.line.x2, spec.line.y2);
g.stroke({
color: PICK_OVERLAY_STYLE.line.color,
alpha: PICK_OVERLAY_STYLE.line.alpha,
width: PICK_OVERLAY_STYLE.line.width,
});
}
if (spec.hoverOutline !== null) {
g.circle(
spec.hoverOutline.x,
spec.hoverOutline.y,
spec.hoverOutline.radius,
);
g.stroke({
color: PICK_OVERLAY_STYLE.hover.color,
alpha: PICK_OVERLAY_STYLE.hover.alpha,
width: PICK_OVERLAY_STYLE.hover.width,
});
}
};
const teardownPickMode = (): void => {
if (!pickModeActive) return;
pickModeActive = false;
for (const detach of detachPickListeners) detach();
detachPickListeners.length = 0;
for (const [g, alpha] of dimmedAlphaBackup) g.alpha = alpha;
dimmedAlphaBackup.clear();
if (pickOverlay !== null) {
pickOverlay.destroy();
pickOverlay = null;
}
pickOptions = null;
};
const openPickMode = (options: PickModeOptions): PickModeHandle => {
// An existing session is cancelled first so the previous
// `onPick(null)` is delivered before the new one starts.
if (pickModeActive) {
const previous = pickOptions;
teardownPickMode();
previous?.onPick(null);
}
pickOptions = options;
pickModeActive = true;
// Dim every primitive that's not the source and not reachable.
for (const [id, list] of primitiveGraphics) {
if (id === options.sourcePrimitiveId) continue;
if (options.reachableIds.has(id)) continue;
for (const g of list) {
dimmedAlphaBackup.set(g, g.alpha);
g.alpha = PICK_OVERLAY_STYLE.dimAlpha;
}
}
// Overlay graphic. Lives in the origin copy so the central
// tile owns it; the camera always wraps back into this tile
// (`wrapTorusCamera`), so the user sees the overlay
// regardless of how far they have panned.
pickOverlay = new Graphics();
copies[ORIGIN_COPY_INDEX]!.addChild(pickOverlay);
redrawPickOverlay();
// Pointer-move drives the cursor line; hover changes drive
// the outline. Both go through the renderer's existing
// callback registries.
detachPickListeners.push(handle.onPointerMove(redrawPickOverlay));
detachPickListeners.push(handle.onHoverChange(redrawPickOverlay));
// Click resolution is handled by the shared
// `handleViewportClicked` dispatcher above; pick mode does
// not subscribe its own `clicked` listener — see the
// rationale in the dispatcher's comment.
const keyHandler = (event: KeyboardEvent): void => {
if (event.key !== "Escape") return;
if (pickOptions === null) return;
event.preventDefault();
const cb = pickOptions.onPick;
teardownPickMode();
cb(null);
};
document.addEventListener("keydown", keyHandler);
detachPickListeners.push(() =>
document.removeEventListener("keydown", keyHandler),
);
return {
cancel: (): void => {
if (pickOptions === null) return;
const cb = pickOptions.onPick;
teardownPickMode();
cb(null);
},
};
};
const handle: RendererHandle = {
app,
viewport,
@@ -233,16 +541,89 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
}),
getBackend: () => rendererBackendName(app.renderer),
hitAt: (cursorPx) =>
hitTest(opts.world, handle.getCamera(), handle.getViewport(), cursorPx, mode),
hitTest(
currentWorld,
handle.getCamera(),
handle.getViewport(),
cursorPx,
mode,
),
setExtraPrimitives: (prims) => {
// Drop the previous extras layer.
for (const id of extraPrimitiveIds) {
const list = primitiveGraphics.get(id);
if (list !== undefined) {
for (const g of list) {
g.parent?.removeChild(g);
g.destroy();
}
primitiveGraphics.delete(id);
}
pointPrimitivesById.delete(id);
const idx = allPrimitiveIds.indexOf(id);
if (idx >= 0) allPrimitiveIds.splice(idx, 1);
}
extraPrimitiveIds.clear();
// Add the new extras.
for (const p of prims) {
populatePrimitives(p, true);
}
// Rebuild the snapshot World hit-test reads from. The
// renderer keeps `currentWorld` mutable so the live
// extras participate in click/hover tests on the same
// frame they're drawn.
currentWorld = new World(opts.world.width, opts.world.height, [
...opts.world.primitives,
...prims,
]);
},
getPrimitives: () => currentWorld.primitives,
onClick: (cb) => {
const handler = (e: { screen: { x: number; y: number } }): void => {
cb({ x: e.screen.x, y: e.screen.y });
};
viewport.on("clicked", handler);
clickSubscribers.add(cb);
return () => {
viewport.off("clicked", handler);
clickSubscribers.delete(cb);
};
},
onPointerMove: (cb) => {
pointerMoveCallbacks.add(cb);
return () => {
pointerMoveCallbacks.delete(cb);
};
},
onHoverChange: (cb) => {
hoverChangeCallbacks.add(cb);
// Fire the current state once so subscribers do not have to
// wait for the next pointer movement to learn what's under
// the cursor.
cb(lastHoveredId);
return () => {
hoverChangeCallbacks.delete(cb);
};
},
setPickMode: (options) => {
if (options === null) {
if (!pickModeActive) return null;
const previous = pickOptions;
teardownPickMode();
previous?.onPick(null);
return null;
}
return openPickMode(options);
},
isPickModeActive: () => pickModeActive,
getPickState: () => ({
active: pickModeActive,
sourcePrimitiveId: pickOptions?.sourcePrimitiveId ?? null,
reachableIds: pickOptions?.reachableIds ?? null,
hoveredId: lastHoveredId,
}),
getPrimitiveAlpha: (id) => {
const list = primitiveGraphics.get(id);
if (list === undefined || list.length === 0) return 1;
// All copies share the same alpha (dim is applied to every
// torus tile), so the central-tile entry is representative.
return list[Math.min(ORIGIN_COPY_INDEX, list.length - 1)]!.alpha;
},
resize: (w, h) => {
app.renderer.resize(w, h);
viewport.resize(w, h, opts.world.width, opts.world.height);
@@ -255,8 +636,24 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
}
},
dispose: () => {
// Tear down any open pick session before destroying the
// app — the resolution callback might reference Svelte
// stores that disappear next tick on dispose, but
// `onPick(null)` here is a synchronous notification the
// caller is responsible for handling.
if (pickModeActive) {
const previous = pickOptions;
teardownPickMode();
previous?.onPick(null);
}
viewport.off("moved", enforceCentreWhenLarger);
viewport.off("moved", wrapTorusCamera);
viewport.off("clicked", handleViewportClicked);
canvas.removeEventListener("pointermove", handlePointerMove);
canvas.removeEventListener("pointerleave", handlePointerLeave);
pointerMoveCallbacks.clear();
hoverChangeCallbacks.clear();
clickSubscribers.clear();
app.destroy({ removeView: false }, { children: true });
},
};
@@ -283,7 +680,7 @@ function buildGraphics(p: Primitive, theme: Theme): Graphics {
function drawPoint(g: Graphics, p: PointPrim, theme: Theme): void {
const color = p.style.fillColor ?? theme.pointFill;
const alpha = p.style.fillAlpha ?? 1;
const radiusPx = p.style.pointRadiusPx ?? 3;
const radiusPx = p.style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX;
g.circle(p.x, p.y, radiusPx);
g.fill({ color, alpha });
}
+12 -3
View File
@@ -63,14 +63,23 @@ export type Primitive = PointPrim | CirclePrim | LinePrim;
export type PrimitiveKind = Primitive["kind"];
// Default hit slop in screen pixels per primitive kind. Chosen for
// touch ergonomics; per-primitive `hitSlopPx` overrides the default.
// Default hit slop in screen pixels per primitive kind. Added on top
// of the visible footprint of each primitive — for points, the
// effective hit radius is `pointRadiusPx + slopPx`. Chosen for touch
// ergonomics; per-primitive `hitSlopPx` overrides the default.
export const DEFAULT_HIT_SLOP_PX: Record<PrimitiveKind, number> = {
point: 8,
point: 4,
circle: 6,
line: 6,
};
// Default world-unit radius drawn for a `PointPrim` when its
// `style.pointRadiusPx` is unset. Shared between the renderer
// (`render.ts.drawPoint`) and the hit-test
// (`hit-test.ts.matchPoint`) so the click target always covers the
// visible disc.
export const DEFAULT_POINT_RADIUS_PX = 3;
// kindOrder is the deterministic tie-break order used during hit-test
// when two primitives match a cursor at identical priority and
// distance. Smaller value wins.