feat: polish
This commit is contained in:
parent
2e6177fe74
commit
4bd927bb4e
137 changed files with 6357 additions and 137560 deletions
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue