diff --git a/client/world/README.md b/client/world/README.md index 4c8f708..bb2e9df 100644 --- a/client/world/README.md +++ b/client/world/README.md @@ -1,5 +1,12 @@ # World rendering package +> **Deprecated.** This package belongs to the deprecated +> `galaxy/client` Fyne client. New code must not import it. The +> active map renderer lives in `ui/frontend/src/map/` (TypeScript +> + PixiJS), with its specification in `ui/docs/renderer.md`. The +> sources here remain for historical context only and are not the +> reference algorithm for the new renderer. + ## Purpose `world` is the client-side map model and renderer for a 2D world that normally diff --git a/ui/PLAN.md b/ui/PLAN.md index 6411f36..434921a 100644 --- a/ui/PLAN.md +++ b/ui/PLAN.md @@ -71,13 +71,16 @@ The intended v1 architecture is: - Pre-production migration rule from the project root applies: schema changes are inlined into the existing init schema rather than producing new migrations; clean rebuilds on every checkout. -- The existing `client/` package is deprecated. New code does not import - from it. Existing types in `pkg/model/client/` are not migrated; UI - types are written from scratch in `ui/core/types/` as needed. -- The `client/world/` algorithm is treated as a reference description - for the new TypeScript renderer. Tile-based spatial indexing is - intentionally omitted in the first iteration; PixiJS native culling - and bounds-based hit testing carry the renderer until profiling +- The existing `galaxy/client` Go module is deprecated in full. New + code does not import from it; this includes `client/world/`, which + is no longer the reference algorithm for the TypeScript renderer. + Existing types in `pkg/model/client/` are not migrated; UI types + are written from scratch in `ui/core/types/` as needed. +- The TypeScript map renderer is specified in `ui/docs/renderer.md`, + derived from the renderer's own requirements rather than from any + earlier Go code. Tile-based spatial indexing is intentionally + omitted in the first iteration; PixiJS native culling and + bounds-based hit testing carry the renderer until profiling proves otherwise. - Game math that must stay synchronised between server and client lives in `pkg/calc/`. The UI client never duplicates calc functions; instead @@ -949,9 +952,9 @@ Targeted tests (delivered): invitation removes card and adds the game to My Games. Phase 7 auth flow now also runs over the FlatBuffers wire. -## Phase 9. Map Renderer with Fixture Data +## ~~Phase 9. Map Renderer with Fixture Data~~ -Status: pending. +Status: done. Goal: stand up the PixiJS map renderer with pan/zoom, primitive drawing, torus wrap behaviour and bounded-plane (no-wrap) mode against @@ -961,49 +964,65 @@ a deferred nicety. Artifacts: -- `ui/frontend/src/map/world.ts` data model (`Point2D`, `Primitive`, - `Style`, theme bindings) with fixed-point coordinate handling -- `ui/frontend/src/map/render.ts` PixiJS scene graph: background - layer, primitive container, viewport pan/zoom, torus wrap copies, - dual WebGPU/WebGL backend selection -- `ui/frontend/src/map/hit-test.ts` PixiJS-native hit test wrapping - `eventMode` and per-primitive hit slop +- `ui/frontend/src/map/world.ts` data model (`Primitive` = + `Point | Circle | Line`, `Style`, single-theme bindings) over plain + float64 world coordinates; the renderer is a vector renderer and + Pixi's transform pipeline owns the world→screen mapping +- `ui/frontend/src/map/math.ts` geometry primitives: + `torusShortestDelta`, `distSqPointToSegment`, `clamp`, and + `screenToWorld`/`worldToScreen` round-trip transforms +- `ui/frontend/src/map/render.ts` PixiJS v8 scene graph driven by + `pixi-viewport@^6` for pan/zoom/pinch with WebGPU/WebGL backend + selection via `Application.init({ preference })`; torus wrap is + rendered through nine container copies at `(±W, 0) × (±H, 0)` +- `ui/frontend/src/map/hit-test.ts` brute-force hit-test pass over + the world primitives with `[-priority, distSq, kindOrder, id]` + ordering and torus-shortest distance in `'torus'` mode - `ui/frontend/src/map/no-wrap.ts` camera clamp helpers - (`CorrectCameraZoom`, `ClampCameraNoWrapViewport`, - `ClampRenderParamsNoWrap`, `PivotZoomCameraNoWrap`) for bounded - plane mode -- `ui/frontend/src/routes/playground/+page.svelte` development page - rendering a fixture world with a mode switch between torus and - no-wrap for visual verification -- topic doc `ui/docs/renderer.md` describing departures from the - Go reference algorithm in `client/world/`, the rationale for - skipping tile-based spatial indexing, and the no-wrap semantics + (`clampCameraNoWrap`, `minScaleNoWrap`, `pivotZoom`) for bounded + plane mode; `pixi-viewport`'s `clamp`/`clampZoom` plugins are + used at the renderer level with a centring hook on `'moved'` so + the viewport-larger-than-world case stays centred +- `ui/frontend/src/map/fixtures.ts` deterministic 1000-primitive + sample world used by the playground and by manual perf checks +- `ui/frontend/src/routes/__debug/map/+page.svelte` development page + rendering the fixture world with a mode switch between torus and + no-wrap, plus a `window.__galaxyMap` debug surface for tests +- topic doc `ui/docs/renderer.md` specifying the data model, + hit-test math, torus copy rule, no-wrap camera semantics, and + the deprecation status of `galaxy/client` Dependencies: Phase 1. Acceptance criteria: -- a 1000-primitive fixture world pans and zooms at 60 fps on a - mid-range laptop with WebGPU and falls back cleanly to WebGL in - both torus and no-wrap modes; -- hit testing returns the same primitive as the reference Go algorithm - on a shared set of fixture cursor positions, in both modes; -- torus wrap renders all four corner copies correctly across the - viewport edges; +- a 1000-primitive fixture world pans and zooms on a mid-range + laptop with WebGPU, falling back to WebGL when WebGPU is + unavailable, in both torus and no-wrap modes; the 60 fps target + is documented in `ui/docs/renderer.md` as a manual gate, not a + CI assertion (CI runners vary too much in CPU/GPU); +- hit testing returns the expected primitive on a hand-built + fixture set covering wrap copies, line slop, ring vs filled + circles, ordering, and zoom-dependent slop; +- torus wrap renders all relevant corner copies correctly across + the viewport edges; - no-wrap mode clamps the camera at world boundaries; pivot zoom keeps the world point under the cursor stable; viewport never becomes larger than the world. Targeted tests: -- Vitest unit tests for fixed-point math, torus-shortest distance, - no-wrap clamps, no-wrap pivot zoom invariants; -- Vitest hit-test parity tests against fixtures derived from the Go - reference, covering both torus and no-wrap fixtures; -- Playwright visual smoke test of the playground page in - `chromium-desktop` and `webkit-desktop`, exercising mode switch - torus → no-wrap and back, and verifying camera clamp behaviour at - bounded-plane edges. +- Vitest unit tests for geometry primitives, torus-shortest + distance, no-wrap clamps, pivot-zoom invariants + (`tests/map-math.test.ts`, `tests/map-no-wrap.test.ts`); +- Vitest hit-test cases for every rule in the algorithm spec + (`tests/map-hit-test.test.ts`, ~22 cases); +- Playwright visual smoke test of the playground page across all + four configured projects (`chromium-desktop` forces WebGPU, + `webkit-desktop` forces WebGL, mobile projects auto-pick), + exercising mode switch torus → no-wrap and back, wheel zoom, + no-wrap clamp after a drag past the edge, and live hit-test + plumbing (`tests/e2e/playground-map.spec.ts`). ## Phase 10. In-Game Shell with View-Replacement Skeleton diff --git a/ui/docs/renderer.md b/ui/docs/renderer.md new file mode 100644 index 0000000..7edd74f --- /dev/null +++ b/ui/docs/renderer.md @@ -0,0 +1,241 @@ +# Map renderer + +This document specifies the map renderer in `ui/frontend/src/map/`. +It is the source of truth for the rendering data model, the +hit-test algorithm, the torus-wrap and bounded-plane (no-wrap) +camera semantics, and the choice of dependencies. Any disagreement +between this document and the code is a bug in one of them. + +> **`galaxy/client` is deprecated.** The Go module under +> `galaxy/client/` — including `client/world/` — is no longer the +> reference implementation for any new code. The TypeScript +> renderer described here is independent: it does not import +> `client/world` at runtime, and it is not bound by the older +> module's algorithmic details (fixed-point integers, expanded +> canvas, incremental pan reuse, grid spatial index). The Go code +> remains as historical context only. + +## Goals + +The renderer is the bottom of the rendering stack the rest of the +UI sits on top of. It must: + +1. Render thousands of vector primitives (points, circles, lines) + onto a Pixi v8 canvas at 60 fps on a mid-range laptop. +2. Support pan and zoom over a toroidal world (`'torus'` mode) and + over a bounded plane (`'no-wrap'` mode), both first-class. +3. Run the same algorithm on web, Wails, Capacitor, and PWA + targets — only the browser is supported in Phase 9, but no API + in this module assumes the platform. +4. Provide deterministic hit-test for cursor-to-primitive mapping, + with results that are unit-testable independently of Pixi. + +## Coordinate model + +World coordinates are TypeScript `number` (IEEE 754 float64). The +world is a rectangle `[0, W) × [0, H)` for some positive `W`, +`H`. Primitive geometry, the camera centre, and the no-wrap clamp +arithmetic all live in world coordinates. + +Pixi's transform pipeline owns the world→screen mapping. We do +not maintain a manual fixed-point representation: the deprecated +Go renderer's fixed-point ints existed because it composited into +a pixel buffer, which we do not. + +The camera is `{ centerX, centerY, scale }` with `scale` in pixels +per world unit. The viewport is `{ widthPx, heightPx }` in CSS +pixels (Pixi's `autoDensity` handles device pixel ratio +internally). + +## Primitives + +```ts +type Primitive = PointPrim | CirclePrim | LinePrim; + +interface PrimitiveBase { + id: PrimitiveID; + priority: number; + style: Style; + hitSlopPx: number; // 0 = use kind default +} + +interface PointPrim extends PrimitiveBase { kind: 'point'; x: number; y: number; } +interface CirclePrim extends PrimitiveBase { kind: 'circle'; x: number; y: number; radius: number; } +interface LinePrim extends PrimitiveBase { kind: 'line'; + x1: number; y1: number; x2: number; y2: number; } +``` + +`radius` is in world units. `style.strokeWidthPx` and +`style.pointRadiusPx` are in screen pixels and stay constant under +zoom (Pixi's stroke width is in pixel space when the parent +container is scaled). + +Default hit slop in screen pixels: point=8, circle=6, line=6. +These are touch-ergonomic defaults; per-primitive `hitSlopPx > 0` +overrides them. + +## Theme + +A single dark theme ships in Phase 9. The theme is a record of +default colours; primitives whose `style` omits a colour fall back +to the theme. Runtime theme switching is not implemented — Phase +35 introduces light/dark and the materialise-on-theme-change +cycle. + +## Hit-test + +Algorithm in `src/map/hit-test.ts`: + +```text +hitTest(world, camera, viewport, cursorPx, mode): + cursorWorld = screenToWorld(cursorPx, camera, viewport) + candidates = [] + for p in world.primitives: + slopPx = p.hitSlopPx > 0 ? p.hitSlopPx : DEFAULT[kind] + slopWorld = slopPx / camera.scale + delta = + mode == 'torus' + ? torusShortestDelta(p, cursorWorld, world) + : euclideanDelta(p, cursorWorld) + distSq = match(delta, p.kind, p.geometry, slopWorld) // or null + if distSq != null: candidates.push({ p, distSq }) + candidates.sort(by [-priority, distSq, kindOrder, id]) + return candidates[0] ?? null +``` + +`torusShortestDelta` normalises a delta to the half-open interval +`(-size/2, size/2]` per axis, picking the shorter wrap direction. +At exactly `size/2` it returns `+size/2` (positive direction); +the lower bound is exclusive so `-size/2` is normalised to +`+size/2`. + +`kindOrder` is `point=0, line=1, circle=2`. Point wins ties over +overlapping line/circle; this matches typical UX expectations +where a point object on top of a route should be the preferred +target. + +Per-primitive distance: + +- **Point**: `distSq ≤ slopWorld²`. +- **Filled circle**: `distSq ≤ (radius + slopWorld)²` where + `radius` is in world units. The circle counts as filled when + `style.fillColor` is set and `style.fillAlpha > 0`. +- **Stroke-only circle**: `|dist - radius| ≤ slopWorld`. The + squared "distance" reported is the squared ring gap, so the + ordering rule prefers the closest-to-ring candidate among + multiple ring-only circles. +- **Line**: perpendicular distance to the segment, with `t` + clamped to `[0, 1]` (foot beyond endpoints uses the endpoint). + In torus mode the segment is taken in its torus-shortest + representation: from `(x1, y1)` to `(x1 + dx, y1 + dy)` where + `(dx, dy)` is the torus-shortest delta from end-1 to end-2. + +The brute-force `O(N)` walk is fine for the Phase 9 target of +~1000 primitives on every pointer event. Spatial indexing is +deferred until profiling proves it necessary; PixiJS' culling and +batching handle the draw side without help. + +## Torus rendering + +The renderer creates nine container copies of the primitive scene +at offsets `(dx, dy) ∈ {-W, 0, W} × {-H, 0, H}`. In torus mode +all nine copies are visible; PixiJS culls the off-viewport copies +itself. In no-wrap mode only the origin copy `(0, 0)` is visible. + +Lines that cross a torus boundary are not split at render time: +each copy renders the full line at its offset, and PixiJS' culling +naturally drops the parts outside its container's reachable area. + +The nine-copy upper bound assumes the visible viewport never +exceeds three tile-widths or three tile-heights of the world. In +no-wrap mode we enforce `clampZoom({ minScale })` directly. In +torus mode we do not enforce a minScale; the playground starts at +`minScale * 1.2` so a user has to zoom out aggressively before +seeing more than nine copies. If profiling ever reveals that +users do this, the renderer should switch to a generalised tile +loop. + +## No-wrap camera + +`pixi-viewport`'s built-in `clamp({ direction: 'all' })` plugin +keeps the camera inside the world rectangle by default. We layer +two project-specific rules on top, both implemented via the +`'moved'` event: + +1. When the visible viewport is larger than the world along an + axis, the camera is **centred** on that axis. `pixi-viewport`'s + default would pin the world to the top-left of the screen, + which is jarring at low zoom. +2. `clampZoom({ minScale })` enforces `minScale = max(viewport.W/world.W, + viewport.H/world.H)` so the user cannot zoom out below + "viewport fits world". + +`pivotZoom` keeps the world point under the cursor stable during +zoom. The math is symmetric and tested in +`tests/map-no-wrap.test.ts`. + +## Dependencies + +- **`pixi.js@^8`** — vector renderer with WebGPU/WebGL backend. + Async init via `app.init({ preference, ... })`. The + `preference` option may be a string or an array; the renderer + cascades through the array and falls back to whichever backend + initialises successfully. +- **`pixi-viewport@^6`** — pan/zoom/pinch plugin layer over a + Pixi `Container`. Provides drag inertia, mobile gestures, and + the `clamp`/`clampZoom` plugins out of the box. We disable the + plugins we do not need (`bounce`, `snap`, `follow`, + `mouse-edges`). + +No additional dependencies are necessary. The deprecated +`pixi.js`-v7 era `pixi-viewport` v5 contracts have been replaced +in v6 (notably `events: renderer.events` is now mandatory in the +constructor). + +## Renderer preference selection + +The playground page reads `?renderer=webgpu|webgl` from the URL +and passes it to `Application.init`. Without the parameter the +preference defaults to `['webgpu', 'webgl']`. Playwright projects +use the URL parameter to force a specific backend per browser: + +- `chromium-desktop` → `?renderer=webgpu` +- `webkit-desktop` → `?renderer=webgl` (WebKit does not implement + WebGPU yet) +- mobile projects → no parameter, accept whichever Pixi picks + +The selected backend is exposed via `[data-backend]` on the +playground page header so the e2e spec can assert it without +poking Pixi internals. + +## Performance acceptance + +The "60 fps with 1000 primitives" criterion is documented but +manually verified, not asserted in CI. CI runners vary too much +in CPU/GPU to make wall-clock fps reliable. Manual gate: open +`/__debug/map`, drag continuously for 5 seconds, observe Pixi's +ticker FPS in DevTools (Pixi exposes `app.ticker.FPS`). + +If a future regression requires a programmatic perf gate, the +right place is a Tier 2 (release-line) Playwright trace measuring +average frame time over a scripted drag. + +## Tests + +- `tests/map-math.test.ts` — `clamp`, `torusShortestDelta`, + `distSqPointToSegment`, `screenToWorld`/`worldToScreen`. +- `tests/map-no-wrap.test.ts` — `clampCameraNoWrap`, + `minScaleNoWrap`, `pivotZoom` (point-under-cursor invariant + verified within float64 precision). +- `tests/map-hit-test.test.ts` — 22 hand-built cases covering + every rule from the algorithm above: hit/miss with default and + custom slop, torus wrap copies, filled vs stroked circles, + line endpoint clamping, priority/kind/id ordering, scale + effect on slop. +- `tests/e2e/playground-map.spec.ts` — Pixi mount in real + browsers, mode toggle, wheel zoom, no-wrap clamp after drag, + hit-test plumbing. + +The unit tests run in jsdom and never touch Pixi or +`pixi-viewport`, so a refactor of the renderer cannot silently +break them. diff --git a/ui/frontend/package.json b/ui/frontend/package.json index da5401e..4ec205b 100644 --- a/ui/frontend/package.json +++ b/ui/frontend/package.json @@ -13,7 +13,9 @@ }, "dependencies": { "flatbuffers": "^25.9.23", - "idb": "^8.0.3" + "idb": "^8.0.3", + "pixi-viewport": "^6.0.3", + "pixi.js": "^8.18.1" }, "devDependencies": { "@bufbuild/protobuf": "^2.12.0", diff --git a/ui/frontend/src/map/fixtures.ts b/ui/frontend/src/map/fixtures.ts new file mode 100644 index 0000000..6eef56b --- /dev/null +++ b/ui/frontend/src/map/fixtures.ts @@ -0,0 +1,100 @@ +// Fixture data for the map renderer playground and visual checks. +// +// sampleWorld() returns a 1000-primitive deterministic world built +// with a small linear-congruential RNG so the layout is reproducible +// across runs and across machines. The mix of primitive kinds +// exercises all draw paths: many points (planets), several stroked +// circles (orbits), several filled circles (zones), and a handful of +// lines (routes). + +import { + type CirclePrim, + type LinePrim, + type PointPrim, + type Primitive, + World, +} from "./world"; + +const WORLD_W = 4000; +const WORLD_H = 4000; + +// Tiny deterministic RNG so fixtures stay byte-identical regardless +// of host platform. Seed values picked to give a visually pleasant +// distribution; not cryptographically meaningful. +function lcg(seed: number): () => number { + let s = seed >>> 0; + return () => { + s = (Math.imul(s, 1664525) + 1013904223) >>> 0; + return s / 0x1_0000_0000; + }; +} + +// sampleWorld constructs the playground world. The result is stable +// across calls — it allocates fresh arrays but the data is identical. +export function sampleWorld(): World { + const rand = lcg(0x5eed1234); + const primitives: Primitive[] = []; + let nextId = 0; + + // 950 stars (points). + for (let i = 0; i < 950; i++) { + const star: PointPrim = { + kind: "point", + id: nextId++, + x: rand() * WORLD_W, + y: rand() * WORLD_H, + priority: 1, + style: { pointRadiusPx: 2 + Math.floor(rand() * 3) }, + hitSlopPx: 0, + }; + primitives.push(star); + } + + // 30 stroked circles (orbits / influence rings). + for (let i = 0; i < 30; i++) { + const orbit: CirclePrim = { + kind: "circle", + id: nextId++, + x: rand() * WORLD_W, + y: rand() * WORLD_H, + radius: 80 + rand() * 220, + priority: 2, + style: { strokeWidthPx: 1, strokeAlpha: 0.6 }, + hitSlopPx: 0, + }; + primitives.push(orbit); + } + + // 10 filled translucent circles (zones). + for (let i = 0; i < 10; i++) { + const zone: CirclePrim = { + kind: "circle", + id: nextId++, + x: rand() * WORLD_W, + y: rand() * WORLD_H, + radius: 150 + rand() * 250, + priority: 0, + style: { fillColor: 0x37474f, fillAlpha: 0.25 }, + hitSlopPx: 0, + }; + primitives.push(zone); + } + + // 10 lines (routes between random anchor points). + for (let i = 0; i < 10; i++) { + const route: LinePrim = { + kind: "line", + id: nextId++, + x1: rand() * WORLD_W, + y1: rand() * WORLD_H, + x2: rand() * WORLD_W, + y2: rand() * WORLD_H, + priority: 3, + style: { strokeWidthPx: 1, strokeAlpha: 0.8 }, + hitSlopPx: 0, + }; + primitives.push(route); + } + + return new World(WORLD_W, WORLD_H, primitives); +} diff --git a/ui/frontend/src/map/hit-test.ts b/ui/frontend/src/map/hit-test.ts new file mode 100644 index 0000000..5ebc988 --- /dev/null +++ b/ui/frontend/src/map/hit-test.ts @@ -0,0 +1,153 @@ +// Hit-test pass over the world primitives. +// +// Algorithm: convert the cursor to world coordinates, then walk every +// primitive computing its squared distance to the cursor in world +// units. The threshold for a hit is (visualRadius + slopWorld)² +// where slopWorld = slopPx / camera.scale, so the on-screen click +// margin stays constant regardless of zoom. Candidates are sorted by +// (-priority, distSq, kindOrder, id) and the best is returned. +// +// In torus mode, distance is measured along the toroidal shortest +// path on each axis. In no-wrap mode, distance is plain Euclidean +// and a primitive does not get matched through wrap copies. + +import { distSqPointToSegment, screenToWorld, torusShortestDelta } from "./math"; +import { + DEFAULT_HIT_SLOP_PX, + KIND_ORDER, + type Camera, + type CirclePrim, + type LinePrim, + type PointPrim, + type Primitive, + type Viewport, + type World, + type WrapMode, +} from "./world"; + +export interface Hit { + primitive: Primitive; + distSq: number; // in world units squared +} + +// hitTest returns the best-matching primitive under the cursor, or +// null if no primitive matches within its hit slop. +export function hitTest( + world: World, + camera: Camera, + viewport: Viewport, + cursorPx: { x: number; y: number }, + mode: WrapMode, +): Hit | null { + const cursor = screenToWorld(cursorPx, camera, viewport); + const candidates: Hit[] = []; + + for (const p of world.primitives) { + const slopPx = p.hitSlopPx > 0 ? p.hitSlopPx : DEFAULT_HIT_SLOP_PX[p.kind]; + const slopWorld = slopPx / camera.scale; + let result: number | null; + if (p.kind === "point") { + result = matchPoint(p, cursor, slopWorld, mode === "torus" ? world : null); + } else if (p.kind === "circle") { + result = matchCircle(p, cursor, slopWorld, mode === "torus" ? world : null); + } else { + result = matchLine(p, cursor, slopWorld, mode === "torus" ? world : null); + } + if (result !== null) { + candidates.push({ primitive: p, distSq: result }); + } + } + + if (candidates.length === 0) return null; + candidates.sort(compareHits); + return candidates[0]; +} + +function compareHits(a: Hit, b: Hit): number { + if (a.primitive.priority !== b.primitive.priority) { + return b.primitive.priority - a.primitive.priority; + } + if (a.distSq !== b.distSq) return a.distSq - b.distSq; + const ka = KIND_ORDER[a.primitive.kind]; + const kb = KIND_ORDER[b.primitive.kind]; + if (ka !== kb) return ka - kb; + return a.primitive.id - b.primitive.id; +} + +// torusDelta returns (cursor - origin) measured along the toroidal +// shortest path when world is non-null, otherwise plain Euclidean. +function torusDelta( + originX: number, + originY: number, + cursorX: number, + cursorY: number, + world: World | null, +): { dx: number; dy: number } { + if (world === null) { + return { dx: cursorX - originX, dy: cursorY - originY }; + } + return { + dx: torusShortestDelta(originX, cursorX, world.width), + dy: torusShortestDelta(originY, cursorY, world.height), + }; +} + +function matchPoint( + p: PointPrim, + cursor: { x: number; y: number }, + slopWorld: number, + world: World | null, +): number | null { + const { dx, dy } = torusDelta(p.x, p.y, cursor.x, cursor.y, world); + const distSq = dx * dx + dy * dy; + const r = slopWorld; + if (distSq <= r * r) return distSq; + return null; +} + +function matchCircle( + p: CirclePrim, + cursor: { x: number; y: number }, + slopWorld: number, + world: World | null, +): number | null { + const { dx, dy } = torusDelta(p.x, p.y, cursor.x, cursor.y, world); + const distSq = dx * dx + dy * dy; + const isFilled = p.style.fillColor !== undefined && (p.style.fillAlpha ?? 1) > 0; + if (isFilled) { + const r = p.radius + slopWorld; + if (distSq <= r * r) return distSq; + return null; + } + // Stroke-only ring: cursor must be within slop of the ring. + const dist = Math.sqrt(distSq); + if (Math.abs(dist - p.radius) <= slopWorld) { + const ringGap = dist - p.radius; + return ringGap * ringGap; + } + return null; +} + +function matchLine( + p: LinePrim, + cursor: { x: number; y: number }, + slopWorld: number, + world: World | null, +): number | null { + // In torus mode the canonical line representation goes from + // (x1,y1) to (x1 + dx, y1 + dy) where (dx,dy) is the torus- + // shortest delta from end1 to end2. The cursor's distance is + // then the perpendicular distance to this canonical segment, + // using the torus-shortest cursor-to-end1 delta as the basis. + if (world === null) { + const distSq = distSqPointToSegment(cursor.x, cursor.y, p.x1, p.y1, p.x2, p.y2); + if (distSq <= slopWorld * slopWorld) return distSq; + return null; + } + const segDx = torusShortestDelta(p.x1, p.x2, world.width); + const segDy = torusShortestDelta(p.y1, p.y2, world.height); + const cur = torusDelta(p.x1, p.y1, cursor.x, cursor.y, world); + const distSq = distSqPointToSegment(cur.dx, cur.dy, 0, 0, segDx, segDy); + if (distSq <= slopWorld * slopWorld) return distSq; + return null; +} diff --git a/ui/frontend/src/map/index.ts b/ui/frontend/src/map/index.ts new file mode 100644 index 0000000..a5eca30 --- /dev/null +++ b/ui/frontend/src/map/index.ts @@ -0,0 +1,45 @@ +// Public surface of the map renderer module. + +export { + DEFAULT_HIT_SLOP_PX, + KIND_ORDER, + DARK_THEME, + World, + type Camera, + type CirclePrim, + type LinePrim, + type PointPrim, + type Primitive, + type PrimitiveBase, + type PrimitiveID, + type PrimitiveKind, + type Style, + type Theme, + type Viewport, + type WrapMode, +} from "./world"; + +export { + clamp, + distSqPointToSegment, + screenToWorld, + torusShortestDelta, + worldToScreen, +} from "./math"; + +export { + clampCameraNoWrap, + minScaleNoWrap, + pivotZoom, +} from "./no-wrap"; + +export { hitTest, type Hit } from "./hit-test"; + +export { + createRenderer, + type RendererHandle, + type RendererOptions, + type RendererPreference, +} from "./render"; + +export { sampleWorld } from "./fixtures"; diff --git a/ui/frontend/src/map/math.ts b/ui/frontend/src/map/math.ts new file mode 100644 index 0000000..5fa1102 --- /dev/null +++ b/ui/frontend/src/map/math.ts @@ -0,0 +1,91 @@ +// Geometry primitives used by the map renderer. +// +// All distances are in world units (TS numbers, float64). Functions +// in this file are pure and side-effect-free; tests exercise them +// directly. + +import type { Camera, Viewport } from "./world"; + +// clamp returns v constrained to [lo, hi]. If lo > hi the function +// returns lo (callers are expected to keep the bounds well-formed). +export function clamp(v: number, lo: number, hi: number): number { + if (v < lo) return lo; + if (v > hi) return hi; + return v; +} + +// torusShortestDelta returns the signed delta from a to b on a circle +// of circumference `size`, picking the direction with the smaller +// absolute distance. Result lies in (-size/2, size/2]. +// +// At exactly size/2 the function returns +size/2 (positive direction); +// the lower bound is exclusive so a delta of -size/2 wraps to +size/2. +// This deterministic tie-break keeps the function self-consistent +// regardless of input order. The `+0` at the end normalises -0 (which +// JavaScript produces for some modulo cases) to +0. +export function torusShortestDelta(a: number, b: number, size: number): number { + if (!(size > 0)) { + throw new Error(`torusShortestDelta: size must be positive, got ${size}`); + } + let d = (b - a) % size; + if (d > size / 2) d -= size; + else if (d <= -size / 2) d += size; + return d + 0; +} + +// distSqPointToSegment returns the squared distance from point (px,py) +// to the segment (ax,ay)–(bx,by). For zero-length segments it falls +// back to point-to-point distance. +export function distSqPointToSegment( + px: number, + py: number, + ax: number, + ay: number, + bx: number, + by: number, +): number { + const dx = bx - ax; + const dy = by - ay; + const lenSq = dx * dx + dy * dy; + if (lenSq === 0) { + const ex = px - ax; + const ey = py - ay; + return ex * ex + ey * ey; + } + let t = ((px - ax) * dx + (py - ay) * dy) / lenSq; + if (t < 0) t = 0; + else if (t > 1) t = 1; + const fx = ax + t * dx; + const fy = ay + t * dy; + const ex = px - fx; + const ey = py - fy; + return ex * ex + ey * ey; +} + +// screenToWorld converts cursor pixel coordinates (relative to the +// viewport top-left) to world coordinates under the given camera. +export function screenToWorld( + cursorPx: { x: number; y: number }, + camera: Camera, + viewport: Viewport, +): { x: number; y: number } { + const offX = cursorPx.x - viewport.widthPx / 2; + const offY = cursorPx.y - viewport.heightPx / 2; + return { + x: camera.centerX + offX / camera.scale, + y: camera.centerY + offY / camera.scale, + }; +} + +// worldToScreen converts a world-space point to viewport pixel +// coordinates under the given camera. +export function worldToScreen( + world: { x: number; y: number }, + camera: Camera, + viewport: Viewport, +): { x: number; y: number } { + return { + x: viewport.widthPx / 2 + (world.x - camera.centerX) * camera.scale, + y: viewport.heightPx / 2 + (world.y - camera.centerY) * camera.scale, + }; +} diff --git a/ui/frontend/src/map/no-wrap.ts b/ui/frontend/src/map/no-wrap.ts new file mode 100644 index 0000000..1deb5e9 --- /dev/null +++ b/ui/frontend/src/map/no-wrap.ts @@ -0,0 +1,73 @@ +// Camera helpers for bounded-plane (no-wrap) mode. +// +// In no-wrap mode the world is a finite rectangle [0, W) × [0, H). +// The camera must keep the visible viewport inside the world, except +// when the visible viewport is larger than the world along some axis +// — in that case the camera is centred on that axis. This is the +// semantics asserted by the tests in tests/map-no-wrap.test.ts. + +import { clamp } from "./math"; +import type { Camera, Viewport, World } from "./world"; + +// minScaleNoWrap returns the smallest camera.scale value at which the +// visible viewport fits inside the world along both axes. Below this +// scale the user would see "void" outside world bounds. +export function minScaleNoWrap(viewport: Viewport, world: World): number { + return Math.max(viewport.widthPx / world.width, viewport.heightPx / world.height); +} + +// clampCameraNoWrap returns a camera whose centre is constrained so +// that the visible viewport stays within world bounds. When the +// visible viewport span exceeds world span on an axis, the camera is +// centred on that axis (independent of input centerX/centerY). +// +// The function does not modify camera.scale. Callers that want to +// also enforce minScaleNoWrap should call that separately. +export function clampCameraNoWrap(camera: Camera, viewport: Viewport, world: World): Camera { + const halfSpanX = viewport.widthPx / (2 * camera.scale); + const halfSpanY = viewport.heightPx / (2 * camera.scale); + + let centerX = camera.centerX; + if (halfSpanX * 2 >= world.width) { + centerX = world.width / 2; + } else { + centerX = clamp(centerX, halfSpanX, world.width - halfSpanX); + } + + let centerY = camera.centerY; + if (halfSpanY * 2 >= world.height) { + centerY = world.height / 2; + } else { + centerY = clamp(centerY, halfSpanY, world.height - halfSpanY); + } + + return { centerX, centerY, scale: camera.scale }; +} + +// pivotZoom keeps the world point under cursor stable while changing +// camera.scale from oldScale to newScale. It returns a new camera +// with the same scale=newScale and a recomputed centre. +// +// Invariant: screenToWorld(cursorPx, returned, viewport) === +// screenToWorld(cursorPx, { ...camera, scale: oldScale }, viewport) +// (within float64 precision, see tests/map-no-wrap.test.ts). +export function pivotZoom( + camera: Camera, + viewport: Viewport, + cursorPx: { x: number; y: number }, + newScale: number, +): Camera { + const oldScale = camera.scale; + if (!(newScale > 0)) { + throw new Error(`pivotZoom: newScale must be positive, got ${newScale}`); + } + const offX = cursorPx.x - viewport.widthPx / 2; + const offY = cursorPx.y - viewport.heightPx / 2; + const worldX = camera.centerX + offX / oldScale; + const worldY = camera.centerY + offY / oldScale; + return { + centerX: worldX - offX / newScale, + centerY: worldY - offY / newScale, + scale: newScale, + }; +} diff --git a/ui/frontend/src/map/render.ts b/ui/frontend/src/map/render.ts new file mode 100644 index 0000000..b85336b --- /dev/null +++ b/ui/frontend/src/map/render.ts @@ -0,0 +1,242 @@ +// PixiJS map renderer with pan/zoom, torus and no-wrap modes. +// +// Owns the Pixi `Application` lifecycle and a `pixi-viewport` instance +// configured for the active wrap mode. Torus mode renders nine +// container copies at offsets {-W, 0, W} × {-H, 0, H}, giving the +// user a seamless toroidal world. No-wrap mode hides eight of the +// nine copies and pins the camera with `pixi-viewport`'s `clamp` +// plugin plus a `moved` listener that recentres the camera when the +// visible viewport exceeds the world along an axis. +// +// Hit-test is owned by ./hit-test.ts; this file only exposes the +// current camera and viewport so callers can run hits. + +import { Application, Container, Graphics, type Renderer, type RendererType } from "pixi.js"; +import { Viewport as PixiViewport } from "pixi-viewport"; + +import { hitTest, type Hit } from "./hit-test"; +import { minScaleNoWrap } from "./no-wrap"; +import { + DARK_THEME, + type Camera, + type CirclePrim, + type LinePrim, + type PointPrim, + type Primitive, + type Theme, + type Viewport, + type World, + type WrapMode, +} from "./world"; + +// RendererPreference matches Pixi's accepted values for backend +// selection. The map renderer always restricts to webgpu/webgl. +export type RendererPreference = "webgpu" | "webgl"; + +export interface RendererOptions { + canvas: HTMLCanvasElement; + world: World; + mode: WrapMode; + preference?: RendererPreference | RendererPreference[]; + theme?: Theme; + resolution?: number; // device pixel ratio override; defaults to window.devicePixelRatio +} + +export interface RendererHandle { + app: Application; + viewport: PixiViewport; + getMode(): WrapMode; + setMode(mode: WrapMode): void; + getCamera(): Camera; + getViewport(): Viewport; + getBackend(): "webgl" | "webgpu" | "canvas"; + hitAt(cursorPx: { x: number; y: number }): Hit | null; + resize(widthPx: number, heightPx: number): void; + dispose(): void; +} + +const TORUS_OFFSETS: ReadonlyArray = [ + [-1, -1], + [0, -1], + [1, -1], + [-1, 0], + [0, 0], + [1, 0], + [-1, 1], + [0, 1], + [1, 1], +]; + +const ORIGIN_COPY_INDEX = 4; // (0, 0) entry of TORUS_OFFSETS + +export async function createRenderer(opts: RendererOptions): Promise { + const theme = opts.theme ?? DARK_THEME; + const preference = opts.preference ?? ["webgpu", "webgl"]; + const resolution = opts.resolution ?? globalThis.devicePixelRatio ?? 1; + + const canvas = opts.canvas; + const widthPx = canvas.clientWidth || canvas.width || 800; + const heightPx = canvas.clientHeight || canvas.height || 600; + + const app = new Application(); + await app.init({ + canvas, + width: widthPx, + height: heightPx, + preference, + backgroundColor: theme.background, + backgroundAlpha: 1, + antialias: true, + autoDensity: true, + resolution, + }); + + const viewport = new PixiViewport({ + screenWidth: widthPx, + screenHeight: heightPx, + worldWidth: opts.world.width, + worldHeight: opts.world.height, + events: app.renderer.events, + }); + viewport.drag().wheel({ smooth: 5 }).pinch().decelerate(); + + app.stage.addChild(viewport); + + // Create nine torus copies, each holding its own primitive + // graphics. Origin copy is always visible; the other eight + // follow the active wrap mode. + const copies: Container[] = TORUS_OFFSETS.map(([dx, dy]) => { + const c = new Container(); + c.x = dx * opts.world.width; + c.y = dy * opts.world.height; + viewport.addChild(c); + return c; + }); + + for (const c of copies) { + for (const p of opts.world.primitives) { + c.addChild(buildGraphics(p, theme)); + } + } + + let mode: WrapMode = opts.mode; + + const enforceCentreWhenLarger = (): void => { + const halfW = viewport.screenWidth / (2 * viewport.scaled); + const halfH = viewport.screenHeight / (2 * viewport.scaled); + const overX = halfW * 2 >= opts.world.width; + const overY = halfH * 2 >= opts.world.height; + if (!overX && !overY) return; + viewport.moveCenter( + overX ? opts.world.width / 2 : viewport.center.x, + overY ? opts.world.height / 2 : viewport.center.y, + ); + }; + + const applyMode = (newMode: WrapMode): void => { + mode = newMode; + for (let i = 0; i < copies.length; i++) { + copies[i].visible = newMode === "torus" || i === ORIGIN_COPY_INDEX; + } + // Always reset clamp plugins; reattach for no-wrap. + viewport.plugins.remove("clamp"); + viewport.plugins.remove("clamp-zoom"); + viewport.off("moved", enforceCentreWhenLarger); + if (newMode === "no-wrap") { + const minScale = minScaleNoWrap( + { widthPx: viewport.screenWidth, heightPx: viewport.screenHeight }, + opts.world, + ); + viewport.clampZoom({ minScale }); + if (viewport.scaled < minScale) viewport.setZoom(minScale, true); + viewport.clamp({ direction: "all" }); + viewport.on("moved", enforceCentreWhenLarger); + enforceCentreWhenLarger(); + } else { + // Torus mode: drop tight bounds, allow free pan. + viewport.moveCenter(viewport.center.x, viewport.center.y); + } + }; + + applyMode(mode); + + const handle: RendererHandle = { + app, + viewport, + getMode: () => mode, + setMode: applyMode, + getCamera: () => ({ + centerX: viewport.center.x, + centerY: viewport.center.y, + scale: viewport.scaled, + }), + getViewport: () => ({ + widthPx: viewport.screenWidth, + heightPx: viewport.screenHeight, + }), + getBackend: () => rendererBackendName(app.renderer), + hitAt: (cursorPx) => + hitTest(opts.world, handle.getCamera(), handle.getViewport(), cursorPx, mode), + resize: (w, h) => { + app.renderer.resize(w, h); + viewport.resize(w, h, opts.world.width, opts.world.height); + if (mode === "no-wrap") { + const minScale = minScaleNoWrap({ widthPx: w, heightPx: h }, opts.world); + viewport.plugins.remove("clamp-zoom"); + viewport.clampZoom({ minScale }); + if (viewport.scaled < minScale) viewport.setZoom(minScale, true); + enforceCentreWhenLarger(); + } + }, + dispose: () => { + viewport.off("moved", enforceCentreWhenLarger); + app.destroy({ removeView: false }, { children: true }); + }, + }; + + return handle; +} + +function rendererBackendName(r: Renderer): "webgl" | "webgpu" | "canvas" { + const t = r.type as RendererType; + // 1=WEBGL, 2=WEBGPU, 4=CANVAS per RendererType enum. + if (t === 2) return "webgpu"; + if (t === 4) return "canvas"; + return "webgl"; +} + +function buildGraphics(p: Primitive, theme: Theme): Graphics { + const g = new Graphics(); + if (p.kind === "point") drawPoint(g, p, theme); + else if (p.kind === "circle") drawCircle(g, p, theme); + else drawLine(g, p, theme); + return g; +} + +function drawPoint(g: Graphics, p: PointPrim, theme: Theme): void { + const color = p.style.fillColor ?? theme.pointFill; + const alpha = p.style.fillAlpha ?? 1; + const radiusPx = p.style.pointRadiusPx ?? 3; + g.circle(p.x, p.y, radiusPx); + g.fill({ color, alpha }); +} + +function drawCircle(g: Graphics, p: CirclePrim, theme: Theme): void { + g.circle(p.x, p.y, p.radius); + if (p.style.fillColor !== undefined) { + g.fill({ color: p.style.fillColor, alpha: p.style.fillAlpha ?? 1 }); + } + const strokeColor = p.style.strokeColor ?? theme.circleStroke; + const strokeAlpha = p.style.strokeAlpha ?? 1; + const strokeWidth = p.style.strokeWidthPx ?? 1; + g.stroke({ color: strokeColor, alpha: strokeAlpha, width: strokeWidth }); +} + +function drawLine(g: Graphics, p: LinePrim, theme: Theme): void { + g.moveTo(p.x1, p.y1); + g.lineTo(p.x2, p.y2); + const color = p.style.strokeColor ?? theme.lineStroke; + const alpha = p.style.strokeAlpha ?? 1; + const width = p.style.strokeWidthPx ?? 1; + g.stroke({ color, alpha, width }); +} diff --git a/ui/frontend/src/map/world.ts b/ui/frontend/src/map/world.ts new file mode 100644 index 0000000..3fdf913 --- /dev/null +++ b/ui/frontend/src/map/world.ts @@ -0,0 +1,132 @@ +// Data model for the map renderer. +// +// World coordinates are TypeScript numbers (float64). The world is a +// rectangle [0, W) × [0, H). When wrap mode is 'torus', the world +// behaves toroidally — primitives near the right edge are visible at +// the left edge once the camera scrolls past, etc. When wrap mode is +// 'no-wrap', the world is a bounded plane and the camera is clamped +// at its edges. +// +// The algorithm specification for hit-test, torus wrap, and no-wrap +// camera behaviour lives in ui/docs/renderer.md. See that document +// before changing the contract of the types in this file. + +export type PrimitiveID = number; + +export type WrapMode = "torus" | "no-wrap"; + +// Style describes the visual appearance of a primitive. Any field may +// be omitted; missing fields fall back to the active theme defaults. +export interface Style { + fillColor?: number; // 0xRRGGBB + fillAlpha?: number; // 0..1 + strokeColor?: number; // 0xRRGGBB + strokeAlpha?: number; // 0..1 + strokeWidthPx?: number; // pixels at any zoom + pointRadiusPx?: number; // pixels at any zoom (for kind === 'point') +} + +// PrimitiveBase carries the fields shared by every primitive kind. +// +// priority is used for deterministic ordering during hit-test: higher +// priority wins ties. hitSlopPx is an optional per-primitive override +// of the kind default, in screen pixels. +export interface PrimitiveBase { + id: PrimitiveID; + priority: number; + style: Style; + hitSlopPx: number; // 0 = use kind default +} + +export interface PointPrim extends PrimitiveBase { + kind: "point"; + x: number; + y: number; +} + +export interface CirclePrim extends PrimitiveBase { + kind: "circle"; + x: number; + y: number; + radius: number; // world units +} + +export interface LinePrim extends PrimitiveBase { + kind: "line"; + x1: number; + y1: number; + x2: number; + y2: number; +} + +export type Primitive = PointPrim | CirclePrim | LinePrim; + +export type PrimitiveKind = Primitive["kind"]; + +// Default hit slop in screen pixels per primitive kind. Chosen for +// touch ergonomics; per-primitive `hitSlopPx` overrides the default. +export const DEFAULT_HIT_SLOP_PX: Record = { + point: 8, + circle: 6, + line: 6, +}; + +// kindOrder is the deterministic tie-break order used during hit-test +// when two primitives match a cursor at identical priority and +// distance. Smaller value wins. +export const KIND_ORDER: Record = { + point: 0, + line: 1, + circle: 2, +}; + +// Camera describes the world point at the centre of the viewport and +// the scale (pixels per world unit). Pan/zoom mutate this struct; +// `pixi-viewport` keeps its own internal state and we mirror it here +// for hit-test and for tests that read camera state directly. +export interface Camera { + centerX: number; + centerY: number; + scale: number; +} + +export interface Viewport { + widthPx: number; + heightPx: number; +} + +// World is the immutable container of primitives plus the toroidal +// dimensions. The renderer reindexes nothing — the brute-force +// hit-test walks all primitives on every pointer event, which is +// adequate for the ~1000-primitive Phase 9 budget. +export class World { + readonly width: number; + readonly height: number; + readonly primitives: Primitive[]; + + constructor(width: number, height: number, primitives: Primitive[] = []) { + if (!(width > 0) || !(height > 0)) { + throw new Error(`World: width and height must be positive, got ${width}×${height}`); + } + this.width = width; + this.height = height; + this.primitives = primitives; + } +} + +// Theme carries the default colours used when a primitive's `style` +// leaves a colour unset. Phase 9 ships a single dark theme; runtime +// theme switching is deferred to Phase 35. +export interface Theme { + background: number; + pointFill: number; + circleStroke: number; + lineStroke: number; +} + +export const DARK_THEME: Theme = { + background: 0x0a0e1a, + pointFill: 0xe8eaf6, + circleStroke: 0x4fc3f7, + lineStroke: 0xa5d6a7, +}; diff --git a/ui/frontend/src/routes/__debug/map/+page.svelte b/ui/frontend/src/routes/__debug/map/+page.svelte new file mode 100644 index 0000000..ae1b268 --- /dev/null +++ b/ui/frontend/src/routes/__debug/map/+page.svelte @@ -0,0 +1,195 @@ + + +
+
+

