feat: polish

This commit is contained in:
Anatoly Antonov 2026-04-22 01:27:38 +09:00
parent 2e6177fe74
commit 4bd927bb4e
137 changed files with 6357 additions and 137560 deletions

View file

@ -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",
});
}

123
src/lib/api/client.ts Normal file
View file

@ -0,0 +1,123 @@
import Cookies from 'js-cookie';
/**
* Thin wrapper around fetch that:
* - prepends the configured API base URL
* - includes Django session cookies
* - attaches the CSRF token from cookies
* - parses structured DRF errors into a single ApiError
*
* The 401 handler is pluggable (see `setUnauthorizedHandler`) so the auth
* layer can react (clear session, route to /login) without this module
* importing from $auth (which would create a cycle).
*
* Default base URL is the relative path `/api` so the app works out of the
* box against either the Vite dev proxy, the mock plugin, or a same-origin
* production deployment. Set VITE_API_BASE_URL to point directly at a
* cross-origin backend if needed.
*/
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api';
export class ApiError extends Error {
constructor(
public status: number,
message: string,
public details?: unknown,
) {
super(message);
this.name = 'ApiError';
}
}
type UnauthorizedHandler = () => void;
let onUnauthorized: UnauthorizedHandler | null = null;
export function setUnauthorizedHandler(fn: UnauthorizedHandler | null) {
onUnauthorized = fn;
}
async function ensureCsrf(): Promise<string> {
const existing = Cookies.get('csrftoken');
if (existing) return existing;
await fetch(`${API_BASE_URL}/csrf/`, { method: 'GET', credentials: 'include' });
return Cookies.get('csrftoken') ?? '';
}
function extractError(body: unknown, fallback: string): string {
if (body && typeof body === 'object') {
const b = body as Record<string, unknown>;
if (typeof b.detail === 'string') return b.detail;
if (b.non_field_errors && Array.isArray(b.non_field_errors)) {
return b.non_field_errors.join(', ');
}
if (b.field_errors && typeof b.field_errors === 'object') {
return Object.values(b.field_errors as Record<string, unknown>)
.flat()
.join(', ');
}
}
return fallback;
}
export interface RequestOptions extends Omit<RequestInit, 'body'> {
body?: unknown;
query?: Record<string, string | number | boolean | undefined>;
}
function buildUrl(path: string, query?: RequestOptions['query']): string {
let url = `${API_BASE_URL}${path}`;
if (!query) return url;
const params = new URLSearchParams();
for (const [k, v] of Object.entries(query)) {
if (v !== undefined) params.set(k, String(v));
}
const qs = params.toString();
if (qs) url += (url.includes('?') ? '&' : '?') + qs;
return url;
}
export async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const csrf = await ensureCsrf();
const init: RequestInit = {
...options,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrf,
...options.headers,
},
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
};
const res = await fetch(buildUrl(path, options.query), init);
if (res.status === 401) onUnauthorized?.();
if (!res.ok) {
let body: unknown = null;
try {
body = await res.json();
} catch {
// ignore
}
throw new ApiError(res.status, extractError(body, res.statusText), body);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
export const api = {
get: <T>(path: string, options?: RequestOptions) =>
request<T>(path, { ...options, method: 'GET' }),
post: <T>(path: string, body?: unknown, options?: RequestOptions) =>
request<T>(path, { ...options, method: 'POST', body }),
put: <T>(path: string, body?: unknown, options?: RequestOptions) =>
request<T>(path, { ...options, method: 'PUT', body }),
patch: <T>(path: string, body?: unknown, options?: RequestOptions) =>
request<T>(path, { ...options, method: 'PATCH', body }),
delete: <T>(path: string, options?: RequestOptions) =>
request<T>(path, { ...options, method: 'DELETE' }),
};

5
src/lib/api/index.ts Normal file
View file

@ -0,0 +1,5 @@
export { api, ApiError, API_BASE_URL, setUnauthorizedHandler } from './client';
export { pointsApi } from './points';
export { profilesApi } from './profiles';
export { scenariosApi } from './scenarios';
export { predictionsApi, getLatestDataset, buildLaunchDateTime } from './predictions';

View file

@ -1,19 +1,11 @@
/* API functions for Saved Points */
import type { SavedPoint } from "$lib/types";
import { getAPI, postAPI, putAPI, deleteAPI } from "./base";
import { api } from './client';
import type { SavedPoint } from '$domain';
export function getSavedPoints(): Promise<SavedPoint[]> {
return getAPI<SavedPoint[]>("/saved-points/");
}
const base = '/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}/`);
}
export const pointsApi = {
list: () => api.get<SavedPoint[]>(base),
create: (p: SavedPoint) => api.post<SavedPoint>(base, p),
update: (p: SavedPoint) => api.put<SavedPoint>(`${base}${p.id}/`, p),
delete: (id: number) => api.delete<void>(`${base}${id}/`),
};

View file

@ -0,0 +1,34 @@
import { api } from './client';
import type { FlightParameters, RawPrediction } from '$domain';
/**
* GFS datasets are published every 6 hours with a ~6 hour processing lag.
* Round down to the most recent available slot.
*/
export function getLatestDataset(now: Date = new Date()): string {
const rounded = new Date(now);
rounded.setUTCHours(Math.floor(rounded.getUTCHours() / 6) * 6, 0, 0, 0);
rounded.setUTCHours(rounded.getUTCHours() - 6);
return rounded.toISOString();
}
export function buildLaunchDateTime(date: string, time: string): string {
const fullTime = time.split(':').length === 2 ? `${time}:00` : time;
return new Date(`${date}T${fullTime}Z`).toISOString();
}
export interface PredictionResponse {
result: RawPrediction;
}
export const predictionsApi = {
run: (params: FlightParameters, launchDateTime: string) => {
const payload: FlightParameters & { launch_datetime: string } = {
...params,
dataset: params.dataset || getLatestDataset(),
launch_datetime: launchDateTime,
};
if (payload.start_point === -1) delete payload.start_point;
return api.post<PredictionResponse>('/predictions/', payload);
},
};

View file

@ -1,19 +1,11 @@
/* API functions for SavedFlightProfile */
import type {SavedFlightProfile } from "$lib/types";
import { getAPI, postAPI, putAPI, deleteAPI } from "./base";
import { api } from './client';
import type { SavedFlightProfile } from '$domain';
export function getSavedFlightProfiles(): Promise<SavedFlightProfile[]> {
return getAPI<SavedFlightProfile[]>("/saved-profiles/");
}
const base = '/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}/`);
}
export const profilesApi = {
list: () => api.get<SavedFlightProfile[]>(base),
create: (p: SavedFlightProfile) => api.post<SavedFlightProfile>(base, p),
update: (p: SavedFlightProfile) => api.put<SavedFlightProfile>(`${base}${p.id}/`, p),
delete: (id: number) => api.delete<void>(`${base}${id}/`),
};

View file

@ -1,19 +1,11 @@
/* API functions for SavedScenario */
import type { SavedScenario } from "$lib/types";
import { getAPI, postAPI, putAPI, deleteAPI } from "./base";
import { api } from './client';
import type { SavedScenario } from '$domain';
export function getSavedScenarios(): Promise<SavedScenario[]> {
return getAPI<SavedScenario[]>("/saved-templates/");
}
const base = '/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}/`);
}
export const scenariosApi = {
list: () => api.get<SavedScenario[]>(base),
create: (s: SavedScenario) => api.post<SavedScenario>(base, s),
update: (s: SavedScenario) => api.put<SavedScenario>(`${base}${s.id}/`, s),
delete: (id: number) => api.delete<void>(`${base}${id}/`),
};

View file

@ -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);
}
}

17
src/lib/auth/api.ts Normal file
View file

@ -0,0 +1,17 @@
import { api } from '$api';
export interface SessionInfo {
isAuthenticated: boolean;
}
export interface WhoAmI {
username: string;
}
export const authApi = {
session: () => api.get<SessionInfo>('/session/'),
whoami: () => api.get<WhoAmI>('/whoami/'),
login: (username: string, password: string) =>
api.post<{ detail?: string }>('/login/', { username, password }),
logout: () => api.post<void>('/logout/', {}),
};

32
src/lib/auth/guard.ts Normal file
View file

@ -0,0 +1,32 @@
import { goto } from '$app/navigation';
import { get } from 'svelte/store';
import { authStore } from './store';
/**
* Await a non-`unknown` auth status, refreshing from the server if needed.
* Use inside a `+page.svelte` onMount not inside load functions, since auth
* depends on browser cookies and we run SPA-only.
*/
export async function requireAuthenticated(redirectTo = '/login'): Promise<boolean> {
let state = authStore.snapshot();
if (state.status === 'unknown') state = await authStore.refresh();
if (state.status !== 'authenticated') {
await goto(redirectTo);
return false;
}
return true;
}
export async function requireAnonymous(redirectTo = '/'): Promise<boolean> {
let state = authStore.snapshot();
if (state.status === 'unknown') state = await authStore.refresh();
if (state.status === 'authenticated') {
await goto(redirectTo);
return false;
}
return true;
}
export function currentUser(): string | null {
return get(authStore).username;
}

3
src/lib/auth/index.ts Normal file
View file

@ -0,0 +1,3 @@
export { authStore, type AuthState, type AuthStatus } from './store';
export { authApi } from './api';
export { requireAuthenticated, requireAnonymous, currentUser } from './guard';

79
src/lib/auth/store.ts Normal file
View file

@ -0,0 +1,79 @@
import { writable, get } from 'svelte/store';
import { goto } from '$app/navigation';
import { setUnauthorizedHandler } from '$api';
import { authApi } from './api';
/**
* Authentication state is intentionally held here rather than in a plain
* writable so the guard and the UI share a single refresh path.
*
* `status` transitions: `unknown` `anonymous` | `authenticated`.
* Tests or code that assumes a route is available should wait for a terminal
* status (anything != `unknown`) before deciding what to do.
*/
export type AuthStatus = 'unknown' | 'anonymous' | 'authenticated';
export interface AuthState {
status: AuthStatus;
username: string | null;
}
const initial: AuthState = { status: 'unknown', username: null };
function createAuthStore() {
const { subscribe, set, update } = writable<AuthState>(initial);
async function refresh(): Promise<AuthState> {
try {
const session = await authApi.session();
if (!session.isAuthenticated) {
const next: AuthState = { status: 'anonymous', username: null };
set(next);
return next;
}
const me = await authApi.whoami();
const next: AuthState = { status: 'authenticated', username: me.username };
set(next);
return next;
} catch {
const next: AuthState = { status: 'anonymous', username: null };
set(next);
return next;
}
}
async function login(username: string, password: string) {
await authApi.login(username, password);
await refresh();
}
async function logout() {
try {
await authApi.logout();
} finally {
set({ status: 'anonymous', username: null });
}
}
function snapshot(): AuthState {
return get({ subscribe });
}
return { subscribe, refresh, login, logout, snapshot };
}
export const authStore = createAuthStore();
// Wire API 401s back through the auth store so expired sessions redirect once.
let redirecting = false;
setUnauthorizedHandler(() => {
authStore.refresh();
if (redirecting) return;
if (typeof window === 'undefined') return;
if (window.location.pathname === '/login') return;
redirecting = true;
goto('/login').finally(() => {
redirecting = false;
});
});

View file

@ -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>

View file

@ -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
}
}} />

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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">енарий:</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} />

View file

@ -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>

View file

@ -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>

View file

@ -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> -->

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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}
>
&#10005;
</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>

View file

@ -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>

33
src/lib/domain/geo.ts Normal file
View file

@ -0,0 +1,33 @@
/**
* Geographic primitives used by map layers and predictions.
*
* LngLat convention matches MapLibre (longitude first) for on-map work;
* LatLng is preserved for API payloads and legacy Leaflet-era code paths.
*/
export interface LatLng {
lat: number;
lng: number;
alt?: number;
}
export type LatLngTuple = [lat: number, lng: number] | [lat: number, lng: number, alt: number];
export type LatLngExpression = LatLng | LatLngTuple;
export type LngLatTuple = [lng: number, lat: number];
export function toLngLat(p: LatLngExpression): LngLatTuple {
if (Array.isArray(p)) return [p[1], p[0]];
return [p.lng, p.lat];
}
export function toLatLng(p: LatLngExpression): LatLng {
if (Array.isArray(p)) return { lat: p[0], lng: p[1], alt: p[2] };
return p;
}
export function normalizeLng(lng: number): number {
// API occasionally returns longitudes in the 0..360 range.
return lng > 180 ? lng - 360 : lng;
}

5
src/lib/domain/index.ts Normal file
View file

@ -0,0 +1,5 @@
export * from './geo';
export * from './math';
export * from './scenario';
export * from './prediction';
export * from './telemetry';

34
src/lib/domain/math.ts Normal file
View file

@ -0,0 +1,34 @@
import type { LatLng } from './geo';
const EARTH_RADIUS_KM = 6371;
const toRad = (deg: number): number => (deg * Math.PI) / 180;
const toDeg = (rad: number): number => (rad * 180) / Math.PI;
export function distHaversine(p1: LatLng, p2: LatLng, precision?: number): number {
const dLat = toRad(p2.lat - p1.lat);
const dLng = toRad(p2.lng - p1.lng);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(p1.lat)) * Math.cos(toRad(p2.lat)) * Math.sin(dLng / 2) ** 2;
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const d = EARTH_RADIUS_KM * c;
return precision !== undefined ? parseFloat(d.toFixed(precision)) : d;
}
export function bearingHaversine(p1: LatLng, p2: LatLng): number {
const dLng = toRad(p2.lng - p1.lng);
const y = Math.sin(dLng) * Math.cos(toRad(p2.lat));
const x =
Math.cos(toRad(p1.lat)) * Math.sin(toRad(p2.lat)) -
Math.sin(toRad(p1.lat)) * Math.cos(toRad(p2.lat)) * Math.cos(dLng);
return toDeg(Math.atan2(y, x));
}
export function toFixedNumber(num: number, digits: number): number {
const pow = 10 ** digits;
return Math.round(num * pow) / pow;
}

View file

@ -0,0 +1,73 @@
import type { LatLngTuple, LatLng } from './geo';
import { normalizeLng } from './geo';
export interface Point {
latlng: LatLng;
datetime: Date;
}
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: LatLngTuple[];
launch: Point;
burst: Point;
landing: Point;
profile: string;
flight_time: number;
}
function pointFromTrajectory(tp: TrajectoryPoint): Point {
return {
latlng: { lat: tp.latitude, lng: normalizeLng(tp.longitude), alt: tp.altitude },
datetime: new Date(tp.datetime),
};
}
/**
* Fold a raw prediction response (stages trajectory) into a flat Prediction
* with derived launch/burst/landing markers and a single flight_path array.
*/
export function parsePrediction(stages: PredictionStage[]): Prediction {
if (stages.length < 2) {
throw new Error('Prediction requires at least ascent and descent stages');
}
const ascent = stages[0].trajectory;
const descent = stages[1].trajectory;
const flight_path: LatLngTuple[] = [...ascent, ...descent].map((p) => [
p.latitude,
normalizeLng(p.longitude),
p.altitude,
]);
const launch = pointFromTrajectory(ascent[0]);
const burst = pointFromTrajectory(descent[0]);
const landing = pointFromTrajectory(descent[descent.length - 1]);
const profile = stages[1].stage === 'descent' ? 'standard_profile' : 'float_profile';
const flight_time = (landing.datetime.getTime() - launch.datetime.getTime()) / 1000;
return { flight_path, launch, burst, landing, profile, flight_time };
}

View file

@ -0,0 +1,92 @@
/**
* Scenario-related domain types.
*
* A scenario is a named prediction configuration a user saves and reapplies.
* Scenarios are composed of "flight parameters" plus dataset/model metadata.
*
* Identifiers (`standard_profile` etc.) are the canonical form. Localized
* labels live in `$i18n/locales` do not use the `PROFILE_NAMES` here for
* UI text; look up the i18n key `domain.profile.<identifier>`.
*/
export const PROFILE_IDENTIFIERS = [
'standard_profile',
'float_profile',
'reverse_profile',
'custom_profile',
] as const;
export type ProfileIdentifier = (typeof PROFILE_IDENTIFIERS)[number];
export const PREDICTION_MODES = ['single', 'hourly', 'ensemble'] as const;
export type PredictionMode = (typeof PREDICTION_MODES)[number];
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: ProfileIdentifier;
version: number;
start_point?: number;
rate_profile?: number;
template?: number;
}
export interface SavedPoint {
id: number;
name: string;
lat: number;
lon: number;
alt: number;
}
export interface RateCurvePoint {
order: number;
time_constraint: number;
alt_constraint: number;
rate: number;
}
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;
}
export const DEFAULT_FLIGHT_PARAMETERS: 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 DEFAULT_SCENARIO: SavedScenario = {
id: -1,
name: 'Новый сценарий',
description: '',
prediction_mode: 'single',
model: '',
dataset: '',
flight_parameters: DEFAULT_FLIGHT_PARAMETERS,
};

View file

@ -0,0 +1,41 @@
import type { LatLngTuple } from './geo';
import type { Point } from './prediction';
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: LatLngTuple[];
launch: Point;
datapoints: TelemetryPoint[];
}
export function parseTelemetry(points: TelemetryPoint[]): Telemetry {
if (points.length === 0) {
throw new Error('Telemetry requires at least one datapoint');
}
const flight_path: LatLngTuple[] = points.map((p) => [p.latitude, p.longitude, p.altitude]);
const launch: Point = {
latlng: { lat: points[0].latitude, lng: points[0].longitude, alt: points[0].altitude },
datetime: new Date(points[0].datetime),
};
return { flight_path, launch, datapoints: points };
}

View file

