Scenario system & point editor rework

This commit is contained in:
ThePetrovich 2025-07-05 23:04:29 +08:00
parent 7d01fce094
commit 19f969c18c
13 changed files with 1010 additions and 694 deletions

19
src/lib/api/scenarios.ts Normal file
View file

@ -0,0 +1,19 @@
/* API functions for SavedScenario */
import type { SavedScenario } from "$lib/types";
import { getAPI, postAPI, putAPI, deleteAPI } from "./base";
export function getSavedScenarios(): Promise<SavedScenario[]> {
return getAPI<SavedScenario[]>("/saved-templates/");
}
export function saveScenario(template: SavedScenario): Promise<SavedScenario> {
return postAPI<SavedScenario>("/saved-templates/", template);
}
export function updateScenario(template: SavedScenario): Promise<SavedScenario> {
return putAPI<SavedScenario>(`/saved-templates/${template.id}/`, template);
}
export function deleteScenario(id: number): Promise<void> {
return deleteAPI<void>(`/saved-templates/${id}/`);
}

View file

@ -1,19 +0,0 @@
/* API functions for SavedScenarioTemplate */
import type { SavedScenarioTemplate } from "$lib/types";
import { getAPI, postAPI, putAPI, deleteAPI } from "./base";
export function getSavedScenarioTemplates(): Promise<SavedScenarioTemplate[]> {
return getAPI<SavedScenarioTemplate[]>("/saved-templates/");
}
export function saveScenarioTemplate(template: SavedScenarioTemplate): Promise<SavedScenarioTemplate> {
return postAPI<SavedScenarioTemplate>("/saved-templates/", template);
}
export function updateScenarioTemplate(template: SavedScenarioTemplate): Promise<SavedScenarioTemplate> {
return putAPI<SavedScenarioTemplate>(`/saved-templates/${template.id}/`, template);
}
export function deleteScenarioTemplate(id: number): Promise<void> {
return deleteAPI<void>(`/saved-templates/${id}/`);
}

View file

@ -1,199 +1,216 @@
<script lang="ts">
/*
Component Naming and Style Conventions:
1. **State Variables (`$state`)**:
- Use camelCase.
- No special prefixes are needed as `$state` already marks them as reactive state.
- Example: `let isCollapsed = $state(false);`
2. **Derived State (`$derived`)**:
- Use camelCase.
- No special prefixes are needed as `$derived` already marks them as reactive derived state.
- Example: `let inputLat = $derived(...)`
3. **Component Instance References**:
- Use camelCase and suffix with `Ref`.
- Example: `let PointEditorRef: PointEditor | null = null;`
4. **Event Handlers**:
- Use `handle<EventName>` or `handle<Element><Event>` naming.
- Example: `function handleToggleCollapse() { ... }`
5. **Props**:
- For event callback props, use `on<EventName>`.
- Example: `let { onSelectOnMapClick = () => {} }: Props = $props();`
6. **HTML Element IDs**:
- Use kebab-case.
- Prefix with a component-specific identifier to avoid global scope conflicts.
- Example: `id="cp-start-time"` (cp for ControlPanel)
7. **Stores**:
- Use PascalCase and suffix with `Store`.
- Example: `import { SavedPointsStore } from '$lib/stores';`
- The reactive Svelte store prefix `$` is used as standard.
*/
import { onMount } from "svelte";
import {
Card,
CardHeader,
CardBody,
Button,
Card,
CardBody,
CardHeader,
FormGroup,
Label,
Icon,
Input,
InputGroup,
InputGroupText,
Icon,
Label,
} from "@sveltestrap/sveltestrap";
import PointListModal from "$lib/components/PointListModal.svelte";
import { getSavedPoints, updatePoint } from "$lib/api/points";
import { addToast } from "$lib/components/Toast.svelte";
import PointEditor from "$lib/components/PointEditor.svelte";
import SelectSearchable from "$lib/components/SelectSearchable.svelte";
import { getForecast } from "$lib/prediction";
import type { FlightParameters, ProfileName, ProfileIdentifier, SavedPoint } from "$lib/types";
import { PROFILE_MAP, PROFILE_NAMES } from "$lib/types";
import { SavedPointsStore, FlightParametersStore, writeLocalStorage } from "$lib/stores";
import { onMount } from "svelte";
import { addToast } from "$lib/components/Toast.svelte";
import { getSavedPoints, savePoint, updatePoint, deletePoint } from "$lib/api/points";
// TODO: Move to $lib/utils/datetime.js
// function getCurrentDateTime() {
// const now = new Date();
// return {
// date: now.toISOString().split("T")[0],
// time: now.toISOString().split("T")[1].split(".")[0]
// };
// }
// TODO: Move to $lib/utils/validation.js
// function validateCoordinates(lat: string, lng: string): { isValid: boolean; lat?: number; lng?: number } {
// const latNum = parseFloat(lat);
// const lngNum = parseFloat(lng);
// if (isNaN(latNum) || isNaN(lngNum)) {
// return { isValid: false };
// }
// return { isValid: true, lat: latNum, lng: lngNum };
// }
// TODO: Move to $lib/components/PredictionService.js
// async function handlePredictionRequest(params: FlightParameters) {
// try {
// const response = await getForecast(params);
// // Emit event or update store
// return response;
// } catch (error) {
// console.error("Error fetching forecast:", error);
// throw error;
// }
// }
import { FlightParametersStore, SavedPointsStore, writeLocalStorage } from "$lib/stores";
import { PROFILE_MAP, type FlightParameters, type ProfileName, type SavedPoint } from "$lib/types";
// Props
interface Props {
handleClickSelectOnMap?: () => void;
onSelectOnMapClick?: () => void;
}
let {
handleClickSelectOnMap = () => console.log("Select on map clicked"),
}: Props = $props();
let { onSelectOnMapClick = () => console.log("Select on map clicked") }: Props = $props();
// State
let isCollapsed = $state(false);
let pointListModal: PointListModal | null = null;
// Initialize date/time
let isPointDirty = $state(false);
const now = new Date();
let startDate = $state(now.toISOString().split("T")[0]);
let startTime = $state(now.toISOString().split("T")[1].split(".")[0]);
let selectedPoint = $state($FlightParametersStore.start_point); // Default to custom point
let selectedPointId = $state($FlightParametersStore.start_point);
// Coordinate inputs
let inputLat = $derived($FlightParametersStore.start_point === -1
? $FlightParametersStore.launch_latitude.toFixed(6)
: $SavedPointsStore.find(point => point.id === $FlightParametersStore.start_point)?.lat.toFixed(6) || "0.000000");
let inputLng = $derived($FlightParametersStore.start_point === -1
? $FlightParametersStore.launch_longitude.toFixed(6)
: $SavedPointsStore.find(point => point.id === $FlightParametersStore.start_point)?.lon.toFixed(6) || "0.000000");
let inputAlt = $derived($FlightParametersStore.start_point === -1
? $FlightParametersStore.launch_altitude.toFixed(2)
: $SavedPointsStore.find(point => point.id === $FlightParametersStore.start_point)?.alt.toFixed(2) || "0.00");
// Component References
let PointEditorRef: PointEditor | null = null;
function setToCustomOnChange() {
if ($FlightParametersStore.start_point !== -1) {
$FlightParametersStore.start_point = -1;
}
}
// Derived State
let inputLat = $derived(
$FlightParametersStore.start_point === -1
? $FlightParametersStore.launch_latitude.toFixed(6)
: $SavedPointsStore.find((point) => point.id === $FlightParametersStore.start_point)?.lat.toFixed(6) ||
"0.000000",
);
let inputLng = $derived(
$FlightParametersStore.start_point === -1
? $FlightParametersStore.launch_longitude.toFixed(6)
: $SavedPointsStore.find((point) => point.id === $FlightParametersStore.start_point)?.lon.toFixed(6) ||
"0.000000",
);
let inputAlt = $derived(
$FlightParametersStore.start_point === -1
? $FlightParametersStore.launch_altitude.toFixed(2)
: $SavedPointsStore.find((point) => point.id === $FlightParametersStore.start_point)?.alt.toFixed(2) ||
"0.00",
);
function applySeletedPoint() {
if (selectedPoint && selectedPoint !== -1) {
$FlightParametersStore.start_point = selectedPoint;
}
}
function saveCurrentPoint() {
if (selectedPoint !== -1) {
const point = $SavedPointsStore.find(p => p.id === selectedPoint);
if (point) {
updatePoint(point)
.then((updatedPoint) => {
$SavedPointsStore = $SavedPointsStore.map((p) => (p.id === updatedPoint.id ? updatedPoint : p));
SavedPointsStore.set($SavedPointsStore);
addToast({
header: "Точка обновлена",
body: `Точка "${updatedPoint.name}" успешно обновлена.`,
color: "success",
});
})
.catch((error) => {
addToast({
header: "Ошибка обновления точки",
body: `Ошибка при обновлении точки: ${error.message}`,
color: "danger",
});
console.error("Ошибка при обновлении точки:", error);
// Lifecycle Hooks
onMount(() => {
getSavedPoints()
.then((points) => SavedPointsStore.set(points))
.catch((error) => {
addToast({
header: "Error Loading Points",
body: `Failed to load saved points: ${error.message}`,
color: "danger",
});
return [];
});
selectedPointId = $FlightParametersStore.start_point;
});
function handleApplySelectedPoint() {
if (selectedPointId && selectedPointId !== -1) {
$FlightParametersStore.start_point = selectedPointId;
isPointDirty = false;
}
}
export function handleSelectPointInModal(point: SavedPoint) {
console.log("Selected point from modal:", point);
selectedPointId = point.id;
$FlightParametersStore.start_point = selectedPointId;
isPointDirty = false;
}
function handleSaveCurrentPoint() {
if (selectedPointId !== -1) {
const point = $SavedPointsStore.find((p) => p.id === selectedPointId);
if (point) {
point.lat = parseFloat(inputLat);
point.lon = parseFloat(inputLng);
point.alt = parseFloat(inputAlt);
updatePoint(point)
.then((updatedPoint) => {
$SavedPointsStore = $SavedPointsStore.map((p) => (p.id === updatedPoint.id ? updatedPoint : p));
SavedPointsStore.set($SavedPointsStore);
addToast({
header: "Точка обновлена",
body: `Точка "${updatedPoint.name}" успешно обновлена.`,
color: "success",
});
isPointDirty = false;
})
.catch((error) => {
addToast({
header: "Ошибка обновления точки",
body: `Ошибка при обновлении точки: ${error.message}`,
color: "danger",
});
console.error("Ошибка при обновлении точки:", error);
});
}
} else {
pointListModal?.openModalAndCreate(null, {
id: 0,
name: `Новая точка ${new Date().toLocaleString()}`,
lat: parseFloat(inputLat),
lon: parseFloat(inputLng),
alt: parseFloat(inputAlt),
}, true, onModalSave);
PointEditorRef?.openModalAndCreate(
null,
{
id: 0,
name: `Новая точка ${new Date().toLocaleString()}`,
lat: parseFloat(inputLat),
lon: parseFloat(inputLng),
alt: parseFloat(inputAlt),
},
true,
false,
handleModalSave,
);
}
}
function onModalSave(savedPoint: SavedPoint) {
function handleModalSave(savedPoint: SavedPoint) {
if (savedPoint) {
$FlightParametersStore.start_point = savedPoint.id;
selectedPoint = savedPoint.id;
setToCustomOnChange();
selectedPointId = savedPoint.id;
isPointDirty = false;
}
}
function handleClickPointListModal() {
pointListModal?.openModal();
function handlePointEditorOpen() {
PointEditorRef?.openModal(true);
}
// function applyCoordinatesFromInput() {
// const lat = parseFloat(inputLat);
// const lng = parseFloat(inputLng);
// const alt = parseFloat(inputAlt);
// if (!isNaN(lat) && !isNaN(lng)) {
// $FlightParametersStore.launch_latitude = lat;
// $FlightParametersStore.launch_longitude = lng;
// $FlightParametersStore.launch_altitude = alt || 0; // Default to 0 if alt is NaN
// } else {
// console.error("Invalid coordinate input");
// // TODO: Show validation error to user
// }
// }
async function handleGetPrediction() {
$FlightParametersStore.launch_datetime = `${startDate}T${startTime}Z`;
async function handlePredictionRequest() {
$FlightParametersStore.launch_latitude = parseFloat(inputLat);
$FlightParametersStore.launch_longitude = parseFloat(inputLng);
$FlightParametersStore.launch_altitude = parseFloat(inputAlt);
writeLocalStorage<FlightParameters>("flightParameters", $FlightParametersStore);
getForecast($FlightParametersStore)
.then((data) => {
console.log("Forecast request successful:", data);
addToast({
header: "Forecast Request",
body: "Forecast request successful!",
color: "success",
});
// Handle the response data as needed
})
.catch((error) => {
console.error("Error getting forecast:", error);
addToast({
header: "Forecast Error",
body: `Error getting forecast: ${error.message}`,
color: "danger",
});
try {
const data = await getForecast($FlightParametersStore, `${startDate}T${startTime}Z`);
console.log("Forecast request successful:", data);
addToast({
header: "Forecast Request",
body: "Forecast request successful!",
color: "success",
});
} catch (error: any) {
console.error("Error getting forecast:", error);
addToast({
header: "Forecast Error",
body: `Error getting forecast: ${error.message}`,
color: "danger",
});
}
}
function toggleCollapse() {
function handleToggleCollapse() {
isCollapsed = !isCollapsed;
}
// Exported functions for parent components
// Public API
export function updateLaunchPosition(lat: number, lng: number) {
$FlightParametersStore.launch_latitude = lat;
$FlightParametersStore.launch_longitude = lng;
inputLat = lat.toFixed(6);
inputLng = lng.toFixed(6);
setToCustomOnChange();
isPointDirty = true;
}
export function getSelectedProfile() {
@ -215,21 +232,6 @@
export function togglePanel() {
isCollapsed = !isCollapsed;
}
onMount(() => {
getSavedPoints()
.then((points) => SavedPointsStore.set(points))
.catch((error) => {
addToast({
header: "Error Loading Points",
body: `Failed to load saved points: ${error.message}`,
color: "danger",
});
return [];
});
selectedPoint = $FlightParametersStore.start_point;
console.log("ControlPanel mounted", selectedPoint);
});
</script>
<Card>
@ -240,11 +242,11 @@
<button
type="button"
class="d-flex w-100 justify-content-between align-items-center bg-transparent border-0 p-0"
aria-label="Свернуть/развернуть параметры прогнозирования"
onclick={toggleCollapse}
aria-label="Свернуть/развернуть условия прогнозирования"
onclick={handleToggleCollapse}
>
<b class="card-title mb-0 text-white p-0">Параметры прогнозирования</b>
<Button class="p-0" size="sm" color="primary" onclick={toggleCollapse}>
<b class="card-title mb-0 text-white p-0">Условия прогнозирования</b>
<Button class="p-0" size="sm" color="primary" onclick={handleToggleCollapse}>
{#if isCollapsed}
<Icon name="caret-left-fill" class="text-white" />
{:else}
@ -258,54 +260,47 @@
<CardBody>
<div class="d-flex gap-2">
<FormGroup class="flex-fill w-50" spacing="mb-2">
<Label for="startTime" class="form-label">Время старта (UTC):</Label>
<Input type="time" id="startTime" class="form-control-sm" bind:value={startTime} step="1" />
<Label for="cp-start-time" class="form-label">Время старта (UTC):</Label>
<Input type="time" id="cp-start-time" class="form-control-sm" bind:value={startTime} step="1" />
</FormGroup>
<FormGroup class="flex-fill w-50" spacing="mb-2">
<Label for="startDate" class="form-label">Дата старта:</Label>
<Input type="date" id="startDate" class="form-control-sm" bind:value={startDate} />
<Label for="cp-start-date" class="form-label">Дата старта:</Label>
<Input type="date" id="cp-start-date" class="form-control-sm" bind:value={startDate} />
</FormGroup>
</div>
<FormGroup spacing="mb-2">
<Label for="flightProfile" class="form-label">Профиль полета:</Label>
<Label for="cp-flight-profile" class="form-label">Профиль полета:</Label>
<InputGroup size="sm">
<Input type="select" id="flightProfile" bind:value={$FlightParametersStore.profile}>
<optgroup label="Стандартные профили">
{#each Object.keys(PROFILE_MAP) as profileName}
<option value={PROFILE_MAP[profileName as ProfileName]}>{profileName}</option>
{/each}
</optgroup>
<optgroup label="Пользовательские профили">
<option>Custom</option>
</optgroup>
<Input type="select" id="cp-flight-profile" bind:value={$FlightParametersStore.profile}>
{#each Object.keys(PROFILE_MAP) as profileName}
<option value={PROFILE_MAP[profileName as ProfileName]}>{profileName}</option>
{/each}
</Input>
<Button
color="secondary"
size="sm"
title="Edit profile"
disabled={$FlightParametersStore.profile !== "custom_profile"}
>
<span>Редакт.</span>
<Icon name="gear-fill" />
</Button>
</InputGroup>
</FormGroup>
<FormGroup spacing="mb-2">
<Label for="startPoint" class="form-label">Точка старта:</Label>
<Label for="cp-start-point" class="form-label">Точка старта:</Label>
<InputGroup size="sm">
<div class="position-relative flex-grow-1">
<SelectSearchable
style="min-height: calc(1.5em + .5rem + 2px); padding: .25rem .5rem; font-size: .875rem;"
id="startPoint"
bind:selected={selectedPoint}
options={$SavedPointsStore.map(point => ({
id="cp-start-point"
bind:selected={selectedPointId}
options={$SavedPointsStore.map((point) => ({
value: point.id,
label: point.name,
label:
point.name +
`${point.id == $FlightParametersStore.start_point && isPointDirty ? " (изменено)" : ""}`,
}))}
placeholder="Выберите точку старта"
searchPlaceholder="Поиск точки..."
placeholder="Новая точка..."
searchPlaceholder="Поиск по точкам..."
on:change={() => {
if (!isPointDirty) {
$FlightParametersStore.start_point = selectedPointId;
}
}}
/>
<Button
size="sm"
@ -313,17 +308,17 @@
class="position-absolute top-50 end-0 translate-middle-y rounded-circle d-flex align-items-center justify-content-center"
style="width: 16px; height: 16px; border: none; background: var(--bs-secondary); color: var(--bs-white); z-index: 10; margin-right: 2rem;"
on:click={() => {
selectedPoint = -1;
selectedPointId = -1;
$FlightParametersStore.start_point = -1;
}}
disabled={selectedPoint === -1}
disabled={selectedPointId === -1}
>
<Icon name="x" style="font-size: 16px;" />
</Button>
</div>
<Button color="success" size="sm" onclick={applySeletedPoint} title="Apply Coordinates">
</Button>
<Button color="success" size="sm" onclick={handleApplySelectedPoint} title="Apply Coordinates"
>✓</Button
>
</InputGroup>
</FormGroup>
@ -331,7 +326,7 @@
<Button
color="secondary flex-fill"
size="sm"
onclick={handleClickPointListModal}
onclick={handlePointEditorOpen}
title="Открыть список точек"
>
Все точки
@ -341,54 +336,60 @@
<Button
color="primary flex-fill"
size="sm"
onclick={saveCurrentPoint}
onclick={handleSaveCurrentPoint}
title="Сохранить текущие координаты"
disabled={!isPointDirty && selectedPointId !== -1}
>
Сохранить точку
{selectedPointId !== -1 ? "Обновить точку" : "Сохранить точку"}
<Icon name="floppy2-fill" />
</Button>
</div>
<FormGroup spacing="mb-2">
<Label for="latitude" class="form-label">Широта/Долгота:</Label>
<Label for="cp-latitude" class="form-label">Широта/Долгота:</Label>
<InputGroup size="sm">
<Input
id="latitude"
id="cp-latitude"
type="text"
bind:value={inputLat}
placeholder="Latitude"
onchange={setToCustomOnChange}
on:change={() => {
isPointDirty = true;
}}
/>
<InputGroupText>/</InputGroupText>
<Input
id="longitude"
id="cp-longitude"
type="text"
bind:value={inputLng}
placeholder="Longitude"
onchange={setToCustomOnChange}
on:change={() => {
isPointDirty = true;
}}
/>
<Button color="secondary" size="sm" onclick={handleClickSelectOnMap}>
<Button color="secondary" size="sm" onclick={onSelectOnMapClick}>
<Icon name="geo-alt-fill" />
</Button>
</InputGroup>
</FormGroup>
<div class="d-flex gap-2">
<FormGroup class="flex-fill w-50" spacing="mb-2">
<Label for="startHeight" class="form-label">Высота старта (м):</Label>
<Label for="cp-start-height" class="form-label">Высота старта (м):</Label>
<Input
type="number"
id="startHeight"
id="cp-start-height"
class="form-control-sm"
onchange={setToCustomOnChange}
on:change={() => {
isPointDirty = true;
}}
bind:value={inputAlt}
/>
</FormGroup>
<FormGroup class="flex-fill w-50" spacing="mb-2">
<Label for="burstAltitude" class="form-label">Высота разрыва (м):</Label>
<Label for="cp-burst-altitude" class="form-label">Высота разрыва (м):</Label>
<Input
type="number"
id="burstAltitude"
id="cp-burst-altitude"
class="form-control-sm"
bind:value={$FlightParametersStore.burst_altitude}
/>
@ -398,31 +399,59 @@
{#if $FlightParametersStore.profile !== "custom_profile"}
<div class="mb-2 d-flex gap-2">
<FormGroup class="flex-fill w-50" spacing="mb-2">
<Label for="ascentRate" class="form-label">Скорость подъема (м/с):</Label>
<Label for="cp-ascent-rate" class="form-label">Скорость подъема (м/с):</Label>
<Input
type="number"
id="ascentRate"
id="cp-ascent-rate"
class="form-control-sm"
bind:value={$FlightParametersStore.ascent_rate}
/>
</FormGroup>
<FormGroup class="flex-fill w-50" spacing="mb-2">
<Label for="descentRate" class="form-label">Скорость спуска (м/с):</Label>
<Label for="cp-descent-rate" class="form-label">Скорость спуска (м/с):</Label>
<Input
type="number"
id="descentRate"
id="cp-descent-rate"
class="form-control-sm"
bind:value={$FlightParametersStore.descent_rate}
/>
</FormGroup>
</div>
{:else}
<FormGroup spacing="mb-2">
<Label for="cp-flight-profile" class="form-label">Пользовательский профиль:</Label>
<InputGroup size="sm">
<SelectSearchable
id="cp-flight-profile"
bind:selected={$FlightParametersStore.profile}
options={Object.keys(PROFILE_MAP).map((profileName) => ({
// stub, replace with actual profiles
value: PROFILE_MAP[profileName as ProfileName],
label: profileName,
}))}
placeholder="Выберите профиль..."
searchPlaceholder="Поиск профилей..."
on:change={() => {
$FlightParametersStore.profile = $FlightParametersStore.profile;
}}
/>
<Button
color="secondary"
size="sm"
title="Edit profile"
disabled={$FlightParametersStore.profile !== "custom_profile"}
>
<span>Редакт.</span>
<Icon name="gear-fill" />
</Button>
</InputGroup>
</FormGroup>
{/if}
<div class="d-grid gap-1">
<Button color="outline-secondary" size="sm">Показать график высоты</Button>
<Button size="sm" color="primary" onclick={handleGetPrediction}>Выполнить прогнозирование</Button>
<Button size="sm" color="primary" onclick={handlePredictionRequest}>Выполнить прогнозирование</Button>
</div>
</CardBody>
{/if}
</Card>
<PointListModal bind:this={pointListModal} />
<PointEditor bind:this={PointEditorRef} onSelectPoint={handleSelectPointInModal} />

View file

@ -11,6 +11,7 @@
Pagination,
PaginationItem,
PaginationLink,
InputGroup,
} from "@sveltestrap/sveltestrap";
import { onMount } from "svelte";
import { addToast } from "$lib/components/Toast.svelte";
@ -20,39 +21,116 @@
import { getSavedPoints, savePoint, updatePoint, deletePoint } from "$lib/api/points";
// Props
let { isOpen = $bindable(false), onClose = () => {}, onChange = () => {}, point} = $props();
let {
isOpen = $bindable(false),
onClose = () => {},
onSave = (p: SavedPoint) => {},
onSelectPoint = (p: SavedPoint) => {},
showTable = false,
point = null,
editor = false,
closeOnSave = false,
closeOnDelete = false,
} = $props();
// Runes
let selectedPoint = $derived<SavedPoint>(point);
let selectedPoint = $derived<SavedPoint | null>(point);
let newPoint = $state<SavedPoint>({ id: 0, name: "", lat: 0, lon: 0, alt: 0 });
let isEditing = $state(editor);
let isAlertVisible = $state(false);
let isConfirmationVisible = $state(false);
let alertText = $state("");
let closeOnSave_ = $state(closeOnSave);
// Table handler
let table = $derived(new TableHandler($SavedPointsStore, { rowsPerPage: 10 }));
let search = $derived(table.createSearch(["name"]));
$effect(() => {
onChange();
if (showTable) {
getSavedPoints().then((pts) => {
$SavedPointsStore = pts;
SavedPointsStore.set($SavedPointsStore);
});
}
if (editor && point) {
selectedPoint = point;
newPoint = { ...point };
isEditing = true;
}
});
// On mount, fetch points
onMount(async () => {
if (showTable) {
const pts = await getSavedPoints();
$SavedPointsStore = pts;
SavedPointsStore.set($SavedPointsStore);
}
});
// Modal controls
export function openModal() {
export function openModal(table_: boolean = false) {
showTable = table_;
isOpen = true;
}
export function openModalAndCreate(
point: SavedPoint | null = null,
coordinates: SavedPoint = { id: 0, name: "", lat: 0, lon: 0, alt: 0 },
close: boolean = false,
table_: boolean = false,
onSaveCallback: (point: SavedPoint) => void = () => {},
) {
if (point) {
selectedPoint = point;
newPoint = { ...point };
isEditing = true;
} else {
selectedPoint = null;
newPoint = coordinates || { id: 0, name: "", lat: 0, lon: 0, alt: 0 };
isEditing = false;
}
showTable = table_;
isOpen = true;
closeOnSave_ = close;
onSave = onSaveCallback;
}
function closeModal() {
isOpen = false;
if (closeOnSave_ != closeOnSave) {
closeOnSave = closeOnSave_;
}
onClose();
}
function handleDeletePoint(point: SavedPoint) {
function handleEditPoint(point: SavedPoint) {
selectedPoint = point;
newPoint = { ...point };
isEditing = true;
}
function confirmDeletePoint(point: SavedPoint) {
selectedPoint = point;
isConfirmationVisible = true;
}
function handleDeletePoint(point: SavedPoint | null) {
if (!point) return;
deletePoint(point.id)
.then(() => {
$SavedPointsStore = $SavedPointsStore.filter((p) => p.id !== point.id);
SavedPointsStore.set($SavedPointsStore);
resetForm();
addToast({
header: "Точка удалена",
body: `Точка "${point.name}" успешно удалена.`,
color: "success",
});
if (closeOnDelete) {
closeModal();
}
})
.catch((error) => {
showAlert(`Ошибка при удалении точки: ${error.message}`);
@ -60,21 +138,47 @@
});
}
function handleSavePoint() {
updatePoint(selectedPoint)
.then((updatedPoint) => {
$SavedPointsStore = $SavedPointsStore.map((p) => (p.id === updatedPoint.id ? updatedPoint : p));
SavedPointsStore.set($SavedPointsStore);
resetForm();
addToast({
header: "Точка обновлена",
body: `Точка "${updatedPoint.name}" успешно обновлена.`,
color: "success",
export function handleSavePoint() {
if (isEditing && selectedPoint) {
updatePoint(newPoint)
.then((updatedPoint) => {
$SavedPointsStore = $SavedPointsStore.map((p) => (p.id === updatedPoint.id ? updatedPoint : p));
SavedPointsStore.set($SavedPointsStore);
resetForm();
addToast({
header: "Точка обновлена",
body: `Точка "${updatedPoint.name}" успешно обновлена.`,
color: "success",
});
if (closeOnSave_) {
closeModal();
}
onSave(updatedPoint);
})
.catch((error) => {
showAlert(`Ошибка при обновлении точки: ${error.message}`);
});
})
.catch((error) => {
showAlert(`Ошибка при обновлении точки: ${error.message}`);
});
} else {
savePoint(newPoint)
.then((savedPoint) => {
$SavedPointsStore = [...$SavedPointsStore, savedPoint];
SavedPointsStore.set($SavedPointsStore);
resetForm();
addToast({
header: "Точка сохранена",
body: `Точка "${savedPoint.name}" успешно сохранена.`,
color: "success",
});
if (closeOnSave_) {
closeModal();
}
onSave(savedPoint);
})
.catch((error) => {
showAlert(`Ошибка при сохранении точки: ${error.message}`);
console.error("Ошибка при сохранении точки:", error);
});
}
}
export function showAlert(message: string) {
@ -88,19 +192,111 @@
}
export function resetForm() {
selectedPoint = null;
newPoint = { id: 0, name: "", lat: 0, lon: 0, alt: 0 };
isEditing = false;
hideAlert();
closeModal();
}
</script>
<Modal {isOpen} toggle={closeModal} size="lg" fade={false} backdrop={true} scrollable class={ isConfirmationVisible ? "modal-tinted" : ""}>
<Modal
{isOpen}
toggle={closeModal}
size="lg"
fade={false}
backdrop={true}
scrollable
class={isConfirmationVisible ? "modal-tinted" : ""}
>
<div class="modal-header">
<h5 class="modal-title">Редактирование точки</h5>
<h5 class="modal-title">{isEditing ? "Редактирование точки" : showTable ? "Сохраненные точки" : "Добавить новую точку"}</h5>
<button type="button" class="btn-close" onclick={closeModal} aria-label="Close"></button>
</div>
<div class="modal-body">
{#if showTable}
<div class="position-relative mb-2">
<Input
type="text"
class="form-control-sm pe-5"
placeholder="Поиск по названию..."
bind:value={search.value}
oninput={() => search.set()}
/>
<Button
size="sm"
color="white"
class="position-absolute top-50 end-0 translate-middle-y me-2 rounded-circle d-flex align-items-center justify-content-center"
style="width: 16px; height: 16px; border: none; background: var(--bs-secondary); color: var(--bs-white);"
onclick={() => {
search.value = "";
search.set();
}}
disabled={!search.value}
>
<Icon name="x" style="font-size: 16px;" />
</Button>
</div>
<div bind:this={table.element} class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Название точки</th>
<th>Широта</th>
<th>Долгота</th>
<th>Высота</th>
<th class="fit">Действия</th>
</tr>
</thead>
<tbody>
{#each table.rows as row}
<tr>
<td>{row.name}</td>
<td>{row.lat} °</td>
<td>{row.lon} °</td>
<td>{row.alt} м</td>
<td class="fit">
<InputGroup size="sm" class="d-flex gap-1 flex-nowrap">
<Button
color="success"
size="sm"
onclick={() => {onSelectPoint(row); closeModal();}}>
</Button>
<Button color="primary" size="sm" onclick={() => handleEditPoint(row)}>
<Icon name="pencil" />
</Button>
<Button color="danger" size="sm" onclick={() => confirmDeletePoint(row)}>
<Icon name="trash" />
</Button>
</InputGroup>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<Pagination aria-label="Page navigation" 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>
{/if}
{#if showTable && (isEditing || newPoint.lat || newPoint.lon)}<hr />{/if}
<!-- Form for adding/editing points -->
<div>
<h5>{"Редактирование точки"}</h5>
{#if showTable}
<h5>{isEditing ? "Редактирование точки" : "Добавить новую точку"}</h5>
{/if}
<Alert
color="danger"
isOpen={isAlertVisible}
@ -119,7 +315,7 @@
>
<div class="mb-2">
<Label for="name" class="small">Название точки:</Label>
<Input class="form-control-sm" type="text" id="name" bind:value={selectedPoint.name} required />
<Input class="form-control-sm" type="text" id="name" bind:value={newPoint.name} required />
</div>
<div class="d-flex gap-2">
<FormGroup class="flex-grow-1">
@ -129,7 +325,7 @@
type="number"
step="any"
id="lat"
bind:value={selectedPoint.lat}
bind:value={newPoint.lat}
required
/>
<span class="form-text">Градусы</span>
@ -141,7 +337,7 @@
type="number"
step="any"
id="lon"
bind:value={selectedPoint.lon}
bind:value={newPoint.lon}
required
/>
<span class="form-text">Градусы</span>
@ -153,7 +349,7 @@
type="number"
step="any"
id="alt"
bind:value={selectedPoint.alt}
bind:value={newPoint.alt}
required
/>
<span class="form-text">Метры над ур. моря</span>
@ -161,18 +357,29 @@
</div>
<div class="d-grid gap-2 d-md-flex">
<Button type="submit" color="success" size="sm">
Обновить точку
{isEditing ? "Обновить точку" : "Сохранить точку"}
</Button>
<Button size="sm" type="button" color="secondary" onclick={resetForm}>Отмена</Button>
{#if isEditing}
<Button size="sm" type="button" color="secondary" onclick={resetForm}>Отмена</Button>
{/if}
<span class="flex-grow-1"></span>
<Button
type="button"
color="danger"
size="sm"
onclick={() => isConfirmationVisible = true}
>
Удалить точку
</Button>
{#if isEditing}
<Button color="danger" size="sm" type="button" onclick={() => confirmDeletePoint(newPoint)}>
Удалить точку
</Button>
{:else}
<Button
color="secondary"
size="sm"
type="button"
onclick={() => {
resetForm();
closeModal();
}}
>
Закрыть без сохранения
</Button>
{/if}
</div>
</form>
</div>

View file

@ -1,347 +0,0 @@
<script lang="ts">
import { TableHandler } from "@vincjo/datatables";
import {
Modal,
Button,
FormGroup,
Label,
Input,
Alert,
Icon,
Pagination,
PaginationItem,
PaginationLink,
} from "@sveltestrap/sveltestrap";
import { onMount } from "svelte";
import { addToast } from "$lib/components/Toast.svelte";
import type { SavedPoint } from "$lib/types";
import { SavedPointsStore } from "$lib/stores";
import ConfirmationPrompt from "./ConfirmationPrompt.svelte";
import { getSavedPoints, savePoint, updatePoint, deletePoint } from "$lib/api/points";
// Props
let {
isOpen = $bindable(false),
onClose = () => {},
onChange = () => {},
onSave = () => {},
point = null,
coordinates = { id: 0, name: "", lat: 0, lon: 0, alt: 0 },
} = $props();
// Runes
let selectedPoint = $state<SavedPoint | null>(point);
let newPoint = $state<SavedPoint>(coordinates as SavedPoint);
let closeOnSave = $state(false);
let isEditing = $state(false);
let isAlertVisible = $state(false);
let isConfirmationVisible = $state(false);
let alertText = $state("");
// Table handler
let table = $derived(new TableHandler($SavedPointsStore, { rowsPerPage: 10 }));
let search = $derived(table.createSearch(["name"]));
$effect(() => {
onChange();
});
// On mount, fetch points
onMount(async () => {
const pts = await getSavedPoints();
$SavedPointsStore = pts;
SavedPointsStore.set($SavedPointsStore);
});
// Modal controls
export function openModal() {
isOpen = true;
}
export function openModalAndCreate(
point: SavedPoint | null = null,
coordinates: SavedPoint = { id: 0, name: "", lat: 0, lon: 0, alt: 0 },
close: boolean = false,
onSaveCallback: (point: SavedPoint) => void = () => {}
) {
if (point) {
selectedPoint = point;
newPoint = { ...point };
isEditing = true;
} else {
selectedPoint = null;
newPoint = coordinates || { id: 0, name: "", lat: 0, lon: 0, alt: 0 };
isEditing = false;
}
isOpen = true;
closeOnSave = close;
onSave = onSaveCallback;
}
function closeModal() {
isOpen = false;
closeOnSave = false;
onClose();
}
function handleEditPoint(point: SavedPoint) {
selectedPoint = point;
newPoint = { ...point };
isEditing = true;
}
function confirmDeletePoint(point: SavedPoint) {
selectedPoint = point;
isConfirmationVisible = true;
}
function handleDeletePoint(point: SavedPoint | null) {
if (!point) return;
deletePoint(point.id)
.then(() => {
$SavedPointsStore = $SavedPointsStore.filter((p) => p.id !== point.id);
SavedPointsStore.set($SavedPointsStore);
addToast({
header: "Точка удалена",
body: `Точка "${point.name}" успешно удалена.`,
color: "success",
});
})
.catch((error) => {
showAlert(`Ошибка при удалении точки: ${error.message}`);
console.error("Ошибка при удалении точки:", error);
});
}
export function handleSavePoint() {
if (isEditing && selectedPoint) {
updatePoint(newPoint)
.then((updatedPoint) => {
$SavedPointsStore = $SavedPointsStore.map((p) => (p.id === updatedPoint.id ? updatedPoint : p));
SavedPointsStore.set($SavedPointsStore);
resetForm();
addToast({
header: "Точка обновлена",
body: `Точка "${updatedPoint.name}" успешно обновлена.`,
color: "success",
});
if (closeOnSave) {
closeModal();
}
onSave(updatedPoint);
})
.catch((error) => {
showAlert(`Ошибка при обновлении точки: ${error.message}`);
});
} else {
savePoint(newPoint)
.then((savedPoint) => {
$SavedPointsStore = [...$SavedPointsStore, savedPoint];
SavedPointsStore.set($SavedPointsStore);
resetForm();
addToast({
header: "Точка сохранена",
body: `Точка "${savedPoint.name}" успешно сохранена.`,
color: "success",
});
if (closeOnSave) {
closeModal();
}
onSave(savedPoint);
})
.catch((error) => {
showAlert(`Ошибка при сохранении точки: ${error.message}`);
console.error("Ошибка при сохранении точки:", error);
});
}
}
export function showAlert(message: string) {
isAlertVisible = true;
alertText = message;
}
export function hideAlert() {
isAlertVisible = false;
alertText = "";
}
export function resetForm() {
selectedPoint = null;
newPoint = { id: 0, name: "", lat: 0, lon: 0, alt: 0 };
isEditing = false;
hideAlert();
}
</script>
<Modal
{isOpen}
toggle={closeModal}
size="lg"
fade={false}
backdrop={true}
scrollable
class={isConfirmationVisible ? "modal-tinted" : ""}
>
<div class="modal-header">
<h5 class="modal-title">Сохраненные точки</h5>
<button type="button" class="btn-close" onclick={closeModal} aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="position-relative mb-2">
<Input
type="text"
class="form-control-sm pe-5"
placeholder="Поиск по названию..."
bind:value={search.value}
oninput={() => search.set()}
/>
<Button
size="sm"
color="white"
class="position-absolute top-50 end-0 translate-middle-y me-2 rounded-circle d-flex align-items-center justify-content-center"
style="width: 16px; height: 16px; border: none; background: var(--bs-secondary); color: var(--bs-white);"
onclick={() => {
search.value = "";
search.set();
}}
disabled={!search.value}
>
<Icon name="x" style="font-size: 16px;" />
</Button>
</div>
<div bind:this={table.element} class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Название точки</th>
<th>Широта</th>
<th>Долгота</th>
<th>Высота</th>
<th class="fit"></th>
</tr>
</thead>
<tbody>
{#each table.rows as row}
<tr>
<td>{row.name}</td>
<td>{row.lat} °</td>
<td>{row.lon} °</td>
<td>{row.alt} м</td>
<td class="fit">
<Button color="primary" size="sm" onclick={() => handleEditPoint(row)}>
<Icon name="pencil" />
</Button>
<Button color="danger" size="sm" onclick={() => confirmDeletePoint(row)}>
<Icon name="trash" />
</Button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<Pagination aria-label="Page navigation" 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>
<hr />
<!-- Form for adding/editing points -->
<div>
<h5>{isEditing ? "Редактирование точки" : "Добавить новую точку"}</h5>
<Alert
color="danger"
isOpen={isAlertVisible}
toggle={() => (isAlertVisible = false)}
fade={false}
class="mb-2"
>
<Icon name="exclamation-triangle" class="me-2" />
{alertText}
</Alert>
<form
onsubmit={(e) => {
e.preventDefault();
handleSavePoint();
}}
>
<div class="mb-2">
<Label for="name" class="small">Название точки:</Label>
<Input class="form-control-sm" type="text" id="name" bind:value={newPoint.name} required />
</div>
<div class="d-flex gap-2">
<FormGroup class="flex-grow-1">
<Label for="lat" class="small">Широта:</Label>
<Input
class="form-control-sm"
type="number"
step="any"
id="lat"
bind:value={newPoint.lat}
required
/>
<span class="form-text">Градусы</span>
</FormGroup>
<FormGroup class="flex-grow-1">
<Label for="lon" class="small">Долгота:</Label>
<Input
class="form-control-sm"
type="number"
step="any"
id="lon"
bind:value={newPoint.lon}
required
/>
<span class="form-text">Градусы</span>
</FormGroup>
<FormGroup class="flex-grow-1">
<Label for="alt" class="small">Высота:</Label>
<Input
class="form-control-sm"
type="number"
step="any"
id="alt"
bind:value={newPoint.alt}
required
/>
<span class="form-text">Метры над ур. моря</span>
</FormGroup>
</div>
<Button type="submit" color="success" size="sm">
{isEditing ? "Обновить точку" : "Сохранить точку"}
</Button>
{#if isEditing}
<Button size="sm" type="button" color="secondary" onclick={resetForm}>Отмена</Button>
{/if}
</form>
</div>
</div>
</Modal>
<ConfirmationPrompt
isOpen={isConfirmationVisible}
title="Подтвердите удаление"
confirmText="Удалить"
cancelText="Отмена"
confirmVariant="danger"
onconfirm={() => {
isConfirmationVisible = false;
handleDeletePoint(selectedPoint);
}}
oncancel={() => {
isConfirmationVisible = false;
}}
>
<p>Вы уверены, что хотите удалить эту точку?</p>
</ConfirmationPrompt>

View file

@ -0,0 +1,262 @@
<script lang="ts">
import { TableHandler } from "@vincjo/datatables";
import {
Modal,
Button,
FormGroup,
Label,
Input,
Alert,
Icon,
Pagination,
PaginationItem,
PaginationLink,
} from "@sveltestrap/sveltestrap";
import { onMount } from "svelte";
import { addToast } from "$lib/components/Toast.svelte";
import type { SavedScenario } from "$lib/types";
import { FlightParametersStore, SavedScenarioStore } from "$lib/stores";
import ConfirmationPrompt from "./ConfirmationPrompt.svelte";
import { getSavedScenarios, saveScenario, updateScenario, deleteScenario } from "$lib/api/scenarios";
// Props
let {
isOpen = $bindable(false),
onClose = () => {},
onChange = () => {},
onSave = () => {},
onSelectScenario = (p: SavedScenario) => {},
scenario = null,
scenario_data = {
id: 0,
name: "",
template_data: {
flight_parameters: $FlightParametersStore,
description: "",
model: "",
dataset: "",
prediction_mode: "",
},
},
} = $props();
// Runes
let selectedScenario = $derived<SavedScenario | null>(scenario);
let isEditing = $state(false);
let closeOnSave = $state(false);
let isAlertVisible = $state(false);
let isConfirmationVisible = $state(false);
let alertText = $state("");
let newScenario = $derived<SavedScenario>(scenario_data as SavedScenario);
$effect(() => {
onChange();
});
// Modal controls
export function openModal() {
isOpen = true;
}
export function openModalAndCreate(
scenario: SavedScenario | null = null,
scenario_data: SavedScenario = {
id: 0,
name: "",
template_data: {
flight_parameters: $FlightParametersStore,
description: "",
model: "",
dataset: "",
prediction_mode: "",
},
},
close: boolean = false,
onSaveCallback: (point: SavedScenario) => void = () => {},
) {
if (scenario) {
selectedScenario = scenario;
newScenario = { ...scenario };
isEditing = true;
} else {
selectedScenario = null;
newScenario = scenario_data;
isEditing = false;
}
isOpen = true;
closeOnSave = close;
onSave = onSaveCallback;
}
function closeModal() {
isOpen = false;
closeOnSave = false;
onClose();
}
function handleDeleteScenario(scenario: SavedScenario | null) {
if (!scenario) {
return;
}
deleteScenario(scenario.id)
.then(() => {
$SavedScenarioStore = $SavedScenarioStore.filter((s) => s.id !== scenario.id);
SavedScenarioStore.set($SavedScenarioStore);
resetForm();
addToast({
header: "Точка удалена",
body: `Точка "${scenario.name}" успешно удалена.`,
color: "success",
});
})
.catch((error) => {
showAlert(`Ошибка при удалении сценария: ${error.message}`);
console.error("Ошибка при удалении сценария:", error);
});
}
export function handleSaveScenario() {
if (isEditing && selectedScenario) {
updateScenario(newScenario)
.then((updatedScenario) => {
$SavedScenarioStore = $SavedScenarioStore.map((s) =>
s.id === updatedScenario.id ? updatedScenario : s,
);
SavedScenarioStore.set($SavedScenarioStore);
resetForm();
addToast({
header: "Сценарий обновлен",
body: `Сценарий "${updatedScenario.name}" успешно обновлен.`,
color: "success",
});
if (closeOnSave) {
closeModal();
}
onSave(updatedScenario);
})
.catch((error) => {
showAlert(`Ошибка при обновлении сценария: ${error.message}`);
});
} else {
saveScenario(newScenario)
.then((savedScenario) => {
$SavedScenarioStore = [...$SavedScenarioStore, savedScenario];
SavedScenarioStore.set($SavedScenarioStore);
resetForm();
addToast({
header: "Сценарий сохранен",
body: `Сценарий "${savedScenario.name}" успешно сохранен.`,
color: "success",
});
if (closeOnSave) {
closeModal();
}
onSave(savedScenario);
})
.catch((error) => {
showAlert(`Ошибка при сохранении сценария: ${error.message}`);
console.error("Ошибка при сохранении сценария:", error);
});
}
}
export function showAlert(message: string) {
isAlertVisible = true;
alertText = message;
}
export function hideAlert() {
isAlertVisible = false;
alertText = "";
}
export function resetForm() {
hideAlert();
closeModal();
}
</script>
<Modal
{isOpen}
toggle={closeModal}
size="lg"
fade={false}
backdrop={true}
scrollable
class={isConfirmationVisible ? "modal-tinted" : ""}
>
<div class="modal-header">
<h5 class="modal-title">Редактирование сценария</h5>
<button type="button" class="btn-close" onclick={closeModal} aria-label="Close"></button>
</div>
<div class="modal-body">
<div>
<h5>{"Редактирование сценария"}</h5>
<Alert
color="danger"
isOpen={isAlertVisible}
toggle={() => (isAlertVisible = false)}
fade={false}
class="mb-2"
>
<Icon name="exclamation-triangle" class="me-2" />
{alertText}
</Alert>
<form
onsubmit={(e) => {
e.preventDefault();
handleSaveScenario();
}}
>
<div class="mb-2">
<Label for="name" class="small">Название сценария:</Label>
<Input class="form-control-sm" type="text" id="name" bind:value={newScenario.name} required />
</div>
<div class="d-grid gap-2 d-md-flex">
<Button type="submit" color="success" size="sm">
{isEditing ? "Обновить сценарий" : "Сохранить сценарий"}
</Button>
{#if isEditing}
<Button size="sm" type="button" color="secondary" onclick={resetForm}>Отмена</Button>
{/if}
<span class="flex-grow-1"></span>
{#if isEditing}
<Button color="danger" size="sm" type="button" onclick={() => {}}>
Удалить сценарий
</Button>
{:else}
<Button
color="secondary"
size="sm"
type="button"
onclick={() => {
resetForm();
closeModal();
}}
>
Закрыть без сохранения
</Button>
{/if}
</div>
</form>
</div>
</div>
</Modal>
<ConfirmationPrompt
isOpen={isConfirmationVisible}
title="Подтвердите удаление"
confirmText="Удалить"
cancelText="Отмена"
confirmVariant="danger"
onconfirm={() => {
isConfirmationVisible = false;
handleDeleteScenario(selectedScenario);
}}
oncancel={() => {
isConfirmationVisible = false;
}}
>
<p>Вы уверены, что хотите удалить этот сценарий?</p>
</ConfirmationPrompt>

View file

@ -12,11 +12,131 @@
Icon,
} from "@sveltestrap/sveltestrap";
import { PROFILE_MAP } from "$lib/types";
import { FlightParametersStore, writeLocalStorage, ScenarioStore, SavedScenarioTemplatesStore } from "$lib/stores";
import { PREDICTION_MODE_MAP, PPREDICTION_MODE_NAMES } from "$lib/types";
import type { SavedScenario } from "$lib/types";
import { getSavedScenarios, updateScenario, saveScenario } from "$lib/api/scenarios";
import { FlightParametersStore, writeLocalStorage, ScenarioStore, SavedScenarioStore } from "$lib/stores";
import SelectSearchable from "$lib/components/SelectSearchable.svelte";
import { onMount } from "svelte";
import { addToast } from "./Toast.svelte";
import ScenarioEditor from "./ScenarioEditor.svelte";
let isCollapsed = false;
let isCollapsed = $state(false);
let scenarioUnsaved = $derived(checkScenarioUnsaved());
let selectedScenarioId = $state(-1);
let scenarioEditorRef: ScenarioEditor | null = null;
onMount(() => {
getSavedScenarios()
.then((scenarios) => SavedScenarioStore.set(scenarios))
.catch((error) => {
addToast({
header: "Error Loading Points",
body: `Failed to load saved points: ${error.message}`,
color: "danger",
});
return [];
});
selectedScenarioId = $ScenarioStore.id;
});
function checkScenarioUnsaved() {
const savedScenario = $SavedScenarioStore.find((scenario) => scenario.id === $ScenarioStore.id);
if (!savedScenario) {
return false; // No saved scenario found
}
const flightParameters = $FlightParametersStore;
const savedData = savedScenario.template_data;
const savedFlightParameters = savedData.flight_parameters;
// Compare flight parameters excluding launch_datetime
return JSON.stringify(flightParameters) !== JSON.stringify(savedFlightParameters);
}
function handleSaveCurrentScenario() {
console.log("handleSaveCurrentScenario called");
const scenario = $SavedScenarioStore.find((s) => s.id === selectedScenarioId);
if (selectedScenarioId !== -1 && scenario) {
$ScenarioStore.id = selectedScenarioId;
updateScenario($ScenarioStore)
.then((updatedScenario) => {
$SavedScenarioStore = $SavedScenarioStore.map((s) =>
s.id === updatedScenario.id ? updatedScenario : s,
);
SavedScenarioStore.set($SavedScenarioStore);
$ScenarioStore = updatedScenario;
selectedScenarioId = updatedScenario.id;
addToast({
header: "Сценарий обновлен",
body: `Сценарий "${updatedScenario.name}" успешно обновлен.`,
color: "success",
});
scenarioUnsaved = false;
})
.catch((error) => {
addToast({
header: "Ошибка обновления сценария",
body: `Ошибка при обновлении сценария: ${error.message}`,
color: "danger",
});
console.error("Ошибка при обновлении сценария:", error);
});
} else {
scenarioEditorRef?.openModalAndCreate(
null,
{
id: 0,
name: "",
template_data: {
flight_parameters: $FlightParametersStore,
description: "test",
model: "test",
dataset: "test",
prediction_mode: $ScenarioStore.template_data.prediction_mode
},
},
true,
handleModalSave,
);
}
}
function handleApplySelectedScenario(showToast = true) {
const selectedScenario = $SavedScenarioStore.find((scenario) => scenario.id === selectedScenarioId);
if (selectedScenario) {
$ScenarioStore = selectedScenario;
$FlightParametersStore = selectedScenario.template_data.flight_parameters;
scenarioUnsaved = false;
writeLocalStorage("scenario", $ScenarioStore);
if (showToast) {
addToast({
header: "Сценарий применен",
body: `Сценарий "${selectedScenario.name}" успешно применен.`,
color: "success",
});
}
} else {
if (showToast)
addToast({
header: "Сценарий не найден",
body: "Выбранный сценарий не существует.",
color: "warning",
});
console.warn("Selected scenario not found:", selectedScenarioId);
}
}
function handleModalSave(savedScenario: SavedScenario) {
if (savedScenario) {
$ScenarioStore = savedScenario;
selectedScenarioId = savedScenario.id;
scenarioUnsaved = false;
}
}
export const collapsePanel = () => {
isCollapsed = true;
@ -41,10 +161,10 @@
class="d-flex w-100 justify-content-between align-items-center bg-transparent border-0 p-0"
style="width:100%;"
aria-label="Свернуть/развернуть параметры прогнозирования"
on:click={() => (isCollapsed = !isCollapsed)}
onclick={() => (isCollapsed = !isCollapsed)}
>
<b class="card-title mb-0 text-white p-0">Сценарий прогнозирования</b>
<Button class="p-0" size="sm" color="primary" on:click={() => (isCollapsed = !isCollapsed)}>
<Button class="p-0" size="sm" color="primary" onclick={() => (isCollapsed = !isCollapsed)}>
{#if isCollapsed}
<Icon name="caret-left-fill" class="text-white" />
{:else}
@ -58,50 +178,77 @@
<FormGroup spacing="mb-2">
<Label for="scenarioName" class="form-label">енарий:</Label>
<InputGroup size="sm">
<SelectSearchable
id="startPoint"
options={$SavedScenarioTemplatesStore.map(scenario => ({
value: scenario.id,
label: scenario.name,
}))}
placeholder="Выберите сценарий..."
searchPlaceholder="Поиск сценариев..."
/>
<Button color="success" title="Применить сценарий">
<div class="position-relative flex-grow-1">
<SelectSearchable
style="min-height: calc(1.5em + .5rem + 2px); padding: .25rem .5rem; font-size: .875rem;"
id="cp-start-point"
options={$SavedScenarioStore.map((scenario) => ({
value: scenario.id,
label:
scenario.name +
`${scenario.id == $ScenarioStore.id && scenarioUnsaved ? " (изменено)" : ""}`,
}))}
bind:selected={selectedScenarioId}
placeholder="Новый сценарий..."
searchPlaceholder="Поиск сценариев..."
on:change={() => {
if (!scenarioUnsaved) {
handleApplySelectedScenario(false);
}
}}
/>
<Button
size="sm"
color="white"
class="position-absolute top-50 end-0 translate-middle-y rounded-circle d-flex align-items-center justify-content-center"
style="width: 16px; height: 16px; border: none; background: var(--bs-secondary); color: var(--bs-white); z-index: 10; margin-right: 2rem;"
on:click={() => {
selectedScenarioId = -1;
}}
disabled={selectedScenarioId === -1}
>
<Icon name="x" style="font-size: 16px;" />
</Button>
</div>
<Button color="success" title="Применить сценарий" onclick={() => {handleApplySelectedScenario(true)}}>
<span></span>
</Button>
</InputGroup>
</FormGroup>
<div class="d-flex gap-2 mb-2">
<Button class="flex-fill" color="secondary" size="sm">
Сохранить
<Icon name="save" />
<Button color="secondary flex-fill" size="sm" title="Открыть список сценариев">
Все сценарии
<Icon name="journal-bookmark-fill" />
</Button>
<Button class="flex-fill" color="secondary" size="sm">
Загрузить
<Icon name="folder2-open" />
<Button
color="primary flex-fill"
size="sm"
title="Сохранить текущие условия как сценарий"
onclick={handleSaveCurrentScenario}
disabled={!scenarioUnsaved && selectedScenarioId !== -1}
>
{selectedScenarioId !== -1 ? "Обновить сценарий" : "Сохранить сценарий"}
<Icon name="floppy2-fill" />
</Button>
</div>
<Button
color="primary"
size="sm"
class="mb-0 w-100"
>
Редактировать сохраненные сценарии
<Icon name="journal-bookmark-fill" />
</Button>
<hr />
<FormGroup spacing="mb-2">
<Label for="scenarioMode" class="form-label">Режим сценария:</Label>
<InputGroup size="sm">
<Input type="select" id="scenarioMode">
<option>Обычный</option>
<option>Почасовой</option>
<option>Ансамблевый</option>
<Input type="select" id="scenarioMode" bind:value={$ScenarioStore.template_data.prediction_mode}
on:change={() => {
scenarioUnsaved = true;
}}>
{#each Object.entries(PREDICTION_MODE_MAP) as [key, value]}
<option {value}
>{PPREDICTION_MODE_NAMES[key as keyof typeof PPREDICTION_MODE_NAMES]}
{key}
</option>
{/each}
</Input>
</InputGroup>
</FormGroup>
@ -141,7 +288,7 @@
<Button
color="primary"
title="Edit Saved Locations"
on:click={() => console.log("Not implemented yet")}
onclick={() => console.log("Not implemented yet")}
>
<span>Экспорт</span>
<Icon name="file-earmark-arrow-down" />
@ -151,3 +298,4 @@
</CardBody>
{/if}
</Card>
<ScenarioEditor bind:this={scenarioEditorRef} onSave={handleModalSave} />

View file

@ -11,6 +11,7 @@
searchPlaceholder?: string;
disabled?: boolean;
class?: string;
onChange?: (value: any) => void;
}
let {
@ -74,6 +75,9 @@
isOpen = false;
searchTerm = '';
dispatch('change', selected);
if (restProps.onChange) {
restProps.onChange(selected);
}
}
function handleClickOutside(event: MouseEvent) {
@ -129,7 +133,7 @@
{#each filteredOptions as option}
<button
type="button"
class="dropdown-item "
class="dropdown-item small"
class:active={option.value === selected}
onclick={(e) => {
e.stopPropagation();

View file

@ -46,9 +46,11 @@ function formatLaunchDateTime(dateObj: string | Date, timeStr: string): string {
export const getForecast = async (
flightParameters: Record<string, any>,
launchDateTime: string
): Promise<void> => {
// Create request object
flightParameters.dataset = getLatestDataset();
flightParameters.launch_datetime = launchDateTime;
console.log("Sending request:", flightParameters);

View file

@ -1,7 +1,7 @@
import { writable } from "svelte/store";
import type { FlightParameters, RawTelemetry, Telemetry } from "./types";
import type { RawPrediction, Prediction } from "./types";
import type { SavedPoint, SavedFlightProfile, SavedScenarioTemplate, TemplateData } from "./types";
import type { SavedPoint, SavedFlightProfile, SavedScenario, TemplateData } from "./types";
export const readLocalStorage = <T>(key: string, defaultValue: T): T => {
const item = localStorage.getItem(key);
@ -42,7 +42,6 @@ const flightParametersDefaults: FlightParameters = {
descent_rate: 5.0,
format: "json",
launch_altitude: 0.0,
launch_datetime: "",
launch_latitude: 62.1234,
launch_longitude: 129.1234,
profile: "standard_profile",
@ -55,14 +54,14 @@ export const FlightParametersStore = writable<FlightParameters>(
const templateDataDefaults: TemplateData = {
description: "",
prediction_mode: false,
prediction_mode: "",
model: "",
dataset: "",
flight_parameters: flightParametersDefaults,
};
export const ScenarioStore = writable<TemplateData>(
readLocalStorage<TemplateData>("scenario", templateDataDefaults)
export const ScenarioStore = writable<SavedScenario>(
readLocalStorage<SavedScenario>("scenario", {} as SavedScenario)
);
export const RawTelemetryStore = writable<RawTelemetry>(
@ -87,4 +86,4 @@ export const SavedPointsStore = writable<SavedPoint[]>([]);
export const SavedFlightProfilesStore = writable<SavedFlightProfile[]>([]);
// stub
export const SavedScenarioTemplatesStore = writable<SavedScenarioTemplate[]>([]);
export const SavedScenarioStore = writable<SavedScenario[]>([]);

View file

@ -1,18 +1,18 @@
import type { LatLngExpression, LatLngLiteral } from "leaflet";
export const PROFILE_MAP = {
Normal: "standard_profile",
Float: "float_profile",
Reverse: "reverse_profile",
Custom: "custom_profile",
"Обычный": "standard_profile",
"Дрейф": "float_profile",
"Реверсивный": "reverse_profile",
"Пользовательский": "custom_profile",
};
// Map of profile names to their string identifiers
export const PROFILE_NAMES = {
standard_profile: "Normal",
float_profile: "Float",
reverse_profile: "Reverse",
custom_profile: "Custom",
standard_profile: "Обычный",
float_profile: "Дрейф",
reverse_profile: "Реверсивный",
custom_profile: "Пользовательский"
};
export type ProfileName = keyof typeof PROFILE_MAP;
@ -26,7 +26,6 @@ export interface FlightParameters {
descent_rate: number;
format: "json";
launch_altitude: number;
launch_datetime: string;
launch_latitude: number;
launch_longitude: number;
profile: (typeof PROFILE_MAP)[ProfileName];
@ -36,9 +35,22 @@ export interface FlightParameters {
template?: number; // Optional, used for saved scenarios
}
export const PREDICTION_MODE_MAP = {
"Разовый": "single",
"Почасовой": "hourly",
"Ансамблевый": "ensemble"
};
// Map of profile names to their string identifiers
export const PPREDICTION_MODE_NAMES = {
single: "Разовый",
hourly: "Почасовой",
ensemble: "Ансамблевый"
};
export interface TemplateData {
description: string;
prediction_mode: boolean;
prediction_mode: string;
model: string;
dataset: string;
flight_parameters: FlightParameters;
@ -120,7 +132,7 @@ export interface SavedFlightProfile {
rate_profile_data: object;
}
export interface SavedScenarioTemplate {
export interface SavedScenario {
id: number;
name: string;
template_data: TemplateData;

View file

@ -5,7 +5,7 @@
import PanelContainer from "$lib/components/PanelContainer.svelte";
import ScenarioPanel from "$lib/components/ScenarioPanel.svelte";
import TabComponent from "$lib/components/TabComponent.svelte";
import PointListModal from "$lib/components/PointListModal.svelte";
import PointEditor from "$lib/components/PointEditor.svelte";
import { onMount } from "svelte";
import { PredictionStore } from "$lib/stores";
import { addToast, removeToast } from "$lib/components/Toast.svelte";
@ -86,7 +86,7 @@
<div>
{#if activeTab === 'control'}
<ControlPanel {handleClickSelectOnMap} bind:this={controlPanel} />
<ControlPanel onSelectOnMapClick={handleClickSelectOnMap} bind:this={controlPanel} />
{:else if activeTab === 'scenario'}
<ScenarioPanel />
{:else if activeTab === 'settings'}

View file

@ -21,11 +21,11 @@
import { addToast } from "$lib/components/Toast.svelte";
// TODO: Implement these imports
import { SavedPointsStore, SavedFlightProfilesStore, SavedScenarioTemplatesStore } from "$lib/stores";
import { SavedPointsStore, SavedFlightProfilesStore, SavedScenarioStore } from "$lib/stores";
import { getSavedPoints, deletePoint } from "$lib/api/points";
import { getSavedFlightProfiles, deleteFlightProfile } from "$lib/api/profiles";
import { getSavedScenarioTemplates, deleteScenarioTemplate } from "$lib/api/templates";
import type { SavedPoint, SavedFlightProfile, SavedScenarioTemplate } from "$lib/types";
import { getSavedScenarios, deleteScenario } from "$lib/api/scenarios";
import type { SavedPoint, SavedFlightProfile, SavedScenario } from "$lib/types";
// Table handlers
let pointsTable = $derived(new TableHandler($SavedPointsStore, { rowsPerPage: 5 }));
@ -34,7 +34,7 @@
let profilesTable = $derived(new TableHandler($SavedFlightProfilesStore, { rowsPerPage: 5 }));
let profilesSearch = $derived(profilesTable.createSearch(["name"]));
let templatesTable = $derived(new TableHandler($SavedScenarioTemplatesStore, { rowsPerPage: 5 }));
let templatesTable = $derived(new TableHandler($SavedScenarioStore, { rowsPerPage: 5 }));
let templatesSearch = $derived(templatesTable.createSearch(["name"]));
let editPoint: SavedPoint | null = $state(null);
@ -49,10 +49,7 @@
{ id: 1, name: "Standard Weather Balloon", rate_profile_data: {ascent_rate: 5, descent_rate: 8, burst_altitude: 30000} },
{ id: 2, name: "High Altitude Probe", rate_profile_data: {ascent_rate: 6, descent_rate: 10, burst_altitude: 40000} },
];
$SavedScenarioTemplatesStore = [
{ id: 1, name: "Summer Launch from Baikonur", template_data: {description: "Standard summer conditions test."} },
{ id: 2, name: "Winter Launch from KSC", template_data: {description: "High wind scenario."} },
];
/*
// TODO: Uncomment when API is ready
@ -374,6 +371,9 @@
point={editPoint}
isOpen={editPoint !== null}
onClose={() => { editPoint = null; pointsTable.setRows($SavedPointsStore) }}
editor={true}
closeOnSave={true}
closeOnDelete={true}
/>
<ToastContainer />