Compare commits

..

3 Commits

Author SHA1 Message Date
developer 8e8b34d112 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
2026-05-27 08:08:10 +00:00
Ilia Denisov 175bf25794 docs(ui): F8-07 update renderer.md pick-mode lifecycle for per-copy overlay
Tests · UI / test (pull_request) Successful in 2m55s
Spec described the overlay as a single Graphics in the origin tile,
which was both the bug source and out of date after the F8-07 fix.
Updates the Open / Tick steps to describe the nine-copy replication
and the torus-shortest line endpoint contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:51:06 +02:00
Ilia Denisov 3d8aa91973 fix(ui): F8-07 cargo-route picker — torus-wrap overlay + thinner arrows
Tests · UI / test (push) Successful in 3m4s
Pick overlay (anchor ring, cursor line, hover outline) was drawn into a
single Pixi container — copies[ORIGIN_COPY_INDEX] — so any view of a wrap
copy lost it: picker from A1/A2 to the right (across the seam) showed no
hover highlight on A3's wrap copy, and the picker on A3 (x≈1.44, near the
left edge) put its anchor far left of the viewport. Fix replicates the
overlay across all nine torus copies (matching how primitives and fog
already render) and switches the cursor-line endpoint to torus-shortest
geometry via torusShortestDelta. Anchor and hover-outline coordinates
stay canonical; the per-copy replication renders them under the user's
view in whatever tile is on screen.

Also reduces cargo-route arrow strokes: COL/CAP/MAT 2->0.6 wu and EMP
1->0.4 wu (~3 / ~2 screen px at typical zoom) per the owner's request.

