control panel ux

This commit is contained in:
ThePetrovich 2025-07-04 00:40:53 +08:00
parent a1d80eb984
commit 7d01fce094
8 changed files with 219 additions and 60 deletions

View file

@ -12,14 +12,15 @@
Icon,
} from "@sveltestrap/sveltestrap";
import PointListModal from "$lib/components/PointListModal.svelte";
import SelectSearchable from "$lib/components/SelectSearchable.svelte";
import { getForecast } from "$lib/prediction";
import type { FlightParameters, ProfileName, ProfileIdentifier } from "$lib/types";
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 { getSavedPoints } from "$lib/api/points";
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() {
@ -54,21 +55,22 @@
interface Props {
handleClickSelectOnMap?: () => void;
handleClickPointListModal?: () => void;
}
let {
handleClickSelectOnMap = () => console.log("Select on map clicked"),
handleClickPointListModal = () => console.log("Open Point List Modal"),
}: Props = $props();
// State
let isCollapsed = $state(false);
let pointListModal: PointListModal | null = null;
// Initialize date/time
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
// Coordinate inputs
let inputLat = $derived($FlightParametersStore.start_point === -1
@ -87,21 +89,73 @@
}
}
function applyCoordinatesFromInput() {
const lat = parseFloat(inputLat);
const lng = parseFloat(inputLng);
const alt = parseFloat(inputAlt);
function applySeletedPoint() {
if (selectedPoint && selectedPoint !== -1) {
$FlightParametersStore.start_point = selectedPoint;
}
}
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
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);
});
}
} else {
console.error("Invalid coordinate input");
// TODO: Show validation error to user
pointListModal?.openModalAndCreate(null, {
id: 0,
name: `Новая точка ${new Date().toLocaleString()}`,
lat: parseFloat(inputLat),
lon: parseFloat(inputLng),
alt: parseFloat(inputAlt),
}, true, onModalSave);
}
}
function onModalSave(savedPoint: SavedPoint) {
if (savedPoint) {
$FlightParametersStore.start_point = savedPoint.id;
selectedPoint = savedPoint.id;
setToCustomOnChange();
}
}
function handleClickPointListModal() {
pointListModal?.openModal();
}
// 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`;
$FlightParametersStore.launch_latitude = parseFloat(inputLat);
@ -173,6 +227,8 @@
});
return [];
});
selectedPoint = $FlightParametersStore.start_point;
console.log("ControlPanel mounted", selectedPoint);
});
</script>
@ -239,9 +295,11 @@
<FormGroup spacing="mb-2">
<Label for="startPoint" 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={$FlightParametersStore.start_point}
bind:selected={selectedPoint}
options={$SavedPointsStore.map(point => ({
value: point.id,
label: point.name,
@ -249,13 +307,49 @@
placeholder="Выберите точку старта"
searchPlaceholder="Поиск точки..."
/>
<Button color="secondary" title="Edit Saved Locations" onclick={handleClickPointListModal}>
<span>Редакт.</span>
<Icon name="journal-bookmark-fill" />
<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={() => {
selectedPoint = -1;
$FlightParametersStore.start_point = -1;
}}
disabled={selectedPoint === -1}
>
<Icon name="x" style="font-size: 16px;" />
</Button>
</div>
<Button color="success" size="sm" onclick={applySeletedPoint} title="Apply Coordinates">
</Button>
</InputGroup>
</FormGroup>
<div class="d-flex gap-2 mb-2">
<Button
color="secondary flex-fill"
size="sm"
onclick={handleClickPointListModal}
title="Открыть список точек"
>
Все точки
<Icon name="journal-bookmark-fill" />
</Button>
<Button
color="primary flex-fill"
size="sm"
onclick={saveCurrentPoint}
title="Сохранить текущие координаты"
>
Сохранить точку
<Icon name="floppy2-fill" />
</Button>
</div>
<FormGroup spacing="mb-2">
<Label for="latitude" class="form-label">Широта/Долгота:</Label>
<InputGroup size="sm">
@ -274,18 +368,11 @@
placeholder="Longitude"
onchange={setToCustomOnChange}
/>
<Button color="success" size="sm" onclick={applyCoordinatesFromInput} title="Apply Coordinates">
<Button color="secondary" size="sm" onclick={handleClickSelectOnMap}>
<Icon name="geo-alt-fill" />
</Button>
</InputGroup>
</FormGroup>
<FormGroup spacing="mb-2">
<Button color="outline-secondary" size="sm" class="w-100" onclick={handleClickSelectOnMap}>
Указать на карте
</Button>
</FormGroup>
<div class="d-flex gap-2">
<FormGroup class="flex-fill w-50" spacing="mb-2">
<Label for="startHeight" class="form-label">Высота старта (м):</Label>
@ -338,3 +425,4 @@
</CardBody>
{/if}
</Card>
<PointListModal bind:this={pointListModal} />

View file

@ -24,6 +24,7 @@
isOpen = $bindable(false),
onClose = () => {},
onChange = () => {},
onSave = () => {},
point = null,
coordinates = { id: 0, name: "", lat: 0, lon: 0, alt: 0 },
} = $props();
@ -31,6 +32,7 @@
// 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);
@ -56,8 +58,29 @@
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();
}
@ -90,7 +113,7 @@
});
}
function handleSavePoint() {
export function handleSavePoint() {
if (isEditing && selectedPoint) {
updatePoint(newPoint)
.then((updatedPoint) => {
@ -102,6 +125,10 @@
body: `Точка "${updatedPoint.name}" успешно обновлена.`,
color: "success",
});
if (closeOnSave) {
closeModal();
}
onSave(updatedPoint);
})
.catch((error) => {
showAlert(`Ошибка при обновлении точки: ${error.message}`);
@ -117,6 +144,10 @@
body: `Точка "${savedPoint.name}" успешно сохранена.`,
color: "success",
});
if (closeOnSave) {
closeModal();
}
onSave(savedPoint);
})
.catch((error) => {
showAlert(`Ошибка при сохранении точки: ${error.message}`);
@ -143,7 +174,15 @@
}
</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>
<button type="button" class="btn-close" onclick={closeModal} aria-label="Close"></button>

View file

@ -13,7 +13,8 @@
} from "@sveltestrap/sveltestrap";
import { PROFILE_MAP } from "$lib/types";
import { FlightParametersStore, writeLocalStorage } from "$lib/stores";
import { FlightParametersStore, writeLocalStorage, ScenarioStore, SavedScenarioTemplatesStore } from "$lib/stores";
import SelectSearchable from "$lib/components/SelectSearchable.svelte";
let isCollapsed = false;
@ -55,9 +56,20 @@
{#if !isCollapsed}
<CardBody>
<FormGroup spacing="mb-2">
<Label for="scenarioName" class="form-label">Название сценария:</Label>
<Label for="scenarioName" class="form-label">енарий:</Label>
<InputGroup size="sm">
<Input id="scenarioName" type="text" />
<SelectSearchable
id="startPoint"
options={$SavedScenarioTemplatesStore.map(scenario => ({
value: scenario.id,
label: scenario.name,
}))}
placeholder="Выберите сценарий..."
searchPlaceholder="Поиск сценариев..."
/>
<Button color="success" title="Применить сценарий">
<span></span>
</Button>
</InputGroup>
</FormGroup>

View file

@ -1,6 +1,8 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { onMount } from 'svelte';
import { on } from 'svelte/events';
interface Props extends HTMLAttributes<HTMLDivElement> {
options?: { value: any; label:string }[];
@ -40,6 +42,11 @@
options.find(opt => opt.value === selected)?.label || ''
);
onMount(() => {
// Update dropdown position on mount
updateDropdownPosition();
});
function updateDropdownPosition() {
if (!selectElement) return;
const rect = selectElement.getBoundingClientRect();
@ -106,7 +113,7 @@
<span class:text-muted={!selected}>{selectedLabel || placeholder}</span>
{#if isOpen}
<div class="dropdown-menu show" bind:this={dropdownElement} style={dropdownStyle}>
<div class="pt-0 dropdown-menu show" bind:this={dropdownElement} style={dropdownStyle}>
<div class="p-2">
<input
type="text"

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 } from "./types";
import type { SavedPoint, SavedFlightProfile, SavedScenarioTemplate, TemplateData } from "./types";
export const readLocalStorage = <T>(key: string, defaultValue: T): T => {
const item = localStorage.getItem(key);
@ -53,6 +53,18 @@ export const FlightParametersStore = writable<FlightParameters>(
readLocalStorage<FlightParameters>("flightParameters", flightParametersDefaults)
);
const templateDataDefaults: TemplateData = {
description: "",
prediction_mode: false,
model: "",
dataset: "",
flight_parameters: flightParametersDefaults,
};
export const ScenarioStore = writable<TemplateData>(
readLocalStorage<TemplateData>("scenario", templateDataDefaults)
);
export const RawTelemetryStore = writable<RawTelemetry>(
readLocalStorage<RawTelemetry>("rawTelemetry", {} as RawTelemetry)
);

View file

@ -32,8 +32,19 @@ export interface FlightParameters {
profile: (typeof PROFILE_MAP)[ProfileName];
version: number;
start_point?: number; // Optional, used for saved points
rate_profile?: number; // Optional, used for custom profiles
template?: number; // Optional, used for saved scenarios
}
export interface TemplateData {
description: string;
prediction_mode: boolean;
model: string;
dataset: string;
flight_parameters: FlightParameters;
}
export interface Point {
latlng: LatLngLiteral & { alt?: number };
datetime: Date;
@ -112,5 +123,5 @@ export interface SavedFlightProfile {
export interface SavedScenarioTemplate {
id: number;
name: string;
template_data: object;
template_data: TemplateData;
}

View file

@ -18,8 +18,6 @@
let selectionToastId: string | null = null;
let activeTab: 'control' | 'scenario' | 'settings' | 'about' = 'scenario';
let pointListModal: PointListModal | null = null;
onMount(() => {
PredictionStore.subscribe((data) => {
if (data) {
@ -43,8 +41,8 @@
console.log("Selection mode enabled");
if (!selectionToastId) {
selectionToastId = addToast({
header: "Selection Mode",
body: "Click on the map to select a position.",
header: "Режим выбора координат",
body: "Кликните на карту, чтобы выбрать координаты",
color: "info",
persistent: true,
onRemoveCallback: () => {
@ -67,13 +65,6 @@
}
}
function handleClickPointListModal() {
if (map) {
map.stopSelection();
console.log("Selection mode disabled");
}
pointListModal?.openModal();
}
</script>
@ -95,7 +86,7 @@
<div>
{#if activeTab === 'control'}
<ControlPanel {handleClickSelectOnMap} {handleClickPointListModal} bind:this={controlPanel} />
<ControlPanel {handleClickSelectOnMap} bind:this={controlPanel} />
{:else if activeTab === 'scenario'}
<ScenarioPanel />
{:else if activeTab === 'settings'}
@ -106,6 +97,5 @@
</div>
</PanelContainer>
<ToastContainer />
<PointListModal bind:this={pointListModal} />
</Map>
</main>