Files
galaxy-game/ui/frontend/src/lib/calculator/ship-design-area.svelte
T
Ilia Denisov cc4727a32e
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m48s
Tests · Go / test (pull_request) Successful in 2m3s
Tests · UI / test (pull_request) Successful in 2m28s
fix(ui): F8-06 calculator polish — always 3-decimal display, mono font, input cap
Owner feedback round 2 on PR #61:

- Pad every read-only calculator value to three decimals: tech labels,
  derived results (mass, speed, attack, defence, bombing, cargo
  capacity), planet MAT, planet build-rate, modernization cost, and
  the full-cargo capacity label all read as "1.000" instead of "1",
  matching the goal-seek back-solved input and the report. Drops
  thousands grouping so the same `fmt()` string also embeds cleanly
  in the read-only `<input type="number">` cell.
- Switch label and input styling onto the existing `--font-mono`
  token (right-aligned, tabular-nums) so columns line up vertically
  across rows like a financial table.
- Refuse a fourth decimal as the user types in every calculator
  number input (DWSC blocks, tech, MAT, custom load, lock value,
  modernization target tech): the `oninput` truncates the input text
  past three decimal digits and explicitly writes the truncated
  value back through `bind:value`, so Svelte's later reactive flush
  cannot undo the cap.
- Doc + tests follow the rule (five new vitest cases covering the
  3-decimal label format, the input cap on each input class, and
  the integer-padding rule for derived results).
2026-05-26 18:43:32 +02:00

