This commit is contained in:
ThePetrovich 2025-07-06 19:16:17 +08:00
parent 19f969c18c
commit 4bb7d214e8
4 changed files with 139 additions and 196 deletions

View file

@ -10,11 +10,11 @@
2. **Derived State (`$derived`)**:
- Use camelCase.
- No special prefixes are needed as `$derived` already marks them as reactive derived state.
- Example: `let inputLat = $derived(...)`
- Example: `let currentPoint = $derived(...)`
3. **Component Instance References**:
- Use camelCase and suffix with `Ref`.
- Example: `let PointEditorRef: PointEditor | null = null;`
- Example: `let pointEditorRef: PointEditor | null = null;`
4. **Event Handlers**:
- Use `handle<EventName>` or `handle<Element><Event>` naming.
@ -34,7 +34,7 @@
- Example: `import { SavedPointsStore } from '$lib/stores';`
- The reactive Svelte store prefix `$` is used as standard.
*/
import { onMount } from "svelte";
import { onMount, onDestroy } from "svelte";
import {
Button,
Card,
@ -53,8 +53,14 @@
import PointEditor from "$lib/components/PointEditor.svelte";
import SelectSearchable from "$lib/components/SelectSearchable.svelte";
import { getForecast } from "$lib/prediction";
import { FlightParametersStore, SavedPointsStore, writeLocalStorage } from "$lib/stores";
import { PROFILE_MAP, type FlightParameters, type ProfileName, type SavedPoint } from "$lib/types";
import {
FlightParametersStore,
SavedPointsStore,
writeLocalStorage,
readLocalStorage,
flightParametersDefaults,
} from "$lib/stores";
import { PROFILE_MAP, type FlightParameters, type SavedPoint } from "$lib/types";
// Props
interface Props {
@ -64,37 +70,30 @@
// State
let isCollapsed = $state(false);
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 selectedPointId = $state($FlightParametersStore.start_point);
let startDate = $state(readLocalStorage<string>("startDate", new Date().toISOString().split("T")[0]));
let startTime = $state(readLocalStorage<string>("startTime", "12:00:00"));
let selectedPointId = $state($FlightParametersStore.start_point || -1);
// Component References
let PointEditorRef: PointEditor | null = null;
let pointEditorRef: PointEditor | null = null;
// 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",
);
let currentPoint = $derived($SavedPointsStore.find((p) => p.id === selectedPointId) || null);
let isPointDirty = $derived(() => {
if (!currentPoint) return false; // Not dirty if no point is selected
const latMatch = $FlightParametersStore.launch_latitude.toFixed(6) === currentPoint.lat.toFixed(6);
const lonMatch = $FlightParametersStore.launch_longitude.toFixed(6) === currentPoint.lon.toFixed(6);
const altMatch = $FlightParametersStore.launch_altitude.toFixed(2) === currentPoint.alt.toFixed(2);
return !(latMatch && lonMatch && altMatch);
});
// Lifecycle Hooks
onMount(() => {
// NOTE: Consider moving localStorage logic into the store itself for better encapsulation.
$FlightParametersStore =
readLocalStorage<FlightParameters>("flightParameters", flightParametersDefaults) || $FlightParametersStore;
selectedPointId = $FlightParametersStore.start_point || -1;
getSavedPoints()
.then((points) => SavedPointsStore.set(points))
.catch((error) => {
@ -103,102 +102,91 @@
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;
}
}
onDestroy(() => {
writeLocalStorage<FlightParameters>("flightParameters", $FlightParametersStore);
writeLocalStorage<string>("startDate", startDate);
writeLocalStorage<string>("startTime", startTime);
});
export function handleSelectPointInModal(point: SavedPoint) {
console.log("Selected point from modal:", point);
selectedPointId = point.id;
$FlightParametersStore.start_point = selectedPointId;
isPointDirty = false;
// Event Handlers
function handlePointSelection(newPointId: number) {
console.log("Point selection changed:", newPointId);
selectedPointId = newPointId;
const point = $SavedPointsStore.find((p) => p.id === newPointId);
if (point) {
console.log("Selected point:", point);
$FlightParametersStore.start_point = point.id;
$FlightParametersStore.launch_latitude = point.lat;
$FlightParametersStore.launch_longitude = point.lon;
$FlightParametersStore.launch_altitude = point.alt;
} else if (newPointId === -1) {
$FlightParametersStore.start_point = -1;
// When clearing the selection, we can reset to defaults or leave as is.
// For now, we'll just update the ID. The user can manually edit coordinates.
}
}
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);
if (currentPoint) {
// Update existing point
const updatedPointData = {
...currentPoint,
lat: $FlightParametersStore.launch_latitude,
lon: $FlightParametersStore.launch_longitude,
alt: $FlightParametersStore.launch_altitude,
};
updatePoint(updatedPointData)
.then((savedPoint) => {
SavedPointsStore.update((points) => points.map((p) => (p.id === savedPoint.id ? savedPoint : p)));
addToast({
header: "Точка обновлена",
body: `Точка "${updatedPoint.name}" успешно обновлена.`,
header: "Point Updated",
body: `Point "${savedPoint.name}" was successfully updated.`,
color: "success",
});
isPointDirty = false;
})
.catch((error) => {
addToast({
header: "Ошибка обновления точки",
body: `Ошибка при обновлении точки: ${error.message}`,
header: "Update Error",
body: `Failed to update point: ${error.message}`,
color: "danger",
});
console.error("Ошибка при обновлении точки:", error);
});
}
} else {
PointEditorRef?.openModalAndCreate(
// Create new point
pointEditorRef?.openModalAndCreate(
null,
{
id: 0,
name: `Новая точка ${new Date().toLocaleString()}`,
lat: parseFloat(inputLat),
lon: parseFloat(inputLng),
alt: parseFloat(inputAlt),
name: `New Point ${new Date().toLocaleString()}`,
lat: $FlightParametersStore.launch_latitude,
lon: $FlightParametersStore.launch_longitude,
alt: $FlightParametersStore.launch_altitude,
},
true,
false,
handleModalSave,
(savedPoint) => {
if (savedPoint) {
handlePointSelection(savedPoint.id);
}
},
);
}
}
function handleModalSave(savedPoint: SavedPoint) {
if (savedPoint) {
$FlightParametersStore.start_point = savedPoint.id;
selectedPointId = savedPoint.id;
isPointDirty = false;
}
}
function handlePointEditorOpen() {
PointEditorRef?.openModal(true);
}
async function handlePredictionRequest() {
$FlightParametersStore.launch_latitude = parseFloat(inputLat);
$FlightParametersStore.launch_longitude = parseFloat(inputLng);
$FlightParametersStore.launch_altitude = parseFloat(inputAlt);
// Persist current parameters before running prediction
writeLocalStorage<FlightParameters>("flightParameters", $FlightParametersStore);
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",
});
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",
});
addToast({ header: "Forecast Error", body: `Error getting forecast: ${error.message}`, color: "danger" });
}
}
@ -210,25 +198,23 @@
export function updateLaunchPosition(lat: number, lng: number) {
$FlightParametersStore.launch_latitude = lat;
$FlightParametersStore.launch_longitude = lng;
isPointDirty = true;
}
export function getSelectedProfile() {
return $FlightParametersStore.profile;
export function loadFlightParameters(params: FlightParameters) {
$FlightParametersStore = params;
selectedPointId = params.start_point || -1;
}
export function selectProfile(profile: ProfileName) {
$FlightParametersStore.profile = profile;
export function getFlightParameters(): FlightParameters {
return $FlightParametersStore;
}
export function collapsePanel() {
isCollapsed = true;
}
export function expandPanel() {
isCollapsed = false;
}
export function togglePanel() {
isCollapsed = !isCollapsed;
}
@ -238,22 +224,12 @@
<CardHeader
class="bg-primary text-white d-flex justify-content-between align-items-center card-header p-1 px-3"
style="cursor:pointer;"
>
<button
type="button"
class="d-flex w-100 justify-content-between align-items-center bg-transparent border-0 p-0"
aria-label="Свернуть/развернуть условия прогнозирования"
onclick={handleToggleCollapse}
>
<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}
<Icon name="caret-down-fill" class="text-white" />
{/if}
<Button class="p-0" size="sm" color="primary" aria-label="Свернуть/развернуть условия прогнозирования">
<Icon name={isCollapsed ? "caret-left-fill" : "caret-down-fill"} class="text-white" />
</Button>
</button>
</CardHeader>
{#if !isCollapsed}
@ -273,8 +249,8 @@
<Label for="cp-flight-profile" class="form-label">Профиль полета:</Label>
<InputGroup size="sm">
<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 Object.entries(PROFILE_MAP) as [name, value]}
<option {value}>{name}</option>
{/each}
</Input>
</InputGroup>
@ -287,46 +263,37 @@
<SelectSearchable
style="min-height: calc(1.5em + .5rem + 2px); padding: .25rem .5rem; font-size: .875rem;"
id="cp-start-point"
bind:selected={selectedPointId}
selected={selectedPointId}
onChange={(e) => handlePointSelection(e)}
options={$SavedPointsStore.map((point) => ({
value: point.id,
label:
point.name +
`${point.id == $FlightParametersStore.start_point && isPointDirty ? " (изменено)" : ""}`,
label: `${point.name} ${point.id === selectedPointId && isPointDirty() ? "(изменено)" : ""}`,
}))}
placeholder="Новая точка..."
searchPlaceholder="Поиск по точкам..."
on:change={() => {
if (!isPointDirty) {
$FlightParametersStore.start_point = selectedPointId;
}
}}
/>
{#if selectedPointId !== -1}
<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={() => {
selectedPointId = -1;
$FlightParametersStore.start_point = -1;
}}
disabled={selectedPointId === -1}
on:click={() => handlePointSelection(-1)}
title="Clear selection"
>
<Icon name="x" style="font-size: 16px;" />
</Button>
{/if}
</div>
<Button color="success" size="sm" onclick={handleApplySelectedPoint} title="Apply Coordinates"
>✓</Button
>
</InputGroup>
</FormGroup>
<div class="d-flex gap-2 mb-2">
<Button
color="secondary flex-fill"
color="secondary"
class="flex-fill"
size="sm"
onclick={handlePointEditorOpen}
onclick={() => pointEditorRef?.openModal(true)}
title="Открыть список точек"
>
Все точки
@ -334,7 +301,8 @@
</Button>
<Button
color="primary flex-fill"
color="primary"
class="flex-fill"
size="sm"
onclick={handleSaveCurrentPoint}
title="Сохранить текущие координаты"
@ -350,28 +318,25 @@
<InputGroup size="sm">
<Input
id="cp-latitude"
type="text"
bind:value={inputLat}
type="number"
step="0.000001"
bind:value={$FlightParametersStore.launch_latitude}
placeholder="Latitude"
on:change={() => {
isPointDirty = true;
}}
/>
<InputGroupText>/</InputGroupText>
<Input
id="cp-longitude"
type="text"
bind:value={inputLng}
type="number"
step="0.000001"
bind:value={$FlightParametersStore.launch_longitude}
placeholder="Longitude"
on:change={() => {
isPointDirty = true;
}}
/>
<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="cp-start-height" class="form-label">Высота старта (м):</Label>
@ -379,10 +344,7 @@
type="number"
id="cp-start-height"
class="form-control-sm"
on:change={() => {
isPointDirty = true;
}}
bind:value={inputAlt}
bind:value={$FlightParametersStore.launch_altitude}
/>
</FormGroup>
<FormGroup class="flex-fill w-50" spacing="mb-2">
@ -418,34 +380,8 @@
</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>
<!-- NOTE: Custom profile UI to be implemented -->
<p class="text-muted text-center small my-3">Custom profile editor is not yet implemented.</p>
{/if}
<div class="d-grid gap-1">
@ -454,4 +390,4 @@
</CardBody>
{/if}
</Card>
<PointEditor bind:this={PointEditorRef} onSelectPoint={handleSelectPointInModal} />
<PointEditor bind:this={pointEditorRef} onSelectPoint={(point: SavedPoint) => handlePointSelection(point.id)} />

View file

@ -54,6 +54,7 @@
const savedFlightParameters = savedData.flight_parameters;
// Compare flight parameters excluding launch_datetime
console.log("Comparing flight parameters:", flightParameters, savedFlightParameters);
return JSON.stringify(flightParameters) !== JSON.stringify(savedFlightParameters);
}
@ -112,6 +113,7 @@
$FlightParametersStore = selectedScenario.template_data.flight_parameters;
scenarioUnsaved = false;
writeLocalStorage("scenario", $ScenarioStore);
writeLocalStorage("flightParameters", $FlightParametersStore);
if (showToast) {
addToast({
header: "Сценарий применен",

View file

@ -52,6 +52,11 @@ export const getForecast = async (
flightParameters.dataset = getLatestDataset();
flightParameters.launch_datetime = launchDateTime;
if (flightParameters.start_point === -1) {
// remove start_point if it is -1
delete flightParameters.start_point;
}
console.log("Sending request:", flightParameters);
try {

View file

@ -35,7 +35,7 @@ export const clearLocalStorage = (key: string): void => {
}
}
const flightParametersDefaults: FlightParameters = {
export const flightParametersDefaults: FlightParameters = {
ascent_rate: 5.0,
burst_altitude: 30000.0,
dataset: "",
@ -52,7 +52,7 @@ export const FlightParametersStore = writable<FlightParameters>(
readLocalStorage<FlightParameters>("flightParameters", flightParametersDefaults)
);
const templateDataDefaults: TemplateData = {
export const templateDataDefaults: TemplateData = {
description: "",
prediction_mode: "",
model: "",