From 3d8aa9197323229fdc510cd78ccfb92fb43fd243 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 27 May 2026 09:49:48 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui):=20F8-07=20cargo-route=20picker=20?= =?UTF-8?q?=E2=80=94=20torus-wrap=20overlay=20+=20thinner=20arrows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ui/frontend/src/map/cargo-routes.ts | 8 +-- ui/frontend/src/map/pick-mode.ts | 53 +++++++++++--- ui/frontend/src/map/render.ts | 93 ++++++++++++++---------- ui/frontend/tests/map-pick-mode.test.ts | 96 ++++++++++++++++++++++++- 4 files changed, 198 insertions(+), 52 deletions(-) diff --git a/ui/frontend/src/map/cargo-routes.ts b/ui/frontend/src/map/cargo-routes.ts index 6551322..8c943b9 100644 --- a/ui/frontend/src/map/cargo-routes.ts +++ b/ui/frontend/src/map/cargo-routes.ts @@ -22,10 +22,10 @@ import { DARK_THEME, type LinePrim, type PrimitiveID, type Style, type Theme } f */ function routeStylesByLoadType(theme: Theme): Record { 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 }, }; } diff --git a/ui/frontend/src/map/pick-mode.ts b/ui/frontend/src/map/pick-mode.ts index 19e7c50..405e109 100644 --- a/ui/frontend/src/map/pick-mode.ts +++ b/ui/frontend/src/map/pick-mode.ts @@ -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, allPrimitiveIds: Iterable, + 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 ( diff --git a/ui/frontend/src/map/render.ts b/ui/frontend/src/map/render.ts index 5c7a811..a85b4ab 100644 --- a/ui/frontend/src/map/render.ts +++ b/ui/frontend/src/map/render.ts @@ -596,10 +596,16 @@ export async function createRenderer(opts: RendererOptions): Promise(); const dimmedTintBackup = new Map(); const detachPickListeners: Array<() => void> = []; @@ -631,7 +637,7 @@ export async function createRenderer(opts: RendererOptions): Promise { - if (pickOverlay === null || pickOptions === null) return; + if (pickOverlays.length === 0 || pickOptions === null) return; const cursorWorld = lastCursorPx === null ? null @@ -640,41 +646,48 @@ export async function createRenderer(opts: RendererOptions): Promise { + 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 diff --git a/ui/frontend/tests/map-pick-mode.test.ts b/ui/frontend/tests/map-pick-mode.test.ts index 0ec66ac..3a3f1b6 100644 --- a/ui/frontend/tests/map-pick-mode.test.ts +++ b/ui/frontend/tests/map-pick-mode.test.ts @@ -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(),