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:
+46
-7
@@ -66,13 +66,48 @@ interface LinePrim extends PrimitiveBase { kind: 'line';
|
||||
|
||||
`radius` is in world units. `style.strokeWidthPx` and
|
||||
`style.pointRadiusPx` are in screen pixels and stay constant under
|
||||
zoom (Pixi's stroke width is in pixel space when the parent
|
||||
container is scaled).
|
||||
zoom — F8-12 / #28 wired the renderer to repaint every affected
|
||||
`Graphics` on every `viewport.zoomed` event with
|
||||
`size_in_world = size_in_pixels / cameraScale`. `displayStrokeWidthWorld`
|
||||
and `displayPointRadiusWorld` (in `src/map/world.ts`) compute those
|
||||
world-space values; the hit-test reads the same helpers so the click
|
||||
zone always matches the visible footprint.
|
||||
|
||||
`style.pointRadiusWorld` is the alternative sizing rule for planet
|
||||
discs with a known `size`: the renderer treats the base radius as
|
||||
world units and softens its growth with the camera scale through
|
||||
`PLANET_SIZE_ZOOM_ALPHA` (0.33). At `scale = scaleRef` (the
|
||||
"whole world fits the viewport" zoom) the visible radius equals the
|
||||
base radius; zooming in grows it sub-linearly so on-screen pixel
|
||||
size scales as `scale^α`. Setting both `pointRadiusWorld` and
|
||||
`pointRadiusPx` ignores the pixel-space field.
|
||||
|
||||
Default hit slop in screen pixels: point=8, circle=6, line=6.
|
||||
These are touch-ergonomic defaults; per-primitive `hitSlopPx > 0`
|
||||
overrides them.
|
||||
|
||||
### Planet label layer
|
||||
|
||||
Independent of the primitive stream, the renderer mounts a per-copy
|
||||
`labelLayer` (F8-12 / #29). `RendererHandle.setPlanetLabels(labels,
|
||||
selectedPlanetId)` replaces the dataset; the renderer keeps each
|
||||
label container at `(planet.x, planet.y + visibleRadius + gapPx)`
|
||||
and at `scale = 1 / cameraScale` so the text reads at the same
|
||||
pixel size regardless of zoom. The selected planet gets an
|
||||
inverse-fill frame around its label, replacing the retired
|
||||
`selection-ring` primitive (F8-12 / #30).
|
||||
|
||||
### Planet outline overlay
|
||||
|
||||
`RendererHandle.setPlanetOutlines(outlines)` paints a thin stroke
|
||||
around the visible disc of any planet number listed in the spec.
|
||||
The map view feeds it the union of bombings (damaged / wiped accent
|
||||
colour, gated by the `bombingMarkers` toggle) and the current
|
||||
selection (`selectionAccent` colour); selection wins on the same
|
||||
planet. The radius follows `displayPointRadiusWorld`, so the
|
||||
outline hugs the disc through every zoom step — softened or
|
||||
pixel-space alike.
|
||||
|
||||
## Theme
|
||||
|
||||
A `Theme` is the renderer's full colour palette: the canvas background
|
||||
@@ -127,11 +162,15 @@ target.
|
||||
|
||||
Per-primitive distance:
|
||||
|
||||
- **Point**: `distSq ≤ (pointRadiusPx + slopWorld)²`. The visible
|
||||
disc is part of the click target — a click on any pixel of the
|
||||
rendered planet registers as a hit, with `slopWorld` adding a
|
||||
small ergonomic margin on top. `pointRadiusPx` defaults to
|
||||
`DEFAULT_POINT_RADIUS_PX = 3` when unset.
|
||||
- **Point**: `distSq ≤ (visibleRadiusWorld + slopWorld)²`. The
|
||||
visible disc is part of the click target — a click on any pixel of
|
||||
the rendered planet registers as a hit, with `slopWorld` adding a
|
||||
small ergonomic margin on top. `visibleRadiusWorld` comes from
|
||||
`displayPointRadiusWorld` (F8-12 / #28 + #31): pixel-space
|
||||
`pointRadiusPx / scale` for unidentified planets and most ship
|
||||
groups, softened-by-zoom `pointRadiusWorld * (scale / scaleRef)^(α-1)`
|
||||
for planets with a known `size`. `pointRadiusPx` defaults to
|
||||
`DEFAULT_POINT_RADIUS_PX = 3` when neither field is set.
|
||||
- **Filled circle**: `distSq ≤ (radius + slopWorld)²` where
|
||||
`radius` is in world units. The circle counts as filled when
|
||||
`style.fillColor` is set and `style.fillAlpha > 0`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Phase 27 battle and bombing markers on the map.
|
||||
//
|
||||
// Two visual markers per planet:
|
||||
// Phase 27 battle markers on the map. Bombing markers used to live
|
||||
// here as a separate ring primitive but F8-12 / #30 turned them into
|
||||
// a planet-outline overlay drawn by `render.ts.setPlanetOutlines`,
|
||||
// driven from `map.svelte`. The remaining surface here is the battle
|
||||
// X-cross:
|
||||
//
|
||||
// * Battle marker — an X cross drawn through the corners of the
|
||||
// square that circumscribes the planet circle. Two yellow
|
||||
@@ -8,18 +10,10 @@
|
||||
// shots: 1 shot → 1px, 100+ shots → 5px (capped). Clicking
|
||||
// either line opens the Battle Viewer for the corresponding
|
||||
// UUID.
|
||||
// * Bombing marker — a thin stroke-only circle slightly larger
|
||||
// than the planet circle. Yellow on damaged planets, red on
|
||||
// wiped planets. Clicking it deep-links to the bombings row in
|
||||
// the Reports view for the planet number.
|
||||
//
|
||||
// Both markers are wired into `state-binding.ts` so they live in the
|
||||
// same `world` / `hitLookup` plumbing as planets and ship groups.
|
||||
|
||||
import type { GameReport, ReportPlanet } from "../api/game-state";
|
||||
import {
|
||||
DARK_THEME,
|
||||
type CirclePrim,
|
||||
type LinePrim,
|
||||
type Primitive,
|
||||
type PrimitiveID,
|
||||
@@ -27,20 +21,17 @@ import {
|
||||
type Theme,
|
||||
} from "./world";
|
||||
|
||||
/** Battle and bombing marker primitive ids use a high-bit prefix to
|
||||
* avoid colliding with planet numbers or cargo-route line ids. */
|
||||
/** Battle marker primitive ids use a high-bit prefix to avoid
|
||||
* colliding with planet numbers or cargo-route line ids. */
|
||||
export const BATTLE_MARKER_ID_PREFIX = 0xa0000000;
|
||||
export const BOMBING_MARKER_ID_PREFIX = 0xc0000000;
|
||||
|
||||
const PLANET_RADIUS_WORLD = 6;
|
||||
const BOMBING_RING_RADIUS = PLANET_RADIUS_WORLD + 3;
|
||||
const BATTLE_CROSS_HALF = PLANET_RADIUS_WORLD + 2;
|
||||
|
||||
/** Battle marker priority sits between planets (1..4) and cargo
|
||||
* routes; the cross is over the planet but loses clicks against the
|
||||
* planet glyph itself. */
|
||||
const BATTLE_MARKER_PRIORITY = 9;
|
||||
const BOMBING_MARKER_PRIORITY = 10;
|
||||
|
||||
const BATTLE_LINE_INDEX_A = 0;
|
||||
const BATTLE_LINE_INDEX_B = 1;
|
||||
@@ -51,21 +42,16 @@ export interface BattleMarkerTarget {
|
||||
planet: number;
|
||||
}
|
||||
|
||||
export interface BombingMarkerTarget {
|
||||
kind: "bombing";
|
||||
planet: number;
|
||||
}
|
||||
|
||||
export type MarkerTarget = BattleMarkerTarget | BombingMarkerTarget;
|
||||
export type MarkerTarget = BattleMarkerTarget;
|
||||
|
||||
/**
|
||||
* MarkerCategory tags every emitted primitive with the toggleable
|
||||
* surface it belongs to so the Phase 29 hide-set machinery can flip
|
||||
* each independently. Battles and bombings have their own toggles —
|
||||
* a player can hide the bombing rings while keeping the battle
|
||||
* crosses visible.
|
||||
* each independently. Battle markers are the only category left here;
|
||||
* the `bombingMarker` toggle now hides the planet-outline overlay
|
||||
* built in `map.svelte.applyPlanetOutlines` (F8-12 / #30).
|
||||
*/
|
||||
export type MarkerCategory = "battleMarker" | "bombingMarker";
|
||||
export type MarkerCategory = "battleMarker";
|
||||
|
||||
export interface BuildMarkersResult {
|
||||
primitives: Primitive[];
|
||||
@@ -92,11 +78,13 @@ export function battleMarkerStrokeWidth(shots: number): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* buildBattleAndBombingMarkers emits battle and bombing marker
|
||||
* primitives plus a hit-lookup mapping for the current-turn report.
|
||||
* Battles whose planet is not visible (e.g. observer-only without a
|
||||
* report.planets entry) are skipped — they have no on-map location
|
||||
* to anchor against.
|
||||
* buildBattleAndBombingMarkers emits battle X-cross primitives plus a
|
||||
* hit-lookup mapping for the current-turn report. Battles whose
|
||||
* planet is not visible (e.g. observer-only without a report.planets
|
||||
* entry) are skipped — they have no on-map location to anchor
|
||||
* against. Bombing visuals are no longer produced here (F8-12 / #30);
|
||||
* the renderer paints them as a planet-outline overlay driven from
|
||||
* `map.svelte.applyPlanetOutlines`.
|
||||
*/
|
||||
export function buildBattleAndBombingMarkers(
|
||||
report: GameReport,
|
||||
@@ -167,32 +155,8 @@ export function buildBattleAndBombingMarkers(
|
||||
addDependent(battle.planet, lineB.id);
|
||||
}
|
||||
|
||||
for (let i = 0; i < report.bombings.length; i++) {
|
||||
const bombing = report.bombings[i];
|
||||
const planet = planetByNumber.get(bombing.planetNumber);
|
||||
if (planet === undefined) continue;
|
||||
const color = bombing.wiped ? theme.bombingWiped : theme.bombingDamaged;
|
||||
const style: Style = {
|
||||
strokeColor: color,
|
||||
strokeAlpha: 0.9,
|
||||
strokeWidthPx: 1.5,
|
||||
};
|
||||
const id = BOMBING_MARKER_ID_PREFIX | i;
|
||||
const ring: CirclePrim = {
|
||||
kind: "circle",
|
||||
id,
|
||||
priority: BOMBING_MARKER_PRIORITY,
|
||||
style,
|
||||
hitSlopPx: 0,
|
||||
x: planet.x,
|
||||
y: planet.y,
|
||||
radius: BOMBING_RING_RADIUS,
|
||||
};
|
||||
primitives.push(ring);
|
||||
lookup.set(id, { kind: "bombing", planet: bombing.planetNumber });
|
||||
categories.set(id, "bombingMarker");
|
||||
addDependent(bombing.planetNumber, id);
|
||||
}
|
||||
|
||||
// Bombing visuals are produced by `setPlanetOutlines` in the
|
||||
// renderer (F8-12 / #30); the data still lives on
|
||||
// `report.bombings`, but no primitive is emitted here.
|
||||
return { primitives, lookup, categories, planetDependents };
|
||||
}
|
||||
|
||||
@@ -12,9 +12,10 @@
|
||||
// and a primitive does not get matched through wrap copies.
|
||||
|
||||
import { distSqPointToSegment, screenToWorld, torusShortestDelta } from "./math";
|
||||
import { minScaleNoWrap } from "./no-wrap";
|
||||
import {
|
||||
DEFAULT_HIT_SLOP_PX,
|
||||
DEFAULT_POINT_RADIUS_PX,
|
||||
displayPointRadiusWorld,
|
||||
KIND_ORDER,
|
||||
type Camera,
|
||||
type CirclePrim,
|
||||
@@ -50,6 +51,10 @@ export function hitTest(
|
||||
): Hit | null {
|
||||
const cursor = screenToWorld(cursorPx, camera, viewport);
|
||||
const candidates: Hit[] = [];
|
||||
// The non-linear planet radius (F8-12 / #31) is softened relative
|
||||
// to the "whole world fits" reference scale. Computing it here
|
||||
// once keeps the visible disc and the click zone in lock-step.
|
||||
const scaleRef = minScaleNoWrap(viewport, world);
|
||||
|
||||
for (const p of world.primitives) {
|
||||
if (hiddenIds !== undefined && hiddenIds.has(p.id)) continue;
|
||||
@@ -57,7 +62,18 @@ export function hitTest(
|
||||
const slopWorld = slopPx / camera.scale;
|
||||
let result: number | null;
|
||||
if (p.kind === "point") {
|
||||
result = matchPoint(p, cursor, slopWorld, mode === "torus" ? world : null);
|
||||
const visibleRadius = displayPointRadiusWorld(
|
||||
p.style,
|
||||
camera.scale,
|
||||
scaleRef,
|
||||
);
|
||||
result = matchPoint(
|
||||
p,
|
||||
cursor,
|
||||
visibleRadius,
|
||||
slopWorld,
|
||||
mode === "torus" ? world : null,
|
||||
);
|
||||
} else if (p.kind === "circle") {
|
||||
result = matchCircle(p, cursor, slopWorld, mode === "torus" ? world : null);
|
||||
} else {
|
||||
@@ -105,15 +121,16 @@ function torusDelta(
|
||||
function matchPoint(
|
||||
p: PointPrim,
|
||||
cursor: { x: number; y: number },
|
||||
visibleRadius: number,
|
||||
slopWorld: number,
|
||||
world: World | null,
|
||||
): number | null {
|
||||
const { dx, dy } = torusDelta(p.x, p.y, cursor.x, cursor.y, world);
|
||||
const distSq = dx * dx + dy * dy;
|
||||
// The visible disc is `pointRadiusPx` world units; the hit zone
|
||||
// is the disc plus a small ergonomic slop on top. A click on any
|
||||
// painted pixel of the planet must register as a hit.
|
||||
const visibleRadius = p.style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX;
|
||||
// `visibleRadius` already reflects whichever sizing rule the
|
||||
// PointPrim uses (pixel-space for `pointRadiusPx`, softened world
|
||||
// units for `pointRadiusWorld`). The hit zone is that disc plus
|
||||
// a small ergonomic slop on top.
|
||||
const r = visibleRadius + slopWorld;
|
||||
if (distSq <= r * r) return distSq;
|
||||
return null;
|
||||
|
||||
@@ -40,9 +40,12 @@ export { hitTest, type Hit } from "./hit-test";
|
||||
|
||||
export {
|
||||
createRenderer,
|
||||
type PlanetOutlineSpec,
|
||||
type RendererHandle,
|
||||
type RendererOptions,
|
||||
type RendererPreference,
|
||||
} from "./render";
|
||||
|
||||
export { buildPlanetLabels, type PlanetLabelData } from "./labels";
|
||||
|
||||
export { sampleWorld } from "./fixtures";
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Two-line planet labels drawn on the map under each planet (F8-12 /
|
||||
// issue #55, п.29). The first line shows the planet name when known
|
||||
// and the `planetNames` toggle is on; the second line shows `#N`. For
|
||||
// unidentified or unnamed planets only the `#N` line is rendered.
|
||||
//
|
||||
// Selection is wired through `selectedPlanetId`: the renderer wraps
|
||||
// the selected planet's label in an inverse-fill frame (F8-12 / п.30)
|
||||
// instead of drawing a separate ring around the planet disc — see the
|
||||
// "label-driven selection" branch in `render.ts`.
|
||||
|
||||
import type { GameReport } from "../api/game-state";
|
||||
|
||||
export interface PlanetLabelData {
|
||||
planetNumber: number;
|
||||
x: number;
|
||||
y: number;
|
||||
/**
|
||||
* The primary line: planet name. `null` when the `planetNames`
|
||||
* toggle is off or the planet has no name (unidentified, or a
|
||||
* legacy report row with an empty string). When null the renderer
|
||||
* only paints the secondary `#N` line.
|
||||
*/
|
||||
name: string | null;
|
||||
/** Secondary line — always present. Pre-formatted as `#N`. */
|
||||
numberLabel: string;
|
||||
}
|
||||
|
||||
export interface BuildPlanetLabelsOptions {
|
||||
/** Mirrors `MapToggles.planetNames`. */
|
||||
showNames: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* buildPlanetLabels translates the report's planet list into the
|
||||
* on-map label dataset. The toggle drives whether the name line is
|
||||
* present; for unidentified planets the name is suppressed even when
|
||||
* the toggle is on, because the player has no name to display.
|
||||
*/
|
||||
export function buildPlanetLabels(
|
||||
report: GameReport,
|
||||
opts: BuildPlanetLabelsOptions,
|
||||
): PlanetLabelData[] {
|
||||
const out: PlanetLabelData[] = [];
|
||||
for (const p of report.planets) {
|
||||
const named =
|
||||
opts.showNames && p.kind !== "unidentified" && p.name.length > 0;
|
||||
out.push({
|
||||
planetNumber: p.number,
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
name: named ? p.name : null,
|
||||
numberLabel: `#${p.number}`,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -12,7 +12,11 @@
|
||||
// booting a Pixi `Application`.
|
||||
|
||||
import { torusShortestDelta } from "./math";
|
||||
import { DEFAULT_POINT_RADIUS_PX, type PointPrim, type PrimitiveID } from "./world";
|
||||
import {
|
||||
displayPointRadiusWorld,
|
||||
type PointPrim,
|
||||
type PrimitiveID,
|
||||
} from "./world";
|
||||
|
||||
/**
|
||||
* PickModeOptions configures a pick-mode session. The caller is
|
||||
@@ -110,11 +114,15 @@ export function computePickOverlay(
|
||||
pointPrimitivesById: ReadonlyMap<PrimitiveID, PointPrim>,
|
||||
allPrimitiveIds: Iterable<PrimitiveID>,
|
||||
world: { width: number; height: number } | null = null,
|
||||
cameraScale: number = 1,
|
||||
scaleRef: number = 1,
|
||||
): PickOverlaySpec {
|
||||
const sourcePrim = pointPrimitivesById.get(options.sourcePrimitiveId);
|
||||
const sourceRadius =
|
||||
(sourcePrim?.style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX) +
|
||||
ANCHOR_PADDING_WORLD;
|
||||
const sourceVisibleRadius =
|
||||
sourcePrim === undefined
|
||||
? 0
|
||||
: displayPointRadiusWorld(sourcePrim.style, cameraScale, scaleRef);
|
||||
const sourceRadius = sourceVisibleRadius + ANCHOR_PADDING_WORLD;
|
||||
|
||||
const dimmed = new Set<PrimitiveID>();
|
||||
for (const id of allPrimitiveIds) {
|
||||
@@ -160,12 +168,15 @@ export function computePickOverlay(
|
||||
) {
|
||||
const target = pointPrimitivesById.get(hoveredId);
|
||||
if (target !== undefined) {
|
||||
const targetRadius = displayPointRadiusWorld(
|
||||
target.style,
|
||||
cameraScale,
|
||||
scaleRef,
|
||||
);
|
||||
hoverOutline = {
|
||||
x: target.x,
|
||||
y: target.y,
|
||||
radius:
|
||||
(target.style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX) +
|
||||
HOVER_PADDING_WORLD,
|
||||
radius: targetRadius + HOVER_PADDING_WORLD,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+411
-17
@@ -21,6 +21,7 @@ import {
|
||||
Application,
|
||||
Container,
|
||||
Graphics,
|
||||
Text,
|
||||
Ticker,
|
||||
UPDATE_PRIORITY,
|
||||
type Renderer,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
import { Viewport as PixiViewport } from "pixi-viewport";
|
||||
|
||||
import { hitTest, type Hit } from "./hit-test";
|
||||
import type { PlanetLabelData } from "./labels";
|
||||
import { screenToWorld } from "./math";
|
||||
import { minScaleNoWrap } from "./no-wrap";
|
||||
import {
|
||||
@@ -40,7 +42,8 @@ import {
|
||||
import { wrapCameraTorus } from "./torus";
|
||||
import {
|
||||
DARK_THEME,
|
||||
DEFAULT_POINT_RADIUS_PX,
|
||||
displayPointRadiusWorld,
|
||||
displayStrokeWidthWorld,
|
||||
World,
|
||||
type Camera,
|
||||
type CirclePrim,
|
||||
@@ -57,6 +60,20 @@ import {
|
||||
// selection. The map renderer always restricts to webgpu/webgl.
|
||||
export type RendererPreference = "webgpu" | "webgl";
|
||||
|
||||
/**
|
||||
* PlanetOutlineSpec drives the F8-12 planet-outline overlay (#30):
|
||||
* a thin stroke painted around the planet disc that signals
|
||||
* selection / bombing without adding a separate ring marker. The
|
||||
* renderer hugs the outline to the visible disc on every zoom step,
|
||||
* so callers do not have to recompute it.
|
||||
*/
|
||||
export interface PlanetOutlineSpec {
|
||||
readonly planetNumber: number;
|
||||
readonly color: number;
|
||||
/** Stroke width in screen pixels. Defaults to 1.5 when omitted. */
|
||||
readonly widthPx?: number;
|
||||
}
|
||||
|
||||
export interface RendererOptions {
|
||||
canvas: HTMLCanvasElement;
|
||||
world: World;
|
||||
@@ -202,6 +219,28 @@ export interface RendererHandle {
|
||||
setVisibilityFog(
|
||||
circles: ReadonlyArray<{ x: number; y: number; radius: number }>,
|
||||
): void;
|
||||
/**
|
||||
* setPlanetLabels replaces the on-map planet label dataset
|
||||
* (F8-12 / #29). Each entry is anchored to its planet's
|
||||
* `(x, y)` and the renderer keeps the labels just below the
|
||||
* disc, repositioning them on every zoom step so the gap stays
|
||||
* constant in screen pixels. `selectedPlanetId` (or `null`)
|
||||
* controls which label gets the inverse-fill selection frame
|
||||
* (F8-12 / #30); pass `null` when no planet is selected.
|
||||
*/
|
||||
setPlanetLabels(
|
||||
labels: ReadonlyArray<PlanetLabelData>,
|
||||
selectedPlanetId: number | null,
|
||||
): void;
|
||||
/**
|
||||
* setPlanetOutlines replaces the planet-outline overlay set
|
||||
* (F8-12 / #30). Each entry paints a thin stroke around the
|
||||
* planet's visible disc — the radius follows the soft / pixel
|
||||
* sizing rules so the outline hugs the planet at any zoom.
|
||||
* Used by both selection (selection accent colour) and bombing
|
||||
* (damaged / wiped colour) signals.
|
||||
*/
|
||||
setPlanetOutlines(outlines: ReadonlyArray<PlanetOutlineSpec>): void;
|
||||
resize(widthPx: number, heightPx: number): void;
|
||||
dispose(): void;
|
||||
}
|
||||
@@ -414,14 +453,52 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
return c;
|
||||
});
|
||||
|
||||
// Outline layer per copy (F8-12 / #30). Sits between the
|
||||
// primitive disc and the labels so the stroke reads against the
|
||||
// planet fill while staying below the textual layer. Each entry
|
||||
// is rebuilt in `updateOutlineTransforms` on every zoom step so
|
||||
// the radius hugs the visible disc.
|
||||
const outlineLayers: Container[] = copies.map((c) => {
|
||||
const layer = new Container();
|
||||
c.addChild(layer);
|
||||
return layer;
|
||||
});
|
||||
|
||||
// Label layer per copy (F8-12 / #29). Labels render above every
|
||||
// primitive so the text reads on top of fog / route lines, and the
|
||||
// per-copy layout mirrors the primitive copies so wrap mode still
|
||||
// shows the labels in whichever torus tile the user is panned over.
|
||||
// Each layer holds one `Container` per planet (built lazily by
|
||||
// `setPlanetLabels`), and we keep the scale + y-offset of those
|
||||
// containers in lock-step with the camera in `updateLabelTransforms`.
|
||||
const labelLayers: Container[] = copies.map((c) => {
|
||||
const layer = new Container();
|
||||
c.addChild(layer);
|
||||
return layer;
|
||||
});
|
||||
|
||||
// Per-id `Graphics` lookup. Each primitive lives in nine copies
|
||||
// (one per torus tile); pick-mode dims them by id, so the lookup
|
||||
// indexes the full set of `Graphics` instances per primitive id.
|
||||
const primitiveGraphics = new Map<PrimitiveID, Graphics[]>();
|
||||
const pointPrimitivesById = new Map<PrimitiveID, PointPrim>();
|
||||
// primitiveById holds the original `Primitive` for every emitted
|
||||
// id so the F8-12 zoom rebuild can replay the geometry with the
|
||||
// current camera scale without having to re-derive it from a
|
||||
// fresh report. The map covers base + extras alike.
|
||||
const primitiveById = new Map<PrimitiveID, Primitive>();
|
||||
const allPrimitiveIds: PrimitiveID[] = [];
|
||||
const extraPrimitiveIds = new Set<PrimitiveID>();
|
||||
let currentWorld: World = opts.world;
|
||||
// currentScaleRef mirrors the `minScaleNoWrap` value: the scale at
|
||||
// which the whole world fits the viewport. The non-linear planet
|
||||
// size formula softens growth relative to this reference (#31), and
|
||||
// it changes when the viewport resizes — recomputed in `applyMode`
|
||||
// and `resize`.
|
||||
let currentScaleRef = minScaleNoWrap(
|
||||
{ widthPx: widthPx, heightPx: heightPx },
|
||||
opts.world,
|
||||
);
|
||||
// hiddenIds is the Phase 29 hide-by-id snapshot. Empty by default;
|
||||
// every map-view effect run replaces it with the current
|
||||
// MapToggles-derived set via `setHiddenPrimitiveIds`. Both
|
||||
@@ -436,9 +513,20 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
const visible = !hiddenIds.has(id);
|
||||
for (const g of list) g.visible = visible;
|
||||
};
|
||||
const drawPrimitiveInto = (prim: Primitive, g: Graphics): void => {
|
||||
g.clear();
|
||||
if (prim.kind === "point") {
|
||||
drawPoint(g, prim, theme, viewport.scaled, currentScaleRef);
|
||||
} else if (prim.kind === "circle") {
|
||||
drawCircle(g, prim, theme, viewport.scaled);
|
||||
} else {
|
||||
drawLine(g, prim, theme, viewport.scaled);
|
||||
}
|
||||
};
|
||||
const populatePrimitives = (prim: Primitive, isExtra: boolean): void => {
|
||||
for (const c of copies) {
|
||||
const g = buildGraphics(prim, theme);
|
||||
const g = new Graphics();
|
||||
drawPrimitiveInto(prim, g);
|
||||
c.addChild(g);
|
||||
let list = primitiveGraphics.get(prim.id);
|
||||
if (list === undefined) {
|
||||
@@ -448,6 +536,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
list.push(g);
|
||||
}
|
||||
allPrimitiveIds.push(prim.id);
|
||||
primitiveById.set(prim.id, prim);
|
||||
if (prim.kind === "point") pointPrimitivesById.set(prim.id, prim);
|
||||
if (isExtra) extraPrimitiveIds.add(prim.id);
|
||||
// Fresh primitives honour the current hide set so cargo-route
|
||||
@@ -456,10 +545,253 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
const list = primitiveGraphics.get(prim.id);
|
||||
if (list !== undefined) applyHiddenStateTo(prim.id, list);
|
||||
};
|
||||
// redrawAllPrimitives replays every primitive's geometry into its
|
||||
// existing per-copy `Graphics` instances using the current camera
|
||||
// scale. The F8-12 zoom-invariant contract requires the renderer
|
||||
// to repaint pixel-space sizes (#28) and the softened planet
|
||||
// radius (#31) whenever the camera zoom changes — instead of
|
||||
// destroying and recreating the Graphics, we `clear()` and redraw
|
||||
// so dim/visibility state on each Graphics is preserved.
|
||||
const redrawAllPrimitives = (): void => {
|
||||
for (const [id, list] of primitiveGraphics) {
|
||||
const prim = primitiveById.get(id);
|
||||
if (prim === undefined) continue;
|
||||
for (const g of list) drawPrimitiveInto(prim, g);
|
||||
}
|
||||
};
|
||||
for (const p of opts.world.primitives) {
|
||||
populatePrimitives(p, false);
|
||||
}
|
||||
|
||||
// Planet label state (F8-12 / #29 + #30). The renderer holds one
|
||||
// `Container` per planet per torus copy; text + selection frame
|
||||
// live inside that container. `currentLabels` mirrors the dataset
|
||||
// last passed into `setPlanetLabels` so a zoom-driven transform
|
||||
// update does not need a fresh report.
|
||||
interface LabelGfx {
|
||||
readonly container: Container;
|
||||
readonly frame: Graphics;
|
||||
readonly nameText: Text | null;
|
||||
readonly numberText: Text;
|
||||
}
|
||||
const planetLabelInstances = new Map<number, LabelGfx[]>();
|
||||
let currentLabels: ReadonlyArray<PlanetLabelData> = [];
|
||||
const LABEL_FONT_SIZE_PX = 11;
|
||||
const LABEL_LINE_GAP_PX = 0;
|
||||
const LABEL_FRAME_PADDING_PX = 3;
|
||||
const LABEL_OFFSET_PX = 4; // gap between planet disc and the label
|
||||
|
||||
const buildLabelText = (
|
||||
content: string,
|
||||
fillColor: number,
|
||||
): Text => {
|
||||
const t = new Text({
|
||||
text: content,
|
||||
style: {
|
||||
fontFamily:
|
||||
"system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
fontSize: LABEL_FONT_SIZE_PX,
|
||||
fill: fillColor,
|
||||
align: "center",
|
||||
},
|
||||
});
|
||||
t.anchor.set(0.5, 0);
|
||||
return t;
|
||||
};
|
||||
|
||||
const disposeLabelGfx = (entry: LabelGfx): void => {
|
||||
entry.nameText?.destroy();
|
||||
entry.numberText.destroy();
|
||||
entry.frame.destroy();
|
||||
entry.container.parent?.removeChild(entry.container);
|
||||
entry.container.destroy();
|
||||
};
|
||||
|
||||
const clearAllLabels = (): void => {
|
||||
for (const list of planetLabelInstances.values()) {
|
||||
for (const entry of list) disposeLabelGfx(entry);
|
||||
}
|
||||
planetLabelInstances.clear();
|
||||
};
|
||||
|
||||
const paintLabelEntry = (entry: LabelGfx, isSelected: boolean): void => {
|
||||
// Text colours flip on selection so the legend reads on the
|
||||
// inverse-fill frame.
|
||||
const nameFill = isSelected ? theme.labelInverseText : theme.labelText;
|
||||
const numberFill = isSelected
|
||||
? theme.labelInverseText
|
||||
: entry.nameText !== null
|
||||
? theme.labelMuted
|
||||
: theme.labelText;
|
||||
if (entry.nameText !== null) {
|
||||
entry.nameText.style.fill = nameFill;
|
||||
}
|
||||
entry.numberText.style.fill = numberFill;
|
||||
const nameHeight = entry.nameText?.height ?? 0;
|
||||
const numberHeight = entry.numberText.height;
|
||||
const totalTextHeight =
|
||||
nameHeight + (entry.nameText !== null ? LABEL_LINE_GAP_PX : 0) + numberHeight;
|
||||
entry.numberText.y = entry.nameText !== null ? nameHeight + LABEL_LINE_GAP_PX : 0;
|
||||
entry.frame.clear();
|
||||
if (!isSelected) {
|
||||
entry.frame.visible = false;
|
||||
return;
|
||||
}
|
||||
const widestText = Math.max(
|
||||
entry.nameText?.width ?? 0,
|
||||
entry.numberText.width,
|
||||
);
|
||||
const frameWidth = widestText + LABEL_FRAME_PADDING_PX * 2;
|
||||
const frameHeight = totalTextHeight + LABEL_FRAME_PADDING_PX * 2;
|
||||
entry.frame.roundRect(
|
||||
-frameWidth / 2,
|
||||
-LABEL_FRAME_PADDING_PX,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
3,
|
||||
);
|
||||
entry.frame.fill({ color: theme.labelInverseBackground, alpha: 0.95 });
|
||||
entry.frame.visible = true;
|
||||
};
|
||||
|
||||
const updateLabelTransforms = (): void => {
|
||||
const cameraScale = viewport.scaled;
|
||||
if (cameraScale <= 0) return;
|
||||
const labelScale = 1 / cameraScale;
|
||||
const gapWorld = LABEL_OFFSET_PX / cameraScale;
|
||||
for (const [planetNumber, list] of planetLabelInstances) {
|
||||
const planetPrim = pointPrimitivesById.get(planetNumber);
|
||||
if (planetPrim === undefined) continue;
|
||||
const visibleRadius = displayPointRadiusWorld(
|
||||
planetPrim.style,
|
||||
cameraScale,
|
||||
currentScaleRef,
|
||||
);
|
||||
const labelData = currentLabels.find(
|
||||
(l) => l.planetNumber === planetNumber,
|
||||
);
|
||||
const anchorX = labelData?.x ?? planetPrim.x;
|
||||
const anchorY = labelData?.y ?? planetPrim.y;
|
||||
for (const entry of list) {
|
||||
entry.container.x = anchorX;
|
||||
entry.container.y = anchorY + visibleRadius + gapWorld;
|
||||
entry.container.scale.set(labelScale);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Planet outline state (F8-12 / #30). One Graphics per planet per
|
||||
// torus copy. Width and colour come from `PlanetOutlineSpec`; the
|
||||
// radius is recomputed on every zoom step so the outline tracks
|
||||
// the visible disc — the planet itself may grow / shrink with
|
||||
// zoom (`pointRadiusWorld` softening) or stay constant
|
||||
// (`pointRadiusPx` pixel-space).
|
||||
interface PlanetOutlineGfx {
|
||||
readonly graphics: Graphics[];
|
||||
readonly spec: PlanetOutlineSpec;
|
||||
}
|
||||
const planetOutlineInstances = new Map<number, PlanetOutlineGfx>();
|
||||
const OUTLINE_DEFAULT_WIDTH_PX = 1.5;
|
||||
const OUTLINE_RADIUS_PADDING_PX = 1; // gap between disc edge and stroke
|
||||
|
||||
const clearAllOutlines = (): void => {
|
||||
for (const entry of planetOutlineInstances.values()) {
|
||||
for (const g of entry.graphics) {
|
||||
g.parent?.removeChild(g);
|
||||
g.destroy();
|
||||
}
|
||||
}
|
||||
planetOutlineInstances.clear();
|
||||
};
|
||||
|
||||
const paintOutlineEntry = (entry: PlanetOutlineGfx): void => {
|
||||
const cameraScale = viewport.scaled;
|
||||
if (cameraScale <= 0) return;
|
||||
const planetPrim = pointPrimitivesById.get(entry.spec.planetNumber);
|
||||
if (planetPrim === undefined) {
|
||||
for (const g of entry.graphics) g.clear();
|
||||
return;
|
||||
}
|
||||
const visibleRadius = displayPointRadiusWorld(
|
||||
planetPrim.style,
|
||||
cameraScale,
|
||||
currentScaleRef,
|
||||
);
|
||||
const paddingWorld = OUTLINE_RADIUS_PADDING_PX / cameraScale;
|
||||
const widthWorld =
|
||||
(entry.spec.widthPx ?? OUTLINE_DEFAULT_WIDTH_PX) / cameraScale;
|
||||
const outlineRadius = visibleRadius + paddingWorld;
|
||||
for (const g of entry.graphics) {
|
||||
g.clear();
|
||||
g.circle(planetPrim.x, planetPrim.y, outlineRadius);
|
||||
g.stroke({
|
||||
color: entry.spec.color,
|
||||
alpha: 0.95,
|
||||
width: widthWorld,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const updateOutlineTransforms = (): void => {
|
||||
for (const entry of planetOutlineInstances.values()) {
|
||||
paintOutlineEntry(entry);
|
||||
}
|
||||
};
|
||||
|
||||
const setPlanetOutlines = (
|
||||
outlines: ReadonlyArray<PlanetOutlineSpec>,
|
||||
): void => {
|
||||
clearAllOutlines();
|
||||
for (const spec of outlines) {
|
||||
const list: Graphics[] = [];
|
||||
for (const layer of outlineLayers) {
|
||||
const g = new Graphics();
|
||||
layer.addChild(g);
|
||||
list.push(g);
|
||||
}
|
||||
const entry: PlanetOutlineGfx = { graphics: list, spec };
|
||||
planetOutlineInstances.set(spec.planetNumber, entry);
|
||||
paintOutlineEntry(entry);
|
||||
}
|
||||
requestRender();
|
||||
};
|
||||
|
||||
const setPlanetLabels = (
|
||||
labels: ReadonlyArray<PlanetLabelData>,
|
||||
selectedPlanetId: number | null,
|
||||
): void => {
|
||||
clearAllLabels();
|
||||
currentLabels = labels.slice();
|
||||
for (const data of labels) {
|
||||
const list: LabelGfx[] = [];
|
||||
for (const layer of labelLayers) {
|
||||
const container = new Container();
|
||||
const frame = new Graphics();
|
||||
frame.visible = false;
|
||||
container.addChild(frame);
|
||||
const nameText =
|
||||
data.name === null
|
||||
? null
|
||||
: buildLabelText(data.name, theme.labelText);
|
||||
if (nameText !== null) container.addChild(nameText);
|
||||
const numberText = buildLabelText(data.numberLabel, theme.labelMuted);
|
||||
container.addChild(numberText);
|
||||
layer.addChild(container);
|
||||
const entry: LabelGfx = {
|
||||
container,
|
||||
frame,
|
||||
nameText,
|
||||
numberText,
|
||||
};
|
||||
paintLabelEntry(entry, data.planetNumber === selectedPlanetId);
|
||||
list.push(entry);
|
||||
}
|
||||
planetLabelInstances.set(data.planetNumber, list);
|
||||
}
|
||||
updateLabelTransforms();
|
||||
requestRender();
|
||||
};
|
||||
|
||||
let mode: WrapMode = opts.mode;
|
||||
|
||||
const enforceCentreWhenLarger = (): void => {
|
||||
@@ -513,6 +845,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
{ widthPx: viewport.screenWidth, heightPx: viewport.screenHeight },
|
||||
opts.world,
|
||||
);
|
||||
currentScaleRef = minScale;
|
||||
// Both modes enforce minScale on zoom-out: the world (origin
|
||||
// copy) always fills at least the viewport. Without this, in
|
||||
// torus mode the user would zoom out far enough to see the
|
||||
@@ -535,6 +868,15 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
viewport.on("moved", wrapTorusCamera);
|
||||
wrapTorusCamera();
|
||||
}
|
||||
// The F8-12 zoom-invariant contract requires pixel-space sizes
|
||||
// (#28) and the softened planet radius (#31) to track the
|
||||
// current scale. `applyMode` can change `viewport.scaled` (e.g.
|
||||
// the `setZoom(minScale, true)` clamp above), so redraw all
|
||||
// primitives before the next paint and re-place the labels /
|
||||
// outlines too.
|
||||
redrawAllPrimitives();
|
||||
updateOutlineTransforms();
|
||||
updateLabelTransforms();
|
||||
// Toggling `copy.visible` does not move the camera, so request a
|
||||
// repaint explicitly; any camera change above also sets
|
||||
// `viewport.dirty`, which is harmless to request twice.
|
||||
@@ -543,6 +885,14 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
|
||||
applyMode(mode);
|
||||
|
||||
// `handleZoomed` is defined inline here so it can close over both
|
||||
// `redrawAllPrimitives` and the still-to-be-declared
|
||||
// `redrawPickOverlay`; the `viewport.on("zoomed", …)` subscription
|
||||
// happens further below, after both helpers are in scope. F8-12
|
||||
// (#28, #31): pixel-space sizes and the softened planet radius
|
||||
// depend on `viewport.scaled`, so a zoom step needs to repaint
|
||||
// every base primitive in the same frame.
|
||||
|
||||
// Pointer-move + hover plumbing. Listening on the underlying
|
||||
// canvas keeps the renderer agnostic of pixi-viewport's plugin
|
||||
// chain (drag/pinch can swallow Pixi-level pointer events while
|
||||
@@ -658,6 +1008,8 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
mode === "torus"
|
||||
? { width: opts.world.width, height: opts.world.height }
|
||||
: null,
|
||||
viewport.scaled,
|
||||
currentScaleRef,
|
||||
);
|
||||
for (const g of pickOverlays) {
|
||||
g.clear();
|
||||
@@ -772,6 +1124,18 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
};
|
||||
};
|
||||
|
||||
// Zoom-driven repaint. Both `redrawAllPrimitives` and
|
||||
// `redrawPickOverlay` are in scope now, so the subscription is
|
||||
// safe even on a synchronous zoomed event.
|
||||
const handleZoomed = (): void => {
|
||||
redrawAllPrimitives();
|
||||
updateOutlineTransforms();
|
||||
updateLabelTransforms();
|
||||
redrawPickOverlay();
|
||||
requestRender();
|
||||
};
|
||||
viewport.on("zoomed", handleZoomed);
|
||||
|
||||
const handle: RendererHandle = {
|
||||
app,
|
||||
viewport,
|
||||
@@ -809,6 +1173,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
primitiveGraphics.delete(id);
|
||||
}
|
||||
pointPrimitivesById.delete(id);
|
||||
primitiveById.delete(id);
|
||||
const idx = allPrimitiveIds.indexOf(id);
|
||||
if (idx >= 0) allPrimitiveIds.splice(idx, 1);
|
||||
}
|
||||
@@ -947,16 +1312,25 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
mask.destroy();
|
||||
}
|
||||
},
|
||||
setPlanetLabels,
|
||||
setPlanetOutlines,
|
||||
resize: (w, h) => {
|
||||
app.renderer.resize(w, h);
|
||||
viewport.resize(w, h, opts.world.width, opts.world.height);
|
||||
const minScale = minScaleNoWrap({ widthPx: w, heightPx: h }, opts.world);
|
||||
currentScaleRef = minScale;
|
||||
viewport.plugins.remove("clamp-zoom");
|
||||
viewport.clampZoom({ minScale });
|
||||
if (viewport.scaled < minScale) viewport.setZoom(minScale, true);
|
||||
if (mode === "no-wrap") {
|
||||
enforceCentreWhenLarger();
|
||||
}
|
||||
// Resize changes the reference scale and may clamp the zoom;
|
||||
// in both cases the softened planet radius / pixel-space
|
||||
// strokes need to follow.
|
||||
redrawAllPrimitives();
|
||||
updateOutlineTransforms();
|
||||
updateLabelTransforms();
|
||||
// The drawing buffer was resized; repaint at the new size.
|
||||
requestRender();
|
||||
},
|
||||
@@ -988,8 +1362,11 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
fogMask.destroy();
|
||||
fogMask = null;
|
||||
}
|
||||
clearAllLabels();
|
||||
clearAllOutlines();
|
||||
viewport.off("moved", enforceCentreWhenLarger);
|
||||
viewport.off("moved", wrapTorusCamera);
|
||||
viewport.off("zoomed", handleZoomed);
|
||||
viewport.off("clicked", handleViewportClicked);
|
||||
canvas.removeEventListener("pointermove", handlePointerMove);
|
||||
canvas.removeEventListener("pointerleave", handlePointerLeave);
|
||||
@@ -1011,37 +1388,54 @@ function rendererBackendName(r: Renderer): "webgl" | "webgpu" | "canvas" {
|
||||
return "webgl";
|
||||
}
|
||||
|
||||
function buildGraphics(p: Primitive, theme: Theme): Graphics {
|
||||
const g = new Graphics();
|
||||
if (p.kind === "point") drawPoint(g, p, theme);
|
||||
else if (p.kind === "circle") drawCircle(g, p, theme);
|
||||
else drawLine(g, p, theme);
|
||||
return g;
|
||||
}
|
||||
|
||||
function drawPoint(g: Graphics, p: PointPrim, theme: Theme): void {
|
||||
function drawPoint(
|
||||
g: Graphics,
|
||||
p: PointPrim,
|
||||
theme: Theme,
|
||||
cameraScale: number,
|
||||
scaleRef: number,
|
||||
): void {
|
||||
const color = p.style.fillColor ?? theme.pointFill;
|
||||
const alpha = p.style.fillAlpha ?? 1;
|
||||
const radiusPx = p.style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX;
|
||||
g.circle(p.x, p.y, radiusPx);
|
||||
const radius = displayPointRadiusWorld(p.style, cameraScale, scaleRef);
|
||||
g.circle(p.x, p.y, radius);
|
||||
g.fill({ color, alpha });
|
||||
if (p.style.strokeColor !== undefined && (p.style.strokeWidthPx ?? 0) > 0) {
|
||||
const strokeAlpha = p.style.strokeAlpha ?? 1;
|
||||
const strokeWidth = displayStrokeWidthWorld(p.style, cameraScale);
|
||||
g.stroke({
|
||||
color: p.style.strokeColor,
|
||||
alpha: strokeAlpha,
|
||||
width: strokeWidth,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function drawCircle(g: Graphics, p: CirclePrim, theme: Theme): void {
|
||||
function drawCircle(
|
||||
g: Graphics,
|
||||
p: CirclePrim,
|
||||
theme: Theme,
|
||||
cameraScale: number,
|
||||
): void {
|
||||
g.circle(p.x, p.y, p.radius);
|
||||
if (p.style.fillColor !== undefined) {
|
||||
g.fill({ color: p.style.fillColor, alpha: p.style.fillAlpha ?? 1 });
|
||||
}
|
||||
const strokeColor = p.style.strokeColor ?? theme.circleStroke;
|
||||
const strokeAlpha = p.style.strokeAlpha ?? 1;
|
||||
const strokeWidth = p.style.strokeWidthPx ?? 1;
|
||||
const strokeWidth = displayStrokeWidthWorld(p.style, cameraScale);
|
||||
g.stroke({ color: strokeColor, alpha: strokeAlpha, width: strokeWidth });
|
||||
}
|
||||
|
||||
function drawLine(g: Graphics, p: LinePrim, theme: Theme): void {
|
||||
function drawLine(
|
||||
g: Graphics,
|
||||
p: LinePrim,
|
||||
theme: Theme,
|
||||
cameraScale: number,
|
||||
): void {
|
||||
const color = p.style.strokeColor ?? theme.lineStroke;
|
||||
const alpha = p.style.strokeAlpha ?? 1;
|
||||
const width = p.style.strokeWidthPx ?? 1;
|
||||
const width = displayStrokeWidthWorld(p.style, cameraScale);
|
||||
const dash = p.style.strokeDashPx;
|
||||
if (dash === undefined || dash <= 0) {
|
||||
g.moveTo(p.x1, p.y1);
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
// Selected-planet marker. When the SelectionStore holds a planet, the
|
||||
// map draws one accent ring tight around it so the current selection is
|
||||
// visible on the canvas itself (the inspector/sheet show the detail).
|
||||
// Ship-group selection is intentionally not ringed here — groups are
|
||||
// addressed by report index and have no single stable map coordinate.
|
||||
|
||||
import { DARK_THEME, type CirclePrim, type Theme } from "./world";
|
||||
|
||||
/** Planet marker radius in world units; mirrors `battle-markers.ts`. */
|
||||
const PLANET_RADIUS_WORLD = 6;
|
||||
/** The ring sits just outside the marker (and the bombing ring at +3). */
|
||||
const SELECTION_RING_RADIUS = PLANET_RADIUS_WORLD + 4;
|
||||
|
||||
/** High-bit prefix so the ring id never collides with planet numbers,
|
||||
* route lines, reach rings (`0xb…`), or battle markers. */
|
||||
export const SELECTION_RING_ID = 0xc0000000;
|
||||
/** Below interactive primitives so it never wins a click. */
|
||||
const SELECTION_RING_PRIORITY = 0;
|
||||
|
||||
/**
|
||||
* computeSelectionRing returns one ring primitive centred on the selected
|
||||
* planet, or `null` when nothing (or a non-planet) is selected or the
|
||||
* planet is absent from the current report. `theme` supplies the ring
|
||||
* colour and defaults to `DARK_THEME`.
|
||||
*/
|
||||
export function computeSelectionRing(
|
||||
planets: ReadonlyArray<{ number: number; x: number; y: number }>,
|
||||
selectedPlanetId: number | null,
|
||||
theme: Theme = DARK_THEME,
|
||||
): CirclePrim | null {
|
||||
if (selectedPlanetId === null) return null;
|
||||
const planet = planets.find((p) => p.number === selectedPlanetId);
|
||||
if (planet === undefined) return null;
|
||||
return {
|
||||
kind: "circle",
|
||||
id: SELECTION_RING_ID,
|
||||
priority: SELECTION_RING_PRIORITY,
|
||||
hitSlopPx: 0,
|
||||
x: planet.x,
|
||||
y: planet.y,
|
||||
radius: SELECTION_RING_RADIUS,
|
||||
style: {
|
||||
strokeColor: theme.selectionRing,
|
||||
strokeAlpha: 0.95,
|
||||
strokeWidthPx: 1.5,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -37,24 +37,48 @@ import {
|
||||
// binding uses the engine number directly as the primitive id so the
|
||||
// click handler can recover a planet by hit-test result without an
|
||||
// extra lookup.
|
||||
function styleFor(kind: ReportPlanet["kind"], theme: Theme): Style {
|
||||
|
||||
/**
|
||||
* KNOWN_PLANET_BASE_RADIUS_WORLD is the world-unit scale of the cube-
|
||||
* root size mapping for planets with a known `size`. With α = 0.33
|
||||
* the on-screen pixel radius at default zoom is roughly
|
||||
* `BASE * cbrt(size) * scaleRef`. The cube root keeps planet area
|
||||
* proportional to volume, so a Size-8 planet reads twice as big as a
|
||||
* Size-1 one. Owner can tune this together with `PLANET_SIZE_ZOOM_ALPHA`
|
||||
* during the F8 manual-QA loop.
|
||||
*/
|
||||
const KNOWN_PLANET_BASE_RADIUS_WORLD = 4;
|
||||
|
||||
/**
|
||||
* UNKNOWN_PLANET_PIXEL_RADIUS matches issue #55 / п.28: planets with
|
||||
* an unknown size — `unidentified` planets and the rare `null`-size
|
||||
* report rows — sit at a constant 3-pixel disc regardless of zoom.
|
||||
*/
|
||||
const UNKNOWN_PLANET_PIXEL_RADIUS = 3;
|
||||
|
||||
function styleFor(planet: ReportPlanet, theme: Theme): Style {
|
||||
const fill = fillForKind(planet.kind, theme);
|
||||
const size = planet.size;
|
||||
if (planet.kind === "unidentified" || size === null || !(size > 0)) {
|
||||
return { ...fill, pointRadiusPx: UNKNOWN_PLANET_PIXEL_RADIUS };
|
||||
}
|
||||
const baseRadius = KNOWN_PLANET_BASE_RADIUS_WORLD * Math.cbrt(size);
|
||||
return { ...fill, pointRadiusWorld: baseRadius };
|
||||
}
|
||||
|
||||
function fillForKind(
|
||||
kind: ReportPlanet["kind"],
|
||||
theme: Theme,
|
||||
): { fillColor: number; fillAlpha: number } {
|
||||
switch (kind) {
|
||||
case "local":
|
||||
return { fillColor: theme.planetLocal, fillAlpha: 1, pointRadiusPx: 6 };
|
||||
return { fillColor: theme.planetLocal, fillAlpha: 1 };
|
||||
case "other":
|
||||
return { fillColor: theme.planetOther, fillAlpha: 1, pointRadiusPx: 5 };
|
||||
return { fillColor: theme.planetOther, fillAlpha: 1 };
|
||||
case "uninhabited":
|
||||
return {
|
||||
fillColor: theme.planetUninhabited,
|
||||
fillAlpha: 0.85,
|
||||
pointRadiusPx: 4,
|
||||
};
|
||||
return { fillColor: theme.planetUninhabited, fillAlpha: 0.85 };
|
||||
case "unidentified":
|
||||
return {
|
||||
fillColor: theme.planetUnidentified,
|
||||
fillAlpha: 0.7,
|
||||
pointRadiusPx: 3,
|
||||
};
|
||||
return { fillColor: theme.planetUnidentified, fillAlpha: 0.7 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,13 +100,16 @@ function priorityFor(kind: ReportPlanet["kind"]): number {
|
||||
* resolves to. The click handler in `lib/active-view/map.svelte`
|
||||
* looks the hit primitive's id up in the binding's hitLookup map
|
||||
* and dispatches `selection.selectPlanet` or
|
||||
* `selection.selectShipGroup` accordingly.
|
||||
* `selection.selectShipGroup` accordingly. Bombing markers no longer
|
||||
* surface as their own hit target (F8-12 / #30) — the visual cue is
|
||||
* a planet outline, the click on the planet still selects the
|
||||
* planet, and the bombing → report navigation starts in the
|
||||
* inspector.
|
||||
*/
|
||||
export type HitTarget =
|
||||
| { kind: "planet"; number: number }
|
||||
| { kind: "shipGroup"; ref: ShipGroupRef }
|
||||
| { kind: "battle"; battleId: string; planet: number }
|
||||
| { kind: "bombing"; planet: number };
|
||||
| { kind: "battle"; battleId: string; planet: number };
|
||||
|
||||
/**
|
||||
* PlanetCategory is the per-`ReportPlanet.kind` flavour exposed to the
|
||||
@@ -155,7 +182,7 @@ export function reportToWorld(
|
||||
kind: "point",
|
||||
id: planet.number,
|
||||
priority: priorityFor(planet.kind),
|
||||
style: styleFor(planet.kind, theme),
|
||||
style: styleFor(planet, theme),
|
||||
hitSlopPx: 0,
|
||||
x: planet.x,
|
||||
y: planet.y,
|
||||
|
||||
@@ -65,8 +65,6 @@ export function isCategoryVisible(
|
||||
return toggles.unidentifiedGroups;
|
||||
case "battleMarker":
|
||||
return toggles.battleMarkers;
|
||||
case "bombingMarker":
|
||||
return toggles.bombingMarkers;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,19 +17,34 @@ export type WrapMode = "torus" | "no-wrap";
|
||||
|
||||
// Style describes the visual appearance of a primitive. Any field may
|
||||
// be omitted; missing fields fall back to the active theme defaults.
|
||||
//
|
||||
// `strokeWidthPx` / `pointRadiusPx` are honest screen-pixel sizes
|
||||
// since F8-12 (#28): the renderer divides them by the current camera
|
||||
// scale before drawing, and rebuilds the affected `Graphics` whenever
|
||||
// the camera zooms. This keeps thin lines crisp and small markers
|
||||
// readable across the whole zoom range — the camera-relative
|
||||
// thickening that the old contract promised but never delivered is
|
||||
// gone.
|
||||
//
|
||||
// `pointRadiusWorld` is the opposite intent: a planet's known
|
||||
// `size` produces a base radius in world units, and the renderer
|
||||
// softens its growth with the camera scale through
|
||||
// `PLANET_SIZE_ZOOM_ALPHA` (F8-12 / #31). When `pointRadiusWorld`
|
||||
// is set on a `PointPrim`, `pointRadiusPx` is ignored.
|
||||
export interface Style {
|
||||
fillColor?: number; // 0xRRGGBB
|
||||
fillAlpha?: number; // 0..1
|
||||
strokeColor?: number; // 0xRRGGBB
|
||||
strokeAlpha?: number; // 0..1
|
||||
strokeWidthPx?: number; // pixels at any zoom
|
||||
pointRadiusPx?: number; // pixels at any zoom (for kind === 'point')
|
||||
strokeWidthPx?: number; // screen pixels at any zoom
|
||||
pointRadiusPx?: number; // screen pixels at any zoom (for kind === 'point')
|
||||
pointRadiusWorld?: number; // world units, softened by PLANET_SIZE_ZOOM_ALPHA
|
||||
// strokeDashPx — when set on a `LinePrim`, the line is rendered as
|
||||
// a dashed pattern whose dash and gap are both this length. When
|
||||
// unset (or zero), the stroke is solid. Interpreted in the same
|
||||
// world-unit space as `strokeWidthPx`, so the dash spacing scales
|
||||
// with the camera. Phase 19 uses this for the IncomingGroup
|
||||
// trajectory line; ignored on point and circle primitives.
|
||||
// unset (or zero), the stroke is solid. Interpreted in world-unit
|
||||
// space — the dash spacing scales with the camera. Phase 19 uses
|
||||
// this for the IncomingGroup trajectory line; ignored on point
|
||||
// and circle primitives.
|
||||
strokeDashPx?: number;
|
||||
}
|
||||
|
||||
@@ -171,20 +186,91 @@ export interface Theme {
|
||||
routeCap: number;
|
||||
routeMat: number;
|
||||
routeEmp: number;
|
||||
// Battle X-crosses and bombing rings (damaged vs wiped).
|
||||
// Battle X-crosses and the bombing accent (damaged vs wiped). The
|
||||
// bombing accent is now drawn as the planet's outline rather than a
|
||||
// separate ring (F8-12 / issue #55, п.30).
|
||||
battleMarker: number;
|
||||
bombingDamaged: number;
|
||||
bombingWiped: number;
|
||||
// Reach rings, the selected-planet ring, and pending-Send tracks.
|
||||
// Reach rings, the selection accent (planet outline + label frame),
|
||||
// and pending-Send tracks. `selectionRing` is kept around for the
|
||||
// soon-to-be-removed `selection-ring.ts` and the test that locks
|
||||
// the colour; both lines disappear once the label-driven selection
|
||||
// lands.
|
||||
reachCircle: number;
|
||||
selectionRing: number;
|
||||
selectionAccent: number;
|
||||
pendingSend: number;
|
||||
// Planet label colours. `labelText` paints the primary line
|
||||
// (planet name when the toggle is on), `labelMuted` paints the
|
||||
// `#N` companion line. The inverse pair fills the rounded frame
|
||||
// drawn around the selected planet's label (background = the
|
||||
// selection accent, text = the canvas background colour).
|
||||
labelText: number;
|
||||
labelMuted: number;
|
||||
labelInverseText: number;
|
||||
labelInverseBackground: number;
|
||||
// Pick-mode overlay: the anchor / cursor-line / hover highlight
|
||||
// colour and the multiply tint applied to non-reachable primitives.
|
||||
pickHighlight: number;
|
||||
pickDimTint: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* PLANET_SIZE_ZOOM_ALPHA is the exponent that softens the on-screen
|
||||
* growth of known-size planets with the camera scale (F8-12 / п.31).
|
||||
* `α = 1` keeps the historical linear-with-zoom behaviour; `α = 0`
|
||||
* would make planets fully zoom-invariant. 0.33 (cube-root soft scaling
|
||||
* relative to `scale_ref`) is the owner-approved starting point — it
|
||||
* gives a noticeable, but moderated, growth as the user zooms in. The
|
||||
* constant lives next to the themes so the tuning knob is in one
|
||||
* obvious place.
|
||||
*/
|
||||
export const PLANET_SIZE_ZOOM_ALPHA = 0.33;
|
||||
|
||||
/**
|
||||
* displayPointRadiusWorld returns the world-space radius the renderer
|
||||
* should draw a `PointPrim` with at the current camera scale. When
|
||||
* `style.pointRadiusWorld` is set (known-size planets), the radius is
|
||||
* the base world radius softened by `PLANET_SIZE_ZOOM_ALPHA` relative
|
||||
* to `scaleRef` — at `scale = scaleRef` it equals the base radius;
|
||||
* zooming in grows it sub-linearly. Otherwise the radius collapses to
|
||||
* `pointRadiusPx / cameraScale` so the on-screen disc stays the same
|
||||
* pixel size regardless of zoom.
|
||||
*
|
||||
* Used by both the renderer (`render.ts:drawPoint`) and the hit-test
|
||||
* (`hit-test.ts:matchPoint`) so the visible disc and the click zone
|
||||
* always agree.
|
||||
*/
|
||||
export function displayPointRadiusWorld(
|
||||
style: Style,
|
||||
cameraScale: number,
|
||||
scaleRef: number,
|
||||
): number {
|
||||
if (style.pointRadiusWorld !== undefined) {
|
||||
const softening = Math.pow(cameraScale / scaleRef, PLANET_SIZE_ZOOM_ALPHA - 1);
|
||||
return style.pointRadiusWorld * softening;
|
||||
}
|
||||
const px = style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX;
|
||||
if (cameraScale <= 0) return px;
|
||||
return px / cameraScale;
|
||||
}
|
||||
|
||||
/**
|
||||
* displayStrokeWidthWorld converts `style.strokeWidthPx` (a screen-pixel
|
||||
* thickness, F8-12 / #28) into the world-space width the renderer
|
||||
* passes to `g.stroke({...})`. The renderer redraws strokes on every
|
||||
* `viewport.zoomed` so the on-screen thickness stays constant.
|
||||
*/
|
||||
export function displayStrokeWidthWorld(
|
||||
style: Style,
|
||||
cameraScale: number,
|
||||
): number {
|
||||
const px = style.strokeWidthPx ?? 1;
|
||||
if (cameraScale <= 0) return px;
|
||||
return px / cameraScale;
|
||||
}
|
||||
|
||||
export const DARK_THEME: Theme = {
|
||||
background: 0x0a0e1a,
|
||||
fog: 0x12162a,
|
||||
@@ -208,7 +294,12 @@ export const DARK_THEME: Theme = {
|
||||
bombingWiped: 0xff3030,
|
||||
reachCircle: 0x6d8cff,
|
||||
selectionRing: 0x6d8cff,
|
||||
selectionAccent: 0x6d8cff,
|
||||
pendingSend: 0x66bb6a,
|
||||
labelText: 0xc7d2e0,
|
||||
labelMuted: 0x90a4ae,
|
||||
labelInverseText: 0x0a0e1a,
|
||||
labelInverseBackground: 0x6d8cff,
|
||||
pickHighlight: 0xffe082,
|
||||
pickDimTint: 0x303841,
|
||||
};
|
||||
@@ -245,7 +336,12 @@ export const LIGHT_THEME: Theme = {
|
||||
bombingWiped: 0xc62828,
|
||||
reachCircle: 0x3949ab,
|
||||
selectionRing: 0x3949ab,
|
||||
selectionAccent: 0x3949ab,
|
||||
pendingSend: 0x388e3c,
|
||||
labelText: 0x263240,
|
||||
labelMuted: 0x5a6d8a,
|
||||
labelInverseText: 0xf3f5fb,
|
||||
labelInverseBackground: 0x3949ab,
|
||||
pickHighlight: 0xef6c00,
|
||||
pickDimTint: 0xaeb6c4,
|
||||
};
|
||||
|
||||
@@ -110,7 +110,7 @@ describe("buildBattleAndBombingMarkers", () => {
|
||||
expect(out.primitives).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("emits one yellow ring per damaged bombing and red per wiped", () => {
|
||||
it("does not emit bombing primitives (F8-12 / #30) — the planet outline is drawn elsewhere", () => {
|
||||
const report = makeReport({
|
||||
planets: [
|
||||
{
|
||||
@@ -163,28 +163,12 @@ describe("buildBattleAndBombingMarkers", () => {
|
||||
attackPower: 1,
|
||||
wiped: false,
|
||||
},
|
||||
{
|
||||
planetNumber: 2,
|
||||
planet: "B",
|
||||
owner: "X",
|
||||
attacker: "Y",
|
||||
production: "MAT",
|
||||
industry: 0,
|
||||
population: 0,
|
||||
colonists: 0,
|
||||
industryStockpile: 0,
|
||||
materialsStockpile: 0,
|
||||
attackPower: 1,
|
||||
wiped: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const out = buildBattleAndBombingMarkers(report);
|
||||
const rings = out.primitives.filter((p) => p.kind === "circle");
|
||||
expect(rings).toHaveLength(2);
|
||||
expect(rings[0].style.strokeColor).toBe(DARK_THEME.bombingDamaged);
|
||||
expect(rings[1].style.strokeColor).toBe(DARK_THEME.bombingWiped);
|
||||
expect(out.primitives.filter((p) => p.kind === "circle")).toHaveLength(0);
|
||||
// `setPlanetOutlines` in the renderer paints the bombing accent.
|
||||
});
|
||||
|
||||
it("paints markers with the supplied palette's colours", () => {
|
||||
@@ -231,11 +215,9 @@ describe("buildBattleAndBombingMarkers", () => {
|
||||
|
||||
const out = buildBattleAndBombingMarkers(report, LIGHT_THEME);
|
||||
const lines = out.primitives.filter((p) => p.kind === "line");
|
||||
const rings = out.primitives.filter((p) => p.kind === "circle");
|
||||
for (const l of lines) {
|
||||
expect(l.style.strokeColor).toBe(LIGHT_THEME.battleMarker);
|
||||
}
|
||||
expect(rings[0].style.strokeColor).toBe(LIGHT_THEME.bombingWiped);
|
||||
// The accents are deliberately distinct between the palettes.
|
||||
expect(LIGHT_THEME.battleMarker).not.toBe(DARK_THEME.battleMarker);
|
||||
expect(LIGHT_THEME.bombingWiped).not.toBe(DARK_THEME.bombingWiped);
|
||||
|
||||
@@ -365,9 +365,39 @@ test("toggle state persists across a page reload", async ({ page }) => {
|
||||
expect(
|
||||
await page.getByTestId("map-toggles-bombing-markers").isChecked(),
|
||||
).toBe(false);
|
||||
// Battle X-cross and bombing ring are hidden in the renderer.
|
||||
// Battle X-cross primitives stay hidden in the renderer. F8-12 / #30
|
||||
// retired the bombing CirclePrim — the toggle now hides a planet
|
||||
// outline overlay, which sits outside the primitive surface; the
|
||||
// high-bit 0xc… range is permanently empty.
|
||||
expect(await visibleHighBitCount(page, 0xa0000000)).toBe(0);
|
||||
expect(await visibleHighBitCount(page, 0xc0000000)).toBe(0);
|
||||
});
|
||||
|
||||
test("planet-names toggle persists across a page reload (F8-12 / #29)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockGateway(page, { currentTurn: 1 });
|
||||
await bootSession(page);
|
||||
await openGame(page);
|
||||
|
||||
await page.getByTestId("map-toggles-trigger").click();
|
||||
// Default ON; flip it OFF.
|
||||
expect(await page.getByTestId("map-toggles-planet-names").isChecked()).toBe(
|
||||
true,
|
||||
);
|
||||
await page.getByTestId("map-toggles-planet-names").click();
|
||||
expect(await page.getByTestId("map-toggles-planet-names").isChecked()).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
await page.reload({ waitUntil: "commit" });
|
||||
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||
"data-status",
|
||||
"ready",
|
||||
);
|
||||
await page.getByTestId("map-toggles-trigger").click();
|
||||
expect(await page.getByTestId("map-toggles-planet-names").isChecked()).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// settledRenderCount waits out the mount/resize paint burst and returns
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Coverage for the F8-12 sizing helpers in src/map/world.ts:
|
||||
// `displayPointRadiusWorld` (the union of the pixel-space and the
|
||||
// softened-by-zoom rules) and `displayStrokeWidthWorld` (pixel-space
|
||||
// stroke widths). Both are pure math, so this file stays Pixi-free.
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_POINT_RADIUS_PX,
|
||||
PLANET_SIZE_ZOOM_ALPHA,
|
||||
displayPointRadiusWorld,
|
||||
displayStrokeWidthWorld,
|
||||
} from "../src/map/world";
|
||||
|
||||
describe("displayPointRadiusWorld — pixel-space (pointRadiusPx)", () => {
|
||||
test("returns pixel size divided by scale at scale=1", () => {
|
||||
expect(displayPointRadiusWorld({ pointRadiusPx: 5 }, 1, 0.2)).toBe(5);
|
||||
});
|
||||
|
||||
test("shrinks the world footprint as zoom grows", () => {
|
||||
expect(displayPointRadiusWorld({ pointRadiusPx: 6 }, 3, 0.2)).toBeCloseTo(2);
|
||||
});
|
||||
|
||||
test("falls back to DEFAULT_POINT_RADIUS_PX when the style is bare", () => {
|
||||
expect(displayPointRadiusWorld({}, 2, 0.2)).toBeCloseTo(
|
||||
DEFAULT_POINT_RADIUS_PX / 2,
|
||||
);
|
||||
});
|
||||
|
||||
test("zero-scale guard returns the raw pixel size", () => {
|
||||
expect(displayPointRadiusWorld({ pointRadiusPx: 4 }, 0, 0.2)).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("displayPointRadiusWorld — softened by zoom (pointRadiusWorld)", () => {
|
||||
test("at scale=scaleRef the visible radius equals the base radius", () => {
|
||||
const radius = displayPointRadiusWorld(
|
||||
{ pointRadiusWorld: 6 },
|
||||
0.2,
|
||||
0.2,
|
||||
);
|
||||
expect(radius).toBeCloseTo(6);
|
||||
});
|
||||
|
||||
test("zooming in grows the radius sub-linearly", () => {
|
||||
const r1 = displayPointRadiusWorld({ pointRadiusWorld: 6 }, 0.2, 0.2);
|
||||
const r10 = displayPointRadiusWorld({ pointRadiusWorld: 6 }, 2.0, 0.2);
|
||||
// On-screen pixel size grows by scale^α (α = 0.33) instead of
|
||||
// linearly: 10x zoom → ~10^0.33 ≈ 2.15x growth.
|
||||
const onScreenAt1 = r1 * 0.2;
|
||||
const onScreenAt10 = r10 * 2.0;
|
||||
expect(onScreenAt10 / onScreenAt1).toBeCloseTo(
|
||||
Math.pow(10, PLANET_SIZE_ZOOM_ALPHA),
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
test("ignores pointRadiusPx when pointRadiusWorld is set", () => {
|
||||
const r = displayPointRadiusWorld(
|
||||
{ pointRadiusPx: 99, pointRadiusWorld: 4 },
|
||||
0.4,
|
||||
0.2,
|
||||
);
|
||||
// World radius is the base softened by (0.4/0.2)^(α-1).
|
||||
expect(r).toBeCloseTo(4 * Math.pow(2, PLANET_SIZE_ZOOM_ALPHA - 1), 4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("displayStrokeWidthWorld", () => {
|
||||
test("returns width / scale at any zoom", () => {
|
||||
expect(displayStrokeWidthWorld({ strokeWidthPx: 2 }, 1)).toBe(2);
|
||||
expect(displayStrokeWidthWorld({ strokeWidthPx: 2 }, 4)).toBeCloseTo(0.5);
|
||||
expect(displayStrokeWidthWorld({ strokeWidthPx: 2 }, 0.5)).toBeCloseTo(4);
|
||||
});
|
||||
|
||||
test("falls back to 1 when strokeWidthPx is omitted", () => {
|
||||
expect(displayStrokeWidthWorld({}, 2)).toBeCloseTo(0.5);
|
||||
});
|
||||
|
||||
test("zero-scale guard returns the raw pixel value", () => {
|
||||
expect(displayStrokeWidthWorld({ strokeWidthPx: 3 }, 0)).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// Coverage for the F8-12 / #29 planet-label formatting. The
|
||||
// renderer's per-Pixi.Text drawing lives behind Pixi APIs (and is
|
||||
// exercised by Playwright); this file pins the pure data step.
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { GameReport } from "../src/api/game-state";
|
||||
import { buildPlanetLabels } from "../src/map/labels";
|
||||
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
||||
|
||||
function makeReport(overrides: Partial<GameReport> = {}): GameReport {
|
||||
return {
|
||||
turn: 1,
|
||||
mapWidth: 100,
|
||||
mapHeight: 100,
|
||||
planetCount: 0,
|
||||
planets: [],
|
||||
race: "",
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 0,
|
||||
localPlayerWeapons: 0,
|
||||
localPlayerShields: 0,
|
||||
localPlayerCargo: 0,
|
||||
...EMPTY_SHIP_GROUPS,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildPlanetLabels", () => {
|
||||
test("named planet with showNames=true emits both lines", () => {
|
||||
const report = makeReport({
|
||||
planets: [
|
||||
{
|
||||
number: 5,
|
||||
name: "Tancordia",
|
||||
kind: "local",
|
||||
x: 10,
|
||||
y: 20,
|
||||
owner: null,
|
||||
size: null,
|
||||
resources: null,
|
||||
industryStockpile: null,
|
||||
materialsStockpile: null,
|
||||
industry: null,
|
||||
population: null,
|
||||
colonists: null,
|
||||
production: null,
|
||||
freeIndustry: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
const out = buildPlanetLabels(report, { showNames: true });
|
||||
expect(out).toEqual([
|
||||
{
|
||||
planetNumber: 5,
|
||||
x: 10,
|
||||
y: 20,
|
||||
name: "Tancordia",
|
||||
numberLabel: "#5",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("named planet with showNames=false drops the name line", () => {
|
||||
const report = makeReport({
|
||||
planets: [
|
||||
{
|
||||
number: 5,
|
||||
name: "Tancordia",
|
||||
kind: "local",
|
||||
x: 10,
|
||||
y: 20,
|
||||
owner: null,
|
||||
size: null,
|
||||
resources: null,
|
||||
industryStockpile: null,
|
||||
materialsStockpile: null,
|
||||
industry: null,
|
||||
population: null,
|
||||
colonists: null,
|
||||
production: null,
|
||||
freeIndustry: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
const out = buildPlanetLabels(report, { showNames: false });
|
||||
expect(out[0].name).toBeNull();
|
||||
expect(out[0].numberLabel).toBe("#5");
|
||||
});
|
||||
|
||||
test("unidentified planet always renders #N only, ignoring the toggle", () => {
|
||||
const report = makeReport({
|
||||
planets: [
|
||||
{
|
||||
number: 42,
|
||||
name: "Tancordia",
|
||||
kind: "unidentified",
|
||||
x: 5,
|
||||
y: 5,
|
||||
owner: null,
|
||||
size: null,
|
||||
resources: null,
|
||||
industryStockpile: null,
|
||||
materialsStockpile: null,
|
||||
industry: null,
|
||||
population: null,
|
||||
colonists: null,
|
||||
production: null,
|
||||
freeIndustry: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
const on = buildPlanetLabels(report, { showNames: true });
|
||||
const off = buildPlanetLabels(report, { showNames: false });
|
||||
expect(on[0].name).toBeNull();
|
||||
expect(off[0].name).toBeNull();
|
||||
expect(on[0].numberLabel).toBe("#42");
|
||||
});
|
||||
|
||||
test("empty-name planet falls back to #N", () => {
|
||||
const report = makeReport({
|
||||
planets: [
|
||||
{
|
||||
number: 9,
|
||||
name: "",
|
||||
kind: "uninhabited",
|
||||
x: 1,
|
||||
y: 1,
|
||||
owner: null,
|
||||
size: null,
|
||||
resources: null,
|
||||
industryStockpile: null,
|
||||
materialsStockpile: null,
|
||||
industry: null,
|
||||
population: null,
|
||||
colonists: null,
|
||||
production: null,
|
||||
freeIndustry: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
const out = buildPlanetLabels(report, { showNames: true });
|
||||
expect(out[0].name).toBeNull();
|
||||
expect(out[0].numberLabel).toBe("#9");
|
||||
});
|
||||
});
|
||||
@@ -59,6 +59,7 @@ describe("MapTogglesControl", () => {
|
||||
expect(ui.getByTestId("map-toggles-uninhabited-planets")).toBeChecked();
|
||||
expect(ui.getByTestId("map-toggles-unidentified-planets")).toBeChecked();
|
||||
expect(ui.getByTestId("map-toggles-unreachable-planets")).toBeChecked();
|
||||
expect(ui.getByTestId("map-toggles-planet-names")).toBeChecked();
|
||||
expect(ui.getByTestId("map-toggles-visible-hyperspace")).toBeChecked();
|
||||
expect(ui.queryByTestId("map-toggles-wrap-torus")).toBeNull();
|
||||
expect(ui.queryByTestId("map-toggles-wrap-no-wrap")).toBeNull();
|
||||
@@ -91,6 +92,17 @@ describe("MapTogglesControl", () => {
|
||||
expect(setMapToggle).not.toHaveBeenCalledWith("bombingMarkers", false);
|
||||
});
|
||||
|
||||
test("planet-names checkbox flips the planetNames toggle (F8-12 / #29)", async () => {
|
||||
const store = buildStore();
|
||||
const setMapToggle = vi
|
||||
.spyOn(store, "setMapToggle")
|
||||
.mockResolvedValue(undefined);
|
||||
const ui = render(MapTogglesControl, { props: { store } });
|
||||
await fireEvent.click(ui.getByTestId("map-toggles-trigger"));
|
||||
await fireEvent.click(ui.getByTestId("map-toggles-planet-names"));
|
||||
expect(setMapToggle).toHaveBeenCalledWith("planetNames", false);
|
||||
});
|
||||
|
||||
test("Escape closes the popover", async () => {
|
||||
const store = buildStore();
|
||||
const ui = render(MapTogglesControl, { props: { store } });
|
||||
|
||||
@@ -113,6 +113,7 @@ describe("GameStateStore.mapToggles persistence", () => {
|
||||
await a.init({ client: makeFakeClient(3), cache, gameId: GAME_ID });
|
||||
await a.setMapToggle("hyperspaceGroups", false);
|
||||
await a.setMapToggle("battleMarkers", false);
|
||||
await a.setMapToggle("planetNames", false);
|
||||
await a.setMapToggle("visibleHyperspace", false);
|
||||
a.dispose();
|
||||
|
||||
@@ -121,6 +122,7 @@ describe("GameStateStore.mapToggles persistence", () => {
|
||||
await b.init({ client: makeFakeClient(3), cache, gameId: GAME_ID });
|
||||
expect(b.mapToggles.hyperspaceGroups).toBe(false);
|
||||
expect(b.mapToggles.battleMarkers).toBe(false);
|
||||
expect(b.mapToggles.planetNames).toBe(false);
|
||||
expect(b.mapToggles.visibleHyperspace).toBe(false);
|
||||
// Untouched flags retain defaults.
|
||||
expect(b.mapToggles.bombingMarkers).toBe(true);
|
||||
@@ -141,6 +143,7 @@ describe("GameStateStore.mapToggles persistence", () => {
|
||||
expect(store.mapToggles.hyperspaceGroups).toBe(false);
|
||||
expect(store.mapToggles.battleMarkers).toBe(true);
|
||||
expect(store.mapToggles.bombingMarkers).toBe(true);
|
||||
expect(store.mapToggles.planetNames).toBe(true);
|
||||
expect(store.mapToggles.visibleHyperspace).toBe(true);
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { computeSelectionRing, SELECTION_RING_ID } from "../src/map/selection-ring";
|
||||
import { DARK_THEME, LIGHT_THEME } from "../src/map/world";
|
||||
|
||||
const planets = [
|
||||
{ number: 1, x: 10, y: 20 },
|
||||
{ number: 2, x: 30, y: 40 },
|
||||
];
|
||||
|
||||
describe("computeSelectionRing", () => {
|
||||
it("returns null when nothing is selected", () => {
|
||||
expect(computeSelectionRing(planets, null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the selected planet is absent from the report", () => {
|
||||
expect(computeSelectionRing(planets, 99)).toBeNull();
|
||||
});
|
||||
|
||||
it("rings the selected planet at its coordinates", () => {
|
||||
const ring = computeSelectionRing(planets, 2);
|
||||
expect(ring).toMatchObject({
|
||||
kind: "circle",
|
||||
id: SELECTION_RING_ID,
|
||||
x: 30,
|
||||
y: 40,
|
||||
hitSlopPx: 0,
|
||||
});
|
||||
// Defaults to the dark palette.
|
||||
expect(ring?.style.strokeColor).toBe(DARK_THEME.selectionRing);
|
||||
// Sits outside the planet marker (radius 6 world units).
|
||||
expect(ring?.radius ?? 0).toBeGreaterThan(6);
|
||||
});
|
||||
|
||||
it("uses the supplied palette's ring colour", () => {
|
||||
const ring = computeSelectionRing(planets, 2, LIGHT_THEME);
|
||||
expect(ring?.style.strokeColor).toBe(LIGHT_THEME.selectionRing);
|
||||
expect(LIGHT_THEME.selectionRing).not.toBe(DARK_THEME.selectionRing);
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
ReportPlanet,
|
||||
ReportUnidentifiedShipGroup,
|
||||
} from "../src/api/game-state";
|
||||
import { BATTLE_MARKER_ID_PREFIX, BOMBING_MARKER_ID_PREFIX } from "../src/map/battle-markers";
|
||||
import { BATTLE_MARKER_ID_PREFIX } from "../src/map/battle-markers";
|
||||
import { SHIP_GROUP_ID_OFFSETS } from "../src/map/ship-groups";
|
||||
import { reportToWorld } from "../src/map/state-binding";
|
||||
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
||||
@@ -200,7 +200,7 @@ describe("reportToWorld — categories", () => {
|
||||
expect(categories.get(unidentifiedId)).toBe("unidentifiedGroup");
|
||||
});
|
||||
|
||||
test("battle markers and bombing markers each carry their own category", () => {
|
||||
test("battle markers carry the battleMarker category", () => {
|
||||
const { categories } = reportToWorld(
|
||||
makeReport({
|
||||
planets: [
|
||||
@@ -208,6 +208,9 @@ describe("reportToWorld — categories", () => {
|
||||
makePlanet({ number: 2, kind: "other", x: 200, y: 200 }),
|
||||
],
|
||||
battles: [makeBattle({ id: "b1", planet: 2 })],
|
||||
// F8-12 / #30: bombings no longer emit their own
|
||||
// primitives — the planet outline is drawn by
|
||||
// `setPlanetOutlines` from the map view.
|
||||
bombings: [makeBombing({ planetNumber: 2 })],
|
||||
}),
|
||||
);
|
||||
@@ -216,8 +219,6 @@ describe("reportToWorld — categories", () => {
|
||||
const battleB = BATTLE_MARKER_ID_PREFIX | (0 << 4) | 1;
|
||||
expect(categories.get(battleA)).toBe("battleMarker");
|
||||
expect(categories.get(battleB)).toBe("battleMarker");
|
||||
const bombingId = BOMBING_MARKER_ID_PREFIX | 0;
|
||||
expect(categories.get(bombingId)).toBe("bombingMarker");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -235,7 +236,7 @@ describe("reportToWorld — planetDependents", () => {
|
||||
expect(planetDependents.get(7)?.has(7)).toBe(true);
|
||||
});
|
||||
|
||||
test("battle / bombing markers cascade onto their anchor planet", () => {
|
||||
test("battle markers cascade onto their anchor planet", () => {
|
||||
const { planetDependents } = reportToWorld(
|
||||
makeReport({
|
||||
planets: [
|
||||
@@ -243,17 +244,18 @@ describe("reportToWorld — planetDependents", () => {
|
||||
makePlanet({ number: 2, kind: "other", x: 200, y: 200 }),
|
||||
],
|
||||
battles: [makeBattle({ planet: 2 })],
|
||||
// Bombings are still in the report but no primitive
|
||||
// rides the cascade now — they paint a planet outline
|
||||
// straight from `map.svelte`.
|
||||
bombings: [makeBombing({ planetNumber: 2 })],
|
||||
}),
|
||||
);
|
||||
const battleA = BATTLE_MARKER_ID_PREFIX | (0 << 4) | 0;
|
||||
const battleB = BATTLE_MARKER_ID_PREFIX | (0 << 4) | 1;
|
||||
const bombingId = BOMBING_MARKER_ID_PREFIX | 0;
|
||||
const deps = planetDependents.get(2) ?? new Set();
|
||||
expect(deps.has(2)).toBe(true);
|
||||
expect(deps.has(battleA)).toBe(true);
|
||||
expect(deps.has(battleB)).toBe(true);
|
||||
expect(deps.has(bombingId)).toBe(true);
|
||||
});
|
||||
|
||||
test("in-space groups cascade onto their destination planet", () => {
|
||||
|
||||
@@ -82,10 +82,10 @@ describe("isCategoryVisible", () => {
|
||||
expect(isCategoryVisible("planet-unidentified", t)).toBe(false);
|
||||
});
|
||||
|
||||
test("battle and bombing markers have independent toggles", () => {
|
||||
const t = toggles({ battleMarkers: false, bombingMarkers: true });
|
||||
test("battleMarker toggle hides battle X-crosses without touching other layers", () => {
|
||||
const t = toggles({ battleMarkers: false });
|
||||
expect(isCategoryVisible("battleMarker", t)).toBe(false);
|
||||
expect(isCategoryVisible("bombingMarker", t)).toBe(true);
|
||||
expect(isCategoryVisible("planet-foreign", t)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -202,6 +202,9 @@ describe("computeHiddenPlanetNumbers", () => {
|
||||
});
|
||||
|
||||
describe("computeHiddenIds", () => {
|
||||
// F8-12 / #30: bombings no longer ride the cascade as their own
|
||||
// primitive — they paint a planet outline directly. The fixture
|
||||
// here mirrors what `reportToWorld` currently emits.
|
||||
const categories: Map<PrimitiveID, MapCategory> = new Map<
|
||||
PrimitiveID,
|
||||
MapCategory
|
||||
@@ -212,11 +215,10 @@ describe("computeHiddenIds", () => {
|
||||
[150, "hyperspaceGroup"],
|
||||
[200, "incomingGroup"],
|
||||
[300, "battleMarker"],
|
||||
[400, "bombingMarker"],
|
||||
]);
|
||||
const planetDependents = new Map<number, ReadonlySet<PrimitiveID>>([
|
||||
[1, new Set([1])],
|
||||
[2, new Set([2, 100, 150, 200, 300, 400])],
|
||||
[2, new Set([2, 100, 150, 200, 300])],
|
||||
]);
|
||||
|
||||
test("category-toggle off hides every primitive in that category", () => {
|
||||
@@ -239,10 +241,10 @@ describe("computeHiddenIds", () => {
|
||||
new Set([2]),
|
||||
toggles(),
|
||||
);
|
||||
expect(hidden).toEqual(new Set([2, 100, 150, 200, 300, 400]));
|
||||
expect(hidden).toEqual(new Set([2, 100, 150, 200, 300]));
|
||||
});
|
||||
|
||||
test("battle / bombing markers have independent toggles", () => {
|
||||
test("battle markers honour the battleMarkers toggle independently", () => {
|
||||
const hidden = computeHiddenIds(
|
||||
categories,
|
||||
planetDependents,
|
||||
@@ -250,7 +252,7 @@ describe("computeHiddenIds", () => {
|
||||
toggles({ battleMarkers: false }),
|
||||
);
|
||||
expect(hidden.has(300)).toBe(true);
|
||||
expect(hidden.has(400)).toBe(false);
|
||||
expect(hidden.has(150)).toBe(false);
|
||||
});
|
||||
|
||||
test("planet cascade and category toggle compose without duplicates", () => {
|
||||
@@ -262,7 +264,7 @@ describe("computeHiddenIds", () => {
|
||||
);
|
||||
// 300 is already present from the cascade; the category toggle
|
||||
// re-adds it but Set semantics dedupe.
|
||||
expect(hidden).toEqual(new Set([2, 100, 150, 200, 300, 400]));
|
||||
expect(hidden).toEqual(new Set([2, 100, 150, 200, 300]));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user