feat(ui): F8-12 — map polish (zoom invariance, labels, selection, soft radius) (#55)
* 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:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user