Step 10.1: add PixiJS system map
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ff136377f4
commit
29290de478
10
DECISIONS.md
10
DECISIONS.md
|
|
@ -453,3 +453,13 @@ 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.
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
"@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",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, type ReactNode } from "react";
|
import { lazy, Suspense, 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,8 +15,16 @@ 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 />,
|
||||||
|
|
|
||||||
|
|
@ -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-6 border-t border-slate-800 bg-slate-950 pb-[env(safe-area-inset-bottom)]"
|
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)]"
|
||||||
>
|
>
|
||||||
{SCREENS.map((screen) => (
|
{SCREENS.map((screen) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
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 { buildScene } from "./scene";
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
background: 0x020617,
|
||||||
|
orbit: 0x1e293b,
|
||||||
|
sun: 0xfbbf24,
|
||||||
|
discovered: 0xe2e8f0,
|
||||||
|
undiscovered: 0x475569,
|
||||||
|
bay: 0x34d399,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The system map canvas (Step 10.1). Draws every frame from `state` and the
|
||||||
|
* server clock; drag to pan, pinch or wheel to zoom.
|
||||||
|
*/
|
||||||
|
export function MapCanvas({ state }: { state: PlayerState }) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const stateRef = useRef(state);
|
||||||
|
stateRef.current = state;
|
||||||
|
|
||||||
|
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 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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.
|
||||||
|
const pointers = new Map<number, { x: number; y: number }>();
|
||||||
|
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));
|
||||||
|
};
|
||||||
|
const onMove = (e: PointerEvent) => {
|
||||||
|
const previous = pointers.get(e.pointerId);
|
||||||
|
if (!previous) return;
|
||||||
|
const current = local(e);
|
||||||
|
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);
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { content } from "@deadhead/content";
|
||||||
|
import type { PlayerState } from "@deadhead/schema";
|
||||||
|
import { bodyPosition, 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Scene {
|
||||||
|
locations: SceneLocation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
return {
|
||||||
|
locations: content.locations.map((location) => {
|
||||||
|
const sun = bodyPosition(location.body, gameTime);
|
||||||
|
return {
|
||||||
|
id: location.id,
|
||||||
|
name: location.name,
|
||||||
|
point: locationMapPoint(location, gameTime),
|
||||||
|
orbitRadius: location.body === "moon" ? null : radialScale(Math.hypot(sun.x, sun.y)),
|
||||||
|
discovered: state.locations.some((l) => l.id === location.id && l.discovered),
|
||||||
|
hasBay: state.bays.some((b) => b.locationId === location.id),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
/** The app's top-level screens, in bottom-navigation order. */
|
/** The app's top-level screens, in bottom-navigation order. Labels are short to fit seven tabs. */
|
||||||
export const SCREENS = [
|
export const SCREENS = [
|
||||||
{ path: "/", title: "HQ", label: "HQ" },
|
{ path: "/", title: "HQ", label: "HQ" },
|
||||||
{ path: "/contracts", title: "Contracts", label: "Contracts" },
|
{ path: "/map", title: "Map", label: "Map" },
|
||||||
|
{ 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: "Market" },
|
{ path: "/market", title: "Market", label: "Shop" },
|
||||||
{ path: "/locations", title: "Locations", label: "Places" },
|
{ path: "/locations", title: "Locations", label: "Places" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { DEFAULT_CAMERA, useCamera } from "../map/camera";
|
||||||
|
import { MapCanvas } from "../map/MapCanvas";
|
||||||
|
import { useLoadedPlayer } from "../state/actions";
|
||||||
|
|
||||||
|
/** The solar system map (Phase 10). */
|
||||||
|
export function MapScreen() {
|
||||||
|
const state = useLoadedPlayer();
|
||||||
|
return (
|
||||||
|
<section className="relative h-[calc(100dvh-7.5rem)]">
|
||||||
|
<h1 className="sr-only">Map</h1>
|
||||||
|
<MapCanvas state={state} />
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import type { PlayerState } from "@deadhead/schema";
|
||||||
|
|
||||||
|
/** Test stand-in for the PixiJS map canvas. */
|
||||||
|
export function MapCanvas(_props: { state: PlayerState }) {
|
||||||
|
return <div data-testid="map-canvas" />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
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 } from "vitest";
|
import { afterEach, vi } 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"));
|
||||||
|
|
|
||||||
|
|
@ -95,3 +95,9 @@
|
||||||
- [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.
|
||||||
|
- [ ] Step 10.2 — Ships on the map
|
||||||
|
- [ ] Step 10.3 — Map interactions
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,9 @@ 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
|
||||||
|
|
@ -919,6 +922,9 @@ 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==}
|
||||||
|
|
||||||
|
|
@ -1170,6 +1176,9 @@ 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==}
|
||||||
|
|
||||||
|
|
@ -1299,6 +1308,13 @@ 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==}
|
||||||
|
|
||||||
|
|
@ -1606,6 +1622,9 @@ 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'}
|
||||||
|
|
@ -1687,6 +1706,9 @@ 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'}
|
||||||
|
|
@ -1755,6 +1777,9 @@ 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'}
|
||||||
|
|
@ -1814,6 +1839,9 @@ 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
|
||||||
|
|
@ -1821,6 +1849,9 @@ 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==}
|
||||||
|
|
||||||
|
|
@ -2085,6 +2116,9 @@ 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==}
|
||||||
|
|
||||||
|
|
@ -2154,6 +2188,9 @@ 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}
|
||||||
|
|
@ -2346,6 +2383,10 @@ 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'}
|
||||||
|
|
@ -3067,6 +3108,8 @@ 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':
|
||||||
|
|
@ -3239,6 +3282,8 @@ 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': {}
|
||||||
|
|
@ -3388,6 +3433,10 @@ 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):
|
||||||
|
|
@ -3563,6 +3612,8 @@ 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
|
||||||
|
|
@ -3731,6 +3782,8 @@ 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: {}
|
||||||
|
|
@ -3814,6 +3867,10 @@ 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
|
||||||
|
|
@ -3856,10 +3913,14 @@ 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: {}
|
||||||
|
|
@ -4077,6 +4138,8 @@ 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
|
||||||
|
|
@ -4154,6 +4217,19 @@ 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
|
||||||
|
|
@ -4317,6 +4393,8 @@ 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: {}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue