feat(ui): F8-12 — map polish (zoom invariance, labels, selection, soft radius) (#55)
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Failing after 5m16s

* Honest pixel-space sizing for `pointRadiusPx` / `strokeWidthPx`: the
  renderer divides by the current camera scale on every
  `viewport.zoomed` so thin lines / small markers stay the same on-screen
  size at any zoom.
* Known-size planets switch to `pointRadiusWorld`, softened against the
  reference scale by `PLANET_SIZE_ZOOM_ALPHA = 0.33`; unidentified
  planets pin to a 3-px disc.
* New planet label layer renders a two-line `name / #N` legend under
  each planet (`#N` only for unidentified or when the new `planetNames`
  toggle is off). Selection now paints an inverse-fill frame around the
  selected planet's label plus an outline on the disc; the old
  selection-ring primitive is retired.
* Bombing markers swap the separate CirclePrim for a planet-outline
  overlay (damaged / wiped colour); the report deep-link moves to a
  "view bombing report" link in the planet inspector.
* Docs + tests follow: `renderer.md` reflects the new sizing contract +
  label / outline layers, vitest covers the sizing math, label
  formatting, and the new toggle, and the map-toggles e2e adds a
  persistence case for `planetNames`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-27 23:51:16 +02:00
parent ba93a9092e
commit 680ebac919
30 changed files with 1240 additions and 322 deletions
+104 -8
View File
@@ -17,19 +17,34 @@ 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.
//
// `pointRadiusWorld` is the opposite intent: a planet's known
// `size` produces a base radius in world units, and the renderer
// softens its growth with the camera scale through
// `PLANET_SIZE_ZOOM_ALPHA` (F8-12 / #31). When `pointRadiusWorld`
// 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; // pixels at any zoom
pointRadiusPx?: number; // pixels at any zoom (for kind === 'point')
strokeWidthPx?: number; // screen pixels at any zoom
pointRadiusPx?: number; // screen pixels at any zoom (for kind === 'point')
pointRadiusWorld?: number; // world units, 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 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.
// 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;
}
@@ -171,20 +186,91 @@ export interface Theme {
routeCap: number;
routeMat: number;
routeEmp: number;
// Battle X-crosses and bombing rings (damaged vs wiped).
// 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 selected-planet ring, and pending-Send tracks.
// 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.pointRadiusWorld` is set (known-size planets), the radius is
* the base world radius softened by `PLANET_SIZE_ZOOM_ALPHA` relative
* to `scaleRef` — at `scale = scaleRef` it equals the base radius;
* zooming in grows it sub-linearly. 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 (style.pointRadiusWorld !== undefined) {
const softening = Math.pow(cameraScale / scaleRef, PLANET_SIZE_ZOOM_ALPHA - 1);
return style.pointRadiusWorld * softening;
}
const px = style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX;
if (cameraScale <= 0) return 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;
}
export const DARK_THEME: Theme = {
background: 0x0a0e1a,
fog: 0x12162a,
@@ -208,7 +294,12 @@ export const DARK_THEME: Theme = {
bombingWiped: 0xff3030,
reachCircle: 0x6d8cff,
selectionRing: 0x6d8cff,
selectionAccent: 0x6d8cff,
pendingSend: 0x66bb6a,
labelText: 0xc7d2e0,
labelMuted: 0x90a4ae,
labelInverseText: 0x0a0e1a,
labelInverseBackground: 0x6d8cff,
pickHighlight: 0xffe082,
pickDimTint: 0x303841,
};
@@ -245,7 +336,12 @@ export const LIGHT_THEME: Theme = {
bombingWiped: 0xc62828,
reachCircle: 0x3949ab,
selectionRing: 0x3949ab,
selectionAccent: 0x3949ab,
pendingSend: 0x388e3c,
labelText: 0x263240,
labelMuted: 0x5a6d8a,
labelInverseText: 0xf3f5fb,
labelInverseBackground: 0x3949ab,
pickHighlight: 0xef6c00,
pickDimTint: 0xaeb6c4,
};