Initial implementation of custom profile editor + formatting

This commit is contained in:
ThePetrovich 2025-07-09 20:14:47 +08:00
parent 82b36f96d0
commit ffb27c2e0a
21 changed files with 3045 additions and 2034 deletions

View file

@ -2,5 +2,7 @@
"tabWidth": 4, "tabWidth": 4,
"endOfLine": "lf", "endOfLine": "lf",
"printWidth": 120, "printWidth": 120,
"useTabs": false "useTabs": true,
"htmlWhitespaceSensitivity": "ignore",
"bracketSameLine": true
} }

96
package-lock.json generated
View file

@ -11,17 +11,23 @@
"@sveltestrap/sveltestrap": "^7.1.0", "@sveltestrap/sveltestrap": "^7.1.0",
"@types/leaflet": "^1.9.19", "@types/leaflet": "^1.9.19",
"bootstrap-icons": "^1.13.1", "bootstrap-icons": "^1.13.1",
"chart.js": "^4.5.0",
"chartjs-adapter-luxon": "^1.3.1",
"chartjs-plugin-dragdata": "^2.3.1",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"leaflet-heatmap": "^1.0.0", "leaflet-heatmap": "^1.0.0",
"leaflet-timedimension": "^1.1.1", "leaflet-timedimension": "^1.1.1",
"leaflet-velocity": "^2.1.4", "leaflet-velocity": "^2.1.4",
"leaflet.heat": "^0.2.0" "leaflet.heat": "^0.2.0",
"luxon": "^3.6.1",
"svelte5-chartjs": "^1.0.0"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^4.0.0", "@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/kit": "^2.16.0", "@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/luxon": "^3.6.2",
"@vincjo/datatables": "^2.5.0", "@vincjo/datatables": "^2.5.0",
"svelte": "^5.34.8", "svelte": "^5.34.8",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
@ -484,6 +490,11 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="
},
"node_modules/@polka/url": { "node_modules/@polka/url": {
"version": "1.0.0-next.28", "version": "1.0.0-next.28",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.28.tgz", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.28.tgz",
@ -884,6 +895,12 @@
"@types/geojson": "*" "@types/geojson": "*"
} }
}, },
"node_modules/@types/luxon": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.6.2.tgz",
"integrity": "sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==",
"dev": true
},
"node_modules/@vincjo/datatables": { "node_modules/@vincjo/datatables": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/@vincjo/datatables/-/datatables-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@vincjo/datatables/-/datatables-2.5.0.tgz",
@ -935,6 +952,38 @@
} }
] ]
}, },
"node_modules/chart.js": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chartjs-adapter-luxon": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/chartjs-adapter-luxon/-/chartjs-adapter-luxon-1.3.1.tgz",
"integrity": "sha512-yxHov3X8y+reIibl1o+j18xzrcdddCLqsXhriV2+aQ4hCR66IYFchlRXUvrJVoxglJ380pgytU7YWtoqdIgqhg==",
"peerDependencies": {
"chart.js": ">=3.0.0",
"luxon": ">=1.0.0"
}
},
"node_modules/chartjs-plugin-dragdata": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/chartjs-plugin-dragdata/-/chartjs-plugin-dragdata-2.3.1.tgz",
"integrity": "sha512-CFD1e2d+gyH9EXb92qWu4Zb2zVoY4OtrbJYLMoGWOInE7ftoOD//4B0/k9IvKzQbdVU3JsPqUQI9KHcMpqQVfg==",
"dependencies": {
"d3-drag": "^3.0.0",
"d3-selection": "^3.0.0"
},
"peerDependencies": {
"chart.js": "^3.9.1 || ^4.0.1"
}
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@ -967,6 +1016,34 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"engines": {
"node": ">=12"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.0", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
@ -1163,6 +1240,14 @@
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==" "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="
}, },
"node_modules/luxon": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz",
"integrity": "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==",
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.17", "version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
@ -1399,6 +1484,15 @@
"typescript": ">=5.0.0" "typescript": ">=5.0.0"
} }
}, },
"node_modules/svelte5-chartjs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/svelte5-chartjs/-/svelte5-chartjs-1.0.0.tgz",
"integrity": "sha512-SMk+D5ECbsoeFurKE/Nr9sqD4H3WqZkQ4eLxwchDSh8gu7YSGN3ASXYCz9kzFhrH2QGQYpebHwLIMHg7FOI/7A==",
"peerDependencies": {
"chart.js": "^3.5.0 || ^4.0.0",
"svelte": "^5.0.0"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.14", "version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",

View file

@ -15,6 +15,7 @@
"@sveltejs/adapter-auto": "^4.0.0", "@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/kit": "^2.16.0", "@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/luxon": "^3.6.2",
"@vincjo/datatables": "^2.5.0", "@vincjo/datatables": "^2.5.0",
"svelte": "^5.34.8", "svelte": "^5.34.8",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
@ -25,11 +26,16 @@
"@sveltestrap/sveltestrap": "^7.1.0", "@sveltestrap/sveltestrap": "^7.1.0",
"@types/leaflet": "^1.9.19", "@types/leaflet": "^1.9.19",
"bootstrap-icons": "^1.13.1", "bootstrap-icons": "^1.13.1",
"chart.js": "^4.5.0",
"chartjs-adapter-luxon": "^1.3.1",
"chartjs-plugin-dragdata": "^2.3.1",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"leaflet-heatmap": "^1.0.0", "leaflet-heatmap": "^1.0.0",
"leaflet-timedimension": "^1.1.1", "leaflet-timedimension": "^1.1.1",
"leaflet-velocity": "^2.1.4", "leaflet-velocity": "^2.1.4",
"leaflet.heat": "^0.2.0" "leaflet.heat": "^0.2.0",
"luxon": "^3.6.1",
"svelte5-chartjs": "^1.0.0"
} }
} }

View file