354 lines
11 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
Reusable "Ship Class design area": the five design blocks (drive,
armament, weapons, shields, cargo) plus the four tech levels they are
built with. Tech and MAT locks follow the same idiom as goal-seek
locks below the design area — by default the value renders as plain
text with an open padlock; clicking it overrides (input + closed
padlock). Reserved space for the padlock keeps the column width
stable as the lock state toggles. 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 { tick } from "svelte";
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>;
// Lower bound for the tech inputs: the player's current tech on
// this turn. A design cannot be built with tech below the player's
// own level, so we surface that as a per-field validation error.
techFloor: TechState;
computedInput?: ClaimedInput | null;
blocksReadonly?: boolean;
// Formatter applied to the read-only tech value and to the
// resolved (goal-seek) ship-block value. Same `fmt` as the
// rest of the calculator, passed in so the design area stays
// presentational and the parent owns the rounding policy.
formatNumber: (value: number) => string;
onTechInput: (key: TechKey) => void;
onResetTech: (key: TechKey) => void;
};
let {
blocks = $bindable(),
resolved,
techs = $bindable(),
techOverridden,
techFloor,
computedInput = null,
blocksReadonly = false,
formatNumber,
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 {
const value = techs[key];
if (value < 0) return i18n.t("game.calculator.invalid.tech_value");
if (value < techFloor[key]) {
return i18n.t("game.calculator.invalid.tech_below_current");
}
return "";
}
// Smart step on the four ship-class blocks (drive, weapons, shields,
// cargo): values must be 0 or ≥ 1 per `pkg/calc/validator.go`, so the
// native 0.01 step would produce invalid intermediates like 0.01.
// Up: 0 jumps straight to 1; otherwise +0.1. Down: 1 collapses to 0;
// otherwise 0.1 down to 1, clamped at 0. Armament uses a plain
// integer step (±1, clamped at 0) so it follows the same
// JS-driven idiom and we can hide the native spinner uniformly.
function bumpBlock(value: number, dir: 1 | -1): number {
if (dir === 1) {
if (value < 1) return 1;
return Math.round((value + 0.1) * 10) / 10;
}
if (value <= 1) return 0;
return Math.round((value - 0.1) * 10) / 10;
}
function bumpArmament(value: number, dir: 1 | -1): number {
const next = Math.trunc(value) + dir;
return next < 0 ? 0 : next;
}
function onBlockKey(
event: KeyboardEvent,
key: keyof DesignBlocksState,
smart: boolean,
): void {
const dir = event.key === "ArrowUp" ? 1 : event.key === "ArrowDown" ? -1 : 0;
if (dir === 0) return;
event.preventDefault();
blocks[key] = smart
? bumpBlock(blocks[key], dir)
: bumpArmament(blocks[key], dir);
}
// Tech / modernization-target inputs all use the same ±0.001 step
// with a per-row floor; lifted into a helper so the parent can
// reuse it (modernization area in `calculator-tab`).
function bumpTech(event: KeyboardEvent, key: TechKey): void {
const dir = event.key === "ArrowUp" ? 1 : event.key === "ArrowDown" ? -1 : 0;
if (dir === 0) return;
event.preventDefault();
const current = techs[key];
const next = Math.round((current + dir * 0.001) * 1000) / 1000;
const floor = techFloor[key];
techs[key] = next < floor ? floor : next;
}
// Refuse a fourth decimal as typing happens: keeps the calculator
// from ever displaying a >3-decimal fraction the user could not
// have intended (the calculator math is `Ceil3`-rounded for display
// anyway). Pairs with `bind:value` — `apply` overwrites the bound
// state when Svelte's own bind handler has already read the
// over-precise number.
function capDecimals(event: Event, apply: (next: number) => void): void {
const el = event.currentTarget as HTMLInputElement;
const txt = el.value;
const dot = txt.indexOf(".");
if (dot < 0 || txt.length - dot - 1 <= 3) return;
el.value = txt.slice(0, dot + 4);
apply(el.valueAsNumber);
}
const BLOCK_ROWS: {
key: keyof DesignBlocksState;
label: () => string;
tech: TechKey | null;
smartStep: boolean;
}[] = [
{ key: "drive", label: () => i18n.t("game.calculator.field.drive"), tech: "drive", smartStep: true },
{ key: "armament", label: () => i18n.t("game.calculator.field.armament"), tech: null, smartStep: false },
{ key: "weapons", label: () => i18n.t("game.calculator.field.weapons"), tech: "weapons", smartStep: true },
{ key: "shields", label: () => i18n.t("game.calculator.field.shields"), tech: "shields", smartStep: true },
{ key: "cargo", label: () => i18n.t("game.calculator.field.cargo"), tech: "cargo", smartStep: true },
];
const techInputEls: Partial<Record<TechKey, HTMLInputElement>> = {};
async function activateTechOverride(key: TechKey): Promise<void> {
onTechInput(key);
await tick();
techInputEls[key]?.focus();
techInputEls[key]?.select();
}
</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 no-spin"
type="number"
step="any"
readonly
value={formatNumber(resolved[row.key])}
data-computed="true"
data-testid={`calculator-block-${row.key}`}
title={i18n.t("game.calculator.lock.reset")}
/>
{:else}
<input
class="ship no-spin"
type="number"
step="any"
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}`}
onkeydown={(e) => onBlockKey(e, row.key, row.smartStep)}
oninput={(e) => capDecimals(e, (v) => (blocks[row.key] = v))}
/>
{/if}
{#if row.tech !== null}
{@const techKey = row.tech}
<span class="tech-cell">
{#if techOverridden[techKey]}
<input
bind:this={techInputEls[techKey]}
class="tech no-spin"
type="number"
step="any"
min={techFloor[techKey]}
bind:value={techs[techKey]}
aria-invalid={techError(techKey) !== "" ? "true" : "false"}
title={techError(techKey)}
data-testid={`calculator-tech-${techKey}`}
onkeydown={(e) => bumpTech(e, techKey)}
oninput={(e) => capDecimals(e, (v) => (techs[techKey] = v))}
/>
<button
type="button"
class="lock active"
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>
{:else}
<span
class="tech-val"
data-testid={`calculator-tech-value-${techKey}`}
>
{formatNumber(techs[techKey])}
</span>
<button
type="button"
class="lock"
title={i18n.t("game.calculator.tech.override")}
aria-label={i18n.t("game.calculator.tech.override")}
data-testid={`calculator-tech-override-${techKey}`}
onclick={() => void activateTechOverride(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: var(--color-text-muted);
font-size: 0.7rem;
text-align: center;
text-transform: lowercase;
}
.label {
color: var(--color-text-muted);
font-size: 0.8rem;
}
input {
font-family: var(--font-mono);
font-size: 0.8rem;
width: 100%;
min-width: 0;
padding: 0.2rem 0.35rem;
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 3px;
font-variant-numeric: tabular-nums;
text-align: right;
}
/* Hide native spinners across the design area — the row drives
every numeric edit through ArrowUp/ArrowDown so the column
width stays stable and the inputs read consistently. */
input.no-spin::-webkit-inner-spin-button,
input.no-spin::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input.no-spin {
-moz-appearance: textfield;
appearance: textfield;
}
input[data-computed="true"],
input[readonly] {
color: var(--color-accent);
background: var(--color-surface-raised);
}
input[aria-invalid="true"] {
border-color: var(--color-danger);
}
.tech-cell {
display: flex;
align-items: center;
gap: 0.2rem;
}
.tech-val {
flex: 1;
min-width: 0;
font-family: var(--font-mono);
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
text-align: right;
padding: 0.2rem 0.35rem;
}
.lock {
flex: none;
padding: 0;
font-size: 0.7rem;
line-height: 1;
background: transparent;
border: 0;
cursor: pointer;
opacity: 0.5;
}
.lock.active,
.lock:hover {
opacity: 1;
}
</style>