diff --git a/ui/docs/renderer.md b/ui/docs/renderer.md index dbcfb47..78c011c 100644 --- a/ui/docs/renderer.md +++ b/ui/docs/renderer.md @@ -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 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(),