Step 10.1: add PixiJS system map

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 07:06:55 -04:00
parent ff136377f4
commit 29290de478
15 changed files with 511 additions and 6 deletions

View File

@ -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.

View File

@ -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",

View File

@ -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 />,

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-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

View File

@ -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"
/>
);
}

View File

@ -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,
};
}

View File

@ -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,
};
}

36
apps/web/src/map/scene.ts Normal file
View File

@ -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),
};
}),
};
}

View File

@ -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;

View File

@ -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>
);
}

View File

@ -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" />;
}

View File

@ -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);
});
});

View File

@ -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"));

View File

@ -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

View File

@ -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: {}