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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user