Files
galaxy-game/ui/frontend/src/lib/calculator/ship-design-area.svelte
T
Ilia Denisov b1b87c8521
Tests · Go / test (push) Successful in 2m26s
Tests · UI / test (push) Successful in 2m26s
feat(ui-calculator): input validation, load caps, ceil display, modernization layout
- custom load capped at cargo capacity (error when exceeded); full load shows the cargo capacity; zero cargo pins load to empty and disables the toggle

- per-input red border + tooltip for every invalid value (blocks, techs, load, MAT, modernization target); no value may be negative; locking a speed is disabled when drive is zero

- display every computed number (results + goal-seek back-solved input) rounded up to 3 decimals via a shared pkg/calc Ceil3 bridged to wasm; engine keeps its own round-to-nearest util.Fixed*

- modernization total upgrade cost spans two columns (single line)

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

219 lines
6.0 KiB
Svelte

<!--
Reusable "Ship Class design area": the five design blocks (drive,
armament, weapons, shields, cargo) plus the four tech levels they are
built with. Each tech defaults to the player's current level and shows a
lock icon once overridden; clicking the lock resets it. A block claimed
by an active goal-seek lock renders read-only with its own lock marker.
The component is presentational — the parent owns the state and the
calculator math — so the ship-group upgrade flow can reuse it later.
-->
<script lang="ts">
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import {
shipClassFieldErrors,
type ShipClassValueInvalidReason,
} from "$lib/util/ship-class-validation";
import type { ClaimedInput } from "./calc-model";
export interface DesignBlocksState {
drive: number;
armament: number;
weapons: number;
shields: number;
cargo: number;
}
export interface TechState {
drive: number;
weapons: number;
shields: number;
cargo: number;
}
export type TechKey = keyof TechState;
type Props = {
blocks: DesignBlocksState;
// Blocks after goal-seek: the claimed block carries its solved
// value, which is what the read-only computed cell displays.
resolved: DesignBlocksState;
techs: TechState;
techOverridden: Record<TechKey, boolean>;
computedInput?: ClaimedInput | null;
blocksReadonly?: boolean;
onTechInput: (key: TechKey) => void;
onResetTech: (key: TechKey) => void;
};
let {
blocks = $bindable(),
resolved,
techs = $bindable(),
techOverridden,
computedInput = null,
blocksReadonly = false,
onTechInput,
onResetTech,
}: Props = $props();
const VALUE_REASON_KEY: Record<ShipClassValueInvalidReason, TranslationKey> = {
drive_value: "game.calculator.invalid.drive_value",
armament_value: "game.calculator.invalid.armament_value",
armament_not_integer: "game.calculator.invalid.armament_not_integer",
weapons_value: "game.calculator.invalid.weapons_value",
shields_value: "game.calculator.invalid.shields_value",
cargo_value: "game.calculator.invalid.cargo_value",
armament_weapons_pair: "game.calculator.invalid.armament_weapons_pair",
all_zero: "game.calculator.invalid.all_zero",
};
// Per-block validity (independent of which one failed first) so every
// invalid input is highlighted, not only the first.
const blockErrors = $derived(shipClassFieldErrors(blocks));
function blockError(key: keyof DesignBlocksState): string {
const reason = blockErrors[key];
return reason === undefined ? "" : i18n.t(VALUE_REASON_KEY[reason]);
}
function techError(key: TechKey): string {
return techs[key] < 0 ? i18n.t("game.calculator.invalid.tech_value") : "";
}
const BLOCK_ROWS: {
key: keyof DesignBlocksState;
label: () => string;
step: string;
tech: TechKey | null;
}[] = [
{ key: "drive", label: () => i18n.t("game.calculator.field.drive"), step: "0.01", tech: "drive" },
{ key: "armament", label: () => i18n.t("game.calculator.field.armament"), step: "1", tech: null },
{ key: "weapons", label: () => i18n.t("game.calculator.field.weapons"), step: "0.01", tech: "weapons" },
{ key: "shields", label: () => i18n.t("game.calculator.field.shields"), step: "0.01", tech: "shields" },
{ key: "cargo", label: () => i18n.t("game.calculator.field.cargo"), step: "0.01", tech: "cargo" },
];
</script>
<div class="design" data-testid="calculator-design-area">
<div class="cols">
<span></span>
<span class="col-head">{i18n.t("game.calculator.col.ship")}</span>
<span class="col-head">{i18n.t("game.calculator.col.tech")}</span>
</div>
{#each BLOCK_ROWS as row (row.key)}
{@const isComputed = computedInput === row.key}
<div class="row">
<span class="label">{row.label()}</span>
{#if isComputed}
<input
class="ship"
type="number"
step={row.step}
readonly
value={resolved[row.key]}
data-computed="true"
data-testid={`calculator-block-${row.key}`}
title={i18n.t("game.calculator.lock.reset")}
/>
{:else}
<input
class="ship"
type="number"
step={row.step}
min="0"
bind:value={blocks[row.key]}
readonly={blocksReadonly}
aria-invalid={blockError(row.key) !== "" ? "true" : "false"}
title={blockError(row.key)}
data-testid={`calculator-block-${row.key}`}
/>
{/if}
{#if row.tech !== null}
{@const techKey = row.tech}
<span class="tech-cell">
<input
class="tech"
type="number"
step="0.001"
min="0"
bind:value={techs[techKey]}
oninput={() => onTechInput(techKey)}
aria-invalid={techError(techKey) !== "" ? "true" : "false"}
title={techError(techKey)}
data-testid={`calculator-tech-${techKey}`}
/>
{#if techOverridden[techKey]}
<button
type="button"
class="lock"
title={i18n.t("game.calculator.tech.reset")}
aria-label={i18n.t("game.calculator.tech.reset")}
data-testid={`calculator-tech-reset-${techKey}`}
onclick={() => onResetTech(techKey)}
>
🔒
</button>
{/if}
</span>
{:else}
<span></span>
{/if}
</div>
{/each}
</div>
<style>
.design {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.cols,
.row {
display: grid;
grid-template-columns: 4.5rem 1fr 1fr;
align-items: center;
gap: 0.35rem;
}
.col-head {
color: #8890b0;
font-size: 0.7rem;
text-align: center;
text-transform: lowercase;
}
.label {
color: #aab;
font-size: 0.8rem;
}
input {
font: inherit;
font-size: 0.8rem;
width: 100%;
min-width: 0;
padding: 0.2rem 0.35rem;
background: #0a0e1a;
color: #e8eaf6;
border: 1px solid #2a3150;
border-radius: 3px;
font-variant-numeric: tabular-nums;
}
input[data-computed="true"],
input[readonly] {
color: #9fb0ff;
background: #11162a;
}
input[aria-invalid="true"] {
border-color: #d97a7a;
}
.tech-cell {
display: flex;
align-items: center;
gap: 0.2rem;
}
.lock {
flex: none;
padding: 0;
font-size: 0.7rem;
line-height: 1;
background: transparent;
border: 0;
cursor: pointer;
}
</style>