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
+39 -23
View File
@@ -5,11 +5,13 @@
// expected hit is obvious from the geometry; the camera is at scale=1
// in most cases so slop in pixels equals slop in world units.
//
// The point hit zone is `(pointRadiusPx + slopPx) / camera.scale`
// world units — the visible disc plus an ergonomic slop on top. The
// default `pointRadiusPx` (`DEFAULT_POINT_RADIUS_PX`) is 3 and the
// default point slop (`DEFAULT_HIT_SLOP_PX.point`) is 4, so a default
// point is hit out to 7 world units at scale=1.
// F8-12 / #28 made `pointRadiusPx` and `strokeWidthPx` honest screen-
// pixel sizes — the hit zone is `(pointRadiusPx + slopPx) / scale`
// world units, which equals `pointRadiusPx + slopPx` *pixels* on
// screen at any zoom. The default `pointRadiusPx`
// (`DEFAULT_POINT_RADIUS_PX`) is 3 and the default point slop
// (`DEFAULT_HIT_SLOP_PX.point`) is 4, so a default point is hit out
// to 7 *screen* pixels — equal to 7 world units at scale=1.
import { describe, expect, test } from "vitest";
import { hitTest } from "../src/map/hit-test";
@@ -256,28 +258,42 @@ describe("hitTest — empty results and scale", () => {
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(null);
});
test("higher zoom shrinks the on-screen slop in world units", () => {
// At scale=4, slopPx 4 = 1 world unit; visible radius stays 3
// world units. Threshold = 4 world units.
const w = new World(1000, 1000, [point(1, 503, 500)]);
test("higher zoom shrinks the world-unit footprint of the default disc", () => {
// At scale=4, pointRadiusPx 3 = 0.75 world units; slop 4 = 1
// world unit. Threshold = 1.75 world units.
const cam4 = camAt(500, 500, 4);
// 3 world units away → on the disc edge → hit.
expect(ids(w, "torus", cam4, cursorOver(503, 500, cam4))).toBe(1);
// 5 world units away → beyond radius+slop → null.
const wFar = new World(1000, 1000, [point(1, 505, 500)]);
expect(ids(wFar, "torus", cam4, cursorOver(500, 500, cam4))).toBe(null);
const w = new World(1000, 1000, [point(1, 500, 500)]);
// 1.5 world units away → within 1.75 → hit.
expect(ids(w, "torus", cam4, cursorOver(501.5, 500, cam4))).toBe(1);
// 2 world units away → beyond 1.75 → null.
expect(ids(w, "torus", cam4, cursorOver(502, 500, cam4))).toBe(null);
});
test("lower zoom widens the on-screen slop in world units", () => {
// At scale=0.5, slopPx 4 = 8 world units; visible radius
// stays 3 → threshold = 11 world units.
test("lower zoom inflates the world-unit footprint of the default disc", () => {
// At scale=0.5, pointRadiusPx 3 = 6 world units; slop 4 = 8
// world units. Threshold = 14 world units.
const cam05 = camAt(500, 500, 0.5);
const w = new World(1000, 1000, [point(1, 510, 500)]);
// 10 world units away → within 11 → hit.
expect(ids(w, "torus", cam05, cursorOver(500, 500, cam05))).toBe(1);
const wFar = new World(1000, 1000, [point(1, 514, 500)]);
// 14 world units away → beyond 11 → null.
expect(ids(wFar, "torus", cam05, cursorOver(500, 500, cam05))).toBe(null);
const w = new World(1000, 1000, [point(1, 500, 500)]);
// 13 world units away → within 14 → hit.
expect(ids(w, "torus", cam05, cursorOver(513, 500, cam05))).toBe(1);
// 16 world units away → beyond 14 → null.
expect(ids(w, "torus", cam05, cursorOver(516, 500, cam05))).toBe(null);
});
test("pointRadiusWorld scales softly with zoom (F8-12 / #31)", () => {
// world 1000×1000, viewport 200×200 → scaleRef = 0.2 (every
// world unit becomes 0.2 px on screen at the "whole world fits"
// zoom). PLANET_SIZE_ZOOM_ALPHA is 0.33: r_display =
// r_base * (scale / scaleRef)^(α - 1).
const cam05 = camAt(500, 500, 0.5);
const wBase = new World(1000, 1000, [
point(1, 500, 500, { style: { pointRadiusWorld: 6 } }),
]);
// At scale=0.5 the softening factor is (0.5/0.2)^(0.33-1) ≈ 0.554.
// Visible radius ≈ 3.32 world units, slop 8, threshold ≈ 11.32.
expect(ids(wBase, "torus", cam05, cursorOver(510, 500, cam05))).toBe(1);
// Cursor 12 world units away exceeds the threshold.
expect(ids(wBase, "torus", cam05, cursorOver(512, 500, cam05))).toBe(null);
});
});