New panel layout
This commit is contained in:
parent
87f0a53cb5
commit
329c1c2215
18 changed files with 671 additions and 515 deletions
316
src/lib/components/ControlPanel.svelte
Normal file
316
src/lib/components/ControlPanel.svelte
Normal 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>
|
||||
152
src/lib/components/Map.svelte
Normal file
152
src/lib/components/Map.svelte
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
<script lang="ts">
|
||||
import { onMount, createEventDispatcher } from "svelte";
|
||||
import * as L from "leaflet";
|
||||
import type { Map as LeafletMap, LayerGroup } from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import WindVisualization from "$lib/components/WindVisualisation.svelte";
|
||||
import { distHaversine } from "$lib/mathutil";
|
||||
import type { PredictionData, TelemetryData } from "$lib/types";
|
||||
|
||||
/**
|
||||
* @type {'prediction' | 'telemetry'}
|
||||
*/
|
||||
export let mode: "prediction" | "telemetry" = "prediction";
|
||||
export let data: PredictionData | TelemetryData | null = null;
|
||||
|
||||
let map: typeof LeafletMap | undefined;
|
||||
let mapContainer: HTMLDivElement;
|
||||
let plotLayerGroup: typeof LayerGroup;
|
||||
let mouseLat = 0;
|
||||
let mouseLng = 0;
|
||||
let isSelecting = false;
|
||||
|
||||
let windData: any;
|
||||
|
||||
const dispatch = createEventDispatcher<{ coordinatesSelected: { lat: number; lng: number } }>();
|
||||
|
||||
onMount(async () => {
|
||||
if (!mapContainer) return;
|
||||
|
||||
map = L.map(mapContainer, { zoomControl: false }).setView([51.505, -0.09], 13);
|
||||
L.control.zoom({ position: "bottomleft" }).addTo(map);
|
||||
|
||||
plotLayerGroup = L.layerGroup().addTo(map);
|
||||
|
||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
}).addTo(map);
|
||||
|
||||
const response = await fetch("src/routes/testVelo.json");
|
||||
windData = await response.json();
|
||||
|
||||
map.on("mousemove", (e: any) => {
|
||||
mouseLat = e.latlng.lat;
|
||||
mouseLng = e.latlng.lng;
|
||||
});
|
||||
|
||||
map.on("click", (e: any) => {
|
||||
if (isSelecting) {
|
||||
dispatch("coordinatesSelected", { lat: e.latlng.lat, lng: e.latlng.lng });
|
||||
stopSelection();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$: if (map && data) {
|
||||
plotData(data);
|
||||
} else if (map) {
|
||||
clearMapLayers();
|
||||
}
|
||||
|
||||
export const startSelection = () => {
|
||||
isSelecting = true;
|
||||
if (mapContainer) mapContainer.style.cursor = "crosshair";
|
||||
};
|
||||
|
||||
export const stopSelection = () => {
|
||||
isSelecting = false;
|
||||
if (mapContainer) mapContainer.style.cursor = "";
|
||||
};
|
||||
|
||||
export const plotData = (plotData: PredictionData | TelemetryData) => {
|
||||
if (mode === "prediction") {
|
||||
plotPrediction(plotData as PredictionData);
|
||||
} else if (mode === "telemetry") {
|
||||
plotTelemetry(plotData as TelemetryData);
|
||||
}
|
||||
};
|
||||
|
||||
export const clearMapLayers = () => {
|
||||
plotLayerGroup?.clearLayers();
|
||||
};
|
||||
|
||||
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 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 plotPrediction = (prediction: PredictionData) => {
|
||||
const { launch, landing, burst, flight_path, flight_time } = prediction;
|
||||
|
||||
const range = distHaversine(launch.latlng, landing.latlng, 1);
|
||||
const f_hours = Math.floor(flight_time / 3600);
|
||||
const f_minutes = Math.floor((flight_time % 3600) / 60).toString().padStart(2, "0");
|
||||
const flighttime = `${f_hours}hr${f_minutes}`;
|
||||
|
||||
L.marker(launch.latlng, { title: `Launch`, icon: launchIcon }).addTo(plotLayerGroup);
|
||||
L.marker(landing.latlng, { title: `Landing`, icon: landIcon }).addTo(plotLayerGroup);
|
||||
L.marker(burst.latlng, { title: `Burst`, icon: burstIcon }).addTo(plotLayerGroup);
|
||||
|
||||
L.polyline(flight_path, { weight: 3, color: "#000000" }).addTo(plotLayerGroup);
|
||||
|
||||
map?.fitBounds(L.latLngBounds(flight_path));
|
||||
};
|
||||
|
||||
const plotTelemetry = (telemetry: TelemetryData) => {
|
||||
L.marker(telemetry.launch.latlng, { title: `Launch`, icon: launchIcon }).addTo(plotLayerGroup);
|
||||
|
||||
telemetry.datapoints.forEach((point) => {
|
||||
L.marker([point.latitude, point.longitude], {
|
||||
title: `Telemetry at ${point.datetime}`,
|
||||
icon: telemetryIcon,
|
||||
})
|
||||
.bindPopup(`<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);
|
||||
|
||||
map?.fitBounds(L.latLngBounds(telemetry.flight_path));
|
||||
};
|
||||
|
||||
export const panTo = (lat: number, lng: number) => {
|
||||
if (map) {
|
||||
map.setView([lat, lng], map.getZoom());
|
||||
}
|
||||
};
|
||||
|
||||
export const zoomTo = (lat: number, lng: number, zoomLevel: number) => {
|
||||
if (map) {
|
||||
map.setView([lat, lng], zoomLevel);
|
||||
}
|
||||
};
|
||||
|
||||
export const getMap = () => {
|
||||
return map;
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="map-container" bind:this={mapContainer}>
|
||||
<div class="card coordinates-display">
|
||||
<p class="card-text">
|
||||
<b>Lat:</b>
|
||||
{mouseLat.toFixed(6)},
|
||||
<b>Lon:</b>
|
||||
{mouseLng.toFixed(6)}
|
||||
</p>
|
||||
</div>
|
||||
<slot />
|
||||
{#if map && windData}
|
||||
<WindVisualization {map} windData={windData} />
|
||||
{/if}
|
||||
</div>
|
||||
102
src/lib/components/Navbar.svelte
Normal file
102
src/lib/components/Navbar.svelte
Normal 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>
|
||||
14
src/lib/components/PanelContainer.svelte
Normal file
14
src/lib/components/PanelContainer.svelte
Normal 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>
|
||||
0
src/lib/components/ScenarioPanel.svelte
Normal file
0
src/lib/components/ScenarioPanel.svelte
Normal file
50
src/lib/components/TabComponent.svelte
Normal file
50
src/lib/components/TabComponent.svelte
Normal 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>
|
||||
97
src/lib/components/TelemetryPanel.svelte
Normal file
97
src/lib/components/TelemetryPanel.svelte
Normal 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>
|
||||
|
||||
97
src/lib/components/Toast.svelte
Normal file
97
src/lib/components/Toast.svelte
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<script context="module">
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
/**
|
||||
* @typedef {'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark'} ToastColor
|
||||
* @typedef {object} ToastMessage
|
||||
* @property {string} id - Unique identifier
|
||||
* @property {string} header - Toast title
|
||||
* @property {string} body - Toast message content
|
||||
* @property {ToastColor} [color='info'] - The color of the toast header icon
|
||||
* @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
|
||||
*/
|
||||
|
||||
/** @type {import('svelte/store').Writable<ToastMessage[]>} */
|
||||
export const toasts = writable([]);
|
||||
|
||||
/**
|
||||
* Adds a new toast to the list.
|
||||
* @param {Omit<ToastMessage, 'id'>} toast
|
||||
* @returns {string} The ID of the new toast.
|
||||
*/
|
||||
export function addToast(toast) {
|
||||
const id = crypto.randomUUID();
|
||||
toasts.update((all) => [...all, { id, ...toast }]);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a toast by its ID.
|
||||
* @param {string} id
|
||||
*/
|
||||
export function removeToast(id) {
|
||||
// call the onRemoveCallback if it exists
|
||||
toasts.update((all) => {
|
||||
const toast = all.find((t) => t.id === id);
|
||||
if (toast && toast.onRemoveCallback) {
|
||||
toast.onRemoveCallback(id);
|
||||
}
|
||||
return all.filter((t) => t.id !== id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback function to be called when a toast is removed.
|
||||
* @param {string} id - The ID of the removed toast.
|
||||
*/
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import { Toast, ToastBody, ToastHeader } from '@sveltestrap/sveltestrap';
|
||||
|
||||
/**
|
||||
* Removes a toast from the list by its ID.
|
||||
* @param {string} id
|
||||
*/
|
||||
|
||||
</script>
|
||||
|
||||
<!--
|
||||
This container holds all the toasts.
|
||||
To use this component:
|
||||
1. Import it into your layout or page: `import ToastContainer from './Toast.svelte';`
|
||||
2. Place `<ToastContainer />` in your markup.
|
||||
3. To show a toast from any other component:
|
||||
import { addToast } from './Toast.svelte';
|
||||
|
||||
// For an auto-closing error message
|
||||
addToast({ header: 'Error', body: 'Something went wrong.', color: 'danger' });
|
||||
|
||||
// For a persistent "map mode" indication
|
||||
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">
|
||||
{#each $toasts as toast (toast.id)}
|
||||
<Toast
|
||||
isOpen={true}
|
||||
autohide={!toast.persistent}
|
||||
delay={5000}
|
||||
color={toast.color || 'info'}
|
||||
on:close={() => removeToast(toast.id)}
|
||||
>
|
||||
<ToastHeader toggle={() => removeToast(toast.id)}>
|
||||
{toast.header}
|
||||
</ToastHeader>
|
||||
<ToastBody>
|
||||
{toast.body}
|
||||
</ToastBody>
|
||||
</Toast>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toast-container {
|
||||
z-index: 1090; /* High z-index to appear above other elements */
|
||||
}
|
||||
</style>
|
||||
197
src/lib/components/WindVisualisation.svelte
Normal file
197
src/lib/components/WindVisualisation.svelte
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import 'leaflet-velocity/dist/leaflet-velocity.css';
|
||||
import 'leaflet-velocity/dist/leaflet-velocity';
|
||||
import 'leaflet.heat';
|
||||
|
||||
export let map; // принимаем карту из родительского компонента
|
||||
export let windData;
|
||||
|
||||
let velocityLayer;
|
||||
let heatLayer;
|
||||
let legend;
|
||||
|
||||
// Состояние переключателей
|
||||
let showHeatmap = true;
|
||||
let showVectors = true;
|
||||
let layerControl;
|
||||
|
||||
// Функция для нормализации данных тепловой карты
|
||||
const prepareHeatData = (windData) => {
|
||||
if (!windData || !windData.header || !windData.data) {
|
||||
console.warn("Wind data is missing or incomplete");
|
||||
return [];
|
||||
}
|
||||
|
||||
const { lo1, la1, dx, dy, nx, ny } = windData.header;
|
||||
const heatData = [];
|
||||
let maxSpeed = 0;
|
||||
|
||||
// Собираем данные и находим максимальную скорость
|
||||
for (let y = 0; y < ny; y++) {
|
||||
for (let x = 0; x < nx; x++) {
|
||||
const u = windData.data[y][x * 2];
|
||||
const v = windData.data[y][x * 2 + 1];
|
||||
const speed = Math.sqrt(u * u + v * v);
|
||||
|
||||
if (!isNaN(speed)) {
|
||||
const lat = la1 - y * dy;
|
||||
const lng = lo1 + x * dx;
|
||||
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) => {
|
||||
if (!data || data.length === 0) {
|
||||
console.warn("No valid heat data provided");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return L.heatLayer(data, {
|
||||
radius: 15,
|
||||
blur: 20,
|
||||
maxZoom: 17,
|
||||
minOpacity: 0.5,
|
||||
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);
|
||||
|
||||
// Создаем слой векторов ветра
|
||||
velocityLayer = L.velocityLayer({
|
||||
displayValues: true,
|
||||
displayOptions: {
|
||||
velocityType: 'Wind Speed',
|
||||
position: 'bottomright',
|
||||
emptyString: 'No wind data',
|
||||
},
|
||||
data: windData
|
||||
}).addTo(map);
|
||||
|
||||
// Создаем тепловую карту
|
||||
const heatData = prepareHeatData(windData);
|
||||
heatLayer = createHeatLayer(heatData);
|
||||
|
||||
if (heatLayer) {
|
||||
heatLayer.addTo(map);
|
||||
createLegend(Math.max(...heatData.map(point => point[2])));
|
||||
} else {
|
||||
console.warn("Heat layer was not created");
|
||||
}
|
||||
};
|
||||
|
||||
// Создание легенды с учетом максимальной скорости
|
||||
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>
|
||||
<div class="legend-scale">
|
||||
<div class="legend-color" style="background: #0000FF;"></div>
|
||||
<div class="legend-color" style="background: #00FFFF;"></div>
|
||||
<div class="legend-color" style="background: #00FF00;"></div>
|
||||
<div class="legend-color" style="background: #FFFF00;"></div>
|
||||
<div class="legend-color" style="background: #FF0000;"></div>
|
||||
</div>
|
||||
<div class="legend-labels">
|
||||
<span>0</span>
|
||||
<span>${(maxSpeed * 0.25).toFixed(1)}</span>
|
||||
<span>${(maxSpeed * 0.5).toFixed(1)}</span>
|
||||
<span>${(maxSpeed * 0.75).toFixed(1)}</span>
|
||||
<span>${maxSpeed.toFixed(1)}</span>
|
||||
</div>
|
||||
`;
|
||||
return div;
|
||||
};
|
||||
|
||||
legend.addTo(map);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
updateLayers();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (map) {
|
||||
if (velocityLayer) map.removeLayer(velocityLayer);
|
||||
if (heatLayer) map.removeLayer(heatLayer);
|
||||
if (legend) map.removeControl(legend);
|
||||
}
|
||||
});
|
||||
|
||||
// Реактивность на изменение параметров
|
||||
$: if (map && windData) {
|
||||
updateLayers();
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
.wind-heat-legend {
|
||||
padding: 8px 10px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0 15px rgba(0,0,0,0.2);
|
||||
line-height: 1.2;
|
||||
color: #333;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.wind-heat-legend h4 {
|
||||
margin: 0 0 5px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.legend-scale {
|
||||
display: flex;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
height: 12px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.legend-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue