Compare commits
No commits in common. "components" and "master" have entirely different histories.
components
...
master
4
.gitignore
vendored
|
|
@ -21,7 +21,3 @@ Thumbs.db
|
|||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# AI tools
|
||||
.claude
|
||||
tmpclaude*
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"tabWidth": 4,
|
||||
"endOfLine": "lf",
|
||||
"printWidth": 120,
|
||||
"useTabs": true,
|
||||
"htmlWhitespaceSensitivity": "ignore",
|
||||
"bracketSameLine": true
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@
|
|||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
|
|
|
|||
1073
package-lock.json
generated
20
package.json
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "app4",
|
||||
"name": "project",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
|
|
@ -15,23 +15,13 @@
|
|||
"@sveltejs/adapter-auto": "^4.0.0",
|
||||
"@sveltejs/kit": "^2.16.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@types/luxon": "^3.6.2",
|
||||
"@vincjo/datatables": "^2.5.0",
|
||||
"svelte": "^5.34.8",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.2.5"
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sakitam-gis/maplibre-wind": "^2.0.3",
|
||||
"@sveltestrap/sveltestrap": "^7.1.0",
|
||||
"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",
|
||||
"luxon": "^3.6.1",
|
||||
"maplibre-gl": "^4.0.0",
|
||||
"svelte5-chartjs": "^1.0.0"
|
||||
"leaflet": "^1.9.4",
|
||||
"svelte-map-leaflet": "^0.5.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
src/app.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
16
src/app.html
|
|
@ -3,24 +3,8 @@
|
|||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<link rel="stylesheet" href="%sveltekit.assets%/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="%sveltekit.assets%/css/bootstrap-icons.css" />
|
||||
<link rel="stylesheet" href="%sveltekit.assets%/css/custom.css" />
|
||||
|
||||
<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%
|
||||
<style>
|
||||
body {
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
|
|
|
|||
|
|
@ -1,85 +0,0 @@
|
|||
import { getCsrfToken } from "$lib/auth";
|
||||
|
||||
export const API_BASE_URL = "http://localhost:8000/api";
|
||||
|
||||
export async function fetchAPI<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
let csrfToken = await getCsrfToken();
|
||||
if (!csrfToken) {
|
||||
console.warn("CSRF token not found, using empty string.");
|
||||
csrfToken = "";
|
||||
}
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
options.credentials = "include"; // Include cookies in the request
|
||||
options.headers = {
|
||||
...options.headers,
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRFToken": csrfToken,
|
||||
};
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
if (!response.ok) {
|
||||
let errorText = await response.json();
|
||||
if (
|
||||
errorText &&
|
||||
typeof errorText === "object" &&
|
||||
("detail" in errorText || "field_errors" in errorText || "non_field_errors" in errorText)
|
||||
) {
|
||||
// Handle structured error responses
|
||||
if ("detail" in errorText) {
|
||||
errorText = errorText.detail;
|
||||
} else if ("field_errors" in errorText) {
|
||||
errorText = Object.values(errorText.field_errors).join(", ");
|
||||
} else if ("non_field_errors" in errorText) {
|
||||
errorText = errorText.non_field_errors.join(", ");
|
||||
}
|
||||
} else {
|
||||
errorText = `Unexpected error: ${response.statusText}`;
|
||||
}
|
||||
throw new Error(`${errorText}`);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
// No content response
|
||||
return {} as T; // Return an empty object for 204 responses
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching ${url}:`, error);
|
||||
if (error instanceof Error) {
|
||||
// If the error is an instance of Error, rethrow it
|
||||
return Promise.reject(new Error(`${error.message}`));
|
||||
}
|
||||
return Promise.reject(new Error(`${error}`));
|
||||
}
|
||||
}
|
||||
|
||||
export function postAPI<T>(endpoint: string, data: any): Promise<T> {
|
||||
return fetchAPI<T>(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function getAPI<T>(endpoint: string): Promise<T> {
|
||||
return fetchAPI<T>(endpoint, {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
export function putAPI<T>(endpoint: string, data: any): Promise<T> {
|
||||
return fetchAPI<T>(endpoint, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteAPI<T>(endpoint: string): Promise<T> {
|
||||
return fetchAPI<T>(endpoint, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
/* API functions for Saved Points */
|
||||
import type { SavedPoint } from "$lib/types";
|
||||
import { getAPI, postAPI, putAPI, deleteAPI } from "./base";
|
||||
|
||||
export function getSavedPoints(): Promise<SavedPoint[]> {
|
||||
return getAPI<SavedPoint[]>("/saved-points/");
|
||||
}
|
||||
|
||||
export function savePoint(point: SavedPoint): Promise<SavedPoint> {
|
||||
return postAPI<SavedPoint>("/saved-points/", point);
|
||||
}
|
||||
|
||||
export function updatePoint(point: SavedPoint): Promise<SavedPoint> {
|
||||
return putAPI<SavedPoint>(`/saved-points/${point.id}/`, point);
|
||||
}
|
||||
|
||||
export function deletePoint(id: number): Promise<void> {
|
||||
return deleteAPI<void>(`/saved-points/${id}/`);
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
/* API functions for SavedFlightProfile */
|
||||
import type {SavedFlightProfile } from "$lib/types";
|
||||
import { getAPI, postAPI, putAPI, deleteAPI } from "./base";
|
||||
|
||||
export function getSavedFlightProfiles(): Promise<SavedFlightProfile[]> {
|
||||
return getAPI<SavedFlightProfile[]>("/saved-profiles/");
|
||||
}
|
||||
|
||||
export function saveFlightProfile(profile: SavedFlightProfile): Promise<SavedFlightProfile> {
|
||||
return postAPI<SavedFlightProfile>("/saved-profiles/", profile);
|
||||
}
|
||||
|
||||
export function updateFlightProfile(profile: SavedFlightProfile): Promise<SavedFlightProfile> {
|
||||
return putAPI<SavedFlightProfile>(`/saved-profiles/${profile.id}/`, profile);
|
||||
}
|
||||
|
||||
export function deleteFlightProfile(id: number): Promise<void> {
|
||||
return deleteAPI<void>(`/saved-profiles/${id}/`);
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
/* API functions for SavedScenario */
|
||||
import type { SavedScenario } from "$lib/types";
|
||||
import { getAPI, postAPI, putAPI, deleteAPI } from "./base";
|
||||
|
||||
export function getSavedScenarios(): Promise<SavedScenario[]> {
|
||||
return getAPI<SavedScenario[]>("/saved-templates/");
|
||||
}
|
||||
|
||||
export function saveScenario(template: SavedScenario): Promise<SavedScenario> {
|
||||
return postAPI<SavedScenario>("/saved-templates/", template);
|
||||
}
|
||||
|
||||
export function updateScenario(template: SavedScenario): Promise<SavedScenario> {
|
||||
return putAPI<SavedScenario>(`/saved-templates/${template.id}/`, template);
|
||||
}
|
||||
|
||||
export function deleteScenario(id: number): Promise<void> {
|
||||
return deleteAPI<void>(`/saved-templates/${id}/`);
|
||||
}
|
||||
136
src/lib/auth.ts
|
|
@ -1,136 +0,0 @@
|
|||
import Cookies from 'js-cookie';
|
||||
|
||||
export const CSRF_URL = 'http://localhost:8000/api/csrf/';
|
||||
export const LOGIN_URL = 'http://localhost:8000/api/login/';
|
||||
export const LOGOUT_URL = 'http://localhost:8000/api/logout/';
|
||||
export const SESSION_URL = 'http://localhost:8000/api/session/';
|
||||
export const WHOAMI_URL = 'http://localhost:8000/api/whoami/';
|
||||
export async function getCsrfToken(): Promise<string | null> {
|
||||
return Cookies.get('csrftoken') || null;
|
||||
}
|
||||
|
||||
export async function getCsrfTokenAuth(): Promise<string | null> {
|
||||
try {
|
||||
await fetch(CSRF_URL, {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
});
|
||||
return Cookies.get('csrftoken') || null;
|
||||
} catch (error) {
|
||||
console.error('Failed to get CSRF token:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkAuthenticated(): Promise<boolean> {
|
||||
try {
|
||||
const csrfToken = await getCsrfTokenAuth();
|
||||
if (!csrfToken) {
|
||||
throw new Error('CSRF token not found');
|
||||
}
|
||||
const response = await fetch(SESSION_URL, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Authentication check failed: ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return data.isAuthenticated;
|
||||
} catch (error) {
|
||||
console.error('Authentication check failed:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string): Promise<any> {
|
||||
try {
|
||||
const csrfToken = await getCsrfTokenAuth();
|
||||
if (!csrfToken) {
|
||||
throw new Error('CSRF token not found');
|
||||
}
|
||||
|
||||
const response = await fetch(LOGIN_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(`Login failed: ${response.statusText} - ${errorData.detail || ''}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
try {
|
||||
const csrfToken = await getCsrfTokenAuth();
|
||||
if (!csrfToken) {
|
||||
throw new Error('CSRF token not found');
|
||||
}
|
||||
|
||||
const response = await fetch(LOGOUT_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Logout failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
console.log('Logout successful');
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function whoami(): Promise<any> {
|
||||
try {
|
||||
const csrfToken = await getCsrfTokenAuth();
|
||||
if (!csrfToken) {
|
||||
throw new Error('CSRF token not found');
|
||||
}
|
||||
|
||||
const response = await fetch(WHOAMI_URL, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Whoami failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data || !data.username) {
|
||||
throw new Error('No user data found');
|
||||
}
|
||||
|
||||
return data.username;
|
||||
} catch (error) {
|
||||
console.error('Whoami failed:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
<script>
|
||||
import { Modal, ModalHeader, ModalBody, ModalFooter, Button } from "@sveltestrap/sveltestrap";
|
||||
|
||||
let {
|
||||
isOpen = $bindable(false),
|
||||
title = "Confirm Action",
|
||||
confirmText = "Confirm",
|
||||
cancelText = "Cancel",
|
||||
confirmVariant = "primary",
|
||||
cancelVariant = "secondary",
|
||||
onconfirm,
|
||||
oncancel,
|
||||
children,
|
||||
} = $props();
|
||||
|
||||
function handleConfirm() {
|
||||
onconfirm?.();
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
oncancel?.();
|
||||
isOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal {isOpen} toggle={handleCancel} fade={false} backdrop={true}>
|
||||
<ModalHeader toggle={handleCancel}>{title}</ModalHeader>
|
||||
<ModalBody>
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else}
|
||||
Вы действительно хотите продолжить?
|
||||
{/if}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color={cancelVariant} on:click={handleCancel}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button color={confirmVariant} on:click={handleConfirm}>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
|
@ -1,492 +0,0 @@
|
|||
<script lang="ts">
|
||||
/*
|
||||
Component Naming and Style Conventions:
|
||||
|
||||
1. **State Variables (`$state`)**:
|
||||
- Use camelCase.
|
||||
- No special prefixes are needed as `$state` already marks them as reactive state.
|
||||
- Example: `let isCollapsed = $state(false);`
|
||||
|
||||
2. **Derived State (`$derived`)**:
|
||||
- Use camelCase.
|
||||
- No special prefixes are needed as `$derived` already marks them as reactive derived state.
|
||||
- Example: `let currentPoint = $derived(...)`
|
||||
|
||||
3. **Component Instance References**:
|
||||
- Use camelCase and suffix with `Ref`.
|
||||
- Example: `let pointEditorRef: PointEditor | null = null;`
|
||||
|
||||
4. **Event Handlers**:
|
||||
- Use `handle<EventName>` or `handle<Element><Event>` naming.
|
||||
- Example: `function handleToggleCollapse() { ... }`
|
||||
|
||||
5. **Props**:
|
||||
- For event callback props, use `on<EventName>`.
|
||||
- Example: `let { onSelectOnMapClick = () => {} }: Props = $props();`
|
||||
|
||||
6. **HTML Element IDs**:
|
||||
- Use kebab-case.
|
||||
- Prefix with a component-specific identifier to avoid global scope conflicts.
|
||||
- Example: `id="cp-start-time"` (cp for ControlPanel)
|
||||
|
||||
7. **Stores**:
|
||||
- Use PascalCase and suffix with `Store`.
|
||||
- Example: `import { SavedPointsStore } from '$lib/stores';`
|
||||
- The reactive Svelte store prefix `$` is used as standard.
|
||||
*/
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
FormGroup,
|
||||
Icon,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputGroupText,
|
||||
Label,
|
||||
Dropdown,
|
||||
DropdownToggle,
|
||||
DropdownMenu,
|
||||
DropdownItem,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { getSavedPoints, updatePoint } from "$lib/api/points";
|
||||
import { addToast } from "$lib/components/ui/Toast.svelte";
|
||||
import PointEditor from "$lib/components/editors/PointEditor.svelte";
|
||||
import SelectSearchable from "$lib/components/ui/SelectSearchable.svelte";
|
||||
import { getForecast } from "$lib/prediction";
|
||||
import {
|
||||
FlightParametersStore,
|
||||
SavedPointsStore,
|
||||
writeLocalStorage,
|
||||
readLocalStorage,
|
||||
flightParametersDefaults,
|
||||
} from "$lib/stores";
|
||||
import { PROFILE_MAP, type FlightParameters, type SavedPoint } from "$lib/types";
|
||||
import CurveEditor from "$lib/components/editors/CurveEditor.svelte";
|
||||
import SpoilerGroup from "$lib/components/ui/SpoilerGroup.svelte";
|
||||
import LabelGroup from "./ui/LabelGroup.svelte";
|
||||
import { toFixedNumber } from "$lib/mathutil";
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
onSelectOnMapClick?: () => void;
|
||||
}
|
||||
let { onSelectOnMapClick = () => console.log("Select on map clicked") }: Props = $props();
|
||||
|
||||
// State
|
||||
let isCollapsed = $state(false);
|
||||
let startDate = $state(readLocalStorage<string>("startDate", new Date().toISOString().split("T")[0]));
|
||||
let startTime = $state(readLocalStorage<string>("startTime", "12:00:00"));
|
||||
let selectedPointId = $state($FlightParametersStore.start_point || -1);
|
||||
|
||||
let ascentProfile = $state("standard");
|
||||
let descentProfile = $state("standard");
|
||||
|
||||
// Component References
|
||||
let pointEditorRef: PointEditor | null = null;
|
||||
let curveEditorRef: CurveEditor | null = null;
|
||||
|
||||
// Derived State
|
||||
let currentPoint = $derived($SavedPointsStore.find((p) => p.id === selectedPointId) || null);
|
||||
let isPointDirty = $derived(() => {
|
||||
if (!currentPoint) return false; // Not dirty if no point is selected
|
||||
const latMatch = $FlightParametersStore.launch_latitude.toFixed(6) === currentPoint.lat.toFixed(6);
|
||||
const lonMatch = $FlightParametersStore.launch_longitude.toFixed(6) === currentPoint.lon.toFixed(6);
|
||||
const altMatch = $FlightParametersStore.launch_altitude.toFixed(2) === currentPoint.alt.toFixed(2);
|
||||
return !(latMatch && lonMatch && altMatch);
|
||||
});
|
||||
|
||||
// Lifecycle Hooks
|
||||
onMount(() => {
|
||||
// NOTE: Consider moving localStorage logic into the store itself for better encapsulation.
|
||||
$FlightParametersStore =
|
||||
readLocalStorage<FlightParameters>("flightParameters", flightParametersDefaults) || $FlightParametersStore;
|
||||
selectedPointId = $FlightParametersStore.start_point || -1;
|
||||
|
||||
getSavedPoints()
|
||||
.then((points) => SavedPointsStore.set(points))
|
||||
.catch((error) => {
|
||||
addToast({
|
||||
header: "Error Loading Points",
|
||||
body: `Failed to load saved points: ${error.message}`,
|
||||
color: "danger",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
writeLocalStorage<FlightParameters>("flightParameters", $FlightParametersStore);
|
||||
writeLocalStorage<string>("startDate", startDate);
|
||||
writeLocalStorage<string>("startTime", startTime);
|
||||
});
|
||||
|
||||
// Event Handlers
|
||||
function handlePointSelection(newPointId: number) {
|
||||
console.log("Point selection changed:", newPointId);
|
||||
selectedPointId = newPointId;
|
||||
const point = $SavedPointsStore.find((p) => p.id === newPointId);
|
||||
|
||||
if (point) {
|
||||
console.log("Selected point:", point);
|
||||
$FlightParametersStore.start_point = point.id;
|
||||
$FlightParametersStore.launch_latitude = point.lat;
|
||||
$FlightParametersStore.launch_longitude = point.lon;
|
||||
$FlightParametersStore.launch_altitude = point.alt;
|
||||
} else if (newPointId === -1) {
|
||||
$FlightParametersStore.start_point = -1;
|
||||
// When clearing the selection, we can reset to defaults or leave as is.
|
||||
// For now, we'll just update the ID. The user can manually edit coordinates.
|
||||
}
|
||||
}
|
||||
|
||||
function handleSaveCurrentPoint() {
|
||||
if (currentPoint) {
|
||||
// Update existing point
|
||||
const updatedPointData = {
|
||||
...currentPoint,
|
||||
lat: $FlightParametersStore.launch_latitude,
|
||||
lon: $FlightParametersStore.launch_longitude,
|
||||
alt: $FlightParametersStore.launch_altitude,
|
||||
};
|
||||
updatePoint(updatedPointData)
|
||||
.then((savedPoint) => {
|
||||
SavedPointsStore.update((points) => points.map((p) => (p.id === savedPoint.id ? savedPoint : p)));
|
||||
addToast({
|
||||
header: "Point Updated",
|
||||
body: `Point "${savedPoint.name}" was successfully updated.`,
|
||||
color: "success",
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
addToast({
|
||||
header: "Update Error",
|
||||
body: `Failed to update point: ${error.message}`,
|
||||
color: "danger",
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Create new point
|
||||
pointEditorRef?.open({
|
||||
id: 0, // Assuming 0 or a negative number indicates a new point
|
||||
name: `New Point ${new Date().toLocaleString()}`,
|
||||
lat: $FlightParametersStore.launch_latitude,
|
||||
lon: $FlightParametersStore.launch_longitude,
|
||||
alt: $FlightParametersStore.launch_altitude,
|
||||
// The onSave callback is handled by the onSelectPoint prop on the component
|
||||
}, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePredictionRequest() {
|
||||
// Persist current parameters before running prediction
|
||||
writeLocalStorage<FlightParameters>("flightParameters", $FlightParametersStore);
|
||||
try {
|
||||
const data = await getForecast($FlightParametersStore, `${startDate}T${startTime}Z`);
|
||||
console.log("Forecast request successful:", data);
|
||||
addToast({ header: "Forecast Request", body: "Forecast request successful!", color: "success" });
|
||||
} catch (error: any) {
|
||||
console.error("Error getting forecast:", error);
|
||||
addToast({ header: "Forecast Error", body: `Error getting forecast: ${error.message}`, color: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggleCollapse() {
|
||||
isCollapsed = !isCollapsed;
|
||||
}
|
||||
|
||||
// Public API
|
||||
export function updateLaunchPosition(lat: number, lng: number) {
|
||||
$FlightParametersStore.launch_latitude = toFixedNumber(lat, 6);
|
||||
$FlightParametersStore.launch_longitude = toFixedNumber(lng, 6);
|
||||
}
|
||||
|
||||
export function loadFlightParameters(params: FlightParameters) {
|
||||
$FlightParametersStore = params;
|
||||
selectedPointId = params.start_point || -1;
|
||||
}
|
||||
|
||||
export function getFlightParameters(): FlightParameters {
|
||||
return $FlightParametersStore;
|
||||
}
|
||||
|
||||
export function collapsePanel() {
|
||||
isCollapsed = true;
|
||||
}
|
||||
export function expandPanel() {
|
||||
isCollapsed = false;
|
||||
}
|
||||
export function 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;"
|
||||
onclick={handleToggleCollapse}>
|
||||
<b class="card-title mb-0 text-white p-0">Условия прогнозирования</b>
|
||||
<Button class="p-0" size="sm" color="primary" aria-label="Свернуть/развернуть условия прогнозирования">
|
||||
<Icon name={isCollapsed ? "caret-left-fill" : "caret-down-fill"} class="text-white" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
{#if !isCollapsed}
|
||||
<CardBody>
|
||||
<div class="d-flex gap-2">
|
||||
<FormGroup class="flex-fill w-50" spacing="mb-2">
|
||||
<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" />
|
||||
</FormGroup>
|
||||
<FormGroup class="flex-fill w-50" spacing="mb-2">
|
||||
<Label for="cp-start-date" class="form-label">Дата старта:</Label>
|
||||
<Input type="date" id="cp-start-date" class="form-control-sm" bind:value={startDate} />
|
||||
</FormGroup>
|
||||
</div>
|
||||
|
||||
<FormGroup spacing="mb-2">
|
||||
<Label for="cp-flight-profile" class="form-label">Профиль полета:</Label>
|
||||
<InputGroup size="sm">
|
||||
<Input type="select" id="cp-flight-profile" bind:value={$FlightParametersStore.profile}>
|
||||
{#each Object.entries(PROFILE_MAP) as [name, value]}
|
||||
<option {value}>{name}</option>
|
||||
{/each}
|
||||
</Input>
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup spacing="mb-2">
|
||||
<Label for="cp-start-point" class="form-label">Точка старта:</Label>
|
||||
<InputGroup size="sm">
|
||||
<SelectSearchable
|
||||
style="min-height: calc(1.5em + .5rem + 2px); padding: .25rem .5rem; font-size: .875rem;"
|
||||
id="cp-start-point"
|
||||
selected={selectedPointId}
|
||||
onChange={(e) => handlePointSelection(e)}
|
||||
options={$SavedPointsStore.map((point) => ({
|
||||
value: point.id,
|
||||
label: `${point.name} ${point.id === selectedPointId && isPointDirty() ? "(изменено)" : ""}`,
|
||||
}))}
|
||||
placeholder="Новая точка..."
|
||||
clearable={true}
|
||||
searchPlaceholder="Поиск по точкам..." />
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onclick={() => pointEditorRef?.open()}
|
||||
title="Открыть список точек">
|
||||
<Icon name="journal-bookmark-fill"/>
|
||||
</Button>
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup spacing="mb-2">
|
||||
<Label for="cp-latitude" class="form-label">Широта/Долгота:</Label>
|
||||
<InputGroup size="sm">
|
||||
<Input
|
||||
id="cp-latitude"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
bind:value={$FlightParametersStore.launch_latitude}
|
||||
placeholder="Latitude" />
|
||||
<InputGroupText>/</InputGroupText>
|
||||
<Input
|
||||
id="cp-longitude"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
bind:value={$FlightParametersStore.launch_longitude}
|
||||
placeholder="Longitude" />
|
||||
<Button color="secondary" size="sm" onclick={onSelectOnMapClick}>
|
||||
<Icon name="geo-alt-fill" />
|
||||
</Button>
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
|
||||
<div class="d-flex mb-2">
|
||||
<Button
|
||||
color="primary"
|
||||
class="flex-fill"
|
||||
size="sm"
|
||||
onclick={handleSaveCurrentPoint}
|
||||
title="Сохранить текущие координаты"
|
||||
disabled={!isPointDirty && selectedPointId !== -1}>
|
||||
Сохранить
|
||||
<Icon name="floppy2-fill" class="ms-1" />
|
||||
</Button>
|
||||
<Dropdown size="sm">
|
||||
<DropdownToggle
|
||||
class="dropdown-toggle-standalone"
|
||||
caret
|
||||
color="primary"
|
||||
size="sm"
|
||||
title="Дополнительные действия"
|
||||
style="background-color: var(--bs-indigo); border-color: var(--bs-indigo);">
|
||||
</DropdownToggle>
|
||||
<DropdownMenu>
|
||||
<DropdownItem class="small">Сохранить как новую...</DropdownItem>
|
||||
<DropdownItem class="small">Удалить выбранную точку</DropdownItem>
|
||||
<DropdownItem divider />
|
||||
<DropdownItem class="small">Сбросить изменения</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<FormGroup class="flex-fill w-50" spacing="mb-2">
|
||||
<Label for="cp-start-height" class="form-label">Высота старта (м):</Label>
|
||||
<Input
|
||||
type="number"
|
||||
id="cp-start-height"
|
||||
class="form-control-sm"
|
||||
bind:value={$FlightParametersStore.launch_altitude} />
|
||||
</FormGroup>
|
||||
<FormGroup class="flex-fill w-50" spacing="mb-2">
|
||||
<Label for="cp-burst-altitude" class="form-label">Высота разрыва (м):</Label>
|
||||
<Input
|
||||
type="number"
|
||||
id="cp-burst-altitude"
|
||||
class="form-control-sm"
|
||||
bind:value={$FlightParametersStore.burst_altitude} />
|
||||
</FormGroup>
|
||||
</div>
|
||||
|
||||
{#if $FlightParametersStore.profile !== "custom_profile"}
|
||||
<div class="mb-2 d-flex gap-2">
|
||||
<FormGroup class="flex-fill w-50" spacing="mb-2">
|
||||
<Label for="cp-ascent-rate" class="form-label">Скорость подъема (м/с):</Label>
|
||||
<Input
|
||||
type="number"
|
||||
id="cp-ascent-rate"
|
||||
class="form-control-sm"
|
||||
bind:value={$FlightParametersStore.ascent_rate} />
|
||||
</FormGroup>
|
||||
<FormGroup class="flex-fill w-50" spacing="mb-2">
|
||||
<Label for="cp-descent-rate" class="form-label">Скорость спуска (м/с):</Label>
|
||||
<Input
|
||||
type="number"
|
||||
id="cp-descent-rate"
|
||||
class="form-control-sm"
|
||||
bind:value={$FlightParametersStore.descent_rate} />
|
||||
</FormGroup>
|
||||
</div>
|
||||
{:else}
|
||||
<SpoilerGroup label="Профили подъема и спуска" class="mb-2">
|
||||
<Label class="form-label mb-0">Стадия подъема:</Label>
|
||||
<div class="d-flex gap-2 mb-0">
|
||||
<Input type="radio" bind:group={ascentProfile} value={"none"} label={"Нет"} />
|
||||
<Input type="radio" bind:group={ascentProfile} value={"standard"} label={"Стандартная"} />
|
||||
<Input type="radio" bind:group={ascentProfile} value={"custom"} label={"Пользовательская"} />
|
||||
</div>
|
||||
{#if ascentProfile === "custom"}
|
||||
<InputGroup size="sm" class="mb-2">
|
||||
<SelectSearchable
|
||||
style="min-height: calc(1.5em + .5rem + 2px); padding: .25rem .5rem; font-size: .875rem;"
|
||||
id="cp-start-point"
|
||||
selected={selectedPointId}
|
||||
onChange={() => {}}
|
||||
options={$SavedPointsStore.map((point) => ({
|
||||
value: point.id,
|
||||
label: `test`,
|
||||
}))}
|
||||
clearable={true}
|
||||
placeholder="Выбрать профиль..."
|
||||
searchPlaceholder="Поиск по профилям..." />
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onclick={() => pointEditorRef?.open()}
|
||||
title="Открыть список точек">
|
||||
<Icon name="pencil"/>
|
||||
</Button>
|
||||
</InputGroup>
|
||||
{:else if ascentProfile === "standard"}
|
||||
<InputGroup size="sm" class="mb-2">
|
||||
<Input type="select">
|
||||
<!-- {#each Object.entries(PROFILE_MAP) as [name, value]} -->
|
||||
<option value={"const"}>Постоянная скорость</option>
|
||||
<option value={"reverse"}>Аэродинамический спуск (реверс)</option>
|
||||
<!-- {/each} -->
|
||||
</Input>
|
||||
</InputGroup>
|
||||
{/if}
|
||||
<Label class="form-label mb-0">Стадия спуска:</Label>
|
||||
<div class="d-flex gap-2 mb-0">
|
||||
<Input type="radio" bind:group={descentProfile} value={"none"} label={"Нет"} id="cp-descent-stage-none" />
|
||||
<Input type="radio" bind:group={descentProfile} value={"standard"} label={"Стандартная"} id="cp-descent-stage-std" />
|
||||
<Input type="radio" bind:group={descentProfile} value={"custom"} label={"Пользовательская"} id="cp-descent-stage-custom" />
|
||||
</div>
|
||||
{#if descentProfile === "custom"}
|
||||
<InputGroup size="sm" class="mb-2">
|
||||
<SelectSearchable
|
||||
style="min-height: calc(1.5em + .5rem + 2px); padding: .25rem .5rem; font-size: .875rem;"
|
||||
id="cp-start-point"
|
||||
selected={selectedPointId}
|
||||
onChange={() => {}}
|
||||
options={$SavedPointsStore.map((point) => ({
|
||||
value: point.id,
|
||||
label: `test`,
|
||||
}))}
|
||||
clearable={true}
|
||||
placeholder="Выбрать профиль..."
|
||||
searchPlaceholder="Поиск по профилям..." />
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onclick={() => pointEditorRef?.open()}
|
||||
title="Открыть список точек">
|
||||
<Icon name="pencil"/>
|
||||
</Button>
|
||||
</InputGroup>
|
||||
{:else if descentProfile === "standard"}
|
||||
<InputGroup size="sm" class="mb-2">
|
||||
<Input type="select">
|
||||
<!-- {#each Object.entries(PROFILE_MAP) as [name, value]} -->
|
||||
<option value={"drag"}>Аэродинамический спуск</option>
|
||||
<option value={"const"}>Постоянная скорость</option>
|
||||
<option value={"const"}>Постоянная скорость (реверс)</option>
|
||||
<!-- {/each} -->
|
||||
</Input>
|
||||
</InputGroup>
|
||||
{/if}
|
||||
<Button size="sm" color="secondary" onclick={() => curveEditorRef?.openModal()} class="w-100">
|
||||
Открыть редактор кривых
|
||||
<Icon name="graph-up-arrow" />
|
||||
</Button>
|
||||
</SpoilerGroup>
|
||||
{/if}
|
||||
|
||||
<div class="d-flex">
|
||||
<Button class="flex-fill" size="sm" color="primary" onclick={handlePredictionRequest}>Выполнить прогнозирование</Button>
|
||||
<Dropdown size="sm">
|
||||
<DropdownToggle
|
||||
class="dropdown-toggle-standalone"
|
||||
caret
|
||||
color="primary"
|
||||
size="sm"
|
||||
title="Дополнительные действия"
|
||||
style="background-color: var(--bs-indigo); border-color: var(--bs-indigo);">
|
||||
</DropdownToggle>
|
||||
<DropdownMenu>
|
||||
<DropdownItem class="small">Сохранить</DropdownItem>
|
||||
<DropdownItem class="small">Сохранить как новый...</DropdownItem>
|
||||
<DropdownItem divider />
|
||||
<DropdownItem class="small">Сбросить настройки</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</CardBody>
|
||||
{/if}
|
||||
</Card>
|
||||
<CurveEditor bind:this={curveEditorRef} showTable={true} editor={false} />
|
||||
<PointEditor
|
||||
bind:this={pointEditorRef}
|
||||
onSelectPoint={(point: SavedPoint | null) => {
|
||||
if (point) {
|
||||
handlePointSelection(point.id);
|
||||
} else {
|
||||
handlePointSelection(-1); // Clear selection
|
||||
}
|
||||
}} />
|
||||
|
|
@ -1,278 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
<!-- Footer -->
|
||||
<footer class="bg-dark text-bg-dark mt-auto">
|
||||
<div class="container pt-5">
|
||||
<div class="row gy-5">
|
||||
<div class="col-lg-3 mw-lg-2">
|
||||
<div class="mb-4">
|
||||
<a class="navbar-brand" href="/">
|
||||
<img
|
||||
src="/logo-full-ru-dark.svg"
|
||||
class="d-inline-block align-middle img-fluid"
|
||||
alt="ООО «ЯКС»"
|
||||
width="250" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-8 offset-lg-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container pb-4">
|
||||
<div class="row">
|
||||
<div class="col-6 small">
|
||||
<div>Copyright © 2024 ООО «Якутские Космические Системы»</div>
|
||||
</div>
|
||||
<div class="col-6 text-end small">
|
||||
<div>
|
||||
<p>
|
||||
<a class="text-decoration-none" href="/usage_policy">Условия использования</a>
|
||||
-
|
||||
<a class="text-decoration-none" href="/privacy">Политика конфиденциальности</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Button,
|
||||
FormGroup,
|
||||
Label,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputGroupText,
|
||||
Icon,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
|
||||
let isCollapsed = $state(false);
|
||||
|
||||
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="Свернуть/развернуть параметры прогнозирования"
|
||||
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)}>
|
||||
{#if isCollapsed}
|
||||
<Icon name="caret-left-fill" class="text-white" />
|
||||
{:else}
|
||||
<Icon name="caret-down-fill" class="text-white" />
|
||||
{/if}
|
||||
</Button>
|
||||
</button>
|
||||
</CardHeader>
|
||||
{#if !isCollapsed}
|
||||
<CardBody>
|
||||
|
||||
</CardBody>
|
||||
{/if}
|
||||
</Card>
|
||||
|
|
@ -1,309 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount, createEventDispatcher } from "svelte";
|
||||
import { MapLibreCore, type IMapCore, type IMapMarker } from "$lib/mapcore";
|
||||
import WindVisualization from "$lib/components/WindVisualisation.svelte";
|
||||
import { distHaversine } from "$lib/mathutil";
|
||||
import type { Prediction, Telemetry } from "$lib/types";
|
||||
|
||||
export let mode: "prediction" | "telemetry" = "prediction";
|
||||
export let data: Prediction | Telemetry | null = null;
|
||||
|
||||
let mapCore: IMapCore;
|
||||
let mapContainer: HTMLDivElement;
|
||||
let markers: IMapMarker[] = [];
|
||||
let animatedMarker: IMapMarker | null = null;
|
||||
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;
|
||||
|
||||
mapCore = new MapLibreCore();
|
||||
mapCore.init(mapContainer, { center: [-0.09, 51.505], zoom: 13 });
|
||||
|
||||
mapCore.addNavigationControl("bottom-left");
|
||||
mapCore.addScaleControl({ maxWidth: 100, unit: "metric" }, "bottom-right");
|
||||
|
||||
const response = await fetch("src/routes/testVelo.json");
|
||||
windData = await response.json();
|
||||
|
||||
mapCore.on("mousemove", (e) => {
|
||||
mouseLat = e.lngLat.lat;
|
||||
mouseLng = e.lngLat.lng;
|
||||
});
|
||||
|
||||
mapCore.on("click", (e) => {
|
||||
if (isSelecting) {
|
||||
dispatch("coordinatesSelected", { lat: e.lngLat.lat, lng: e.lngLat.lng });
|
||||
stopSelection();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$: if (mapCore && data) {
|
||||
plotData(data);
|
||||
} else if (mapCore) {
|
||||
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: Prediction | Telemetry) => {
|
||||
if (mode === "prediction") {
|
||||
plotPrediction(plotData as Prediction);
|
||||
} else if (mode === "telemetry") {
|
||||
plotTelemetry(plotData as Telemetry);
|
||||
}
|
||||
};
|
||||
|
||||
export const clearMapLayers = () => {
|
||||
markers.forEach((marker) => marker.remove());
|
||||
markers = [];
|
||||
|
||||
removeAnimatedMarker();
|
||||
|
||||
if (mapCore && mapCore.hasLayer("flight-path")) mapCore.removeLayer("flight-path");
|
||||
if (mapCore && mapCore.hasSource("flight-path")) mapCore.removeSource("flight-path");
|
||||
if (mapCore && mapCore.hasLayer("telemetry-path")) mapCore.removeLayer("telemetry-path");
|
||||
if (mapCore && mapCore.hasSource("telemetry-path")) mapCore.removeSource("telemetry-path");
|
||||
};
|
||||
|
||||
const createMarker = (lng: number, lat: number, iconUrl: string, title: string) => {
|
||||
const el = document.createElement("div");
|
||||
el.className = "custom-marker";
|
||||
el.style.backgroundImage = `url(${iconUrl})`;
|
||||
el.style.width = "10px";
|
||||
el.style.height = "10px";
|
||||
el.style.backgroundSize = "100%";
|
||||
el.title = title;
|
||||
|
||||
const popup = mapCore
|
||||
.createPopup({ offset: 25, closeButton: false })
|
||||
.setHTML(`<b>${title}</b><br>Lat: ${lat.toFixed(6)}<br>Lng: ${lng.toFixed(6)}`);
|
||||
|
||||
const marker = mapCore
|
||||
.createMarker({ element: el })
|
||||
.setLngLat([lng, lat])
|
||||
.setPopup(popup)
|
||||
.addTo(mapCore);
|
||||
|
||||
el.addEventListener("mouseenter", () => marker.togglePopup());
|
||||
el.addEventListener("mouseleave", () => marker.togglePopup());
|
||||
|
||||
markers.push(marker);
|
||||
return marker;
|
||||
};
|
||||
|
||||
const createBurstMarker = (lng: number, lat: number, title: string) => {
|
||||
const el = document.createElement("div");
|
||||
el.className = "custom-marker";
|
||||
el.style.backgroundImage = `url(pop-marker.png)`;
|
||||
el.style.width = "16px";
|
||||
el.style.height = "16px";
|
||||
el.style.backgroundSize = "100%";
|
||||
el.title = title;
|
||||
|
||||
const popup = mapCore
|
||||
.createPopup({ offset: 25, closeButton: false })
|
||||
.setHTML(`<b>${title}</b><br>Lat: ${lat.toFixed(6)}<br>Lng: ${lng.toFixed(6)}`);
|
||||
|
||||
const marker = mapCore
|
||||
.createMarker({ element: el })
|
||||
.setLngLat([lng, lat])
|
||||
.setPopup(popup)
|
||||
.addTo(mapCore);
|
||||
|
||||
el.addEventListener("mouseenter", () => marker.togglePopup());
|
||||
el.addEventListener("mouseleave", () => marker.togglePopup());
|
||||
|
||||
markers.push(marker);
|
||||
return marker;
|
||||
};
|
||||
|
||||
const plotPrediction = (prediction: Prediction) => {
|
||||
clearMapLayers();
|
||||
|
||||
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}`;
|
||||
|
||||
const getLat = (latlng: any) => (Array.isArray(latlng) ? latlng[0] : latlng.lat);
|
||||
const getLng = (latlng: any) => (Array.isArray(latlng) ? latlng[1] : latlng.lng);
|
||||
|
||||
createMarker(getLng(launch.latlng), getLat(launch.latlng), "target-blue.png", "Launch");
|
||||
createMarker(getLng(landing.latlng), getLat(landing.latlng), "target-red.png", "Landing");
|
||||
createBurstMarker(getLng(burst.latlng), getLat(burst.latlng), "Burst");
|
||||
|
||||
const coordinates: [number, number][] = flight_path.map((coord) =>
|
||||
Array.isArray(coord) ? [coord[1], coord[0]] : [coord.lng, coord.lat],
|
||||
);
|
||||
|
||||
mapCore.addSource("flight-path", {
|
||||
type: "geojson",
|
||||
data: { type: "Feature", properties: {}, geometry: { type: "LineString", coordinates } },
|
||||
});
|
||||
|
||||
mapCore.addLayer({
|
||||
id: "flight-path",
|
||||
type: "line",
|
||||
source: "flight-path",
|
||||
layout: { "line-join": "round", "line-cap": "round" },
|
||||
paint: { "line-color": "#000000", "line-width": 3 },
|
||||
});
|
||||
|
||||
mapCore.fitBounds(coordinates, 50);
|
||||
};
|
||||
|
||||
const plotTelemetry = (telemetry: Telemetry) => {
|
||||
clearMapLayers();
|
||||
|
||||
const getLat = (latlng: any) => (Array.isArray(latlng) ? latlng[0] : latlng.lat);
|
||||
const getLng = (latlng: any) => (Array.isArray(latlng) ? latlng[1] : latlng.lng);
|
||||
|
||||
createMarker(
|
||||
getLng(telemetry.launch.latlng),
|
||||
getLat(telemetry.launch.latlng),
|
||||
"target-blue.png",
|
||||
"Launch",
|
||||
);
|
||||
|
||||
telemetry.datapoints.forEach((point) => {
|
||||
const el = document.createElement("div");
|
||||
el.className = "custom-marker";
|
||||
el.style.backgroundImage = `url(marker-sm-red.png)`;
|
||||
el.style.width = "10px";
|
||||
el.style.height = "10px";
|
||||
el.style.backgroundSize = "100%";
|
||||
|
||||
const popup = mapCore
|
||||
.createPopup({ offset: 25 })
|
||||
.setHTML(
|
||||
`<b>Telemetry Point</b><br>Lat: ${point.latitude.toFixed(6)}<br>Lon: ${point.longitude.toFixed(6)}`,
|
||||
);
|
||||
|
||||
const marker = mapCore
|
||||
.createMarker({ element: el })
|
||||
.setLngLat([point.longitude, point.latitude])
|
||||
.setPopup(popup)
|
||||
.addTo(mapCore);
|
||||
|
||||
markers.push(marker);
|
||||
});
|
||||
|
||||
const coordinates: [number, number][] = telemetry.flight_path.map((coord) =>
|
||||
Array.isArray(coord) ? [coord[1], coord[0]] : [coord.lng, coord.lat],
|
||||
);
|
||||
|
||||
mapCore.addSource("telemetry-path", {
|
||||
type: "geojson",
|
||||
data: { type: "Feature", properties: {}, geometry: { type: "LineString", coordinates } },
|
||||
});
|
||||
|
||||
mapCore.addLayer({
|
||||
id: "telemetry-path",
|
||||
type: "line",
|
||||
source: "telemetry-path",
|
||||
layout: { "line-join": "round", "line-cap": "round" },
|
||||
paint: { "line-color": "#000000", "line-width": 3 },
|
||||
});
|
||||
|
||||
mapCore.fitBounds(coordinates, 50);
|
||||
};
|
||||
|
||||
export const panTo = (lat: number, lng: number) => {
|
||||
if (mapCore) mapCore.setCenter([lng, lat]);
|
||||
};
|
||||
|
||||
export const zoomTo = (lat: number, lng: number, zoomLevel: number) => {
|
||||
if (mapCore) {
|
||||
mapCore.setCenter([lng, lat]);
|
||||
mapCore.setZoom(zoomLevel);
|
||||
}
|
||||
};
|
||||
|
||||
export const getMap = () => mapCore;
|
||||
|
||||
export const updateAnimatedMarker = (lat: number, lng: number) => {
|
||||
if (!mapCore) return;
|
||||
|
||||
if (!animatedMarker) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "animated-marker";
|
||||
el.innerHTML = `
|
||||
<svg width="32" height="32" viewBox="0 0 32 32">
|
||||
<circle cx="16" cy="16" r="14" fill="#FF6B6B" opacity="0.3" class="pulse-ring"/>
|
||||
<circle cx="16" cy="16" r="8" fill="#FF1744" stroke="white" stroke-width="2"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
animatedMarker = mapCore
|
||||
.createMarker({ element: el, anchor: "center" })
|
||||
.setLngLat([lng, lat])
|
||||
.addTo(mapCore);
|
||||
} else {
|
||||
animatedMarker.setLngLat([lng, lat]);
|
||||
}
|
||||
|
||||
mapCore.panTo([lng, lat], { duration: 100 });
|
||||
};
|
||||
|
||||
export const removeAnimatedMarker = () => {
|
||||
if (animatedMarker) {
|
||||
animatedMarker.remove();
|
||||
animatedMarker = null;
|
||||
}
|
||||
};
|
||||
</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 mapCore && windData}
|
||||
<WindVisualization map={mapCore.getInstance()} {windData} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.animated-marker) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:global(.animated-marker .pulse-ring) {
|
||||
animation: pulse 2s ease-out infinite;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
@keyframes :global(pulse) {
|
||||
0% {
|
||||
r: 8;
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
r: 14;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
import { checkAuthenticated, logout, whoami } 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;
|
||||
|
||||
// Authentication state
|
||||
let isAuthenticated: boolean | null = null; // null represents the initial, unknown state
|
||||
let user: string | null = null;
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const authStatus = await checkAuthenticated();
|
||||
isAuthenticated = authStatus;
|
||||
if (authStatus) {
|
||||
user = await whoami();
|
||||
} else {
|
||||
user = null;
|
||||
|
||||
if ($page.url.pathname !== "/") goto("/login"); // Redirect to login if not authenticated
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Authentication check failed:", error);
|
||||
isAuthenticated = false;
|
||||
user = null;
|
||||
if ($page.url.pathname !== "/") goto("/login"); // Redirect to login if not authenticated
|
||||
}
|
||||
});
|
||||
|
||||
function handleLogout() {
|
||||
try {
|
||||
logout();
|
||||
isAuthenticated = false;
|
||||
user = null;
|
||||
goto("/");
|
||||
} catch (error) {
|
||||
console.error("Logout failed:", error);
|
||||
}
|
||||
}
|
||||
</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 === true && user}
|
||||
<Dropdown nav inNavbar>
|
||||
<DropdownToggle nav caret class="nav-full-height border border-top-0">
|
||||
{user ?? "Пользователь"}
|
||||
</DropdownToggle>
|
||||
<DropdownMenu end>
|
||||
<DropdownItem href="/user/account">Учетная запись</DropdownItem>
|
||||
<DropdownItem href="/user/templates">Сохраненные сценарии</DropdownItem>
|
||||
<DropdownItem href="/user/predictions">История прогнозов</DropdownItem>
|
||||
<DropdownItem href="/user/flights">История слежения</DropdownItem>
|
||||
<DropdownItem divider />
|
||||
<DropdownItem on:click={handleLogout}>Выйти</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
{:else if isAuthenticated === false}
|
||||
<NavItem>
|
||||
<NavLink
|
||||
href="/login"
|
||||
class="nav-full-height border border-top-0"
|
||||
active={$page.url.pathname === "/login"}>
|
||||
Войти
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
{/if}
|
||||
<!-- While isAuthenticated is null (loading), nothing is rendered in this block -->
|
||||
</Nav>
|
||||
</div>
|
||||
</Navbar>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
<script lang="ts">
|
||||
export let element: HTMLDivElement | null = null;
|
||||
export let position: 'left' | 'right' = 'left';
|
||||
|
||||
export function getElement() {
|
||||
return element;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div bind:this={element} class="panel-container-{position}">
|
||||
<slot />
|
||||
</div>
|
||||
|
|
@ -1,302 +0,0 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Button,
|
||||
FormGroup,
|
||||
Label,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputGroupText,
|
||||
Icon,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { PREDICTION_MODE_MAP, PPREDICTION_MODE_NAMES } from "$lib/types";
|
||||
import type { SavedScenario } from "$lib/types";
|
||||
import { getSavedScenarios, updateScenario, saveScenario } from "$lib/api/scenarios";
|
||||
import { FlightParametersStore, writeLocalStorage, ScenarioStore, SavedScenarioStore } from "$lib/stores";
|
||||
import SelectSearchable from "$lib/components/ui/SelectSearchable.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { addToast } from "./ui/Toast.svelte";
|
||||
import ScenarioEditor from "$lib/components/editors/ScenarioEditor.svelte";
|
||||
|
||||
let isCollapsed = $state(false);
|
||||
let scenarioUnsaved = $derived(checkScenarioUnsaved());
|
||||
let selectedScenarioId = $state(-1);
|
||||
|
||||
let scenarioEditorRef: ScenarioEditor | null = null;
|
||||
|
||||
onMount(() => {
|
||||
getSavedScenarios()
|
||||
.then((scenarios) => SavedScenarioStore.set(scenarios))
|
||||
.catch((error) => {
|
||||
addToast({
|
||||
header: "Error Loading Points",
|
||||
body: `Failed to load saved points: ${error.message}`,
|
||||
color: "danger",
|
||||
});
|
||||
return [];
|
||||
});
|
||||
selectedScenarioId = $ScenarioStore.id;
|
||||
});
|
||||
|
||||
function checkScenarioUnsaved() {
|
||||
const savedScenario = $SavedScenarioStore.find((scenario) => scenario.id === $ScenarioStore.id);
|
||||
|
||||
if (!savedScenario) {
|
||||
return false; // No saved scenario found
|
||||
}
|
||||
|
||||
const flightParameters = $FlightParametersStore;
|
||||
const savedFlightParameters = savedScenario.flight_parameters;
|
||||
|
||||
// Compare flight parameters excluding launch_datetime
|
||||
console.log("Comparing flight parameters:", flightParameters, savedFlightParameters);
|
||||
return JSON.stringify(flightParameters) !== JSON.stringify(savedFlightParameters);
|
||||
}
|
||||
|
||||
function handleSaveCurrentScenario() {
|
||||
console.log("handleSaveCurrentScenario called");
|
||||
const scenario = $SavedScenarioStore.find((s) => s.id === selectedScenarioId);
|
||||
if (selectedScenarioId !== -1 && scenario) {
|
||||
$ScenarioStore.id = selectedScenarioId;
|
||||
updateScenario($ScenarioStore)
|
||||
.then((updatedScenario) => {
|
||||
$SavedScenarioStore = $SavedScenarioStore.map((s) =>
|
||||
s.id === updatedScenario.id ? updatedScenario : s,
|
||||
);
|
||||
SavedScenarioStore.set($SavedScenarioStore);
|
||||
$ScenarioStore = updatedScenario;
|
||||
selectedScenarioId = updatedScenario.id;
|
||||
addToast({
|
||||
header: "Сценарий обновлен",
|
||||
body: `Сценарий "${updatedScenario.name}" успешно обновлен.`,
|
||||
color: "success",
|
||||
});
|
||||
scenarioUnsaved = false;
|
||||
})
|
||||
.catch((error) => {
|
||||
addToast({
|
||||
header: "Ошибка обновления сценария",
|
||||
body: `Ошибка при обновлении сценария: ${error.message}`,
|
||||
color: "danger",
|
||||
});
|
||||
console.error("Ошибка при обновлении сценария:", error);
|
||||
});
|
||||
} else {
|
||||
scenarioEditorRef?.openModalAndCreate(
|
||||
null,
|
||||
{
|
||||
id: 0,
|
||||
name: "",
|
||||
flight_parameters: $FlightParametersStore,
|
||||
description: "test",
|
||||
model: "test",
|
||||
dataset: "test",
|
||||
prediction_mode: $ScenarioStore.prediction_mode,
|
||||
},
|
||||
true,
|
||||
handleModalSave,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleApplySelectedScenario(showToast = true) {
|
||||
const selectedScenario = $SavedScenarioStore.find((scenario) => scenario.id === selectedScenarioId);
|
||||
if (selectedScenario) {
|
||||
$ScenarioStore = selectedScenario;
|
||||
$FlightParametersStore = selectedScenario.flight_parameters;
|
||||
scenarioUnsaved = false;
|
||||
writeLocalStorage("scenario", $ScenarioStore);
|
||||
writeLocalStorage("flightParameters", $FlightParametersStore);
|
||||
if (showToast) {
|
||||
addToast({
|
||||
header: "Сценарий применен",
|
||||
body: `Сценарий "${selectedScenario.name}" успешно применен.`,
|
||||
color: "success",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (showToast)
|
||||
addToast({
|
||||
header: "Сценарий не найден",
|
||||
body: "Выбранный сценарий не существует.",
|
||||
color: "warning",
|
||||
});
|
||||
console.warn("Selected scenario not found:", selectedScenarioId);
|
||||
}
|
||||
}
|
||||
|
||||
function handleModalSave(savedScenario: SavedScenario) {
|
||||
if (savedScenario) {
|
||||
$ScenarioStore = savedScenario;
|
||||
selectedScenarioId = savedScenario.id;
|
||||
scenarioUnsaved = false;
|
||||
}
|
||||
}
|
||||
|
||||
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="Свернуть/развернуть параметры прогнозирования"
|
||||
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)}>
|
||||
{#if isCollapsed}
|
||||
<Icon name="caret-left-fill" class="text-white" />
|
||||
{:else}
|
||||
<Icon name="caret-down-fill" class="text-white" />
|
||||
{/if}
|
||||
</Button>
|
||||
</button>
|
||||
</CardHeader>
|
||||
{#if !isCollapsed}
|
||||
<CardBody>
|
||||
<FormGroup spacing="mb-2">
|
||||
<Label for="scenarioName" class="form-label">Cценарий:</Label>
|
||||
<InputGroup size="sm">
|
||||
<div class="position-relative flex-grow-1">
|
||||
<SelectSearchable
|
||||
style="min-height: calc(1.5em + .5rem + 2px); padding: .25rem .5rem; font-size: .875rem;"
|
||||
id="cp-start-point"
|
||||
options={$SavedScenarioStore.map((scenario) => ({
|
||||
value: scenario.id,
|
||||
label:
|
||||
scenario.name +
|
||||
`${scenario.id == $ScenarioStore.id && scenarioUnsaved ? " (изменено)" : ""}`,
|
||||
}))}
|
||||
bind:selected={selectedScenarioId}
|
||||
placeholder="Новый сценарий..."
|
||||
searchPlaceholder="Поиск сценариев..."
|
||||
clearable={true}
|
||||
onChange={() => {
|
||||
if (!scenarioUnsaved) {
|
||||
handleApplySelectedScenario(false);
|
||||
}
|
||||
}} />
|
||||
<!-- <Button
|
||||
size="sm"
|
||||
color="white"
|
||||
class="position-absolute top-50 end-0 translate-middle-y rounded-circle d-flex align-items-center justify-content-center"
|
||||
style="width: 16px; height: 16px; border: none; background: var(--bs-secondary); color: var(--bs-white); z-index: 10; margin-right: 2rem;"
|
||||
on:click={() => {
|
||||
selectedScenarioId = -1;
|
||||
}}
|
||||
disabled={selectedScenarioId === -1}>
|
||||
<Icon name="x" style="font-size: 16px;" />
|
||||
</Button> -->
|
||||
</div>
|
||||
<Button
|
||||
color="success"
|
||||
title="Применить сценарий"
|
||||
onclick={() => {
|
||||
handleApplySelectedScenario(true);
|
||||
}}>
|
||||
<span>✓</span>
|
||||
</Button>
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
|
||||
<div class="d-flex gap-2 mb-2">
|
||||
<Button color="secondary flex-fill" size="sm" title="Открыть список сценариев">
|
||||
Все сценарии
|
||||
<Icon name="journal-bookmark-fill" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color="primary flex-fill"
|
||||
size="sm"
|
||||
title="Сохранить текущие условия как сценарий"
|
||||
onclick={handleSaveCurrentScenario}
|
||||
disabled={!scenarioUnsaved && selectedScenarioId !== -1}>
|
||||
{selectedScenarioId !== -1 ? "Обновить сценарий" : "Сохранить сценарий"}
|
||||
<Icon name="floppy2-fill" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<FormGroup spacing="mb-2">
|
||||
<Label for="scenarioMode" class="form-label">Режим сценария:</Label>
|
||||
<InputGroup size="sm">
|
||||
<Input
|
||||
type="select"
|
||||
id="scenarioMode"
|
||||
bind:value={$ScenarioStore.prediction_mode}
|
||||
on:change={() => {
|
||||
scenarioUnsaved = true;
|
||||
}}>
|
||||
{#each Object.entries(PREDICTION_MODE_MAP) as [key, value]}
|
||||
<option {value}>
|
||||
{PPREDICTION_MODE_NAMES[key as keyof typeof PPREDICTION_MODE_NAMES]}
|
||||
{key}
|
||||
</option>
|
||||
{/each}
|
||||
</Input>
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup spacing="mb-2">
|
||||
<Label for="scenarioMode" class="form-label">Модель атмосферы:</Label>
|
||||
<InputGroup size="sm">
|
||||
<Input type="select" id="scenarioMode">
|
||||
<option>GFS (0.25°)</option>
|
||||
<option>GFS (0.5°)</option>
|
||||
</Input>
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup spacing="mb-2">
|
||||
<Label for="scenarioMode" class="form-label">Набор данных:</Label>
|
||||
<InputGroup size="sm">
|
||||
<Input type="select" id="scenarioMode">
|
||||
<option>Выбрать автоматически</option>
|
||||
<!-- TODO ручка апи для доступных наборов -->
|
||||
<option>20250701-00</option>
|
||||
<option>20250701-06</option>
|
||||
</Input>
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
|
||||
<hr />
|
||||
|
||||
<FormGroup spacing="mb-0">
|
||||
<Label for="export" class="form-label">Экспортировать результат:</Label>
|
||||
<InputGroup size="sm">
|
||||
<Input type="select" id="export">
|
||||
<option>JSON</option>
|
||||
<option>CSV</option>
|
||||
<option>KML</option>
|
||||
</Input>
|
||||
<Button
|
||||
color="primary"
|
||||
title="Edit Saved Locations"
|
||||
onclick={() => console.log("Not implemented yet")}>
|
||||
<span>Экспорт</span>
|
||||
<Icon name="file-earmark-arrow-down" />
|
||||
</Button>
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
</CardBody>
|
||||
{/if}
|
||||
</Card>
|
||||
<ScenarioEditor bind:this={scenarioEditorRef} onSave={handleModalSave} />
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,310 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import type { Prediction } from "$lib/types";
|
||||
import "bootstrap-icons/font/bootstrap-icons.css";
|
||||
|
||||
let { prediction }: { prediction: Prediction | null } = $props();
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
timeUpdate: { index: number; lat: number; lng: number; alt: number; datetime: Date };
|
||||
}>();
|
||||
|
||||
let isPlaying = $state(false);
|
||||
let currentIndex = $state(0);
|
||||
let playbackSpeed = $state(1);
|
||||
let isCollapsed = $state(false);
|
||||
let animationFrame: number | null = null;
|
||||
let lastUpdateTime = 0;
|
||||
|
||||
$effect(() => {
|
||||
if (prediction && currentIndex >= flightPathLength) {
|
||||
currentIndex = 0;
|
||||
}
|
||||
});
|
||||
|
||||
const flightPathLength = $derived(prediction?.flight_path?.length || 0);
|
||||
const progress = $derived(flightPathLength > 0 ? (currentIndex / flightPathLength) * 100 : 0);
|
||||
|
||||
const currentPosition = $derived.by(() => {
|
||||
if (!prediction || !prediction.flight_path[currentIndex]) return null;
|
||||
|
||||
const point = prediction.flight_path[currentIndex];
|
||||
let lat: number, lng: number, alt: number;
|
||||
|
||||
if (Array.isArray(point)) {
|
||||
lat = point[0];
|
||||
lng = point[1];
|
||||
alt = point[2] || 0;
|
||||
} else {
|
||||
lat = point.lat;
|
||||
lng = point.lng;
|
||||
alt = point.alt || 0;
|
||||
}
|
||||
|
||||
const totalTime = prediction.flight_time;
|
||||
const timeProgress = (currentIndex / flightPathLength) * totalTime;
|
||||
const launchTime = prediction.launch.datetime instanceof Date
|
||||
? prediction.launch.datetime.getTime()
|
||||
: new Date(prediction.launch.datetime).getTime();
|
||||
const datetime = new Date(launchTime + timeProgress * 1000);
|
||||
|
||||
return { lat, lng, alt, datetime };
|
||||
});
|
||||
|
||||
const timeElapsed = $derived.by(() => {
|
||||
if (!prediction || !currentPosition) return "00:00:00";
|
||||
const launchTime = prediction.launch.datetime instanceof Date
|
||||
? prediction.launch.datetime.getTime()
|
||||
: new Date(prediction.launch.datetime).getTime();
|
||||
const totalSeconds = Math.floor(
|
||||
(currentPosition.datetime.getTime() - launchTime) / 1000,
|
||||
);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
});
|
||||
|
||||
function animate(timestamp: number) {
|
||||
if (!isPlaying) return;
|
||||
|
||||
if (!lastUpdateTime) lastUpdateTime = timestamp;
|
||||
const deltaTime = timestamp - lastUpdateTime;
|
||||
|
||||
if (deltaTime >= 50 / playbackSpeed) {
|
||||
if (currentIndex < flightPathLength - 1) {
|
||||
currentIndex++;
|
||||
if (currentPosition) {
|
||||
dispatch("timeUpdate", { ...currentPosition, index: currentIndex });
|
||||
}
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
lastUpdateTime = timestamp;
|
||||
}
|
||||
|
||||
animationFrame = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
function play() {
|
||||
if (!prediction) return;
|
||||
if (currentIndex >= flightPathLength - 1) {
|
||||
currentIndex = 0;
|
||||
}
|
||||
isPlaying = true;
|
||||
lastUpdateTime = 0;
|
||||
animationFrame = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
function pause() {
|
||||
isPlaying = false;
|
||||
if (animationFrame !== null) {
|
||||
cancelAnimationFrame(animationFrame);
|
||||
animationFrame = null;
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
pause();
|
||||
currentIndex = 0;
|
||||
if (currentPosition) {
|
||||
dispatch("timeUpdate", { ...currentPosition, index: currentIndex });
|
||||
}
|
||||
}
|
||||
|
||||
function handleSliderChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
currentIndex = parseInt(target.value);
|
||||
if (currentPosition) {
|
||||
dispatch("timeUpdate", { ...currentPosition, index: currentIndex });
|
||||
}
|
||||
}
|
||||
|
||||
function changeSpeed() {
|
||||
const speeds = [1, 2, 5, 0.5];
|
||||
const currentSpeedIndex = speeds.indexOf(playbackSpeed);
|
||||
playbackSpeed = speeds[(currentSpeedIndex + 1) % speeds.length];
|
||||
}
|
||||
|
||||
function handleToggleCollapse() {
|
||||
isCollapsed = !isCollapsed;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
return () => {
|
||||
if (animationFrame !== null) {
|
||||
cancelAnimationFrame(animationFrame);
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="timeline-container card shadow-sm">
|
||||
<div
|
||||
class="card-header bg-primary text-white d-flex justify-content-between align-items-center p-1 px-3"
|
||||
style="cursor:pointer;"
|
||||
onclick={handleToggleCollapse}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === 'Enter' && handleToggleCollapse()}
|
||||
>
|
||||
<span class="fw-bold mb-0">Flight Timeline</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-primary p-0"
|
||||
aria-label="Toggle timeline visibility"
|
||||
>
|
||||
<i class="bi {isCollapsed ? 'bi-caret-left-fill' : 'bi-caret-down-fill'}"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if !isCollapsed}
|
||||
<div class="card-body p-3">
|
||||
<div class="timeline-info mb-2">
|
||||
<div class="info-section">
|
||||
<span class="form-label mb-1">Time:</span>
|
||||
<span class="fw-bold font-monospace">{timeElapsed}</span>
|
||||
</div>
|
||||
{#if currentPosition}
|
||||
<div class="info-section">
|
||||
<span class="form-label mb-1">Altitude:</span>
|
||||
<span class="fw-bold font-monospace">{Math.round(currentPosition.alt)} m</span>
|
||||
</div>
|
||||
<div class="info-section">
|
||||
<span class="form-label mb-1">Position:</span>
|
||||
<span class="fw-bold font-monospace"
|
||||
>{currentPosition.lat.toFixed(4)}, {currentPosition.lng.toFixed(4)}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="timeline-controls">
|
||||
<div class="btn-group me-2" role="group">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
onclick={stop}
|
||||
disabled={!prediction || currentIndex === 0}
|
||||
title="Reset to start"
|
||||
aria-label="Reset to start"
|
||||
>
|
||||
<i class="bi bi-skip-start-fill"></i>
|
||||
</button>
|
||||
|
||||
{#if !isPlaying}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-success"
|
||||
onclick={play}
|
||||
disabled={!prediction}
|
||||
title="Play animation"
|
||||
aria-label="Play animation"
|
||||
>
|
||||
<i class="bi bi-play-fill"></i>
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-warning"
|
||||
onclick={pause}
|
||||
title="Pause animation"
|
||||
aria-label="Pause animation"
|
||||
>
|
||||
<i class="bi bi-pause-fill"></i>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
onclick={changeSpeed}
|
||||
disabled={!prediction}
|
||||
title="Change playback speed"
|
||||
aria-label="Change playback speed"
|
||||
>
|
||||
{playbackSpeed}x
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow-1 position-relative">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={flightPathLength - 1}
|
||||
value={currentIndex}
|
||||
oninput={handleSliderChange}
|
||||
disabled={!prediction}
|
||||
class="form-range timeline-slider"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.timeline-container {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
min-width: 500px;
|
||||
max-width: 700px;
|
||||
z-index: 1000;
|
||||
background: var(--bs-body-bg, #fff);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.timeline-info {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.001rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.timeline-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Custom range slider styling to match Bootstrap theme */
|
||||
.timeline-slider::-webkit-slider-thumb {
|
||||
background: var(--bs-primary, #007bff);
|
||||
}
|
||||
|
||||
.timeline-slider::-moz-range-thumb {
|
||||
background: var(--bs-primary, #007bff);
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 767.98px) {
|
||||
.timeline-container {
|
||||
min-width: calc(100vw - 40px);
|
||||
max-width: calc(100vw - 40px);
|
||||
bottom: 10px;
|
||||
}
|
||||
|
||||
.timeline-info {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
|
||||
// Props
|
||||
let { map, windData }: { map: any; windData: any } = $props();
|
||||
|
||||
// State for layer toggles
|
||||
let showHeatmap = $state(false);
|
||||
let showParticles = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
if (!map || !windData) {
|
||||
console.warn('Map or wind data not available');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("WindVisualization component mounted");
|
||||
console.log("Wind data available:", windData);
|
||||
|
||||
// NOTE: @sakitam-gis/maplibre-wind requires tile-based or image URL sources
|
||||
// It does not support raw wind data arrays directly
|
||||
//
|
||||
// The library expects:
|
||||
// - TileSource with URL template (e.g., 'https://tiles.example.com/{z}/{x}/{y}.png')
|
||||
// - ImageSource with image URL and coordinates
|
||||
//
|
||||
// To use this library, we would need to:
|
||||
// 1. Convert wind data to tiles or images
|
||||
// 2. Serve them via a tile server
|
||||
// 3. Use TileSource or ImageSource with the URLs
|
||||
//
|
||||
// Alternative approaches:
|
||||
// 1. Use deck.gl with ParticleLayer for raw data visualization
|
||||
// 2. Use MapLibre's native heatmap layers for color visualization
|
||||
// 3. Create a custom WebGL layer for particle animation
|
||||
// 4. Pre-process wind data into tiles/images server-side
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
console.log("WindVisualization component destroyed");
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- <div class="layer-controls">
|
||||
<div class="control-group">
|
||||
<label>
|
||||
<input type="checkbox" bind:checked={showHeatmap} disabled />
|
||||
Тепловая карта
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" bind:checked={showParticles} disabled />
|
||||
Частицы ветра
|
||||
</label>
|
||||
</div>
|
||||
<small style="color: #666; font-size: 11px; margin-top: 8px; display: block;">
|
||||
Wind visualization requires tile/image source
|
||||
</small>
|
||||
<small style="color: #999; font-size: 10px; margin-top: 4px; display: block;">
|
||||
See WindVisualisation.svelte for implementation notes
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.layer-controls {
|
||||
position: absolute;
|
||||
bottom: 30px;
|
||||
left: 10px;
|
||||
z-index: 1000;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 10px 12px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
cursor: not-allowed;
|
||||
user-select: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.control-group input[type="checkbox"] {
|
||||
cursor: not-allowed;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
small {
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style> -->
|
||||
|
|
@ -1,607 +0,0 @@
|
|||
<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/ui/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 "$lib/components/ui/EditableCell.svelte";
|
||||
import CurveChart from "$lib/components/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>
|
||||
|
|
@ -1,366 +0,0 @@
|
|||
<script module lang="ts">
|
||||
export type EditorConfig<T> = {
|
||||
showTable?: boolean;
|
||||
closeOnSave?: boolean;
|
||||
closeOnDelete?: boolean;
|
||||
searchBy?: (keyof T)[];
|
||||
labels?: {
|
||||
item?: string;
|
||||
itemGenitive?: string;
|
||||
items?: string;
|
||||
add?: string;
|
||||
edit?: string;
|
||||
save?: string;
|
||||
update?: string;
|
||||
delete?: string;
|
||||
cancel?: string;
|
||||
close?: string;
|
||||
searchPlaceholder?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type EditorApi<T> = {
|
||||
save: (item: T) => Promise<T>;
|
||||
update: (item: T) => Promise<T>;
|
||||
delete: (item: T) => Promise<void>;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts" generics="T extends { id: number; name: string }">
|
||||
import { TableHandler } from "@vincjo/datatables";
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Alert,
|
||||
Icon,
|
||||
Pagination,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
Input,
|
||||
InputGroup,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
import { addToast } from "$lib/components/ui/Toast.svelte";
|
||||
import ConfirmationPrompt from "../ConfirmationPrompt.svelte";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
type Renderable = (props: any) => Snippet;
|
||||
|
||||
let {
|
||||
// Control
|
||||
isOpen = $bindable(false),
|
||||
items = $bindable([] as T[]),
|
||||
|
||||
// Snippets
|
||||
tableHeader,
|
||||
tableRow,
|
||||
formFields,
|
||||
|
||||
// Configuration
|
||||
itemFactory,
|
||||
api,
|
||||
config = {
|
||||
showTable: false,
|
||||
closeOnSave: false,
|
||||
closeOnDelete: false,
|
||||
searchBy: ["name"],
|
||||
labels: {
|
||||
item: "элемент",
|
||||
itemGenitive: "элемента",
|
||||
items: "элементы",
|
||||
add: "Добавить",
|
||||
edit: "Редактировать",
|
||||
save: "Сохранить",
|
||||
update: "Обновить",
|
||||
delete: "Удалить",
|
||||
cancel: "Отмена",
|
||||
close: "Закрыть",
|
||||
searchPlaceholder: "Поиск...",
|
||||
},
|
||||
},
|
||||
|
||||
// Callbacks
|
||||
onClose = () => {},
|
||||
onSave = (item: T) => {},
|
||||
onSelect = (item: T) => {},
|
||||
} = $props<{
|
||||
isOpen?: boolean;
|
||||
items?: T[];
|
||||
itemFactory: () => T;
|
||||
api: EditorApi<T>;
|
||||
config?: EditorConfig<T>;
|
||||
onClose?: () => void;
|
||||
onSave?: (item: T) => void;
|
||||
onSelect?: (item: T) => void;
|
||||
tableHeader: Renderable;
|
||||
tableRow: Renderable;
|
||||
formFields: Renderable;
|
||||
}>();
|
||||
|
||||
let isEditing = $state(false);
|
||||
let isAlertVisible = $state(false);
|
||||
let isConfirmationVisible = $state(false);
|
||||
let isTableVisible = $derived(config.showTable);
|
||||
let alertText = $state("");
|
||||
let selectedItem = $state<T | null>(null);
|
||||
let currentItem = $state<T>(itemFactory());
|
||||
|
||||
const table = $derived(new TableHandler(items, { rowsPerPage: 10 }));
|
||||
const search = $derived(table.createSearch(config.searchBy));
|
||||
|
||||
$effect(() => {
|
||||
table.setRows(items);
|
||||
});
|
||||
|
||||
export function open(item: T | null = null, showTable: boolean = config.showTable) {
|
||||
if (item) {
|
||||
handleEdit(item);
|
||||
} else {
|
||||
resetForm(false);
|
||||
}
|
||||
isOpen = true;
|
||||
isTableVisible = showTable;
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen = false;
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleEdit(item: T) {
|
||||
selectedItem = item;
|
||||
currentItem = { ...item };
|
||||
isEditing = true;
|
||||
}
|
||||
|
||||
function resetForm(clearSelection = true) {
|
||||
if (clearSelection) {
|
||||
selectedItem = null;
|
||||
}
|
||||
currentItem = itemFactory();
|
||||
isEditing = false;
|
||||
hideAlert();
|
||||
}
|
||||
|
||||
function handleSelect(item: T) {
|
||||
onSelect(item);
|
||||
close();
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
if (isEditing && selectedItem) {
|
||||
const updatedItem = await api.update(currentItem);
|
||||
items = items.map((i: T) => (i.id === updatedItem.id ? updatedItem : i));
|
||||
showToast("обновлен(а)", updatedItem.name);
|
||||
onSave(updatedItem);
|
||||
} else {
|
||||
const savedItem = await api.save(currentItem);
|
||||
items = [...items, savedItem];
|
||||
showToast("сохранен(а)", savedItem.name);
|
||||
onSave(savedItem);
|
||||
}
|
||||
resetForm();
|
||||
if (config.closeOnSave) {
|
||||
close();
|
||||
}
|
||||
} catch (error: any) {
|
||||
showAlert(`Ошибка: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(item: T) {
|
||||
selectedItem = item;
|
||||
isConfirmationVisible = true;
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!selectedItem) return;
|
||||
try {
|
||||
await api.delete(selectedItem);
|
||||
items = items.filter((i: T) => i.id !== selectedItem!.id);
|
||||
showToast("удален(а)", selectedItem.name);
|
||||
if (config.closeOnDelete) {
|
||||
close();
|
||||
}
|
||||
} catch (error: any) {
|
||||
showAlert(`Ошибка при удалении: ${error.message}`);
|
||||
} finally {
|
||||
isConfirmationVisible = false;
|
||||
resetForm();
|
||||
}
|
||||
}
|
||||
|
||||
function showAlert(message: string) {
|
||||
isAlertVisible = true;
|
||||
alertText = message;
|
||||
}
|
||||
|
||||
function hideAlert() {
|
||||
isAlertVisible = false;
|
||||
alertText = "";
|
||||
}
|
||||
|
||||
function showToast(action: string, name: string) {
|
||||
addToast({
|
||||
header: `${config.labels.item.charAt(0).toUpperCase() + config.labels.item.slice(1)} ${action}`,
|
||||
body: `${config.labels.item.charAt(0).toUpperCase() + config.labels.item.slice(1)} "${name}" успешно ${action}.`,
|
||||
color: "success",
|
||||
});
|
||||
}
|
||||
|
||||
const modalTitle = $derived(
|
||||
isEditing
|
||||
? `${config.labels.edit} ${config.labels.itemGenitive}`
|
||||
: config.showTable
|
||||
? `Сохраненные ${config.labels.items}`
|
||||
: `${config.labels.add} ${config.labels.itemGenitive}`,
|
||||
);
|
||||
const formTitle = $derived(
|
||||
isEditing
|
||||
? `${config.labels.edit} ${config.labels.itemGenitive}`
|
||||
: `${config.labels.add} новый ${config.labels.item}`,
|
||||
);
|
||||
const submitButtonText = $derived(isEditing ? config.labels.update : config.labels.save);
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
{isOpen}
|
||||
toggle={close}
|
||||
size="lg"
|
||||
fade={false}
|
||||
backdrop={true}
|
||||
scrollable
|
||||
class={isConfirmationVisible ? "modal-tinted" : ""}>
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{modalTitle}</h5>
|
||||
<button type="button" class="btn-close" onclick={close} aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{#if isTableVisible}
|
||||
<div class="position-relative mb-2">
|
||||
<Input
|
||||
type="text"
|
||||
class="form-control-sm pe-5"
|
||||
placeholder={config.labels.searchPlaceholder}
|
||||
bind:value={search.value}
|
||||
oninput={() => search.set()} />
|
||||
{#if search.value}
|
||||
<Button
|
||||
size="sm"
|
||||
color="white"
|
||||
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);"
|
||||
onclick={() => {
|
||||
search.value = "";
|
||||
search.set();
|
||||
}}>
|
||||
<Icon name="x" style="font-size: 16px;" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<div bind:this={table.element} class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
{@render tableHeader()}
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each table.rows as row (row.id)}
|
||||
<tr>
|
||||
{@render tableRow({ row })}
|
||||
<td class="fit">
|
||||
<InputGroup size="sm" class="d-flex gap-1 flex-nowrap">
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onclick={() => handleSelect(row as T)}
|
||||
class="p-0 border-0 bg-transparent text-success px-1"
|
||||
style="cursor: pointer; font-size: initial;">
|
||||
<Icon name="check-lg" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
onclick={() => handleEdit(row as T)}
|
||||
class="p-0 border-0 bg-transparent text-primary px-1"
|
||||
style="cursor: pointer; font-size: initial;">
|
||||
<Icon name="pencil" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="danger"
|
||||
on:click={() => confirmDelete(row as T)}
|
||||
class="p-0 border-0 bg-transparent text-danger px-1"
|
||||
style="cursor: pointer; font-size: initial;">
|
||||
<Icon name="trash" />
|
||||
</Button>
|
||||
</InputGroup>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination aria-label="Page navigation" size="sm">
|
||||
<PaginationItem>
|
||||
<PaginationLink previous onclick={() => table.setPage("previous")} />
|
||||
</PaginationItem>
|
||||
{#each table.pagesWithEllipsis as page}
|
||||
<PaginationItem active={table.currentPage === page}>
|
||||
<PaginationLink onclick={() => table.setPage(page)}>{page}</PaginationLink>
|
||||
</PaginationItem>
|
||||
{/each}
|
||||
<PaginationItem>
|
||||
<PaginationLink next onclick={() => table.setPage("next")} />
|
||||
</PaginationItem>
|
||||
</Pagination>
|
||||
{/if}
|
||||
|
||||
{#if isTableVisible && (isEditing || currentItem.id)}<hr />{/if}
|
||||
|
||||
<!-- Form for adding/editing -->
|
||||
<div>
|
||||
{#if isTableVisible}
|
||||
<h5>{formTitle}</h5>
|
||||
{/if}
|
||||
<Alert color="danger" isOpen={isAlertVisible} toggle={hideAlert} fade={false} class="mb-2">
|
||||
<Icon name="exclamation-triangle" class="me-2" />
|
||||
{alertText}
|
||||
</Alert>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}}>
|
||||
{@render formFields({ item: currentItem })}
|
||||
<div class="d-grid gap-2 d-md-flex mt-3">
|
||||
<Button type="submit" color="success" size="sm">{submitButtonText}</Button>
|
||||
{#if isEditing}
|
||||
<Button size="sm" type="button" color="secondary" onclick={() => resetForm()}>
|
||||
{config.labels.cancel}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if isEditing}
|
||||
<Button color="danger" size="sm" type="button" onclick={() => confirmDelete(currentItem)}>
|
||||
{config.labels.delete}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button color="secondary" size="sm" type="button" onclick={close}>
|
||||
{config.labels.close}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmationPrompt
|
||||
isOpen={isConfirmationVisible}
|
||||
title={`Подтвердите удаление ${config.labels.itemGenitive}`}
|
||||
confirmText={config.labels.delete}
|
||||
cancelText={config.labels.cancel}
|
||||
confirmVariant="danger"
|
||||
onconfirm={handleDelete}
|
||||
oncancel={() => (isConfirmationVisible = false)}>
|
||||
<p>Вы уверены, что хотите удалить {config.labels.item} "{selectedItem?.name}"?</p>
|
||||
</ConfirmationPrompt>
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount, type Snippet } from "svelte";
|
||||
import { FormGroup, Label, Input } from "@sveltestrap/sveltestrap";
|
||||
import type { SavedPoint } from "$lib/types";
|
||||
import { SavedPointsStore } from "$lib/stores";
|
||||
import { getSavedPoints, savePoint, updatePoint, deletePoint } from "$lib/api/points";
|
||||
import GenericEditor from "./GenericEditor.svelte";
|
||||
import type { EditorConfig, EditorApi } from "./GenericEditor.svelte";
|
||||
|
||||
type $$Props = {
|
||||
isOpen?: boolean;
|
||||
onClose?: () => void;
|
||||
onSave?: (p: SavedPoint) => void;
|
||||
onSelectPoint?: (p: SavedPoint) => void;
|
||||
point?: SavedPoint | null;
|
||||
editor?: boolean;
|
||||
config?: Partial<EditorConfig<SavedPoint>>;
|
||||
api?: Partial<EditorApi<SavedPoint>>;
|
||||
};
|
||||
|
||||
// Props
|
||||
let {
|
||||
isOpen = $bindable(false),
|
||||
onClose = () => {},
|
||||
onSave = (p: SavedPoint) => {},
|
||||
onSelectPoint = (p: SavedPoint) => {},
|
||||
point = null,
|
||||
editor = false,
|
||||
config: propConfig = {},
|
||||
api: propApi = {},
|
||||
} = $props();
|
||||
|
||||
// State
|
||||
let points = $state<SavedPoint[]>([]);
|
||||
let editorRef: GenericEditor<SavedPoint> | null = $state(null);
|
||||
let config: EditorConfig<SavedPoint> = $state<EditorConfig<SavedPoint>>({
|
||||
showTable: true,
|
||||
closeOnSave: false,
|
||||
closeOnDelete: false,
|
||||
searchBy: ["name"],
|
||||
labels: {
|
||||
item: "точка",
|
||||
itemGenitive: "точки",
|
||||
items: "точки",
|
||||
add: "Добавить",
|
||||
edit: "Редактирование",
|
||||
save: "Сохранить",
|
||||
update: "Обновить",
|
||||
delete: "Удалить",
|
||||
cancel: "Отмена",
|
||||
close: "Закрыть без сохранения",
|
||||
searchPlaceholder: "Поиск по названию...",
|
||||
},
|
||||
});
|
||||
let api: EditorApi<SavedPoint> = $state<EditorApi<SavedPoint>>({
|
||||
save: savePoint,
|
||||
update: updatePoint,
|
||||
delete: (p: SavedPoint) => deletePoint(p.id),
|
||||
});
|
||||
|
||||
// Load points from store or fetch from API
|
||||
onMount(async () => {
|
||||
if ($SavedPointsStore.length > 0) {
|
||||
points = $SavedPointsStore;
|
||||
} else if (config.showTable) {
|
||||
const pts = await getSavedPoints();
|
||||
points = pts;
|
||||
SavedPointsStore.set(pts);
|
||||
}
|
||||
});
|
||||
|
||||
// Sync local state with store changes
|
||||
$effect(() => {
|
||||
points = $SavedPointsStore;
|
||||
});
|
||||
|
||||
// Sync store with local state changes
|
||||
$effect(() => {
|
||||
SavedPointsStore.set(points);
|
||||
});
|
||||
|
||||
// Open editor in edit mode if point and editor props are set
|
||||
$effect(() => {
|
||||
if (editor && point && editorRef) {
|
||||
editorRef.open(point);
|
||||
}
|
||||
});
|
||||
|
||||
// Factory function for creating a new point
|
||||
const pointFactory = (): SavedPoint => ({ id: 0, name: "", lat: 0, lon: 0, alt: 0 });
|
||||
|
||||
// Public method to control the editor
|
||||
export function open(item: SavedPoint | null = null, showTable = config.showTable) {
|
||||
editorRef?.open(item, showTable);
|
||||
}
|
||||
</script>
|
||||
|
||||
<GenericEditor
|
||||
bind:this={editorRef}
|
||||
bind:isOpen
|
||||
bind:items={points}
|
||||
onClose={() => onClose()}
|
||||
onSave={(p) => onSave(p)}
|
||||
onSelect={(p) => onSelectPoint(p)}
|
||||
itemFactory={pointFactory}
|
||||
{api}
|
||||
{config}>
|
||||
{#snippet tableHeader()}
|
||||
<tr>
|
||||
<th>Название точки</th>
|
||||
<th>Широта</th>
|
||||
<th>Долгота</th>
|
||||
<th>Высота</th>
|
||||
<th class="fit">Действия</th>
|
||||
</tr>
|
||||
{/snippet}
|
||||
|
||||
{#snippet tableRow({ row })}
|
||||
<td>{row.name}</td>
|
||||
<td>{row.lat.toFixed(5)} °</td>
|
||||
<td>{row.lon.toFixed(5)} °</td>
|
||||
<td>{row.alt} м</td>
|
||||
{/snippet}
|
||||
|
||||
{#snippet formFields({ item })}
|
||||
<div class="mb-2">
|
||||
<Label for="name" class="small">Название точки:</Label>
|
||||
<Input class="form-control-sm" type="text" id="name" bind:value={item.name} required />
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<FormGroup class="flex-grow-1">
|
||||
<Label for="lat" class="small">Широта:</Label>
|
||||
<Input
|
||||
class="form-control-sm"
|
||||
type="number"
|
||||
step="any"
|
||||
id="lat"
|
||||
bind:value={item.lat}
|
||||
required />
|
||||
<span class="form-text">Градусы</span>
|
||||
</FormGroup>
|
||||
<FormGroup class="flex-grow-1">
|
||||
<Label for="lon" class="small">Долгота:</Label>
|
||||
<Input
|
||||
class="form-control-sm"
|
||||
type="number"
|
||||
step="any"
|
||||
id="lon"
|
||||
bind:value={item.lon}
|
||||
required />
|
||||
<span class="form-text">Градусы</span>
|
||||
</FormGroup>
|
||||
<FormGroup class="flex-grow-1">
|
||||
<Label for="alt" class="small">Высота:</Label>
|
||||
<Input
|
||||
class="form-control-sm"
|
||||
type="number"
|
||||
step="any"
|
||||
id="alt"
|
||||
bind:value={item.alt}
|
||||
required />
|
||||
<span class="form-text">Метры над ур. моря</span>
|
||||
</FormGroup>
|
||||
</div>
|
||||
{/snippet}
|
||||
</GenericEditor>
|
||||
|
|
@ -1,251 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { TableHandler } from "@vincjo/datatables";
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
FormGroup,
|
||||
Label,
|
||||
Input,
|
||||
Alert,
|
||||
Icon,
|
||||
Pagination,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
import { onMount } from "svelte";
|
||||
import { addToast } from "$lib/components/ui/Toast.svelte";
|
||||
import type { SavedScenario } from "$lib/types";
|
||||
import { FlightParametersStore, SavedScenarioStore } from "$lib/stores";
|
||||
import ConfirmationPrompt from "../ConfirmationPrompt.svelte";
|
||||
import { getSavedScenarios, saveScenario, updateScenario, deleteScenario } from "$lib/api/scenarios";
|
||||
|
||||
// Props
|
||||
let {
|
||||
isOpen = $bindable(false),
|
||||
onClose = () => {},
|
||||
onChange = () => {},
|
||||
onSave = () => {},
|
||||
onSelectScenario = (p: SavedScenario) => {},
|
||||
scenario = null,
|
||||
scenario_data = {
|
||||
id: 0,
|
||||
name: "",
|
||||
flight_parameters: $FlightParametersStore,
|
||||
description: "",
|
||||
model: "",
|
||||
dataset: "",
|
||||
prediction_mode: "",
|
||||
} as SavedScenario,
|
||||
} = $props();
|
||||
|
||||
// Runes
|
||||
let selectedScenario = $derived<SavedScenario | null>(scenario);
|
||||
let isEditing = $state(false);
|
||||
let closeOnSave = $state(false);
|
||||
let isAlertVisible = $state(false);
|
||||
let isConfirmationVisible = $state(false);
|
||||
let alertText = $state("");
|
||||
|
||||
let newScenario = $derived<SavedScenario>(scenario_data as SavedScenario);
|
||||
|
||||
$effect(() => {
|
||||
onChange();
|
||||
});
|
||||
|
||||
// Modal controls
|
||||
export function openModal() {
|
||||
isOpen = true;
|
||||
}
|
||||
|
||||
export function openModalAndCreate(
|
||||
scenario: SavedScenario | null = null,
|
||||
scenario_data: SavedScenario = {
|
||||
id: 0,
|
||||
name: "",
|
||||
flight_parameters: $FlightParametersStore,
|
||||
description: "",
|
||||
model: "",
|
||||
dataset: "",
|
||||
prediction_mode: "",
|
||||
},
|
||||
close: boolean = false,
|
||||
onSaveCallback: (point: SavedScenario) => void = () => {},
|
||||
) {
|
||||
if (scenario) {
|
||||
selectedScenario = scenario;
|
||||
newScenario = { ...scenario };
|
||||
isEditing = true;
|
||||
} else {
|
||||
selectedScenario = null;
|
||||
newScenario = scenario_data;
|
||||
isEditing = false;
|
||||
}
|
||||
isOpen = true;
|
||||
closeOnSave = close;
|
||||
onSave = onSaveCallback;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
isOpen = false;
|
||||
closeOnSave = false;
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleDeleteScenario(scenario: SavedScenario | null) {
|
||||
if (!scenario) {
|
||||
return;
|
||||
}
|
||||
deleteScenario(scenario.id)
|
||||
.then(() => {
|
||||
$SavedScenarioStore = $SavedScenarioStore.filter((s) => s.id !== scenario.id);
|
||||
SavedScenarioStore.set($SavedScenarioStore);
|
||||
resetForm();
|
||||
addToast({
|
||||
header: "Точка удалена",
|
||||
body: `Точка "${scenario.name}" успешно удалена.`,
|
||||
color: "success",
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
showAlert(`Ошибка при удалении сценария: ${error.message}`);
|
||||
console.error("Ошибка при удалении сценария:", error);
|
||||
});
|
||||
}
|
||||
|
||||
export function handleSaveScenario() {
|
||||
if (isEditing && selectedScenario) {
|
||||
updateScenario(newScenario)
|
||||
.then((updatedScenario) => {
|
||||
$SavedScenarioStore = $SavedScenarioStore.map((s) =>
|
||||
s.id === updatedScenario.id ? updatedScenario : s,
|
||||
);
|
||||
SavedScenarioStore.set($SavedScenarioStore);
|
||||
resetForm();
|
||||
addToast({
|
||||
header: "Сценарий обновлен",
|
||||
body: `Сценарий "${updatedScenario.name}" успешно обновлен.`,
|
||||
color: "success",
|
||||
});
|
||||
if (closeOnSave) {
|
||||
closeModal();
|
||||
}
|
||||
onSave(updatedScenario);
|
||||
})
|
||||
.catch((error) => {
|
||||
showAlert(`Ошибка при обновлении сценария: ${error.message}`);
|
||||
});
|
||||
} else {
|
||||
saveScenario(newScenario)
|
||||
.then((savedScenario) => {
|
||||
$SavedScenarioStore = [...$SavedScenarioStore, savedScenario];
|
||||
SavedScenarioStore.set($SavedScenarioStore);
|
||||
resetForm();
|
||||
addToast({
|
||||
header: "Сценарий сохранен",
|
||||
body: `Сценарий "${savedScenario.name}" успешно сохранен.`,
|
||||
color: "success",
|
||||
});
|
||||
if (closeOnSave) {
|
||||
closeModal();
|
||||
}
|
||||
onSave(savedScenario);
|
||||
})
|
||||
.catch((error) => {
|
||||
showAlert(`Ошибка при сохранении сценария: ${error.message}`);
|
||||
console.error("Ошибка при сохранении сценария:", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function showAlert(message: string) {
|
||||
isAlertVisible = true;
|
||||
alertText = message;
|
||||
}
|
||||
|
||||
export function hideAlert() {
|
||||
isAlertVisible = false;
|
||||
alertText = "";
|
||||
}
|
||||
|
||||
export function resetForm() {
|
||||
hideAlert();
|
||||
closeModal();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
{isOpen}
|
||||
toggle={closeModal}
|
||||
size="lg"
|
||||
fade={false}
|
||||
backdrop={true}
|
||||
scrollable
|
||||
class={isConfirmationVisible ? "modal-tinted" : ""}>
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Редактирование сценария</h5>
|
||||
<button type="button" class="btn-close" onclick={closeModal} aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div>
|
||||
<h5>{"Редактирование сценария"}</h5>
|
||||
<Alert
|
||||
color="danger"
|
||||
isOpen={isAlertVisible}
|
||||
toggle={() => (isAlertVisible = false)}
|
||||
fade={false}
|
||||
class="mb-2">
|
||||
<Icon name="exclamation-triangle" class="me-2" />
|
||||
{alertText}
|
||||
</Alert>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
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>
|
||||
<div class="d-grid gap-2 d-md-flex">
|
||||
<Button type="submit" color="success" size="sm">
|
||||
{isEditing ? "Обновить сценарий" : "Сохранить сценарий"}
|
||||
</Button>
|
||||
{#if isEditing}
|
||||
<Button size="sm" type="button" color="secondary" onclick={resetForm}>Отмена</Button>
|
||||
{/if}
|
||||
<span class="flex-grow-1"></span>
|
||||
{#if isEditing}
|
||||
<Button color="danger" size="sm" type="button" onclick={() => {}}>Удалить сценарий</Button>
|
||||
{:else}
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
type="button"
|
||||
onclick={() => {
|
||||
resetForm();
|
||||
closeModal();
|
||||
}}>
|
||||
Закрыть без сохранения
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmationPrompt
|
||||
isOpen={isConfirmationVisible}
|
||||
title="Подтвердите удаление"
|
||||
confirmText="Удалить"
|
||||
cancelText="Отмена"
|
||||
confirmVariant="danger"
|
||||
onconfirm={() => {
|
||||
isConfirmationVisible = false;
|
||||
handleDeleteScenario(selectedScenario);
|
||||
}}
|
||||
oncancel={() => {
|
||||
isConfirmationVisible = false;
|
||||
}}>
|
||||
<p>Вы уверены, что хотите удалить этот сценарий?</p>
|
||||
</ConfirmationPrompt>
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
<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,37 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
id?: string | undefined;
|
||||
label?: string;
|
||||
children?: () => any;
|
||||
}
|
||||
|
||||
let { id, label = "", class: className = "", children, ...restProps }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div {id} class="spoiler-group {className}" {...restProps}>
|
||||
<button class="btn btn-link p-0 d-flex align-items-center w-100 text-decoration-none spoiler-header">
|
||||
<div class="border-top" style="width: 10px;"></div>
|
||||
<span class="small text-nowrap ms-2">{label}</span>
|
||||
<div class="flex-fill border-top ms-2"></div>
|
||||
</button>
|
||||
|
||||
<div class="p-2 border border-top-0 spoiler-content">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.spoiler-header {
|
||||
margin-bottom: -0.75em;
|
||||
}
|
||||
.spoiler-content {
|
||||
padding-top: 0.75em !important;
|
||||
}
|
||||
|
||||
.spoiler-icon {
|
||||
line-height: 1;
|
||||
padding-bottom: 0.1em;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,236 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
options?: { value: any; label:string }[];
|
||||
selected?: any;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
disabled?: boolean;
|
||||
class?: string;
|
||||
onChange?: (value: any) => void;
|
||||
clearable?: boolean;
|
||||
}
|
||||
|
||||
|
||||
let {
|
||||
id = 'select-searchable',
|
||||
options = [],
|
||||
selected = $bindable(null),
|
||||
placeholder = 'Select an option...',
|
||||
searchPlaceholder = 'Search...',
|
||||
disabled = false,
|
||||
class: className = '',
|
||||
onChange,
|
||||
clearable = false,
|
||||
...restProps
|
||||
}: Props = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
let searchTerm = $state('');
|
||||
let dropdownElement = $state<HTMLElement>();
|
||||
let selectElement = $state<HTMLElement>();
|
||||
let searchInputElement = $state<HTMLInputElement>();
|
||||
let dropdownStyle = $state('');
|
||||
|
||||
let filteredOptions = $derived(
|
||||
options.filter(option =>
|
||||
option.label.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
let selectedLabel = $derived(
|
||||
options.find(opt => opt.value === selected)?.label || ''
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
updateDropdownPosition();
|
||||
});
|
||||
|
||||
function updateDropdownPosition() {
|
||||
if (!selectElement || !dropdownElement) return;
|
||||
const rect = selectElement.getBoundingClientRect();
|
||||
const dropdownHeight = dropdownElement.offsetHeight;
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
|
||||
let top, bottom;
|
||||
|
||||
if (spaceBelow >= dropdownHeight || spaceBelow >= spaceAbove) {
|
||||
top = `${rect.bottom}px`;
|
||||
bottom = 'auto';
|
||||
} else {
|
||||
top = 'auto';
|
||||
bottom = `${window.innerHeight - rect.top}px`;
|
||||
}
|
||||
|
||||
dropdownStyle = `
|
||||
position: fixed;
|
||||
top: ${top};
|
||||
bottom: ${bottom};
|
||||
left: ${rect.left}px;
|
||||
min-width: ${rect.width}px;
|
||||
`;
|
||||
}
|
||||
|
||||
function toggleDropdown() {
|
||||
if (!disabled) {
|
||||
isOpen = !isOpen;
|
||||
if (isOpen) {
|
||||
searchTerm = '';
|
||||
Promise.resolve().then(() => {
|
||||
updateDropdownPosition();
|
||||
searchInputElement?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectOption(option: { value: any; label: string }) {
|
||||
selected = option.value;
|
||||
isOpen = false;
|
||||
searchTerm = '';
|
||||
if (onChange) {
|
||||
onChange(selected);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (selectElement && !selectElement.contains(event.target as Node)) {
|
||||
isOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection(e: Event) {
|
||||
e.stopPropagation();
|
||||
selected = null;
|
||||
if (onChange) {
|
||||
onChange(null);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
window.addEventListener('scroll', updateDropdownPosition, true);
|
||||
window.addEventListener('resize', updateDropdownPosition);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('scroll', updateDropdownPosition, true);
|
||||
window.removeEventListener('resize', updateDropdownPosition);
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen && dropdownElement) {
|
||||
updateDropdownPosition();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleClickOutside} />
|
||||
|
||||
<div
|
||||
bind:this={selectElement}
|
||||
{id}
|
||||
class="form-control form-select select-container {className}"
|
||||
class:disabled
|
||||
class:show={isOpen}
|
||||
onclick={toggleDropdown}
|
||||
onkeydown={(e) => e.key === 'Enter' && toggleDropdown()}
|
||||
role="combobox"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
tabindex={disabled ? -1 : 0}
|
||||
{...restProps}
|
||||
>
|
||||
<span class:text-muted={!selected}>{selectedLabel || placeholder}</span>
|
||||
|
||||
{#if clearable && selected != null}
|
||||
<button
|
||||
type="button"
|
||||
class="clear-btn"
|
||||
tabindex="-1"
|
||||
aria-label="Clear selection"
|
||||
onclick={clearSelection}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if isOpen}
|
||||
<div class="pt-0 dropdown-menu show" bind:this={dropdownElement} style={dropdownStyle}>
|
||||
<div class="p-2">
|
||||
<input
|
||||
bind:this={searchInputElement}
|
||||
type="text"
|
||||
class="form-control form-control-sm"
|
||||
placeholder={searchPlaceholder}
|
||||
bind:value={searchTerm}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="options-list">
|
||||
{#each filteredOptions as option}
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-item small"
|
||||
class:active={option.value === selected}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
selectOption(option);
|
||||
}}
|
||||
onkeydown={(e) => e.key === 'Enter' && selectOption(option)}
|
||||
role="option"
|
||||
aria-selected={option.value === selected}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
{/each}
|
||||
{#if filteredOptions.length === 0}
|
||||
<div class="dropdown-item text-muted disabled">No options found</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.select-container {
|
||||
position: relative;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
z-index: 1000;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.options-list {
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 2rem;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: #2a2a2a;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.clear-btn:focus {
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
id?: string | undefined;
|
||||
label?: string;
|
||||
expanded?: boolean;
|
||||
children?: () => any;
|
||||
}
|
||||
|
||||
let {
|
||||
id,
|
||||
label = "",
|
||||
expanded = $bindable(false),
|
||||
class: className = "",
|
||||
children,
|
||||
...restProps
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div {id} class="spoiler-group {className}" {...restProps}>
|
||||
<button
|
||||
class="btn btn-link p-0 d-flex align-items-center w-100 text-decoration-none spoiler-header"
|
||||
onclick={() => (expanded = !expanded)}
|
||||
aria-expanded={expanded}>
|
||||
<span class="font-monospace fs-5 ms-1 fw-bold text-muted spoiler-icon" class:expanded>
|
||||
{expanded ? "−" : "+"}
|
||||
</span>
|
||||
<span class="small text-nowrap ms-1">{label}</span>
|
||||
<div class="flex-fill border-top ms-1"></div>
|
||||
</button>
|
||||
|
||||
{#if expanded}
|
||||
<div class="p-2 border border-top-0 spoiler-content">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{:else}
|
||||
<div style="padding-top: 0.75em;" class={className}></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.spoiler-header {
|
||||
margin-bottom: -0.75em;
|
||||
}
|
||||
.spoiler-content {
|
||||
padding-top: 0.75em !important;
|
||||
}
|
||||
|
||||
.spoiler-icon {
|
||||
line-height: 1;
|
||||
padding-bottom: 0.1em;
|
||||
}
|
||||
|
||||
.btn:hover .spoiler-icon {
|
||||
color: var(--bs-dark) !important;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { Icon } from "@sveltestrap/sveltestrap";
|
||||
|
||||
type Tab = {
|
||||
id: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export let tabs: Tab[] = [];
|
||||
export let activeTab: string;
|
||||
export let justify: 'start' | 'center' | 'end' = 'start';
|
||||
</script>
|
||||
|
||||
<div class="d-flex justify-content-{justify} 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>
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
<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([]);
|
||||
|
||||
const TOAST_ICONS = {
|
||||
primary: "info-circle-fill",
|
||||
secondary: "info-circle-fill",
|
||||
success: "check-circle-fill",
|
||||
danger: "exclamation-triangle-fill",
|
||||
warning: "exclamation-circle-fill",
|
||||
info: "info-circle-fill",
|
||||
light: "lightbulb",
|
||||
dark: "question",
|
||||
};
|
||||
|
||||
/**
|
||||
* 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, Icon } 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)} class={`text-${toast.color || "text-info"}`}>
|
||||
<Icon
|
||||
slot="icon"
|
||||
name={TOAST_ICONS[toast.color ? toast.color : "info"]}
|
||||
class="me-2"
|
||||
color={toast.color || "info"} />
|
||||
{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>
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
|
||||
// ─── Generic interfaces ────────────────────────────────────────────────────────
|
||||
// Implement these with any map library (Leaflet, OpenLayers, etc.)
|
||||
|
||||
export interface IMapPopup {
|
||||
setHTML(html: string): this;
|
||||
}
|
||||
|
||||
export interface IMapMarker {
|
||||
setLngLat(lngLat: [number, number]): IMapMarker;
|
||||
setPopup(popup: IMapPopup): IMapMarker;
|
||||
addTo(core: IMapCore): IMapMarker;
|
||||
remove(): void;
|
||||
togglePopup(): void;
|
||||
}
|
||||
|
||||
export interface IMapCore {
|
||||
init(container: HTMLElement, options: { center: [number, number]; zoom: number }): void;
|
||||
addNavigationControl(position: string): void;
|
||||
addScaleControl(options: { maxWidth: number; unit: string }, position: string): void;
|
||||
on(event: string, handler: (e: { lngLat: { lat: number; lng: number } }) => void): void;
|
||||
hasLayer(id: string): boolean;
|
||||
hasSource(id: string): boolean;
|
||||
addSource(id: string, source: object): void;
|
||||
addLayer(layer: object): void;
|
||||
removeLayer(id: string): void;
|
||||
removeSource(id: string): void;
|
||||
fitBounds(coords: [number, number][], padding: number): void;
|
||||
setCenter(lngLat: [number, number]): void;
|
||||
setZoom(zoom: number): void;
|
||||
panTo(lngLat: [number, number], options?: { duration?: number }): void;
|
||||
createMarker(options?: { element?: HTMLElement; anchor?: string }): IMapMarker;
|
||||
createPopup(options?: { offset?: number; closeButton?: boolean }): IMapPopup;
|
||||
/** Returns the underlying raw map instance (e.g. for library-specific plugins). */
|
||||
getInstance(): unknown;
|
||||
}
|
||||
|
||||
// ─── MapLibre GL implementation ────────────────────────────────────────────────
|
||||
|
||||
class MapLibrePopup implements IMapPopup {
|
||||
private _popup: maplibregl.Popup;
|
||||
|
||||
constructor(options?: { offset?: number; closeButton?: boolean }) {
|
||||
this._popup = new maplibregl.Popup(options);
|
||||
}
|
||||
|
||||
setHTML(html: string): this {
|
||||
this._popup.setHTML(html);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** @internal used by MapLibreMarker */
|
||||
raw(): maplibregl.Popup {
|
||||
return this._popup;
|
||||
}
|
||||
}
|
||||
|
||||
class MapLibreMarker implements IMapMarker {
|
||||
private _marker: maplibregl.Marker;
|
||||
|
||||
constructor(options?: { element?: HTMLElement; anchor?: string }) {
|
||||
this._marker = new maplibregl.Marker(options as maplibregl.MarkerOptions);
|
||||
}
|
||||
|
||||
setLngLat(lngLat: [number, number]): this {
|
||||
this._marker.setLngLat(lngLat);
|
||||
return this;
|
||||
}
|
||||
|
||||
setPopup(popup: IMapPopup): this {
|
||||
this._marker.setPopup((popup as MapLibrePopup).raw());
|
||||
return this;
|
||||
}
|
||||
|
||||
addTo(core: IMapCore): this {
|
||||
this._marker.addTo(core.getInstance() as maplibregl.Map);
|
||||
return this;
|
||||
}
|
||||
|
||||
remove(): void {
|
||||
this._marker.remove();
|
||||
}
|
||||
|
||||
togglePopup(): void {
|
||||
this._marker.togglePopup();
|
||||
}
|
||||
}
|
||||
|
||||
export class MapLibreCore implements IMapCore {
|
||||
private _map!: maplibregl.Map;
|
||||
|
||||
init(container: HTMLElement, options: { center: [number, number]; zoom: number }): void {
|
||||
this._map = new maplibregl.Map({
|
||||
container,
|
||||
style: {
|
||||
version: 8,
|
||||
sources: {
|
||||
osm: {
|
||||
type: 'raster',
|
||||
tiles: ['https://a.tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: 'osm',
|
||||
type: 'raster',
|
||||
source: 'osm',
|
||||
minzoom: 0,
|
||||
maxzoom: 19,
|
||||
},
|
||||
],
|
||||
},
|
||||
center: options.center,
|
||||
zoom: options.zoom,
|
||||
});
|
||||
}
|
||||
|
||||
addNavigationControl(position: string): void {
|
||||
this._map.addControl(
|
||||
new maplibregl.NavigationControl(),
|
||||
position as maplibregl.ControlPosition,
|
||||
);
|
||||
}
|
||||
|
||||
addScaleControl(options: { maxWidth: number; unit: string }, position: string): void {
|
||||
this._map.addControl(
|
||||
new maplibregl.ScaleControl(options as maplibregl.ScaleControlOptions),
|
||||
position as maplibregl.ControlPosition,
|
||||
);
|
||||
}
|
||||
|
||||
on(event: string, handler: (e: { lngLat: { lat: number; lng: number } }) => void): void {
|
||||
this._map.on(event as maplibregl.MapEventType, handler as never);
|
||||
}
|
||||
|
||||
hasLayer(id: string): boolean {
|
||||
return !!this._map.getLayer(id);
|
||||
}
|
||||
|
||||
hasSource(id: string): boolean {
|
||||
return !!this._map.getSource(id);
|
||||
}
|
||||
|
||||
addSource(id: string, source: object): void {
|
||||
this._map.addSource(id, source as maplibregl.SourceSpecification);
|
||||
}
|
||||
|
||||
addLayer(layer: object): void {
|
||||
this._map.addLayer(layer as maplibregl.LayerSpecification);
|
||||
}
|
||||
|
||||
removeLayer(id: string): void {
|
||||
this._map.removeLayer(id);
|
||||
}
|
||||
|
||||
removeSource(id: string): void {
|
||||
this._map.removeSource(id);
|
||||
}
|
||||
|
||||
fitBounds(coords: [number, number][], padding: number): void {
|
||||
const bounds = coords.reduce(
|
||||
(b, coord) => b.extend(coord),
|
||||
new maplibregl.LngLatBounds(coords[0], coords[0]),
|
||||
);
|
||||
this._map.fitBounds(bounds, { padding });
|
||||
}
|
||||
|
||||
setCenter(lngLat: [number, number]): void {
|
||||
this._map.setCenter(lngLat);
|
||||
}
|
||||
|
||||
setZoom(zoom: number): void {
|
||||
this._map.setZoom(zoom);
|
||||
}
|
||||
|
||||
panTo(lngLat: [number, number], options?: { duration?: number }): void {
|
||||
this._map.panTo(lngLat, options);
|
||||
}
|
||||
|
||||
createMarker(options?: { element?: HTMLElement; anchor?: string }): IMapMarker {
|
||||
return new MapLibreMarker(options);
|
||||
}
|
||||
|
||||
createPopup(options?: { offset?: number; closeButton?: boolean }): IMapPopup {
|
||||
return new MapLibrePopup(options);
|
||||
}
|
||||
|
||||
getInstance(): maplibregl.Map {
|
||||
return this._map;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
export function distHaversine(
|
||||
p1: { lat: number; lng: number },
|
||||
p2: { lat: number; lng: number },
|
||||
precision?: number
|
||||
): number {
|
||||
const R = 6371; // Earth's mean radius in km
|
||||
|
||||
const rad = (x: number): number => (x * Math.PI) / 180;
|
||||
|
||||
const dLat = rad(p2.lat - p1.lat);
|
||||
const dLong = rad(p2.lng - p1.lng);
|
||||
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(rad(p1.lat)) * Math.cos(rad(p2.lat)) * Math.sin(dLong / 2) * Math.sin(dLong / 2);
|
||||
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
const d = R * c;
|
||||
|
||||
return precision ? parseFloat(d.toFixed(precision)) : d;
|
||||
}
|
||||
|
||||
export function bearingHaversine(p1: { lat: number; lng: number }, p2: { lat: number; lng: number }): number {
|
||||
const rad = (x: number): number => (x * Math.PI) / 180;
|
||||
|
||||
const dLong = rad(p2.lng - p1.lng);
|
||||
const y = Math.sin(dLong) * Math.cos(rad(p2.lat));
|
||||
const x =
|
||||
Math.cos(rad(p1.lat)) * Math.sin(rad(p2.lat)) - Math.sin(rad(p1.lat)) * Math.cos(rad(p2.lat)) * Math.cos(dLong);
|
||||
|
||||
return (Math.atan2(y, x) * 180) / Math.PI;
|
||||
}
|
||||
|
||||
export function toFixedNumber(num: number, digits: number, base: number = 10): number {
|
||||
const pow = Math.pow(base ?? 10, digits);
|
||||
return Math.round(num * pow) / pow;
|
||||
}
|
||||
|
|
@ -1,161 +0,0 @@
|
|||
import { writable } from "svelte/store";
|
||||
import type { LatLngExpression } from "./types";
|
||||
|
||||
import { getCsrfToken } from "./auth";
|
||||
import type { PredictionStage, RawPrediction, Prediction, Point } from "./types";
|
||||
import { PredictionStore, RawPredictionStore, writeLocalStorage } from "./stores";
|
||||
|
||||
function getLatestDataset() {
|
||||
const now = new Date();
|
||||
const hours = now.getUTCHours();
|
||||
const minutes = now.getUTCMinutes();
|
||||
const seconds = now.getUTCSeconds();
|
||||
|
||||
// Round down to the nearest 6-hour interval
|
||||
const roundedHours = Math.floor(hours / 6) * 6;
|
||||
const roundedDate = new Date(now);
|
||||
roundedDate.setUTCHours(roundedHours, 0, 0, 0);
|
||||
|
||||
// Subtract 6 hours to account for the lag
|
||||
roundedDate.setUTCHours(roundedDate.getUTCHours() - 6);
|
||||
|
||||
return roundedDate.toISOString();
|
||||
}
|
||||
|
||||
function formatLaunchDateTime(dateObj: string | Date, timeStr: string): string {
|
||||
// Ensure date is a Date object
|
||||
const date = new Date(dateObj);
|
||||
|
||||
// Extract date components
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
|
||||
// Format time (ensure it has seconds)
|
||||
let formattedTime = timeStr;
|
||||
if (timeStr.split(":").length === 2) {
|
||||
formattedTime += ":00"; // Add seconds if missing
|
||||
}
|
||||
|
||||
// Combine into ISO string
|
||||
const isoString = new Date(`${year}-${month}-${day}T${formattedTime}Z`).toISOString();
|
||||
|
||||
return isoString;
|
||||
}
|
||||
|
||||
export const getForecast = async (
|
||||
flightParameters: Record<string, any>,
|
||||
launchDateTime: string
|
||||
): Promise<void> => {
|
||||
// Create request object
|
||||
flightParameters.dataset = getLatestDataset();
|
||||
flightParameters.launch_datetime = launchDateTime;
|
||||
|
||||
if (flightParameters.start_point === -1) {
|
||||
// remove start_point if it is -1
|
||||
delete flightParameters.start_point;
|
||||
}
|
||||
|
||||
console.log("Sending request:", flightParameters);
|
||||
|
||||
try {
|
||||
// Example POST request - replace with your actual API endpoint
|
||||
const csrfToken = await getCsrfToken();
|
||||
if (!csrfToken) {
|
||||
throw new Error("CSRF token not found");
|
||||
}
|
||||
|
||||
const response = await fetch("http://localhost:8000/api/predictions/", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRFToken": csrfToken,
|
||||
},
|
||||
body: JSON.stringify(flightParameters),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("Forecast response:", data);
|
||||
|
||||
RawPredictionStore.set(data.result as RawPrediction);
|
||||
PredictionStore.set(parsePrediction(data.result.prediction) as Prediction);
|
||||
writeLocalStorage("rawPrediction", data.result as RawPrediction);
|
||||
writeLocalStorage("prediction", parsePrediction(data.result.prediction) as Prediction);
|
||||
// Handle the response data as needed
|
||||
} catch (error) {
|
||||
console.error("Error sending forecast request:", error);
|
||||
return Promise.reject(new Error(`${error}`));
|
||||
}
|
||||
};
|
||||
|
||||
export function parsePrediction(prediction: PredictionStage[]): Prediction {
|
||||
const flight_path: LatLngExpression[] = [];
|
||||
const launch: Point = {} as any;
|
||||
const burst: Point = {} as any;
|
||||
const landing: Point = {} as any;
|
||||
|
||||
const ascent = prediction[0].trajectory;
|
||||
const descent = prediction[1].trajectory;
|
||||
|
||||
// Add the ascent track to the flight path array.
|
||||
ascent.forEach((item) => {
|
||||
let lon = item.longitude;
|
||||
if (lon > 180.0) {
|
||||
lon -= 360.0;
|
||||
}
|
||||
|
||||
flight_path.push([item.latitude, lon, item.altitude]);
|
||||
});
|
||||
|
||||
// Add the descent track to the flight path array.
|
||||
descent.forEach((item) => {
|
||||
let lon = item.longitude;
|
||||
if (lon > 180.0) {
|
||||
lon -= 360.0;
|
||||
}
|
||||
|
||||
flight_path.push([item.latitude, lon, item.altitude]);
|
||||
});
|
||||
|
||||
// Populate the launch, burst, and landing points
|
||||
const launchObj = ascent[0];
|
||||
let lon = launchObj.longitude;
|
||||
if (lon > 180.0) {
|
||||
lon -= 360.0;
|
||||
}
|
||||
launch.latlng = { lat: launchObj.latitude, lng: lon, alt: launchObj.altitude };
|
||||
launch.datetime = new Date(launchObj.datetime);
|
||||
|
||||
const burstObj = descent[0];
|
||||
lon = burstObj.longitude;
|
||||
if (lon > 180.0) {
|
||||
lon -= 360.0;
|
||||
}
|
||||
burst.latlng = { lat: burstObj.latitude, lng: lon, alt: burstObj.altitude };
|
||||
burst.datetime = new Date(burstObj.datetime);
|
||||
|
||||
const landingObj = descent[descent.length - 1];
|
||||
lon = landingObj.longitude;
|
||||
if (lon > 180.0) {
|
||||
lon -= 360.0;
|
||||
}
|
||||
landing.latlng = { lat: landingObj.latitude, lng: lon, alt: landingObj.altitude };
|
||||
landing.datetime = new Date(landingObj.datetime);
|
||||
|
||||
const profile = prediction[1].stage === "descent" ? "standard_profile" : "float_profile";
|
||||
const flight_time = (new Date(landing.datetime).getTime() - new Date(launch.datetime).getTime()) / 1000;
|
||||
|
||||
return {
|
||||
flight_path,
|
||||
launch,
|
||||
burst,
|
||||
landing,
|
||||
profile,
|
||||
flight_time,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
import { writable } from "svelte/store";
|
||||
import type { FlightParameters, RawTelemetry, Telemetry } from "./types";
|
||||
import type { RawPrediction, Prediction } from "./types";
|
||||
import type { SavedPoint, SavedFlightProfile, SavedScenario } from "./types";
|
||||
|
||||
export const readLocalStorage = <T>(key: string, defaultValue: T): T => {
|
||||
const item = localStorage.getItem(key);
|
||||
if (item) {
|
||||
try {
|
||||
const parsed = JSON.parse(item);
|
||||
if (typeof parsed === "object" && parsed !== null) {
|
||||
return parsed as T;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error parsing ${key} from localStorage:`, error);
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
export const writeLocalStorage = <T>(key: string, value: T): void => {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.error(`Error writing ${key} to localStorage:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
export const clearLocalStorage = (key: string): void => {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`Error clearing ${key} from localStorage:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
export const flightParametersDefaults: FlightParameters = {
|
||||
ascent_rate: 5.0,
|
||||
burst_altitude: 30000.0,
|
||||
dataset: "",
|
||||
descent_rate: 5.0,
|
||||
format: "json",
|
||||
launch_altitude: 0.0,
|
||||
launch_latitude: 62.1234,
|
||||
launch_longitude: 129.1234,
|
||||
profile: "standard_profile",
|
||||
version: 2,
|
||||
};
|
||||
|
||||
export const FlightParametersStore = writable<FlightParameters>(
|
||||
readLocalStorage<FlightParameters>("flightParameters", flightParametersDefaults)
|
||||
);
|
||||
|
||||
export const templateDataDefaults = {
|
||||
description: "",
|
||||
prediction_mode: "",
|
||||
model: "",
|
||||
dataset: "",
|
||||
flight_parameters: flightParametersDefaults,
|
||||
};
|
||||
|
||||
export const scenarioDefaults: SavedScenario = {
|
||||
id: -1,
|
||||
name: "Новый сценарий",
|
||||
...templateDataDefaults,
|
||||
}
|
||||
|
||||
export const ScenarioStore = writable<SavedScenario>(
|
||||
readLocalStorage<SavedScenario>("scenario", scenarioDefaults as SavedScenario)
|
||||
);
|
||||
|
||||
export const RawTelemetryStore = writable<RawTelemetry>(
|
||||
readLocalStorage<RawTelemetry>("rawTelemetry", {} as RawTelemetry)
|
||||
);
|
||||
|
||||
export const TelemetryStore = writable<Telemetry>(
|
||||
readLocalStorage<Telemetry>("telemetry", {} as Telemetry)
|
||||
);
|
||||
|
||||
export const RawPredictionStore = writable<RawPrediction>(
|
||||
readLocalStorage<RawPrediction>("rawPrediction", {} as RawPrediction)
|
||||
);
|
||||
|
||||
export const PredictionStore = writable<Prediction>(
|
||||
readLocalStorage<Prediction>("prediction", {} as Prediction)
|
||||
);
|
||||
|
||||
export const SavedPointsStore = writable<SavedPoint[]>([]);
|
||||
|
||||
// stub
|
||||
export const SavedFlightProfilesStore = writable<SavedFlightProfile[]>([]);
|
||||
|
||||
// stub
|
||||
export const SavedScenarioStore = writable<SavedScenario[]>([]);
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { writable } from "svelte/store"
|
||||
|
||||
import type { TelemetryPoint, Telemetry } from "./types";
|
||||
|
||||
export function parseTelemetry(telemetry: TelemetryPoint[]): Telemetry {
|
||||
const flight_path: [number, number, number][] = telemetry.map((point) => [
|
||||
point.latitude,
|
||||
point.longitude,
|
||||
point.altitude
|
||||
]);
|
||||
|
||||
const launch = {
|
||||
latlng: { lat: telemetry[0].latitude, lng: telemetry[0].longitude },
|
||||
datetime: new Date(telemetry[0].datetime)
|
||||
};
|
||||
|
||||
return {
|
||||
flight_path,
|
||||
launch,
|
||||
datapoints: telemetry
|
||||
};
|
||||
}
|
||||
149
src/lib/types.ts
|
|
@ -1,149 +0,0 @@
|
|||
// Define coordinate types (previously from Leaflet)
|
||||
export type LatLngTuple = [number, number] | [number, number, number]; // Support 2D and 3D coordinates
|
||||
export interface LatLngLiteral {
|
||||
lat: number;
|
||||
lng: number;
|
||||
alt?: number; // Optional altitude
|
||||
}
|
||||
export type LatLngExpression = LatLngTuple | LatLngLiteral;
|
||||
|
||||
export const PROFILE_MAP = {
|
||||
"Обычный": "standard_profile",
|
||||
"Дрейф": "float_profile",
|
||||
"Реверсивный": "reverse_profile",
|
||||
"Пользовательский": "custom_profile",
|
||||
};
|
||||
|
||||
// Map of profile names to their string identifiers
|
||||
export const PROFILE_NAMES = {
|
||||
standard_profile: "Обычный",
|
||||
float_profile: "Дрейф",
|
||||
reverse_profile: "Реверсивный",
|
||||
custom_profile: "Пользовательский"
|
||||
};
|
||||
|
||||
export type ProfileName = keyof typeof PROFILE_MAP;
|
||||
|
||||
export type ProfileIdentifier = keyof typeof PROFILE_NAMES;
|
||||
|
||||
export interface FlightParameters {
|
||||
ascent_rate: number;
|
||||
burst_altitude: number;
|
||||
dataset: string;
|
||||
descent_rate: number;
|
||||
format: "json";
|
||||
launch_altitude: number;
|
||||
launch_latitude: number;
|
||||
launch_longitude: number;
|
||||
profile: (typeof PROFILE_MAP)[ProfileName];
|
||||
version: number;
|
||||
start_point?: number; // Optional, used for saved points
|
||||
rate_profile?: number; // Optional, used for custom profiles
|
||||
template?: number; // Optional, used for saved scenarios
|
||||
}
|
||||
|
||||
export const PREDICTION_MODE_MAP = {
|
||||
"Разовый": "single",
|
||||
"Почасовой": "hourly",
|
||||
"Ансамблевый": "ensemble"
|
||||
};
|
||||
|
||||
// Map of profile names to their string identifiers
|
||||
export const PPREDICTION_MODE_NAMES = {
|
||||
single: "Разовый",
|
||||
hourly: "Почасовой",
|
||||
ensemble: "Ансамблевый"
|
||||
};
|
||||
|
||||
export interface Point {
|
||||
latlng: LatLngLiteral & { alt?: number };
|
||||
datetime: Date;
|
||||
}
|
||||
|
||||
export interface TelemetryPoint {
|
||||
altitude: number;
|
||||
datetime: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export interface TelemetryMetadata {
|
||||
complete_datetime: string;
|
||||
start_datetime: string;
|
||||
}
|
||||
|
||||
export interface RawTelemetry {
|
||||
metadata: TelemetryMetadata;
|
||||
telemetry: TelemetryPoint[];
|
||||
}
|
||||
|
||||
export interface Telemetry {
|
||||
flight_path: LatLngExpression[];
|
||||
launch: Point;
|
||||
datapoints: TelemetryPoint[];
|
||||
}
|
||||
|
||||
|
||||
export interface TrajectoryPoint {
|
||||
altitude: number;
|
||||
datetime: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface PredictionStage {
|
||||
stage: string;
|
||||
trajectory: TrajectoryPoint[];
|
||||
}
|
||||
|
||||
export interface PredictionMetadata {
|
||||
complete_datetime: string;
|
||||
start_datetime: string;
|
||||
}
|
||||
|
||||
export interface RawPrediction {
|
||||
metadata: PredictionMetadata;
|
||||
prediction: PredictionStage[];
|
||||
}
|
||||
|
||||
export interface Prediction {
|
||||
flight_path: LatLngExpression[];
|
||||
launch: Point;
|
||||
burst: Point;
|
||||
landing: Point;
|
||||
profile: string;
|
||||
flight_time: number;
|
||||
}
|
||||
|
||||
export interface SavedPoint {
|
||||
id: number;
|
||||
name: string;
|
||||
lat: number;
|
||||
lon: 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 {
|
||||
id: number;
|
||||
name: string;
|
||||
type?: string;
|
||||
rate_profile_data: RateCurvePoint[];
|
||||
}
|
||||
|
||||
export interface SavedScenario {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
prediction_mode: string;
|
||||
model: string;
|
||||
dataset: string;
|
||||
flight_parameters: FlightParameters;
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<script>
|
||||
import Navbar from '$lib/components/Navbar.svelte';
|
||||
import Map from './leaflet.svelte';
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<Navbar />
|
||||
<Map />
|
||||
</main>
|
||||
548
src/routes/leaflet.svelte
Normal file
|
|
@ -0,0 +1,548 @@
|
|||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import * as L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
/**
|
||||
* @type {{ removeLayer: (arg0: any) => void; setView: (arg0: number[], arg1: any) => void; getZoom: () => any; on: (arg0: string, arg1: (e: any) => void) => void; }}
|
||||
*/
|
||||
let map;
|
||||
let mouseLat = 0;
|
||||
let mouseLng = 0;
|
||||
/**
|
||||
* @type {null}
|
||||
*/
|
||||
let marker = null;
|
||||
|
||||
let startPoint = 'Custom';
|
||||
let startHeight = 0;
|
||||
let startTime = '13:13';
|
||||
let startDate = new Date(2025, 2, 24);
|
||||
let ascentRate = 5;
|
||||
let burstAltitude = 30000;
|
||||
let flightProfile = 'Normal';
|
||||
let descentRate = 5;
|
||||
let forecastMode = 'Single';
|
||||
let inputLat = '56.3576';
|
||||
let inputLng = '39.8666';
|
||||
|
||||
let showBurstCalculator = false;
|
||||
let payloadMass = 1500;
|
||||
let balloonMass = 1000;
|
||||
let desiredBurstAltitude = 33000;
|
||||
let desiredAscentRate = 2.33;
|
||||
|
||||
let burstAltitudeResult = 33000;
|
||||
let timeToBurst = 236;
|
||||
let initialVolume = 2.66;
|
||||
let ascentRateResult = 2.33;
|
||||
let liftForce = 1733;
|
||||
let volumeLiters = 2662;
|
||||
let volumeCubicFeet = 94.0;
|
||||
|
||||
const toggleBurstCalculator = () => {
|
||||
showBurstCalculator = !showBurstCalculator;
|
||||
};
|
||||
|
||||
const calculateBurst = () => {
|
||||
// In a real app, you would implement actual calculations here
|
||||
// These are just placeholder values matching your image
|
||||
burstAltitudeResult = desiredBurstAltitude;
|
||||
timeToBurst = 236;
|
||||
initialVolume = 2.66;
|
||||
ascentRateResult = desiredAscentRate;
|
||||
liftForce = 1733;
|
||||
volumeLiters = 2662;
|
||||
volumeCubicFeet = 94.0;
|
||||
};
|
||||
|
||||
const updateMapPosition = () => {
|
||||
const lat = parseFloat(inputLat);
|
||||
const lng = parseFloat(inputLng);
|
||||
|
||||
if (isNaN(lat)) {
|
||||
alert("Please enter a valid latitude");
|
||||
return;
|
||||
}
|
||||
if (isNaN(lng)) {
|
||||
alert("Please enter a valid longitude");
|
||||
return;
|
||||
}
|
||||
if (lat < -90 || lat > 90) {
|
||||
alert("Latitude must be between -90 and 90");
|
||||
return;
|
||||
}
|
||||
if (lng < -180 || lng > 180) {
|
||||
alert("Longitude must be between -180 and 180");
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove existing marker if it exists
|
||||
if (marker) {
|
||||
map.removeLayer(marker);
|
||||
}
|
||||
|
||||
// Create new marker
|
||||
marker = L.marker([lat, lng]).addTo(map)
|
||||
.bindPopup("Launch Point");
|
||||
|
||||
// Center map on new coordinates
|
||||
map.setView([lat, lng], map.getZoom());
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
map = L.map('map').setView([51.505, -0.09], 13);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
}).addTo(map);
|
||||
|
||||
map.on('mousemove', (e) => {
|
||||
mouseLat = e.latlng.lat.toFixed(6);
|
||||
mouseLng = e.latlng.lng.toFixed(6);
|
||||
});
|
||||
|
||||
marker = L.marker([56.3576, 39.8666]).addTo(map)
|
||||
.bindPopup(() => {
|
||||
return `
|
||||
<b>Launch Point</b><br>, Lat: ${marker.getLatLng().lat.toFixed(6)}<br>, Long: ${marker.getLatLng().lng.toFixed(6)}<br>
|
||||
`;
|
||||
});
|
||||
});
|
||||
|
||||
// Forecast request function
|
||||
const getForecast = async () => {
|
||||
// Create request object
|
||||
const request = {
|
||||
ascent_rate: parseFloat(ascentRate),
|
||||
burst_altitude: parseFloat(burstAltitude),
|
||||
dataset: new Date().toISOString(), // Current time as dataset timestamp
|
||||
descent_rate: parseFloat(descentRate),
|
||||
format: "json",
|
||||
launch_altitude: parseFloat(startHeight),
|
||||
launch_datetime: new Date(
|
||||
`${startDate.getFullYear()}-${startDate.getMonth() + 1}-${startDate.getDate()}T${startTime}:00Z`
|
||||
).toISOString(),
|
||||
launch_latitude: parseFloat(inputLat),
|
||||
launch_longitude: parseFloat(inputLng),
|
||||
profile: flightProfile === 'Normal' ? 'standard_profile' : 'custom_profile',
|
||||
version: 2
|
||||
};
|
||||
|
||||
console.log("Sending request:", request);
|
||||
|
||||
try {
|
||||
// Example POST request - replace with your actual API endpoint
|
||||
const response = await fetch('https://api.example.com/forecast', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("Forecast response:", data);
|
||||
alert("Forecast request successful!");
|
||||
// Handle the response data as needed
|
||||
} catch (error) {
|
||||
console.error("Error sending forecast request:", error);
|
||||
alert("Error getting forecast: " + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to format date as YYYY-MM-DD
|
||||
const formatDateForAPI = (date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="map-container">
|
||||
<div id="map"></div>
|
||||
<div class="coordinates-display">
|
||||
Lat: {mouseLat}, Long: {mouseLng}
|
||||
</div>
|
||||
<div class="control-panel">
|
||||
<div class="control-header">Launch Point</div>
|
||||
|
||||
<div class="control-row">
|
||||
<span>Start Point:</span>
|
||||
<select bind:value={startPoint}>
|
||||
<option>Custom</option>
|
||||
<option>Preset 1</option>
|
||||
<option>Preset 2</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="control-row">
|
||||
<span>Latitude/Longitude:</span>
|
||||
<div class="coordinate-inputs">
|
||||
<input type="text" bind:value={inputLat} placeholder="Latitude">
|
||||
<p>/</p>
|
||||
<input type="text" bind:value={inputLng} placeholder="Longitude">
|
||||
<button on:click={updateMapPosition} class="update-button">✓</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-row">
|
||||
<button class="map-button" on:click={() => {
|
||||
inputLat = mouseLat;
|
||||
inputLng = mouseLng;
|
||||
updateMapPosition();
|
||||
}}>Specify on map (click location)</button>
|
||||
</div>
|
||||
|
||||
<div class="control-row">
|
||||
<span>Launch Height (m):</span>
|
||||
<input type="number" bind:value={startHeight}>
|
||||
</div>
|
||||
|
||||
<div class="control-row">
|
||||
<span>Launch Time (UTC):</span>
|
||||
<input type="time" bind:value={startTime}>
|
||||
</div>
|
||||
|
||||
<div class="control-row">
|
||||
<span>Launch Date:</span>
|
||||
<input type="date" bind:value={startDate}>
|
||||
</div>
|
||||
|
||||
<div class="control-row">
|
||||
<span>Ascent Rate (m/s):</span>
|
||||
<input type="number" bind:value={ascentRate}>
|
||||
</div>
|
||||
|
||||
<div class="control-row">
|
||||
<span>Burst/Drift Altitude (m):</span>
|
||||
<input type="number" bind:value={burstAltitude}>
|
||||
</div>
|
||||
|
||||
<div class="control-row">
|
||||
<span>Flight Profile:</span>
|
||||
<select bind:value={flightProfile}>
|
||||
<option>Normal</option>
|
||||
<option>Drift</option>
|
||||
<option>Reversible (on the rise)</option>
|
||||
<option>Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="control-row buttons">
|
||||
<button on:click={toggleBurstCalculator}>Open Burst Calculator</button>
|
||||
<button>Set Custom Flight Profile</button>
|
||||
<button>Show Last Altitude Graph</button>
|
||||
</div>
|
||||
|
||||
<div class="control-row">
|
||||
<span>Descent Rate (m/s):</span>
|
||||
<input type="number" bind:value={descentRate}>
|
||||
</div>
|
||||
|
||||
<div class="control-row">
|
||||
<span>Forecast Mode (help):</span>
|
||||
<div class="radio-group">
|
||||
<label>
|
||||
<input type="radio" bind:group={forecastMode} value="Single"> Single
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" bind:group={forecastMode} value="Multiple"> Multiple
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-row">
|
||||
<button class="primary-button" on:click={getForecast}>Get Forecast</button>
|
||||
<button>Save Point</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Burst Calculator Modal -->
|
||||
{#if showBurstCalculator}
|
||||
<div class="modal-overlay" on:click|self={toggleBurstCalculator}>
|
||||
<div class="modal-content">
|
||||
<h2>Balloon Burst Calculation</h2>
|
||||
|
||||
<div class="calculator-grid">
|
||||
<div class="input-group">
|
||||
<label>Payload Mass (g)</label>
|
||||
<input type="number" bind:value={payloadMass}>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label>Balloon Mass (g)</label>
|
||||
<input type="number" bind:value={balloonMass}>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label>Desired Burst Altitude (m)</label>
|
||||
<input type="number" bind:value={desiredBurstAltitude}>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label>Desired Ascent Rate (m/s)</label>
|
||||
<input type="number" bind:value={desiredAscentRate} step="0.01">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results-section">
|
||||
<h3>Results</h3>
|
||||
<div class="result-row">
|
||||
<span>Burst Altitude:</span>
|
||||
<span>{burstAltitudeResult} m</span>
|
||||
</div>
|
||||
<div class="result-row">
|
||||
<span>Time to Burst:</span>
|
||||
<span>{timeToBurst} min</span>
|
||||
</div>
|
||||
<div class="result-row">
|
||||
<span>Initial Volume:</span>
|
||||
<span>{initialVolume} m³</span>
|
||||
</div>
|
||||
<div class="result-row">
|
||||
<span>Ascent Rate:</span>
|
||||
<span>{ascentRateResult} m/s</span>
|
||||
</div>
|
||||
<div class="result-row">
|
||||
<span>Lift Force at Launch:</span>
|
||||
<span>{liftForce} g</span>
|
||||
</div>
|
||||
<div class="result-row">
|
||||
<span>Volume:</span>
|
||||
<span>{volumeLiters} L ({volumeCubicFeet} ft³)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="secondary-button">Additional Settings</button>
|
||||
<button class="primary-button" on:click={calculateBurst}>Use Results</button>
|
||||
<button on:click={toggleBurstCalculator}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.map-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
#map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.coordinates-display {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
z-index: 1000; /* Ensure it's above the map */
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.control-panel {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
|
||||
width: 320px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.control-header {
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
font-size: 16px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.control-row {
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.control-row span {
|
||||
width: 160px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.control-row input[type="number"],
|
||||
.control-row input[type="date"],
|
||||
.control-row input[type="time"],
|
||||
.control-row select {
|
||||
width: 120px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.coordinate-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.coordinate-inputs input {
|
||||
width: 70px !important;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.update-button {
|
||||
padding: 3px 8px;
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.map-button {
|
||||
width: 100%;
|
||||
padding: 5px;
|
||||
background: #f0f0f0;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.buttons button {
|
||||
width: 100%;
|
||||
padding: 5px;
|
||||
margin-bottom: 5px;
|
||||
background: #f0f0f0;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
background: #4CAF50 !important;
|
||||
color: white;
|
||||
border: 1px solid #3e8e41 !important;
|
||||
}
|
||||
|
||||
.radio-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.radio-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
width: 500px;
|
||||
max-width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.calculator-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.results-section {
|
||||
background-color: #f5f5f5;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.results-section h3 {
|
||||
margin-top: 0;
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.result-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.result-row span:first-child {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
background: #f0f0f0;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import { login } from '$lib/auth';
|
||||
|
||||
let username = '';
|
||||
let password = '';
|
||||
let error = '';
|
||||
let isLoading = false;
|
||||
|
||||
async function handleLogin() {
|
||||
if (!username || !password) {
|
||||
error = 'Please enter both username and password';
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
error = '';
|
||||
|
||||
console.log("Sending request:", username, password);
|
||||
|
||||
// login request
|
||||
try {
|
||||
await login(username, password);
|
||||
|
||||
goto('/'); // Redirect after successful login
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
error = err.message || 'Invalid credentials';
|
||||
} else {
|
||||
error = 'Invalid credentials';
|
||||
}
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<main class="container pt-3">
|
||||
<div class="text-center mt-5 mb-4">
|
||||
<img src="/logo-lg.svg" alt="ООО ЯКС" width="300" class="rounded-3" />
|
||||
<h2 class="text-center mt-4 mb-5">Стратосферные полеты | ООО ЯКС</h2>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-6 col-lg-4 offset-md-3 offset-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Вход в учетную запись</h5>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-danger mb-4" role="alert">{error}</div>
|
||||
{/if}
|
||||
|
||||
<form on:submit|preventDefault={handleLogin} class="mt-4">
|
||||
<div class="form-floating mb-3">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="username"
|
||||
placeholder="Имя пользователя"
|
||||
bind:value={username}
|
||||
required
|
||||
/>
|
||||
<label for="username">Имя пользователя</label>
|
||||
</div>
|
||||
<div class="form-floating mb-3">
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
id="password"
|
||||
placeholder="Пароль"
|
||||
bind:value={password}
|
||||
required
|
||||
/>
|
||||
<label for="password">Пароль</label>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary w-100"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{#if isLoading}
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
Вход...
|
||||
{:else}
|
||||
Войти
|
||||
{/if}
|
||||
</button>
|
||||
<a href="/" class="btn btn-secondary mt-3 w-100">Назад</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
|
|
@ -1 +0,0 @@
|
|||
export const ssr =false;
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
<script lang="ts">
|
||||
import Map from "$lib/components/Map.svelte";
|
||||
import ControlPanel from "$lib/components/ControlPanel.svelte";
|
||||
import Navbar from "$lib/components/Navbar.svelte";
|
||||
import PanelContainer from "$lib/components/PanelContainer.svelte";
|
||||
import ScenarioPanel from "$lib/components/ScenarioPanel.svelte";
|
||||
import TabComponent from "$lib/components/ui/TabComponent.svelte";
|
||||
import PointEditor from "$lib/components/editors/PointEditor.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { PredictionStore } from "$lib/stores";
|
||||
import { addToast, removeToast } from "$lib/components/ui/Toast.svelte";
|
||||
import ToastContainer from '$lib/components/ui/Toast.svelte';
|
||||
import GenericPanel from "$lib/components/GenericPanel.svelte";
|
||||
import TimeLine from "$lib/components/TimeLine.svelte";
|
||||
|
||||
let map: Map | null = null;
|
||||
let panelContainer: PanelContainer | null = null;
|
||||
let controlPanel: ControlPanel | null = null;
|
||||
let selectionToastId: string | null = null;
|
||||
let activeTabLeft: 'control' | 'scenario' | 'about' = 'scenario';
|
||||
let activeTabRight: 'layers' | 'settings' | 'results' = 'results';
|
||||
|
||||
onMount(() => {
|
||||
PredictionStore.subscribe((data) => {
|
||||
if (data) {
|
||||
map?.clearMapLayers();
|
||||
}
|
||||
});
|
||||
console.log("ControlPanel mounted");
|
||||
console.log(panelContainer);
|
||||
|
||||
if (panelContainer) {
|
||||
let element = panelContainer.getElement();
|
||||
if (!element) return;
|
||||
|
||||
// Disable click and scroll propagation to prevent map interaction
|
||||
element.addEventListener('click', (e) => e.stopPropagation());
|
||||
element.addEventListener('dblclick', (e) => e.stopPropagation());
|
||||
element.addEventListener('mousedown', (e) => e.stopPropagation());
|
||||
element.addEventListener('touchstart', (e) => e.stopPropagation());
|
||||
element.addEventListener('wheel', (e) => e.stopPropagation());
|
||||
}
|
||||
});
|
||||
|
||||
function handleClickSelectOnMap() {
|
||||
if (map) {
|
||||
map.startSelection();
|
||||
console.log("Selection mode enabled");
|
||||
if (!selectionToastId) {
|
||||
selectionToastId = addToast({
|
||||
header: "Режим выбора координат",
|
||||
body: "Кликните на карту, чтобы выбрать координаты",
|
||||
color: "info",
|
||||
persistent: true,
|
||||
onRemoveCallback: () => {
|
||||
selectionToastId = null;
|
||||
map?.stopSelection();
|
||||
console.log("Selection mode disabled");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleCoordinateSelection(event: CustomEvent<{ lat: number; lng: number }>) {
|
||||
const { lat, lng } = event.detail;
|
||||
controlPanel?.updateLaunchPosition(lat, lng);
|
||||
console.log(`Selected coordinates: ${lat}, ${lng}`);
|
||||
if (selectionToastId) {
|
||||
removeToast(selectionToastId);
|
||||
selectionToastId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTimeUpdate(event: CustomEvent<{ index: number; lat: number; lng: number; alt: number; datetime: Date }>) {
|
||||
const { lat, lng } = event.detail;
|
||||
map?.updateAnimatedMarker(lat, lng);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<Navbar />
|
||||
<div style="height: var(--navbar-height);"></div> <!-- Spacer for fixed navbar -->
|
||||
<Map bind:this={map} mode="prediction" bind:data={$PredictionStore} on:coordinatesSelected={handleCoordinateSelection}>
|
||||
<PanelContainer bind:this={panelContainer} position="left">
|
||||
<TabComponent
|
||||
tabs={[
|
||||
{ id: 'scenario', icon: 'file-earmark-play', label: 'Сценарий' },
|
||||
{ id: 'control', icon: 'sliders', label: 'Условия' },
|
||||
{ id: 'about', icon: 'info-circle', label: 'О проекте' }
|
||||
]}
|
||||
bind:activeTab={activeTabLeft}
|
||||
/>
|
||||
|
||||
<div>
|
||||
{#if activeTabLeft === 'control'}
|
||||
<ControlPanel onSelectOnMapClick={handleClickSelectOnMap} bind:this={controlPanel} />
|
||||
{:else if activeTabLeft === 'scenario'}
|
||||
<ScenarioPanel />
|
||||
{:else if activeTabLeft === 'about'}
|
||||
<!-- <AboutPanel /> -->
|
||||
{/if}
|
||||
</div>
|
||||
</PanelContainer>
|
||||
<PanelContainer position="right">
|
||||
<TabComponent
|
||||
justify="end"
|
||||
tabs={[
|
||||
{ id: 'results', icon: 'bar-chart-line', label: 'Результаты' },
|
||||
{ id: 'layers', icon: 'layers', label: 'Слои' },
|
||||
{ id: 'settings', icon: 'gear', label: 'Настройки' },
|
||||
]}
|
||||
bind:activeTab={activeTabRight}
|
||||
/>
|
||||
|
||||
<div>
|
||||
{#if activeTabRight === 'results'}
|
||||
<GenericPanel />
|
||||
{:else if activeTabRight === 'layers'}
|
||||
<GenericPanel />
|
||||
{/if}
|
||||
</div>
|
||||
</PanelContainer>
|
||||
<ToastContainer />
|
||||
{#if $PredictionStore}
|
||||
<TimeLine prediction={$PredictionStore} on:timeUpdate={handleTimeUpdate} />
|
||||
{/if}
|
||||
</Map>
|
||||
</main>
|
||||
|
|
@ -1 +0,0 @@
|
|||
export const ssr =false;
|
||||
130432
src/routes/testVelo.json
|
|
@ -1,20 +0,0 @@
|
|||
<script>
|
||||
import Map from '$lib/components/Map.svelte';
|
||||
import TelemetryPanel from '$lib/components/TelemetryPanel.svelte';
|
||||
import Navbar from '$lib/components/Navbar.svelte';
|
||||
// import BurstCalculator from './BurstCalculator.svelte';
|
||||
|
||||
let coordinates = {
|
||||
lat: '56.3576',
|
||||
lng: '39.8666'
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<Navbar />
|
||||
<div style="height: var(--navbar-height);"></div> <!-- Spacer for fixed navbar -->
|
||||
<Map>
|
||||
<TelemetryPanel
|
||||
/>
|
||||
</Map>
|
||||
</main>
|
||||
|
|
@ -1 +0,0 @@
|
|||
export const ssr =false;
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
<script lang="ts">
|
||||
import Navbar from "$lib/components/Navbar.svelte";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Button,
|
||||
FormGroup,
|
||||
Label,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputGroupText,
|
||||
Icon,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
|
||||
import ConfirmationPrompt from "$lib/components/ConfirmationPrompt.svelte";
|
||||
import Footer from "$lib/components/Footer.svelte";
|
||||
|
||||
let editMode = false;
|
||||
let showToken = false;
|
||||
|
||||
type ConfirmConfig = {
|
||||
title: string;
|
||||
body: string;
|
||||
confirmText: string;
|
||||
confirmVariant?: string;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
// State for the single confirmation prompt
|
||||
let showConfirm = false;
|
||||
let confirmConfig: ConfirmConfig = {
|
||||
title: "",
|
||||
body: "",
|
||||
confirmText: "",
|
||||
confirmVariant: "primary",
|
||||
onConfirm: () => {},
|
||||
};
|
||||
|
||||
function openConfirmation(config: Partial<ConfirmConfig>) {
|
||||
confirmConfig = { ...confirmConfig, ...config } as ConfirmConfig;
|
||||
showConfirm = true;
|
||||
}
|
||||
|
||||
function handleDeleteAccount() {
|
||||
openConfirmation({
|
||||
title: "Подтвердите удаление",
|
||||
body: "Вы уверены, что хотите удалить свою учетную запись? Это действие необратимо.",
|
||||
confirmText: "Удалить",
|
||||
confirmVariant: "danger",
|
||||
onConfirm: confirmDeleteAccount,
|
||||
});
|
||||
}
|
||||
|
||||
function handleResetSettings() {
|
||||
openConfirmation({
|
||||
title: "Подтвердите сброс",
|
||||
body: "Вы уверены, что хотите сбросить учетную запись? Это также удалит все сохранные сценарии, шаблоны и точки запуска.",
|
||||
confirmText: "Сбросить",
|
||||
confirmVariant: "warning",
|
||||
onConfirm: confirmResetSettings,
|
||||
});
|
||||
}
|
||||
|
||||
function handleGenerateToken() {
|
||||
openConfirmation({
|
||||
title: "Подтвердите создание токена",
|
||||
body: "Генерация нового токена API приведет к прекращению действия старого токена. Приложения, использующие старый токен, перестанут работать. Вы уверены, что хотите создать новый токен?",
|
||||
confirmText: "Создать",
|
||||
confirmVariant: "primary",
|
||||
onConfirm: confirmGenerateToken,
|
||||
});
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
openConfirmation({
|
||||
title: "Подтвердите выход",
|
||||
body: "Вы уверены, что хотите выйти из учетной записи? Вы будете перенаправлены на страницу входа.",
|
||||
confirmText: "Выйти",
|
||||
onConfirm: confirmLogout,
|
||||
});
|
||||
}
|
||||
|
||||
function confirmDeleteAccount() {
|
||||
// Implement account deletion logic
|
||||
console.log("Account deleted");
|
||||
}
|
||||
|
||||
function confirmResetSettings() {
|
||||
// Implement settings reset logic
|
||||
console.log("Settings reset");
|
||||
}
|
||||
|
||||
function confirmGenerateToken() {
|
||||
// Implement token generation logic
|
||||
console.log("New token generated");
|
||||
}
|
||||
|
||||
function confirmLogout() {
|
||||
// Implement logout logic
|
||||
console.log("Logged out");
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (confirmConfig.onConfirm) {
|
||||
confirmConfig.onConfirm();
|
||||
}
|
||||
showConfirm = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="force-page-height">
|
||||
<Navbar />
|
||||
<div style="height: var(--navbar-height);"></div>
|
||||
<!-- Spacer for fixed navbar -->
|
||||
<div class="container my-4">
|
||||
<div class="row">
|
||||
<!-- Side Navigation -->
|
||||
<div class="col-md-3 col-lg-2 mb-4">
|
||||
<nav class="nav nav-pills flex-column">
|
||||
<a class="nav-link active" href="/user/account">Учетная запись</a>
|
||||
<a class="nav-link" href="/user/templates">Сохраненные сценарии</a>
|
||||
<a class="nav-link" href="#api-tokens">История прогнозов</a>
|
||||
<a class="nav-link" href="#actions">История слежения</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="col-md-9 col-lg-10">
|
||||
<!-- Account Information -->
|
||||
<Card class="mb-4">
|
||||
<CardHeader>
|
||||
<h5 class="mb-0">Основная информация</h5>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<FormGroup>
|
||||
<Label for="username">Имя пользователя:</Label>
|
||||
<Input id="username" value="user123" readonly disabled />
|
||||
</FormGroup>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<FormGroup>
|
||||
<Label for="email">Email:</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value="user@example.com"
|
||||
readonly={!editMode}
|
||||
disabled={!editMode}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
</div>
|
||||
<FormGroup>
|
||||
<Label for="fullname">Полное имя:</Label>
|
||||
<Input id="fullname" value="Иван Иванов" readonly={!editMode} disabled={!editMode} />
|
||||
</FormGroup>
|
||||
{#if editMode}
|
||||
<Button color="success" on:click={() => (editMode = false)}>Сохранить</Button>
|
||||
<Button color="secondary" on:click={() => (editMode = false)}>Отменить</Button>
|
||||
{:else}
|
||||
<Button color="primary" on:click={() => (editMode = true)}>Редактировать</Button>
|
||||
{/if}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<!-- Password Change -->
|
||||
<Card class="mb-4">
|
||||
<CardHeader>
|
||||
<h5 class="mb-0">Смена пароля</h5>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<FormGroup>
|
||||
<Label for="currentPassword">Текущий пароль:</Label>
|
||||
<Input id="currentPassword" type="password" />
|
||||
</FormGroup>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<FormGroup>
|
||||
<Label for="newPassword">Новый пароль:</Label>
|
||||
<Input id="newPassword" type="password" />
|
||||
</FormGroup>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<FormGroup>
|
||||
<Label for="confirmPassword">Повтор пароля:</Label>
|
||||
<Input id="confirmPassword" type="password" />
|
||||
</FormGroup>
|
||||
</div>
|
||||
</div>
|
||||
<Button color="primary">Изменить пароль</Button>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<!-- API Token -->
|
||||
<Card class="mb-4">
|
||||
<CardHeader>
|
||||
<h5 class="mb-0">Токен API</h5>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<FormGroup>
|
||||
<Label for="apiToken">Токен доступа</Label>
|
||||
<InputGroup>
|
||||
<div class="position-relative flex-grow-1">
|
||||
<Input
|
||||
id="apiToken"
|
||||
class="form-control pe-5"
|
||||
type={showToken ? "text" : "password"}
|
||||
value="abc123def456..."
|
||||
readonly
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
color="white"
|
||||
class="position-absolute top-50 end-0 translate-middle-y me-2 rounded-circle d-flex align-items-center justify-content-center"
|
||||
style="width: 32px; height: 32px; border: none; color: var(--bs-secondary); z-index: 10;"
|
||||
on:click={() => {
|
||||
showToken = !showToken;
|
||||
}}
|
||||
>
|
||||
<Icon name={showToken ? "eye-slash" : "eye"} style="font-size: 16px;" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button>
|
||||
<Icon name="clipboard" />
|
||||
</Button>
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
<Button color="warning" on:click={handleGenerateToken}>Сгенерировать новый токен</Button>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<!-- Account Actions -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h5 class="mb-0">Действия с аккаунтом</h5>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div class="d-grid gap-2 d-md-flex">
|
||||
<Button color="secondary" on:click={handleLogout}>Выйти</Button>
|
||||
<!-- spacer -->
|
||||
<span class="d-none d-md-inline-block flex-grow-1"></span>
|
||||
<Button color="warning" on:click={handleResetSettings}>Сбросить настройки</Button>
|
||||
<Button color="danger" on:click={handleDeleteAccount}>Удалить аккаунт</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</main>
|
||||
|
||||
<!-- Single Dynamic Confirmation Prompt -->
|
||||
<ConfirmationPrompt
|
||||
bind:isOpen={showConfirm}
|
||||
title={confirmConfig.title}
|
||||
confirmText={confirmConfig.confirmText}
|
||||
confirmVariant={confirmConfig.confirmVariant || "primary"}
|
||||
cancelText="Отмена"
|
||||
onconfirm={handleConfirm}
|
||||
oncancel={() => (showConfirm = false)}
|
||||
>
|
||||
<p>{confirmConfig.body}</p>
|
||||
</ConfirmationPrompt>
|
||||
|
|
@ -1 +0,0 @@
|
|||
export const ssr =false;
|
||||
|
|
@ -1 +0,0 @@
|
|||
export const ssr =false;
|
||||
|
|
@ -1 +0,0 @@
|
|||
export const ssr =false;
|
||||
|
|
@ -1,379 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { TableHandler } from "@vincjo/datatables";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Button,
|
||||
Input,
|
||||
Icon,
|
||||
Pagination,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
|
||||
import Navbar from "$lib/components/Navbar.svelte";
|
||||
import Footer from "$lib/components/Footer.svelte";
|
||||
import ConfirmationPrompt from "$lib/components/ConfirmationPrompt.svelte";
|
||||
import PointEditor from "$lib/components/editors/PointEditor.svelte";
|
||||
import ToastContainer from "$lib/components/ui/Toast.svelte";
|
||||
import { addToast } from "$lib/components/ui/Toast.svelte";
|
||||
|
||||
// TODO: Implement these imports
|
||||
import { SavedPointsStore, SavedFlightProfilesStore, SavedScenarioStore } from "$lib/stores";
|
||||
import { getSavedPoints, deletePoint } from "$lib/api/points";
|
||||
import { getSavedFlightProfiles, deleteFlightProfile } from "$lib/api/profiles";
|
||||
import { getSavedScenarios, deleteScenario } from "$lib/api/scenarios";
|
||||
import type { SavedPoint, SavedFlightProfile, SavedScenario } from "$lib/types";
|
||||
|
||||
// Table handlers
|
||||
let pointsTable = $derived(new TableHandler($SavedPointsStore, { rowsPerPage: 5 }));
|
||||
let pointsSearch = $derived(pointsTable.createSearch(["name"]));
|
||||
|
||||
let profilesTable = $derived(new TableHandler($SavedFlightProfilesStore, { rowsPerPage: 5 }));
|
||||
let profilesSearch = $derived(profilesTable.createSearch(["name"]));
|
||||
|
||||
let templatesTable = $derived(new TableHandler($SavedScenarioStore, { rowsPerPage: 5 }));
|
||||
let templatesSearch = $derived(templatesTable.createSearch(["name"]));
|
||||
|
||||
let editPoint: SavedPoint | null = $state(null);
|
||||
|
||||
onMount(async () => {
|
||||
// Mock data for demonstration. Replace with API calls.
|
||||
const pts = await getSavedPoints();
|
||||
$SavedPointsStore = pts;
|
||||
SavedPointsStore.set($SavedPointsStore);
|
||||
|
||||
$SavedFlightProfilesStore = [
|
||||
{ id: 1, name: "Standard Weather Balloon", rate_profile_data: {ascent_rate: 5, descent_rate: 8, burst_altitude: 30000} },
|
||||
{ id: 2, name: "High Altitude Probe", rate_profile_data: {ascent_rate: 6, descent_rate: 10, burst_altitude: 40000} },
|
||||
];
|
||||
|
||||
|
||||
/*
|
||||
// TODO: Uncomment when API is ready
|
||||
const [points, profiles, templates] = await Promise.all([
|
||||
getSavedPoints(),
|
||||
getSavedFlightProfiles(),
|
||||
getSavedScenarioTemplates()
|
||||
]);
|
||||
$SavedPointsStore = points;
|
||||
$SavedFlightProfilesStore = profiles;
|
||||
$SavedScenarioTemplatesStore = templates;
|
||||
*/
|
||||
});
|
||||
|
||||
// --- Confirmation Prompt Logic ---
|
||||
type ConfirmConfig = {
|
||||
title: string;
|
||||
body: string;
|
||||
confirmText: string;
|
||||
confirmVariant?: string;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
let showConfirm = $state(false);
|
||||
let confirmConfig = $state<ConfirmConfig>({
|
||||
title: "",
|
||||
body: "",
|
||||
confirmText: "",
|
||||
onConfirm: () => {},
|
||||
});
|
||||
|
||||
function openConfirmation(config: Partial<ConfirmConfig>) {
|
||||
confirmConfig = { ...confirmConfig, ...config } as ConfirmConfig;
|
||||
showConfirm = true;
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (confirmConfig.onConfirm) {
|
||||
confirmConfig.onConfirm();
|
||||
}
|
||||
showConfirm = false;
|
||||
}
|
||||
|
||||
// --- Delete Handlers ---
|
||||
function handleDelete<T extends { id: number; name: string }>(
|
||||
item: T,
|
||||
deleteFn: (id: number) => Promise<any>,
|
||||
store: any,
|
||||
itemName: string,
|
||||
) {
|
||||
openConfirmation({
|
||||
title: `Подтвердите удаление`,
|
||||
body: `Вы уверены, что хотите удалить ${itemName} "${item.name}"?`,
|
||||
confirmText: "Удалить",
|
||||
confirmVariant: "danger",
|
||||
onConfirm: () => {
|
||||
// deleteFn(item.id).then(() => { // TODO: Uncomment when API is ready
|
||||
store.update((items: T[]) => items.filter((i) => i.id !== item.id));
|
||||
addToast({
|
||||
header: `${itemName} удален`,
|
||||
body: `${itemName} "${item.name}" успешно удален.`,
|
||||
color: "success",
|
||||
});
|
||||
// }).catch(error => addToast({ header: 'Ошибка', body: `Не удалось удалить ${itemName}: ${error.message}`, color: 'danger' }));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleEditPoint(point: SavedPoint) {
|
||||
editPoint = point;
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="force-page-height">
|
||||
<Navbar />
|
||||
<div style="height: var(--navbar-height);"></div>
|
||||
<!-- Spacer for fixed navbar -->
|
||||
<div class="container my-4">
|
||||
<div class="row">
|
||||
<!-- Side Navigation -->
|
||||
<div class="col-md-3 col-lg-2 mb-4">
|
||||
<nav class="nav nav-pills flex-column">
|
||||
<a class="nav-link" href="/user/account">Учетная запись</a>
|
||||
<a class="nav-link active" href="/user/templates">Сохраненные сценарии</a>
|
||||
<a class="nav-link" href="#/">История прогнозов</a>
|
||||
<a class="nav-link" href="#/">История слежения</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="col-md-9 col-lg-10">
|
||||
<!-- Saved Points -->
|
||||
<Card class="mb-4">
|
||||
<CardHeader>
|
||||
<h5 class="mb-0">Точки запуска</h5>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div class="position-relative mb-2">
|
||||
<Input
|
||||
type="text"
|
||||
class="form-control-sm pe-5"
|
||||
placeholder="Поиск по названию..."
|
||||
bind:value={pointsSearch.value}
|
||||
oninput={() => pointsSearch.set()}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
color="white"
|
||||
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);"
|
||||
onclick={() => {
|
||||
pointsSearch.value = "";
|
||||
pointsSearch.set();
|
||||
}}
|
||||
disabled={!pointsSearch.value}
|
||||
>
|
||||
<Icon name="x" style="font-size: 16px;" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Название</th>
|
||||
<th>Широта</th>
|
||||
<th>Долгота</th>
|
||||
<th>Высота</th>
|
||||
<th class="fit"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each pointsTable.rows as row}
|
||||
<tr>
|
||||
<td>{row.name}</td>
|
||||
<td>{row.lat.toFixed(4)} °</td>
|
||||
<td>{row.lon.toFixed(4)} °</td>
|
||||
<td>{row.alt} м</td>
|
||||
<td class="fit">
|
||||
<Button color="primary" size="sm" onclick={() => handleEditPoint(row)}>
|
||||
<Icon name="pencil" />
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
size="sm"
|
||||
onclick={() =>
|
||||
handleDelete(row, deletePoint, SavedPointsStore, "Точка")}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination aria-label="Points page navigation" size="sm">
|
||||
<PaginationItem>
|
||||
<PaginationLink previous onclick={() => pointsTable.setPage("previous")} />
|
||||
</PaginationItem>
|
||||
{#each pointsTable.pagesWithEllipsis as page}
|
||||
<PaginationItem active={pointsTable.currentPage === page}>
|
||||
<PaginationLink onclick={() => pointsTable.setPage(page)}>{page}</PaginationLink>
|
||||
</PaginationItem>
|
||||
{/each}
|
||||
<PaginationItem>
|
||||
<PaginationLink next onclick={() => pointsTable.setPage("next")} />
|
||||
</PaginationItem>
|
||||
</Pagination>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<!-- Saved Flight Profiles -->
|
||||
<Card class="mb-4">
|
||||
<CardHeader>
|
||||
<h5 class="mb-0">Профили полета</h5>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Input
|
||||
type="text"
|
||||
class="form-control-sm mb-2"
|
||||
placeholder="Поиск по названию..."
|
||||
bind:value={profilesSearch.value}
|
||||
oninput={() => profilesSearch.set()}
|
||||
/>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Название</th>
|
||||
<th>Скороподъемность</th>
|
||||
<th>Скорость снижения</th>
|
||||
<th>Высота разрыва</th>
|
||||
<th class="fit"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each profilesTable.rows as row}
|
||||
<tr>
|
||||
<td>{row.name}</td>
|
||||
<td>{row.rate_profile_data.ascent_rate} м/с</td>
|
||||
<td>{row.rate_profile_data.descent_rate} м/с</td>
|
||||
<td>{row.rate_profile_data.burst_altitude} м</td>
|
||||
<td class="fit">
|
||||
<Button
|
||||
color="danger"
|
||||
size="sm"
|
||||
onclick={() =>
|
||||
handleDelete(
|
||||
row,
|
||||
deleteFlightProfile,
|
||||
SavedFlightProfilesStore,
|
||||
"Профиль",
|
||||
)}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination aria-label="Profiles page navigation" size="sm">
|
||||
<PaginationItem>
|
||||
<PaginationLink previous onclick={() => profilesTable.setPage("previous")} />
|
||||
</PaginationItem>
|
||||
{#each profilesTable.pagesWithEllipsis as page}
|
||||
<PaginationItem active={profilesTable.currentPage === page}>
|
||||
<PaginationLink onclick={() => profilesTable.setPage(page)}>{page}</PaginationLink>
|
||||
</PaginationItem>
|
||||
{/each}
|
||||
<PaginationItem>
|
||||
<PaginationLink next onclick={() => profilesTable.setPage("next")} />
|
||||
</PaginationItem>
|
||||
</Pagination>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<!-- Saved Scenario Templates -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h5 class="mb-0">Сценарии</h5>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Input
|
||||
type="text"
|
||||
class="form-control-sm mb-2"
|
||||
placeholder="Поиск по названию..."
|
||||
bind:value={templatesSearch.value}
|
||||
oninput={() => templatesSearch.set()}
|
||||
/>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Название</th>
|
||||
<th>Описание</th>
|
||||
<th class="fit"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each templatesTable.rows as row}
|
||||
<tr>
|
||||
<td>{row.name}</td>
|
||||
<td>{row.template_data.description}</td>
|
||||
<td class="fit">
|
||||
<Button
|
||||
color="danger"
|
||||
size="sm"
|
||||
onclick={() =>
|
||||
handleDelete(
|
||||
row,
|
||||
deleteScenarioTemplate,
|
||||
SavedScenarioTemplatesStore,
|
||||
"Шаблон",
|
||||
)}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination aria-label="Templates page navigation" size="sm">
|
||||
<PaginationItem>
|
||||
<PaginationLink previous onclick={() => templatesTable.setPage("previous")} />
|
||||
</PaginationItem>
|
||||
{#each templatesTable.pagesWithEllipsis as page}
|
||||
<PaginationItem active={templatesTable.currentPage === page}>
|
||||
<PaginationLink onclick={() => templatesTable.setPage(page)}>{page}</PaginationLink>
|
||||
</PaginationItem>
|
||||
{/each}
|
||||
<PaginationItem>
|
||||
<PaginationLink next onclick={() => templatesTable.setPage("next")} />
|
||||
</PaginationItem>
|
||||
</Pagination>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</main>
|
||||
|
||||
<ConfirmationPrompt
|
||||
bind:isOpen={showConfirm}
|
||||
title={confirmConfig.title}
|
||||
confirmText={confirmConfig.confirmText}
|
||||
confirmVariant={confirmConfig.confirmVariant || "danger"}
|
||||
cancelText="Отмена"
|
||||
onconfirm={handleConfirm}
|
||||
oncancel={() => (showConfirm = false)}
|
||||
>
|
||||
<p>{confirmConfig.body}</p>
|
||||
</ConfirmationPrompt>
|
||||
|
||||
<PointEditor
|
||||
point={editPoint}
|
||||
isOpen={editPoint !== null}
|
||||
onClose={() => { editPoint = null; pointsTable.setRows($SavedPointsStore) }}
|
||||
editor={true}
|
||||
closeOnSave={true}
|
||||
closeOnDelete={true}
|
||||
/>
|
||||
|
||||
<ToastContainer />
|
||||
|
|
@ -1 +0,0 @@
|
|||
export const ssr = false;
|
||||
7
static/css/bootstrap.min.css
vendored
|
|
@ -1,151 +0,0 @@
|
|||
.custom-navbar {
|
||||
height: var(--navbar-height);
|
||||
padding-top: 0rem;
|
||||
padding-bottom: 0rem;
|
||||
z-index: 1002;
|
||||
border: none;
|
||||
background-color: white !important;
|
||||
}
|
||||
|
||||
.nav-full-height.nav-link {
|
||||
color: inherit;
|
||||
padding-left: 1rem !important;
|
||||
padding-right: 1rem !important;
|
||||
padding-top: 12px;
|
||||
background-color: white;
|
||||
margin-right: -1px;
|
||||
}
|
||||
|
||||
.nav-full-height.nav-link:hover {
|
||||
color: white !important;;
|
||||
background-color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.nav-full-height.nav-link.active {
|
||||
color: white !important;
|
||||
background-color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.nav-full-height {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.white-bg {
|
||||
background-color: #fff !important;
|
||||
}
|
||||
.navbar-brand {
|
||||
margin-right: 1em;
|
||||
}
|
||||
.navbar {
|
||||
z-index: 1002;
|
||||
}
|
||||
|
||||
.card {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.card-header {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:root {
|
||||
--navbar-height: 44px;
|
||||
--panel-left: 20px;
|
||||
--panel-top: 20px;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: calc(100vh - var(--navbar-height));
|
||||
}
|
||||
|
||||
.coordinates-display {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: #fff;
|
||||
padding: 3px 8px;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
z-index: 1000;
|
||||
border: 1px solid #ccc;
|
||||
width: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.panel-container-left {
|
||||
position: absolute;
|
||||
top: var(--panel-top);
|
||||
left: var(--panel-left);
|
||||
width: 23rem;
|
||||
max-height: 90vh;
|
||||
max-width: calc(100vw - var(--panel-left) - var(--panel-left));
|
||||
overflow-y: auto;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.panel-container-right {
|
||||
position: absolute;
|
||||
top: var(--panel-top);
|
||||
right: var(--panel-left);
|
||||
width: 23rem;
|
||||
max-height: 90vh;
|
||||
max-width: calc(100vw - var(--panel-left) - var(--panel-left));
|
||||
overflow-y: auto;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
/* MapLibre control styles */
|
||||
.maplibregl-ctrl-group {
|
||||
border: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important;
|
||||
border-radius: var(--bs-border-radius) !important;
|
||||
}
|
||||
|
||||
.maplibregl-popup-tip {
|
||||
border-top-color: var(--bs-border-color) !important;
|
||||
}
|
||||
|
||||
.maplibregl-popup-content {
|
||||
background-color: var(--bs-body-bg) !important;
|
||||
border: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important;
|
||||
border-radius: var(--bs-border-radius) !important;
|
||||
color: var(--bs-body-color);
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.maplibregl-popup-close-button {
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
opacity: var(--bs-backdrop-opacity) !important;
|
||||
}
|
||||
|
||||
.table td.fit,
|
||||
.table th.fit {
|
||||
white-space: nowrap;
|
||||
width: 1%;
|
||||
}
|
||||
|
||||
.force-page-height {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-tinted {
|
||||
filter: brightness(0.6);
|
||||
}
|
||||
|
||||
.dropdown-toggle-standalone::after {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px)
|
||||
{
|
||||
.coordinates-display {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 756 B |
|
|
@ -1,41 +0,0 @@
|
|||
.leaflet-ruler{
|
||||
height: 35px;
|
||||
width: 35px;
|
||||
background-image: url("./icon.png"); /* <div>Icons made by <a href="http://www.freepik.com" title="Freepik">Freepik</a> from <a href="http://www.flaticon.com" title="Flaticon">www.flaticon.com</a> is licensed by <a href="http://creativecommons.org/licenses/by/3.0/" title="Creative Commons BY 3.0" target="_blank">CC 3.0 BY</a></div> */
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
.leaflet-ruler:hover{
|
||||
background-image: url("./icon.png"); /* <div>Icons made by <a href="http://www.freepik.com" title="Freepik">Freepik</a> from <a href="http://www.flaticon.com" title="Flaticon">www.flaticon.com</a> is licensed by <a href="http://creativecommons.org/licenses/by/3.0/" title="Creative Commons BY 3.0" target="_blank">CC 3.0 BY</a></div> */
|
||||
}
|
||||
.leaflet-ruler-clicked{
|
||||
height: 35px;
|
||||
width: 35px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-image: url("./icon.png");
|
||||
border-color: chartreuse !important;
|
||||
}
|
||||
.leaflet-bar{
|
||||
background-color: #ffffff;
|
||||
}
|
||||
.leaflet-control {
|
||||
cursor: pointer;
|
||||
}
|
||||
.result-tooltip{
|
||||
background-color: white;
|
||||
border-width: medium;
|
||||
border-color: #de0000;
|
||||
font-size: smaller;
|
||||
}
|
||||
.moving-tooltip{
|
||||
background-color: rgba(255, 255, 255, .7);
|
||||
background-clip: padding-box;
|
||||
opacity: 0.5;
|
||||
border: dotted;
|
||||
border-color: red;
|
||||
font-size: smaller;
|
||||
}
|
||||
.plus-length{
|
||||
padding-left: 45px;
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
<svg width="224" height="55" viewBox="0 0 224 55" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M128.048 26.5659H120.685C120.708 25.5886 120.583 24.725 120.31 23.975C120.037 23.2137 119.628 22.566 119.083 22.0319C118.549 21.4978 117.895 21.0944 117.122 20.8217C116.35 20.5376 115.48 20.3956 114.515 20.3956C112.651 20.3956 110.941 20.8615 109.384 21.7933C107.827 22.7251 106.515 24.0773 105.447 25.85C104.378 27.6113 103.64 29.742 103.231 32.2419C102.833 34.6509 102.856 36.6679 103.299 38.2929C103.742 39.9179 104.526 41.1451 105.651 41.9746C106.787 42.7928 108.202 43.2019 109.895 43.2019C110.941 43.2019 111.935 43.0712 112.878 42.8099C113.821 42.5371 114.679 42.1508 115.452 41.6508C116.236 41.1394 116.918 40.5201 117.497 39.7929C118.088 39.0656 118.549 38.2418 118.878 37.3213H126.293C125.827 38.9236 125.1 40.469 124.111 41.9576C123.134 43.4462 121.923 44.7757 120.48 45.9461C119.037 47.1052 117.395 48.0256 115.554 48.7074C113.713 49.3893 111.696 49.7302 109.503 49.7302C106.333 49.7302 103.611 49.0029 101.339 47.5484C99.0774 46.0939 97.4467 43.9973 96.4467 41.2587C95.4468 38.5202 95.2593 35.2191 95.8843 31.3556C96.5092 27.617 97.7308 24.4466 99.5489 21.8444C101.378 19.2308 103.6 17.2479 106.214 15.8957C108.839 14.5434 111.645 13.8673 114.634 13.8673C116.713 13.8673 118.594 14.1514 120.276 14.7196C121.958 15.2877 123.389 16.1173 124.571 17.2081C125.764 18.2877 126.662 19.6115 127.264 21.1796C127.867 22.7478 128.128 24.5432 128.048 26.5659Z" fill="white"/>
|
||||
<path d="M85.6189 49.2529L76.8747 34.3726H74.3521L71.8805 49.2529H64.5L70.2953 14.3446H77.6758L75.3577 28.2875H76.8406L90.7494 14.3446H99.9367L83.6416 30.5374L94.7721 49.2529H85.6189Z" fill="white"/>
|
||||
<path d="M58.449 49.2529H51.0685L55.8581 20.3786H50.881C49.4606 20.3786 48.2447 20.5888 47.2333 21.0092C46.2334 21.4183 45.4322 22.0206 44.83 22.816C44.2391 23.6114 43.847 24.5887 43.6539 25.7478C43.472 26.8955 43.5459 27.8557 43.8754 28.6284C44.2163 29.4011 44.8186 29.9806 45.6822 30.367C46.5572 30.7533 47.6992 30.9465 49.1083 30.9465H57.1195L56.1309 36.8782H46.9095C44.2391 36.8782 42.0289 36.435 40.2789 35.5487C38.5403 34.6623 37.3074 33.3896 36.5802 31.7306C35.8529 30.0602 35.6768 28.0659 36.0518 25.7478C36.4495 23.441 37.279 21.4354 38.5403 19.7308C39.813 18.015 41.4607 16.6911 43.4834 15.7593C45.5061 14.8162 47.8356 14.3446 50.4719 14.3446H64.2273L58.449 49.2529ZM42.2391 33.3669H50.1992L39.0687 49.2529H30.9382L42.2391 33.3669Z" fill="white"/>
|
||||
<path d="M27.4586 0.703261C36.0581 -1.03272 44.1649 1.01002 50.8356 4.62122C43.0338 2.24607 34.7084 2.9756 27.5572 6.21008C26.4346 5.16755 24.9317 4.52852 23.2789 4.52845C19.8046 4.52845 16.9879 7.3452 16.9879 10.8194C16.9879 11.7015 17.1699 12.5409 17.4976 13.3028C15.1859 15.724 13.2463 18.54 11.8091 21.7052C6.20557 34.0461 11.8607 47.2099 22.3297 54.1445C12.4671 51.1328 3.20424 43.9147 1.25151 34.2422C-1.77463 19.2521 9.9587 4.23604 27.4586 0.703261Z" fill="#008DD2"/>
|
||||
<path d="M36.7742 6.64835C44.1001 5.45592 51.1813 7.06493 56.7557 10.6396C43.9622 5.75811 28.9667 12.107 23.0516 25.0136C18.0326 35.9656 21.145 48.1648 29.866 54.9257C21.5728 51.7527 15.2724 44.7896 13.8289 35.9228C12.6366 28.5975 14.9995 21.4525 19.7244 16.0097C20.7356 16.7034 21.9592 17.1102 23.2782 17.1103C26.7524 17.1103 29.5692 14.2935 29.5692 10.8193C29.5691 10.1553 29.4649 9.5158 29.2742 8.91496C31.5966 7.85721 34.1112 7.08187 36.7742 6.64835Z" fill="#009846"/>
|
||||
<path d="M27.242 10.7744C27.2669 12.9633 25.5126 14.7579 23.3236 14.7828C21.1347 14.8077 19.3401 13.0533 19.3152 10.8644C19.2904 8.67548 21.0447 6.88085 23.2336 6.85599C25.4225 6.83113 27.2172 8.58545 27.242 10.7744Z" fill="#C42526"/>
|
||||
<path d="M136.082 50.1671L134.582 50.1671L134.582 13.1534L136.082 13.1534L136.082 50.1671Z" fill="#008DD2"/>
|
||||
<path d="M190.118 45.3004H192.074C192.94 45.3004 193.616 45.5078 194.102 45.9225C194.588 46.3345 194.831 46.8856 194.831 47.5759C194.831 48.0248 194.721 48.4197 194.503 48.7606C194.284 49.0987 193.969 49.3629 193.557 49.5532C193.145 49.7407 192.65 49.8345 192.074 49.8345H188.98V43.289H190.714V48.4069H192.074C192.378 48.4069 192.628 48.3302 192.824 48.1768C193.02 48.0234 193.119 47.8274 193.122 47.5887C193.119 47.3359 193.02 47.1299 192.824 46.9708C192.628 46.8089 192.378 46.7279 192.074 46.7279H190.118V45.3004ZM195.534 49.8345V43.289H197.349V49.8345H195.534Z" fill="white"/>
|
||||
<path d="M183.638 47.8274L185.411 43.289H186.792L184.239 49.8345H183.033L180.54 43.289H181.917L183.638 47.8274ZM181.503 43.289V49.8345H179.769V43.289H181.503ZM185.863 49.8345V43.289H187.576V49.8345H185.863Z" fill="white"/>
|
||||
<path d="M175.58 49.9623C174.907 49.9623 174.327 49.8259 173.842 49.5532C173.359 49.2776 172.986 48.8884 172.725 48.3856C172.464 47.8799 172.333 47.2819 172.333 46.5915C172.333 45.9182 172.464 45.3273 172.725 44.8188C172.986 44.3103 173.354 43.914 173.829 43.6299C174.306 43.3458 174.866 43.2037 175.508 43.2037C175.94 43.2037 176.342 43.2733 176.714 43.4125C177.089 43.5489 177.415 43.7549 177.694 44.0304C177.975 44.306 178.194 44.6526 178.35 45.0702C178.506 45.485 178.584 45.9708 178.584 46.5276V47.0262H173.057V45.9012H176.876C176.876 45.6398 176.819 45.4083 176.705 45.2066C176.592 45.0049 176.434 44.8472 176.232 44.7336C176.033 44.6171 175.802 44.5588 175.538 44.5588C175.262 44.5588 175.018 44.6228 174.805 44.7506C174.594 44.8756 174.43 45.0446 174.31 45.2577C174.191 45.4679 174.13 45.7023 174.127 45.9608V47.0304C174.127 47.3543 174.187 47.6341 174.306 47.8699C174.428 48.1057 174.6 48.2875 174.822 48.4154C175.043 48.5432 175.306 48.6071 175.61 48.6071C175.812 48.6071 175.996 48.5787 176.164 48.5219C176.332 48.4651 176.475 48.3799 176.594 48.2662C176.714 48.1526 176.805 48.0134 176.867 47.8486L178.546 47.9594C178.461 48.3628 178.286 48.7151 178.022 49.0162C177.761 49.3145 177.422 49.5475 177.008 49.7151C176.596 49.8799 176.12 49.9623 175.58 49.9623Z" fill="white"/>
|
||||
<path d="M165.799 44.7166V43.289H171.731V44.7166H169.622V49.8345H167.892V44.7166H165.799Z" fill="white"/>
|
||||
<path d="M162.16 49.9623C161.49 49.9623 160.913 49.8202 160.43 49.5361C159.95 49.2492 159.581 48.8515 159.322 48.3429C159.066 47.8344 158.938 47.2492 158.938 46.5873C158.938 45.9168 159.068 45.3287 159.326 44.8231C159.588 44.3145 159.958 43.9182 160.438 43.6341C160.919 43.3472 161.49 43.2037 162.152 43.2037C162.723 43.2037 163.223 43.3074 163.652 43.5148C164.081 43.7222 164.42 44.0134 164.67 44.3884C164.92 44.7634 165.058 45.2037 165.083 45.7094H163.37C163.322 45.3827 163.194 45.1199 162.987 44.9211C162.782 44.7194 162.514 44.6185 162.181 44.6185C161.9 44.6185 161.654 44.6952 161.444 44.8486C161.237 44.9992 161.075 45.2194 160.958 45.5091C160.842 45.7989 160.784 46.1498 160.784 46.5617C160.784 46.9793 160.84 47.3344 160.954 47.627C161.071 47.9196 161.234 48.1427 161.444 48.2961C161.654 48.4495 161.9 48.5262 162.181 48.5262C162.389 48.5262 162.575 48.4836 162.74 48.3983C162.907 48.3131 163.045 48.1895 163.153 48.0276C163.264 47.8628 163.336 47.6654 163.37 47.4353H165.083C165.055 47.9353 164.919 48.3756 164.674 48.7563C164.433 49.1341 164.099 49.4296 163.673 49.6427C163.247 49.8557 162.742 49.9623 162.16 49.9623Z" fill="white"/>
|
||||
<path d="M153.601 47.4396L155.958 43.289H157.747V49.8345H156.017V45.6711L153.669 49.8345H151.867V43.289H153.601V47.4396Z" fill="white"/>
|
||||
<path d="M150.578 44.1626H148.711C148.677 43.9211 148.607 43.7066 148.502 43.5191C148.397 43.3288 148.262 43.1668 148.098 43.0333C147.933 42.8998 147.742 42.7975 147.526 42.7265C147.313 42.6555 147.082 42.62 146.832 42.62C146.38 42.62 145.987 42.7322 145.651 42.9566C145.316 43.1782 145.056 43.5021 144.872 43.9282C144.687 44.3515 144.595 44.8657 144.595 45.4708C144.595 46.093 144.687 46.6157 144.872 47.039C145.059 47.4623 145.321 47.7819 145.656 47.9978C145.991 48.2137 146.379 48.3217 146.819 48.3217C147.066 48.3217 147.295 48.289 147.505 48.2237C147.718 48.1583 147.907 48.0631 148.072 47.9381C148.237 47.8103 148.373 47.6555 148.481 47.4737C148.592 47.2918 148.669 47.0844 148.711 46.8515L150.578 46.86C150.529 47.2606 150.409 47.6469 150.215 48.0191C150.025 48.3884 149.768 48.7194 149.444 49.012C149.123 49.3018 148.74 49.5319 148.294 49.7023C147.85 49.87 147.349 49.9538 146.789 49.9538C146.011 49.9538 145.315 49.7776 144.701 49.4254C144.09 49.0731 143.607 48.5631 143.252 47.8955C142.9 47.2279 142.724 46.4197 142.724 45.4708C142.724 44.5191 142.903 43.7094 143.261 43.0418C143.619 42.3742 144.105 41.8657 144.718 41.5163C145.332 41.164 146.022 40.9879 146.789 40.9879C147.295 40.9879 147.764 41.0589 148.196 41.2009C148.63 41.343 149.015 41.5504 149.35 41.8231C149.686 42.093 149.958 42.4239 150.169 42.816C150.382 43.208 150.518 43.6569 150.578 44.1626Z" fill="white"/>
|
||||
<path d="M220.463 35.9623C219.79 35.9623 219.21 35.8259 218.724 35.5532C218.241 35.2776 217.869 34.8884 217.608 34.3856C217.347 33.8799 217.216 33.2819 217.216 32.5915C217.216 31.9182 217.347 31.3273 217.608 30.8188C217.869 30.3103 218.237 29.914 218.712 29.6299C219.189 29.3458 219.748 29.2037 220.391 29.2037C220.822 29.2037 221.224 29.2733 221.597 29.4125C221.972 29.5489 222.298 29.7549 222.577 30.0304C222.858 30.306 223.077 30.6526 223.233 31.0702C223.389 31.485 223.467 31.9708 223.467 32.5276V33.0262H217.94V31.9012H221.758C221.758 31.6398 221.702 31.4083 221.588 31.2066C221.474 31.0049 221.317 30.8472 221.115 30.7336C220.916 30.6171 220.685 30.5588 220.42 30.5588C220.145 30.5588 219.9 30.6228 219.687 30.7506C219.477 30.8756 219.312 31.0446 219.193 31.2577C219.074 31.4679 219.013 31.7023 219.01 31.9608V33.0304C219.01 33.3543 219.07 33.6341 219.189 33.8699C219.311 34.1057 219.483 34.2875 219.704 34.4154C219.926 34.5432 220.189 34.6071 220.493 34.6071C220.695 34.6071 220.879 34.5787 221.047 34.5219C221.214 34.4651 221.358 34.3799 221.477 34.2662C221.596 34.1526 221.687 34.0134 221.75 33.8486L223.429 33.9594C223.344 34.3628 223.169 34.7151 222.905 35.0162C222.643 35.3145 222.305 35.5475 221.891 35.7151C221.479 35.8799 221.003 35.9623 220.463 35.9623Z" fill="white"/>
|
||||
<path d="M211.878 33.4396L214.235 29.289H216.025V35.8345H214.295V31.6711L211.947 35.8345H210.144V29.289H211.878V33.4396Z" fill="white"/>
|
||||
<path d="M203.113 35.8345V29.289H204.928V31.8288H205.439L207.221 29.289H209.351L207.038 32.5362L209.377 35.8345H207.221L205.606 33.512H204.928V35.8345H203.113Z" fill="white"/>
|
||||
<path d="M199.027 35.9623C198.357 35.9623 197.78 35.8202 197.297 35.5361C196.817 35.2492 196.448 34.8515 196.189 34.3429C195.934 33.8344 195.806 33.2492 195.806 32.5873C195.806 31.9168 195.935 31.3287 196.193 30.8231C196.455 30.3145 196.826 29.9182 197.306 29.6341C197.786 29.3472 198.357 29.2037 199.019 29.2037C199.59 29.2037 200.09 29.3074 200.519 29.5148C200.948 29.7222 201.287 30.0134 201.537 30.3884C201.787 30.7634 201.925 31.2037 201.951 31.7094H200.237C200.189 31.3827 200.061 31.1199 199.854 30.9211C199.649 30.7194 199.381 30.6185 199.049 30.6185C198.767 30.6185 198.522 30.6952 198.311 30.8486C198.104 30.9992 197.942 31.2194 197.826 31.5091C197.709 31.7989 197.651 32.1498 197.651 32.5617C197.651 32.9793 197.708 33.3344 197.821 33.627C197.938 33.9196 198.101 34.1427 198.311 34.2961C198.522 34.4495 198.767 34.5262 199.049 34.5262C199.256 34.5262 199.442 34.4836 199.607 34.3983C199.774 34.3131 199.912 34.1895 200.02 34.0276C200.131 33.8628 200.203 33.6654 200.237 33.4353H201.951C201.922 33.9353 201.786 34.3756 201.541 34.7563C201.3 35.1341 200.966 35.4296 200.54 35.6427C200.114 35.8557 199.61 35.9623 199.027 35.9623Z" fill="white"/>
|
||||
<path d="M191.881 35.9623C191.208 35.9623 190.628 35.8259 190.142 35.5532C189.659 35.2776 189.287 34.8884 189.026 34.3856C188.764 33.8799 188.634 33.2819 188.634 32.5915C188.634 31.9182 188.764 31.3273 189.026 30.8188C189.287 30.3103 189.655 29.914 190.13 29.6299C190.607 29.3458 191.166 29.2037 191.809 29.2037C192.24 29.2037 192.642 29.2733 193.014 29.4125C193.389 29.5489 193.716 29.7549 193.995 30.0304C194.276 30.306 194.495 30.6526 194.651 31.0702C194.807 31.485 194.885 31.9708 194.885 32.5276V33.0262H189.358V31.9012H193.176C193.176 31.6398 193.12 31.4083 193.006 31.2066C192.892 31.0049 192.735 30.8472 192.533 30.7336C192.334 30.6171 192.103 30.5588 191.838 30.5588C191.563 30.5588 191.318 30.6228 191.105 30.7506C190.895 30.8756 190.73 31.0446 190.611 31.2577C190.492 31.4679 190.431 31.7023 190.428 31.9608V33.0304C190.428 33.3543 190.487 33.6341 190.607 33.8699C190.729 34.1057 190.901 34.2875 191.122 34.4154C191.344 34.5432 191.607 34.6071 191.911 34.6071C192.112 34.6071 192.297 34.5787 192.465 34.5219C192.632 34.4651 192.776 34.3799 192.895 34.2662C193.014 34.1526 193.105 34.0134 193.168 33.8486L194.847 33.9594C194.762 34.3628 194.587 34.7151 194.323 35.0162C194.061 35.3145 193.723 35.5475 193.309 35.7151C192.897 35.8799 192.421 35.9623 191.881 35.9623Z" fill="white"/>
|
||||
<path d="M187.489 29.289V35.8344H185.767V29.289H187.489ZM186.649 32.1611V33.593C186.51 33.6555 186.337 33.7151 186.129 33.7719C185.922 33.8259 185.704 33.8699 185.477 33.904C185.25 33.9381 185.037 33.9552 184.838 33.9552C183.898 33.9552 183.158 33.762 182.618 33.3756C182.078 32.9864 181.808 32.3657 181.808 31.5134V29.2805H183.521V31.5134C183.521 31.7663 183.564 31.9665 183.649 32.1143C183.737 32.262 183.876 32.3685 184.067 32.4339C184.26 32.4964 184.517 32.5276 184.838 32.5276C185.136 32.5276 185.429 32.4964 185.716 32.4339C186.003 32.3714 186.314 32.2805 186.649 32.1611Z" fill="white"/>
|
||||
<path d="M176.207 33.4396L178.563 29.289H180.353V35.8345H178.623V31.6711L176.275 35.8345H174.472V29.289H176.207V33.4396Z" fill="white"/>
|
||||
<path d="M169.131 33.8274L170.903 29.289H172.284L169.731 35.8345H168.525L166.033 29.289H167.409L169.131 33.8274ZM166.996 29.289V35.8345H165.261V29.289H166.996ZM171.355 35.8345V29.289H173.068V35.8345H171.355Z" fill="white"/>
|
||||
<path d="M161.176 35.9623C160.505 35.9623 159.928 35.8202 159.446 35.5361C158.965 35.2492 158.596 34.8515 158.338 34.3429C158.082 33.8344 157.954 33.2492 157.954 32.5873C157.954 31.9168 158.083 31.3287 158.342 30.8231C158.603 30.3145 158.974 29.9182 159.454 29.6341C159.934 29.3472 160.505 29.2037 161.167 29.2037C161.738 29.2037 162.238 29.3074 162.667 29.5148C163.096 29.7222 163.436 30.0134 163.686 30.3884C163.936 30.7634 164.073 31.2037 164.099 31.7094H162.386C162.338 31.3827 162.21 31.1199 162.002 30.9211C161.798 30.7194 161.529 30.6185 161.197 30.6185C160.916 30.6185 160.67 30.6952 160.46 30.8486C160.252 30.9992 160.09 31.2194 159.974 31.5091C159.857 31.7989 159.799 32.1498 159.799 32.5617C159.799 32.9793 159.856 33.3344 159.97 33.627C160.086 33.9196 160.249 34.1427 160.46 34.2961C160.67 34.4495 160.916 34.5262 161.197 34.5262C161.404 34.5262 161.59 34.4836 161.755 34.3983C161.923 34.3131 162.061 34.1895 162.169 34.0276C162.279 33.8628 162.352 33.6654 162.386 33.4353H164.099C164.071 33.9353 163.934 34.3756 163.69 34.7563C163.448 35.1341 163.115 35.4296 162.688 35.6427C162.262 35.8557 161.758 35.9623 161.176 35.9623Z" fill="white"/>
|
||||
<path d="M153.816 35.9623C153.154 35.9623 152.582 35.8216 152.099 35.5404C151.619 35.2563 151.248 34.8614 150.987 34.3557C150.725 33.8472 150.595 33.2577 150.595 32.5873C150.595 31.9111 150.725 31.3202 150.987 30.8145C151.248 30.306 151.619 29.9111 152.099 29.6299C152.582 29.3458 153.154 29.2037 153.816 29.2037C154.478 29.2037 155.049 29.3458 155.529 29.6299C156.012 29.9111 156.384 30.306 156.646 30.8145C156.907 31.3202 157.038 31.9111 157.038 32.5873C157.038 33.2577 156.907 33.8472 156.646 34.3557C156.384 34.8614 156.012 35.2563 155.529 35.5404C155.049 35.8216 154.478 35.9623 153.816 35.9623ZM153.825 34.556C154.126 34.556 154.377 34.4708 154.579 34.3003C154.781 34.127 154.933 33.8912 155.035 33.5929C155.14 33.2946 155.193 32.9552 155.193 32.5745C155.193 32.1938 155.14 31.8543 155.035 31.556C154.933 31.2577 154.781 31.0219 154.579 30.8486C154.377 30.6753 154.126 30.5887 153.825 30.5887C153.521 30.5887 153.265 30.6753 153.058 30.8486C152.853 31.0219 152.698 31.2577 152.593 31.556C152.491 31.8543 152.44 32.1938 152.44 32.5745C152.44 32.9552 152.491 33.2946 152.593 33.5929C152.698 33.8912 152.853 34.127 153.058 34.3003C153.265 34.4708 153.521 34.556 153.825 34.556Z" fill="white"/>
|
||||
<path d="M148.157 35.8344L145.353 32.1143H144.723V35.8344H142.877V27.1072H144.723V30.593H145.093L147.991 27.1072H150.288L146.883 31.1555L150.446 35.8344H148.157Z" fill="white"/>
|
||||
<path d="M196.076 21.9623C195.403 21.9623 194.823 21.8259 194.338 21.5532C193.855 21.2776 193.483 20.8884 193.221 20.3856C192.96 19.8799 192.829 19.2819 192.829 18.5915C192.829 17.9182 192.96 17.3273 193.221 16.8188C193.483 16.3103 193.85 15.914 194.325 15.6299C194.802 15.3458 195.362 15.2037 196.004 15.2037C196.436 15.2037 196.838 15.2733 197.21 15.4125C197.585 15.5489 197.911 15.7549 198.19 16.0304C198.471 16.306 198.69 16.6526 198.846 17.0702C199.002 17.485 199.081 17.9708 199.081 18.5276V19.0262H193.554V17.9012H197.372C197.372 17.6398 197.315 17.4083 197.201 17.2066C197.088 17.0049 196.93 16.8472 196.728 16.7336C196.529 16.6171 196.298 16.5588 196.034 16.5588C195.758 16.5588 195.514 16.6228 195.301 16.7506C195.09 16.8756 194.926 17.0446 194.806 17.2577C194.687 17.4679 194.626 17.7023 194.623 17.9608V19.0304C194.623 19.3543 194.683 19.6341 194.802 19.8699C194.924 20.1057 195.096 20.2875 195.318 20.4154C195.539 20.5432 195.802 20.6071 196.106 20.6071C196.308 20.6071 196.492 20.5787 196.66 20.5219C196.828 20.4651 196.971 20.3799 197.09 20.2662C197.21 20.1526 197.301 20.0134 197.363 19.8486L199.042 19.9594C198.957 20.3628 198.782 20.7151 198.518 21.0162C198.257 21.3145 197.919 21.5475 197.504 21.7151C197.092 21.8799 196.616 21.9623 196.076 21.9623Z" fill="white"/>
|
||||
<path d="M187.492 19.4396L189.848 15.289H191.638V21.8345H189.908V17.6711L187.56 21.8345H185.757V15.289H187.492V19.4396Z" fill="white"/>
|
||||
<path d="M178.726 21.8345V15.289H180.541V17.8288H181.053L182.834 15.289H184.965L182.651 18.5362L184.99 21.8345H182.834L181.219 19.512H180.541V21.8345H178.726Z" fill="white"/>
|
||||
<path d="M174.641 21.9623C173.97 21.9623 173.393 21.8202 172.91 21.5361C172.43 21.2492 172.061 20.8515 171.802 20.3429C171.547 19.8344 171.419 19.2492 171.419 18.5873C171.419 17.9168 171.548 17.3287 171.807 16.8231C172.068 16.3145 172.439 15.9182 172.919 15.6341C173.399 15.3472 173.97 15.2037 174.632 15.2037C175.203 15.2037 175.703 15.3074 176.132 15.5148C176.561 15.7222 176.9 16.0134 177.15 16.3884C177.4 16.7634 177.538 17.2037 177.564 17.7094H175.851C175.802 17.3827 175.675 17.1199 175.467 16.9211C175.263 16.7194 174.994 16.6185 174.662 16.6185C174.381 16.6185 174.135 16.6952 173.925 16.8486C173.717 16.9992 173.555 17.2194 173.439 17.5091C173.322 17.7989 173.264 18.1498 173.264 18.5617C173.264 18.9793 173.321 19.3344 173.435 19.627C173.551 19.9196 173.714 20.1427 173.925 20.2961C174.135 20.4495 174.381 20.5262 174.662 20.5262C174.869 20.5262 175.055 20.4836 175.22 20.3983C175.388 20.3131 175.525 20.1895 175.633 20.0276C175.744 19.8628 175.817 19.6654 175.851 19.4353H177.564C177.535 19.9353 177.399 20.3756 177.155 20.7563C176.913 21.1341 176.579 21.4296 176.153 21.6427C175.727 21.8557 175.223 21.9623 174.641 21.9623Z" fill="white"/>
|
||||
<path d="M164.885 16.7166V15.289H170.817V16.7166H168.708V21.8345H166.977V16.7166H164.885Z" fill="white"/>
|
||||
<path d="M159.316 24.289C159.085 24.289 158.87 24.2705 158.668 24.2336C158.469 24.1995 158.304 24.1555 158.174 24.1015L158.583 22.7464C158.796 22.8117 158.987 22.8472 159.158 22.8529C159.331 22.8586 159.48 22.8188 159.605 22.7336C159.733 22.6484 159.837 22.5035 159.916 22.2989L160.023 22.022L157.675 15.289H159.584L160.939 20.0958H161.007L162.375 15.289H164.297L161.753 22.5418C161.631 22.8941 161.465 23.2009 161.254 23.4623C161.047 23.7265 160.784 23.9296 160.466 24.0717C160.148 24.2166 159.764 24.289 159.316 24.289Z" fill="white"/>
|
||||
<path d="M151.058 21.8345V15.289H152.873V17.8288H153.385L155.166 15.289H157.297L154.983 18.5362L157.322 21.8345H155.166L153.551 19.512H152.873V21.8345H151.058Z" fill="white"/>
|
||||
<path d="M149.576 21.8345H147.735V14.6157H146.491C146.136 14.6157 145.84 14.6683 145.605 14.7734C145.372 14.8756 145.197 15.0262 145.08 15.2251C144.964 15.4239 144.906 15.6683 144.906 15.958C144.906 16.245 144.964 16.485 145.08 16.6782C145.197 16.8714 145.372 17.0163 145.605 17.1129C145.838 17.2095 146.13 17.2577 146.482 17.2577H148.485V18.7407H146.184C145.517 18.7407 144.946 18.6299 144.471 18.4083C143.997 18.1867 143.634 17.8685 143.384 17.4538C143.134 17.0362 143.009 16.5376 143.009 15.958C143.009 15.3813 143.132 14.8799 143.376 14.4538C143.623 14.0248 143.98 13.6938 144.446 13.4609C144.914 13.2251 145.478 13.1072 146.137 13.1072H149.576V21.8345ZM144.867 17.8629H146.857L144.735 21.8345H142.698L144.867 17.8629Z" fill="white"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 20 KiB |
|
|
@ -1,8 +0,0 @@
|
|||
<svg width="128" height="55" viewBox="0 0 128 55" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M127.629 26.5648H120.266C120.289 25.5875 120.164 24.7239 119.891 23.9739C119.618 23.2126 119.209 22.5649 118.664 22.0308C118.129 21.4967 117.476 21.0933 116.703 20.8206C115.931 20.5365 115.061 20.3945 114.095 20.3945C112.232 20.3945 110.522 20.8604 108.965 21.7922C107.408 22.724 106.096 24.0762 105.027 25.8489C103.959 27.6102 103.221 29.7409 102.812 32.2408C102.414 34.6498 102.437 36.6668 102.88 38.2918C103.323 39.9168 104.107 41.144 105.232 41.9735C106.368 42.7917 107.783 43.2008 109.476 43.2008C110.522 43.2008 111.516 43.0701 112.459 42.8088C113.402 42.536 114.26 42.1497 115.033 41.6497C115.817 41.1383 116.499 40.519 117.078 39.7918C117.669 39.0645 118.129 38.2407 118.459 37.3202H125.874C125.408 38.9225 124.68 40.4679 123.692 41.9565C122.715 43.4451 121.504 44.7746 120.061 45.945C118.618 47.1041 116.976 48.0245 115.135 48.7063C113.294 49.3882 111.277 49.7291 109.084 49.7291C105.914 49.7291 103.192 49.0018 100.92 47.5473C98.6583 46.0928 97.0276 43.9962 96.0277 41.2576C95.0277 38.5191 94.8402 35.218 95.4652 31.3545C96.0902 27.6159 97.3117 24.4455 99.1299 21.8433C100.959 19.2297 103.181 17.2468 105.795 15.8946C108.419 14.5423 111.226 13.8662 114.215 13.8662C116.294 13.8662 118.175 14.1503 119.857 14.7185C121.538 15.2866 122.97 16.1162 124.152 17.207C125.345 18.2866 126.243 19.6104 126.845 21.1786C127.447 22.7467 127.709 24.5421 127.629 26.5648Z" fill="#1E1E25"/>
|
||||
<path d="M85.1998 49.2518L76.4557 34.3715H73.933L71.4615 49.2518H64.0809L69.8763 14.3435H77.2568L74.9386 28.2864H76.4216L90.3304 14.3435H99.5177L83.2226 30.5363L94.353 49.2518H85.1998Z" fill="#1E1E25"/>
|
||||
<path d="M58.0299 49.2518H50.6494L55.4391 20.3774H50.4619C49.0415 20.3774 47.8256 20.5877 46.8143 21.0081C45.8143 21.4172 45.0132 22.0195 44.4109 22.8149C43.82 23.6103 43.428 24.5876 43.2348 25.7466C43.053 26.8944 43.1268 27.8546 43.4564 28.6273C43.7973 29.4 44.3995 29.9795 45.2632 30.3659C46.1381 30.7522 47.2802 30.9454 48.6892 30.9454H56.7004L55.7118 36.8771H46.4904C43.82 36.8771 41.6098 36.4339 39.8599 35.5476C38.1213 34.6612 36.8883 33.3885 36.1611 31.7295C35.4338 30.0591 35.2577 28.0648 35.6327 25.7466C36.0304 23.4399 36.8599 21.4342 38.1213 19.7297C39.394 18.0139 41.0417 16.69 43.0643 15.7582C45.087 14.8151 47.4165 14.3435 50.0528 14.3435H63.8082L58.0299 49.2518ZM41.8201 33.3658H49.7801L38.6497 49.2518H30.5192L41.8201 33.3658Z" fill="#1E1E25"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.3903 21.7039C5.78668 34.0449 11.4416 47.2087 21.9106 54.1434C12.048 51.1317 2.7851 43.9135 0.832447 34.2409C-2.19369 19.2507 9.53957 4.23493 27.0394 0.702149C35.6391 -1.03391 43.7457 1.00909 50.4165 4.62037C42.6151 2.24532 34.2901 2.97345 27.1391 6.20747C26.0164 5.16499 24.5125 4.52753 22.8596 4.52753C19.3853 4.52753 16.5689 7.34398 16.5689 10.8183C16.5689 11.7004 16.7504 12.5401 17.0782 13.302C14.7667 15.7231 12.8274 18.5389 11.3903 21.7039Z" fill="#008DD2"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M22.8595 17.109C26.3338 17.109 29.1502 14.2926 29.1502 10.8183C29.1502 10.1536 29.0472 9.51302 28.8561 8.91161C31.1779 7.85429 33.6924 7.08033 36.3547 6.64694C43.6811 5.45428 50.7637 7.06258 56.3383 10.6377C43.5445 5.75535 28.5474 12.1054 22.6323 25.0127C17.6132 35.9648 20.7246 48.165 29.4457 54.9259C21.1528 51.7528 14.8535 44.7886 13.4101 35.922C12.2177 28.5968 14.5801 21.452 19.3047 16.0091C20.316 16.703 21.5404 17.109 22.8595 17.109Z" fill="#009846"/>
|
||||
<circle cx="22.8595" cy="10.8183" r="3.96366" transform="rotate(-0.650663 22.8595 10.8183)" fill="#C42526"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.5 KiB |
|
|
@ -1,28 +0,0 @@
|
|||
<svg width="305" height="56" viewBox="0 0 305 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M127.296 26.7009H119.933C119.955 25.7237 119.83 24.8601 119.558 24.1101C119.285 23.3487 118.876 22.701 118.331 22.1669C117.796 21.6329 117.143 21.2295 116.37 20.9567C115.598 20.6727 114.728 20.5306 113.762 20.5306C111.899 20.5306 110.189 20.9965 108.632 21.9283C107.075 22.8601 105.763 24.2124 104.694 25.985C103.626 27.7464 102.888 29.877 102.479 32.3769C102.081 34.786 102.104 36.803 102.547 38.428C102.99 40.0529 103.774 41.2802 104.899 42.1097C106.035 42.9279 107.45 43.3369 109.143 43.3369C110.189 43.3369 111.183 43.2063 112.126 42.9449C113.069 42.6722 113.927 42.2858 114.7 41.7858C115.484 41.2745 116.166 40.6552 116.745 39.9279C117.336 39.2007 117.796 38.3768 118.126 37.4564H125.541C125.075 39.0586 124.347 40.604 123.359 42.0926C122.382 43.5812 121.171 44.9108 119.728 46.0812C118.285 47.2403 116.643 48.1607 114.802 48.8425C112.961 49.5243 110.944 49.8652 108.751 49.8652C105.581 49.8652 102.859 49.1379 100.587 47.6834C98.3253 46.2289 96.6946 44.1324 95.6947 41.3938C94.6947 38.6552 94.5072 35.3542 95.1322 31.4906C95.7572 27.7521 96.9787 24.5817 98.7969 21.9795C100.626 19.3659 102.848 17.383 105.461 16.0307C108.086 14.6785 110.893 14.0024 113.882 14.0024C115.961 14.0024 117.842 14.2864 119.524 14.8546C121.205 15.4228 122.637 16.2523 123.819 17.3432C125.012 18.4227 125.91 19.7465 126.512 21.3147C127.114 22.8828 127.376 24.6783 127.296 26.7009Z" fill="#1E1E25"/>
|
||||
<path d="M84.8668 49.388L76.1227 34.5076H73.6L71.1284 49.388H63.7479L69.5433 14.4796H76.9238L74.6056 28.4225H76.0886L89.9973 14.4796H99.1846L82.8896 30.6725L94.02 49.388H84.8668Z" fill="#1E1E25"/>
|
||||
<path d="M57.6969 49.388H50.3164L55.1061 20.5136H50.1289C48.7085 20.5136 47.4926 20.7238 46.4813 21.1443C45.4813 21.5533 44.6802 22.1556 44.0779 22.951C43.487 23.7465 43.095 24.7237 42.9018 25.8828C42.72 27.0305 42.7938 27.9907 43.1234 28.7634C43.4643 29.5361 44.0665 30.1157 44.9302 30.502C45.8051 30.8884 46.9472 31.0815 48.3562 31.0815H56.3674L55.3788 37.0132H46.1574C43.487 37.0132 41.2768 36.5701 39.5269 35.6837C37.7883 34.7974 36.5553 33.5247 35.8281 31.8656C35.1008 30.1952 34.9247 28.2009 35.2997 25.8828C35.6974 23.576 36.5269 21.5704 37.7883 19.8659C39.061 18.15 40.7087 16.8262 42.7313 15.8944C44.754 14.9512 47.0835 14.4796 49.7198 14.4796H63.4752L57.6969 49.388ZM41.487 33.5019H49.4471L38.3167 49.388H30.1862L41.487 33.5019Z" fill="#1E1E25"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.0573 21.8401C5.45367 34.1811 11.1086 47.3449 21.5776 54.2795C11.715 51.2678 2.45209 44.0496 0.499439 34.3771C-2.5267 19.3869 9.20656 4.37107 26.7064 0.838284C35.3061 -0.897771 43.4127 1.14522 50.0835 4.75651C42.2821 2.38145 33.9571 3.10958 26.8061 6.3436C25.6834 5.30112 24.1795 4.66366 22.5266 4.66366C19.0523 4.66366 16.2359 7.48012 16.2359 10.9544C16.2359 11.8365 16.4174 12.6762 16.7452 13.4381C14.4337 15.8592 12.4944 18.6751 11.0573 21.8401Z" fill="#008DD2"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M22.5265 17.2452C26.0008 17.2452 28.8172 14.4287 28.8172 10.9544C28.8172 10.2898 28.7141 9.64916 28.5231 9.04774C30.8449 7.99042 33.3594 7.21647 36.0217 6.78307C43.348 5.59041 50.4307 7.19872 56.0053 10.7738C43.2114 5.89148 28.2144 12.2416 22.2993 25.1488C17.2802 36.1009 20.3916 48.3011 29.1127 55.062C20.8198 51.8889 14.5205 44.9248 13.0771 36.0581C11.8847 28.733 14.2471 21.5881 18.9717 16.1452C19.983 16.8391 21.2074 17.2452 22.5265 17.2452Z" fill="#009846"/>
|
||||
<path d="M26.4899 10.9094C26.5148 13.0984 24.7605 14.893 22.5716 14.9178C20.3826 14.9427 18.588 13.1884 18.5631 10.9995C18.5383 8.81053 20.2926 7.0159 22.4815 6.99104C24.6705 6.96618 26.4651 8.72051 26.4899 10.9094Z" fill="#C42526"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M133.83 50.3023L133.83 13.2885L135.33 13.2885L135.33 50.3023L133.83 50.3023Z" fill="#008DD2"/>
|
||||
<path d="M202.963 39.4941H205.708V43.8007H208.461C209.542 43.8007 210.366 43.8789 210.932 44.0351C211.505 44.1848 211.987 44.5169 212.377 45.0312C212.775 45.5455 212.973 46.151 212.973 46.8476C212.973 47.8502 212.628 48.6054 211.938 49.1132C211.248 49.6145 210.213 49.8652 208.833 49.8652H202.963V39.4941ZM205.708 48.1269H208.256C208.92 48.1269 209.402 48.0227 209.702 47.8144C210.001 47.6061 210.151 47.2675 210.151 46.7988C210.151 46.2975 209.956 45.9557 209.565 45.7734C209.181 45.5846 208.487 45.4902 207.485 45.4902H205.708V48.1269ZM214.536 39.4941H217.28V49.8652H214.536V39.4941Z" fill="#1E1E25"/>
|
||||
<path d="M191.909 39.4941H201.293V41.7109H197.973V49.8652H195.229V41.7109H191.909V39.4941Z" fill="#1E1E25"/>
|
||||
<path d="M188.286 46.5644L191.02 47.0234C190.668 48.026 190.112 48.791 189.35 49.3183C188.595 49.8391 187.648 50.0996 186.508 50.0996C184.705 50.0996 183.37 49.5104 182.504 48.332C181.821 47.388 181.479 46.1966 181.479 44.7578C181.479 43.039 181.928 41.6946 182.827 40.7246C183.725 39.748 184.861 39.2597 186.235 39.2597C187.778 39.2597 188.995 39.7708 189.887 40.7929C190.779 41.8085 191.206 43.3678 191.167 45.4707H184.292C184.311 46.2845 184.532 46.9192 184.956 47.3749C185.379 47.8242 185.906 48.0488 186.538 48.0488C186.967 48.0488 187.329 47.9316 187.622 47.6972C187.915 47.4628 188.136 47.0852 188.286 46.5644ZM188.442 43.791C188.422 42.9967 188.217 42.3945 187.827 41.9843C187.436 41.5677 186.961 41.3593 186.401 41.3593C185.802 41.3593 185.307 41.5774 184.917 42.0136C184.526 42.4498 184.334 43.0423 184.34 43.791H188.442Z" fill="#1E1E25"/>
|
||||
<path d="M170.581 39.4941H179.77V49.8652H177.036V41.7207H173.295V46.3789C173.295 47.5638 173.159 48.3971 172.885 48.8789C172.612 49.3541 172.309 49.6666 171.977 49.8164C171.645 49.9661 171.111 50.041 170.375 50.041C169.939 50.041 169.363 49.9824 168.647 49.8652V47.8437C168.706 47.8437 168.859 47.8502 169.106 47.8632C169.392 47.8828 169.614 47.8925 169.77 47.8925C170.141 47.8925 170.369 47.7721 170.454 47.5312C170.538 47.2838 170.581 46.6035 170.581 45.4902V39.4941Z" fill="#1E1E25"/>
|
||||
<path d="M157.485 44.5332C157.485 43.6217 157.709 42.7395 158.159 41.8867C158.608 41.0338 159.243 40.3828 160.063 39.9335C160.89 39.4843 161.811 39.2597 162.827 39.2597C164.396 39.2597 165.681 39.7708 166.684 40.7929C167.687 41.8085 168.188 43.0943 168.188 44.6503C168.188 46.2193 167.68 47.5214 166.665 48.5566C165.655 49.5852 164.383 50.0996 162.846 50.0996C161.896 50.0996 160.987 49.8847 160.122 49.455C159.262 49.0253 158.608 48.3971 158.159 47.5703C157.709 46.7369 157.485 45.7246 157.485 44.5332ZM160.297 44.6796C160.297 45.7083 160.542 46.496 161.03 47.0429C161.518 47.5898 162.12 47.8632 162.836 47.8632C163.553 47.8632 164.152 47.5898 164.633 47.0429C165.122 46.496 165.366 45.7018 165.366 44.6601C165.366 43.6445 165.122 42.8632 164.633 42.3164C164.152 41.7695 163.553 41.496 162.836 41.496C162.12 41.496 161.518 41.7695 161.03 42.3164C160.542 42.8632 160.297 43.651 160.297 44.6796Z" fill="#1E1E25"/>
|
||||
<path d="M145.922 39.4941H155.131V49.8652H152.387V41.7109H148.666V49.8652H145.922V39.4941Z" fill="#1E1E25"/>
|
||||
<path d="M302.036 24.5644L304.77 25.0234C304.418 26.026 303.862 26.791 303.1 27.3183C302.345 27.8391 301.398 28.0996 300.258 28.0996C298.455 28.0996 297.12 27.5104 296.254 26.332C295.571 25.388 295.229 24.1966 295.229 22.7578C295.229 21.039 295.678 19.6946 296.577 18.7246C297.475 17.748 298.611 17.2597 299.985 17.2597C301.528 17.2597 302.745 17.7708 303.637 18.7929C304.529 19.8085 304.956 21.3678 304.917 23.4707H298.042C298.061 24.2845 298.282 24.9192 298.706 25.3749C299.129 25.8242 299.656 26.0488 300.288 26.0488C300.717 26.0488 301.079 25.9316 301.372 25.6972C301.665 25.4628 301.886 25.0852 302.036 24.5644ZM302.192 21.791C302.172 20.9967 301.967 20.3945 301.577 19.9843C301.186 19.5677 300.711 19.3593 300.151 19.3593C299.552 19.3593 299.057 19.5774 298.667 20.0136C298.276 20.4498 298.084 21.0423 298.09 21.791H302.192Z" fill="#1E1E25"/>
|
||||
<path d="M278.959 17.4941H281.704V21.8007H284.458C285.538 21.8007 286.362 21.8789 286.928 22.0351C287.501 22.1848 287.983 22.5169 288.374 23.0312C288.771 23.5455 288.969 24.151 288.969 24.8476C288.969 25.8502 288.624 26.6054 287.934 27.1132C287.244 27.6145 286.209 27.8652 284.829 27.8652H278.959V17.4941ZM281.704 26.1269H284.252C284.917 26.1269 285.398 26.0227 285.698 25.8144C285.997 25.6061 286.147 25.2675 286.147 24.7988C286.147 24.2975 285.952 23.9557 285.561 23.7734C285.177 23.5846 284.484 23.4902 283.481 23.4902H281.704V26.1269ZM290.532 17.4941H293.276V27.8652H290.532V17.4941Z" fill="#1E1E25"/>
|
||||
<path d="M266.743 17.4941H269.487V21.2929H273.413V17.4941H276.167V27.8652H273.413V23.5097H269.487V27.8652H266.743V17.4941Z" fill="#1E1E25"/>
|
||||
<path d="M254.545 17.4941H257.104V19.0175C257.436 18.4967 257.885 18.0735 258.452 17.748C259.018 17.4225 259.646 17.2597 260.336 17.2597C261.541 17.2597 262.563 17.7317 263.403 18.6757C264.243 19.6197 264.663 20.9348 264.663 22.621C264.663 24.3528 264.239 25.7005 263.393 26.664C262.547 27.621 261.521 28.0996 260.317 28.0996C259.744 28.0996 259.223 27.9856 258.754 27.7578C258.292 27.5299 257.804 27.1393 257.29 26.5859V31.8105H254.545V17.4941ZM257.26 22.5039C257.26 23.6692 257.491 24.5318 257.954 25.0917C258.416 25.6451 258.979 25.9218 259.643 25.9218C260.281 25.9218 260.812 25.6679 261.235 25.1601C261.658 24.6458 261.87 23.8059 261.87 22.6406C261.87 21.5533 261.652 20.746 261.215 20.2187C260.779 19.6914 260.239 19.4277 259.594 19.4277C258.924 19.4277 258.367 19.6881 257.924 20.2089C257.482 20.7233 257.26 21.4882 257.26 22.5039Z" fill="#1E1E25"/>
|
||||
<path d="M249.497 24.5644L252.231 25.0234C251.879 26.026 251.323 26.791 250.561 27.3183C249.806 27.8391 248.859 28.0996 247.719 28.0996C245.916 28.0996 244.581 27.5104 243.715 26.332C243.032 25.388 242.69 24.1966 242.69 22.7578C242.69 21.039 243.139 19.6946 244.038 18.7246C244.936 17.748 246.072 17.2597 247.446 17.2597C248.989 17.2597 250.206 17.7708 251.098 18.7929C251.99 19.8085 252.417 21.3678 252.377 23.4707H245.502C245.522 24.2845 245.743 24.9192 246.167 25.3749C246.59 25.8242 247.117 26.0488 247.749 26.0488C248.178 26.0488 248.54 25.9316 248.833 25.6972C249.125 25.4628 249.347 25.0852 249.497 24.5644ZM249.653 21.791C249.633 20.9967 249.428 20.3945 249.038 19.9843C248.647 19.5677 248.172 19.3593 247.612 19.3593C247.013 19.3593 246.518 19.5774 246.127 20.0136C245.737 20.4498 245.545 21.0423 245.551 21.791H249.653Z" fill="#1E1E25"/>
|
||||
<path d="M232.231 13.5488H234.956V18.8124C235.288 18.2981 235.685 17.914 236.147 17.6601C236.609 17.3997 237.137 17.2695 237.729 17.2695C238.842 17.2695 239.75 17.8098 240.454 18.8906C241.163 19.9648 241.518 21.2506 241.518 22.748C241.518 24.2519 241.121 25.5214 240.327 26.5566C239.539 27.5852 238.605 28.0996 237.524 28.0996C237.036 28.0996 236.577 27.9791 236.147 27.7382C235.717 27.4908 235.32 27.1263 234.956 26.6445V31.8105H232.231V26.6445C231.834 27.1263 231.407 27.4908 230.952 27.7382C230.502 27.9791 230.024 28.0996 229.516 28.0996C228.396 28.0996 227.472 27.582 226.743 26.5468C226.014 25.5117 225.649 24.2096 225.649 22.6406C225.649 21.0846 226.066 19.7988 226.899 18.7832C227.732 17.7675 228.634 17.2597 229.604 17.2597C230.131 17.2597 230.613 17.3834 231.049 17.6308C231.486 17.8782 231.879 18.2493 232.231 18.7441V13.5488ZM230.395 19.3593C229.887 19.3593 229.441 19.6653 229.057 20.2773C228.68 20.8828 228.491 21.6835 228.491 22.6796C228.491 23.6822 228.667 24.4863 229.018 25.0917C229.376 25.6972 229.819 25.9999 230.346 25.9999C230.88 25.9999 231.333 25.6972 231.704 25.0917C232.075 24.4863 232.26 23.6595 232.26 22.6113C232.26 21.472 232.058 20.6451 231.655 20.1308C231.251 19.6165 230.831 19.3593 230.395 19.3593ZM236.811 19.3789C236.251 19.3789 235.795 19.7076 235.444 20.3652C235.092 21.0162 234.917 21.8333 234.917 22.8164C234.917 23.7994 235.102 24.5807 235.473 25.1601C235.851 25.733 236.297 26.0195 236.811 26.0195C237.325 26.0195 237.768 25.694 238.139 25.0429C238.51 24.3919 238.696 23.6236 238.696 22.7382C238.696 21.7942 238.527 20.9999 238.188 20.3554C237.849 19.7044 237.39 19.3789 236.811 19.3789Z" fill="#1E1E25"/>
|
||||
<path d="M224.174 20.5605L221.469 21.0488C221.378 20.5084 221.17 20.1015 220.844 19.8281C220.525 19.5546 220.109 19.4179 219.594 19.4179C218.911 19.4179 218.364 19.6555 217.954 20.1308C217.55 20.5996 217.348 21.3873 217.348 22.4941C217.348 23.7246 217.553 24.5937 217.963 25.1015C218.38 25.6093 218.937 25.8632 219.633 25.8632C220.154 25.8632 220.581 25.7167 220.913 25.4238C221.245 25.1243 221.479 24.6132 221.616 23.8906L224.311 24.3496C224.031 25.5865 223.494 26.5208 222.7 27.1523C221.905 27.7838 220.841 28.0996 219.506 28.0996C217.989 28.0996 216.778 27.621 215.874 26.664C214.975 25.707 214.526 24.3821 214.526 22.6894C214.526 20.9772 214.978 19.6458 215.883 18.6953C216.788 17.7382 218.012 17.2597 219.555 17.2597C220.818 17.2597 221.821 17.5332 222.563 18.08C223.312 18.6204 223.849 19.4472 224.174 20.5605Z" fill="#1E1E25"/>
|
||||
<path d="M202.543 22.5332C202.543 21.6217 202.768 20.7395 203.217 19.8867C203.667 19.0338 204.301 18.3828 205.122 17.9335C205.948 17.4843 206.87 17.2597 207.885 17.2597C209.454 17.2597 210.74 17.7708 211.743 18.7929C212.745 19.8085 213.247 21.0943 213.247 22.6503C213.247 24.2193 212.739 25.5214 211.723 26.5566C210.714 27.5852 209.441 28.0996 207.905 28.0996C206.954 28.0996 206.046 27.8847 205.18 27.455C204.321 27.0253 203.667 26.3971 203.217 25.5703C202.768 24.7369 202.543 23.7246 202.543 22.5332ZM205.356 22.6796C205.356 23.7083 205.6 24.496 206.088 25.0429C206.577 25.5898 207.179 25.8632 207.895 25.8632C208.611 25.8632 209.21 25.5898 209.692 25.0429C210.18 24.496 210.424 23.7018 210.424 22.6601C210.424 21.6445 210.18 20.8632 209.692 20.3164C209.21 19.7695 208.611 19.496 207.895 19.496C207.179 19.496 206.577 19.7695 206.088 20.3164C205.6 20.8632 205.356 21.651 205.356 22.6796Z" fill="#1E1E25"/>
|
||||
<path d="M192.417 17.4941H201.801V19.7109H198.481V27.8652H195.737V19.7109H192.417V17.4941Z" fill="#1E1E25"/>
|
||||
<path d="M184.565 20.6582L182.075 20.2089C182.355 19.2063 182.836 18.4641 183.52 17.9824C184.204 17.5006 185.219 17.2597 186.567 17.2597C187.791 17.2597 188.702 17.4062 189.301 17.6992C189.9 17.9856 190.32 18.3535 190.561 18.8027C190.808 19.2454 190.932 20.0624 190.932 21.2539L190.903 24.457C190.903 25.3684 190.945 26.0423 191.03 26.4785C191.121 26.9082 191.287 27.3704 191.528 27.8652H188.813C188.741 27.6829 188.653 27.4127 188.549 27.0546C188.504 26.8919 188.471 26.7845 188.452 26.7324C187.983 27.1881 187.482 27.5299 186.948 27.7578C186.414 27.9856 185.844 28.0996 185.239 28.0996C184.171 28.0996 183.328 27.8098 182.709 27.2304C182.097 26.651 181.792 25.9186 181.792 25.0332C181.792 24.4472 181.931 23.9264 182.211 23.4707C182.491 23.0084 182.882 22.6568 183.383 22.416C183.891 22.1686 184.62 21.9537 185.571 21.7714C186.853 21.5305 187.742 21.3059 188.237 21.0976V20.8242C188.237 20.2968 188.107 19.9225 187.846 19.7011C187.586 19.4733 187.094 19.3593 186.372 19.3593C185.883 19.3593 185.502 19.457 185.229 19.6523C184.956 19.8411 184.734 20.1764 184.565 20.6582ZM188.237 22.8847C187.885 23.0019 187.329 23.1419 186.567 23.3046C185.805 23.4674 185.307 23.6269 185.073 23.7832C184.715 24.0371 184.536 24.3593 184.536 24.7499C184.536 25.1341 184.679 25.4661 184.965 25.746C185.252 26.026 185.616 26.166 186.059 26.166C186.554 26.166 187.026 26.0032 187.475 25.6777C187.807 25.4303 188.025 25.1276 188.129 24.7695C188.201 24.5351 188.237 24.0891 188.237 23.4316V22.8847Z" fill="#1E1E25"/>
|
||||
<path d="M170.209 17.4941H172.768V19.0175C173.1 18.4967 173.549 18.0735 174.116 17.748C174.682 17.4225 175.31 17.2597 176 17.2597C177.205 17.2597 178.227 17.7317 179.067 18.6757C179.907 19.6197 180.327 20.9348 180.327 22.621C180.327 24.3528 179.903 25.7005 179.057 26.664C178.211 27.621 177.185 28.0996 175.981 28.0996C175.408 28.0996 174.887 27.9856 174.418 27.7578C173.956 27.5299 173.468 27.1393 172.954 26.5859V31.8105H170.209V17.4941ZM172.924 22.5039C172.924 23.6692 173.155 24.5318 173.618 25.0917C174.08 25.6451 174.643 25.9218 175.307 25.9218C175.945 25.9218 176.476 25.6679 176.899 25.1601C177.322 24.6458 177.534 23.8059 177.534 22.6406C177.534 21.5533 177.316 20.746 176.879 20.2187C176.443 19.6914 175.903 19.4277 175.258 19.4277C174.588 19.4277 174.031 19.6881 173.588 20.2089C173.146 20.7233 172.924 21.4882 172.924 22.5039Z" fill="#1E1E25"/>
|
||||
<path d="M159.252 17.4941H168.637V19.7109H165.317V27.8652H162.573V19.7109H159.252V17.4941Z" fill="#1E1E25"/>
|
||||
<path d="M155.209 22.6015L158.012 23.4902C157.582 25.0527 156.866 26.2148 155.864 26.9765C154.868 27.7317 153.601 28.1093 152.065 28.1093C150.164 28.1093 148.601 27.4615 147.377 26.166C146.153 24.8639 145.541 23.0865 145.541 20.8339C145.541 18.4511 146.157 16.6022 147.387 15.2871C148.618 13.9654 150.235 13.3046 152.241 13.3046C153.992 13.3046 155.414 13.8222 156.508 14.8574C157.159 15.4693 157.648 16.3483 157.973 17.4941L155.112 18.1777C154.942 17.4355 154.588 16.8496 154.047 16.4199C153.513 15.9902 152.862 15.7753 152.094 15.7753C151.033 15.7753 150.17 16.1562 149.506 16.9179C148.849 17.6796 148.52 18.9134 148.52 20.6191C148.52 22.429 148.845 23.718 149.497 24.4863C150.148 25.2545 150.994 25.6386 152.036 25.6386C152.804 25.6386 153.465 25.3945 154.018 24.9062C154.571 24.4179 154.969 23.6497 155.209 22.6015Z" fill="#1E1E25"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 421 B |
|
Before Width: | Height: | Size: 776 B |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |