Compare commits

..

No commits in common. "a28fe5c5cbfb81a1565c7b61dab1ebc69e41921b" and "ff136377f4234f03306cd338d630c81c131bfc2d" have entirely different histories.

20 changed files with 7 additions and 1071 deletions

View File

@ -453,24 +453,3 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Two levels of network error:** if the first player-state load fails there is nothing to show, so the app shows an error with Retry. If a later refresh fails, the last state stays on screen under an "Offline — showing last update" banner with Retry, which serves every screen (Phase 11 builds on this for offline mode). Contract boards have their own error and Retry. - **Two levels of network error:** if the first player-state load fails there is nothing to show, so the app shows an error with Retry. If a later refresh fails, the last state stays on screen under an "Offline — showing last update" banner with Retry, which serves every screen (Phase 11 builds on this for offline mode). Contract boards have their own error and Retry.
- **Market and Locations have no empty state:** they list static content, which always exists. Their error handling is the app-level one. - **Market and Locations have no empty state:** they list static content, which always exists. Their error handling is the app-level one.
- **The placeholder screen is gone;** every route has a real screen. - **The placeholder screen is gone;** every route has a real screen.
## Step 10.1
- **PixiJS v8 used directly** (an `Application` created in an effect), not `@pixi/react`: the map is one imperative canvas redrawn each frame from player state and the server clock, which needs no React reconciliation. The effect copes with StrictMode double-mounting.
- **Radial scale is a square root of the distance from the Sun,** normalised so 1,000M km is the map edge. Order is preserved, and Jupiter (~780M km) sits about 2.3× Earth's map radius instead of 5×. Directions from the Sun are unchanged.
- **The Moon is drawn a fixed small offset from Earth** in its true direction; at map scale it would sit on top of Earth.
- **All map logic is plain, testable functions** (projection, camera, scene; hit-testing in Step 10.3). jsdom has no WebGL, so component tests replace `MapCanvas` with a test double (`test/setup.ts`).
- **Camera state (zoom, pan) is a Zustand store**, per the tech stack; zoom is clamped to 0.5–40×, and "Reset view" restores it.
- **The Map screen is lazy-loaded,** so PixiJS (~285 KB) downloads only when the map is opened.
- **Seven tabs:** a Map tab is added after HQ. To fit 375 px, the Contracts tab is labelled "Jobs" and Market "Shop"; screen titles are unchanged.
## Step 10.2
- **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

@ -14,7 +14,6 @@
"@deadhead/schema": "workspace:*", "@deadhead/schema": "workspace:*",
"@deadhead/sim": "workspace:*", "@deadhead/sim": "workspace:*",
"@tanstack/react-query": "^5.104.1", "@tanstack/react-query": "^5.104.1",
"pixi.js": "^8.22.0",
"react": "^19.3.0", "react": "^19.3.0",
"react-dom": "^19.3.0", "react-dom": "^19.3.0",
"react-router": "^8.4.0", "react-router": "^8.4.0",

View File

@ -1,4 +1,4 @@
import { lazy, Suspense, useEffect, type ReactNode } from "react"; import { useEffect, type ReactNode } from "react";
import { Route, Routes } from "react-router"; import { Route, Routes } from "react-router";
import { useApi } from "./api/context"; import { useApi } from "./api/context";
import { startClockTicker, syncClock } from "./clock/clock"; import { startClockTicker, syncClock } from "./clock/clock";
@ -15,16 +15,8 @@ import { Pilots } from "./screens/Pilots";
import { StartScreen } from "./screens/StartScreen"; import { StartScreen } from "./screens/StartScreen";
import { usePlayerState, useRefetchOnArrival } from "./state/player"; import { usePlayerState, useRefetchOnArrival } from "./state/player";
// The map pulls in PixiJS; load it only when the Map tab is opened.
const MapScreen = lazy(() => import("./screens/MapScreen").then((m) => ({ default: m.MapScreen })));
const SCREEN_COMPONENTS: Record<(typeof SCREENS)[number]["path"], ReactNode> = { const SCREEN_COMPONENTS: Record<(typeof SCREENS)[number]["path"], ReactNode> = {
"/": <HQ />, "/": <HQ />,
"/map": (
<Suspense fallback={<p className="p-4 text-slate-400">Loading map…</p>}>
<MapScreen />
</Suspense>
),
"/contracts": <Contracts />, "/contracts": <Contracts />,
"/fleet": <Fleet />, "/fleet": <Fleet />,
"/pilots": <Pilots />, "/pilots": <Pilots />,

View File

@ -32,7 +32,7 @@ export function Layout({ credits = "—", clock = <GameClock />, banner }: Layou
<nav <nav
aria-label="Main" aria-label="Main"
className="fixed inset-x-0 bottom-0 grid grid-cols-7 border-t border-slate-800 bg-slate-950 pb-[env(safe-area-inset-bottom)]" className="fixed inset-x-0 bottom-0 grid grid-cols-6 border-t border-slate-800 bg-slate-950 pb-[env(safe-area-inset-bottom)]"
> >
{SCREENS.map((screen) => ( {SCREENS.map((screen) => (
<NavLink <NavLink

View File

@ -1,205 +0,0 @@
import type { PlayerState } from "@deadhead/schema";
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 = {
background: 0x020617,
orbit: 0x1e293b,
sun: 0xfbbf24,
discovered: 0xe2e8f0,
undiscovered: 0x475569,
bay: 0x34d399,
route: 0x38bdf8,
ship: 0xfbbf24,
};
/**
* 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,
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;
if (!container) return;
let cancelled = false;
const app = new Application();
const graphics = new Graphics();
const labels = new Map<string, Text>();
const badges = new Map<string, Text>();
const viewport = (): Viewport => ({
width: container.clientWidth,
height: container.clientHeight,
});
const draw = () => {
const scene = buildScene(stateRef.current, serverNow());
const camera = useCamera.getState();
const view = viewport();
const sun = toScreen({ x: 0, y: 0 }, view, camera);
const unit = toScreen({ x: 1, y: 0 }, view, camera).x - sun.x;
graphics.clear();
for (const location of scene.locations) {
if (location.orbitRadius !== null) {
graphics
.circle(sun.x, sun.y, location.orbitRadius * unit)
.stroke({ width: 1, color: COLORS.orbit });
}
}
graphics.circle(sun.x, sun.y, 7).fill(COLORS.sun);
for (const location of scene.locations) {
const p = toScreen(location.point, view, camera);
const color = location.discovered ? COLORS.discovered : COLORS.undiscovered;
graphics.circle(p.x, p.y, 5).fill(color);
if (location.hasBay) graphics.circle(p.x, p.y, 9).stroke({ width: 2, color: COLORS.bay });
let label = labels.get(location.id);
if (!label) {
label = new Text({ text: location.name, style: { fontSize: 12, fill: color } });
labels.set(location.id, label);
app.stage.addChild(label);
}
label.text = location.discovered ? location.name : `${location.name}?`;
label.style.fill = color;
label.position.set(p.x + 8, p.y - 7);
// Idle ships here: a small badge with the count (Step 10.2).
let badge = badges.get(location.id);
if (!badge) {
badge = new Text({ text: "", style: { fontSize: 10, fill: COLORS.background } });
badge.anchor.set(0.5);
badges.set(location.id, badge);
app.stage.addChild(badge);
}
badge.visible = location.idleShips > 0;
if (badge.visible) {
graphics.circle(p.x - 10, p.y + 10, 7).fill(COLORS.ship);
badge.text = String(location.idleShips);
badge.position.set(p.x - 10, p.y + 10);
}
}
// In-flight ships: the route line and a marker moving along it (Step 10.2).
for (const flight of scene.flights) {
const from = toScreen(flight.from, view, camera);
const to = toScreen(flight.to, view, camera);
const marker = toScreen(flight.marker, view, camera);
graphics
.moveTo(from.x, from.y)
.lineTo(to.x, to.y)
.stroke({ width: 1, color: COLORS.route });
graphics.circle(marker.x, marker.y, 4).fill(COLORS.ship);
}
};
void app
.init({
resizeTo: container,
background: COLORS.background,
antialias: true,
autoDensity: true,
resolution: window.devicePixelRatio,
})
.then(() => {
if (cancelled) {
app.destroy(true);
return;
}
container.appendChild(app.canvas);
app.stage.addChild(graphics);
app.ticker.add(draw);
})
.catch(() => {
// 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. 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 };
};
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) {
const other = [...pointers.entries()].find(([id]) => id !== e.pointerId)![1];
const before = Math.hypot(previous.x - other.x, previous.y - other.y);
const after = Math.hypot(current.x - other.x, current.y - other.y);
const mid = { x: (current.x + other.x) / 2, y: (current.y + other.y) / 2 };
if (before > 0) useCamera.setState((c) => zoomAt(c, after / before, mid, viewport()));
}
pointers.set(e.pointerId, current);
};
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()));
};
container.addEventListener("pointerdown", onDown);
container.addEventListener("pointermove", onMove);
container.addEventListener("pointerup", onUp);
container.addEventListener("pointercancel", onUp);
container.addEventListener("wheel", onWheel, { passive: false });
return () => {
cancelled = true;
container.removeEventListener("pointerdown", onDown);
container.removeEventListener("pointermove", onMove);
container.removeEventListener("pointerup", onUp);
container.removeEventListener("pointercancel", onUp);
container.removeEventListener("wheel", onWheel);
if (app.renderer) app.destroy(true, { children: true });
};
}, []);
return (
<div
ref={containerRef}
data-testid="map-canvas"
className="relative h-full w-full touch-none overflow-hidden"
/>
);
}

View File

@ -1,58 +0,0 @@
import { create } from "zustand";
import type { MapPoint } from "./projection";
/** Pan (pixels) and zoom (1 = whole system fits) of the map view. */
export interface Camera {
zoom: number;
panX: number;
panY: number;
}
export interface Viewport {
width: number;
height: number;
}
export const MIN_ZOOM = 0.5;
export const MAX_ZOOM = 40;
/** Leave a little room around the map edge at zoom 1. */
const FIT = 0.92;
export const DEFAULT_CAMERA: Camera = { zoom: 1, panX: 0, panY: 0 };
export const useCamera = create<Camera>(() => DEFAULT_CAMERA);
/** Pixels per map unit at this zoom. */
function pixelsPerUnit(viewport: Viewport, camera: Camera): number {
return (Math.min(viewport.width, viewport.height) / 2) * FIT * camera.zoom;
}
export function toScreen(point: MapPoint, viewport: Viewport, camera: Camera): MapPoint {
const k = pixelsPerUnit(viewport, camera);
return {
x: viewport.width / 2 + camera.panX + point.x * k,
y: viewport.height / 2 + camera.panY + point.y * k,
};
}
export function pan(camera: Camera, dx: number, dy: number): Camera {
return { ...camera, panX: camera.panX + dx, panY: camera.panY + dy };
}
/** Zooms by `factor`, keeping the map point under `anchor` (screen pixels) in place. */
export function zoomAt(
camera: Camera,
factor: number,
anchor: MapPoint,
viewport: Viewport,
): Camera {
const zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, camera.zoom * factor));
const ratio = zoom / camera.zoom;
const cx = viewport.width / 2;
const cy = viewport.height / 2;
return {
zoom,
panX: anchor.x - cx - (anchor.x - cx - camera.panX) * ratio,
panY: anchor.y - cy - (anchor.y - cy - camera.panY) * ratio,
};
}

View File

@ -1,36 +0,0 @@
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,53 +0,0 @@
import type { Location } from "@deadhead/content";
import { bodyPosition, type GameTime, type Position } from "@deadhead/sim";
/**
* Map coordinates: the Sun at (0, 0), the edge of the map at radius 1, y down.
* Real distances are compressed so the inner planets and Jupiter fit together.
*/
export interface MapPoint {
x: number;
y: number;
}
/** Heliocentric radius (million km) shown at the edge of the map. */
export const MAP_EDGE_MILLION_KM = 1000;
/** Moons are drawn this far from their planet (map units), whatever the real distance. */
export const MOON_MAP_OFFSET = 0.04;
/**
* Compressed radial scale: real distance from the Sun (million km) to map
* radius. Square root keeps the order (farther stays farther) while bringing
* Jupiter (~780M km) within a few times Earth's (~150M km) distance.
*/
export function radialScale(millionKm: number): number {
return Math.sqrt(Math.max(0, millionKm) / MAP_EDGE_MILLION_KM);
}
/** A heliocentric position on the map: same angle, compressed radius. */
export function toMapPoint(position: Position): MapPoint {
const r = Math.hypot(position.x, position.y);
if (r === 0) return { x: 0, y: 0 };
const k = radialScale(r) / r;
// Screens grow downwards; flip y so orbits run counter-clockwise as seen from above.
return { x: position.x * k, y: -position.y * k };
}
/**
* Where a location is drawn. The Moon sits a fixed small distance from Earth
* in its true direction, since at map scale it would overlap Earth.
*/
export function locationMapPoint(location: Location, gameTime: GameTime): MapPoint {
if (location.body !== "moon") return toMapPoint(bodyPosition(location.body, gameTime));
const earth = bodyPosition("earth", gameTime);
const moon = bodyPosition("moon", gameTime);
const dx = moon.x - earth.x;
const dy = -(moon.y - earth.y);
const length = Math.hypot(dx, dy) || 1;
const base = toMapPoint(earth);
return {
x: base.x + (dx / length) * MOON_MAP_OFFSET,
y: base.y + (dy / length) * MOON_MAP_OFFSET,
};
}

View File

@ -1,90 +0,0 @@
import { content } from "@deadhead/content";
import type { Dispatch, PlayerState } from "@deadhead/schema";
import { bodyPosition, dispatchProgress, realToGame } from "@deadhead/sim";
import { locationMapPoint, radialScale, type MapPoint } from "./projection";
export interface SceneLocation {
id: string;
name: string;
point: MapPoint;
/** Map radius of its orbit around the Sun (the faint ring); null for moons. */
orbitRadius: number | null;
discovered: boolean;
hasBay: boolean;
/** Idle ships here (the tutorial loaner included), shown as a badge. */
idleShips: number;
}
export interface SceneFlight {
dispatchId: string;
/** The ship's id, or "loaner" for a tutorial run. */
shipId: string;
/** Origin at departure and destination at arrival: the route line. */
from: MapPoint;
to: MapPoint;
/** Where the ship is now, on the route line. */
marker: MapPoint;
progress: number;
}
export interface Scene {
locations: SceneLocation[];
flights: SceneFlight[];
}
function location(id: string) {
const found = content.location(id);
if (!found) throw new Error(`Unknown location ${id}`);
return found;
}
/**
* A flight's route and current marker (Step 10.2). The marker moves along the
* straight route line by the sim's progress, in map space, so it always stays
* on the line however the map is scaled.
*/
export function flightMarker(dispatch: Dispatch, now: number): SceneFlight {
const from = locationMapPoint(
location(dispatch.originId),
realToGame(dispatch.departureAt, content.config),
);
const to = locationMapPoint(
location(dispatch.destinationId),
realToGame(dispatch.arrivalAt, content.config),
);
const progress = dispatchProgress(dispatch, now);
return {
dispatchId: dispatch.id,
shipId: dispatch.shipId,
from,
to,
marker: { x: from.x + (to.x - from.x) * progress, y: from.y + (to.y - from.y) * progress },
progress,
};
}
/** Everything the map draws at real time `now`, in map coordinates. */
export function buildScene(state: PlayerState, now: number): Scene {
const gameTime = realToGame(now, content.config);
const idleAt = (locationId: string) =>
state.ships.filter((s) => s.status === "idle" && s.locationId === locationId).length +
(state.tutorial && !state.tutorial.runInFlight && state.tutorial.loanerLocationId === locationId
? 1
: 0);
return {
locations: content.locations.map((loc) => {
const sun = bodyPosition(loc.body, gameTime);
return {
id: loc.id,
name: loc.name,
point: locationMapPoint(loc, gameTime),
orbitRadius: loc.body === "moon" ? null : radialScale(Math.hypot(sun.x, sun.y)),
discovered: state.locations.some((l) => l.id === loc.id && l.discovered),
hasBay: state.bays.some((b) => b.locationId === loc.id),
idleShips: idleAt(loc.id),
};
}),
flights: state.activeDispatches.map((d) => flightMarker(d, now)),
};
}

View File

@ -1,10 +1,9 @@
/** The app's top-level screens, in bottom-navigation order. Labels are short to fit seven tabs. */ /** The app's top-level screens, in bottom-navigation order. */
export const SCREENS = [ export const SCREENS = [
{ path: "/", title: "HQ", label: "HQ" }, { path: "/", title: "HQ", label: "HQ" },
{ path: "/map", title: "Map", label: "Map" }, { path: "/contracts", title: "Contracts", label: "Contracts" },
{ path: "/contracts", title: "Contracts", label: "Jobs" },
{ path: "/fleet", title: "Fleet", label: "Fleet" }, { path: "/fleet", title: "Fleet", label: "Fleet" },
{ path: "/pilots", title: "Pilots", label: "Pilots" }, { path: "/pilots", title: "Pilots", label: "Pilots" },
{ path: "/market", title: "Market", label: "Shop" }, { path: "/market", title: "Market", label: "Market" },
{ path: "/locations", title: "Locations", label: "Places" }, { path: "/locations", title: "Locations", label: "Places" },
] as const; ] as const;

View File

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

View File

@ -1,136 +0,0 @@
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";
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} onSelect={setSelection} />
<button
type="button"
onClick={() => useCamera.setState(DEFAULT_CAMERA)}
className="absolute right-3 top-3 rounded-md bg-slate-900/80 px-2 py-1 text-xs text-slate-300"
>
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,38 +0,0 @@
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: 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

@ -1,155 +0,0 @@
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

@ -1,74 +0,0 @@
import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import { DEFAULT_CAMERA, pan, toScreen, zoomAt } from "../src/map/camera";
import { locationMapPoint, radialScale, toMapPoint } from "../src/map/projection";
describe("radial scale", () => {
it("is monotonic: farther in reality is farther on screen", () => {
let previous = radialScale(0);
for (let km = 0.5; km <= 1200; km += 0.5) {
const next = radialScale(km);
expect(next).toBeGreaterThan(previous);
previous = next;
}
});
it("is compressed: Jupiter is much less than 5× Earth's distance on screen", () => {
expect(radialScale(780) / radialScale(150)).toBeLessThan(2.5);
expect(radialScale(1000)).toBe(1);
});
it("keeps a position's direction from the Sun", () => {
const p = toMapPoint({ x: 300, y: 400 });
expect(Math.atan2(-p.y, p.x)).toBeCloseTo(Math.atan2(400, 300), 10);
expect(Math.hypot(p.x, p.y)).toBeCloseTo(radialScale(500), 10);
});
});
describe("map fits a phone", () => {
// A 375 px wide phone, minus header and tab bar.
const viewport = { width: 375, height: 690 };
const start = Date.parse("2100-01-01T00:00:00Z");
it("shows all five locations on screen at default zoom, across 12 years", () => {
for (let month = 0; month < 12 * 12; month++) {
const gameTime = start + month * 30 * 86_400_000;
for (const location of content.locations) {
const p = toScreen(locationMapPoint(location, gameTime), viewport, DEFAULT_CAMERA);
expect(p.x).toBeGreaterThanOrEqual(0);
expect(p.x).toBeLessThanOrEqual(viewport.width);
expect(p.y).toBeGreaterThanOrEqual(0);
expect(p.y).toBeLessThanOrEqual(viewport.height);
}
}
});
it("draws the Moon apart from Earth", () => {
const earth = locationMapPoint(content.location("earth")!, start);
const moon = locationMapPoint(content.location("moon")!, start);
const a = toScreen(earth, viewport, DEFAULT_CAMERA);
const b = toScreen(moon, viewport, DEFAULT_CAMERA);
expect(Math.hypot(a.x - b.x, a.y - b.y)).toBeGreaterThan(5);
});
});
describe("camera", () => {
const viewport = { width: 400, height: 600 };
it("keeps the map point under the fingers fixed while zooming", () => {
const anchor = { x: 300, y: 150 };
const camera = pan(DEFAULT_CAMERA, 20, -40);
const point = { x: 0.37, y: -0.21 };
const before = toScreen(point, viewport, camera);
const zoomed = zoomAt(camera, 3, anchor, viewport);
const after = toScreen(point, viewport, zoomed);
// Distance from the anchor scales by the zoom factor; the anchor itself stays put.
expect(after.x - anchor.x).toBeCloseTo((before.x - anchor.x) * 3, 6);
expect(after.y - anchor.y).toBeCloseTo((before.y - anchor.y) * 3, 6);
});
it("clamps zoom", () => {
expect(zoomAt(DEFAULT_CAMERA, 1000, { x: 0, y: 0 }, viewport).zoom).toBe(40);
expect(zoomAt(DEFAULT_CAMERA, 0.0001, { x: 0, y: 0 }, viewport).zoom).toBe(0.5);
});
});

View File

@ -1,96 +0,0 @@
import type { Dispatch } from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import { DEFAULT_CAMERA, toScreen, zoomAt } from "../src/map/camera";
import { buildScene, flightMarker } from "../src/map/scene";
import { samplePlayerState } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
const flight: Dispatch = {
id: "d1",
shipId: "s1",
pilotId: "p1",
contract: null,
originId: "earth",
destinationId: "mars",
tutorial: false,
departureAt: NOW - 3_600_000,
arrivalAt: NOW + 3_600_000,
distanceMillionKm: 150,
fuelCost: 0,
expectedPayout: 0,
seed: 1,
status: "in-flight",
resolvedAt: null,
};
describe("ship markers", () => {
it("puts a ship at 50% progress on the route line between the endpoints, on screen", () => {
const viewport = { width: 375, height: 690 };
const camera = zoomAt(DEFAULT_CAMERA, 2.5, { x: 100, y: 200 }, viewport);
const scene = flightMarker(flight, NOW);
expect(scene.progress).toBe(0.5);
const a = toScreen(scene.from, viewport, camera);
const b = toScreen(scene.to, viewport, camera);
const m = toScreen(scene.marker, viewport, camera);
// On the line: the cross product of (b - a) and (m - a) is zero.
const cross = (b.x - a.x) * (m.y - a.y) - (b.y - a.y) * (m.x - a.x);
expect(Math.abs(cross)).toBeLessThan(1e-6);
// Between the endpoints, halfway along.
expect(Math.hypot(m.x - a.x, m.y - a.y)).toBeCloseTo(Math.hypot(b.x - a.x, b.y - a.y) / 2, 6);
});
it("starts at the origin and ends at the destination", () => {
expect(flightMarker(flight, flight.departureAt).marker).toEqual(flightMarker(flight, NOW).from);
const end = flightMarker(flight, flight.arrivalAt + 60_000);
expect(end.marker.x).toBeCloseTo(end.to.x, 12);
expect(end.marker.y).toBeCloseTo(end.to.y, 12);
});
it("builds one flight per active dispatch and badges idle ships at their location", () => {
const state = samplePlayerState({
tutorial: null,
activeDispatches: [flight],
ships: [
{
id: "s1",
modelId: "courier",
name: "A",
status: "in-flight",
locationId: null,
dispatchId: "d1",
pilotId: "p1",
},
{
id: "s2",
modelId: "courier",
name: "B",
status: "idle",
locationId: "moon",
dispatchId: null,
pilotId: null,
},
{
id: "s3",
modelId: "courier",
name: "C",
status: "idle",
locationId: "moon",
dispatchId: null,
pilotId: null,
},
],
});
const scene = buildScene(state, NOW);
expect(scene.flights.map((f) => f.dispatchId)).toEqual(["d1"]);
expect(scene.locations.find((l) => l.id === "moon")!.idleShips).toBe(2);
expect(scene.locations.find((l) => l.id === "earth")!.idleShips).toBe(0);
});
it("counts the idle tutorial loaner at its location", () => {
const scene = buildScene(samplePlayerState(), NOW);
expect(scene.locations.find((l) => l.id === "earth")!.idleShips).toBe(1);
});
});

View File

@ -1,9 +1,5 @@
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react"; import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest"; import { afterEach } from "vitest";
afterEach(() => cleanup()); afterEach(() => cleanup());
// PixiJS needs WebGL, which jsdom lacks. Component tests use a stand-in canvas;
// the map's logic (projection, camera, scene, hit-testing) is unit-tested directly.
vi.mock("../src/map/MapCanvas", () => import("./map-canvas-double"));

View File

@ -421,7 +421,6 @@ 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/screens/*` | HQ, Contracts (TutorialBoard / BayBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen |
| `src/components/*` | `AwaySummary` dialog, `Countdown` | | `src/components/*` | `AwaySummary` dialog, `Countdown` |
| `src/state/actions.ts` | `useLoadedPlayer`, `useGameAction(route)` (mutation + invalidate state and boards) | | `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 | | `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. Session: Better Auth's cookie (`credentials: "include"`); the client stores nothing itself.

View File

@ -95,9 +95,3 @@
- [x] **Step 9.8 — Market screen.** `src/screens/Market.tsx`: ship models with speed, capacity, range, landing class and price; Buy disabled when unaffordable; a bought ship shows a confirmation and appears in Fleet after the state refresh. - [x] **Step 9.8 — Market screen.** `src/screens/Market.tsx`: ship models with speed, capacity, range, landing class and price; Buy disabled when unaffordable; a bought ship shows a confirmation and appears in Fleet after the state refresh.
- [x] **Step 9.9 — Locations screen.** `src/screens/Locations.tsx`: discovered locations show type, landing need, current distance from Earth, produces/demands and "Lease bay (price)" or "Bay owned"; undiscovered ones show only the name and "Undiscovered — deliver here to unlock." - [x] **Step 9.9 — Locations screen.** `src/screens/Locations.tsx`: discovered locations show type, landing need, current distance from Earth, produces/demands and "Lease bay (price)" or "Bay owned"; undiscovered ones show only the name and "Undiscovered — deliver here to unlock."
- [x] **Step 9.10 — Empty and error states.** First-load failure shows "Could not reach the server" with Retry; a failed refresh keeps the last state with an "Offline — showing last update" banner and Retry; boards have their own Retry. Empty states on HQ, Fleet, Contracts (no bays, empty bay/tutorial board) and Pilots. Tests cover every screen. - [x] **Step 9.10 — Empty and error states.** First-load failure shows "Could not reach the server" with Retry; a failed refresh keeps the last state with an "Offline — showing last update" banner and Retry; boards have their own Retry. Empty states on HQ, Fleet, Contracts (no bays, empty bay/tutorial board) and Pilots. Tests cover every screen.
## Phase 10 — Simple System Map
- [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.
- [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.

View File

@ -93,9 +93,6 @@ importers:
'@tanstack/react-query': '@tanstack/react-query':
specifier: ^5.104.1 specifier: ^5.104.1
version: 5.104.1(react@19.3.0) version: 5.104.1(react@19.3.0)
pixi.js:
specifier: ^8.22.0
version: 8.22.0
react: react:
specifier: ^19.3.0 specifier: ^19.3.0
version: 19.3.0 version: 19.3.0
@ -922,9 +919,6 @@ packages:
'@pinojs/redact@0.4.0': '@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
'@pixi/colord@2.9.6':
resolution: {integrity: sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==}
'@remix-run/route-pattern@0.22.1': '@remix-run/route-pattern@0.22.1':
resolution: {integrity: sha512-czdGJWh09Lapvcqt7Vb09KlrU2PhRJ8xQaI+AItTuD9aDJ5MAgxnS38lnys6Ll+6RJEqPiaUqTwIQhVLwFvv+w==} resolution: {integrity: sha512-czdGJWh09Lapvcqt7Vb09KlrU2PhRJ8xQaI+AItTuD9aDJ5MAgxnS38lnys6Ll+6RJEqPiaUqTwIQhVLwFvv+w==}
@ -1176,9 +1170,6 @@ packages:
'@types/deep-eql@4.0.2': '@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/earcut@3.0.0':
resolution: {integrity: sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==}
'@types/esrecurse@4.3.1': '@types/esrecurse@4.3.1':
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
@ -1308,13 +1299,6 @@ packages:
'@vitest/spy@5.0.3': '@vitest/spy@5.0.3':
resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==} resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==}
'@webgpu/types@0.1.74':
resolution: {integrity: sha512-lgiI4hbuLcI9unnm2cL/tvCaQU45dp0xcLWJh5uB/9MBGvIA4F8XIk7nSiBeg+K4xWGYwgZKn80LmERI5CxoTA==}
'@xmldom/xmldom@0.8.15':
resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==}
engines: {node: '>=10.0.0'}
abstract-logging@2.0.1: abstract-logging@2.0.1:
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
@ -1622,9 +1606,6 @@ packages:
sqlite3: sqlite3:
optional: true optional: true
earcut@3.2.4:
resolution: {integrity: sha512-FHc6yNpnTORB0tM5kmZ03iD2mDuNakDE2lvf4Hw1XkfxtEHIGVi01B/GqUSwx+NcC1WGjidAfpTXzeALtPtfpg==}
enhanced-resolve@5.26.0: enhanced-resolve@5.26.0:
resolution: {integrity: sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==} resolution: {integrity: sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
@ -1706,9 +1687,6 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
expect-type@1.4.0: expect-type@1.4.0:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@ -1777,9 +1755,6 @@ packages:
get-tsconfig@4.14.3: get-tsconfig@4.14.3:
resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==}
gifuct-js@2.1.2:
resolution: {integrity: sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==}
glob-parent@6.0.2: glob-parent@6.0.2:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
@ -1839,9 +1814,6 @@ packages:
isexe@2.0.0: isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
ismobilejs@1.1.1:
resolution: {integrity: sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==}
jiti@2.7.0: jiti@2.7.0:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true hasBin: true
@ -1849,9 +1821,6 @@ packages:
jose@6.2.12: jose@6.2.12:
resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==}
js-binary-schema-parser@2.0.3:
resolution: {integrity: sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==}
js-tokens@10.0.0: js-tokens@10.0.0:
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
@ -2116,9 +2085,6 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'} engines: {node: '>=10'}
parse-svg-path@0.2.0:
resolution: {integrity: sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg==}
parse5@8.0.1: parse5@8.0.1:
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
@ -2188,9 +2154,6 @@ packages:
resolution: {integrity: sha512-bk1ZMTwG/Vymx+zPoa28MhNzucFvXJWp3csrSuxuHPSMeUettaCoQ5Jvg07mtC4JGNpcfTwz41oVCnNgBK9+vA==} resolution: {integrity: sha512-bk1ZMTwG/Vymx+zPoa28MhNzucFvXJWp3csrSuxuHPSMeUettaCoQ5Jvg07mtC4JGNpcfTwz41oVCnNgBK9+vA==}
hasBin: true hasBin: true
pixi.js@8.22.0:
resolution: {integrity: sha512-QbTANHMZ751MIjLiLSUSM8a7LNMWwMIf0KbRCqlW+SPS9hNLfSVS+cl1O2SEp9g1K/wyLzj/OJHv+1TUfiPr/w==}
postcss@8.5.28: postcss@8.5.28:
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
@ -2383,10 +2346,6 @@ packages:
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
engines: {node: '>=20'} engines: {node: '>=20'}
tiny-lru@11.4.7:
resolution: {integrity: sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==}
engines: {node: '>=12'}
tinybench@6.2.0: tinybench@6.2.0:
resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==} resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@ -3108,8 +3067,6 @@ snapshots:
'@pinojs/redact@0.4.0': {} '@pinojs/redact@0.4.0': {}
'@pixi/colord@2.9.6': {}
'@remix-run/route-pattern@0.22.1': {} '@remix-run/route-pattern@0.22.1': {}
'@rolldown/binding-android-arm-eabi@1.2.12': '@rolldown/binding-android-arm-eabi@1.2.12':
@ -3282,8 +3239,6 @@ snapshots:
'@types/deep-eql@4.0.2': {} '@types/deep-eql@4.0.2': {}
'@types/earcut@3.0.0': {}
'@types/esrecurse@4.3.1': {} '@types/esrecurse@4.3.1': {}
'@types/estree@1.0.9': {} '@types/estree@1.0.9': {}
@ -3433,10 +3388,6 @@ snapshots:
'@vitest/spy@5.0.3': {} '@vitest/spy@5.0.3': {}
'@webgpu/types@0.1.74': {}
'@xmldom/xmldom@0.8.15': {}
abstract-logging@2.0.1: {} abstract-logging@2.0.1: {}
acorn-jsx@5.3.2(acorn@8.18.0): acorn-jsx@5.3.2(acorn@8.18.0):
@ -3612,8 +3563,6 @@ snapshots:
kysely: 0.29.6 kysely: 0.29.6
pg: 8.23.1 pg: 8.23.1
earcut@3.2.4: {}
enhanced-resolve@5.26.0: enhanced-resolve@5.26.0:
dependencies: dependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
@ -3782,8 +3731,6 @@ snapshots:
esutils@2.0.3: {} esutils@2.0.3: {}
eventemitter3@5.0.4: {}
expect-type@1.4.0: {} expect-type@1.4.0: {}
fast-decode-uri-component@1.0.1: {} fast-decode-uri-component@1.0.1: {}
@ -3867,10 +3814,6 @@ snapshots:
dependencies: dependencies:
resolve-pkg-maps: 1.0.0 resolve-pkg-maps: 1.0.0
gifuct-js@2.1.2:
dependencies:
js-binary-schema-parser: 2.0.3
glob-parent@6.0.2: glob-parent@6.0.2:
dependencies: dependencies:
is-glob: 4.0.3 is-glob: 4.0.3
@ -3913,14 +3856,10 @@ snapshots:
isexe@2.0.0: {} isexe@2.0.0: {}
ismobilejs@1.1.1: {}
jiti@2.7.0: {} jiti@2.7.0: {}
jose@6.2.12: {} jose@6.2.12: {}
js-binary-schema-parser@2.0.3: {}
js-tokens@10.0.0: {} js-tokens@10.0.0: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
@ -4138,8 +4077,6 @@ snapshots:
dependencies: dependencies:
p-limit: 3.1.0 p-limit: 3.1.0
parse-svg-path@0.2.0: {}
parse5@8.0.1: parse5@8.0.1:
dependencies: dependencies:
entities: 8.1.0 entities: 8.1.0
@ -4217,19 +4154,6 @@ snapshots:
sonic-boom: 4.2.1 sonic-boom: 4.2.1
thread-stream: 4.2.0 thread-stream: 4.2.0
pixi.js@8.22.0:
dependencies:
'@pixi/colord': 2.9.6
'@types/earcut': 3.0.0
'@webgpu/types': 0.1.74
'@xmldom/xmldom': 0.8.15
earcut: 3.2.4
eventemitter3: 5.0.4
gifuct-js: 2.1.2
ismobilejs: 1.1.1
parse-svg-path: 0.2.0
tiny-lru: 11.4.7
postcss@8.5.28: postcss@8.5.28:
dependencies: dependencies:
nanoid: 3.3.19 nanoid: 3.3.19
@ -4393,8 +4317,6 @@ snapshots:
dependencies: dependencies:
real-require: 1.0.0 real-require: 1.0.0
tiny-lru@11.4.7: {}
tinybench@6.2.0: {} tinybench@6.2.0: {}
tinyexec@1.3.1: {} tinyexec@1.3.1: {}