feat(ui): Phase 29 map visibility toggles
Tests · Go / test (push) Successful in 2m31s
Tests · UI / test (push) Failing after 8m7s

Adds the gear-icon popover on the map view with per-game persistence
of every category toggle plus the wrap-mode radio. Hide-by-id and
visibility-fog facilities land on the renderer so every flip applies
within one frame without a Pixi remount; the wrap-mode toggle keeps
its existing remount + camera-preserve path. A new server-side turn
force-resets every flag to defaults so a hidden category never makes
the player miss the next turn's news.

Also fixes the FligthDistance → FlightDistance typo in pkg/calc/race.go
(plus the single Go caller); the TS side keeps duplicating the formula
until a race-level WASM bridge lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-19 21:33:53 +02:00
parent 65c0fbb87d
commit 2bd1b54936
32 changed files with 3046 additions and 63 deletions
@@ -0,0 +1,320 @@
<!--
Phase 29 gear popover. Sits in the top-right corner of the map
canvas and exposes the per-game visibility / wrap toggles that the
`GameStateStore` already owns. The component is a thin view of the
store — every checkbox / radio fires `store.setMapToggle(...)` or
`store.setWrapMode(...)` and reads back the current state through
the rune.
Outside-click + Escape close the popover, matching the
`header/view-menu.svelte` precedent. On mobile (<768 px) the
surface re-styles into a bottom-sheet positioned above the
bottom-tabs bar.
-->
<script lang="ts">
import { onMount } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import type { MapToggles, GameStateStore } from "$lib/game-state.svelte";
import type { WrapMode } from "../../map/world";
type Props = { store: GameStateStore };
let { store }: Props = $props();
let open = $state(false);
let rootEl: HTMLDivElement | null = $state(null);
function toggleOpen(): void {
open = !open;
}
function setFlag<K extends keyof MapToggles>(
key: K,
event: Event & { currentTarget: HTMLInputElement },
): void {
void store.setMapToggle(key, event.currentTarget.checked as MapToggles[K]);
}
function setWrap(mode: WrapMode): void {
void store.setWrapMode(mode);
}
function onKeyDown(event: KeyboardEvent): void {
if (event.key === "Escape" && open) {
open = false;
}
}
onMount(() => {
const handleClick = (event: MouseEvent): void => {
if (!open || rootEl === null) return;
const target = event.target;
if (target instanceof Node && rootEl.contains(target)) return;
open = false;
};
document.addEventListener("click", handleClick, true);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("click", handleClick, true);
document.removeEventListener("keydown", onKeyDown);
};
});
</script>
<div class="map-toggles" bind:this={rootEl}>
<button
type="button"
class="trigger"
data-testid="map-toggles-trigger"
aria-haspopup="menu"
aria-expanded={open}
aria-label={open
? i18n.t("game.map.toggles.close")
: i18n.t("game.map.toggles.open")}
onclick={toggleOpen}
>
<span aria-hidden="true"></span>
</button>
{#if open}
<div class="surface" role="menu" data-testid="map-toggles-surface">
<fieldset>
<legend>{i18n.t("game.map.toggles.section.objects")}</legend>
<label>
<input
type="checkbox"
data-testid="map-toggles-hyperspace-groups"
checked={store.mapToggles.hyperspaceGroups}
onchange={(e) => setFlag("hyperspaceGroups", e)}
/>
<span>{i18n.t("game.map.toggles.hyperspace_groups")}</span>
</label>
<label>
<input
type="checkbox"
data-testid="map-toggles-incoming-groups"
checked={store.mapToggles.incomingGroups}
onchange={(e) => setFlag("incomingGroups", e)}
/>
<span>{i18n.t("game.map.toggles.incoming_groups")}</span>
</label>
<label>
<input
type="checkbox"
data-testid="map-toggles-unidentified-groups"
checked={store.mapToggles.unidentifiedGroups}
onchange={(e) => setFlag("unidentifiedGroups", e)}
/>
<span>{i18n.t("game.map.toggles.unidentified_groups")}</span>
</label>
<label>
<input
type="checkbox"
data-testid="map-toggles-cargo-routes"
checked={store.mapToggles.cargoRoutes}
onchange={(e) => setFlag("cargoRoutes", e)}
/>
<span>{i18n.t("game.map.toggles.cargo_routes")}</span>
</label>
<label>
<input
type="checkbox"
data-testid="map-toggles-battle-markers"
checked={store.mapToggles.battleMarkers}
onchange={(e) => setFlag("battleMarkers", e)}
/>
<span>{i18n.t("game.map.toggles.battle_markers")}</span>
</label>
<label>
<input
type="checkbox"
data-testid="map-toggles-bombing-markers"
checked={store.mapToggles.bombingMarkers}
onchange={(e) => setFlag("bombingMarkers", e)}
/>
<span>{i18n.t("game.map.toggles.bombing_markers")}</span>
</label>
</fieldset>
<fieldset>
<legend>{i18n.t("game.map.toggles.section.planets")}</legend>
<label>
<input
type="checkbox"
data-testid="map-toggles-foreign-planets"
checked={store.mapToggles.foreignPlanets}
onchange={(e) => setFlag("foreignPlanets", e)}
/>
<span>{i18n.t("game.map.toggles.foreign_planets")}</span>
</label>
<label>
<input
type="checkbox"
data-testid="map-toggles-uninhabited-planets"
checked={store.mapToggles.uninhabitedPlanets}
onchange={(e) => setFlag("uninhabitedPlanets", e)}
/>
<span>{i18n.t("game.map.toggles.uninhabited_planets")}</span>
</label>
<label>
<input
type="checkbox"
data-testid="map-toggles-unidentified-planets"
checked={store.mapToggles.unidentifiedPlanets}
onchange={(e) => setFlag("unidentifiedPlanets", e)}
/>
<span>{i18n.t("game.map.toggles.unidentified_planets")}</span>
</label>
<label>
<input
type="checkbox"
data-testid="map-toggles-unreachable-planets"
checked={store.mapToggles.unreachablePlanets}
onchange={(e) => setFlag("unreachablePlanets", e)}
/>
<span>{i18n.t("game.map.toggles.unreachable_planets")}</span>
</label>
</fieldset>
<fieldset>
<legend>{i18n.t("game.map.toggles.section.view")}</legend>
<label>
<input
type="checkbox"
data-testid="map-toggles-visibility-fog"
checked={store.mapToggles.visibilityFog}
onchange={(e) => setFlag("visibilityFog", e)}
/>
<span>{i18n.t("game.map.toggles.visibility_fog")}</span>
</label>
<div class="wrap-row">
<span class="wrap-label">{i18n.t("game.map.toggles.wrap.label")}</span>
<label class="radio">
<input
type="radio"
name="map-toggles-wrap"
data-testid="map-toggles-wrap-torus"
value="torus"
checked={store.wrapMode === "torus"}
onchange={() => setWrap("torus")}
/>
<span>{i18n.t("game.map.toggles.wrap.torus")}</span>
</label>
<label class="radio">
<input
type="radio"
name="map-toggles-wrap"
data-testid="map-toggles-wrap-no-wrap"
value="no-wrap"
checked={store.wrapMode === "no-wrap"}
onchange={() => setWrap("no-wrap")}
/>
<span>{i18n.t("game.map.toggles.wrap.no_wrap")}</span>
</label>
</div>
</fieldset>
</div>
{/if}
</div>
<style>
.map-toggles {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 20;
}
.trigger {
min-width: 44px;
min-height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
font: inherit;
font-size: 1.4rem;
padding: 0.25rem 0.5rem;
background: rgba(20, 24, 42, 0.85);
color: #e8eaf6;
border: 1px solid #2a3150;
border-radius: 6px;
cursor: pointer;
}
.trigger:hover {
background: #1c2238;
}
.surface {
position: absolute;
top: calc(100% + 0.25rem);
right: 0;
min-width: 16rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
background: #14182a;
color: #e8eaf6;
border: 1px solid #2a3150;
border-radius: 6px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
padding: 0.5rem;
z-index: 50;
}
fieldset {
border: 0;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
legend {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #aab;
padding: 0 0 0.15rem 0;
}
label {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.9rem;
padding: 0.2rem 0.25rem;
border-radius: 3px;
cursor: pointer;
}
label:hover {
background: #1c2238;
}
input[type="checkbox"],
input[type="radio"] {
accent-color: #6dd2ff;
}
.wrap-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
font-size: 0.9rem;
}
.wrap-label {
color: #aab;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
width: 100%;
}
.radio {
padding: 0.15rem 0.4rem;
}
@media (max-width: 767.98px) {
.surface {
position: fixed;
top: auto;
right: 0;
left: 0;
bottom: 3.25rem;
max-height: calc(100vh - 6rem);
overflow-y: auto;
border-radius: 0;
border-left: 0;
border-right: 0;
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.4);
}
}
</style>