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

18
.env.example Normal file
View file

@ -0,0 +1,18 @@
# Base URL of the Django REST backend.
#
# - '/api' same-origin. The Vite dev server proxies
# this prefix to VITE_API_PROXY_TARGET (see
# below); in production your web server
# routes /api to Django.
# - 'http://localhost:8000/api' talk to Django directly. CORS must be
# enabled there. No proxy is registered.
VITE_API_BASE_URL=/api
# Where the dev server should proxy API requests when VITE_API_BASE_URL is a
# relative path. Ignored for absolute URLs or when mocking.
VITE_API_PROXY_TARGET=http://localhost:8000
# When set to 'true', the Vite dev server serves a fake backend from
# mocks/vitePlugin.ts. No Django required. The real backend (if any) is
# ignored in that case.
VITE_USE_MOCK_API=false

View file

@ -1,38 +1,40 @@
# sv # leaflet_svelte
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). Weather-balloon trajectory planner. Static SvelteKit SPA that talks to a Django
REST backend.
## Creating a project ## Quick start
If you're seeing this, you've probably already done this step. Congrats!
```bash ```bash
# create a new project in the current directory npm install
npx sv create
# create a new project in my-app # dev against a local Django on :8000 (see .env.example)
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev npm run dev
# or start the server and open the app in a new browser tab # dev with a fake backend (no Django needed)
npm run dev -- --open VITE_USE_MOCK_API=true npm run dev
```
## Building # type-check + production build (emits static files to ./build)
npm run check
To create a production version of your app:
```bash
npm run build npm run build
``` ```
You can preview the production build with `npm run preview`. Serve `build/` from any static host. Route fallback is `index.html`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. ## Documentation
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — module layout and data flow.
- [`docs/CONVENTIONS.md`](docs/CONVENTIONS.md) — naming, styling, component patterns.
- [`docs/ADDING_A_FEATURE.md`](docs/ADDING_A_FEATURE.md) — walkthrough for adding
a new panel/feature.
- [`mocks/`](mocks/) — Vite dev-server mock backend.
## Stack
- **SvelteKit + Vite** (TypeScript, Svelte 5 runes) — built as a pure SPA with
`@sveltejs/adapter-static`.
- **MapLibre GL JS** via the `$map` abstraction — the app never imports
`maplibre-gl` directly outside `src/lib/map/`.
- **Sveltestrap** + Bootstrap 5 for UI chrome.
- **Chart.js** for the ascent/descent profile editor.
- **@vincjo/datatables** for editor tables.

390
build.js
View file

@ -1,390 +0,0 @@
const compile = require( 'svelte/compiler' ).compile
const chokidar = require( 'chokidar' );
const esbuild = require( 'esbuild' );
const {readdirSync, statSync, existsSync, writeFileSync, readFileSync} = require( 'fs' );
const {join, basename, resolve, dirname, relative} = require( 'path' );
const sveltePlugin = require( 'esbuild-svelte' );
const {sum} = require( 'lodash' );
const parse5 = require( 'parse5' );
const notifier = require('node-notifier');
process.on('uncaughtException', error => {
notifier.notify({
title: 'Error occurs',
message: `${error}`
});
});
const [watch, serve, minify, debug, logVars] = ['--watch', '--serve', '--minify', '--debug', '--log-vars'].map( s =>
process.argv.includes( s )
);
const debug_console_log = ( args, returnIndex = 0 ) => (debug && console.log( ...args ), args[ returnIndex ]);
const ignorePath = new Set( [
'node_modules',
'.vscode',
'.idea',
'.git',
'.gitignore',
'build.js',
'package-lock.json',
'package.json',
'README.md',
'build.js',
'pullpush.sh',
] );
// find page candidates
function findPages( dir = '.', sink = [] ) {
if( ignorePath.has( dir.replace( './', '' ).replace( '.\\', '' ) ) ) {
debug && console.log( 'skip:', dir );
return;
}
const files = readdirSync( dir ).filter( f => f[ 0 ]!=='_' );
const svelteFiles = files.filter( f => f.endsWith( '.svelte' ) && statSync( join( dir, f ) ).isFile() );
svelteFiles.forEach( f => sink.push( join( dir, f ) ) );
files
.filter( f => !svelteFiles.includes( f ) )
.map( f => join( dir, f ) )
.filter( f => statSync( f ).isDirectory() )
.forEach( f => findPages( f, sink ) );
return sink;
}
const _zId_prefix = `z_placeholder_${Math.floor( Math.random() * 1000000000 ).toString( 16 )}_`;
const _zReplacer = s => debug_console_log( ['z-replace:', s, `"${_zId_prefix}${Buffer.from( s ).toString( 'base64' )}"`], 2 );
const zPlaceholderReplacer = content =>
content?.replace( /\#\{\s*\w+\s*\}/gs, _zReplacer ) // #{ key }
.replace( /\/\*\!\s*\w+\s*\*\//gs, _zReplacer ) // map /*! mapKey */
.replace( /\[\s*\/\*\s*\w+\s*\*\/\s*\]/gs, _zReplacer ) // map [/* mapKey */]
.replace( /\{\s*\/\*\s*\w+\s*\*\/\s*\}/gs, _zReplacer ); // map {/* mapKey */}
global.zPlaceholderReplacer = zPlaceholderReplacer;
const zPlaceholderRestore = ( content, sink ) =>
content?.replace( new RegExp( `("|')${_zId_prefix}(\\w+=*)\\1`, 'g' ), ( _, _2, s ) => {
s = Buffer.from( s, 'base64' ).toString( 'ascii' );
sink.push( s );
debug && console.log( 'z-restore', _, s );
return s;
} );
const svelteJsPathResolver = {
name: 'svelteJsPathResolver',
setup( build ) {
const options = {filter: /\.svelte\.(ts)$/};
build.onResolve( options, ( {path, resolveDir} ) => ({path: join( resolveDir, path )}) );
build.onLoad( options, ( {path} ) => {
return {
contents: `
import App from "./${basename( path ).replace( /\.ts$/, '' )}";
export const app = new App({ target: document.getElementById("app") });
`,
loader: 'ts',
resolveDir: dirname( path ),
};
} );
},
};
function createBuilder( entryPoints ) {
console.log( 'pages:', entryPoints );
return esbuild.build( {
entryPoints: entryPoints.map( s => s + '.ts' ),
bundle: true,
outdir: '.',
write: false,
plugins: [svelteJsPathResolver, sveltePlugin( require( './svelte.config' ) )],
incremental: !!watch,
sourcemap: false,
minify,
} )
}
function layoutFor( path, content = {} ) {
path = (() => {
let temp = join( path, '..', '_layout.html' );
while( true ) {
if( existsSync( temp ) ) return temp;
if( resolve( __dirname )===resolve( dirname( temp ) ) ) return;
temp = join( temp, '../..', '_layout.html' );
}
})();
layoutFor.cache = layoutFor.cache || {};
const defaultKey = '_DEFAULT_LAYOUT';
if( !path && layoutFor.cache[ defaultKey ] ) return layoutFor.cache[ defaultKey ];
let cache = layoutFor.cache[ path ];
const m = statSync( path ).mtimeMs;
if( cache && m===cache.m ) return cache;
const tree = parse5.parse(
path
? readFileSync( path, 'utf-8' )
: `<!DOCTYPE html>
<html>
<head>
<title>#{title}</title>
</head>
<body>
<h1>layout not found, please create <b>_layout.html</b></h1>
<slot></slot>
</body>
</html>`
);
let slot = null;
let body = null;
let stack = [...tree.childNodes];
while( stack.length && (slot==null || body==null) ) {
const t = stack.pop();
if( t.nodeName==='body' ) body = t;
if( t.nodeName==='slot' || (t.nodeName==='#comment' && t.data?.trim()==='content_goes_here') ) slot = t;
t.childNodes && (stack = [...stack, ...t.childNodes]);
}
if( !body ) throw new Error( 'body not found' );
const appKEY = `${Math.random()}-APP-${Math.random()}`;
if( slot ) {
slot.nodeName = 'main';
slot.tagName = 'main';
delete slot.data;
slot.attrs = [
{name: 'id', value: 'app'},
...(slot.attrs || [])?.filter( t => t.name!=='id' )
];
slot.childNodes = [{nodeName: '#text', value: appKEY}]
} else {
body.childNodes.push( {
nodeName: 'main',
tagName: 'main',
attrs: [{name: 'id', value: 'app'}],
childNodes: [{nodeName: '#text', value: appKEY}],
namespaceURI: body.namespaceURI,
} );
}
// Remove main content
// content showing only for seo friendly
body.childNodes.push( {
nodeName: 'script',
tagName: 'script',
attrs: [],
childNodes: [{nodeName: '#text', value: "document.getElementById('app').innerHTML=''"}],
namespaceURI: body.namespaceURI,
} )
const jsKEY = `${Math.random()}-JS-${Math.random()}`;
const cssKEY = `${Math.random()}-CSS-${Math.random()}`;
const comments = {
nodeName: '#comment',
data: '',
};
const cssVarsComments = {
nodeName: '#comment',
data: '',
};
const jsVarsComments = {
nodeName: '#comment',
data: '',
};
body.childNodes = [
...body.childNodes,
comments,
{
nodeName: '#text',
value: '\n',
},
logVars && cssVarsComments,
{
nodeName: '#text',
value: '\n',
},
logVars && jsVarsComments,
{
nodeName: '#text',
value: '\n',
},
{
nodeName: 'style',
tagName: 'style',
attrs: [],
childNodes: [{nodeName: '#text', value: cssKEY}],
namespaceURI: body.namespaceURI,
},
{
nodeName: '#text',
value: '\n',
},
{
nodeName: 'script',
tagName: 'script',
attrs: [],
childNodes: [{nodeName: '#text', value: jsKEY}],
namespaceURI: body.namespaceURI,
},
{
nodeName: '#text',
value: '\n',
},
];
debug && console.log( 'build layout for:', path || defaultKey );
return (layoutFor.cache[ path || defaultKey ] = ( {js, css} ) => {
const cssVars = [],
jsVars = [];
js = zPlaceholderRestore( js, jsVars ) || '';
css = zPlaceholderRestore( css, cssVars ) || '';
//comments.data = `BUILD TIME: ${new Date().toISOString()}`;
cssVarsComments.data = cssVars.length ? `--- CSS z-vars --- \n${cssVars.join( '\n' )}` : '';
jsVarsComments.data = jsVars.length ? `--- JS z-vars --- \n${jsVars.join( '\n' )}` : '';
let html = content.html || '';
const innerCss = (content.css || {}).code || '';
return parse5.serialize( tree ).
replace( cssKEY, css + innerCss ).
replace( jsKEY, js ).
replace( appKEY, html ).
replaceAll("fakecss:"+__dirname,'fakecss:.');
});
}
(async () => {
let watcherReady = false;
watch && console.log( 'first build start' );
let pages = findPages();
let builder = await createBuilder( pages );
const compiledFiles = new Set();
let cache = {};
function saveFiles( files = builder, layoutChanged = false ) {
const output = {};
let unchanged = 0;
// path = bla.svelte.js or bla.svelte.css
for( const {path, text} of files.outputFiles ) {
const ext = /\.(\w+)$/.exec( path )?.[ 1 ];
if( ext!=='css' && ext!=='js' ) throw new Error( 'unknown ext:' + ext );
// bla.js or bla.css
const key = path.replace( /\.svelte\.\w+$/, '' );
output[ key ] = output[ key ] || {};
output[ key ][ ext ] = text
if( cache[ path ]===text && !layoutChanged ) {
unchanged += 1;
continue;
}
cache[ path ] = text;
}
// do nothing if nothing's changed
if( unchanged===files.outputFiles.length ) return;
if( Object.keys( output ).length===0 ) return console.log( 'no changes' );
// for each .html files need to be generated
Object.entries( output ).forEach( ( [path, data] ) => {
const renderedSvelte = compile( path + '.svelte' );
const content = layoutFor( path, renderedSvelte )( data );
path = resolve( path + '.html' );
compiledFiles.add( path );
console.log( 'compiled:', relative( resolve( __dirname ), path ) );
writeFileSync( path, content );
} );
}
saveFiles();
watch && console.log( 'first build end' );
if( watch ) {
const pagesPaths = new Set( pages.map( p => resolve( p ) ) );
let timeRef = null;
function changeListener( path, stats, type, watcher ) {
switch (type) {
case 'change':
notifier.notify({
title: 'Change occurs',
message: `Change occurs in "${path}"`
});
break;
case 'add':
notifier.notify({
title: 'File added',
message: `Added file "${path}"`
});
break;
case 'unlink':
notifier.notify({
title: 'File remove',
message: `Removed file "${path}"`
});
break;
}
if( compiledFiles.has( resolve( path ) ) ) return;
console.log( type + ':', path.replace( __dirname, '' ) );
const svelteFile = path[ 0 ]!=='_' && path.endsWith( '.svelte' );
let pagesChanged = true;
if( svelteFile && type==='add' ) pagesPaths.add( resolve( path ) );
else if( svelteFile && type==='unlink' ) pagesPaths.delete( resolve( path ) );
else pagesChanged = false;
let layoutChanged = path.endsWith( '_layout.html' );
if( timeRef ) clearTimeout( timeRef );
timeRef = setTimeout( async () => {
pagesChanged
? saveFiles( (builder = await createBuilder( Array.from( pagesPaths, p => relative( __dirname, p ) ) )), layoutChanged )
: saveFiles( await builder.rebuild(), layoutChanged );
}, 200 );
}
const watcher = chokidar
.watch( '.', {ignored: s => ignorePath.has( s ) || ignorePath.has( join( './', s ) ), ignoreInitial: true} )
.on( 'change', ( path, stats ) => changeListener( path, stats, 'change', watcher ) )
.on( 'add', ( path, stats ) => changeListener( path, stats, 'add', watcher ) )
.on( 'unlink', ( path, stats ) => changeListener( path, stats, 'unlink', watcher ) )
.on( 'ready', () => {
console.log( `watching ${sum( Object.values( watcher.getWatched() ).map( t => t.length ) )} files/dirs for changes` );
watcherReady = true;
} )
.on( 'error', err => console.log( 'ERROR:', err ) );
}
const FiveServer = require( 'five-server' ).default;
serve &&
(await new FiveServer().start( {
open: true,
workspace: __dirname,
ignore: [...ignorePath, /\.(js|ts|svelte)$/, /\_layout\.html$/],
wait: 500,
} ));
})();

152
docs/ADDING_A_FEATURE.md Normal file
View file

@ -0,0 +1,152 @@
# Adding a feature
Walk-through for building a new side-panel feature. We'll use a hypothetical
"NOTAMs" panel (no-fly-zone overlays) as the example.
## 1. Pick a feature folder
```
src/lib/features/notams/
├── index.ts
├── store.ts
├── types.ts
├── NotamsPanel.svelte
└── NotamRenderer.svelte # if you draw on the map
```
Re-export the public surface from `index.ts`.
## 2. Define the domain
If the feature introduces a durable type (something saved to a server or
localStorage), put the type in `src/lib/domain/notam.ts` and re-export from
`src/lib/domain/index.ts`. Ephemeral state types can live in the feature's
`types.ts`.
```ts
// src/lib/domain/notam.ts
export interface Notam {
id: string;
code: string;
polygon: LatLngTuple[];
validFrom: string;
validTo: string;
}
```
## 3. Add state
Feature-scoped stores go in the feature folder. Use `persisted()` if the user
should not lose the state on reload, plain `writable()` otherwise.
```ts
// src/lib/features/notams/store.ts
import { persisted } from '$state';
import type { Notam } from '$domain';
export const notamsStore = persisted<Notam[]>('notams', []);
```
## 4. Add API (if needed)
```ts
// src/lib/api/notams.ts
import { api } from './client';
import type { Notam } from '$domain';
export const notamsApi = { list: () => api.get<Notam[]>('/notams/') };
```
And export from `src/lib/api/index.ts`.
## 5. Build the panel
Re-use UI primitives. Wrap everything in `CollapsibleCard` so it lands in the
side panel.
```svelte
<!-- src/lib/features/notams/NotamsPanel.svelte -->
<script lang="ts">
import { CollapsibleCard } from '$ui';
import { t } from '$i18n';
import { notamsStore } from './store';
</script>
<CollapsibleCard title={$t('notams.title')}>
{#each $notamsStore as notam (notam.id)}
<div>{notam.code}</div>
{/each}
</CollapsibleCard>
```
## 6. Add i18n keys
Append to `src/lib/i18n/locales/ru.json` and `en.json`. The lookup is
dot-separated:
```json
{
"notams": {
"title": "NOTAMs",
"empty": "Нет активных NOTAMs"
}
}
```
## 7. Draw on the map (optional)
Every on-map drawing goes through a Scene. One scene per feature, or per sub-
entity if the feature manages many independent overlays (like workspaces).
```svelte
<!-- src/lib/features/notams/NotamRenderer.svelte -->
<script lang="ts">
import { getMap } from '$map';
import { notamsStore } from './store';
const map = getMap();
if (!map) throw new Error('NotamRenderer requires a <Map /> ancestor');
$effect(() => {
const scene = map.scene('notams');
scene.clear();
for (const n of $notamsStore) {
// scene.addLine/addCircle/etc.
}
});
</script>
```
Add `<NotamRenderer />` as a child of `<MapView />` in the predict route.
## 8. Wire into the route
```svelte
<!-- src/routes/predict/+page.svelte -->
<MapView>
<WorkspaceRenderer />
<NotamRenderer />
<PanelContainer position="right">
<TabBar tabs={[...] } bind:active={rightTab} />
{#if rightTab === 'notams'}
<NotamsPanel />
{/if}
</PanelContainer>
</MapView>
```
## 9. Add a settings entry (optional)
If the feature should be toggleable, extend `src/lib/features/settings/store.ts`
and `schema.ts`. Keep the field declarative (kind + labelKey + path) — the
SettingsPanel renders it automatically.
## 10. Checklist before you open a PR
- [ ] `npm run check` passes
- [ ] `npm run build` passes
- [ ] All user-visible strings routed through `$t`
- [ ] Feature only depends on shared modules (`$domain`, `$state`, `$api`,
`$ui`, `$map`) — not on other features' internals
- [ ] `index.ts` exports only what the outside world needs
- [ ] Panel matches existing visual language (CollapsibleCard, Bootstrap sm
form controls, same spacing)

105
docs/ARCHITECTURE.md Normal file
View file

@ -0,0 +1,105 @@
# Architecture
## Layer overview
```
src/
├── routes/ # SvelteKit pages (thin; they compose features)
├── app.html # plain SPA shell
├── app.css # global styles (imports Bootstrap + bootstrap-icons)
└── lib/
├── domain/ # pure types + pure functions (no Svelte, no fetch)
├── state/ # store factories (persisted, broadcast across tabs)
├── api/ # HTTP client + resource modules (points, scenarios…)
├── auth/ # auth store, login/logout, requireAuthenticated()
├── i18n/ # t() store, locale loading, message dictionaries
├── map/ # IMap interface + MapLibre impl + plot helpers + tools
├── ui/ # library-agnostic primitives (panel, tab, editor…)
└── features/ # vertical slices — one folder per feature
├── auth/ # Navbar, LoginForm
├── footer/
├── prediction/ # ControlPanel, ScenarioPanel, Point/Scenario/Curve editors
├── tracking/ # TelemetryPanel
├── workspaces/ # multi-layer workspace system
├── timeline/ # global playback clock
└── settings/ # schema-driven settings panel
mocks/ # dev-server API mock (enabled via VITE_USE_MOCK_API)
```
**Rules of thumb**
- `domain/` depends on nothing.
- `state/`, `i18n/`, `api/` depend only on `domain/`.
- `map/` depends on `domain/` (and Maplibre, internally).
- `ui/` depends on `domain/`, `state/`, `i18n/` at most.
- `features/*` depend on everything in `lib/` but never on each other directly
— cross-feature coordination happens through stores (e.g. `workspacesStore`
is shared by prediction + timeline).
- `routes/` compose features. Pages stay thin.
## Data flow
### Stores
`$state` → persisted via `persisted()` (localStorage + BroadcastChannel for
cross-tab sync). `workspaces`, `settings`, and `auth` state live here.
### API calls
All go through `$api` (`src/lib/api/client.ts`) which:
- prepends `VITE_API_BASE_URL`
- reads the `csrftoken` cookie (priming it via `/api/csrf/` if missing)
- serializes JSON bodies and parses structured DRF errors into `ApiError`
- delegates 401s to the auth layer (`setUnauthorizedHandler`)
### Auth
`authStore` is the single source of truth for who is logged in. Pages that
require auth call `requireAuthenticated()` from `$auth` in `onMount` — this
refreshes state and redirects to `/login` if anonymous. The API client triggers
the same redirect on 401s.
### Map
The map library is behind the `IMap` interface. Every plot operation (line,
marker, circle) runs through a **Scene** — a named collection of layers owned
by a feature. When a feature is done with its layers, `scene.clear()` or
`map.disposeScene(name)` removes them all at once. This is how each workspace
paints an independent trajectory without stepping on its neighbors.
Swapping MapLibre for another library means implementing `IMap` once (see
`src/lib/map/maplibre.ts`) and updating `createMapLibreMap()` at one
call-site inside `Map.svelte`.
### Workspaces
A workspace is an independently-configured prediction layer. The user creates
several, edits their flight parameters separately, runs each one, and sees all
their trajectories overlaid on the same map with their own colors and
opacities. `workspacesStore.run(id)` calls the prediction API and stores the
result in-memory on that workspace.
`WorkspaceRenderer.svelte` listens to the workspaces store and repaints layers
when workspaces change. It also drives the **global timeline**: whenever
workspaces change it recomputes the global `[min, max]` time range (the union
of every visible workspaces trajectory span) and updates the timeline store.
### Timeline
`timelineStore` is a plain writable backed by a `requestAnimationFrame` loop.
It exposes `play`/`pause`/`seek`/`setSpeed`. `WorkspaceRenderer` subscribes to
`time` and draws a cursor marker on each visible workspace at that timestamp.
Workspaces stay in sync because they all sample the same clock.
### i18n
`initI18n()` (called from the root layout) loads the saved locale from
localStorage, imports the JSON dictionary, and stores it. Components use the
`$t` store from `$i18n`:
```svelte
<h5>{$t('panel.workspaces')}</h5>
```
Adding a locale: drop `xx.json` under `src/lib/i18n/locales/`, register it in
`loaders` and in the `Locale` type.
## Build & deploy
- `@sveltejs/adapter-static` with `fallback: 'index.html'` produces a pure SPA.
- SSR is disabled in the root `+layout.ts` (`ssr = false`, `prerender = false`).
- CSS is imported once in `+layout.svelte` (which loads `app.css`).
- No server-side routes. All data comes from the Django REST backend.

132
docs/CONVENTIONS.md Normal file
View file

@ -0,0 +1,132 @@
# Conventions
## TypeScript
- Every file is `.ts` or `<script lang="ts">`. No plain JS outside build config.
- Types live next to the code they describe. Cross-feature types go in
`src/lib/domain/`.
- Prefer `interface` for object shapes, `type` for unions/aliases.
- Avoid `any`. `unknown` + a narrowing cast is preferred.
## File & folder naming
- **Components**: `PascalCase.svelte`. Exception: pseudo-routes `+page.svelte`
etc. per SvelteKit.
- **Plain modules**: `kebab-or-camel.ts`.
- **Feature directories**: lowercase (`workspaces`, `prediction`).
- Each feature folder has an `index.ts` that re-exports its public surface;
outside code imports from the index, not from individual files.
## Component naming inside `<script>`
- `$state`/`$derived` variables — camelCase, no prefix.
```ts
let isCollapsed = $state(false);
let currentPoint = $derived($pointsStore.find(p => p.id === id));
```
- Component instance refs — camelCase + `Ref`.
```ts
let pointEditorRef: PointEditor | null = $state(null);
```
- Event handlers — `handleXxx`.
```ts
function handleSave() { … }
```
- Props that are event callbacks — `onXxx`.
```svelte
let { onSelect = () => {} }: Props = $props();
```
- HTML `id` attributes — kebab-case, prefixed with a 23 letter component code
(`cp-start-time` for ControlPanel). This keeps IDs globally unique.
- Stores — PascalCase + `Store` (for top-level domain stores) OR camelCase
(for feature-scoped stores). Both are fine; be consistent within a module.
## Props contract
Use the Svelte 5 `Props` interface pattern:
```ts
interface Props {
title?: string;
collapsed?: boolean;
onToggle?: () => void;
children?: Snippet;
}
let {
title = '',
collapsed = $bindable(false),
onToggle = () => {},
children,
}: Props = $props();
```
Do not use `export let` in new components.
## State
- Persisted state (survives reload, syncs across tabs): `persisted(key, initial)`
from `$state`.
- Ephemeral UI state (modal open, which tab is active): plain `$state`.
- Cross-component state that isn't user-facing persistence: create a small
factory module under the feature folder (`store.ts`) — never a bare
`writable()` in a `.svelte` file.
## Imports
Use path aliases (`$api`, `$auth`, `$domain`, `$map`, `$state`, `$ui`, `$i18n`,
`$features`). Example:
```ts
import { api } from '$api';
import { authStore, requireAuthenticated } from '$auth';
import type { Prediction } from '$domain';
import { Map, plotPrediction } from '$map';
import { CollapsibleCard, addToast } from '$ui';
import { t } from '$i18n';
import { workspacesStore } from '$features/workspaces';
```
Avoid importing from `src/lib/components/...` (it no longer exists).
## Strings
No hardcoded Russian/English text in new code. Add the key to both
`src/lib/i18n/locales/ru.json` and `en.json` and read it with `$t('...')`.
Placeholders use `{name}`:
```ts
$t('workspaces.deleteConfirm', { name: w.name })
```
## Comments
Default is no comment. Add one only when the *why* is non-obvious (hidden
invariants, workarounds, API quirks). Dont restate what the code does.
Module-level docstrings are welcome: put a short paragraph at the top of a
file that describes the role of the module, mentioning any non-obvious design
choices. See `src/lib/state/persisted.ts` for an example.
## CSS
- Global chrome lives in `src/app.css`.
- Component-scoped styles go inside the `.svelte` file.
- Use Bootstrap utility classes when possible; only add custom CSS when the
design requires it.
- Never use `!important` except to override third-party libs (MapLibre,
Bootstrap).
## Testing
Not set up yet. When tests arrive: feature modules should be testable without
Svelte — that's why `domain/` and `state/` are pure.
## Don't
- Dont reach into `maplibre-gl` directly from a feature component — extend
`IMap` or add a helper in `$map` instead.
- Dont mutate localStorage from a component. Use a persisted store.
- Dont perform fetches from a component. Call a `$api` module.
- Dont redirect the user on auth failures from individual pages. Use
`requireAuthenticated()` at the top of `onMount`.

6
mocks/data/points.json Normal file
View file

@ -0,0 +1,6 @@
[
{ "id": 1, "name": "Якутск (аэропорт)", "lat": 62.0928, "lon": 129.7706, "alt": 99 },
{ "id": 2, "name": "Тикси", "lat": 71.6347, "lon": 128.8661, "alt": 11 },
{ "id": 3, "name": "Мирный", "lat": 62.5344, "lon": 113.9589, "alt": 346 },
{ "id": 4, "name": "Нерюнгри", "lat": 56.6588, "lon": 124.7181, "alt": 750 }
]

22
mocks/data/scenarios.json Normal file
View file

@ -0,0 +1,22 @@
[
{
"id": 101,
"name": "Стандартный 30 км",
"description": "Обычный полет со стандартным профилем",
"prediction_mode": "single",
"model": "gfs_0p25",
"dataset": "",
"flight_parameters": {
"ascent_rate": 5.0,
"burst_altitude": 30000.0,
"dataset": "",
"descent_rate": 5.0,
"format": "json",
"launch_altitude": 99.0,
"launch_latitude": 62.0928,
"launch_longitude": 129.7706,
"profile": "standard_profile",
"version": 2
}
}
]

View file

@ -0,0 +1,56 @@
import type { FlightParameters } from '../../src/lib/domain/scenario';
/**
* Generate a fake prediction trajectory. We fake an ascent phase that climbs
* to burst_altitude at ascent_rate, then a descent at descent_rate. The
* ground track drifts roughly east as a function of altitude to loosely
* mimic wind drift.
*/
export function fakePrediction(params: FlightParameters & { launch_datetime: string }) {
const launch = new Date(params.launch_datetime);
const ascentDurationSec = Math.round(
(params.burst_altitude - params.launch_altitude) / params.ascent_rate,
);
const descentDurationSec = Math.round(params.burst_altitude / params.descent_rate);
const stepSec = 30;
const ascent: unknown[] = [];
for (let t = 0; t <= ascentDurationSec; t += stepSec) {
const alt = params.launch_altitude + t * params.ascent_rate;
ascent.push({
altitude: alt,
datetime: new Date(launch.getTime() + t * 1000).toISOString(),
latitude: params.launch_latitude + t * 0.00002,
longitude: params.launch_longitude + t * 0.00005,
});
}
const descent: unknown[] = [];
const burstTime = launch.getTime() + ascentDurationSec * 1000;
const burstLat = params.launch_latitude + ascentDurationSec * 0.00002;
const burstLng = params.launch_longitude + ascentDurationSec * 0.00005;
for (let t = 0; t <= descentDurationSec; t += stepSec) {
const alt = Math.max(0, params.burst_altitude - t * params.descent_rate);
descent.push({
altitude: alt,
datetime: new Date(burstTime + t * 1000).toISOString(),
latitude: burstLat + t * 0.00001,
longitude: burstLng + t * 0.00003,
});
}
return {
result: {
metadata: {
start_datetime: new Date(launch.getTime() - 3600 * 1000).toISOString(),
complete_datetime: new Date(
burstTime + descentDurationSec * 1000,
).toISOString(),
},
prediction: [
{ stage: 'ascent', trajectory: ascent },
{ stage: 'descent', trajectory: descent },
],
},
};
}

200
mocks/vitePlugin.ts Normal file
View file

@ -0,0 +1,200 @@
import type { Connect, Plugin } from 'vite';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { fakePrediction } from './handlers/prediction';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DATA_DIR = join(__dirname, 'data');
/**
* Dev-only mock for the Django REST API. Enable by setting
* `VITE_USE_MOCK_API=true` when running `vite dev`.
*
* Covered endpoints (everything under /api/):
* - GET/POST/PUT/DELETE /saved-points/
* - GET/POST/PUT/DELETE /saved-templates/
* - GET/POST/PUT/DELETE /saved-profiles/
* - POST /predictions/
* - GET /session/, /whoami/, /csrf/
* - POST /login/, /logout/
*
* State lives on disk so edits survive dev-server restarts.
*/
interface MockSession {
isAuthenticated: boolean;
username: string | null;
}
let session: MockSession = { isAuthenticated: true, username: 'demo-user' };
function loadArray<T>(file: string, fallback: T[]): T[] {
const path = join(DATA_DIR, file);
if (!existsSync(path)) return fallback;
try {
return JSON.parse(readFileSync(path, 'utf-8')) as T[];
} catch {
return fallback;
}
}
function saveArray<T>(file: string, items: T[]): void {
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
writeFileSync(join(DATA_DIR, file), JSON.stringify(items, null, '\t'));
}
function send(res: ServerResponse, status: number, body: unknown) {
res.statusCode = status;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(body));
}
function cors(res: ServerResponse, req: IncomingMessage) {
res.setHeader('Access-Control-Allow-Origin', req.headers.origin ?? '*');
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,PATCH,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-CSRFToken');
}
async function readBody(req: IncomingMessage): Promise<unknown> {
return await new Promise((resolve) => {
const chunks: Uint8Array[] = [];
req.on('data', (chunk: Uint8Array) => chunks.push(chunk));
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf-8');
if (!raw) return resolve({});
try {
resolve(JSON.parse(raw));
} catch {
resolve({});
}
});
});
}
function crudEndpoint<T extends { id: number }>(
file: string,
path: string,
): (req: IncomingMessage, res: ServerResponse) => Promise<boolean> {
return async (req, res) => {
const url = req.url ?? '';
if (!url.startsWith(path)) return false;
const remainder = url.slice(path.length);
const [idPart] = remainder.replace(/\/$/, '').split('?');
const id = idPart ? Number(idPart) : null;
let items = loadArray<T>(file, []);
switch (req.method) {
case 'GET':
if (id == null || Number.isNaN(id)) {
send(res, 200, items);
} else {
const item = items.find((i) => i.id === id);
item ? send(res, 200, item) : send(res, 404, { detail: 'not found' });
}
return true;
case 'POST': {
const body = (await readBody(req)) as Partial<T>;
const nextId = items.reduce((m, i) => Math.max(m, i.id), 0) + 1;
const created = { ...body, id: nextId } as T;
items = [...items, created];
saveArray(file, items);
send(res, 201, created);
return true;
}
case 'PUT': {
const body = (await readBody(req)) as T;
items = items.map((i) => (i.id === (id ?? body.id) ? body : i));
saveArray(file, items);
send(res, 200, body);
return true;
}
case 'DELETE':
items = items.filter((i) => i.id !== id);
saveArray(file, items);
res.statusCode = 204;
res.end();
return true;
default:
return false;
}
};
}
export function mockApiPlugin(): Plugin {
const handlers: Array<(req: IncomingMessage, res: ServerResponse) => Promise<boolean>> = [
crudEndpoint('points.json', '/api/saved-points/'),
crudEndpoint('scenarios.json', '/api/saved-templates/'),
crudEndpoint('profiles.json', '/api/saved-profiles/'),
];
const middleware: Connect.NextHandleFunction = async (req, res, next) => {
const url = req.url ?? '';
if (!url.startsWith('/api/')) return next();
cors(res, req);
if (req.method === 'OPTIONS') {
res.statusCode = 204;
res.end();
return;
}
if (url.startsWith('/api/csrf/')) {
res.setHeader('Set-Cookie', 'csrftoken=mock-csrf; Path=/');
send(res, 200, { detail: 'ok' });
return;
}
if (url.startsWith('/api/session/')) {
send(res, 200, { isAuthenticated: session.isAuthenticated });
return;
}
if (url.startsWith('/api/whoami/')) {
if (!session.username) return send(res, 401, { detail: 'not authenticated' });
send(res, 200, { username: session.username });
return;
}
if (url.startsWith('/api/login/')) {
const body = (await readBody(req)) as { username: string; password: string };
if (!body.username || !body.password) {
return send(res, 400, { detail: 'missing credentials' });
}
session = { isAuthenticated: true, username: body.username };
send(res, 200, { detail: 'ok', username: body.username });
return;
}
if (url.startsWith('/api/logout/')) {
session = { isAuthenticated: false, username: null };
send(res, 200, { detail: 'ok' });
return;
}
if (url.startsWith('/api/predictions/')) {
const body = (await readBody(req)) as Parameters<typeof fakePrediction>[0];
send(res, 200, fakePrediction(body));
return;
}
for (const h of handlers) {
if (await h(req, res)) return;
}
send(res, 404, { detail: 'mock: endpoint not implemented' });
};
return {
name: 'lsv-mock-api',
apply: 'serve',
configureServer(server) {
server.middlewares.use(middleware);
},
};
}

