Initial implementation of custom profile editor + formatting
This commit is contained in:
parent
82b36f96d0
commit
ffb27c2e0a
21 changed files with 3045 additions and 2034 deletions
|
|
@ -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
96
package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}/>
|
||||||
|
|
|
||||||
278
src/lib/components/CurveChart.svelte
Normal file
278
src/lib/components/CurveChart.svelte
Normal 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>
|
||||||
607
src/lib/components/CurveEditor.svelte
Normal file
607
src/lib/components/CurveEditor.svelte
Normal 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>
|
||||||
45
src/lib/components/EditableCell.svelte
Normal file
45
src/lib/components/EditableCell.svelte
Normal 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>
|
||||||
|
|
@ -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>
|
|
||||||
|
|
|
||||||
|
|
@ -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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
attribution: '© <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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">Cценарий:</Label>
|
||||||
<FormGroup spacing="mb-2">
|
<InputGroup size="sm">
|
||||||
<Label for="scenarioName" class="form-label">Cценарий:</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} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 или текущую дату, если не указана
|
|
||||||
const refTime = windData[0]?.header?.refTime || new Date().toISOString();
|
|
||||||
|
|
||||||
return {
|
|
||||||
[refTime]: {
|
|
||||||
u: windData[0].data, // U-компонента (первый объект в массиве)
|
|
||||||
v: windData[1].data // V-компонента (второй объект)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Используем дату из header или текущую дату, если не указана
|
||||||
|
const refTime = windData[0]?.header?.refTime || new Date().toISOString();
|
||||||
|
|
||||||
// Функция для нормализации данных тепловой карты
|
return {
|
||||||
const prepareHeatData = (windData) => {
|
[refTime]: {
|
||||||
if (!windData || windData.length < 2) {
|
u: windData[0].data, // U-компонента (первый объект в массиве)
|
||||||
console.warn("Invalid wind data structure");
|
v: windData[1].data, // V-компонента (второй объект)
|
||||||
return [];
|
},
|
||||||
}
|
};
|
||||||
|
};
|
||||||
// Получаем U и V компоненты
|
|
||||||
const uComponent = windData.find(item => item.header.parameterNumber === 2);
|
|
||||||
const vComponent = windData.find(item => item.header.parameterNumber === 3);
|
|
||||||
|
|
||||||
if (!uComponent || !vComponent) {
|
|
||||||
console.warn("Missing wind components");
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const header = uComponent.header; // Используем header из U компоненты
|
|
||||||
const { lo1, la1, dx, dy, nx, ny } = header;
|
|
||||||
const heatData = [];
|
|
||||||
let maxSpeed = 0;
|
|
||||||
|
|
||||||
// Проверяем совпадение размеров данных
|
|
||||||
if (uComponent.data.length !== vComponent.data.length) {
|
|
||||||
console.warn("U and V components have different lengths");
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Собираем данные и находим максимальную скорость
|
|
||||||
for (let i = 0; i < uComponent.data.length; i++) {
|
|
||||||
const u = uComponent.data[i];
|
|
||||||
const v = vComponent.data[i];
|
|
||||||
const speed = Math.sqrt(u * u + v * v);
|
|
||||||
|
|
||||||
if (!isNaN(speed)) {
|
|
||||||
// Вычисляем координаты для текущей точки
|
|
||||||
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]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return heatData;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Создание тепловой карты
|
// Функция для нормализации данных тепловой карты
|
||||||
const createHeatLayer = (data) => {
|
const prepareHeatData = (windData) => {
|
||||||
if (!data || data.length === 0) {
|
if (!windData || windData.length < 2) {
|
||||||
console.warn("No valid heat data provided");
|
console.warn("Invalid wind data structure");
|
||||||
return null;
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
return L.heatLayer(data, {
|
|
||||||
radius: 8, // Увеличьте радиус для глобальной карты
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Обновление слоев
|
// Получаем U и V компоненты
|
||||||
const updateLayers = () => {
|
const uComponent = windData.find((item) => item.header.parameterNumber === 2);
|
||||||
if (!map || !windData) return;
|
const vComponent = windData.find((item) => item.header.parameterNumber === 3);
|
||||||
|
|
||||||
// Удаляем старые слои
|
|
||||||
if (velocityLayer) map.removeLayer(velocityLayer);
|
|
||||||
if (heatLayer) map.removeLayer(heatLayer);
|
|
||||||
if (legend) map.removeControl(legend);
|
|
||||||
|
|
||||||
// Создаем слой векторов ветра
|
|
||||||
if (showVectors) {
|
|
||||||
velocityLayer = L.velocityLayer({
|
|
||||||
displayValues: true,
|
|
||||||
displayOptions: {
|
|
||||||
velocityType: 'Wind Speed',
|
|
||||||
position: 'bottomright',
|
|
||||||
emptyString: 'No wind data',
|
|
||||||
},
|
|
||||||
data: windData
|
|
||||||
}).addTo(map);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Создаем тепловую карту
|
|
||||||
if (showHeatmap) {
|
|
||||||
const heatData = prepareHeatData(windData);
|
|
||||||
heatLayer = createHeatLayer(heatData);
|
|
||||||
|
|
||||||
if (heatLayer) {
|
|
||||||
heatLayer.addTo(map);
|
|
||||||
createLegend(Math.max(...heatData.map(point => point[2])));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем контроль слоев
|
if (!uComponent || !vComponent) {
|
||||||
updateLayerControl();
|
console.warn("Missing wind components");
|
||||||
};
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const updateLayerControl = () => {
|
const header = uComponent.header; // Используем header из U компоненты
|
||||||
if (layerControl) {
|
const { lo1, la1, dx, dy, nx, ny } = header;
|
||||||
map.removeControl(layerControl);
|
const heatData = [];
|
||||||
}
|
let maxSpeed = 0;
|
||||||
|
|
||||||
const overlays = {};
|
// Проверяем совпадение размеров данных
|
||||||
|
if (uComponent.data.length !== vComponent.data.length) {
|
||||||
if (velocityLayer) {
|
console.warn("U and V components have different lengths");
|
||||||
overlays['Векторы ветра'] = velocityLayer;
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (heatLayer) {
|
// Собираем данные и находим максимальную скорость
|
||||||
overlays['Тепловая карта'] = heatLayer;
|
for (let i = 0; i < uComponent.data.length; i++) {
|
||||||
}
|
const u = uComponent.data[i];
|
||||||
|
const v = vComponent.data[i];
|
||||||
layerControl = L.control.layers(null, overlays, {
|
const speed = Math.sqrt(u * u + v * v);
|
||||||
collapsed: false,
|
|
||||||
position: 'topright'
|
if (!isNaN(speed)) {
|
||||||
}).addTo(map);
|
// Вычисляем координаты для текущей точки
|
||||||
};
|
const y = Math.floor(i / nx);
|
||||||
// Создание легенды с учетом максимальной скорости
|
const x = i % nx;
|
||||||
const createLegend = (maxSpeed) => {
|
let lat = la1 - y * dy;
|
||||||
if (!map) return;
|
let lng = lo1 + x * dx;
|
||||||
|
if (lng >= 180) lng -= 360;
|
||||||
legend = L.control({ position: 'bottomright' });
|
heatData.push([lat, lng, speed]);
|
||||||
|
maxSpeed = Math.max(maxSpeed, speed);
|
||||||
legend.onAdd = () => {
|
}
|
||||||
const div = L.DomUtil.create('div', 'wind-heat-legend');
|
}
|
||||||
div.innerHTML = `
|
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return heatData;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Создание тепловой карты
|
||||||
|
const createHeatLayer = (data) => {
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
console.warn("No valid heat data provided");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return L.heatLayer(data, {
|
||||||
|
radius: 8, // Увеличьте радиус для глобальной карты
|
||||||
|
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 (!map || !windData) return;
|
||||||
|
|
||||||
|
// Удаляем старые слои
|
||||||
|
if (velocityLayer) map.removeLayer(velocityLayer);
|
||||||
|
if (heatLayer) map.removeLayer(heatLayer);
|
||||||
|
if (legend) map.removeControl(legend);
|
||||||
|
|
||||||
|
// Создаем слой векторов ветра
|
||||||
|
if (showVectors) {
|
||||||
|
velocityLayer = L.velocityLayer({
|
||||||
|
displayValues: true,
|
||||||
|
displayOptions: {
|
||||||
|
velocityType: "Wind Speed",
|
||||||
|
position: "bottomright",
|
||||||
|
emptyString: "No wind data",
|
||||||
|
},
|
||||||
|
data: windData,
|
||||||
|
}).addTo(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем тепловую карту
|
||||||
|
if (showHeatmap) {
|
||||||
|
const heatData = prepareHeatData(windData);
|
||||||
|
heatLayer = createHeatLayer(heatData);
|
||||||
|
|
||||||
|
if (heatLayer) {
|
||||||
|
heatLayer.addTo(map);
|
||||||
|
createLegend(Math.max(...heatData.map((point) => point[2])));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем контроль слоев
|
||||||
|
updateLayerControl();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateLayerControl = () => {
|
||||||
|
if (layerControl) {
|
||||||
|
map.removeControl(layerControl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlays = {};
|
||||||
|
|
||||||
|
if (velocityLayer) {
|
||||||
|
overlays["Векторы ветра"] = velocityLayer;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (heatLayer) {
|
||||||
|
overlays["Тепловая карта"] = heatLayer;
|
||||||
|
}
|
||||||
|
|
||||||
|
layerControl = L.control
|
||||||
|
.layers(null, overlays, {
|
||||||
|
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");
|
||||||
|
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
onMount(() => {
|
legend.addTo(map);
|
||||||
if (!map) return;
|
};
|
||||||
|
|
||||||
// 1. Настройка TimeDimension (добавьте эти строки в начале)
|
onMount(() => {
|
||||||
// L.TimeDimension.Util.setProxy('https://your-proxy.com/?url='); // Для загрузки больших данных
|
if (!map) return;
|
||||||
L.TimeDimension.Util.setCacheLimit(10); // Лимит кэшированных кадров
|
|
||||||
|
|
||||||
// 1. Подготовка данных
|
// 1. Настройка TimeDimension (добавьте эти строки в начале)
|
||||||
const timeData = prepareTimeData(windData);
|
// L.TimeDimension.Util.setProxy('https://your-proxy.com/?url='); // Для загрузки больших данных
|
||||||
const firstTime = Object.keys(timeData)[0];
|
L.TimeDimension.Util.setCacheLimit(10); // Лимит кэшированных кадров
|
||||||
|
|
||||||
// Инициализация TimeDimension
|
// 1. Подготовка данных
|
||||||
timeDimension = new L.TimeDimension({
|
const timeData = prepareTimeData(windData);
|
||||||
period: "PT1H", // Интервал 1 час
|
const firstTime = Object.keys(timeData)[0];
|
||||||
timeInterval: '${firstTime}/${firstTime}',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Добавляем контролы времени
|
// Инициализация TimeDimension
|
||||||
timeDimensionControl = new L.Control.TimeDimension({
|
timeDimension = new L.TimeDimension({
|
||||||
timeDimension,
|
period: "PT1H", // Интервал 1 час
|
||||||
position: 'bottomleft',
|
timeInterval: "${firstTime}/${firstTime}",
|
||||||
// autoPlay: true,
|
});
|
||||||
playerOptions: {
|
|
||||||
// transitionTime: 1000,
|
|
||||||
loop: false,
|
|
||||||
minBufferReady: -1
|
|
||||||
}
|
|
||||||
});
|
|
||||||
map.addControl(timeDimensionControl);
|
|
||||||
|
|
||||||
// 4. Создание слоев
|
// Добавляем контролы времени
|
||||||
const velocityLayer = L.timeDimension.layer.windVelocity({
|
timeDimensionControl = new L.Control.TimeDimension({
|
||||||
displayValues: true,
|
timeDimension,
|
||||||
data: timeData,
|
position: "bottomleft",
|
||||||
displayOptions: {
|
// autoPlay: true,
|
||||||
velocityType: 'Wind Speed',
|
playerOptions: {
|
||||||
position: 'bottomleft'
|
// transitionTime: 1000,
|
||||||
}
|
loop: false,
|
||||||
}).addTo(map);
|
minBufferReady: -1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
map.addControl(timeDimensionControl);
|
||||||
|
|
||||||
// 5. Тепловая карта (адаптируйте под ваш формат)
|
// 4. Создание слоев
|
||||||
const heatLayer = L.timeDimension.layer.heat({
|
const velocityLayer = L.timeDimension.layer
|
||||||
radius: 15,
|
.windVelocity({
|
||||||
data: prepareTimeHeatData(timeData)
|
displayValues: true,
|
||||||
}).addTo(map);
|
data: timeData,
|
||||||
});
|
displayOptions: {
|
||||||
|
velocityType: "Wind Speed",
|
||||||
|
position: "bottomleft",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.addTo(map);
|
||||||
|
|
||||||
onDestroy(() => {
|
// 5. Тепловая карта (адаптируйте под ваш формат)
|
||||||
if (map) {
|
const heatLayer = L.timeDimension.layer
|
||||||
if (velocityLayer) map.removeLayer(velocityLayer);
|
.heat({
|
||||||
if (heatLayer) map.removeLayer(heatLayer);
|
radius: 15,
|
||||||
if (legend) map.removeControl(legend);
|
data: prepareTimeHeatData(timeData),
|
||||||
}
|
})
|
||||||
});
|
.addTo(map);
|
||||||
|
});
|
||||||
|
|
||||||
// Реактивность на изменение параметров
|
onDestroy(() => {
|
||||||
$: if (map && windData) {
|
if (map) {
|
||||||
updateLayers();
|
if (velocityLayer) map.removeLayer(velocityLayer);
|
||||||
};
|
if (heatLayer) map.removeLayer(heatLayer);
|
||||||
|
if (legend) map.removeControl(legend);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Реактивность на изменение параметров
|
||||||
|
$: if (map && windData) {
|
||||||
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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>(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue