Merge pull request 'fix(ui): F8-07 cargo-route picker — torus-wrap overlay + thinner arrows' (#63) from feature/issue-50-cargo-picker-torus into development
Deploy · Dev / deploy (push) Successful in 48s
Tests · UI / test (push) Successful in 2m53s

This commit was merged in pull request #63.
This commit is contained in:
2026-05-27 08:08:10 +00:00
5 changed files with 215 additions and 58 deletions
+17 -6
View File
@@ -280,14 +280,25 @@ Lifecycle (`RendererHandle.setPickMode(opts)`):
1. **Open** (`opts !== null`): renderer marks `pickModeActive`,
sets `alpha = 0.3` on every primitive whose id is neither the
source nor in `reachableIds`, mounts an overlay `Graphics` in
the origin tile, and subscribes to pointer-move + hover-change
+ viewport `clicked` + document `keydown`.
source nor in `reachableIds`, mounts one overlay `Graphics`
per torus copy (nine total — matching the primitive layer's
wrap-copy layout), and subscribes to pointer-move +
hover-change + viewport `clicked` + document `keydown`. In
no-wrap mode the eight non-origin copies are `visible = false`,
so their overlay children render nothing automatically.
2. **Tick** (every pointer-move and hover transition): the
renderer asks `computePickOverlay(opts, cursorWorld,
hoveredId, points, allIds)` (`src/map/pick-mode.ts`) for a
draw spec — anchor ring + cursor line + optional hover
outline + dim set — and re-paints the overlay.
hoveredId, points, allIds, world)` (`src/map/pick-mode.ts`)
for a draw spec — anchor ring + cursor line + optional hover
outline + dim set — and re-paints every overlay graphic with
the same world-coord ops. Each copy's container already
applies its `(dx*W, dy*H)` transform, so the overlay shows in
whatever tile the user is panned over. In torus mode the
`world` argument switches the cursor-line endpoint to
`(sourceX + torusShortestDelta(...), sourceY + ...)` so the
line takes the short wrap path; anchor and hover-outline
coordinates stay canonical — the per-copy replication renders
them under the user's view in the matching tile.
3. **Resolve**: a click on a primitive whose id is in
`reachableIds` calls `opts.onPick(id)` and tears down. A click
on empty space or a non-reachable primitive is a no-op
+4 -4
View File
@@ -22,10 +22,10 @@ import { DARK_THEME, type LinePrim, type PrimitiveID, type Style, type Theme } f
*/
function routeStylesByLoadType(theme: Theme): Record<CargoLoadType, Style> {
return {
COL: { strokeColor: theme.routeCol, strokeAlpha: 0.95, strokeWidthPx: 2 },
CAP: { strokeColor: theme.routeCap, strokeAlpha: 0.95, strokeWidthPx: 2 },
MAT: { strokeColor: theme.routeMat, strokeAlpha: 0.95, strokeWidthPx: 2 },
EMP: { strokeColor: theme.routeEmp, strokeAlpha: 0.85, strokeWidthPx: 1 },
COL: { strokeColor: theme.routeCol, strokeAlpha: 0.95, strokeWidthPx: 0.6 },
CAP: { strokeColor: theme.routeCap, strokeAlpha: 0.95, strokeWidthPx: 0.6 },
MAT: { strokeColor: theme.routeMat, strokeAlpha: 0.95, strokeWidthPx: 0.6 },
EMP: { strokeColor: theme.routeEmp, strokeAlpha: 0.85, strokeWidthPx: 0.4 },
};
}
+39 -4
View File
@@ -11,6 +11,7 @@
// hover outline / click+Escape resolution) can be tested without
// booting a Pixi `Application`.
import { torusShortestDelta } from "./math";
import { DEFAULT_POINT_RADIUS_PX, type PointPrim, type PrimitiveID } from "./world";
/**
@@ -87,6 +88,20 @@ 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.
*
* `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,
@@ -94,6 +109,7 @@ export function computePickOverlay(
hoveredId: PrimitiveID | null,
pointPrimitivesById: ReadonlyMap<PrimitiveID, PointPrim>,
allPrimitiveIds: Iterable<PrimitiveID>,
world: { width: number; height: number } | null = null,
): PickOverlaySpec {
const sourcePrim = pointPrimitivesById.get(options.sourcePrimitiveId);
const sourceRadius =
@@ -107,15 +123,34 @@ export function computePickOverlay(
dimmed.add(id);
}
const line =
cursorWorld === null
? null
: {
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 (
+31 -14
View File
@@ -596,10 +596,16 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
// Pick-mode state. Owned by the renderer so all callers funnel
// through `setPickMode`; tests for the pure overlay math live in
// `pick-mode.ts`.
// `pick-mode.ts`. The overlay graphic is replicated across all nine
// torus copies — identical world-coord ops drawn into each copy's
// container, which already carries the wrap offset — so the
// anchor / cursor line / hover outline always render in whatever
// copy the user is panned over. In no-wrap mode the eight wrap
// copies are `visible = false`, so their overlay children render
// nothing for free; no special-case path is needed.
let pickModeActive = false;
let pickOptions: PickModeOptions | null = null;
let pickOverlay: Graphics | null = null;
let pickOverlays: Graphics[] = [];
const dimmedAlphaBackup = new Map<Graphics, number>();
const dimmedTintBackup = new Map<Graphics, number>();
const detachPickListeners: Array<() => void> = [];
@@ -631,7 +637,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
};
viewport.on("clicked", handleViewportClicked);
const redrawPickOverlay = (): void => {
if (pickOverlay === null || pickOptions === null) return;
if (pickOverlays.length === 0 || pickOptions === null) return;
const cursorWorld =
lastCursorPx === null
? null
@@ -640,14 +646,20 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
handle.getCamera(),
handle.getViewport(),
);
// Pass the world dims only in torus mode: the function
// switches to torus-shortest line geometry when world is
// non-null, so the line endpoint goes the wrap-short way.
const spec = computePickOverlay(
pickOptions,
cursorWorld,
lastHoveredId,
pointPrimitivesById,
allPrimitiveIds,
mode === "torus"
? { width: opts.world.width, height: opts.world.height }
: null,
);
const g = pickOverlay;
for (const g of pickOverlays) {
g.clear();
g.circle(spec.anchor.x, spec.anchor.y, spec.anchor.radius);
g.stroke({
@@ -676,6 +688,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
width: PICK_OVERLAY_STYLE.hover.width,
});
}
}
requestRender();
};
const teardownPickMode = (): void => {
@@ -687,10 +700,8 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
dimmedAlphaBackup.clear();
for (const [g, tint] of dimmedTintBackup) g.tint = tint;
dimmedTintBackup.clear();
if (pickOverlay !== null) {
pickOverlay.destroy();
pickOverlay = null;
}
for (const g of pickOverlays) g.destroy();
pickOverlays = [];
pickOptions = null;
// Un-dimming primitives and removing the overlay are scene
// changes that do not move the camera.
@@ -717,12 +728,18 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
g.tint = theme.pickDimTint;
}
}
// 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);
// Overlay graphics — one per torus copy. Every copy receives an
// identical set of draw ops in world coords, and each copy's
// container already applies its `(dx*W, dy*H)` transform, so
// the anchor / cursor line / hover outline render in whatever
// copy the user is currently panned over. In no-wrap mode the
// non-origin copies are `visible = false`, so their overlay
// children are invisible without extra wiring.
pickOverlays = copies.map((c) => {
const g = new Graphics();
c.addChild(g);
return g;
});
redrawPickOverlay();
// Pointer-move drives the cursor line; hover changes drive
// the outline. Both go through the renderer's existing
+95 -1
View File
@@ -100,7 +100,7 @@ describe("computePickOverlay", () => {
expect(spec.line).toBeNull();
});
test("line endpoints follow the cursor when present", () => {
test("line endpoints follow the cursor when present (no-wrap)", () => {
const spec = computePickOverlay(
makeOptions(),
{ x: 250, y: 320 },
@@ -116,6 +116,100 @@ describe("computePickOverlay", () => {
});
});
test("torus line endpoint uses torusShortestDelta — short path matches direct cursor", () => {
// Source at (100,100), cursor at (150,120). Wrap world 201×201
// — the direct delta is already the shortest, so the endpoint
// equals the cursor.
const spec = computePickOverlay(
makeOptions(),
{ x: 150, y: 120 },
null,
points,
allIds,
{ width: 201, height: 201 },
);
expect(spec.line).toEqual({ x1: 100, y1: 100, x2: 150, y2: 120 });
});
test("torus line endpoint wraps when the short path crosses the seam (x axis)", () => {
// Issue #50 repro: source A3 near the left edge (x=1.44) of a
// 201-wide world, cursor over a wrap copy of an A1-like planet
// at world x ≈ -15 (the user is panned to the left side, seeing
// the wrap copy that A1's canonical x=184 produces at x=184-201
// = -17 in the screen-space the renderer paints in the -W tile).
// torusShortestDelta(1.44, -15, 201) is -16.44 — the line endpoint
// is the source + that delta, extending left past x=0.
const spec = computePickOverlay(
makeOptions({ sourceX: 1.44, sourceY: 146 }),
{ x: -15, y: 137 },
null,
points,
allIds,
{ width: 201, height: 201 },
);
expect(spec.line).not.toBeNull();
expect(spec.line!.x1).toBe(1.44);
expect(spec.line!.y1).toBe(146);
expect(spec.line!.x2).toBeCloseTo(-15);
expect(spec.line!.y2).toBe(137);
});
test("torus line endpoint wraps the long-way cursor back through the seam (x axis)", () => {
// Source at x=184 (canonical A1), cursor at x=200 (just inside
// the [0, W) tile near the right edge). Direct delta is +16
// (no wrap), but if the cursor sits in a wrap copy of an
// A3-like planet at world x ≈ 202.44, the torus-shortest delta
// from 184 is +18.44 and the endpoint extends past the seam.
const spec = computePickOverlay(
makeOptions({ sourceX: 184, sourceY: 137 }),
{ x: 202.44, y: 146 },
null,
points,
allIds,
{ width: 201, height: 201 },
);
expect(spec.line!.x1).toBe(184);
expect(spec.line!.x2).toBeCloseTo(202.44);
});
test("torus line endpoint wraps when the short path crosses the seam (y axis)", () => {
// Symmetric coverage on the y axis — source near the bottom,
// cursor pulled across the seam via the negative-y wrap.
const spec = computePickOverlay(
makeOptions({ sourceX: 50, sourceY: 3 }),
{ x: 50, y: -10 },
null,
points,
allIds,
{ width: 100, height: 50 },
);
expect(spec.line!.y1).toBe(3);
expect(spec.line!.y2).toBeCloseTo(-10);
expect(spec.line!.x1).toBe(50);
expect(spec.line!.x2).toBe(50);
});
test("torus mode preserves canonical anchor and hover-outline coords", () => {
// The renderer paints the overlay into every torus copy with
// its own offset, so the spec stays in canonical coords; the
// torus argument must only affect the cursor line. This test
// guards the contract for the future-careless reader.
const spec = computePickOverlay(
makeOptions(),
{ x: 250, y: 320 },
2,
points,
allIds,
{ width: 400, height: 400 },
);
expect(spec.anchor).toEqual({ x: 100, y: 100, radius: 6 + ANCHOR_PADDING_WORLD });
expect(spec.hoverOutline).toEqual({
x: 200,
y: 100,
radius: 5 + HOVER_PADDING_WORLD,
});
});
test("hoverOutline is null when nothing is hovered", () => {
const spec = computePickOverlay(
makeOptions(),