New panel layout

This commit is contained in:
ThePetrovich 2025-06-30 19:23:46 +08:00
parent 87f0a53cb5
commit 329c1c2215
18 changed files with 671 additions and 515 deletions

16
package-lock.json generated
View file

@ -9,10 +9,11 @@
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@sveltestrap/sveltestrap": "^7.1.0", "@sveltestrap/sveltestrap": "^7.1.0",
"bootstrap-icons": "^1.11.3", "bootstrap-icons": "^1.13.1",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"leaflet-velocity": "^2.1.4" "leaflet-velocity": "^2.1.4",
"leaflet.heat": "^0.2.0"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^4.0.0", "@sveltejs/adapter-auto": "^4.0.0",
@ -894,9 +895,9 @@
} }
}, },
"node_modules/bootstrap-icons": { "node_modules/bootstrap-icons": {
"version": "1.11.3", "version": "1.13.1",
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.11.3.tgz", "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.13.1.tgz",
"integrity": "sha512-+3lpHrCw/it2/7lBL15VR0HEumaBss0+f/Lb6ZvHISn1mlK83jjFpooTLsMWbIjJMDjDjOExMsTxnXSIT4k4ww==", "integrity": "sha512-ijombt4v6bv5CLeXvRWKy7CuM3TRTuPEuGaGKvTV5cz65rQSY8RQ2JcHt6b90cBBAC7s8fsf2EkQDldzCoXUjw==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -1098,6 +1099,11 @@
"resolved": "https://registry.npmjs.org/leaflet-velocity/-/leaflet-velocity-2.1.4.tgz", "resolved": "https://registry.npmjs.org/leaflet-velocity/-/leaflet-velocity-2.1.4.tgz",
"integrity": "sha512-uTmSb2/Kn28S0itlmJBMy2ZRKsisWUr2wm9rtkKXjpq9Sai7tqKdTRHKfLgTOgEdWFf5Ctt2bQoB7kb50qC7eg==" "integrity": "sha512-uTmSb2/Kn28S0itlmJBMy2ZRKsisWUr2wm9rtkKXjpq9Sai7tqKdTRHKfLgTOgEdWFf5Ctt2bQoB7kb50qC7eg=="
}, },
"node_modules/leaflet.heat": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/leaflet.heat/-/leaflet.heat-0.2.0.tgz",
"integrity": "sha512-Cd5PbAA/rX3X3XKxfDoUGi9qp78FyhWYurFg3nsfhntcM/MCNK08pRkf4iEenO1KNqwVPKCmkyktjW3UD+h9bQ=="
},
"node_modules/locate-character": { "node_modules/locate-character": {
"version": "3.0.0", "version": "3.0.0",
"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",

View file

@ -22,9 +22,10 @@
}, },
"dependencies": { "dependencies": {
"@sveltestrap/sveltestrap": "^7.1.0", "@sveltestrap/sveltestrap": "^7.1.0",
"bootstrap-icons": "^1.11.3", "bootstrap-icons": "^1.13.1",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"leaflet-velocity": "^2.1.4" "leaflet-velocity": "^2.1.4",
"leaflet.heat": "^0.2.0"
} }
} }

View file

