feat: polish

This commit is contained in:
Anatoly Antonov 2026-04-22 01:27:38 +09:00
parent 2e6177fe74
commit 4bd927bb4e
137 changed files with 6357 additions and 137560 deletions

View file

@ -0,0 +1,342 @@
<script lang="ts">
/*
* Conventions (apply to every .svelte file under features/):
* - $state variables: camelCase, no prefix.
* - $derived: camelCase.
* - Component refs: camelCase + Ref.
* - Event handlers: handleXxx.
* - Prop callbacks: onXxx.
* - HTML IDs: kebab-case, prefixed with a component-specific short code
* (e.g. "cp-..." for ControlPanel) so IDs stay globally unique.
*/
import { onMount } from 'svelte';
import {
Button,
Dropdown,
DropdownItem,
DropdownMenu,
DropdownToggle,
FormGroup,
Icon,
Input,
InputGroup,
InputGroupText,
Label,
} from '@sveltestrap/sveltestrap';
import { CollapsibleCard, SelectSearchable, SpoilerGroup, addToast } from '$ui';
import { pointsApi } from '$api';
import {
DEFAULT_FLIGHT_PARAMETERS,
PROFILE_IDENTIFIERS,
toFixedNumber,
type FlightParameters,
type ProfileIdentifier,
type SavedPoint,
} from '$domain';
import { workspacesStore, getActiveWorkspace } from '$features/workspaces';
import { t } from '$i18n';
import { pointsStore } from './pointsStore';
import PointEditor from './PointEditor.svelte';
import CurveEditor from './CurveEditor.svelte';
interface Props {
onSelectOnMapClick?: () => void;
}
let { onSelectOnMapClick = () => {} }: Props = $props();
let pointEditorRef: PointEditor | null = $state(null);
let curveEditorRef: CurveEditor | null = $state(null);
let active = $derived(getActiveWorkspace($workspacesStore));
let params = $derived<FlightParameters>(active?.flightParameters ?? DEFAULT_FLIGHT_PARAMETERS);
let ascentProfile = $state('standard');
let descentProfile = $state('standard');
let selectedPointId = $derived(params.start_point ?? -1);
let currentPoint = $derived($pointsStore.find((p) => p.id === selectedPointId) ?? null);
let isPointDirty = $derived.by(() => {
if (!currentPoint) return false;
return (
params.launch_latitude.toFixed(6) !== currentPoint.lat.toFixed(6) ||
params.launch_longitude.toFixed(6) !== currentPoint.lon.toFixed(6) ||
params.launch_altitude.toFixed(2) !== currentPoint.alt.toFixed(2)
);
});
onMount(async () => {
if ($pointsStore.length === 0) {
try {
pointsStore.set(await pointsApi.list());
} catch (err: unknown) {
addToast({ header: $t('common.error'), body: (err as Error).message, color: 'danger' });
}
}
});
function patchActive(patch: Partial<FlightParameters>) {
if (!active) return;
workspacesStore.setFlightParameters(active.id, { ...active.flightParameters, ...patch });
}
function handlePointSelection(newPointId: number | null) {
if (!active) return;
if (newPointId == null || newPointId === -1) {
patchActive({ start_point: -1 });
return;
}
const point = $pointsStore.find((p) => p.id === newPointId);
if (!point) return;
patchActive({
start_point: point.id,
launch_latitude: point.lat,
launch_longitude: point.lon,
launch_altitude: point.alt,
});
}
async function handleSaveCurrentPoint() {
if (!currentPoint) {
pointEditorRef?.open(
{
id: 0,
name: `New Point ${new Date().toLocaleString()}`,
lat: params.launch_latitude,
lon: params.launch_longitude,
alt: params.launch_altitude,
},
false,
);
return;
}
try {
const saved = await pointsApi.update({
...currentPoint,
lat: params.launch_latitude,
lon: params.launch_longitude,
alt: params.launch_altitude,
});
pointsStore.update((list) => list.map((p) => (p.id === saved.id ? saved : p)));
addToast({ header: $t('common.success'), body: saved.name, color: 'success' });
} catch (err: unknown) {
addToast({ header: $t('common.error'), body: (err as Error).message, color: 'danger' });
}
}
async function handleRun() {
if (!active) return;
try {
await workspacesStore.run(active.id);
addToast({
header: $t('forecast.success'),
body: $t('forecast.successBody'),
color: 'success',
});
} catch (err: unknown) {
addToast({
header: $t('forecast.error'),
body: $t('forecast.errorBody', { error: (err as Error).message }),
color: 'danger',
});
}
}
export function updateLaunchPosition(lat: number, lng: number) {
patchActive({
launch_latitude: toFixedNumber(lat, 6),
launch_longitude: toFixedNumber(lng, 6),
});
}
</script>
<CollapsibleCard title={$t('conditions.title')}>
{#if !active}
<div class="text-muted small">{$t('workspaces.empty')}</div>
{:else}
<div class="d-flex gap-2">
<FormGroup class="flex-fill w-50" spacing="mb-2">
<Label for="cp-start-time" class="form-label">{$t('conditions.startTime')}</Label>
<Input
type="time"
id="cp-start-time"
class="form-control-sm"
step="1"
value={active.launchTime}
oninput={(e) =>
workspacesStore.patch(active!.id, {
launchTime: (e.currentTarget as HTMLInputElement).value,
})} />
</FormGroup>
<FormGroup class="flex-fill w-50" spacing="mb-2">
<Label for="cp-start-date" class="form-label">{$t('conditions.startDate')}</Label>
<Input
type="date"
id="cp-start-date"
class="form-control-sm"
value={active.launchDate}
oninput={(e) =>
workspacesStore.patch(active!.id, {
launchDate: (e.currentTarget as HTMLInputElement).value,
})} />
</FormGroup>
</div>
<FormGroup spacing="mb-2">
<Label for="cp-flight-profile" class="form-label">{$t('conditions.flightProfile')}</Label>
<InputGroup size="sm">
<Input
type="select"
id="cp-flight-profile"
value={params.profile}
onchange={(e) =>
patchActive({
profile: (e.currentTarget as HTMLSelectElement).value as ProfileIdentifier,
})}>
{#each PROFILE_IDENTIFIERS as id}
<option value={id}>{$t(`profile.${id}`)}</option>
{/each}
</Input>
</InputGroup>
</FormGroup>
<FormGroup spacing="mb-2">
<Label for="cp-start-point" class="form-label">{$t('conditions.startPoint')}</Label>
<InputGroup size="sm">
<SelectSearchable
style="min-height: calc(1.5em + .5rem + 2px); padding: .25rem .5rem; font-size: .875rem;"
id="cp-start-point"
selected={selectedPointId}
onChange={handlePointSelection}
options={$pointsStore.map((p) => ({
value: p.id,
label: `${p.name}${p.id === selectedPointId && isPointDirty ? ` (${$t('scenario.modified')})` : ''}`,
}))}
placeholder={$t('conditions.pointPlaceholder')}
searchPlaceholder={$t('conditions.pointSearchPlaceholder')}
clearable={true} />
<Button color="secondary" size="sm" onclick={() => pointEditorRef?.open(null, true)}>
<Icon name="journal-bookmark-fill" />
</Button>
</InputGroup>
</FormGroup>
<FormGroup spacing="mb-2">
<Label class="form-label">{$t('conditions.latLng')}</Label>
<InputGroup size="sm">
<Input
type="number"
step="0.000001"
value={params.launch_latitude}
oninput={(e) =>
patchActive({
launch_latitude: parseFloat((e.currentTarget as HTMLInputElement).value),
})} />
<InputGroupText>/</InputGroupText>
<Input
type="number"
step="0.000001"
value={params.launch_longitude}
oninput={(e) =>
patchActive({
launch_longitude: parseFloat((e.currentTarget as HTMLInputElement).value),
})} />
<Button color="secondary" size="sm" onclick={onSelectOnMapClick}>
<Icon name="geo-alt-fill" />
</Button>
</InputGroup>
</FormGroup>
<div class="d-flex mb-2">
<Button
color="primary"
class="flex-fill"
size="sm"
onclick={handleSaveCurrentPoint}
disabled={!isPointDirty && selectedPointId !== -1}>
{$t('conditions.save')}
<Icon name="floppy2-fill" class="ms-1" />
</Button>
</div>
<div class="d-flex gap-2">
<FormGroup class="flex-fill w-50" spacing="mb-2">
<Label class="form-label">{$t('conditions.launchAlt')}</Label>
<Input
type="number"
class="form-control-sm"
value={params.launch_altitude}
oninput={(e) =>
patchActive({
launch_altitude: parseFloat((e.currentTarget as HTMLInputElement).value),
})} />
</FormGroup>
<FormGroup class="flex-fill w-50" spacing="mb-2">
<Label class="form-label">{$t('conditions.burstAlt')}</Label>
<Input
type="number"
class="form-control-sm"
value={params.burst_altitude}
oninput={(e) =>
patchActive({
burst_altitude: parseFloat((e.currentTarget as HTMLInputElement).value),
})} />
</FormGroup>
</div>
{#if params.profile !== 'custom_profile'}
<div class="mb-2 d-flex gap-2">
<FormGroup class="flex-fill w-50" spacing="mb-2">
<Label class="form-label">{$t('conditions.ascentRate')}</Label>
<Input
type="number"
class="form-control-sm"
value={params.ascent_rate}
oninput={(e) =>
patchActive({
ascent_rate: parseFloat((e.currentTarget as HTMLInputElement).value),
})} />
</FormGroup>
<FormGroup class="flex-fill w-50" spacing="mb-2">
<Label class="form-label">{$t('conditions.descentRate')}</Label>
<Input
type="number"
class="form-control-sm"
value={params.descent_rate}
oninput={(e) =>
patchActive({
descent_rate: parseFloat((e.currentTarget as HTMLInputElement).value),
})} />
</FormGroup>
</div>
{:else}
<SpoilerGroup label={$t('conditions.profileEdit')} class="mb-2">
<Label class="form-label mb-0">{$t('conditions.ascentStage')}</Label>
<div class="d-flex gap-2 mb-0">
<Input type="radio" bind:group={ascentProfile} value="none" label={$t('conditions.stageNone')} />
<Input type="radio" bind:group={ascentProfile} value="standard" label={$t('conditions.stageStandard')} />
<Input type="radio" bind:group={ascentProfile} value="custom" label={$t('conditions.stageCustom')} />
</div>
<Label class="form-label mb-0">{$t('conditions.descentStage')}</Label>
<div class="d-flex gap-2 mb-0">
<Input type="radio" bind:group={descentProfile} value="none" label={$t('conditions.stageNone')} />
<Input type="radio" bind:group={descentProfile} value="standard" label={$t('conditions.stageStandard')} />
<Input type="radio" bind:group={descentProfile} value="custom" label={$t('conditions.stageCustom')} />
</div>
<Button size="sm" color="secondary" onclick={() => curveEditorRef?.openModal()} class="w-100">
{$t('conditions.openCurveEditor')}
<Icon name="graph-up-arrow" />
</Button>
</SpoilerGroup>
{/if}
<div class="d-flex">
<Button class="flex-fill" size="sm" color="primary" onclick={handleRun}>
{$t('conditions.run')}
</Button>
</div>
{/if}
</CollapsibleCard>
<CurveEditor bind:this={curveEditorRef} showTable={true} editor={false} />
<PointEditor
bind:this={pointEditorRef}
onSelectPoint={(p: SavedPoint | null) => handlePointSelection(p?.id ?? -1)} />