Implement basic saved point editor

This commit is contained in:
ThePetrovich 2025-07-02 15:32:46 +08:00
parent bb390d50dc
commit 0f79cefdac
12 changed files with 414 additions and 41 deletions

64
src/lib/api/base.ts Normal file
View file

@ -0,0 +1,64 @@
import { getCsrfToken } from "$lib/auth";
export const API_BASE_URL = "http://localhost:8000/api";
export async function fetchAPI<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
let csrfToken = await getCsrfToken();
if (!csrfToken) {
console.warn("CSRF token not found, using empty string.");
csrfToken = "";
}
const url = `${API_BASE_URL}${endpoint}`;
options.credentials = "include"; // Include cookies in the request
options.headers = {
...options.headers,
"Content-Type": "application/json",
"X-CSRFToken": csrfToken,
};
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
if (response.status === 204) {
// No content response
return {} as T; // Return an empty object for 204 responses
}
return await response.json() as T;
} catch (error) {
console.error(`Error fetching ${url}:`, error);
throw error;
}
}
export function postAPI<T>(endpoint: string, data: any): Promise<T> {
return fetchAPI<T>(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
}
export function getAPI<T>(endpoint: string): Promise<T> {
return fetchAPI<T>(endpoint, {
method: "GET",
});
}
export function putAPI<T>(endpoint: string, data: any): Promise<T> {
return fetchAPI<T>(endpoint, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
}
export function deleteAPI<T>(endpoint: string): Promise<T> {
return fetchAPI<T>(endpoint, {
method: "DELETE",
});
}

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

@ -0,0 +1,19 @@
/* API functions for Saved Points */
import type { SavedPoint } from "$lib/types";
import { getAPI, postAPI, putAPI, deleteAPI } from "./base";
export function getSavedPoints(): Promise<SavedPoint[]> {
return getAPI<SavedPoint[]>("/saved-points/");
}
export function savePoint(point: SavedPoint): Promise<SavedPoint> {
return postAPI<SavedPoint>("/saved-points/", point);
}
export function updatePoint(point: SavedPoint): Promise<SavedPoint> {
return putAPI<SavedPoint>(`/saved-points/${point.id}/`, point);
}
export function deletePoint(id: number): Promise<void> {
return deleteAPI<void>(`/saved-points/${id}/`);
}

View file

@ -15,7 +15,10 @@
import { getForecast } from "$lib/prediction";
import type { FlightParameters, ProfileName } from "$lib/types";
import { PROFILE_MAP } from "$lib/types";
import { FlightParametersStore, writeLocalStorage } from "$lib/stores";
import { SavedPointsStore, FlightParametersStore, writeLocalStorage } from "$lib/stores";
import { getSavedPoints } from "$lib/api/points";
import type { SavedPoint } from "$lib/types";
import { onMount } from "svelte";
let isCollapsed = false;
let selectedProfile: ProfileName = "Normal";
@ -27,14 +30,55 @@
let startDate = now.toISOString().split("T")[0]; // YYYY-MM-DD
let startTime = now.toISOString().split("T")[1].split(".")[0]; // HH:MM:SS
let inputLat = $FlightParametersStore.launch_latitude.toString();
let inputLng = $FlightParametersStore.launch_longitude.toString();
let inputLat = $FlightParametersStore.launch_latitude.toFixed(6).toString();
let inputLng = $FlightParametersStore.launch_longitude.toFixed(6).toString();
$: $FlightParametersStore = {
...$FlightParametersStore,
profile: PROFILE_MAP[selectedProfile],
};
$: $SavedPointsStore, setCoordinatesFromSavedPoint();
$: inputLat, inputLng, setToCustomOnChange();
function setCoordinatesFromSavedPoint() {
console.log("Start point changed:", startPoint);
if (startPoint === "Custom") {
$FlightParametersStore.launch_latitude = parseFloat(inputLat);
$FlightParametersStore.launch_longitude = parseFloat(inputLng);
} else {
const selectedOption = document.querySelector(
`#startPoint option[value="${startPoint}"]`
) as HTMLOptionElement;
if (selectedOption) {
const lat = parseFloat(selectedOption.getAttribute("data-lat") || "0");
const lng = parseFloat(selectedOption.getAttribute("data-lng") || "0");
const alt = parseFloat(selectedOption.getAttribute("data-alt") || "0");
inputLat = lat.toFixed(6).toString();
inputLng = lng.toFixed(6).toString();
$FlightParametersStore.launch_latitude = lat;
$FlightParametersStore.launch_longitude = lng;
$FlightParametersStore.launch_altitude = alt;
console.log("Updated position from saved point:", lat, lng, alt);
}
}
}
function setToCustomOnChange() {
if (startPoint !== "Custom") {
startPoint = "Custom";
console.log("Switched to Custom point");
}
}
onMount(async () => {
// Load saved points from the server or local storage
const savedPoints = await getSavedPoints();
SavedPointsStore.set(savedPoints);
});
const handleGetPrediction = async () => {
console.log("Fetching prediction with parameters:", $FlightParametersStore);
console.log(startDate, startTime);
@ -58,6 +102,10 @@
console.log("Select on map clicked");
}
export let handleClickPointListModal = () => {
console.log("Open Point List Modal");
};
const applyCoordinatesFromInput = () => {
const lat = parseFloat(inputLat);
const lng = parseFloat(inputLng);
@ -179,15 +227,22 @@
<FormGroup spacing="mb-2">
<Label for="startPoint" class="form-label">Точка старта:</Label>
<InputGroup size="sm">
<Input type="select" id="startPoint" bind:value={startPoint}>
<option>Custom</option>
<option>Preset 1</option>
<option>Preset 2</option>
<Input type="select" id="startPoint" bind:value={startPoint} on:change={setCoordinatesFromSavedPoint}>
<optgroup label="Сохраненные точки">
{#each $SavedPointsStore as point}
<option value={point.name} data-lat={point.lat} data-lng={point.lon} data-alt={point.alt}>
{point.name}
</option>
{/each}
</optgroup>
<optgroup label="Задать вручную">
<option value="Custom">Custom</option>
</optgroup>
</Input>
<Button
color="secondary"
title="Edit Saved Locations"
on:click={() => console.log("Not implemented yet")}
on:click={handleClickPointListModal}
>
<span>Редакт.</span>
<Icon name="journal-bookmark-fill" />
@ -236,27 +291,29 @@
/>
</FormGroup>
</div>
<div class="mb-2 d-flex gap-2">
<FormGroup class="flex-fill w-50" spacing="mb-2">
<Label for="ascentRate" class="form-label">Скорость подъема (м/с):</Label>
<Input
type="number"
id="ascentRate"
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>
<Input
type="number"
id="descentRate"
class="form-control-sm"
bind:value={$FlightParametersStore.descent_rate}
/>
</FormGroup>
</div>
{#if selectedProfile != "Custom"}
<div class="mb-2 d-flex gap-2">
<FormGroup class="flex-fill w-50" spacing="mb-2">
<Label for="ascentRate" class="form-label">Скорость подъема (м/с):</Label>
<Input
type="number"
id="ascentRate"
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>
<Input
type="number"
id="descentRate"
class="form-control-sm"
bind:value={$FlightParametersStore.descent_rate}
/>
</FormGroup>
</div>
{/if}
<div class="d-grid gap-1">
<Button color="outline-secondary" size="sm">Показать график высоты</Button>

View file

@ -0,0 +1,8 @@
<script lang="ts">
import { Datatable, Search, RowsPerPage, RowCount, Pagination } from '@vincjo/datatables'
import { Modal } from '@sveltestrap/sveltestrap';
import { onMount } from 'svelte';
</script>

View file

@ -0,0 +1,181 @@
<script lang="ts">
import { TableHandler, Datatable } from '@vincjo/datatables'
import { Modal,
Button,
FormGroup,
Label,
Input,
InputGroup,
InputGroupText,
Icon,
Pagination,
PaginationItem,
PaginationLink,
} from '@sveltestrap/sveltestrap';
import { onMount } from 'svelte';
import type { SavedPoint } from '$lib/types';
import { SavedPointsStore } from '$lib/stores';
import { getSavedPoints, savePoint, updatePoint, deletePoint } from '$lib/api/points';
export let isOpen: boolean = false;
export let onClose: () => void = () => {};
let points: SavedPoint[] = [];
const table = new TableHandler($SavedPointsStore, { rowsPerPage: 10 })
let selectedPoint: SavedPoint | null = null;
let newPoint: SavedPoint = { id: 0, name: '', lat: 0, lon: 0, alt: 0 };
let isEditing: boolean = false;
let modalTitle: string = 'Сохраненные точки';
onMount(async () => {
points = await getSavedPoints();
SavedPointsStore.set(points);
});
export function openModal() {
isOpen = true;
modalTitle = 'Сохраненные точки';
}
export function closeModal() {
isOpen = false;
onClose();
}
function handleEditPoint(point: SavedPoint) {
selectedPoint = point;
newPoint = { ...point };
isEditing = true;
modalTitle = 'Редактирование точки';
}
function handleDeletePoint(point: SavedPoint) {
deletePoint(point.id).then(() => {
points = points.filter(p => p.id !== point.id);
SavedPointsStore.set(points);
});
}
function handleSavePoint() {
if (isEditing && selectedPoint) {
updatePoint(newPoint).then(updatedPoint => {
points = points.map(p => (p.id === updatedPoint.id ? updatedPoint : p));
SavedPointsStore.set(points);
table.setRows(points);
resetForm();
});
} else {
savePoint(newPoint).then(savedPoint => {
points.push(savedPoint);
points = [...points]; // Trigger reactivity
SavedPointsStore.set(points);
table.setRows(points);
resetForm();
});
}
}
export function resetForm() {
selectedPoint = null;
newPoint = { id: 0, name: '', lat: 0, lon: 0, alt: 0 };
isEditing = false;
modalTitle = 'Сохраненные точки';
}
$: if (isOpen) {
SavedPointsStore.set(points);
}
</script>
<Modal isOpen={isOpen} toggle={closeModal} size="lg" fade={false} backdrop={true} scrollable>
<div class="modal-header">
<h5 class="modal-title">{modalTitle}</h5>
<button type="button" class="btn-close" on:click={closeModal} aria-label="Close"></button>
</div>
<div class="modal-body" >
<Datatable {table}>
<table class="table table-sm">
<thead>
<tr>
<th>Имя</th>
<th>Широта</th>
<th>Долгота</th>
<th>Высота</th>
<th class="fit"></th>
</tr>
</thead>
<tbody>
{#each points as point (point.id)}
<tr>
<td>{point.name}</td>
<td>{point.lat}</td>
<td>{point.lon}</td>
<td>{point.alt}</td>
<td class="fit">
<Button color="primary" size="sm" on:click={() => handleEditPoint(point)}>
<Icon name="pencil" />
</Button>
<Button color="danger" size="sm" on:click={() => handleDeletePoint(point)}>
<Icon name="trash" />
</Button>
</td>
</tr>
{/each}
</tbody>
</table>
</Datatable>
<Pagination aria-label="Page navigation">
<PaginationItem>
<PaginationLink previous on:click={() => table.setPage("previous")} />
</PaginationItem>
{#each table.pagesWithEllipsis as page}
<PaginationItem active={table.currentPage === page}>
<PaginationLink on:click={() => table.setPage(page)}>{page}</PaginationLink>
</PaginationItem>
{/each}
<PaginationItem>
<PaginationLink next on:click={() => table.setPage("next")} />
</PaginationItem>
</Pagination>
<!-- Form for adding/editing points -->
<div class="mt-2">
<h5>{isEditing ? 'Редактирование точки' : 'Добавить новую точку'}</h5>
<form on:submit|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" 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" 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" id="alt" bind:value={newPoint.alt} required />
<span class="form-text">Метры над ур. моря</span>
</FormGroup>
</div>
<Button type="submit" color="success">
{isEditing ? 'Обновить точку' : 'Сохранить точку'}
</Button>
{#if isEditing}
<Button type="button" color="secondary" on:click={resetForm} class="ms-2">Отмена</Button>
{/if}
</form>
</div>
</div>
<div class="modal-footer">
<Button color="secondary" on:click={closeModal}>Закрыть</Button>
</div>
</Modal>

View file

@ -3,7 +3,7 @@ import type { LatLngExpression } from "leaflet";
import L from "leaflet";
import { getCsrfToken } from "./auth";
import type { PredictionStage, RawPrediction, Prediction } from "./types";
import type { PredictionStage, RawPrediction, Prediction, Point } from "./types";
import { PredictionStore, RawPredictionStore, writeLocalStorage } from "./stores";
function getLatestDataset() {
@ -90,10 +90,10 @@ export const getForecast = async (
};
export function parsePrediction(prediction: PredictionStage[]): Prediction {
const flight_path: [number, number, number][] = [];
const launch: { latlng: LatLngExpression; datetime: Date } = {} as any;
const burst: { latlng: LatLngExpression; datetime: Date } = {} as any;
const landing: { latlng: LatLngExpression; datetime: Date } = {} as any;
const flight_path: LatLngExpression[] = [];
const launch: Point = {} as any;
const burst: Point = {} as any;
const landing: Point = {} as any;
const ascent = prediction[0].trajectory;
const descent = prediction[1].trajectory;

View file

@ -1,6 +1,7 @@
import { writable } from "svelte/store";
import type { FlightParameters, RawTelemetry, Telemetry } from "./types";
import type { RawPrediction, Prediction } from "./types";
import type { SavedPoint } from "./types";
export const readLocalStorage = <T>(key: string, defaultValue: T): T => {
const item = localStorage.getItem(key);
@ -67,3 +68,5 @@ export const RawPredictionStore = writable<RawPrediction>(
export const PredictionStore = writable<Prediction>(
readLocalStorage<Prediction>("prediction", {} as Prediction)
);
export const SavedPointsStore = writable<SavedPoint[]>([]);

View file

@ -21,10 +21,11 @@ export interface FlightParameters {
launch_longitude: number;
profile: (typeof PROFILE_MAP)[ProfileName];
version: number;
start_point?: string; // Optional, used for saved points
}
export interface Point {
latlng: LatLngLiteral & { alt: number };
latlng: LatLngLiteral & { alt?: number };
datetime: Date;
}
@ -83,3 +84,11 @@ export interface Prediction {
profile: string;
flight_time: number;
}
export interface SavedPoint {
id: number;
name: string;
lat: number;
lon: number;
alt: number;
}

View file

@ -3,15 +3,14 @@
import ControlPanel from "$lib/components/ControlPanel.svelte";
import Navbar from "$lib/components/Navbar.svelte";
import PanelContainer from "$lib/components/PanelContainer.svelte";
import TelemetryPanel from '$lib/components/TelemetryPanel.svelte';
import ScenarioPanel from "$lib/components/ScenarioPanel.svelte";
import TabComponent from "$lib/components/TabComponent.svelte";
import PointListModal from "$lib/components/PointListModal.svelte";
import { onMount } from "svelte";
import { PredictionStore } from "$lib/stores";
import { Modal, Icon } from "@sveltestrap/sveltestrap";
import Toast, { addToast, removeToast } from "$lib/components/Toast.svelte";
import { addToast, removeToast } from "$lib/components/Toast.svelte";
import ToastContainer from '$lib/components/Toast.svelte';
import L from "leaflet";
import L, { point } from "leaflet";
let map: Map | null = null;
let panelContainer: PanelContainer | null = null;
@ -19,6 +18,8 @@
let selectionToastId: string | null = null;
let activeTab: 'control' | 'scenario' | 'settings' | 'about' = 'scenario';
let pointListModal: PointListModal | null = null;
onMount(() => {
PredictionStore.subscribe((data) => {
if (data) {
@ -65,6 +66,16 @@
selectionToastId = null;
}
}
function handleClickPointListModal() {
if (map) {
map.stopSelection();
console.log("Selection mode disabled");
}
pointListModal?.openModal();
}
</script>
<main>
@ -73,7 +84,7 @@
<PanelContainer bind:this={panelContainer} >
<TabComponent
tabs={[
{ id: 'scenario', icon: 'activity', label: 'Сценарий' },
{ id: 'scenario', icon: 'file-earmark-play', label: 'Сценарий' },
{ id: 'control', icon: 'sliders', label: 'Условия' },
{ id: 'settings', icon: 'gear', label: 'Настройки' },
{ id: 'about', icon: 'info-circle', label: 'О проекте' }
@ -83,7 +94,7 @@
<div>
{#if activeTab === 'control'}
<ControlPanel {handleClickSelectOnMap} bind:this={controlPanel} />
<ControlPanel {handleClickSelectOnMap} {handleClickPointListModal} bind:this={controlPanel} />
{:else if activeTab === 'scenario'}
<ScenarioPanel />
{:else if activeTab === 'settings'}
@ -94,5 +105,6 @@
</div>
</PanelContainer>
<ToastContainer />
<PointListModal bind:this={pointListModal} />
</Map>
</main>