f6e4a4f6bd
The map view now selects a DARK_THEME or LIGHT_THEME palette from the resolved app theme and threads it through every primitive builder, so the canvas, planets, ship groups, cargo routes, battle/bombing markers, fog, reach + selection rings, pending-Send tracks, and the pick overlay all switch with the rest of the chrome. A theme flip remounts the renderer preserving the camera — Pixi bakes the background at init and every primitive bakes its colour at build, so a live re-tint is not possible on the same instance. This also fixes the reported bug: the gear-popover trigger and the loading overlay hardcoded a dark navy background, so in light theme the gear was invisible (dark icon on dark chip) until hover flipped it to a white chip. Both now use the --color-surface-overlay token and read correctly in both themes. The light palette mirrors the dark one role-for-role, darkened / saturated for contrast on a light background while keeping the incoming, battle, and bombing accents vivid. The values are a first pass meant to be refined during the F8 manual-QA loop. Removes the now-dead "Phase 35" references from the code and lifts the map-recoloring prohibition from the design-system / renderer docs; the battle scene stays a fixed-palette data-viz surface. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
252 lines
8.2 KiB
TypeScript
252 lines
8.2 KiB
TypeScript
// 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<PrimitiveKind, number> = {
|
||
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<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 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,
|
||
};
|