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;
}
}
}
+9
View File
@@ -57,6 +57,14 @@ export interface MapToggles {
cargoRoutes: boolean;
battleMarkers: boolean;
bombingMarkers: boolean;
/**
* planetNames toggles the on-map two-line label drawn under each
* planet (F8-12 / issue #55, п.29). When ON, the first line shows
* the planet name (when known) and the second line shows `#N`.
* When OFF, the name line is suppressed for every planet — only
* `#N` remains. Default ON.
*/
planetNames: boolean;
/**
* visibleHyperspace toggles the foggy overlay that darkens the
* world OUTSIDE the union of `VisibilityDistance` circles around
@@ -78,6 +86,7 @@ export const DEFAULT_MAP_TOGGLES: MapToggles = {
cargoRoutes: true,
battleMarkers: true,
bombingMarkers: true,
planetNames: true,
visibleHyperspace: true,
};
+3
View File
@@ -175,6 +175,7 @@ const en = {
"game.map.toggles.uninhabited_planets": "uninhabited planets",
"game.map.toggles.unidentified_planets": "unidentified planets",
"game.map.toggles.unreachable_planets": "show unreachable planets",
"game.map.toggles.planet_names": "planet names",
"game.map.toggles.visible_hyperspace": "visible hyperspace",
"game.view.table": "table",
"game.view.table.planets": "planets",
@@ -279,6 +280,8 @@ const en = {
"game.inspector.planet.field.free_industry": "free production",
"game.inspector.planet.production_none": "none",
"game.inspector.planet.unidentified_no_data": "no data — only the location is known",
"game.inspector.planet.view_bombing": "view bombing report",
"game.inspector.planet.view_bombing_wiped": "view bombing report (wiped)",
"game.inspector.sheet_close": "close",
"game.inspector.planet.action.rename": "rename",
"game.inspector.planet.rename.title": "rename planet",
+3
View File
@@ -176,6 +176,7 @@ const ru: Record<keyof typeof en, string> = {
"game.map.toggles.uninhabited_planets": "необитаемые планеты",
"game.map.toggles.unidentified_planets": "неопознанные планеты",
"game.map.toggles.unreachable_planets": "показывать недостижимые планеты",
"game.map.toggles.planet_names": "имена планет",
"game.map.toggles.visible_hyperspace": "видимое гиперпространство",
"game.view.table": "таблица",
"game.view.table.planets": "планеты",
@@ -280,6 +281,8 @@ const ru: Record<keyof typeof en, string> = {
"game.inspector.planet.field.free_industry": "свободные мощности",
"game.inspector.planet.production_none": "не задано",
"game.inspector.planet.unidentified_no_data": "нет данных — известно только местоположение",
"game.inspector.planet.view_bombing": "открыть отчёт о бомбардировке",
"game.inspector.planet.view_bombing_wiped": "открыть отчёт о бомбардировке (стёрта)",
"game.inspector.sheet_close": "закрыть",
"game.inspector.planet.action.rename": "переименовать",
"game.inspector.planet.rename.title": "переименование планеты",
@@ -12,6 +12,7 @@ dismiss from the IA section §6 are deferred to a later polish pass.
-->
<script lang="ts">
import type {
ReportBombing,
ReportLocalShipGroup,
ReportOtherShipGroup,
ReportPlanet,
@@ -35,6 +36,7 @@ dismiss from the IA section §6 are deferred to a later polish pass.
localShipGroups: ReportLocalShipGroup[];
otherShipGroups: ReportOtherShipGroup[];
localRace: string;
bombing?: ReportBombing | null;
onMap: boolean;
onClose: () => void;
};
@@ -50,6 +52,7 @@ dismiss from the IA section §6 are deferred to a later polish pass.
localShipGroups,
otherShipGroups,
localRace,
bombing = null,
onMap,
onClose,
}: Props = $props();
@@ -84,6 +87,7 @@ dismiss from the IA section §6 are deferred to a later polish pass.
{localShipGroups}
{otherShipGroups}
{localRace}
{bombing}
/>
</section>
{/if}
@@ -19,6 +19,7 @@ the entry point, Escape (or unmounting the inspector) reverts.
<script lang="ts">
import { getContext, tick } from "svelte";
import type {
ReportBombing,
ReportLocalShipGroup,
ReportOtherShipGroup,
ReportPlanet,
@@ -36,6 +37,8 @@ the entry point, Escape (or unmounting the inspector) reverts.
type EntityNameInvalidReason,
} from "$lib/util/entity-name";
import { formatFloat } from "$lib/util/number-format";
import { scrollToBombingRow } from "$lib/report-nav";
import { activeView } from "$lib/app-nav.svelte";
import CargoRoutes from "./planet/cargo-routes.svelte";
import Production from "./planet/production.svelte";
import ShipGroups from "./planet/ship-groups.svelte";
@@ -52,6 +55,7 @@ the entry point, Escape (or unmounting the inspector) reverts.
localShipGroups: ReportLocalShipGroup[];
otherShipGroups: ReportOtherShipGroup[];
localRace: string;
bombing?: ReportBombing | null;
};
let {
planet,
@@ -65,8 +69,15 @@ the entry point, Escape (or unmounting the inspector) reverts.
localShipGroups,
otherShipGroups,
localRace,
bombing = null,
}: Props = $props();
function openBombingReport(): void {
if (bombing === null) return;
activeView.select("report");
scrollToBombingRow(bombing.planetNumber);
}
const kindKeyMap: Record<ReportPlanet["kind"], TranslationKey> = {
local: "game.inspector.planet.kind.local",
other: "game.inspector.planet.kind.other",
@@ -314,6 +325,23 @@ the entry point, Escape (or unmounting the inspector) reverts.
{i18n.t("game.inspector.planet.unidentified_no_data")}
</p>
{/if}
{#if bombing !== null}
<button
type="button"
class="bombing-link"
class:bombing-link--wiped={bombing.wiped}
data-testid="inspector-planet-view-bombing"
data-bombing-wiped={bombing.wiped ? "true" : "false"}
onclick={openBombingReport}
>
{i18n.t(
bombing.wiped
? "game.inspector.planet.view_bombing_wiped"
: "game.inspector.planet.view_bombing",
)}
</button>
{/if}
</section>
<style>
@@ -432,4 +460,28 @@ the entry point, Escape (or unmounting the inspector) reverts.
.icon-action--apply:not(:disabled) {
color: var(--color-success);
}
.bombing-link {
font: inherit;
font-size: 0.85rem;
padding: 0.4rem 0.6rem;
background: var(--color-surface-hover);
color: var(--color-text);
border: 1px solid var(--color-border);
border-left: 3px solid var(--color-warning, #f57f17);
border-radius: 3px;
cursor: pointer;
text-align: left;
}
.bombing-link:hover,
.bombing-link:focus-visible {
border-color: var(--color-accent);
border-left-color: var(--color-warning, #f57f17);
}
.bombing-link--wiped {
border-left-color: var(--color-danger);
}
.bombing-link--wiped:hover,
.bombing-link--wiped:focus-visible {
border-left-color: var(--color-danger);
}
</style>
+30
View File
@@ -0,0 +1,30 @@
// Navigation helpers for the in-game report view. The two entry
// points (planet inspector + map) used to scroll-into-view the
// bombing row in slightly different ways; F8-12 / #30 consolidated
// the path so the bombing CirclePrim could go away.
/**
* scrollToBombingRow polls a bounded number of animation frames for
* the matching `[data-testid="report-bombing-row"][data-planet="N"]`
* row to mount and, once it does, scrolls it into the centre of the
* viewport. The poll is bounded (no infinite loop) because the report
* view is rendered reactively after the active-view switch and the
* row is not in the DOM on the same frame the caller requested it.
*/
export 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);
}
@@ -108,6 +108,12 @@ from the Phase 10 stub.
const localFleets = $derived(renderedReport?.report?.localFleets ?? []);
const otherRaces = $derived(renderedReport?.report?.otherRaces ?? []);
const localRace = $derived(renderedReport?.report?.race ?? "");
const bombings = $derived(renderedReport?.report?.bombings ?? []);
const selectedPlanetBombing = $derived(
selectedPlanet === null
? null
: (bombings.find((b) => b.planetNumber === selectedPlanet.number) ?? null),
);
</script>
<section class="tool" data-testid="sidebar-tool-inspector">
@@ -124,6 +130,7 @@ from the Phase 10 stub.
{localShipGroups}
{otherShipGroups}
{localRace}
bombing={selectedPlanetBombing}
/>
{:else if selectedShipGroup !== null}
<ShipGroup