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