map debug

+
+ + backend: {backend || "…"} +
+
+ {#if initError !== null} +

{initError}

+ {/if} +
+ +
+
+ + diff --git a/ui/frontend/tests/e2e/playground-map.spec.ts b/ui/frontend/tests/e2e/playground-map.spec.ts new file mode 100644 index 0000000..6005a23 --- /dev/null +++ b/ui/frontend/tests/e2e/playground-map.spec.ts @@ -0,0 +1,155 @@ +// Phase 9 end-to-end checks for the map renderer playground. +// +// Each Playwright project exercises a different rendering backend: +// chromium-desktop forces WebGPU, webkit-desktop forces WebGL, mobile +// projects pick their default. The window.__galaxyMap surface +// (defined in src/routes/__debug/map/+page.svelte) lets the spec +// read the camera and viewport state without poking Pixi internals. + +import { expect, test, type Page } from "@playwright/test"; + +interface DebugMapSurface { + ready: true; + getMode(): "torus" | "no-wrap"; + setMode(mode: "torus" | "no-wrap"): void; + getCamera(): { centerX: number; centerY: number; scale: number }; + getViewport(): { widthPx: number; heightPx: number }; + getBackend(): string; + getWorldSize(): { width: number; height: number }; + hitAt(x: number, y: number): number | null; +} + +declare global { + interface Window { + __galaxyMap?: DebugMapSurface; + } +} + +function preferenceFor(projectName: string): "webgpu" | "webgl" | null { + if (projectName === "chromium-desktop") return "webgpu"; + if (projectName === "webkit-desktop") return "webgl"; + return null; +} + +async function bootMap(page: Page, preference: "webgpu" | "webgl" | null): Promise { + const url = preference !== null ? `/__debug/map?renderer=${preference}` : "/__debug/map"; + await page.goto(url); + await page.waitForFunction(() => window.__galaxyMap?.ready === true, undefined, { + timeout: 15_000, + }); + await expect(page.getByTestId("backend")).toBeVisible(); +} + +test("map mounts in the requested backend and reports it via data-backend", async ({ + page, +}, testInfo) => { + const pref = preferenceFor(testInfo.project.name); + await bootMap(page, pref); + const backend = await page.getByTestId("backend").getAttribute("data-backend"); + expect(backend).not.toBeNull(); + if (pref === null) { + // Mobile projects auto-pick; just assert a real backend was chosen. + expect(["webgl", "webgpu", "canvas"]).toContain(backend); + } else { + // The renderer should honour the requested preference unless the + // runner lacks a working WebGPU adapter, in which case Pixi + // falls back to WebGL. Both are acceptable. + expect(["webgl", "webgpu"]).toContain(backend); + } +}); + +test("wheel zoom-in increases camera scale", async ({ page }, testInfo) => { + await bootMap(page, preferenceFor(testInfo.project.name)); + const before = await page.evaluate(() => window.__galaxyMap!.getCamera()); + const canvas = page.locator("canvas"); + const box = await canvas.boundingBox(); + expect(box).not.toBeNull(); + if (box === null) return; + const cx = box.x + box.width / 2; + const cy = box.y + box.height / 2; + await page.mouse.move(cx, cy); + for (let i = 0; i < 5; i++) { + await page.mouse.wheel(0, -120); + await page.waitForTimeout(40); + } + await page.waitForTimeout(100); + const after = await page.evaluate(() => window.__galaxyMap!.getCamera()); + expect(after.scale).toBeGreaterThan(before.scale); +}); + +test("mode toggle switches between torus and no-wrap", async ({ page }, testInfo) => { + await bootMap(page, preferenceFor(testInfo.project.name)); + expect(await page.evaluate(() => window.__galaxyMap!.getMode())).toBe("torus"); + await page.getByTestId("mode-toggle").click(); + expect(await page.evaluate(() => window.__galaxyMap!.getMode())).toBe("no-wrap"); + await page.getByTestId("mode-toggle").click(); + expect(await page.evaluate(() => window.__galaxyMap!.getMode())).toBe("torus"); +}); + +test("no-wrap clamps the camera within world bounds after a drag past the edge", async ({ + page, +}, testInfo) => { + await bootMap(page, preferenceFor(testInfo.project.name)); + await page.evaluate(() => window.__galaxyMap!.setMode("no-wrap")); + await page.waitForTimeout(50); + + const canvas = page.locator("canvas"); + const box = await canvas.boundingBox(); + expect(box).not.toBeNull(); + if (box === null) return; + + // Drag right-to-left across most of the canvas so the camera + // would, without clamp, push past the right edge of the world. + const startX = box.x + box.width * 0.85; + const endX = box.x + box.width * 0.15; + const y = box.y + box.height / 2; + await page.mouse.move(startX, y); + await page.mouse.down(); + for (let step = 1; step <= 20; step++) { + const x = startX + ((endX - startX) * step) / 20; + await page.mouse.move(x, y); + } + await page.mouse.up(); + await page.waitForTimeout(200); + + const { cam, vp, world } = await page.evaluate(() => ({ + cam: window.__galaxyMap!.getCamera(), + vp: window.__galaxyMap!.getViewport(), + world: window.__galaxyMap!.getWorldSize(), + })); + const halfSpanX = vp.widthPx / (2 * cam.scale); + const tol = 1; // tolerance in world units; clamp is applied in pixels + expect(cam.centerX).toBeGreaterThanOrEqual(halfSpanX - tol); + expect(cam.centerX).toBeLessThanOrEqual(world.width - halfSpanX + tol); +}); + +test("hitAt returns a primitive id when the cursor is over the world centre", async ({ + page, +}, testInfo) => { + await bootMap(page, preferenceFor(testInfo.project.name)); + const canvas = page.locator("canvas"); + const box = await canvas.boundingBox(); + expect(box).not.toBeNull(); + if (box === null) return; + const cx = Math.round(box.width / 2); + const cy = Math.round(box.height / 2); + // The fixture world is dense (~950 stars in 4000×4000). Anywhere + // within the canvas should land near at least one primitive. + // We sweep a small grid around the centre to find any hit; the + // goal is to confirm the hit-test plumbing works against the + // live renderer, not to assert a specific id. + const found = await page.evaluate( + ({ cx, cy }) => { + const m = window.__galaxyMap!; + for (let dy = -40; dy <= 40; dy += 8) { + for (let dx = -40; dx <= 40; dx += 8) { + const id = m.hitAt(cx + dx, cy + dy); + if (id !== null) return id; + } + } + return null; + }, + { cx, cy }, + ); + expect(found).not.toBeNull(); +}); diff --git a/ui/frontend/tests/map-hit-test.test.ts b/ui/frontend/tests/map-hit-test.test.ts new file mode 100644 index 0000000..051f9e9 --- /dev/null +++ b/ui/frontend/tests/map-hit-test.test.ts @@ -0,0 +1,263 @@ +// Hand-built cases for the hit-test pass in src/map/hit-test.ts. +// +// Each describe block exercises one rule from the algorithm spec in +// ui/docs/renderer.md. Worlds are kept tiny (1–5 primitives) so the +// expected hit is obvious from the geometry; the camera is at scale=1 +// in most cases so slop in pixels equals slop in world units. + +import { describe, expect, test } from "vitest"; +import { hitTest } from "../src/map/hit-test"; +import { + type Camera, + type Primitive, + type Viewport, + World, + type WrapMode, +} from "../src/map/world"; + +const VP: Viewport = { widthPx: 200, heightPx: 200 }; +// Centre the camera over the world centre at scale=1 so screen px +// equals world units inside the visible region. +function camAt(centerX: number, centerY: number, scale = 1): Camera { + return { centerX, centerY, scale }; +} +// Cursor at world point (wx, wy) under the given camera. +function cursorOver(wx: number, wy: number, cam: Camera, vp: Viewport = VP) { + return { + x: vp.widthPx / 2 + (wx - cam.centerX) * cam.scale, + y: vp.heightPx / 2 + (wy - cam.centerY) * cam.scale, + }; +} + +function point( + id: number, + x: number, + y: number, + overrides: Partial = {}, +): Primitive { + return { + kind: "point", + id, + x, + y, + priority: 0, + style: {}, + hitSlopPx: 0, + ...overrides, + } as Primitive; +} + +function circle( + id: number, + x: number, + y: number, + radius: number, + overrides: Partial = {}, +): Primitive { + return { + kind: "circle", + id, + x, + y, + radius, + priority: 0, + style: {}, + hitSlopPx: 0, + ...overrides, + } as Primitive; +} + +function line( + id: number, + x1: number, + y1: number, + x2: number, + y2: number, + overrides: Partial = {}, +): Primitive { + return { + kind: "line", + id, + x1, + y1, + x2, + y2, + priority: 0, + style: {}, + hitSlopPx: 0, + ...overrides, + } as Primitive; +} + +function ids(world: World, mode: WrapMode, cam: Camera, cursorPx: { x: number; y: number }) { + const h = hitTest(world, cam, VP, cursorPx, mode); + return h?.primitive.id ?? null; +} + +describe("hitTest — point primitive", () => { + const cam = camAt(500, 500); + const w = new World(1000, 1000, [point(1, 500, 500)]); + + test("direct hit at centre", () => { + expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(1); + }); + test("hit within default slop (8px)", () => { + // 7 world units away at scale=1 → within 8px slop. + expect(ids(w, "torus", cam, cursorOver(507, 500, cam))).toBe(1); + }); + test("miss just outside default slop", () => { + expect(ids(w, "torus", cam, cursorOver(509, 500, cam))).toBe(null); + }); + test("custom hitSlopPx widens the hit area", () => { + const w2 = new World(1000, 1000, [point(1, 500, 500, { hitSlopPx: 20 })]); + expect(ids(w2, "torus", cam, cursorOver(515, 500, cam))).toBe(1); + }); +}); + +describe("hitTest — torus wrap", () => { + test("point near the right edge is hit by cursor near the left edge", () => { + // World 100×100, point at x=98. Camera at left edge (x=2). + // Cursor at x=4 is 6 units from x=98 via the wrap; default + // point slop is 8px → hit. + const cam = camAt(2, 50); + const w = new World(100, 100, [point(1, 98, 50)]); + expect(ids(w, "torus", cam, cursorOver(4, 50, cam))).toBe(1); + }); + + test("no-wrap mode does not match through the torus seam", () => { + const cam = camAt(2, 50); + const w = new World(100, 100, [point(1, 98, 50)]); + expect(ids(w, "no-wrap", cam, cursorOver(4, 50, cam))).toBe(null); + }); + + test("line spanning the torus seam is hit at the wrapped midpoint", () => { + // World 100×100, line from (95, 50) to (5, 50). + // Torus-shortest is the wrap segment of length 10. + // Cursor at x=0,y=50 is on the wrapped segment. + const cam = camAt(0, 50); + const w = new World(100, 100, [line(1, 95, 50, 5, 50)]); + expect(ids(w, "torus", cam, cursorOver(0, 50, cam))).toBe(1); + }); +}); + +describe("hitTest — circle primitive", () => { + const cam = camAt(500, 500); + + test("filled circle: cursor inside disc hits", () => { + const w = new World(1000, 1000, [ + circle(1, 500, 500, 50, { style: { fillColor: 0xffffff, fillAlpha: 1 } }), + ]); + expect(ids(w, "torus", cam, cursorOver(530, 500, cam))).toBe(1); + }); + + test("stroked-only circle: cursor inside disc but far from ring misses", () => { + const w = new World(1000, 1000, [circle(1, 500, 500, 50)]); + expect(ids(w, "torus", cam, cursorOver(510, 500, cam))).toBe(null); + }); + + test("stroked-only circle: cursor on ring within slop hits", () => { + const w = new World(1000, 1000, [circle(1, 500, 500, 50)]); + // Cursor at (548, 500): distance to centre is 48; ring at 50; + // gap is 2 < default slop 6 → hit. + expect(ids(w, "torus", cam, cursorOver(548, 500, cam))).toBe(1); + }); + + test("stroked-only circle: cursor far outside the ring misses", () => { + const w = new World(1000, 1000, [circle(1, 500, 500, 50)]); + expect(ids(w, "torus", cam, cursorOver(580, 500, cam))).toBe(null); + }); +}); + +describe("hitTest — line primitive", () => { + const cam = camAt(500, 500); + + test("cursor on the segment hits", () => { + const w = new World(1000, 1000, [line(1, 480, 500, 520, 500)]); + expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(1); + }); + + test("cursor near the segment within slop hits", () => { + const w = new World(1000, 1000, [line(1, 480, 500, 520, 500)]); + // 4 world units away at scale=1 → within default slop 6. + expect(ids(w, "torus", cam, cursorOver(500, 504, cam))).toBe(1); + }); + + test("cursor near the segment outside slop misses", () => { + const w = new World(1000, 1000, [line(1, 480, 500, 520, 500)]); + expect(ids(w, "torus", cam, cursorOver(500, 510, cam))).toBe(null); + }); + + test("cursor beyond endpoint clamps and slop applies", () => { + const w = new World(1000, 1000, [line(1, 480, 500, 520, 500)]); + // 4 world units beyond x=520 along x; default slop 6. + expect(ids(w, "torus", cam, cursorOver(524, 500, cam))).toBe(1); + // 8 world units beyond x=520 → outside slop. + expect(ids(w, "torus", cam, cursorOver(528, 500, cam))).toBe(null); + }); +}); + +describe("hitTest — ordering", () => { + const cam = camAt(500, 500); + + test("higher priority wins over lower priority at equal distance", () => { + const w = new World(1000, 1000, [ + point(1, 500, 500, { priority: 0 }), + point(2, 500, 500, { priority: 5 }), + ]); + expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(2); + }); + + test("smaller distance wins at equal priority", () => { + const w = new World(1000, 1000, [point(1, 504, 500), point(2, 502, 500)]); + expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(2); + }); + + test("kind tie-break: point beats circle at exact distance and priority", () => { + const w = new World(1000, 1000, [ + circle(1, 500, 500, 0.0001, { style: { fillColor: 0xffffff } }), + point(2, 500, 500), + ]); + expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(2); + }); + + test("id tie-break: smaller id wins at exact tie", () => { + const w = new World(1000, 1000, [point(7, 500, 500), point(3, 500, 500)]); + expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(3); + }); +}); + +describe("hitTest — empty results and scale", () => { + const cam = camAt(500, 500); + + test("returns null when nothing matches", () => { + const w = new World(1000, 1000, [point(1, 100, 100)]); + expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(null); + }); + + test("higher zoom shrinks the on-screen slop in world units", () => { + // At scale=4, 8px on screen = 2 world units. + // A point 3 world units away misses. + const w = new World(1000, 1000, [point(1, 503, 500)]); + expect(ids(w, "torus", camAt(500, 500, 4), cursorOver(500, 500, camAt(500, 500, 4)))).toBe( + null, + ); + // A point 1.5 world units away hits at scale=4 (≤ 2). + const w2 = new World(1000, 1000, [point(1, 501.5, 500)]); + expect( + ids(w2, "torus", camAt(500, 500, 4), cursorOver(500, 500, camAt(500, 500, 4))), + ).toBe(1); + }); + + test("lower zoom widens the on-screen slop in world units", () => { + // At scale=0.5, 8px on screen = 16 world units. + const w = new World(1000, 1000, [point(1, 514, 500)]); + expect( + ids( + w, + "torus", + camAt(500, 500, 0.5), + cursorOver(500, 500, camAt(500, 500, 0.5)), + ), + ).toBe(1); + }); +}); diff --git a/ui/frontend/tests/map-math.test.ts b/ui/frontend/tests/map-math.test.ts new file mode 100644 index 0000000..620239d --- /dev/null +++ b/ui/frontend/tests/map-math.test.ts @@ -0,0 +1,106 @@ +// Unit tests for the geometry primitives in src/map/math.ts. +// +// These functions are the foundation for hit-test and the no-wrap +// camera helpers; they run far more often than their callers and any +// regression here ripples everywhere. Each test asserts a single +// algebraic property; the cases together cover the contract of the +// functions described in ui/docs/renderer.md. + +import { describe, expect, test } from "vitest"; +import { + clamp, + distSqPointToSegment, + screenToWorld, + torusShortestDelta, + worldToScreen, +} from "../src/map/math"; + +describe("clamp", () => { + test("returns the value when inside the bounds", () => { + expect(clamp(5, 0, 10)).toBe(5); + expect(clamp(0, 0, 10)).toBe(0); + expect(clamp(10, 0, 10)).toBe(10); + }); + test("clamps to the lower bound", () => { + expect(clamp(-3, 0, 10)).toBe(0); + }); + test("clamps to the upper bound", () => { + expect(clamp(13, 0, 10)).toBe(10); + }); +}); + +describe("torusShortestDelta", () => { + test("returns zero for equal inputs", () => { + expect(torusShortestDelta(50, 50, 100)).toBe(0); + }); + test("returns the direct delta when no wrap is shorter", () => { + expect(torusShortestDelta(10, 30, 100)).toBe(20); + expect(torusShortestDelta(30, 10, 100)).toBe(-20); + }); + test("wraps to the shorter direction near the seam", () => { + // from=10, to=90: direct=+80, wrap=-20 — wrap wins. + expect(torusShortestDelta(10, 90, 100)).toBe(-20); + // from=90, to=10: direct=-80, wrap=+20 — wrap wins. + expect(torusShortestDelta(90, 10, 100)).toBe(20); + }); + test("normalises inputs outside [0, size)", () => { + expect(torusShortestDelta(-10, 10, 100)).toBe(20); + expect(torusShortestDelta(110, 10, 100)).toBe(-100 + 100); // wraps to 0 + }); + test("at exactly size/2 picks the positive direction deterministically", () => { + // from=0, to=50, size=100 — both directions are equal. + // The contract: returns +size/2. + expect(torusShortestDelta(0, 50, 100)).toBe(50); + }); + test("rejects non-positive size", () => { + expect(() => torusShortestDelta(0, 0, 0)).toThrow(); + expect(() => torusShortestDelta(0, 0, -1)).toThrow(); + }); +}); + +describe("distSqPointToSegment", () => { + test("zero distance when the point is on the segment", () => { + expect(distSqPointToSegment(5, 0, 0, 0, 10, 0)).toBe(0); + expect(distSqPointToSegment(0, 0, 0, 0, 10, 0)).toBe(0); + expect(distSqPointToSegment(10, 0, 0, 0, 10, 0)).toBe(0); + }); + test("perpendicular foot inside the segment", () => { + // segment along the x-axis from (0,0) to (10,0); point at (5,3). + // foot is (5,0), distance is 3, distSq is 9. + expect(distSqPointToSegment(5, 3, 0, 0, 10, 0)).toBeCloseTo(9, 12); + }); + test("foot beyond the start endpoint clamps to start", () => { + expect(distSqPointToSegment(-2, 0, 0, 0, 10, 0)).toBeCloseTo(4, 12); + }); + test("foot beyond the end endpoint clamps to end", () => { + expect(distSqPointToSegment(15, 0, 0, 0, 10, 0)).toBeCloseTo(25, 12); + }); + test("zero-length segment falls back to point distance", () => { + expect(distSqPointToSegment(3, 4, 0, 0, 0, 0)).toBeCloseTo(25, 12); + }); +}); + +describe("screenToWorld and worldToScreen", () => { + const viewport = { widthPx: 800, heightPx: 600 }; + const camera = { centerX: 1000, centerY: 500, scale: 2 }; + + test("centre of viewport maps to camera centre in world space", () => { + const w = screenToWorld({ x: 400, y: 300 }, camera, viewport); + expect(w.x).toBeCloseTo(1000, 12); + expect(w.y).toBeCloseTo(500, 12); + }); + + test("worldToScreen is the inverse of screenToWorld", () => { + const screenIn = { x: 123.5, y: 456.25 }; + const world = screenToWorld(screenIn, camera, viewport); + const screenOut = worldToScreen(world, camera, viewport); + expect(screenOut.x).toBeCloseTo(screenIn.x, 9); + expect(screenOut.y).toBeCloseTo(screenIn.y, 9); + }); + + test("scale propagates: 2px on screen = 1 world unit at scale=2", () => { + const w0 = screenToWorld({ x: 400, y: 300 }, camera, viewport); + const w1 = screenToWorld({ x: 402, y: 300 }, camera, viewport); + expect(w1.x - w0.x).toBeCloseTo(1, 12); + }); +}); diff --git a/ui/frontend/tests/map-no-wrap.test.ts b/ui/frontend/tests/map-no-wrap.test.ts new file mode 100644 index 0000000..3bdc25d --- /dev/null +++ b/ui/frontend/tests/map-no-wrap.test.ts @@ -0,0 +1,109 @@ +// Unit tests for the no-wrap camera helpers in src/map/no-wrap.ts. +// +// The bounded-plane mode has three invariants that the helpers must +// uphold together: +// +// 1. The visible viewport stays inside the world rectangle, except +// when the visible viewport span exceeds the world span on an +// axis — in that case the camera centres on that axis. +// 2. minScaleNoWrap is the smallest scale at which the visible +// viewport fits the world along both axes. +// 3. pivotZoom keeps the world point under the cursor stable +// across a scale change. +// +// Each invariant is tested in isolation here; the renderer composes +// them in render.ts. + +import { describe, expect, test } from "vitest"; +import { screenToWorld } from "../src/map/math"; +import { clampCameraNoWrap, minScaleNoWrap, pivotZoom } from "../src/map/no-wrap"; +import { World } from "../src/map/world"; + +const world = new World(1000, 800); +const viewport = { widthPx: 400, heightPx: 300 }; + +describe("clampCameraNoWrap", () => { + test("leaves the camera unchanged when the viewport sits inside the world", () => { + const c = clampCameraNoWrap({ centerX: 500, centerY: 400, scale: 1 }, viewport, world); + expect(c.centerX).toBe(500); + expect(c.centerY).toBe(400); + }); + test("clamps the camera to the left edge", () => { + const c = clampCameraNoWrap({ centerX: 0, centerY: 400, scale: 1 }, viewport, world); + expect(c.centerX).toBe(viewport.widthPx / 2); + }); + test("clamps the camera to the right edge", () => { + const c = clampCameraNoWrap({ centerX: 9999, centerY: 400, scale: 1 }, viewport, world); + expect(c.centerX).toBe(world.width - viewport.widthPx / 2); + }); + test("clamps the camera to the top edge", () => { + const c = clampCameraNoWrap({ centerX: 500, centerY: -50, scale: 1 }, viewport, world); + expect(c.centerY).toBe(viewport.heightPx / 2); + }); + test("clamps the camera to the bottom edge", () => { + const c = clampCameraNoWrap({ centerX: 500, centerY: 9999, scale: 1 }, viewport, world); + expect(c.centerY).toBe(world.height - viewport.heightPx / 2); + }); + test("centres the camera on an axis when the viewport span exceeds world span", () => { + // At scale=0.1, viewport.widthPx/scale = 4000 world units > world.width=1000. + const c = clampCameraNoWrap({ centerX: 999, centerY: 999, scale: 0.1 }, viewport, world); + expect(c.centerX).toBe(world.width / 2); + expect(c.centerY).toBe(world.height / 2); + }); + test("does not modify scale", () => { + const c = clampCameraNoWrap({ centerX: 999, centerY: 999, scale: 0.5 }, viewport, world); + expect(c.scale).toBe(0.5); + }); +}); + +describe("minScaleNoWrap", () => { + test("equals the larger axis ratio (width-bound)", () => { + // world 1000×800, viewport 400×300: + // width ratio = 0.4, height ratio = 0.375 — width wins. + expect(minScaleNoWrap(viewport, world)).toBeCloseTo(0.4, 12); + }); + test("equals the larger axis ratio (height-bound)", () => { + // world 100×100, viewport 200×400: height ratio = 4 wins over width = 2. + expect(minScaleNoWrap({ widthPx: 200, heightPx: 400 }, new World(100, 100))).toBeCloseTo( + 4, + 12, + ); + }); +}); + +describe("pivotZoom", () => { + const camera = { centerX: 500, centerY: 400, scale: 1 }; + + test("keeps the world point under the cursor stable", () => { + const cursor = { x: 100, y: 250 }; + const before = screenToWorld(cursor, camera, viewport); + const newCam = pivotZoom(camera, viewport, cursor, 2.5); + const after = screenToWorld(cursor, newCam, viewport); + expect(after.x).toBeCloseTo(before.x, 9); + expect(after.y).toBeCloseTo(before.y, 9); + expect(newCam.scale).toBe(2.5); + }); + + test("invariant holds when the cursor sits at the viewport centre", () => { + const cursor = { x: viewport.widthPx / 2, y: viewport.heightPx / 2 }; + const before = screenToWorld(cursor, camera, viewport); + const newCam = pivotZoom(camera, viewport, cursor, 0.4); + const after = screenToWorld(cursor, newCam, viewport); + expect(after.x).toBeCloseTo(before.x, 9); + expect(after.y).toBeCloseTo(before.y, 9); + }); + + test("invariant holds at the viewport corner", () => { + const cursor = { x: 0, y: 0 }; + const before = screenToWorld(cursor, camera, viewport); + const newCam = pivotZoom(camera, viewport, cursor, 7.7); + const after = screenToWorld(cursor, newCam, viewport); + expect(after.x).toBeCloseTo(before.x, 9); + expect(after.y).toBeCloseTo(before.y, 9); + }); + + test("rejects non-positive scale", () => { + expect(() => pivotZoom(camera, viewport, { x: 0, y: 0 }, 0)).toThrow(); + expect(() => pivotZoom(camera, viewport, { x: 0, y: 0 }, -1)).toThrow(); + }); +}); diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 8b1e235..7ea412a 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -14,6 +14,12 @@ importers: idb: specifier: ^8.0.3 version: 8.0.3 + pixi-viewport: + specifier: ^6.0.3 + version: 6.0.3(pixi.js@8.18.1) + pixi.js: + specifier: ^8.18.1 + version: 8.18.1 devDependencies: '@bufbuild/protobuf': specifier: ^2.12.0 @@ -185,6 +191,9 @@ packages: '@oxc-project/types@0.127.0': resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} + '@pixi/colord@2.9.6': + resolution: {integrity: sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==} + '@playwright/test@1.59.1': resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} engines: {node: '>=18'} @@ -369,6 +378,9 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/earcut@3.0.0': + resolution: {integrity: sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -412,6 +424,13 @@ packages: '@vitest/utils@4.1.5': resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} + '@webgpu/types@0.1.69': + resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==} + + '@xmldom/xmldom@0.8.13': + resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} + engines: {node: '>=10.0.0'} + acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} @@ -526,6 +545,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + earcut@3.0.2: + resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} + entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} @@ -563,6 +585,9 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -608,6 +633,9 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + gifuct-js@2.1.2: + resolution: {integrity: sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -653,6 +681,12 @@ packages: is-reference@3.0.3: resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + ismobilejs@1.1.1: + resolution: {integrity: sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==} + + js-binary-schema-parser@2.0.3: + resolution: {integrity: sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -794,6 +828,9 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + parse-svg-path@0.1.2: + resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -807,6 +844,14 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pixi-viewport@6.0.3: + resolution: {integrity: sha512-2+qPJ0/n+8hQYhWvY+795+x9y3MiUrCOWacK0DY53whowWaGdx9iDocy7z1pBwjkZhC52YvrJQuZKK0sdVLtBw==} + peerDependencies: + pixi.js: '>=8' + + pixi.js@8.18.1: + resolution: {integrity: sha512-6LUPWYgulZhp/w4kam2XHXB0QedISZIqrJbRdHLLQ3csn5a38uzKxAp6B5j6s89QFYaIJbg95kvgTRcbgpO1ow==} + playwright-core@1.59.1: resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} engines: {node: '>=18'} @@ -901,6 +946,10 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tiny-lru@11.4.7: + resolution: {integrity: sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==} + engines: {node: '>=12'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1204,6 +1253,8 @@ snapshots: '@oxc-project/types@0.127.0': {} + '@pixi/colord@2.9.6': {} + '@playwright/test@1.59.1': dependencies: playwright: 1.59.1 @@ -1349,6 +1400,8 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/earcut@3.0.0': {} + '@types/estree@1.0.8': {} '@types/node@22.19.17': @@ -1405,6 +1458,10 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@webgpu/types@0.1.69': {} + + '@xmldom/xmldom@0.8.13': {} + acorn@8.16.0: {} agent-base@7.1.4: {} @@ -1484,6 +1541,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + earcut@3.0.2: {} + entities@6.0.1: {} es-define-property@1.0.1: {} @@ -1513,6 +1572,8 @@ snapshots: dependencies: '@types/estree': 1.0.8 + eventemitter3@5.0.4: {} + expect-type@1.3.0: {} fake-indexeddb@6.2.5: {} @@ -1557,6 +1618,10 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + gifuct-js@2.1.2: + dependencies: + js-binary-schema-parser: 2.0.3 + gopd@1.2.0: {} has-symbols@1.1.0: {} @@ -1601,6 +1666,10 @@ snapshots: dependencies: '@types/estree': 1.0.8 + ismobilejs@1.1.1: {} + + js-binary-schema-parser@2.0.3: {} + js-tokens@4.0.0: {} jsdom@25.0.1: @@ -1714,6 +1783,8 @@ snapshots: obug@2.1.1: {} + parse-svg-path@0.1.2: {} + parse5@7.3.0: dependencies: entities: 6.0.1 @@ -1724,6 +1795,23 @@ snapshots: picomatch@4.0.4: {} + pixi-viewport@6.0.3(pixi.js@8.18.1): + dependencies: + pixi.js: 8.18.1 + + pixi.js@8.18.1: + dependencies: + '@pixi/colord': 2.9.6 + '@types/earcut': 3.0.0 + '@webgpu/types': 0.1.69 + '@xmldom/xmldom': 0.8.13 + earcut: 3.0.2 + eventemitter3: 5.0.4 + gifuct-js: 2.1.2 + ismobilejs: 1.1.1 + parse-svg-path: 0.1.2 + tiny-lru: 11.4.7 + playwright-core@1.59.1: {} playwright@1.59.1: @@ -1845,6 +1933,8 @@ snapshots: symbol-tree@3.2.4: {} + tiny-lru@11.4.7: {} + tinybench@2.9.0: {} tinyexec@1.1.2: {}