feat(ui): F8-12 — map polish (zoom invariance, labels, selection, soft radius) (#55) #70
File diff suppressed because it is too large
Load Diff
+50
-7
@@ -66,13 +66,51 @@ interface LinePrim extends PrimitiveBase { kind: 'line';
|
|||||||
|
|
||||||
`radius` is in world units. `style.strokeWidthPx` and
|
`radius` is in world units. `style.strokeWidthPx` and
|
||||||
`style.pointRadiusPx` are in screen pixels and stay constant under
|
`style.pointRadiusPx` are in screen pixels and stay constant under
|
||||||
zoom (Pixi's stroke width is in pixel space when the parent
|
zoom — F8-12 / #28 wired the renderer to repaint every affected
|
||||||
container is scaled).
|
`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.pointRadiusBasePx` is the alternative sizing rule for planet
|
||||||
|
discs with a known `size`: the renderer treats the base radius as
|
||||||
|
on-screen pixels **at the reference scale** and grows its on-screen
|
||||||
|
pixel size with the camera scale through `PLANET_SIZE_ZOOM_ALPHA`
|
||||||
|
(0.33). At `scale = scaleRef` (the "whole world fits the viewport"
|
||||||
|
zoom) the visible disc reads at `pointRadiusBasePx` screen pixels;
|
||||||
|
zooming in grows it as `scale^α` instead of linearly. This keeps
|
||||||
|
known-size planets sane on every world rectangle — a 4000×4000 map
|
||||||
|
and a 100×100 map both default to the same on-screen size. Setting
|
||||||
|
both `pointRadiusBasePx` and `pointRadiusPx` ignores the pixel-space
|
||||||
|
field.
|
||||||
|
|
||||||
Default hit slop in screen pixels: point=8, circle=6, line=6.
|
Default hit slop in screen pixels: point=8, circle=6, line=6.
|
||||||
These are touch-ergonomic defaults; per-primitive `hitSlopPx > 0`
|
These are touch-ergonomic defaults; per-primitive `hitSlopPx > 0`
|
||||||
overrides them.
|
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
|
## Theme
|
||||||
|
|
||||||
A `Theme` is the renderer's full colour palette: the canvas background
|
A `Theme` is the renderer's full colour palette: the canvas background
|
||||||
@@ -127,11 +165,16 @@ target.
|
|||||||
|
|
||||||
Per-primitive distance:
|
Per-primitive distance:
|
||||||
|
|
||||||
- **Point**: `distSq ≤ (pointRadiusPx + slopWorld)²`. The visible
|
- **Point**: `distSq ≤ (visibleRadiusWorld + slopWorld)²`. The
|
||||||
disc is part of the click target — a click on any pixel of the
|
visible disc is part of the click target — a click on any pixel of
|
||||||
rendered planet registers as a hit, with `slopWorld` adding a
|
the rendered planet registers as a hit, with `slopWorld` adding a
|
||||||
small ergonomic margin on top. `pointRadiusPx` defaults to
|
small ergonomic margin on top. `visibleRadiusWorld` comes from
|
||||||
`DEFAULT_POINT_RADIUS_PX = 3` when unset.
|
`displayPointRadiusWorld` (F8-12 / #28 + #31): pixel-space
|
||||||
|
`pointRadiusPx / scale` for unidentified planets and most ship
|
||||||
|
groups, softened-by-zoom
|
||||||
|
`pointRadiusBasePx * (scale / scaleRef)^α / scale` 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
|
- **Filled circle**: `distSq ≤ (radius + slopWorld)²` where
|
||||||
`radius` is in world units. The circle counts as filled when
|
`radius` is in world units. The circle counts as filled when
|
||||||
`style.fillColor` is set and `style.fillAlpha > 0`.
|
`style.fillColor` is set and `style.fillAlpha > 0`.
|
||||||
|
|||||||
@@ -177,6 +177,15 @@ bottom-tabs bar.
|
|||||||
/>
|
/>
|
||||||
<span>{i18n.t("game.map.toggles.unreachable_planets")}</span>
|
<span>{i18n.t("game.map.toggles.unreachable_planets")}</span>
|
||||||
</label>
|
</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>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{i18n.t("game.map.toggles.section.view")}</legend>
|
<legend>{i18n.t("game.map.toggles.section.view")}</legend>
|
||||||
|
|||||||
@@ -26,12 +26,13 @@ preference the store already manages.
|
|||||||
import {
|
import {
|
||||||
createRenderer,
|
createRenderer,
|
||||||
minScaleNoWrap,
|
minScaleNoWrap,
|
||||||
|
type PlanetOutlineSpec,
|
||||||
type RendererHandle,
|
type RendererHandle,
|
||||||
} from "../../map/index";
|
} from "../../map/index";
|
||||||
import { buildCargoRouteLines } from "../../map/cargo-routes";
|
import { buildCargoRouteLines } from "../../map/cargo-routes";
|
||||||
|
import { buildPlanetLabels } from "../../map/labels";
|
||||||
import { buildPendingSendLines } from "../../map/pending-send-routes";
|
import { buildPendingSendLines } from "../../map/pending-send-routes";
|
||||||
import { computeReachCircles } from "../../map/reach-circles";
|
import { computeReachCircles } from "../../map/reach-circles";
|
||||||
import { computeSelectionRing } from "../../map/selection-ring";
|
|
||||||
import { reachStore } from "$lib/calculator/reach.svelte";
|
import { reachStore } from "$lib/calculator/reach.svelte";
|
||||||
import { theme as themeStore } from "$lib/theme/theme.svelte";
|
import { theme as themeStore } from "$lib/theme/theme.svelte";
|
||||||
import {
|
import {
|
||||||
@@ -116,6 +117,50 @@ preference the store already manages.
|
|||||||
let containerEl: HTMLDivElement | null = $state(null);
|
let containerEl: HTMLDivElement | null = $state(null);
|
||||||
let mountError: string | null = $state(null);
|
let mountError: string | null = $state(null);
|
||||||
|
|
||||||
|
// F8-12 follow-up: an opt-in technical overlay activated by adding
|
||||||
|
// `?debug=1` to the URL. Shows the current camera scale and the
|
||||||
|
// world-units rectangle currently visible inside the viewport — so
|
||||||
|
// the owner can decide what to clamp `maxScale` to once it lands.
|
||||||
|
const debugOverlayEnabled = (() => {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
return new URLSearchParams(window.location.search).get("debug") === "1";
|
||||||
|
})();
|
||||||
|
let debugInfo: {
|
||||||
|
scale: number;
|
||||||
|
scaleRef: number;
|
||||||
|
viewWorldWidth: number;
|
||||||
|
viewWorldHeight: number;
|
||||||
|
} | null = $state(null);
|
||||||
|
let debugFrame: number | null = null;
|
||||||
|
function startDebugLoop(): void {
|
||||||
|
if (!debugOverlayEnabled) return;
|
||||||
|
const tick = (): void => {
|
||||||
|
if (handle !== null) {
|
||||||
|
const camera = handle.getCamera();
|
||||||
|
const vp = handle.getViewport();
|
||||||
|
const safeScale = camera.scale > 0 ? camera.scale : 1;
|
||||||
|
const worldW = store?.report?.mapWidth ?? 1;
|
||||||
|
const worldH = store?.report?.mapHeight ?? 1;
|
||||||
|
debugInfo = {
|
||||||
|
scale: camera.scale,
|
||||||
|
scaleRef: Math.max(vp.widthPx / worldW, vp.heightPx / worldH),
|
||||||
|
viewWorldWidth: vp.widthPx / safeScale,
|
||||||
|
viewWorldHeight: vp.heightPx / safeScale,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
debugInfo = null;
|
||||||
|
}
|
||||||
|
debugFrame = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
debugFrame = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
function stopDebugLoop(): void {
|
||||||
|
if (debugFrame !== null) {
|
||||||
|
cancelAnimationFrame(debugFrame);
|
||||||
|
debugFrame = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let handle: RendererHandle | null = null;
|
let handle: RendererHandle | null = null;
|
||||||
let hitLookup = new Map<PrimitiveID, HitTarget>();
|
let hitLookup = new Map<PrimitiveID, HitTarget>();
|
||||||
// currentCategories / currentPlanetDependents are populated by
|
// currentCategories / currentPlanetDependents are populated by
|
||||||
@@ -216,6 +261,7 @@ preference the store already manages.
|
|||||||
void toggles.cargoRoutes;
|
void toggles.cargoRoutes;
|
||||||
void toggles.battleMarkers;
|
void toggles.battleMarkers;
|
||||||
void toggles.bombingMarkers;
|
void toggles.bombingMarkers;
|
||||||
|
void toggles.planetNames;
|
||||||
void toggles.visibleHyperspace;
|
void toggles.visibleHyperspace;
|
||||||
|
|
||||||
// Subscribe to the calculator's published reach so the rings
|
// Subscribe to the calculator's published reach so the rings
|
||||||
@@ -253,11 +299,9 @@ preference the store already manages.
|
|||||||
reachOrigin === null
|
reachOrigin === null
|
||||||
? ""
|
? ""
|
||||||
: `${reachOrigin.x},${reachOrigin.y},${reachStore.speedPerTurn}`;
|
: `${reachOrigin.x},${reachOrigin.y},${reachStore.speedPerTurn}`;
|
||||||
const selectedPlanetId =
|
|
||||||
selection?.selected?.kind === "planet" ? selection.selected.id : null;
|
|
||||||
const extrasFingerprint =
|
const extrasFingerprint =
|
||||||
`cr=${toggles.cargoRoutes ? "1" : "0"}|hp=${hiddenPlanetFingerprint}|` +
|
`cr=${toggles.cargoRoutes ? "1" : "0"}|hp=${hiddenPlanetFingerprint}|` +
|
||||||
`reach=${reachFingerprint}|sel=${selectedPlanetId ?? ""}|` +
|
`reach=${reachFingerprint}|` +
|
||||||
computeRoutesFingerprint(report.routes) +
|
computeRoutesFingerprint(report.routes) +
|
||||||
"|" +
|
"|" +
|
||||||
computePendingSendFingerprint(draftCommands, draftStatuses);
|
computePendingSendFingerprint(draftCommands, draftStatuses);
|
||||||
@@ -363,19 +407,7 @@ preference the store already manages.
|
|||||||
palette,
|
palette,
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
const selectedPlanetId =
|
return [...cargo, ...pending, ...reach];
|
||||||
selection?.selected?.kind === "planet" ? selection.selected.id : null;
|
|
||||||
const selectionRing = computeSelectionRing(
|
|
||||||
report.planets,
|
|
||||||
selectedPlanetId,
|
|
||||||
palette,
|
|
||||||
);
|
|
||||||
return [
|
|
||||||
...cargo,
|
|
||||||
...pending,
|
|
||||||
...reach,
|
|
||||||
...(selectionRing === null ? [] : [selectionRing]),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyVisibilityState(
|
function applyVisibilityState(
|
||||||
@@ -394,6 +426,55 @@ preference the store already manages.
|
|||||||
const fogCircles = computeFogCircles(report, toggles);
|
const fogCircles = computeFogCircles(report, toggles);
|
||||||
currentFogCircles = fogCircles;
|
currentFogCircles = fogCircles;
|
||||||
handle.setVisibilityFog(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(
|
async function runSerializedMount(
|
||||||
@@ -562,6 +643,14 @@ preference the store already manages.
|
|||||||
handle.viewport.moveCenter(world.width / 2, world.height / 2);
|
handle.viewport.moveCenter(world.width / 2, world.height / 2);
|
||||||
handle.viewport.setZoom(minScale * 1.05, true);
|
handle.viewport.setZoom(minScale * 1.05, true);
|
||||||
}
|
}
|
||||||
|
// `viewport.setZoom` emits `zoomed` through the next Ticker
|
||||||
|
// tick, but the handler can race the synchronous setExtras /
|
||||||
|
// label / outline calls that follow — and a theme-flip
|
||||||
|
// remount has been observed to leave primitives drawn at the
|
||||||
|
// boot scale until the user nudges the wheel. Force the
|
||||||
|
// camera-derived redraw explicitly here so the post-mount
|
||||||
|
// state always matches `viewport.scaled`.
|
||||||
|
handle.refreshCameraDerivedDraws();
|
||||||
if (mode === "no-wrap") handle.setMode("no-wrap");
|
if (mode === "no-wrap") handle.setMode("no-wrap");
|
||||||
detachClick = handle.onClick(handleMapClick);
|
detachClick = handle.onClick(handleMapClick);
|
||||||
pickService?.bindResolver(({ sourcePlanetNumber, reachableIds, onResolve }) => {
|
pickService?.bindResolver(({ sourcePlanetNumber, reachableIds, onResolve }) => {
|
||||||
@@ -718,30 +807,9 @@ preference the store already manages.
|
|||||||
// current selection. The Phase 19 ship-group surface dispatches
|
// current selection. The Phase 19 ship-group surface dispatches
|
||||||
// through the same `hit-test` plumbing — the hitLookup map keyed
|
// through the same `hit-test` plumbing — the hitLookup map keyed
|
||||||
// by primitive id resolves a hit back to either a planet or a
|
// by primitive id resolves a hit back to either a planet or a
|
||||||
// ship-group selection variant.
|
// ship-group selection variant. F8-12 / #30 retired the separate
|
||||||
// scrollToBombingRow waits for the report's bombing row for the
|
// bombing-ring click; bombing → report navigation now starts in
|
||||||
// given planet to mount, then scrolls it into view. The map context
|
// the inspector via `scrollToBombingRow` (`lib/report-nav.ts`).
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMapClick(cursorPx: { x: number; y: number }): void {
|
function handleMapClick(cursorPx: { x: number; y: number }): void {
|
||||||
if (handle === null || store?.report === undefined || store.report === null) {
|
if (handle === null || store?.report === undefined || store.report === null) {
|
||||||
return;
|
return;
|
||||||
@@ -768,20 +836,12 @@ preference the store already manages.
|
|||||||
});
|
});
|
||||||
break;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
mounted = true;
|
mounted = true;
|
||||||
|
startDebugLoop();
|
||||||
onResize = (): void => {
|
onResize = (): void => {
|
||||||
if (handle === null || containerEl === null) return;
|
if (handle === null || containerEl === null) return;
|
||||||
handle.resize(containerEl.clientWidth, containerEl.clientHeight);
|
handle.resize(containerEl.clientWidth, containerEl.clientHeight);
|
||||||
@@ -799,6 +859,7 @@ preference the store already manages.
|
|||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
|
stopDebugLoop();
|
||||||
if (onResize !== null) {
|
if (onResize !== null) {
|
||||||
window.removeEventListener("resize", onResize);
|
window.removeEventListener("resize", onResize);
|
||||||
onResize = null;
|
onResize = null;
|
||||||
@@ -853,6 +914,30 @@ preference the store already manages.
|
|||||||
{#if store !== undefined && store.status === "ready"}
|
{#if store !== undefined && store.status === "ready"}
|
||||||
<MapTogglesControl {store} />
|
<MapTogglesControl {store} />
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if debugOverlayEnabled && debugInfo !== null}
|
||||||
|
<div class="debug-overlay" data-testid="map-debug-overlay" aria-hidden="true">
|
||||||
|
<div class="debug-row">
|
||||||
|
<span class="debug-key">scale</span>
|
||||||
|
<span class="debug-val">{debugInfo.scale.toFixed(3)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="debug-row">
|
||||||
|
<span class="debug-key">scale_ref</span>
|
||||||
|
<span class="debug-val">{debugInfo.scaleRef.toFixed(3)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="debug-row">
|
||||||
|
<span class="debug-key">scale_ratio</span>
|
||||||
|
<span class="debug-val">
|
||||||
|
×{(debugInfo.scale / debugInfo.scaleRef).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="debug-row">
|
||||||
|
<span class="debug-key">view W×H</span>
|
||||||
|
<span class="debug-val">
|
||||||
|
{debugInfo.viewWorldWidth.toFixed(1)} × {debugInfo.viewWorldHeight.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -896,4 +981,32 @@ preference the store already manages.
|
|||||||
border-color: var(--color-danger);
|
border-color: var(--color-danger);
|
||||||
color: var(--color-danger);
|
color: var(--color-danger);
|
||||||
}
|
}
|
||||||
|
.debug-overlay {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.5rem;
|
||||||
|
left: 0.5rem;
|
||||||
|
min-width: 11rem;
|
||||||
|
padding: 0.35rem 0.55rem;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
color: #f3f5fb;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
.debug-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.debug-key {
|
||||||
|
color: rgba(243, 245, 251, 0.65);
|
||||||
|
}
|
||||||
|
.debug-val {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -57,6 +57,14 @@ export interface MapToggles {
|
|||||||
cargoRoutes: boolean;
|
cargoRoutes: boolean;
|
||||||
battleMarkers: boolean;
|
battleMarkers: boolean;
|
||||||
bombingMarkers: 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
|
* visibleHyperspace toggles the foggy overlay that darkens the
|
||||||
* world OUTSIDE the union of `VisibilityDistance` circles around
|
* world OUTSIDE the union of `VisibilityDistance` circles around
|
||||||
@@ -78,6 +86,7 @@ export const DEFAULT_MAP_TOGGLES: MapToggles = {
|
|||||||
cargoRoutes: true,
|
cargoRoutes: true,
|
||||||
battleMarkers: true,
|
battleMarkers: true,
|
||||||
bombingMarkers: true,
|
bombingMarkers: true,
|
||||||
|
planetNames: true,
|
||||||
visibleHyperspace: true,
|
visibleHyperspace: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ const en = {
|
|||||||
"game.map.toggles.uninhabited_planets": "uninhabited planets",
|
"game.map.toggles.uninhabited_planets": "uninhabited planets",
|
||||||
"game.map.toggles.unidentified_planets": "unidentified planets",
|
"game.map.toggles.unidentified_planets": "unidentified planets",
|
||||||
"game.map.toggles.unreachable_planets": "show unreachable planets",
|
"game.map.toggles.unreachable_planets": "show unreachable planets",
|
||||||
|
"game.map.toggles.planet_names": "planet names",
|
||||||
"game.map.toggles.visible_hyperspace": "visible hyperspace",
|
"game.map.toggles.visible_hyperspace": "visible hyperspace",
|
||||||
"game.view.table": "table",
|
"game.view.table": "table",
|
||||||
"game.view.table.planets": "planets",
|
"game.view.table.planets": "planets",
|
||||||
@@ -279,6 +280,8 @@ const en = {
|
|||||||
"game.inspector.planet.field.free_industry": "free production",
|
"game.inspector.planet.field.free_industry": "free production",
|
||||||
"game.inspector.planet.production_none": "none",
|
"game.inspector.planet.production_none": "none",
|
||||||
"game.inspector.planet.unidentified_no_data": "no data — only the location is known",
|
"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.sheet_close": "close",
|
||||||
"game.inspector.planet.action.rename": "rename",
|
"game.inspector.planet.action.rename": "rename",
|
||||||
"game.inspector.planet.rename.title": "rename planet",
|
"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.uninhabited_planets": "необитаемые планеты",
|
||||||
"game.map.toggles.unidentified_planets": "неопознанные планеты",
|
"game.map.toggles.unidentified_planets": "неопознанные планеты",
|
||||||
"game.map.toggles.unreachable_planets": "показывать недостижимые планеты",
|
"game.map.toggles.unreachable_planets": "показывать недостижимые планеты",
|
||||||
|
"game.map.toggles.planet_names": "имена планет",
|
||||||
"game.map.toggles.visible_hyperspace": "видимое гиперпространство",
|
"game.map.toggles.visible_hyperspace": "видимое гиперпространство",
|
||||||
"game.view.table": "таблица",
|
"game.view.table": "таблица",
|
||||||
"game.view.table.planets": "планеты",
|
"game.view.table.planets": "планеты",
|
||||||
@@ -280,6 +281,8 @@ const ru: Record<keyof typeof en, string> = {
|
|||||||
"game.inspector.planet.field.free_industry": "свободные мощности",
|
"game.inspector.planet.field.free_industry": "свободные мощности",
|
||||||
"game.inspector.planet.production_none": "не задано",
|
"game.inspector.planet.production_none": "не задано",
|
||||||
"game.inspector.planet.unidentified_no_data": "нет данных — известно только местоположение",
|
"game.inspector.planet.unidentified_no_data": "нет данных — известно только местоположение",
|
||||||
|
"game.inspector.planet.view_bombing": "открыть отчёт о бомбардировке",
|
||||||
|
"game.inspector.planet.view_bombing_wiped": "открыть отчёт о бомбардировке (стёрта)",
|
||||||
"game.inspector.sheet_close": "закрыть",
|
"game.inspector.sheet_close": "закрыть",
|
||||||
"game.inspector.planet.action.rename": "переименовать",
|
"game.inspector.planet.action.rename": "переименовать",
|
||||||
"game.inspector.planet.rename.title": "переименование планеты",
|
"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">
|
<script lang="ts">
|
||||||
import type {
|
import type {
|
||||||
|
ReportBombing,
|
||||||
ReportLocalShipGroup,
|
ReportLocalShipGroup,
|
||||||
ReportOtherShipGroup,
|
ReportOtherShipGroup,
|
||||||
ReportPlanet,
|
ReportPlanet,
|
||||||
@@ -35,6 +36,7 @@ dismiss from the IA section §6 are deferred to a later polish pass.
|
|||||||
localShipGroups: ReportLocalShipGroup[];
|
localShipGroups: ReportLocalShipGroup[];
|
||||||
otherShipGroups: ReportOtherShipGroup[];
|
otherShipGroups: ReportOtherShipGroup[];
|
||||||
localRace: string;
|
localRace: string;
|
||||||
|
bombing?: ReportBombing | null;
|
||||||
onMap: boolean;
|
onMap: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
@@ -50,6 +52,7 @@ dismiss from the IA section §6 are deferred to a later polish pass.
|
|||||||
localShipGroups,
|
localShipGroups,
|
||||||
otherShipGroups,
|
otherShipGroups,
|
||||||
localRace,
|
localRace,
|
||||||
|
bombing = null,
|
||||||
onMap,
|
onMap,
|
||||||
onClose,
|
onClose,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
@@ -84,6 +87,7 @@ dismiss from the IA section §6 are deferred to a later polish pass.
|
|||||||
{localShipGroups}
|
{localShipGroups}
|
||||||
{otherShipGroups}
|
{otherShipGroups}
|
||||||
{localRace}
|
{localRace}
|
||||||
|
{bombing}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ the entry point, Escape (or unmounting the inspector) reverts.
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext, tick } from "svelte";
|
import { getContext, tick } from "svelte";
|
||||||
import type {
|
import type {
|
||||||
|
ReportBombing,
|
||||||
ReportLocalShipGroup,
|
ReportLocalShipGroup,
|
||||||
ReportOtherShipGroup,
|
ReportOtherShipGroup,
|
||||||
ReportPlanet,
|
ReportPlanet,
|
||||||
@@ -36,6 +37,8 @@ the entry point, Escape (or unmounting the inspector) reverts.
|
|||||||
type EntityNameInvalidReason,
|
type EntityNameInvalidReason,
|
||||||
} from "$lib/util/entity-name";
|
} from "$lib/util/entity-name";
|
||||||
import { formatFloat } from "$lib/util/number-format";
|
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 CargoRoutes from "./planet/cargo-routes.svelte";
|
||||||
import Production from "./planet/production.svelte";
|
import Production from "./planet/production.svelte";
|
||||||
import ShipGroups from "./planet/ship-groups.svelte";
|
import ShipGroups from "./planet/ship-groups.svelte";
|
||||||
@@ -52,6 +55,7 @@ the entry point, Escape (or unmounting the inspector) reverts.
|
|||||||
localShipGroups: ReportLocalShipGroup[];
|
localShipGroups: ReportLocalShipGroup[];
|
||||||
otherShipGroups: ReportOtherShipGroup[];
|
otherShipGroups: ReportOtherShipGroup[];
|
||||||
localRace: string;
|
localRace: string;
|
||||||
|
bombing?: ReportBombing | null;
|
||||||
};
|
};
|
||||||
let {
|
let {
|
||||||
planet,
|
planet,
|
||||||
@@ -65,8 +69,15 @@ the entry point, Escape (or unmounting the inspector) reverts.
|
|||||||
localShipGroups,
|
localShipGroups,
|
||||||
otherShipGroups,
|
otherShipGroups,
|
||||||
localRace,
|
localRace,
|
||||||
|
bombing = null,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
function openBombingReport(): void {
|
||||||
|
if (bombing === null) return;
|
||||||
|
activeView.select("report");
|
||||||
|
scrollToBombingRow(bombing.planetNumber);
|
||||||
|
}
|
||||||
|
|
||||||
const kindKeyMap: Record<ReportPlanet["kind"], TranslationKey> = {
|
const kindKeyMap: Record<ReportPlanet["kind"], TranslationKey> = {
|
||||||
local: "game.inspector.planet.kind.local",
|
local: "game.inspector.planet.kind.local",
|
||||||
other: "game.inspector.planet.kind.other",
|
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")}
|
{i18n.t("game.inspector.planet.unidentified_no_data")}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/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>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -432,4 +460,28 @@ the entry point, Escape (or unmounting the inspector) reverts.
|
|||||||
.icon-action--apply:not(:disabled) {
|
.icon-action--apply:not(:disabled) {
|
||||||
color: var(--color-success);
|
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>
|
</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 localFleets = $derived(renderedReport?.report?.localFleets ?? []);
|
||||||
const otherRaces = $derived(renderedReport?.report?.otherRaces ?? []);
|
const otherRaces = $derived(renderedReport?.report?.otherRaces ?? []);
|
||||||
const localRace = $derived(renderedReport?.report?.race ?? "");
|
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>
|
</script>
|
||||||
|
|
||||||
<section class="tool" data-testid="sidebar-tool-inspector">
|
<section class="tool" data-testid="sidebar-tool-inspector">
|
||||||
@@ -124,6 +130,7 @@ from the Phase 10 stub.
|
|||||||
{localShipGroups}
|
{localShipGroups}
|
||||||
{otherShipGroups}
|
{otherShipGroups}
|
||||||
{localRace}
|
{localRace}
|
||||||
|
bombing={selectedPlanetBombing}
|
||||||
/>
|
/>
|
||||||
{:else if selectedShipGroup !== null}
|
{:else if selectedShipGroup !== null}
|
||||||
<ShipGroup
|
<ShipGroup
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// Phase 27 battle and bombing markers on the map.
|
// 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
|
||||||
// Two visual markers per planet:
|
// 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
|
// * Battle marker — an X cross drawn through the corners of the
|
||||||
// square that circumscribes the planet circle. Two yellow
|
// square that circumscribes the planet circle. Two yellow
|
||||||
@@ -8,18 +10,10 @@
|
|||||||
// shots: 1 shot → 1px, 100+ shots → 5px (capped). Clicking
|
// shots: 1 shot → 1px, 100+ shots → 5px (capped). Clicking
|
||||||
// either line opens the Battle Viewer for the corresponding
|
// either line opens the Battle Viewer for the corresponding
|
||||||
// UUID.
|
// 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 type { GameReport, ReportPlanet } from "../api/game-state";
|
||||||
import {
|
import {
|
||||||
DARK_THEME,
|
DARK_THEME,
|
||||||
type CirclePrim,
|
|
||||||
type LinePrim,
|
type LinePrim,
|
||||||
type Primitive,
|
type Primitive,
|
||||||
type PrimitiveID,
|
type PrimitiveID,
|
||||||
@@ -27,20 +21,17 @@ import {
|
|||||||
type Theme,
|
type Theme,
|
||||||
} from "./world";
|
} from "./world";
|
||||||
|
|
||||||
/** Battle and bombing marker primitive ids use a high-bit prefix to
|
/** Battle marker primitive ids use a high-bit prefix to avoid
|
||||||
* avoid colliding with planet numbers or cargo-route line ids. */
|
* colliding with planet numbers or cargo-route line ids. */
|
||||||
export const BATTLE_MARKER_ID_PREFIX = 0xa0000000;
|
export const BATTLE_MARKER_ID_PREFIX = 0xa0000000;
|
||||||
export const BOMBING_MARKER_ID_PREFIX = 0xc0000000;
|
|
||||||
|
|
||||||
const PLANET_RADIUS_WORLD = 6;
|
const PLANET_RADIUS_WORLD = 6;
|
||||||
const BOMBING_RING_RADIUS = PLANET_RADIUS_WORLD + 3;
|
|
||||||
const BATTLE_CROSS_HALF = PLANET_RADIUS_WORLD + 2;
|
const BATTLE_CROSS_HALF = PLANET_RADIUS_WORLD + 2;
|
||||||
|
|
||||||
/** Battle marker priority sits between planets (1..4) and cargo
|
/** Battle marker priority sits between planets (1..4) and cargo
|
||||||
* routes; the cross is over the planet but loses clicks against the
|
* routes; the cross is over the planet but loses clicks against the
|
||||||
* planet glyph itself. */
|
* planet glyph itself. */
|
||||||
const BATTLE_MARKER_PRIORITY = 9;
|
const BATTLE_MARKER_PRIORITY = 9;
|
||||||
const BOMBING_MARKER_PRIORITY = 10;
|
|
||||||
|
|
||||||
const BATTLE_LINE_INDEX_A = 0;
|
const BATTLE_LINE_INDEX_A = 0;
|
||||||
const BATTLE_LINE_INDEX_B = 1;
|
const BATTLE_LINE_INDEX_B = 1;
|
||||||
@@ -51,21 +42,16 @@ export interface BattleMarkerTarget {
|
|||||||
planet: number;
|
planet: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BombingMarkerTarget {
|
export type MarkerTarget = BattleMarkerTarget;
|
||||||
kind: "bombing";
|
|
||||||
planet: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MarkerTarget = BattleMarkerTarget | BombingMarkerTarget;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MarkerCategory tags every emitted primitive with the toggleable
|
* MarkerCategory tags every emitted primitive with the toggleable
|
||||||
* surface it belongs to so the Phase 29 hide-set machinery can flip
|
* surface it belongs to so the Phase 29 hide-set machinery can flip
|
||||||
* each independently. Battles and bombings have their own toggles —
|
* each independently. Battle markers are the only category left here;
|
||||||
* a player can hide the bombing rings while keeping the battle
|
* the `bombingMarker` toggle now hides the planet-outline overlay
|
||||||
* crosses visible.
|
* built in `map.svelte.applyPlanetOutlines` (F8-12 / #30).
|
||||||
*/
|
*/
|
||||||
export type MarkerCategory = "battleMarker" | "bombingMarker";
|
export type MarkerCategory = "battleMarker";
|
||||||
|
|
||||||
export interface BuildMarkersResult {
|
export interface BuildMarkersResult {
|
||||||
primitives: Primitive[];
|
primitives: Primitive[];
|
||||||
@@ -92,11 +78,13 @@ export function battleMarkerStrokeWidth(shots: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* buildBattleAndBombingMarkers emits battle and bombing marker
|
* buildBattleAndBombingMarkers emits battle X-cross primitives plus a
|
||||||
* primitives plus a hit-lookup mapping for the current-turn report.
|
* hit-lookup mapping for the current-turn report. Battles whose
|
||||||
* Battles whose planet is not visible (e.g. observer-only without a
|
* planet is not visible (e.g. observer-only without a report.planets
|
||||||
* report.planets entry) are skipped — they have no on-map location
|
* entry) are skipped — they have no on-map location to anchor
|
||||||
* to anchor against.
|
* 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(
|
export function buildBattleAndBombingMarkers(
|
||||||
report: GameReport,
|
report: GameReport,
|
||||||
@@ -129,6 +117,11 @@ export function buildBattleAndBombingMarkers(
|
|||||||
strokeColor: theme.battleMarker,
|
strokeColor: theme.battleMarker,
|
||||||
strokeAlpha: 0.95,
|
strokeAlpha: 0.95,
|
||||||
strokeWidthPx,
|
strokeWidthPx,
|
||||||
|
// F8-12 / #4 follow-up: grow the X-cross length sub-linearly
|
||||||
|
// with zoom (the planet disc does the same, so the marker
|
||||||
|
// stays proportional). Endpoints listed below are the "at
|
||||||
|
// reference scale" geometry.
|
||||||
|
softLengthAnchor: "center",
|
||||||
};
|
};
|
||||||
const baseId = BATTLE_MARKER_ID_PREFIX | (i << 4);
|
const baseId = BATTLE_MARKER_ID_PREFIX | (i << 4);
|
||||||
const lineA: LinePrim = {
|
const lineA: LinePrim = {
|
||||||
@@ -167,32 +160,8 @@ export function buildBattleAndBombingMarkers(
|
|||||||
addDependent(battle.planet, lineB.id);
|
addDependent(battle.planet, lineB.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < report.bombings.length; i++) {
|
// Bombing visuals are produced by `setPlanetOutlines` in the
|
||||||
const bombing = report.bombings[i];
|
// renderer (F8-12 / #30); the data still lives on
|
||||||
const planet = planetByNumber.get(bombing.planetNumber);
|
// `report.bombings`, but no primitive is emitted here.
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { primitives, lookup, categories, planetDependents };
|
return { primitives, lookup, categories, planetDependents };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,13 +20,64 @@ import { DARK_THEME, type LinePrim, type PrimitiveID, type Style, type Theme } f
|
|||||||
* active theme. A single `Style` object is shared by every line of a
|
* active theme. A single `Style` object is shared by every line of a
|
||||||
* given load type within one call so the renderer can dedupe them.
|
* given load type within one call so the renderer can dedupe them.
|
||||||
*/
|
*/
|
||||||
function routeStylesByLoadType(theme: Theme): Record<CargoLoadType, Style> {
|
function routeStylesByLoadType(
|
||||||
return {
|
theme: Theme,
|
||||||
COL: { strokeColor: theme.routeCol, strokeAlpha: 0.95, strokeWidthPx: 0.6 },
|
): Record<CargoLoadType, { shaft: Style; wing: Style }> {
|
||||||
CAP: { strokeColor: theme.routeCap, strokeAlpha: 0.95, strokeWidthPx: 0.6 },
|
const styles: Record<CargoLoadType, { shaft: Style; wing: Style }> = {
|
||||||
MAT: { strokeColor: theme.routeMat, strokeAlpha: 0.95, strokeWidthPx: 0.6 },
|
COL: {
|
||||||
EMP: { strokeColor: theme.routeEmp, strokeAlpha: 0.85, strokeWidthPx: 0.4 },
|
shaft: {
|
||||||
|
strokeColor: theme.routeCol,
|
||||||
|
strokeAlpha: 0.95,
|
||||||
|
strokeWidthPx: 0.6,
|
||||||
|
},
|
||||||
|
wing: {
|
||||||
|
strokeColor: theme.routeCol,
|
||||||
|
strokeAlpha: 0.95,
|
||||||
|
strokeWidthPx: 0.6,
|
||||||
|
softLengthAnchor: "start",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CAP: {
|
||||||
|
shaft: {
|
||||||
|
strokeColor: theme.routeCap,
|
||||||
|
strokeAlpha: 0.95,
|
||||||
|
strokeWidthPx: 0.6,
|
||||||
|
},
|
||||||
|
wing: {
|
||||||
|
strokeColor: theme.routeCap,
|
||||||
|
strokeAlpha: 0.95,
|
||||||
|
strokeWidthPx: 0.6,
|
||||||
|
softLengthAnchor: "start",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MAT: {
|
||||||
|
shaft: {
|
||||||
|
strokeColor: theme.routeMat,
|
||||||
|
strokeAlpha: 0.95,
|
||||||
|
strokeWidthPx: 0.6,
|
||||||
|
},
|
||||||
|
wing: {
|
||||||
|
strokeColor: theme.routeMat,
|
||||||
|
strokeAlpha: 0.95,
|
||||||
|
strokeWidthPx: 0.6,
|
||||||
|
softLengthAnchor: "start",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
EMP: {
|
||||||
|
shaft: {
|
||||||
|
strokeColor: theme.routeEmp,
|
||||||
|
strokeAlpha: 0.85,
|
||||||
|
strokeWidthPx: 0.4,
|
||||||
|
},
|
||||||
|
wing: {
|
||||||
|
strokeColor: theme.routeEmp,
|
||||||
|
strokeAlpha: 0.85,
|
||||||
|
strokeWidthPx: 0.4,
|
||||||
|
softLengthAnchor: "start",
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
return styles;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Per-load-type priority. Higher wins hit-test ties; planets sit
|
/** Per-load-type priority. Higher wins hit-test ties; planets sit
|
||||||
@@ -59,9 +110,11 @@ const SHAFT_OFFSET = 0;
|
|||||||
const WING_LEFT_OFFSET = 1;
|
const WING_LEFT_OFFSET = 1;
|
||||||
const WING_RIGHT_OFFSET = 2;
|
const WING_RIGHT_OFFSET = 2;
|
||||||
|
|
||||||
/** Arrowhead size in world units. Picked so the head is visible
|
/** Arrowhead size in world units **at the reference zoom**. F8-12 /
|
||||||
* at default zoom but does not eat the destination planet glyph. */
|
* #4 follow-up halved the head from 6 → 3 world units and added
|
||||||
const HEAD_LENGTH_WORLD = 6;
|
* `softLengthAnchor: "start"` so the wings grow sub-linearly with
|
||||||
|
* zoom instead of stretching across the whole approach. */
|
||||||
|
const HEAD_LENGTH_WORLD = 3;
|
||||||
/** Half-angle of the arrowhead opening, in radians (~25°). */
|
/** Half-angle of the arrowhead opening, in radians (~25°). */
|
||||||
const HEAD_HALF_ANGLE = (25 * Math.PI) / 180;
|
const HEAD_HALF_ANGLE = (25 * Math.PI) / 180;
|
||||||
|
|
||||||
@@ -122,13 +175,13 @@ export function buildCargoRouteLines(
|
|||||||
route.sourcePlanetNumber,
|
route.sourcePlanetNumber,
|
||||||
entry.loadType,
|
entry.loadType,
|
||||||
);
|
);
|
||||||
const style = styleByLoadType[entry.loadType];
|
const styles = styleByLoadType[entry.loadType];
|
||||||
const priority = PRIORITY_BY_LOAD_TYPE[entry.loadType];
|
const priority = PRIORITY_BY_LOAD_TYPE[entry.loadType];
|
||||||
lines.push({
|
lines.push({
|
||||||
kind: "line",
|
kind: "line",
|
||||||
id: baseId + SHAFT_OFFSET,
|
id: baseId + SHAFT_OFFSET,
|
||||||
priority,
|
priority,
|
||||||
style,
|
style: styles.shaft,
|
||||||
hitSlopPx: 0,
|
hitSlopPx: 0,
|
||||||
x1: source.x,
|
x1: source.x,
|
||||||
y1: source.y,
|
y1: source.y,
|
||||||
@@ -139,7 +192,7 @@ export function buildCargoRouteLines(
|
|||||||
kind: "line",
|
kind: "line",
|
||||||
id: baseId + WING_LEFT_OFFSET,
|
id: baseId + WING_LEFT_OFFSET,
|
||||||
priority,
|
priority,
|
||||||
style,
|
style: styles.wing,
|
||||||
hitSlopPx: 0,
|
hitSlopPx: 0,
|
||||||
x1: headX,
|
x1: headX,
|
||||||
y1: headY,
|
y1: headY,
|
||||||
@@ -150,7 +203,7 @@ export function buildCargoRouteLines(
|
|||||||
kind: "line",
|
kind: "line",
|
||||||
id: baseId + WING_RIGHT_OFFSET,
|
id: baseId + WING_RIGHT_OFFSET,
|
||||||
priority,
|
priority,
|
||||||
style,
|
style: styles.wing,
|
||||||
hitSlopPx: 0,
|
hitSlopPx: 0,
|
||||||
x1: headX,
|
x1: headX,
|
||||||
y1: headY,
|
y1: headY,
|
||||||
|
|||||||
@@ -12,9 +12,11 @@
|
|||||||
// and a primitive does not get matched through wrap copies.
|
// and a primitive does not get matched through wrap copies.
|
||||||
|
|
||||||
import { distSqPointToSegment, screenToWorld, torusShortestDelta } from "./math";
|
import { distSqPointToSegment, screenToWorld, torusShortestDelta } from "./math";
|
||||||
|
import { minScaleNoWrap } from "./no-wrap";
|
||||||
import {
|
import {
|
||||||
DEFAULT_HIT_SLOP_PX,
|
DEFAULT_HIT_SLOP_PX,
|
||||||
DEFAULT_POINT_RADIUS_PX,
|
displayLineEndpoints,
|
||||||
|
displayPointRadiusWorld,
|
||||||
KIND_ORDER,
|
KIND_ORDER,
|
||||||
type Camera,
|
type Camera,
|
||||||
type CirclePrim,
|
type CirclePrim,
|
||||||
@@ -30,6 +32,16 @@ import {
|
|||||||
export interface Hit {
|
export interface Hit {
|
||||||
primitive: Primitive;
|
primitive: Primitive;
|
||||||
distSq: number; // in world units squared
|
distSq: number; // in world units squared
|
||||||
|
/**
|
||||||
|
* insideDisc is `true` when the cursor sits *inside* a planet's
|
||||||
|
* visible disc (point primitive, distance ≤ `visibleRadius`, no
|
||||||
|
* slop required). F8-12 / #4 follow-up uses it to break tie
|
||||||
|
* against overlapping route shafts or battle X-crosses: a click
|
||||||
|
* inside the disc always picks the planet, even though those
|
||||||
|
* other primitives carry a higher base `priority` (so they still
|
||||||
|
* win when the cursor is "near the line, outside any planet").
|
||||||
|
*/
|
||||||
|
insideDisc: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// hitTest returns the best-matching primitive under the cursor, or
|
// hitTest returns the best-matching primitive under the cursor, or
|
||||||
@@ -50,21 +62,47 @@ export function hitTest(
|
|||||||
): Hit | null {
|
): Hit | null {
|
||||||
const cursor = screenToWorld(cursorPx, camera, viewport);
|
const cursor = screenToWorld(cursorPx, camera, viewport);
|
||||||
const candidates: Hit[] = [];
|
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) {
|
for (const p of world.primitives) {
|
||||||
if (hiddenIds !== undefined && hiddenIds.has(p.id)) continue;
|
if (hiddenIds !== undefined && hiddenIds.has(p.id)) continue;
|
||||||
const slopPx = p.hitSlopPx > 0 ? p.hitSlopPx : DEFAULT_HIT_SLOP_PX[p.kind];
|
const slopPx = p.hitSlopPx > 0 ? p.hitSlopPx : DEFAULT_HIT_SLOP_PX[p.kind];
|
||||||
const slopWorld = slopPx / camera.scale;
|
const slopWorld = slopPx / camera.scale;
|
||||||
let result: number | null;
|
let result: number | null;
|
||||||
|
let insideDisc = false;
|
||||||
if (p.kind === "point") {
|
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,
|
||||||
|
);
|
||||||
|
if (result !== null) {
|
||||||
|
insideDisc = result <= visibleRadius * visibleRadius;
|
||||||
|
}
|
||||||
} else if (p.kind === "circle") {
|
} else if (p.kind === "circle") {
|
||||||
result = matchCircle(p, cursor, slopWorld, mode === "torus" ? world : null);
|
result = matchCircle(p, cursor, slopWorld, mode === "torus" ? world : null);
|
||||||
} else {
|
} else {
|
||||||
result = matchLine(p, cursor, slopWorld, mode === "torus" ? world : null);
|
result = matchLine(
|
||||||
|
p,
|
||||||
|
cursor,
|
||||||
|
slopWorld,
|
||||||
|
camera.scale,
|
||||||
|
scaleRef,
|
||||||
|
mode === "torus" ? world : null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (result !== null) {
|
if (result !== null) {
|
||||||
candidates.push({ primitive: p, distSq: result });
|
candidates.push({ primitive: p, distSq: result, insideDisc });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +112,15 @@ export function hitTest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function compareHits(a: Hit, b: Hit): number {
|
function compareHits(a: Hit, b: Hit): number {
|
||||||
|
// F8-12 / #4 follow-up: a click that sits *inside* a planet disc
|
||||||
|
// always picks the planet, even when a route shaft or a battle
|
||||||
|
// X-cross with a higher `priority` overlaps it. The base priority
|
||||||
|
// tie-break still rules every "near a line, outside any disc"
|
||||||
|
// case so battle markers / cargo arrows remain clickable in the
|
||||||
|
// gap between planets.
|
||||||
|
if (a.insideDisc !== b.insideDisc) {
|
||||||
|
return a.insideDisc ? -1 : 1;
|
||||||
|
}
|
||||||
if (a.primitive.priority !== b.primitive.priority) {
|
if (a.primitive.priority !== b.primitive.priority) {
|
||||||
return b.primitive.priority - a.primitive.priority;
|
return b.primitive.priority - a.primitive.priority;
|
||||||
}
|
}
|
||||||
@@ -105,15 +152,16 @@ function torusDelta(
|
|||||||
function matchPoint(
|
function matchPoint(
|
||||||
p: PointPrim,
|
p: PointPrim,
|
||||||
cursor: { x: number; y: number },
|
cursor: { x: number; y: number },
|
||||||
|
visibleRadius: number,
|
||||||
slopWorld: number,
|
slopWorld: number,
|
||||||
world: World | null,
|
world: World | null,
|
||||||
): number | null {
|
): number | null {
|
||||||
const { dx, dy } = torusDelta(p.x, p.y, cursor.x, cursor.y, world);
|
const { dx, dy } = torusDelta(p.x, p.y, cursor.x, cursor.y, world);
|
||||||
const distSq = dx * dx + dy * dy;
|
const distSq = dx * dx + dy * dy;
|
||||||
// The visible disc is `pointRadiusPx` world units; the hit zone
|
// `visibleRadius` already reflects whichever sizing rule the
|
||||||
// is the disc plus a small ergonomic slop on top. A click on any
|
// PointPrim uses (pixel-space for `pointRadiusPx`, softened world
|
||||||
// painted pixel of the planet must register as a hit.
|
// units for `pointRadiusWorld`). The hit zone is that disc plus
|
||||||
const visibleRadius = p.style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX;
|
// a small ergonomic slop on top.
|
||||||
const r = visibleRadius + slopWorld;
|
const r = visibleRadius + slopWorld;
|
||||||
if (distSq <= r * r) return distSq;
|
if (distSq <= r * r) return distSq;
|
||||||
return null;
|
return null;
|
||||||
@@ -146,6 +194,8 @@ function matchLine(
|
|||||||
p: LinePrim,
|
p: LinePrim,
|
||||||
cursor: { x: number; y: number },
|
cursor: { x: number; y: number },
|
||||||
slopWorld: number,
|
slopWorld: number,
|
||||||
|
cameraScale: number,
|
||||||
|
scaleRef: number,
|
||||||
world: World | null,
|
world: World | null,
|
||||||
): number | null {
|
): number | null {
|
||||||
// In torus mode the canonical line representation goes from
|
// In torus mode the canonical line representation goes from
|
||||||
@@ -153,14 +203,30 @@ function matchLine(
|
|||||||
// shortest delta from end1 to end2. The cursor's distance is
|
// shortest delta from end1 to end2. The cursor's distance is
|
||||||
// then the perpendicular distance to this canonical segment,
|
// then the perpendicular distance to this canonical segment,
|
||||||
// using the torus-shortest cursor-to-end1 delta as the basis.
|
// using the torus-shortest cursor-to-end1 delta as the basis.
|
||||||
|
const ends = displayLineEndpoints(
|
||||||
|
p.style,
|
||||||
|
p.x1,
|
||||||
|
p.y1,
|
||||||
|
p.x2,
|
||||||
|
p.y2,
|
||||||
|
cameraScale,
|
||||||
|
scaleRef,
|
||||||
|
);
|
||||||
if (world === null) {
|
if (world === null) {
|
||||||
const distSq = distSqPointToSegment(cursor.x, cursor.y, p.x1, p.y1, p.x2, p.y2);
|
const distSq = distSqPointToSegment(
|
||||||
|
cursor.x,
|
||||||
|
cursor.y,
|
||||||
|
ends.x1,
|
||||||
|
ends.y1,
|
||||||
|
ends.x2,
|
||||||
|
ends.y2,
|
||||||
|
);
|
||||||
if (distSq <= slopWorld * slopWorld) return distSq;
|
if (distSq <= slopWorld * slopWorld) return distSq;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const segDx = torusShortestDelta(p.x1, p.x2, world.width);
|
const segDx = torusShortestDelta(ends.x1, ends.x2, world.width);
|
||||||
const segDy = torusShortestDelta(p.y1, p.y2, world.height);
|
const segDy = torusShortestDelta(ends.y1, ends.y2, world.height);
|
||||||
const cur = torusDelta(p.x1, p.y1, cursor.x, cursor.y, world);
|
const cur = torusDelta(ends.x1, ends.y1, cursor.x, cursor.y, world);
|
||||||
const distSq = distSqPointToSegment(cur.dx, cur.dy, 0, 0, segDx, segDy);
|
const distSq = distSqPointToSegment(cur.dx, cur.dy, 0, 0, segDx, segDy);
|
||||||
if (distSq <= slopWorld * slopWorld) return distSq;
|
if (distSq <= slopWorld * slopWorld) return distSq;
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -40,9 +40,12 @@ export { hitTest, type Hit } from "./hit-test";
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
createRenderer,
|
createRenderer,
|
||||||
|
type PlanetOutlineSpec,
|
||||||
type RendererHandle,
|
type RendererHandle,
|
||||||
type RendererOptions,
|
type RendererOptions,
|
||||||
type RendererPreference,
|
type RendererPreference,
|
||||||
} from "./render";
|
} from "./render";
|
||||||
|
|
||||||
|
export { buildPlanetLabels, type PlanetLabelData } from "./labels";
|
||||||
|
|
||||||
export { sampleWorld } from "./fixtures";
|
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`.
|
// booting a Pixi `Application`.
|
||||||
|
|
||||||
import { torusShortestDelta } from "./math";
|
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
|
* PickModeOptions configures a pick-mode session. The caller is
|
||||||
@@ -79,10 +83,17 @@ export interface PickOverlaySpec {
|
|||||||
readonly dimmedIds: ReadonlySet<PrimitiveID>;
|
readonly dimmedIds: ReadonlySet<PrimitiveID>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Anchor / hover outline padding in world units (the rings sit
|
/** Anchor / hover outline padding. F8-12 / #5 retired the anchor
|
||||||
* outside the visible disc so the planet stays clearly visible). */
|
* ring from the picker overlay, so `ANCHOR_PADDING_WORLD` is now
|
||||||
|
* dead — kept exported for legacy test coverage that asserts the
|
||||||
|
* spec stays shaped the same way. `HOVER_PADDING_PX` is the
|
||||||
|
* screen-pixel gap the picker hover-ring leaves between the
|
||||||
|
* destination disc edge and the stroke; it matches the regular
|
||||||
|
* planet outline (`OUTLINE_RADIUS_PADDING_PX` in `render.ts`) so
|
||||||
|
* "selection" and "pick hover" outlines feel identical at every
|
||||||
|
* zoom. */
|
||||||
export const ANCHOR_PADDING_WORLD = 6;
|
export const ANCHOR_PADDING_WORLD = 6;
|
||||||
export const HOVER_PADDING_WORLD = 4;
|
export const HOVER_PADDING_PX = 1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* computePickOverlay produces a `PickOverlaySpec` for the current
|
* computePickOverlay produces a `PickOverlaySpec` for the current
|
||||||
@@ -110,11 +121,15 @@ export function computePickOverlay(
|
|||||||
pointPrimitivesById: ReadonlyMap<PrimitiveID, PointPrim>,
|
pointPrimitivesById: ReadonlyMap<PrimitiveID, PointPrim>,
|
||||||
allPrimitiveIds: Iterable<PrimitiveID>,
|
allPrimitiveIds: Iterable<PrimitiveID>,
|
||||||
world: { width: number; height: number } | null = null,
|
world: { width: number; height: number } | null = null,
|
||||||
|
cameraScale: number = 1,
|
||||||
|
scaleRef: number = 1,
|
||||||
): PickOverlaySpec {
|
): PickOverlaySpec {
|
||||||
const sourcePrim = pointPrimitivesById.get(options.sourcePrimitiveId);
|
const sourcePrim = pointPrimitivesById.get(options.sourcePrimitiveId);
|
||||||
const sourceRadius =
|
const sourceVisibleRadius =
|
||||||
(sourcePrim?.style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX) +
|
sourcePrim === undefined
|
||||||
ANCHOR_PADDING_WORLD;
|
? 0
|
||||||
|
: displayPointRadiusWorld(sourcePrim.style, cameraScale, scaleRef);
|
||||||
|
const sourceRadius = sourceVisibleRadius + ANCHOR_PADDING_WORLD;
|
||||||
|
|
||||||
const dimmed = new Set<PrimitiveID>();
|
const dimmed = new Set<PrimitiveID>();
|
||||||
for (const id of allPrimitiveIds) {
|
for (const id of allPrimitiveIds) {
|
||||||
@@ -160,12 +175,16 @@ export function computePickOverlay(
|
|||||||
) {
|
) {
|
||||||
const target = pointPrimitivesById.get(hoveredId);
|
const target = pointPrimitivesById.get(hoveredId);
|
||||||
if (target !== undefined) {
|
if (target !== undefined) {
|
||||||
|
const targetRadius = displayPointRadiusWorld(
|
||||||
|
target.style,
|
||||||
|
cameraScale,
|
||||||
|
scaleRef,
|
||||||
|
);
|
||||||
|
const paddingWorld = cameraScale > 0 ? HOVER_PADDING_PX / cameraScale : 0;
|
||||||
hoverOutline = {
|
hoverOutline = {
|
||||||
x: target.x,
|
x: target.x,
|
||||||
y: target.y,
|
y: target.y,
|
||||||
radius:
|
radius: targetRadius + paddingWorld,
|
||||||
(target.style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX) +
|
|
||||||
HOVER_PADDING_WORLD,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,8 +216,13 @@ export function computePickOverlay(
|
|||||||
* as obviously inert against the map background.
|
* as obviously inert against the map background.
|
||||||
*/
|
*/
|
||||||
export const PICK_OVERLAY_STYLE = {
|
export const PICK_OVERLAY_STYLE = {
|
||||||
anchor: { alpha: 0.9, width: 2 },
|
anchor: { alpha: 0.9, widthPx: 2 },
|
||||||
line: { alpha: 0.5, width: 1 },
|
// F8-12 / #5: cursor line uses the same screen-pixel thickness as
|
||||||
hover: { alpha: 1, width: 2 },
|
// a regular cargo-route shaft (0.6 px), and the hover ring around
|
||||||
|
// the destination matches the planet-outline stroke (1.5 px). The
|
||||||
|
// renderer divides by `cameraScale` before drawing so the values
|
||||||
|
// stay constant on screen at any zoom.
|
||||||
|
line: { alpha: 0.95, widthPx: 0.6 },
|
||||||
|
hover: { alpha: 0.95, widthPx: 1.5 },
|
||||||
dimAlpha: 0.35,
|
dimAlpha: 0.35,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
+796
-36
File diff suppressed because it is too large
Load Diff
@@ -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,57 @@ import {
|
|||||||
// binding uses the engine number directly as the primitive id so the
|
// binding uses the engine number directly as the primitive id so the
|
||||||
// click handler can recover a planet by hit-test result without an
|
// click handler can recover a planet by hit-test result without an
|
||||||
// extra lookup.
|
// extra lookup.
|
||||||
function styleFor(kind: ReportPlanet["kind"], theme: Theme): Style {
|
|
||||||
|
/**
|
||||||
|
* KNOWN_PLANET_MIN_RADIUS_PX / KNOWN_PLANET_GROWTH_PX calibrate the
|
||||||
|
* cube-root size mapping in screen-pixel space. At the "whole world
|
||||||
|
* fits" reference zoom (`scaleRef`) a Size-`SIZE_NORMALIZER` planet
|
||||||
|
* reads at `MIN + GROWTH` pixels; smaller / larger planets scale by
|
||||||
|
* `cbrt(size / SIZE_NORMALIZER)`, which keeps disc area proportional
|
||||||
|
* to volume — Size-800 reads twice as big as Size-100. The pixel
|
||||||
|
* frame is the right one to calibrate in, because it stays sane no
|
||||||
|
* matter how large the world rectangle is.
|
||||||
|
*
|
||||||
|
* The renderer combines these with `PLANET_SIZE_ZOOM_ALPHA` so the
|
||||||
|
* pixel radius grows sub-linearly as the player zooms in: 10× zoom
|
||||||
|
* scales the radius by ~2.15×, not by 10×.
|
||||||
|
*/
|
||||||
|
const KNOWN_PLANET_MIN_RADIUS_PX = 2;
|
||||||
|
const KNOWN_PLANET_GROWTH_PX = 2;
|
||||||
|
const SIZE_NORMALIZER = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 basePx =
|
||||||
|
KNOWN_PLANET_MIN_RADIUS_PX +
|
||||||
|
KNOWN_PLANET_GROWTH_PX * Math.cbrt(size / SIZE_NORMALIZER);
|
||||||
|
return { ...fill, pointRadiusBasePx: basePx };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillForKind(
|
||||||
|
kind: ReportPlanet["kind"],
|
||||||
|
theme: Theme,
|
||||||
|
): { fillColor: number; fillAlpha: number } {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case "local":
|
case "local":
|
||||||
return { fillColor: theme.planetLocal, fillAlpha: 1, pointRadiusPx: 6 };
|
return { fillColor: theme.planetLocal, fillAlpha: 1 };
|
||||||
case "other":
|
case "other":
|
||||||
return { fillColor: theme.planetOther, fillAlpha: 1, pointRadiusPx: 5 };
|
return { fillColor: theme.planetOther, fillAlpha: 1 };
|
||||||
case "uninhabited":
|
case "uninhabited":
|
||||||
return {
|
return { fillColor: theme.planetUninhabited, fillAlpha: 0.85 };
|
||||||
fillColor: theme.planetUninhabited,
|
|
||||||
fillAlpha: 0.85,
|
|
||||||
pointRadiusPx: 4,
|
|
||||||
};
|
|
||||||
case "unidentified":
|
case "unidentified":
|
||||||
return {
|
return { fillColor: theme.planetUnidentified, fillAlpha: 0.7 };
|
||||||
fillColor: theme.planetUnidentified,
|
|
||||||
fillAlpha: 0.7,
|
|
||||||
pointRadiusPx: 3,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,13 +109,16 @@ function priorityFor(kind: ReportPlanet["kind"]): number {
|
|||||||
* resolves to. The click handler in `lib/active-view/map.svelte`
|
* resolves to. The click handler in `lib/active-view/map.svelte`
|
||||||
* looks the hit primitive's id up in the binding's hitLookup map
|
* looks the hit primitive's id up in the binding's hitLookup map
|
||||||
* and dispatches `selection.selectPlanet` or
|
* 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 =
|
export type HitTarget =
|
||||||
| { kind: "planet"; number: number }
|
| { kind: "planet"; number: number }
|
||||||
| { kind: "shipGroup"; ref: ShipGroupRef }
|
| { kind: "shipGroup"; ref: ShipGroupRef }
|
||||||
| { kind: "battle"; battleId: string; planet: number }
|
| { kind: "battle"; battleId: string; planet: number };
|
||||||
| { kind: "bombing"; planet: number };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PlanetCategory is the per-`ReportPlanet.kind` flavour exposed to the
|
* PlanetCategory is the per-`ReportPlanet.kind` flavour exposed to the
|
||||||
@@ -155,7 +191,7 @@ export function reportToWorld(
|
|||||||
kind: "point",
|
kind: "point",
|
||||||
id: planet.number,
|
id: planet.number,
|
||||||
priority: priorityFor(planet.kind),
|
priority: priorityFor(planet.kind),
|
||||||
style: styleFor(planet.kind, theme),
|
style: styleFor(planet, theme),
|
||||||
hitSlopPx: 0,
|
hitSlopPx: 0,
|
||||||
x: planet.x,
|
x: planet.x,
|
||||||
y: planet.y,
|
y: planet.y,
|
||||||
|
|||||||
@@ -65,8 +65,6 @@ export function isCategoryVisible(
|
|||||||
return toggles.unidentifiedGroups;
|
return toggles.unidentifiedGroups;
|
||||||
case "battleMarker":
|
case "battleMarker":
|
||||||
return toggles.battleMarkers;
|
return toggles.battleMarkers;
|
||||||
case "bombingMarker":
|
|
||||||
return toggles.bombingMarkers;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,20 +17,45 @@ export type WrapMode = "torus" | "no-wrap";
|
|||||||
|
|
||||||
// Style describes the visual appearance of a primitive. Any field may
|
// Style describes the visual appearance of a primitive. Any field may
|
||||||
// be omitted; missing fields fall back to the active theme defaults.
|
// 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.
|
||||||
|
//
|
||||||
|
// `pointRadiusBasePx` is the opposite intent: a planet's known
|
||||||
|
// `size` produces a base on-screen pixel radius at the "whole world
|
||||||
|
// fits" reference zoom, and the renderer grows it sub-linearly with
|
||||||
|
// the camera scale through `PLANET_SIZE_ZOOM_ALPHA` (F8-12 / #31).
|
||||||
|
// When `pointRadiusBasePx` is set on a `PointPrim`, `pointRadiusPx`
|
||||||
|
// is ignored.
|
||||||
export interface Style {
|
export interface Style {
|
||||||
fillColor?: number; // 0xRRGGBB
|
fillColor?: number; // 0xRRGGBB
|
||||||
fillAlpha?: number; // 0..1
|
fillAlpha?: number; // 0..1
|
||||||
strokeColor?: number; // 0xRRGGBB
|
strokeColor?: number; // 0xRRGGBB
|
||||||
strokeAlpha?: number; // 0..1
|
strokeAlpha?: number; // 0..1
|
||||||
strokeWidthPx?: number; // pixels at any zoom
|
strokeWidthPx?: number; // screen pixels at any zoom
|
||||||
pointRadiusPx?: number; // pixels at any zoom (for kind === 'point')
|
pointRadiusPx?: number; // screen pixels at any zoom (for kind === 'point')
|
||||||
|
pointRadiusBasePx?: number; // screen pixels at scaleRef, softened by PLANET_SIZE_ZOOM_ALPHA
|
||||||
// strokeDashPx — when set on a `LinePrim`, the line is rendered as
|
// strokeDashPx — when set on a `LinePrim`, the line is rendered as
|
||||||
// a dashed pattern whose dash and gap are both this length. When
|
// a dashed pattern whose dash and gap are both this length. When
|
||||||
// unset (or zero), the stroke is solid. Interpreted in the same
|
// unset (or zero), the stroke is solid. Interpreted in world-unit
|
||||||
// world-unit space as `strokeWidthPx`, so the dash spacing scales
|
// space — the dash spacing scales with the camera. Phase 19 uses
|
||||||
// with the camera. Phase 19 uses this for the IncomingGroup
|
// this for the IncomingGroup trajectory line; ignored on point
|
||||||
// trajectory line; ignored on point and circle primitives.
|
// and circle primitives.
|
||||||
strokeDashPx?: number;
|
strokeDashPx?: number;
|
||||||
|
// softLengthAnchor — when set on a `LinePrim`, the renderer treats
|
||||||
|
// the world-coord endpoints as the line length "at the reference
|
||||||
|
// scale" and grows / shrinks them with `PLANET_SIZE_ZOOM_ALPHA`
|
||||||
|
// the same way planet discs do. `'center'` scales both endpoints
|
||||||
|
// around the segment midpoint (used by battle X-crosses anchored
|
||||||
|
// on the planet centre); `'start'` keeps `(x1, y1)` fixed and
|
||||||
|
// only scales `(x2, y2)` along the original direction (used by
|
||||||
|
// cargo-route arrowhead wings anchored at the destination).
|
||||||
|
softLengthAnchor?: "center" | "start";
|
||||||
}
|
}
|
||||||
|
|
||||||
// PrimitiveBase carries the fields shared by every primitive kind.
|
// PrimitiveBase carries the fields shared by every primitive kind.
|
||||||
@@ -171,20 +196,153 @@ export interface Theme {
|
|||||||
routeCap: number;
|
routeCap: number;
|
||||||
routeMat: number;
|
routeMat: number;
|
||||||
routeEmp: 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;
|
battleMarker: number;
|
||||||
bombingDamaged: number;
|
bombingDamaged: number;
|
||||||
bombingWiped: 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;
|
reachCircle: number;
|
||||||
selectionRing: number;
|
selectionRing: number;
|
||||||
|
selectionAccent: number;
|
||||||
pendingSend: 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
|
// Pick-mode overlay: the anchor / cursor-line / hover highlight
|
||||||
// colour and the multiply tint applied to non-reachable primitives.
|
// colour and the multiply tint applied to non-reachable primitives.
|
||||||
pickHighlight: number;
|
pickHighlight: number;
|
||||||
pickDimTint: 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.pointRadiusBasePx` is set (known-size planets), the radius
|
||||||
|
* is the base pixel size at `scaleRef`, grown by
|
||||||
|
* `(scale / scaleRef)^α` and converted back into world units —
|
||||||
|
* `α = PLANET_SIZE_ZOOM_ALPHA`. At `scale = scaleRef` the visible
|
||||||
|
* pixel size equals the base; a 10× zoom-in only grows it ~2.15×.
|
||||||
|
* 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 (cameraScale <= 0) {
|
||||||
|
return style.pointRadiusBasePx ?? style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX;
|
||||||
|
}
|
||||||
|
if (style.pointRadiusBasePx !== undefined) {
|
||||||
|
const refScale = scaleRef > 0 ? scaleRef : cameraScale;
|
||||||
|
const screenPx =
|
||||||
|
style.pointRadiusBasePx *
|
||||||
|
Math.pow(cameraScale / refScale, PLANET_SIZE_ZOOM_ALPHA);
|
||||||
|
return screenPx / cameraScale;
|
||||||
|
}
|
||||||
|
const px = style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* softLengthFactor returns the multiplier that scales a line's
|
||||||
|
* length when `style.softLengthAnchor` is set. The factor matches
|
||||||
|
* the planet-radius softening rule: at `scale = scaleRef` it equals
|
||||||
|
* `1` (the recorded geometry is the reference length); zooming in
|
||||||
|
* shrinks the world-space length so the on-screen length grows by
|
||||||
|
* `(scale / scaleRef)^α`. `displayLineEndpoints` is the convenience
|
||||||
|
* wrapper that applies it to a line's `(x1, y1)–(x2, y2)` pair
|
||||||
|
* given the configured anchor.
|
||||||
|
*/
|
||||||
|
export function softLengthFactor(
|
||||||
|
cameraScale: number,
|
||||||
|
scaleRef: number,
|
||||||
|
): number {
|
||||||
|
if (cameraScale <= 0 || scaleRef <= 0) return 1;
|
||||||
|
return Math.pow(cameraScale / scaleRef, PLANET_SIZE_ZOOM_ALPHA - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* displayLineEndpoints returns the world-space endpoints the
|
||||||
|
* renderer should draw a `LinePrim` between, honouring
|
||||||
|
* `style.softLengthAnchor` if set. Used by both the renderer and
|
||||||
|
* the hit-test so the click zone always matches the visible stroke.
|
||||||
|
*/
|
||||||
|
export function displayLineEndpoints(
|
||||||
|
style: Style,
|
||||||
|
x1: number,
|
||||||
|
y1: number,
|
||||||
|
x2: number,
|
||||||
|
y2: number,
|
||||||
|
cameraScale: number,
|
||||||
|
scaleRef: number,
|
||||||
|
): { x1: number; y1: number; x2: number; y2: number } {
|
||||||
|
if (style.softLengthAnchor === undefined) {
|
||||||
|
return { x1, y1, x2, y2 };
|
||||||
|
}
|
||||||
|
const factor = softLengthFactor(cameraScale, scaleRef);
|
||||||
|
if (factor === 1) return { x1, y1, x2, y2 };
|
||||||
|
if (style.softLengthAnchor === "start") {
|
||||||
|
return {
|
||||||
|
x1,
|
||||||
|
y1,
|
||||||
|
x2: x1 + (x2 - x1) * factor,
|
||||||
|
y2: y1 + (y2 - y1) * factor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const cx = (x1 + x2) / 2;
|
||||||
|
const cy = (y1 + y2) / 2;
|
||||||
|
return {
|
||||||
|
x1: cx + (x1 - cx) * factor,
|
||||||
|
y1: cy + (y1 - cy) * factor,
|
||||||
|
x2: cx + (x2 - cx) * factor,
|
||||||
|
y2: cy + (y2 - cy) * factor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const DARK_THEME: Theme = {
|
export const DARK_THEME: Theme = {
|
||||||
background: 0x0a0e1a,
|
background: 0x0a0e1a,
|
||||||
fog: 0x12162a,
|
fog: 0x12162a,
|
||||||
@@ -208,7 +366,12 @@ export const DARK_THEME: Theme = {
|
|||||||
bombingWiped: 0xff3030,
|
bombingWiped: 0xff3030,
|
||||||
reachCircle: 0x6d8cff,
|
reachCircle: 0x6d8cff,
|
||||||
selectionRing: 0x6d8cff,
|
selectionRing: 0x6d8cff,
|
||||||
|
selectionAccent: 0x6d8cff,
|
||||||
pendingSend: 0x66bb6a,
|
pendingSend: 0x66bb6a,
|
||||||
|
labelText: 0xc7d2e0,
|
||||||
|
labelMuted: 0x90a4ae,
|
||||||
|
labelInverseText: 0x0a0e1a,
|
||||||
|
labelInverseBackground: 0x6d8cff,
|
||||||
pickHighlight: 0xffe082,
|
pickHighlight: 0xffe082,
|
||||||
pickDimTint: 0x303841,
|
pickDimTint: 0x303841,
|
||||||
};
|
};
|
||||||
@@ -245,7 +408,12 @@ export const LIGHT_THEME: Theme = {
|
|||||||
bombingWiped: 0xc62828,
|
bombingWiped: 0xc62828,
|
||||||
reachCircle: 0x3949ab,
|
reachCircle: 0x3949ab,
|
||||||
selectionRing: 0x3949ab,
|
selectionRing: 0x3949ab,
|
||||||
|
selectionAccent: 0x3949ab,
|
||||||
pendingSend: 0x388e3c,
|
pendingSend: 0x388e3c,
|
||||||
|
labelText: 0x263240,
|
||||||
|
labelMuted: 0x5a6d8a,
|
||||||
|
labelInverseText: 0xf3f5fb,
|
||||||
|
labelInverseBackground: 0x3949ab,
|
||||||
pickHighlight: 0xef6c00,
|
pickHighlight: 0xef6c00,
|
||||||
pickDimTint: 0xaeb6c4,
|
pickDimTint: 0xaeb6c4,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ describe("buildBattleAndBombingMarkers", () => {
|
|||||||
expect(out.primitives).toHaveLength(0);
|
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({
|
const report = makeReport({
|
||||||
planets: [
|
planets: [
|
||||||
{
|
{
|
||||||
@@ -163,28 +163,12 @@ describe("buildBattleAndBombingMarkers", () => {
|
|||||||
attackPower: 1,
|
attackPower: 1,
|
||||||
wiped: false,
|
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 out = buildBattleAndBombingMarkers(report);
|
||||||
const rings = out.primitives.filter((p) => p.kind === "circle");
|
expect(out.primitives.filter((p) => p.kind === "circle")).toHaveLength(0);
|
||||||
expect(rings).toHaveLength(2);
|
// `setPlanetOutlines` in the renderer paints the bombing accent.
|
||||||
expect(rings[0].style.strokeColor).toBe(DARK_THEME.bombingDamaged);
|
|
||||||
expect(rings[1].style.strokeColor).toBe(DARK_THEME.bombingWiped);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("paints markers with the supplied palette's colours", () => {
|
it("paints markers with the supplied palette's colours", () => {
|
||||||
@@ -231,11 +215,9 @@ describe("buildBattleAndBombingMarkers", () => {
|
|||||||
|
|
||||||
const out = buildBattleAndBombingMarkers(report, LIGHT_THEME);
|
const out = buildBattleAndBombingMarkers(report, LIGHT_THEME);
|
||||||
const lines = out.primitives.filter((p) => p.kind === "line");
|
const lines = out.primitives.filter((p) => p.kind === "line");
|
||||||
const rings = out.primitives.filter((p) => p.kind === "circle");
|
|
||||||
for (const l of lines) {
|
for (const l of lines) {
|
||||||
expect(l.style.strokeColor).toBe(LIGHT_THEME.battleMarker);
|
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.
|
// The accents are deliberately distinct between the palettes.
|
||||||
expect(LIGHT_THEME.battleMarker).not.toBe(DARK_THEME.battleMarker);
|
expect(LIGHT_THEME.battleMarker).not.toBe(DARK_THEME.battleMarker);
|
||||||
expect(LIGHT_THEME.bombingWiped).not.toBe(DARK_THEME.bombingWiped);
|
expect(LIGHT_THEME.bombingWiped).not.toBe(DARK_THEME.bombingWiped);
|
||||||
|
|||||||
@@ -461,10 +461,12 @@ test("cargo-routes flow: pick a destination, arrow appears, reload restores", as
|
|||||||
lines: prims.filter((p) => p.kind === "line").length,
|
lines: prims.filter((p) => p.kind === "line").length,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
// `total` also counts the selected source planet's selection ring
|
// F8-12 / #30 retired the selection-ring CirclePrim: selection is
|
||||||
// (F4 — one circle), so it is one more than the planet + line prims.
|
// now drawn as an outline overlay around the planet disc, outside
|
||||||
|
// the primitive surface. Expected total = 4 planets + 3 cargo
|
||||||
|
// arrow lines.
|
||||||
await expect.poll(debugLineCount, { timeout: 15000 }).toEqual({
|
await expect.poll(debugLineCount, { timeout: 15000 }).toEqual({
|
||||||
total: 8,
|
total: 7,
|
||||||
lines: 3,
|
lines: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -268,10 +268,12 @@ test("gear popover toggles a planet kind off and cascades onto its markers", asy
|
|||||||
await openGame(page);
|
await openGame(page);
|
||||||
|
|
||||||
// Baseline — every planet shows up, plus the battle X-cross (2
|
// Baseline — every planet shows up, plus the battle X-cross (2
|
||||||
// LinePrim) and the bombing ring on the foreign planet.
|
// LinePrim). F8-12 / #30 retired the bombing CirclePrim; the
|
||||||
|
// visual cue is now a planet outline drawn outside the primitive
|
||||||
|
// surface, so the high-bit 0xc… range stays empty by construction.
|
||||||
expect(await visiblePlanets(page)).toEqual([1, 2, 3, 4, 5]);
|
expect(await visiblePlanets(page)).toEqual([1, 2, 3, 4, 5]);
|
||||||
expect(await visibleHighBitCount(page, 0xa0000000)).toBe(2);
|
expect(await visibleHighBitCount(page, 0xa0000000)).toBe(2);
|
||||||
expect(await visibleHighBitCount(page, 0xc0000000)).toBe(1);
|
expect(await visibleHighBitCount(page, 0xc0000000)).toBe(0);
|
||||||
|
|
||||||
await page.getByTestId("map-toggles-trigger").click();
|
await page.getByTestId("map-toggles-trigger").click();
|
||||||
await expect(page.getByTestId("map-toggles-surface")).toBeVisible();
|
await expect(page.getByTestId("map-toggles-surface")).toBeVisible();
|
||||||
@@ -293,7 +295,6 @@ test("gear popover toggles a planet kind off and cascades onto its markers", asy
|
|||||||
|
|
||||||
expect(await visiblePlanets(page)).toEqual([1, 2, 4, 5]);
|
expect(await visiblePlanets(page)).toEqual([1, 2, 4, 5]);
|
||||||
expect(await visibleHighBitCount(page, 0xa0000000)).toBe(0);
|
expect(await visibleHighBitCount(page, 0xa0000000)).toBe(0);
|
||||||
expect(await visibleHighBitCount(page, 0xc0000000)).toBe(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("visibility fog toggles between the LOCAL-planet circle list and an empty overlay", async ({
|
test("visibility fog toggles between the LOCAL-planet circle list and an empty overlay", async ({
|
||||||
@@ -365,9 +366,50 @@ test("toggle state persists across a page reload", async ({ page }) => {
|
|||||||
expect(
|
expect(
|
||||||
await page.getByTestId("map-toggles-bombing-markers").isChecked(),
|
await page.getByTestId("map-toggles-bombing-markers").isChecked(),
|
||||||
).toBe(false);
|
).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, 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();
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page
|
||||||
|
.getByTestId("map-toggles-planet-names")
|
||||||
|
.isChecked(),
|
||||||
|
)
|
||||||
|
.toBe(false);
|
||||||
|
// Wait for the IndexedDB write to flush so the reload observes the
|
||||||
|
// persisted blob instead of the pre-flip defaults.
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
await page.reload({ waitUntil: "commit" });
|
||||||
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
|
"data-status",
|
||||||
|
"ready",
|
||||||
|
);
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const prims = window.__galaxyDebug?.getMapPrimitives?.() ?? [];
|
||||||
|
return prims.length > 0;
|
||||||
|
});
|
||||||
|
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
|
// settledRenderCount waits out the mount/resize paint burst and returns
|
||||||
|
|||||||
@@ -137,17 +137,31 @@ describe("buildCargoRouteLines", () => {
|
|||||||
);
|
);
|
||||||
const lines = buildCargoRouteLines(report);
|
const lines = buildCargoRouteLines(report);
|
||||||
expect(lines.length).toBe(12);
|
expect(lines.length).toBe(12);
|
||||||
const styleByPriority = new Map<number, typeof lines[number]["style"]>();
|
// F8-12 / #4 follow-up: shafts and wings now use different
|
||||||
|
// Style objects so the arrowhead wings can carry
|
||||||
|
// `softLengthAnchor: "start"`. Colour / priority remain shared
|
||||||
|
// across both, which is what the de-dupe loop here verifies.
|
||||||
|
const colourByPriority = new Map<number, number | undefined>();
|
||||||
|
const softLengthByLineId = new Map<number, string | undefined>();
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const existing = styleByPriority.get(line.priority);
|
const existing = colourByPriority.get(line.priority);
|
||||||
if (existing === undefined) styleByPriority.set(line.priority, line.style);
|
if (existing === undefined) {
|
||||||
else expect(existing).toBe(line.style);
|
colourByPriority.set(line.priority, line.style.strokeColor);
|
||||||
|
} else {
|
||||||
|
expect(line.style.strokeColor).toBe(existing);
|
||||||
|
}
|
||||||
|
softLengthByLineId.set(line.id & 0xf, line.style.softLengthAnchor);
|
||||||
}
|
}
|
||||||
|
// Shaft (offset 0) stays linear; wings (offsets 1/2) get the
|
||||||
|
// new softening anchor so the arrowhead grows sub-linearly.
|
||||||
|
expect(softLengthByLineId.get(0)).toBeUndefined();
|
||||||
|
expect(softLengthByLineId.get(1)).toBe("start");
|
||||||
|
expect(softLengthByLineId.get(2)).toBe("start");
|
||||||
// Default (dark) palette colours, one per load type.
|
// Default (dark) palette colours, one per load type.
|
||||||
expect(styleByPriority.get(8)?.strokeColor).toBe(DARK_THEME.routeCol);
|
expect(colourByPriority.get(8)).toBe(DARK_THEME.routeCol);
|
||||||
expect(styleByPriority.get(7)?.strokeColor).toBe(DARK_THEME.routeCap);
|
expect(colourByPriority.get(7)).toBe(DARK_THEME.routeCap);
|
||||||
expect(styleByPriority.get(6)?.strokeColor).toBe(DARK_THEME.routeMat);
|
expect(colourByPriority.get(6)).toBe(DARK_THEME.routeMat);
|
||||||
expect(styleByPriority.get(5)?.strokeColor).toBe(DARK_THEME.routeEmp);
|
expect(colourByPriority.get(5)).toBe(DARK_THEME.routeEmp);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses the supplied palette's stroke colours", () => {
|
test("uses the supplied palette's stroke colours", () => {
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// 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 (pointRadiusBasePx)", () => {
|
||||||
|
test("at scale=scaleRef the on-screen pixel size equals the base", () => {
|
||||||
|
const radius = displayPointRadiusWorld(
|
||||||
|
{ pointRadiusBasePx: 6 },
|
||||||
|
0.2,
|
||||||
|
0.2,
|
||||||
|
);
|
||||||
|
// world units → 6 (base px) / 0.2 (scale) = 30
|
||||||
|
expect(radius).toBeCloseTo(30);
|
||||||
|
// confirm pixel-space: world * scale ≈ 6.
|
||||||
|
expect(radius * 0.2).toBeCloseTo(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("zooming in grows the on-screen pixel size sub-linearly", () => {
|
||||||
|
const r1 = displayPointRadiusWorld({ pointRadiusBasePx: 6 }, 0.2, 0.2);
|
||||||
|
const r10 = displayPointRadiusWorld({ pointRadiusBasePx: 6 }, 2.0, 0.2);
|
||||||
|
// On-screen pixel size grows by scale^α (α = 0.33): 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 pointRadiusBasePx is set", () => {
|
||||||
|
const r = displayPointRadiusWorld(
|
||||||
|
{ pointRadiusPx: 99, pointRadiusBasePx: 4 },
|
||||||
|
0.4,
|
||||||
|
0.2,
|
||||||
|
);
|
||||||
|
// On-screen pixel size: 4 * (0.4 / 0.2)^α = 4 * 2^0.33
|
||||||
|
// In world units: (4 * 2^0.33) / 0.4.
|
||||||
|
const expected = (4 * Math.pow(2, PLANET_SIZE_ZOOM_ALPHA)) / 0.4;
|
||||||
|
expect(r).toBeCloseTo(expected, 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
|
// 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.
|
// in most cases so slop in pixels equals slop in world units.
|
||||||
//
|
//
|
||||||
// The point hit zone is `(pointRadiusPx + slopPx) / camera.scale`
|
// F8-12 / #28 made `pointRadiusPx` and `strokeWidthPx` honest screen-
|
||||||
// world units — the visible disc plus an ergonomic slop on top. The
|
// pixel sizes — the hit zone is `(pointRadiusPx + slopPx) / scale`
|
||||||
// default `pointRadiusPx` (`DEFAULT_POINT_RADIUS_PX`) is 3 and the
|
// world units, which equals `pointRadiusPx + slopPx` *pixels* on
|
||||||
// default point slop (`DEFAULT_HIT_SLOP_PX.point`) is 4, so a default
|
// screen at any zoom. The default `pointRadiusPx`
|
||||||
// point is hit out to 7 world units at scale=1.
|
// (`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 { describe, expect, test } from "vitest";
|
||||||
import { hitTest } from "../src/map/hit-test";
|
import { hitTest } from "../src/map/hit-test";
|
||||||
@@ -256,28 +258,56 @@ describe("hitTest — empty results and scale", () => {
|
|||||||
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(null);
|
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("higher zoom shrinks the on-screen slop in world units", () => {
|
test("higher zoom shrinks the world-unit footprint of the default disc", () => {
|
||||||
// At scale=4, slopPx 4 = 1 world unit; visible radius stays 3
|
// At scale=4, pointRadiusPx 3 = 0.75 world units; slop 4 = 1
|
||||||
// world units. Threshold = 4 world units.
|
// world unit. Threshold = 1.75 world units.
|
||||||
const w = new World(1000, 1000, [point(1, 503, 500)]);
|
|
||||||
const cam4 = camAt(500, 500, 4);
|
const cam4 = camAt(500, 500, 4);
|
||||||
// 3 world units away → on the disc edge → hit.
|
const w = new World(1000, 1000, [point(1, 500, 500)]);
|
||||||
expect(ids(w, "torus", cam4, cursorOver(503, 500, cam4))).toBe(1);
|
// 1.5 world units away → within 1.75 → hit.
|
||||||
// 5 world units away → beyond radius+slop → null.
|
expect(ids(w, "torus", cam4, cursorOver(501.5, 500, cam4))).toBe(1);
|
||||||
const wFar = new World(1000, 1000, [point(1, 505, 500)]);
|
// 2 world units away → beyond 1.75 → null.
|
||||||
expect(ids(wFar, "torus", cam4, cursorOver(500, 500, cam4))).toBe(null);
|
expect(ids(w, "torus", cam4, cursorOver(502, 500, cam4))).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("lower zoom widens the on-screen slop in world units", () => {
|
test("lower zoom inflates the world-unit footprint of the default disc", () => {
|
||||||
// At scale=0.5, slopPx 4 = 8 world units; visible radius
|
// At scale=0.5, pointRadiusPx 3 = 6 world units; slop 4 = 8
|
||||||
// stays 3 → threshold = 11 world units.
|
// world units. Threshold = 14 world units.
|
||||||
const cam05 = camAt(500, 500, 0.5);
|
const cam05 = camAt(500, 500, 0.5);
|
||||||
const w = new World(1000, 1000, [point(1, 510, 500)]);
|
const w = new World(1000, 1000, [point(1, 500, 500)]);
|
||||||
// 10 world units away → within 11 → hit.
|
// 13 world units away → within 14 → hit.
|
||||||
expect(ids(w, "torus", cam05, cursorOver(500, 500, cam05))).toBe(1);
|
expect(ids(w, "torus", cam05, cursorOver(513, 500, cam05))).toBe(1);
|
||||||
const wFar = new World(1000, 1000, [point(1, 514, 500)]);
|
// 16 world units away → beyond 14 → null.
|
||||||
// 14 world units away → beyond 11 → null.
|
expect(ids(w, "torus", cam05, cursorOver(516, 500, cam05))).toBe(null);
|
||||||
expect(ids(wFar, "torus", cam05, cursorOver(500, 500, cam05))).toBe(null);
|
});
|
||||||
|
|
||||||
|
test("F8-12 / #6 — clicks inside the disc hit, not just on its edge", () => {
|
||||||
|
// At scale=1 with pointRadiusBasePx=10 and scaleRef=1, the
|
||||||
|
// visible world radius is 10. Any cursor inside that disc must
|
||||||
|
// resolve to the planet — the bug owner spotted in the picker
|
||||||
|
// was the click being ignored once the cursor moved off the
|
||||||
|
// circumference toward the centre.
|
||||||
|
const camAtRef = camAt(500, 500, 1);
|
||||||
|
const w = new World(1000, 1000, [
|
||||||
|
point(1, 500, 500, { style: { pointRadiusBasePx: 10 } }),
|
||||||
|
]);
|
||||||
|
for (const dx of [0, 2, 5, 8, 9.5]) {
|
||||||
|
expect(ids(w, "torus", camAtRef, cursorOver(500 + dx, 500, camAtRef))).toBe(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pointRadiusBasePx scales softly with zoom (F8-12 / #31)", () => {
|
||||||
|
// world 1000×1000, viewport 200×200 → scaleRef = 0.2. At
|
||||||
|
// scale=0.5 the on-screen pixel size is
|
||||||
|
// basePx * (scale/scaleRef)^α
|
||||||
|
// → 6 * (0.5/0.2)^0.33 ≈ 6 * 1.354 ≈ 8.13 px. In world units
|
||||||
|
// that becomes ≈ 16.27, plus slop 4/0.5 = 8 → threshold ≈ 24.27.
|
||||||
|
const cam05 = camAt(500, 500, 0.5);
|
||||||
|
const wBase = new World(1000, 1000, [
|
||||||
|
point(1, 500, 500, { style: { pointRadiusBasePx: 6 } }),
|
||||||
|
]);
|
||||||
|
expect(ids(wBase, "torus", cam05, cursorOver(520, 500, cam05))).toBe(1);
|
||||||
|
// Cursor 26 world units away exceeds the threshold (~24.27).
|
||||||
|
expect(ids(wBase, "torus", cam05, cursorOver(526, 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,7 +8,7 @@ import { describe, expect, test } from "vitest";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ANCHOR_PADDING_WORLD,
|
ANCHOR_PADDING_WORLD,
|
||||||
HOVER_PADDING_WORLD,
|
HOVER_PADDING_PX,
|
||||||
computePickOverlay,
|
computePickOverlay,
|
||||||
type PickModeOptions,
|
type PickModeOptions,
|
||||||
} from "../src/map/pick-mode";
|
} from "../src/map/pick-mode";
|
||||||
@@ -206,7 +206,7 @@ describe("computePickOverlay", () => {
|
|||||||
expect(spec.hoverOutline).toEqual({
|
expect(spec.hoverOutline).toEqual({
|
||||||
x: 200,
|
x: 200,
|
||||||
y: 100,
|
y: 100,
|
||||||
radius: 5 + HOVER_PADDING_WORLD,
|
radius: 5 + HOVER_PADDING_PX,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -243,7 +243,7 @@ describe("computePickOverlay", () => {
|
|||||||
expect(spec.hoverOutline).toBeNull();
|
expect(spec.hoverOutline).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("hoverOutline reflects the reachable target with HOVER_PADDING_WORLD", () => {
|
test("hoverOutline reflects the reachable target with HOVER_PADDING_PX", () => {
|
||||||
const spec = computePickOverlay(
|
const spec = computePickOverlay(
|
||||||
makeOptions(),
|
makeOptions(),
|
||||||
{ x: 1, y: 1 },
|
{ x: 1, y: 1 },
|
||||||
@@ -254,7 +254,7 @@ describe("computePickOverlay", () => {
|
|||||||
expect(spec.hoverOutline).toEqual({
|
expect(spec.hoverOutline).toEqual({
|
||||||
x: 200,
|
x: 200,
|
||||||
y: 100,
|
y: 100,
|
||||||
radius: 5 + HOVER_PADDING_WORLD,
|
radius: 5 + HOVER_PADDING_PX,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -267,7 +267,7 @@ describe("computePickOverlay", () => {
|
|||||||
allIds,
|
allIds,
|
||||||
);
|
);
|
||||||
expect(spec.hoverOutline?.radius).toBe(
|
expect(spec.hoverOutline?.radius).toBe(
|
||||||
DEFAULT_POINT_RADIUS_PX + HOVER_PADDING_WORLD,
|
DEFAULT_POINT_RADIUS_PX + HOVER_PADDING_PX,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ describe("MapTogglesControl", () => {
|
|||||||
expect(ui.getByTestId("map-toggles-uninhabited-planets")).toBeChecked();
|
expect(ui.getByTestId("map-toggles-uninhabited-planets")).toBeChecked();
|
||||||
expect(ui.getByTestId("map-toggles-unidentified-planets")).toBeChecked();
|
expect(ui.getByTestId("map-toggles-unidentified-planets")).toBeChecked();
|
||||||
expect(ui.getByTestId("map-toggles-unreachable-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.getByTestId("map-toggles-visible-hyperspace")).toBeChecked();
|
||||||
expect(ui.queryByTestId("map-toggles-wrap-torus")).toBeNull();
|
expect(ui.queryByTestId("map-toggles-wrap-torus")).toBeNull();
|
||||||
expect(ui.queryByTestId("map-toggles-wrap-no-wrap")).toBeNull();
|
expect(ui.queryByTestId("map-toggles-wrap-no-wrap")).toBeNull();
|
||||||
@@ -91,6 +92,17 @@ describe("MapTogglesControl", () => {
|
|||||||
expect(setMapToggle).not.toHaveBeenCalledWith("bombingMarkers", false);
|
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 () => {
|
test("Escape closes the popover", async () => {
|
||||||
const store = buildStore();
|
const store = buildStore();
|
||||||
const ui = render(MapTogglesControl, { props: { store } });
|
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.init({ client: makeFakeClient(3), cache, gameId: GAME_ID });
|
||||||
await a.setMapToggle("hyperspaceGroups", false);
|
await a.setMapToggle("hyperspaceGroups", false);
|
||||||
await a.setMapToggle("battleMarkers", false);
|
await a.setMapToggle("battleMarkers", false);
|
||||||
|
await a.setMapToggle("planetNames", false);
|
||||||
await a.setMapToggle("visibleHyperspace", false);
|
await a.setMapToggle("visibleHyperspace", false);
|
||||||
a.dispose();
|
a.dispose();
|
||||||
|
|
||||||
@@ -121,6 +122,7 @@ describe("GameStateStore.mapToggles persistence", () => {
|
|||||||
await b.init({ client: makeFakeClient(3), cache, gameId: GAME_ID });
|
await b.init({ client: makeFakeClient(3), cache, gameId: GAME_ID });
|
||||||
expect(b.mapToggles.hyperspaceGroups).toBe(false);
|
expect(b.mapToggles.hyperspaceGroups).toBe(false);
|
||||||
expect(b.mapToggles.battleMarkers).toBe(false);
|
expect(b.mapToggles.battleMarkers).toBe(false);
|
||||||
|
expect(b.mapToggles.planetNames).toBe(false);
|
||||||
expect(b.mapToggles.visibleHyperspace).toBe(false);
|
expect(b.mapToggles.visibleHyperspace).toBe(false);
|
||||||
// Untouched flags retain defaults.
|
// Untouched flags retain defaults.
|
||||||
expect(b.mapToggles.bombingMarkers).toBe(true);
|
expect(b.mapToggles.bombingMarkers).toBe(true);
|
||||||
@@ -141,6 +143,7 @@ describe("GameStateStore.mapToggles persistence", () => {
|
|||||||
expect(store.mapToggles.hyperspaceGroups).toBe(false);
|
expect(store.mapToggles.hyperspaceGroups).toBe(false);
|
||||||
expect(store.mapToggles.battleMarkers).toBe(true);
|
expect(store.mapToggles.battleMarkers).toBe(true);
|
||||||
expect(store.mapToggles.bombingMarkers).toBe(true);
|
expect(store.mapToggles.bombingMarkers).toBe(true);
|
||||||
|
expect(store.mapToggles.planetNames).toBe(true);
|
||||||
expect(store.mapToggles.visibleHyperspace).toBe(true);
|
expect(store.mapToggles.visibleHyperspace).toBe(true);
|
||||||
store.dispose();
|
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,
|
ReportPlanet,
|
||||||
ReportUnidentifiedShipGroup,
|
ReportUnidentifiedShipGroup,
|
||||||
} from "../src/api/game-state";
|
} 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 { SHIP_GROUP_ID_OFFSETS } from "../src/map/ship-groups";
|
||||||
import { reportToWorld } from "../src/map/state-binding";
|
import { reportToWorld } from "../src/map/state-binding";
|
||||||
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
||||||
@@ -200,7 +200,7 @@ describe("reportToWorld — categories", () => {
|
|||||||
expect(categories.get(unidentifiedId)).toBe("unidentifiedGroup");
|
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(
|
const { categories } = reportToWorld(
|
||||||
makeReport({
|
makeReport({
|
||||||
planets: [
|
planets: [
|
||||||
@@ -208,6 +208,9 @@ describe("reportToWorld — categories", () => {
|
|||||||
makePlanet({ number: 2, kind: "other", x: 200, y: 200 }),
|
makePlanet({ number: 2, kind: "other", x: 200, y: 200 }),
|
||||||
],
|
],
|
||||||
battles: [makeBattle({ id: "b1", planet: 2 })],
|
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 })],
|
bombings: [makeBombing({ planetNumber: 2 })],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -216,8 +219,6 @@ describe("reportToWorld — categories", () => {
|
|||||||
const battleB = BATTLE_MARKER_ID_PREFIX | (0 << 4) | 1;
|
const battleB = BATTLE_MARKER_ID_PREFIX | (0 << 4) | 1;
|
||||||
expect(categories.get(battleA)).toBe("battleMarker");
|
expect(categories.get(battleA)).toBe("battleMarker");
|
||||||
expect(categories.get(battleB)).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);
|
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(
|
const { planetDependents } = reportToWorld(
|
||||||
makeReport({
|
makeReport({
|
||||||
planets: [
|
planets: [
|
||||||
@@ -243,17 +244,18 @@ describe("reportToWorld — planetDependents", () => {
|
|||||||
makePlanet({ number: 2, kind: "other", x: 200, y: 200 }),
|
makePlanet({ number: 2, kind: "other", x: 200, y: 200 }),
|
||||||
],
|
],
|
||||||
battles: [makeBattle({ planet: 2 })],
|
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 })],
|
bombings: [makeBombing({ planetNumber: 2 })],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const battleA = BATTLE_MARKER_ID_PREFIX | (0 << 4) | 0;
|
const battleA = BATTLE_MARKER_ID_PREFIX | (0 << 4) | 0;
|
||||||
const battleB = BATTLE_MARKER_ID_PREFIX | (0 << 4) | 1;
|
const battleB = BATTLE_MARKER_ID_PREFIX | (0 << 4) | 1;
|
||||||
const bombingId = BOMBING_MARKER_ID_PREFIX | 0;
|
|
||||||
const deps = planetDependents.get(2) ?? new Set();
|
const deps = planetDependents.get(2) ?? new Set();
|
||||||
expect(deps.has(2)).toBe(true);
|
expect(deps.has(2)).toBe(true);
|
||||||
expect(deps.has(battleA)).toBe(true);
|
expect(deps.has(battleA)).toBe(true);
|
||||||
expect(deps.has(battleB)).toBe(true);
|
expect(deps.has(battleB)).toBe(true);
|
||||||
expect(deps.has(bombingId)).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("in-space groups cascade onto their destination planet", () => {
|
test("in-space groups cascade onto their destination planet", () => {
|
||||||
|
|||||||
@@ -82,10 +82,10 @@ describe("isCategoryVisible", () => {
|
|||||||
expect(isCategoryVisible("planet-unidentified", t)).toBe(false);
|
expect(isCategoryVisible("planet-unidentified", t)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("battle and bombing markers have independent toggles", () => {
|
test("battleMarker toggle hides battle X-crosses without touching other layers", () => {
|
||||||
const t = toggles({ battleMarkers: false, bombingMarkers: true });
|
const t = toggles({ battleMarkers: false });
|
||||||
expect(isCategoryVisible("battleMarker", t)).toBe(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", () => {
|
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<
|
const categories: Map<PrimitiveID, MapCategory> = new Map<
|
||||||
PrimitiveID,
|
PrimitiveID,
|
||||||
MapCategory
|
MapCategory
|
||||||
@@ -212,11 +215,10 @@ describe("computeHiddenIds", () => {
|
|||||||
[150, "hyperspaceGroup"],
|
[150, "hyperspaceGroup"],
|
||||||
[200, "incomingGroup"],
|
[200, "incomingGroup"],
|
||||||
[300, "battleMarker"],
|
[300, "battleMarker"],
|
||||||
[400, "bombingMarker"],
|
|
||||||
]);
|
]);
|
||||||
const planetDependents = new Map<number, ReadonlySet<PrimitiveID>>([
|
const planetDependents = new Map<number, ReadonlySet<PrimitiveID>>([
|
||||||
[1, new Set([1])],
|
[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", () => {
|
test("category-toggle off hides every primitive in that category", () => {
|
||||||
@@ -239,10 +241,10 @@ describe("computeHiddenIds", () => {
|
|||||||
new Set([2]),
|
new Set([2]),
|
||||||
toggles(),
|
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(
|
const hidden = computeHiddenIds(
|
||||||
categories,
|
categories,
|
||||||
planetDependents,
|
planetDependents,
|
||||||
@@ -250,7 +252,7 @@ describe("computeHiddenIds", () => {
|
|||||||
toggles({ battleMarkers: false }),
|
toggles({ battleMarkers: false }),
|
||||||
);
|
);
|
||||||
expect(hidden.has(300)).toBe(true);
|
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", () => {
|
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
|
// 300 is already present from the cascade; the category toggle
|
||||||
// re-adds it but Set semantics dedupe.
|
// 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