feat: polish #13
170 changed files with 14001 additions and 137554 deletions
18
.env.example
Normal file
18
.env.example
Normal 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
|
||||||
58
README.md
58
README.md
|
|
@ -1,38 +1,44 @@
|
||||||
# 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.
|
||||||
|
- [`docs/TESTING.md`](docs/TESTING.md) — e2e tests against the real Django +
|
||||||
|
predictor stack.
|
||||||
|
- [`mocks/`](mocks/) — Vite dev-server mock backend.
|
||||||
|
- [`scripts/run-stack.sh`](scripts/run-stack.sh) — boot Vite + Django +
|
||||||
|
fake predictor in one command.
|
||||||
|
|
||||||
|
## 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
390
build.js
|
|
@ -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
152
docs/ADDING_A_FEATURE.md
Normal 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
105
docs/ARCHITECTURE.md
Normal 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 workspace’s 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
132
docs/CONVENTIONS.md
Normal 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 2–3 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). Don’t 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
|
||||||
|
|
||||||
|
- Don’t reach into `maplibre-gl` directly from a feature component — extend
|
||||||
|
`IMap` or add a helper in `$map` instead.
|
||||||
|
- Don’t mutate localStorage from a component. Use a persisted store.
|
||||||
|
- Don’t perform fetches from a component. Call a `$api` module.
|
||||||
|
- Don’t redirect the user on auth failures from individual pages. Use
|
||||||
|
`requireAuthenticated()` at the top of `onMount`.
|
||||||
120
docs/TESTING.md
Normal file
120
docs/TESTING.md
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
# Testing
|
||||||
|
|
||||||
|
End-to-end tests run against the **full chain**:
|
||||||
|
|
||||||
|
```
|
||||||
|
Playwright → leaflet_svelte (Vite :5173)
|
||||||
|
│ proxies /api → :8000
|
||||||
|
▼
|
||||||
|
stratoflights Django (:8000)
|
||||||
|
│ TAWHIRI_BASE_URL → :8001
|
||||||
|
▼
|
||||||
|
fake_tawhiri stub (:8001)
|
||||||
|
```
|
||||||
|
|
||||||
|
The real production flow replaces the stub with
|
||||||
|
[tawhiri](https://fly.stratonautica.ru/api/v2/) or the Go-based
|
||||||
|
`predictor-refactored` running on `:8080`. Any service that speaks the
|
||||||
|
Tawhiri v2 query-string protocol is drop-in compatible.
|
||||||
|
|
||||||
|
## One-time setup
|
||||||
|
|
||||||
|
**Python deps for stratoflights** (uses sqlite via `DJANGO_ENV=production` to
|
||||||
|
avoid needing Postgres):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install --user --break-system-packages \
|
||||||
|
Django djangorestframework djangorestframework-simplejwt drf-spectacular \
|
||||||
|
requests django-cors-headers Pillow python-dotenv channels daphne
|
||||||
|
```
|
||||||
|
|
||||||
|
**Initialize the Django DB** (sqlite at `stratoflights/db.sqlite3`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/anton/stratoflights
|
||||||
|
DJANGO_ENV=production python3 manage.py migrate
|
||||||
|
DJANGO_ENV=production \
|
||||||
|
DJANGO_SUPERUSER_USERNAME=demo \
|
||||||
|
DJANGO_SUPERUSER_PASSWORD=demo \
|
||||||
|
DJANGO_SUPERUSER_EMAIL=demo@demo.demo \
|
||||||
|
python3 manage.py createsuperuser --noinput
|
||||||
|
```
|
||||||
|
|
||||||
|
**Playwright browsers** (first time only):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/anton/leaflet_svelte
|
||||||
|
npx playwright install chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start the full stack (Vite, Django, fake predictor).
|
||||||
|
scripts/run-stack.sh
|
||||||
|
|
||||||
|
# Run the Playwright suite.
|
||||||
|
npm run test:e2e
|
||||||
|
|
||||||
|
# Tear down.
|
||||||
|
scripts/stop-stack.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Individual files: `npm run test:e2e -- tests/e2e/workspaces.spec.ts`.
|
||||||
|
UI mode for debugging: `npm run test:e2e:ui`.
|
||||||
|
|
||||||
|
## Test organization
|
||||||
|
|
||||||
|
| File | Covers |
|
||||||
|
|------|--------|
|
||||||
|
| `auth.spec.ts` | Anonymous → login redirect, form login, already-authed visits |
|
||||||
|
| `smoke.spec.ts` | Page loads, MapLibre canvas mounts, navbar renders |
|
||||||
|
| `workspaces.spec.ts` | Auto-create, add/remove, prediction render pipeline |
|
||||||
|
| `settings.spec.ts` | Locale switch updates UI text live |
|
||||||
|
| `saved-points.spec.ts` | Point editor modal opens |
|
||||||
|
|
||||||
|
Fixtures (`tests/e2e/fixtures.ts`) provide `login(context)` / `logout(context)`
|
||||||
|
helpers that go through `page.context().request` so cookies are shared with
|
||||||
|
the page.
|
||||||
|
|
||||||
|
## Key gotchas
|
||||||
|
|
||||||
|
- **Django CSRF is origin-aware.** If Playwright hits `127.0.0.1:5173` but
|
||||||
|
`CSRF_TRUSTED_ORIGINS` only lists `localhost:5173`, every POST returns 403
|
||||||
|
with `{"detail": "CSRF Failed: Origin checking failed"}`. `run-stack.sh`
|
||||||
|
adds both hostnames to `CSRF_TRUSTED_ORIGINS`.
|
||||||
|
- **Test parallelism is disabled** (`fullyParallel: false`, `workers: 1`)
|
||||||
|
because fixtures share the Django sqlite DB. If we ever want parallel
|
||||||
|
workers we need per-worker DBs.
|
||||||
|
- **The fake tawhiri never fails.** To test the frontend's error path you
|
||||||
|
need to either stop the fake or point `TAWHIRI_BASE_URL` at an unreachable
|
||||||
|
host.
|
||||||
|
- **`window._lsvMap`** is only set in dev builds (`import.meta.env.DEV`), so
|
||||||
|
the workspace render test won't see the layer names in a production bundle.
|
||||||
|
|
||||||
|
## Swapping in the Go predictor
|
||||||
|
|
||||||
|
`fake_tawhiri.py` is an expedient stub. To swap in the real Go predictor:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/anton/predictor-refactor/predictor-refactored
|
||||||
|
PREDICTOR_DATA_DIR=/tmp/predictor-data go run ./cmd/api &
|
||||||
|
# ^ first run downloads ~9 GB of GFS GRIB data; takes 30–60 minutes.
|
||||||
|
|
||||||
|
# Then restart stratoflights with TAWHIRI_BASE_URL pointing at it:
|
||||||
|
cd /home/anton/stratoflights
|
||||||
|
TAWHIRI_BASE_URL=http://127.0.0.1:8080/api/v1/ python3 manage.py runserver
|
||||||
|
```
|
||||||
|
|
||||||
|
## Patched files in stratoflights
|
||||||
|
|
||||||
|
To make this stack work we made one tiny change in stratoflights so the
|
||||||
|
predictor endpoint is configurable:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# stratoflights_api/services/tawhiri.py
|
||||||
|
class TawhiriClient:
|
||||||
|
BASE_URL = os.getenv("TAWHIRI_BASE_URL", "https://fly.stratonautica.ru/api/v2/")
|
||||||
|
```
|
||||||
|
|
||||||
|
This is backwards-compatible — the default keeps the existing behavior.
|
||||||
41
docs/diagrams/README.md
Normal file
41
docs/diagrams/README.md
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# Диаграммы (PlantUML)
|
||||||
|
|
||||||
|
Исходники диаграмм для диссертации. Построены по фактическому коду
|
||||||
|
(`src/lib/api/*`, `src/lib/features/tracking/*`, `src/lib/features/wind/*`).
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
| Файл | Что описывает |
|
||||||
|
|------|----------------|
|
||||||
|
| [architecture.puml](architecture.puml) | Слоистая архитектура клиента: routes → features → lib (api/map/ui/state/i18n/auth/domain), внешние сервисы (Backend, предсказатель) и библиотеки |
|
||||||
|
|
||||||
|
## Диаграммы последовательности (по всем запросам)
|
||||||
|
|
||||||
|
| Файл | Что описывает | Запросы |
|
||||||
|
|------|----------------|---------|
|
||||||
|
| [seq-request-csrf.puml](seq-request-csrf.puml) | Сквозная обёртка `request<T>()`: CSRF, cookie, обработка ошибок и 401 | `GET /api/csrf/` |
|
||||||
|
| [seq-auth.puml](seq-auth.puml) | Проверка сессии, вход, выход | `GET /api/session/`, `GET /api/whoami/`, `POST /api/login/`, `POST /api/logout/` |
|
||||||
|
| [seq-resource-crud.puml](seq-resource-crud.puml) | Единый CRUD-шаблон справочников | `/api/saved-points/`, `/api/saved-templates/`, `/api/saved-profiles/` (GET/POST/PUT/DELETE) |
|
||||||
|
| [seq-prediction.puml](seq-prediction.puml) | Запуск прогноза траектории | `POST /api/predictions/` |
|
||||||
|
| [seq-telemetry.puml](seq-telemetry.puml) | Загрузка истории и приём телеметрии | `GET /api/{id}/telemetry/`, `WS /api/ws/satellite/{id}/telemetry/` |
|
||||||
|
| [seq-wind.puml](seq-wind.puml) | Визуализация поля ветра (статика + синхронизация) | `GET /api/v1/wind/field`, `GET /api/v1/wind/meta` |
|
||||||
|
|
||||||
|
## Диаграмма потоков данных
|
||||||
|
|
||||||
|
| Файл | Что описывает |
|
||||||
|
|------|----------------|
|
||||||
|
| [dfd-telemetry.puml](dfd-telemetry.puml) | DFD подсистемы слежения: внешние сущности, процессы 1.0–5.0, хранилища D1–D3 |
|
||||||
|
|
||||||
|
## Рендеринг в PNG/SVG
|
||||||
|
|
||||||
|
В системе есть Java, но нет CLI PlantUML. Один раз скачать jar и собрать все
|
||||||
|
диаграммы:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -L -o plantuml.jar https://github.com/plantuml/plantuml/releases/latest/download/plantuml.jar
|
||||||
|
java -jar plantuml.jar -tpng docs/diagrams/*.puml # PNG
|
||||||
|
java -jar plantuml.jar -tsvg docs/diagrams/*.puml # SVG (для печати)
|
||||||
|
```
|
||||||
|
|
||||||
|
Альтернатива без установки — онлайн-редактор <https://www.plantuml.com/plantuml>
|
||||||
|
или расширение PlantUML для VS Code (предпросмотр `Alt+D`).
|
||||||
102
docs/diagrams/architecture.puml
Normal file
102
docs/diagrams/architecture.puml
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
@startuml architecture
|
||||||
|
title Архитектура клиентской части системы
|
||||||
|
|
||||||
|
skinparam shadowing false
|
||||||
|
skinparam defaultFontName Helvetica
|
||||||
|
skinparam packageStyle rectangle
|
||||||
|
skinparam rectangle {
|
||||||
|
BorderColor #2C5AA0
|
||||||
|
BackgroundColor #EAF1FB
|
||||||
|
}
|
||||||
|
skinparam package {
|
||||||
|
BorderColor #6B6B6B
|
||||||
|
BackgroundColor #FFFFFF
|
||||||
|
}
|
||||||
|
skinparam node {
|
||||||
|
BorderColor #444444
|
||||||
|
BackgroundColor #F5F5F5
|
||||||
|
}
|
||||||
|
|
||||||
|
node "Браузер (SPA) — SvelteKit 5 + Vite" as spa {
|
||||||
|
|
||||||
|
package "routes/ (страницы)" as routes {
|
||||||
|
rectangle "/login" as r_login
|
||||||
|
rectangle "/predict" as r_predict
|
||||||
|
rectangle "/track" as r_track
|
||||||
|
rectangle "/user/*" as r_user
|
||||||
|
}
|
||||||
|
|
||||||
|
package "features/ (функциональные модули)" as features {
|
||||||
|
rectangle "auth\nLoginForm, Navbar" as f_auth
|
||||||
|
rectangle "prediction\nControlPanel, ScenarioPanel,\nCurveEditor" as f_pred
|
||||||
|
rectangle "workspaces\nWorkspacesPanel,\nWorkspaceRenderer, store" as f_ws
|
||||||
|
rectangle "tracking\nTelemetryPanel,\nTelemetryStore" as f_track
|
||||||
|
rectangle "wind\nWindRenderer,\nParticleField, WindCache" as f_wind
|
||||||
|
rectangle "timeline\nTimeLine, store" as f_time
|
||||||
|
rectangle "settings\nSettingsPanel, schema, store" as f_set
|
||||||
|
rectangle "footer" as f_foot
|
||||||
|
}
|
||||||
|
|
||||||
|
package "lib/ (ядро)" as core {
|
||||||
|
rectangle "api/\nclient (HTTP+WS, CSRF),\npredictions, telemetry, wind,\npoints, scenarios, profiles" as l_api
|
||||||
|
rectangle "map/\nобёртка MapLibre GL JS,\nслои, инструменты" as l_map
|
||||||
|
rectangle "ui/\nнезависимые примитивы\n(CollapsibleCard, Toast, …)" as l_ui
|
||||||
|
rectangle "i18n/\nсловари ru / en" as l_i18n
|
||||||
|
rectangle "auth/\nguard, store" as l_auth
|
||||||
|
rectangle "state/\npersisted store\n(localStorage)" as l_state
|
||||||
|
rectangle "domain/\ngeo, math, telemetry,\nprediction, scenario, wind\n(чистые типы и функции)" as l_dom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
' ── Внешние сервисы ───────────────────────────────────────────────
|
||||||
|
node "Backend (Django)" as be {
|
||||||
|
rectangle "REST API\n/api/*" as be_rest
|
||||||
|
rectangle "WebSocket\n/api/ws/satellite/{id}/telemetry/" as be_ws
|
||||||
|
}
|
||||||
|
node "Сервис предсказателя\n(127.0.0.1:8080)" as predictor {
|
||||||
|
rectangle "GET /api/v1/wind/*" as pred_wind
|
||||||
|
}
|
||||||
|
|
||||||
|
' ── Внешние библиотеки ───────────────────────────────────────────
|
||||||
|
package "Внешние библиотеки" as libs {
|
||||||
|
rectangle "Svelte 5 / SvelteKit / Vite" as lib_svelte
|
||||||
|
rectangle "MapLibre GL JS" as lib_map
|
||||||
|
rectangle "Bootstrap / Sveltestrap" as lib_bs
|
||||||
|
rectangle "Chart.js, Luxon, js-cookie" as lib_misc
|
||||||
|
}
|
||||||
|
|
||||||
|
' ── Связи: страницы → модули ─────────────────────────────────────
|
||||||
|
r_login --> f_auth
|
||||||
|
r_predict --> f_pred
|
||||||
|
r_predict --> f_ws
|
||||||
|
r_predict --> f_wind
|
||||||
|
r_predict --> f_time
|
||||||
|
r_predict --> f_set
|
||||||
|
r_track --> f_track
|
||||||
|
r_user --> f_pred
|
||||||
|
|
||||||
|
' ── Модули → ядро ────────────────────────────────────────────────
|
||||||
|
features --> l_api
|
||||||
|
features --> l_map
|
||||||
|
features --> l_ui
|
||||||
|
features --> l_state
|
||||||
|
features --> l_i18n
|
||||||
|
f_auth --> l_auth
|
||||||
|
|
||||||
|
' ── Ядро → домен (чистые типы) ───────────────────────────────────
|
||||||
|
l_api --> l_dom
|
||||||
|
l_map --> l_dom
|
||||||
|
l_state --> l_dom
|
||||||
|
|
||||||
|
' ── Ядро → внешние сервисы ───────────────────────────────────────
|
||||||
|
l_api --> be_rest : HTTP /api/*
|
||||||
|
f_track --> be_ws : WebSocket
|
||||||
|
f_wind --> pred_wind : HTTP (без CSRF)
|
||||||
|
|
||||||
|
' ── Использование внешних библиотек ──────────────────────────────
|
||||||
|
spa ..> lib_svelte
|
||||||
|
l_map ..> lib_map
|
||||||
|
l_ui ..> lib_bs
|
||||||
|
core ..> lib_misc
|
||||||
|
|
||||||
|
@enduml
|
||||||
62
docs/diagrams/dfd-telemetry.puml
Normal file
62
docs/diagrams/dfd-telemetry.puml
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
@startuml dfd-telemetry
|
||||||
|
title Диаграмма потоков данных (DFD) подсистемы слежения (телеметрия)
|
||||||
|
|
||||||
|
skinparam shadowing false
|
||||||
|
skinparam defaultFontName Helvetica
|
||||||
|
skinparam rectangle {
|
||||||
|
BorderColor black
|
||||||
|
BackgroundColor #F5F5F5
|
||||||
|
}
|
||||||
|
skinparam usecase {
|
||||||
|
BorderColor #2C5AA0
|
||||||
|
BackgroundColor #E8F0FE
|
||||||
|
}
|
||||||
|
skinparam database {
|
||||||
|
BorderColor #6B6B6B
|
||||||
|
BackgroundColor #FFFFFF
|
||||||
|
}
|
||||||
|
|
||||||
|
' ── Внешние сущности ───────────────────────────────────────────────
|
||||||
|
rectangle "Стратосферный зонд\n(тестовый клиент)" as sat
|
||||||
|
rectangle "Оператор\n(браузер)" as op
|
||||||
|
|
||||||
|
' ── Процессы ──────────────────────────────────────────────────────
|
||||||
|
usecase "1.0\nПриём телеметрии\n(WebSocket onmessage)" as p1
|
||||||
|
usecase "2.0\nЗагрузка истории\n(REST fetchHistory)" as p2
|
||||||
|
usecase "3.0\nНормализация\nparseTelemetry" as p3
|
||||||
|
usecase "4.0\nОтрисовка на карте\n(MapLibre $effect)" as p4
|
||||||
|
usecase "5.0\nАнализ отклонений\ncomputeDeviations" as p5
|
||||||
|
|
||||||
|
' ── Хранилища данных ──────────────────────────────────────────────
|
||||||
|
database "D1 | БД телеметрии\n(Backend)" as d1
|
||||||
|
database "D2 | points[]\n(TelemetryStore, in-memory)" as d2
|
||||||
|
database "D3 | result\n(прогноз рабочей области)" as d3
|
||||||
|
|
||||||
|
' ── Потоки данных ─────────────────────────────────────────────────
|
||||||
|
sat --> p1 : пакет телеметрии\n(JSON: lat, lon, alt, ts)
|
||||||
|
p1 --> d1 : сохранение пакета
|
||||||
|
p1 --> d2 : TelemetryPoint\n(unix-сек → ISO 8601)
|
||||||
|
|
||||||
|
op --> p2 : UUID спутника
|
||||||
|
d1 --> p2 : RawTelemetryPacket[]\n(новые первыми)
|
||||||
|
p2 --> d2 : история (reverse → хронология)
|
||||||
|
|
||||||
|
d2 --> p3 : points[]
|
||||||
|
p3 --> p4 : Telemetry\n{flight_path[lat,lng,alt], launch}
|
||||||
|
p4 --> op : трек + маркеры + анимированный\nмаркер текущего положения
|
||||||
|
|
||||||
|
d2 --> p5 : фактические точки
|
||||||
|
d3 --> p5 : прогнозная траектория
|
||||||
|
p5 --> op : профиль высоты,\nгоризонтальное отклонение (Хаверсин),\nΔh, макс./текущее отклонение
|
||||||
|
|
||||||
|
' ── Текущие показатели (геттер latest) ────────────────────────────
|
||||||
|
d2 --> op : широта, долгота, высота,\nсчётчик пакетов
|
||||||
|
|
||||||
|
legend left
|
||||||
|
Нотация DFD (Йордан/ДеМарко):
|
||||||
|
▢ прямоугольник — внешняя сущность
|
||||||
|
◯ овал — процесс
|
||||||
|
▭ database — хранилище данных
|
||||||
|
→ — поток данных
|
||||||
|
endlegend
|
||||||
|
@enduml
|
||||||
41
docs/diagrams/seq-auth.puml
Normal file
41
docs/diagrams/seq-auth.puml
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
@startuml seq-auth
|
||||||
|
title Аутентификация: проверка сессии, вход, выход
|
||||||
|
autonumber
|
||||||
|
|
||||||
|
actor "Пользователь" as user
|
||||||
|
participant "LoginForm /\nguard.ts" as ui
|
||||||
|
participant "authApi" as authapi
|
||||||
|
participant "client.ts\nrequest<T>()" as client
|
||||||
|
participant "Backend\n(Django)" as be
|
||||||
|
|
||||||
|
== Проверка сессии при открытии защищённой страницы ==
|
||||||
|
ui -> authapi : requireAuthenticated()
|
||||||
|
authapi -> client : session()
|
||||||
|
client -> be : GET /api/session/
|
||||||
|
be --> client : { isAuthenticated }
|
||||||
|
client --> authapi : SessionInfo
|
||||||
|
alt не аутентифицирован
|
||||||
|
authapi --> ui : goto('/login')
|
||||||
|
end
|
||||||
|
|
||||||
|
== Вход ==
|
||||||
|
user -> ui : ввод логина и пароля
|
||||||
|
ui -> authapi : login(username, password)
|
||||||
|
authapi -> client : post('/login/', {username, password})
|
||||||
|
client -> be : POST /api/login/
|
||||||
|
be --> client : 200 { detail } | 400/401 ApiError
|
||||||
|
client --> authapi : результат
|
||||||
|
authapi -> client : whoami()
|
||||||
|
client -> be : GET /api/whoami/
|
||||||
|
be --> client : { username }
|
||||||
|
client --> ui : WhoAmI
|
||||||
|
ui --> user : переход на рабочую страницу
|
||||||
|
|
||||||
|
== Выход ==
|
||||||
|
user -> ui : «Выйти»
|
||||||
|
ui -> authapi : logout()
|
||||||
|
authapi -> client : post('/logout/', {})
|
||||||
|
client -> be : POST /api/logout/
|
||||||
|
be --> client : 204
|
||||||
|
client --> ui : сброс состояния, goto('/login')
|
||||||
|
@enduml
|
||||||
41
docs/diagrams/seq-prediction.puml
Normal file
41
docs/diagrams/seq-prediction.puml
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
@startuml seq-prediction
|
||||||
|
title Запуск прогноза траектории
|
||||||
|
autonumber
|
||||||
|
|
||||||
|
actor "Пользователь" as user
|
||||||
|
participant "ControlPanel /\nWorkspacesPanel" as ui
|
||||||
|
participant "workspacesStore" as store
|
||||||
|
participant "predictionsApi" as predapi
|
||||||
|
participant "client.ts" as client
|
||||||
|
participant "Backend\n(Django)" as be
|
||||||
|
participant "parsePrediction\n(domain)" as parse
|
||||||
|
participant "WorkspaceRenderer\n(карта)" as render
|
||||||
|
|
||||||
|
user -> ui : «Выполнить прогнозирование»
|
||||||
|
ui -> store : run(workspaceId)
|
||||||
|
activate store
|
||||||
|
|
||||||
|
store -> predapi : run(params, launchDateTime)
|
||||||
|
activate predapi
|
||||||
|
predapi -> predapi : buildLaunchDateTime(date, time)\n→ ISO 8601 (UTC, Z)
|
||||||
|
predapi -> predapi : getLatestDataset()\n→ актуальный слот GFS
|
||||||
|
predapi -> client : post('/predictions/', payload)
|
||||||
|
client -> be : POST /api/predictions/\n{FlightParameters, launch_datetime, dataset}
|
||||||
|
be --> client : { result: PredictionStage[] }
|
||||||
|
client --> predapi : PredictionResponse
|
||||||
|
predapi --> store : RawPrediction
|
||||||
|
deactivate predapi
|
||||||
|
|
||||||
|
store -> parse : parsePrediction(stages)
|
||||||
|
parse --> store : Prediction\n{flight_path[lat,lng,alt], timestamps[], launch/burst/landing}
|
||||||
|
store -> store : setResult(workspaceId, prediction)
|
||||||
|
deactivate store
|
||||||
|
|
||||||
|
store -> render : реактивное обновление
|
||||||
|
render -> render : линия трека + маркеры\n(старт, разрыв, приземление)
|
||||||
|
|
||||||
|
alt ошибка сервера/сети
|
||||||
|
store -> store : lastRunError = message
|
||||||
|
store --> ui : toast «Ошибка прогнозирования»
|
||||||
|
end
|
||||||
|
@enduml
|
||||||
60
docs/diagrams/seq-telemetry.puml
Normal file
60
docs/diagrams/seq-telemetry.puml
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
@startuml seq-telemetry
|
||||||
|
title Слежение: загрузка истории и приём телеметрии в реальном времени
|
||||||
|
autonumber
|
||||||
|
|
||||||
|
actor "Пользователь" as user
|
||||||
|
participant "TelemetryPanel" as ui
|
||||||
|
participant "TelemetryStore\n(.svelte.ts)" as store
|
||||||
|
participant "telemetryApi" as tapi
|
||||||
|
participant "client.ts" as client
|
||||||
|
participant "Backend REST" as rest
|
||||||
|
participant "Backend\nWebSocket" as ws
|
||||||
|
participant "track/+page\n($effect)" as page
|
||||||
|
participant "MapLibre\n(карта)" as map
|
||||||
|
|
||||||
|
user -> ui : ввод UUID, «Подключиться»
|
||||||
|
ui -> store : connect(id)
|
||||||
|
activate store
|
||||||
|
|
||||||
|
store -> store : проверка UUID регулярным выражением
|
||||||
|
alt UUID невалиден
|
||||||
|
store -> store : status = 'error'
|
||||||
|
store --> ui : (выход без соединения)
|
||||||
|
end
|
||||||
|
|
||||||
|
store -> store : disconnect()\nзакрыть прежний WS, очистить points
|
||||||
|
store -> store : status = 'connecting'
|
||||||
|
|
||||||
|
== Загрузка истории (некритическая) ==
|
||||||
|
store -> tapi : fetchHistory(id)
|
||||||
|
tapi -> client : get('/{id}/telemetry/')
|
||||||
|
client -> rest : GET /api/{id}/telemetry/?from&till
|
||||||
|
rest --> client : RawTelemetryPacket[] | { results }
|
||||||
|
client --> tapi : массив пакетов
|
||||||
|
tapi --> store : RawTelemetryPacket[]
|
||||||
|
store -> store : reverse() → хронологический порядок,\npoints = [...history]
|
||||||
|
note right of store : сбой сети → console.warn,\nподключение не прерывается
|
||||||
|
|
||||||
|
== WebSocket-соединение ==
|
||||||
|
store -> tapi : buildWsUrl(id)
|
||||||
|
tapi --> store : ws(s)://host/api/ws/satellite/{id}/telemetry/
|
||||||
|
store -> ws : new WebSocket(url)
|
||||||
|
ws --> store : onopen → status = 'connected'
|
||||||
|
|
||||||
|
loop каждый новый пакет
|
||||||
|
ws --> store : onmessage(JSON)
|
||||||
|
store -> store : RawPacket → TelemetryPoint\n(unix-сек → ISO 8601),\npoints = [...points, point]
|
||||||
|
store --> page : реактивное изменение points (Svelte $state)
|
||||||
|
page -> map : scene.clear()
|
||||||
|
page -> map : addLine(трек) + addMarker(старт)\n+ plotAnimatedMarker(текущая точка)
|
||||||
|
page -> map : fitBounds() — однократно (fittedBounds)
|
||||||
|
end
|
||||||
|
|
||||||
|
== Отключение ==
|
||||||
|
user -> ui : «Отключиться»
|
||||||
|
ui -> store : disconnect()
|
||||||
|
store -> ws : close()
|
||||||
|
ws --> store : onclose → status = 'idle'
|
||||||
|
store -> store : points = [], satelliteId = null
|
||||||
|
deactivate store
|
||||||
|
@enduml
|
||||||
54
docs/diagrams/seq-wind.puml
Normal file
54
docs/diagrams/seq-wind.puml
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
@startuml seq-wind
|
||||||
|
title Визуализация поля ветра: статический режим и синхронизация с траекторией
|
||||||
|
autonumber
|
||||||
|
|
||||||
|
participant "predict/+page\n<WindRenderer>" as render
|
||||||
|
participant "WindRenderer\n(.svelte)" as wr
|
||||||
|
participant "WindCache\n(in-memory)" as cache
|
||||||
|
participant "windApi" as wapi
|
||||||
|
participant "Сервис\nпредсказателя\n(127.0.0.1:8080)" as pred
|
||||||
|
participant "createWindInterpolator\n+ ParticleField" as pf
|
||||||
|
|
||||||
|
note over wapi, pred
|
||||||
|
Запросы идут НАПРЯМУЮ через fetch к сервису предсказателя,
|
||||||
|
минуя client.ts: без CSRF и сессионных cookie.
|
||||||
|
end note
|
||||||
|
|
||||||
|
alt Статический режим (ветер по умолчанию)
|
||||||
|
wr -> wr : параметры из активной области\n(высота старта, дата старта)
|
||||||
|
wr -> cache : get(ключ = JSON параметров)
|
||||||
|
alt промах кэша
|
||||||
|
wr -> wapi : field({ altitude, step, time })
|
||||||
|
wapi -> pred : GET /api/v1/wind/field?altitude&step&time
|
||||||
|
pred --> wapi : WindField [C_U, C_V]
|
||||||
|
wapi --> wr : WindField
|
||||||
|
wr -> cache : put(ключ, field)
|
||||||
|
end
|
||||||
|
wr -> pf : установить поле → анимация частиц
|
||||||
|
end
|
||||||
|
|
||||||
|
alt Режим синхронизации с траекторией
|
||||||
|
note over wr : активен прогноз И timeline.max > 0
|
||||||
|
wr -> wr : bbox траектории + проверки\n(F ≤ Fmax, сторона ≤ Dmax)
|
||||||
|
loop δ ∈ {0, ΔT, 2ΔT, …, F}
|
||||||
|
wr -> wr : T = t0 + δ; высота a_{k*} (бин. поиск)
|
||||||
|
wr -> cache : get(ключ кадра)
|
||||||
|
alt промах кэша
|
||||||
|
wr -> wapi : field({ altitude, step, time, min/max lat/lng })
|
||||||
|
wapi -> pred : GET /api/v1/wind/field?…(bbox)
|
||||||
|
pred --> wapi : WindField кадра
|
||||||
|
wapi --> wr : WindField
|
||||||
|
wr -> cache : put(ключ кадра, field)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
loop при воспроизведении (timeline)
|
||||||
|
wr -> wr : fieldAtFlightTime(δ):\nлинейная интерполяция [u,v]\nмежду соседними кадрами
|
||||||
|
wr -> pf : currentField → плавная адвекция частиц
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
note over wapi, pred
|
||||||
|
windApi.meta() → GET /api/v1/wind/meta
|
||||||
|
(опорное время / параметры сетки, при необходимости)
|
||||||
|
end note
|
||||||
|
@enduml
|
||||||
395
docs/wind-vis-math.tex
Normal file
395
docs/wind-vis-math.tex
Normal file
|
|
@ -0,0 +1,395 @@
|
||||||
|
\documentclass[11pt,a4paper]{article}
|
||||||
|
\usepackage{siunitx}
|
||||||
|
\usepackage{amsmath,amssymb,amsthm}
|
||||||
|
\usepackage{geometry}
|
||||||
|
\usepackage{hyperref}
|
||||||
|
\usepackage{booktabs}
|
||||||
|
\geometry{margin=2.5cm}
|
||||||
|
|
||||||
|
\title{Wind Visualisation -- Mathematical Reference\\
|
||||||
|
\large stratoflights-predictor frontend}
|
||||||
|
\author{stratoflights}
|
||||||
|
\date{}
|
||||||
|
|
||||||
|
\begin{document}
|
||||||
|
\maketitle
|
||||||
|
\tableofcontents
|
||||||
|
\bigskip
|
||||||
|
|
||||||
|
%---------------------------------------------------------------------------
|
||||||
|
\section{Wind Field Grid Format}
|
||||||
|
%---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
The predictor's \texttt{GET /api/v1/wind/field} endpoint returns a
|
||||||
|
two-element JSON array $[C_U,\,C_V]$, each element having the shape
|
||||||
|
|
||||||
|
\begin{verbatim}
|
||||||
|
{ "header": { "nx", "ny", "lo1", "la1", "lo2", "la2",
|
||||||
|
"dx", "dy", "refTime", ... },
|
||||||
|
"data": [ float, ... ] // flat row-major array, length = nx * ny
|
||||||
|
}
|
||||||
|
\end{verbatim}
|
||||||
|
|
||||||
|
\noindent where $C_U$ contains the \emph{eastward} (zonal) component $u$
|
||||||
|
and $C_V$ contains the \emph{northward} (meridional) component $v$, both
|
||||||
|
in \si{m\,s^{-1}}.
|
||||||
|
|
||||||
|
\subsection{Grid coordinates}
|
||||||
|
|
||||||
|
Let $n_x$ and $n_y$ be the number of grid points along the longitude and
|
||||||
|
latitude axes respectively. The coordinate of grid cell $(i,\,j)$
|
||||||
|
($i = 0, \ldots, n_x-1$;\; $j = 0, \ldots, n_y-1$) is
|
||||||
|
|
||||||
|
\begin{align}
|
||||||
|
\lambda_{i} &= \operatorname{wrap}\!\left(\lambda_1 + i\,\Delta\lambda\right), \label{eq:lng}\\
|
||||||
|
\varphi_{j} &= \varphi_1 + j\,\Delta\varphi, \label{eq:lat}
|
||||||
|
\end{align}
|
||||||
|
|
||||||
|
\noindent where $\lambda_1 = \texttt{lo1}$ and $\varphi_1 = \texttt{la1}$.
|
||||||
|
|
||||||
|
\paragraph{Increments from the extent, not \texttt{dx}/\texttt{dy}.}
|
||||||
|
The predictor reports \texttt{dx} and \texttt{dy} as positive magnitudes
|
||||||
|
\emph{regardless of scan direction}, and emits longitudes in the
|
||||||
|
$[0,360)$ convention. The per-step increments are therefore derived from
|
||||||
|
the grid extent so the last row/column lands exactly on
|
||||||
|
$(\texttt{la2},\texttt{lo2})$ and the scan direction follows automatically:
|
||||||
|
|
||||||
|
\begin{equation}
|
||||||
|
\Delta\lambda = \frac{(\texttt{lo2}-\texttt{lo1}) \bmod 360}{n_x-1},
|
||||||
|
\qquad
|
||||||
|
\Delta\varphi = \frac{\texttt{la2}-\texttt{la1}}{n_y-1}.
|
||||||
|
\label{eq:increments}
|
||||||
|
\end{equation}
|
||||||
|
|
||||||
|
For the standard GFS global grid $\varphi_1 = 90^\circ$, $\varphi_2 = -90^\circ$,
|
||||||
|
so $\Delta\varphi = -10^\circ$ (rows scan southward) without any case
|
||||||
|
distinction. (When $n_x=1$ or $n_y=1$ the magnitude \texttt{dx}/\texttt{dy}
|
||||||
|
is used as a fallback.)
|
||||||
|
|
||||||
|
\paragraph{Longitude wrapping.}
|
||||||
|
Because longitudes are emitted in $[0,360)$ but the map renders in
|
||||||
|
$(-180,180]$, each longitude is wrapped:
|
||||||
|
\begin{equation}
|
||||||
|
\operatorname{wrap}(\lambda) = \big((\lambda + 180) \bmod 360\big) - 180.
|
||||||
|
\label{eq:wrap}
|
||||||
|
\end{equation}
|
||||||
|
Without \eqref{eq:wrap} a grid sampled near the prime meridian
|
||||||
|
(e.g.\ $\texttt{lo1}=358$ for a query at $-2^\circ$) would be rendered a
|
||||||
|
full $360^\circ$ away from the trajectory.
|
||||||
|
|
||||||
|
The flat data index for cell $(i,\,j)$ is
|
||||||
|
|
||||||
|
\begin{equation}
|
||||||
|
k = j\,n_x + i.
|
||||||
|
\label{eq:index}
|
||||||
|
\end{equation}
|
||||||
|
|
||||||
|
%---------------------------------------------------------------------------
|
||||||
|
\section{Bilinear Interpolation of Wind Components}
|
||||||
|
%---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
The particle renderer samples the wind at arbitrary points (the unprojected
|
||||||
|
pixel positions of individual particles), so bilinear interpolation of the
|
||||||
|
$[u,v]$ grid is performed at run time for every particle, every frame.
|
||||||
|
|
||||||
|
Given a query point $(\lambda,\,\varphi)$ inside the grid, locate the
|
||||||
|
surrounding four cells
|
||||||
|
|
||||||
|
\begin{align*}
|
||||||
|
i_0 &= \left\lfloor \frac{\lambda - \lambda_1}{\Delta\lambda} \right\rfloor,
|
||||||
|
&
|
||||||
|
j_0 &= \left\lfloor \frac{\varphi - \varphi_1}{\Delta\varphi} \right\rfloor,
|
||||||
|
\end{align*}
|
||||||
|
|
||||||
|
and define the fractional offsets
|
||||||
|
|
||||||
|
\begin{equation*}
|
||||||
|
s = \frac{\lambda - \lambda_{i_0}}{\Delta\lambda},
|
||||||
|
\qquad
|
||||||
|
t = \frac{\varphi - \varphi_{j_0}}{\Delta\varphi},
|
||||||
|
\qquad
|
||||||
|
s,t \in [0,1].
|
||||||
|
\end{equation*}
|
||||||
|
|
||||||
|
The bilinearly interpolated value of any scalar field $f$ at $(\lambda,\varphi)$
|
||||||
|
is
|
||||||
|
|
||||||
|
\begin{equation}
|
||||||
|
f(\lambda,\varphi)
|
||||||
|
= (1-s)(1-t)\,f_{i_0,j_0}
|
||||||
|
+ s(1-t)\,f_{i_0+1,j_0}
|
||||||
|
+ (1-s)t\,f_{i_0,j_0+1}
|
||||||
|
+ st\,f_{i_0+1,j_0+1}.
|
||||||
|
\label{eq:bilinear}
|
||||||
|
\end{equation}
|
||||||
|
|
||||||
|
Applied independently to $u$ and $v$, equation~\eqref{eq:bilinear} gives
|
||||||
|
the interpolated wind vector at any interior point.
|
||||||
|
|
||||||
|
%---------------------------------------------------------------------------
|
||||||
|
\section{Particle Advection and Rendering}
|
||||||
|
%---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
The wind is visualised as a dense field of particles that flow with the wind,
|
||||||
|
in the style of \texttt{leaflet-velocity} / cambecc's \emph{earth}. Particles
|
||||||
|
live in screen (CSS-pixel) space; each animation frame they are advected by
|
||||||
|
the local wind and drawn as short fading trails.
|
||||||
|
|
||||||
|
\subsection{Speed (magnitude)}
|
||||||
|
|
||||||
|
The wind speed used for colouring is the Euclidean norm of the horizontal
|
||||||
|
wind vector returned by the interpolator~\eqref{eq:bilinear}:
|
||||||
|
|
||||||
|
\begin{equation}
|
||||||
|
\lVert\mathbf{w}\rVert = \sqrt{u^2 + v^2}.
|
||||||
|
\label{eq:speed}
|
||||||
|
\end{equation}
|
||||||
|
|
||||||
|
\subsection{Advection through the map projection}
|
||||||
|
|
||||||
|
A particle at pixel position $\mathbf{x} = (x,y)$ is unprojected to geographic
|
||||||
|
coordinates $(\lambda,\varphi) = P^{-1}(\mathbf{x})$, where $P$ is the
|
||||||
|
MapLibre projection (Web Mercator composed with the current view transform).
|
||||||
|
The wind $\mathbf{w}=(u,v)$ in the east--north frame must be expressed in
|
||||||
|
pixel space; this is done with the local Jacobian of $P$, estimated by finite
|
||||||
|
differences with a small $\varepsilon$ (degrees):
|
||||||
|
|
||||||
|
\begin{align}
|
||||||
|
\mathbf{J}_\lambda &= \frac{P(\lambda+\varepsilon,\varphi) - \mathbf{x}}{\varepsilon},
|
||||||
|
&
|
||||||
|
\mathbf{J}_\varphi &= \frac{P(\lambda,\varphi+\varepsilon) - \mathbf{x}}{\varepsilon}.
|
||||||
|
\label{eq:jacobian}
|
||||||
|
\end{align}
|
||||||
|
|
||||||
|
\noindent The pixel-space velocity is the Jacobian applied to the wind vector,
|
||||||
|
scaled by a dimensionless speed factor $c$ (the configurable
|
||||||
|
\texttt{particleSpeed} times a base constant):
|
||||||
|
|
||||||
|
\begin{equation}
|
||||||
|
\dot{\mathbf{x}} = c\,\big(\mathbf{J}_\lambda\, u + \mathbf{J}_\varphi\, v\big).
|
||||||
|
\label{eq:pixel_velocity}
|
||||||
|
\end{equation}
|
||||||
|
|
||||||
|
Because $\mathbf{J}_\varphi$ points toward $-y$ (north is up), a northward wind
|
||||||
|
moves the particle up the screen, and the projection automatically supplies
|
||||||
|
the correct scale at every zoom level and the meridian-convergence distortion
|
||||||
|
near the poles. The particle is integrated one explicit Euler step per frame:
|
||||||
|
|
||||||
|
\begin{equation}
|
||||||
|
\mathbf{x}_{t+1} = \mathbf{x}_t + \dot{\mathbf{x}}.
|
||||||
|
\label{eq:euler}
|
||||||
|
\end{equation}
|
||||||
|
|
||||||
|
A particle whose position leaves the data grid (interpolator returns
|
||||||
|
\texttt{null}) or whose age exceeds $A_{\max}$ frames is respawned at a random
|
||||||
|
pixel that has wind, with a randomised initial age to desynchronise the
|
||||||
|
population.
|
||||||
|
|
||||||
|
\subsection{Colour mapping}
|
||||||
|
|
||||||
|
Speed is mapped to a 15-stop colour ramp (blue $\to$ red) by linear
|
||||||
|
quantisation into the range $[v_{\min}, v_{\max}]$:
|
||||||
|
|
||||||
|
\begin{equation}
|
||||||
|
k(\lVert\mathbf{w}\rVert) =
|
||||||
|
\operatorname{round}\!\left(
|
||||||
|
\frac{\lVert\mathbf{w}\rVert - v_{\min}}{v_{\max}-v_{\min}}\,(K-1)
|
||||||
|
\right),
|
||||||
|
\quad k \in \{0,\dots,K-1\},
|
||||||
|
\end{equation}
|
||||||
|
|
||||||
|
\noindent clamped to the ramp, where $K=15$ and $v_{\max}$ is the configurable
|
||||||
|
\texttt{maxVelocity}. Trails sharing a colour bucket are batched into a single
|
||||||
|
stroked path per frame.
|
||||||
|
|
||||||
|
\subsection{Trail fading}
|
||||||
|
|
||||||
|
Rather than clearing the canvas each frame, the previous frame is faded toward
|
||||||
|
\emph{transparency} (so the basemap stays visible) by compositing a translucent
|
||||||
|
rectangle with the \texttt{destination-in} operator:
|
||||||
|
|
||||||
|
\begin{equation}
|
||||||
|
\alpha_{t+1}(\mathbf{x}) = \rho\,\alpha_t(\mathbf{x}),
|
||||||
|
\qquad \rho \in [0,1),
|
||||||
|
\end{equation}
|
||||||
|
|
||||||
|
\noindent where $\rho$ is the configurable \texttt{trailPersistence}; an
|
||||||
|
existing trail therefore decays geometrically, retaining a visible tail of
|
||||||
|
length $\sim 1/(1-\rho)$ frames. New trail segments are then drawn with the
|
||||||
|
\texttt{source-over} operator.
|
||||||
|
|
||||||
|
\subsection{Particle count}
|
||||||
|
|
||||||
|
The particle population is proportional to the canvas area:
|
||||||
|
|
||||||
|
\begin{equation}
|
||||||
|
N = \min\!\big(N_{\max},\; W H \, \mu \, \texttt{density}\big),
|
||||||
|
\end{equation}
|
||||||
|
|
||||||
|
\noindent with base multiplier $\mu = 1/350$ particles per pixel, the
|
||||||
|
configurable \texttt{density} factor, and a hard cap $N_{\max}=6000$ to bound
|
||||||
|
per-frame cost. The field is re-seeded on resize and on map \texttt{moveend};
|
||||||
|
trails are cleared while the map is panning/zooming and resume afterwards.
|
||||||
|
|
||||||
|
%---------------------------------------------------------------------------
|
||||||
|
\section{Trajectory Bounding Box}
|
||||||
|
%---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Let the prediction trajectory be the sequence of points
|
||||||
|
$\{(\varphi_k, \lambda_k)\}_{k=0}^{N-1}$. The axis-aligned bounding box
|
||||||
|
with margin $m$ (degrees) is
|
||||||
|
|
||||||
|
\begin{align}
|
||||||
|
\varphi_{\min}' &= \min_k \varphi_k - m, &
|
||||||
|
\varphi_{\max}' &= \max_k \varphi_k + m, \notag\\
|
||||||
|
\lambda_{\min}' &= \min_k \lambda_k - m, &
|
||||||
|
\lambda_{\max}' &= \max_k \lambda_k + m.
|
||||||
|
\label{eq:bbox}
|
||||||
|
\end{align}
|
||||||
|
|
||||||
|
A wind field request is suppressed when either span exceeds the configured
|
||||||
|
maximum $D_{\max}$:
|
||||||
|
|
||||||
|
\begin{equation}
|
||||||
|
(\varphi_{\max}' - \varphi_{\min}') > D_{\max}
|
||||||
|
\quad\text{or}\quad
|
||||||
|
(\lambda_{\max}' - \lambda_{\min}') > D_{\max}.
|
||||||
|
\label{eq:bbox_guard}
|
||||||
|
\end{equation}
|
||||||
|
|
||||||
|
Default values: $m = 1^\circ$, $D_{\max} = 20^\circ$.
|
||||||
|
|
||||||
|
%---------------------------------------------------------------------------
|
||||||
|
\section{Trajectory Altitude Lookup}
|
||||||
|
%---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
The prediction result contains two parallel arrays:
|
||||||
|
$\mathbf{p} = \{(\varphi_k, \lambda_k, a_k)\}$ (flight path with altitude
|
||||||
|
in metres) and $\mathbf{t} = \{t_k\}$ (absolute epoch timestamps in
|
||||||
|
milliseconds, $t_k < t_{k+1}$).
|
||||||
|
|
||||||
|
Given a flight-time offset $\delta$ (milliseconds from launch), the
|
||||||
|
absolute query time is $T = t_0 + \delta$. The index of the immediately
|
||||||
|
following trajectory point is found by binary search:
|
||||||
|
|
||||||
|
\begin{equation}
|
||||||
|
k^* = \min\{k \in \{0,\ldots,N-1\} : t_k \ge T\}.
|
||||||
|
\label{eq:binsearch}
|
||||||
|
\end{equation}
|
||||||
|
|
||||||
|
The trajectory altitude used for the wind query is then
|
||||||
|
$a_{k^*}$ (nearest-neighbour in time, no interpolation needed since the
|
||||||
|
pre-fetch frames are already spaced far apart relative to the step size).
|
||||||
|
|
||||||
|
%---------------------------------------------------------------------------
|
||||||
|
\section{Time Series Pre-Fetching}
|
||||||
|
%---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
\subsection{Frame schedule}
|
||||||
|
|
||||||
|
Let $F$ be the total flight duration in milliseconds and $\Delta T$ the
|
||||||
|
pre-fetch interval (milliseconds, default $15 \times 60\,000$). The set
|
||||||
|
of pre-fetch time offsets is
|
||||||
|
|
||||||
|
\begin{equation}
|
||||||
|
\mathcal{S} = \{0,\;\Delta T,\;2\Delta T,\;\ldots,\;F\},
|
||||||
|
\label{eq:schedule}
|
||||||
|
\end{equation}
|
||||||
|
|
||||||
|
where the last element is clamped to $F$ (so the landing point is always
|
||||||
|
included). For each $\delta \in \mathcal{S}$ a wind field is fetched at
|
||||||
|
absolute time $T = t_0 + \delta$ and trajectory altitude $a_{k^*(\delta)}$
|
||||||
|
within the bounding box~\eqref{eq:bbox}.
|
||||||
|
|
||||||
|
\subsection{Guard conditions}
|
||||||
|
|
||||||
|
Pre-fetching is skipped entirely when
|
||||||
|
|
||||||
|
\begin{enumerate}
|
||||||
|
\item $F > F_{\max}$ (flight too long; default $F_{\max} = 4$\,h), or
|
||||||
|
\item either bounding-box dimension exceeds $D_{\max}$
|
||||||
|
(equation~\eqref{eq:bbox_guard}).
|
||||||
|
\end{enumerate}
|
||||||
|
|
||||||
|
Both limits are configurable in the application settings.
|
||||||
|
|
||||||
|
\subsection{Temporal interpolation between frames}
|
||||||
|
|
||||||
|
During playback at flight-time offset $\delta$, rather than snapping to the
|
||||||
|
nearest cached frame (which would make the wind jump every $\Delta T$), the
|
||||||
|
displayed field is \emph{linearly interpolated} between the two bracketing
|
||||||
|
frames $\delta_p \le \delta < \delta_{p+1}$ ($\delta_p,\delta_{p+1}\in\mathcal{S}$).
|
||||||
|
With blend factor
|
||||||
|
|
||||||
|
\begin{equation}
|
||||||
|
\alpha = \frac{\delta - \delta_p}{\delta_{p+1} - \delta_p} \in [0,1),
|
||||||
|
\label{eq:blend_alpha}
|
||||||
|
\end{equation}
|
||||||
|
|
||||||
|
each wind component is blended cell-for-cell:
|
||||||
|
|
||||||
|
\begin{equation}
|
||||||
|
u(\delta) = (1-\alpha)\,u^{(p)} + \alpha\,u^{(p+1)},
|
||||||
|
\qquad
|
||||||
|
v(\delta) = (1-\alpha)\,v^{(p)} + \alpha\,v^{(p+1)}.
|
||||||
|
\label{eq:time_lerp}
|
||||||
|
\end{equation}
|
||||||
|
|
||||||
|
This is valid because every frame is fetched over the \emph{same} bounding
|
||||||
|
box~\eqref{eq:bbox} and step, so the grids are cell-aligned ($n_x,n_y,
|
||||||
|
\lambda_1,\varphi_1$ identical) and~\eqref{eq:time_lerp} requires only an
|
||||||
|
element-wise blend of the two flat $[u,v]$ arrays. Note that the two frames
|
||||||
|
are sampled at slightly different altitudes $a_{k^*(\delta_p)}$ and
|
||||||
|
$a_{k^*(\delta_{p+1})}$; the blend therefore also interpolates across the
|
||||||
|
balloon's changing altitude, which is the desired behaviour. The result is
|
||||||
|
continuous wind evolution along the route while still requiring only
|
||||||
|
$|\mathcal{S}|$ network requests per trajectory.
|
||||||
|
|
||||||
|
The blended field~\eqref{eq:time_lerp} is handed to the particle renderer
|
||||||
|
(\S\,Particle Advection) as the interpolator swapped in each frame, so the
|
||||||
|
flowing particles advect through the smoothly time-evolving wind without any
|
||||||
|
visible stepping between pre-fetch frames.
|
||||||
|
|
||||||
|
%---------------------------------------------------------------------------
|
||||||
|
\section{Complexity and Performance Notes}
|
||||||
|
%---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
\subsection{Grid size and render cost}
|
||||||
|
|
||||||
|
For a regional bounding box of $L_\varphi \times L_\lambda$ degrees and
|
||||||
|
step $h$ degrees, the number of grid cells fetched is
|
||||||
|
|
||||||
|
\begin{equation}
|
||||||
|
n = \left\lceil \frac{L_\varphi}{h} \right\rceil
|
||||||
|
\left\lceil \frac{L_\lambda}{h} \right\rceil.
|
||||||
|
\end{equation}
|
||||||
|
|
||||||
|
With the default trajectory step $h = 1^\circ$ and worst-case dimensions
|
||||||
|
$20^\circ \times 20^\circ$ this is $n = 400$ cells; the static global view at
|
||||||
|
$h = 2^\circ$ is $90 \times 180 = 16\,200$ cells. Crucially, the grid size
|
||||||
|
only affects the \emph{fetch} and the per-particle bilinear lookup, not the
|
||||||
|
render budget: the per-frame cost is governed by the particle count $N$
|
||||||
|
(eq.~for $N$), capped at $N_{\max}=6000$, independent of the grid resolution
|
||||||
|
or the geographic extent. Each particle costs one unprojection, one
|
||||||
|
interpolation, and two projections (for the Jacobian) per frame.
|
||||||
|
|
||||||
|
\subsection{Request count}
|
||||||
|
|
||||||
|
The total number of requests for one trajectory is
|
||||||
|
|
||||||
|
\begin{equation}
|
||||||
|
|\mathcal{S}| = \left\lfloor \frac{F}{\Delta T} \right\rfloor + 1
|
||||||
|
\;\le\; \frac{F_{\max}}{\Delta T} + 1.
|
||||||
|
\end{equation}
|
||||||
|
|
||||||
|
With the defaults $F_{\max} = 4$\,h and $\Delta T = 15$\,min:
|
||||||
|
$|\mathcal{S}| \le 17$. Requests are issued sequentially to avoid
|
||||||
|
bursty load on the predictor.
|
||||||
|
|
||||||
|
\subsection{Caching}
|
||||||
|
|
||||||
|
The in-memory cache (keyed by the full parameter tuple) ensures that
|
||||||
|
re-running a prediction with the same parameters, scrubbing the timeline
|
||||||
|
back and forth, or toggling the wind layer all reuse existing responses.
|
||||||
|
|
||||||
|
\end{document}
|
||||||
6
mocks/data/points.json
Normal file
6
mocks/data/points.json
Normal 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
22
mocks/data/scenarios.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
56
mocks/handlers/prediction.ts
Normal file
56
mocks/handlers/prediction.ts
Normal 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
200
mocks/vitePlugin.ts
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
99
package-lock.json
generated
99
package-lock.json
generated
|
|
@ -20,10 +20,12 @@
|
||||||
"svelte5-chartjs": "^1.0.0"
|
"svelte5-chartjs": "^1.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-auto": "^4.0.0",
|
"@playwright/test": "^1.59.1",
|
||||||
|
"@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",
|
||||||
|
|
@ -566,6 +568,21 @@
|
||||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
|
||||||
"integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw=="
|
"integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.59.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@polka/url": {
|
"node_modules/@polka/url": {
|
||||||
"version": "1.0.0-next.28",
|
"version": "1.0.0-next.28",
|
||||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.28.tgz",
|
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.28.tgz",
|
||||||
|
|
@ -890,14 +907,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 +1042,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",
|
||||||
|
|
@ -1365,16 +1388,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",
|
||||||
|
|
@ -1580,6 +1593,50 @@
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.59.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.3",
|
"version": "8.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
|
||||||
|
|
@ -1836,6 +1893,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",
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,18 @@
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch"
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:headed": "playwright test --headed",
|
||||||
|
"test:e2e:ui": "playwright test --ui"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-auto": "^4.0.0",
|
"@playwright/test": "^1.59.1",
|
||||||
|
"@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",
|
||||||
|
|
|
||||||
48
playwright.config.ts
Normal file
48
playwright.config.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playwright configuration.
|
||||||
|
*
|
||||||
|
* Tests run against the Vite dev server with the mock API enabled, so they
|
||||||
|
* don't require Django to be running. Each test gets an isolated user session
|
||||||
|
* via the `storageState` reset in globalSetup (implicit: every test starts
|
||||||
|
* with a blank context).
|
||||||
|
*
|
||||||
|
* Run:
|
||||||
|
* npx playwright install chromium # first time only — installs browsers
|
||||||
|
* npm run test:e2e # headless
|
||||||
|
* npm run test:e2e:headed # opens a browser
|
||||||
|
* npm run test:e2e:ui # Playwright UI mode
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests/e2e',
|
||||||
|
timeout: 30_000,
|
||||||
|
expect: { timeout: 5_000 },
|
||||||
|
fullyParallel: false, // the mock plugin writes to shared JSON files on disk
|
||||||
|
workers: 1,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 1 : 0,
|
||||||
|
reporter: [['list'], ['html', { open: 'never' }]],
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://127.0.0.1:5173',
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
video: 'retain-on-failure',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run dev -- --port 5173 --strictPort',
|
||||||
|
url: 'http://127.0.0.1:5173',
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 60_000,
|
||||||
|
env: {
|
||||||
|
VITE_USE_MOCK_API: 'true',
|
||||||
|
VITE_API_BASE_URL: '/api',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
1
resume
Normal file
1
resume
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
claude --resume 0ed44ce1-31ba-4b4e-b975-387a4b2ae13d
|
||||||
68
scripts/run-stack.sh
Executable file
68
scripts/run-stack.sh
Executable file
|
|
@ -0,0 +1,68 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Boot the full leaflet_svelte ↔ stratoflights ↔ predictor test stack.
|
||||||
|
#
|
||||||
|
# Processes started (background):
|
||||||
|
# - fake_tawhiri (Python http.server, :8001) — synthesizes prediction responses
|
||||||
|
# - stratoflights Django (runserver, :8000) — API backend, talks to fake_tawhiri
|
||||||
|
# - leaflet_svelte Vite dev server (:5173) — proxies /api to Django
|
||||||
|
#
|
||||||
|
# After this, run tests with:
|
||||||
|
# npm run test:e2e
|
||||||
|
#
|
||||||
|
# Stop everything with:
|
||||||
|
# scripts/stop-stack.sh
|
||||||
|
#
|
||||||
|
# Requirements (once):
|
||||||
|
# - Python deps: pip install --user --break-system-packages Django djangorestframework \
|
||||||
|
# djangorestframework-simplejwt drf-spectacular requests django-cors-headers \
|
||||||
|
# Pillow python-dotenv channels daphne
|
||||||
|
# - `demo` user in Django with password `demo`:
|
||||||
|
# DJANGO_ENV=production python3 manage.py createsuperuser (--noinput with env)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
FRONTEND_DIR=/home/anton/leaflet_svelte
|
||||||
|
BACKEND_DIR=/home/anton/stratoflights
|
||||||
|
PID_DIR=/tmp/lsv-stack
|
||||||
|
mkdir -p "$PID_DIR"
|
||||||
|
|
||||||
|
# --- fake_tawhiri ----------------------------------------------------------------
|
||||||
|
if ! ss -tlnp 2>/dev/null | grep -q ':8001'; then
|
||||||
|
echo "starting fake_tawhiri on :8001"
|
||||||
|
python3 "$FRONTEND_DIR/tests/e2e/fake_tawhiri.py" > /tmp/fake-tawhiri.log 2>&1 &
|
||||||
|
echo $! > "$PID_DIR/fake-tawhiri.pid"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- stratoflights Django --------------------------------------------------------
|
||||||
|
if ! ss -tlnp 2>/dev/null | grep -q ':8000'; then
|
||||||
|
echo "starting stratoflights on :8000"
|
||||||
|
cd "$BACKEND_DIR"
|
||||||
|
DJANGO_ENV=production \
|
||||||
|
DEBUG=True \
|
||||||
|
ALLOWED_HOSTS=localhost,127.0.0.1 \
|
||||||
|
CSRF_TRUSTED_ORIGINS="http://localhost:5173,http://localhost:8000,http://127.0.0.1:5173,http://127.0.0.1:8000" \
|
||||||
|
TAWHIRI_BASE_URL=http://127.0.0.1:8001/api/v2/ \
|
||||||
|
python3 manage.py runserver 0.0.0.0:8000 > /tmp/django.log 2>&1 &
|
||||||
|
echo $! > "$PID_DIR/django.pid"
|
||||||
|
sleep 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- leaflet_svelte --------------------------------------------------------------
|
||||||
|
if ! ss -tlnp 2>/dev/null | grep -q ':5173'; then
|
||||||
|
echo "starting Vite on :5173"
|
||||||
|
cd "$FRONTEND_DIR"
|
||||||
|
VITE_USE_MOCK_API=false \
|
||||||
|
VITE_API_BASE_URL=/api \
|
||||||
|
VITE_API_PROXY_TARGET=http://localhost:8000 \
|
||||||
|
npm run dev -- --port 5173 --strictPort > /tmp/vite-dev.log 2>&1 &
|
||||||
|
echo $! > "$PID_DIR/vite.pid"
|
||||||
|
sleep 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "--- stack ready ---"
|
||||||
|
echo " fake_tawhiri: http://127.0.0.1:8001/api/v2/"
|
||||||
|
echo " stratoflights: http://localhost:8000/api/"
|
||||||
|
echo " leaflet_svelte: http://localhost:5173/"
|
||||||
|
echo
|
||||||
|
echo "logs: /tmp/fake-tawhiri.log, /tmp/django.log, /tmp/vite-dev.log"
|
||||||
|
echo "run e2e: (cd $FRONTEND_DIR && npm run test:e2e)"
|
||||||
|
echo "stop: $FRONTEND_DIR/scripts/stop-stack.sh"
|
||||||
21
scripts/stop-stack.sh
Executable file
21
scripts/stop-stack.sh
Executable file
|
|
@ -0,0 +1,21 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Stop everything started by run-stack.sh.
|
||||||
|
set -u
|
||||||
|
|
||||||
|
PID_DIR=/tmp/lsv-stack
|
||||||
|
|
||||||
|
for name in fake-tawhiri django vite; do
|
||||||
|
pidfile="$PID_DIR/$name.pid"
|
||||||
|
if [[ -f "$pidfile" ]]; then
|
||||||
|
pid=$(cat "$pidfile")
|
||||||
|
if kill "$pid" 2>/dev/null; then
|
||||||
|
echo "stopped $name (pid $pid)"
|
||||||
|
fi
|
||||||
|
rm -f "$pidfile"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Belt-and-suspenders: free ports if anything slipped through.
|
||||||
|
for port in 5173 8000 8001; do
|
||||||
|
fuser -k "$port/tcp" 2>/dev/null && echo "freed :$port" || true
|
||||||
|
done
|
||||||
153
src/app.css
Normal file
153
src/app.css
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
/* Custom YKS brand-themed Bootstrap 5.2.3 is served from /css/bootstrap.min.css
|
||||||
|
* (see static/css/bootstrap.min.css). It carries the brand palette
|
||||||
|
* (--bs-primary: #457aab, etc.) that stock Bootstrap doesn't.
|
||||||
|
*/
|
||||||
|
@import '/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);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/app.html
16
src/app.html
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
123
src/lib/api/client.ts
Normal 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' }),
|
||||||
|
};
|
||||||
7
src/lib/api/index.ts
Normal file
7
src/lib/api/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export { api, ApiError, API_BASE_URL, setUnauthorizedHandler } from './client';
|
||||||
|
export { telemetryApi, buildWsUrl, type RawTelemetryPacket } from './telemetry';
|
||||||
|
export { pointsApi } from './points';
|
||||||
|
export { profilesApi } from './profiles';
|
||||||
|
export { scenariosApi } from './scenarios';
|
||||||
|
export { predictionsApi, getLatestDataset, buildLaunchDateTime } from './predictions';
|
||||||
|
export { windApi, type WindFieldParams } from './wind';
|
||||||
|
|
@ -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}/`);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
35
src/lib/api/predictions.ts
Normal file
35
src/lib/api/predictions.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
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();
|
||||||
|
return "2025-04-06T00:00:00Z";
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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}/`);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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}/`);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
35
src/lib/api/telemetry.ts
Normal file
35
src/lib/api/telemetry.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { api, API_BASE_URL } from './client';
|
||||||
|
|
||||||
|
export interface RawTelemetryPacket {
|
||||||
|
id: string;
|
||||||
|
timestamp: number; // unix seconds
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
alt: number;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
raw_data: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derives a WebSocket URL from the configured API base URL. */
|
||||||
|
export function buildWsUrl(satelliteId: string): string {
|
||||||
|
let base = API_BASE_URL;
|
||||||
|
if (!base.startsWith('http')) {
|
||||||
|
base = `${window.location.protocol}//${window.location.host}${base}`;
|
||||||
|
}
|
||||||
|
const url = new URL(base);
|
||||||
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
return `${url.origin}${url.pathname}/ws/satellite/${satelliteId}/telemetry/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const telemetryApi = {
|
||||||
|
fetchHistory: async (
|
||||||
|
satelliteId: string,
|
||||||
|
params?: { from?: number; till?: number },
|
||||||
|
): Promise<RawTelemetryPacket[]> => {
|
||||||
|
const res = await api.get<RawTelemetryPacket[] | { results: RawTelemetryPacket[] }>(
|
||||||
|
`/${satelliteId}/telemetry/`,
|
||||||
|
{ query: params },
|
||||||
|
);
|
||||||
|
return Array.isArray(res) ? res : res.results;
|
||||||
|
},
|
||||||
|
};
|
||||||
58
src/lib/api/wind.ts
Normal file
58
src/lib/api/wind.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
/**
|
||||||
|
* Client for the predictor's wind-visualization endpoints.
|
||||||
|
*
|
||||||
|
* These endpoints live on the predictor service (default 127.0.0.1:8080),
|
||||||
|
* not on the Django backend, so they bypass the shared `api` client and
|
||||||
|
* fetch directly. No CSRF or session cookies are needed.
|
||||||
|
*
|
||||||
|
* Set VITE_PREDICTOR_BASE_URL to point at a non-default predictor address.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { WindField, WindMeta } from '$domain';
|
||||||
|
|
||||||
|
const PREDICTOR_URL = (import.meta.env.VITE_PREDICTOR_BASE_URL as string | undefined) ?? 'http://127.0.0.1:8080';
|
||||||
|
|
||||||
|
export interface WindFieldParams {
|
||||||
|
altitude?: number;
|
||||||
|
step?: number;
|
||||||
|
time?: string;
|
||||||
|
min_lat?: number;
|
||||||
|
max_lat?: number;
|
||||||
|
min_lng?: number;
|
||||||
|
max_lng?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function predictorFetch<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (params) {
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
if (v !== undefined) q.set(k, String(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const qs = q.toString();
|
||||||
|
const url = `${PREDICTOR_URL}${path}${qs ? '?' + qs : ''}`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => res.statusText);
|
||||||
|
throw new Error(`Predictor ${path} failed: HTTP ${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const windApi = {
|
||||||
|
field(params: WindFieldParams = {}): Promise<WindField> {
|
||||||
|
return predictorFetch<WindField>('/api/v1/wind/field', {
|
||||||
|
altitude: params.altitude,
|
||||||
|
step: params.step,
|
||||||
|
time: params.time,
|
||||||
|
min_lat: params.min_lat,
|
||||||
|
max_lat: params.max_lat,
|
||||||
|
min_lng: params.min_lng,
|
||||||
|
max_lng: params.max_lng,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
meta(): Promise<WindMeta> {
|
||||||
|
return predictorFetch<WindMeta>('/api/v1/wind/meta');
|
||||||
|
},
|
||||||
|
};
|
||||||
136
src/lib/auth.ts
136
src/lib/auth.ts
|
|
@ -1,136 +0,0 @@
|
||||||
import Cookies from 'js-cookie';
|
|
||||||
|
|
||||||
export const CSRF_URL = 'http://localhost:8000/api/csrf/';
|
|
||||||
export const LOGIN_URL = 'http://localhost:8000/api/login/';
|
|
||||||
export const LOGOUT_URL = 'http://localhost:8000/api/logout/';
|
|
||||||
export const SESSION_URL = 'http://localhost:8000/api/session/';
|
|
||||||
export const WHOAMI_URL = 'http://localhost:8000/api/whoami/';
|
|
||||||
export async function getCsrfToken(): Promise<string | null> {
|
|
||||||
return Cookies.get('csrftoken') || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCsrfTokenAuth(): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
await fetch(CSRF_URL, {
|
|
||||||
method: 'GET',
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
return Cookies.get('csrftoken') || null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to get CSRF token:', error);
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkAuthenticated(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const csrfToken = await getCsrfTokenAuth();
|
|
||||||
if (!csrfToken) {
|
|
||||||
throw new Error('CSRF token not found');
|
|
||||||
}
|
|
||||||
const response = await fetch(SESSION_URL, {
|
|
||||||
credentials: 'include',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRFToken': csrfToken
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Authentication check failed: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
return data.isAuthenticated;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Authentication check failed:', error);
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function login(username: string, password: string): Promise<any> {
|
|
||||||
try {
|
|
||||||
const csrfToken = await getCsrfTokenAuth();
|
|
||||||
if (!csrfToken) {
|
|
||||||
throw new Error('CSRF token not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(LOGIN_URL, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRFToken': csrfToken
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ username, password }),
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(`Login failed: ${response.statusText} - ${errorData.detail || ''}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Login failed:', error);
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function logout(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const csrfToken = await getCsrfTokenAuth();
|
|
||||||
if (!csrfToken) {
|
|
||||||
throw new Error('CSRF token not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(LOGOUT_URL, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRFToken': csrfToken
|
|
||||||
},
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Logout failed: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Logout successful');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Logout failed:', error);
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function whoami(): Promise<any> {
|
|
||||||
try {
|
|
||||||
const csrfToken = await getCsrfTokenAuth();
|
|
||||||
if (!csrfToken) {
|
|
||||||
throw new Error('CSRF token not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(WHOAMI_URL, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRFToken': csrfToken
|
|
||||||
},
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Whoami failed: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!data || !data.username) {
|
|
||||||
throw new Error('No user data found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return data.username;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Whoami failed:', error);
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
17
src/lib/auth/api.ts
Normal file
17
src/lib/auth/api.ts
Normal 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
32
src/lib/auth/guard.ts
Normal 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
3
src/lib/auth/index.ts
Normal 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
79
src/lib/auth/store.ts
Normal 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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
<script>
|
|
||||||
import { Modal, ModalHeader, ModalBody, ModalFooter, Button } from "@sveltestrap/sveltestrap";
|
|
||||||
|
|
||||||
let {
|
|
||||||
isOpen = $bindable(false),
|
|
||||||
title = "Confirm Action",
|
|
||||||
confirmText = "Confirm",
|
|
||||||
cancelText = "Cancel",
|
|
||||||
confirmVariant = "primary",
|
|
||||||
cancelVariant = "secondary",
|
|
||||||
onconfirm,
|
|
||||||
oncancel,
|
|
||||||
children,
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
function handleConfirm() {
|
|
||||||
onconfirm?.();
|
|
||||||
isOpen = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCancel() {
|
|
||||||
oncancel?.();
|
|
||||||
isOpen = false;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Modal {isOpen} toggle={handleCancel} fade={false} backdrop={true}>
|
|
||||||
<ModalHeader toggle={handleCancel}>{title}</ModalHeader>
|
|
||||||
<ModalBody>
|
|
||||||
{#if children}
|
|
||||||
{@render children()}
|
|
||||||
{:else}
|
|
||||||
Вы действительно хотите продолжить?
|
|
||||||
{/if}
|
|
||||||
</ModalBody>
|
|
||||||
<ModalFooter>
|
|
||||||
<Button color={cancelVariant} on:click={handleCancel}>
|
|
||||||
{cancelText}
|
|
||||||
</Button>
|
|
||||||
<Button color={confirmVariant} on:click={handleConfirm}>
|
|
||||||
{confirmText}
|
|
||||||
</Button>
|
|
||||||
</ModalFooter>
|
|
||||||
</Modal>
|
|
||||||
|
|
@ -1,492 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
/*
|
|
||||||
Component Naming and Style Conventions:
|
|
||||||
|
|
||||||
1. **State Variables (`$state`)**:
|
|
||||||
- Use camelCase.
|
|
||||||
- No special prefixes are needed as `$state` already marks them as reactive state.
|
|
||||||
- Example: `let isCollapsed = $state(false);`
|
|
||||||
|
|
||||||
2. **Derived State (`$derived`)**:
|
|
||||||
- Use camelCase.
|
|
||||||
- No special prefixes are needed as `$derived` already marks them as reactive derived state.
|
|
||||||
- Example: `let currentPoint = $derived(...)`
|
|
||||||
|
|
||||||
3. **Component Instance References**:
|
|
||||||
- Use camelCase and suffix with `Ref`.
|
|
||||||
- Example: `let pointEditorRef: PointEditor | null = null;`
|
|
||||||
|
|
||||||
4. **Event Handlers**:
|
|
||||||
- Use `handle<EventName>` or `handle<Element><Event>` naming.
|
|
||||||
- Example: `function handleToggleCollapse() { ... }`
|
|
||||||
|
|
||||||
5. **Props**:
|
|
||||||
- For event callback props, use `on<EventName>`.
|
|
||||||
- Example: `let { onSelectOnMapClick = () => {} }: Props = $props();`
|
|
||||||
|
|
||||||
6. **HTML Element IDs**:
|
|
||||||
- Use kebab-case.
|
|
||||||
- Prefix with a component-specific identifier to avoid global scope conflicts.
|
|
||||||
- Example: `id="cp-start-time"` (cp for ControlPanel)
|
|
||||||
|
|
||||||
7. **Stores**:
|
|
||||||
- Use PascalCase and suffix with `Store`.
|
|
||||||
- Example: `import { SavedPointsStore } from '$lib/stores';`
|
|
||||||
- The reactive Svelte store prefix `$` is used as standard.
|
|
||||||
*/
|
|
||||||
import { onMount, onDestroy } from "svelte";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardBody,
|
|
||||||
CardHeader,
|
|
||||||
FormGroup,
|
|
||||||
Icon,
|
|
||||||
Input,
|
|
||||||
InputGroup,
|
|
||||||
InputGroupText,
|
|
||||||
Label,
|
|
||||||
Dropdown,
|
|
||||||
DropdownToggle,
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownItem,
|
|
||||||
} from "@sveltestrap/sveltestrap";
|
|
||||||
|
|
||||||
import { getSavedPoints, updatePoint } from "$lib/api/points";
|
|
||||||
import { addToast } from "$lib/components/ui/Toast.svelte";
|
|
||||||
import PointEditor from "$lib/components/editors/PointEditor.svelte";
|
|
||||||
import SelectSearchable from "$lib/components/ui/SelectSearchable.svelte";
|
|
||||||
import { getForecast } from "$lib/prediction";
|
|
||||||
import {
|
|
||||||
FlightParametersStore,
|
|
||||||
SavedPointsStore,
|
|
||||||
writeLocalStorage,
|
|
||||||
readLocalStorage,
|
|
||||||
flightParametersDefaults,
|
|
||||||
} from "$lib/stores";
|
|
||||||
import { PROFILE_MAP, type FlightParameters, type SavedPoint } from "$lib/types";
|
|
||||||
import CurveEditor from "$lib/components/editors/CurveEditor.svelte";
|
|
||||||
import SpoilerGroup from "$lib/components/ui/SpoilerGroup.svelte";
|
|
||||||
import LabelGroup from "./ui/LabelGroup.svelte";
|
|
||||||
import { toFixedNumber } from "$lib/mathutil";
|
|
||||||
|
|
||||||
// Props
|
|
||||||
interface Props {
|
|
||||||
onSelectOnMapClick?: () => void;
|
|
||||||
}
|
|
||||||
let { onSelectOnMapClick = () => console.log("Select on map clicked") }: Props = $props();
|
|
||||||
|
|
||||||
// State
|
|
||||||
let isCollapsed = $state(false);
|
|
||||||
let startDate = $state(readLocalStorage<string>("startDate", new Date().toISOString().split("T")[0]));
|
|
||||||
let startTime = $state(readLocalStorage<string>("startTime", "12:00:00"));
|
|
||||||
let selectedPointId = $state($FlightParametersStore.start_point || -1);
|
|
||||||
|
|
||||||
let ascentProfile = $state("standard");
|
|
||||||
let descentProfile = $state("standard");
|
|
||||||
|
|
||||||
// Component References
|
|
||||||
let pointEditorRef: PointEditor | null = null;
|
|
||||||
let curveEditorRef: CurveEditor | null = null;
|
|
||||||
|
|
||||||
// Derived State
|
|
||||||
let currentPoint = $derived($SavedPointsStore.find((p) => p.id === selectedPointId) || null);
|
|
||||||
let isPointDirty = $derived(() => {
|
|
||||||
if (!currentPoint) return false; // Not dirty if no point is selected
|
|
||||||
const latMatch = $FlightParametersStore.launch_latitude.toFixed(6) === currentPoint.lat.toFixed(6);
|
|
||||||
const lonMatch = $FlightParametersStore.launch_longitude.toFixed(6) === currentPoint.lon.toFixed(6);
|
|
||||||
const altMatch = $FlightParametersStore.launch_altitude.toFixed(2) === currentPoint.alt.toFixed(2);
|
|
||||||
return !(latMatch && lonMatch && altMatch);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Lifecycle Hooks
|
|
||||||
onMount(() => {
|
|
||||||
// NOTE: Consider moving localStorage logic into the store itself for better encapsulation.
|
|
||||||
$FlightParametersStore =
|
|
||||||
readLocalStorage<FlightParameters>("flightParameters", flightParametersDefaults) || $FlightParametersStore;
|
|
||||||
selectedPointId = $FlightParametersStore.start_point || -1;
|
|
||||||
|
|
||||||
getSavedPoints()
|
|
||||||
.then((points) => SavedPointsStore.set(points))
|
|
||||||
.catch((error) => {
|
|
||||||
addToast({
|
|
||||||
header: "Error Loading Points",
|
|
||||||
body: `Failed to load saved points: ${error.message}`,
|
|
||||||
color: "danger",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
writeLocalStorage<FlightParameters>("flightParameters", $FlightParametersStore);
|
|
||||||
writeLocalStorage<string>("startDate", startDate);
|
|
||||||
writeLocalStorage<string>("startTime", startTime);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Event Handlers
|
|
||||||
function handlePointSelection(newPointId: number) {
|
|
||||||
console.log("Point selection changed:", newPointId);
|
|
||||||
selectedPointId = newPointId;
|
|
||||||
const point = $SavedPointsStore.find((p) => p.id === newPointId);
|
|
||||||
|
|
||||||
if (point) {
|
|
||||||
console.log("Selected point:", point);
|
|
||||||
$FlightParametersStore.start_point = point.id;
|
|
||||||
$FlightParametersStore.launch_latitude = point.lat;
|
|
||||||
$FlightParametersStore.launch_longitude = point.lon;
|
|
||||||
$FlightParametersStore.launch_altitude = point.alt;
|
|
||||||
} else if (newPointId === -1) {
|
|
||||||
$FlightParametersStore.start_point = -1;
|
|
||||||
// When clearing the selection, we can reset to defaults or leave as is.
|
|
||||||
// For now, we'll just update the ID. The user can manually edit coordinates.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSaveCurrentPoint() {
|
|
||||||
if (currentPoint) {
|
|
||||||
// Update existing point
|
|
||||||
const updatedPointData = {
|
|
||||||
...currentPoint,
|
|
||||||
lat: $FlightParametersStore.launch_latitude,
|
|
||||||
lon: $FlightParametersStore.launch_longitude,
|
|
||||||
alt: $FlightParametersStore.launch_altitude,
|
|
||||||
};
|
|
||||||
updatePoint(updatedPointData)
|
|
||||||
.then((savedPoint) => {
|
|
||||||
SavedPointsStore.update((points) => points.map((p) => (p.id === savedPoint.id ? savedPoint : p)));
|
|
||||||
addToast({
|
|
||||||
header: "Point Updated",
|
|
||||||
body: `Point "${savedPoint.name}" was successfully updated.`,
|
|
||||||
color: "success",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
addToast({
|
|
||||||
header: "Update Error",
|
|
||||||
body: `Failed to update point: ${error.message}`,
|
|
||||||
color: "danger",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Create new point
|
|
||||||
pointEditorRef?.open({
|
|
||||||
id: 0, // Assuming 0 or a negative number indicates a new point
|
|
||||||
name: `New Point ${new Date().toLocaleString()}`,
|
|
||||||
lat: $FlightParametersStore.launch_latitude,
|
|
||||||
lon: $FlightParametersStore.launch_longitude,
|
|
||||||
alt: $FlightParametersStore.launch_altitude,
|
|
||||||
// The onSave callback is handled by the onSelectPoint prop on the component
|
|
||||||
}, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handlePredictionRequest() {
|
|
||||||
// Persist current parameters before running prediction
|
|
||||||
writeLocalStorage<FlightParameters>("flightParameters", $FlightParametersStore);
|
|
||||||
try {
|
|
||||||
const data = await getForecast($FlightParametersStore, `${startDate}T${startTime}Z`);
|
|
||||||
console.log("Forecast request successful:", data);
|
|
||||||
addToast({ header: "Forecast Request", body: "Forecast request successful!", color: "success" });
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Error getting forecast:", error);
|
|
||||||
addToast({ header: "Forecast Error", body: `Error getting forecast: ${error.message}`, color: "danger" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleToggleCollapse() {
|
|
||||||
isCollapsed = !isCollapsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public API
|
|
||||||
export function updateLaunchPosition(lat: number, lng: number) {
|
|
||||||
$FlightParametersStore.launch_latitude = toFixedNumber(lat, 6);
|
|
||||||
$FlightParametersStore.launch_longitude = toFixedNumber(lng, 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadFlightParameters(params: FlightParameters) {
|
|
||||||
$FlightParametersStore = params;
|
|
||||||
selectedPointId = params.start_point || -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getFlightParameters(): FlightParameters {
|
|
||||||
return $FlightParametersStore;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function collapsePanel() {
|
|
||||||
isCollapsed = true;
|
|
||||||
}
|
|
||||||
export function expandPanel() {
|
|
||||||
isCollapsed = false;
|
|
||||||
}
|
|
||||||
export function togglePanel() {
|
|
||||||
isCollapsed = !isCollapsed;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader
|
|
||||||
class="bg-primary text-white d-flex justify-content-between align-items-center card-header p-1 px-3"
|
|
||||||
style="cursor:pointer;"
|
|
||||||
onclick={handleToggleCollapse}>
|
|
||||||
<b class="card-title mb-0 text-white p-0">Условия прогнозирования</b>
|
|
||||||
<Button class="p-0" size="sm" color="primary" aria-label="Свернуть/развернуть условия прогнозирования">
|
|
||||||
<Icon name={isCollapsed ? "caret-left-fill" : "caret-down-fill"} class="text-white" />
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
{#if !isCollapsed}
|
|
||||||
<CardBody>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<FormGroup class="flex-fill w-50" spacing="mb-2">
|
|
||||||
<Label for="cp-start-time" class="form-label">Время старта (UTC):</Label>
|
|
||||||
<Input type="time" id="cp-start-time" class="form-control-sm" bind:value={startTime} step="1" />
|
|
||||||
</FormGroup>
|
|
||||||
<FormGroup class="flex-fill w-50" spacing="mb-2">
|
|
||||||
<Label for="cp-start-date" class="form-label">Дата старта:</Label>
|
|
||||||
<Input type="date" id="cp-start-date" class="form-control-sm" bind:value={startDate} />
|
|
||||||
</FormGroup>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormGroup spacing="mb-2">
|
|
||||||
<Label for="cp-flight-profile" class="form-label">Профиль полета:</Label>
|
|
||||||
<InputGroup size="sm">
|
|
||||||
<Input type="select" id="cp-flight-profile" bind:value={$FlightParametersStore.profile}>
|
|
||||||
{#each Object.entries(PROFILE_MAP) as [name, value]}
|
|
||||||
<option {value}>{name}</option>
|
|
||||||
{/each}
|
|
||||||
</Input>
|
|
||||||
</InputGroup>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<FormGroup spacing="mb-2">
|
|
||||||
<Label for="cp-start-point" class="form-label">Точка старта:</Label>
|
|
||||||
<InputGroup size="sm">
|
|
||||||
<SelectSearchable
|
|
||||||
style="min-height: calc(1.5em + .5rem + 2px); padding: .25rem .5rem; font-size: .875rem;"
|
|
||||||
id="cp-start-point"
|
|
||||||
selected={selectedPointId}
|
|
||||||
onChange={(e) => handlePointSelection(e)}
|
|
||||||
options={$SavedPointsStore.map((point) => ({
|
|
||||||
value: point.id,
|
|
||||||
label: `${point.name} ${point.id === selectedPointId && isPointDirty() ? "(изменено)" : ""}`,
|
|
||||||
}))}
|
|
||||||
placeholder="Новая точка..."
|
|
||||||
clearable={true}
|
|
||||||
searchPlaceholder="Поиск по точкам..." />
|
|
||||||
<Button
|
|
||||||
color="secondary"
|
|
||||||
size="sm"
|
|
||||||
onclick={() => pointEditorRef?.open()}
|
|
||||||
title="Открыть список точек">
|
|
||||||
<Icon name="journal-bookmark-fill"/>
|
|
||||||
</Button>
|
|
||||||
</InputGroup>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<FormGroup spacing="mb-2">
|
|
||||||
<Label for="cp-latitude" class="form-label">Широта/Долгота:</Label>
|
|
||||||
<InputGroup size="sm">
|
|
||||||
<Input
|
|
||||||
id="cp-latitude"
|
|
||||||
type="number"
|
|
||||||
step="0.000001"
|
|
||||||
bind:value={$FlightParametersStore.launch_latitude}
|
|
||||||
placeholder="Latitude" />
|
|
||||||
<InputGroupText>/</InputGroupText>
|
|
||||||
<Input
|
|
||||||
id="cp-longitude"
|
|
||||||
type="number"
|
|
||||||
step="0.000001"
|
|
||||||
bind:value={$FlightParametersStore.launch_longitude}
|
|
||||||
placeholder="Longitude" />
|
|
||||||
<Button color="secondary" size="sm" onclick={onSelectOnMapClick}>
|
|
||||||
<Icon name="geo-alt-fill" />
|
|
||||||
</Button>
|
|
||||||
</InputGroup>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<div class="d-flex mb-2">
|
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
class="flex-fill"
|
|
||||||
size="sm"
|
|
||||||
onclick={handleSaveCurrentPoint}
|
|
||||||
title="Сохранить текущие координаты"
|
|
||||||
disabled={!isPointDirty && selectedPointId !== -1}>
|
|
||||||
Сохранить
|
|
||||||
<Icon name="floppy2-fill" class="ms-1" />
|
|
||||||
</Button>
|
|
||||||
<Dropdown size="sm">
|
|
||||||
<DropdownToggle
|
|
||||||
class="dropdown-toggle-standalone"
|
|
||||||
caret
|
|
||||||
color="primary"
|
|
||||||
size="sm"
|
|
||||||
title="Дополнительные действия"
|
|
||||||
style="background-color: var(--bs-indigo); border-color: var(--bs-indigo);">
|
|
||||||
</DropdownToggle>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownItem class="small">Сохранить как новую...</DropdownItem>
|
|
||||||
<DropdownItem class="small">Удалить выбранную точку</DropdownItem>
|
|
||||||
<DropdownItem divider />
|
|
||||||
<DropdownItem class="small">Сбросить изменения</DropdownItem>
|
|
||||||
</DropdownMenu>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<FormGroup class="flex-fill w-50" spacing="mb-2">
|
|
||||||
<Label for="cp-start-height" class="form-label">Высота старта (м):</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
id="cp-start-height"
|
|
||||||
class="form-control-sm"
|
|
||||||
bind:value={$FlightParametersStore.launch_altitude} />
|
|
||||||
</FormGroup>
|
|
||||||
<FormGroup class="flex-fill w-50" spacing="mb-2">
|
|
||||||
<Label for="cp-burst-altitude" class="form-label">Высота разрыва (м):</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
id="cp-burst-altitude"
|
|
||||||
class="form-control-sm"
|
|
||||||
bind:value={$FlightParametersStore.burst_altitude} />
|
|
||||||
</FormGroup>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if $FlightParametersStore.profile !== "custom_profile"}
|
|
||||||
<div class="mb-2 d-flex gap-2">
|
|
||||||
<FormGroup class="flex-fill w-50" spacing="mb-2">
|
|
||||||
<Label for="cp-ascent-rate" class="form-label">Скорость подъема (м/с):</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
id="cp-ascent-rate"
|
|
||||||
class="form-control-sm"
|
|
||||||
bind:value={$FlightParametersStore.ascent_rate} />
|
|
||||||
</FormGroup>
|
|
||||||
<FormGroup class="flex-fill w-50" spacing="mb-2">
|
|
||||||
<Label for="cp-descent-rate" class="form-label">Скорость спуска (м/с):</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
id="cp-descent-rate"
|
|
||||||
class="form-control-sm"
|
|
||||||
bind:value={$FlightParametersStore.descent_rate} />
|
|
||||||
</FormGroup>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<SpoilerGroup label="Профили подъема и спуска" class="mb-2">
|
|
||||||
<Label class="form-label mb-0">Стадия подъема:</Label>
|
|
||||||
<div class="d-flex gap-2 mb-0">
|
|
||||||
<Input type="radio" bind:group={ascentProfile} value={"none"} label={"Нет"} />
|
|
||||||
<Input type="radio" bind:group={ascentProfile} value={"standard"} label={"Стандартная"} />
|
|
||||||
<Input type="radio" bind:group={ascentProfile} value={"custom"} label={"Пользовательская"} />
|
|
||||||
</div>
|
|
||||||
{#if ascentProfile === "custom"}
|
|
||||||
<InputGroup size="sm" class="mb-2">
|
|
||||||
<SelectSearchable
|
|
||||||
style="min-height: calc(1.5em + .5rem + 2px); padding: .25rem .5rem; font-size: .875rem;"
|
|
||||||
id="cp-start-point"
|
|
||||||
selected={selectedPointId}
|
|
||||||
onChange={() => {}}
|
|
||||||
options={$SavedPointsStore.map((point) => ({
|
|
||||||
value: point.id,
|
|
||||||
label: `test`,
|
|
||||||
}))}
|
|
||||||
clearable={true}
|
|
||||||
placeholder="Выбрать профиль..."
|
|
||||||
searchPlaceholder="Поиск по профилям..." />
|
|
||||||
<Button
|
|
||||||
color="secondary"
|
|
||||||
size="sm"
|
|
||||||
onclick={() => pointEditorRef?.open()}
|
|
||||||
title="Открыть список точек">
|
|
||||||
<Icon name="pencil"/>
|
|
||||||
</Button>
|
|
||||||
</InputGroup>
|
|
||||||
{:else if ascentProfile === "standard"}
|
|
||||||
<InputGroup size="sm" class="mb-2">
|
|
||||||
<Input type="select">
|
|
||||||
<!-- {#each Object.entries(PROFILE_MAP) as [name, value]} -->
|
|
||||||
<option value={"const"}>Постоянная скорость</option>
|
|
||||||
<option value={"reverse"}>Аэродинамический спуск (реверс)</option>
|
|
||||||
<!-- {/each} -->
|
|
||||||
</Input>
|
|
||||||
</InputGroup>
|
|
||||||
{/if}
|
|
||||||
<Label class="form-label mb-0">Стадия спуска:</Label>
|
|
||||||
<div class="d-flex gap-2 mb-0">
|
|
||||||
<Input type="radio" bind:group={descentProfile} value={"none"} label={"Нет"} id="cp-descent-stage-none" />
|
|
||||||
<Input type="radio" bind:group={descentProfile} value={"standard"} label={"Стандартная"} id="cp-descent-stage-std" />
|
|
||||||
<Input type="radio" bind:group={descentProfile} value={"custom"} label={"Пользовательская"} id="cp-descent-stage-custom" />
|
|
||||||
</div>
|
|
||||||
{#if descentProfile === "custom"}
|
|
||||||
<InputGroup size="sm" class="mb-2">
|
|
||||||
<SelectSearchable
|
|
||||||
style="min-height: calc(1.5em + .5rem + 2px); padding: .25rem .5rem; font-size: .875rem;"
|
|
||||||
id="cp-start-point"
|
|
||||||
selected={selectedPointId}
|
|
||||||
onChange={() => {}}
|
|
||||||
options={$SavedPointsStore.map((point) => ({
|
|
||||||
value: point.id,
|
|
||||||
label: `test`,
|
|
||||||
}))}
|
|
||||||
clearable={true}
|
|
||||||
placeholder="Выбрать профиль..."
|
|
||||||
searchPlaceholder="Поиск по профилям..." />
|
|
||||||
<Button
|
|
||||||
color="secondary"
|
|
||||||
size="sm"
|
|
||||||
onclick={() => pointEditorRef?.open()}
|
|
||||||
title="Открыть список точек">
|
|
||||||
<Icon name="pencil"/>
|
|
||||||
</Button>
|
|
||||||
</InputGroup>
|
|
||||||
{:else if descentProfile === "standard"}
|
|
||||||
<InputGroup size="sm" class="mb-2">
|
|
||||||
<Input type="select">
|
|
||||||
<!-- {#each Object.entries(PROFILE_MAP) as [name, value]} -->
|
|
||||||
<option value={"drag"}>Аэродинамический спуск</option>
|
|
||||||
<option value={"const"}>Постоянная скорость</option>
|
|
||||||
<option value={"const"}>Постоянная скорость (реверс)</option>
|
|
||||||
<!-- {/each} -->
|
|
||||||
</Input>
|
|
||||||
</InputGroup>
|
|
||||||
{/if}
|
|
||||||
<Button size="sm" color="secondary" onclick={() => curveEditorRef?.openModal()} class="w-100">
|
|
||||||
Открыть редактор кривых
|
|
||||||
<Icon name="graph-up-arrow" />
|
|
||||||
</Button>
|
|
||||||
</SpoilerGroup>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="d-flex">
|
|
||||||
<Button class="flex-fill" size="sm" color="primary" onclick={handlePredictionRequest}>Выполнить прогнозирование</Button>
|
|
||||||
<Dropdown size="sm">
|
|
||||||
<DropdownToggle
|
|
||||||
class="dropdown-toggle-standalone"
|
|
||||||
caret
|
|
||||||
color="primary"
|
|
||||||
size="sm"
|
|
||||||
title="Дополнительные действия"
|
|
||||||
style="background-color: var(--bs-indigo); border-color: var(--bs-indigo);">
|
|
||||||
</DropdownToggle>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownItem class="small">Сохранить</DropdownItem>
|
|
||||||
<DropdownItem class="small">Сохранить как новый...</DropdownItem>
|
|
||||||
<DropdownItem divider />
|
|
||||||
<DropdownItem class="small">Сбросить настройки</DropdownItem>
|
|
||||||
</DropdownMenu>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
{/if}
|
|
||||||
</Card>
|
|
||||||
<CurveEditor bind:this={curveEditorRef} showTable={true} editor={false} />
|
|
||||||
<PointEditor
|
|
||||||
bind:this={pointEditorRef}
|
|
||||||
onSelectPoint={(point: SavedPoint | null) => {
|
|
||||||
if (point) {
|
|
||||||
handlePointSelection(point.id);
|
|
||||||
} else {
|
|
||||||
handlePointSelection(-1); // Clear selection
|
|
||||||
}
|
|
||||||
}} />
|
|
||||||
|
|
@ -1,278 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount, onDestroy } from "svelte";
|
|
||||||
import { Chart, type TooltipItem } from "chart.js/auto";
|
|
||||||
import "chartjs-adapter-luxon";
|
|
||||||
import chartjsPluginDragdata from "chartjs-plugin-dragdata";
|
|
||||||
import type { SavedFlightProfile, RateCurvePoint } from "$lib/types";
|
|
||||||
import { DateTime } from "luxon";
|
|
||||||
|
|
||||||
Chart.register(chartjsPluginDragdata);
|
|
||||||
|
|
||||||
// Props
|
|
||||||
let {
|
|
||||||
curve,
|
|
||||||
onUpdate,
|
|
||||||
} = $props<{
|
|
||||||
curve: SavedFlightProfile;
|
|
||||||
onUpdate: (points: RateCurvePoint[]) => void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
// State
|
|
||||||
let canvasElement: HTMLCanvasElement;
|
|
||||||
let chart: Chart | null = $state(null);
|
|
||||||
|
|
||||||
// Reactive derived state for chart data
|
|
||||||
let chartData = $derived(calculateChartData(curve.rate_profile_data));
|
|
||||||
|
|
||||||
// def resolve_constraints_to_abs_time(constraints):
|
|
||||||
// """
|
|
||||||
// Convert relative constraints to absolute time constraints.
|
|
||||||
|
|
||||||
// Args:
|
|
||||||
// constraints: List of [time_constraint, altitude_constraint, vertical_rate]
|
|
||||||
// where -1 indicates no constraint
|
|
||||||
|
|
||||||
// Returns:
|
|
||||||
// List of [absolute_time, rate] pairs
|
|
||||||
// """
|
|
||||||
// abs_constraints = []
|
|
||||||
// current_time = 0
|
|
||||||
// current_alt = 0
|
|
||||||
|
|
||||||
// for constraint in constraints:
|
|
||||||
// time_constraint, alt_constraint, rate = constraint
|
|
||||||
|
|
||||||
// # Calculate time to reach this constraint
|
|
||||||
// if time_constraint != -1:
|
|
||||||
// if alt_constraint != -1:
|
|
||||||
// # Both time and altitude constraints exist
|
|
||||||
// time_for_alt = (alt_constraint - current_alt) / rate if rate != 0 else 0
|
|
||||||
// resolved_time = min(time_constraint, time_for_alt)
|
|
||||||
// else:
|
|
||||||
// # Only time constraint
|
|
||||||
// resolved_time = time_constraint
|
|
||||||
// else:
|
|
||||||
// # Only altitude constraint (or invalid case)
|
|
||||||
// if alt_constraint != -1:
|
|
||||||
// resolved_time = (alt_constraint - current_alt) / rate if rate != 0 else 0
|
|
||||||
// else:
|
|
||||||
// resolved_time = 0 # Invalid case, raise an error or handle as needed
|
|
||||||
|
|
||||||
// if resolved_time < 0:
|
|
||||||
// resolved_time = 0
|
|
||||||
// current_time += resolved_time
|
|
||||||
// current_alt += resolved_time * rate
|
|
||||||
|
|
||||||
// abs_constraints.append([current_time, rate])
|
|
||||||
|
|
||||||
// return abs_constraints
|
|
||||||
|
|
||||||
|
|
||||||
// # Usage:
|
|
||||||
// test_data2 = [
|
|
||||||
// [1000, 6000, 5],
|
|
||||||
// [-1, 14000, 4],
|
|
||||||
// [3000, -1, 0],
|
|
||||||
// [-1, 10000, -2],
|
|
||||||
// [-1, 40000, 3],
|
|
||||||
// [1000, 6000, -10],
|
|
||||||
// [-1, 14000, 4],
|
|
||||||
// [3000, -1, 0],
|
|
||||||
// [-1, 10000, -2],
|
|
||||||
// ]
|
|
||||||
// abs_constraints = resolve_constraints_to_abs_time(test_data2)
|
|
||||||
|
|
||||||
// def quick_propagator(abs_constraints):
|
|
||||||
// T = [0]
|
|
||||||
// A = [0] # Initialize with the starting altitude
|
|
||||||
// for i in range(len(abs_constraints)):
|
|
||||||
// A.append(A[-1] + ((abs_constraints[i][0]-T[-1]) * abs_constraints[i][1]))
|
|
||||||
// T.append(abs_constraints[i][0])
|
|
||||||
// return T, A
|
|
||||||
|
|
||||||
// T, A = quick_propagator(abs_constraints)
|
|
||||||
// plt.plot(T, A)
|
|
||||||
|
|
||||||
|
|
||||||
function calculateChartData(points: RateCurvePoint[]) {
|
|
||||||
const data: { x: number; y: number }[] = [];
|
|
||||||
let currentTime = 0;
|
|
||||||
let currentAltitude = 0;
|
|
||||||
|
|
||||||
data.push({ x: currentTime, y: currentAltitude });
|
|
||||||
|
|
||||||
for (const point of points) {
|
|
||||||
const { time_constraint, alt_constraint, rate } = point;
|
|
||||||
let resolved_time = 0;
|
|
||||||
|
|
||||||
if (time_constraint !== -1) {
|
|
||||||
if (alt_constraint !== -1) {
|
|
||||||
// Both time and altitude constraints exist
|
|
||||||
const time_for_alt = rate !== 0 ? (alt_constraint - currentAltitude) / rate : 0;
|
|
||||||
resolved_time = Math.min(time_constraint, time_for_alt);
|
|
||||||
} else {
|
|
||||||
// Only time constraint
|
|
||||||
resolved_time = time_constraint;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Only altitude constraint (or invalid case)
|
|
||||||
if (alt_constraint !== -1) {
|
|
||||||
resolved_time = rate !== 0 ? (alt_constraint - currentAltitude) / rate : 0;
|
|
||||||
} else {
|
|
||||||
resolved_time = 0; // Invalid case
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resolved_time < 0) {
|
|
||||||
resolved_time = 0; // Prevent time from going backwards
|
|
||||||
}
|
|
||||||
|
|
||||||
currentTime += resolved_time;
|
|
||||||
currentAltitude += resolved_time * rate;
|
|
||||||
|
|
||||||
data.push({ x: currentTime, y: currentAltitude });
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateChart() {
|
|
||||||
if (!chart) return;
|
|
||||||
chart.data.datasets[0].data = chartData;
|
|
||||||
chart.update("none");
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDragEnd(e: any, datasetIndex: number, index: number, value: { x: number; y: number }) {
|
|
||||||
if (index === 0) {
|
|
||||||
// Prevent dragging the start point
|
|
||||||
updateChart(); // Revert change
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent dragging past neighbor points on the X axis
|
|
||||||
const prevPointX = chartData[index - 1].x;
|
|
||||||
const nextPointX = chartData[index + 1] ? chartData[index + 1].x : Infinity;
|
|
||||||
if (value.x <= prevPointX || value.x >= nextPointX) {
|
|
||||||
updateChart();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newPoints = JSON.parse(JSON.stringify(curve.rate_profile_data));
|
|
||||||
const pointToUpdate = newPoints[index - 1];
|
|
||||||
const prevPointData = chartData[index - 1];
|
|
||||||
|
|
||||||
const newSegmentDuration = value.x - prevPointData.x;
|
|
||||||
const newAltitude = value.y;
|
|
||||||
const newAltDiff = newAltitude - prevPointData.y;
|
|
||||||
|
|
||||||
// Update altitude constraint if it exists
|
|
||||||
if (pointToUpdate.alt_constraint !== -1) {
|
|
||||||
pointToUpdate.alt_constraint = Math.round(newAltitude);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update time constraint if it exists
|
|
||||||
if (pointToUpdate.time_constraint !== -1) {
|
|
||||||
pointToUpdate.time_constraint = Math.round(newSegmentDuration);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always recalculate the rate based on the new position.
|
|
||||||
// The logic in calculateChartData will then determine if time or altitude is the driving constraint.
|
|
||||||
if (newSegmentDuration > 0) {
|
|
||||||
pointToUpdate.rate = parseFloat((newAltDiff / newSegmentDuration).toFixed(2));
|
|
||||||
} else {
|
|
||||||
pointToUpdate.rate = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
onUpdate(newPoints);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
const ctx = canvasElement.getContext("2d");
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
chart = new Chart(ctx, {
|
|
||||||
type: "line",
|
|
||||||
data: {
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
label: "Профиль высоты",
|
|
||||||
data: chartData,
|
|
||||||
borderColor: "rgb(75, 192, 192)",
|
|
||||||
backgroundColor: "rgba(75, 192, 192, 0.5)",
|
|
||||||
stepped: false,
|
|
||||||
fill: false,
|
|
||||||
pointRadius: 5,
|
|
||||||
pointHoverRadius: 7,
|
|
||||||
pointBackgroundColor: "rgb(75, 192, 192)",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
type: "linear",
|
|
||||||
position: "bottom",
|
|
||||||
title: {
|
|
||||||
display: true,
|
|
||||||
text: "Время от старта T0+ (сек)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
title: {
|
|
||||||
display: true,
|
|
||||||
text: "Высота над ур. моря (м)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
dragData: {
|
|
||||||
round: 0,
|
|
||||||
onDragEnd: handleDragEnd,
|
|
||||||
dragX: true, // Enable horizontal dragging
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
callbacks: {
|
|
||||||
label: function (context: TooltipItem<"line">) {
|
|
||||||
let label = context.dataset.label || "";
|
|
||||||
if (label) {
|
|
||||||
label += ": ";
|
|
||||||
}
|
|
||||||
if (context.parsed.y !== null) {
|
|
||||||
label += `${context.parsed.y.toFixed(2)} m`;
|
|
||||||
}
|
|
||||||
if (context.parsed.x !== null) {
|
|
||||||
const duration = DateTime.fromSeconds(context.parsed.x);
|
|
||||||
const timeString = duration.toFormat("HH:mm:ss");
|
|
||||||
label += ` at ${timeString}`;
|
|
||||||
}
|
|
||||||
return label;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as any);
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (chart) {
|
|
||||||
updateChart();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
chart?.destroy();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div style="position: relative; height: 100%; min-height: 250px;">
|
|
||||||
<canvas bind:this={canvasElement}></canvas>
|
|
||||||
{#if !chart}
|
|
||||||
<div
|
|
||||||
class="text-center text-muted"
|
|
||||||
style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">
|
|
||||||
Loading chart...
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
<!-- Footer -->
|
|
||||||
<footer class="bg-dark text-bg-dark mt-auto">
|
|
||||||
<div class="container pt-5">
|
|
||||||
<div class="row gy-5">
|
|
||||||
<div class="col-lg-3 mw-lg-2">
|
|
||||||
<div class="mb-4">
|
|
||||||
<a class="navbar-brand" href="/">
|
|
||||||
<img
|
|
||||||
src="/logo-full-ru-dark.svg"
|
|
||||||
class="d-inline-block align-middle img-fluid"
|
|
||||||
alt="ООО «ЯКС»"
|
|
||||||
width="250" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-8 offset-lg-1"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="container pb-4">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-6 small">
|
|
||||||
<div>Copyright © 2024 ООО «Якутские Космические Системы»</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 text-end small">
|
|
||||||
<div>
|
|
||||||
<p>
|
|
||||||
<a class="text-decoration-none" href="/usage_policy">Условия использования</a>
|
|
||||||
-
|
|
||||||
<a class="text-decoration-none" href="/privacy">Политика конфиденциальности</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardBody,
|
|
||||||
Button,
|
|
||||||
FormGroup,
|
|
||||||
Label,
|
|
||||||
Input,
|
|
||||||
InputGroup,
|
|
||||||
InputGroupText,
|
|
||||||
Icon,
|
|
||||||
} from "@sveltestrap/sveltestrap";
|
|
||||||
|
|
||||||
let isCollapsed = $state(false);
|
|
||||||
|
|
||||||
export const collapsePanel = () => {
|
|
||||||
isCollapsed = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const expandPanel = () => {
|
|
||||||
isCollapsed = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const togglePanel = () => {
|
|
||||||
isCollapsed = !isCollapsed;
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader
|
|
||||||
class="bg-primary text-white d-flex justify-content-between align-items-center card-header p-1 px-3"
|
|
||||||
style="cursor:pointer;">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="d-flex w-100 justify-content-between align-items-center bg-transparent border-0 p-0"
|
|
||||||
style="width:100%;"
|
|
||||||
aria-label="Свернуть/развернуть параметры прогнозирования"
|
|
||||||
onclick={() => (isCollapsed = !isCollapsed)}>
|
|
||||||
<b class="card-title mb-0 text-white p-0">Заголовок панели</b>
|
|
||||||
<Button class="p-0" size="sm" color="primary" onclick={() => (isCollapsed = !isCollapsed)}>
|
|
||||||
{#if isCollapsed}
|
|
||||||
<Icon name="caret-left-fill" class="text-white" />
|
|
||||||
{:else}
|
|
||||||
<Icon name="caret-down-fill" class="text-white" />
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
</button>
|
|
||||||
</CardHeader>
|
|
||||||
{#if !isCollapsed}
|
|
||||||
<CardBody>
|
|
||||||
|
|
||||||
</CardBody>
|
|
||||||
{/if}
|
|
||||||
</Card>
|
|
||||||
|
|
@ -1,309 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount, createEventDispatcher } from "svelte";
|
|
||||||
import { MapLibreCore, type IMapCore, type IMapMarker } from "$lib/mapcore";
|
|
||||||
import WindVisualization from "$lib/components/WindVisualisation.svelte";
|
|
||||||
import { distHaversine } from "$lib/mathutil";
|
|
||||||
import type { Prediction, Telemetry } from "$lib/types";
|
|
||||||
|
|
||||||
export let mode: "prediction" | "telemetry" = "prediction";
|
|
||||||
export let data: Prediction | Telemetry | null = null;
|
|
||||||
|
|
||||||
let mapCore: IMapCore;
|
|
||||||
let mapContainer: HTMLDivElement;
|
|
||||||
let markers: IMapMarker[] = [];
|
|
||||||
let animatedMarker: IMapMarker | null = null;
|
|
||||||
let mouseLat = 0;
|
|
||||||
let mouseLng = 0;
|
|
||||||
let isSelecting = false;
|
|
||||||
|
|
||||||
let windData: any;
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{ coordinatesSelected: { lat: number; lng: number } }>();
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
if (!mapContainer) return;
|
|
||||||
|
|
||||||
mapCore = new MapLibreCore();
|
|
||||||
mapCore.init(mapContainer, { center: [-0.09, 51.505], zoom: 13 });
|
|
||||||
|
|
||||||
mapCore.addNavigationControl("bottom-left");
|
|
||||||
mapCore.addScaleControl({ maxWidth: 100, unit: "metric" }, "bottom-right");
|
|
||||||
|
|
||||||
const response = await fetch("src/routes/testVelo.json");
|
|
||||||
windData = await response.json();
|
|
||||||
|
|
||||||
mapCore.on("mousemove", (e) => {
|
|
||||||
mouseLat = e.lngLat.lat;
|
|
||||||
mouseLng = e.lngLat.lng;
|
|
||||||
});
|
|
||||||
|
|
||||||
mapCore.on("click", (e) => {
|
|
||||||
if (isSelecting) {
|
|
||||||
dispatch("coordinatesSelected", { lat: e.lngLat.lat, lng: e.lngLat.lng });
|
|
||||||
stopSelection();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$: if (mapCore && data) {
|
|
||||||
plotData(data);
|
|
||||||
} else if (mapCore) {
|
|
||||||
clearMapLayers();
|
|
||||||
}
|
|
||||||
|
|
||||||
export const startSelection = () => {
|
|
||||||
isSelecting = true;
|
|
||||||
if (mapContainer) mapContainer.style.cursor = "crosshair";
|
|
||||||
};
|
|
||||||
|
|
||||||
export const stopSelection = () => {
|
|
||||||
isSelecting = false;
|
|
||||||
if (mapContainer) mapContainer.style.cursor = "";
|
|
||||||
};
|
|
||||||
|
|
||||||
export const plotData = (plotData: Prediction | Telemetry) => {
|
|
||||||
if (mode === "prediction") {
|
|
||||||
plotPrediction(plotData as Prediction);
|
|
||||||
} else if (mode === "telemetry") {
|
|
||||||
plotTelemetry(plotData as Telemetry);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const clearMapLayers = () => {
|
|
||||||
markers.forEach((marker) => marker.remove());
|
|
||||||
markers = [];
|
|
||||||
|
|
||||||
removeAnimatedMarker();
|
|
||||||
|
|
||||||
if (mapCore && mapCore.hasLayer("flight-path")) mapCore.removeLayer("flight-path");
|
|
||||||
if (mapCore && mapCore.hasSource("flight-path")) mapCore.removeSource("flight-path");
|
|
||||||
if (mapCore && mapCore.hasLayer("telemetry-path")) mapCore.removeLayer("telemetry-path");
|
|
||||||
if (mapCore && mapCore.hasSource("telemetry-path")) mapCore.removeSource("telemetry-path");
|
|
||||||
};
|
|
||||||
|
|
||||||
const createMarker = (lng: number, lat: number, iconUrl: string, title: string) => {
|
|
||||||
const el = document.createElement("div");
|
|
||||||
el.className = "custom-marker";
|
|
||||||
el.style.backgroundImage = `url(${iconUrl})`;
|
|
||||||
el.style.width = "10px";
|
|
||||||
el.style.height = "10px";
|
|
||||||
el.style.backgroundSize = "100%";
|
|
||||||
el.title = title;
|
|
||||||
|
|
||||||
const popup = mapCore
|
|
||||||
.createPopup({ offset: 25, closeButton: false })
|
|
||||||
.setHTML(`<b>${title}</b><br>Lat: ${lat.toFixed(6)}<br>Lng: ${lng.toFixed(6)}`);
|
|
||||||
|
|
||||||
const marker = mapCore
|
|
||||||
.createMarker({ element: el })
|
|
||||||
.setLngLat([lng, lat])
|
|
||||||
.setPopup(popup)
|
|
||||||
.addTo(mapCore);
|
|
||||||
|
|
||||||
el.addEventListener("mouseenter", () => marker.togglePopup());
|
|
||||||
el.addEventListener("mouseleave", () => marker.togglePopup());
|
|
||||||
|
|
||||||
markers.push(marker);
|
|
||||||
return marker;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createBurstMarker = (lng: number, lat: number, title: string) => {
|
|
||||||
const el = document.createElement("div");
|
|
||||||
el.className = "custom-marker";
|
|
||||||
el.style.backgroundImage = `url(pop-marker.png)`;
|
|
||||||
el.style.width = "16px";
|
|
||||||
el.style.height = "16px";
|
|
||||||
el.style.backgroundSize = "100%";
|
|
||||||
el.title = title;
|
|
||||||
|
|
||||||
const popup = mapCore
|
|
||||||
.createPopup({ offset: 25, closeButton: false })
|
|
||||||
.setHTML(`<b>${title}</b><br>Lat: ${lat.toFixed(6)}<br>Lng: ${lng.toFixed(6)}`);
|
|
||||||
|
|
||||||
const marker = mapCore
|
|
||||||
.createMarker({ element: el })
|
|
||||||
.setLngLat([lng, lat])
|
|
||||||
.setPopup(popup)
|
|
||||||
.addTo(mapCore);
|
|
||||||
|
|
||||||
el.addEventListener("mouseenter", () => marker.togglePopup());
|
|
||||||
el.addEventListener("mouseleave", () => marker.togglePopup());
|
|
||||||
|
|
||||||
markers.push(marker);
|
|
||||||
return marker;
|
|
||||||
};
|
|
||||||
|
|
||||||
const plotPrediction = (prediction: Prediction) => {
|
|
||||||
clearMapLayers();
|
|
||||||
|
|
||||||
const { launch, landing, burst, flight_path, flight_time } = prediction;
|
|
||||||
|
|
||||||
const range = distHaversine(launch.latlng, landing.latlng, 1);
|
|
||||||
const f_hours = Math.floor(flight_time / 3600);
|
|
||||||
const f_minutes = Math.floor((flight_time % 3600) / 60).toString().padStart(2, "0");
|
|
||||||
const flighttime = `${f_hours}hr${f_minutes}`;
|
|
||||||
|
|
||||||
const getLat = (latlng: any) => (Array.isArray(latlng) ? latlng[0] : latlng.lat);
|
|
||||||
const getLng = (latlng: any) => (Array.isArray(latlng) ? latlng[1] : latlng.lng);
|
|
||||||
|
|
||||||
createMarker(getLng(launch.latlng), getLat(launch.latlng), "target-blue.png", "Launch");
|
|
||||||
createMarker(getLng(landing.latlng), getLat(landing.latlng), "target-red.png", "Landing");
|
|
||||||
createBurstMarker(getLng(burst.latlng), getLat(burst.latlng), "Burst");
|
|
||||||
|
|
||||||
const coordinates: [number, number][] = flight_path.map((coord) =>
|
|
||||||
Array.isArray(coord) ? [coord[1], coord[0]] : [coord.lng, coord.lat],
|
|
||||||
);
|
|
||||||
|
|
||||||
mapCore.addSource("flight-path", {
|
|
||||||
type: "geojson",
|
|
||||||
data: { type: "Feature", properties: {}, geometry: { type: "LineString", coordinates } },
|
|
||||||
});
|
|
||||||
|
|
||||||
mapCore.addLayer({
|
|
||||||
id: "flight-path",
|
|
||||||
type: "line",
|
|
||||||
source: "flight-path",
|
|
||||||
layout: { "line-join": "round", "line-cap": "round" },
|
|
||||||
paint: { "line-color": "#000000", "line-width": 3 },
|
|
||||||
});
|
|
||||||
|
|
||||||
mapCore.fitBounds(coordinates, 50);
|
|
||||||
};
|
|
||||||
|
|
||||||
const plotTelemetry = (telemetry: Telemetry) => {
|
|
||||||
clearMapLayers();
|
|
||||||
|
|
||||||
const getLat = (latlng: any) => (Array.isArray(latlng) ? latlng[0] : latlng.lat);
|
|
||||||
const getLng = (latlng: any) => (Array.isArray(latlng) ? latlng[1] : latlng.lng);
|
|
||||||
|
|
||||||
createMarker(
|
|
||||||
getLng(telemetry.launch.latlng),
|
|
||||||
getLat(telemetry.launch.latlng),
|
|
||||||
"target-blue.png",
|
|
||||||
"Launch",
|
|
||||||
);
|
|
||||||
|
|
||||||
telemetry.datapoints.forEach((point) => {
|
|
||||||
const el = document.createElement("div");
|
|
||||||
el.className = "custom-marker";
|
|
||||||
el.style.backgroundImage = `url(marker-sm-red.png)`;
|
|
||||||
el.style.width = "10px";
|
|
||||||
el.style.height = "10px";
|
|
||||||
el.style.backgroundSize = "100%";
|
|
||||||
|
|
||||||
const popup = mapCore
|
|
||||||
.createPopup({ offset: 25 })
|
|
||||||
.setHTML(
|
|
||||||
`<b>Telemetry Point</b><br>Lat: ${point.latitude.toFixed(6)}<br>Lon: ${point.longitude.toFixed(6)}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const marker = mapCore
|
|
||||||
.createMarker({ element: el })
|
|
||||||
.setLngLat([point.longitude, point.latitude])
|
|
||||||
.setPopup(popup)
|
|
||||||
.addTo(mapCore);
|
|
||||||
|
|
||||||
markers.push(marker);
|
|
||||||
});
|
|
||||||
|
|
||||||
const coordinates: [number, number][] = telemetry.flight_path.map((coord) =>
|
|
||||||
Array.isArray(coord) ? [coord[1], coord[0]] : [coord.lng, coord.lat],
|
|
||||||
);
|
|
||||||
|
|
||||||
mapCore.addSource("telemetry-path", {
|
|
||||||
type: "geojson",
|
|
||||||
data: { type: "Feature", properties: {}, geometry: { type: "LineString", coordinates } },
|
|
||||||
});
|
|
||||||
|
|
||||||
mapCore.addLayer({
|
|
||||||
id: "telemetry-path",
|
|
||||||
type: "line",
|
|
||||||
source: "telemetry-path",
|
|
||||||
layout: { "line-join": "round", "line-cap": "round" },
|
|
||||||
paint: { "line-color": "#000000", "line-width": 3 },
|
|
||||||
});
|
|
||||||
|
|
||||||
mapCore.fitBounds(coordinates, 50);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const panTo = (lat: number, lng: number) => {
|
|
||||||
if (mapCore) mapCore.setCenter([lng, lat]);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const zoomTo = (lat: number, lng: number, zoomLevel: number) => {
|
|
||||||
if (mapCore) {
|
|
||||||
mapCore.setCenter([lng, lat]);
|
|
||||||
mapCore.setZoom(zoomLevel);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getMap = () => mapCore;
|
|
||||||
|
|
||||||
export const updateAnimatedMarker = (lat: number, lng: number) => {
|
|
||||||
if (!mapCore) return;
|
|
||||||
|
|
||||||
if (!animatedMarker) {
|
|
||||||
const el = document.createElement("div");
|
|
||||||
el.className = "animated-marker";
|
|
||||||
el.innerHTML = `
|
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32">
|
|
||||||
<circle cx="16" cy="16" r="14" fill="#FF6B6B" opacity="0.3" class="pulse-ring"/>
|
|
||||||
<circle cx="16" cy="16" r="8" fill="#FF1744" stroke="white" stroke-width="2"/>
|
|
||||||
</svg>
|
|
||||||
`;
|
|
||||||
|
|
||||||
animatedMarker = mapCore
|
|
||||||
.createMarker({ element: el, anchor: "center" })
|
|
||||||
.setLngLat([lng, lat])
|
|
||||||
.addTo(mapCore);
|
|
||||||
} else {
|
|
||||||
animatedMarker.setLngLat([lng, lat]);
|
|
||||||
}
|
|
||||||
|
|
||||||
mapCore.panTo([lng, lat], { duration: 100 });
|
|
||||||
};
|
|
||||||
|
|
||||||
export const removeAnimatedMarker = () => {
|
|
||||||
if (animatedMarker) {
|
|
||||||
animatedMarker.remove();
|
|
||||||
animatedMarker = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="map-container" bind:this={mapContainer}>
|
|
||||||
<!-- <div class="card coordinates-display">
|
|
||||||
<p class="card-text">
|
|
||||||
<b>Lat:</b>
|
|
||||||
{mouseLat.toFixed(6)},
|
|
||||||
<b>Lon:</b>
|
|
||||||
{mouseLng.toFixed(6)}
|
|
||||||
</p>
|
|
||||||
</div> -->
|
|
||||||
<slot />
|
|
||||||
{#if mapCore && windData}
|
|
||||||
<WindVisualization map={mapCore.getInstance()} {windData} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
:global(.animated-marker) {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.animated-marker .pulse-ring) {
|
|
||||||
animation: pulse 2s ease-out infinite;
|
|
||||||
transform-origin: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes :global(pulse) {
|
|
||||||
0% {
|
|
||||||
r: 8;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
r: 14;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import { page } from "$app/stores";
|
|
||||||
import { checkAuthenticated, logout, whoami } from "$lib/auth";
|
|
||||||
import {
|
|
||||||
Collapse,
|
|
||||||
Dropdown,
|
|
||||||
DropdownItem,
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownToggle,
|
|
||||||
Nav,
|
|
||||||
NavItem,
|
|
||||||
NavLink,
|
|
||||||
Navbar,
|
|
||||||
NavbarBrand,
|
|
||||||
NavbarToggler,
|
|
||||||
} from "@sveltestrap/sveltestrap";
|
|
||||||
|
|
||||||
// State for the navbar toggler
|
|
||||||
let isOpen = false;
|
|
||||||
|
|
||||||
// Authentication state
|
|
||||||
let isAuthenticated: boolean | null = null; // null represents the initial, unknown state
|
|
||||||
let user: string | null = null;
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
try {
|
|
||||||
const authStatus = await checkAuthenticated();
|
|
||||||
isAuthenticated = authStatus;
|
|
||||||
if (authStatus) {
|
|
||||||
user = await whoami();
|
|
||||||
} else {
|
|
||||||
user = null;
|
|
||||||
|
|
||||||
if ($page.url.pathname !== "/") goto("/login"); // Redirect to login if not authenticated
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Authentication check failed:", error);
|
|
||||||
isAuthenticated = false;
|
|
||||||
user = null;
|
|
||||||
if ($page.url.pathname !== "/") goto("/login"); // Redirect to login if not authenticated
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleLogout() {
|
|
||||||
try {
|
|
||||||
logout();
|
|
||||||
isAuthenticated = false;
|
|
||||||
user = null;
|
|
||||||
goto("/");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Logout failed:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Navbar color="light" light expand="lg" fixed="top" class="custom-navbar border-bottom">
|
|
||||||
<NavbarBrand href="/" class="nav-full-height">
|
|
||||||
<img src="/logo.svg" alt="Logo" height="34" class="d-inline-block align-text-top" />
|
|
||||||
</NavbarBrand>
|
|
||||||
<NavbarToggler on:click={() => (isOpen = !isOpen)} />
|
|
||||||
<div class="navbar-collapse collapse" class:show={isOpen} id="navbarContent">
|
|
||||||
<Nav class="me-auto mb-lg-0" navbar>
|
|
||||||
<NavItem>
|
|
||||||
<NavLink
|
|
||||||
href="/predict"
|
|
||||||
class="nav-full-height border border-top-0"
|
|
||||||
active={$page.url.pathname === "/predict"}>
|
|
||||||
Прогнозирование
|
|
||||||
</NavLink>
|
|
||||||
</NavItem>
|
|
||||||
<NavItem>
|
|
||||||
<NavLink
|
|
||||||
href="/track"
|
|
||||||
class="nav-full-height border border-top-0"
|
|
||||||
active={$page.url.pathname === "/track"}>
|
|
||||||
Слежение
|
|
||||||
</NavLink>
|
|
||||||
</NavItem>
|
|
||||||
</Nav>
|
|
||||||
<Nav navbar>
|
|
||||||
{#if isAuthenticated === true && user}
|
|
||||||
<Dropdown nav inNavbar>
|
|
||||||
<DropdownToggle nav caret class="nav-full-height border border-top-0">
|
|
||||||
{user ?? "Пользователь"}
|
|
||||||
</DropdownToggle>
|
|
||||||
<DropdownMenu end>
|
|
||||||
<DropdownItem href="/user/account">Учетная запись</DropdownItem>
|
|
||||||
<DropdownItem href="/user/templates">Сохраненные сценарии</DropdownItem>
|
|
||||||
<DropdownItem href="/user/predictions">История прогнозов</DropdownItem>
|
|
||||||
<DropdownItem href="/user/flights">История слежения</DropdownItem>
|
|
||||||
<DropdownItem divider />
|
|
||||||
<DropdownItem on:click={handleLogout}>Выйти</DropdownItem>
|
|
||||||
</DropdownMenu>
|
|
||||||
</Dropdown>
|
|
||||||
{:else if isAuthenticated === false}
|
|
||||||
<NavItem>
|
|
||||||
<NavLink
|
|
||||||
href="/login"
|
|
||||||
class="nav-full-height border border-top-0"
|
|
||||||
active={$page.url.pathname === "/login"}>
|
|
||||||
Войти
|
|
||||||
</NavLink>
|
|
||||||
</NavItem>
|
|
||||||
{/if}
|
|
||||||
<!-- While isAuthenticated is null (loading), nothing is rendered in this block -->
|
|
||||||
</Nav>
|
|
||||||
</div>
|
|
||||||
</Navbar>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
export let element: HTMLDivElement | null = null;
|
|
||||||
export let position: 'left' | 'right' = 'left';
|
|
||||||
|
|
||||||
export function getElement() {
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div bind:this={element} class="panel-container-{position}">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,302 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardBody,
|
|
||||||
Button,
|
|
||||||
FormGroup,
|
|
||||||
Label,
|
|
||||||
Input,
|
|
||||||
InputGroup,
|
|
||||||
InputGroupText,
|
|
||||||
Icon,
|
|
||||||
} from "@sveltestrap/sveltestrap";
|
|
||||||
|
|
||||||
import { PREDICTION_MODE_MAP, PPREDICTION_MODE_NAMES } from "$lib/types";
|
|
||||||
import type { SavedScenario } from "$lib/types";
|
|
||||||
import { getSavedScenarios, updateScenario, saveScenario } from "$lib/api/scenarios";
|
|
||||||
import { FlightParametersStore, writeLocalStorage, ScenarioStore, SavedScenarioStore } from "$lib/stores";
|
|
||||||
import SelectSearchable from "$lib/components/ui/SelectSearchable.svelte";
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { addToast } from "./ui/Toast.svelte";
|
|
||||||
import ScenarioEditor from "$lib/components/editors/ScenarioEditor.svelte";
|
|
||||||
|
|
||||||
let isCollapsed = $state(false);
|
|
||||||
let scenarioUnsaved = $derived(checkScenarioUnsaved());
|
|
||||||
let selectedScenarioId = $state(-1);
|
|
||||||
|
|
||||||
let scenarioEditorRef: ScenarioEditor | null = null;
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
getSavedScenarios()
|
|
||||||
.then((scenarios) => SavedScenarioStore.set(scenarios))
|
|
||||||
.catch((error) => {
|
|
||||||
addToast({
|
|
||||||
header: "Error Loading Points",
|
|
||||||
body: `Failed to load saved points: ${error.message}`,
|
|
||||||
color: "danger",
|
|
||||||
});
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
selectedScenarioId = $ScenarioStore.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
function checkScenarioUnsaved() {
|
|
||||||
const savedScenario = $SavedScenarioStore.find((scenario) => scenario.id === $ScenarioStore.id);
|
|
||||||
|
|
||||||
if (!savedScenario) {
|
|
||||||
return false; // No saved scenario found
|
|
||||||
}
|
|
||||||
|
|
||||||
const flightParameters = $FlightParametersStore;
|
|
||||||
const savedFlightParameters = savedScenario.flight_parameters;
|
|
||||||
|
|
||||||
// Compare flight parameters excluding launch_datetime
|
|
||||||
console.log("Comparing flight parameters:", flightParameters, savedFlightParameters);
|
|
||||||
return JSON.stringify(flightParameters) !== JSON.stringify(savedFlightParameters);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSaveCurrentScenario() {
|
|
||||||
console.log("handleSaveCurrentScenario called");
|
|
||||||
const scenario = $SavedScenarioStore.find((s) => s.id === selectedScenarioId);
|
|
||||||
if (selectedScenarioId !== -1 && scenario) {
|
|
||||||
$ScenarioStore.id = selectedScenarioId;
|
|
||||||
updateScenario($ScenarioStore)
|
|
||||||
.then((updatedScenario) => {
|
|
||||||
$SavedScenarioStore = $SavedScenarioStore.map((s) =>
|
|
||||||
s.id === updatedScenario.id ? updatedScenario : s,
|
|
||||||
);
|
|
||||||
SavedScenarioStore.set($SavedScenarioStore);
|
|
||||||
$ScenarioStore = updatedScenario;
|
|
||||||
selectedScenarioId = updatedScenario.id;
|
|
||||||
addToast({
|
|
||||||
header: "Сценарий обновлен",
|
|
||||||
body: `Сценарий "${updatedScenario.name}" успешно обновлен.`,
|
|
||||||
color: "success",
|
|
||||||
});
|
|
||||||
scenarioUnsaved = false;
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
addToast({
|
|
||||||
header: "Ошибка обновления сценария",
|
|
||||||
body: `Ошибка при обновлении сценария: ${error.message}`,
|
|
||||||
color: "danger",
|
|
||||||
});
|
|
||||||
console.error("Ошибка при обновлении сценария:", error);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
scenarioEditorRef?.openModalAndCreate(
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
id: 0,
|
|
||||||
name: "",
|
|
||||||
flight_parameters: $FlightParametersStore,
|
|
||||||
description: "test",
|
|
||||||
model: "test",
|
|
||||||
dataset: "test",
|
|
||||||
prediction_mode: $ScenarioStore.prediction_mode,
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
handleModalSave,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleApplySelectedScenario(showToast = true) {
|
|
||||||
const selectedScenario = $SavedScenarioStore.find((scenario) => scenario.id === selectedScenarioId);
|
|
||||||
if (selectedScenario) {
|
|
||||||
$ScenarioStore = selectedScenario;
|
|
||||||
$FlightParametersStore = selectedScenario.flight_parameters;
|
|
||||||
scenarioUnsaved = false;
|
|
||||||
writeLocalStorage("scenario", $ScenarioStore);
|
|
||||||
writeLocalStorage("flightParameters", $FlightParametersStore);
|
|
||||||
if (showToast) {
|
|
||||||
addToast({
|
|
||||||
header: "Сценарий применен",
|
|
||||||
body: `Сценарий "${selectedScenario.name}" успешно применен.`,
|
|
||||||
color: "success",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (showToast)
|
|
||||||
addToast({
|
|
||||||
header: "Сценарий не найден",
|
|
||||||
body: "Выбранный сценарий не существует.",
|
|
||||||
color: "warning",
|
|
||||||
});
|
|
||||||
console.warn("Selected scenario not found:", selectedScenarioId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleModalSave(savedScenario: SavedScenario) {
|
|
||||||
if (savedScenario) {
|
|
||||||
$ScenarioStore = savedScenario;
|
|
||||||
selectedScenarioId = savedScenario.id;
|
|
||||||
scenarioUnsaved = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const collapsePanel = () => {
|
|
||||||
isCollapsed = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const expandPanel = () => {
|
|
||||||
isCollapsed = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const togglePanel = () => {
|
|
||||||
isCollapsed = !isCollapsed;
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader
|
|
||||||
class="bg-primary text-white d-flex justify-content-between align-items-center card-header p-1 px-3"
|
|
||||||
style="cursor:pointer;">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="d-flex w-100 justify-content-between align-items-center bg-transparent border-0 p-0"
|
|
||||||
style="width:100%;"
|
|
||||||
aria-label="Свернуть/развернуть параметры прогнозирования"
|
|
||||||
onclick={() => (isCollapsed = !isCollapsed)}>
|
|
||||||
<b class="card-title mb-0 text-white p-0">Сценарий прогнозирования</b>
|
|
||||||
<Button class="p-0" size="sm" color="primary" onclick={() => (isCollapsed = !isCollapsed)}>
|
|
||||||
{#if isCollapsed}
|
|
||||||
<Icon name="caret-left-fill" class="text-white" />
|
|
||||||
{:else}
|
|
||||||
<Icon name="caret-down-fill" class="text-white" />
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
</button>
|
|
||||||
</CardHeader>
|
|
||||||
{#if !isCollapsed}
|
|
||||||
<CardBody>
|
|
||||||
<FormGroup spacing="mb-2">
|
|
||||||
<Label for="scenarioName" class="form-label">Cценарий:</Label>
|
|
||||||
<InputGroup size="sm">
|
|
||||||
<div class="position-relative flex-grow-1">
|
|
||||||
<SelectSearchable
|
|
||||||
style="min-height: calc(1.5em + .5rem + 2px); padding: .25rem .5rem; font-size: .875rem;"
|
|
||||||
id="cp-start-point"
|
|
||||||
options={$SavedScenarioStore.map((scenario) => ({
|
|
||||||
value: scenario.id,
|
|
||||||
label:
|
|
||||||
scenario.name +
|
|
||||||
`${scenario.id == $ScenarioStore.id && scenarioUnsaved ? " (изменено)" : ""}`,
|
|
||||||
}))}
|
|
||||||
bind:selected={selectedScenarioId}
|
|
||||||
placeholder="Новый сценарий..."
|
|
||||||
searchPlaceholder="Поиск сценариев..."
|
|
||||||
clearable={true}
|
|
||||||
onChange={() => {
|
|
||||||
if (!scenarioUnsaved) {
|
|
||||||
handleApplySelectedScenario(false);
|
|
||||||
}
|
|
||||||
}} />
|
|
||||||
<!-- <Button
|
|
||||||
size="sm"
|
|
||||||
color="white"
|
|
||||||
class="position-absolute top-50 end-0 translate-middle-y rounded-circle d-flex align-items-center justify-content-center"
|
|
||||||
style="width: 16px; height: 16px; border: none; background: var(--bs-secondary); color: var(--bs-white); z-index: 10; margin-right: 2rem;"
|
|
||||||
on:click={() => {
|
|
||||||
selectedScenarioId = -1;
|
|
||||||
}}
|
|
||||||
disabled={selectedScenarioId === -1}>
|
|
||||||
<Icon name="x" style="font-size: 16px;" />
|
|
||||||
</Button> -->
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
color="success"
|
|
||||||
title="Применить сценарий"
|
|
||||||
onclick={() => {
|
|
||||||
handleApplySelectedScenario(true);
|
|
||||||
}}>
|
|
||||||
<span>✓</span>
|
|
||||||
</Button>
|
|
||||||
</InputGroup>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<div class="d-flex gap-2 mb-2">
|
|
||||||
<Button color="secondary flex-fill" size="sm" title="Открыть список сценариев">
|
|
||||||
Все сценарии
|
|
||||||
<Icon name="journal-bookmark-fill" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
color="primary flex-fill"
|
|
||||||
size="sm"
|
|
||||||
title="Сохранить текущие условия как сценарий"
|
|
||||||
onclick={handleSaveCurrentScenario}
|
|
||||||
disabled={!scenarioUnsaved && selectedScenarioId !== -1}>
|
|
||||||
{selectedScenarioId !== -1 ? "Обновить сценарий" : "Сохранить сценарий"}
|
|
||||||
<Icon name="floppy2-fill" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr />
|
|
||||||
|
|
||||||
<FormGroup spacing="mb-2">
|
|
||||||
<Label for="scenarioMode" class="form-label">Режим сценария:</Label>
|
|
||||||
<InputGroup size="sm">
|
|
||||||
<Input
|
|
||||||
type="select"
|
|
||||||
id="scenarioMode"
|
|
||||||
bind:value={$ScenarioStore.prediction_mode}
|
|
||||||
on:change={() => {
|
|
||||||
scenarioUnsaved = true;
|
|
||||||
}}>
|
|
||||||
{#each Object.entries(PREDICTION_MODE_MAP) as [key, value]}
|
|
||||||
<option {value}>
|
|
||||||
{PPREDICTION_MODE_NAMES[key as keyof typeof PPREDICTION_MODE_NAMES]}
|
|
||||||
{key}
|
|
||||||
</option>
|
|
||||||
{/each}
|
|
||||||
</Input>
|
|
||||||
</InputGroup>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<FormGroup spacing="mb-2">
|
|
||||||
<Label for="scenarioMode" class="form-label">Модель атмосферы:</Label>
|
|
||||||
<InputGroup size="sm">
|
|
||||||
<Input type="select" id="scenarioMode">
|
|
||||||
<option>GFS (0.25°)</option>
|
|
||||||
<option>GFS (0.5°)</option>
|
|
||||||
</Input>
|
|
||||||
</InputGroup>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<FormGroup spacing="mb-2">
|
|
||||||
<Label for="scenarioMode" class="form-label">Набор данных:</Label>
|
|
||||||
<InputGroup size="sm">
|
|
||||||
<Input type="select" id="scenarioMode">
|
|
||||||
<option>Выбрать автоматически</option>
|
|
||||||
<!-- TODO ручка апи для доступных наборов -->
|
|
||||||
<option>20250701-00</option>
|
|
||||||
<option>20250701-06</option>
|
|
||||||
</Input>
|
|
||||||
</InputGroup>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<hr />
|
|
||||||
|
|
||||||
<FormGroup spacing="mb-0">
|
|
||||||
<Label for="export" class="form-label">Экспортировать результат:</Label>
|
|
||||||
<InputGroup size="sm">
|
|
||||||
<Input type="select" id="export">
|
|
||||||
<option>JSON</option>
|
|
||||||
<option>CSV</option>
|
|
||||||
<option>KML</option>
|
|
||||||
</Input>
|
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
title="Edit Saved Locations"
|
|
||||||
onclick={() => console.log("Not implemented yet")}>
|
|
||||||
<span>Экспорт</span>
|
|
||||||
<Icon name="file-earmark-arrow-down" />
|
|
||||||
</Button>
|
|
||||||
</InputGroup>
|
|
||||||
</FormGroup>
|
|
||||||
</CardBody>
|
|
||||||
{/if}
|
|
||||||
</Card>
|
|
||||||
<ScenarioEditor bind:this={scenarioEditorRef} onSave={handleModalSave} />
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { Card, CardHeader, CardBody, Button, FormGroup, Label, Input, InputGroup } from "@sveltestrap/sveltestrap";
|
|
||||||
//import { telemetryStore } from '../lib/telemetry.ts'; // Import your telemetry store
|
|
||||||
|
|
||||||
let telemetry: { latitude?: number; longitude?: number; altitude?: number } = {};
|
|
||||||
let isCollapsed = false;
|
|
||||||
|
|
||||||
// Subscribe to the telemetry store
|
|
||||||
//const unsubscribe = telemetryStore.subscribe((data) => {
|
|
||||||
// telemetry = data;
|
|
||||||
//});
|
|
||||||
|
|
||||||
telemetry = {
|
|
||||||
latitude: 56.3576,
|
|
||||||
longitude: 39.8666,
|
|
||||||
altitude: 1000,
|
|
||||||
};
|
|
||||||
|
|
||||||
// onMount(() => {
|
|
||||||
// return () => {
|
|
||||||
// unsubscribe(); // Cleanup subscription on component destroy
|
|
||||||
// };
|
|
||||||
// });
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader
|
|
||||||
class="bg-primary text-white d-flex justify-content-between align-items-center p-1 px-3"
|
|
||||||
style="cursor:pointer;">
|
|
||||||
<b class="card-title mb-0 p-0">Последние данные телеметрии</b>
|
|
||||||
<Button class="p-0" size="sm" color="primary" on:click={() => (isCollapsed = !isCollapsed)}>
|
|
||||||
{#if isCollapsed}
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
fill="currentColor"
|
|
||||||
class="bi bi-caret-left-fill"
|
|
||||||
viewBox="0 0 16 16">
|
|
||||||
<path
|
|
||||||
d="m3.86 8.753 5.482 4.796c.646.566 1.658.106 1.658-.753V3.204a1 1 0 0 0-1.659-.753l-5.48 4.796a1 1 0 0 0 0 1.506z" />
|
|
||||||
</svg>
|
|
||||||
{:else}
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
fill="currentColor"
|
|
||||||
class="bi bi-caret-down"
|
|
||||||
viewBox="0 0 16 16">
|
|
||||||
<path
|
|
||||||
d="M7.247 11.14 2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z" />
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
{#if !isCollapsed}
|
|
||||||
<CardBody>
|
|
||||||
<FormGroup spacing="mb-2">
|
|
||||||
<Label class="small">Широта:</Label>
|
|
||||||
<InputGroup size="sm">
|
|
||||||
<Input type="text" value={telemetry.latitude || "N/A"} readonly />
|
|
||||||
</InputGroup>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<FormGroup spacing="mb-2">
|
|
||||||
<Label class="small">Долгота:</Label>
|
|
||||||
<InputGroup size="sm">
|
|
||||||
<Input type="text" value={telemetry.longitude || "N/A"} readonly />
|
|
||||||
</InputGroup>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<FormGroup spacing="mb-2">
|
|
||||||
<Label class="small">Высота (м):</Label>
|
|
||||||
<InputGroup size="sm">
|
|
||||||
<Input type="text" value={telemetry.altitude || "N/A"} readonly />
|
|
||||||
</InputGroup>
|
|
||||||
</FormGroup>
|
|
||||||
</CardBody>
|
|
||||||
{/if}
|
|
||||||
</Card>
|
|
||||||
|
|
@ -1,310 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
import type { Prediction } from "$lib/types";
|
|
||||||
import "bootstrap-icons/font/bootstrap-icons.css";
|
|
||||||
|
|
||||||
let { prediction }: { prediction: Prediction | null } = $props();
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{
|
|
||||||
timeUpdate: { index: number; lat: number; lng: number; alt: number; datetime: Date };
|
|
||||||
}>();
|
|
||||||
|
|
||||||
let isPlaying = $state(false);
|
|
||||||
let currentIndex = $state(0);
|
|
||||||
let playbackSpeed = $state(1);
|
|
||||||
let isCollapsed = $state(false);
|
|
||||||
let animationFrame: number | null = null;
|
|
||||||
let lastUpdateTime = 0;
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (prediction && currentIndex >= flightPathLength) {
|
|
||||||
currentIndex = 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const flightPathLength = $derived(prediction?.flight_path?.length || 0);
|
|
||||||
const progress = $derived(flightPathLength > 0 ? (currentIndex / flightPathLength) * 100 : 0);
|
|
||||||
|
|
||||||
const currentPosition = $derived.by(() => {
|
|
||||||
if (!prediction || !prediction.flight_path[currentIndex]) return null;
|
|
||||||
|
|
||||||
const point = prediction.flight_path[currentIndex];
|
|
||||||
let lat: number, lng: number, alt: number;
|
|
||||||
|
|
||||||
if (Array.isArray(point)) {
|
|
||||||
lat = point[0];
|
|
||||||
lng = point[1];
|
|
||||||
alt = point[2] || 0;
|
|
||||||
} else {
|
|
||||||
lat = point.lat;
|
|
||||||
lng = point.lng;
|
|
||||||
alt = point.alt || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalTime = prediction.flight_time;
|
|
||||||
const timeProgress = (currentIndex / flightPathLength) * totalTime;
|
|
||||||
const launchTime = prediction.launch.datetime instanceof Date
|
|
||||||
? prediction.launch.datetime.getTime()
|
|
||||||
: new Date(prediction.launch.datetime).getTime();
|
|
||||||
const datetime = new Date(launchTime + timeProgress * 1000);
|
|
||||||
|
|
||||||
return { lat, lng, alt, datetime };
|
|
||||||
});
|
|
||||||
|
|
||||||
const timeElapsed = $derived.by(() => {
|
|
||||||
if (!prediction || !currentPosition) return "00:00:00";
|
|
||||||
const launchTime = prediction.launch.datetime instanceof Date
|
|
||||||
? prediction.launch.datetime.getTime()
|
|
||||||
: new Date(prediction.launch.datetime).getTime();
|
|
||||||
const totalSeconds = Math.floor(
|
|
||||||
(currentPosition.datetime.getTime() - launchTime) / 1000,
|
|
||||||
);
|
|
||||||
const hours = Math.floor(totalSeconds / 3600);
|
|
||||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
||||||
const seconds = totalSeconds % 60;
|
|
||||||
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
function animate(timestamp: number) {
|
|
||||||
if (!isPlaying) return;
|
|
||||||
|
|
||||||
if (!lastUpdateTime) lastUpdateTime = timestamp;
|
|
||||||
const deltaTime = timestamp - lastUpdateTime;
|
|
||||||
|
|
||||||
if (deltaTime >= 50 / playbackSpeed) {
|
|
||||||
if (currentIndex < flightPathLength - 1) {
|
|
||||||
currentIndex++;
|
|
||||||
if (currentPosition) {
|
|
||||||
dispatch("timeUpdate", { ...currentPosition, index: currentIndex });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
stop();
|
|
||||||
}
|
|
||||||
lastUpdateTime = timestamp;
|
|
||||||
}
|
|
||||||
|
|
||||||
animationFrame = requestAnimationFrame(animate);
|
|
||||||
}
|
|
||||||
|
|
||||||
function play() {
|
|
||||||
if (!prediction) return;
|
|
||||||
if (currentIndex >= flightPathLength - 1) {
|
|
||||||
currentIndex = 0;
|
|
||||||
}
|
|
||||||
isPlaying = true;
|
|
||||||
lastUpdateTime = 0;
|
|
||||||
animationFrame = requestAnimationFrame(animate);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pause() {
|
|
||||||
isPlaying = false;
|
|
||||||
if (animationFrame !== null) {
|
|
||||||
cancelAnimationFrame(animationFrame);
|
|
||||||
animationFrame = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stop() {
|
|
||||||
pause();
|
|
||||||
currentIndex = 0;
|
|
||||||
if (currentPosition) {
|
|
||||||
dispatch("timeUpdate", { ...currentPosition, index: currentIndex });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSliderChange(event: Event) {
|
|
||||||
const target = event.target as HTMLInputElement;
|
|
||||||
currentIndex = parseInt(target.value);
|
|
||||||
if (currentPosition) {
|
|
||||||
dispatch("timeUpdate", { ...currentPosition, index: currentIndex });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function changeSpeed() {
|
|
||||||
const speeds = [1, 2, 5, 0.5];
|
|
||||||
const currentSpeedIndex = speeds.indexOf(playbackSpeed);
|
|
||||||
playbackSpeed = speeds[(currentSpeedIndex + 1) % speeds.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleToggleCollapse() {
|
|
||||||
isCollapsed = !isCollapsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
return () => {
|
|
||||||
if (animationFrame !== null) {
|
|
||||||
cancelAnimationFrame(animationFrame);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="timeline-container card shadow-sm">
|
|
||||||
<div
|
|
||||||
class="card-header bg-primary text-white d-flex justify-content-between align-items-center p-1 px-3"
|
|
||||||
style="cursor:pointer;"
|
|
||||||
onclick={handleToggleCollapse}
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
onkeydown={(e) => e.key === 'Enter' && handleToggleCollapse()}
|
|
||||||
>
|
|
||||||
<span class="fw-bold mb-0">Flight Timeline</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-primary p-0"
|
|
||||||
aria-label="Toggle timeline visibility"
|
|
||||||
>
|
|
||||||
<i class="bi {isCollapsed ? 'bi-caret-left-fill' : 'bi-caret-down-fill'}"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if !isCollapsed}
|
|
||||||
<div class="card-body p-3">
|
|
||||||
<div class="timeline-info mb-2">
|
|
||||||
<div class="info-section">
|
|
||||||
<span class="form-label mb-1">Time:</span>
|
|
||||||
<span class="fw-bold font-monospace">{timeElapsed}</span>
|
|
||||||
</div>
|
|
||||||
{#if currentPosition}
|
|
||||||
<div class="info-section">
|
|
||||||
<span class="form-label mb-1">Altitude:</span>
|
|
||||||
<span class="fw-bold font-monospace">{Math.round(currentPosition.alt)} m</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-section">
|
|
||||||
<span class="form-label mb-1">Position:</span>
|
|
||||||
<span class="fw-bold font-monospace"
|
|
||||||
>{currentPosition.lat.toFixed(4)}, {currentPosition.lng.toFixed(4)}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="timeline-controls">
|
|
||||||
<div class="btn-group me-2" role="group">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-outline-primary"
|
|
||||||
onclick={stop}
|
|
||||||
disabled={!prediction || currentIndex === 0}
|
|
||||||
title="Reset to start"
|
|
||||||
aria-label="Reset to start"
|
|
||||||
>
|
|
||||||
<i class="bi bi-skip-start-fill"></i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#if !isPlaying}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-success"
|
|
||||||
onclick={play}
|
|
||||||
disabled={!prediction}
|
|
||||||
title="Play animation"
|
|
||||||
aria-label="Play animation"
|
|
||||||
>
|
|
||||||
<i class="bi bi-play-fill"></i>
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-warning"
|
|
||||||
onclick={pause}
|
|
||||||
title="Pause animation"
|
|
||||||
aria-label="Pause animation"
|
|
||||||
>
|
|
||||||
<i class="bi bi-pause-fill"></i>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-outline-secondary"
|
|
||||||
onclick={changeSpeed}
|
|
||||||
disabled={!prediction}
|
|
||||||
title="Change playback speed"
|
|
||||||
aria-label="Change playback speed"
|
|
||||||
>
|
|
||||||
{playbackSpeed}x
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-grow-1 position-relative">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max={flightPathLength - 1}
|
|
||||||
value={currentIndex}
|
|
||||||
oninput={handleSliderChange}
|
|
||||||
disabled={!prediction}
|
|
||||||
class="form-range timeline-slider"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.timeline-container {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 20px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
min-width: 500px;
|
|
||||||
max-width: 700px;
|
|
||||||
z-index: 1000;
|
|
||||||
background: var(--bs-body-bg, #fff);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-info {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-around;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.001rem;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom range slider styling to match Bootstrap theme */
|
|
||||||
.timeline-slider::-webkit-slider-thumb {
|
|
||||||
background: var(--bs-primary, #007bff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-slider::-moz-range-thumb {
|
|
||||||
background: var(--bs-primary, #007bff);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive design */
|
|
||||||
@media (max-width: 767.98px) {
|
|
||||||
.timeline-container {
|
|
||||||
min-width: calc(100vw - 40px);
|
|
||||||
max-width: calc(100vw - 40px);
|
|
||||||
bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-info {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-section {
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-group {
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount, onDestroy } from "svelte";
|
|
||||||
|
|
||||||
// Props
|
|
||||||
let { map, windData }: { map: any; windData: any } = $props();
|
|
||||||
|
|
||||||
// State for layer toggles
|
|
||||||
let showHeatmap = $state(false);
|
|
||||||
let showParticles = $state(false);
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (!map || !windData) {
|
|
||||||
console.warn('Map or wind data not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("WindVisualization component mounted");
|
|
||||||
console.log("Wind data available:", windData);
|
|
||||||
|
|
||||||
// NOTE: @sakitam-gis/maplibre-wind requires tile-based or image URL sources
|
|
||||||
// It does not support raw wind data arrays directly
|
|
||||||
//
|
|
||||||
// The library expects:
|
|
||||||
// - TileSource with URL template (e.g., 'https://tiles.example.com/{z}/{x}/{y}.png')
|
|
||||||
// - ImageSource with image URL and coordinates
|
|
||||||
//
|
|
||||||
// To use this library, we would need to:
|
|
||||||
// 1. Convert wind data to tiles or images
|
|
||||||
// 2. Serve them via a tile server
|
|
||||||
// 3. Use TileSource or ImageSource with the URLs
|
|
||||||
//
|
|
||||||
// Alternative approaches:
|
|
||||||
// 1. Use deck.gl with ParticleLayer for raw data visualization
|
|
||||||
// 2. Use MapLibre's native heatmap layers for color visualization
|
|
||||||
// 3. Create a custom WebGL layer for particle animation
|
|
||||||
// 4. Pre-process wind data into tiles/images server-side
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
console.log("WindVisualization component destroyed");
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- <div class="layer-controls">
|
|
||||||
<div class="control-group">
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" bind:checked={showHeatmap} disabled />
|
|
||||||
Тепловая карта
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" bind:checked={showParticles} disabled />
|
|
||||||
Частицы ветра
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<small style="color: #666; font-size: 11px; margin-top: 8px; display: block;">
|
|
||||||
Wind visualization requires tile/image source
|
|
||||||
</small>
|
|
||||||
<small style="color: #999; font-size: 10px; margin-top: 4px; display: block;">
|
|
||||||
See WindVisualisation.svelte for implementation notes
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.layer-controls {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 30px;
|
|
||||||
left: 10px;
|
|
||||||
z-index: 1000;
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 5px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-group label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: not-allowed;
|
|
||||||
user-select: none;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-group input[type="checkbox"] {
|
|
||||||
cursor: not-allowed;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
small {
|
|
||||||
font-style: italic;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
</style> -->
|
|
||||||
|
|
@ -1,607 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { TableHandler } from "@vincjo/datatables";
|
|
||||||
import {
|
|
||||||
Modal,
|
|
||||||
Button,
|
|
||||||
FormGroup,
|
|
||||||
Label,
|
|
||||||
Input,
|
|
||||||
Alert,
|
|
||||||
Icon,
|
|
||||||
Pagination,
|
|
||||||
PaginationItem,
|
|
||||||
PaginationLink,
|
|
||||||
InputGroup,
|
|
||||||
Table,
|
|
||||||
} from "@sveltestrap/sveltestrap";
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { addToast } from "$lib/components/ui/Toast.svelte";
|
|
||||||
import type { SavedFlightProfile, RateCurvePoint } from "$lib/types";
|
|
||||||
import { SavedFlightProfilesStore } from "$lib/stores";
|
|
||||||
import ConfirmationPrompt from "../ConfirmationPrompt.svelte";
|
|
||||||
// import { getSavedCurves, saveCurve, updateCurve, deleteCurve } from "$lib/api/curves";
|
|
||||||
import EditableCell from "$lib/components/ui/EditableCell.svelte";
|
|
||||||
import CurveChart from "$lib/components/CurveChart.svelte";
|
|
||||||
|
|
||||||
// Mock API functions for now
|
|
||||||
const getSavedCurves = async (): Promise<SavedFlightProfile[]> => {
|
|
||||||
console.log("Fetching saved curves");
|
|
||||||
return [];
|
|
||||||
};
|
|
||||||
const saveCurve = async (curve: SavedFlightProfile): Promise<SavedFlightProfile> => {
|
|
||||||
console.log("Saving curve", curve);
|
|
||||||
const newCurve = { ...curve, id: Date.now() };
|
|
||||||
return newCurve;
|
|
||||||
};
|
|
||||||
const updateCurve = async (curve: SavedFlightProfile): Promise<SavedFlightProfile> => {
|
|
||||||
console.log("Updating curve", curve);
|
|
||||||
return curve;
|
|
||||||
};
|
|
||||||
const deleteCurve = async (id: number): Promise<void> => {
|
|
||||||
console.log("Deleting curve", id);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Props
|
|
||||||
let {
|
|
||||||
isOpen = $bindable(false),
|
|
||||||
onClose = () => {},
|
|
||||||
onSave = (p: SavedFlightProfile) => {},
|
|
||||||
onSelectCurve = (p: SavedFlightProfile) => {},
|
|
||||||
showTable = false,
|
|
||||||
curve = null,
|
|
||||||
editor = false,
|
|
||||||
closeOnSave = false,
|
|
||||||
closeOnDelete = false,
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
// Runes
|
|
||||||
let selectedCurve = $derived<SavedFlightProfile | null>(curve);
|
|
||||||
let newCurve = $state<SavedFlightProfile>({ id: 0, name: "", rate_profile_data: [] });
|
|
||||||
let newPoint = $state<RateCurvePoint>({ order: 0, time_constraint: 0, alt_constraint: 0, rate: 0 });
|
|
||||||
|
|
||||||
let isEditing = $state(editor);
|
|
||||||
let isAlertVisible = $state(false);
|
|
||||||
let isConfirmationVisible = $state(false);
|
|
||||||
let alertText = $state("");
|
|
||||||
let closeOnSave_ = $state(closeOnSave);
|
|
||||||
|
|
||||||
// Table handler
|
|
||||||
let curvesTable = $derived(new TableHandler($SavedFlightProfilesStore, { rowsPerPage: 5 }));
|
|
||||||
let search = $derived(curvesTable.createSearch(["name"]));
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (showTable) {
|
|
||||||
getSavedCurves().then((curves) => {
|
|
||||||
$SavedFlightProfilesStore = curves;
|
|
||||||
SavedFlightProfilesStore.set($SavedFlightProfilesStore);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (editor && curve) {
|
|
||||||
selectedCurve = curve;
|
|
||||||
newCurve = { ...curve };
|
|
||||||
isEditing = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ensure curve points are always sorted by the order field
|
|
||||||
$effect(() => {
|
|
||||||
newCurve.rate_profile_data.sort((a, b) => a.order - b.order);
|
|
||||||
});
|
|
||||||
|
|
||||||
// On mount, fetch curves
|
|
||||||
onMount(async () => {
|
|
||||||
if (showTable) {
|
|
||||||
const curves = await getSavedCurves();
|
|
||||||
$SavedFlightProfilesStore = curves;
|
|
||||||
SavedFlightProfilesStore.set($SavedFlightProfilesStore);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Modal controls
|
|
||||||
export function openModal(table_: boolean = false) {
|
|
||||||
showTable = table_;
|
|
||||||
isOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function openModalAndCreate(
|
|
||||||
curve: SavedFlightProfile | null = null,
|
|
||||||
close: boolean = false,
|
|
||||||
table_: boolean = false,
|
|
||||||
onSaveCallback: (curve: SavedFlightProfile) => void = () => {},
|
|
||||||
) {
|
|
||||||
if (curve) {
|
|
||||||
selectedCurve = curve;
|
|
||||||
newCurve = { ...curve, rate_profile_data: [...curve.rate_profile_data] };
|
|
||||||
isEditing = true;
|
|
||||||
} else {
|
|
||||||
selectedCurve = null;
|
|
||||||
newCurve = { id: 0, name: "", rate_profile_data: [] };
|
|
||||||
isEditing = false;
|
|
||||||
}
|
|
||||||
showTable = table_;
|
|
||||||
isOpen = true;
|
|
||||||
closeOnSave_ = close;
|
|
||||||
onSave = onSaveCallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeModal() {
|
|
||||||
isOpen = false;
|
|
||||||
if (closeOnSave_ != closeOnSave) {
|
|
||||||
closeOnSave = closeOnSave_;
|
|
||||||
}
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEditCurve(curve: SavedFlightProfile) {
|
|
||||||
selectedCurve = curve;
|
|
||||||
newCurve = { ...curve, rate_profile_data: [...curve.rate_profile_data] };
|
|
||||||
isEditing = true;
|
|
||||||
showTable = false; // Switch to editor view
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmDeleteCurve(curve: SavedFlightProfile) {
|
|
||||||
selectedCurve = curve;
|
|
||||||
isConfirmationVisible = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDeleteCurve(curve: SavedFlightProfile | null) {
|
|
||||||
if (!curve) return;
|
|
||||||
deleteCurve(curve.id)
|
|
||||||
.then(() => {
|
|
||||||
$SavedFlightProfilesStore = $SavedFlightProfilesStore.filter((p) => p.id !== curve.id);
|
|
||||||
SavedFlightProfilesStore.set($SavedFlightProfilesStore);
|
|
||||||
addToast({
|
|
||||||
header: "Curve deleted",
|
|
||||||
body: `Curve "${curve.name}" has been deleted.`,
|
|
||||||
color: "success",
|
|
||||||
});
|
|
||||||
if (closeOnDelete) {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
showAlert(`Error deleting curve: ${error.message}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function handleSaveCurve() {
|
|
||||||
if (isEditing && selectedCurve) {
|
|
||||||
updateCurve(newCurve)
|
|
||||||
.then((updatedCurve) => {
|
|
||||||
$SavedFlightProfilesStore = $SavedFlightProfilesStore.map((p) =>
|
|
||||||
p.id === updatedCurve.id ? updatedCurve : p,
|
|
||||||
);
|
|
||||||
SavedFlightProfilesStore.set($SavedFlightProfilesStore);
|
|
||||||
addToast({
|
|
||||||
header: "Curve updated",
|
|
||||||
body: `Curve "${updatedCurve.name}" has been updated.`,
|
|
||||||
color: "success",
|
|
||||||
});
|
|
||||||
if (closeOnSave_) {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
onSave(updatedCurve);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
showAlert(`Error updating curve: ${error.message}`);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
saveCurve(newCurve)
|
|
||||||
.then((savedCurve) => {
|
|
||||||
$SavedFlightProfilesStore = [...$SavedFlightProfilesStore, savedCurve];
|
|
||||||
SavedFlightProfilesStore.set($SavedFlightProfilesStore);
|
|
||||||
addToast({
|
|
||||||
header: "Curve saved",
|
|
||||||
body: `Curve "${savedCurve.name}" has been saved.`,
|
|
||||||
color: "success",
|
|
||||||
});
|
|
||||||
if (closeOnSave_) {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
onSave(savedCurve);
|
|
||||||
resetForm();
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
showAlert(`Error saving curve: ${error.message}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateConstraints(point: RateCurvePoint): boolean {
|
|
||||||
if (point.time_constraint <= 0 && point.time_constraint !== -1) {
|
|
||||||
showAlert("Time constraint invalid, must be > 0 or -1 for no constraint.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (point.alt_constraint < 0 && point.alt_constraint !== -1) {
|
|
||||||
showAlert("Altitude constraint invalid, must be >= 0 or -1 for no constraint.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (point.alt_constraint === -1 && point.time_constraint === -1) {
|
|
||||||
showAlert("At least one constraint must be set (time or altitude).");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addPoint() {
|
|
||||||
if (validateConstraints(newPoint)) {
|
|
||||||
const maxOrder = newCurve.rate_profile_data.reduce((max, p) => Math.max(max, p.order), -1);
|
|
||||||
newPoint.order = maxOrder + 1;
|
|
||||||
newCurve.rate_profile_data.push({ ...newPoint });
|
|
||||||
newCurve.rate_profile_data = newCurve.rate_profile_data; // Svelte reactivity
|
|
||||||
newPoint = { order: 0, time_constraint: 0, alt_constraint: 0, rate: 0 };
|
|
||||||
isAlertVisible = false; // Hide alert after successful addition
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function removePoint(index: number) {
|
|
||||||
newCurve.rate_profile_data.splice(index, 1);
|
|
||||||
// Re-index the order of remaining points
|
|
||||||
newCurve.rate_profile_data.forEach((point, i) => {
|
|
||||||
point.order = i;
|
|
||||||
});
|
|
||||||
newCurve.rate_profile_data = newCurve.rate_profile_data; // Svelte reactivity
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFileUpload(event: Event) {
|
|
||||||
const target = event.target as HTMLInputElement;
|
|
||||||
const file = target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (e) => {
|
|
||||||
const text = e.target?.result as string;
|
|
||||||
try {
|
|
||||||
const rate_profile_data: RateCurvePoint[] = text
|
|
||||||
.split("\n")
|
|
||||||
.filter((line) => line.trim() !== "")
|
|
||||||
.map((line, index) => {
|
|
||||||
const [order, time_constraint, alt_constraint, rate] = line.split(",").map(Number);
|
|
||||||
if (isNaN(time_constraint) || isNaN(alt_constraint) || isNaN(rate)) {
|
|
||||||
throw new Error("Invalid number in CSV file.");
|
|
||||||
}
|
|
||||||
// Use file line order as the canonical order
|
|
||||||
return { order: index, time_constraint, alt_constraint, rate };
|
|
||||||
});
|
|
||||||
newCurve.rate_profile_data = rate_profile_data;
|
|
||||||
addToast({
|
|
||||||
header: "CSV imported",
|
|
||||||
body: `${rate_profile_data.length} rate_profile_data loaded.`,
|
|
||||||
color: "success",
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
showAlert(`Error parsing CSV: ${error.message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.readAsText(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function showAlert(message: string) {
|
|
||||||
isAlertVisible = true;
|
|
||||||
alertText = message;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hideAlert() {
|
|
||||||
isAlertVisible = false;
|
|
||||||
alertText = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resetForm() {
|
|
||||||
selectedCurve = null;
|
|
||||||
newCurve = { id: 0, name: "", rate_profile_data: [] };
|
|
||||||
isEditing = false;
|
|
||||||
hideAlert();
|
|
||||||
}
|
|
||||||
|
|
||||||
function movePoint(index: number, direction: number) {
|
|
||||||
const newIndex = index + direction;
|
|
||||||
if (newIndex < 0 || newIndex >= newCurve.rate_profile_data.length) return;
|
|
||||||
|
|
||||||
// Swap order values
|
|
||||||
const tempOrder = newCurve.rate_profile_data[index].order;
|
|
||||||
newCurve.rate_profile_data[index].order = newCurve.rate_profile_data[newIndex].order;
|
|
||||||
newCurve.rate_profile_data[newIndex].order = tempOrder;
|
|
||||||
|
|
||||||
// Trigger reactivity, the $effect will sort the array
|
|
||||||
newCurve.rate_profile_data = [...newCurve.rate_profile_data];
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
{isOpen}
|
|
||||||
toggle={closeModal}
|
|
||||||
size="xl"
|
|
||||||
fade={false}
|
|
||||||
backdrop={true}
|
|
||||||
scrollable
|
|
||||||
class={isConfirmationVisible ? "modal-tinted" : ""}>
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title">
|
|
||||||
{showTable ? "Ascent/Descent Curves" : isEditing ? "Edit Curve" : "Create New Curve"}
|
|
||||||
</h5>
|
|
||||||
<Button close onclick={closeModal} />
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
{#if showTable}
|
|
||||||
<!-- Curve Selection Table -->
|
|
||||||
<div class="d-flex justify-content-between mb-2">
|
|
||||||
<InputGroup>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search by name..."
|
|
||||||
bind:value={search.value}
|
|
||||||
oninput={() => search.set()} />
|
|
||||||
<Button
|
|
||||||
onclick={() => {
|
|
||||||
search.value = "";
|
|
||||||
search.set();
|
|
||||||
}}>
|
|
||||||
<Icon name="x" />
|
|
||||||
</Button>
|
|
||||||
</InputGroup>
|
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
on:click={() => {
|
|
||||||
showTable = false;
|
|
||||||
isEditing = false;
|
|
||||||
resetForm();
|
|
||||||
}}>
|
|
||||||
<Icon name="plus-lg" class="me-1" /> Create New
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div bind:this={curvesTable.element} class="table-responsive">
|
|
||||||
<Table class="table-sm mb-0">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style="width: 70%;">Name</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each curvesTable.rows as curve (curve.id)}
|
|
||||||
<tr>
|
|
||||||
<td>{curve.name}</td>
|
|
||||||
<td>
|
|
||||||
<Button size="sm" color="primary" on:click={() => onSelectCurve(curve)}>
|
|
||||||
<Icon name="check-lg" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
color="secondary"
|
|
||||||
on:click={() => handleEditCurve(curve)}
|
|
||||||
class="ms-1">
|
|
||||||
<Icon name="pencil" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
color="danger"
|
|
||||||
on:click={() => confirmDeleteCurve(curve)}
|
|
||||||
class="ms-1">
|
|
||||||
<Icon name="trash" />
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
<Pagination aria-label="Page navigation" size="sm">
|
|
||||||
<PaginationItem>
|
|
||||||
<PaginationLink previous on:click={() => curvesTable.setPage("previous")} />
|
|
||||||
</PaginationItem>
|
|
||||||
{#each curvesTable.pagesWithEllipsis as page}
|
|
||||||
<PaginationItem active={curvesTable.currentPage === page}>
|
|
||||||
<PaginationLink on:click={() => curvesTable.setPage(page)}>{page}</PaginationLink>
|
|
||||||
</PaginationItem>
|
|
||||||
{/each}
|
|
||||||
<PaginationItem>
|
|
||||||
<PaginationLink next on:click={() => curvesTable.setPage("next")} />
|
|
||||||
</PaginationItem>
|
|
||||||
</Pagination>
|
|
||||||
{:else}
|
|
||||||
<!-- Curve Editor -->
|
|
||||||
<!-- Points Table -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="mb-2">
|
|
||||||
<Label for="name" class="small">Curve Name:</Label>
|
|
||||||
<Input class="form-control-sm" type="text" id="name" bind:value={newCurve.name} required />
|
|
||||||
</div>
|
|
||||||
<h6>Точки профиля</h6>
|
|
||||||
<Alert
|
|
||||||
color="danger"
|
|
||||||
isOpen={isAlertVisible}
|
|
||||||
toggle={() => (isAlertVisible = false)}
|
|
||||||
fade={false}
|
|
||||||
class="mb-2">
|
|
||||||
<Icon name="exclamation-triangle" class="me-2" />
|
|
||||||
{alertText}
|
|
||||||
</Alert>
|
|
||||||
<div class="table-responsive small" style="max-height: 300px;" bind:this={curvesTable.element}>
|
|
||||||
<table class="table table-sm border mb-0">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style="width: 49.8px;"></th>
|
|
||||||
<th>
|
|
||||||
Время (сек)
|
|
||||||
<span
|
|
||||||
title="Время в секундах от предыдущей точки"
|
|
||||||
class="ms-1 text-muted"
|
|
||||||
style="cursor: help;">
|
|
||||||
<Icon name="info-circle-fill" />
|
|
||||||
</span>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
Высота (м)
|
|
||||||
<span
|
|
||||||
title="Высота в метрах над уровнем моря"
|
|
||||||
class="ms-1 text-muted"
|
|
||||||
style="cursor: help;">
|
|
||||||
<Icon name="info-circle-fill" />
|
|
||||||
</span>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
Скорость (м/с)
|
|
||||||
<span
|
|
||||||
title="Вертикальная скорость в метрах в секунду (положительная - подъем, отрицательная - спуск)"
|
|
||||||
class="ms-1 text-muted"
|
|
||||||
style="cursor: help;">
|
|
||||||
<Icon name="info-circle-fill" />
|
|
||||||
</span>
|
|
||||||
</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each newCurve.rate_profile_data as point, i (point.order)}
|
|
||||||
{@const isFirst = i === 0}
|
|
||||||
{@const isLast = i === newCurve.rate_profile_data.length - 1}
|
|
||||||
<tr style="height: 36.8px; vertical-align: middle;">
|
|
||||||
<td class="text-center align-middle" style="cursor: grab; width: 49.8px;">
|
|
||||||
<div class="d-flex flex-row">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
class="p-0 border-0 bg-transparent text-body px-1"
|
|
||||||
on:click={() => movePoint(i, -1)}
|
|
||||||
disabled={isFirst}>
|
|
||||||
<Icon name="chevron-up" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
class="p-0 border-0 bg-transparent text-body px-1"
|
|
||||||
on:click={() => movePoint(i, 1)}
|
|
||||||
disabled={isLast}>
|
|
||||||
<Icon name="chevron-down" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<EditableCell
|
|
||||||
bind:value={point.time_constraint}
|
|
||||||
onchange={() => (newCurve.rate_profile_data = newCurve.rate_profile_data)}
|
|
||||||
valuePrefix="+"
|
|
||||||
valueSuffix=" сек"
|
|
||||||
emptyValue={-1} />
|
|
||||||
<EditableCell
|
|
||||||
bind:value={point.alt_constraint}
|
|
||||||
onchange={() => (newCurve.rate_profile_data = newCurve.rate_profile_data)}
|
|
||||||
valueSuffix=" м"
|
|
||||||
emptyValue={-1} />
|
|
||||||
<EditableCell
|
|
||||||
bind:value={point.rate}
|
|
||||||
onchange={() => (newCurve.rate_profile_data = newCurve.rate_profile_data)}
|
|
||||||
valueSuffix=" м/c" />
|
|
||||||
<td class="text-center align-middle">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
color="danger"
|
|
||||||
on:click={() => removePoint(i)}
|
|
||||||
class="p-0 border-0 bg-transparent text-danger px-1"
|
|
||||||
style="cursor: pointer; font-size: initial;">
|
|
||||||
<Icon name="trash" />
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{:else}
|
|
||||||
<tr style="height: 36.8px; vertical-align: middle;">
|
|
||||||
<td colspan="5" class="text-center text-muted">No points added yet</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td></td>
|
|
||||||
<td>
|
|
||||||
<Input
|
|
||||||
class="form-control-sm"
|
|
||||||
type="number"
|
|
||||||
placeholder="Time (s)"
|
|
||||||
bind:value={newPoint.time_constraint} />
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<Input
|
|
||||||
class="form-control-sm"
|
|
||||||
type="number"
|
|
||||||
placeholder="Altitude (m)"
|
|
||||||
bind:value={newPoint.alt_constraint} />
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<Input
|
|
||||||
class="form-control-sm"
|
|
||||||
type="number"
|
|
||||||
placeholder="Rate (m/s)"
|
|
||||||
bind:value={newPoint.rate} />
|
|
||||||
</td>
|
|
||||||
<td class="text-center align-middle">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
color="success"
|
|
||||||
on:click={addPoint}
|
|
||||||
class="p-0 border-0 bg-transparent px-1 text-success"
|
|
||||||
style="cursor: pointer; display: table;">
|
|
||||||
Добавить
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<CurveChart
|
|
||||||
curve={newCurve}
|
|
||||||
onUpdate={(updatedPoints: RateCurvePoint[]) => {
|
|
||||||
newCurve.rate_profile_data = updatedPoints;
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Import/Export -->
|
|
||||||
<div class="d-flex justify-content-between">
|
|
||||||
<div>
|
|
||||||
<Label for="import-csv" class="small">Import from CSV</Label>
|
|
||||||
<Input
|
|
||||||
type="file"
|
|
||||||
id="import-csv"
|
|
||||||
accept=".csv"
|
|
||||||
on:change={handleFileUpload}
|
|
||||||
class="form-control-sm" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr />
|
|
||||||
|
|
||||||
<div class="d-grid gap-2 d-md-flex justify-content-end">
|
|
||||||
{#if showTable}
|
|
||||||
<Button
|
|
||||||
color="secondary"
|
|
||||||
size="sm"
|
|
||||||
on:click={() => {
|
|
||||||
showTable = true;
|
|
||||||
resetForm();
|
|
||||||
}}>
|
|
||||||
Back to List
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
<Button type="submit" color="success" size="sm" onclick={handleSaveCurve}>
|
|
||||||
{isEditing ? "Update Curve" : "Save New Curve"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<ConfirmationPrompt
|
|
||||||
isOpen={isConfirmationVisible}
|
|
||||||
title="Confirm Deletion"
|
|
||||||
confirmText="Delete"
|
|
||||||
cancelText="Cancel"
|
|
||||||
confirmVariant="danger"
|
|
||||||
onconfirm={() => {
|
|
||||||
isConfirmationVisible = false;
|
|
||||||
handleDeleteCurve(selectedCurve);
|
|
||||||
}}
|
|
||||||
oncancel={() => {
|
|
||||||
isConfirmationVisible = false;
|
|
||||||
}}>
|
|
||||||
<p>Are you sure you want to delete this curve?</p>
|
|
||||||
</ConfirmationPrompt>
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount, type Snippet } from "svelte";
|
|
||||||
import { FormGroup, Label, Input } from "@sveltestrap/sveltestrap";
|
|
||||||
import type { SavedPoint } from "$lib/types";
|
|
||||||
import { SavedPointsStore } from "$lib/stores";
|
|
||||||
import { getSavedPoints, savePoint, updatePoint, deletePoint } from "$lib/api/points";
|
|
||||||
import GenericEditor from "./GenericEditor.svelte";
|
|
||||||
import type { EditorConfig, EditorApi } from "./GenericEditor.svelte";
|
|
||||||
|
|
||||||
type $$Props = {
|
|
||||||
isOpen?: boolean;
|
|
||||||
onClose?: () => void;
|
|
||||||
onSave?: (p: SavedPoint) => void;
|
|
||||||
onSelectPoint?: (p: SavedPoint) => void;
|
|
||||||
point?: SavedPoint | null;
|
|
||||||
editor?: boolean;
|
|
||||||
config?: Partial<EditorConfig<SavedPoint>>;
|
|
||||||
api?: Partial<EditorApi<SavedPoint>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Props
|
|
||||||
let {
|
|
||||||
isOpen = $bindable(false),
|
|
||||||
onClose = () => {},
|
|
||||||
onSave = (p: SavedPoint) => {},
|
|
||||||
onSelectPoint = (p: SavedPoint) => {},
|
|
||||||
point = null,
|
|
||||||
editor = false,
|
|
||||||
config: propConfig = {},
|
|
||||||
api: propApi = {},
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
// State
|
|
||||||
let points = $state<SavedPoint[]>([]);
|
|
||||||
let editorRef: GenericEditor<SavedPoint> | null = $state(null);
|
|
||||||
let config: EditorConfig<SavedPoint> = $state<EditorConfig<SavedPoint>>({
|
|
||||||
showTable: true,
|
|
||||||
closeOnSave: false,
|
|
||||||
closeOnDelete: false,
|
|
||||||
searchBy: ["name"],
|
|
||||||
labels: {
|
|
||||||
item: "точка",
|
|
||||||
itemGenitive: "точки",
|
|
||||||
items: "точки",
|
|
||||||
add: "Добавить",
|
|
||||||
edit: "Редактирование",
|
|
||||||
save: "Сохранить",
|
|
||||||
update: "Обновить",
|
|
||||||
delete: "Удалить",
|
|
||||||
cancel: "Отмена",
|
|
||||||
close: "Закрыть без сохранения",
|
|
||||||
searchPlaceholder: "Поиск по названию...",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
let api: EditorApi<SavedPoint> = $state<EditorApi<SavedPoint>>({
|
|
||||||
save: savePoint,
|
|
||||||
update: updatePoint,
|
|
||||||
delete: (p: SavedPoint) => deletePoint(p.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load points from store or fetch from API
|
|
||||||
onMount(async () => {
|
|
||||||
if ($SavedPointsStore.length > 0) {
|
|
||||||
points = $SavedPointsStore;
|
|
||||||
} else if (config.showTable) {
|
|
||||||
const pts = await getSavedPoints();
|
|
||||||
points = pts;
|
|
||||||
SavedPointsStore.set(pts);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sync local state with store changes
|
|
||||||
$effect(() => {
|
|
||||||
points = $SavedPointsStore;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sync store with local state changes
|
|
||||||
$effect(() => {
|
|
||||||
SavedPointsStore.set(points);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Open editor in edit mode if point and editor props are set
|
|
||||||
$effect(() => {
|
|
||||||
if (editor && point && editorRef) {
|
|
||||||
editorRef.open(point);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Factory function for creating a new point
|
|
||||||
const pointFactory = (): SavedPoint => ({ id: 0, name: "", lat: 0, lon: 0, alt: 0 });
|
|
||||||
|
|
||||||
// Public method to control the editor
|
|
||||||
export function open(item: SavedPoint | null = null, showTable = config.showTable) {
|
|
||||||
editorRef?.open(item, showTable);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<GenericEditor
|
|
||||||
bind:this={editorRef}
|
|
||||||
bind:isOpen
|
|
||||||
bind:items={points}
|
|
||||||
onClose={() => onClose()}
|
|
||||||
onSave={(p) => onSave(p)}
|
|
||||||
onSelect={(p) => onSelectPoint(p)}
|
|
||||||
itemFactory={pointFactory}
|
|
||||||
{api}
|
|
||||||
{config}>
|
|
||||||
{#snippet tableHeader()}
|
|
||||||
<tr>
|
|
||||||
<th>Название точки</th>
|
|
||||||
<th>Широта</th>
|
|
||||||
<th>Долгота</th>
|
|
||||||
<th>Высота</th>
|
|
||||||
<th class="fit">Действия</th>
|
|
||||||
</tr>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
{#snippet tableRow({ row })}
|
|
||||||
<td>{row.name}</td>
|
|
||||||
<td>{row.lat.toFixed(5)} °</td>
|
|
||||||
<td>{row.lon.toFixed(5)} °</td>
|
|
||||||
<td>{row.alt} м</td>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
{#snippet formFields({ item })}
|
|
||||||
<div class="mb-2">
|
|
||||||
<Label for="name" class="small">Название точки:</Label>
|
|
||||||
<Input class="form-control-sm" type="text" id="name" bind:value={item.name} required />
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<FormGroup class="flex-grow-1">
|
|
||||||
<Label for="lat" class="small">Широта:</Label>
|
|
||||||
<Input
|
|
||||||
class="form-control-sm"
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
id="lat"
|
|
||||||
bind:value={item.lat}
|
|
||||||
required />
|
|
||||||
<span class="form-text">Градусы</span>
|
|
||||||
</FormGroup>
|
|
||||||
<FormGroup class="flex-grow-1">
|
|
||||||
<Label for="lon" class="small">Долгота:</Label>
|
|
||||||
<Input
|
|
||||||
class="form-control-sm"
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
id="lon"
|
|
||||||
bind:value={item.lon}
|
|
||||||
required />
|
|
||||||
<span class="form-text">Градусы</span>
|
|
||||||
</FormGroup>
|
|
||||||
<FormGroup class="flex-grow-1">
|
|
||||||
<Label for="alt" class="small">Высота:</Label>
|
|
||||||
<Input
|
|
||||||
class="form-control-sm"
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
id="alt"
|
|
||||||
bind:value={item.alt}
|
|
||||||
required />
|
|
||||||
<span class="form-text">Метры над ур. моря</span>
|
|
||||||
</FormGroup>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</GenericEditor>
|
|
||||||
|
|
@ -1,251 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { TableHandler } from "@vincjo/datatables";
|
|
||||||
import {
|
|
||||||
Modal,
|
|
||||||
Button,
|
|
||||||
FormGroup,
|
|
||||||
Label,
|
|
||||||
Input,
|
|
||||||
Alert,
|
|
||||||
Icon,
|
|
||||||
Pagination,
|
|
||||||
PaginationItem,
|
|
||||||
PaginationLink,
|
|
||||||
} from "@sveltestrap/sveltestrap";
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { addToast } from "$lib/components/ui/Toast.svelte";
|
|
||||||
import type { SavedScenario } from "$lib/types";
|
|
||||||
import { FlightParametersStore, SavedScenarioStore } from "$lib/stores";
|
|
||||||
import ConfirmationPrompt from "../ConfirmationPrompt.svelte";
|
|
||||||
import { getSavedScenarios, saveScenario, updateScenario, deleteScenario } from "$lib/api/scenarios";
|
|
||||||
|
|
||||||
// Props
|
|
||||||
let {
|
|
||||||
isOpen = $bindable(false),
|
|
||||||
onClose = () => {},
|
|
||||||
onChange = () => {},
|
|
||||||
onSave = () => {},
|
|
||||||
onSelectScenario = (p: SavedScenario) => {},
|
|
||||||
scenario = null,
|
|
||||||
scenario_data = {
|
|
||||||
id: 0,
|
|
||||||
name: "",
|
|
||||||
flight_parameters: $FlightParametersStore,
|
|
||||||
description: "",
|
|
||||||
model: "",
|
|
||||||
dataset: "",
|
|
||||||
prediction_mode: "",
|
|
||||||
} as SavedScenario,
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
// Runes
|
|
||||||
let selectedScenario = $derived<SavedScenario | null>(scenario);
|
|
||||||
let isEditing = $state(false);
|
|
||||||
let closeOnSave = $state(false);
|
|
||||||
let isAlertVisible = $state(false);
|
|
||||||
let isConfirmationVisible = $state(false);
|
|
||||||
let alertText = $state("");
|
|
||||||
|
|
||||||
let newScenario = $derived<SavedScenario>(scenario_data as SavedScenario);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
onChange();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Modal controls
|
|
||||||
export function openModal() {
|
|
||||||
isOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function openModalAndCreate(
|
|
||||||
scenario: SavedScenario | null = null,
|
|
||||||
scenario_data: SavedScenario = {
|
|
||||||
id: 0,
|
|
||||||
name: "",
|
|
||||||
flight_parameters: $FlightParametersStore,
|
|
||||||
description: "",
|
|
||||||
model: "",
|
|
||||||
dataset: "",
|
|
||||||
prediction_mode: "",
|
|
||||||
},
|
|
||||||
close: boolean = false,
|
|
||||||
onSaveCallback: (point: SavedScenario) => void = () => {},
|
|
||||||
) {
|
|
||||||
if (scenario) {
|
|
||||||
selectedScenario = scenario;
|
|
||||||
newScenario = { ...scenario };
|
|
||||||
isEditing = true;
|
|
||||||
} else {
|
|
||||||
selectedScenario = null;
|
|
||||||
newScenario = scenario_data;
|
|
||||||
isEditing = false;
|
|
||||||
}
|
|
||||||
isOpen = true;
|
|
||||||
closeOnSave = close;
|
|
||||||
onSave = onSaveCallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeModal() {
|
|
||||||
isOpen = false;
|
|
||||||
closeOnSave = false;
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDeleteScenario(scenario: SavedScenario | null) {
|
|
||||||
if (!scenario) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
deleteScenario(scenario.id)
|
|
||||||
.then(() => {
|
|
||||||
$SavedScenarioStore = $SavedScenarioStore.filter((s) => s.id !== scenario.id);
|
|
||||||
SavedScenarioStore.set($SavedScenarioStore);
|
|
||||||
resetForm();
|
|
||||||
addToast({
|
|
||||||
header: "Точка удалена",
|
|
||||||
body: `Точка "${scenario.name}" успешно удалена.`,
|
|
||||||
color: "success",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
showAlert(`Ошибка при удалении сценария: ${error.message}`);
|
|
||||||
console.error("Ошибка при удалении сценария:", error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function handleSaveScenario() {
|
|
||||||
if (isEditing && selectedScenario) {
|
|
||||||
updateScenario(newScenario)
|
|
||||||
.then((updatedScenario) => {
|
|
||||||
$SavedScenarioStore = $SavedScenarioStore.map((s) =>
|
|
||||||
s.id === updatedScenario.id ? updatedScenario : s,
|
|
||||||
);
|
|
||||||
SavedScenarioStore.set($SavedScenarioStore);
|
|
||||||
resetForm();
|
|
||||||
addToast({
|
|
||||||
header: "Сценарий обновлен",
|
|
||||||
body: `Сценарий "${updatedScenario.name}" успешно обновлен.`,
|
|
||||||
color: "success",
|
|
||||||
});
|
|
||||||
if (closeOnSave) {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
onSave(updatedScenario);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
showAlert(`Ошибка при обновлении сценария: ${error.message}`);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
saveScenario(newScenario)
|
|
||||||
.then((savedScenario) => {
|
|
||||||
$SavedScenarioStore = [...$SavedScenarioStore, savedScenario];
|
|
||||||
SavedScenarioStore.set($SavedScenarioStore);
|
|
||||||
resetForm();
|
|
||||||
addToast({
|
|
||||||
header: "Сценарий сохранен",
|
|
||||||
body: `Сценарий "${savedScenario.name}" успешно сохранен.`,
|
|
||||||
color: "success",
|
|
||||||
});
|
|
||||||
if (closeOnSave) {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
onSave(savedScenario);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
showAlert(`Ошибка при сохранении сценария: ${error.message}`);
|
|
||||||
console.error("Ошибка при сохранении сценария:", error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function showAlert(message: string) {
|
|
||||||
isAlertVisible = true;
|
|
||||||
alertText = message;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hideAlert() {
|
|
||||||
isAlertVisible = false;
|
|
||||||
alertText = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resetForm() {
|
|
||||||
hideAlert();
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
{isOpen}
|
|
||||||
toggle={closeModal}
|
|
||||||
size="lg"
|
|
||||||
fade={false}
|
|
||||||
backdrop={true}
|
|
||||||
scrollable
|
|
||||||
class={isConfirmationVisible ? "modal-tinted" : ""}>
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title">Редактирование сценария</h5>
|
|
||||||
<button type="button" class="btn-close" onclick={closeModal} aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div>
|
|
||||||
<h5>{"Редактирование сценария"}</h5>
|
|
||||||
<Alert
|
|
||||||
color="danger"
|
|
||||||
isOpen={isAlertVisible}
|
|
||||||
toggle={() => (isAlertVisible = false)}
|
|
||||||
fade={false}
|
|
||||||
class="mb-2">
|
|
||||||
<Icon name="exclamation-triangle" class="me-2" />
|
|
||||||
{alertText}
|
|
||||||
</Alert>
|
|
||||||
<form
|
|
||||||
onsubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSaveScenario();
|
|
||||||
}}>
|
|
||||||
<div class="mb-2">
|
|
||||||
<Label for="name" class="small">Название сценария:</Label>
|
|
||||||
<Input class="form-control-sm" type="text" id="name" bind:value={newScenario.name} required />
|
|
||||||
</div>
|
|
||||||
<div class="d-grid gap-2 d-md-flex">
|
|
||||||
<Button type="submit" color="success" size="sm">
|
|
||||||
{isEditing ? "Обновить сценарий" : "Сохранить сценарий"}
|
|
||||||
</Button>
|
|
||||||
{#if isEditing}
|
|
||||||
<Button size="sm" type="button" color="secondary" onclick={resetForm}>Отмена</Button>
|
|
||||||
{/if}
|
|
||||||
<span class="flex-grow-1"></span>
|
|
||||||
{#if isEditing}
|
|
||||||
<Button color="danger" size="sm" type="button" onclick={() => {}}>Удалить сценарий</Button>
|
|
||||||
{:else}
|
|
||||||
<Button
|
|
||||||
color="secondary"
|
|
||||||
size="sm"
|
|
||||||
type="button"
|
|
||||||
onclick={() => {
|
|
||||||
resetForm();
|
|
||||||
closeModal();
|
|
||||||
}}>
|
|
||||||
Закрыть без сохранения
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<ConfirmationPrompt
|
|
||||||
isOpen={isConfirmationVisible}
|
|
||||||
title="Подтвердите удаление"
|
|
||||||
confirmText="Удалить"
|
|
||||||
cancelText="Отмена"
|
|
||||||
confirmVariant="danger"
|
|
||||||
onconfirm={() => {
|
|
||||||
isConfirmationVisible = false;
|
|
||||||
handleDeleteScenario(selectedScenario);
|
|
||||||
}}
|
|
||||||
oncancel={() => {
|
|
||||||
isConfirmationVisible = false;
|
|
||||||
}}>
|
|
||||||
<p>Вы уверены, что хотите удалить этот сценарий?</p>
|
|
||||||
</ConfirmationPrompt>
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
let { value = $bindable(), onchange = () => {}, valuePrefix = "", valueSuffix = "", emptyValue=null, emptyPlaceholder="-" } = $props();
|
|
||||||
|
|
||||||
let editing = $state(false);
|
|
||||||
let inputEl: HTMLInputElement | undefined = $state();
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (inputEl) inputEl.focus();
|
|
||||||
});
|
|
||||||
|
|
||||||
function startEditing() {
|
|
||||||
editing = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopEditing() {
|
|
||||||
editing = false;
|
|
||||||
onchange();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent) {
|
|
||||||
if (event.key === "Enter") {
|
|
||||||
stopEditing();
|
|
||||||
} else if (event.key === "Escape") {
|
|
||||||
editing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<td onclick={startEditing} onfocusin={startEditing}>
|
|
||||||
{#if editing}
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
class="form-control form-control-sm border-0"
|
|
||||||
bind:this={inputEl}
|
|
||||||
bind:value
|
|
||||||
onblur={stopEditing}
|
|
||||||
onkeydown={handleKeydown} />
|
|
||||||
{:else}
|
|
||||||
{#if value === emptyValue || value === null || value === undefined}
|
|
||||||
<span class="text-muted">{emptyPlaceholder}</span>
|
|
||||||
{:else}
|
|
||||||
<span>{valuePrefix}{value}{valueSuffix}</span>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
|
||||||
|
|
||||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
||||||
id?: string | undefined;
|
|
||||||
label?: string;
|
|
||||||
children?: () => any;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { id, label = "", class: className = "", children, ...restProps }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div {id} class="spoiler-group {className}" {...restProps}>
|
|
||||||
<button class="btn btn-link p-0 d-flex align-items-center w-100 text-decoration-none spoiler-header">
|
|
||||||
<div class="border-top" style="width: 10px;"></div>
|
|
||||||
<span class="small text-nowrap ms-2">{label}</span>
|
|
||||||
<div class="flex-fill border-top ms-2"></div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="p-2 border border-top-0 spoiler-content">
|
|
||||||
{@render children?.()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.spoiler-header {
|
|
||||||
margin-bottom: -0.75em;
|
|
||||||
}
|
|
||||||
.spoiler-content {
|
|
||||||
padding-top: 0.75em !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spoiler-icon {
|
|
||||||
line-height: 1;
|
|
||||||
padding-bottom: 0.1em;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,236 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
|
|
||||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
||||||
options?: { value: any; label:string }[];
|
|
||||||
selected?: any;
|
|
||||||
placeholder?: string;
|
|
||||||
searchPlaceholder?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
class?: string;
|
|
||||||
onChange?: (value: any) => void;
|
|
||||||
clearable?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
let {
|
|
||||||
id = 'select-searchable',
|
|
||||||
options = [],
|
|
||||||
selected = $bindable(null),
|
|
||||||
placeholder = 'Select an option...',
|
|
||||||
searchPlaceholder = 'Search...',
|
|
||||||
disabled = false,
|
|
||||||
class: className = '',
|
|
||||||
onChange,
|
|
||||||
clearable = false,
|
|
||||||
...restProps
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
let isOpen = $state(false);
|
|
||||||
let searchTerm = $state('');
|
|
||||||
let dropdownElement = $state<HTMLElement>();
|
|
||||||
let selectElement = $state<HTMLElement>();
|
|
||||||
let searchInputElement = $state<HTMLInputElement>();
|
|
||||||
let dropdownStyle = $state('');
|
|
||||||
|
|
||||||
let filteredOptions = $derived(
|
|
||||||
options.filter(option =>
|
|
||||||
option.label.toLowerCase().includes(searchTerm.toLowerCase())
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
let selectedLabel = $derived(
|
|
||||||
options.find(opt => opt.value === selected)?.label || ''
|
|
||||||
);
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
updateDropdownPosition();
|
|
||||||
});
|
|
||||||
|
|
||||||
function updateDropdownPosition() {
|
|
||||||
if (!selectElement || !dropdownElement) return;
|
|
||||||
const rect = selectElement.getBoundingClientRect();
|
|
||||||
const dropdownHeight = dropdownElement.offsetHeight;
|
|
||||||
const spaceBelow = window.innerHeight - rect.bottom;
|
|
||||||
const spaceAbove = rect.top;
|
|
||||||
|
|
||||||
let top, bottom;
|
|
||||||
|
|
||||||
if (spaceBelow >= dropdownHeight || spaceBelow >= spaceAbove) {
|
|
||||||
top = `${rect.bottom}px`;
|
|
||||||
bottom = 'auto';
|
|
||||||
} else {
|
|
||||||
top = 'auto';
|
|
||||||
bottom = `${window.innerHeight - rect.top}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
dropdownStyle = `
|
|
||||||
position: fixed;
|
|
||||||
top: ${top};
|
|
||||||
bottom: ${bottom};
|
|
||||||
left: ${rect.left}px;
|
|
||||||
min-width: ${rect.width}px;
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleDropdown() {
|
|
||||||
if (!disabled) {
|
|
||||||
isOpen = !isOpen;
|
|
||||||
if (isOpen) {
|
|
||||||
searchTerm = '';
|
|
||||||
Promise.resolve().then(() => {
|
|
||||||
updateDropdownPosition();
|
|
||||||
searchInputElement?.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectOption(option: { value: any; label: string }) {
|
|
||||||
selected = option.value;
|
|
||||||
isOpen = false;
|
|
||||||
searchTerm = '';
|
|
||||||
if (onChange) {
|
|
||||||
onChange(selected);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClickOutside(event: MouseEvent) {
|
|
||||||
if (selectElement && !selectElement.contains(event.target as Node)) {
|
|
||||||
isOpen = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearSelection(e: Event) {
|
|
||||||
e.stopPropagation();
|
|
||||||
selected = null;
|
|
||||||
if (onChange) {
|
|
||||||
onChange(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
window.addEventListener('scroll', updateDropdownPosition, true);
|
|
||||||
window.addEventListener('resize', updateDropdownPosition);
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('scroll', updateDropdownPosition, true);
|
|
||||||
window.removeEventListener('resize', updateDropdownPosition);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (isOpen && dropdownElement) {
|
|
||||||
updateDropdownPosition();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:window onclick={handleClickOutside} />
|
|
||||||
|
|
||||||
<div
|
|
||||||
bind:this={selectElement}
|
|
||||||
{id}
|
|
||||||
class="form-control form-select select-container {className}"
|
|
||||||
class:disabled
|
|
||||||
class:show={isOpen}
|
|
||||||
onclick={toggleDropdown}
|
|
||||||
onkeydown={(e) => e.key === 'Enter' && toggleDropdown()}
|
|
||||||
role="combobox"
|
|
||||||
aria-haspopup="listbox"
|
|
||||||
aria-expanded={isOpen}
|
|
||||||
tabindex={disabled ? -1 : 0}
|
|
||||||
{...restProps}
|
|
||||||
>
|
|
||||||
<span class:text-muted={!selected}>{selectedLabel || placeholder}</span>
|
|
||||||
|
|
||||||
{#if clearable && selected != null}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="clear-btn"
|
|
||||||
tabindex="-1"
|
|
||||||
aria-label="Clear selection"
|
|
||||||
onclick={clearSelection}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if isOpen}
|
|
||||||
<div class="pt-0 dropdown-menu show" bind:this={dropdownElement} style={dropdownStyle}>
|
|
||||||
<div class="p-2">
|
|
||||||
<input
|
|
||||||
bind:this={searchInputElement}
|
|
||||||
type="text"
|
|
||||||
class="form-control form-control-sm"
|
|
||||||
placeholder={searchPlaceholder}
|
|
||||||
bind:value={searchTerm}
|
|
||||||
onclick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="options-list">
|
|
||||||
{#each filteredOptions as option}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="dropdown-item small"
|
|
||||||
class:active={option.value === selected}
|
|
||||||
onclick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
selectOption(option);
|
|
||||||
}}
|
|
||||||
onkeydown={(e) => e.key === 'Enter' && selectOption(option)}
|
|
||||||
role="option"
|
|
||||||
aria-selected={option.value === selected}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{#if filteredOptions.length === 0}
|
|
||||||
<div class="dropdown-item text-muted disabled">No options found</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.select-container {
|
|
||||||
position: relative;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-menu {
|
|
||||||
z-index: 1000;
|
|
||||||
width: max-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.options-list {
|
|
||||||
max-height: 40vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear-btn {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
right: 2rem;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: #2a2a2a;
|
|
||||||
font-size: 1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
z-index: 2;
|
|
||||||
padding: 0;
|
|
||||||
line-height: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.clear-btn:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,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
33
src/lib/domain/geo.ts
Normal 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;
|
||||||
|
}
|
||||||
6
src/lib/domain/index.ts
Normal file
6
src/lib/domain/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export * from './geo';
|
||||||
|
export * from './math';
|
||||||
|
export * from './scenario';
|
||||||
|
export * from './prediction';
|
||||||
|
export * from './telemetry';
|
||||||
|
export * from './wind';
|
||||||
99
src/lib/domain/math.ts
Normal file
99
src/lib/domain/math.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import type { LatLng } from './geo';
|
||||||
|
import type { TelemetryPoint } from './telemetry';
|
||||||
|
import type { Prediction } from './prediction';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One compared sample: telemetry point matched against the closest-in-time prediction point. */
|
||||||
|
export interface DeviationPoint {
|
||||||
|
/** Epoch ms from the telemetry timestamp. */
|
||||||
|
timeMs: number;
|
||||||
|
/** Great-circle distance from actual position to predicted position, km. */
|
||||||
|
horizontal: number;
|
||||||
|
/** Altitude difference (actual − predicted), m. Positive means actual is higher. */
|
||||||
|
vertical: number;
|
||||||
|
/** Actual altitude from telemetry, m. */
|
||||||
|
altActual: number;
|
||||||
|
/** Predicted altitude at the matched index, m. */
|
||||||
|
altPredicted: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For each telemetry point find the closest-in-time point in the prediction
|
||||||
|
* and compute horizontal (haversine) and vertical deviations.
|
||||||
|
*
|
||||||
|
* Telemetry points that fall outside the prediction's time window are skipped —
|
||||||
|
* bisectClosest would clamp them to the boundary and produce misleading values.
|
||||||
|
*/
|
||||||
|
export function computeDeviations(
|
||||||
|
points: TelemetryPoint[],
|
||||||
|
prediction: Prediction,
|
||||||
|
): DeviationPoint[] {
|
||||||
|
if (points.length === 0 || prediction.timestamps.length === 0) return [];
|
||||||
|
|
||||||
|
const predStart = prediction.timestamps[0];
|
||||||
|
const predEnd = prediction.timestamps[prediction.timestamps.length - 1];
|
||||||
|
|
||||||
|
const result: DeviationPoint[] = [];
|
||||||
|
for (const p of points) {
|
||||||
|
const t = new Date(p.datetime).getTime();
|
||||||
|
if (t < predStart || t > predEnd) continue;
|
||||||
|
|
||||||
|
const i = bisectClosest(prediction.timestamps, t);
|
||||||
|
const fp = prediction.flight_path[i];
|
||||||
|
const predAlt = (fp[2] as number | undefined) ?? 0;
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
timeMs: t,
|
||||||
|
horizontal: distHaversine({ lat: p.latitude, lng: p.longitude }, { lat: fp[0], lng: fp[1] }),
|
||||||
|
vertical: p.altitude - predAlt,
|
||||||
|
altActual: p.altitude,
|
||||||
|
altPredicted: predAlt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Binary search: index of the element in `arr` closest to `target`. */
|
||||||
|
function bisectClosest(arr: number[], target: number): number {
|
||||||
|
let lo = 0;
|
||||||
|
let hi = arr.length - 1;
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
if (arr[mid] < target) lo = mid + 1;
|
||||||
|
else hi = mid;
|
||||||
|
}
|
||||||
|
if (lo > 0 && Math.abs(arr[lo - 1] - target) < Math.abs(arr[lo] - target)) return lo - 1;
|
||||||
|
return lo;
|
||||||
|
}
|
||||||
78
src/lib/domain/prediction.ts
Normal file
78
src/lib/domain/prediction.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
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[];
|
||||||
|
/** Epoch-ms timestamp for each point in flight_path (parallel array). */
|
||||||
|
timestamps: number[];
|
||||||
|
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 all = [...ascent, ...descent];
|
||||||
|
|
||||||
|
const flight_path: LatLngTuple[] = all.map((p) => [
|
||||||
|
p.latitude,
|
||||||
|
normalizeLng(p.longitude),
|
||||||
|
p.altitude,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const timestamps: number[] = all.map((p) => new Date(p.datetime).getTime());
|
||||||
|
|
||||||
|
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, timestamps, launch, burst, landing, profile, flight_time };
|
||||||
|
}
|
||||||
92
src/lib/domain/scenario.ts
Normal file
92
src/lib/domain/scenario.ts
Normal 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,
|
||||||
|
};
|
||||||
41
src/lib/domain/telemetry.ts
Normal file
41
src/lib/domain/telemetry.ts
Normal 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 };
|
||||||
|
}
|
||||||
214
src/lib/domain/wind.ts
Normal file
214
src/lib/domain/wind.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
/**
|
||||||
|
* Wind field types matching the wind-js-server / leaflet-velocity format
|
||||||
|
* produced by the predictor's GET /api/v1/wind/field endpoint.
|
||||||
|
*
|
||||||
|
* The response is a two-element array [U, V] where U is the eastward and V
|
||||||
|
* the northward wind component, each stored as a regular lat/lng grid
|
||||||
|
* described by a GRIB-style header.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface WindHeader {
|
||||||
|
parameterUnit: string;
|
||||||
|
parameterNumberName: string;
|
||||||
|
/** Grid points in the longitude direction. */
|
||||||
|
nx: number;
|
||||||
|
/** Grid points in the latitude direction. */
|
||||||
|
ny: number;
|
||||||
|
lo1: number; // longitude of first grid point (degrees)
|
||||||
|
la1: number; // latitude of first grid point (degrees)
|
||||||
|
lo2: number; // longitude of last grid point
|
||||||
|
la2: number; // latitude of last grid point
|
||||||
|
/**
|
||||||
|
* Grid increments in degrees. Both are reported as positive magnitudes by
|
||||||
|
* the predictor regardless of scan direction, so the scan direction must be
|
||||||
|
* inferred from the extent (la1/la2, lo1/lo2) — see decodeWindField.
|
||||||
|
*/
|
||||||
|
dx: number;
|
||||||
|
dy: number;
|
||||||
|
refTime: string; // ISO 8601 reference time
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WindComponent {
|
||||||
|
header: WindHeader;
|
||||||
|
/** Flat row-major array: data[j * nx + i] = value at row j, column i. */
|
||||||
|
data: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [U-component (eastward m/s), V-component (northward m/s)] */
|
||||||
|
export type WindField = [WindComponent, WindComponent];
|
||||||
|
|
||||||
|
export interface WindMeta {
|
||||||
|
source: string;
|
||||||
|
epoch: string;
|
||||||
|
altitudes: number[];
|
||||||
|
bbox: {
|
||||||
|
min_lat: number;
|
||||||
|
max_lat: number;
|
||||||
|
min_lng: number;
|
||||||
|
max_lng: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decoded wind vector at a single grid cell. */
|
||||||
|
export interface WindVector {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
u: number; // eastward component (m/s)
|
||||||
|
v: number; // northward component (m/s)
|
||||||
|
speed: number; // magnitude (m/s)
|
||||||
|
/**
|
||||||
|
* Direction the wind blows TO, degrees clockwise from north.
|
||||||
|
* 0° = northward, 90° = eastward. Used directly as MapLibre icon-rotate.
|
||||||
|
*
|
||||||
|
* Derivation: bearing = atan2(U, V) (see docs/wind-vis-math.tex §3).
|
||||||
|
*/
|
||||||
|
bearing: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WindSettings {
|
||||||
|
/** Master toggle — off by default. */
|
||||||
|
enabled: boolean;
|
||||||
|
/** Grid resolution for static display (degrees). */
|
||||||
|
step: number;
|
||||||
|
/** Grid resolution when synced to a trajectory (degrees). */
|
||||||
|
trajectoryStep: number;
|
||||||
|
/** Time interval between pre-fetched trajectory frames (minutes). */
|
||||||
|
prefetchIntervalMinutes: number;
|
||||||
|
/** Trajectory sync is skipped when flight duration exceeds this (hours). */
|
||||||
|
maxFlightDurationHours: number;
|
||||||
|
/**
|
||||||
|
* Trajectory sync is skipped when the bounding box exceeds this in either
|
||||||
|
* dimension (degrees).
|
||||||
|
*/
|
||||||
|
maxRegionDegrees: number;
|
||||||
|
/** Padding added to the trajectory bounding box on each side (degrees). */
|
||||||
|
trajectoryMarginDegrees: number;
|
||||||
|
/** Particle count scalar (particles per screen pixel). Higher = denser. */
|
||||||
|
particleDensity: number;
|
||||||
|
/** Advection speed multiplier — how fast particles flow. */
|
||||||
|
particleSpeed: number;
|
||||||
|
/** Trail persistence in [0,1): fraction of each trail kept per frame. */
|
||||||
|
trailPersistence: number;
|
||||||
|
/** Wind speed (m/s) mapped to the top of the colour scale. */
|
||||||
|
maxVelocity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_WIND_SETTINGS: WindSettings = {
|
||||||
|
enabled: false,
|
||||||
|
step: 2.0,
|
||||||
|
trajectoryStep: 1.0,
|
||||||
|
prefetchIntervalMinutes: 15,
|
||||||
|
maxFlightDurationHours: 4,
|
||||||
|
maxRegionDegrees: 20,
|
||||||
|
trajectoryMarginDegrees: 1.0,
|
||||||
|
particleDensity: 1.0,
|
||||||
|
particleSpeed: 1.0,
|
||||||
|
trailPersistence: 0.92,
|
||||||
|
maxVelocity: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Wrap a longitude into the (-180, 180] range MapLibre renders. */
|
||||||
|
function wrapLng(lng: number): number {
|
||||||
|
let x = ((lng + 180) % 360) - 180;
|
||||||
|
if (x <= -180) x += 360;
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rasterize a WindField into an array of wind vectors — one per grid cell.
|
||||||
|
*
|
||||||
|
* Coordinate handling is derived from the grid extent (la1/la2, lo1/lo2)
|
||||||
|
* rather than the raw dx/dy increments, because the predictor reports:
|
||||||
|
* • longitudes in the 0..360 range (e.g. lo1 = 358 for a query at -2°), and
|
||||||
|
* • a *positive* dy even when the grid scans north→south (la1 = 90,
|
||||||
|
* la2 = -90), which would otherwise send `la1 + j·dy` past the pole.
|
||||||
|
*
|
||||||
|
* Stepping from the first point toward the last (la1→la2, lo1→lo2) and
|
||||||
|
* wrapping longitudes into (-180, 180] places every arrow at its true
|
||||||
|
* geographic position regardless of scan direction or longitude convention.
|
||||||
|
*/
|
||||||
|
export function decodeWindField(field: WindField): WindVector[] {
|
||||||
|
const [uComp, vComp] = field;
|
||||||
|
const { nx, ny, lo1, la1, lo2, la2, dx, dy } = uComp.header;
|
||||||
|
const vectors: WindVector[] = [];
|
||||||
|
|
||||||
|
// Per-step deltas taken from the grid extent so the last row/column lands
|
||||||
|
// exactly on la2/lo2. Longitude span is taken the short way around the
|
||||||
|
// globe to stay correct for boxes that cross the 0/360 seam.
|
||||||
|
const lonSpan = ((lo2 - lo1) % 360 + 360) % 360;
|
||||||
|
const lngDelta = nx > 1 ? lonSpan / (nx - 1) : dx;
|
||||||
|
const latDelta = ny > 1 ? (la2 - la1) / (ny - 1) : -Math.abs(dy);
|
||||||
|
|
||||||
|
for (let j = 0; j < ny; j++) {
|
||||||
|
const lat = la1 + j * latDelta;
|
||||||
|
for (let i = 0; i < nx; i++) {
|
||||||
|
const idx = j * nx + i;
|
||||||
|
const u = uComp.data[idx];
|
||||||
|
const v = vComp.data[idx];
|
||||||
|
if (!Number.isFinite(u) || !Number.isFinite(v)) continue;
|
||||||
|
const lng = wrapLng(lo1 + i * lngDelta);
|
||||||
|
const speed = Math.sqrt(u * u + v * v);
|
||||||
|
const bearing = (Math.atan2(u, v) * 180) / Math.PI;
|
||||||
|
vectors.push({ lat, lng, u, v, speed, bearing });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return vectors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Samples the wind field at an arbitrary lng/lat. Returns null outside the grid. */
|
||||||
|
export type WindInterpolator = (lng: number, lat: number) => [number, number] | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a bilinear interpolator over a WindField. Used by the particle
|
||||||
|
* renderer to advect points through a continuous [u, v] field.
|
||||||
|
*
|
||||||
|
* Coordinate handling mirrors decodeWindField: longitudes are taken in the
|
||||||
|
* grid's native 0..360 frame (so a query lng is brought into that frame),
|
||||||
|
* and the per-step increments come from the grid extent so scan direction is
|
||||||
|
* handled implicitly.
|
||||||
|
*/
|
||||||
|
export function createWindInterpolator(field: WindField): WindInterpolator {
|
||||||
|
const [uComp, vComp] = field;
|
||||||
|
const { nx, ny, lo1, la1, lo2, la2, dx, dy } = uComp.header;
|
||||||
|
const u = uComp.data;
|
||||||
|
const v = vComp.data;
|
||||||
|
|
||||||
|
const lonSpan = (((lo2 - lo1) % 360) + 360) % 360;
|
||||||
|
const lngDelta = nx > 1 ? lonSpan / (nx - 1) : dx;
|
||||||
|
const latDelta = ny > 1 ? (la2 - la1) / (ny - 1) : -Math.abs(dy);
|
||||||
|
|
||||||
|
return (lng, lat) => {
|
||||||
|
if (lngDelta === 0 || latDelta === 0) return null;
|
||||||
|
|
||||||
|
const rj = (lat - la1) / latDelta;
|
||||||
|
if (rj < 0 || rj > ny - 1) return null;
|
||||||
|
|
||||||
|
// Eastward offset from lo1 in the grid's 0..360 frame.
|
||||||
|
const dLon = (((lng - lo1) % 360) + 360) % 360;
|
||||||
|
const ci = dLon / lngDelta;
|
||||||
|
if (ci < 0 || ci > nx - 1) return null;
|
||||||
|
|
||||||
|
const i0 = Math.floor(ci);
|
||||||
|
const j0 = Math.floor(rj);
|
||||||
|
const i1 = Math.min(i0 + 1, nx - 1);
|
||||||
|
const j1 = Math.min(j0 + 1, ny - 1);
|
||||||
|
const fi = ci - i0;
|
||||||
|
const fj = rj - j0;
|
||||||
|
|
||||||
|
const a = (1 - fi) * (1 - fj);
|
||||||
|
const b = fi * (1 - fj);
|
||||||
|
const c = (1 - fi) * fj;
|
||||||
|
const d = fi * fj;
|
||||||
|
|
||||||
|
const k00 = j0 * nx + i0;
|
||||||
|
const k10 = j0 * nx + i1;
|
||||||
|
const k01 = j1 * nx + i0;
|
||||||
|
const k11 = j1 * nx + i1;
|
||||||
|
|
||||||
|
const ui = u[k00] * a + u[k10] * b + u[k01] * c + u[k11] * d;
|
||||||
|
const vi = v[k00] * a + v[k10] * b + v[k01] * c + v[k11] * d;
|
||||||
|
if (!Number.isFinite(ui) || !Number.isFinite(vi)) return null;
|
||||||
|
return [ui, vi];
|
||||||
|
};
|
||||||
|
}
|
||||||
80
src/lib/features/auth/LoginForm.svelte
Normal file
80
src/lib/features/auth/LoginForm.svelte
Normal 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>
|
||||||
147
src/lib/features/auth/Navbar.svelte
Normal file
147
src/lib/features/auth/Navbar.svelte
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { authStore } from '$auth';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { t } from '$i18n';
|
||||||
|
|
||||||
|
let isNavOpen = $state(false);
|
||||||
|
let isDropdownOpen = $state(false);
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await authStore.logout();
|
||||||
|
await goto('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAll() {
|
||||||
|
isNavOpen = false;
|
||||||
|
isDropdownOpen = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onclick={() => { if (isDropdownOpen) isDropdownOpen = false; }} />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Stretch the container chain so every nav item reaches the full navbar height */
|
||||||
|
.container-fluid {
|
||||||
|
height: 100%;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-collapse {
|
||||||
|
align-self: stretch;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-nav {
|
||||||
|
align-self: stretch;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Normalize <button> to match <a> nav-links exactly */
|
||||||
|
button.nav-link {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0;
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.nav-link:hover {
|
||||||
|
color: white !important;
|
||||||
|
background-color: var(--bs-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep dropdown within viewport — clip vertically, guard horizontal edge */
|
||||||
|
.dropdown-menu {
|
||||||
|
max-height: calc(100vh - var(--navbar-height) - 4px);
|
||||||
|
overflow-y: auto;
|
||||||
|
right: 0;
|
||||||
|
left: auto;
|
||||||
|
max-width: calc(100vw - 1.5rem);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-light fixed-top custom-navbar border-bottom">
|
||||||
|
<div class="container-fluid px-3">
|
||||||
|
<a class="navbar-brand nav-full-height" href="/">
|
||||||
|
<img src="/logo.svg" alt="Logo" height="34" class="d-inline-block align-text-top" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="navbar-toggler"
|
||||||
|
aria-controls="mainNav"
|
||||||
|
aria-expanded={isNavOpen}
|
||||||
|
aria-label="Toggle navigation"
|
||||||
|
onclick={() => (isNavOpen = !isNavOpen)}>
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="collapse navbar-collapse" class:show={isNavOpen} id="mainNav">
|
||||||
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a
|
||||||
|
href="/predict"
|
||||||
|
class="nav-link nav-full-height border border-top-0"
|
||||||
|
class:active={$page.url.pathname === '/predict'}
|
||||||
|
onclick={closeAll}>
|
||||||
|
{$t('nav.predict')}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a
|
||||||
|
href="/track"
|
||||||
|
class="nav-link nav-full-height border border-top-0"
|
||||||
|
class:active={$page.url.pathname === '/track'}
|
||||||
|
onclick={closeAll}>
|
||||||
|
{$t('nav.track')}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
{#if $authStore.status === 'authenticated' && $authStore.username}
|
||||||
|
<li class="nav-item dropdown" class:show={isDropdownOpen}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="nav-link nav-full-height border border-top-0 dropdown-toggle"
|
||||||
|
aria-expanded={isDropdownOpen}
|
||||||
|
onclick={(e) => { e.stopPropagation(); isDropdownOpen = !isDropdownOpen; }}>
|
||||||
|
{$authStore.username}
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end" class:show={isDropdownOpen}>
|
||||||
|
<li><a class="dropdown-item" href="/user/account" onclick={closeAll}>{$t('nav.account')}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="/user/templates" onclick={closeAll}>{$t('nav.scenarios')}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="/user/predictions" onclick={closeAll}>{$t('nav.predictionHistory')}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="/user/flights" onclick={closeAll}>{$t('nav.trackingHistory')}</a></li>
|
||||||
|
<li><hr class="dropdown-divider" /></li>
|
||||||
|
<li>
|
||||||
|
<button type="button" class="dropdown-item" onclick={handleLogout}>
|
||||||
|
{$t('nav.logout')}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{:else if $authStore.status === 'anonymous'}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
class="nav-link nav-full-height border border-top-0"
|
||||||
|
class:active={$page.url.pathname === '/login'}
|
||||||
|
onclick={closeAll}>
|
||||||
|
{$t('nav.login')}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
2
src/lib/features/auth/index.ts
Normal file
2
src/lib/features/auth/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { default as Navbar } from './Navbar.svelte';
|
||||||
|
export { default as LoginForm } from './LoginForm.svelte';
|
||||||
25
src/lib/features/footer/Footer.svelte
Normal file
25
src/lib/features/footer/Footer.svelte
Normal 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>
|
||||||
1
src/lib/features/footer/index.ts
Normal file
1
src/lib/features/footer/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default as Footer } from './Footer.svelte';
|
||||||
342
src/lib/features/prediction/ControlPanel.svelte
Normal file
342
src/lib/features/prediction/ControlPanel.svelte
Normal 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)} />
|
||||||
144
src/lib/features/prediction/CurveChart.svelte
Normal file
144
src/lib/features/prediction/CurveChart.svelte
Normal 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>
|
||||||
338
src/lib/features/prediction/CurveEditor.svelte
Normal file
338
src/lib/features/prediction/CurveEditor.svelte
Normal 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>
|
||||||
140
src/lib/features/prediction/PointEditor.svelte
Normal file
140
src/lib/features/prediction/PointEditor.svelte
Normal 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>
|
||||||
83
src/lib/features/prediction/ScenarioEditor.svelte
Normal file
83
src/lib/features/prediction/ScenarioEditor.svelte
Normal 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>
|
||||||
189
src/lib/features/prediction/ScenarioPanel.svelte
Normal file
189
src/lib/features/prediction/ScenarioPanel.svelte
Normal 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} />
|
||||||
7
src/lib/features/prediction/index.ts
Normal file
7
src/lib/features/prediction/index.ts
Normal 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';
|
||||||
11
src/lib/features/prediction/pointsStore.ts
Normal file
11
src/lib/features/prediction/pointsStore.ts
Normal 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[]>([]);
|
||||||
70
src/lib/features/settings/SettingsPanel.svelte
Normal file
70
src/lib/features/settings/SettingsPanel.svelte
Normal 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>
|
||||||
5
src/lib/features/settings/index.ts
Normal file
5
src/lib/features/settings/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export { settingsStore, DEFAULT_SETTINGS } from './store';
|
||||||
|
export type { AppSettings, MapSettings, UnitsSettings, WindSettings } from './store';
|
||||||
|
export { default as SettingsPanel } from './SettingsPanel.svelte';
|
||||||
|
export { SETTINGS_SCHEMA } from './schema';
|
||||||
|
export type { SettingsField, SettingsSection } from './schema';
|
||||||
172
src/lib/features/settings/schema.ts
Normal file
172
src/lib/features/settings/schema.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
/**
|
||||||
|
* 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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: 'settings.wind',
|
||||||
|
fields: [
|
||||||
|
{ kind: 'boolean', path: 'wind.enabled', labelKey: 'settings.windEnabled' },
|
||||||
|
{
|
||||||
|
kind: 'number',
|
||||||
|
path: 'wind.step',
|
||||||
|
labelKey: 'settings.windStep',
|
||||||
|
min: 0.25,
|
||||||
|
max: 10,
|
||||||
|
step: 0.25,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'number',
|
||||||
|
path: 'wind.trajectoryStep',
|
||||||
|
labelKey: 'settings.windTrajectoryStep',
|
||||||
|
min: 0.25,
|
||||||
|
max: 5,
|
||||||
|
step: 0.25,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'number',
|
||||||
|
path: 'wind.prefetchIntervalMinutes',
|
||||||
|
labelKey: 'settings.windPrefetchInterval',
|
||||||
|
min: 5,
|
||||||
|
max: 60,
|
||||||
|
step: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'number',
|
||||||
|
path: 'wind.maxFlightDurationHours',
|
||||||
|
labelKey: 'settings.windMaxDuration',
|
||||||
|
min: 1,
|
||||||
|
max: 8,
|
||||||
|
step: 0.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'number',
|
||||||
|
path: 'wind.maxRegionDegrees',
|
||||||
|
labelKey: 'settings.windMaxRegion',
|
||||||
|
min: 5,
|
||||||
|
max: 60,
|
||||||
|
step: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'number',
|
||||||
|
path: 'wind.trajectoryMarginDegrees',
|
||||||
|
labelKey: 'settings.windMargin',
|
||||||
|
min: 0.5,
|
||||||
|
max: 5,
|
||||||
|
step: 0.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'number',
|
||||||
|
path: 'wind.particleDensity',
|
||||||
|
labelKey: 'settings.windParticleDensity',
|
||||||
|
min: 0.25,
|
||||||
|
max: 3,
|
||||||
|
step: 0.25,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'number',
|
||||||
|
path: 'wind.particleSpeed',
|
||||||
|
labelKey: 'settings.windParticleSpeed',
|
||||||
|
min: 0.25,
|
||||||
|
max: 4,
|
||||||
|
step: 0.25,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'number',
|
||||||
|
path: 'wind.trailPersistence',
|
||||||
|
labelKey: 'settings.windTrailPersistence',
|
||||||
|
min: 0.7,
|
||||||
|
max: 0.98,
|
||||||
|
step: 0.02,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'number',
|
||||||
|
path: 'wind.maxVelocity',
|
||||||
|
labelKey: 'settings.windMaxVelocity',
|
||||||
|
min: 10,
|
||||||
|
max: 80,
|
||||||
|
step: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
55
src/lib/features/settings/store.ts
Normal file
55
src/lib/features/settings/store.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { persisted } from '$state';
|
||||||
|
import type { Locale } from '$i18n';
|
||||||
|
import { type WindSettings, DEFAULT_WIND_SETTINGS } from '$domain';
|
||||||
|
|
||||||
|
export type { WindSettings };
|
||||||
|
|
||||||
|
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;
|
||||||
|
wind: WindSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: AppSettings = {
|
||||||
|
locale: 'ru',
|
||||||
|
map: { baseLayer: 'osm', showScale: true, showNavigation: true },
|
||||||
|
units: { system: 'metric' },
|
||||||
|
wind: { ...DEFAULT_WIND_SETTINGS },
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
185
src/lib/features/timeline/TimeLine.svelte
Normal file
185
src/lib/features/timeline/TimeLine.svelte
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
<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">
|
||||||
|
<div class="range-wrapper">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
class="form-range"
|
||||||
|
min={$timelineStore.min}
|
||||||
|
max={$timelineStore.max}
|
||||||
|
step="1000"
|
||||||
|
value={$timelineStore.time}
|
||||||
|
oninput={onSeek}
|
||||||
|
disabled={!hasData} />
|
||||||
|
{#if hasData && $timelineStore.markers.length > 0}
|
||||||
|
<div class="marker-ticks">
|
||||||
|
{#each $timelineStore.markers as m}
|
||||||
|
{@const pct = $timelineStore.max > 0 ? m.time / $timelineStore.max : 0}
|
||||||
|
<span
|
||||||
|
class="marker-tick"
|
||||||
|
style="left: calc({pct} * (100% - 1rem) + 0.5rem); --tick-color: {m.color}">
|
||||||
|
<span class="marker-tooltip">{fmtHms(m.time)}</span>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker-ticks {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker-tick {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 3px;
|
||||||
|
height: 14px;
|
||||||
|
background: var(--tick-color, #dc3545);
|
||||||
|
border-radius: 1px;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker-tooltip {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--tick-color, #dc3545);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-family: monospace;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1010;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker-tooltip::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 4px solid transparent;
|
||||||
|
border-top-color: var(--tick-color, #dc3545);
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker-tick:hover .marker-tooltip {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.timeline-container {
|
||||||
|
min-width: calc(100vw - 24px);
|
||||||
|
max-width: calc(100vw - 24px);
|
||||||
|
bottom: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
3
src/lib/features/timeline/index.ts
Normal file
3
src/lib/features/timeline/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { timelineStore } from './store';
|
||||||
|
export type { TimelineState, TimelineMarker } from './store';
|
||||||
|
export { default as TimeLine } from './TimeLine.svelte';
|
||||||
104
src/lib/features/timeline/store.ts
Normal file
104
src/lib/features/timeline/store.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
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 TimelineMarker {
|
||||||
|
time: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineState {
|
||||||
|
time: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
speed: number;
|
||||||
|
playing: boolean;
|
||||||
|
markers: TimelineMarker[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const initial: TimelineState = {
|
||||||
|
time: 0,
|
||||||
|
min: 0,
|
||||||
|
max: 0,
|
||||||
|
speed: 1,
|
||||||
|
playing: false,
|
||||||
|
markers: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMarkers(markers: TimelineMarker[]) {
|
||||||
|
store.update((s) => ({ ...s, markers }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { subscribe: store.subscribe, play, pause, reset, seek, setSpeed, setRange, setMarkers };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const timelineStore = createTimeline();
|
||||||
203
src/lib/features/tracking/DeviationChart.svelte
Normal file
203
src/lib/features/tracking/DeviationChart.svelte
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { Chart as ChartJS, type ChartDataset } from 'chart.js/auto';
|
||||||
|
import 'chartjs-adapter-luxon';
|
||||||
|
import { computeDeviations, type TelemetryPoint, type Prediction } from '$domain';
|
||||||
|
import { t } from '$i18n';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
points: TelemetryPoint[];
|
||||||
|
prediction?: Prediction | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { points, prediction = null }: Props = $props();
|
||||||
|
|
||||||
|
let altCanvas: HTMLCanvasElement;
|
||||||
|
let devCanvas: HTMLCanvasElement;
|
||||||
|
let altChart: ChartJS | null = null;
|
||||||
|
let devChart: ChartJS | null = null;
|
||||||
|
|
||||||
|
const deviations = $derived(
|
||||||
|
prediction && points.length > 0 ? computeDeviations(points, prediction) : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Full prediction altitude series — drawn independently of telemetry sample rate.
|
||||||
|
const predAltData = $derived(
|
||||||
|
prediction
|
||||||
|
? prediction.timestamps.map((tsMs, idx) => ({
|
||||||
|
x: tsMs,
|
||||||
|
y: (prediction.flight_path[idx][2] as number | undefined) ?? 0,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasData = $derived(points.length > 0);
|
||||||
|
const hasDeviation = $derived(!!deviations && deviations.length > 0);
|
||||||
|
|
||||||
|
// ── shared axis options ─────────────────────────────────────────────────
|
||||||
|
const timeAxis = {
|
||||||
|
type: 'time' as const,
|
||||||
|
time: {
|
||||||
|
unit: 'minute' as const,
|
||||||
|
displayFormats: { minute: 'HH:mm' },
|
||||||
|
tooltipFormat: 'HH:mm:ss',
|
||||||
|
},
|
||||||
|
adapters: { date: { zone: 'UTC' } },
|
||||||
|
title: { display: true, text: 'UTC', font: { size: 10 } },
|
||||||
|
ticks: { font: { size: 9 }, maxRotation: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const commonOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
animation: false as const,
|
||||||
|
interaction: { mode: 'index' as const, intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'top' as const, labels: { boxWidth: 10, font: { size: 10 } } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── chart creation ──────────────────────────────────────────────────────
|
||||||
|
onMount(() => {
|
||||||
|
altChart = new ChartJS(altCanvas.getContext('2d')!, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Фактическая, м',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#FF1744',
|
||||||
|
backgroundColor: 'rgba(255,23,68,0.08)',
|
||||||
|
fill: false,
|
||||||
|
pointRadius: 0,
|
||||||
|
borderWidth: 2,
|
||||||
|
} as ChartDataset<'line'>,
|
||||||
|
{
|
||||||
|
label: 'Прогноз, м',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#1565C0',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
fill: false,
|
||||||
|
pointRadius: 0,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderDash: [6, 3],
|
||||||
|
} as ChartDataset<'line'>,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
...commonOptions,
|
||||||
|
scales: {
|
||||||
|
x: timeAxis,
|
||||||
|
y: {
|
||||||
|
title: { display: true, text: 'Высота, м', font: { size: 10 } },
|
||||||
|
ticks: { font: { size: 9 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
devChart = new ChartJS(devCanvas.getContext('2d')!, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Откл., км',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#F57F17',
|
||||||
|
backgroundColor: 'rgba(245,127,23,0.15)',
|
||||||
|
fill: true,
|
||||||
|
pointRadius: 0,
|
||||||
|
borderWidth: 2,
|
||||||
|
} as ChartDataset<'line'>,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
...commonOptions,
|
||||||
|
scales: {
|
||||||
|
x: timeAxis,
|
||||||
|
y: {
|
||||||
|
min: 0,
|
||||||
|
title: { display: true, text: 'Откл., км', font: { size: 10 } },
|
||||||
|
ticks: { font: { size: 9 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: { ...commonOptions.plugins, legend: { display: false } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── altitude chart: update on every telemetry or prediction change ──────
|
||||||
|
$effect(() => {
|
||||||
|
if (!altChart) return;
|
||||||
|
altChart.data.datasets[0].data = points.map((p) => ({
|
||||||
|
x: new Date(p.datetime).getTime(),
|
||||||
|
y: p.altitude,
|
||||||
|
}));
|
||||||
|
altChart.data.datasets[1].data = predAltData;
|
||||||
|
altChart.update('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── deviation chart: update when computed deviations change ────────────
|
||||||
|
$effect(() => {
|
||||||
|
if (!devChart) return;
|
||||||
|
devChart.data.datasets[0].data =
|
||||||
|
deviations?.map((d) => ({ x: d.timeMs, y: d.horizontal })) ?? [];
|
||||||
|
devChart.update('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
altChart?.destroy();
|
||||||
|
devChart?.destroy();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Both canvases are ALWAYS in the DOM so Chart.js instances created in
|
||||||
|
onMount always have a valid canvas reference. Sections are shown/hidden
|
||||||
|
via d-none; Chart.js v3+ ResizeObserver picks up dimension changes when
|
||||||
|
display:none is removed and re-renders at the correct size.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!-- ── No-data placeholder ─────────────────────────────────────────────── -->
|
||||||
|
{#if !hasData}
|
||||||
|
<p class="text-muted small text-center py-3 mb-0">{$t('tracking.noData')}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- ── Altitude profile (always rendered, hidden while no data) ─────────── -->
|
||||||
|
<div class:d-none={!hasData}>
|
||||||
|
<p class="small fw-semibold mb-1">{$t('tracking.altProfile')}</p>
|
||||||
|
<div style="position: relative; height: 170px;">
|
||||||
|
<canvas bind:this={altCanvas}></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !hasDeviation}
|
||||||
|
<p class="small text-muted mt-2 mb-0">{$t('tracking.selectPrediction')}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Horizontal deviation (always rendered, hidden while no prediction) ── -->
|
||||||
|
<div class:d-none={!hasDeviation}>
|
||||||
|
<hr class="my-2" />
|
||||||
|
<p class="small fw-semibold mb-1">{$t('tracking.horizontalDev')}</p>
|
||||||
|
<div style="position: relative; height: 130px;">
|
||||||
|
<canvas bind:this={devCanvas}></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if deviations && deviations.length > 0}
|
||||||
|
{@const maxDev = Math.max(...deviations.map((d) => d.horizontal))}
|
||||||
|
{@const last = deviations[deviations.length - 1]}
|
||||||
|
<div class="d-flex gap-3 mt-2 flex-wrap">
|
||||||
|
<small class="text-muted">
|
||||||
|
{$t('tracking.devMax')} <span class="fw-semibold text-body">{maxDev.toFixed(2)} км</span>
|
||||||
|
</small>
|
||||||
|
<small class="text-muted">
|
||||||
|
{$t('tracking.devCurrent')} <span class="fw-semibold text-body">{last.horizontal.toFixed(2)} км</span>
|
||||||
|
</small>
|
||||||
|
<small class="text-muted">
|
||||||
|
Δh: <span class="fw-semibold text-body">
|
||||||
|
{last.vertical > 0 ? '+' : ''}{last.vertical.toFixed(0)} м
|
||||||
|
</span>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
100
src/lib/features/tracking/TelemetryPanel.svelte
Normal file
100
src/lib/features/tracking/TelemetryPanel.svelte
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import { FormGroup, Label, Input, InputGroup, Button, Badge } from '@sveltestrap/sveltestrap';
|
||||||
|
import { CollapsibleCard } from '$ui';
|
||||||
|
import { t } from '$i18n';
|
||||||
|
import { telemetryStore } from './telemetryStore.svelte';
|
||||||
|
|
||||||
|
let satelliteInput = $state('');
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
idle: 'secondary',
|
||||||
|
connecting: 'warning',
|
||||||
|
connected: 'success',
|
||||||
|
error: 'danger',
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleConnect() {
|
||||||
|
const id = satelliteInput.trim();
|
||||||
|
if (id) telemetryStore.connect(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDisconnect() {
|
||||||
|
telemetryStore.disconnect();
|
||||||
|
satelliteInput = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
telemetryStore.disconnect();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CollapsibleCard title={$t('nav.track')}>
|
||||||
|
<FormGroup spacing="mb-2">
|
||||||
|
<Label class="small">{$t('tracking.satelliteId')}</Label>
|
||||||
|
<div class="d-flex gap-1">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
size="sm"
|
||||||
|
bind:value={satelliteInput}
|
||||||
|
placeholder={$t('tracking.satelliteIdPlaceholder')}
|
||||||
|
disabled={telemetryStore.status !== 'idle'}
|
||||||
|
/>
|
||||||
|
{#if telemetryStore.status === 'idle'}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="primary"
|
||||||
|
onclick={handleConnect}
|
||||||
|
disabled={!satelliteInput.trim()}
|
||||||
|
>
|
||||||
|
{$t('tracking.connect')}
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<Button size="sm" color="secondary" onclick={handleDisconnect}>
|
||||||
|
{$t('tracking.disconnect')}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup spacing="mb-2">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<Label class="small mb-0">{$t('tracking.status')}</Label>
|
||||||
|
<Badge color={STATUS_COLOR[telemetryStore.status]}>
|
||||||
|
{$t(`tracking.status_${telemetryStore.status}`)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{#if telemetryStore.error}
|
||||||
|
<small class="text-danger">{telemetryStore.error}</small>
|
||||||
|
{/if}
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
{#if telemetryStore.latest}
|
||||||
|
<FormGroup spacing="mb-2">
|
||||||
|
<Label class="small">{$t('points.lat')}</Label>
|
||||||
|
<InputGroup size="sm">
|
||||||
|
<Input type="text" value={telemetryStore.latest.latitude.toFixed(6)} readonly />
|
||||||
|
</InputGroup>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup spacing="mb-2">
|
||||||
|
<Label class="small">{$t('points.lon')}</Label>
|
||||||
|
<InputGroup size="sm">
|
||||||
|
<Input type="text" value={telemetryStore.latest.longitude.toFixed(6)} readonly />
|
||||||
|
</InputGroup>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup spacing="mb-2">
|
||||||
|
<Label class="small">{$t('points.alt')}</Label>
|
||||||
|
<InputGroup size="sm">
|
||||||
|
<Input type="text" value={telemetryStore.latest.altitude.toFixed(1)} readonly />
|
||||||
|
</InputGroup>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<small class="text-muted">
|
||||||
|
{$t('tracking.packetCount', { count: telemetryStore.points.length })}
|
||||||
|
</small>
|
||||||
|
{:else if telemetryStore.status !== 'idle'}
|
||||||
|
<small class="text-muted">{$t('tracking.waitingData')}</small>
|
||||||
|
{/if}
|
||||||
|
</CollapsibleCard>
|
||||||
3
src/lib/features/tracking/index.ts
Normal file
3
src/lib/features/tracking/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { default as TelemetryPanel } from './TelemetryPanel.svelte';
|
||||||
|
export { default as DeviationChart } from './DeviationChart.svelte';
|
||||||
|
export { telemetryStore, type TrackingStatus } from './telemetryStore.svelte';
|
||||||
99
src/lib/features/tracking/telemetryStore.svelte.ts
Normal file
99
src/lib/features/tracking/telemetryStore.svelte.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { telemetryApi, buildWsUrl, type RawTelemetryPacket } from '$api/telemetry';
|
||||||
|
import { parseTelemetry, type TelemetryPoint, type Telemetry } from '$domain';
|
||||||
|
|
||||||
|
export type TrackingStatus = 'idle' | 'connecting' | 'connected' | 'error';
|
||||||
|
|
||||||
|
function toPoint(p: RawTelemetryPacket): TelemetryPoint {
|
||||||
|
return {
|
||||||
|
latitude: p.lat,
|
||||||
|
longitude: p.lon,
|
||||||
|
altitude: p.alt,
|
||||||
|
datetime: new Date(p.timestamp * 1000).toISOString(),
|
||||||
|
payload: JSON.stringify(p.payload),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class TelemetryStore {
|
||||||
|
satelliteId = $state('');
|
||||||
|
status = $state<TrackingStatus>('idle');
|
||||||
|
error = $state<string | null>(null);
|
||||||
|
points = $state<TelemetryPoint[]>([]);
|
||||||
|
|
||||||
|
#ws: WebSocket | null = null;
|
||||||
|
|
||||||
|
get latest(): TelemetryPoint | null {
|
||||||
|
return this.points[this.points.length - 1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get telemetry(): Telemetry | null {
|
||||||
|
if (this.points.length === 0) return null;
|
||||||
|
try {
|
||||||
|
return parseTelemetry(this.points);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(id: string): Promise<void> {
|
||||||
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
if (!UUID_RE.test(id)) {
|
||||||
|
this.status = 'error';
|
||||||
|
this.error = `Invalid satellite ID — expected a UUID (e.g. 550e8400-e29b-41d4-a716-446655440000)`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.disconnect();
|
||||||
|
this.satelliteId = id;
|
||||||
|
this.status = 'connecting';
|
||||||
|
this.error = null;
|
||||||
|
this.points = [];
|
||||||
|
|
||||||
|
// Load historical packets first — non-fatal if it fails
|
||||||
|
try {
|
||||||
|
const history = await telemetryApi.fetchHistory(id);
|
||||||
|
// API returns newest-first; reverse to chronological order
|
||||||
|
this.points = [...history].reverse().map(toPoint);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[telemetry] history fetch failed:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ws = new WebSocket(buildWsUrl(id));
|
||||||
|
this.#ws = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
this.status = 'connected';
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = ({ data }) => {
|
||||||
|
try {
|
||||||
|
const packet = JSON.parse(data) as { error?: string } & RawTelemetryPacket;
|
||||||
|
if (!packet.error) {
|
||||||
|
this.points = [...this.points, toPoint(packet)];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore malformed frames
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
this.status = 'error';
|
||||||
|
this.error = 'WebSocket connection failed';
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (this.status !== 'idle') this.status = 'idle';
|
||||||
|
this.#ws = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
this.#ws?.close();
|
||||||
|
this.#ws = null;
|
||||||
|
this.status = 'idle';
|
||||||
|
this.satelliteId = '';
|
||||||
|
this.points = [];
|
||||||
|
this.error = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const telemetryStore = new TelemetryStore();
|
||||||
335
src/lib/features/wind/ParticleField.ts
Normal file
335
src/lib/features/wind/ParticleField.ts
Normal file
|
|
@ -0,0 +1,335 @@
|
||||||
|
/**
|
||||||
|
* ParticleField — an animated wind-flow layer rendered to a 2D canvas
|
||||||
|
* overlaid on the MapLibre container, in the spirit of leaflet-velocity /
|
||||||
|
* cambecc's "earth".
|
||||||
|
*
|
||||||
|
* Particles live in CSS-pixel space. Each frame, every particle is unprojected
|
||||||
|
* to lng/lat, the wind [u, v] there is sampled, and that vector is pushed
|
||||||
|
* through the map projection's local Jacobian to obtain a pixel-space velocity
|
||||||
|
* (so motion is correct at any zoom/latitude). Trails are faded by compositing
|
||||||
|
* a translucent clear over the previous frame, leaving the basemap visible.
|
||||||
|
*
|
||||||
|
* The wind field can change every frame (the renderer interpolates between
|
||||||
|
* pre-fetched trajectory frames over time); only the lightweight interpolator
|
||||||
|
* closure is swapped, so particle motion stays continuous. See
|
||||||
|
* docs/wind-vis-math.tex §"Particle Advection".
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Map as MLMap } from 'maplibre-gl';
|
||||||
|
import type { WindInterpolator } from '$domain';
|
||||||
|
|
||||||
|
export interface ParticleOptions {
|
||||||
|
/** Particles per screen pixel (scaled by the base multiplier). */
|
||||||
|
density: number;
|
||||||
|
/** Advection speed multiplier. */
|
||||||
|
speed: number;
|
||||||
|
/** Trail persistence in [0,1): fraction of the trail kept each frame. */
|
||||||
|
trailPersistence: number;
|
||||||
|
/** Max frames a particle lives before it is respawned. */
|
||||||
|
maxAge: number;
|
||||||
|
/** Trail line width (CSS px). */
|
||||||
|
lineWidth: number;
|
||||||
|
/** Wind speed (m/s) at the bottom / top of the colour scale. */
|
||||||
|
minVelocity: number;
|
||||||
|
maxVelocity: number;
|
||||||
|
/** Target frame rate (the field is re-evaluated at most this often). */
|
||||||
|
frameRate: number;
|
||||||
|
/** Colour ramp from slow → fast wind. */
|
||||||
|
colorScale: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_COLOR_SCALE = [
|
||||||
|
'rgb(36,104,180)',
|
||||||
|
'rgb(60,157,194)',
|
||||||
|
'rgb(128,205,193)',
|
||||||
|
'rgb(151,218,168)',
|
||||||
|
'rgb(198,231,181)',
|
||||||
|
'rgb(238,247,217)',
|
||||||
|
'rgb(255,238,159)',
|
||||||
|
'rgb(252,217,125)',
|
||||||
|
'rgb(255,182,100)',
|
||||||
|
'rgb(252,150,75)',
|
||||||
|
'rgb(250,112,52)',
|
||||||
|
'rgb(245,64,32)',
|
||||||
|
'rgb(237,45,28)',
|
||||||
|
'rgb(220,24,32)',
|
||||||
|
'rgb(180,0,35)',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_PARTICLE_OPTIONS: ParticleOptions = {
|
||||||
|
density: 1.0,
|
||||||
|
speed: 1.0,
|
||||||
|
trailPersistence: 0.92,
|
||||||
|
maxAge: 100,
|
||||||
|
lineWidth: 1.4,
|
||||||
|
minVelocity: 0,
|
||||||
|
maxVelocity: 30,
|
||||||
|
frameRate: 30,
|
||||||
|
colorScale: DEFAULT_COLOR_SCALE,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Base particle count = pixels × this (kept modest for performance). */
|
||||||
|
const PARTICLE_MULTIPLIER = 1 / 350;
|
||||||
|
const MAX_PARTICLES = 6000;
|
||||||
|
|
||||||
|
interface Particle {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
xt: number;
|
||||||
|
yt: number;
|
||||||
|
age: number;
|
||||||
|
speed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ParticleField {
|
||||||
|
private map: MLMap;
|
||||||
|
private host: HTMLElement;
|
||||||
|
private canvas: HTMLCanvasElement;
|
||||||
|
private ctx: CanvasRenderingContext2D;
|
||||||
|
private opts: ParticleOptions;
|
||||||
|
private interp: WindInterpolator | null = null;
|
||||||
|
|
||||||
|
private particles: Particle[] = [];
|
||||||
|
private raf = 0;
|
||||||
|
private then = 0;
|
||||||
|
private moving = false;
|
||||||
|
private width = 0;
|
||||||
|
private height = 0;
|
||||||
|
private debugLogged = false;
|
||||||
|
|
||||||
|
constructor(map: MLMap, opts: Partial<ParticleOptions> = {}) {
|
||||||
|
this.map = map;
|
||||||
|
this.opts = { ...DEFAULT_PARTICLE_OPTIONS, ...opts };
|
||||||
|
|
||||||
|
// Mount inside the MapLibre canvas container so the overlay sits above
|
||||||
|
// the basemap but below the control container and the app's panels.
|
||||||
|
this.host = map.getCanvasContainer();
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.className = 'wind-particles';
|
||||||
|
canvas.style.position = 'absolute';
|
||||||
|
canvas.style.top = '0';
|
||||||
|
canvas.style.left = '0';
|
||||||
|
canvas.style.pointerEvents = 'none';
|
||||||
|
canvas.style.zIndex = '3';
|
||||||
|
this.host.appendChild(canvas);
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.ctx = canvas.getContext('2d')!;
|
||||||
|
|
||||||
|
this.map.on('movestart', this.onMoveStart);
|
||||||
|
this.map.on('moveend', this.onMoveEnd);
|
||||||
|
this.map.on('resize', this.onResize);
|
||||||
|
this.resize();
|
||||||
|
}
|
||||||
|
|
||||||
|
setOptions(opts: Partial<ParticleOptions>): void {
|
||||||
|
const densityChanged = opts.density !== undefined && opts.density !== this.opts.density;
|
||||||
|
this.opts = { ...this.opts, ...opts };
|
||||||
|
if (densityChanged) this.seedParticles();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Swap the wind field. Pass null to clear the flow. */
|
||||||
|
setField(interp: WindInterpolator | null): void {
|
||||||
|
this.interp = interp;
|
||||||
|
if (interp && this.particles.length === 0) this.seedParticles();
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.raf) return;
|
||||||
|
this.then = performance.now();
|
||||||
|
this.raf = requestAnimationFrame(this.frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.raf) cancelAnimationFrame(this.raf);
|
||||||
|
this.raf = 0;
|
||||||
|
this.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.stop();
|
||||||
|
this.map.off('movestart', this.onMoveStart);
|
||||||
|
this.map.off('moveend', this.onMoveEnd);
|
||||||
|
this.map.off('resize', this.onResize);
|
||||||
|
this.canvas.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internals ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private onMoveStart = (): void => {
|
||||||
|
this.moving = true;
|
||||||
|
this.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
private onMoveEnd = (): void => {
|
||||||
|
this.moving = false;
|
||||||
|
this.seedParticles();
|
||||||
|
};
|
||||||
|
|
||||||
|
private onResize = (): void => {
|
||||||
|
this.resize();
|
||||||
|
};
|
||||||
|
|
||||||
|
private resize(): void {
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
// Size from the gl canvas: it always reports the true viewport size,
|
||||||
|
// whereas the canvas-container wrapper can measure 0 in some layouts.
|
||||||
|
const glCanvas = this.map.getCanvas();
|
||||||
|
const w = glCanvas.clientWidth || this.map.getContainer().clientWidth;
|
||||||
|
const h = glCanvas.clientHeight || this.map.getContainer().clientHeight;
|
||||||
|
if (!w || !h) return;
|
||||||
|
this.width = w;
|
||||||
|
this.height = h;
|
||||||
|
this.canvas.style.width = `${w}px`;
|
||||||
|
this.canvas.style.height = `${h}px`;
|
||||||
|
this.canvas.width = Math.round(w * dpr);
|
||||||
|
this.canvas.height = Math.round(h * dpr);
|
||||||
|
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // draw in CSS-pixel space
|
||||||
|
this.seedParticles();
|
||||||
|
}
|
||||||
|
|
||||||
|
private particleCount(): number {
|
||||||
|
const n = this.width * this.height * PARTICLE_MULTIPLIER * this.opts.density;
|
||||||
|
return Math.max(0, Math.min(MAX_PARTICLES, Math.round(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private seedParticles(): void {
|
||||||
|
const count = this.particleCount();
|
||||||
|
this.particles = new Array(count);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
this.particles[i] = { x: 0, y: 0, xt: 0, yt: 0, age: 0, speed: 0 };
|
||||||
|
this.respawn(this.particles[i]);
|
||||||
|
this.particles[i].age = Math.floor(Math.random() * this.opts.maxAge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Place a particle at a random pixel that has wind (a few retries). */
|
||||||
|
private respawn(p: Particle): void {
|
||||||
|
for (let attempt = 0; attempt < 8; attempt++) {
|
||||||
|
const x = Math.random() * this.width;
|
||||||
|
const y = Math.random() * this.height;
|
||||||
|
if (!this.interp) {
|
||||||
|
p.x = p.xt = x;
|
||||||
|
p.y = p.yt = y;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const ll = this.map.unproject([x, y]);
|
||||||
|
if (this.interp(ll.lng, ll.lat)) {
|
||||||
|
p.x = p.xt = x;
|
||||||
|
p.y = p.yt = y;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
p.x = p.xt = x;
|
||||||
|
p.y = p.yt = y;
|
||||||
|
}
|
||||||
|
p.age = 0;
|
||||||
|
p.speed = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private clear(): void {
|
||||||
|
this.ctx.clearRect(0, 0, this.width, this.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private colorIndex(speed: number): number {
|
||||||
|
const { minVelocity, maxVelocity, colorScale } = this.opts;
|
||||||
|
const f = (speed - minVelocity) / (maxVelocity - minVelocity);
|
||||||
|
return Math.max(0, Math.min(colorScale.length - 1, Math.round(f * (colorScale.length - 1))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private evolve(): void {
|
||||||
|
const interp = this.interp;
|
||||||
|
if (!interp) return;
|
||||||
|
const scale = 0.06 * this.opts.speed; // pixel velocity = Jacobian·wind·scale
|
||||||
|
const eps = 0.02; // degrees, for the projection Jacobian
|
||||||
|
|
||||||
|
for (const p of this.particles) {
|
||||||
|
if (p.age >= this.opts.maxAge) {
|
||||||
|
this.respawn(p);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ll = this.map.unproject([p.x, p.y]);
|
||||||
|
const wind = interp(ll.lng, ll.lat);
|
||||||
|
if (!wind) {
|
||||||
|
p.age = this.opts.maxAge; // escaped the field → respawn next tick
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [u, v] = wind;
|
||||||
|
|
||||||
|
// Local projection Jacobian: pixel deltas per degree at this point.
|
||||||
|
const east = this.map.project([ll.lng + eps, ll.lat]);
|
||||||
|
const north = this.map.project([ll.lng, ll.lat + eps]);
|
||||||
|
const jxLng = (east.x - p.x) / eps;
|
||||||
|
const jyLng = (east.y - p.y) / eps;
|
||||||
|
const jxLat = (north.x - p.x) / eps;
|
||||||
|
const jyLat = (north.y - p.y) / eps;
|
||||||
|
|
||||||
|
p.xt = p.x + (jxLng * u + jxLat * v) * scale;
|
||||||
|
p.yt = p.y + (jyLng * u + jyLat * v) * scale;
|
||||||
|
p.speed = Math.sqrt(u * u + v * v);
|
||||||
|
p.age += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private draw(): void {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
|
||||||
|
// Fade existing trails toward transparent (keeps the basemap visible).
|
||||||
|
ctx.globalCompositeOperation = 'destination-in';
|
||||||
|
ctx.fillStyle = `rgba(0,0,0,${this.opts.trailPersistence})`;
|
||||||
|
ctx.fillRect(0, 0, this.width, this.height);
|
||||||
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
|
|
||||||
|
// Draw new trail segments, grouped by colour bucket.
|
||||||
|
const { colorScale } = this.opts;
|
||||||
|
ctx.lineWidth = this.opts.lineWidth;
|
||||||
|
const buckets: Particle[][] = colorScale.map(() => []);
|
||||||
|
for (const p of this.particles) {
|
||||||
|
if (p.age >= this.opts.maxAge || p.speed === 0) continue;
|
||||||
|
buckets[this.colorIndex(p.speed)].push(p);
|
||||||
|
}
|
||||||
|
let drawn = 0;
|
||||||
|
for (let i = 0; i < buckets.length; i++) {
|
||||||
|
const bucket = buckets[i];
|
||||||
|
if (bucket.length === 0) continue;
|
||||||
|
drawn += bucket.length;
|
||||||
|
ctx.strokeStyle = colorScale[i];
|
||||||
|
ctx.beginPath();
|
||||||
|
for (const p of bucket) {
|
||||||
|
ctx.moveTo(p.x, p.y);
|
||||||
|
ctx.lineTo(p.xt, p.yt);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.env.DEV && !this.debugLogged) {
|
||||||
|
this.debugLogged = true;
|
||||||
|
// One-shot diagnostic: confirms field, canvas size, and that segments
|
||||||
|
// are actually being drawn. Remove once the layer is verified.
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.debug('[wind] first draw', {
|
||||||
|
hasInterp: !!this.interp,
|
||||||
|
canvas: `${this.width}x${this.height}`,
|
||||||
|
backing: `${this.canvas.width}x${this.canvas.height}`,
|
||||||
|
particles: this.particles.length,
|
||||||
|
drawnSegments: drawn,
|
||||||
|
host: this.host.className,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance positions for the next frame.
|
||||||
|
for (const p of this.particles) {
|
||||||
|
p.x = p.xt;
|
||||||
|
p.y = p.yt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private frame = (now: number): void => {
|
||||||
|
this.raf = requestAnimationFrame(this.frame);
|
||||||
|
if (this.moving || !this.interp) return;
|
||||||
|
const frameTime = 1000 / this.opts.frameRate;
|
||||||
|
if (now - this.then < frameTime) return;
|
||||||
|
this.then = now - ((now - this.then) % frameTime);
|
||||||
|
this.evolve();
|
||||||
|
this.draw();
|
||||||
|
};
|
||||||
|
}
|
||||||
332
src/lib/features/wind/WindRenderer.svelte
Normal file
332
src/lib/features/wind/WindRenderer.svelte
Normal file
|
|
@ -0,0 +1,332 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* WindRenderer — renderless component that drives an animated particle-flow
|
||||||
|
* wind layer (ParticleField) over the shared MapLibre map.
|
||||||
|
*
|
||||||
|
* Two display modes:
|
||||||
|
*
|
||||||
|
* Static – shown whenever wind is enabled but no trajectory is available.
|
||||||
|
* Fetches the global wind field at the active workspace's launch
|
||||||
|
* altitude and datetime.
|
||||||
|
*
|
||||||
|
* Trajectory sync – activated once the active workspace has a prediction
|
||||||
|
* result AND the timeline has a non-zero range. Pre-fetches one
|
||||||
|
* wind field per `prefetchIntervalMinutes` along the flight path
|
||||||
|
* (altitude matches the trajectory at each time step), then
|
||||||
|
* linearly interpolates [u, v] between the two bracketing frames
|
||||||
|
* as the timeline scrubs, so the flow evolves smoothly.
|
||||||
|
*
|
||||||
|
* Sanity guards (all configurable in settings → Wind):
|
||||||
|
* • Flight duration > maxFlightDurationHours → trajectory sync disabled.
|
||||||
|
* • Bounding box > maxRegionDegrees in either axis → skipped.
|
||||||
|
* • Minimum step clamped to 0.25° (API limit).
|
||||||
|
*
|
||||||
|
* The actual particle rendering lives in ParticleField (a 2D canvas overlay);
|
||||||
|
* getRawInstance() is used here deliberately because that overlay needs the
|
||||||
|
* raw MapLibre projection/container, which the IMap/Scene abstraction does
|
||||||
|
* not expose. See docs/wind-vis-math.tex for the advection math.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import type { Map as MLMap } from 'maplibre-gl';
|
||||||
|
import { getMap } from '$map';
|
||||||
|
import { settingsStore } from '$features/settings';
|
||||||
|
import { workspacesStore, getActiveWorkspace } from '$features/workspaces';
|
||||||
|
import { timelineStore } from '$features/timeline/store';
|
||||||
|
import {
|
||||||
|
createWindInterpolator,
|
||||||
|
DEFAULT_WIND_SETTINGS,
|
||||||
|
type WindField,
|
||||||
|
type WindComponent,
|
||||||
|
type WindSettings,
|
||||||
|
} from '$domain';
|
||||||
|
import type { Prediction, LatLngTuple } from '$domain';
|
||||||
|
import { windCache } from './store';
|
||||||
|
import { ParticleField, type ParticleOptions } from './ParticleField';
|
||||||
|
|
||||||
|
// ── Map handle ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const map = getMap();
|
||||||
|
if (!map) throw new Error('WindRenderer must be a descendant of <Map />');
|
||||||
|
const mlMap = map.getRawInstance() as MLMap;
|
||||||
|
|
||||||
|
// ── State ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface WindFrame {
|
||||||
|
flightTimeMs: number;
|
||||||
|
field: WindField;
|
||||||
|
}
|
||||||
|
|
||||||
|
let particleField: ParticleField | null = null;
|
||||||
|
|
||||||
|
let currentField = $state<WindField | null>(null);
|
||||||
|
let trajectoryFrames = $state<WindFrame[]>([]);
|
||||||
|
let prefetchKey: string | null = null; // non-reactive — tracks last pre-fetch identity
|
||||||
|
let staticFetchSeq = 0; // monotonically incremented to cancel stale static fetches
|
||||||
|
let prefetchSkipReason = $state<string | null>(null);
|
||||||
|
|
||||||
|
// ── Derived reactive values ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
const windSettings = $derived<WindSettings>({
|
||||||
|
...DEFAULT_WIND_SETTINGS,
|
||||||
|
...($settingsStore.wind ?? {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeWorkspace = $derived(getActiveWorkspace($workspacesStore));
|
||||||
|
const activePrediction = $derived(activeWorkspace?.result ?? null);
|
||||||
|
|
||||||
|
const inTrajectoryMode = $derived(
|
||||||
|
windSettings.enabled && activePrediction !== null && $timelineStore.max > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Particle field ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function particleOptions(s: WindSettings): Partial<ParticleOptions> {
|
||||||
|
return {
|
||||||
|
density: s.particleDensity,
|
||||||
|
speed: s.particleSpeed,
|
||||||
|
trailPersistence: s.trailPersistence,
|
||||||
|
maxVelocity: s.maxVelocity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureField(): ParticleField {
|
||||||
|
if (!particleField) {
|
||||||
|
particleField = new ParticleField(mlMap, particleOptions(windSettings));
|
||||||
|
}
|
||||||
|
return particleField;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Trajectory helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function trajectoryBBox(path: LatLngTuple[], marginDeg: number) {
|
||||||
|
let minLat = Infinity,
|
||||||
|
maxLat = -Infinity,
|
||||||
|
minLng = Infinity,
|
||||||
|
maxLng = -Infinity;
|
||||||
|
for (const p of path) {
|
||||||
|
if (p[0] < minLat) minLat = p[0];
|
||||||
|
if (p[0] > maxLat) maxLat = p[0];
|
||||||
|
if (p[1] < minLng) minLng = p[1];
|
||||||
|
if (p[1] > maxLng) maxLng = p[1];
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
min_lat: minLat - marginDeg,
|
||||||
|
max_lat: maxLat + marginDeg,
|
||||||
|
min_lng: minLng - marginDeg,
|
||||||
|
max_lng: maxLng + marginDeg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Binary-search the trajectory for the altitude at a given flight-time offset. */
|
||||||
|
function altAtFlightTime(prediction: Prediction, flightTimeMs: number): number {
|
||||||
|
const { flight_path, timestamps } = prediction;
|
||||||
|
if (!flight_path.length) return 0;
|
||||||
|
const targetMs = timestamps[0] + flightTimeMs;
|
||||||
|
let lo = 0,
|
||||||
|
hi = timestamps.length - 1;
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
if (timestamps[mid] < targetMs) lo = mid + 1;
|
||||||
|
else hi = mid;
|
||||||
|
}
|
||||||
|
const p = flight_path[Math.min(lo, flight_path.length - 1)];
|
||||||
|
return p[2] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Linearly blend one wind component (u or v) of two aligned grids. */
|
||||||
|
function lerpComponent(a: WindComponent, b: WindComponent, f: number): WindComponent {
|
||||||
|
if (a.data.length !== b.data.length) return f < 0.5 ? a : b;
|
||||||
|
const data = new Array<number>(a.data.length);
|
||||||
|
for (let k = 0; k < data.length; k++) data[k] = a.data[k] + (b.data[k] - a.data[k]) * f;
|
||||||
|
return { header: a.header, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wind field at flight-time `t`, linearly interpolated between the two
|
||||||
|
* bracketing pre-fetched frames so the field evolves smoothly as the
|
||||||
|
* timeline scrubs. Frames share the same bbox/step, so their grids align
|
||||||
|
* cell-for-cell and the [u,v] arrays can be blended directly.
|
||||||
|
*/
|
||||||
|
function fieldAtFlightTime(t: number): WindField | null {
|
||||||
|
// trajectoryFrames is $state — reading it here creates a reactive dependency
|
||||||
|
const frames = trajectoryFrames;
|
||||||
|
if (!frames.length) return null;
|
||||||
|
if (frames.length === 1 || t <= frames[0].flightTimeMs) return frames[0].field;
|
||||||
|
const last = frames[frames.length - 1];
|
||||||
|
if (t >= last.flightTimeMs) return last.field;
|
||||||
|
|
||||||
|
let hi = 1;
|
||||||
|
while (hi < frames.length && frames[hi].flightTimeMs < t) hi++;
|
||||||
|
const f0 = frames[hi - 1];
|
||||||
|
const f1 = frames[hi];
|
||||||
|
const span = f1.flightTimeMs - f0.flightTimeMs;
|
||||||
|
const a = span > 0 ? (t - f0.flightTimeMs) / span : 0;
|
||||||
|
if (a <= 0) return f0.field;
|
||||||
|
if (a >= 1) return f1.field;
|
||||||
|
return [lerpComponent(f0.field[0], f1.field[0], a), lerpComponent(f0.field[1], f1.field[1], a)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePrefetchKey(prediction: Prediction, s: WindSettings): string {
|
||||||
|
return [
|
||||||
|
prediction.timestamps[0],
|
||||||
|
prediction.flight_time,
|
||||||
|
s.trajectoryStep,
|
||||||
|
s.prefetchIntervalMinutes,
|
||||||
|
s.maxFlightDurationHours,
|
||||||
|
s.maxRegionDegrees,
|
||||||
|
s.trajectoryMarginDegrees,
|
||||||
|
].join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prefetchTrajectory(prediction: Prediction, settings: WindSettings): Promise<void> {
|
||||||
|
const key = makePrefetchKey(prediction, settings);
|
||||||
|
if (key === prefetchKey) return; // nothing changed
|
||||||
|
|
||||||
|
const flightMs = prediction.flight_time * 1000;
|
||||||
|
|
||||||
|
if (flightMs > settings.maxFlightDurationHours * 3_600_000) {
|
||||||
|
prefetchKey = key;
|
||||||
|
trajectoryFrames = [];
|
||||||
|
prefetchSkipReason = `wind.skippedLong`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bbox = trajectoryBBox(prediction.flight_path, settings.trajectoryMarginDegrees);
|
||||||
|
const latSpan = bbox.max_lat - bbox.min_lat;
|
||||||
|
const lngSpan = bbox.max_lng - bbox.min_lng;
|
||||||
|
if (latSpan > settings.maxRegionDegrees || lngSpan > settings.maxRegionDegrees) {
|
||||||
|
prefetchKey = key;
|
||||||
|
trajectoryFrames = [];
|
||||||
|
prefetchSkipReason = `wind.skippedLarge`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
prefetchKey = key; // claim before async to prevent concurrent duplicate starts
|
||||||
|
prefetchSkipReason = null;
|
||||||
|
const frames: WindFrame[] = [];
|
||||||
|
const intervalMs = settings.prefetchIntervalMinutes * 60_000;
|
||||||
|
const launchMs = prediction.timestamps[0];
|
||||||
|
const step = Math.max(settings.trajectoryStep, 0.25);
|
||||||
|
|
||||||
|
// Frame offsets: every interval, plus the landing point exactly once.
|
||||||
|
const offsets: number[] = [];
|
||||||
|
for (let t = 0; t < flightMs; t += intervalMs) offsets.push(t);
|
||||||
|
offsets.push(flightMs);
|
||||||
|
|
||||||
|
// Sequential fetches so the cache warms predictably; concurrent bursts
|
||||||
|
// could overwhelm the predictor.
|
||||||
|
for (const offset of offsets) {
|
||||||
|
const altitude = altAtFlightTime(prediction, offset);
|
||||||
|
const time = new Date(launchMs + offset).toISOString();
|
||||||
|
try {
|
||||||
|
const field = await windCache.fetch({ time, altitude, step, ...bbox });
|
||||||
|
frames.push({ flightTimeMs: offset, field });
|
||||||
|
} catch {
|
||||||
|
// Skip this frame and continue with others
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trajectoryFrames = frames; // triggers the trajectory render effect
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Effects ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Pre-fetch trajectory wind frames when prediction or relevant settings change.
|
||||||
|
$effect(() => {
|
||||||
|
const prediction = activePrediction;
|
||||||
|
const settings = windSettings;
|
||||||
|
if (!settings.enabled || !prediction || $timelineStore.max === 0) {
|
||||||
|
trajectoryFrames = [];
|
||||||
|
prefetchKey = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Fire-and-forget; prefetchKey prevents duplicate starts.
|
||||||
|
prefetchTrajectory(prediction, settings);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trajectory mode: keep currentField in sync with the scrubbing timeline.
|
||||||
|
$effect(() => {
|
||||||
|
if (!inTrajectoryMode) return;
|
||||||
|
// Reading trajectoryFrames ($state) makes this effect re-run when frames arrive.
|
||||||
|
currentField = fieldAtFlightTime($timelineStore.time);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Static mode: fetch wind field for the active workspace's launch parameters.
|
||||||
|
$effect(() => {
|
||||||
|
if (!windSettings.enabled || inTrajectoryMode) {
|
||||||
|
staticFetchSeq++; // cancel any in-flight static request
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ws = activeWorkspace;
|
||||||
|
if (!ws) {
|
||||||
|
currentField = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seq = ++staticFetchSeq;
|
||||||
|
const step = Math.max(windSettings.step, 0.25);
|
||||||
|
const { launch_altitude } = ws.flightParameters;
|
||||||
|
const time = new Date(`${ws.launchDate}T${ws.launchTime}Z`).toISOString();
|
||||||
|
|
||||||
|
windCache
|
||||||
|
.fetch({ altitude: launch_altitude, time, step })
|
||||||
|
.then((field) => {
|
||||||
|
if (seq !== staticFetchSeq) return; // superseded
|
||||||
|
currentField = field;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (seq !== staticFetchSeq) return;
|
||||||
|
currentField = null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drive the particle field from currentField + settings.
|
||||||
|
$effect(() => {
|
||||||
|
const s = windSettings;
|
||||||
|
const field = currentField;
|
||||||
|
if (!s.enabled || !field) {
|
||||||
|
particleField?.setField(null);
|
||||||
|
particleField?.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pf = ensureField();
|
||||||
|
pf.setOptions(particleOptions(s));
|
||||||
|
pf.setField(createWindInterpolator(field));
|
||||||
|
pf.start();
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
staticFetchSeq++; // cancel any pending static callback
|
||||||
|
particleField?.destroy();
|
||||||
|
particleField = null;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if windSettings.enabled && prefetchSkipReason}
|
||||||
|
<div class="wind-skip-notice">
|
||||||
|
<i class="bi bi-wind"></i>
|
||||||
|
{#if prefetchSkipReason === 'wind.skippedLong'}
|
||||||
|
Wind sync skipped: flight > {windSettings.maxFlightDurationHours}h
|
||||||
|
{:else}
|
||||||
|
Wind sync skipped: region > {windSettings.maxRegionDegrees}°
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wind-skip-notice {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 90px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(0, 0, 0, 0.65);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 900;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
3
src/lib/features/wind/index.ts
Normal file
3
src/lib/features/wind/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { default as WindRenderer } from './WindRenderer.svelte';
|
||||||
|
export { windCache } from './store';
|
||||||
|
export { ParticleField, DEFAULT_PARTICLE_OPTIONS, type ParticleOptions } from './ParticleField';
|
||||||
61
src/lib/features/wind/store.ts
Normal file
61
src/lib/features/wind/store.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
/**
|
||||||
|
* Thin cache layer for wind field responses.
|
||||||
|
*
|
||||||
|
* Each unique set of request parameters is keyed by a stable JSON string so
|
||||||
|
* that the same (time, altitude, bbox, step) combination is fetched only once
|
||||||
|
* per session even if multiple effects request it concurrently. The cache is
|
||||||
|
* intentionally never invalidated during a session — the predictor's dataset
|
||||||
|
* does not change while the user is working.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { windApi, type WindFieldParams } from '$api';
|
||||||
|
import type { WindField } from '$domain';
|
||||||
|
|
||||||
|
function cacheKey(params: WindFieldParams): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
altitude: params.altitude ?? null,
|
||||||
|
step: params.step ?? null,
|
||||||
|
time: params.time ?? null,
|
||||||
|
min_lat: params.min_lat ?? null,
|
||||||
|
max_lat: params.max_lat ?? null,
|
||||||
|
min_lng: params.min_lng ?? null,
|
||||||
|
max_lng: params.max_lng ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class WindCache {
|
||||||
|
private readonly hits = new Map<string, WindField>();
|
||||||
|
private readonly pending = new Map<string, Promise<WindField>>();
|
||||||
|
|
||||||
|
fetch(params: WindFieldParams): Promise<WindField> {
|
||||||
|
const key = cacheKey(params);
|
||||||
|
|
||||||
|
const hit = this.hits.get(key);
|
||||||
|
if (hit) return Promise.resolve(hit);
|
||||||
|
|
||||||
|
const existing = this.pending.get(key);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const promise = windApi
|
||||||
|
.field(params)
|
||||||
|
.then((field) => {
|
||||||
|
this.hits.set(key, field);
|
||||||
|
this.pending.delete(key);
|
||||||
|
return field;
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
this.pending.delete(key);
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.pending.set(key, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.hits.clear();
|
||||||
|
this.pending.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const windCache = new WindCache();
|
||||||
168
src/lib/features/workspaces/WorkspaceRenderer.svelte
Normal file
168
src/lib/features/workspaces/WorkspaceRenderer.svelte
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import { getMap, plotPrediction, plotAnimatedMarker, plotEndMarker } 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>();
|
||||||
|
// Last-rendered state per workspace scene — skip re-plot when nothing changed.
|
||||||
|
const plotCache = new Map<string, { result: unknown; color: string; opacity: number }>();
|
||||||
|
// Cursor scenes that have reached their flight end and show a static end marker.
|
||||||
|
const doneCursorScenes = new Set<string>();
|
||||||
|
|
||||||
|
const sceneName = (w: Workspace) => `ws/${w.id}`;
|
||||||
|
const cursorName = (w: Workspace) => `cursor/${w.id}`;
|
||||||
|
|
||||||
|
function updateGlobalRange(items: Workspace[]) {
|
||||||
|
let maxDuration = 0;
|
||||||
|
const entries: Array<{ duration: number; color: string }> = [];
|
||||||
|
for (const w of items) {
|
||||||
|
if (!w.visible || !w.result) continue;
|
||||||
|
const duration =
|
||||||
|
w.result.landing.datetime.getTime() - w.result.launch.datetime.getTime();
|
||||||
|
if (duration > maxDuration) maxDuration = duration;
|
||||||
|
entries.push({ duration, color: w.color });
|
||||||
|
}
|
||||||
|
timelineStore.setRange(0, maxDuration);
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const markers = entries
|
||||||
|
.filter(({ duration }) => {
|
||||||
|
if (duration >= maxDuration || seen.has(duration)) return false;
|
||||||
|
seen.add(duration);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map(({ duration, color }) => ({ time: duration, color }));
|
||||||
|
timelineStore.setMarkers(markers);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
plotCache.delete(name);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const cached = plotCache.get(name);
|
||||||
|
if (!cached || cached.result !== w.result || cached.color !== w.color || cached.opacity !== w.opacity) {
|
||||||
|
const scene = map.scene(name);
|
||||||
|
plotPrediction(scene, w.result, { color: w.color, opacity: w.opacity });
|
||||||
|
ownedPlotScenes.add(name);
|
||||||
|
plotCache.set(name, { result: w.result, color: w.color, opacity: w.opacity });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name of Array.from(ownedPlotScenes)) {
|
||||||
|
if (!live.has(name)) {
|
||||||
|
map.disposeScene(name);
|
||||||
|
ownedPlotScenes.delete(name);
|
||||||
|
plotCache.delete(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionAt(path: LatLngTuple[], elapsed: number, durationMs: number): LatLngTuple | null {
|
||||||
|
if (path.length === 0) return null;
|
||||||
|
if (durationMs === 0) return path[0];
|
||||||
|
const t = Math.max(0, Math.min(1, elapsed / durationMs));
|
||||||
|
const raw = t * (path.length - 1);
|
||||||
|
const idx = Math.floor(raw);
|
||||||
|
if (idx >= path.length - 1) return path[path.length - 1];
|
||||||
|
const frac = raw - idx;
|
||||||
|
const a = path[idx];
|
||||||
|
const b = path[idx + 1];
|
||||||
|
return [a[0] + (b[0] - a[0]) * frac, a[1] + (b[1] - a[1]) * frac] as LatLngTuple;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
doneCursorScenes.delete(name);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const durationMs =
|
||||||
|
w.result.landing.datetime.getTime() - w.result.launch.datetime.getTime();
|
||||||
|
const p = positionAt(w.result.flight_path, time, durationMs);
|
||||||
|
if (!p) continue;
|
||||||
|
const scene = map.scene(name);
|
||||||
|
const done = time >= durationMs;
|
||||||
|
if (done) {
|
||||||
|
if (!doneCursorScenes.has(name)) {
|
||||||
|
// Transition into done state: swap to static end marker.
|
||||||
|
scene.clear();
|
||||||
|
plotEndMarker(scene, p[1], p[0]);
|
||||||
|
doneCursorScenes.add(name);
|
||||||
|
}
|
||||||
|
// Position is clamped to landing — nothing more to update.
|
||||||
|
} else {
|
||||||
|
if (doneCursorScenes.has(name)) {
|
||||||
|
// Transition back to active (user seeked backwards).
|
||||||
|
scene.clear();
|
||||||
|
doneCursorScenes.delete(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);
|
||||||
|
doneCursorScenes.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();
|
||||||
|
doneCursorScenes.clear();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
159
src/lib/features/workspaces/WorkspacesPanel.svelte
Normal file
159
src/lib/features/workspaces/WorkspacesPanel.svelte
Normal 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>
|
||||||
4
src/lib/features/workspaces/index.ts
Normal file
4
src/lib/features/workspaces/index.ts
Normal 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';
|
||||||
128
src/lib/features/workspaces/store.ts
Normal file
128
src/lib/features/workspaces/store.ts
Normal 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;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue