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
@@ -177,6 +177,15 @@ bottom-tabs bar.
/>
<span>{i18n.t("game.map.toggles.unreachable_planets")}</span>
</label>
<label>
<input
type="checkbox"
data-testid="map-toggles-planet-names"
checked={store.mapToggles.planetNames}
onchange={(e) => setFlag("planetNames", e)}
/>
<span>{i18n.t("game.map.toggles.planet_names")}</span>
</label>
</fieldset>
<fieldset>
<legend>{i18n.t("game.map.toggles.section.view")}</legend>
+57 -50
View File
@@ -26,12 +26,13 @@ preference the store already manages.
import {
createRenderer,
minScaleNoWrap,
type PlanetOutlineSpec,
type RendererHandle,
} from "../../map/index";
import { buildCargoRouteLines } from "../../map/cargo-routes";
import { buildPlanetLabels } from "../../map/labels";
import { buildPendingSendLines } from "../../map/pending-send-routes";
import { computeReachCircles } from "../../map/reach-circles";
import { computeSelectionRing } from "../../map/selection-ring";
import { reachStore } from "$lib/calculator/reach.svelte";
import { theme as themeStore } from "$lib/theme/theme.svelte";
import {
@@ -216,6 +217,7 @@ preference the store already manages.
void toggles.cargoRoutes;
void toggles.battleMarkers;
void toggles.bombingMarkers;
void toggles.planetNames;
void toggles.visibleHyperspace;
// Subscribe to the calculator's published reach so the rings
@@ -253,11 +255,9 @@ preference the store already manages.
reachOrigin === null
? ""
: `${reachOrigin.x},${reachOrigin.y},${reachStore.speedPerTurn}`;
const selectedPlanetId =
selection?.selected?.kind === "planet" ? selection.selected.id : null;
const extrasFingerprint =
`cr=${toggles.cargoRoutes ? "1" : "0"}|hp=${hiddenPlanetFingerprint}|` +
`reach=${reachFingerprint}|sel=${selectedPlanetId ?? ""}|` +
`reach=${reachFingerprint}|` +
computeRoutesFingerprint(report.routes) +
"|" +
computePendingSendFingerprint(draftCommands, draftStatuses);
@@ -363,19 +363,7 @@ preference the store already manages.
palette,
)
: [];
const selectedPlanetId =
selection?.selected?.kind === "planet" ? selection.selected.id : null;
const selectionRing = computeSelectionRing(
report.planets,
selectedPlanetId,
palette,
);
return [
...cargo,
...pending,
...reach,
...(selectionRing === null ? [] : [selectionRing]),
];
return [...cargo, ...pending, ...reach];
}
function applyVisibilityState(
@@ -394,6 +382,55 @@ preference the store already manages.
const fogCircles = computeFogCircles(report, toggles);
currentFogCircles = fogCircles;
handle.setVisibilityFog(fogCircles);
applyPlanetLabels(report, toggles);
}
function applyPlanetLabels(
report: NonNullable<GameStateStore["report"]>,
toggles: MapToggles,
): void {
if (handle === null) return;
const labels = buildPlanetLabels(report, {
showNames: toggles.planetNames,
});
const selectedPlanetId =
selection?.selected?.kind === "planet" ? selection.selected.id : null;
handle.setPlanetLabels(labels, selectedPlanetId);
applyPlanetOutlines(report, toggles, selectedPlanetId);
}
function applyPlanetOutlines(
report: NonNullable<GameStateStore["report"]>,
toggles: MapToggles,
selectedPlanetId: number | null,
): void {
if (handle === null) return;
const palette = mountedPalette ?? DARK_THEME;
const outlines: PlanetOutlineSpec[] = [];
// Bombing outline (F8-12 / #30): every bombed planet gets the
// damaged / wiped accent painted around its disc. The
// `bombingMarkers` toggle hides the visual cue while leaving
// the data intact.
if (toggles.bombingMarkers) {
for (const bombing of report.bombings) {
if (bombing.planetNumber === selectedPlanetId) continue;
outlines.push({
planetNumber: bombing.planetNumber,
color: bombing.wiped
? palette.bombingWiped
: palette.bombingDamaged,
});
}
}
// Selection outline overrides bombing on the same planet so the
// player can always tell which one is currently focused.
if (selectedPlanetId !== null) {
outlines.push({
planetNumber: selectedPlanetId,
color: palette.selectionAccent,
});
}
handle.setPlanetOutlines(outlines);
}
async function runSerializedMount(
@@ -718,30 +755,9 @@ preference the store already manages.
// current selection. The Phase 19 ship-group surface dispatches
// through the same `hit-test` plumbing — the hitLookup map keyed
// by primitive id resolves a hit back to either a planet or a
// ship-group selection variant.
// scrollToBombingRow waits for the report's bombing row for the
// given planet to mount, then scrolls it into view. The map context
// menu switches to the report view through a store mutation, so the
// section renders on a later frame; a short bounded poll bridges
// that gap without coupling the map to the report's render timing.
function scrollToBombingRow(planet: number): void {
if (typeof document === "undefined") return;
let attempts = 60;
const tick = (): void => {
const row = document.querySelector(
`[data-testid="report-bombing-row"][data-planet="${planet}"]`,
);
if (row instanceof HTMLElement) {
row.scrollIntoView({ behavior: "smooth", block: "center" });
return;
}
attempts -= 1;
if (attempts <= 0) return;
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
// ship-group selection variant. F8-12 / #30 retired the separate
// bombing-ring click; bombing → report navigation now starts in
// the inspector via `scrollToBombingRow` (`lib/report-nav.ts`).
function handleMapClick(cursorPx: { x: number; y: number }): void {
if (handle === null || store?.report === undefined || store.report === null) {
return;
@@ -768,15 +784,6 @@ preference the store already manages.
});
break;
}
case "bombing": {
activeView.select("report");
// The report sections render reactively after the view
// switches above, so there is no navigation promise to
// await; poll a bounded number of animation frames for
// the bombing row, then scroll it into view.
scrollToBombingRow(target.planet);
break;
}
}
}