@ -6,6 +6,10 @@
<link rel="stylesheet" href="%sveltekit.assets%/css/bootstrap.min.css"> <link rel="stylesheet" href="%sveltekit.assets%/css/bootstrap.min.css">
<link rel="stylesheet" href="%sveltekit.assets%/css/custom.css" /> <link rel="stylesheet" href="%sveltekit.assets%/css/custom.css" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css"
/>
%sveltekit.head% %sveltekit.head%
<style> <style>
body { body {

View file

@ -0,0 +1,316 @@
<script lang="ts">
import {
Card,
CardHeader,
CardBody,
Button,
FormGroup,
Label,
Input,
InputGroup,
InputGroupText,
} from "@sveltestrap/sveltestrap";
import { getForecast } from "$lib/prediction";
import type { FlightParameters, ProfileName } from "$lib/types";
import { PROFILE_MAP } from "$lib/types";
import { FlightParametersStore, writeLocalStorage } from "$lib/stores";
let isCollapsed = false;
let selectedProfile: ProfileName = "Normal";
let startPoint = "Custom";
let element: HTMLDivElement | null = null;
const now = new Date();
let startDate = now.toISOString().split("T")[0]; // YYYY-MM-DD
let startTime = now.toISOString().split("T")[1].split(".")[0]; // HH:MM:SS
let inputLat = $FlightParametersStore.launch_latitude.toString();
let inputLng = $FlightParametersStore.launch_longitude.toString();
$: $FlightParametersStore = {
...$FlightParametersStore,
profile: PROFILE_MAP[selectedProfile],
};
const handleGetPrediction = async () => {
console.log("Fetching prediction with parameters:", $FlightParametersStore);
console.log(startDate, startTime);
$FlightParametersStore.launch_datetime = `${startDate}T${startTime}Z`;
writeLocalStorage<FlightParameters>("flightParameters", $FlightParametersStore);
try {
const response = await getForecast($FlightParametersStore);
console.log(response);
// TODO: Notify other components of the new prediction.
// const dispatch = createEventDispatcher();
// dispatch('newPrediction', response);
} catch (error) {
console.error("Error fetching forecast:", error);
// TODO: Display a user-friendly error message in the UI.
}
};
export let handleClickSelectOnMap = () => {
console.log("Select on map clicked");
}
const applyCoordinatesFromInput = () => {
const lat = parseFloat(inputLat);
const lng = parseFloat(inputLng);
if (!isNaN(lat) && !isNaN(lng)) {
$FlightParametersStore.launch_latitude = lat;
$FlightParametersStore.launch_longitude = lng;
console.log(
"Updated position:",
$FlightParametersStore.launch_latitude,
$FlightParametersStore.launch_longitude,
);
} else {
console.error("Invalid coordinate input");
// TODO: Show a validation error to the user.
}
};
/**
* Updates the launch coordinates.
* @param {number} lat The new latitude.
* @param {number} lng The new longitude.
*/
export const updateLaunchPosition = (lat: number, lng: number) => {
$FlightParametersStore.launch_latitude = lat;
$FlightParametersStore.launch_longitude = lng;
console.log("Launch position updated:", lat, lng);
inputLat = lat.toFixed(6).toString();
inputLng = lng.toFixed(6).toString();
};
export const getElement = () => {
return element;
};
export const getSelectedProfile = () => {
return selectedProfile;
};
export const selectProfile = (profile: ProfileName) => {
selectedProfile = profile;
$FlightParametersStore.profile = PROFILE_MAP[selectedProfile];
console.log("Selected profile:", selectedProfile);
};
export const collapsePanel = () => {
isCollapsed = true;
};
export const expandPanel = () => {
isCollapsed = false;
};
export const togglePanel = () => {
isCollapsed = !isCollapsed;
};
</script>
<Card>
<CardHeader
class="bg-primary text-white d-flex justify-content-between align-items-center card-header p-1 px-3"
style="cursor:pointer;"
>
<button
type="button"
class="d-flex w-100 justify-content-between align-items-center bg-transparent border-0 p-0"
style="width:100%;"
aria-label="Свернуть/развернуть параметры прогнозирования"
on:click={() => (isCollapsed = !isCollapsed)}
>
<b class="card-title mb-0 text-white p-0">Параметры прогнозирования</b>
<Button class="p-0" size="sm" color="primary" on:click={() => (isCollapsed = !isCollapsed)}>
{#if isCollapsed}
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
class="bi bi-caret-left-fill"
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"
/>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
class="bi bi-caret-down"
viewBox="0 0 16 16"
>
<path
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"
/>
</svg>
{/if}
</Button>
</button>
</CardHeader>
{#if !isCollapsed}
<CardBody>
<FormGroup spacing="mb-2">
<Label for="flightProfile" class="form-label">Профиль полета:</Label>
<InputGroup size="sm">
<Input type="select" id="flightProfile" bind:value={selectedProfile}>
<optgroup label="Стандартные профили">
{#each Object.keys(PROFILE_MAP) as profileName}
<option value={profileName}>{profileName}</option>
{/each}
</optgroup>
<optgroup label="Пользовательские профили">
<option>Custom</option>
</optgroup>
</Input>
<Button
color="secondary"
size="sm"
title="Edit profile"
disabled={selectedProfile !== "Custom"}
>
<span>Редакт.</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
fill="currentColor"
class="bi bi-gear-fill"
viewBox="0 0 16 16"
>
<path
d="M9.405 1.05c-.413-1.4-2.397-1.4-2.81 0l-.1.34a1.464 1.464 0 0 1-2.105.872l-.31-.17c-1.283-.698-2.686.705-1.987 1.987l.169.311c.446.82.023 1.841-.872 2.105l-.34.1c-1.4.413-1.4 2.397 0 2.81l.34.1a1.464 1.464 0 0 1 .872 2.105l-.17.31c-.698 1.283.705 2.686 1.987 1.987l.311-.169a1.464 1.464 0 0 1 2.105.872l.1.34c.413 1.4 2.397 1.4 2.81 0l.1-.34a1.464 1.464 0 0 1 2.105-.872l.31.17c1.283.698 2.686-.705 1.987-1.987l-.169-.311a1.464 1.464 0 0 1 .872-2.105l.34-.1c1.4-.413-1.4-2.397 0-2.81l-.34-.1a1.464 1.464 0 0 1-.872-2.105l.17-.31c.698-1.283-.705-2.686-1.987-1.987l-.311.169a1.464 1.464 0 0 1-2.105-.872zM8 10.93a2.929 2.929 0 1 1 0-5.86 2.929 2.929 0 0 1 0 5.858z"
/>
</svg>
</Button>
</InputGroup>
</FormGroup>
<FormGroup spacing="mb-2">
<Label for="startPoint" class="form-label">Точка старта:</Label>
<InputGroup size="sm">
<Input type="select" id="startPoint" bind:value={startPoint}>
<option>Custom</option>
<option>Preset 1</option>
<option>Preset 2</option>
</Input>
<Button
color="secondary"
title="Edit Saved Locations"
on:click={() => console.log("Not implemented yet")}
>
<span>Редакт.</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
class="bi bi-journal-bookmark-fill"
viewBox="0 0 16 16"
>
<path
fill-rule="evenodd"
d="M6 1h6v7a.5.5 0 0 1-.757.429L9 7.083 6.757 8.43A.5.5 0 0 1 6 8z"
/>
<path
d="M3 0h10a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2v-1h1v1a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v1H1V2a2 2 0 0 1 2-2"
/>
<path
d="M1 5v-.5a.5.5 0 0 1 1 0V5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1zm0 3v-.5a.5.5 0 0 1 1 0V8h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1zm0 3v-.5a.5.5 0 0 1 1 0v.5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1z"
/>
</svg>
</Button>
</InputGroup>
</FormGroup>
<FormGroup spacing="mb-2">
<Label for="latitude" class="form-label">Широта/Долгота:</Label>
<InputGroup size="sm">
<Input id="latitude" type="text" bind:value={inputLat} placeholder="Latitude" />
<InputGroupText>/</InputGroupText>
<Input id="longitude" type="text" bind:value={inputLng} placeholder="Longitude" />
<Button color="success" size="sm" on:click={applyCoordinatesFromInput} title="Apply Coordinates"
>✓</Button
>
</InputGroup>
</FormGroup>
<FormGroup spacing="mb-2">
<Button
color="outline-secondary"
size="sm"
class="w-100"
on:click={ handleClickSelectOnMap }>Указать на карте</Button
>
</FormGroup>
<FormGroup spacing="mb-2">
<Label for="startHeight" class="form-label">Высота точки старта:</Label>
<Input
type="number"
id="startHeight"
class="form-control-sm"
bind:value={$FlightParametersStore.launch_altitude}
/>
</FormGroup>
<div class="mb-2 d-flex gap-2">
<FormGroup class="flex-fill w-50" spacing="mb-0">
<Label for="startTime" class="form-label">Время старта (UTC):</Label>
<Input type="time" id="startTime" class="form-control-sm" bind:value={startTime} step="1" />
</FormGroup>
<FormGroup class="flex-fill w-50" spacing="mb-0">
<Label for="startDate" class="form-label">Дата старта:</Label>
<Input type="date" id="startDate" class="form-control-sm" bind:value={startDate} />
</FormGroup>
</div>
<div class="mb-2 d-flex gap-2">
<FormGroup class="flex-fill w-50" spacing="mb-0">
<Label for="ascentRate" class="form-label">Скорость подъема (м/с):</Label>
<Input
type="number"
id="ascentRate"
class="form-control-sm"
bind:value={$FlightParametersStore.ascent_rate}
/>
</FormGroup>
<FormGroup class="flex-fill w-50" spacing="mb-0">
<Label for="descentRate" class="form-label">Скорость спуска (м/с):</Label>
<Input
type="number"
id="descentRate"
class="form-control-sm"
bind:value={$FlightParametersStore.descent_rate}
/>
</FormGroup>
</div>
<FormGroup spacing="mb-2">
<Label for="burstAltitude" class="form-label">Высота разрыва (м):</Label>
<Input
type="number"
id="burstAltitude"
class="form-control-sm"
bind:value={$FlightParametersStore.burst_altitude}
/>
</FormGroup>
<div class="d-grid gap-1">
<Button color="outline-secondary" size="sm">Показать график высоты</Button>
<Button size="sm" color="primary" on:click={handleGetPrediction}>Выполнить прогнозирование</Button>
</div>
</CardBody>
{/if}
</Card>

View file

@ -3,9 +3,9 @@
import * as L from "leaflet"; import * as L from "leaflet";
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 './WindVisualisation.svelte'; import WindVisualization from "$lib/components/WindVisualisation.svelte";
import { distHaversine } from "../lib/mathutil.ts"; import { distHaversine } from "$lib/mathutil";
import type { PredictionData, TelemetryData } from "../lib/types.ts"; import type { PredictionData, TelemetryData } from "$lib/types";
/** /**
* @type {'prediction' | 'telemetry'} * @type {'prediction' | 'telemetry'}
@ -20,14 +20,16 @@
let mouseLng = 0; let mouseLng = 0;
let isSelecting = false; let isSelecting = false;
let windData; 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).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);
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", {

View file

@ -0,0 +1,102 @@
<script>
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { checkAuthenticated, logout } from '$lib/auth';
import {
Collapse,
Dropdown,
DropdownItem,
DropdownMenu,
DropdownToggle,
Nav,
NavItem,
NavLink,
Navbar,
NavbarBrand,
NavbarToggler
} from '@sveltestrap/sveltestrap';
// State for the navbar toggler
let isOpen = false;
// Check if user is authenticated (using localStorage token)
let isAuthenticated = false;
// This should be reactive to changes in auth status
$: if (typeof window !== 'undefined') {
Promise.resolve(checkAuthenticated()).then((result) => {
isAuthenticated = result;
});
} else {
isAuthenticated = false;
}
function handleLogout() {
// Clear authentication tokens
try {
logout();
} catch (error) {
console.error('Logout failed:', error);
}
// Update auth status
isAuthenticated = false;
// Redirect to login page
goto('/');
}
</script>
<Navbar color="light" light expand="lg" fixed="top" class="custom-navbar border-bottom">
<NavbarBrand href="/" class="nav-full-height">
<img src="/logo.svg" alt="Logo" height="34" class="d-inline-block align-text-top" />
</NavbarBrand>
<NavbarToggler on:click={() => (isOpen = !isOpen)} />
<div class="navbar-collapse collapse" class:show={isOpen} id="navbarContent">
<Nav class="me-auto mb-lg-0" navbar>
<NavItem>
<NavLink
href="/predict"
class="nav-full-height border border-top-0"
active={$page.url.pathname === '/predict'}>
Прогнозирование
</NavLink>
</NavItem>
<NavItem>
<NavLink
href="/track"
class="nav-full-height border border-top-0"
active={$page.url.pathname === '/track'}>
Слежение
</NavLink>
</NavItem>
</Nav>
<Nav navbar>
{#if isAuthenticated}
<Dropdown nav inNavbar>
<DropdownToggle nav caret class="nav-full-height border border-top-0">
Account
</DropdownToggle>
<DropdownMenu end>
<DropdownItem href="/user/account">Account Settings</DropdownItem>
<DropdownItem href="/user/templates">Saved Templates</DropdownItem>
<DropdownItem href="/user/predictions">Prediction History</DropdownItem>
<DropdownItem href="/user/flights">Flight History</DropdownItem>
<DropdownItem divider />
<DropdownItem on:click={handleLogout}>Logout</DropdownItem>
</DropdownMenu>
</Dropdown>
{:else}
<NavItem>
<NavLink
href="/login"
class="nav-full-height border border-top-0"
active={$page.url.pathname === '/login'}>
Login
</NavLink>
</NavItem>
{/if}
</Nav>
</div>
</Navbar>
<style>
</style>

View file

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

View file

View file

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

View file

@ -0,0 +1,97 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
Card,
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 isCollapsed = false;
// Subscribe to the telemetry store
//const unsubscribe = telemetryStore.subscribe((data) => {
// telemetry = data;
//});
telemetry = {
latitude: 56.3576,
longitude: 39.8666,
altitude: 1000,
};
// onMount(() => {
// return () => {
// unsubscribe(); // Cleanup subscription on component destroy
// };
// });
</script>
<Card>
<CardHeader
class="bg-primary text-white d-flex justify-content-between align-items-center p-1 px-3"
style="cursor:pointer;"
>
<b class="card-title mb-0 p-0">Последние данные телеметрии</b>
<Button class="p-0" size="sm" color="primary" on:click={() => (isCollapsed = !isCollapsed)}>
{#if isCollapsed}
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
class="bi bi-caret-left-fill"
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"
/>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
class="bi bi-caret-down"
viewBox="0 0 16 16"
>
<path
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"
/>
</svg>
{/if}
</Button>
</CardHeader>
{#if !isCollapsed}
<CardBody>
<FormGroup spacing="mb-2">
<Label class="small">Широта:</Label>
<InputGroup size="sm">
<Input type="text" value={telemetry.latitude || 'N/A'} readonly />
</InputGroup>
</FormGroup>
<FormGroup spacing="mb-2">
<Label class="small">Долгота:</Label>
<InputGroup size="sm">
<Input type="text" value={telemetry.longitude || 'N/A'} readonly />
</InputGroup>
</FormGroup>
<FormGroup spacing="mb-2">
<Label class="small">Высота (м):</Label>
<InputGroup size="sm">
<Input type="text" value={telemetry.altitude || 'N/A'} readonly />
</InputGroup>
</FormGroup>
</CardBody>
{/if}
</Card>

View file

@ -96,7 +96,7 @@
displayValues: true, displayValues: true,
displayOptions: { displayOptions: {
velocityType: 'Wind Speed', velocityType: 'Wind Speed',
position: 'bottomleft', position: 'bottomright',
emptyString: 'No wind data', emptyString: 'No wind data',
}, },
data: windData data: windData

View file

@ -1,296 +0,0 @@
<script lang="ts">
import {
Card,
CardHeader,
CardBody,
Button,
FormGroup,
Label,
Input,
InputGroup,
InputGroupText,
} from "@sveltestrap/sveltestrap";
import { getForecast } from "$lib/prediction";
import type { FlightParameters, ProfileName } from "$lib/types";
import { PROFILE_MAP } from "$lib/types";
import { FlightParametersStore, writeLocalStorage } from "$lib/stores";
let isCollapsed = false;
let selectedProfile: ProfileName = "Normal";
let startPoint = "Custom";
let element: HTMLDivElement | null = null;
const now = new Date();
let startDate = now.toISOString().split("T")[0]; // YYYY-MM-DD
let startTime = now.toISOString().split("T")[1].split(".")[0]; // HH:MM:SS
let inputLat = $FlightParametersStore.launch_latitude.toString();
let inputLng = $FlightParametersStore.launch_longitude.toString();
$: $FlightParametersStore = {
...$FlightParametersStore,
profile: PROFILE_MAP[selectedProfile],
};
const handleGetPrediction = async () => {
console.log("Fetching prediction with parameters:", $FlightParametersStore);
console.log(startDate, startTime);
$FlightParametersStore.launch_datetime = `${startDate}T${startTime}Z`;
writeLocalStorage<FlightParameters>("flightParameters", $FlightParametersStore);
try {
const response = await getForecast($FlightParametersStore);
console.log(response);
// TODO: Notify other components of the new prediction.
// const dispatch = createEventDispatcher();
// dispatch('newPrediction', response);
} catch (error) {
console.error("Error fetching forecast:", error);
// TODO: Display a user-friendly error message in the UI.
}
};
export let handleClickSelectOnMap = () => {
console.log("Select on map clicked");
}
const applyCoordinatesFromInput = () => {
const lat = parseFloat(inputLat);
const lng = parseFloat(inputLng);
if (!isNaN(lat) && !isNaN(lng)) {
$FlightParametersStore.launch_latitude = lat;
$FlightParametersStore.launch_longitude = lng;
console.log(
"Updated position:",
$FlightParametersStore.launch_latitude,
$FlightParametersStore.launch_longitude,
);
} else {
console.error("Invalid coordinate input");
// TODO: Show a validation error to the user.
}
};
/**
* Updates the launch coordinates.
* @param {number} lat The new latitude.
* @param {number} lng The new longitude.
*/
export const updateLaunchPosition = (lat: number, lng: number) => {
$FlightParametersStore.launch_latitude = lat;
$FlightParametersStore.launch_longitude = lng;
console.log("Launch position updated:", lat, lng);
inputLat = lat.toFixed(6).toString();
inputLng = lng.toFixed(6).toString();
};
export const getElement = () => {
return element;
};
</script>
<div
bind:this={element}
style="width: 23rem; max-height: 80vh; overflow-y: auto; z-index: 1000;"
class="position-absolute shadow-lg panel-container"
>
<Card>
<CardHeader
class="bg-primary text-white d-flex justify-content-between align-items-center card-header"
style="cursor:pointer;"
>
<button
type="button"
class="d-flex w-100 justify-content-between align-items-center bg-transparent border-0 p-0"
style="width:100%;"
aria-label="Свернуть/развернуть параметры прогнозирования"
on:click={() => (isCollapsed = !isCollapsed)}
>
<h6 class="card-title mb-0 text-white">Параметры прогнозирования</h6>
<Button size="sm" color="primary" on:click={() => (isCollapsed = !isCollapsed)}>
{#if isCollapsed}
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
class="bi bi-caret-left-fill"
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"
/>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
class="bi bi-caret-down"
viewBox="0 0 16 16"
>
<path
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"
/>
</svg>
{/if}
</Button>
</button>
</CardHeader>
{#if !isCollapsed}
<CardBody>
<FormGroup spacing="mb-2">
<Label for="flightProfile" class="form-label">Профиль полета:</Label>
<InputGroup size="sm">
<Input type="select" id="flightProfile" bind:value={selectedProfile}>
{#each Object.keys(PROFILE_MAP) as profileName}
<option value={profileName}>{profileName}</option>
{/each}
</Input>
<Button
color="secondary"
size="sm"
title="Edit profile"
disabled={selectedProfile !== "Custom"}
>
<span>Редакт.</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
fill="currentColor"
class="bi bi-gear-fill"
viewBox="0 0 16 16"
>
<path
d="M9.405 1.05c-.413-1.4-2.397-1.4-2.81 0l-.1.34a1.464 1.464 0 0 1-2.105.872l-.31-.17c-1.283-.698-2.686.705-1.987 1.987l.169.311c.446.82.023 1.841-.872 2.105l-.34.1c-1.4.413-1.4 2.397 0 2.81l.34.1a1.464 1.464 0 0 1 .872 2.105l-.17.31c-.698 1.283.705 2.686 1.987 1.987l.311-.169a1.464 1.464 0 0 1 2.105.872l.1.34c.413 1.4 2.397 1.4 2.81 0l.1-.34a1.464 1.464 0 0 1 2.105-.872l.31.17c1.283.698 2.686-.705 1.987-1.987l-.169-.311a1.464 1.464 0 0 1 .872-2.105l.34-.1c1.4-.413-1.4-2.397 0-2.81l-.34-.1a1.464 1.464 0 0 1-.872-2.105l.17-.31c.698-1.283-.705-2.686-1.987-1.987l-.311.169a1.464 1.464 0 0 1-2.105-.872zM8 10.93a2.929 2.929 0 1 1 0-5.86 2.929 2.929 0 0 1 0 5.858z"
/>
</svg>
</Button>
</InputGroup>
</FormGroup>
<FormGroup spacing="mb-2">
<Label for="startPoint" class="form-label">Точка старта:</Label>
<InputGroup size="sm">
<Input type="select" id="startPoint" bind:value={startPoint}>
<option>Custom</option>
<option>Preset 1</option>
<option>Preset 2</option>
</Input>
<Button
color="secondary"
title="Edit Saved Locations"
on:click={() => console.log("Not implemented yet")}
>
<span>Редакт.</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
class="bi bi-journal-bookmark-fill"
viewBox="0 0 16 16"
>
<path
fill-rule="evenodd"
d="M6 1h6v7a.5.5 0 0 1-.757.429L9 7.083 6.757 8.43A.5.5 0 0 1 6 8z"
/>
<path
d="M3 0h10a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2v-1h1v1a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v1H1V2a2 2 0 0 1 2-2"
/>
<path
d="M1 5v-.5a.5.5 0 0 1 1 0V5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1zm0 3v-.5a.5.5 0 0 1 1 0V8h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1zm0 3v-.5a.5.5 0 0 1 1 0v.5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1z"
/>
</svg>
</Button>
</InputGroup>
</FormGroup>
<FormGroup spacing="mb-2">
<Label for="latitude" class="form-label">Широта/Долгота:</Label>
<InputGroup size="sm">
<Input id="latitude" type="text" bind:value={inputLat} placeholder="Latitude" />
<InputGroupText>/</InputGroupText>
<Input id="longitude" type="text" bind:value={inputLng} placeholder="Longitude" />
<Button color="success" size="sm" on:click={applyCoordinatesFromInput} title="Apply Coordinates"
>✓</Button
>
</InputGroup>
</FormGroup>
<FormGroup spacing="mb-2">
<Button
color="outline-secondary"
size="sm"
class="w-100"
on:click={ handleClickSelectOnMap }>Указать на карте</Button
>
</FormGroup>
<FormGroup spacing="mb-2">
<Label for="startHeight" class="form-label">Высота точки старта:</Label>
<Input
type="number"
id="startHeight"
class="form-control-sm"
bind:value={$FlightParametersStore.launch_altitude}
/>
</FormGroup>
<div class="mb-2 d-flex gap-2">
<FormGroup class="flex-fill" spacing="mb-0">
<Label for="startTime" class="form-label">Время старта (UTC):</Label>
<Input type="time" id="startTime" class="form-control-sm" bind:value={startTime} step="1" />
</FormGroup>
<FormGroup class="flex-fill" spacing="mb-0">
<Label for="startDate" class="form-label">Дата старта:</Label>
<Input type="date" id="startDate" class="form-control-sm" bind:value={startDate} />
</FormGroup>
</div>
<div class="mb-2 d-flex gap-2">
<FormGroup class="flex-fill" spacing="mb-0">
<Label for="ascentRate" class="form-label">Скорость подъема (м/с):</Label>
<Input
type="number"
id="ascentRate"
class="form-control-sm"
bind:value={$FlightParametersStore.ascent_rate}
/>
</FormGroup>
<FormGroup class="flex-fill" spacing="mb-0">
<Label for="descentRate" class="form-label">Скорость спуска (м/с):</Label>
<Input
type="number"
id="descentRate"
class="form-control-sm"
bind:value={$FlightParametersStore.descent_rate}
/>
</FormGroup>
</div>
<FormGroup spacing="mb-2">
<Label for="burstAltitude" class="form-label">Высота разрыва (м):</Label>
<Input
type="number"
id="burstAltitude"
class="form-control-sm"
bind:value={$FlightParametersStore.burst_altitude}
/>
</FormGroup>
<div class="mb-2 d-grid gap-1">
<Button color="outline-secondary" size="sm">Показать график высоты</Button>
<Button color="secondary" size="sm">Сохранить как шаблон</Button>
<Button size="sm" color="primary" on:click={handleGetPrediction}>Выполнить прогнозирование</Button>
</div>
</CardBody>
{/if}
</Card>
</div>

View file

@ -1,109 +0,0 @@
<script>
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { checkAuthenticated, logout } from '$lib/auth';
import {
Dropdown,
DropdownItem,
DropdownMenu,
DropdownToggle,
Nav,
NavItem,
NavLink,
Navbar,
NavbarBrand
} from '@sveltestrap/sveltestrap';
// Check if user is authenticated (using localStorage token)
let isAuthenticated = false;
// This should be reactive to changes in auth status
$: if (typeof window !== 'undefined') {
Promise.resolve(checkAuthenticated()).then(result => {
isAuthenticated = result;
});
} else {
isAuthenticated = false;
}
function handleLogout() {
// Clear authentication tokens
try {
logout();
} catch (error) {
console.error('Logout failed:', error);
}
// Update auth status
isAuthenticated = false;
// Redirect to login page
goto('/');
}
</script>
<Navbar color="light" light expand="lg" fixed="top" class="custom-navbar border-bottom">
<Nav class="me-auto mb-2 mb-lg-0" navbar>
<NavbarBrand href="/" class="nav-full-height">
<img src="/logo.svg" alt="Logo" height="34" class="d-inline-block align-text-top" />
</NavbarBrand>
<NavItem>
<NavLink
href="/predict"
class="nav-full-height border-bottom"
active={$page.url.pathname === '/predict'}
>
Прогнозирование
</NavLink>
</NavItem>
<NavItem>
<NavLink
href="/track"
class="nav-full-height border-bottom"
active={$page.url.pathname === '/track'}
>
Слежение
</NavLink>
</NavItem>
</Nav>
<Nav navbar>
{#if isAuthenticated}
<Dropdown nav inNavbar>
<DropdownToggle nav caret class="nav-full-height border-bottom">
Account
</DropdownToggle>
<DropdownMenu end>
<DropdownItem href="/user/account">
Account Settings
</DropdownItem>
<DropdownItem href="/user/templates">
Saved Templates
</DropdownItem>
<DropdownItem href="/user/predictions">
Prediction History
</DropdownItem>
<DropdownItem href="/user/flights">
Flight History
</DropdownItem>
<DropdownItem divider />
<DropdownItem on:click={handleLogout}>
Logout
</DropdownItem>
</DropdownMenu>
</Dropdown>
{:else}
<NavItem>
<NavLink
href="/login"
class="nav-full-height border-bottom"
active={$page.url.pathname === '/login'}
>
Login
</NavLink>
</NavItem>
{/if}
</Nav>
</Navbar>
<style>
</style>

View file

@ -1,74 +0,0 @@
<script>
import { onMount } from 'svelte';
//import { telemetryStore } from '../lib/telemetry.ts'; // Import your telemetry store
let telemetry = {};
let isCollapsed = false;
// Subscribe to the telemetry store
//const unsubscribe = telemetryStore.subscribe((data) => {
// telemetry = data;
//});
telemetry = {
latitude: 56.3576,
longitude: 39.8666,
altitude: 1000
};
// onMount(() => {
// return () => {
// unsubscribe(); // Cleanup subscription on component destroy
// };
// });
</script>
<div class="card shadow-lg position-absolute bottom-0 end-0 m-3" style="width: 23rem; max-height: 80vh; overflow-y: auto; z-index: 1000;">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<h6 class="card-title mb-0">Последние данные телеметрии</h6>
<button class="btn btn-sm btn-primary" on:click={() => (isCollapsed = !isCollapsed)}>
{#if isCollapsed}
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-caret-left-fill" 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"/>
</svg>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-caret-down" viewBox="0 0 16 16">
<path 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"/>
</svg>
{/if}
</button>
</div>
{#if !isCollapsed}
<div class="card-body">
<div class="mb-2">
<label class="form-label small">Широта:</label>
<div class="input-group input-group-sm">
<input type="text" class="form-control" value={telemetry.latitude || 'N/A'} readonly>
</div>
</div>
<div class="mb-2">
<label class="form-label small">Долгота:</label>
<div class="input-group input-group-sm">
<input type="text" class="form-control" value={telemetry.longitude || 'N/A'} readonly>
</div>
</div>
<div class="mb-2">
<label class="form-label small">Высота (м):</label>
<div class="input-group input-group-sm">
<input type="text" class="form-control" value={telemetry.altitude || 'N/A'} readonly>
</div>
</div>
</div>
{/if}
</div>
<style>
.card {
transition: all 0.3s ease;
}
.card-header {
cursor: pointer;
}
</style>

View file

@ -1,17 +1,21 @@
<script lang="ts"> <script lang="ts">
import Map from "../Map.svelte"; import Map from "$lib/components/Map.svelte";
import ControlPanel from "../ControlPanel.svelte"; import ControlPanel from "$lib/components/ControlPanel.svelte";
import Navbar from "../Navbar.svelte"; import Navbar from "$lib/components/Navbar.svelte";
import PanelContainer from "$lib/components/PanelContainer.svelte";
import TelemetryPanel from '$lib/components/TelemetryPanel.svelte';
import TabComponent from "$lib/components/TabComponent.svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { PredictionStore } from "$lib/stores"; import { PredictionStore } from "$lib/stores";
import { Modal } from "@sveltestrap/sveltestrap"; import { Modal, Icon } from "@sveltestrap/sveltestrap";
import { addToast, removeToast } from "../Toast.svelte" import Toast, { addToast, removeToast } from "$lib/components/Toast.svelte";
import ToastContainer from '../Toast.svelte'; import ToastContainer from '$lib/components/Toast.svelte';
import L from "leaflet"; import L from "leaflet";
let map: Map | null = null; let map: Map | null = null;
let panel: ControlPanel | null = null; let panel: PanelContainer | null = null;
let selectionToastId: string | null = null; let selectionToastId: string | null = null;
let activeTab: 'control' | 'telemetry' = 'control';
onMount(() => { onMount(() => {
PredictionStore.subscribe((data) => { PredictionStore.subscribe((data) => {
@ -58,12 +62,34 @@
selectionToastId = null; selectionToastId = null;
} }
} }
</script> </script>
<main> <main>
<Navbar /> <Navbar />
<Map bind:this={map} mode="prediction" bind:data={$PredictionStore} on:coordinatesSelected={handleCoordinateSelection}> <Map bind:this={map} mode="prediction" bind:data={$PredictionStore} on:coordinatesSelected={handleCoordinateSelection}>
<ControlPanel bind:this={panel} {handleClickSelectOnMap} /> <PanelContainer bind:this={panel}>
<TabComponent
tabs={[
{ id: 'control', icon: 'sliders', label: 'Прогноз' },
{ id: 'telemetry', icon: 'activity', label: 'Сценарий' },
{ id: 'settings', icon: 'gear', label: 'Настройки' },
{ id: 'about', icon: 'info-circle', label: 'О проекте' }
]}
bind:activeTab
/>
<div>
{#if activeTab === 'control'}
<ControlPanel {handleClickSelectOnMap} />
{:else if activeTab === 'telemetry'}
<TelemetryPanel />
{/if}
</div>
</PanelContainer>
<ToastContainer /> <ToastContainer />
</Map> </Map>
</main> </main>

View file

@ -1,7 +1,7 @@
<script> <script>
import Map from '../Map.svelte'; import Map from '$lib/components/Map.svelte';
import TelemetryPanel from '../TelemetryPanel.svelte'; import TelemetryPanel from '$lib/components/TelemetryPanel.svelte';
import Navbar from '../Navbar.svelte'; import Navbar from '$lib/components/Navbar.svelte';
// import BurstCalculator from './BurstCalculator.svelte'; // import BurstCalculator from './BurstCalculator.svelte';
let coordinates = { let coordinates = {

View file

@ -2,26 +2,26 @@
height: var(--navbar-height); height: var(--navbar-height);
padding-top: 0rem; padding-top: 0rem;
padding-bottom: 0rem; padding-bottom: 0rem;
z-index: 1000; z-index: 1002;
border: none; border: none;
background-color: white !important; background-color: white !important;
} }
.nav-link { .nav-full-height.nav-link {
color: inherit; color: inherit;
padding-left: 1rem !important; padding-left: 1rem !important;
padding-right: 1rem !important; padding-right: 1rem !important;
padding-top: 12px; padding-top: 12px;
background-color: var(--bs-light); background-color: white;
margin-right: 1px; margin-right: -1px;
} }
.nav-link:hover { .nav-full-height.nav-link:hover {
color: white !important;; color: white !important;;
background-color: var(--bs-primary); background-color: var(--bs-primary);
} }
.nav-link.active { .nav-full-height.nav-link.active {
color: white !important; color: white !important;
background-color: var(--bs-primary); background-color: var(--bs-primary);
} }
@ -39,7 +39,7 @@
margin-right: 1em; margin-right: 1em;
} }
.navbar { .navbar {
z-index: 1001; z-index: 1002;
} }
.card { .card {
@ -51,6 +51,8 @@
:root { :root {
--navbar-height: 44px; --navbar-height: 44px;
--panel-left: 20px;
--panel-top: 20px;
} }
.map-container { .map-container {
@ -69,7 +71,6 @@
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
font-size: 14px; font-size: 14px;
z-index: 1000; z-index: 1000;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
border: 1px solid #ccc; border: 1px solid #ccc;
width: auto; width: auto;
white-space: nowrap; white-space: nowrap;
@ -77,7 +78,23 @@
.panel-container { .panel-container {
position: absolute; position: absolute;
bottom: 20px; top: var(--panel-top);
left: 20px; left: var(--panel-left);
z-index: 1000; width: 23rem;
} max-height: 90vh;
max-width: calc(100vw - var(--panel-left) - var(--panel-left));
overflow-y: auto;
z-index: 1001;
}
.leaflet-bar {
border: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important;
border-radius: var(--bs-border-radius) !important;
}
@media (max-width: 767.98px)
{
.coordinates-display {
display: none;
}
}