Step 10.3: add map interactions
CI / check (push) Successful in 2m25s Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 07:12:05 -04:00
parent ebbc97bea5
commit a28fe5c5cb
9 changed files with 384 additions and 10 deletions

View File

@ -468,3 +468,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Route lines run from the origin's position at departure to the destination's position at arrival**, matching sim's straight-line flight model (Step 3.15). The marker is interpolated in map space by sim `dispatchProgress`; because the camera transform is affine, it stays exactly on the drawn line at any zoom or pan.
- **Idle ships are a count badge per location** rather than individual markers, so a busy bay stays readable on a phone. The idle tutorial loaner counts as one.
## Step 10.3
- **Taps are detected in the canvas's own pointer handling:** one pointer, moved less than 8 px, then released. Hit-testing is a pure function over the same scene the canvas draws, with a 22 px reach (about a fingertip); ship markers win close calls because they are drawn on top. Tapping empty space closes the panel.
- **Panels are React components over the canvas**, so they work like the rest of the UI. The location panel's board link uses `/contracts?bay=<id>`; the Contracts screen now preselects the bay from that parameter.
- **Component tests "tap" through the canvas test double**, which renders one button per tappable item from the real `buildScene` and calls the same `onSelect`. Hit-testing itself is unit-tested with screen coordinates.

View File