@ -1,44 +1,44 @@
<script> <script>
import { Modal, ModalHeader, ModalBody, ModalFooter, Button } from "@sveltestrap/sveltestrap"; import { Modal, ModalHeader, ModalBody, ModalFooter, Button } from "@sveltestrap/sveltestrap";
let { let {
isOpen = $bindable(false), isOpen = $bindable(false),
title = 'Confirm Action', title = "Confirm Action",
confirmText = 'Confirm', confirmText = "Confirm",
cancelText = 'Cancel', cancelText = "Cancel",
confirmVariant = 'primary', confirmVariant = "primary",
cancelVariant = 'secondary', cancelVariant = "secondary",
onconfirm, onconfirm,
oncancel, oncancel,
children children,
} = $props(); } = $props();
function handleConfirm() { function handleConfirm() {
onconfirm?.(); onconfirm?.();
isOpen = false; isOpen = false;
} }
function handleCancel() { function handleCancel() {
oncancel?.(); oncancel?.();
isOpen = false; isOpen = false;
} }
</script> </script>
<Modal {isOpen} toggle={handleCancel} fade={false} backdrop={true}> <Modal {isOpen} toggle={handleCancel} fade={false} backdrop={true}>
<ModalHeader toggle={handleCancel}>{title}</ModalHeader> <ModalHeader toggle={handleCancel}>{title}</ModalHeader>
<ModalBody> <ModalBody>
{#if children} {#if children}
{@render children()} {@render children()}
{:else} {:else}
Вы действительно хотите продолжить? Вы действительно хотите продолжить?
{/if} {/if}
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button color={cancelVariant} on:click={handleCancel}> <Button color={cancelVariant} on:click={handleCancel}>
{cancelText} {cancelText}
</Button> </Button>
<Button color={confirmVariant} on:click={handleConfirm}> <Button color={confirmVariant} on:click={handleConfirm}>
{confirmText} {confirmText}
</Button> </Button>
</ModalFooter> </ModalFooter>
</Modal> </Modal>

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
/* /*
Component Naming and Style Conventions: Component Naming and Style Conventions:
1. **State Variables (`$state`)**: 1. **State Variables (`$state`)**:
@ -34,360 +34,356 @@
- Example: `import { SavedPointsStore } from '$lib/stores';` - Example: `import { SavedPointsStore } from '$lib/stores';`
- The reactive Svelte store prefix `$` is used as standard. - The reactive Svelte store prefix `$` is used as standard.
*/ */
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { import {
Button, Button,
Card, Card,
CardBody, CardBody,
CardHeader, CardHeader,
FormGroup, FormGroup,
Icon, Icon,
Input, Input,
InputGroup, InputGroup,
InputGroupText, InputGroupText,
Label, Label,
} from "@sveltestrap/sveltestrap"; } from "@sveltestrap/sveltestrap";
import { getSavedPoints, updatePoint } from "$lib/api/points"; import { getSavedPoints, updatePoint } from "$lib/api/points";
import { addToast } from "$lib/components/Toast.svelte"; import { addToast } from "$lib/components/Toast.svelte";
import PointEditor from "$lib/components/PointEditor.svelte"; import PointEditor from "$lib/components/PointEditor.svelte";
import SelectSearchable from "$lib/components/SelectSearchable.svelte"; import SelectSearchable from "$lib/components/SelectSearchable.svelte";
import { getForecast } from "$lib/prediction"; import { getForecast } from "$lib/prediction";
import { import {
FlightParametersStore, FlightParametersStore,
SavedPointsStore, SavedPointsStore,
writeLocalStorage, writeLocalStorage,
readLocalStorage, readLocalStorage,
flightParametersDefaults, flightParametersDefaults,
} from "$lib/stores"; } from "$lib/stores";
import { PROFILE_MAP, type FlightParameters, type SavedPoint } from "$lib/types"; import { PROFILE_MAP, type FlightParameters, type SavedPoint } from "$lib/types";
import CurveEditor from "./CurveEditor.svelte";
// Props // Props
interface Props { interface Props {
onSelectOnMapClick?: () => void; onSelectOnMapClick?: () => void;
} }
let { onSelectOnMapClick = () => console.log("Select on map clicked") }: Props = $props(); let { onSelectOnMapClick = () => console.log("Select on map clicked") }: Props = $props();
// State // State
let isCollapsed = $state(false); let isCollapsed = $state(false);
let startDate = $state(readLocalStorage<string>("startDate", new Date().toISOString().split("T")[0])); let startDate = $state(readLocalStorage<string>("startDate", new Date().toISOString().split("T")[0]));
let startTime = $state(readLocalStorage<string>("startTime", "12:00:00")); let startTime = $state(readLocalStorage<string>("startTime", "12:00:00"));
let selectedPointId = $state($FlightParametersStore.start_point || -1); let selectedPointId = $state($FlightParametersStore.start_point || -1);
// Component References // Component References
let pointEditorRef: PointEditor | null = null; let pointEditorRef: PointEditor | null = null;
let curveEditorRef: CurveEditor | null = null;
// Derived State // Derived State
let currentPoint = $derived($SavedPointsStore.find((p) => p.id === selectedPointId) || null); let currentPoint = $derived($SavedPointsStore.find((p) => p.id === selectedPointId) || null);
let isPointDirty = $derived(() => { let isPointDirty = $derived(() => {
if (!currentPoint) return false; // Not dirty if no point is selected if (!currentPoint) return false; // Not dirty if no point is selected
const latMatch = $FlightParametersStore.launch_latitude.toFixed(6) === currentPoint.lat.toFixed(6); const latMatch = $FlightParametersStore.launch_latitude.toFixed(6) === currentPoint.lat.toFixed(6);
const lonMatch = $FlightParametersStore.launch_longitude.toFixed(6) === currentPoint.lon.toFixed(6); const lonMatch = $FlightParametersStore.launch_longitude.toFixed(6) === currentPoint.lon.toFixed(6);
const altMatch = $FlightParametersStore.launch_altitude.toFixed(2) === currentPoint.alt.toFixed(2); const altMatch = $FlightParametersStore.launch_altitude.toFixed(2) === currentPoint.alt.toFixed(2);
return !(latMatch && lonMatch && altMatch); return !(latMatch && lonMatch && altMatch);
}); });
// Lifecycle Hooks // Lifecycle Hooks
onMount(() => { onMount(() => {
// NOTE: Consider moving localStorage logic into the store itself for better encapsulation. // NOTE: Consider moving localStorage logic into the store itself for better encapsulation.
$FlightParametersStore = $FlightParametersStore =
readLocalStorage<FlightParameters>("flightParameters", flightParametersDefaults) || $FlightParametersStore; readLocalStorage<FlightParameters>("flightParameters", flightParametersDefaults) || $FlightParametersStore;
selectedPointId = $FlightParametersStore.start_point || -1; selectedPointId = $FlightParametersStore.start_point || -1;
getSavedPoints() getSavedPoints()
.then((points) => SavedPointsStore.set(points)) .then((points) => SavedPointsStore.set(points))
.catch((error) => { .catch((error) => {
addToast({ addToast({
header: "Error Loading Points", header: "Error Loading Points",
body: `Failed to load saved points: ${error.message}`, body: `Failed to load saved points: ${error.message}`,
color: "danger", color: "danger",
}); });
}); });
}); });
onDestroy(() => { onDestroy(() => {
writeLocalStorage<FlightParameters>("flightParameters", $FlightParametersStore); writeLocalStorage<FlightParameters>("flightParameters", $FlightParametersStore);
writeLocalStorage<string>("startDate", startDate); writeLocalStorage<string>("startDate", startDate);
writeLocalStorage<string>("startTime", startTime); writeLocalStorage<string>("startTime", startTime);
}); });
// Event Handlers // Event Handlers
function handlePointSelection(newPointId: number) { function handlePointSelection(newPointId: number) {
console.log("Point selection changed:", newPointId); console.log("Point selection changed:", newPointId);
selectedPointId = newPointId; selectedPointId = newPointId;
const point = $SavedPointsStore.find((p) => p.id === newPointId); const point = $SavedPointsStore.find((p) => p.id === newPointId);
if (point) { if (point) {
console.log("Selected point:", point); console.log("Selected point:", point);
$FlightParametersStore.start_point = point.id; $FlightParametersStore.start_point = point.id;
$FlightParametersStore.launch_latitude = point.lat; $FlightParametersStore.launch_latitude = point.lat;
$FlightParametersStore.launch_longitude = point.lon; $FlightParametersStore.launch_longitude = point.lon;
$FlightParametersStore.launch_altitude = point.alt; $FlightParametersStore.launch_altitude = point.alt;
} else if (newPointId === -1) { } else if (newPointId === -1) {
$FlightParametersStore.start_point = -1; $FlightParametersStore.start_point = -1;
// When clearing the selection, we can reset to defaults or leave as is. // 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. // For now, we'll just update the ID. The user can manually edit coordinates.
} }
} }
function handleSaveCurrentPoint() { function handleSaveCurrentPoint() {
if (currentPoint) { if (currentPoint) {
// Update existing point // Update existing point
const updatedPointData = { const updatedPointData = {
...currentPoint, ...currentPoint,
lat: $FlightParametersStore.launch_latitude, lat: $FlightParametersStore.launch_latitude,
lon: $FlightParametersStore.launch_longitude, lon: $FlightParametersStore.launch_longitude,
alt: $FlightParametersStore.launch_altitude, alt: $FlightParametersStore.launch_altitude,
}; };
updatePoint(updatedPointData) updatePoint(updatedPointData)
.then((savedPoint) => { .then((savedPoint) => {
SavedPointsStore.update((points) => points.map((p) => (p.id === savedPoint.id ? savedPoint : p))); SavedPointsStore.update((points) => points.map((p) => (p.id === savedPoint.id ? savedPoint : p)));
addToast({ addToast({
header: "Point Updated", header: "Point Updated",
body: `Point "${savedPoint.name}" was successfully updated.`, body: `Point "${savedPoint.name}" was successfully updated.`,
color: "success", color: "success",
}); });
}) })
.catch((error) => { .catch((error) => {
addToast({ addToast({
header: "Update Error", header: "Update Error",
body: `Failed to update point: ${error.message}`, body: `Failed to update point: ${error.message}`,
color: "danger", color: "danger",
}); });
}); });
} else { } else {
// Create new point // Create new point
pointEditorRef?.openModalAndCreate( pointEditorRef?.openModalAndCreate(
null, null,
{ {
id: 0, id: 0,
name: `New Point ${new Date().toLocaleString()}`, name: `New Point ${new Date().toLocaleString()}`,
lat: $FlightParametersStore.launch_latitude, lat: $FlightParametersStore.launch_latitude,
lon: $FlightParametersStore.launch_longitude, lon: $FlightParametersStore.launch_longitude,
alt: $FlightParametersStore.launch_altitude, alt: $FlightParametersStore.launch_altitude,
}, },
true, true,
false, false,
(savedPoint) => { (savedPoint) => {
if (savedPoint) { if (savedPoint) {
handlePointSelection(savedPoint.id); handlePointSelection(savedPoint.id);
} }
}, },
); );
} }
} }
async function handlePredictionRequest() { async function handlePredictionRequest() {
// Persist current parameters before running prediction // Persist current parameters before running prediction
writeLocalStorage<FlightParameters>("flightParameters", $FlightParametersStore); writeLocalStorage<FlightParameters>("flightParameters", $FlightParametersStore);
try { try {
const data = await getForecast($FlightParametersStore, `${startDate}T${startTime}Z`); const data = await getForecast($FlightParametersStore, `${startDate}T${startTime}Z`);
console.log("Forecast request successful:", data); 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) { } catch (error: any) {
console.error("Error getting forecast:", error); 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" });
} }
} }
function handleToggleCollapse() { function handleToggleCollapse() {
isCollapsed = !isCollapsed; isCollapsed = !isCollapsed;
} }
// Public API // Public API
export function updateLaunchPosition(lat: number, lng: number) { export function updateLaunchPosition(lat: number, lng: number) {
$FlightParametersStore.launch_latitude = lat; $FlightParametersStore.launch_latitude = lat;
$FlightParametersStore.launch_longitude = lng; $FlightParametersStore.launch_longitude = lng;
} }
export function loadFlightParameters(params: FlightParameters) { export function loadFlightParameters(params: FlightParameters) {
$FlightParametersStore = params; $FlightParametersStore = params;
selectedPointId = params.start_point || -1; selectedPointId = params.start_point || -1;
} }
export function getFlightParameters(): FlightParameters { export function getFlightParameters(): FlightParameters {
return $FlightParametersStore; return $FlightParametersStore;
} }
export function collapsePanel() { export function collapsePanel() {
isCollapsed = true; isCollapsed = true;
} }
export function expandPanel() { export function expandPanel() {
isCollapsed = false; isCollapsed = false;
} }
export function togglePanel() { export function togglePanel() {
isCollapsed = !isCollapsed; isCollapsed = !isCollapsed;
} }
</script> </script>
<Card> <Card>
<CardHeader <CardHeader
class="bg-primary text-white d-flex justify-content-between align-items-center card-header p-1 px-3" class="bg-primary text-white d-flex justify-content-between align-items-center card-header p-1 px-3"
style="cursor:pointer;" style="cursor:pointer;"
onclick={handleToggleCollapse} onclick={handleToggleCollapse}>
> <b class="card-title mb-0 text-white p-0">Условия прогнозирования</b>
<b class="card-title mb-0 text-white p-0">Условия прогнозирования</b> <Button class="p-0" size="sm" color="primary" aria-label="Свернуть/развернуть условия прогнозирования">
<Button class="p-0" size="sm" color="primary" aria-label="Свернуть/развернуть условия прогнозирования"> <Icon name={isCollapsed ? "caret-left-fill" : "caret-down-fill"} class="text-white" />
<Icon name={isCollapsed ? "caret-left-fill" : "caret-down-fill"} class="text-white" /> </Button>
</Button> </CardHeader>
</CardHeader>
{#if !isCollapsed} {#if !isCollapsed}
<CardBody> <CardBody>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<FormGroup class="flex-fill w-50" spacing="mb-2"> <FormGroup class="flex-fill w-50" spacing="mb-2">
<Label for="cp-start-time" class="form-label">Время старта (UTC):</Label> <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" /> <Input type="time" id="cp-start-time" class="form-control-sm" bind:value={startTime} step="1" />
</FormGroup> </FormGroup>
<FormGroup class="flex-fill w-50" spacing="mb-2"> <FormGroup class="flex-fill w-50" spacing="mb-2">
<Label for="cp-start-date" class="form-label">Дата старта:</Label> <Label for="cp-start-date" class="form-label">Дата старта:</Label>
<Input type="date" id="cp-start-date" class="form-control-sm" bind:value={startDate} /> <Input type="date" id="cp-start-date" class="form-control-sm" bind:value={startDate} />
</FormGroup> </FormGroup>
</div> </div>
<FormGroup spacing="mb-2"> <FormGroup spacing="mb-2">
<Label for="cp-flight-profile" class="form-label">Профиль полета:</Label> <Label for="cp-flight-profile" class="form-label">Профиль полета:</Label>
<InputGroup size="sm"> <InputGroup size="sm">
<Input type="select" id="cp-flight-profile" bind:value={$FlightParametersStore.profile}> <Input type="select" id="cp-flight-profile" bind:value={$FlightParametersStore.profile}>
{#each Object.entries(PROFILE_MAP) as [name, value]} {#each Object.entries(PROFILE_MAP) as [name, value]}
<option {value}>{name}</option> <option {value}>{name}</option>
{/each} {/each}
</Input> </Input>
</InputGroup> </InputGroup>
</FormGroup> </FormGroup>
<FormGroup spacing="mb-2"> <FormGroup spacing="mb-2">
<Label for="cp-start-point" class="form-label">Точка старта:</Label> <Label for="cp-start-point" class="form-label">Точка старта:</Label>
<InputGroup size="sm"> <InputGroup size="sm">
<div class="position-relative flex-grow-1"> <div class="position-relative flex-grow-1">
<SelectSearchable <SelectSearchable
style="min-height: calc(1.5em + .5rem + 2px); padding: .25rem .5rem; font-size: .875rem;" style="min-height: calc(1.5em + .5rem + 2px); padding: .25rem .5rem; font-size: .875rem;"
id="cp-start-point" id="cp-start-point"
selected={selectedPointId} selected={selectedPointId}
onChange={(e) => handlePointSelection(e)} onChange={(e) => handlePointSelection(e)}
options={$SavedPointsStore.map((point) => ({ options={$SavedPointsStore.map((point) => ({
value: point.id, value: point.id,
label: `${point.name} ${point.id === selectedPointId && isPointDirty() ? "(изменено)" : ""}`, label: `${point.name} ${point.id === selectedPointId && isPointDirty() ? "(изменено)" : ""}`,
}))} }))}
placeholder="Новая точка..." placeholder="Новая точка..."
searchPlaceholder="Поиск по точкам..." searchPlaceholder="Поиск по точкам..." />
/> {#if selectedPointId !== -1}
{#if selectedPointId !== -1} <Button
<Button size="sm"
size="sm" color="white"
color="white" class="position-absolute top-50 end-0 translate-middle-y rounded-circle d-flex align-items-center justify-content-center"
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;"
style="width: 16px; height: 16px; border: none; background: var(--bs-secondary); color: var(--bs-white); z-index: 10; margin-right: 2rem;" on:click={() => handlePointSelection(-1)}
on:click={() => handlePointSelection(-1)} title="Clear selection">
title="Clear selection" <Icon name="x" style="font-size: 16px;" />
> </Button>
<Icon name="x" style="font-size: 16px;" /> {/if}
</Button> </div>
{/if} </InputGroup>
</div> </FormGroup>
</InputGroup>
</FormGroup>
<div class="d-flex gap-2 mb-2"> <div class="d-flex gap-2 mb-2">
<Button <Button
color="secondary" color="secondary"
class="flex-fill" class="flex-fill"
size="sm" size="sm"
onclick={() => pointEditorRef?.openModal(true)} onclick={() => pointEditorRef?.openModal(true)}
title="Открыть список точек" title="Открыть список точек">
> Все точки
Все точки <Icon name="journal-bookmark-fill" />
<Icon name="journal-bookmark-fill" /> </Button>
</Button>
<Button <Button
color="primary" color="primary"
class="flex-fill" class="flex-fill"
size="sm" size="sm"
onclick={handleSaveCurrentPoint} onclick={handleSaveCurrentPoint}
title="Сохранить текущие координаты" title="Сохранить текущие координаты"
disabled={!isPointDirty && selectedPointId !== -1} disabled={!isPointDirty && selectedPointId !== -1}>
> {selectedPointId !== -1 ? "Обновить точку" : "Сохранить точку"}
{selectedPointId !== -1 ? "Обновить точку" : "Сохранить точку"} <Icon name="floppy2-fill" />
<Icon name="floppy2-fill" /> </Button>
</Button> </div>
</div>
<FormGroup spacing="mb-2"> <FormGroup spacing="mb-2">
<Label for="cp-latitude" class="form-label">Широта/Долгота:</Label> <Label for="cp-latitude" class="form-label">Широта/Долгота:</Label>
<InputGroup size="sm"> <InputGroup size="sm">
<Input <Input
id="cp-latitude" id="cp-latitude"
type="number" type="number"
step="0.000001" step="0.000001"
bind:value={$FlightParametersStore.launch_latitude} bind:value={$FlightParametersStore.launch_latitude}
placeholder="Latitude" placeholder="Latitude" />
/> <InputGroupText>/</InputGroupText>
<InputGroupText>/</InputGroupText> <Input
<Input id="cp-longitude"
id="cp-longitude" type="number"
type="number" step="0.000001"
step="0.000001" bind:value={$FlightParametersStore.launch_longitude}
bind:value={$FlightParametersStore.launch_longitude} placeholder="Longitude" />
placeholder="Longitude" <Button color="secondary" size="sm" onclick={onSelectOnMapClick}>
/> <Icon name="geo-alt-fill" />
<Button color="secondary" size="sm" onclick={onSelectOnMapClick}> </Button>
<Icon name="geo-alt-fill" /> </InputGroup>
</Button> </FormGroup>
</InputGroup>
</FormGroup>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<FormGroup class="flex-fill w-50" spacing="mb-2"> <FormGroup class="flex-fill w-50" spacing="mb-2">
<Label for="cp-start-height" class="form-label">Высота старта (м):</Label> <Label for="cp-start-height" class="form-label">Высота старта (м):</Label>
<Input <Input
type="number" type="number"
id="cp-start-height" id="cp-start-height"
class="form-control-sm" class="form-control-sm"
bind:value={$FlightParametersStore.launch_altitude} bind:value={$FlightParametersStore.launch_altitude} />
/> </FormGroup>
</FormGroup> <FormGroup class="flex-fill w-50" spacing="mb-2">
<FormGroup class="flex-fill w-50" spacing="mb-2"> <Label for="cp-burst-altitude" class="form-label">Высота разрыва (м):</Label>
<Label for="cp-burst-altitude" class="form-label">Высота разрыва (м):</Label> <Input
<Input type="number"
type="number" id="cp-burst-altitude"
id="cp-burst-altitude" class="form-control-sm"
class="form-control-sm" bind:value={$FlightParametersStore.burst_altitude} />
bind:value={$FlightParametersStore.burst_altitude} </FormGroup>
/> </div>
</FormGroup>
</div>
{#if $FlightParametersStore.profile !== "custom_profile"} {#if $FlightParametersStore.profile !== "custom_profile"}
<div class="mb-2 d-flex gap-2"> <div class="mb-2 d-flex gap-2">
<FormGroup class="flex-fill w-50" spacing="mb-2"> <FormGroup class="flex-fill w-50" spacing="mb-2">
<Label for="cp-ascent-rate" class="form-label">Скорость подъема (м/с):</Label> <Label for="cp-ascent-rate" class="form-label">Скорость подъема (м/с):</Label>
<Input <Input
type="number" type="number"
id="cp-ascent-rate" id="cp-ascent-rate"
class="form-control-sm" class="form-control-sm"
bind:value={$FlightParametersStore.ascent_rate} bind:value={$FlightParametersStore.ascent_rate} />
/> </FormGroup>
</FormGroup> <FormGroup class="flex-fill w-50" spacing="mb-2">
<FormGroup class="flex-fill w-50" spacing="mb-2"> <Label for="cp-descent-rate" class="form-label">Скорость спуска (м/с):</Label>
<Label for="cp-descent-rate" class="form-label">Скорость спуска (м/с):</Label> <Input
<Input type="number"
type="number" id="cp-descent-rate"
id="cp-descent-rate" class="form-control-sm"
class="form-control-sm" bind:value={$FlightParametersStore.descent_rate} />
bind:value={$FlightParametersStore.descent_rate} </FormGroup>
/> </div>
</FormGroup> {:else}
</div> <!-- NOTE: Custom profile UI to be implemented -->
{:else} <p class="text-muted text-center small my-3">Custom profile editor is not yet implemented.</p>
<!-- NOTE: Custom profile UI to be implemented --> <Button size="sm" color="secondary" onclick={() => curveEditorRef?.openModal()} class="mb-2">
<p class="text-muted text-center small my-3">Custom profile editor is not yet implemented.</p> Открыть редактор кривых
{/if} <Icon name="graph-up-arrow" />
</Button>
{/if}
<div class="d-grid gap-1"> <div class="d-grid gap-1">
<Button size="sm" color="primary" onclick={handlePredictionRequest}>Выполнить прогнозирование</Button> <Button size="sm" color="primary" onclick={handlePredictionRequest}>Выполнить прогнозирование</Button>
</div> </div>
</CardBody> </CardBody>
{/if} {/if}
</Card> </Card>
<PointEditor bind:this={pointEditorRef} onSelectPoint={(point: SavedPoint) => handlePointSelection(point.id)} /> <PointEditor bind:this={pointEditorRef} onSelectPoint={(point: SavedPoint) => handlePointSelection(point.id)} />
<CurveEditor bind:this={curveEditorRef} showTable={true} editor={false}/>

View file

@ -0,0 +1,278 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { Chart, type TooltipItem } from "chart.js/auto";
import "chartjs-adapter-luxon";
import chartjsPluginDragdata from "chartjs-plugin-dragdata";
import type { SavedFlightProfile, RateCurvePoint } from "$lib/types";
import { DateTime } from "luxon";
Chart.register(chartjsPluginDragdata);
// Props
let {
curve,
onUpdate,
} = $props<{
curve: SavedFlightProfile;
onUpdate: (points: RateCurvePoint[]) => void;
}>();
// State
let canvasElement: HTMLCanvasElement;
let chart: Chart | null = $state(null);
// Reactive derived state for chart data
let chartData = $derived(calculateChartData(curve.rate_profile_data));
// def resolve_constraints_to_abs_time(constraints):
// """
// Convert relative constraints to absolute time constraints.
// Args:
// constraints: List of [time_constraint, altitude_constraint, vertical_rate]
// where -1 indicates no constraint
// Returns:
// List of [absolute_time, rate] pairs
// """
// abs_constraints = []
// current_time = 0
// current_alt = 0
// for constraint in constraints:
// time_constraint, alt_constraint, rate = constraint
// # Calculate time to reach this constraint
// if time_constraint != -1:
// if alt_constraint != -1:
// # Both time and altitude constraints exist
// time_for_alt = (alt_constraint - current_alt) / rate if rate != 0 else 0
// resolved_time = min(time_constraint, time_for_alt)
// else:
// # Only time constraint
// resolved_time = time_constraint
// else:
// # Only altitude constraint (or invalid case)
// if alt_constraint != -1:
// resolved_time = (alt_constraint - current_alt) / rate if rate != 0 else 0
// else:
// resolved_time = 0 # Invalid case, raise an error or handle as needed
// if resolved_time < 0:
// resolved_time = 0
// current_time += resolved_time
// current_alt += resolved_time * rate
// abs_constraints.append([current_time, rate])
// return abs_constraints
// # Usage:
// test_data2 = [
// [1000, 6000, 5],
// [-1, 14000, 4],
// [3000, -1, 0],
// [-1, 10000, -2],
// [-1, 40000, 3],
// [1000, 6000, -10],
// [-1, 14000, 4],
// [3000, -1, 0],
// [-1, 10000, -2],
// ]
// abs_constraints = resolve_constraints_to_abs_time(test_data2)
// def quick_propagator(abs_constraints):
// T = [0]
// A = [0] # Initialize with the starting altitude
// for i in range(len(abs_constraints)):
// A.append(A[-1] + ((abs_constraints[i][0]-T[-1]) * abs_constraints[i][1]))
// T.append(abs_constraints[i][0])
// return T, A
// T, A = quick_propagator(abs_constraints)
// plt.plot(T, A)
function calculateChartData(points: RateCurvePoint[]) {
const data: { x: number; y: number }[] = [];
let currentTime = 0;
let currentAltitude = 0;
data.push({ x: currentTime, y: currentAltitude });
for (const point of points) {
const { time_constraint, alt_constraint, rate } = point;
let resolved_time = 0;
if (time_constraint !== -1) {
if (alt_constraint !== -1) {
// Both time and altitude constraints exist
const time_for_alt = rate !== 0 ? (alt_constraint - currentAltitude) / rate : 0;
resolved_time = Math.min(time_constraint, time_for_alt);
} else {
// Only time constraint
resolved_time = time_constraint;
}
} else {
// Only altitude constraint (or invalid case)
if (alt_constraint !== -1) {
resolved_time = rate !== 0 ? (alt_constraint - currentAltitude) / rate : 0;
} else {
resolved_time = 0; // Invalid case
}
}
if (resolved_time < 0) {
resolved_time = 0; // Prevent time from going backwards
}
currentTime += resolved_time;
currentAltitude += resolved_time * rate;
data.push({ x: currentTime, y: currentAltitude });
}
return data;
}
function updateChart() {
if (!chart) return;
chart.data.datasets[0].data = chartData;
chart.update("none");
}
function handleDragEnd(e: any, datasetIndex: number, index: number, value: { x: number; y: number }) {
if (index === 0) {
// Prevent dragging the start point
updateChart(); // Revert change
return;
}
// Prevent dragging past neighbor points on the X axis
const prevPointX = chartData[index - 1].x;
const nextPointX = chartData[index + 1] ? chartData[index + 1].x : Infinity;
if (value.x <= prevPointX || value.x >= nextPointX) {
updateChart();
return;
}
const newPoints = JSON.parse(JSON.stringify(curve.rate_profile_data));
const pointToUpdate = newPoints[index - 1];
const prevPointData = chartData[index - 1];
const newSegmentDuration = value.x - prevPointData.x;
const newAltitude = value.y;
const newAltDiff = newAltitude - prevPointData.y;
// Update altitude constraint if it exists
if (pointToUpdate.alt_constraint !== -1) {
pointToUpdate.alt_constraint = Math.round(newAltitude);
}
// Update time constraint if it exists
if (pointToUpdate.time_constraint !== -1) {
pointToUpdate.time_constraint = Math.round(newSegmentDuration);
}
// Always recalculate the rate based on the new position.
// The logic in calculateChartData will then determine if time or altitude is the driving constraint.
if (newSegmentDuration > 0) {
pointToUpdate.rate = parseFloat((newAltDiff / newSegmentDuration).toFixed(2));
} else {
pointToUpdate.rate = 0;
}
onUpdate(newPoints);
}
onMount(() => {
const ctx = canvasElement.getContext("2d");
if (!ctx) return;
chart = new Chart(ctx, {
type: "line",
data: {
datasets: [
{
label: "Профиль высоты",
data: chartData,
borderColor: "rgb(75, 192, 192)",
backgroundColor: "rgba(75, 192, 192, 0.5)",
stepped: false,
fill: false,
pointRadius: 5,
pointHoverRadius: 7,
pointBackgroundColor: "rgb(75, 192, 192)",
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
type: "linear",
position: "bottom",
title: {
display: true,
text: "Время от старта T0+ (сек)",
},
},
y: {
title: {
display: true,
text: "Высота над ур. моря (м)",
},
},
},
plugins: {
dragData: {
round: 0,
onDragEnd: handleDragEnd,
dragX: true, // Enable horizontal dragging
},
tooltip: {
callbacks: {
label: function (context: TooltipItem<"line">) {
let label = context.dataset.label || "";
if (label) {
label += ": ";
}
if (context.parsed.y !== null) {
label += `${context.parsed.y.toFixed(2)} m`;
}
if (context.parsed.x !== null) {
const duration = DateTime.fromSeconds(context.parsed.x);
const timeString = duration.toFormat("HH:mm:ss");
label += ` at ${timeString}`;
}
return label;
},
},
},
},
},
} as any);
});
$effect(() => {
if (chart) {
updateChart();
}
});
onDestroy(() => {
chart?.destroy();
});
</script>
<div style="position: relative; height: 100%; min-height: 250px;">
<canvas bind:this={canvasElement}></canvas>
{#if !chart}
<div
class="text-center text-muted"
style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">
Loading chart...
</div>
{/if}
</div>

View file

@ -0,0 +1,607 @@
<script lang="ts">
import { TableHandler } from "@vincjo/datatables";
import {
Modal,
Button,
FormGroup,
Label,
Input,
Alert,
Icon,
Pagination,
PaginationItem,
PaginationLink,
InputGroup,
Table,
} from "@sveltestrap/sveltestrap";
import { onMount } from "svelte";
import { addToast } from "$lib/components/Toast.svelte";
import type { SavedFlightProfile, RateCurvePoint } from "$lib/types";
import { SavedFlightProfilesStore } from "$lib/stores";
import ConfirmationPrompt from "./ConfirmationPrompt.svelte";
// import { getSavedCurves, saveCurve, updateCurve, deleteCurve } from "$lib/api/curves";
import EditableCell from "./EditableCell.svelte";
import CurveChart from "./CurveChart.svelte";
// Mock API functions for now
const getSavedCurves = async (): Promise<SavedFlightProfile[]> => {
console.log("Fetching saved curves");
return [];
};
const saveCurve = async (curve: SavedFlightProfile): Promise<SavedFlightProfile> => {
console.log("Saving curve", curve);
const newCurve = { ...curve, id: Date.now() };
return newCurve;
};
const updateCurve = async (curve: SavedFlightProfile): Promise<SavedFlightProfile> => {
console.log("Updating curve", curve);
return curve;
};
const deleteCurve = async (id: number): Promise<void> => {
console.log("Deleting curve", id);
};
// Props
let {
isOpen = $bindable(false),
onClose = () => {},
onSave = (p: SavedFlightProfile) => {},
onSelectCurve = (p: SavedFlightProfile) => {},
showTable = false,
curve = null,
editor = false,
closeOnSave = false,
closeOnDelete = false,
} = $props();
// Runes
let selectedCurve = $derived<SavedFlightProfile | null>(curve);
let newCurve = $state<SavedFlightProfile>({ id: 0, name: "", rate_profile_data: [] });
let newPoint = $state<RateCurvePoint>({ order: 0, time_constraint: 0, alt_constraint: 0, rate: 0 });
let isEditing = $state(editor);
let isAlertVisible = $state(false);
let isConfirmationVisible = $state(false);
let alertText = $state("");
let closeOnSave_ = $state(closeOnSave);
// Table handler
let curvesTable = $derived(new TableHandler($SavedFlightProfilesStore, { rowsPerPage: 5 }));
let search = $derived(curvesTable.createSearch(["name"]));
$effect(() => {
if (showTable) {
getSavedCurves().then((curves) => {
$SavedFlightProfilesStore = curves;
SavedFlightProfilesStore.set($SavedFlightProfilesStore);
});
}
if (editor && curve) {
selectedCurve = curve;
newCurve = { ...curve };
isEditing = true;
}
});
// Ensure curve points are always sorted by the order field
$effect(() => {
newCurve.rate_profile_data.sort((a, b) => a.order - b.order);
});
// On mount, fetch curves
onMount(async () => {
if (showTable) {
const curves = await getSavedCurves();
$SavedFlightProfilesStore = curves;
SavedFlightProfilesStore.set($SavedFlightProfilesStore);
}
});
// Modal controls
export function openModal(table_: boolean = false) {
showTable = table_;
isOpen = true;
}
export function openModalAndCreate(
curve: SavedFlightProfile | null = null,
close: boolean = false,
table_: boolean = false,
onSaveCallback: (curve: SavedFlightProfile) => void = () => {},
) {
if (curve) {
selectedCurve = curve;
newCurve = { ...curve, rate_profile_data: [...curve.rate_profile_data] };
isEditing = true;
} else {
selectedCurve = null;
newCurve = { id: 0, name: "", rate_profile_data: [] };
isEditing = false;
}
showTable = table_;
isOpen = true;
closeOnSave_ = close;
onSave = onSaveCallback;
}
function closeModal() {
isOpen = false;
if (closeOnSave_ != closeOnSave) {
closeOnSave = closeOnSave_;
}
onClose();
}
function handleEditCurve(curve: SavedFlightProfile) {
selectedCurve = curve;
newCurve = { ...curve, rate_profile_data: [...curve.rate_profile_data] };
isEditing = true;
showTable = false; // Switch to editor view
}
function confirmDeleteCurve(curve: SavedFlightProfile) {
selectedCurve = curve;
isConfirmationVisible = true;
}
function handleDeleteCurve(curve: SavedFlightProfile | null) {
if (!curve) return;
deleteCurve(curve.id)
.then(() => {
$SavedFlightProfilesStore = $SavedFlightProfilesStore.filter((p) => p.id !== curve.id);
SavedFlightProfilesStore.set($SavedFlightProfilesStore);
addToast({
header: "Curve deleted",
body: `Curve "${curve.name}" has been deleted.`,
color: "success",
});
if (closeOnDelete) {
closeModal();
}
})
.catch((error) => {
showAlert(`Error deleting curve: ${error.message}`);
});
}
export function handleSaveCurve() {
if (isEditing && selectedCurve) {
updateCurve(newCurve)
.then((updatedCurve) => {
$SavedFlightProfilesStore = $SavedFlightProfilesStore.map((p) =>
p.id === updatedCurve.id ? updatedCurve : p,
);
SavedFlightProfilesStore.set($SavedFlightProfilesStore);
addToast({
header: "Curve updated",
body: `Curve "${updatedCurve.name}" has been updated.`,
color: "success",
});
if (closeOnSave_) {
closeModal();
}
onSave(updatedCurve);
})
.catch((error) => {
showAlert(`Error updating curve: ${error.message}`);
});
} else {
saveCurve(newCurve)
.then((savedCurve) => {
$SavedFlightProfilesStore = [...$SavedFlightProfilesStore, savedCurve];
SavedFlightProfilesStore.set($SavedFlightProfilesStore);
addToast({
header: "Curve saved",
body: `Curve "${savedCurve.name}" has been saved.`,
color: "success",
});
if (closeOnSave_) {
closeModal();
}
onSave(savedCurve);
resetForm();
})
.catch((error) => {
showAlert(`Error saving curve: ${error.message}`);
});
}
}
function validateConstraints(point: RateCurvePoint): boolean {
if (point.time_constraint <= 0 && point.time_constraint !== -1) {
showAlert("Time constraint invalid, must be > 0 or -1 for no constraint.");
return false;
}
if (point.alt_constraint < 0 && point.alt_constraint !== -1) {
showAlert("Altitude constraint invalid, must be >= 0 or -1 for no constraint.");
return false;
}
if (point.alt_constraint === -1 && point.time_constraint === -1) {
showAlert("At least one constraint must be set (time or altitude).");
return false;
}
return true;
}
function addPoint() {
if (validateConstraints(newPoint)) {
const maxOrder = newCurve.rate_profile_data.reduce((max, p) => Math.max(max, p.order), -1);
newPoint.order = maxOrder + 1;
newCurve.rate_profile_data.push({ ...newPoint });
newCurve.rate_profile_data = newCurve.rate_profile_data; // Svelte reactivity
newPoint = { order: 0, time_constraint: 0, alt_constraint: 0, rate: 0 };
isAlertVisible = false; // Hide alert after successful addition
}
}
function removePoint(index: number) {
newCurve.rate_profile_data.splice(index, 1);
// Re-index the order of remaining points
newCurve.rate_profile_data.forEach((point, i) => {
point.order = i;
});
newCurve.rate_profile_data = newCurve.rate_profile_data; // Svelte reactivity
}
function handleFileUpload(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result as string;
try {
const rate_profile_data: RateCurvePoint[] = text
.split("\n")
.filter((line) => line.trim() !== "")
.map((line, index) => {
const [order, time_constraint, alt_constraint, rate] = line.split(",").map(Number);
if (isNaN(time_constraint) || isNaN(alt_constraint) || isNaN(rate)) {
throw new Error("Invalid number in CSV file.");
}
// Use file line order as the canonical order
return { order: index, time_constraint, alt_constraint, rate };
});
newCurve.rate_profile_data = rate_profile_data;
addToast({
header: "CSV imported",
body: `${rate_profile_data.length} rate_profile_data loaded.`,
color: "success",
});
} catch (error: any) {
showAlert(`Error parsing CSV: ${error.message}`);
}
};
reader.readAsText(file);
}
export function showAlert(message: string) {
isAlertVisible = true;
alertText = message;
}
export function hideAlert() {
isAlertVisible = false;
alertText = "";
}
export function resetForm() {
selectedCurve = null;
newCurve = { id: 0, name: "", rate_profile_data: [] };
isEditing = false;
hideAlert();
}
function movePoint(index: number, direction: number) {
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= newCurve.rate_profile_data.length) return;
// Swap order values
const tempOrder = newCurve.rate_profile_data[index].order;
newCurve.rate_profile_data[index].order = newCurve.rate_profile_data[newIndex].order;
newCurve.rate_profile_data[newIndex].order = tempOrder;
// Trigger reactivity, the $effect will sort the array
newCurve.rate_profile_data = [...newCurve.rate_profile_data];
}
</script>
<Modal
{isOpen}
toggle={closeModal}
size="xl"
fade={false}
backdrop={true}
scrollable
class={isConfirmationVisible ? "modal-tinted" : ""}>
<div class="modal-header">
<h5 class="modal-title">
{showTable ? "Ascent/Descent Curves" : isEditing ? "Edit Curve" : "Create New Curve"}
</h5>
<Button close onclick={closeModal} />
</div>
<div class="modal-body">
{#if showTable}
<!-- Curve Selection Table -->
<div class="d-flex justify-content-between mb-2">
<InputGroup>
<Input
type="text"
placeholder="Search by name..."
bind:value={search.value}
oninput={() => search.set()} />
<Button
onclick={() => {
search.value = "";
search.set();
}}>
<Icon name="x" />
</Button>
</InputGroup>
<Button
color="primary"
on:click={() => {
showTable = false;
isEditing = false;
resetForm();
}}>
<Icon name="plus-lg" class="me-1" /> Create New
</Button>
</div>
<div bind:this={curvesTable.element} class="table-responsive">
<Table class="table-sm mb-0">
<thead>
<tr>
<th style="width: 70%;">Name</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each curvesTable.rows as curve (curve.id)}
<tr>
<td>{curve.name}</td>
<td>
<Button size="sm" color="primary" on:click={() => onSelectCurve(curve)}>
<Icon name="check-lg" />
</Button>
<Button
size="sm"
color="secondary"
on:click={() => handleEditCurve(curve)}
class="ms-1">
<Icon name="pencil" />
</Button>
<Button
size="sm"
color="danger"
on:click={() => confirmDeleteCurve(curve)}
class="ms-1">
<Icon name="trash" />
</Button>
</td>
</tr>
{/each}
</tbody>
</Table>
</div>
<Pagination aria-label="Page navigation" size="sm">
<PaginationItem>
<PaginationLink previous on:click={() => curvesTable.setPage("previous")} />
</PaginationItem>
{#each curvesTable.pagesWithEllipsis as page}
<PaginationItem active={curvesTable.currentPage === page}>
<PaginationLink on:click={() => curvesTable.setPage(page)}>{page}</PaginationLink>
</PaginationItem>
{/each}
<PaginationItem>
<PaginationLink next on:click={() => curvesTable.setPage("next")} />
</PaginationItem>
</Pagination>
{:else}
<!-- Curve Editor -->
<!-- Points Table -->
<div class="row">
<div class="col-lg-6">
<div class="mb-2">
<Label for="name" class="small">Curve Name:</Label>
<Input class="form-control-sm" type="text" id="name" bind:value={newCurve.name} required />
</div>
<h6>Точки профиля</h6>
<Alert
color="danger"
isOpen={isAlertVisible}
toggle={() => (isAlertVisible = false)}
fade={false}
class="mb-2">
<Icon name="exclamation-triangle" class="me-2" />
{alertText}
</Alert>
<div class="table-responsive small" style="max-height: 300px;" bind:this={curvesTable.element}>
<table class="table table-sm border mb-0">
<thead>
<tr>
<th style="width: 49.8px;"></th>
<th>
Время (сек)
<span
title="Время в секундах от предыдущей точки"
class="ms-1 text-muted"
style="cursor: help;">
<Icon name="info-circle-fill" />
</span>
</th>
<th>
Высота (м)
<span
title="Высота в метрах над уровнем моря"
class="ms-1 text-muted"
style="cursor: help;">
<Icon name="info-circle-fill" />
</span>
</th>
<th>
Скорость (м/с)
<span
title="Вертикальная скорость в метрах в секунду (положительная - подъем, отрицательная - спуск)"
class="ms-1 text-muted"
style="cursor: help;">
<Icon name="info-circle-fill" />
</span>
</th>
<th></th>
</tr>
</thead>
<tbody>
{#each newCurve.rate_profile_data as point, i (point.order)}
{@const isFirst = i === 0}
{@const isLast = i === newCurve.rate_profile_data.length - 1}
<tr style="height: 36.8px; vertical-align: middle;">
<td class="text-center align-middle" style="cursor: grab; width: 49.8px;">
<div class="d-flex flex-row">
<Button
size="sm"
class="p-0 border-0 bg-transparent text-body px-1"
on:click={() => movePoint(i, -1)}
disabled={isFirst}>
<Icon name="chevron-up" />
</Button>
<Button
size="sm"
class="p-0 border-0 bg-transparent text-body px-1"
on:click={() => movePoint(i, 1)}
disabled={isLast}>
<Icon name="chevron-down" />
</Button>
</div>
</td>
<EditableCell
bind:value={point.time_constraint}
onchange={() => (newCurve.rate_profile_data = newCurve.rate_profile_data)}
valuePrefix="+"
valueSuffix=" сек"
emptyValue={-1} />
<EditableCell
bind:value={point.alt_constraint}
onchange={() => (newCurve.rate_profile_data = newCurve.rate_profile_data)}
valueSuffix=" м"
emptyValue={-1} />
<EditableCell
bind:value={point.rate}
onchange={() => (newCurve.rate_profile_data = newCurve.rate_profile_data)}
valueSuffix=" м/c" />
<td class="text-center align-middle">
<Button
size="sm"
color="danger"
on:click={() => removePoint(i)}
class="p-0 border-0 bg-transparent text-danger px-1"
style="cursor: pointer; font-size: initial;">
<Icon name="trash" />
</Button>
</td>
</tr>
{:else}
<tr style="height: 36.8px; vertical-align: middle;">
<td colspan="5" class="text-center text-muted">No points added yet</td>
</tr>
{/each}
</tbody>
<tfoot>
<tr>
<td></td>
<td>
<Input
class="form-control-sm"
type="number"
placeholder="Time (s)"
bind:value={newPoint.time_constraint} />
</td>
<td>
<Input
class="form-control-sm"
type="number"
placeholder="Altitude (m)"
bind:value={newPoint.alt_constraint} />
</td>
<td>
<Input
class="form-control-sm"
type="number"
placeholder="Rate (m/s)"
bind:value={newPoint.rate} />
</td>
<td class="text-center align-middle">
<Button
size="sm"
color="success"
on:click={addPoint}
class="p-0 border-0 bg-transparent px-1 text-success"
style="cursor: pointer; display: table;">
Добавить
</Button>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
<div class="col-lg-6">
<CurveChart
curve={newCurve}
onUpdate={(updatedPoints: RateCurvePoint[]) => {
newCurve.rate_profile_data = updatedPoints;
}} />
</div>
</div>
<!-- Import/Export -->
<div class="d-flex justify-content-between">
<div>
<Label for="import-csv" class="small">Import from CSV</Label>
<Input
type="file"
id="import-csv"
accept=".csv"
on:change={handleFileUpload}
class="form-control-sm" />
</div>
</div>
<hr />
<div class="d-grid gap-2 d-md-flex justify-content-end">
{#if showTable}
<Button
color="secondary"
size="sm"
on:click={() => {
showTable = true;
resetForm();
}}>
Back to List
</Button>
{/if}
<Button type="submit" color="success" size="sm" onclick={handleSaveCurve}>
{isEditing ? "Update Curve" : "Save New Curve"}
</Button>
</div>
{/if}
</div>
</Modal>
<ConfirmationPrompt
isOpen={isConfirmationVisible}
title="Confirm Deletion"
confirmText="Delete"
cancelText="Cancel"
confirmVariant="danger"
onconfirm={() => {
isConfirmationVisible = false;
handleDeleteCurve(selectedCurve);
}}
oncancel={() => {
isConfirmationVisible = false;
}}>
<p>Are you sure you want to delete this curve?</p>
</ConfirmationPrompt>

View file

@ -0,0 +1,45 @@
<script lang="ts">
let { value = $bindable(), onchange = () => {}, valuePrefix = "", valueSuffix = "", emptyValue=null, emptyPlaceholder="-" } = $props();
let editing = $state(false);
let inputEl: HTMLInputElement | undefined = $state();
$effect(() => {
if (inputEl) inputEl.focus();
});
function startEditing() {
editing = true;
}
function stopEditing() {
editing = false;
onchange();
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Enter") {
stopEditing();
} else if (event.key === "Escape") {
editing = false;
}
}
</script>
<td onclick={startEditing} onfocusin={startEditing}>
{#if editing}
<input
type="number"
class="form-control form-control-sm border-0"
bind:this={inputEl}
bind:value
onblur={stopEditing}
onkeydown={handleKeydown} />
{:else}
{#if value === emptyValue || value === null || value === undefined}
<span class="text-muted">{emptyPlaceholder}</span>
{:else}
<span>{valuePrefix}{value}{valueSuffix}</span>
{/if}
{/if}
</td>

View file

@ -1,36 +1,35 @@
<!-- Footer --> <!-- Footer -->
<footer class="bg-dark text-bg-dark mt-auto"> <footer class="bg-dark text-bg-dark mt-auto">
<div class="container pt-5"> <div class="container pt-5">
<div class="row gy-5"> <div class="row gy-5">
<div class="col-lg-3 mw-lg-2"> <div class="col-lg-3 mw-lg-2">
<div class="mb-4"> <div class="mb-4">
<a class="navbar-brand" href="/"> <a class="navbar-brand" href="/">
<img <img
src="/logo-full-ru-dark.svg" src="/logo-full-ru-dark.svg"
class="d-inline-block align-middle img-fluid" class="d-inline-block align-middle img-fluid"
alt="ООО «ЯКС»" alt="ООО «ЯКС»"
width="250" width="250" />
/> </a>
</a> </div>
</div> </div>
</div> <div class="col-lg-8 offset-lg-1"></div>
<div class="col-lg-8 offset-lg-1"> </div>
</div> </div>
</div> <div class="container pb-4">
</div> <div class="row">
<div class="container pb-4"> <div class="col-6 small">
<div class="row"> <div>Copyright © 2024 ООО «Якутские Космические Системы»</div>
<div class="col-6 small"> </div>
<div>Copyright © 2024 ООО «Якутские Космические Системы»</div> <div class="col-6 text-end small">
</div> <div>
<div class="col-6 text-end small"> <p>
<div> <a class="text-decoration-none" href="/usage_policy">Условия использования</a>
<p> -
<a class="text-decoration-none" href="/usage_policy">Условия использования</a> - <a class="text-decoration-none" href="/privacy">Политика конфиденциальности</a>
<a class="text-decoration-none" href="/privacy">Политика конфиденциальности</a> </p>
</p> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
</footer> </footer>

View file

@ -1,154 +1,158 @@
<script lang="ts"> <script lang="ts">
import { onMount, createEventDispatcher } from "svelte"; import { onMount, createEventDispatcher } from "svelte";
import * as L from "leaflet"; import * as L from "leaflet";
import { ruler, Ruler } from "$lib/ext/leaflet-ruler/leaflet-ruler"; import { ruler, Ruler } from "$lib/ext/leaflet-ruler/leaflet-ruler";
import type { Map as LeafletMap, LayerGroup } from "leaflet"; import type { Map as LeafletMap, LayerGroup } from "leaflet";
import "leaflet/dist/leaflet.css"; import "leaflet/dist/leaflet.css";
import WindVisualization from "$lib/components/WindVisualisation.svelte"; import WindVisualization from "$lib/components/WindVisualisation.svelte";
import { distHaversine } from "$lib/mathutil"; import { distHaversine } from "$lib/mathutil";
import type { Prediction, Telemetry } from "$lib/types"; import type { Prediction, Telemetry } from "$lib/types";
export let mode: "prediction" | "telemetry" = "prediction"; export let mode: "prediction" | "telemetry" = "prediction";
export let data: Prediction | Telemetry | null = null; export let data: Prediction | Telemetry | null = null;
let map: LeafletMap; let map: LeafletMap;
let mapContainer: HTMLDivElement; let mapContainer: HTMLDivElement;
let plotLayerGroup: LayerGroup; let plotLayerGroup: LayerGroup;
let mouseLat = 0; let mouseLat = 0;
let mouseLng = 0; let mouseLng = 0;
let isSelecting = false; let isSelecting = false;
let windData: any; let windData: any;
const dispatch = createEventDispatcher<{ coordinatesSelected: { lat: number; lng: number } }>(); const dispatch = createEventDispatcher<{ coordinatesSelected: { lat: number; lng: number } }>();
onMount(async () => { onMount(async () => {
if (!mapContainer) return; if (!mapContainer) return;
map = L.map(mapContainer, { zoomControl: false }).setView([51.505, -0.09], 13); map = L.map(mapContainer, { zoomControl: false }).setView([51.505, -0.09], 13);
L.control.zoom({ position: "bottomleft" }).addTo(map); L.control.zoom({ position: "bottomleft" }).addTo(map);
plotLayerGroup = L.layerGroup().addTo(map); plotLayerGroup = L.layerGroup().addTo(map);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors', attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map); }).addTo(map);
ruler({ ruler({
position: "bottomright", position: "bottomright",
}).addTo(map); }).addTo(map);
const response = await fetch("src/routes/testVelo.json"); const response = await fetch("src/routes/testVelo.json");
windData = await response.json(); windData = await response.json();
map.on("mousemove", (e: any) => { map.on("mousemove", (e: any) => {
mouseLat = e.latlng.lat; mouseLat = e.latlng.lat;
mouseLng = e.latlng.lng; mouseLng = e.latlng.lng;
}); });
map.on("click", (e: any) => { map.on("click", (e: any) => {
if (isSelecting) { if (isSelecting) {
dispatch("coordinatesSelected", { lat: e.latlng.lat, lng: e.latlng.lng }); dispatch("coordinatesSelected", { lat: e.latlng.lat, lng: e.latlng.lng });
stopSelection(); stopSelection();
} }
}); });
}); });
$: if (map && data) { $: if (map && data) {
plotData(data); plotData(data);
} else if (map) { } else if (map) {
clearMapLayers(); clearMapLayers();
} }
export const startSelection = () => { export const startSelection = () => {
isSelecting = true; isSelecting = true;
if (mapContainer) mapContainer.style.cursor = "crosshair"; if (mapContainer) mapContainer.style.cursor = "crosshair";
}; };
export const stopSelection = () => { export const stopSelection = () => {
isSelecting = false; isSelecting = false;
if (mapContainer) mapContainer.style.cursor = ""; if (mapContainer) mapContainer.style.cursor = "";
}; };
export const plotData = (plotData: Prediction | Telemetry) => { export const plotData = (plotData: Prediction | Telemetry) => {
if (mode === "prediction") { if (mode === "prediction") {
plotPrediction(plotData as Prediction); plotPrediction(plotData as Prediction);
} else if (mode === "telemetry") { } else if (mode === "telemetry") {
plotTelemetry(plotData as Telemetry); plotTelemetry(plotData as Telemetry);
} }
}; };
export const clearMapLayers = () => { export const clearMapLayers = () => {
plotLayerGroup?.clearLayers(); plotLayerGroup?.clearLayers();
}; };
const launchIcon = L.icon({ iconUrl: "target-blue.png", iconSize: [10, 10], iconAnchor: [5, 5] }); const launchIcon = L.icon({ iconUrl: "target-blue.png", iconSize: [10, 10], iconAnchor: [5, 5] });
const landIcon = L.icon({ iconUrl: "target-red.png", iconSize: [10, 10], iconAnchor: [5, 5] }); const landIcon = L.icon({ iconUrl: "target-red.png", iconSize: [10, 10], iconAnchor: [5, 5] });
const burstIcon = L.icon({ iconUrl: "pop-marker.png", iconSize: [16, 16], iconAnchor: [8, 8] }); const burstIcon = L.icon({ iconUrl: "pop-marker.png", iconSize: [16, 16], iconAnchor: [8, 8] });
const telemetryIcon = L.icon({ iconUrl: "marker-sm-red.png", iconSize: [10, 10], iconAnchor: [5, 5] }); const telemetryIcon = L.icon({ iconUrl: "marker-sm-red.png", iconSize: [10, 10], iconAnchor: [5, 5] });
const plotPrediction = (prediction: Prediction) => { const plotPrediction = (prediction: Prediction) => {
const { launch, landing, burst, flight_path, flight_time } = prediction; const { launch, landing, burst, flight_path, flight_time } = prediction;
const range = distHaversine(launch.latlng, landing.latlng, 1); const range = distHaversine(launch.latlng, landing.latlng, 1);
const f_hours = Math.floor(flight_time / 3600); const f_hours = Math.floor(flight_time / 3600);
const f_minutes = Math.floor((flight_time % 3600) / 60).toString().padStart(2, "0"); const f_minutes = Math.floor((flight_time % 3600) / 60)
const flighttime = `${f_hours}hr${f_minutes}`; .toString()
.padStart(2, "0");
const flighttime = `${f_hours}hr${f_minutes}`;
L.marker(launch.latlng, { title: `Launch`, icon: launchIcon }).addTo(plotLayerGroup); L.marker(launch.latlng, { title: `Launch`, icon: launchIcon }).addTo(plotLayerGroup);
L.marker(landing.latlng, { title: `Landing`, icon: landIcon }).addTo(plotLayerGroup); L.marker(landing.latlng, { title: `Landing`, icon: landIcon }).addTo(plotLayerGroup);
L.marker(burst.latlng, { title: `Burst`, icon: burstIcon }).addTo(plotLayerGroup); L.marker(burst.latlng, { title: `Burst`, icon: burstIcon }).addTo(plotLayerGroup);
L.polyline(flight_path, { weight: 3, color: "#000000" }).addTo(plotLayerGroup); L.polyline(flight_path, { weight: 3, color: "#000000" }).addTo(plotLayerGroup);
map?.fitBounds(L.latLngBounds(flight_path)); map?.fitBounds(L.latLngBounds(flight_path));
}; };
const plotTelemetry = (telemetry: Telemetry) => { const plotTelemetry = (telemetry: Telemetry) => {
L.marker(telemetry.launch.latlng, { title: `Launch`, icon: launchIcon }).addTo(plotLayerGroup); L.marker(telemetry.launch.latlng, { title: `Launch`, icon: launchIcon }).addTo(plotLayerGroup);
telemetry.datapoints.forEach((point) => { telemetry.datapoints.forEach((point) => {
L.marker([point.latitude, point.longitude], { L.marker([point.latitude, point.longitude], {
title: `Telemetry at ${point.datetime}`, title: `Telemetry at ${point.datetime}`,
icon: telemetryIcon, icon: telemetryIcon,
}) })
.bindPopup(`<b>Telemetry Point</b><br>Lat: ${point.latitude.toFixed(6)}<br>Lon: ${point.longitude.toFixed(6)}`) .bindPopup(
.addTo(plotLayerGroup); `<b>Telemetry Point</b><br>Lat: ${point.latitude.toFixed(6)}<br>Lon: ${point.longitude.toFixed(6)}`,
}); )
.addTo(plotLayerGroup);
});
L.polyline(telemetry.flight_path, { weight: 3, color: "#000000" }).addTo(plotLayerGroup); L.polyline(telemetry.flight_path, { weight: 3, color: "#000000" }).addTo(plotLayerGroup);
map?.fitBounds(L.latLngBounds(telemetry.flight_path)); map?.fitBounds(L.latLngBounds(telemetry.flight_path));
}; };
export const panTo = (lat: number, lng: number) => { export const panTo = (lat: number, lng: number) => {
if (map) { if (map) {
map.setView([lat, lng], map.getZoom()); map.setView([lat, lng], map.getZoom());
} }
}; };
export const zoomTo = (lat: number, lng: number, zoomLevel: number) => { export const zoomTo = (lat: number, lng: number, zoomLevel: number) => {
if (map) { if (map) {
map.setView([lat, lng], zoomLevel); map.setView([lat, lng], zoomLevel);
} }
}; };
export const getMap = () => { export const getMap = () => {
return map; return map;
}; };
</script> </script>
<div class="map-container" bind:this={mapContainer}> <div class="map-container" bind:this={mapContainer}>
<div class="card coordinates-display"> <div class="card coordinates-display">
<p class="card-text"> <p class="card-text">
<b>Lat:</b> <b>Lat:</b>
{mouseLat.toFixed(6)}, {mouseLat.toFixed(6)},
<b>Lon:</b> <b>Lon:</b>
{mouseLng.toFixed(6)} {mouseLng.toFixed(6)}
</p> </p>
</div> </div>
<slot /> <slot />
{#if map && windData} {#if map && windData}
<WindVisualization {map} windData={windData} /> <WindVisualization {map} {windData} />
{/if} {/if}
</div> </div>

View file

@ -1,113 +1,112 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from "svelte";
import { goto } from '$app/navigation'; import { goto } from "$app/navigation";
import { page } from '$app/stores'; import { page } from "$app/stores";
import { checkAuthenticated, logout, whoami } from '$lib/auth'; import { checkAuthenticated, logout, whoami } from "$lib/auth";
import { import {
Collapse, Collapse,
Dropdown, Dropdown,
DropdownItem, DropdownItem,
DropdownMenu, DropdownMenu,
DropdownToggle, DropdownToggle,
Nav, Nav,
NavItem, NavItem,
NavLink, NavLink,
Navbar, Navbar,
NavbarBrand, NavbarBrand,
NavbarToggler NavbarToggler,
} from '@sveltestrap/sveltestrap'; } from "@sveltestrap/sveltestrap";
// State for the navbar toggler // State for the navbar toggler
let isOpen = false; let isOpen = false;
// Authentication state // Authentication state
let isAuthenticated: boolean | null = null; // null represents the initial, unknown state let isAuthenticated: boolean | null = null; // null represents the initial, unknown state
let user: string | null = null; let user: string | null = null;
onMount(async () => { onMount(async () => {
try { try {
const authStatus = await checkAuthenticated(); const authStatus = await checkAuthenticated();
isAuthenticated = authStatus; isAuthenticated = authStatus;
if (authStatus) { if (authStatus) {
user = await whoami(); user = await whoami();
} else { } else {
user = null; user = null;
if ($page.url.pathname !== '/') if ($page.url.pathname !== "/") goto("/login"); // Redirect to login if not authenticated
goto('/login'); // Redirect to login if not authenticated }
} } catch (error) {
} catch (error) { console.error("Authentication check failed:", error);
console.error('Authentication check failed:', error); isAuthenticated = false;
isAuthenticated = false; user = null;
user = null; if ($page.url.pathname !== "/") goto("/login"); // Redirect to login if not authenticated
if ($page.url.pathname !== '/') }
goto('/login'); // Redirect to login if not authenticated });
}
});
function handleLogout() { function handleLogout() {
try { try {
logout(); logout();
isAuthenticated = false; isAuthenticated = false;
user = null; user = null;
goto('/'); goto("/");
} catch (error) { } catch (error) {
console.error('Logout failed:', error); console.error("Logout failed:", error);
} }
} }
</script> </script>
<Navbar color="light" light expand="lg" fixed="top" class="custom-navbar border-bottom"> <Navbar color="light" light expand="lg" fixed="top" class="custom-navbar border-bottom">
<NavbarBrand href="/" class="nav-full-height"> <NavbarBrand href="/" class="nav-full-height">
<img src="/logo.svg" alt="Logo" height="34" class="d-inline-block align-text-top" /> <img src="/logo.svg" alt="Logo" height="34" class="d-inline-block align-text-top" />
</NavbarBrand> </NavbarBrand>
<NavbarToggler on:click={() => (isOpen = !isOpen)} /> <NavbarToggler on:click={() => (isOpen = !isOpen)} />
<div class="navbar-collapse collapse" class:show={isOpen} id="navbarContent"> <div class="navbar-collapse collapse" class:show={isOpen} id="navbarContent">
<Nav class="me-auto mb-lg-0" navbar> <Nav class="me-auto mb-lg-0" navbar>
<NavItem> <NavItem>
<NavLink <NavLink
href="/predict" href="/predict"
class="nav-full-height border border-top-0" class="nav-full-height border border-top-0"
active={$page.url.pathname === '/predict'}> active={$page.url.pathname === "/predict"}>
Прогнозирование Прогнозирование
</NavLink> </NavLink>
</NavItem> </NavItem>
<NavItem> <NavItem>
<NavLink <NavLink
href="/track" href="/track"
class="nav-full-height border border-top-0" class="nav-full-height border border-top-0"
active={$page.url.pathname === '/track'}> active={$page.url.pathname === "/track"}>
Слежение Слежение
</NavLink> </NavLink>
</NavItem> </NavItem>
</Nav> </Nav>
<Nav navbar> <Nav navbar>
{#if isAuthenticated === true && user} {#if isAuthenticated === true && user}
<Dropdown nav inNavbar> <Dropdown nav inNavbar>
<DropdownToggle nav caret class="nav-full-height border border-top-0"> <DropdownToggle nav caret class="nav-full-height border border-top-0">
{user ?? 'Пользователь'} {user ?? "Пользователь"}
</DropdownToggle> </DropdownToggle>
<DropdownMenu end> <DropdownMenu end>
<DropdownItem href="/user/account">Учетная запись</DropdownItem> <DropdownItem href="/user/account">Учетная запись</DropdownItem>
<DropdownItem href="/user/templates">Сохраненные сценарии</DropdownItem> <DropdownItem href="/user/templates">Сохраненные сценарии</DropdownItem>
<DropdownItem href="/user/predictions">История прогнозов</DropdownItem> <DropdownItem href="/user/predictions">История прогнозов</DropdownItem>
<DropdownItem href="/user/flights">История слежения</DropdownItem> <DropdownItem href="/user/flights">История слежения</DropdownItem>
<DropdownItem divider /> <DropdownItem divider />
<DropdownItem on:click={handleLogout}>Выйти</DropdownItem> <DropdownItem on:click={handleLogout}>Выйти</DropdownItem>
</DropdownMenu> </DropdownMenu>
</Dropdown> </Dropdown>
{:else if isAuthenticated === false} {:else if isAuthenticated === false}
<NavItem> <NavItem>
<NavLink <NavLink
href="/login" href="/login"
class="nav-full-height border border-top-0" class="nav-full-height border border-top-0"
active={$page.url.pathname === '/login'}> active={$page.url.pathname === "/login"}>
Войти Войти
</NavLink> </NavLink>
</NavItem> </NavItem>
{/if} {/if}
<!-- While isAuthenticated is null (loading), nothing is rendered in this block --> <!-- While isAuthenticated is null (loading), nothing is rendered in this block -->
</Nav> </Nav>
</div> </div>
</Navbar> </Navbar>
<style> <style>

View file

@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
export let element: HTMLDivElement | null = null; export let element: HTMLDivElement | null = null;
export function getElement() { export function getElement() {
return element; return element;
} }
</script> </script>
<div bind:this={element} class="panel-container"> <div bind:this={element} class="panel-container">
<slot /> <slot />
</div> </div>

View file

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

View file

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

View file

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

View file

@ -1,50 +1,49 @@
<script lang="ts"> <script lang="ts">
import { Icon } from '@sveltestrap/sveltestrap'; import { Icon } from "@sveltestrap/sveltestrap";
type Tab = { type Tab = {
id: string; id: string;
icon: string; icon: string;
label: string; label: string;
}; };
/** An array of tab objects to display. */ /** An array of tab objects to display. */
export let tabs: Tab[] = []; export let tabs: Tab[] = [];
/** The id of the currently active tab. Use `bind:activeTab` for two-way binding. */ /** The id of the currently active tab. Use `bind:activeTab` for two-way binding. */
export let activeTab: string; export let activeTab: string;
</script> </script>
<div class="d-flex justify-content-start mb-1 gap-1"> <div class="d-flex justify-content-start mb-1 gap-1">
{#each tabs as tab (tab.id)} {#each tabs as tab (tab.id)}
<button <button
class="custom-tab btn btn-outline-primary d-flex flex-column align-items-center justify-content-center px-1" class="custom-tab btn btn-outline-primary d-flex flex-column align-items-center justify-content-center px-1"
class:active={activeTab === tab.id} class:active={activeTab === tab.id}
on:click={() => (activeTab = tab.id)} on:click={() => (activeTab = tab.id)}
type="button" type="button">
> <Icon name={tab.icon} class="custom-tab-icon" />
<Icon name={tab.icon} class="custom-tab-icon" /> <span class="custom-tab-label">{tab.label}</span>
<span class="custom-tab-label">{tab.label}</span> </button>
</button> {/each}
{/each}
</div> </div>
<style> <style>
.custom-tab { .custom-tab {
width: 4.5rem; width: 4.5rem;
background: var(--bs-body-bg); background: var(--bs-body-bg);
} }
.custom-tab.active { .custom-tab.active {
background-color: var(--bs-primary) !important; background-color: var(--bs-primary) !important;
color: var(--bs-btn-active-color); color: var(--bs-btn-active-color);
} }
.custom-tab:hover { .custom-tab:hover {
background-color: var(--bs-primary) !important; background-color: var(--bs-primary) !important;
color: var(--bs-btn-active-color); color: var(--bs-btn-active-color);
} }
.custom-tab-label { .custom-tab-label {
font-size: 0.66rem; font-size: 0.66rem;
font-weight: 500; font-weight: 500;
} }
</style> </style>

View file

@ -1,97 +1,82 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from "svelte";
import { import { Card, CardHeader, CardBody, Button, FormGroup, Label, Input, InputGroup } from "@sveltestrap/sveltestrap";
Card, //import { telemetryStore } from '../lib/telemetry.ts'; // Import your telemetry store
CardHeader,
CardBody,
Button,
FormGroup,
Label,
Input,
InputGroup,
} from '@sveltestrap/sveltestrap';
//import { telemetryStore } from '../lib/telemetry.ts'; // Import your telemetry store
let telemetry: { latitude?: number; longitude?: number; altitude?: number } = {}; let telemetry: { latitude?: number; longitude?: number; altitude?: number } = {};
let isCollapsed = false; let isCollapsed = false;
// Subscribe to the telemetry store // Subscribe to the telemetry store
//const unsubscribe = telemetryStore.subscribe((data) => { //const unsubscribe = telemetryStore.subscribe((data) => {
// telemetry = data; // telemetry = data;
//}); //});
telemetry = { telemetry = {
latitude: 56.3576, latitude: 56.3576,
longitude: 39.8666, longitude: 39.8666,
altitude: 1000, altitude: 1000,
}; };
// onMount(() => { // onMount(() => {
// return () => { // return () => {
// unsubscribe(); // Cleanup subscription on component destroy // unsubscribe(); // Cleanup subscription on component destroy
// }; // };
// }); // });
</script> </script>
<Card> <Card>
<CardHeader <CardHeader
class="bg-primary text-white d-flex justify-content-between align-items-center p-1 px-3" class="bg-primary text-white d-flex justify-content-between align-items-center p-1 px-3"
style="cursor:pointer;" style="cursor:pointer;">
> <b class="card-title mb-0 p-0">Последние данные телеметрии</b>
<b class="card-title mb-0 p-0">Последние данные телеметрии</b> <Button class="p-0" size="sm" color="primary" on:click={() => (isCollapsed = !isCollapsed)}>
<Button class="p-0" size="sm" color="primary" on:click={() => (isCollapsed = !isCollapsed)}> {#if isCollapsed}
{#if isCollapsed} <svg
<svg xmlns="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg" width="16"
width="16" height="16"
height="16" fill="currentColor"
fill="currentColor" class="bi bi-caret-left-fill"
class="bi bi-caret-left-fill" viewBox="0 0 16 16">
viewBox="0 0 16 16" <path
> d="m3.86 8.753 5.482 4.796c.646.566 1.658.106 1.658-.753V3.204a1 1 0 0 0-1.659-.753l-5.48 4.796a1 1 0 0 0 0 1.506z" />
<path </svg>
d="m3.86 8.753 5.482 4.796c.646.566 1.658.106 1.658-.753V3.204a1 1 0 0 0-1.659-.753l-5.48 4.796a1 1 0 0 0 0 1.506z" {:else}
/> <svg
</svg> xmlns="http://www.w3.org/2000/svg"
{:else} width="16"
<svg height="16"
xmlns="http://www.w3.org/2000/svg" fill="currentColor"
width="16" class="bi bi-caret-down"
height="16" viewBox="0 0 16 16">
fill="currentColor" <path
class="bi bi-caret-down" d="M7.247 11.14 2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z" />
viewBox="0 0 16 16" </svg>
> {/if}
<path </Button>
d="M7.247 11.14 2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z" </CardHeader>
/> {#if !isCollapsed}
</svg> <CardBody>
{/if} <FormGroup spacing="mb-2">
</Button> <Label class="small">Широта:</Label>
</CardHeader> <InputGroup size="sm">
{#if !isCollapsed} <Input type="text" value={telemetry.latitude || "N/A"} readonly />
<CardBody> </InputGroup>
<FormGroup spacing="mb-2"> </FormGroup>
<Label class="small">Широта:</Label>
<InputGroup size="sm">
<Input type="text" value={telemetry.latitude || 'N/A'} readonly />
</InputGroup>
</FormGroup>
<FormGroup spacing="mb-2"> <FormGroup spacing="mb-2">
<Label class="small">Долгота:</Label> <Label class="small">Долгота:</Label>
<InputGroup size="sm"> <InputGroup size="sm">
<Input type="text" value={telemetry.longitude || 'N/A'} readonly /> <Input type="text" value={telemetry.longitude || "N/A"} readonly />
</InputGroup> </InputGroup>
</FormGroup> </FormGroup>
<FormGroup spacing="mb-2"> <FormGroup spacing="mb-2">
<Label class="small">Высота (м):</Label> <Label class="small">Высота (м):</Label>
<InputGroup size="sm"> <InputGroup size="sm">
<Input type="text" value={telemetry.altitude || 'N/A'} readonly /> <Input type="text" value={telemetry.altitude || "N/A"} readonly />
</InputGroup> </InputGroup>
</FormGroup> </FormGroup>
</CardBody> </CardBody>
{/if} {/if}
</Card> </Card>

View file

@ -1,71 +1,70 @@
<script context="module"> <script context="module">
import { writable } from 'svelte/store'; import { writable } from "svelte/store";
/** /**
* @typedef {'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark'} ToastColor * @typedef {'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark'} ToastColor
* @typedef {object} ToastMessage * @typedef {object} ToastMessage
* @property {string} id - Unique identifier * @property {string} id - Unique identifier
* @property {string} header - Toast title * @property {string} header - Toast title
* @property {string} body - Toast message content * @property {string} body - Toast message content
* @property {ToastColor} [color='info'] - The color of the toast header icon * @property {ToastColor} [color='info'] - The color of the toast header icon
* @property {boolean} [persistent=false] - If true, toast will not auto-close * @property {boolean} [persistent=false] - If true, toast will not auto-close
* @property {function} [onRemoveCallback=null] - Callback function to be called when the toast is removed * @property {function} [onRemoveCallback=null] - Callback function to be called when the toast is removed
*/ */
/** @type {import('svelte/store').Writable<ToastMessage[]>} */ /** @type {import('svelte/store').Writable<ToastMessage[]>} */
export const toasts = writable([]); export const toasts = writable([]);
const TOAST_ICONS = { const TOAST_ICONS = {
primary: 'info-circle-fill', primary: "info-circle-fill",
secondary: 'info-circle-fill', secondary: "info-circle-fill",
success: 'check-circle-fill', success: "check-circle-fill",
danger: 'exclamation-triangle-fill', danger: "exclamation-triangle-fill",
warning: 'exclamation-circle-fill', warning: "exclamation-circle-fill",
info: 'info-circle-fill', info: "info-circle-fill",
light: 'lightbulb', light: "lightbulb",
dark: 'question' dark: "question",
}; };
/** /**
* Adds a new toast to the list. * Adds a new toast to the list.
* @param {Omit<ToastMessage, 'id'>} toast * @param {Omit<ToastMessage, 'id'>} toast
* @returns {string} The ID of the new toast. * @returns {string} The ID of the new toast.
*/ */
export function addToast(toast) { export function addToast(toast) {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
toasts.update((all) => [...all, { id, ...toast }]); toasts.update((all) => [...all, { id, ...toast }]);
return id; return id;
} }
/** /**
* Removes a toast by its ID. * Removes a toast by its ID.
* @param {string} id * @param {string} id
*/ */
export function removeToast(id) { export function removeToast(id) {
// call the onRemoveCallback if it exists // call the onRemoveCallback if it exists
toasts.update((all) => { toasts.update((all) => {
const toast = all.find((t) => t.id === id); const toast = all.find((t) => t.id === id);
if (toast && toast.onRemoveCallback) { if (toast && toast.onRemoveCallback) {
toast.onRemoveCallback(id); toast.onRemoveCallback(id);
} }
return all.filter((t) => t.id !== id); return all.filter((t) => t.id !== id);
}); });
} }
/** /**
* Callback function to be called when a toast is removed. * Callback function to be called when a toast is removed.
* @param {string} id - The ID of the removed toast. * @param {string} id - The ID of the removed toast.
*/ */
</script> </script>
<script> <script>
import { Toast, ToastBody, ToastHeader, Icon } from '@sveltestrap/sveltestrap'; import { Toast, ToastBody, ToastHeader, Icon } from "@sveltestrap/sveltestrap";
/**
* Removes a toast from the list by its ID.
* @param {string} id
*/
/**
* Removes a toast from the list by its ID.
* @param {string} id
*/
</script> </script>
<!-- <!--
@ -83,27 +82,30 @@
addToast({ header: 'Map Mode', body: 'You are in satellite view.', color: 'info', persistent: true }); addToast({ header: 'Map Mode', body: 'You are in satellite view.', color: 'info', persistent: true });
--> -->
<div class="toast-container position-fixed bottom-0 end-0 p-3"> <div class="toast-container position-fixed bottom-0 end-0 p-3">
{#each $toasts as toast (toast.id)} {#each $toasts as toast (toast.id)}
<Toast <Toast
isOpen={true} isOpen={true}
autohide={!toast.persistent} autohide={!toast.persistent}
delay={5000} delay={5000}
color={toast.color || 'info'} color={toast.color || "info"}
on:close={() => removeToast(toast.id)} on:close={() => removeToast(toast.id)}>
> <ToastHeader toggle={() => removeToast(toast.id)} class={`text-${toast.color || "text-info"}`}>
<ToastHeader toggle={() => removeToast(toast.id)} class={`text-${toast.color || 'text-info'}`}> <Icon
<Icon slot="icon" name={TOAST_ICONS[toast.color ? toast.color : 'info']} class="me-2" color={toast.color || 'info'} /> slot="icon"
{toast.header} name={TOAST_ICONS[toast.color ? toast.color : "info"]}
</ToastHeader> class="me-2"
<ToastBody> color={toast.color || "info"} />
{toast.body} {toast.header}
</ToastBody> </ToastHeader>
</Toast> <ToastBody>
{/each} {toast.body}
</ToastBody>
</Toast>
{/each}
</div> </div>
<style> <style>
.toast-container { .toast-container {
z-index: 1090; /* High z-index to appear above other elements */ z-index: 1090; /* High z-index to appear above other elements */
} }
</style> </style>

View file

@ -1,190 +1,191 @@
<script> <script>
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from "svelte";
import L from 'leaflet'; import L from "leaflet";
import 'leaflet/dist/leaflet.css'; import "leaflet/dist/leaflet.css";
import 'leaflet-velocity/dist/leaflet-velocity.css'; import "leaflet-velocity/dist/leaflet-velocity.css";
import 'leaflet-velocity/dist/leaflet-velocity'; import "leaflet-velocity/dist/leaflet-velocity";
import 'leaflet.heat'; import "leaflet.heat";
import 'leaflet-timedimension'; import "leaflet-timedimension";
export let map; // принимаем карту из родительского компонента export let map; // принимаем карту из родительского компонента
export let windData; export let windData;
let timeDimension; let timeDimension;
let timeDimensionControl; let timeDimensionControl;
let velocityLayer; let velocityLayer;
let heatLayer; let heatLayer;
let legend; let legend;
// Состояние переключателей // Состояние переключателей
let showHeatmap = true; let showHeatmap = true;
let showVectors = true; let showVectors = true;
let layerControl; let layerControl;
// Преобразование testVelo.json в формат timeData // Преобразование testVelo.json в формат timeData
const prepareTimeData = (windData) => { const prepareTimeData = (windData) => {
if (!windData || windData.length < 2) return {}; if (!windData || windData.length < 2) return {};
// Используем дату из header или текущую дату, если не указана // Используем дату из header или текущую дату, если не указана
const refTime = windData[0]?.header?.refTime || new Date().toISOString(); const refTime = windData[0]?.header?.refTime || new Date().toISOString();
return { return {
[refTime]: { [refTime]: {
u: windData[0].data, // U-компонента (первый объект в массиве) u: windData[0].data, // U-компонента (первый объект в массиве)
v: windData[1].data // V-компонента (второй объект) v: windData[1].data, // V-компонента (второй объект)
} },
}; };
}; };
// Функция для нормализации данных тепловой карты
const prepareHeatData = (windData) => {
if (!windData || windData.length < 2) {
console.warn("Invalid wind data structure");
return [];
}
// Функция для нормализации данных тепловой карты // Получаем U и V компоненты
const prepareHeatData = (windData) => { const uComponent = windData.find((item) => item.header.parameterNumber === 2);
if (!windData || windData.length < 2) { const vComponent = windData.find((item) => item.header.parameterNumber === 3);
console.warn("Invalid wind data structure");
return [];
}
// Получаем U и V компоненты if (!uComponent || !vComponent) {
const uComponent = windData.find(item => item.header.parameterNumber === 2); console.warn("Missing wind components");
const vComponent = windData.find(item => item.header.parameterNumber === 3); return [];
}
if (!uComponent || !vComponent) { const header = uComponent.header; // Используем header из U компоненты
console.warn("Missing wind components"); const { lo1, la1, dx, dy, nx, ny } = header;
return []; const heatData = [];
} let maxSpeed = 0;
const header = uComponent.header; // Используем header из U компоненты // Проверяем совпадение размеров данных
const { lo1, la1, dx, dy, nx, ny } = header; if (uComponent.data.length !== vComponent.data.length) {
const heatData = []; console.warn("U and V components have different lengths");
let maxSpeed = 0; return [];
}
// Проверяем совпадение размеров данных // Собираем данные и находим максимальную скорость
if (uComponent.data.length !== vComponent.data.length) { for (let i = 0; i < uComponent.data.length; i++) {
console.warn("U and V components have different lengths"); const u = uComponent.data[i];
return []; const v = vComponent.data[i];
} const speed = Math.sqrt(u * u + v * v);
// Собираем данные и находим максимальную скорость if (!isNaN(speed)) {
for (let i = 0; i < uComponent.data.length; i++) { // Вычисляем координаты для текущей точки
const u = uComponent.data[i]; const y = Math.floor(i / nx);
const v = vComponent.data[i]; const x = i % nx;
const speed = Math.sqrt(u * u + v * v); let lat = la1 - y * dy;
let lng = lo1 + x * dx;
if (lng >= 180) lng -= 360;
heatData.push([lat, lng, speed]);
maxSpeed = Math.max(maxSpeed, speed);
}
}
if (!isNaN(speed)) { console.log(`Prepared heat data: ${heatData.length} points, max speed: ${maxSpeed}`);
// Вычисляем координаты для текущей точки
const y = Math.floor(i / nx);
const x = i % nx;
let lat = la1 - y * dy;
let lng = lo1 + x * dx;
if (lng >= 180) lng -= 360;
heatData.push([lat, lng, speed]);
maxSpeed = Math.max(maxSpeed, speed);
}
}
console.log(`Prepared heat data: ${heatData.length} points, max speed: ${maxSpeed}`); // Нормализуем значения интенсивности от 0 до 1
if (maxSpeed > 0) {
return heatData.map(([lat, lng, intensity]) => [lat, lng, intensity / maxSpeed]);
}
// Нормализуем значения интенсивности от 0 до 1 return heatData;
if (maxSpeed > 0) { };
return heatData.map(([lat, lng, intensity]) => [lat, lng, intensity / maxSpeed]);
}
return heatData; // Создание тепловой карты
}; const createHeatLayer = (data) => {
if (!data || data.length === 0) {
console.warn("No valid heat data provided");
return null;
}
// Создание тепловой карты try {
const createHeatLayer = (data) => { return L.heatLayer(data, {
if (!data || data.length === 0) { radius: 8, // Увеличьте радиус для глобальной карты
console.warn("No valid heat data provided"); blur: 20,
return null; // maxZoom: 10,
} minOpacity: 0.7,
gradient: {
0.1: "blue",
0.3: "cyan",
0.5: "lime",
0.7: "yellow",
1.0: "red",
},
});
} catch (e) {
console.error("Failed to create heat layer:", e);
return null;
}
};
try { // Обновление слоев
return L.heatLayer(data, { const updateLayers = () => {
radius: 8, // Увеличьте радиус для глобальной карты if (!map || !windData) return;
blur: 20,
// maxZoom: 10,
minOpacity: 0.7,
gradient: {
0.1: 'blue',
0.3: 'cyan',
0.5: 'lime',
0.7: 'yellow',
1.0: 'red'
}
});
} catch (e) {
console.error("Failed to create heat layer:", e);
return null;
}
};
// Обновление слоев // Удаляем старые слои
const updateLayers = () => { if (velocityLayer) map.removeLayer(velocityLayer);
if (!map || !windData) return; if (heatLayer) map.removeLayer(heatLayer);
if (legend) map.removeControl(legend);
// Удаляем старые слои // Создаем слой векторов ветра
if (velocityLayer) map.removeLayer(velocityLayer); if (showVectors) {
if (heatLayer) map.removeLayer(heatLayer); velocityLayer = L.velocityLayer({
if (legend) map.removeControl(legend); displayValues: true,
displayOptions: {
velocityType: "Wind Speed",
position: "bottomright",
emptyString: "No wind data",
},
data: windData,
}).addTo(map);
}
// Создаем слой векторов ветра // Создаем тепловую карту
if (showVectors) { if (showHeatmap) {
velocityLayer = L.velocityLayer({ const heatData = prepareHeatData(windData);
displayValues: true, heatLayer = createHeatLayer(heatData);
displayOptions: {
velocityType: 'Wind Speed',
position: 'bottomright',
emptyString: 'No wind data',
},
data: windData
}).addTo(map);
}
// Создаем тепловую карту if (heatLayer) {
if (showHeatmap) { heatLayer.addTo(map);
const heatData = prepareHeatData(windData); createLegend(Math.max(...heatData.map((point) => point[2])));
heatLayer = createHeatLayer(heatData); }
}
if (heatLayer) { // Обновляем контроль слоев
heatLayer.addTo(map); updateLayerControl();
createLegend(Math.max(...heatData.map(point => point[2]))); };
}
}
// Обновляем контроль слоев const updateLayerControl = () => {
updateLayerControl(); if (layerControl) {
}; map.removeControl(layerControl);
}
const updateLayerControl = () => { const overlays = {};
if (layerControl) {
map.removeControl(layerControl);
}
const overlays = {}; if (velocityLayer) {
overlays["Векторы ветра"] = velocityLayer;
}
if (velocityLayer) { if (heatLayer) {
overlays['Векторы ветра'] = velocityLayer; overlays["Тепловая карта"] = heatLayer;
} }
if (heatLayer) { layerControl = L.control
overlays['Тепловая карта'] = heatLayer; .layers(null, overlays, {
} collapsed: false,
position: "topright",
})
.addTo(map);
};
// Создание легенды с учетом максимальной скорости
const createLegend = (maxSpeed) => {
if (!map) return;
layerControl = L.control.layers(null, overlays, { legend = L.control({ position: "bottomright" });
collapsed: false,
position: 'topright'
}).addTo(map);
};
// Создание легенды с учетом максимальной скорости
const createLegend = (maxSpeed) => {
if (!map) return;
legend = L.control({ position: 'bottomright' }); legend.onAdd = () => {
const div = L.DomUtil.create("div", "wind-heat-legend");
legend.onAdd = () => { div.innerHTML = `
const div = L.DomUtil.create('div', 'wind-heat-legend');
div.innerHTML = `
<h4>Wind Speed (m/s)</h4> <h4>Wind Speed (m/s)</h4>
<div class="legend-scale"> <div class="legend-scale">
<div class="legend-color" style="background: #0000FF;"></div> <div class="legend-color" style="background: #0000FF;"></div>
@ -201,136 +202,144 @@
<span>${maxSpeed.toFixed(1)}</span> <span>${maxSpeed.toFixed(1)}</span>
</div> </div>
`; `;
return div; return div;
}; };
legend.addTo(map); legend.addTo(map);
}; };
onMount(() => { onMount(() => {
if (!map) return; if (!map) return;
// 1. Настройка TimeDimension (добавьте эти строки в начале) // 1. Настройка TimeDimension (добавьте эти строки в начале)
// L.TimeDimension.Util.setProxy('https://your-proxy.com/?url='); // Для загрузки больших данных // L.TimeDimension.Util.setProxy('https://your-proxy.com/?url='); // Для загрузки больших данных
L.TimeDimension.Util.setCacheLimit(10); // Лимит кэшированных кадров L.TimeDimension.Util.setCacheLimit(10); // Лимит кэшированных кадров
// 1. Подготовка данных // 1. Подготовка данных
const timeData = prepareTimeData(windData); const timeData = prepareTimeData(windData);
const firstTime = Object.keys(timeData)[0]; const firstTime = Object.keys(timeData)[0];
// Инициализация TimeDimension // Инициализация TimeDimension
timeDimension = new L.TimeDimension({ timeDimension = new L.TimeDimension({
period: "PT1H", // Интервал 1 час period: "PT1H", // Интервал 1 час
timeInterval: '${firstTime}/${firstTime}', timeInterval: "${firstTime}/${firstTime}",
}); });
// Добавляем контролы времени // Добавляем контролы времени
timeDimensionControl = new L.Control.TimeDimension({ timeDimensionControl = new L.Control.TimeDimension({
timeDimension, timeDimension,
position: 'bottomleft', position: "bottomleft",
// autoPlay: true, // autoPlay: true,
playerOptions: { playerOptions: {
// transitionTime: 1000, // transitionTime: 1000,
loop: false, loop: false,
minBufferReady: -1 minBufferReady: -1,
} },
}); });
map.addControl(timeDimensionControl); map.addControl(timeDimensionControl);
// 4. Создание слоев // 4. Создание слоев
const velocityLayer = L.timeDimension.layer.windVelocity({ const velocityLayer = L.timeDimension.layer
displayValues: true, .windVelocity({
data: timeData, displayValues: true,
displayOptions: { data: timeData,
velocityType: 'Wind Speed', displayOptions: {
position: 'bottomleft' velocityType: "Wind Speed",
} position: "bottomleft",
}).addTo(map); },
})
.addTo(map);
// 5. Тепловая карта (адаптируйте под ваш формат) // 5. Тепловая карта (адаптируйте под ваш формат)
const heatLayer = L.timeDimension.layer.heat({ const heatLayer = L.timeDimension.layer
radius: 15, .heat({
data: prepareTimeHeatData(timeData) radius: 15,
}).addTo(map); data: prepareTimeHeatData(timeData),
}); })
.addTo(map);
});
onDestroy(() => { onDestroy(() => {
if (map) { if (map) {
if (velocityLayer) map.removeLayer(velocityLayer); if (velocityLayer) map.removeLayer(velocityLayer);
if (heatLayer) map.removeLayer(heatLayer); if (heatLayer) map.removeLayer(heatLayer);
if (legend) map.removeControl(legend); if (legend) map.removeControl(legend);
} }
}); });
// Реактивность на изменение параметров // Реактивность на изменение параметров
$: if (map && windData) { $: if (map && windData) {
updateLayers(); updateLayers();
}; }
</script> </script>
<div class="layer-controls"> <div class="layer-controls">
<div class="control-group"> <div class="control-group">
<label> <label>
<input type="checkbox" bind:checked={showHeatmap}> Тепловая карта <input type="checkbox" bind:checked={showHeatmap} />
</label> Тепловая карта
<label> </label>
<input type="checkbox" bind:checked={showVectors}> Векторы ветра <label>
</label> <input type="checkbox" bind:checked={showVectors} />
</div> Векторы ветра
</label>
</div>
</div> </div>
<style> <style>
.layer-controls { .layer-controls {
position: absolute; position: absolute;
bottom: 30px; bottom: 30px;
left: 10px; left: 10px;
z-index: 1000; z-index: 1000;
background: rgba(255, 255, 255, 0.8); background: rgba(255, 255, 255, 0.8);
padding: 10px; padding: 10px;
border-radius: 5px; border-radius: 5px;
box-shadow: 0 0 10px rgba(0,0,0,0.2); box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
} }
.control-group { .control-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 5px; gap: 5px;
} }
.control-group label { .control-group label {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
font-size: 14px; font-size: 14px;
cursor: pointer; cursor: pointer;
} }
:global(.wind-heat-legend) { :global(.wind-heat-legend) {
padding: 8px 10px; padding: 8px 10px;
background: rgba(255, 255, 255, 0.9); background: rgba(255, 255, 255, 0.9);
border-radius: 5px; border-radius: 5px;
box-shadow: 0 0 15px rgba(0,0,0,0.2); box-shadow: 0 0 15px rgba(0, 0, 0, 0.2);
line-height: 1.2; line-height: 1.2;
color: #333; color: #333;
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
} }
:global(.wind-heat-legend h4) { :global(.wind-heat-legend h4) {
margin: 0 0 5px; margin: 0 0 5px;
font-size: 14px; font-size: 14px;
font-weight: bold; font-weight: bold;
} }
:global(legend-scale) { :global(legend-scale) {
display: flex; display: flex;
margin-bottom: 3px; margin-bottom: 3px;
} }
:global(legend-color) { :global(legend-color) {
height: 12px; height: 12px;
flex-grow: 1; flex-grow: 1;
} }
:global(.legend-labels) { :global(.legend-labels) {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
font-size: 11px; font-size: 11px;
} }
</style> </style>

View file

@ -1,7 +1,7 @@
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import type { FlightParameters, RawTelemetry, Telemetry } from "./types"; import type { FlightParameters, RawTelemetry, Telemetry } from "./types";
import type { RawPrediction, Prediction } from "./types"; import type { RawPrediction, Prediction } from "./types";
import type { SavedPoint, SavedFlightProfile, SavedScenario, TemplateData } from "./types"; import type { SavedPoint, SavedFlightProfile, SavedScenario } from "./types";
export const readLocalStorage = <T>(key: string, defaultValue: T): T => { export const readLocalStorage = <T>(key: string, defaultValue: T): T => {
const item = localStorage.getItem(key); const item = localStorage.getItem(key);
@ -52,7 +52,7 @@ export const FlightParametersStore = writable<FlightParameters>(
readLocalStorage<FlightParameters>("flightParameters", flightParametersDefaults) readLocalStorage<FlightParameters>("flightParameters", flightParametersDefaults)
); );
export const templateDataDefaults: TemplateData = { export const templateDataDefaults = {
description: "", description: "",
prediction_mode: "", prediction_mode: "",
model: "", model: "",
@ -63,7 +63,7 @@ export const templateDataDefaults: TemplateData = {
export const scenarioDefaults: SavedScenario = { export const scenarioDefaults: SavedScenario = {
id: -1, id: -1,
name: "Новый сценарий", name: "Новый сценарий",
template_data: templateDataDefaults, ...templateDataDefaults,
} }
export const ScenarioStore = writable<SavedScenario>( export const ScenarioStore = writable<SavedScenario>(

View file

@ -48,15 +48,6 @@ export const PPREDICTION_MODE_NAMES = {
ensemble: "Ансамблевый" ensemble: "Ансамблевый"
}; };
export interface TemplateData {
description: string;
prediction_mode: string;
model: string;
dataset: string;
flight_parameters: FlightParameters;
}
export interface Point { export interface Point {
latlng: LatLngLiteral & { alt?: number }; latlng: LatLngLiteral & { alt?: number };
datetime: Date; datetime: Date;
@ -126,14 +117,26 @@ export interface SavedPoint {
alt: number; alt: number;
} }
export interface RateCurvePoint {
order: number; // Order in the curve
time_constraint: number; // Time in seconds
alt_constraint: number; // Altitude constraint in meters
rate: number; // Rate in m/s
}
export interface SavedFlightProfile { export interface SavedFlightProfile {
id: number; id: number;
name: string; name: string;
rate_profile_data: object; type?: string;
rate_profile_data: RateCurvePoint[];
} }
export interface SavedScenario { export interface SavedScenario {
id: number; id: number;
name: string; name: string;
template_data: TemplateData; description: string;
prediction_mode: string;
model: string;
dataset: string;
flight_parameters: FlightParameters;
} }