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
This commit was merged in pull request #63.
This commit is contained in:
+17
-6
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user