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,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)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user