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)} />
|
||||
144
src/lib/features/prediction/CurveChart.svelte
Normal file
144
src/lib/features/prediction/CurveChart.svelte
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { Chart as ChartJS, type TooltipItem } from 'chart.js/auto';
|
||||
import 'chartjs-adapter-luxon';
|
||||
import chartjsPluginDragdata from 'chartjs-plugin-dragdata';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { RateCurvePoint, SavedFlightProfile } from '$domain';
|
||||
|
||||
ChartJS.register(chartjsPluginDragdata);
|
||||
|
||||
interface Props {
|
||||
curve: SavedFlightProfile;
|
||||
onUpdate: (points: RateCurvePoint[]) => void;
|
||||
}
|
||||
let { curve, onUpdate }: Props = $props();
|
||||
|
||||
let canvasEl: HTMLCanvasElement;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let chart: any = $state(null);
|
||||
|
||||
const chartData = $derived(calculateChartData(curve.rate_profile_data));
|
||||
|
||||
/**
|
||||
* Fold relative constraints into absolute (time, altitude) segments.
|
||||
* Each point declares either a time constraint, an altitude constraint,
|
||||
* or both (minimum of the two wins).
|
||||
*/
|
||||
function calculateChartData(points: RateCurvePoint[]) {
|
||||
const data: { x: number; y: number }[] = [{ x: 0, y: 0 }];
|
||||
let currentTime = 0;
|
||||
let currentAltitude = 0;
|
||||
|
||||
for (const point of points) {
|
||||
const { time_constraint, alt_constraint, rate } = point;
|
||||
let resolved = 0;
|
||||
|
||||
if (time_constraint !== -1) {
|
||||
if (alt_constraint !== -1) {
|
||||
const timeForAlt = rate !== 0 ? (alt_constraint - currentAltitude) / rate : 0;
|
||||
resolved = Math.min(time_constraint, timeForAlt);
|
||||
} else {
|
||||
resolved = time_constraint;
|
||||
}
|
||||
} else if (alt_constraint !== -1) {
|
||||
resolved = rate !== 0 ? (alt_constraint - currentAltitude) / rate : 0;
|
||||
}
|
||||
|
||||
if (resolved < 0) resolved = 0;
|
||||
currentTime += resolved;
|
||||
currentAltitude += resolved * rate;
|
||||
|
||||
data.push({ x: currentTime, y: currentAltitude });
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function updateChart() {
|
||||
if (!chart) return;
|
||||
chart.data.datasets[0].data = chartData;
|
||||
chart.update('none');
|
||||
}
|
||||
|
||||
function handleDragEnd(
|
||||
_e: unknown,
|
||||
_datasetIndex: number,
|
||||
index: number,
|
||||
value: { x: number; y: number },
|
||||
) {
|
||||
if (index === 0) {
|
||||
updateChart();
|
||||
return;
|
||||
}
|
||||
const prevX = chartData[index - 1].x;
|
||||
const nextX = chartData[index + 1]?.x ?? Infinity;
|
||||
if (value.x <= prevX || value.x >= nextX) {
|
||||
updateChart();
|
||||
return;
|
||||
}
|
||||
|
||||
const newPoints: RateCurvePoint[] = JSON.parse(JSON.stringify(curve.rate_profile_data));
|
||||
const point = newPoints[index - 1];
|
||||
const prev = chartData[index - 1];
|
||||
|
||||
const newDuration = value.x - prev.x;
|
||||
const newAltDiff = value.y - prev.y;
|
||||
|
||||
if (point.alt_constraint !== -1) point.alt_constraint = Math.round(value.y);
|
||||
if (point.time_constraint !== -1) point.time_constraint = Math.round(newDuration);
|
||||
|
||||
point.rate = newDuration > 0 ? parseFloat((newAltDiff / newDuration).toFixed(2)) : 0;
|
||||
onUpdate(newPoints);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const ctx = canvasEl.getContext('2d');
|
||||
if (!ctx) return;
|
||||
chart = new ChartJS(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
label: 'Altitude profile',
|
||||
data: chartData,
|
||||
borderColor: 'rgb(75, 192, 192)',
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.5)',
|
||||
fill: false,
|
||||
pointRadius: 5,
|
||||
pointHoverRadius: 7,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: { type: 'linear', position: 'bottom', title: { display: true, text: 't, sec' } },
|
||||
y: { title: { display: true, text: 'altitude, m' } },
|
||||
},
|
||||
plugins: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
dragData: { round: 0, onDragEnd: handleDragEnd as any, dragX: true },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (c: TooltipItem<'line'>) => {
|
||||
const t = DateTime.fromSeconds(c.parsed.x ?? 0).toFormat('HH:mm:ss');
|
||||
return `${c.parsed.y?.toFixed(0)} m @ ${t}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (chart) updateChart();
|
||||
});
|
||||
|
||||
onDestroy(() => chart?.destroy());
|
||||
</script>
|
||||
|
||||
<div style="position: relative; height: 100%; min-height: 250px;">
|
||||
<canvas bind:this={canvasEl}></canvas>
|
||||
</div>
|
||||
338
src/lib/features/prediction/CurveEditor.svelte
Normal file
338
src/lib/features/prediction/CurveEditor.svelte
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Label,
|
||||
Input,
|
||||
Alert,
|
||||
Icon,
|
||||
Pagination,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
InputGroup,
|
||||
Table,
|
||||
} from '@sveltestrap/sveltestrap';
|
||||
import { TableHandler } from '@vincjo/datatables';
|
||||
import { onMount } from 'svelte';
|
||||
import { EditableCell, addToast, ConfirmationPrompt } from '$ui';
|
||||
import { profilesApi } from '$api';
|
||||
import type { RateCurvePoint, SavedFlightProfile } from '$domain';
|
||||
import { profilesStore } from './pointsStore';
|
||||
import CurveChart from './CurveChart.svelte';
|
||||
|
||||
interface Props {
|
||||
isOpen?: boolean;
|
||||
onClose?: () => void;
|
||||
onSave?: (p: SavedFlightProfile) => void;
|
||||
onSelectCurve?: (p: SavedFlightProfile) => void;
|
||||
showTable?: boolean;
|
||||
curve?: SavedFlightProfile | null;
|
||||
editor?: boolean;
|
||||
closeOnSave?: boolean;
|
||||
closeOnDelete?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
isOpen = $bindable(false),
|
||||
onClose = () => {},
|
||||
onSave = (_: SavedFlightProfile) => {},
|
||||
onSelectCurve = (_: SavedFlightProfile) => {},
|
||||
showTable = $bindable(false),
|
||||
curve = null,
|
||||
editor = false,
|
||||
closeOnSave = false,
|
||||
closeOnDelete = false,
|
||||
}: Props = $props();
|
||||
|
||||
let selectedCurve = $state<SavedFlightProfile | null>(curve);
|
||||
let draft = $state<SavedFlightProfile>({ id: 0, name: '', rate_profile_data: [] });
|
||||
let newPoint = $state<RateCurvePoint>({ order: 0, time_constraint: 0, alt_constraint: 0, rate: 0 });
|
||||
let isEditing = $state(editor);
|
||||
let alertText = $state('');
|
||||
let isConfirmationVisible = $state(false);
|
||||
|
||||
let table = $derived(new TableHandler($profilesStore, { rowsPerPage: 5 }));
|
||||
let search = $derived(table.createSearch(['name']));
|
||||
|
||||
$effect(() => {
|
||||
if (editor && curve) {
|
||||
selectedCurve = curve;
|
||||
draft = { ...curve, rate_profile_data: [...curve.rate_profile_data] };
|
||||
isEditing = true;
|
||||
}
|
||||
});
|
||||
|
||||
function sortByOrder() {
|
||||
draft.rate_profile_data = [...draft.rate_profile_data].sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (showTable && $profilesStore.length === 0) {
|
||||
try {
|
||||
profilesStore.set(await profilesApi.list());
|
||||
} catch {
|
||||
// ignore; saved profiles endpoint may not be active in dev
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export function openModal(withTable = false) {
|
||||
showTable = withTable;
|
||||
isOpen = true;
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen = false;
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleEdit(c: SavedFlightProfile) {
|
||||
selectedCurve = c;
|
||||
draft = { ...c, rate_profile_data: [...c.rate_profile_data] };
|
||||
isEditing = true;
|
||||
showTable = false;
|
||||
}
|
||||
|
||||
function confirmDelete(c: SavedFlightProfile) {
|
||||
selectedCurve = c;
|
||||
isConfirmationVisible = true;
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!selectedCurve) return;
|
||||
try {
|
||||
await profilesApi.delete(selectedCurve.id);
|
||||
profilesStore.update((items) => items.filter((p) => p.id !== selectedCurve!.id));
|
||||
if (closeOnDelete) close();
|
||||
} catch (err: unknown) {
|
||||
alertText = (err as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
const saved =
|
||||
draft.id && draft.id > 0 ? await profilesApi.update(draft) : await profilesApi.create(draft);
|
||||
profilesStore.update((items) => {
|
||||
const exists = items.some((p) => p.id === saved.id);
|
||||
return exists ? items.map((p) => (p.id === saved.id ? saved : p)) : [...items, saved];
|
||||
});
|
||||
addToast({ header: 'Curve saved', body: saved.name, color: 'success' });
|
||||
if (closeOnSave) close();
|
||||
onSave(saved);
|
||||
} catch (err: unknown) {
|
||||
alertText = (err as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
function validatePoint(point: RateCurvePoint): boolean {
|
||||
if (point.time_constraint <= 0 && point.time_constraint !== -1) {
|
||||
alertText = 'Time constraint invalid';
|
||||
return false;
|
||||
}
|
||||
if (point.alt_constraint < 0 && point.alt_constraint !== -1) {
|
||||
alertText = 'Altitude constraint invalid';
|
||||
return false;
|
||||
}
|
||||
if (point.alt_constraint === -1 && point.time_constraint === -1) {
|
||||
alertText = 'At least one constraint required';
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function addPoint() {
|
||||
if (!validatePoint(newPoint)) return;
|
||||
const maxOrder = draft.rate_profile_data.reduce((m, p) => Math.max(m, p.order), -1);
|
||||
draft.rate_profile_data = [...draft.rate_profile_data, { ...newPoint, order: maxOrder + 1 }];
|
||||
newPoint = { order: 0, time_constraint: 0, alt_constraint: 0, rate: 0 };
|
||||
alertText = '';
|
||||
}
|
||||
|
||||
function removePoint(index: number) {
|
||||
draft.rate_profile_data.splice(index, 1);
|
||||
draft.rate_profile_data.forEach((p, i) => (p.order = i));
|
||||
draft.rate_profile_data = [...draft.rate_profile_data];
|
||||
}
|
||||
|
||||
function movePoint(index: number, direction: number) {
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= draft.rate_profile_data.length) return;
|
||||
const t = draft.rate_profile_data[index].order;
|
||||
draft.rate_profile_data[index].order = draft.rate_profile_data[target].order;
|
||||
draft.rate_profile_data[target].order = t;
|
||||
sortByOrder();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
{isOpen}
|
||||
toggle={close}
|
||||
size="xl"
|
||||
fade={false}
|
||||
scrollable
|
||||
class={isConfirmationVisible ? 'modal-tinted' : ''}>
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{showTable ? 'Curves' : isEditing ? 'Edit Curve' : 'New Curve'}</h5>
|
||||
<Button close onclick={close} />
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{#if showTable}
|
||||
<InputGroup class="mb-2">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
bind:value={search.value}
|
||||
oninput={() => search.set()} />
|
||||
<Button
|
||||
onclick={() => {
|
||||
search.value = '';
|
||||
search.set();
|
||||
}}>
|
||||
<Icon name="x" />
|
||||
</Button>
|
||||
</InputGroup>
|
||||
<div bind:this={table.element} class="table-responsive">
|
||||
<Table class="table-sm mb-0">
|
||||
<thead>
|
||||
<tr><th style="width: 70%;">Name</th><th>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each table.rows as c (c.id)}
|
||||
<tr>
|
||||
<td>{c.name}</td>
|
||||
<td>
|
||||
<Button size="sm" color="primary" onclick={() => onSelectCurve(c)}>
|
||||
<Icon name="check-lg" />
|
||||
</Button>
|
||||
<Button size="sm" color="secondary" onclick={() => handleEdit(c)} class="ms-1">
|
||||
<Icon name="pencil" />
|
||||
</Button>
|
||||
<Button size="sm" color="danger" onclick={() => confirmDelete(c)} class="ms-1">
|
||||
<Icon name="trash" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
<Pagination size="sm">
|
||||
<PaginationItem>
|
||||
<PaginationLink previous onclick={() => table.setPage('previous')} />
|
||||
</PaginationItem>
|
||||
{#each table.pagesWithEllipsis as page}
|
||||
<PaginationItem active={table.currentPage === page}>
|
||||
<PaginationLink onclick={() => table.setPage(page)}>{page}</PaginationLink>
|
||||
</PaginationItem>
|
||||
{/each}
|
||||
<PaginationItem>
|
||||
<PaginationLink next onclick={() => table.setPage('next')} />
|
||||
</PaginationItem>
|
||||
</Pagination>
|
||||
{:else}
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<div class="mb-2">
|
||||
<Label class="small">Curve name</Label>
|
||||
<Input class="form-control-sm" type="text" bind:value={draft.name} required />
|
||||
</div>
|
||||
<h6>Points</h6>
|
||||
<Alert color="danger" isOpen={!!alertText} toggle={() => (alertText = '')} fade={false} class="mb-2">
|
||||
<Icon name="exclamation-triangle" class="me-2" />
|
||||
{alertText}
|
||||
</Alert>
|
||||
<div class="table-responsive small" style="max-height: 300px;">
|
||||
<table class="table table-sm border mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>t, sec</th>
|
||||
<th>alt, m</th>
|
||||
<th>rate, m/s</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each draft.rate_profile_data as point, i (point.order)}
|
||||
{@const isFirst = i === 0}
|
||||
{@const isLast = i === draft.rate_profile_data.length - 1}
|
||||
<tr>
|
||||
<td>
|
||||
<Button
|
||||
size="sm"
|
||||
class="p-0 border-0 bg-transparent"
|
||||
onclick={() => movePoint(i, -1)}
|
||||
disabled={isFirst}>
|
||||
<Icon name="chevron-up" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
class="p-0 border-0 bg-transparent"
|
||||
onclick={() => movePoint(i, 1)}
|
||||
disabled={isLast}>
|
||||
<Icon name="chevron-down" />
|
||||
</Button>
|
||||
</td>
|
||||
<EditableCell
|
||||
bind:value={point.time_constraint}
|
||||
onchange={() => (draft.rate_profile_data = [...draft.rate_profile_data])}
|
||||
valueSuffix=" s"
|
||||
emptyValue={-1} />
|
||||
<EditableCell
|
||||
bind:value={point.alt_constraint}
|
||||
onchange={() => (draft.rate_profile_data = [...draft.rate_profile_data])}
|
||||
valueSuffix=" m"
|
||||
emptyValue={-1} />
|
||||
<EditableCell
|
||||
bind:value={point.rate}
|
||||
onchange={() => (draft.rate_profile_data = [...draft.rate_profile_data])}
|
||||
valueSuffix=" m/s" />
|
||||
<td>
|
||||
<Button size="sm" color="danger" onclick={() => removePoint(i)} class="p-0 border-0 bg-transparent">
|
||||
<Icon name="trash" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr><td colspan="5" class="text-center text-muted">No points yet</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td><Input class="form-control-sm" type="number" placeholder="t" bind:value={newPoint.time_constraint} /></td>
|
||||
<td><Input class="form-control-sm" type="number" placeholder="alt" bind:value={newPoint.alt_constraint} /></td>
|
||||
<td><Input class="form-control-sm" type="number" placeholder="rate" bind:value={newPoint.rate} /></td>
|
||||
<td>
|
||||
<Button size="sm" color="success" onclick={addPoint} class="p-0 border-0 bg-transparent">Add</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<CurveChart curve={draft} onUpdate={(pts) => (draft.rate_profile_data = pts)} />
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-end">
|
||||
<Button color="success" size="sm" onclick={handleSave}>
|
||||
{isEditing ? 'Update' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmationPrompt
|
||||
bind:isOpen={isConfirmationVisible}
|
||||
title="Confirm deletion"
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
confirmVariant="danger"
|
||||
onconfirm={handleDelete}
|
||||
oncancel={() => (isConfirmationVisible = false)}>
|
||||
<p>Delete curve "{selectedCurve?.name}"?</p>
|
||||
</ConfirmationPrompt>
|
||||
140
src/lib/features/prediction/PointEditor.svelte
Normal file
140
src/lib/features/prediction/PointEditor.svelte
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { FormGroup, Label, Input } from '@sveltestrap/sveltestrap';
|
||||
import type { SavedPoint } from '$domain';
|
||||
import { pointsApi } from '$api';
|
||||
import { addToast, ListEditor } from '$ui';
|
||||
import type { ListEditorApi, ListEditorConfig } from '$ui';
|
||||
import { t } from '$i18n';
|
||||
import { pointsStore } from './pointsStore';
|
||||
|
||||
interface Props {
|
||||
isOpen?: boolean;
|
||||
onClose?: () => void;
|
||||
onSave?: (p: SavedPoint) => void;
|
||||
onSelectPoint?: (p: SavedPoint | null) => void;
|
||||
point?: SavedPoint | null;
|
||||
}
|
||||
|
||||
let {
|
||||
isOpen = $bindable(false),
|
||||
onClose = () => {},
|
||||
onSave = (_: SavedPoint) => {},
|
||||
onSelectPoint = (_: SavedPoint | null) => {},
|
||||
point = null,
|
||||
}: Props = $props();
|
||||
|
||||
let editorRef: ListEditor<SavedPoint> | null = $state(null);
|
||||
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (point && editorRef) editorRef.open(point);
|
||||
});
|
||||
|
||||
const config: ListEditorConfig = {
|
||||
showTable: true,
|
||||
closeOnSave: false,
|
||||
closeOnDelete: false,
|
||||
searchBy: ['name'],
|
||||
labels: {
|
||||
item: 'Point',
|
||||
itemGenitive: 'point',
|
||||
items: 'Points',
|
||||
add: 'Add',
|
||||
edit: 'Edit',
|
||||
save: 'Save',
|
||||
update: 'Update',
|
||||
delete: 'Delete',
|
||||
cancel: 'Cancel',
|
||||
close: 'Close',
|
||||
searchPlaceholder: 'Search by name...',
|
||||
},
|
||||
};
|
||||
|
||||
const api: ListEditorApi<SavedPoint> = {
|
||||
save: (p) => pointsApi.create(p),
|
||||
update: (p) => pointsApi.update(p),
|
||||
delete: (p) => pointsApi.delete(p.id),
|
||||
};
|
||||
|
||||
const factory = (): SavedPoint => ({ id: 0, name: '', lat: 0, lon: 0, alt: 0 });
|
||||
|
||||
let items = $state<SavedPoint[]>($pointsStore);
|
||||
|
||||
$effect(() => {
|
||||
items = $pointsStore;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
pointsStore.set(items);
|
||||
});
|
||||
|
||||
export function open(p: SavedPoint | null = null, showTable: boolean = config.showTable ?? true) {
|
||||
editorRef?.open(p, showTable);
|
||||
}
|
||||
</script>
|
||||
|
||||
<ListEditor
|
||||
bind:this={editorRef}
|
||||
bind:isOpen
|
||||
bind:items
|
||||
{api}
|
||||
{config}
|
||||
itemFactory={factory}
|
||||
onClose={() => onClose()}
|
||||
onSave={(p) => onSave(p)}
|
||||
onSelect={(p) => onSelectPoint(p)}>
|
||||
{#snippet tableHeader()}
|
||||
<tr>
|
||||
<th>{$t('points.name')}</th>
|
||||
<th>{$t('points.lat')}</th>
|
||||
<th>{$t('points.lon')}</th>
|
||||
<th>{$t('points.alt')}</th>
|
||||
<th class="fit"></th>
|
||||
</tr>
|
||||
{/snippet}
|
||||
|
||||
{#snippet tableRow({ row })}
|
||||
<td>{row.name}</td>
|
||||
<td>{row.lat.toFixed(5)} °</td>
|
||||
<td>{row.lon.toFixed(5)} °</td>
|
||||
<td>{row.alt} м</td>
|
||||
{/snippet}
|
||||
|
||||
{#snippet formFields({ item })}
|
||||
<div class="mb-2">
|
||||
<Label class="small">{$t('points.name')}</Label>
|
||||
<Input class="form-control-sm" type="text" bind:value={item.name} required />
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<FormGroup class="flex-grow-1">
|
||||
<Label class="small">{$t('points.lat')}</Label>
|
||||
<Input class="form-control-sm" type="number" step="any" bind:value={item.lat} required />
|
||||
<span class="form-text">{$t('points.degrees')}</span>
|
||||
</FormGroup>
|
||||
<FormGroup class="flex-grow-1">
|
||||
<Label class="small">{$t('points.lon')}</Label>
|
||||
<Input class="form-control-sm" type="number" step="any" bind:value={item.lon} required />
|
||||
<span class="form-text">{$t('points.degrees')}</span>
|
||||
</FormGroup>
|
||||
<FormGroup class="flex-grow-1">
|
||||
<Label class="small">{$t('points.alt')}</Label>
|
||||
<Input class="form-control-sm" type="number" step="any" bind:value={item.alt} required />
|
||||
<span class="form-text">{$t('points.metersAsl')}</span>
|
||||
</FormGroup>
|
||||
</div>
|
||||
{/snippet}
|
||||
</ListEditor>
|
||||
83
src/lib/features/prediction/ScenarioEditor.svelte
Normal file
83
src/lib/features/prediction/ScenarioEditor.svelte
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<script lang="ts">
|
||||
import { Modal, Button, Alert, Icon, Input, Label } from '@sveltestrap/sveltestrap';
|
||||
import { scenariosApi } from '$api';
|
||||
import { DEFAULT_SCENARIO, type FlightParameters, type SavedScenario } from '$domain';
|
||||
import { addToast } from '$ui';
|
||||
import { t } from '$i18n';
|
||||
|
||||
interface Props {
|
||||
isOpen?: boolean;
|
||||
onSaved?: (s: SavedScenario) => void;
|
||||
}
|
||||
|
||||
let { isOpen = $bindable(false), onSaved = (_: SavedScenario) => {} }: Props = $props();
|
||||
|
||||
let draft = $state<SavedScenario>({ ...DEFAULT_SCENARIO, id: 0 });
|
||||
let alertText = $state('');
|
||||
|
||||
export function openCreate(flightParameters: FlightParameters) {
|
||||
draft = {
|
||||
...DEFAULT_SCENARIO,
|
||||
id: 0,
|
||||
name: '',
|
||||
flight_parameters: flightParameters,
|
||||
};
|
||||
alertText = '';
|
||||
isOpen = true;
|
||||
}
|
||||
|
||||
export function openEdit(scenario: SavedScenario) {
|
||||
draft = { ...scenario };
|
||||
alertText = '';
|
||||
isOpen = true;
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
const saved =
|
||||
draft.id && draft.id > 0
|
||||
? await scenariosApi.update(draft)
|
||||
: await scenariosApi.create(draft);
|
||||
addToast({ header: $t('scenario.updated'), body: saved.name, color: 'success' });
|
||||
onSaved(saved);
|
||||
close();
|
||||
} catch (err: unknown) {
|
||||
alertText = (err as Error).message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal {isOpen} toggle={close} size="lg" fade={false} scrollable>
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{$t('scenario.title')}</h5>
|
||||
<button type="button" class="btn-close" onclick={close} aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{#if alertText}
|
||||
<Alert color="danger" isOpen={true} toggle={() => (alertText = '')} fade={false}>
|
||||
<Icon name="exclamation-triangle" class="me-2" />
|
||||
{alertText}
|
||||
</Alert>
|
||||
{/if}
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}}>
|
||||
<div class="mb-2">
|
||||
<Label class="small">{$t('scenario.select')}</Label>
|
||||
<Input class="form-control-sm" type="text" bind:value={draft.name} required />
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<Button type="submit" color="success" size="sm">{$t('editor.save')}</Button>
|
||||
<Button color="secondary" size="sm" type="button" onclick={close}>
|
||||
{$t('editor.close')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
189
src/lib/features/prediction/ScenarioPanel.svelte
Normal file
189
src/lib/features/prediction/ScenarioPanel.svelte
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
Button,
|
||||
FormGroup,
|
||||
Icon,
|
||||
Input,
|
||||
InputGroup,
|
||||
Label,
|
||||
} from '@sveltestrap/sveltestrap';
|
||||
import { CollapsibleCard, SelectSearchable, addToast } from '$ui';
|
||||
import { scenariosApi } from '$api';
|
||||
import { PREDICTION_MODES, type SavedScenario } from '$domain';
|
||||
import { workspacesStore, getActiveWorkspace } from '$features/workspaces';
|
||||
import { t } from '$i18n';
|
||||
import { scenariosStore } from './pointsStore';
|
||||
import ScenarioEditor from './ScenarioEditor.svelte';
|
||||
|
||||
let selectedScenarioId = $state<number>(-1);
|
||||
let editorRef: ScenarioEditor | null = $state(null);
|
||||
|
||||
let active = $derived(getActiveWorkspace($workspacesStore));
|
||||
let scenarioUnsaved = $derived.by(() => {
|
||||
if (!active) return false;
|
||||
const saved = $scenariosStore.find((s) => s.id === selectedScenarioId);
|
||||
if (!saved) return false;
|
||||
return (
|
||||
JSON.stringify(active.flightParameters) !== JSON.stringify(saved.flight_parameters)
|
||||
);
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
scenariosStore.set(await scenariosApi.list());
|
||||
} catch (err: unknown) {
|
||||
addToast({ header: $t('common.error'), body: (err as Error).message, color: 'danger' });
|
||||
}
|
||||
});
|
||||
|
||||
function handleApplySelected(showToast = true) {
|
||||
const scenario = $scenariosStore.find((s) => s.id === selectedScenarioId);
|
||||
if (!scenario || !active) {
|
||||
if (showToast) {
|
||||
addToast({
|
||||
header: $t('scenario.notFound'),
|
||||
body: $t('scenario.notFoundBody'),
|
||||
color: 'warning',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
workspacesStore.patch(active.id, {
|
||||
name: scenario.name,
|
||||
flightParameters: scenario.flight_parameters,
|
||||
});
|
||||
if (showToast) {
|
||||
addToast({
|
||||
header: $t('scenario.applied'),
|
||||
body: $t('scenario.appliedBody', { name: scenario.name }),
|
||||
color: 'success',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveCurrent() {
|
||||
if (!active) return;
|
||||
const existing = $scenariosStore.find((s) => s.id === selectedScenarioId);
|
||||
if (existing) {
|
||||
try {
|
||||
const updated = await scenariosApi.update({
|
||||
...existing,
|
||||
flight_parameters: active.flightParameters,
|
||||
});
|
||||
scenariosStore.update((list) =>
|
||||
list.map((s) => (s.id === updated.id ? updated : s)),
|
||||
);
|
||||
addToast({
|
||||
header: $t('scenario.updated'),
|
||||
body: $t('scenario.updatedBody', { name: updated.name }),
|
||||
color: 'success',
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
addToast({
|
||||
header: $t('scenario.updateError'),
|
||||
body: $t('scenario.updateErrorBody', { error: (err as Error).message }),
|
||||
color: 'danger',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
editorRef?.openCreate(active.flightParameters);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditorSaved(s: SavedScenario) {
|
||||
scenariosStore.update((list) => [...list.filter((x) => x.id !== s.id), s]);
|
||||
selectedScenarioId = s.id;
|
||||
}
|
||||
</script>
|
||||
|
||||
<CollapsibleCard title={$t('scenario.title')}>
|
||||
<FormGroup spacing="mb-2">
|
||||
<Label class="form-label">{$t('scenario.select')}</Label>
|
||||
<InputGroup size="sm">
|
||||
<SelectSearchable
|
||||
style="min-height: calc(1.5em + .5rem + 2px); padding: .25rem .5rem; font-size: .875rem;"
|
||||
id="sp-scenario"
|
||||
options={$scenariosStore.map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.name}${s.id === selectedScenarioId && scenarioUnsaved ? ` (${$t('scenario.modified')})` : ''}`,
|
||||
}))}
|
||||
bind:selected={selectedScenarioId}
|
||||
placeholder={$t('scenario.placeholder')}
|
||||
searchPlaceholder={$t('scenario.searchPlaceholder')}
|
||||
clearable={true}
|
||||
onChange={() => {
|
||||
if (!scenarioUnsaved) handleApplySelected(false);
|
||||
}} />
|
||||
<Button color="success" title={$t('scenario.apply')} onclick={() => handleApplySelected(true)}>
|
||||
<span>✓</span>
|
||||
</Button>
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
|
||||
<div class="d-flex gap-2 mb-2">
|
||||
<Button color="secondary flex-fill" size="sm">
|
||||
{$t('scenario.all')}
|
||||
<Icon name="journal-bookmark-fill" />
|
||||
</Button>
|
||||
<Button
|
||||
color="primary flex-fill"
|
||||
size="sm"
|
||||
onclick={handleSaveCurrent}
|
||||
disabled={!scenarioUnsaved && selectedScenarioId !== -1}>
|
||||
{selectedScenarioId !== -1 ? $t('scenario.update') : $t('scenario.save')}
|
||||
<Icon name="floppy2-fill" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
{#if active}
|
||||
<FormGroup spacing="mb-2">
|
||||
<Label class="form-label">{$t('scenario.mode')}</Label>
|
||||
<InputGroup size="sm">
|
||||
<Input type="select" class="form-control-sm">
|
||||
{#each PREDICTION_MODES as mode}
|
||||
<option value={mode}>{$t(`predictionMode.${mode}`)}</option>
|
||||
{/each}
|
||||
</Input>
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup spacing="mb-2">
|
||||
<Label class="form-label">{$t('scenario.model')}</Label>
|
||||
<InputGroup size="sm">
|
||||
<Input type="select" class="form-control-sm">
|
||||
<option>GFS (0.25°)</option>
|
||||
<option>GFS (0.5°)</option>
|
||||
</Input>
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup spacing="mb-2">
|
||||
<Label class="form-label">{$t('scenario.dataset')}</Label>
|
||||
<InputGroup size="sm">
|
||||
<Input type="select" class="form-control-sm">
|
||||
<option>{$t('scenario.datasetAuto')}</option>
|
||||
</Input>
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup spacing="mb-0">
|
||||
<Label class="form-label">{$t('scenario.export')}</Label>
|
||||
<InputGroup size="sm">
|
||||
<Input type="select" class="form-control-sm">
|
||||
<option>JSON</option>
|
||||
<option>CSV</option>
|
||||
<option>KML</option>
|
||||
</Input>
|
||||
<Button color="primary">
|
||||
<span>{$t('scenario.exportBtn')}</span>
|
||||
<Icon name="file-earmark-arrow-down" />
|
||||
</Button>
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
{/if}
|
||||
</CollapsibleCard>
|
||||
|
||||
<ScenarioEditor bind:this={editorRef} onSaved={handleEditorSaved} />
|
||||
7
src/lib/features/prediction/index.ts
Normal file
7
src/lib/features/prediction/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export { default as ControlPanel } from './ControlPanel.svelte';
|
||||
export { default as ScenarioPanel } from './ScenarioPanel.svelte';
|
||||
export { default as PointEditor } from './PointEditor.svelte';
|
||||
export { default as ScenarioEditor } from './ScenarioEditor.svelte';
|
||||
export { default as CurveEditor } from './CurveEditor.svelte';
|
||||
export { default as CurveChart } from './CurveChart.svelte';
|
||||
export { pointsStore, profilesStore, scenariosStore } from './pointsStore';
|
||||
11
src/lib/features/prediction/pointsStore.ts
Normal file
11
src/lib/features/prediction/pointsStore.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { writable } from 'svelte/store';
|
||||
import type { SavedPoint, SavedFlightProfile, SavedScenario } from '$domain';
|
||||
|
||||
/**
|
||||
* Session-scoped caches for the user's saved points, profiles, and scenarios.
|
||||
* Persisting these would fight the server as the source of truth, so they
|
||||
* are plain in-memory writables hydrated from the API on mount.
|
||||
*/
|
||||
export const pointsStore = writable<SavedPoint[]>([]);
|
||||
export const profilesStore = writable<SavedFlightProfile[]>([]);
|
||||
export const scenariosStore = writable<SavedScenario[]>([]);
|
||||
Loading…
Add table
Add a link
Reference in a new issue