From 680ebac9198421487015c05f9e5c89e2d31ab789 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 27 May 2026 23:51:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20F8-12=20=E2=80=94=20map=20polish=20?= =?UTF-8?q?(zoom=20invariance,=20labels,=20selection,=20soft=20radius)=20(?= =?UTF-8?q?#55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- ui/docs/renderer.md | 53 ++- .../src/lib/active-view/map-toggles.svelte | 9 + ui/frontend/src/lib/active-view/map.svelte | 107 +++-- ui/frontend/src/lib/game-state.svelte.ts | 9 + ui/frontend/src/lib/i18n/locales/en.ts | 3 + ui/frontend/src/lib/i18n/locales/ru.ts | 3 + .../src/lib/inspectors/planet-sheet.svelte | 4 + ui/frontend/src/lib/inspectors/planet.svelte | 52 +++ ui/frontend/src/lib/report-nav.ts | 30 ++ .../src/lib/sidebar/inspector-tab.svelte | 7 + ui/frontend/src/map/battle-markers.ts | 80 +--- ui/frontend/src/map/hit-test.ts | 29 +- ui/frontend/src/map/index.ts | 3 + ui/frontend/src/map/labels.ts | 56 +++ ui/frontend/src/map/pick-mode.ts | 25 +- ui/frontend/src/map/render.ts | 428 +++++++++++++++++- ui/frontend/src/map/selection-ring.ts | 48 -- ui/frontend/src/map/state-binding.ts | 61 ++- ui/frontend/src/map/visibility.ts | 2 - ui/frontend/src/map/world.ts | 112 ++++- ui/frontend/tests/battle-markers.test.ts | 24 +- ui/frontend/tests/e2e/map-toggles.spec.ts | 34 +- ui/frontend/tests/map-display-sizing.test.ts | 83 ++++ ui/frontend/tests/map-hit-test.test.ts | 62 ++- ui/frontend/tests/map-labels.test.ts | 147 ++++++ .../tests/map-toggles-component.test.ts | 12 + ui/frontend/tests/map-toggles-state.test.ts | 3 + ui/frontend/tests/selection-ring.test.ts | 40 -- .../tests/state-binding-cascade.test.ts | 16 +- ui/frontend/tests/visibility-helpers.test.ts | 20 +- 30 files changed, 1240 insertions(+), 322 deletions(-) create mode 100644 ui/frontend/src/lib/report-nav.ts create mode 100644 ui/frontend/src/map/labels.ts delete mode 100644 ui/frontend/src/map/selection-ring.ts create mode 100644 ui/frontend/tests/map-display-sizing.test.ts create mode 100644 ui/frontend/tests/map-labels.test.ts delete mode 100644 ui/frontend/tests/selection-ring.test.ts diff --git a/ui/docs/renderer.md b/ui/docs/renderer.md index 78c011c..2417b14 100644 --- a/ui/docs/renderer.md +++ b/ui/docs/renderer.md @@ -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`. diff --git a/ui/frontend/src/lib/active-view/map-toggles.svelte b/ui/frontend/src/lib/active-view/map-toggles.svelte index 9b74f0c..c5fccdf 100644 --- a/ui/frontend/src/lib/active-view/map-toggles.svelte +++ b/ui/frontend/src/lib/active-view/map-toggles.svelte @@ -177,6 +177,15 @@ bottom-tabs bar. /> {i18n.t("game.map.toggles.unreachable_planets")} +
{i18n.t("game.map.toggles.section.view")} diff --git a/ui/frontend/src/lib/active-view/map.svelte b/ui/frontend/src/lib/active-view/map.svelte index c965df2..8a321b4 100644 --- a/ui/frontend/src/lib/active-view/map.svelte +++ b/ui/frontend/src/lib/active-view/map.svelte @@ -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, + 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, + 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; - } } } diff --git a/ui/frontend/src/lib/game-state.svelte.ts b/ui/frontend/src/lib/game-state.svelte.ts index 5ac54e7..605cb97 100644 --- a/ui/frontend/src/lib/game-state.svelte.ts +++ b/ui/frontend/src/lib/game-state.svelte.ts @@ -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, }; diff --git a/ui/frontend/src/lib/i18n/locales/en.ts b/ui/frontend/src/lib/i18n/locales/en.ts index 1f33073..cf01635 100644 --- a/ui/frontend/src/lib/i18n/locales/en.ts +++ b/ui/frontend/src/lib/i18n/locales/en.ts @@ -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", diff --git a/ui/frontend/src/lib/i18n/locales/ru.ts b/ui/frontend/src/lib/i18n/locales/ru.ts index d4c1ce9..15994ff 100644 --- a/ui/frontend/src/lib/i18n/locales/ru.ts +++ b/ui/frontend/src/lib/i18n/locales/ru.ts @@ -176,6 +176,7 @@ const ru: Record = { "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 = { "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": "переименование планеты", diff --git a/ui/frontend/src/lib/inspectors/planet-sheet.svelte b/ui/frontend/src/lib/inspectors/planet-sheet.svelte index eba8899..65d0f9b 100644 --- a/ui/frontend/src/lib/inspectors/planet-sheet.svelte +++ b/ui/frontend/src/lib/inspectors/planet-sheet.svelte @@ -12,6 +12,7 @@ dismiss from the IA section §6 are deferred to a later polish pass. -->
@@ -124,6 +130,7 @@ from the Phase 10 stub. {localShipGroups} {otherShipGroups} {localRace} + bombing={selectedPlanetBombing} /> {:else if selectedShipGroup !== null} 0; + out.push({ + planetNumber: p.number, + x: p.x, + y: p.y, + name: named ? p.name : null, + numberLabel: `#${p.number}`, + }); + } + return out; +} diff --git a/ui/frontend/src/map/pick-mode.ts b/ui/frontend/src/map/pick-mode.ts index 405e109..6f22c7b 100644 --- a/ui/frontend/src/map/pick-mode.ts +++ b/ui/frontend/src/map/pick-mode.ts @@ -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, allPrimitiveIds: Iterable, 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(); 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, }; } } diff --git a/ui/frontend/src/map/render.ts b/ui/frontend/src/map/render.ts index a85b4ab..a9d56fd 100644 --- a/ui/frontend/src/map/render.ts +++ b/ui/frontend/src/map/render.ts @@ -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, + 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): void; resize(widthPx: number, heightPx: number): void; dispose(): void; } @@ -414,14 +453,52 @@ export async function createRenderer(opts: RendererOptions): Promise { + 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(); const pointPrimitivesById = new Map(); + // 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(); const allPrimitiveIds: PrimitiveID[] = []; const extraPrimitiveIds = new Set(); 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 { + 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 { + 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(); + let currentLabels: ReadonlyArray = []; + 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(); + 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, + ): 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, + 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 { + 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= 0) allPrimitiveIds.splice(idx, 1); } @@ -947,16 +1312,25 @@ export async function createRenderer(opts: RendererOptions): Promise { 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 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); diff --git a/ui/frontend/src/map/selection-ring.ts b/ui/frontend/src/map/selection-ring.ts deleted file mode 100644 index 9179fc9..0000000 --- a/ui/frontend/src/map/selection-ring.ts +++ /dev/null @@ -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, - }, - }; -} diff --git a/ui/frontend/src/map/state-binding.ts b/ui/frontend/src/map/state-binding.ts index 5e6f929..5316138 100644 --- a/ui/frontend/src/map/state-binding.ts +++ b/ui/frontend/src/map/state-binding.ts @@ -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, diff --git a/ui/frontend/src/map/visibility.ts b/ui/frontend/src/map/visibility.ts index d1fb0aa..1e48b6b 100644 --- a/ui/frontend/src/map/visibility.ts +++ b/ui/frontend/src/map/visibility.ts @@ -65,8 +65,6 @@ export function isCategoryVisible( return toggles.unidentifiedGroups; case "battleMarker": return toggles.battleMarkers; - case "bombingMarker": - return toggles.bombingMarkers; } } diff --git a/ui/frontend/src/map/world.ts b/ui/frontend/src/map/world.ts index 303658a..054df0b 100644 --- a/ui/frontend/src/map/world.ts +++ b/ui/frontend/src/map/world.ts @@ -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, }; diff --git a/ui/frontend/tests/battle-markers.test.ts b/ui/frontend/tests/battle-markers.test.ts index f2c7b45..cb64875 100644 --- a/ui/frontend/tests/battle-markers.test.ts +++ b/ui/frontend/tests/battle-markers.test.ts @@ -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); diff --git a/ui/frontend/tests/e2e/map-toggles.spec.ts b/ui/frontend/tests/e2e/map-toggles.spec.ts index f46101a..b0c96f5 100644 --- a/ui/frontend/tests/e2e/map-toggles.spec.ts +++ b/ui/frontend/tests/e2e/map-toggles.spec.ts @@ -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 diff --git a/ui/frontend/tests/map-display-sizing.test.ts b/ui/frontend/tests/map-display-sizing.test.ts new file mode 100644 index 0000000..0c09206 --- /dev/null +++ b/ui/frontend/tests/map-display-sizing.test.ts @@ -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); + }); +}); diff --git a/ui/frontend/tests/map-hit-test.test.ts b/ui/frontend/tests/map-hit-test.test.ts index e106d57..1fd03ea 100644 --- a/ui/frontend/tests/map-hit-test.test.ts +++ b/ui/frontend/tests/map-hit-test.test.ts @@ -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); }); }); diff --git a/ui/frontend/tests/map-labels.test.ts b/ui/frontend/tests/map-labels.test.ts new file mode 100644 index 0000000..4cc408d --- /dev/null +++ b/ui/frontend/tests/map-labels.test.ts @@ -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 { + 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"); + }); +}); diff --git a/ui/frontend/tests/map-toggles-component.test.ts b/ui/frontend/tests/map-toggles-component.test.ts index da4f9f0..db66263 100644 --- a/ui/frontend/tests/map-toggles-component.test.ts +++ b/ui/frontend/tests/map-toggles-component.test.ts @@ -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 } }); diff --git a/ui/frontend/tests/map-toggles-state.test.ts b/ui/frontend/tests/map-toggles-state.test.ts index b5b21d9..93ae5f4 100644 --- a/ui/frontend/tests/map-toggles-state.test.ts +++ b/ui/frontend/tests/map-toggles-state.test.ts @@ -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(); }); diff --git a/ui/frontend/tests/selection-ring.test.ts b/ui/frontend/tests/selection-ring.test.ts deleted file mode 100644 index de1d8ae..0000000 --- a/ui/frontend/tests/selection-ring.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/ui/frontend/tests/state-binding-cascade.test.ts b/ui/frontend/tests/state-binding-cascade.test.ts index e58ee54..014f5bd 100644 --- a/ui/frontend/tests/state-binding-cascade.test.ts +++ b/ui/frontend/tests/state-binding-cascade.test.ts @@ -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", () => { diff --git a/ui/frontend/tests/visibility-helpers.test.ts b/ui/frontend/tests/visibility-helpers.test.ts index 322327a..33aab14 100644 --- a/ui/frontend/tests/visibility-helpers.test.ts +++ b/ui/frontend/tests/visibility-helpers.test.ts @@ -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 = new Map< PrimitiveID, MapCategory @@ -212,11 +215,10 @@ describe("computeHiddenIds", () => { [150, "hyperspaceGroup"], [200, "incomingGroup"], [300, "battleMarker"], - [400, "bombingMarker"], ]); const planetDependents = new Map>([ [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])); }); });