From a28fe5c5cbfb81a1565c7b61dab1ebc69e41921b Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 07:12:05 -0400 Subject: [PATCH] Step 10.3: add map interactions Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 6 + apps/web/src/map/MapCanvas.tsx | 32 ++++- apps/web/src/map/hitTest.ts | 36 ++++++ apps/web/src/screens/BayBoards.tsx | 5 +- apps/web/src/screens/MapScreen.tsx | 119 +++++++++++++++++- apps/web/test/map-canvas-double.tsx | 38 +++++- apps/web/test/map-interactions.test.tsx | 155 ++++++++++++++++++++++++ memory-bank/architecture.md | 1 + memory-bank/progress.md | 2 +- 9 files changed, 384 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/map/hitTest.ts create mode 100644 apps/web/test/map-interactions.test.tsx diff --git a/DECISIONS.md b/DECISIONS.md index 1a0741f..1e16f2d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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=`; 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. diff --git a/apps/web/src/map/MapCanvas.tsx b/apps/web/src/map/MapCanvas.tsx index e6cd5ae..0a2e95b 100644 --- a/apps/web/src/map/MapCanvas.tsx +++ b/apps/web/src/map/MapCanvas.tsx @@ -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(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(); + 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())); diff --git a/apps/web/src/map/hitTest.ts b/apps/web/src/map/hitTest.ts new file mode 100644 index 0000000..7978427 --- /dev/null +++ b/apps/web/src/map/hitTest.ts @@ -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; +} diff --git a/apps/web/src/screens/BayBoards.tsx b/apps/web/src/screens/BayBoards.tsx index ae6db14..ca47032 100644 --- a/apps/web/src/screens/BayBoards.tsx +++ b/apps/web/src/screens/BayBoards.tsx @@ -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; diff --git a/apps/web/src/screens/MapScreen.tsx b/apps/web/src/screens/MapScreen.tsx index 068c385..7fe6386 100644 --- a/apps/web/src/screens/MapScreen.tsx +++ b/apps/web/src/screens/MapScreen.tsx @@ -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 = { + "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(null); + return (

Map

- + + {selection?.kind === "location" && ( + setSelection(null)} /> + )} + {selection?.kind === "flight" && ( + setSelection(null)} /> + )}
); } + +function Panel({ + title, + onClose, + children, +}: { + title: string; + onClose: () => void; + children: ReactNode; +}) { + return ( +
+
+

{title}

+ +
+
{children}
+
+ ); +} + +/** 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 ( + + {discovered ? ( + <> +

+ {TYPE_LABELS[location.type]} ·{" "} + {location.id === "earth" + ? "Home" + : `${formatMillionKm(distanceAt("earth", location.id, now))} from Earth`} +

+

+ Produces {location.produces.map(cargoName).join(", ") || "nothing"} · demands{" "} + {location.demands.map(cargoName).join(", ") || "nothing"} +

+ {hasBay ? ( + + View contract board + + ) : ( +

No bay here yet.

+ )} + + ) : ( +

Undiscovered — deliver here to unlock.

+ )} +
+ ); +} + +/** 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 ( + +

+ {locationName(dispatch.originId)} → {locationName(dispatch.destinationId)} +

+

Pilot: {pilotName}

+

+ Arrives in ( + {formatGameTime(realToGame(dispatch.arrivalAt, content.config))}) +

+
+ ); +} diff --git a/apps/web/test/map-canvas-double.tsx b/apps/web/test/map-canvas-double.tsx index 84830fb..a2e80ce 100644 --- a/apps/web/test/map-canvas-double.tsx +++ b/apps/web/test/map-canvas-double.tsx @@ -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
; +/** + * 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 ( +
+ {scene.locations.map((l) => ( + + ))} + {scene.flights.map((f) => ( + + ))} + +
+ ); } diff --git a/apps/web/test/map-interactions.test.tsx b/apps/web/test/map-interactions.test.tsx new file mode 100644 index 0000000..0f257a8 --- /dev/null +++ b/apps/web/test/map-interactions.test.tsx @@ -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"); + }); +}); diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 69a4c0f..19107a3 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -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. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 77748ff..de87f0a 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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.