diff --git a/DECISIONS.md b/DECISIONS.md
index 45dc514..923857a 100644
--- a/DECISIONS.md
+++ b/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.
- **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.
+
+## 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.
diff --git a/apps/web/package.json b/apps/web/package.json
index 83f6451..642e29b 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -14,6 +14,7 @@
"@deadhead/schema": "workspace:*",
"@deadhead/sim": "workspace:*",
"@tanstack/react-query": "^5.104.1",
+ "pixi.js": "^8.22.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-router": "^8.4.0",
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index b82dc9e..89dc96d 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -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 { useApi } from "./api/context";
import { startClockTicker, syncClock } from "./clock/clock";
@@ -15,8 +15,16 @@ import { Pilots } from "./screens/Pilots";
import { StartScreen } from "./screens/StartScreen";
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> = {
"/": ,
+ "/map": (
+ Loading map…
}>
+
+
+ ),
"/contracts": ,
"/fleet": ,
"/pilots": ,
diff --git a/apps/web/src/layout/Layout.tsx b/apps/web/src/layout/Layout.tsx
index a21e1cc..1b3acc4 100644
--- a/apps/web/src/layout/Layout.tsx
+++ b/apps/web/src/layout/Layout.tsx
@@ -32,7 +32,7 @@ export function Layout({ credits = "—", clock = , banner }: Layou
{SCREENS.map((screen) => (
(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();
+
+ 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();
+ 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 (
+
+ );
+}
diff --git a/apps/web/src/map/camera.ts b/apps/web/src/map/camera.ts
new file mode 100644
index 0000000..6fc552d
--- /dev/null
+++ b/apps/web/src/map/camera.ts
@@ -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(() => 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,
+ };
+}
diff --git a/apps/web/src/map/projection.ts b/apps/web/src/map/projection.ts
new file mode 100644
index 0000000..23abb57
--- /dev/null
+++ b/apps/web/src/map/projection.ts
@@ -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,
+ };
+}
diff --git a/apps/web/src/map/scene.ts b/apps/web/src/map/scene.ts
new file mode 100644
index 0000000..93d676d
--- /dev/null
+++ b/apps/web/src/map/scene.ts
@@ -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),
+ };
+ }),
+ };
+}
diff --git a/apps/web/src/routes.ts b/apps/web/src/routes.ts
index d4aaf9b..3183960 100644
--- a/apps/web/src/routes.ts
+++ b/apps/web/src/routes.ts
@@ -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 = [
{ 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: "/pilots", title: "Pilots", label: "Pilots" },
- { path: "/market", title: "Market", label: "Market" },
+ { path: "/market", title: "Market", label: "Shop" },
{ path: "/locations", title: "Locations", label: "Places" },
] as const;
diff --git a/apps/web/src/screens/MapScreen.tsx b/apps/web/src/screens/MapScreen.tsx
new file mode 100644
index 0000000..068c385
--- /dev/null
+++ b/apps/web/src/screens/MapScreen.tsx
@@ -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 (
+
+ Map
+
+ 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
+
+
+ );
+}
diff --git a/apps/web/test/map-canvas-double.tsx b/apps/web/test/map-canvas-double.tsx
new file mode 100644
index 0000000..84830fb
--- /dev/null
+++ b/apps/web/test/map-canvas-double.tsx
@@ -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
;
+}
diff --git a/apps/web/test/map-projection.test.ts b/apps/web/test/map-projection.test.ts
new file mode 100644
index 0000000..90e54e8
--- /dev/null
+++ b/apps/web/test/map-projection.test.ts
@@ -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);
+ });
+});
diff --git a/apps/web/test/setup.ts b/apps/web/test/setup.ts
index 0ecbed3..b3ce3ea 100644
--- a/apps/web/test/setup.ts
+++ b/apps/web/test/setup.ts
@@ -1,5 +1,9 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
-import { afterEach } from "vitest";
+import { afterEach, vi } from "vitest";
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"));
diff --git a/memory-bank/progress.md b/memory-bank/progress.md
index dece1c3..3baf09d 100644
--- a/memory-bank/progress.md
+++ b/memory-bank/progress.md
@@ -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.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.
+
+## 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
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 120c767..f635f7f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -93,6 +93,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.104.1
version: 5.104.1(react@19.3.0)
+ pixi.js:
+ specifier: ^8.22.0
+ version: 8.22.0
react:
specifier: ^19.3.0
version: 19.3.0
@@ -919,6 +922,9 @@ packages:
'@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
+ '@pixi/colord@2.9.6':
+ resolution: {integrity: sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==}
+
'@remix-run/route-pattern@0.22.1':
resolution: {integrity: sha512-czdGJWh09Lapvcqt7Vb09KlrU2PhRJ8xQaI+AItTuD9aDJ5MAgxnS38lnys6Ll+6RJEqPiaUqTwIQhVLwFvv+w==}
@@ -1170,6 +1176,9 @@ packages:
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+ '@types/earcut@3.0.0':
+ resolution: {integrity: sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==}
+
'@types/esrecurse@4.3.1':
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
@@ -1299,6 +1308,13 @@ packages:
'@vitest/spy@5.0.3':
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:
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
@@ -1606,6 +1622,9 @@ packages:
sqlite3:
optional: true
+ earcut@3.2.4:
+ resolution: {integrity: sha512-FHc6yNpnTORB0tM5kmZ03iD2mDuNakDE2lvf4Hw1XkfxtEHIGVi01B/GqUSwx+NcC1WGjidAfpTXzeALtPtfpg==}
+
enhanced-resolve@5.26.0:
resolution: {integrity: sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==}
engines: {node: '>=10.13.0'}
@@ -1687,6 +1706,9 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
+ eventemitter3@5.0.4:
+ resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
+
expect-type@1.4.0:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'}
@@ -1755,6 +1777,9 @@ packages:
get-tsconfig@4.14.3:
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:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
@@ -1814,6 +1839,9 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+ ismobilejs@1.1.1:
+ resolution: {integrity: sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==}
+
jiti@2.7.0:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
@@ -1821,6 +1849,9 @@ packages:
jose@6.2.12:
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:
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
@@ -2085,6 +2116,9 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
+ parse-svg-path@0.2.0:
+ resolution: {integrity: sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg==}
+
parse5@8.0.1:
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
@@ -2154,6 +2188,9 @@ packages:
resolution: {integrity: sha512-bk1ZMTwG/Vymx+zPoa28MhNzucFvXJWp3csrSuxuHPSMeUettaCoQ5Jvg07mtC4JGNpcfTwz41oVCnNgBK9+vA==}
hasBin: true
+ pixi.js@8.22.0:
+ resolution: {integrity: sha512-QbTANHMZ751MIjLiLSUSM8a7LNMWwMIf0KbRCqlW+SPS9hNLfSVS+cl1O2SEp9g1K/wyLzj/OJHv+1TUfiPr/w==}
+
postcss@8.5.28:
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
engines: {node: ^10 || ^12 || >=14}
@@ -2346,6 +2383,10 @@ packages:
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
engines: {node: '>=20'}
+ tiny-lru@11.4.7:
+ resolution: {integrity: sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==}
+ engines: {node: '>=12'}
+
tinybench@6.2.0:
resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==}
engines: {node: '>=20.0.0'}
@@ -3067,6 +3108,8 @@ snapshots:
'@pinojs/redact@0.4.0': {}
+ '@pixi/colord@2.9.6': {}
+
'@remix-run/route-pattern@0.22.1': {}
'@rolldown/binding-android-arm-eabi@1.2.12':
@@ -3239,6 +3282,8 @@ snapshots:
'@types/deep-eql@4.0.2': {}
+ '@types/earcut@3.0.0': {}
+
'@types/esrecurse@4.3.1': {}
'@types/estree@1.0.9': {}
@@ -3388,6 +3433,10 @@ snapshots:
'@vitest/spy@5.0.3': {}
+ '@webgpu/types@0.1.74': {}
+
+ '@xmldom/xmldom@0.8.15': {}
+
abstract-logging@2.0.1: {}
acorn-jsx@5.3.2(acorn@8.18.0):
@@ -3563,6 +3612,8 @@ snapshots:
kysely: 0.29.6
pg: 8.23.1
+ earcut@3.2.4: {}
+
enhanced-resolve@5.26.0:
dependencies:
graceful-fs: 4.2.11
@@ -3731,6 +3782,8 @@ snapshots:
esutils@2.0.3: {}
+ eventemitter3@5.0.4: {}
+
expect-type@1.4.0: {}
fast-decode-uri-component@1.0.1: {}
@@ -3814,6 +3867,10 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
+ gifuct-js@2.1.2:
+ dependencies:
+ js-binary-schema-parser: 2.0.3
+
glob-parent@6.0.2:
dependencies:
is-glob: 4.0.3
@@ -3856,10 +3913,14 @@ snapshots:
isexe@2.0.0: {}
+ ismobilejs@1.1.1: {}
+
jiti@2.7.0: {}
jose@6.2.12: {}
+ js-binary-schema-parser@2.0.3: {}
+
js-tokens@10.0.0: {}
js-tokens@4.0.0: {}
@@ -4077,6 +4138,8 @@ snapshots:
dependencies:
p-limit: 3.1.0
+ parse-svg-path@0.2.0: {}
+
parse5@8.0.1:
dependencies:
entities: 8.1.0
@@ -4154,6 +4217,19 @@ snapshots:
sonic-boom: 4.2.1
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:
dependencies:
nanoid: 3.3.19
@@ -4317,6 +4393,8 @@ snapshots:
dependencies:
real-require: 1.0.0
+ tiny-lru@11.4.7: {}
+
tinybench@6.2.0: {}
tinyexec@1.3.1: {}