// 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') // strokeDashPx — when set on a `LinePrim`, the line is rendered as // a dashed pattern whose dash and gap are both this length. When // unset (or zero), the stroke is solid. Interpreted in the same // world-unit space as `strokeWidthPx`, so the dash spacing scales // with the camera. Phase 19 uses this for the IncomingGroup // trajectory line; ignored on point and circle primitives. strokeDashPx?: number; } // 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. Added on top // of the visible footprint of each primitive — for points, the // effective hit radius is `pointRadiusPx + slopPx`. Chosen for touch // ergonomics; per-primitive `hitSlopPx` overrides the default. export const DEFAULT_HIT_SLOP_PX: Record = { point: 4, circle: 6, line: 6, }; // Default world-unit radius drawn for a `PointPrim` when its // `style.pointRadiusPx` is unset. Shared between the renderer // (`render.ts.drawPoint`) and the hit-test // (`hit-test.ts.matchPoint`) so the click target always covers the // visible disc. export const DEFAULT_POINT_RADIUS_PX = 3; // kindOrder is the deterministic tie-break order used during hit-test // when two primitives match a cursor at identical priority and // distance. Smaller value wins. export const KIND_ORDER: Record = { point: 0, line: 1, circle: 2, }; // Camera describes the world point at the centre of the viewport and // the scale (pixels per world unit). Pan/zoom mutate this struct; // `pixi-viewport` keeps its own internal state and we mirror it here // for hit-test and for tests that read camera state directly. export interface Camera { centerX: number; centerY: number; scale: number; } export interface Viewport { widthPx: number; heightPx: number; } // World is the immutable container of primitives plus the toroidal // dimensions. The renderer reindexes nothing — the brute-force // hit-test walks all primitives on every pointer event, which is // adequate for the ~1000-primitive Phase 9 budget. export class World { readonly width: number; readonly height: number; readonly primitives: Primitive[]; constructor(width: number, height: number, primitives: Primitive[] = []) { if (!(width > 0) || !(height > 0)) { throw new Error(`World: width and height must be positive, got ${width}×${height}`); } this.width = width; this.height = height; this.primitives = primitives; } } // Theme is the renderer's colour palette. It carries both the generic // fallbacks used when a primitive's `style` omits a colour and the // semantic colours every primitive builder paints with (planets, ship // groups, cargo routes, battle / bombing markers, reach + selection // rings, pending-Send tracks, and the pick-mode overlay). Two concrete // palettes are shipped — `DARK_THEME` and `LIGHT_THEME` — and the map // view selects between them from the resolved app theme // (`$lib/theme/theme.svelte.ts`), so the canvas follows the user's // light / dark choice like the rest of the chrome. // // Only colours live here: per-primitive alphas, widths, and radii are // emphasis / geometry, not theme, and stay as constants in the builder // modules. The light palette mirrors the dark one role-for-role but // darkens / saturates each hue so it reads against a light background; // the incoming-group, battle, and bombing accents stay deliberately // vivid in both palettes. export interface Theme { // Canvas background and the visibility-fog veil drawn over // unscanned hyperspace. background: number; fog: number; // Generic fallbacks for primitives whose `style` omits a colour. pointFill: number; circleStroke: number; lineStroke: number; // Planet glyphs, one colour per `ReportPlanet.kind`. planetLocal: number; planetOther: number; planetUninhabited: number; planetUnidentified: number; // Ship groups. The in-space track reuses `shipLocal` and the // incoming trajectory line reuses `shipIncoming`. shipLocal: number; shipOther: number; shipIncoming: number; shipUnidentified: number; // Cargo-route arrows, one colour per load type. routeCol: number; routeCap: number; routeMat: number; routeEmp: number; // Battle X-crosses and bombing rings (damaged vs wiped). battleMarker: number; bombingDamaged: number; bombingWiped: number; // Reach rings, the selected-planet ring, and pending-Send tracks. reachCircle: number; selectionRing: number; pendingSend: number; // Pick-mode overlay: the anchor / cursor-line / hover highlight // colour and the multiply tint applied to non-reachable primitives. pickHighlight: number; pickDimTint: number; } export const DARK_THEME: Theme = { background: 0x0a0e1a, fog: 0x12162a, pointFill: 0xe8eaf6, circleStroke: 0x4fc3f7, lineStroke: 0xa5d6a7, planetLocal: 0x6dd2ff, planetOther: 0xff8a65, planetUninhabited: 0xb0bec5, planetUnidentified: 0x546e7a, shipLocal: 0xfff176, shipOther: 0xff6f40, shipIncoming: 0xff5252, shipUnidentified: 0x9aa3a8, routeCol: 0x4fc3f7, routeCap: 0xffb74d, routeMat: 0x81c784, routeEmp: 0x90a4ae, battleMarker: 0xffd400, bombingDamaged: 0xffd400, bombingWiped: 0xff3030, reachCircle: 0x6d8cff, selectionRing: 0x6d8cff, pendingSend: 0x66bb6a, pickHighlight: 0xffe082, pickDimTint: 0x303841, }; // LIGHT_THEME mirrors DARK_THEME role-for-role. The background matches // the app's light shell background (`--color-bg` in `tokens.css`) so // the canvas blends into the surrounding chrome instead of reading as a // dark rectangle; the fog is a faint darkening over the lighter base. // Hues are darkened / saturated relative to the dark palette so small // glyphs and thin strokes stay legible on a light surface, while the // incoming (red), battle (amber), and bombing (amber / red) accents are // kept vivid. Values are a first pass meant to be refined during the // owner's F8 manual-QA loop. export const LIGHT_THEME: Theme = { background: 0xf3f5fb, fog: 0xe2e7f1, pointFill: 0x1a2138, circleStroke: 0x1565c0, lineStroke: 0x2e7d32, planetLocal: 0x1565c0, planetOther: 0xe64a19, planetUninhabited: 0x78909c, planetUnidentified: 0x90a4ae, shipLocal: 0xc79100, shipOther: 0xd84315, shipIncoming: 0xd50000, shipUnidentified: 0x607d8b, routeCol: 0x0288d1, routeCap: 0xef6c00, routeMat: 0x2e7d32, routeEmp: 0x607d8b, battleMarker: 0xf57f17, bombingDamaged: 0xf57f17, bombingWiped: 0xc62828, reachCircle: 0x3949ab, selectionRing: 0x3949ab, pendingSend: 0x388e3c, pickHighlight: 0xef6c00, pickDimTint: 0xaeb6c4, };