@ -3,6 +3,7 @@ import { Application, Graphics, Text } from "pixi.js";
import { useEffect, useRef } from "react";
import { serverNow } from "../clock/clock";
import { pan, toScreen, useCamera, zoomAt, type Viewport } from "./camera";
import { hitTest, type MapSelection } from "./hitTest";
import { buildScene } from "./scene";
const COLORS = {
@ -20,10 +21,19 @@ const COLORS = {
* The system map canvas (Steps 10.1–10.2). Draws locations and ships every
* frame from `state` and the server clock; drag to pan, pinch or wheel to zoom.
*/
export function MapCanvas({ state }: { state: PlayerState }) {
export function MapCanvas({
state,
onSelect,
}: {
state: PlayerState;
/** A tap on a location or ship, or on empty space (null) (Step 10.3). */
onSelect?: (selection: MapSelection | null) => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const stateRef = useRef(state);
stateRef.current = state;
const onSelectRef = useRef(onSelect);
onSelectRef.current = onSelect;
useEffect(() => {
const container = containerRef.current;
@ -122,8 +132,10 @@ export function MapCanvas({ state }: { state: PlayerState }) {
// No WebGL/WebGPU (e.g. very old browsers, tests): the map stays blank.
});
// Pan with one pointer, pinch-zoom with two, wheel-zoom on desktop.
// Pan with one pointer, pinch-zoom with two, wheel-zoom on desktop. A single
// pointer that barely moves is a tap, which selects what is under it.
const pointers = new Map<number, { x: number; y: number }>();
let tap: { id: number; start: { x: number; y: number }; moved: number } | null = null;
const local = (e: PointerEvent | WheelEvent) => {
const rect = container.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
@ -131,11 +143,18 @@ export function MapCanvas({ state }: { state: PlayerState }) {
const onDown = (e: PointerEvent) => {
container.setPointerCapture(e.pointerId);
pointers.set(e.pointerId, local(e));
tap = pointers.size === 1 ? { id: e.pointerId, start: local(e), moved: 0 } : null;
};
const onMove = (e: PointerEvent) => {
const previous = pointers.get(e.pointerId);
if (!previous) return;
const current = local(e);
if (tap?.id === e.pointerId) {
tap.moved = Math.max(
tap.moved,
Math.hypot(current.x - tap.start.x, current.y - tap.start.y),
);
}
if (pointers.size === 1) {
useCamera.setState((c) => pan(c, current.x - previous.x, current.y - previous.y));
} else if (pointers.size === 2) {
@ -147,7 +166,14 @@ export function MapCanvas({ state }: { state: PlayerState }) {
}
pointers.set(e.pointerId, current);
};
const onUp = (e: PointerEvent) => pointers.delete(e.pointerId);
const onUp = (e: PointerEvent) => {
pointers.delete(e.pointerId);
if (e.type === "pointerup" && tap?.id === e.pointerId && tap.moved < 8) {
const scene = buildScene(stateRef.current, serverNow());
onSelectRef.current?.(hitTest(scene, local(e), viewport(), useCamera.getState()));
}
if (tap?.id === e.pointerId) tap = null;
};
const onWheel = (e: WheelEvent) => {
e.preventDefault();
useCamera.setState((c) => zoomAt(c, Math.exp(-e.deltaY * 0.001), local(e), viewport()));

View File

@ -0,0 +1,36 @@
import { toScreen, type Camera, type Viewport } from "./camera";
import type { MapPoint } from "./projection";
import type { Scene } from "./scene";
export type MapSelection =
{ kind: "location"; id: string } | { kind: "flight"; dispatchId: string };
/** How close (screen pixels) a tap must be to a location or ship: about a fingertip. */
export const TAP_RADIUS = 22;
/**
* What a tap at `tap` (screen pixels) selects: the nearest ship marker or
* location within reach. Ships win ties, since they are drawn on top.
*/
export function hitTest(
scene: Scene,
tap: MapPoint,
viewport: Viewport,
camera: Camera,
): MapSelection | null {
let best: { selection: MapSelection; distance: number } | null = null;
const consider = (point: MapPoint, selection: MapSelection, bias: number) => {
const p = toScreen(point, viewport, camera);
const distance = Math.hypot(p.x - tap.x, p.y - tap.y) - bias;
if (distance <= TAP_RADIUS && (!best || distance < best.distance)) {
best = { selection, distance };
}
};
for (const location of scene.locations) {
consider(location.point, { kind: "location", id: location.id }, 0);
}
for (const flight of scene.flights) {
consider(flight.marker, { kind: "flight", dispatchId: flight.dispatchId }, 4);
}
return (best as { selection: MapSelection } | null)?.selection ?? null;
}

View File

@ -1,6 +1,7 @@
import type { Contract } from "@deadhead/schema";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useSearchParams } from "react-router";
import { useApi } from "../api/context";
import { useClock } from "../clock/clock";
import { Countdown } from "../components/Countdown";
@ -24,7 +25,9 @@ export function requirementsText(contract: Contract): string | null {
/** Contract boards for the player's bays (Step 9.4). */
export function BayBoards({ onSelect }: { onSelect?: (contract: Contract) => void }) {
const state = useLoadedPlayer();
const [selected, setSelected] = useState(state.bays[0]?.locationId);
// `?bay=moon` (e.g. from the map) preselects that bay.
const [params] = useSearchParams();
const [selected, setSelected] = useState(params.get("bay") ?? state.bays[0]?.locationId);
const bayId = state.bays.some((b) => b.locationId === selected)
? selected
: state.bays[0]?.locationId;

View File

@ -1,14 +1,33 @@
import { content, type Location } from "@deadhead/content";
import { realToGame } from "@deadhead/sim";
import { useState, type ReactNode } from "react";
import { Link } from "react-router";
import { useClock } from "../clock/clock";
import { formatGameTime } from "../clock/GameClock";
import { Countdown } from "../components/Countdown";
import { cargoName, formatMillionKm, locationName } from "../format";
import { distanceAt } from "../game";
import { DEFAULT_CAMERA, useCamera } from "../map/camera";
import type { MapSelection } from "../map/hitTest";
import { MapCanvas } from "../map/MapCanvas";
import { useLoadedPlayer } from "../state/actions";
/** The solar system map (Phase 10). */
const TYPE_LABELS: Record<Location["type"], string> = {
"atmospheric-world": "Atmospheric world",
"airless-body": "Airless body",
"cloud-platform": "Cloud platform",
"belt-station": "Belt station",
};
/** The solar system map (Phase 10). Tap a location or ship for details. */
export function MapScreen() {
const state = useLoadedPlayer();
const [selection, setSelection] = useState<MapSelection | null>(null);
return (
<section className="relative h-[calc(100dvh-7.5rem)]">
<h1 className="sr-only">Map</h1>
<MapCanvas state={state} />
<MapCanvas state={state} onSelect={setSelection} />
<button
type="button"
onClick={() => useCamera.setState(DEFAULT_CAMERA)}
@ -16,6 +35,102 @@ export function MapScreen() {
>
Reset view
</button>
{selection?.kind === "location" && (
<LocationPanel locationId={selection.id} onClose={() => setSelection(null)} />
)}
{selection?.kind === "flight" && (
<FlightPanel dispatchId={selection.dispatchId} onClose={() => setSelection(null)} />
)}
</section>
);
}
function Panel({
title,
onClose,
children,
}: {
title: string;
onClose: () => void;
children: ReactNode;
}) {
return (
<div
role="dialog"
aria-label={title}
className="absolute inset-x-2 bottom-2 rounded-xl border border-slate-700 bg-slate-900/95 p-3 text-sm"
>
<div className="flex items-baseline justify-between gap-2">
<h2 className="font-semibold">{title}</h2>
<button type="button" onClick={onClose} aria-label="Close" className="px-2 text-slate-400">
✕
</button>
</div>
<div className="mt-1">{children}</div>
</div>
);
}
/** A tapped location: details if discovered, and a link to its board if there is a bay. */
function LocationPanel({ locationId, onClose }: { locationId: string; onClose: () => void }) {
const state = useLoadedPlayer();
const now = useClock((s) => s.now);
const location = content.location(locationId)!;
const discovered = state.locations.some((l) => l.id === locationId && l.discovered);
const hasBay = state.bays.some((b) => b.locationId === locationId);
return (
<Panel title={location.name} onClose={onClose}>
{discovered ? (
<>
<p className="text-slate-300">
{TYPE_LABELS[location.type]} ·{" "}
{location.id === "earth"
? "Home"
: `${formatMillionKm(distanceAt("earth", location.id, now))} from Earth`}
</p>
<p className="text-slate-400">
Produces {location.produces.map(cargoName).join(", ") || "nothing"} · demands{" "}
{location.demands.map(cargoName).join(", ") || "nothing"}
</p>
{hasBay ? (
<Link
to={`/contracts?bay=${location.id}`}
className="mt-2 inline-block font-medium text-amber-400 underline"
>
View contract board
</Link>
) : (
<p className="mt-2 text-slate-400">No bay here yet.</p>
)}
</>
) : (
<p className="text-slate-400">Undiscovered — deliver here to unlock.</p>
)}
</Panel>
);
}
/** A tapped ship in flight: its route, pilot and ETA. */
function FlightPanel({ dispatchId, onClose }: { dispatchId: string; onClose: () => void }) {
const state = useLoadedPlayer();
const dispatch = state.activeDispatches.find((d) => d.id === dispatchId);
if (!dispatch) return null;
const shipName = dispatch.tutorial
? "Company loaner"
: (state.ships.find((s) => s.id === dispatch.shipId)?.name ?? "Ship");
const pilotName = state.pilots.find((p) => p.id === dispatch.pilotId)?.name ?? "Unknown pilot";
return (
<Panel title={shipName} onClose={onClose}>
<p className="text-slate-300">
{locationName(dispatch.originId)} → {locationName(dispatch.destinationId)}
</p>
<p className="text-slate-400">Pilot: {pilotName}</p>
<p className="text-slate-400">
Arrives in <Countdown to={dispatch.arrivalAt} /> (
{formatGameTime(realToGame(dispatch.arrivalAt, content.config))})
</p>
</Panel>
);
}

View File

@ -1,6 +1,38 @@
import type { PlayerState } from "@deadhead/schema";
import type { MapSelection } from "../src/map/hitTest";
import { buildScene } from "../src/map/scene";
/** Test stand-in for the PixiJS map canvas. */
export function MapCanvas(_props: { state: PlayerState }) {
return <div data-testid="map-canvas" />;
/**
* Test stand-in for the PixiJS map canvas: one button per tappable thing in
* the real scene, calling `onSelect` with the same selection a tap would.
*/
export function MapCanvas({
state,
onSelect,
}: {
state: PlayerState;
onSelect?: (selection: MapSelection | null) => void;
}) {
const scene = buildScene(state, Date.now());
return (
<div data-testid="map-canvas">
{scene.locations.map((l) => (
<button key={l.id} type="button" onClick={() => onSelect?.({ kind: "location", id: l.id })}>
Tap {l.name}
</button>
))}
{scene.flights.map((f) => (
<button
key={f.dispatchId}
type="button"
onClick={() => onSelect?.({ kind: "flight", dispatchId: f.dispatchId })}
>
Tap ship {f.shipId}
</button>
))}
<button type="button" onClick={() => onSelect?.(null)}>
Tap empty space
</button>
</div>
);
}

View File

@ -0,0 +1,155 @@
import type { Dispatch, PlayerState } from "@deadhead/schema";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { DEFAULT_CAMERA, toScreen } from "../src/map/camera";
import { hitTest } from "../src/map/hitTest";
import { buildScene } from "../src/map/scene";
import { fakeApi, renderApp, samplePlayerState } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
const flight: Dispatch = {
id: "d1",
shipId: "s1",
pilotId: "p-rae",
contract: null,
originId: "earth",
destinationId: "mars",
tutorial: false,
departureAt: NOW - 3_600_000,
arrivalAt: NOW + 5 * 60_000,
distanceMillionKm: 100,
fuelCost: 0,
expectedPayout: 0,
seed: 1,
status: "in-flight",
resolvedAt: null,
};
function mapState(): PlayerState {
const base = samplePlayerState({ tutorial: null });
return {
...base,
bays: [{ id: "b1", locationId: "moon" }],
ships: [
{
id: "s1",
modelId: "freighter",
name: "Slow Ox",
status: "in-flight",
locationId: null,
dispatchId: "d1",
pilotId: "p-rae",
},
],
pilots: [
...base.pilots,
{
...base.pilots[0]!,
id: "p-rae",
name: "Rae Okafor",
isAvatar: false,
assignedShipId: "s1",
status: "in-flight",
locationId: null,
},
],
activeDispatches: [flight],
};
}
describe("map hit-testing", () => {
const viewport = { width: 375, height: 690 };
it("selects the location under a tap, and nothing on empty space", () => {
const scene = buildScene(mapState(), NOW);
// Ceres: nothing else is near it (the test flight is almost at Mars).
const ceres = scene.locations.find((l) => l.id === "ceres-station")!;
const p = toScreen(ceres.point, viewport, DEFAULT_CAMERA);
expect(hitTest(scene, { x: p.x + 5, y: p.y - 5 }, viewport, DEFAULT_CAMERA)).toEqual({
kind: "location",
id: "ceres-station",
});
expect(hitTest(scene, { x: 2, y: 2 }, viewport, DEFAULT_CAMERA)).toBeNull();
});
it("selects a ship marker under a tap", () => {
const scene = buildScene(mapState(), NOW);
const p = toScreen(scene.flights[0]!.marker, viewport, DEFAULT_CAMERA);
expect(hitTest(scene, p, viewport, DEFAULT_CAMERA)).toEqual({
kind: "flight",
dispatchId: "d1",
});
});
});
describe("map panels", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
});
afterEach(() => vi.useRealTimers());
const openMap = async () => {
renderApp(fakeApi({ getPlayerState: mapState }), "/map");
return screen.findByTestId("map-canvas");
};
it("tapping a location with a bay shows its details and a link to its contract board", async () => {
const canvas = await openMap();
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Moon" }));
const panel = screen.getByRole("dialog", { name: "Moon" });
expect(panel).toHaveTextContent("Airless body");
expect(panel).toHaveTextContent(/from Earth/);
expect(within(panel).getByRole("link", { name: "View contract board" })).toHaveAttribute(
"href",
"/contracts?bay=moon",
);
});
it("tapping a discovered location without a bay shows no board link", async () => {
const canvas = await openMap();
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Earth" }));
const panel = screen.getByRole("dialog", { name: "Earth" });
expect(panel).toHaveTextContent("No bay here yet.");
expect(within(panel).queryByRole("link")).not.toBeInTheDocument();
});
it("tapping an undiscovered location hides its details", async () => {
const canvas = await openMap();
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Mars" }));
expect(screen.getByRole("dialog", { name: "Mars" })).toHaveTextContent(
"Undiscovered — deliver here to unlock.",
);
});
it("tapping a ship shows its route, pilot and ETA", async () => {
const canvas = await openMap();
await userEvent.click(within(canvas).getByRole("button", { name: "Tap ship s1" }));
const panel = screen.getByRole("dialog", { name: "Slow Ox" });
expect(panel).toHaveTextContent("Earth → Mars");
expect(panel).toHaveTextContent("Pilot: Rae Okafor");
expect(panel).toHaveTextContent("Arrives in 5m 00s");
});
it("tapping empty space or Close dismisses the panel", async () => {
const canvas = await openMap();
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Moon" }));
await userEvent.click(within(canvas).getByRole("button", { name: "Tap empty space" }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Earth" }));
await userEvent.click(screen.getByRole("button", { name: "Close" }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("the board link opens Contracts with that bay selected", async () => {
const canvas = await openMap();
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Moon" }));
await userEvent.click(screen.getByRole("link", { name: "View contract board" }));
expect(await screen.findByLabelText("Bay")).toHaveValue("moon");
});
});

View File

@ -421,6 +421,7 @@ Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). In developmen
| `src/screens/*` | HQ, Contracts (TutorialBoard / BayBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen |
| `src/components/*` | `AwaySummary` dialog, `Countdown` |
| `src/state/actions.ts` | `useLoadedPlayer`, `useGameAction(route)` (mutation + invalidate state and boards) |
| `src/map/*` | System map: `projection` (square-root radial scale), `camera` (Zustand pan/zoom), `scene` (locations, flights, idle badges), `hitTest`, `MapCanvas` (PixiJS v8, lazy-loaded with the Map screen) |
| `src/game.ts`, `src/format.ts` | Sim-based predictions (distance, trip time) and display formatting |
Session: Better Auth's cookie (`credentials: "include"`); the client stores nothing itself.

View File

@ -100,4 +100,4 @@
- [x] **Step 10.1 — PixiJS map component.** Map tab (`/map`, lazy-loaded) with a PixiJS v8 canvas (`src/map/MapCanvas.tsx`): Sun, orbit rings, five locations (dimmed if undiscovered, ring if a bay), drag to pan, pinch or wheel to zoom, "Reset view". Square-root radial scale and screen projection in `src/map/projection.ts`, camera in `src/map/camera.ts`. Tests: scale monotonic, locations fit a 375 px map for 12 years, zoom keeps the anchor fixed. Manual phone check pending.
- [x] **Step 10.2 — Ships on the map.** `flightMarker` / `buildScene` (`src/map/scene.ts`): each in-flight dispatch draws a route line (origin at departure → destination at arrival) with a marker placed by sim progress every frame; idle ships (tutorial loaner included) show as a count badge at their location. Test: marker at 50% lies halfway along the route line on screen. Manual smoothness check pending.
- [ ] Step 10.3 — Map interactions
- [x] **Step 10.3 — Map interactions.** Taps (single pointer, under 8 px of movement) are hit-tested (`src/map/hitTest.ts`, 22 px reach, ships win ties). A location panel shows details (or "Undiscovered") and, with a bay, a link to `/contracts?bay=…` which preselects that board; a ship panel shows route, pilot and ETA. Component tests tap through a canvas test double that uses the real scene.