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
@@ -0,0 +1,83 @@
// Coverage for the F8-12 sizing helpers in src/map/world.ts:
// `displayPointRadiusWorld` (the union of the pixel-space and the
// softened-by-zoom rules) and `displayStrokeWidthWorld` (pixel-space
// stroke widths). Both are pure math, so this file stays Pixi-free.
import { describe, expect, test } from "vitest";
import {
DEFAULT_POINT_RADIUS_PX,
PLANET_SIZE_ZOOM_ALPHA,
displayPointRadiusWorld,
displayStrokeWidthWorld,
} from "../src/map/world";
describe("displayPointRadiusWorld — pixel-space (pointRadiusPx)", () => {
test("returns pixel size divided by scale at scale=1", () => {
expect(displayPointRadiusWorld({ pointRadiusPx: 5 }, 1, 0.2)).toBe(5);
});
test("shrinks the world footprint as zoom grows", () => {
expect(displayPointRadiusWorld({ pointRadiusPx: 6 }, 3, 0.2)).toBeCloseTo(2);
});
test("falls back to DEFAULT_POINT_RADIUS_PX when the style is bare", () => {
expect(displayPointRadiusWorld({}, 2, 0.2)).toBeCloseTo(
DEFAULT_POINT_RADIUS_PX / 2,
);
});
test("zero-scale guard returns the raw pixel size", () => {
expect(displayPointRadiusWorld({ pointRadiusPx: 4 }, 0, 0.2)).toBe(4);
});
});
describe("displayPointRadiusWorld — softened by zoom (pointRadiusWorld)", () => {
test("at scale=scaleRef the visible radius equals the base radius", () => {
const radius = displayPointRadiusWorld(
{ pointRadiusWorld: 6 },
0.2,
0.2,
);
expect(radius).toBeCloseTo(6);
});
test("zooming in grows the radius sub-linearly", () => {
const r1 = displayPointRadiusWorld({ pointRadiusWorld: 6 }, 0.2, 0.2);
const r10 = displayPointRadiusWorld({ pointRadiusWorld: 6 }, 2.0, 0.2);
// On-screen pixel size grows by scale^α (α = 0.33) instead of
// linearly: 10x zoom → ~10^0.33 ≈ 2.15x growth.
const onScreenAt1 = r1 * 0.2;
const onScreenAt10 = r10 * 2.0;
expect(onScreenAt10 / onScreenAt1).toBeCloseTo(
Math.pow(10, PLANET_SIZE_ZOOM_ALPHA),
3,
);
});
test("ignores pointRadiusPx when pointRadiusWorld is set", () => {
const r = displayPointRadiusWorld(
{ pointRadiusPx: 99, pointRadiusWorld: 4 },
0.4,
0.2,
);
// World radius is the base softened by (0.4/0.2)^(α-1).
expect(r).toBeCloseTo(4 * Math.pow(2, PLANET_SIZE_ZOOM_ALPHA - 1), 4);
});
});
describe("displayStrokeWidthWorld", () => {
test("returns width / scale at any zoom", () => {
expect(displayStrokeWidthWorld({ strokeWidthPx: 2 }, 1)).toBe(2);
expect(displayStrokeWidthWorld({ strokeWidthPx: 2 }, 4)).toBeCloseTo(0.5);
expect(displayStrokeWidthWorld({ strokeWidthPx: 2 }, 0.5)).toBeCloseTo(4);
});
test("falls back to 1 when strokeWidthPx is omitted", () => {
expect(displayStrokeWidthWorld({}, 2)).toBeCloseTo(0.5);
});
test("zero-scale guard returns the raw pixel value", () => {
expect(displayStrokeWidthWorld({ strokeWidthPx: 3 }, 0)).toBe(3);
});
});