357 lines
11 KiB
Svelte
357 lines
11 KiB
Svelte
<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,
|
|
MAX_LAUNCH_LATITUDE,
|
|
PROFILE_IDENTIFIERS,
|
|
clampLaunchLatitude,
|
|
wrapLongitude,
|
|
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;
|
|
// Single choke point for launch latitude: typing it, clicking the map
|
|
// (updateLaunchPosition) and picking a saved point all land here, so one
|
|
// clamp covers every path.
|
|
const safe = { ...patch };
|
|
if (safe.launch_latitude !== undefined) {
|
|
safe.launch_latitude = clampLaunchLatitude(safe.launch_latitude);
|
|
}
|
|
if (safe.launch_longitude !== undefined) {
|
|
safe.launch_longitude = wrapLongitude(safe.launch_longitude);
|
|
}
|
|
workspacesStore.setFlightParameters(active.id, { ...active.flightParameters, ...safe });
|
|
}
|
|
|
|
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"
|
|
min={-MAX_LAUNCH_LATITUDE}
|
|
max={MAX_LAUNCH_LATITUDE}
|
|
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)} />
|