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>
This commit is contained in:
Ilia Denisov
2026-05-27 09:49:48 +02:00
parent 3153a95292
commit 3d8aa91973
4 changed files with 198 additions and 52 deletions
+44 -9
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
: {
x1: options.sourceX,
y1: options.sourceY,
x2: cursorWorld.x,
y2: cursorWorld.y,
};
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 (