ui/phase-9: PixiJS map renderer with torus and no-wrap modes
Stand up the vector map renderer in ui/frontend/src/map/ on top of PixiJS v8 + pixi-viewport@^6. Torus mode renders nine container copies for seamless wrap; no-wrap mode pins the camera at world bounds and centres on an axis when the viewport exceeds the world along that axis. Hit-test is a brute-force pass with deterministic [-priority, distSq, kindOrder, id] ordering and torus-shortest distance, validated by hand-built unit cases. The development playground at /__debug/map exposes a window debug surface for the Playwright spec, which forces WebGPU on chromium-desktop, WebGL on webkit-desktop, and accepts the auto-picked backend on mobile projects. Algorithm spec lives in ui/docs/renderer.md, which also pins the new deprecation status of galaxy/client (the entire Fyne client module, including client/world). client/world/README.md and the Phase 9 stub in ui/PLAN.md gain matching deprecation banners. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
+59
-40
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<readonly [number, number]> = [
|
||||
[-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<RendererHandle> {
|
||||
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 });
|
||||
}
|
||||
@@ -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<PrimitiveKind, number> = {
|
||||
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<PrimitiveKind, number> = {
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,195 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { page } from "$app/state";
|
||||
import {
|
||||
createRenderer,
|
||||
sampleWorld,
|
||||
type RendererHandle,
|
||||
type RendererPreference,
|
||||
type WrapMode,
|
||||
} from "../../../map/index";
|
||||
|
||||
interface DebugMapSurface {
|
||||
ready: true;
|
||||
getMode(): WrapMode;
|
||||
setMode(mode: WrapMode): 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;
|
||||
}
|
||||
|
||||
type DebugMapWindow = typeof globalThis & { __galaxyMap?: DebugMapSurface };
|
||||
|
||||
let canvasEl: HTMLCanvasElement | null = $state(null);
|
||||
let containerEl: HTMLDivElement | null = $state(null);
|
||||
let mode: WrapMode = $state("torus");
|
||||
let backend = $state("");
|
||||
let initError: string | null = $state(null);
|
||||
|
||||
let handle: RendererHandle | null = null;
|
||||
let onResize: (() => void) | null = null;
|
||||
|
||||
function readPreference(): RendererPreference | RendererPreference[] {
|
||||
const v = page.url.searchParams.get("renderer");
|
||||
if (v === "webgl") return "webgl";
|
||||
if (v === "webgpu") return "webgpu";
|
||||
return ["webgpu", "webgl"];
|
||||
}
|
||||
|
||||
function describe(err: unknown): string {
|
||||
if (err instanceof Error) return `${err.name}: ${err.message}`;
|
||||
return String(err);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
(async () => {
|
||||
if (canvasEl === null || containerEl === null) return;
|
||||
const world = sampleWorld();
|
||||
try {
|
||||
handle = await createRenderer({
|
||||
canvas: canvasEl,
|
||||
world,
|
||||
mode,
|
||||
preference: readPreference(),
|
||||
});
|
||||
} catch (err) {
|
||||
initError = describe(err);
|
||||
return;
|
||||
}
|
||||
backend = handle.getBackend();
|
||||
|
||||
// Initial camera: place world centre.
|
||||
handle.viewport.moveCenter(world.width / 2, world.height / 2);
|
||||
// Initial zoom: fit-ish (slight zoom-in from minScale).
|
||||
const minScale = Math.max(
|
||||
containerEl.clientWidth / world.width,
|
||||
containerEl.clientHeight / world.height,
|
||||
);
|
||||
handle.viewport.setZoom(minScale * 1.2, true);
|
||||
if (mode === "no-wrap") handle.setMode("no-wrap"); // re-clamp post zoom
|
||||
|
||||
const surface: DebugMapSurface = {
|
||||
ready: true,
|
||||
getMode: () => handle?.getMode() ?? "torus",
|
||||
setMode: (m) => {
|
||||
if (handle === null) return;
|
||||
handle.setMode(m);
|
||||
mode = m;
|
||||
},
|
||||
getCamera: () => handle?.getCamera() ?? { centerX: 0, centerY: 0, scale: 1 },
|
||||
getViewport: () =>
|
||||
handle?.getViewport() ?? { widthPx: 0, heightPx: 0 },
|
||||
getBackend: () => handle?.getBackend() ?? "",
|
||||
getWorldSize: () => ({ width: world.width, height: world.height }),
|
||||
hitAt: (x, y) => {
|
||||
if (handle === null) return null;
|
||||
const hit = handle.hitAt({ x, y });
|
||||
return hit?.primitive.id ?? null;
|
||||
},
|
||||
};
|
||||
(window as DebugMapWindow).__galaxyMap = surface;
|
||||
|
||||
onResize = (): void => {
|
||||
if (handle === null || containerEl === null) return;
|
||||
handle.resize(containerEl.clientWidth, containerEl.clientHeight);
|
||||
};
|
||||
window.addEventListener("resize", onResize);
|
||||
})();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (onResize !== null) {
|
||||
window.removeEventListener("resize", onResize);
|
||||
onResize = null;
|
||||
}
|
||||
if (handle !== null) {
|
||||
handle.dispose();
|
||||
handle = null;
|
||||
}
|
||||
const w = window as DebugMapWindow;
|
||||
if (w.__galaxyMap !== undefined) delete w.__galaxyMap;
|
||||
});
|
||||
|
||||
function toggleMode(): void {
|
||||
if (handle === null) return;
|
||||
const next: WrapMode = mode === "torus" ? "no-wrap" : "torus";
|
||||
handle.setMode(next);
|
||||
mode = next;
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<header>
|
||||
<h1>map debug</h1>
|
||||
<div class="controls">
|
||||
<button type="button" data-testid="mode-toggle" onclick={toggleMode}>
|
||||
mode: {mode}
|
||||
</button>
|
||||
<span data-testid="backend" data-backend={backend}>backend: {backend || "…"}</span>
|
||||
</div>
|
||||
</header>
|
||||
{#if initError !== null}
|
||||
<p class="error" data-testid="init-error">{initError}</p>
|
||||
{/if}
|
||||
<div class="canvas-wrap" bind:this={containerEl}>
|
||||
<canvas bind:this={canvasEl}></canvas>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
font-family: system-ui, sans-serif;
|
||||
color: #e8eaf6;
|
||||
background: #0a0e1a;
|
||||
}
|
||||
header {
|
||||
padding: 0.5rem 1rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #20253a;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
button {
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: #1c2238;
|
||||
color: #e8eaf6;
|
||||
border: 1px solid #2a3150;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
button:hover {
|
||||
background: #232b48;
|
||||
}
|
||||
.canvas-wrap {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.error {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #4a1820;
|
||||
color: #ffb4b4;
|
||||
}
|
||||
</style>
|
||||
@@ -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<void> {
|
||||
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();
|
||||
});
|
||||
@@ -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> = {},
|
||||
): 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> = {},
|
||||
): 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> = {},
|
||||
): 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Generated
+90
@@ -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: {}
|
||||
|
||||
Reference in New Issue
Block a user