@ -0,0 +1,80 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore } from '$auth';
import { t } from '$i18n';
let username = $state('');
let password = $state('');
let error = $state('');
let isLoading = $state(false);
async function handleLogin(e: Event) {
e.preventDefault();
if (!username || !password) {
error = $t('login.fieldsRequired');
return;
}
isLoading = true;
error = '';
try {
await authStore.login(username, password);
goto('/');
} catch (err: unknown) {
error = (err as Error).message || $t('login.invalidCredentials');
} finally {
isLoading = false;
}
}
</script>
<main class="container pt-3">
<div class="text-center mt-5 mb-4">
<img src="/logo-lg.svg" alt="Logo" width="300" class="rounded-3" />
<h2 class="text-center mt-4 mb-5">{$t('app.title')}</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">{$t('login.heading')}</h5>
{#if error}
<div class="alert alert-danger mb-4" role="alert">{error}</div>
{/if}
<form onsubmit={handleLogin} class="mt-4">
<div class="form-floating mb-3">
<input
type="text"
class="form-control"
id="username"
placeholder={$t('login.username')}
bind:value={username}
required />
<label for="username">{$t('login.username')}</label>
</div>
<div class="form-floating mb-3">
<input
type="password"
class="form-control"
id="password"
placeholder={$t('login.password')}
bind:value={password}
required />
<label for="password">{$t('login.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"></span>
{$t('login.submitting')}
{:else}
{$t('login.submit')}
{/if}
</button>
<a href="/" class="btn btn-secondary mt-3 w-100">{$t('login.back')}</a>
</form>
</div>
</div>
</div>
</div>
</main>

View file

@ -0,0 +1,80 @@
<script lang="ts">
import { page } from '$app/stores';
import {
Collapse,
Dropdown,
DropdownItem,
DropdownMenu,
DropdownToggle,
Nav,
NavItem,
NavLink,
Navbar,
NavbarBrand,
NavbarToggler,
} from '@sveltestrap/sveltestrap';
import { authStore } from '$auth';
import { goto } from '$app/navigation';
import { t } from '$i18n';
// Auth is already refreshed by the root layout before this mounts.
let isOpen = $state(false);
async function handleLogout() {
await authStore.logout();
goto('/');
}
</script>
<Navbar color="light" light expand="lg" fixed="top" class="custom-navbar border-bottom">
<NavbarBrand href="/" class="nav-full-height">
<img src="/logo.svg" alt="Logo" height="34" class="d-inline-block align-text-top" />
</NavbarBrand>
<NavbarToggler on:click={() => (isOpen = !isOpen)} />
<Collapse {isOpen} navbar expand="lg">
<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'}>
{$t('nav.predict')}
</NavLink>
</NavItem>
<NavItem>
<NavLink
href="/track"
class="nav-full-height border border-top-0"
active={$page.url.pathname === '/track'}>
{$t('nav.track')}
</NavLink>
</NavItem>
</Nav>
<Nav navbar>
{#if $authStore.status === 'authenticated' && $authStore.username}
<Dropdown nav inNavbar>
<DropdownToggle nav caret class="nav-full-height border border-top-0">
{$authStore.username}
</DropdownToggle>
<DropdownMenu end>
<DropdownItem href="/user/account">{$t('nav.account')}</DropdownItem>
<DropdownItem href="/user/templates">{$t('nav.scenarios')}</DropdownItem>
<DropdownItem href="/user/predictions">{$t('nav.predictionHistory')}</DropdownItem>
<DropdownItem href="/user/flights">{$t('nav.trackingHistory')}</DropdownItem>
<DropdownItem divider />
<DropdownItem on:click={handleLogout}>{$t('nav.logout')}</DropdownItem>
</DropdownMenu>
</Dropdown>
{:else if $authStore.status === 'anonymous'}
<NavItem>
<NavLink
href="/login"
class="nav-full-height border border-top-0"
active={$page.url.pathname === '/login'}>
{$t('nav.login')}
</NavLink>
</NavItem>
{/if}
</Nav>
</Collapse>
</Navbar>

View file

@ -0,0 +1,2 @@
export { default as Navbar } from './Navbar.svelte';
export { default as LoginForm } from './LoginForm.svelte';

View file

@ -0,0 +1,25 @@
<script lang="ts">
import { t } from '$i18n';
</script>
<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="img-fluid" alt={$t('app.company')} 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 {$t('app.company')}</div>
</div>
</div>
</div>
</footer>

View file

@ -0,0 +1 @@
export { default as Footer } from './Footer.svelte';

View file

@ -0,0 +1,342 @@
<script lang="ts">
/*
* Conventions (apply to every .svelte file under features/):
* - $state variables: camelCase, no prefix.
* - $derived: camelCase.
* - Component refs: camelCase + Ref.
* - Event handlers: handleXxx.
* - Prop callbacks: onXxx.
* - HTML IDs: kebab-case, prefixed with a component-specific short code
* (e.g. "cp-..." for ControlPanel) so IDs stay globally unique.
*/
import { onMount } from 'svelte';
import {
Button,
Dropdown,
DropdownItem,
DropdownMenu,
DropdownToggle,
FormGroup,
Icon,
Input,
InputGroup,
InputGroupText,
Label,
} from '@sveltestrap/sveltestrap';
import { CollapsibleCard, SelectSearchable, SpoilerGroup, addToast } from '$ui';
import { pointsApi } from '$api';
import {
DEFAULT_FLIGHT_PARAMETERS,
PROFILE_IDENTIFIERS,
toFixedNumber,
type FlightParameters,
type ProfileIdentifier,
type SavedPoint,
} from '$domain';
import { workspacesStore, getActiveWorkspace } from '$features/workspaces';
import { t } from '$i18n';
import { pointsStore } from './pointsStore';
import PointEditor from './PointEditor.svelte';
import CurveEditor from './CurveEditor.svelte';
interface Props {
onSelectOnMapClick?: () => void;
}
let { onSelectOnMapClick = () => {} }: Props = $props();
let pointEditorRef: PointEditor | null = $state(null);
let curveEditorRef: CurveEditor | null = $state(null);
let active = $derived(getActiveWorkspace($workspacesStore));
let params = $derived<FlightParameters>(active?.flightParameters ?? DEFAULT_FLIGHT_PARAMETERS);
let ascentProfile = $state('standard');
let descentProfile = $state('standard');
let selectedPointId = $derived(params.start_point ?? -1);
let currentPoint = $derived($pointsStore.find((p) => p.id === selectedPointId) ?? null);
let isPointDirty = $derived.by(() => {
if (!currentPoint) return false;
return (
params.launch_latitude.toFixed(6) !== currentPoint.lat.toFixed(6) ||
params.launch_longitude.toFixed(6) !== currentPoint.lon.toFixed(6) ||
params.launch_altitude.toFixed(2) !== currentPoint.alt.toFixed(2)
);
});
onMount(async () => {
if ($pointsStore.length === 0) {
try {
pointsStore.set(await pointsApi.list());
} catch (err: unknown) {
addToast({ header: $t('common.error'), body: (err as Error).message, color: 'danger' });
}
}
});
function patchActive(patch: Partial<FlightParameters>) {
if (!active) return;
workspacesStore.setFlightParameters(active.id, { ...active.flightParameters, ...patch });
}
function handlePointSelection(newPointId: number | null) {
if (!active) return;
if (newPointId == null || newPointId === -1) {
patchActive({ start_point: -1 });
return;
}
const point = $pointsStore.find((p) => p.id === newPointId);
if (!point) return;
patchActive({
start_point: point.id,
launch_latitude: point.lat,
launch_longitude: point.lon,
launch_altitude: point.alt,
});
}
async function handleSaveCurrentPoint() {
if (!currentPoint) {
pointEditorRef?.open(
{
id: 0,
name: `New Point ${new Date().toLocaleString()}`,
lat: params.launch_latitude,
lon: params.launch_longitude,
alt: params.launch_altitude,
},
false,
);
return;
}
try {
const saved = await pointsApi.update({
...currentPoint,
lat: params.launch_latitude,
lon: params.launch_longitude,
alt: params.launch_altitude,
});
pointsStore.update((list) => list.map((p) => (p.id === saved.id ? saved : p)));
addToast({ header: $t('common.success'), body: saved.name, color: 'success' });
} catch (err: unknown) {
addToast({ header: $t('common.error'), body: (err as Error).message, color: 'danger' });
}
}
async function handleRun() {
if (!active) return;
try {
await workspacesStore.run(active.id);
addToast({
header: $t('forecast.success'),
body: $t('forecast.successBody'),
color: 'success',
});
} catch (err: unknown) {
addToast({
header: $t('forecast.error'),
body: $t('forecast.errorBody', { error: (err as Error).message }),
color: 'danger',
});
}
}
export function updateLaunchPosition(lat: number, lng: number) {
patchActive({
launch_latitude: toFixedNumber(lat, 6),
launch_longitude: toFixedNumber(lng, 6),
});
}
</script>
<CollapsibleCard title={$t('conditions.title')}>
{#if !active}
<div class="text-muted small">{$t('workspaces.empty')}</div>
{:else}
<div class="d-flex gap-2">
<FormGroup class="flex-fill w-50" spacing="mb-2">
<Label for="cp-start-time" class="form-label">{$t('conditions.startTime')}</Label>
<Input
type="time"
id="cp-start-time"
class="form-control-sm"
step="1"
value={active.launchTime}
oninput={(e) =>
workspacesStore.patch(active!.id, {
launchTime: (e.currentTarget as HTMLInputElement).value,
})} />
</FormGroup>
<FormGroup class="flex-fill w-50" spacing="mb-2">
<Label for="cp-start-date" class="form-label">{$t('conditions.startDate')}</Label>
<Input
type="date"
id="cp-start-date"
class="form-control-sm"
value={active.launchDate}
oninput={(e) =>
workspacesStore.patch(active!.id, {
launchDate: (e.currentTarget as HTMLInputElement).value,
})} />
</FormGroup>
</div>
<FormGroup spacing="mb-2">
<Label for="cp-flight-profile" class="form-label">{$t('conditions.flightProfile')}</Label>
<InputGroup size="sm">
<Input
type="select"
id="cp-flight-profile"
value={params.profile}
onchange={(e) =>
patchActive({
profile: (e.currentTarget as HTMLSelectElement).value as ProfileIdentifier,
})}>
{#each PROFILE_IDENTIFIERS as id}
<option value={id}>{$t(`profile.${id}`)}</option>
{/each}
</Input>
</InputGroup>
</FormGroup>
<FormGroup spacing="mb-2">
<Label for="cp-start-point" class="form-label">{$t('conditions.startPoint')}</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={handlePointSelection}
options={$pointsStore.map((p) => ({
value: p.id,
label: `${p.name}${p.id === selectedPointId && isPointDirty ? ` (${$t('scenario.modified')})` : ''}`,
}))}
placeholder={$t('conditions.pointPlaceholder')}
searchPlaceholder={$t('conditions.pointSearchPlaceholder')}
clearable={true} />
<Button color="secondary" size="sm" onclick={() => pointEditorRef?.open(null, true)}>
<Icon name="journal-bookmark-fill" />
</Button>
</InputGroup>
</FormGroup>
<FormGroup spacing="mb-2">
<Label class="form-label">{$t('conditions.latLng')}</Label>
<InputGroup size="sm">
<Input
type="number"
step="0.000001"
value={params.launch_latitude}
oninput={(e) =>
patchActive({
launch_latitude: parseFloat((e.currentTarget as HTMLInputElement).value),
})} />
<InputGroupText>/</InputGroupText>
<Input
type="number"
step="0.000001"
value={params.launch_longitude}
oninput={(e) =>
patchActive({
launch_longitude: parseFloat((e.currentTarget as HTMLInputElement).value),
})} />
<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}
disabled={!isPointDirty && selectedPointId !== -1}>
{$t('conditions.save')}
<Icon name="floppy2-fill" class="ms-1" />
</Button>
</div>
<div class="d-flex gap-2">
<FormGroup class="flex-fill w-50" spacing="mb-2">
<Label class="form-label">{$t('conditions.launchAlt')}</Label>
<Input
type="number"
class="form-control-sm"
value={params.launch_altitude}
oninput={(e) =>
patchActive({
launch_altitude: parseFloat((e.currentTarget as HTMLInputElement).value),
})} />
</FormGroup>
<FormGroup class="flex-fill w-50" spacing="mb-2">
<Label class="form-label">{$t('conditions.burstAlt')}</Label>
<Input
type="number"
class="form-control-sm"
value={params.burst_altitude}
oninput={(e) =>
patchActive({
burst_altitude: parseFloat((e.currentTarget as HTMLInputElement).value),
})} />
</FormGroup>
</div>
{#if params.profile !== 'custom_profile'}
<div class="mb-2 d-flex gap-2">
<FormGroup class="flex-fill w-50" spacing="mb-2">
<Label class="form-label">{$t('conditions.ascentRate')}</Label>
<Input
type="number"
class="form-control-sm"
value={params.ascent_rate}
oninput={(e) =>
patchActive({
ascent_rate: parseFloat((e.currentTarget as HTMLInputElement).value),
})} />
</FormGroup>
<FormGroup class="flex-fill w-50" spacing="mb-2">
<Label class="form-label">{$t('conditions.descentRate')}</Label>
<Input
type="number"
class="form-control-sm"
value={params.descent_rate}
oninput={(e) =>
patchActive({
descent_rate: parseFloat((e.currentTarget as HTMLInputElement).value),
})} />
</FormGroup>
</div>
{:else}
<SpoilerGroup label={$t('conditions.profileEdit')} class="mb-2">
<Label class="form-label mb-0">{$t('conditions.ascentStage')}</Label>
<div class="d-flex gap-2 mb-0">
<Input type="radio" bind:group={ascentProfile} value="none" label={$t('conditions.stageNone')} />
<Input type="radio" bind:group={ascentProfile} value="standard" label={$t('conditions.stageStandard')} />
<Input type="radio" bind:group={ascentProfile} value="custom" label={$t('conditions.stageCustom')} />
</div>
<Label class="form-label mb-0">{$t('conditions.descentStage')}</Label>
<div class="d-flex gap-2 mb-0">
<Input type="radio" bind:group={descentProfile} value="none" label={$t('conditions.stageNone')} />
<Input type="radio" bind:group={descentProfile} value="standard" label={$t('conditions.stageStandard')} />
<Input type="radio" bind:group={descentProfile} value="custom" label={$t('conditions.stageCustom')} />
</div>
<Button size="sm" color="secondary" onclick={() => curveEditorRef?.openModal()} class="w-100">
{$t('conditions.openCurveEditor')}
<Icon name="graph-up-arrow" />
</Button>
</SpoilerGroup>
{/if}
<div class="d-flex">
<Button class="flex-fill" size="sm" color="primary" onclick={handleRun}>
{$t('conditions.run')}
</Button>
</div>
{/if}
</CollapsibleCard>
<CurveEditor bind:this={curveEditorRef} showTable={true} editor={false} />
<PointEditor
bind:this={pointEditorRef}
onSelectPoint={(p: SavedPoint | null) => handlePointSelection(p?.id ?? -1)} />

View file

@ -0,0 +1,144 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Chart as ChartJS, type TooltipItem } from 'chart.js/auto';
import 'chartjs-adapter-luxon';
import chartjsPluginDragdata from 'chartjs-plugin-dragdata';
import { DateTime } from 'luxon';
import type { RateCurvePoint, SavedFlightProfile } from '$domain';
ChartJS.register(chartjsPluginDragdata);
interface Props {
curve: SavedFlightProfile;
onUpdate: (points: RateCurvePoint[]) => void;
}
let { curve, onUpdate }: Props = $props();
let canvasEl: HTMLCanvasElement;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let chart: any = $state(null);
const chartData = $derived(calculateChartData(curve.rate_profile_data));
/**
* Fold relative constraints into absolute (time, altitude) segments.
* Each point declares either a time constraint, an altitude constraint,
* or both (minimum of the two wins).
*/
function calculateChartData(points: RateCurvePoint[]) {
const data: { x: number; y: number }[] = [{ x: 0, y: 0 }];
let currentTime = 0;
let currentAltitude = 0;
for (const point of points) {
const { time_constraint, alt_constraint, rate } = point;
let resolved = 0;
if (time_constraint !== -1) {
if (alt_constraint !== -1) {
const timeForAlt = rate !== 0 ? (alt_constraint - currentAltitude) / rate : 0;
resolved = Math.min(time_constraint, timeForAlt);
} else {
resolved = time_constraint;
}
} else if (alt_constraint !== -1) {
resolved = rate !== 0 ? (alt_constraint - currentAltitude) / rate : 0;
}
if (resolved < 0) resolved = 0;
currentTime += resolved;
currentAltitude += resolved * 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: unknown,
_datasetIndex: number,
index: number,
value: { x: number; y: number },
) {
if (index === 0) {
updateChart();
return;
}
const prevX = chartData[index - 1].x;
const nextX = chartData[index + 1]?.x ?? Infinity;
if (value.x <= prevX || value.x >= nextX) {
updateChart();
return;
}
const newPoints: RateCurvePoint[] = JSON.parse(JSON.stringify(curve.rate_profile_data));
const point = newPoints[index - 1];
const prev = chartData[index - 1];
const newDuration = value.x - prev.x;
const newAltDiff = value.y - prev.y;
if (point.alt_constraint !== -1) point.alt_constraint = Math.round(value.y);
if (point.time_constraint !== -1) point.time_constraint = Math.round(newDuration);
point.rate = newDuration > 0 ? parseFloat((newAltDiff / newDuration).toFixed(2)) : 0;
onUpdate(newPoints);
}
onMount(() => {
const ctx = canvasEl.getContext('2d');
if (!ctx) return;
chart = new ChartJS(ctx, {
type: 'line',
data: {
datasets: [
{
label: 'Altitude profile',
data: chartData,
borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.5)',
fill: false,
pointRadius: 5,
pointHoverRadius: 7,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: { type: 'linear', position: 'bottom', title: { display: true, text: 't, sec' } },
y: { title: { display: true, text: 'altitude, m' } },
},
plugins: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
dragData: { round: 0, onDragEnd: handleDragEnd as any, dragX: true },
tooltip: {
callbacks: {
label: (c: TooltipItem<'line'>) => {
const t = DateTime.fromSeconds(c.parsed.x ?? 0).toFormat('HH:mm:ss');
return `${c.parsed.y?.toFixed(0)} m @ ${t}`;
},
},
},
},
},
});
});
$effect(() => {
if (chart) updateChart();
});
onDestroy(() => chart?.destroy());
</script>
<div style="position: relative; height: 100%; min-height: 250px;">
<canvas bind:this={canvasEl}></canvas>
</div>

View file

@ -0,0 +1,338 @@
<script lang="ts">
import {
Modal,
Button,
Label,
Input,
Alert,
Icon,
Pagination,
PaginationItem,
PaginationLink,
InputGroup,
Table,
} from '@sveltestrap/sveltestrap';
import { TableHandler } from '@vincjo/datatables';
import { onMount } from 'svelte';
import { EditableCell, addToast, ConfirmationPrompt } from '$ui';
import { profilesApi } from '$api';
import type { RateCurvePoint, SavedFlightProfile } from '$domain';
import { profilesStore } from './pointsStore';
import CurveChart from './CurveChart.svelte';
interface Props {
isOpen?: boolean;
onClose?: () => void;
onSave?: (p: SavedFlightProfile) => void;
onSelectCurve?: (p: SavedFlightProfile) => void;
showTable?: boolean;
curve?: SavedFlightProfile | null;
editor?: boolean;
closeOnSave?: boolean;
closeOnDelete?: boolean;
}
let {
isOpen = $bindable(false),
onClose = () => {},
onSave = (_: SavedFlightProfile) => {},
onSelectCurve = (_: SavedFlightProfile) => {},
showTable = $bindable(false),
curve = null,
editor = false,
closeOnSave = false,
closeOnDelete = false,
}: Props = $props();
let selectedCurve = $state<SavedFlightProfile | null>(curve);
let draft = $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 alertText = $state('');
let isConfirmationVisible = $state(false);
let table = $derived(new TableHandler($profilesStore, { rowsPerPage: 5 }));
let search = $derived(table.createSearch(['name']));
$effect(() => {
if (editor && curve) {
selectedCurve = curve;
draft = { ...curve, rate_profile_data: [...curve.rate_profile_data] };
isEditing = true;
}
});
function sortByOrder() {
draft.rate_profile_data = [...draft.rate_profile_data].sort((a, b) => a.order - b.order);
}
onMount(async () => {
if (showTable && $profilesStore.length === 0) {
try {
profilesStore.set(await profilesApi.list());
} catch {
// ignore; saved profiles endpoint may not be active in dev
}
}
});
export function openModal(withTable = false) {
showTable = withTable;
isOpen = true;
}
function close() {
isOpen = false;
onClose();
}
function handleEdit(c: SavedFlightProfile) {
selectedCurve = c;
draft = { ...c, rate_profile_data: [...c.rate_profile_data] };
isEditing = true;
showTable = false;
}
function confirmDelete(c: SavedFlightProfile) {
selectedCurve = c;
isConfirmationVisible = true;
}
async function handleDelete() {
if (!selectedCurve) return;
try {
await profilesApi.delete(selectedCurve.id);
profilesStore.update((items) => items.filter((p) => p.id !== selectedCurve!.id));
if (closeOnDelete) close();
} catch (err: unknown) {
alertText = (err as Error).message;
}
}
async function handleSave() {
try {
const saved =
draft.id && draft.id > 0 ? await profilesApi.update(draft) : await profilesApi.create(draft);
profilesStore.update((items) => {
const exists = items.some((p) => p.id === saved.id);
return exists ? items.map((p) => (p.id === saved.id ? saved : p)) : [...items, saved];
});
addToast({ header: 'Curve saved', body: saved.name, color: 'success' });
if (closeOnSave) close();
onSave(saved);
} catch (err: unknown) {
alertText = (err as Error).message;
}
}
function validatePoint(point: RateCurvePoint): boolean {
if (point.time_constraint <= 0 && point.time_constraint !== -1) {
alertText = 'Time constraint invalid';
return false;
}
if (point.alt_constraint < 0 && point.alt_constraint !== -1) {
alertText = 'Altitude constraint invalid';
return false;
}
if (point.alt_constraint === -1 && point.time_constraint === -1) {
alertText = 'At least one constraint required';
return false;
}
return true;
}
function addPoint() {
if (!validatePoint(newPoint)) return;
const maxOrder = draft.rate_profile_data.reduce((m, p) => Math.max(m, p.order), -1);
draft.rate_profile_data = [...draft.rate_profile_data, { ...newPoint, order: maxOrder + 1 }];
newPoint = { order: 0, time_constraint: 0, alt_constraint: 0, rate: 0 };
alertText = '';
}
function removePoint(index: number) {
draft.rate_profile_data.splice(index, 1);
draft.rate_profile_data.forEach((p, i) => (p.order = i));
draft.rate_profile_data = [...draft.rate_profile_data];
}
function movePoint(index: number, direction: number) {
const target = index + direction;
if (target < 0 || target >= draft.rate_profile_data.length) return;
const t = draft.rate_profile_data[index].order;
draft.rate_profile_data[index].order = draft.rate_profile_data[target].order;
draft.rate_profile_data[target].order = t;
sortByOrder();
}
</script>
<Modal
{isOpen}
toggle={close}
size="xl"
fade={false}
scrollable
class={isConfirmationVisible ? 'modal-tinted' : ''}>
<div class="modal-header">
<h5 class="modal-title">{showTable ? 'Curves' : isEditing ? 'Edit Curve' : 'New Curve'}</h5>
<Button close onclick={close} />
</div>
<div class="modal-body">
{#if showTable}
<InputGroup class="mb-2">
<Input
type="text"
placeholder="Search..."
bind:value={search.value}
oninput={() => search.set()} />
<Button
onclick={() => {
search.value = '';
search.set();
}}>
<Icon name="x" />
</Button>
</InputGroup>
<div bind:this={table.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 table.rows as c (c.id)}
<tr>
<td>{c.name}</td>
<td>
<Button size="sm" color="primary" onclick={() => onSelectCurve(c)}>
<Icon name="check-lg" />
</Button>
<Button size="sm" color="secondary" onclick={() => handleEdit(c)} class="ms-1">
<Icon name="pencil" />
</Button>
<Button size="sm" color="danger" onclick={() => confirmDelete(c)} class="ms-1">
<Icon name="trash" />
</Button>
</td>
</tr>
{/each}
</tbody>
</Table>
</div>
<Pagination 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>
{:else}
<div class="row">
<div class="col-lg-6">
<div class="mb-2">
<Label class="small">Curve name</Label>
<Input class="form-control-sm" type="text" bind:value={draft.name} required />
</div>
<h6>Points</h6>
<Alert color="danger" isOpen={!!alertText} toggle={() => (alertText = '')} fade={false} class="mb-2">
<Icon name="exclamation-triangle" class="me-2" />
{alertText}
</Alert>
<div class="table-responsive small" style="max-height: 300px;">
<table class="table table-sm border mb-0">
<thead>
<tr>
<th></th>
<th>t, sec</th>
<th>alt, m</th>
<th>rate, m/s</th>
<th></th>
</tr>
</thead>
<tbody>
{#each draft.rate_profile_data as point, i (point.order)}
{@const isFirst = i === 0}
{@const isLast = i === draft.rate_profile_data.length - 1}
<tr>
<td>
<Button
size="sm"
class="p-0 border-0 bg-transparent"
onclick={() => movePoint(i, -1)}
disabled={isFirst}>
<Icon name="chevron-up" />
</Button>
<Button
size="sm"
class="p-0 border-0 bg-transparent"
onclick={() => movePoint(i, 1)}
disabled={isLast}>
<Icon name="chevron-down" />
</Button>
</td>
<EditableCell
bind:value={point.time_constraint}
onchange={() => (draft.rate_profile_data = [...draft.rate_profile_data])}
valueSuffix=" s"
emptyValue={-1} />
<EditableCell
bind:value={point.alt_constraint}
onchange={() => (draft.rate_profile_data = [...draft.rate_profile_data])}
valueSuffix=" m"
emptyValue={-1} />
<EditableCell
bind:value={point.rate}
onchange={() => (draft.rate_profile_data = [...draft.rate_profile_data])}
valueSuffix=" m/s" />
<td>
<Button size="sm" color="danger" onclick={() => removePoint(i)} class="p-0 border-0 bg-transparent">
<Icon name="trash" />
</Button>
</td>
</tr>
{:else}
<tr><td colspan="5" class="text-center text-muted">No points yet</td></tr>
{/each}
</tbody>
<tfoot>
<tr>
<td></td>
<td><Input class="form-control-sm" type="number" placeholder="t" bind:value={newPoint.time_constraint} /></td>
<td><Input class="form-control-sm" type="number" placeholder="alt" bind:value={newPoint.alt_constraint} /></td>
<td><Input class="form-control-sm" type="number" placeholder="rate" bind:value={newPoint.rate} /></td>
<td>
<Button size="sm" color="success" onclick={addPoint} class="p-0 border-0 bg-transparent">Add</Button>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
<div class="col-lg-6">
<CurveChart curve={draft} onUpdate={(pts) => (draft.rate_profile_data = pts)} />
</div>
</div>
<hr />
<div class="d-grid gap-2 d-md-flex justify-content-end">
<Button color="success" size="sm" onclick={handleSave}>
{isEditing ? 'Update' : 'Save'}
</Button>
</div>
{/if}
</div>
</Modal>
<ConfirmationPrompt
bind:isOpen={isConfirmationVisible}
title="Confirm deletion"
confirmText="Delete"
cancelText="Cancel"
confirmVariant="danger"
onconfirm={handleDelete}
oncancel={() => (isConfirmationVisible = false)}>
<p>Delete curve "{selectedCurve?.name}"?</p>
</ConfirmationPrompt>

View file

@ -0,0 +1,140 @@
<script lang="ts">
import { onMount } from 'svelte';
import { FormGroup, Label, Input } from '@sveltestrap/sveltestrap';
import type { SavedPoint } from '$domain';
import { pointsApi } from '$api';
import { addToast, ListEditor } from '$ui';
import type { ListEditorApi, ListEditorConfig } from '$ui';
import { t } from '$i18n';
import { pointsStore } from './pointsStore';
interface Props {
isOpen?: boolean;
onClose?: () => void;
onSave?: (p: SavedPoint) => void;
onSelectPoint?: (p: SavedPoint | null) => void;
point?: SavedPoint | null;
}
let {
isOpen = $bindable(false),
onClose = () => {},
onSave = (_: SavedPoint) => {},
onSelectPoint = (_: SavedPoint | null) => {},
point = null,
}: Props = $props();
let editorRef: ListEditor<SavedPoint> | null = $state(null);
onMount(async () => {
if ($pointsStore.length === 0) {
try {
pointsStore.set(await pointsApi.list());
} catch (err: unknown) {
addToast({
header: $t('common.error'),
body: (err as Error).message,
color: 'danger',
});
}
}
});
$effect(() => {
if (point && editorRef) editorRef.open(point);
});
const config: ListEditorConfig = {
showTable: true,
closeOnSave: false,
closeOnDelete: false,
searchBy: ['name'],
labels: {
item: 'Point',
itemGenitive: 'point',
items: 'Points',
add: 'Add',
edit: 'Edit',
save: 'Save',
update: 'Update',
delete: 'Delete',
cancel: 'Cancel',
close: 'Close',
searchPlaceholder: 'Search by name...',
},
};
const api: ListEditorApi<SavedPoint> = {
save: (p) => pointsApi.create(p),
update: (p) => pointsApi.update(p),
delete: (p) => pointsApi.delete(p.id),
};
const factory = (): SavedPoint => ({ id: 0, name: '', lat: 0, lon: 0, alt: 0 });
let items = $state<SavedPoint[]>($pointsStore);
$effect(() => {
items = $pointsStore;
});
$effect(() => {
pointsStore.set(items);
});
export function open(p: SavedPoint | null = null, showTable: boolean = config.showTable ?? true) {
editorRef?.open(p, showTable);
}
</script>
<ListEditor
bind:this={editorRef}
bind:isOpen
bind:items
{api}
{config}
itemFactory={factory}
onClose={() => onClose()}
onSave={(p) => onSave(p)}
onSelect={(p) => onSelectPoint(p)}>
{#snippet tableHeader()}
<tr>
<th>{$t('points.name')}</th>
<th>{$t('points.lat')}</th>
<th>{$t('points.lon')}</th>
<th>{$t('points.alt')}</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 class="small">{$t('points.name')}</Label>
<Input class="form-control-sm" type="text" bind:value={item.name} required />
</div>
<div class="d-flex gap-2">
<FormGroup class="flex-grow-1">
<Label class="small">{$t('points.lat')}</Label>
<Input class="form-control-sm" type="number" step="any" bind:value={item.lat} required />
<span class="form-text">{$t('points.degrees')}</span>
</FormGroup>
<FormGroup class="flex-grow-1">
<Label class="small">{$t('points.lon')}</Label>
<Input class="form-control-sm" type="number" step="any" bind:value={item.lon} required />
<span class="form-text">{$t('points.degrees')}</span>
</FormGroup>
<FormGroup class="flex-grow-1">
<Label class="small">{$t('points.alt')}</Label>
<Input class="form-control-sm" type="number" step="any" bind:value={item.alt} required />
<span class="form-text">{$t('points.metersAsl')}</span>
</FormGroup>
</div>
{/snippet}
</ListEditor>

View file

@ -0,0 +1,83 @@
<script lang="ts">
import { Modal, Button, Alert, Icon, Input, Label } from '@sveltestrap/sveltestrap';
import { scenariosApi } from '$api';
import { DEFAULT_SCENARIO, type FlightParameters, type SavedScenario } from '$domain';
import { addToast } from '$ui';
import { t } from '$i18n';
interface Props {
isOpen?: boolean;
onSaved?: (s: SavedScenario) => void;
}
let { isOpen = $bindable(false), onSaved = (_: SavedScenario) => {} }: Props = $props();
let draft = $state<SavedScenario>({ ...DEFAULT_SCENARIO, id: 0 });
let alertText = $state('');
export function openCreate(flightParameters: FlightParameters) {
draft = {
...DEFAULT_SCENARIO,
id: 0,
name: '',
flight_parameters: flightParameters,
};
alertText = '';
isOpen = true;
}
export function openEdit(scenario: SavedScenario) {
draft = { ...scenario };
alertText = '';
isOpen = true;
}
function close() {
isOpen = false;
}
async function handleSave() {
try {
const saved =
draft.id && draft.id > 0
? await scenariosApi.update(draft)
: await scenariosApi.create(draft);
addToast({ header: $t('scenario.updated'), body: saved.name, color: 'success' });
onSaved(saved);
close();
} catch (err: unknown) {
alertText = (err as Error).message;
}
}
</script>
<Modal {isOpen} toggle={close} size="lg" fade={false} scrollable>
<div class="modal-header">
<h5 class="modal-title">{$t('scenario.title')}</h5>
<button type="button" class="btn-close" onclick={close} aria-label="Close"></button>
</div>
<div class="modal-body">
{#if alertText}
<Alert color="danger" isOpen={true} toggle={() => (alertText = '')} fade={false}>
<Icon name="exclamation-triangle" class="me-2" />
{alertText}
</Alert>
{/if}
<form
onsubmit={(e) => {
e.preventDefault();
handleSave();
}}>
<div class="mb-2">
<Label class="small">{$t('scenario.select')}</Label>
<Input class="form-control-sm" type="text" bind:value={draft.name} required />
</div>
<div class="d-flex gap-2">
<Button type="submit" color="success" size="sm">{$t('editor.save')}</Button>
<Button color="secondary" size="sm" type="button" onclick={close}>
{$t('editor.close')}
</Button>
</div>
</form>
</div>
</Modal>

View file

@ -0,0 +1,189 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
Button,
FormGroup,
Icon,
Input,
InputGroup,
Label,
} from '@sveltestrap/sveltestrap';
import { CollapsibleCard, SelectSearchable, addToast } from '$ui';
import { scenariosApi } from '$api';
import { PREDICTION_MODES, type SavedScenario } from '$domain';
import { workspacesStore, getActiveWorkspace } from '$features/workspaces';
import { t } from '$i18n';
import { scenariosStore } from './pointsStore';
import ScenarioEditor from './ScenarioEditor.svelte';
let selectedScenarioId = $state<number>(-1);
let editorRef: ScenarioEditor | null = $state(null);
let active = $derived(getActiveWorkspace($workspacesStore));
let scenarioUnsaved = $derived.by(() => {
if (!active) return false;
const saved = $scenariosStore.find((s) => s.id === selectedScenarioId);
if (!saved) return false;
return (
JSON.stringify(active.flightParameters) !== JSON.stringify(saved.flight_parameters)
);
});
onMount(async () => {
try {
scenariosStore.set(await scenariosApi.list());
} catch (err: unknown) {
addToast({ header: $t('common.error'), body: (err as Error).message, color: 'danger' });
}
});
function handleApplySelected(showToast = true) {
const scenario = $scenariosStore.find((s) => s.id === selectedScenarioId);
if (!scenario || !active) {
if (showToast) {
addToast({
header: $t('scenario.notFound'),
body: $t('scenario.notFoundBody'),
color: 'warning',
});
}
return;
}
workspacesStore.patch(active.id, {
name: scenario.name,
flightParameters: scenario.flight_parameters,
});
if (showToast) {
addToast({
header: $t('scenario.applied'),
body: $t('scenario.appliedBody', { name: scenario.name }),
color: 'success',
});
}
}
async function handleSaveCurrent() {
if (!active) return;
const existing = $scenariosStore.find((s) => s.id === selectedScenarioId);
if (existing) {
try {
const updated = await scenariosApi.update({
...existing,
flight_parameters: active.flightParameters,
});
scenariosStore.update((list) =>
list.map((s) => (s.id === updated.id ? updated : s)),
);
addToast({
header: $t('scenario.updated'),
body: $t('scenario.updatedBody', { name: updated.name }),
color: 'success',
});
} catch (err: unknown) {
addToast({
header: $t('scenario.updateError'),
body: $t('scenario.updateErrorBody', { error: (err as Error).message }),
color: 'danger',
});
}
} else {
editorRef?.openCreate(active.flightParameters);
}
}
function handleEditorSaved(s: SavedScenario) {
scenariosStore.update((list) => [...list.filter((x) => x.id !== s.id), s]);
selectedScenarioId = s.id;
}
</script>
<CollapsibleCard title={$t('scenario.title')}>
<FormGroup spacing="mb-2">
<Label class="form-label">{$t('scenario.select')}</Label>
<InputGroup size="sm">
<SelectSearchable
style="min-height: calc(1.5em + .5rem + 2px); padding: .25rem .5rem; font-size: .875rem;"
id="sp-scenario"
options={$scenariosStore.map((s) => ({
value: s.id,
label: `${s.name}${s.id === selectedScenarioId && scenarioUnsaved ? ` (${$t('scenario.modified')})` : ''}`,
}))}
bind:selected={selectedScenarioId}
placeholder={$t('scenario.placeholder')}
searchPlaceholder={$t('scenario.searchPlaceholder')}
clearable={true}
onChange={() => {
if (!scenarioUnsaved) handleApplySelected(false);
}} />
<Button color="success" title={$t('scenario.apply')} onclick={() => handleApplySelected(true)}>
<span></span>
</Button>
</InputGroup>
</FormGroup>
<div class="d-flex gap-2 mb-2">
<Button color="secondary flex-fill" size="sm">
{$t('scenario.all')}
<Icon name="journal-bookmark-fill" />
</Button>
<Button
color="primary flex-fill"
size="sm"
onclick={handleSaveCurrent}
disabled={!scenarioUnsaved && selectedScenarioId !== -1}>
{selectedScenarioId !== -1 ? $t('scenario.update') : $t('scenario.save')}
<Icon name="floppy2-fill" />
</Button>
</div>
<hr />
{#if active}
<FormGroup spacing="mb-2">
<Label class="form-label">{$t('scenario.mode')}</Label>
<InputGroup size="sm">
<Input type="select" class="form-control-sm">
{#each PREDICTION_MODES as mode}
<option value={mode}>{$t(`predictionMode.${mode}`)}</option>
{/each}
</Input>
</InputGroup>
</FormGroup>
<FormGroup spacing="mb-2">
<Label class="form-label">{$t('scenario.model')}</Label>
<InputGroup size="sm">
<Input type="select" class="form-control-sm">
<option>GFS (0.25°)</option>
<option>GFS (0.5°)</option>
</Input>
</InputGroup>
</FormGroup>
<FormGroup spacing="mb-2">
<Label class="form-label">{$t('scenario.dataset')}</Label>
<InputGroup size="sm">
<Input type="select" class="form-control-sm">
<option>{$t('scenario.datasetAuto')}</option>
</Input>
</InputGroup>
</FormGroup>
<FormGroup spacing="mb-0">
<Label class="form-label">{$t('scenario.export')}</Label>
<InputGroup size="sm">
<Input type="select" class="form-control-sm">
<option>JSON</option>
<option>CSV</option>
<option>KML</option>
</Input>
<Button color="primary">
<span>{$t('scenario.exportBtn')}</span>
<Icon name="file-earmark-arrow-down" />
</Button>
</InputGroup>
</FormGroup>
{/if}
</CollapsibleCard>
<ScenarioEditor bind:this={editorRef} onSaved={handleEditorSaved} />

View file

@ -0,0 +1,7 @@
export { default as ControlPanel } from './ControlPanel.svelte';
export { default as ScenarioPanel } from './ScenarioPanel.svelte';
export { default as PointEditor } from './PointEditor.svelte';
export { default as ScenarioEditor } from './ScenarioEditor.svelte';
export { default as CurveEditor } from './CurveEditor.svelte';
export { default as CurveChart } from './CurveChart.svelte';
export { pointsStore, profilesStore, scenariosStore } from './pointsStore';

View file

@ -0,0 +1,11 @@
import { writable } from 'svelte/store';
import type { SavedPoint, SavedFlightProfile, SavedScenario } from '$domain';
/**
* Session-scoped caches for the user's saved points, profiles, and scenarios.
* Persisting these would fight the server as the source of truth, so they
* are plain in-memory writables hydrated from the API on mount.
*/
export const pointsStore = writable<SavedPoint[]>([]);
export const profilesStore = writable<SavedFlightProfile[]>([]);
export const scenariosStore = writable<SavedScenario[]>([]);

View file

@ -0,0 +1,70 @@
<script lang="ts">
import { FormGroup, Label, Input } from '@sveltestrap/sveltestrap';
import { CollapsibleCard } from '$ui';
import { t, setLocale, type Locale } from '$i18n';
import { SETTINGS_SCHEMA } from './schema';
import { settingsStore, setPath, getPath, type AppSettings } from './store';
function applyChange<T extends object>(path: string, value: unknown) {
settingsStore.update((s) => setPath(s as T, path, value) as AppSettings);
if (path === 'locale') setLocale(value as Locale);
}
</script>
<CollapsibleCard title={$t('settings.title')}>
{#each SETTINGS_SCHEMA as section}
<h6 class="mt-2 small text-muted text-uppercase">{$t(section.titleKey)}</h6>
{#each section.fields as field}
{@const current = getPath($settingsStore, field.path)}
<FormGroup spacing="mb-2">
{#if field.kind !== 'boolean'}
<Label class="form-label">{$t(field.labelKey)}</Label>
{/if}
{#if field.kind === 'boolean'}
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id={`setting-${field.path}`}
checked={Boolean(current)}
onchange={(e) =>
applyChange(field.path, (e.currentTarget as HTMLInputElement).checked)} />
<label class="form-check-label" for={`setting-${field.path}`}>
{$t(field.labelKey)}
</label>
</div>
{:else if field.kind === 'select'}
<Input
type="select"
class="form-control-sm"
value={current}
onchange={(e) => applyChange(field.path, (e.currentTarget as HTMLSelectElement).value)}>
{#each field.options as option}
<option value={option.value}>{option.value}</option>
{/each}
</Input>
{:else if field.kind === 'number'}
<Input
type="number"
class="form-control-sm"
value={current as number}
min={field.min}
max={field.max}
step={field.step}
oninput={(e) =>
applyChange(field.path, parseFloat((e.currentTarget as HTMLInputElement).value))} />
{:else if field.kind === 'string'}
<Input
type="text"
class="form-control-sm"
value={current as string}
placeholder={field.placeholder}
oninput={(e) => applyChange(field.path, (e.currentTarget as HTMLInputElement).value)} />
{/if}
</FormGroup>
{/each}
{/each}
</CollapsibleCard>

View file

@ -0,0 +1,5 @@
export { settingsStore, DEFAULT_SETTINGS } from './store';
export type { AppSettings, MapSettings, UnitsSettings } from './store';
export { default as SettingsPanel } from './SettingsPanel.svelte';
export { SETTINGS_SCHEMA } from './schema';
export type { SettingsField, SettingsSection } from './schema';

View file

@ -0,0 +1,86 @@
/**
* Declarative settings schema. Each `SettingsField` describes a single setting
* that renders as a labeled form control in the Settings panel. Keep this
* independent of Svelte so the same schema can drive future serializers
* (export/import settings, URL state, etc.).
*/
export type FieldKind = 'boolean' | 'select' | 'number' | 'string';
export interface BaseField<K extends FieldKind> {
kind: K;
/** Dot-separated path into AppSettings (e.g. `'map.baseLayer'`). */
path: string;
labelKey: string;
descriptionKey?: string;
}
export interface BooleanField extends BaseField<'boolean'> {}
export interface NumberField extends BaseField<'number'> {
min?: number;
max?: number;
step?: number;
}
export interface StringField extends BaseField<'string'> {
placeholder?: string;
}
export interface SelectField extends BaseField<'select'> {
options: { value: string; labelKey: string }[];
}
export type SettingsField = BooleanField | NumberField | StringField | SelectField;
export interface SettingsSection {
titleKey: string;
fields: SettingsField[];
}
export const SETTINGS_SCHEMA: SettingsSection[] = [
{
titleKey: 'settings.language',
fields: [
{
kind: 'select',
path: 'locale',
labelKey: 'settings.language',
options: [
{ value: 'ru', labelKey: 'common.yes' },
{ value: 'en', labelKey: 'common.yes' },
],
},
],
},
{
titleKey: 'settings.map',
fields: [
{
kind: 'select',
path: 'map.baseLayer',
labelKey: 'settings.baseLayer',
options: [
{ value: 'osm', labelKey: 'settings.baseLayer' },
{ value: 'satellite', labelKey: 'settings.baseLayer' },
],
},
{ kind: 'boolean', path: 'map.showScale', labelKey: 'settings.showScale' },
{ kind: 'boolean', path: 'map.showNavigation', labelKey: 'settings.showNavigation' },
],
},
{
titleKey: 'settings.units',
fields: [
{
kind: 'select',
path: 'units.system',
labelKey: 'settings.units',
options: [
{ value: 'metric', labelKey: 'settings.metric' },
{ value: 'imperial', labelKey: 'settings.imperial' },
],
},
],
},
];

View file

@ -0,0 +1,50 @@
import { persisted } from '$state';
import type { Locale } from '$i18n';
export interface MapSettings {
baseLayer: 'osm' | 'satellite';
showScale: boolean;
showNavigation: boolean;
}
export interface UnitsSettings {
system: 'metric' | 'imperial';
}
export interface AppSettings {
locale: Locale;
map: MapSettings;
units: UnitsSettings;
}
export const DEFAULT_SETTINGS: AppSettings = {
locale: 'ru',
map: { baseLayer: 'osm', showScale: true, showNavigation: true },
units: { system: 'metric' },
};
export const settingsStore = persisted<AppSettings>('settings', DEFAULT_SETTINGS);
/** Resolve `'a.b.c'` to `obj.a.b.c`. Used by the schema-driven settings form. */
export function getPath(obj: unknown, path: string): unknown {
return path.split('.').reduce<unknown>((acc, key) => {
if (acc && typeof acc === 'object' && key in acc) {
return (acc as Record<string, unknown>)[key];
}
return undefined;
}, obj);
}
export function setPath<T extends object>(obj: T, path: string, value: unknown): T {
const keys = path.split('.');
const next: Record<string, unknown> = { ...(obj as Record<string, unknown>) };
let cursor: Record<string, unknown> = next;
for (let i = 0; i < keys.length - 1; i++) {
const k = keys[i];
const existing = cursor[k];
cursor[k] = { ...((existing as Record<string, unknown> | undefined) ?? {}) };
cursor = cursor[k] as Record<string, unknown>;
}
cursor[keys[keys.length - 1]] = value;
return next as T;
}

View file

@ -0,0 +1,117 @@
<script lang="ts">
import { timelineStore } from './store';
import { t } from '$i18n';
const SPEEDS = [0.5, 1, 2, 5, 10];
function cycleSpeed() {
const i = SPEEDS.indexOf($timelineStore.speed);
timelineStore.setSpeed(SPEEDS[(i + 1) % SPEEDS.length]);
}
function onSeek(e: Event) {
const v = parseFloat((e.currentTarget as HTMLInputElement).value);
timelineStore.seek(v);
}
let duration = $derived(Math.max(0, $timelineStore.max - $timelineStore.min));
let elapsed = $derived(Math.max(0, $timelineStore.time - $timelineStore.min));
function fmtHms(ms: number): string {
if (!isFinite(ms) || ms < 0) return '00:00:00';
const s = Math.floor(ms / 1000);
const hh = String(Math.floor(s / 3600)).padStart(2, '0');
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0');
const ss = String(s % 60).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
let hasData = $derived(duration > 0);
</script>
<div class="timeline-container card shadow-sm" class:disabled={!hasData}>
<div class="card-body p-2">
<div class="d-flex align-items-center gap-2">
<div class="btn-group btn-group-sm" role="group">
<button
type="button"
class="btn btn-outline-primary"
onclick={() => timelineStore.reset()}
disabled={!hasData}
title={$t('timeline.stop')}
aria-label={$t('timeline.stop')}>
<i class="bi bi-skip-start-fill"></i>
</button>
{#if $timelineStore.playing}
<button
type="button"
class="btn btn-warning"
onclick={() => timelineStore.pause()}
title={$t('timeline.pause')}
aria-label={$t('timeline.pause')}>
<i class="bi bi-pause-fill"></i>
</button>
{:else}
<button
type="button"
class="btn btn-success"
onclick={() => timelineStore.play()}
disabled={!hasData}
title={$t('timeline.play')}
aria-label={$t('timeline.play')}>
<i class="bi bi-play-fill"></i>
</button>
{/if}
<button
type="button"
class="btn btn-outline-secondary"
onclick={cycleSpeed}
disabled={!hasData}
title={$t('timeline.speed')}>
{$timelineStore.speed}x
</button>
</div>
<div class="flex-fill d-flex flex-column">
<input
type="range"
class="form-range"
min={$timelineStore.min}
max={$timelineStore.max}
step="1000"
value={$timelineStore.time}
oninput={onSeek}
disabled={!hasData} />
<div class="d-flex justify-content-between small font-monospace text-muted">
<span>{fmtHms(elapsed)}</span>
<span>{fmtHms(duration)}</span>
</div>
</div>
</div>
</div>
</div>
<style>
.timeline-container {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
min-width: 500px;
max-width: 720px;
z-index: 1000;
background: var(--bs-body-bg);
backdrop-filter: blur(10px);
}
.timeline-container.disabled {
opacity: 0.7;
}
@media (max-width: 767.98px) {
.timeline-container {
min-width: calc(100vw - 24px);
max-width: calc(100vw - 24px);
bottom: 10px;
}
}
</style>

View file

@ -0,0 +1,3 @@
export { timelineStore } from './store';
export type { TimelineState } from './store';
export { default as TimeLine } from './TimeLine.svelte';

View file

@ -0,0 +1,93 @@
import { writable } from 'svelte/store';
/**
* Global playback clock.
*
* `time` is an absolute timestamp in ms (UTC). Each layer/workspace samples
* its own trajectory against this clock, so multiple workspaces stay in sync.
*
* Consumers should treat the range `[min, max]` as the current domain; if
* they own a trajectory spanning a different interval they can clamp
* locally, but the UI slider always operates over the global range.
*/
export interface TimelineState {
time: number;
min: number;
max: number;
speed: number;
playing: boolean;
}
const initial: TimelineState = {
time: 0,
min: 0,
max: 0,
speed: 1,
playing: false,
};
function createTimeline() {
const store = writable<TimelineState>(initial);
let frame: number | null = null;
let lastTick = 0;
function tick(ts: number) {
store.update((s) => {
if (!s.playing) return s;
if (!lastTick) lastTick = ts;
const dt = (ts - lastTick) * s.speed;
lastTick = ts;
let next = s.time + dt;
if (next >= s.max) {
next = s.max;
frame = null;
return { ...s, time: next, playing: false };
}
frame = requestAnimationFrame(tick);
return { ...s, time: next };
});
}
function play() {
store.update((s) => {
if (s.max <= s.min) return s;
if (s.time >= s.max) return { ...s, time: s.min, playing: true };
return { ...s, playing: true };
});
lastTick = 0;
frame = requestAnimationFrame(tick);
}
function pause() {
if (frame !== null) cancelAnimationFrame(frame);
frame = null;
store.update((s) => ({ ...s, playing: false }));
}
function reset() {
pause();
store.update((s) => ({ ...s, time: s.min }));
}
function seek(time: number) {
store.update((s) => ({ ...s, time: Math.max(s.min, Math.min(s.max, time)) }));
}
function setSpeed(speed: number) {
store.update((s) => ({ ...s, speed }));
}
function setRange(min: number, max: number) {
store.update((s) => {
const nextMin = Number.isFinite(min) ? min : s.min;
const nextMax = Number.isFinite(max) ? max : s.max;
const t = Math.max(nextMin, Math.min(nextMax, s.time));
return { ...s, min: nextMin, max: nextMax, time: t };
});
}
return { subscribe: store.subscribe, play, pause, reset, seek, setSpeed, setRange };
}
export const timelineStore = createTimeline();

View file

@ -0,0 +1,36 @@
<script lang="ts">
import { FormGroup, Label, Input, InputGroup } from '@sveltestrap/sveltestrap';
import { CollapsibleCard } from '$ui';
import { t } from '$i18n';
// Placeholder — wire to the tracking telemetry store once the tracking
// pipeline has a real data source.
let telemetry: { latitude?: number; longitude?: number; altitude?: number } = $state({
latitude: 56.3576,
longitude: 39.8666,
altitude: 1000,
});
</script>
<CollapsibleCard title={$t('nav.track')}>
<FormGroup spacing="mb-2">
<Label class="small">{$t('points.lat')}</Label>
<InputGroup size="sm">
<Input type="text" value={telemetry.latitude ?? 'N/A'} readonly />
</InputGroup>
</FormGroup>
<FormGroup spacing="mb-2">
<Label class="small">{$t('points.lon')}</Label>
<InputGroup size="sm">
<Input type="text" value={telemetry.longitude ?? 'N/A'} readonly />
</InputGroup>
</FormGroup>
<FormGroup spacing="mb-2">
<Label class="small">{$t('points.alt')}</Label>
<InputGroup size="sm">
<Input type="text" value={telemetry.altitude ?? 'N/A'} readonly />
</InputGroup>
</FormGroup>
</CollapsibleCard>

View file

@ -0,0 +1 @@
export { default as TelemetryPanel } from './TelemetryPanel.svelte';

View file

@ -0,0 +1,132 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { getMap, plotPrediction, plotAnimatedMarker } from '$map';
import { timelineStore } from '$features/timeline/store';
import { workspacesStore } from './store';
import type { Workspace } from './types';
import type { LatLngTuple } from '$domain';
/**
* Renders every workspace onto the shared map. Each workspace gets its own
* named scene (`ws/<id>`) so its layers can be cleared independently, plus
* a `cursor/<id>` scene for the animated playback marker.
*
* The component is placed as a child of <Map /> so `getMap()` returns a
* non-null instance via context.
*/
const map = getMap();
if (!map) throw new Error('WorkspaceRenderer must be a descendant of <Map />');
// Track the scenes we currently own so we can dispose the ones that
// belong to workspaces which were removed from the store since last tick.
const ownedPlotScenes = new Set<string>();
const ownedCursorScenes = new Set<string>();
const sceneName = (w: Workspace) => `ws/${w.id}`;
const cursorName = (w: Workspace) => `cursor/${w.id}`;
function updateGlobalRange(items: Workspace[]) {
let min = Infinity;
let max = -Infinity;
for (const w of items) {
if (!w.visible || !w.result) continue;
const l = w.result.launch.datetime.getTime();
const r = w.result.landing.datetime.getTime();
if (l < min) min = l;
if (r > max) max = r;
}
if (!isFinite(min) || !isFinite(max)) {
timelineStore.setRange(0, 0);
} else {
timelineStore.setRange(min, max);
}
}
function renderAll(items: Workspace[]) {
if (!map) return;
const live = new Set<string>();
for (const w of items) {
const name = sceneName(w);
live.add(name);
if (!w.visible || !w.result) {
if (ownedPlotScenes.has(name)) {
map.disposeScene(name);
ownedPlotScenes.delete(name);
}
continue;
}
const scene = map.scene(name);
plotPrediction(scene, w.result, { color: w.color, opacity: w.opacity });
ownedPlotScenes.add(name);
}
for (const name of Array.from(ownedPlotScenes)) {
if (!live.has(name)) {
map.disposeScene(name);
ownedPlotScenes.delete(name);
}
}
}
function positionAt(path: LatLngTuple[], time: number, launchMs: number, landingMs: number) {
if (path.length === 0) return null;
if (launchMs === landingMs) return path[0];
const t = Math.max(0, Math.min(1, (time - launchMs) / (landingMs - launchMs)));
const idx = Math.min(path.length - 1, Math.floor(t * (path.length - 1)));
return path[idx];
}
function renderCursors(items: Workspace[], time: number) {
if (!map) return;
const live = new Set<string>();
for (const w of items) {
const name = cursorName(w);
live.add(name);
if (!w.visible || !w.result) {
if (ownedCursorScenes.has(name)) {
map.disposeScene(name);
ownedCursorScenes.delete(name);
}
continue;
}
const p = positionAt(
w.result.flight_path,
time,
w.result.launch.datetime.getTime(),
w.result.landing.datetime.getTime(),
);
if (!p) continue;
const scene = map.scene(name);
plotAnimatedMarker(scene, p[1], p[0]);
ownedCursorScenes.add(name);
}
for (const name of Array.from(ownedCursorScenes)) {
if (!live.has(name)) {
map.disposeScene(name);
ownedCursorScenes.delete(name);
}
}
}
$effect(() => {
const items = $workspacesStore.items;
renderAll(items);
updateGlobalRange(items);
});
$effect(() => {
renderCursors($workspacesStore.items, $timelineStore.time);
});
onDestroy(() => {
if (!map) return;
for (const name of ownedPlotScenes) map.disposeScene(name);
for (const name of ownedCursorScenes) map.disposeScene(name);
ownedPlotScenes.clear();
ownedCursorScenes.clear();
});
</script>

View file

@ -0,0 +1,159 @@
<script lang="ts">
import { Button, Icon, Input, InputGroup } from '@sveltestrap/sveltestrap';
import { CollapsibleCard, ConfirmationPrompt } from '$ui';
import { addToast } from '$ui';
import { t } from '$i18n';
import { workspacesStore } from './store';
import type { Workspace } from './types';
let toDelete = $state<Workspace | null>(null);
let busy = $state<Record<string, boolean>>({});
function handleAdd() {
workspacesStore.add();
}
function handleToggleVisible(w: Workspace) {
workspacesStore.patch(w.id, { visible: !w.visible });
}
function handleRename(w: Workspace, name: string) {
workspacesStore.patch(w.id, { name });
}
function handleColor(w: Workspace, color: string) {
workspacesStore.patch(w.id, { color });
}
function handleOpacity(w: Workspace, opacity: number) {
workspacesStore.patch(w.id, { opacity });
}
function handleSetActive(w: Workspace) {
workspacesStore.setActive(w.id);
}
async function handleRun(w: Workspace) {
busy = { ...busy, [w.id]: true };
try {
await workspacesStore.run(w.id);
} catch (err: unknown) {
addToast({
header: $t('common.error'),
body: $t('workspaces.runError', { error: (err as Error).message }),
color: 'danger',
});
} finally {
busy = { ...busy, [w.id]: false };
}
}
function handleDelete() {
if (!toDelete) return;
workspacesStore.remove(toDelete.id);
toDelete = null;
}
</script>
<CollapsibleCard title={$t('workspaces.title')}>
<div class="d-flex justify-content-end mb-2">
<Button color="primary" size="sm" onclick={handleAdd}>
<Icon name="plus-lg" />
{$t('workspaces.add')}
</Button>
</div>
{#if $workspacesStore.items.length === 0}
<div class="text-muted small text-center py-2">{$t('workspaces.empty')}</div>
{/if}
<div class="d-flex flex-column gap-2">
{#each $workspacesStore.items as w (w.id)}
{@const isActive = $workspacesStore.activeId === w.id}
<div
class="workspace-row border rounded p-2"
class:active={isActive}
style="border-left: 4px solid {w.color} !important;">
<div class="d-flex align-items-center gap-2 mb-2">
<Button
size="sm"
color="light"
title={$t('workspaces.visible')}
onclick={() => handleToggleVisible(w)}>
<Icon name={w.visible ? 'eye' : 'eye-slash'} />
</Button>
<Input
type="text"
class="form-control-sm flex-grow-1"
value={w.name}
oninput={(e) => handleRename(w, (e.currentTarget as HTMLInputElement).value)} />
<Button size="sm" color="danger" onclick={() => (toDelete = w)} title={$t('workspaces.delete')}>
<Icon name="trash" />
</Button>
</div>
<div class="d-flex align-items-center gap-2 mb-2 small">
<span>{$t('workspaces.color')}</span>
<input
type="color"
class="form-control form-control-color form-control-sm"
style="width: 2rem; height: 1.75rem; padding: 0.1rem;"
value={w.color}
oninput={(e) => handleColor(w, (e.currentTarget as HTMLInputElement).value)} />
<span class="ms-2">{$t('workspaces.opacity')}</span>
<input
type="range"
class="form-range"
style="max-width: 6rem;"
min="0.1"
max="1"
step="0.05"
value={w.opacity}
oninput={(e) => handleOpacity(w, parseFloat((e.currentTarget as HTMLInputElement).value))} />
</div>
<InputGroup size="sm">
<Button
color={isActive ? 'primary' : 'outline-primary'}
onclick={() => handleSetActive(w)}
class="flex-fill"
title={$t('workspaces.edit')}>
<Icon name="pencil-square" />
{$t('workspaces.edit')}
</Button>
<Button
color="success"
disabled={busy[w.id]}
onclick={() => handleRun(w)}
class="flex-fill">
<Icon name="play-fill" />
{busy[w.id] ? $t('workspaces.running') : $t('workspaces.run')}
</Button>
</InputGroup>
{#if w.lastRunError}
<div class="text-danger small mt-1">{w.lastRunError}</div>
{/if}
</div>
{/each}
</div>
</CollapsibleCard>
<ConfirmationPrompt
isOpen={toDelete !== null}
title={$t('workspaces.delete')}
confirmText={$t('workspaces.delete')}
cancelText={$t('editor.cancel')}
confirmVariant="danger"
onconfirm={handleDelete}
oncancel={() => (toDelete = null)}>
{#if toDelete}
<p>{$t('workspaces.deleteConfirm', { name: toDelete.name })}</p>
{/if}
</ConfirmationPrompt>
<style>
.workspace-row.active {
background: var(--bs-light);
}
</style>

View file

@ -0,0 +1,4 @@
export { workspacesStore, getActiveWorkspace } from './store';
export type { Workspace, WorkspaceInit } from './types';
export { default as WorkspacesPanel } from './WorkspacesPanel.svelte';
export { default as WorkspaceRenderer } from './WorkspaceRenderer.svelte';

View file

@ -0,0 +1,128 @@
import { get } from 'svelte/store';
import { persisted } from '$state';
import { DEFAULT_FLIGHT_PARAMETERS, type FlightParameters } from '$domain';
import type { Prediction } from '$domain';
import { predictionsApi } from '$api';
import { parsePrediction } from '$domain';
import { buildLaunchDateTime } from '$api';
import type { Workspace, WorkspaceInit } from './types';
const STORAGE_KEY = 'workspaces';
const DEFAULT_COLORS = [
'#0d6efd',
'#dc3545',
'#198754',
'#fd7e14',
'#6f42c1',
'#20c997',
'#d63384',
'#0dcaf0',
];
function todayDate(): string {
return new Date().toISOString().split('T')[0];
}
function makeWorkspace(init: WorkspaceInit = {}, index = 0): Workspace {
return {
id: crypto.randomUUID(),
name: init.name ?? `Рабочая область ${index + 1}`,
color: init.color ?? DEFAULT_COLORS[index % DEFAULT_COLORS.length],
opacity: 1,
visible: true,
flightParameters: init.flightParameters ?? { ...DEFAULT_FLIGHT_PARAMETERS },
launchDate: init.launchDate ?? todayDate(),
launchTime: init.launchTime ?? '12:00:00',
result: null,
};
}
export interface WorkspaceSlice {
items: Workspace[];
activeId: string | null;
}
const initial: WorkspaceSlice = { items: [], activeId: null };
/**
* Don't persist prediction results — they're large, transient, and can be
* re-fetched. The serializer strips `result` before writing to localStorage.
*/
const workspacesPersisted = persisted<WorkspaceSlice>(STORAGE_KEY, initial, {
serializer: {
stringify: (value) => {
const lean: WorkspaceSlice = {
...value,
items: value.items.map((w) => ({ ...w, result: null, lastRunError: undefined })),
};
return JSON.stringify(lean);
},
parse: (raw) => JSON.parse(raw) as WorkspaceSlice,
},
});
function update(fn: (s: WorkspaceSlice) => WorkspaceSlice): void {
workspacesPersisted.update(fn);
}
export const workspacesStore = {
subscribe: workspacesPersisted.subscribe,
add(init: WorkspaceInit = {}): Workspace {
let created: Workspace | null = null;
update((s) => {
const w = makeWorkspace(init, s.items.length);
created = w;
return { items: [...s.items, w], activeId: w.id };
});
return created!;
},
remove(id: string): void {
update((s) => {
const items = s.items.filter((w) => w.id !== id);
const activeId = s.activeId === id ? (items[0]?.id ?? null) : s.activeId;
return { items, activeId };
});
},
patch(id: string, patch: Partial<Workspace>): void {
update((s) => ({
...s,
items: s.items.map((w) => (w.id === id ? { ...w, ...patch } : w)),
}));
},
setActive(id: string | null): void {
update((s) => ({ ...s, activeId: id }));
},
setFlightParameters(id: string, params: FlightParameters): void {
workspacesStore.patch(id, { flightParameters: params });
},
setResult(id: string, result: Prediction | null, error?: string): void {
workspacesStore.patch(id, { result, lastRunError: error });
},
async run(id: string): Promise<void> {
const slice = get(workspacesPersisted);
const w = slice.items.find((x) => x.id === id);
if (!w) return;
try {
const launchDatetime = buildLaunchDateTime(w.launchDate, w.launchTime);
const response = await predictionsApi.run(w.flightParameters, launchDatetime);
const prediction = parsePrediction(response.result.prediction);
workspacesStore.setResult(id, prediction);
} catch (err: unknown) {
workspacesStore.setResult(id, null, (err as Error).message);
throw err;
}
},
};
export function getActiveWorkspace(slice: WorkspaceSlice): Workspace | null {
if (!slice.activeId) return slice.items[0] ?? null;
return slice.items.find((w) => w.id === slice.activeId) ?? null;
}

View file

@ -0,0 +1,23 @@
import type { FlightParameters, Prediction } from '$domain';
/** A "workspace" is an independently-configured prediction layer on the map. */
export interface Workspace {
id: string;
name: string;
color: string;
opacity: number;
visible: boolean;
flightParameters: FlightParameters;
launchDate: string;
launchTime: string;
result: Prediction | null;
lastRunError?: string;
}
export interface WorkspaceInit {
name?: string;
color?: string;
flightParameters?: FlightParameters;
launchDate?: string;
launchTime?: string;
}

87
src/lib/i18n/index.ts Normal file
View file

@ -0,0 +1,87 @@
import { derived, writable, type Readable } from 'svelte/store';
import { browser } from '$app/environment';
/**
* Minimal i18n layer no external deps.
*
* Lookup is key-based (`t('panel.title')`) against flat or nested JSON
* dictionaries. Interpolation uses `{name}` placeholders. Missing keys fall
* back to the key itself so screens remain functional during translation.
*
* Adding a locale
* ---------------
* 1. Drop a JSON file into `src/lib/i18n/locales/<code>.json`.
* 2. Register it in `loaders` below.
* 3. Expose it in the `Locale` type.
*/
export type Locale = 'ru' | 'en';
export const DEFAULT_LOCALE: Locale = 'ru';
export const SUPPORTED_LOCALES: Locale[] = ['ru', 'en'];
const STORAGE_KEY = 'locale';
type Messages = Record<string, unknown>;
const loaders: Record<Locale, () => Promise<{ default: Messages }>> = {
ru: () => import('./locales/ru.json'),
en: () => import('./locales/en.json'),
};
const messages = writable<Record<Locale, Messages>>({} as Record<Locale, Messages>);
const locale = writable<Locale>(DEFAULT_LOCALE);
async function loadLocale(code: Locale): Promise<void> {
const mod = await loaders[code]();
messages.update((m) => ({ ...m, [code]: mod.default }));
}
export async function setLocale(code: Locale): Promise<void> {
if (!SUPPORTED_LOCALES.includes(code)) return;
await loadLocale(code);
locale.set(code);
if (browser) localStorage.setItem(STORAGE_KEY, code);
}
export async function initI18n(): Promise<void> {
const stored = browser ? (localStorage.getItem(STORAGE_KEY) as Locale | null) : null;
const next = stored && SUPPORTED_LOCALES.includes(stored) ? stored : DEFAULT_LOCALE;
await setLocale(next);
}
function lookup(dict: Messages, key: string): string | undefined {
const parts = key.split('.');
let node: unknown = dict;
for (const p of parts) {
if (node && typeof node === 'object' && p in (node as Messages)) {
node = (node as Messages)[p];
} else {
return undefined;
}
}
return typeof node === 'string' ? node : undefined;
}
function interpolate(template: string, values: Record<string, string | number>): string {
return template.replace(/\{(\w+)\}/g, (_, name) =>
name in values ? String(values[name]) : `{${name}}`,
);
}
export interface Translator {
(key: string, values?: Record<string, string | number>): string;
}
export const t: Readable<Translator> = derived(
[locale, messages],
([$locale, $messages]) => {
const dict = $messages[$locale];
return (key: string, values?: Record<string, string | number>) => {
const str = dict ? lookup(dict, key) : undefined;
if (str === undefined) return key;
return values ? interpolate(str, values) : str;
};
},
);
export const currentLocale: Readable<Locale> = { subscribe: locale.subscribe };

View file

@ -0,0 +1,172 @@
{
"app": {
"title": "Stratospheric Flights | YKS Ltd.",
"company": "Yakutsk Space Systems Ltd."
},
"nav": {
"predict": "Predict",
"track": "Track",
"login": "Log in",
"logout": "Log out",
"account": "Account",
"scenarios": "Saved scenarios",
"predictionHistory": "Prediction history",
"trackingHistory": "Tracking history",
"user": "User"
},
"login": {
"heading": "Sign in to your account",
"username": "Username",
"password": "Password",
"submit": "Sign in",
"submitting": "Signing in...",
"back": "Back",
"invalidCredentials": "Invalid credentials",
"fieldsRequired": "Please enter a username and password"
},
"panel": {
"scenario": "Scenario",
"conditions": "Conditions",
"about": "About",
"layers": "Layers",
"results": "Results",
"settings": "Settings",
"workspaces": "Workspaces"
},
"scenario": {
"title": "Prediction scenario",
"select": "Scenario",
"placeholder": "New scenario...",
"searchPlaceholder": "Search scenarios...",
"apply": "Apply scenario",
"applied": "Scenario applied",
"appliedBody": "Scenario \"{name}\" successfully applied.",
"notFound": "Scenario not found",
"notFoundBody": "The selected scenario does not exist.",
"updated": "Scenario updated",
"updatedBody": "Scenario \"{name}\" successfully updated.",
"updateError": "Scenario update error",
"updateErrorBody": "Error updating scenario: {error}",
"save": "Save scenario",
"update": "Update scenario",
"all": "All scenarios",
"mode": "Scenario mode",
"model": "Atmospheric model",
"dataset": "Dataset",
"datasetAuto": "Pick automatically",
"modified": "modified",
"export": "Export result",
"exportBtn": "Export"
},
"predictionMode": {
"single": "Single",
"hourly": "Hourly",
"ensemble": "Ensemble"
},
"profile": {
"standard_profile": "Standard",
"float_profile": "Float",
"reverse_profile": "Reverse",
"custom_profile": "Custom"
},
"conditions": {
"title": "Prediction conditions",
"startTime": "Launch time (UTC)",
"startDate": "Launch date",
"flightProfile": "Flight profile",
"startPoint": "Launch point",
"pointPlaceholder": "New point...",
"pointSearchPlaceholder": "Search points...",
"latLng": "Latitude/Longitude",
"save": "Save",
"launchAlt": "Launch altitude (m)",
"burstAlt": "Burst altitude (m)",
"ascentRate": "Ascent rate (m/s)",
"descentRate": "Descent rate (m/s)",
"run": "Run prediction",
"profileEdit": "Ascent/descent profiles",
"ascentStage": "Ascent stage",
"descentStage": "Descent stage",
"stageNone": "None",
"stageStandard": "Standard",
"stageCustom": "Custom",
"openCurveEditor": "Open curve editor"
},
"workspaces": {
"title": "Workspaces",
"empty": "No workspaces yet.",
"add": "Add",
"rename": "Rename",
"delete": "Delete",
"visible": "Show/hide",
"color": "Color",
"opacity": "Opacity",
"run": "Run",
"running": "Running...",
"edit": "Edit",
"defaultName": "Workspace {n}",
"deleteConfirm": "Delete workspace \"{name}\"?",
"runError": "Run error: {error}"
},
"timeline": {
"title": "Flight timeline",
"time": "Time",
"altitude": "Altitude",
"position": "Position",
"play": "Play",
"pause": "Pause",
"stop": "Reset",
"speed": "Speed"
},
"settings": {
"title": "Settings",
"language": "Language",
"map": "Map",
"baseLayer": "Base layer",
"showScale": "Show scale",
"showNavigation": "Navigation controls",
"units": "Units",
"metric": "Metric",
"imperial": "Imperial",
"saved": "Settings saved"
},
"editor": {
"add": "Add",
"edit": "Edit",
"save": "Save",
"update": "Update",
"delete": "Delete",
"cancel": "Cancel",
"close": "Close",
"searchPlaceholder": "Search..."
},
"points": {
"item": "point",
"itemGenitive": "point",
"items": "points",
"name": "Point name",
"lat": "Latitude",
"lon": "Longitude",
"alt": "Altitude",
"degrees": "Degrees",
"metersAsl": "Meters ASL"
},
"common": {
"error": "Error",
"success": "Success",
"warning": "Warning",
"info": "Info",
"yes": "Yes",
"no": "No"
},
"selection": {
"header": "Coordinate select mode",
"body": "Click the map to pick coordinates"
},
"forecast": {
"success": "Forecast request",
"successBody": "Forecast request successful!",
"error": "Forecast error",
"errorBody": "Error running forecast: {error}"
}
}

View file

@ -0,0 +1,172 @@
{
"app": {
"title": "Стратосферные полеты | ООО ЯКС",
"company": "ООО «Якутские Космические Системы»"
},
"nav": {
"predict": "Прогнозирование",
"track": "Слежение",
"login": "Войти",
"logout": "Выйти",
"account": "Учетная запись",
"scenarios": "Сохраненные сценарии",
"predictionHistory": "История прогнозов",
"trackingHistory": "История слежения",
"user": "Пользователь"
},
"login": {
"heading": "Вход в учетную запись",
"username": "Имя пользователя",
"password": "Пароль",
"submit": "Войти",
"submitting": "Вход...",
"back": "Назад",
"invalidCredentials": "Неверные учетные данные",
"fieldsRequired": "Пожалуйста, введите имя пользователя и пароль"
},
"panel": {
"scenario": "Сценарий",
"conditions": "Условия",
"about": "О проекте",
"layers": "Слои",
"results": "Результаты",
"settings": "Настройки",
"workspaces": "Рабочие области"
},
"scenario": {
"title": "Сценарий прогнозирования",
"select": "Сценарий",
"placeholder": "Новый сценарий...",
"searchPlaceholder": "Поиск сценариев...",
"apply": "Применить сценарий",
"applied": "Сценарий применен",
"appliedBody": "Сценарий \"{name}\" успешно применен.",
"notFound": "Сценарий не найден",
"notFoundBody": "Выбранный сценарий не существует.",
"updated": "Сценарий обновлен",
"updatedBody": "Сценарий \"{name}\" успешно обновлен.",
"updateError": "Ошибка обновления сценария",
"updateErrorBody": "Ошибка при обновлении сценария: {error}",
"save": "Сохранить сценарий",
"update": "Обновить сценарий",
"all": "Все сценарии",
"mode": "Режим сценария",
"model": "Модель атмосферы",
"dataset": "Набор данных",
"datasetAuto": "Выбрать автоматически",
"modified": "изменено",
"export": "Экспортировать результат",
"exportBtn": "Экспорт"
},
"predictionMode": {
"single": "Разовый",
"hourly": "Почасовой",
"ensemble": "Ансамблевый"
},
"profile": {
"standard_profile": "Обычный",
"float_profile": "Дрейф",
"reverse_profile": "Реверсивный",
"custom_profile": "Пользовательский"
},
"conditions": {
"title": "Условия прогнозирования",
"startTime": "Время старта (UTC)",
"startDate": "Дата старта",
"flightProfile": "Профиль полета",
"startPoint": "Точка старта",
"pointPlaceholder": "Новая точка...",
"pointSearchPlaceholder": "Поиск по точкам...",
"latLng": "Широта/Долгота",
"save": "Сохранить",
"launchAlt": "Высота старта (м)",
"burstAlt": "Высота разрыва (м)",
"ascentRate": "Скорость подъема (м/с)",
"descentRate": "Скорость спуска (м/с)",
"run": "Выполнить прогнозирование",
"profileEdit": "Профили подъема и спуска",
"ascentStage": "Стадия подъема",
"descentStage": "Стадия спуска",
"stageNone": "Нет",
"stageStandard": "Стандартная",
"stageCustom": "Пользовательская",
"openCurveEditor": "Открыть редактор кривых"
},
"workspaces": {
"title": "Рабочие области",
"empty": "Нет созданных областей.",
"add": "Добавить",
"rename": "Переименовать",
"delete": "Удалить",
"visible": "Показать/скрыть",
"color": "Цвет",
"opacity": "Прозрачность",
"run": "Рассчитать",
"running": "Расчет...",
"edit": "Редактировать",
"defaultName": "Рабочая область {n}",
"deleteConfirm": "Удалить рабочую область \"{name}\"?",
"runError": "Ошибка расчета: {error}"
},
"timeline": {
"title": "Временная шкала",
"time": "Время",
"altitude": "Высота",
"position": "Позиция",
"play": "Воспроизвести",
"pause": "Пауза",
"stop": "Сбросить",
"speed": "Скорость"
},
"settings": {
"title": "Настройки",
"language": "Язык",
"map": "Карта",
"baseLayer": "Базовый слой",
"showScale": "Показывать масштаб",
"showNavigation": "Навигационные элементы",
"units": "Единицы измерения",
"metric": "Метрические",
"imperial": "Имперские",
"saved": "Настройки сохранены"
},
"editor": {
"add": "Добавить",
"edit": "Редактировать",
"save": "Сохранить",
"update": "Обновить",
"delete": "Удалить",
"cancel": "Отмена",
"close": "Закрыть",
"searchPlaceholder": "Поиск..."
},
"points": {
"item": "точка",
"itemGenitive": "точки",
"items": "точки",
"name": "Название точки",
"lat": "Широта",
"lon": "Долгота",
"alt": "Высота",
"degrees": "Градусы",
"metersAsl": "Метры над ур. моря"
},
"common": {
"error": "Ошибка",
"success": "Успех",
"warning": "Предупреждение",
"info": "Информация",
"yes": "Да",
"no": "Нет"
},
"selection": {
"header": "Режим выбора координат",
"body": "Кликните на карту, чтобы выбрать координаты"
},
"forecast": {
"success": "Запрос прогноза",
"successBody": "Запрос прогноза успешно выполнен!",
"error": "Ошибка прогноза",
"errorBody": "Ошибка при получении прогноза: {error}"
}
}

View file

@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.

69
src/lib/map/Map.svelte Normal file
View file

@ -0,0 +1,69 @@
<script lang="ts">
import { onMount, onDestroy, type Snippet } from 'svelte';
import type { IMap } from './core';
import type { LngLatTuple } from '$domain';
import { createMapLibreMap } from './maplibre';
import { setMapContext } from './context';
interface Props {
center?: LngLatTuple;
zoom?: number;
baseLayer?: 'osm' | 'satellite';
showNavigationControl?: boolean;
showScaleControl?: boolean;
children?: Snippet;
onReady?: (map: IMap) => void;
}
let {
center = [129.1234, 62.1234],
zoom = 4,
baseLayer = 'osm',
showNavigationControl = true,
showScaleControl = true,
children,
onReady,
}: Props = $props();
let container: HTMLDivElement;
let map: IMap | null = $state(null);
/**
* Children must not render until the map's first `load` event. MapLibre
* throws if addSource/addLayer is called on an unloaded style, and this
* component is the natural gate for that invariant.
*/
let ready = $state(false);
setMapContext(() => map);
export function getInstance(): IMap | null {
return map;
}
onMount(() => {
map = createMapLibreMap({
container,
center,
zoom,
baseLayer,
showNavigationControl,
showScaleControl,
});
map.ready.then(() => {
ready = true;
if (map) onReady?.(map);
});
});
onDestroy(() => {
map?.dispose();
map = null;
ready = false;
});
</script>
<div class="map-container" bind:this={container}>
{#if ready && map}
{@render children?.()}
{/if}
</div>

17
src/lib/map/context.ts Normal file
View file

@ -0,0 +1,17 @@
import { getContext, setContext } from 'svelte';
import type { IMap } from './core';
const KEY = Symbol('lsv-map');
/** Child components fetch the `IMap` instance via `getMap()`. */
export interface MapContext {
get(): IMap | null;
}
export function setMapContext(getter: () => IMap | null): void {
setContext<MapContext>(KEY, { get: getter });
}
export function getMap(): IMap | null {
return getContext<MapContext | undefined>(KEY)?.get() ?? null;
}

121
src/lib/map/core.ts Normal file
View file

@ -0,0 +1,121 @@
import type { LatLngTuple, LngLatTuple } from '$domain';
/**
* Map abstraction.
*
* Goals:
* - Isolate all MapLibre-specific types inside src/lib/map/maplibre.ts.
* - Expose a small, map-library-agnostic vocabulary (markers, polylines,
* icons, events) so features (workspaces, timeline, tools) can be tested
* against the interface alone.
* - Support "scenes" named collections of layers owned by a feature so
* each workspace/tool can add/remove everything it owns atomically.
*
* If another library ever replaces MapLibre, implementing IMap is the only
* file that changes.
*/
export type MapEvent = 'click' | 'mousemove' | 'move' | 'zoom' | 'load';
export interface MapClickEvent {
lngLat: { lat: number; lng: number };
originalEvent: MouseEvent;
}
export type MapEventPayload = {
click: MapClickEvent;
mousemove: MapClickEvent;
move: { center: LngLatTuple; zoom: number };
zoom: { zoom: number };
load: undefined;
};
export type MapEventHandler<E extends MapEvent> = (e: MapEventPayload[E]) => void;
export interface MarkerOptions {
lngLat: LngLatTuple;
iconUrl?: string;
iconSize?: [number, number];
className?: string;
/** Optional HTML shown in a popup on hover. */
popupHtml?: string;
}
export interface LineOptions {
coords: LatLngTuple[];
color?: string;
width?: number;
opacity?: number;
dashArray?: [number, number];
}
export interface CircleOptions {
center: LngLatTuple;
/** Radius in pixels (visual; screen-space, matches MapLibre circle layers). */
radiusPx?: number;
color?: string;
opacity?: number;
strokeColor?: string;
strokeWidth?: number;
}
export interface Marker {
setLngLat(pos: LngLatTuple): void;
remove(): void;
}
export interface MapLayer {
readonly id: string;
remove(): void;
}
export interface Scene {
readonly name: string;
addLine(id: string, options: LineOptions): MapLayer;
addCircle(id: string, options: CircleOptions): MapLayer;
addMarker(id: string, options: MarkerOptions): Marker;
/** Remove an individual layer in this scene by its id. */
remove(id: string): void;
/** Remove everything added to this scene. */
clear(): void;
/** Called by IMap.dispose() to release resources. */
dispose(): void;
}
export interface IMap {
ready: Promise<void>;
on<E extends MapEvent>(event: E, handler: MapEventHandler<E>): () => void;
setCenter(pos: LngLatTuple, zoom?: number): void;
panTo(pos: LngLatTuple, durationMs?: number): void;
fitBounds(coords: LatLngTuple[], paddingPx?: number): void;
getZoom(): number;
setZoom(zoom: number): void;
setCursor(cursor: string | null): void;
/**
* Get or create a named scene. Scenes are the unit of layer ownership:
* a feature adds all its layers through a scene and calls `.clear()` to
* remove them in one step.
*/
scene(name: string): Scene;
disposeScene(name: string): void;
/** Underlying implementation instance, for code that needs library-specific APIs. Use sparingly. */
getRawInstance(): unknown;
dispose(): void;
}
export interface MapInit {
container: HTMLElement;
center: LngLatTuple;
zoom: number;
baseLayer?: 'osm' | 'satellite';
showNavigationControl?: boolean;
showScaleControl?: boolean;
}
export type MapFactory = (init: MapInit) => IMap;

9
src/lib/map/index.ts Normal file
View file

@ -0,0 +1,9 @@
export * from './core';
export { createMapLibreMap } from './maplibre';
export { plotPrediction, plotTelemetry, plotAnimatedMarker } from './layers';
export type { TrajectoryStyle } from './layers';
export { startCoordinateSelection } from './tools/selection';
export { startMeasure } from './tools/measure';
export type { MeasureHandle, MeasureOptions } from './tools/measure';
export { setMapContext, getMap } from './context';
export { default as Map } from './Map.svelte';

124
src/lib/map/layers.ts Normal file
View file

@ -0,0 +1,124 @@
import type { Prediction, Telemetry } from '$domain';
import { toLngLat } from '$domain';
import type { IMap, Scene } from './core';
/**
* Plot helpers for high-level domain objects. These live outside MapLibreMap
* so they can be reused against any IMap implementation.
*
* Icons are served from /static; pass explicit overrides if a workspace
* should use custom markers.
*/
export interface TrajectoryStyle {
color?: string;
width?: number;
opacity?: number;
launchIcon?: string;
landingIcon?: string;
burstIcon?: string;
iconSize?: [number, number];
}
const DEFAULT_STYLE: Required<Omit<TrajectoryStyle, 'launchIcon' | 'landingIcon' | 'burstIcon'>> &
Pick<TrajectoryStyle, 'launchIcon' | 'landingIcon' | 'burstIcon'> = {
color: '#000000',
width: 3,
opacity: 1,
iconSize: [12, 12],
launchIcon: '/target-blue.png',
landingIcon: '/target-red.png',
burstIcon: '/pop-marker.png',
};
export function plotPrediction(
scene: Scene,
prediction: Prediction,
style: TrajectoryStyle = {},
): void {
const s = { ...DEFAULT_STYLE, ...style };
scene.clear();
scene.addLine('path', {
coords: prediction.flight_path,
color: s.color,
width: s.width,
opacity: s.opacity,
});
scene.addMarker('launch', {
lngLat: toLngLat(prediction.launch.latlng),
iconUrl: s.launchIcon,
iconSize: s.iconSize,
popupHtml: `<b>Launch</b><br>${prediction.launch.latlng.lat.toFixed(6)}, ${prediction.launch.latlng.lng.toFixed(6)}`,
});
scene.addMarker('landing', {
lngLat: toLngLat(prediction.landing.latlng),
iconUrl: s.landingIcon,
iconSize: s.iconSize,
popupHtml: `<b>Landing</b><br>${prediction.landing.latlng.lat.toFixed(6)}, ${prediction.landing.latlng.lng.toFixed(6)}`,
});
scene.addMarker('burst', {
lngLat: toLngLat(prediction.burst.latlng),
iconUrl: s.burstIcon,
iconSize: [s.iconSize[0] + 4, s.iconSize[1] + 4],
popupHtml: `<b>Burst</b><br>${prediction.burst.latlng.lat.toFixed(6)}, ${prediction.burst.latlng.lng.toFixed(6)}`,
});
}
export function plotTelemetry(
map: IMap,
scene: Scene,
telemetry: Telemetry,
style: TrajectoryStyle = {},
): void {
const s = { ...DEFAULT_STYLE, ...style };
scene.clear();
scene.addLine('path', {
coords: telemetry.flight_path,
color: s.color,
width: s.width,
opacity: s.opacity,
});
scene.addMarker('launch', {
lngLat: toLngLat(telemetry.launch.latlng),
iconUrl: s.launchIcon,
iconSize: s.iconSize,
});
telemetry.datapoints.forEach((p, i) => {
scene.addMarker(`point-${i}`, {
lngLat: [p.longitude, p.latitude],
iconUrl: '/marker-sm-red.png',
iconSize: [8, 8],
popupHtml: `<b>${p.datetime}</b><br>${p.latitude.toFixed(6)}, ${p.longitude.toFixed(6)}`,
});
});
if (telemetry.flight_path.length > 0) {
map.fitBounds(telemetry.flight_path, 50);
}
}
export function plotAnimatedMarker(scene: Scene, lng: number, lat: number): void {
scene.clear();
scene.addCircle('marker-ring', {
center: [lng, lat],
radiusPx: 14,
color: '#FF6B6B',
opacity: 0.3,
strokeColor: '#FF1744',
strokeWidth: 0,
});
scene.addCircle('marker-core', {
center: [lng, lat],
radiusPx: 6,
color: '#FF1744',
strokeColor: '#ffffff',
strokeWidth: 2,
});
}

316
src/lib/map/maplibre.ts Normal file
View file

@ -0,0 +1,316 @@
import maplibregl, {
type Map as MLMap,
type LngLatLike,
type MarkerOptions as MLMarkerOptions,
} from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import type {
CircleOptions,
IMap,
LineOptions,
MapEvent,
MapEventHandler,
MapEventPayload,
MapInit,
MapLayer,
Marker,
MarkerOptions,
Scene,
} from './core';
import type { LatLngTuple, LngLatTuple } from '$domain';
/** Map common base-layer names to MapLibre style JSON. */
const BASE_STYLES: Record<NonNullable<MapInit['baseLayer']>, maplibregl.StyleSpecification> = {
osm: {
version: 8,
sources: {
osm: {
type: 'raster',
tiles: ['https://a.tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
},
},
layers: [{ id: 'osm', type: 'raster', source: 'osm', minzoom: 0, maxzoom: 19 }],
},
satellite: {
version: 8,
sources: {
sat: {
type: 'raster',
tiles: [
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
],
tileSize: 256,
attribution: 'Tiles &copy; Esri',
},
},
layers: [{ id: 'sat', type: 'raster', source: 'sat', minzoom: 0, maxzoom: 19 }],
},
};
class MapLibreScene implements Scene {
private sources = new Set<string>();
private layers = new Set<string>();
private markers = new Map<string, maplibregl.Marker>();
constructor(
public readonly name: string,
private map: MLMap,
) {}
private scopeId(id: string): string {
return `${this.name}__${id}`;
}
addLine(id: string, options: LineOptions): MapLayer {
const layerId = this.scopeId(id);
const coords = options.coords.map<[number, number]>((c) => [c[1], c[0]]);
if (this.map.getSource(layerId)) this.remove(id);
this.map.addSource(layerId, {
type: 'geojson',
data: {
type: 'Feature',
properties: {},
geometry: { type: 'LineString', coordinates: coords },
},
});
this.map.addLayer({
id: layerId,
type: 'line',
source: layerId,
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: {
'line-color': options.color ?? '#000',
'line-width': options.width ?? 3,
'line-opacity': options.opacity ?? 1,
...(options.dashArray ? { 'line-dasharray': options.dashArray } : {}),
},
});
this.sources.add(layerId);
this.layers.add(layerId);
return {
id: layerId,
remove: () => this.remove(id),
};
}
addCircle(id: string, options: CircleOptions): MapLayer {
const layerId = this.scopeId(id);
if (this.map.getSource(layerId)) this.remove(id);
this.map.addSource(layerId, {
type: 'geojson',
data: {
type: 'Feature',
properties: {},
geometry: { type: 'Point', coordinates: options.center },
},
});
this.map.addLayer({
id: layerId,
type: 'circle',
source: layerId,
paint: {
'circle-radius': options.radiusPx ?? 6,
'circle-color': options.color ?? '#0b5ed7',
'circle-opacity': options.opacity ?? 1,
'circle-stroke-color': options.strokeColor ?? '#ffffff',
'circle-stroke-width': options.strokeWidth ?? 2,
},
});
this.sources.add(layerId);
this.layers.add(layerId);
return {
id: layerId,
remove: () => this.remove(id),
};
}
addMarker(id: string, options: MarkerOptions): Marker {
const scoped = this.scopeId(id);
const existing = this.markers.get(scoped);
if (existing) existing.remove();
let mlOptions: MLMarkerOptions | undefined;
if (options.iconUrl) {
const el = document.createElement('div');
el.className = options.className ?? 'lsv-marker';
el.style.backgroundImage = `url(${options.iconUrl})`;
const [w, h] = options.iconSize ?? [12, 12];
el.style.width = `${w}px`;
el.style.height = `${h}px`;
el.style.backgroundSize = '100%';
mlOptions = { element: el };
}
const marker = new maplibregl.Marker(mlOptions).setLngLat(options.lngLat as LngLatLike);
if (options.popupHtml) {
const popup = new maplibregl.Popup({ offset: 16, closeButton: false }).setHTML(
options.popupHtml,
);
marker.setPopup(popup);
marker.getElement().addEventListener('mouseenter', () => marker.togglePopup());
marker.getElement().addEventListener('mouseleave', () => marker.togglePopup());
}
marker.addTo(this.map);
this.markers.set(scoped, marker);
return {
setLngLat: (pos) => marker.setLngLat(pos as LngLatLike),
remove: () => this.remove(id),
};
}
remove(id: string): void {
const scoped = this.scopeId(id);
if (this.map.getLayer(scoped)) this.map.removeLayer(scoped);
if (this.map.getSource(scoped)) this.map.removeSource(scoped);
this.layers.delete(scoped);
this.sources.delete(scoped);
const marker = this.markers.get(scoped);
if (marker) {
marker.remove();
this.markers.delete(scoped);
}
}
clear(): void {
for (const id of Array.from(this.layers)) {
if (this.map.getLayer(id)) this.map.removeLayer(id);
}
for (const id of Array.from(this.sources)) {
if (this.map.getSource(id)) this.map.removeSource(id);
}
for (const marker of this.markers.values()) marker.remove();
this.layers.clear();
this.sources.clear();
this.markers.clear();
}
dispose(): void {
this.clear();
}
}
export class MapLibreMap implements IMap {
private map: MLMap;
private scenes = new Map<string, MapLibreScene>();
public readonly ready: Promise<void>;
constructor(init: MapInit) {
this.map = new maplibregl.Map({
container: init.container,
style: BASE_STYLES[init.baseLayer ?? 'osm'],
center: init.center,
zoom: init.zoom,
});
if (init.showNavigationControl !== false) {
this.map.addControl(new maplibregl.NavigationControl(), 'bottom-left');
}
if (init.showScaleControl !== false) {
this.map.addControl(new maplibregl.ScaleControl({ maxWidth: 100, unit: 'metric' }), 'bottom-right');
}
this.ready = new Promise((resolve) => this.map.once('load', () => resolve()));
}
on<E extends MapEvent>(event: E, handler: MapEventHandler<E>): () => void {
const wrapped = (e: unknown) => {
switch (event) {
case 'click':
case 'mousemove': {
const ev = e as maplibregl.MapMouseEvent;
(handler as MapEventHandler<'click'>)({
lngLat: { lat: ev.lngLat.lat, lng: ev.lngLat.lng },
originalEvent: ev.originalEvent,
});
break;
}
case 'move':
(handler as MapEventHandler<'move'>)({
center: [this.map.getCenter().lng, this.map.getCenter().lat],
zoom: this.map.getZoom(),
});
break;
case 'zoom':
(handler as MapEventHandler<'zoom'>)({ zoom: this.map.getZoom() });
break;
case 'load':
(handler as MapEventHandler<'load'>)(undefined as MapEventPayload['load']);
break;
}
};
this.map.on(event as 'click', wrapped);
return () => this.map.off(event as 'click', wrapped);
}
setCenter(pos: LngLatTuple, zoom?: number): void {
this.map.setCenter(pos);
if (zoom !== undefined) this.map.setZoom(zoom);
}
panTo(pos: LngLatTuple, durationMs?: number): void {
this.map.panTo(pos, durationMs ? { duration: durationMs } : undefined);
}
fitBounds(coords: LatLngTuple[], paddingPx = 50): void {
if (coords.length === 0) return;
const first: [number, number] = [coords[0][1], coords[0][0]];
const bounds = coords.reduce(
(b, c) => b.extend([c[1], c[0]] as [number, number]),
new maplibregl.LngLatBounds(first, first),
);
this.map.fitBounds(bounds, { padding: paddingPx });
}
getZoom(): number {
return this.map.getZoom();
}
setZoom(zoom: number): void {
this.map.setZoom(zoom);
}
setCursor(cursor: string | null): void {
this.map.getCanvas().style.cursor = cursor ?? '';
}
scene(name: string): Scene {
let scene = this.scenes.get(name);
if (!scene) {
scene = new MapLibreScene(name, this.map);
this.scenes.set(name, scene);
}
return scene;
}
disposeScene(name: string): void {
const scene = this.scenes.get(name);
if (!scene) return;
scene.dispose();
this.scenes.delete(name);
}
getRawInstance(): MLMap {
return this.map;
}
dispose(): void {
for (const s of this.scenes.values()) s.dispose();
this.scenes.clear();
this.map.remove();
}
}
export function createMapLibreMap(init: MapInit): IMap {
return new MapLibreMap(init);
}

View file

@ -0,0 +1,75 @@
import type { IMap } from '../core';
import type { LatLngTuple } from '$domain';
import { distHaversine } from '$domain';
/**
* Library-agnostic measure tool. Click to drop points; each new point draws
* a segment and reports the running total distance in kilometers.
*
* Returns a disposer that removes all overlays and deactivates listeners.
*/
export interface MeasureHandle {
dispose(): void;
reset(): void;
}
export interface MeasureOptions {
onUpdate?: (totalKm: number, points: LatLngTuple[]) => void;
color?: string;
width?: number;
sceneName?: string;
}
export function startMeasure(map: IMap, options: MeasureOptions = {}): MeasureHandle {
const scene = map.scene(options.sceneName ?? 'measure');
const points: LatLngTuple[] = [];
let segmentCounter = 0;
let pointCounter = 0;
let total = 0;
map.setCursor('crosshair');
const off = map.on('click', (e) => {
const latlng: LatLngTuple = [e.lngLat.lat, e.lngLat.lng];
points.push(latlng);
scene.addMarker(`p-${pointCounter++}`, {
lngLat: [e.lngLat.lng, e.lngLat.lat],
iconSize: [8, 8],
className: 'lsv-measure-dot',
});
if (points.length > 1) {
const prev = points[points.length - 2];
total += distHaversine(
{ lat: prev[0], lng: prev[1] },
{ lat: latlng[0], lng: latlng[1] },
);
scene.addLine(`seg-${segmentCounter++}`, {
coords: [prev, latlng],
color: options.color ?? '#2563eb',
width: options.width ?? 2,
});
}
options.onUpdate?.(total, [...points]);
});
function reset() {
scene.clear();
points.length = 0;
total = 0;
segmentCounter = 0;
pointCounter = 0;
options.onUpdate?.(0, []);
}
return {
dispose() {
off();
map.setCursor(null);
map.disposeScene(options.sceneName ?? 'measure');
},
reset,
};
}

View file

@ -0,0 +1,23 @@
import type { IMap } from '../core';
/**
* One-shot coordinate selection tool: next click reports a lat/lng and the
* tool disarms itself.
*/
export function startCoordinateSelection(
map: IMap,
onSelect: (coords: { lat: number; lng: number }) => void,
): () => void {
map.setCursor('crosshair');
const off = map.on('click', (e) => {
map.setCursor(null);
off();
onSelect(e.lngLat);
});
return () => {
map.setCursor(null);
off();
};
}

View file

@ -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:
'&copy; <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;
}
}

View file

@ -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;
}

View file

@ -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,
};
}

2
src/lib/state/index.ts Normal file
View file

@ -0,0 +1,2 @@
export { persisted } from './persisted';
export type { Serializer, PersistedOptions } from './persisted';

131
src/lib/state/persisted.ts Normal file
View file

@ -0,0 +1,131 @@
import { writable, type Writable, type Updater } from 'svelte/store';
import { browser } from '$app/environment';
/**
* Writable store backed by localStorage, synced across tabs via
* BroadcastChannel. The store initializes with a snapshot from storage if
* present and falls back to `initial` otherwise. Subsequent writes are
* serialized to localStorage and broadcast to other tabs.
*
* Notes
* -----
* - Serialization uses JSON by default; pass custom `serializer` for Dates etc.
* - Reads on the server (or before hydration) return `initial` no storage
* access happens until the `browser` flag is true.
* - Removing the key from storage (via another tab or devtools) resets the
* store to `initial` on the next notification.
* - A per-store "suppress rebroadcast" flag prevents a ping-pong loop when a
* remote update arrives: the incoming `set` must not trigger a new
* broadcast or we'd echo forever.
*/
export interface Serializer<T> {
parse: (raw: string) => T;
stringify: (value: T) => string;
}
const json: Serializer<unknown> = { parse: JSON.parse, stringify: JSON.stringify };
export interface PersistedOptions<T> {
serializer?: Serializer<T>;
/**
* Skip cross-tab sync if multiple stores must not share a channel or if a
* store's value is too large to broadcast efficiently.
*/
syncTabs?: boolean;
}
const CHANNEL = 'leaflet-svelte:store';
let channel: BroadcastChannel | null = null;
function getChannel(): BroadcastChannel | null {
if (!browser) return null;
if (typeof BroadcastChannel === 'undefined') return null;
channel ??= new BroadcastChannel(CHANNEL);
return channel;
}
export function persisted<T>(
key: string,
initial: T,
options: PersistedOptions<T> = {},
): Writable<T> {
const serializer = (options.serializer ?? (json as Serializer<T>)) as Serializer<T>;
const syncTabs = options.syncTabs ?? true;
const readFromStorage = (): T => {
if (!browser) return initial;
const raw = localStorage.getItem(key);
if (raw === null) return initial;
try {
return serializer.parse(raw);
} catch (e) {
console.warn(`[persisted] failed to parse "${key}":`, e);
return initial;
}
};
const store = writable<T>(readFromStorage());
const write = (value: T) => {
if (!browser) return;
try {
localStorage.setItem(key, serializer.stringify(value));
} catch (e) {
console.warn(`[persisted] failed to write "${key}":`, e);
}
};
let firstTick = true;
let remoteUpdate = false;
if (browser) {
store.subscribe((value) => {
// The first subscribe fires synchronously with the initial value. We
// already read it from storage, so re-writing it is a no-op; skipping
// also avoids a spurious broadcast to other tabs on every page load.
if (firstTick) {
firstTick = false;
return;
}
write(value);
if (syncTabs && !remoteUpdate) {
getChannel()?.postMessage({ key, value });
}
});
if (syncTabs) {
getChannel()?.addEventListener('message', (event) => {
if (event.data?.key !== key) return;
remoteUpdate = true;
try {
store.set(event.data.value);
} finally {
remoteUpdate = false;
}
});
}
window.addEventListener('storage', (event) => {
if (event.storageArea !== localStorage || event.key !== key) return;
remoteUpdate = true;
try {
if (event.newValue === null) {
store.set(initial);
} else {
store.set(serializer.parse(event.newValue));
}
} catch {
// ignore malformed writes from another tab
} finally {
remoteUpdate = false;
}
});
}
return {
subscribe: store.subscribe,
set: store.set,
update: (fn: Updater<T>) => store.update(fn),
};
}

View file

@ -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[]>([]);

View file

@ -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
};
}

View file

@ -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;
}

View file

@ -0,0 +1,60 @@
<script lang="ts">
import { Card, CardHeader, CardBody, Button, Icon } from '@sveltestrap/sveltestrap';
import type { Snippet } from 'svelte';
/**
* Standardized collapsible panel card used by every side-panel feature.
* The header shows a title and a caret; clicking the header toggles body
* visibility. The `initialCollapsed` prop seeds state but callers can
* drive it externally via `bind:collapsed` if they want to persist state.
*/
interface Props {
title?: string;
collapsed?: boolean;
headerClass?: string;
bodyClass?: string;
variant?: 'primary' | 'secondary' | 'light';
headerExtra?: Snippet;
children?: Snippet;
}
let {
title = '',
collapsed = $bindable(false),
headerClass = '',
bodyClass = '',
variant = 'primary',
headerExtra,
children,
}: Props = $props();
function toggle() {
collapsed = !collapsed;
}
</script>
<Card class="mb-2 collapsible-card">
<CardHeader
class="d-flex justify-content-between align-items-center card-header p-1 px-3 bg-{variant} text-white {headerClass}"
style="cursor:pointer;">
<button
type="button"
class="d-flex w-100 justify-content-between align-items-center bg-transparent border-0 p-0 text-white"
style="width:100%;"
aria-expanded={!collapsed}
onclick={toggle}>
<b class="card-title mb-0 text-white p-0">{title}</b>
<span class="d-flex align-items-center gap-2">
{#if headerExtra}{@render headerExtra()}{/if}
<Button class="p-0" size="sm" color={variant}>
<Icon name={collapsed ? 'caret-left-fill' : 'caret-down-fill'} class="text-white" />
</Button>
</span>
</button>
</CardHeader>
{#if !collapsed}
<CardBody class={bodyClass}>
{@render children?.()}
</CardBody>
{/if}
</Card>

View file

@ -0,0 +1,49 @@
<script lang="ts">
import { Modal, ModalHeader, ModalBody, ModalFooter, Button } from '@sveltestrap/sveltestrap';
import type { Snippet } from 'svelte';
interface Props {
isOpen?: boolean;
title?: string;
confirmText?: string;
cancelText?: string;
confirmVariant?: string;
cancelVariant?: string;
onconfirm?: () => void;
oncancel?: () => void;
children?: Snippet;
}
let {
isOpen = $bindable(false),
title = 'Confirm',
confirmText = 'Confirm',
cancelText = 'Cancel',
confirmVariant = 'primary',
cancelVariant = 'secondary',
onconfirm,
oncancel,
children,
}: Props = $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()}{/if}
</ModalBody>
<ModalFooter>
<Button color={cancelVariant} on:click={handleCancel}>{cancelText}</Button>
<Button color={confirmVariant} on:click={handleConfirm}>{confirmText}</Button>
</ModalFooter>
</Modal>

View file

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

View file

@ -0,0 +1,33 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import type { Snippet } from 'svelte';
interface Props extends HTMLAttributes<HTMLDivElement> {
id?: string;
label?: string;
children?: Snippet;
}
let { id, label = '', class: className = '', children, ...rest }: Props = $props();
</script>
<div {id} class="label-group {className}" {...rest}>
<div class="btn btn-link p-0 d-flex align-items-center w-100 text-decoration-none label-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>
</div>
<div class="p-2 border border-top-0 label-content">
{@render children?.()}
</div>
</div>
<style>
.label-header {
margin-bottom: -0.75em;
}
.label-content {
padding-top: 0.75em !important;
}
</style>

View file

@ -0,0 +1,38 @@
<script lang="ts">
import type { Snippet } from 'svelte';
/**
* Fixed-position side panel attached to the map. Stops mouse/touch
* propagation so interactions inside the panel don't pan the map.
*/
interface Props {
position?: 'left' | 'right';
children?: Snippet;
}
let { position = 'left', children }: Props = $props();
let element: HTMLDivElement | null = $state(null);
export function getElement(): HTMLDivElement | null {
return element;
}
function stop(e: Event) {
e.stopPropagation();
}
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
bind:this={element}
class="panel-container-{position}"
onclick={stop}
ondblclick={stop}
onmousedown={stop}
ontouchstart={stop}
onwheel={stop}
role="complementary">
{@render children?.()}
</div>

View file

@ -0,0 +1,191 @@
<script lang="ts" generics="V">
import { onMount } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
type Option = { value: V; label: string };
interface Props extends HTMLAttributes<HTMLDivElement> {
id?: string;
options?: Option[];
selected?: V | null;
placeholder?: string;
searchPlaceholder?: string;
disabled?: boolean;
clearable?: boolean;
onChange?: (value: V | null) => void;
}
let {
id = 'select-searchable',
options = [],
selected = $bindable<V | null>(null),
placeholder = 'Select an option...',
searchPlaceholder = 'Search...',
disabled = false,
clearable = false,
onChange,
class: className = '',
...restProps
}: Props = $props();
let isOpen = $state(false);
let searchTerm = $state('');
let selectElement = $state<HTMLElement>();
let dropdownElement = $state<HTMLElement>();
let searchInput = $state<HTMLInputElement>();
let dropdownStyle = $state('');
let filteredOptions = $derived(
options.filter((o) => o.label.toLowerCase().includes(searchTerm.toLowerCase())),
);
let selectedLabel = $derived(options.find((o) => o.value === selected)?.label ?? '');
function positionDropdown() {
if (!selectElement || !dropdownElement) return;
const rect = selectElement.getBoundingClientRect();
const height = dropdownElement.offsetHeight;
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const placeBelow = spaceBelow >= height || spaceBelow >= spaceAbove;
dropdownStyle = [
'position:fixed',
`top:${placeBelow ? rect.bottom : 'auto'}${typeof rect.bottom === 'number' && placeBelow ? 'px' : ''}`,
`bottom:${placeBelow ? 'auto' : `${window.innerHeight - rect.top}px`}`,
`left:${rect.left}px`,
`min-width:${rect.width}px`,
].join(';');
}
function toggle() {
if (disabled) return;
isOpen = !isOpen;
if (isOpen) {
searchTerm = '';
queueMicrotask(() => {
positionDropdown();
searchInput?.focus();
});
}
}
function pick(option: Option) {
selected = option.value;
isOpen = false;
searchTerm = '';
onChange?.(option.value);
}
function clear(e: Event) {
e.stopPropagation();
selected = null;
onChange?.(null);
}
function handleOutside(e: MouseEvent) {
if (selectElement && !selectElement.contains(e.target as Node)) isOpen = false;
}
$effect(() => {
if (!isOpen) return;
const listener = () => positionDropdown();
window.addEventListener('scroll', listener, true);
window.addEventListener('resize', listener);
return () => {
window.removeEventListener('scroll', listener, true);
window.removeEventListener('resize', listener);
};
});
onMount(() => positionDropdown());
</script>
<svelte:window onclick={handleOutside} />
<div
bind:this={selectElement}
{id}
class="form-control form-select select-container {className}"
class:disabled
class:show={isOpen}
role="combobox"
aria-haspopup="listbox"
aria-expanded={isOpen}
tabindex={disabled ? -1 : 0}
onclick={toggle}
onkeydown={(e) => e.key === 'Enter' && toggle()}
{...restProps}>
<span class:text-muted={selected == null}>{selectedLabel || placeholder}</span>
{#if clearable && selected != null}
<button type="button" class="clear-btn" tabindex="-1" aria-label="Clear selection" onclick={clear}>
&#10005;
</button>
{/if}
{#if isOpen}
<div class="pt-0 dropdown-menu show" bind:this={dropdownElement} style={dropdownStyle}>
<div class="p-2">
<input
bind:this={searchInput}
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();
pick(option);
}}
role="option"
aria-selected={option.value === selected}>
{option.label}
</button>
{:else}
<div class="dropdown-item text-muted disabled">No options found</div>
{/each}
</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;
display: flex;
align-items: center;
justify-content: center;
}
.clear-btn:focus {
outline: none;
}
</style>

View file

@ -1,31 +1,31 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import type { HTMLAttributes } from 'svelte/elements';
import type { Snippet } from 'svelte';
interface Props extends HTMLAttributes<HTMLDivElement> {
id?: string | undefined;
id?: string;
label?: string;
expanded?: boolean;
children?: () => any;
children?: Snippet;
}
let {
id,
label = "",
label = '',
expanded = $bindable(false),
class: className = "",
class: className = '',
children,
...restProps
...rest
}: Props = $props();
</script>
<div {id} class="spoiler-group {className}" {...restProps}>
<div {id} class="spoiler-group {className}" {...rest}>
<button
type="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="font-monospace fs-5 ms-1 fw-bold text-muted spoiler-icon">{expanded ? '' : '+'}</span>
<span class="small text-nowrap ms-1">{label}</span>
<div class="flex-fill border-top ms-1"></div>
</button>
@ -35,7 +35,7 @@
{@render children?.()}
</div>
{:else}
<div style="padding-top: 0.75em;" class={className}></div>
<div style="padding-top: 0.75em;"></div>
{/if}
</div>
@ -46,12 +46,10 @@
.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;
}

View file

@ -1,24 +1,28 @@
<script lang="ts">
import { Icon } from "@sveltestrap/sveltestrap";
<script lang="ts" generics="T extends string">
import { Icon } from '@sveltestrap/sveltestrap';
type Tab = {
id: string;
interface Tab {
id: T;
icon: string;
label: string;
};
}
export let tabs: Tab[] = [];
export let activeTab: string;
export let justify: 'start' | 'center' | 'end' = 'start';
interface Props {
tabs: Tab[];
active: T;
justify?: 'start' | 'center' | 'end';
}
let { tabs, active = $bindable(), justify = 'start' }: Props = $props();
</script>
<div class="d-flex justify-content-{justify} mb-1 gap-1">
{#each tabs as tab (tab.id)}
<button
type="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">
class:active={active === tab.id}
onclick={() => (active = tab.id)}>
<Icon name={tab.icon} class="custom-tab-icon" />
<span class="custom-tab-label">{tab.label}</span>
</button>
@ -31,10 +35,7 @@
background: var(--bs-body-bg);
}
.custom-tab.active {
background-color: var(--bs-primary) !important;
color: var(--bs-btn-active-color);
}
.custom-tab.active,
.custom-tab:hover {
background-color: var(--bs-primary) !important;
color: var(--bs-btn-active-color);

40
src/lib/ui/Toast.svelte Normal file
View file

@ -0,0 +1,40 @@
<script lang="ts">
import { Toast, ToastBody, ToastHeader, Icon } from '@sveltestrap/sveltestrap';
import { toasts, removeToast, type ToastColor } from './toasts';
const ICONS: Record<ToastColor, string> = {
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',
};
</script>
<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}
on:close={() => removeToast(toast.id)}>
<ToastHeader toggle={() => removeToast(toast.id)} class={`text-${toast.color}`}>
<Icon slot="icon" name={ICONS[toast.color]} class="me-2" color={toast.color} />
{toast.header}
</ToastHeader>
<ToastBody>
{toast.body}
</ToastBody>
</Toast>
{/each}
</div>
<style>
.toast-container {
z-index: 1090;
}
</style>

View file

@ -1,9 +1,9 @@
<script module lang="ts">
export type EditorConfig<T> = {
export interface ListEditorConfig {
showTable?: boolean;
closeOnSave?: boolean;
closeOnDelete?: boolean;
searchBy?: (keyof T)[];
searchBy?: string[];
labels?: {
item?: string;
itemGenitive?: string;
@ -17,17 +17,17 @@
close?: string;
searchPlaceholder?: string;
};
};
}
export type EditorApi<T> = {
export interface ListEditorApi<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 { TableHandler } from '@vincjo/datatables';
import {
Modal,
Button,
@ -38,80 +38,73 @@
PaginationLink,
Input,
InputGroup,
} from "@sveltestrap/sveltestrap";
import { addToast } from "$lib/components/ui/Toast.svelte";
import ConfirmationPrompt from "../ConfirmationPrompt.svelte";
import type { Snippet } from "svelte";
} from '@sveltestrap/sveltestrap';
import { addToast } from '../toasts';
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<{
interface Props {
isOpen?: boolean;
items?: T[];
itemFactory: () => T;
api: EditorApi<T>;
config?: EditorConfig<T>;
api: ListEditorApi<T>;
config?: ListEditorConfig;
onClose?: () => void;
onSave?: (item: T) => void;
onSelect?: (item: T) => void;
tableHeader: Renderable;
tableRow: Renderable;
formFields: Renderable;
}>();
tableHeader: Snippet;
tableRow: Snippet<[{ row: T }]>;
formFields: Snippet<[{ item: T }]>;
}
const DEFAULT_LABELS = {
item: 'item',
itemGenitive: 'item',
items: 'items',
add: 'Add',
edit: 'Edit',
save: 'Save',
update: 'Update',
delete: 'Delete',
cancel: 'Cancel',
close: 'Close',
searchPlaceholder: 'Search...',
};
let {
isOpen = $bindable(false),
items = $bindable([] as T[]),
itemFactory,
api,
config = {},
onClose = () => {},
onSave = (_i: T) => {},
onSelect = (_i: T) => {},
tableHeader,
tableRow,
formFields,
}: Props = $props();
const labels = $derived({ ...DEFAULT_LABELS, ...(config.labels ?? {}) });
const searchBy = $derived((config.searchBy ?? ['name']) as (keyof T)[]);
const showTableProp = $derived(config.showTable ?? false);
let isEditing = $state(false);
let isAlertVisible = $state(false);
let isConfirmationVisible = $state(false);
let isTableVisible = $derived(config.showTable);
let alertText = $state("");
let isTableVisible = $state(config.showTable ?? false);
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));
const search = $derived(table.createSearch(searchBy));
$effect(() => {
table.setRows(items);
});
export function open(item: T | null = null, showTable: boolean = config.showTable) {
export function open(item: T | null = null, showTable: boolean = showTableProp) {
if (item) {
handleEdit(item);
} else {
@ -133,9 +126,7 @@
}
function resetForm(clearSelection = true) {
if (clearSelection) {
selectedItem = null;
}
if (clearSelection) selectedItem = null;
currentItem = itemFactory();
isEditing = false;
hideAlert();
@ -149,22 +140,20 @@
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);
const updated = await api.update(currentItem);
items = items.map((i) => (i.id === updated.id ? updated : i));
toastOk('updated', updated.name);
onSave(updated);
} else {
const savedItem = await api.save(currentItem);
items = [...items, savedItem];
showToast("сохранен(а)", savedItem.name);
onSave(savedItem);
const saved = await api.save(currentItem);
items = [...items, saved];
toastOk('saved', saved.name);
onSave(saved);
}
resetForm();
if (config.closeOnSave) {
close();
}
} catch (error: any) {
showAlert(`Ошибка: ${error.message}`);
if (config.closeOnSave) close();
} catch (error: unknown) {
showAlert(`Error: ${(error as Error).message}`);
}
}
@ -177,13 +166,11 @@
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}`);
items = items.filter((i) => i.id !== selectedItem!.id);
toastOk('deleted', selectedItem.name);
if (config.closeOnDelete) close();
} catch (error: unknown) {
showAlert(`Error: ${(error as Error).message}`);
} finally {
isConfirmationVisible = false;
resetForm();
@ -197,30 +184,27 @@
function hideAlert() {
isAlertVisible = false;
alertText = "";
alertText = '';
}
function showToast(action: string, name: string) {
function toastOk(action: string, name: string) {
const itemLabel = labels.item.charAt(0).toUpperCase() + labels.item.slice(1);
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",
header: `${itemLabel} ${action}`,
body: `${itemLabel} "${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}`,
? `${labels.edit} ${labels.itemGenitive}`
: isTableVisible
? `${labels.items}`
: `${labels.add} ${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);
const submitButtonText = $derived(isEditing ? labels.update : labels.save);
</script>
<Modal
@ -230,7 +214,7 @@
fade={false}
backdrop={true}
scrollable
class={isConfirmationVisible ? "modal-tinted" : ""}>
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>
@ -241,7 +225,7 @@
<Input
type="text"
class="form-control-sm pe-5"
placeholder={config.labels.searchPlaceholder}
placeholder={labels.searchPlaceholder}
bind:value={search.value}
oninput={() => search.set()} />
{#if search.value}
@ -251,7 +235,7 @@
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.value = '';
search.set();
}}>
<Icon name="x" style="font-size: 16px;" />
@ -266,33 +250,30 @@
<tbody>
{#each table.rows as row (row.id)}
<tr>
{@render tableRow({ row })}
{@render tableRow({ row: row as T })}
<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>
color="secondary"
onclick={() => handleSelect(row as T)}
class="p-0 border-0 bg-transparent text-success px-1">
<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>
color="primary"
onclick={() => handleEdit(row as T)}
class="p-0 border-0 bg-transparent text-primary px-1">
<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>
size="sm"
color="danger"
onclick={() => confirmDelete(row as T)}
class="p-0 border-0 bg-transparent text-danger px-1">
<Icon name="trash" />
</Button>
</InputGroup>
</td>
</tr>
@ -302,7 +283,7 @@
</div>
<Pagination aria-label="Page navigation" size="sm">
<PaginationItem>
<PaginationLink previous onclick={() => table.setPage("previous")} />
<PaginationLink previous onclick={() => table.setPage('previous')} />
</PaginationItem>
{#each table.pagesWithEllipsis as page}
<PaginationItem active={table.currentPage === page}>
@ -310,18 +291,13 @@
</PaginationItem>
{/each}
<PaginationItem>
<PaginationLink next onclick={() => table.setPage("next")} />
<PaginationLink next onclick={() => table.setPage('next')} />
</PaginationItem>
</Pagination>
<hr />
{/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}
@ -336,17 +312,13 @@
<Button type="submit" color="success" size="sm">{submitButtonText}</Button>
{#if isEditing}
<Button size="sm" type="button" color="secondary" onclick={() => resetForm()}>
{config.labels.cancel}
{labels.cancel}
</Button>
{/if}
{#if isEditing}
<Button color="danger" size="sm" type="button" onclick={() => confirmDelete(currentItem)}>
{config.labels.delete}
{labels.delete}
</Button>
{:else}
<Button color="secondary" size="sm" type="button" onclick={close}>
{config.labels.close}
</Button>
<Button color="secondary" size="sm" type="button" onclick={close}>{labels.close}</Button>
{/if}
</div>
</form>
@ -355,12 +327,12 @@
</Modal>
<ConfirmationPrompt
isOpen={isConfirmationVisible}
title={`Подтвердите удаление ${config.labels.itemGenitive}`}
confirmText={config.labels.delete}
cancelText={config.labels.cancel}
bind:isOpen={isConfirmationVisible}
title={`${labels.delete} ${labels.itemGenitive}`}
confirmText={labels.delete}
cancelText={labels.cancel}
confirmVariant="danger"
onconfirm={handleDelete}
oncancel={() => (isConfirmationVisible = false)}>
<p>Вы уверены, что хотите удалить {config.labels.item} "{selectedItem?.name}"?</p>
<p>Delete "{selectedItem?.name}"?</p>
</ConfirmationPrompt>

13
src/lib/ui/index.ts Normal file
View file

@ -0,0 +1,13 @@
export { default as CollapsibleCard } from './CollapsibleCard.svelte';
export { default as PanelContainer } from './PanelContainer.svelte';
export { default as TabBar } from './TabBar.svelte';
export { default as Toast } from './Toast.svelte';
export { default as ConfirmationPrompt } from './ConfirmationPrompt.svelte';
export { default as SelectSearchable } from './SelectSearchable.svelte';
export { default as SpoilerGroup } from './SpoilerGroup.svelte';
export { default as LabelGroup } from './LabelGroup.svelte';
export { default as EditableCell } from './EditableCell.svelte';
export { default as ListEditor } from './editor/ListEditor.svelte';
export type { ListEditorConfig, ListEditorApi } from './editor/ListEditor.svelte';
export { addToast, removeToast, toasts } from './toasts';
export type { Toast as ToastMessage, ToastColor, ToastInit } from './toasts';

54
src/lib/ui/toasts.ts Normal file
View file

@ -0,0 +1,54 @@
import { writable } from 'svelte/store';
export type ToastColor =
| 'primary'
| 'secondary'
| 'success'
| 'danger'
| 'warning'
| 'info'
| 'light'
| 'dark';
export interface Toast {
id: string;
header: string;
body: string;
color: ToastColor;
persistent: boolean;
onRemove?: (id: string) => void;
}
export interface ToastInit {
header: string;
body: string;
color?: ToastColor;
persistent?: boolean;
onRemove?: (id: string) => void;
}
export const toasts = writable<Toast[]>([]);
export function addToast(init: ToastInit): string {
const id = crypto.randomUUID();
toasts.update((all) => [
...all,
{
id,
header: init.header,
body: init.body,
color: init.color ?? 'info',
persistent: init.persistent ?? false,
onRemove: init.onRemove,
},
]);
return id;
}
export function removeToast(id: string): void {
toasts.update((all) => {
const t = all.find((x) => x.id === id);
t?.onRemove?.(id);
return all.filter((x) => x.id !== id);
});
}