feat: polish
This commit is contained in:
parent
2e6177fe74
commit
4bd927bb4e
137 changed files with 6357 additions and 137560 deletions
342
src/lib/features/prediction/ControlPanel.svelte
Normal file
342
src/lib/features/prediction/ControlPanel.svelte
Normal 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)} />
|
||||
Loading…
Add table
Add a link
Reference in a new issue