58
package-lock.json generated
View file

@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@sakitam-gis/maplibre-wind": "^2.0.3", "@sakitam-gis/maplibre-wind": "^2.0.3",
"@sveltestrap/sveltestrap": "^7.1.0", "@sveltestrap/sveltestrap": "^7.1.0",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1", "bootstrap-icons": "^1.13.1",
"chart.js": "^4.5.0", "chart.js": "^4.5.0",
"chartjs-adapter-luxon": "^1.3.1", "chartjs-adapter-luxon": "^1.3.1",
@ -20,10 +21,11 @@
"svelte5-chartjs": "^1.0.0" "svelte5-chartjs": "^1.0.0"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^4.0.0", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.16.0", "@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/luxon": "^3.6.2", "@types/luxon": "^3.6.2",
"@types/node": "^25.6.0",
"@vincjo/datatables": "^2.5.0", "@vincjo/datatables": "^2.5.0",
"svelte": "^5.34.8", "svelte": "^5.34.8",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
@ -890,14 +892,11 @@
"acorn": "^8.9.0" "acorn": "^8.9.0"
} }
}, },
"node_modules/@sveltejs/adapter-auto": { "node_modules/@sveltejs/adapter-static": {
"version": "4.0.0", "version": "3.0.10",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-4.0.0.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz",
"integrity": "sha512-kmuYSQdD2AwThymQF0haQhM8rE5rhutQXG4LNbnbShwhMO4qQGnKaaTy+88DuNSuoQDi58+thpq8XpHc1+oEKQ==", "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==",
"dev": true, "dev": true,
"dependencies": {
"import-meta-resolve": "^4.1.0"
},
"peerDependencies": { "peerDependencies": {
"@sveltejs/kit": "^2.0.0" "@sveltejs/kit": "^2.0.0"
} }
@ -1028,6 +1027,15 @@
"@types/pbf": "*" "@types/pbf": "*"
} }
}, },
"node_modules/@types/node": {
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"dev": true,
"dependencies": {
"undici-types": "~7.19.0"
}
},
"node_modules/@types/pbf": { "node_modules/@types/pbf": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz",
@ -1077,6 +1085,24 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/bootstrap": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
"integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
}
],
"peerDependencies": {
"@popperjs/core": "^2.11.8"
}
},
"node_modules/bootstrap-icons": { "node_modules/bootstrap-icons": {
"version": "1.13.1", "version": "1.13.1",
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.13.1.tgz", "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.13.1.tgz",
@ -1365,16 +1391,6 @@
} }
] ]
}, },
"node_modules/import-meta-resolve": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz",
"integrity": "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==",
"dev": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/ini": { "node_modules/ini": {
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz",
@ -1836,6 +1852,12 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/undici-types": {
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"dev": true
},
"node_modules/vite": { "node_modules/vite": {
"version": "6.3.5", "version": "6.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",

View file

@ -12,10 +12,11 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch" "check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^4.0.0", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.16.0", "@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/luxon": "^3.6.2", "@types/luxon": "^3.6.2",
"@types/node": "^25.6.0",
"@vincjo/datatables": "^2.5.0", "@vincjo/datatables": "^2.5.0",
"svelte": "^5.34.8", "svelte": "^5.34.8",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
@ -25,6 +26,7 @@
"dependencies": { "dependencies": {
"@sakitam-gis/maplibre-wind": "^2.0.3", "@sakitam-gis/maplibre-wind": "^2.0.3",
"@sveltestrap/sveltestrap": "^7.1.0", "@sveltestrap/sveltestrap": "^7.1.0",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1", "bootstrap-icons": "^1.13.1",
"chart.js": "^4.5.0", "chart.js": "^4.5.0",
"chartjs-adapter-luxon": "^1.3.1", "chartjs-adapter-luxon": "^1.3.1",

149
src/app.css Normal file
View file

@ -0,0 +1,149 @@
@import 'bootstrap/dist/css/bootstrap.min.css';
@import 'bootstrap-icons/font/bootstrap-icons.css';
/*
* Global application styles.
*
* Keep this file focused on cross-feature concerns: the navbar chrome, the
* panel-container geometry, and overrides for third-party libs (MapLibre,
* Bootstrap). Feature-specific styles live in the relevant Svelte component.
*/
:root {
--navbar-height: 44px;
--panel-left: 20px;
--panel-top: 20px;
}
body {
background-color: #f5f5f5;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.custom-navbar {
height: var(--navbar-height);
padding: 0;
z-index: 1002;
border: none;
background-color: white !important;
}
.nav-full-height.nav-link {
color: inherit;
padding: 12px 1rem 0 1rem !important;
background-color: white;
margin-right: -1px;
}
.nav-full-height.nav-link:hover,
.nav-full-height.nav-link.active {
color: white !important;
background-color: var(--bs-primary);
}
.nav-full-height {
display: flex;
align-items: center;
height: 100%;
}
.navbar-brand {
margin-right: 1em;
}
.navbar {
z-index: 1002;
}
.card {
transition: all 0.3s ease;
}
.card-header {
cursor: pointer;
}
.map-container {
position: relative;
width: 100%;
height: calc(100vh - var(--navbar-height));
}
.panel-container-left,
.panel-container-right {
position: absolute;
top: var(--panel-top);
width: 23rem;
max-height: calc(100vh - var(--navbar-height) - 2 * var(--panel-top));
max-width: calc(100vw - var(--panel-left) * 2);
overflow-y: auto;
z-index: 1001;
}
.panel-container-left {
left: var(--panel-left);
}
.panel-container-right {
right: var(--panel-left);
}
.maplibregl-ctrl-group {
border: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important;
border-radius: var(--bs-border-radius) !important;
}
.maplibregl-popup-tip {
border-top-color: var(--bs-border-color) !important;
}
.maplibregl-popup-content {
background-color: var(--bs-body-bg) !important;
border: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important;
border-radius: var(--bs-border-radius) !important;
color: var(--bs-body-color);
box-shadow: none !important;
}
.maplibregl-popup-close-button {
color: var(--bs-body-color);
}
.modal-backdrop {
opacity: var(--bs-backdrop-opacity) !important;
}
.table td.fit,
.table th.fit {
white-space: nowrap;
width: 1%;
}
.force-page-height {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.modal-tinted {
filter: brightness(0.6);
}
.dropdown-toggle-standalone::after {
margin-left: 0;
}
.lsv-measure-dot {
background: var(--bs-primary);
border-radius: 50%;
border: 2px solid white;
box-shadow: 0 0 0 1px var(--bs-primary);
}
@media (max-width: 767.98px) {
.panel-container-left,
.panel-container-right {
width: calc(100vw - var(--panel-left) * 2);
}
}

View file

@ -3,24 +3,8 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> <link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="stylesheet" href="%sveltekit.assets%/css/bootstrap.min.css">
<link rel="stylesheet" href="%sveltekit.assets%/css/bootstrap-icons.css" />
<link rel="stylesheet" href="%sveltekit.assets%/css/custom.css" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css"
/>
%sveltekit.head% %sveltekit.head%
<style>
body {
background-color: #f5f5f5;
min-height: 100vh;
display: flex;
flex-direction: column;
}
</style>
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div> <div style="display: contents">%sveltekit.body%</div>

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 { api } from './client';
import type { SavedPoint } from "$lib/types"; import type { SavedPoint } from '$domain';
import { getAPI, postAPI, putAPI, deleteAPI } from "./base";
export function getSavedPoints(): Promise<SavedPoint[]> { const base = '/saved-points/';
return getAPI<SavedPoint[]>("/saved-points/");
}
export function savePoint(point: SavedPoint): Promise<SavedPoint> { export const pointsApi = {
return postAPI<SavedPoint>("/saved-points/", point); list: () => api.get<SavedPoint[]>(base),
} create: (p: SavedPoint) => api.post<SavedPoint>(base, p),
update: (p: SavedPoint) => api.put<SavedPoint>(`${base}${p.id}/`, p),
export function updatePoint(point: SavedPoint): Promise<SavedPoint> { delete: (id: number) => api.delete<void>(`${base}${id}/`),
return putAPI<SavedPoint>(`/saved-points/${point.id}/`, point); };
}
export function deletePoint(id: number): Promise<void> {
return deleteAPI<void>(`/saved-points/${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 { api } from './client';
import type {SavedFlightProfile } from "$lib/types"; import type { SavedFlightProfile } from '$domain';
import { getAPI, postAPI, putAPI, deleteAPI } from "./base";
export function getSavedFlightProfiles(): Promise<SavedFlightProfile[]> { const base = '/saved-profiles/';
return getAPI<SavedFlightProfile[]>("/saved-profiles/");
}
export function saveFlightProfile(profile: SavedFlightProfile): Promise<SavedFlightProfile> { export const profilesApi = {
return postAPI<SavedFlightProfile>("/saved-profiles/", profile); list: () => api.get<SavedFlightProfile[]>(base),
} create: (p: SavedFlightProfile) => api.post<SavedFlightProfile>(base, p),
update: (p: SavedFlightProfile) => api.put<SavedFlightProfile>(`${base}${p.id}/`, p),
export function updateFlightProfile(profile: SavedFlightProfile): Promise<SavedFlightProfile> { delete: (id: number) => api.delete<void>(`${base}${id}/`),
return putAPI<SavedFlightProfile>(`/saved-profiles/${profile.id}/`, profile); };
}
export function deleteFlightProfile(id: number): Promise<void> {
return deleteAPI<void>(`/saved-profiles/${id}/`);
}

View file

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

Some files were not shown because too many files have changed in this diff Show more