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:
Ilia Denisov
2026-05-08 14:06:23 +02:00
parent 9d2504c42d
commit db415f8aa4
17 changed files with 2064 additions and 41 deletions
+132
View File
@@ -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,
};