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:
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user