Tests cover the new torus path: source near the left edge with cursor on
the wrap copy across the seam (x axis), source near the top edge with
cursor across the y seam, and a guard that anchor / hover-outline coords
stay canonical regardless of the world argument.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:49:48 +02: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`, 1. **Open** (`opts !== null`): renderer marks `pickModeActive`,
sets `alpha = 0.3` on every primitive whose id is neither the sets `alpha = 0.3` on every primitive whose id is neither the
source nor in `reachableIds`, mounts an overlay `Graphics` in source nor in `reachableIds`, mounts one overlay `Graphics`
the origin tile, and subscribes to pointer-move + hover-change per torus copy (nine total — matching the primitive layer's
+ viewport `clicked` + document `keydown`. 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 2. **Tick** (every pointer-move and hover transition): the
renderer asks `computePickOverlay(opts, cursorWorld, renderer asks `computePickOverlay(opts, cursorWorld,
hoveredId, points, allIds)` (`src/map/pick-mode.ts`) for a hoveredId, points, allIds, world)` (`src/map/pick-mode.ts`)
draw spec — anchor ring + cursor line + optional hover for a draw spec — anchor ring + cursor line + optional hover
outline + dim set — and re-paints the overlay. 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 3. **Resolve**: a click on a primitive whose id is in
`reachableIds` calls `opts.onPick(id)` and tears down. A click `reachableIds` calls `opts.onPick(id)` and tears down. A click
on empty space or a non-reachable primitive is a no-op 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> { function routeStylesByLoadType(theme: Theme): Record<CargoLoadType, Style> {
return { return {
COL: { strokeColor: theme.routeCol, strokeAlpha: 0.95, strokeWidthPx: 2 }, COL: { strokeColor: theme.routeCol, strokeAlpha: 0.95, strokeWidthPx: 0.6 },
CAP: { strokeColor: theme.routeCap, strokeAlpha: 0.95, strokeWidthPx: 2 }, CAP: { strokeColor: theme.routeCap, strokeAlpha: 0.95, strokeWidthPx: 0.6 },
MAT: { strokeColor: theme.routeMat, strokeAlpha: 0.95, strokeWidthPx: 2 }, MAT: { strokeColor: theme.routeMat, strokeAlpha: 0.95, strokeWidthPx: 0.6 },
EMP: { strokeColor: theme.routeEmp, strokeAlpha: 0.85, strokeWidthPx: 1 }, 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 // hover outline / click+Escape resolution) can be tested without
// booting a Pixi `Application`. // booting a Pixi `Application`.
import { torusShortestDelta } from "./math";
import { DEFAULT_POINT_RADIUS_PX, type PointPrim, type PrimitiveID } from "./world"; 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 * computePickOverlay produces a `PickOverlaySpec` for the current
* pick state. Pure: no DOM access, no Pixi calls. Callers prepare * pick state. Pure: no DOM access, no Pixi calls. Callers prepare
* `pointPrimitivesById` from the active world before invoking. * `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( export function computePickOverlay(
options: PickModeOptions, options: PickModeOptions,
@@ -94,6 +109,7 @@ export function computePickOverlay(
hoveredId: PrimitiveID | null, hoveredId: PrimitiveID | null,
pointPrimitivesById: ReadonlyMap<PrimitiveID, PointPrim>, pointPrimitivesById: ReadonlyMap<PrimitiveID, PointPrim>,
allPrimitiveIds: Iterable<PrimitiveID>, allPrimitiveIds: Iterable<PrimitiveID>,
world: { width: number; height: number } | null = null,
): PickOverlaySpec { ): PickOverlaySpec {
const sourcePrim = pointPrimitivesById.get(options.sourcePrimitiveId); const sourcePrim = pointPrimitivesById.get(options.sourcePrimitiveId);
const sourceRadius = const sourceRadius =
@@ -107,15 +123,34 @@ export function computePickOverlay(
dimmed.add(id); dimmed.add(id);
} }
const line = let line: PickOverlaySpec["line"] = null;
cursorWorld === null if (cursorWorld !== null) {
? 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, x1: options.sourceX,
y1: options.sourceY, y1: options.sourceY,
x2: cursorWorld.x, x2: cursorWorld.x,
y2: cursorWorld.y, y2: cursorWorld.y,
}; };
}
}
let hoverOutline: PickOverlaySpec["hoverOutline"] = null; let hoverOutline: PickOverlaySpec["hoverOutline"] = null;
if ( 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 // Pick-mode state. Owned by the renderer so all callers funnel
// through `setPickMode`; tests for the pure overlay math live in // 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 pickModeActive = false;
let pickOptions: PickModeOptions | null = null; let pickOptions: PickModeOptions | null = null;
let pickOverlay: Graphics | null = null; let pickOverlays: Graphics[] = [];
const dimmedAlphaBackup = new Map<Graphics, number>(); const dimmedAlphaBackup = new Map<Graphics, number>();
const dimmedTintBackup = new Map<Graphics, number>(); const dimmedTintBackup = new Map<Graphics, number>();
const detachPickListeners: Array<() => void> = []; const detachPickListeners: Array<() => void> = [];
@@ -631,7 +637,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
}; };
viewport.on("clicked", handleViewportClicked); viewport.on("clicked", handleViewportClicked);
const redrawPickOverlay = (): void => { const redrawPickOverlay = (): void => {
if (pickOverlay === null || pickOptions === null) return; if (pickOverlays.length === 0 || pickOptions === null) return;
const cursorWorld = const cursorWorld =
lastCursorPx === null lastCursorPx === null
? null ? null
@@ -640,14 +646,20 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
handle.getCamera(), handle.getCamera(),
handle.getViewport(), 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( const spec = computePickOverlay(
pickOptions, pickOptions,
cursorWorld, cursorWorld,
lastHoveredId, lastHoveredId,
pointPrimitivesById, pointPrimitivesById,
allPrimitiveIds, allPrimitiveIds,
mode === "torus"
? { width: opts.world.width, height: opts.world.height }
: null,
); );
const g = pickOverlay; for (const g of pickOverlays) {
g.clear(); g.clear();
g.circle(spec.anchor.x, spec.anchor.y, spec.anchor.radius); g.circle(spec.anchor.x, spec.anchor.y, spec.anchor.radius);
g.stroke({ g.stroke({
@@ -676,6 +688,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
width: PICK_OVERLAY_STYLE.hover.width, width: PICK_OVERLAY_STYLE.hover.width,
}); });
} }
}
requestRender(); requestRender();
}; };
const teardownPickMode = (): void => { const teardownPickMode = (): void => {
@@ -687,10 +700,8 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
dimmedAlphaBackup.clear(); dimmedAlphaBackup.clear();
for (const [g, tint] of dimmedTintBackup) g.tint = tint; for (const [g, tint] of dimmedTintBackup) g.tint = tint;
dimmedTintBackup.clear(); dimmedTintBackup.clear();
if (pickOverlay !== null) { for (const g of pickOverlays) g.destroy();
pickOverlay.destroy(); pickOverlays = [];
pickOverlay = null;
}
pickOptions = null; pickOptions = null;
// Un-dimming primitives and removing the overlay are scene // Un-dimming primitives and removing the overlay are scene
// changes that do not move the camera. // changes that do not move the camera.
@@ -717,12 +728,18 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
g.tint = theme.pickDimTint; g.tint = theme.pickDimTint;
} }
} }
// Overlay graphic. Lives in the origin copy so the central // Overlay graphics — one per torus copy. Every copy receives an
// tile owns it; the camera always wraps back into this tile // identical set of draw ops in world coords, and each copy's
// (`wrapTorusCamera`), so the user sees the overlay // container already applies its `(dx*W, dy*H)` transform, so
// regardless of how far they have panned. // the anchor / cursor line / hover outline render in whatever
pickOverlay = new Graphics(); // copy the user is currently panned over. In no-wrap mode the
copies[ORIGIN_COPY_INDEX]!.addChild(pickOverlay); // 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(); redrawPickOverlay();
// Pointer-move drives the cursor line; hover changes drive // Pointer-move drives the cursor line; hover changes drive
// the outline. Both go through the renderer's existing // the outline. Both go through the renderer's existing
+95 -1
View File
@@ -100,7 +100,7 @@ describe("computePickOverlay", () => {
expect(spec.line).toBeNull(); 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( const spec = computePickOverlay(
makeOptions(), makeOptions(),
{ x: 250, y: 320 }, { 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", () => { test("hoverOutline is null when nothing is hovered", () => {
const spec = computePickOverlay( const spec = computePickOverlay(
makeOptions(), makeOptions(),