fix(ui): F8-07 cargo-route picker — torus-wrap overlay + thinner arrows #63
+17
-6
@@ -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
|
||||||
|
|||||||
@@ -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 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user