// 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. // // `strokeWidthPx` / `pointRadiusPx` are honest screen-pixel sizes // since F8-12 (#28): the renderer divides them by the current camera // scale before drawing, and rebuilds the affected `Graphics` whenever // the camera zooms. This keeps thin lines crisp and small markers // readable across the whole zoom range — the camera-relative // thickening that the old contract promised but never delivered is // gone. // // `pointRadiusBasePx` is the opposite intent: a planet's known // `size` produces a base on-screen pixel radius at the "whole world // fits" reference zoom, and the renderer grows it sub-linearly with // the camera scale through `PLANET_SIZE_ZOOM_ALPHA` (F8-12 / #31). // When `pointRadiusBasePx` is set on a `PointPrim`, `pointRadiusPx` // is ignored. export interface Style { fillColor?: number; // 0xRRGGBB fillAlpha?: number; // 0..1 strokeColor?: number; // 0xRRGGBB strokeAlpha?: number; // 0..1 strokeWidthPx?: number; // screen pixels at any zoom pointRadiusPx?: number; // screen pixels at any zoom (for kind === 'point') pointRadiusBasePx?: number; // screen pixels at scaleRef, softened by PLANET_SIZE_ZOOM_ALPHA // 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 world-unit // space — the dash spacing scales with the camera. Phase 19 uses // this for the IncomingGroup trajectory line; ignored on point // and circle primitives. strokeDashPx?: number; // softLengthAnchor — when set on a `LinePrim`, the renderer treats // the world-coord endpoints as the line length "at the reference // scale" and grows / shrinks them with `PLANET_SIZE_ZOOM_ALPHA` // the same way planet discs do. `'center'` scales both endpoints // around the segment midpoint (used by battle X-crosses anchored // on the planet centre); `'start'` keeps `(x1, y1)` fixed and // only scales `(x2, y2)` along the original direction (used by // cargo-route arrowhead wings anchored at the destination). softLengthAnchor?: "center" | "start"; } // 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 the bombing accent (damaged vs wiped). The // bombing accent is now drawn as the planet's outline rather than a // separate ring (F8-12 / issue #55, п.30). battleMarker: number; bombingDamaged: number; bombingWiped: number; // Reach rings, the selection accent (planet outline + label frame), // and pending-Send tracks. `selectionRing` is kept around for the // soon-to-be-removed `selection-ring.ts` and the test that locks // the colour; both lines disappear once the label-driven selection // lands. reachCircle: number; selectionRing: number; selectionAccent: number; pendingSend: number; // Planet label colours. `labelText` paints the primary line // (planet name when the toggle is on), `labelMuted` paints the // `#N` companion line. The inverse pair fills the rounded frame // drawn around the selected planet's label (background = the // selection accent, text = the canvas background colour). labelText: number; labelMuted: number; labelInverseText: number; labelInverseBackground: number; // Pick-mode overlay: the anchor / cursor-line / hover highlight // colour and the multiply tint applied to non-reachable primitives. pickHighlight: number; pickDimTint: number; } /** * PLANET_SIZE_ZOOM_ALPHA is the exponent that softens the on-screen * growth of known-size planets with the camera scale (F8-12 / п.31). * `α = 1` keeps the historical linear-with-zoom behaviour; `α = 0` * would make planets fully zoom-invariant. 0.33 (cube-root soft scaling * relative to `scale_ref`) is the owner-approved starting point — it * gives a noticeable, but moderated, growth as the user zooms in. The * constant lives next to the themes so the tuning knob is in one * obvious place. */ export const PLANET_SIZE_ZOOM_ALPHA = 0.33; /** * displayPointRadiusWorld returns the world-space radius the renderer * should draw a `PointPrim` with at the current camera scale. When * `style.pointRadiusBasePx` is set (known-size planets), the radius * is the base pixel size at `scaleRef`, grown by * `(scale / scaleRef)^α` and converted back into world units — * `α = PLANET_SIZE_ZOOM_ALPHA`. At `scale = scaleRef` the visible * pixel size equals the base; a 10× zoom-in only grows it ~2.15×. * Otherwise the radius collapses to `pointRadiusPx / cameraScale` so * the on-screen disc stays the same pixel size regardless of zoom. * * Used by both the renderer (`render.ts:drawPoint`) and the hit-test * (`hit-test.ts:matchPoint`) so the visible disc and the click zone * always agree. */ export function displayPointRadiusWorld( style: Style, cameraScale: number, scaleRef: number, ): number { if (cameraScale <= 0) { return style.pointRadiusBasePx ?? style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX; } if (style.pointRadiusBasePx !== undefined) { const refScale = scaleRef > 0 ? scaleRef : cameraScale; const screenPx = style.pointRadiusBasePx * Math.pow(cameraScale / refScale, PLANET_SIZE_ZOOM_ALPHA); return screenPx / cameraScale; } const px = style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX; return px / cameraScale; } /** * displayStrokeWidthWorld converts `style.strokeWidthPx` (a screen-pixel * thickness, F8-12 / #28) into the world-space width the renderer * passes to `g.stroke({...})`. The renderer redraws strokes on every * `viewport.zoomed` so the on-screen thickness stays constant. */ export function displayStrokeWidthWorld( style: Style, cameraScale: number, ): number { const px = style.strokeWidthPx ?? 1; if (cameraScale <= 0) return px; return px / cameraScale; } /** * softLengthFactor returns the multiplier that scales a line's * length when `style.softLengthAnchor` is set. The factor matches * the planet-radius softening rule: at `scale = scaleRef` it equals * `1` (the recorded geometry is the reference length); zooming in * shrinks the world-space length so the on-screen length grows by * `(scale / scaleRef)^α`. `displayLineEndpoints` is the convenience * wrapper that applies it to a line's `(x1, y1)–(x2, y2)` pair * given the configured anchor. */ export function softLengthFactor( cameraScale: number, scaleRef: number, ): number { if (cameraScale <= 0 || scaleRef <= 0) return 1; return Math.pow(cameraScale / scaleRef, PLANET_SIZE_ZOOM_ALPHA - 1); } /** * displayLineEndpoints returns the world-space endpoints the * renderer should draw a `LinePrim` between, honouring * `style.softLengthAnchor` if set. Used by both the renderer and * the hit-test so the click zone always matches the visible stroke. */ export function displayLineEndpoints( style: Style, x1: number, y1: number, x2: number, y2: number, cameraScale: number, scaleRef: number, ): { x1: number; y1: number; x2: number; y2: number } { if (style.softLengthAnchor === undefined) { return { x1, y1, x2, y2 }; } const factor = softLengthFactor(cameraScale, scaleRef); if (factor === 1) return { x1, y1, x2, y2 }; if (style.softLengthAnchor === "start") { return { x1, y1, x2: x1 + (x2 - x1) * factor, y2: y1 + (y2 - y1) * factor, }; } const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; return { x1: cx + (x1 - cx) * factor, y1: cy + (y1 - cy) * factor, x2: cx + (x2 - cx) * factor, y2: cy + (y2 - cy) * factor, }; } 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, selectionAccent: 0x6d8cff, pendingSend: 0x66bb6a, labelText: 0xc7d2e0, labelMuted: 0x90a4ae, labelInverseText: 0x0a0e1a, labelInverseBackground: 0x6d8cff, 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, selectionAccent: 0x3949ab, pendingSend: 0x388e3c, labelText: 0x263240, labelMuted: 0x5a6d8a, labelInverseText: 0xf3f5fb, labelInverseBackground: 0x3949ab, pickHighlight: 0xef6c00, pickDimTint: 0xaeb6c4, };