Files
galaxy-game/ui/frontend/src/lib/header/view-menu.svelte
T
Ilia Denisov 9ae7b88b89
Tests · UI / test (push) Successful in 2m14s
Tests · Go / test (push) Successful in 2m25s
feat(ui): Phase 30 ship-class calculator with goal-seek and reach circles
Fuse the standalone ship-class designer (Phases 17/18) into a sidebar calculator: live mass/speed/attack/defence/bombing results, a planet build-rate readout, single-target goal-seek, a modernization-cost mode, and auto reach circles on the map for the selected planet.

pkg/calc becomes the single source for the new math (no mirroring): extract BombingPower from the engine model and the per-turn ship-production loop from controller.ProduceShip into pkg/calc (engine now delegates), and add inverse goal-seek solvers in pkg/calc/solve.go. Thin-bridge the combat, planet-build, and solver functions through ui/core/calc + ui/wasm and rebuild core.wasm.

Remove the standalone designer view/route; the ship-classes table and the view/bottom menus open the calculator via a shared request store.

Docs: rewrite ui/PLAN.md Phase 30, adjust Phase 34 (realistic forecast + CAP/COL ownership), add ui/docs/calculator-ux.md, extend calc-bridge.md, fix navigation.md; remove ui/CALCULATOR.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:04:07 +02:00

271 lines
6.4 KiB
Svelte

<!--
Active-view picker that drives navigation between every in-game view.
The trigger swaps icon between desktop dropdown (▾) and mobile
hamburger (☰) at the 1024 px breakpoint via CSS only; the surface
itself is identical. The same component is reused for the mobile
"More" drawer entry of `bottom-tabs.svelte`.
Lists the seven IA destinations: map, tables (sub-list of six
entities), report, battle, mail, ship-class designer, science
designer. Closes on Escape, on outside click, and after a
navigation. Phase 26 introduces the history-mode entry; Phase 35
polishes microcopy.
-->
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { mailStore } from "$lib/mail-store.svelte";
type Props = { gameId: string };
let { gameId }: Props = $props();
const mailUnread = $derived(mailStore.unreadCount);
let open = $state(false);
let rootEl: HTMLDivElement | null = $state(null);
const tableEntities: ReadonlyArray<{ slug: string; key: TranslationKey }> = [
{ slug: "planets", key: "game.view.table.planets" },
{ slug: "ship-classes", key: "game.view.table.ship_classes" },
{ slug: "ship-groups", key: "game.view.table.ship_groups" },
{ slug: "fleets", key: "game.view.table.fleets" },
{ slug: "sciences", key: "game.view.table.sciences" },
{ slug: "races", key: "game.view.table.races" },
];
function toggleOpen(): void {
open = !open;
}
function go(path: string): void {
open = false;
void goto(path);
}
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="view-menu" bind:this={rootEl}>
<button
type="button"
class="trigger"
data-testid="view-menu-trigger"
aria-haspopup="menu"
aria-expanded={open}
aria-label={open
? i18n.t("game.shell.menu.close_views")
: i18n.t("game.shell.menu.open_views")}
onclick={toggleOpen}
>
<span class="icon-dropdown" aria-hidden="true"></span>
<span class="icon-hamburger" aria-hidden="true"></span>
</button>
{#if open}
<div class="surface" role="menu" data-testid="view-menu-list">
<button
type="button"
role="menuitem"
data-testid="view-menu-item-map"
onclick={() => go(`/games/${gameId}/map`)}
>
{i18n.t("game.view.map")}
</button>
<details data-testid="view-menu-tables">
<summary>{i18n.t("game.view.table")}</summary>
<div class="sub">
{#each tableEntities as entry (entry.slug)}
<button
type="button"
role="menuitem"
data-testid="view-menu-item-table-{entry.slug}"
onclick={() => go(`/games/${gameId}/table/${entry.slug}`)}
>
{i18n.t(entry.key)}
</button>
{/each}
</div>
</details>
<button
type="button"
role="menuitem"
data-testid="view-menu-item-report"
onclick={() => go(`/games/${gameId}/report`)}
>
{i18n.t("game.view.report")}
</button>
<button
type="button"
role="menuitem"
data-testid="view-menu-item-battle"
onclick={() => go(`/games/${gameId}/battle`)}
>
{i18n.t("game.view.battle")}
</button>
<button
type="button"
role="menuitem"
data-testid="view-menu-item-mail"
class="with-badge"
onclick={() => go(`/games/${gameId}/mail`)}
>
<span>{i18n.t("game.view.mail")}</span>
{#if mailUnread > 0}
<span class="badge" data-testid="view-menu-item-mail-badge">
{i18n.t("game.view.mail.badge", { count: String(mailUnread) })}
</span>
{/if}
</button>
<button
type="button"
role="menuitem"
data-testid="view-menu-item-designer-science"
onclick={() => go(`/games/${gameId}/designer/science`)}
>
{i18n.t("game.view.designer.science")}
</button>
</div>
{/if}
</div>
<style>
.view-menu {
position: relative;
}
.trigger {
font: inherit;
font-size: 1.1rem;
padding: 0.25rem 0.6rem;
background: transparent;
color: inherit;
border: 1px solid #2a3150;
border-radius: 4px;
cursor: pointer;
}
.trigger:hover {
background: #1c2238;
}
.icon-dropdown {
display: inline;
}
.icon-hamburger {
display: none;
}
@media (max-width: 767.98px) {
.icon-dropdown {
display: none;
}
.icon-hamburger {
display: inline;
}
}
.surface {
position: absolute;
top: calc(100% + 0.25rem);
right: 0;
min-width: 14rem;
display: flex;
flex-direction: column;
background: #14182a;
border: 1px solid #2a3150;
border-radius: 6px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
z-index: 50;
}
.surface > button,
.surface > details > summary {
text-align: left;
font: inherit;
padding: 0.45rem 0.75rem;
background: transparent;
color: inherit;
border: 0;
cursor: pointer;
}
.surface > button.with-badge {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.surface > button.with-badge .badge {
min-width: 1.5rem;
padding: 0 0.4rem;
text-align: center;
font-size: 0.75rem;
border-radius: 999px;
background: #2a4d7d;
color: #fff;
}
.surface > button:hover,
.surface > details > summary:hover {
background: #1c2238;
}
.surface > details > summary {
list-style: none;
}
.surface > details > summary::-webkit-details-marker {
display: none;
}
.surface > details > summary::after {
content: "▸";
float: right;
opacity: 0.7;
}
.surface > details[open] > summary::after {
content: "▾";
}
.sub {
display: flex;
flex-direction: column;
padding-left: 0.5rem;
border-left: 1px solid #2a3150;
margin: 0 0.5rem 0.25rem;
}
.sub > button {
text-align: left;
font: inherit;
padding: 0.35rem 0.5rem;
background: transparent;
color: inherit;
border: 0;
cursor: pointer;
}
.sub > button:hover {
background: #1c2238;
}
@media (max-width: 767.98px) {
.surface {
position: fixed;
top: 3rem;
right: 0;
left: 0;
min-width: 0;
border-radius: 0;
border-left: 0;
border-right: 0;
max-height: calc(100vh - 3rem);
overflow-y: auto;
}
}
</style>