Compare commits
60 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 79e20ca37c | |||
| 4bd927bb4e | |||
|
|
2e6177fe74 | ||
|
|
e984b9730b | ||
|
|
5657c9be8f | ||
|
|
f0aa28ec7c | ||
|
|
3be5d6c515 | ||
|
|
8e3dfa54f9 | ||
|
|
8e9f28a6ac | ||
|
|
eb7066ac6b | ||
|
|
60fe848b0c | ||
|
|
6359ccf9ee | ||
|
|
ffb27c2e0a | ||
|
|
82b36f96d0 | ||
|
|
4360a54b58 | ||
|
|
4bb7d214e8 | ||
|
|
19f969c18c | ||
|
|
7d01fce094 | ||
|
|
a1d80eb984 | ||
|
|
ac4af66cd5 | ||
|
|
551951827d | ||
|
|
cb67c5d93d | ||
|
|
41668498ea | ||
|
|
162bd0813f | ||
|
|
5a1a20df6c | ||
|
|
a5bfed73a1 | ||
|
|
e428f55580 | ||
|
|
0e4d5a8d47 | ||
|
|
1e09b2d7ef | ||
|
|
1a89d49e8a | ||
|
|
0f79cefdac | ||
|
|
bb390d50dc | ||
|
|
329c1c2215 | ||
|
|
aa0ff91a7d | ||
|
|
87f0a53cb5 | ||
|
|
74340cf28e | ||
|
|
3d609771de | ||
|
|
f4b397043a | ||
|
|
72c0d5e609 | ||
|
|
eb29cdc585 | ||
|
|
52558ed3b2 | ||
|
|
c7df38e6ce | ||
|
|
527d4417ff | ||
|
|
a822fb1e36 | ||
|
|
79848ef36f | ||
|
|
19a8cdc1d6 | ||
|
|
14132dfeb6 | ||
|
|
0b4f0fe6d8 | ||
|
|
29d7480753 | ||
|
|
522202b89e | ||
|
|
afc45cc9cc | ||
|
|
51dc62a68f | ||
|
|
55295b84aa | ||
|
|
2db5d14202 | ||
|
|
859966c48d | ||
|
|
6bd3a656f9 | ||
|
|
cd98f04622 | ||
|
|
68aae97597 | ||
|
|
e67a9c6455 | ||
|
|
0f130c640c |
121 changed files with 8318 additions and 2175 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
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -21,3 +21,7 @@ Thumbs.db
|
|||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# AI tools
|
||||
.claude
|
||||
tmpclaude*
|
||||
|
|
|
|||
8
.prettierrc
Normal file
8
.prettierrc
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"tabWidth": 4,
|
||||
"endOfLine": "lf",
|
||||
"printWidth": 120,
|
||||
"useTabs": true,
|
||||
"htmlWhitespaceSensitivity": "ignore",
|
||||
"bracketSameLine": true
|
||||
}
|
||||
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
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# create a new project in the current directory
|
||||
npx sv create
|
||||
npm install
|
||||
|
||||
# create a new project in my-app
|
||||
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
|
||||
# dev against a local Django on :8000 (see .env.example)
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
# dev with a fake backend (no Django needed)
|
||||
VITE_USE_MOCK_API=true npm run dev
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```bash
|
||||
# type-check + production build (emits static files to ./build)
|
||||
npm run check
|
||||
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.
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
|
|
|
|||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
1166
package-lock.json
generated
1166
package-lock.json
generated
File diff suppressed because it is too large
Load diff
29
package.json
29
package.json
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "project",
|
||||
"name": "app4",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
|
|
@ -9,19 +9,34 @@
|
|||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"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": {
|
||||
"@sveltejs/adapter-auto": "^4.0.0",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.16.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"@types/luxon": "^3.6.2",
|
||||
"@types/node": "^25.6.0",
|
||||
"@vincjo/datatables": "^2.5.0",
|
||||
"svelte": "^5.34.8",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
"vite": "^6.2.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"leaflet": "^1.9.4",
|
||||
"svelte-map-leaflet": "^0.5.0"
|
||||
"@sakitam-gis/maplibre-wind": "^2.0.3",
|
||||
"@sveltestrap/sveltestrap": "^7.1.0",
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"chart.js": "^4.5.0",
|
||||
"chartjs-adapter-luxon": "^1.3.1",
|
||||
"chartjs-plugin-dragdata": "^2.3.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"luxon": "^3.6.1",
|
||||
"maplibre-gl": "^4.0.0",
|
||||
"svelte5-chartjs": "^1.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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
|
||||
156
src/app.css
156
src/app.css
|
|
@ -1,3 +1,153 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
/* 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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' }),
|
||||
};
|
||||
5
src/lib/api/index.ts
Normal file
5
src/lib/api/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export { api, ApiError, API_BASE_URL, setUnauthorizedHandler } from './client';
|
||||
export { pointsApi } from './points';
|
||||
export { profilesApi } from './profiles';
|
||||
export { scenariosApi } from './scenarios';
|
||||
export { predictionsApi, getLatestDataset, buildLaunchDateTime } from './predictions';
|
||||
11
src/lib/api/points.ts
Normal file
11
src/lib/api/points.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { api } from './client';
|
||||
import type { SavedPoint } from '$domain';
|
||||
|
||||
const base = '/saved-points/';
|
||||
|
||||
export const pointsApi = {
|
||||
list: () => api.get<SavedPoint[]>(base),
|
||||
create: (p: SavedPoint) => api.post<SavedPoint>(base, p),
|
||||
update: (p: SavedPoint) => api.put<SavedPoint>(`${base}${p.id}/`, p),
|
||||
delete: (id: number) => api.delete<void>(`${base}${id}/`),
|
||||
};
|
||||
34
src/lib/api/predictions.ts
Normal file
34
src/lib/api/predictions.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { api } from './client';
|
||||
import type { FlightParameters, RawPrediction } from '$domain';
|
||||
|
||||
/**
|
||||
* GFS datasets are published every 6 hours with a ~6 hour processing lag.
|
||||
* Round down to the most recent available slot.
|
||||
*/
|
||||
export function getLatestDataset(now: Date = new Date()): string {
|
||||
const rounded = new Date(now);
|
||||
rounded.setUTCHours(Math.floor(rounded.getUTCHours() / 6) * 6, 0, 0, 0);
|
||||
rounded.setUTCHours(rounded.getUTCHours() - 6);
|
||||
return rounded.toISOString();
|
||||
}
|
||||
|
||||
export function buildLaunchDateTime(date: string, time: string): string {
|
||||
const fullTime = time.split(':').length === 2 ? `${time}:00` : time;
|
||||
return new Date(`${date}T${fullTime}Z`).toISOString();
|
||||
}
|
||||
|
||||
export interface PredictionResponse {
|
||||
result: RawPrediction;
|
||||
}
|
||||
|
||||
export const predictionsApi = {
|
||||
run: (params: FlightParameters, launchDateTime: string) => {
|
||||
const payload: FlightParameters & { launch_datetime: string } = {
|
||||
...params,
|
||||
dataset: params.dataset || getLatestDataset(),
|
||||
launch_datetime: launchDateTime,
|
||||
};
|
||||
if (payload.start_point === -1) delete payload.start_point;
|
||||
return api.post<PredictionResponse>('/predictions/', payload);
|
||||
},
|
||||
};
|
||||
11
src/lib/api/profiles.ts
Normal file
11
src/lib/api/profiles.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { api } from './client';
|
||||
import type { SavedFlightProfile } from '$domain';
|
||||
|
||||
const base = '/saved-profiles/';
|
||||
|
||||
export const profilesApi = {
|
||||
list: () => api.get<SavedFlightProfile[]>(base),
|
||||
create: (p: SavedFlightProfile) => api.post<SavedFlightProfile>(base, p),
|
||||
update: (p: SavedFlightProfile) => api.put<SavedFlightProfile>(`${base}${p.id}/`, p),
|
||||
delete: (id: number) => api.delete<void>(`${base}${id}/`),
|
||||
};
|
||||
11
src/lib/api/scenarios.ts
Normal file
11
src/lib/api/scenarios.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { api } from './client';
|
||||
import type { SavedScenario } from '$domain';
|
||||
|
||||
const base = '/saved-templates/';
|
||||
|
||||
export const scenariosApi = {
|
||||
list: () => api.get<SavedScenario[]>(base),
|
||||
create: (s: SavedScenario) => api.post<SavedScenario>(base, s),
|
||||
update: (s: SavedScenario) => api.put<SavedScenario>(`${base}${s.id}/`, s),
|
||||
delete: (id: number) => api.delete<void>(`${base}${id}/`),
|
||||
};
|
||||
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;
|
||||
});
|
||||
});
|
||||
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;
|
||||
}
|
||||
5
src/lib/domain/index.ts
Normal file
5
src/lib/domain/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export * from './geo';
|
||||
export * from './math';
|
||||
export * from './scenario';
|
||||
export * from './prediction';
|
||||
export * from './telemetry';
|
||||
34
src/lib/domain/math.ts
Normal file
34
src/lib/domain/math.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import type { LatLng } from './geo';
|
||||
|
||||
const EARTH_RADIUS_KM = 6371;
|
||||
|
||||
const toRad = (deg: number): number => (deg * Math.PI) / 180;
|
||||
const toDeg = (rad: number): number => (rad * 180) / Math.PI;
|
||||
|
||||
export function distHaversine(p1: LatLng, p2: LatLng, precision?: number): number {
|
||||
const dLat = toRad(p2.lat - p1.lat);
|
||||
const dLng = toRad(p2.lng - p1.lng);
|
||||
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(p1.lat)) * Math.cos(toRad(p2.lat)) * Math.sin(dLng / 2) ** 2;
|
||||
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
const d = EARTH_RADIUS_KM * c;
|
||||
|
||||
return precision !== undefined ? parseFloat(d.toFixed(precision)) : d;
|
||||
}
|
||||
|
||||
export function bearingHaversine(p1: LatLng, p2: LatLng): number {
|
||||
const dLng = toRad(p2.lng - p1.lng);
|
||||
const y = Math.sin(dLng) * Math.cos(toRad(p2.lat));
|
||||
const x =
|
||||
Math.cos(toRad(p1.lat)) * Math.sin(toRad(p2.lat)) -
|
||||
Math.sin(toRad(p1.lat)) * Math.cos(toRad(p2.lat)) * Math.cos(dLng);
|
||||
return toDeg(Math.atan2(y, x));
|
||||
}
|
||||
|
||||
export function toFixedNumber(num: number, digits: number): number {
|
||||
const pow = 10 ** digits;
|
||||
return Math.round(num * pow) / pow;
|
||||
}
|
||||
73
src/lib/domain/prediction.ts
Normal file
73
src/lib/domain/prediction.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import type { LatLngTuple, LatLng } from './geo';
|
||||
import { normalizeLng } from './geo';
|
||||
|
||||
export interface Point {
|
||||
latlng: LatLng;
|
||||
datetime: Date;
|
||||
}
|
||||
|
||||
export interface TrajectoryPoint {
|
||||
altitude: number;
|
||||
datetime: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface PredictionStage {
|
||||
stage: string;
|
||||
trajectory: TrajectoryPoint[];
|
||||
}
|
||||
|
||||
export interface PredictionMetadata {
|
||||
complete_datetime: string;
|
||||
start_datetime: string;
|
||||
}
|
||||
|
||||
export interface RawPrediction {
|
||||
metadata: PredictionMetadata;
|
||||
prediction: PredictionStage[];
|
||||
}
|
||||
|
||||
export interface Prediction {
|
||||
flight_path: LatLngTuple[];
|
||||
launch: Point;
|
||||
burst: Point;
|
||||
landing: Point;
|
||||
profile: string;
|
||||
flight_time: number;
|
||||
}
|
||||
|
||||
function pointFromTrajectory(tp: TrajectoryPoint): Point {
|
||||
return {
|
||||
latlng: { lat: tp.latitude, lng: normalizeLng(tp.longitude), alt: tp.altitude },
|
||||
datetime: new Date(tp.datetime),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a raw prediction response (stages → trajectory) into a flat Prediction
|
||||
* with derived launch/burst/landing markers and a single flight_path array.
|
||||
*/
|
||||
export function parsePrediction(stages: PredictionStage[]): Prediction {
|
||||
if (stages.length < 2) {
|
||||
throw new Error('Prediction requires at least ascent and descent stages');
|
||||
}
|
||||
|
||||
const ascent = stages[0].trajectory;
|
||||
const descent = stages[1].trajectory;
|
||||
|
||||
const flight_path: LatLngTuple[] = [...ascent, ...descent].map((p) => [
|
||||
p.latitude,
|
||||
normalizeLng(p.longitude),
|
||||
p.altitude,
|
||||
]);
|
||||
|
||||
const launch = pointFromTrajectory(ascent[0]);
|
||||
const burst = pointFromTrajectory(descent[0]);
|
||||
const landing = pointFromTrajectory(descent[descent.length - 1]);
|
||||
|
||||
const profile = stages[1].stage === 'descent' ? 'standard_profile' : 'float_profile';
|
||||
const flight_time = (landing.datetime.getTime() - launch.datetime.getTime()) / 1000;
|
||||
|
||||
return { flight_path, launch, burst, landing, profile, flight_time };
|
||||
}
|
||||
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 };
|
||||
}
|
||||
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>
|
||||
80
src/lib/features/auth/Navbar.svelte
Normal file
80
src/lib/features/auth/Navbar.svelte
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import {
|
||||
Collapse,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownToggle,
|
||||
Nav,
|
||||
NavItem,
|
||||
NavLink,
|
||||
Navbar,
|
||||
NavbarBrand,
|
||||
NavbarToggler,
|
||||
} from '@sveltestrap/sveltestrap';
|
||||
import { authStore } from '$auth';
|
||||
import { goto } from '$app/navigation';
|
||||
import { t } from '$i18n';
|
||||
|
||||
// Auth is already refreshed by the root layout before this mounts.
|
||||
let isOpen = $state(false);
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.logout();
|
||||
goto('/');
|
||||
}
|
||||
</script>
|
||||
|
||||
<Navbar color="light" light expand="lg" fixed="top" class="custom-navbar border-bottom">
|
||||
<NavbarBrand href="/" class="nav-full-height">
|
||||
<img src="/logo.svg" alt="Logo" height="34" class="d-inline-block align-text-top" />
|
||||
</NavbarBrand>
|
||||
<NavbarToggler on:click={() => (isOpen = !isOpen)} />
|
||||
<Collapse {isOpen} navbar expand="lg">
|
||||
<Nav class="me-auto mb-lg-0" navbar>
|
||||
<NavItem>
|
||||
<NavLink
|
||||
href="/predict"
|
||||
class="nav-full-height border border-top-0"
|
||||
active={$page.url.pathname === '/predict'}>
|
||||
{$t('nav.predict')}
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
<NavItem>
|
||||
<NavLink
|
||||
href="/track"
|
||||
class="nav-full-height border border-top-0"
|
||||
active={$page.url.pathname === '/track'}>
|
||||
{$t('nav.track')}
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
</Nav>
|
||||
<Nav navbar>
|
||||
{#if $authStore.status === 'authenticated' && $authStore.username}
|
||||
<Dropdown nav inNavbar>
|
||||
<DropdownToggle nav caret class="nav-full-height border border-top-0">
|
||||
{$authStore.username}
|
||||
</DropdownToggle>
|
||||
<DropdownMenu end>
|
||||
<DropdownItem href="/user/account">{$t('nav.account')}</DropdownItem>
|
||||
<DropdownItem href="/user/templates">{$t('nav.scenarios')}</DropdownItem>
|
||||
<DropdownItem href="/user/predictions">{$t('nav.predictionHistory')}</DropdownItem>
|
||||
<DropdownItem href="/user/flights">{$t('nav.trackingHistory')}</DropdownItem>
|
||||
<DropdownItem divider />
|
||||
<DropdownItem on:click={handleLogout}>{$t('nav.logout')}</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
{:else if $authStore.status === 'anonymous'}
|
||||
<NavItem>
|
||||
<NavLink
|
||||
href="/login"
|
||||
class="nav-full-height border border-top-0"
|
||||
active={$page.url.pathname === '/login'}>
|
||||
{$t('nav.login')}
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
{/if}
|
||||
</Nav>
|
||||
</Collapse>
|
||||
</Navbar>
|
||||
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 } from './store';
|
||||
export { default as SettingsPanel } from './SettingsPanel.svelte';
|
||||
export { SETTINGS_SCHEMA } from './schema';
|
||||
export type { SettingsField, SettingsSection } from './schema';
|
||||
86
src/lib/features/settings/schema.ts
Normal file
86
src/lib/features/settings/schema.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* Declarative settings schema. Each `SettingsField` describes a single setting
|
||||
* that renders as a labeled form control in the Settings panel. Keep this
|
||||
* independent of Svelte so the same schema can drive future serializers
|
||||
* (export/import settings, URL state, etc.).
|
||||
*/
|
||||
|
||||
export type FieldKind = 'boolean' | 'select' | 'number' | 'string';
|
||||
|
||||
export interface BaseField<K extends FieldKind> {
|
||||
kind: K;
|
||||
/** Dot-separated path into AppSettings (e.g. `'map.baseLayer'`). */
|
||||
path: string;
|
||||
labelKey: string;
|
||||
descriptionKey?: string;
|
||||
}
|
||||
|
||||
export interface BooleanField extends BaseField<'boolean'> {}
|
||||
|
||||
export interface NumberField extends BaseField<'number'> {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export interface StringField extends BaseField<'string'> {
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface SelectField extends BaseField<'select'> {
|
||||
options: { value: string; labelKey: string }[];
|
||||
}
|
||||
|
||||
export type SettingsField = BooleanField | NumberField | StringField | SelectField;
|
||||
|
||||
export interface SettingsSection {
|
||||
titleKey: string;
|
||||
fields: SettingsField[];
|
||||
}
|
||||
|
||||
export const SETTINGS_SCHEMA: SettingsSection[] = [
|
||||
{
|
||||
titleKey: 'settings.language',
|
||||
fields: [
|
||||
{
|
||||
kind: 'select',
|
||||
path: 'locale',
|
||||
labelKey: 'settings.language',
|
||||
options: [
|
||||
{ value: 'ru', labelKey: 'common.yes' },
|
||||
{ value: 'en', labelKey: 'common.yes' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
titleKey: 'settings.map',
|
||||
fields: [
|
||||
{
|
||||
kind: 'select',
|
||||
path: 'map.baseLayer',
|
||||
labelKey: 'settings.baseLayer',
|
||||
options: [
|
||||
{ value: 'osm', labelKey: 'settings.baseLayer' },
|
||||
{ value: 'satellite', labelKey: 'settings.baseLayer' },
|
||||
],
|
||||
},
|
||||
{ kind: 'boolean', path: 'map.showScale', labelKey: 'settings.showScale' },
|
||||
{ kind: 'boolean', path: 'map.showNavigation', labelKey: 'settings.showNavigation' },
|
||||
],
|
||||
},
|
||||
{
|
||||
titleKey: 'settings.units',
|
||||
fields: [
|
||||
{
|
||||
kind: 'select',
|
||||
path: 'units.system',
|
||||
labelKey: 'settings.units',
|
||||
options: [
|
||||
{ value: 'metric', labelKey: 'settings.metric' },
|
||||
{ value: 'imperial', labelKey: 'settings.imperial' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
50
src/lib/features/settings/store.ts
Normal file
50
src/lib/features/settings/store.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { persisted } from '$state';
|
||||
import type { Locale } from '$i18n';
|
||||
|
||||
export interface MapSettings {
|
||||
baseLayer: 'osm' | 'satellite';
|
||||
showScale: boolean;
|
||||
showNavigation: boolean;
|
||||
}
|
||||
|
||||
export interface UnitsSettings {
|
||||
system: 'metric' | 'imperial';
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
locale: Locale;
|
||||
map: MapSettings;
|
||||
units: UnitsSettings;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: AppSettings = {
|
||||
locale: 'ru',
|
||||
map: { baseLayer: 'osm', showScale: true, showNavigation: true },
|
||||
units: { system: 'metric' },
|
||||
};
|
||||
|
||||
export const settingsStore = persisted<AppSettings>('settings', DEFAULT_SETTINGS);
|
||||
|
||||
/** Resolve `'a.b.c'` to `obj.a.b.c`. Used by the schema-driven settings form. */
|
||||
export function getPath(obj: unknown, path: string): unknown {
|
||||
return path.split('.').reduce<unknown>((acc, key) => {
|
||||
if (acc && typeof acc === 'object' && key in acc) {
|
||||
return (acc as Record<string, unknown>)[key];
|
||||
}
|
||||
return undefined;
|
||||
}, obj);
|
||||
}
|
||||
|
||||
export function setPath<T extends object>(obj: T, path: string, value: unknown): T {
|
||||
const keys = path.split('.');
|
||||
const next: Record<string, unknown> = { ...(obj as Record<string, unknown>) };
|
||||
let cursor: Record<string, unknown> = next;
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const k = keys[i];
|
||||
const existing = cursor[k];
|
||||
cursor[k] = { ...((existing as Record<string, unknown> | undefined) ?? {}) };
|
||||
cursor = cursor[k] as Record<string, unknown>;
|
||||
}
|
||||
cursor[keys[keys.length - 1]] = value;
|
||||
return next as T;
|
||||
}
|
||||
117
src/lib/features/timeline/TimeLine.svelte
Normal file
117
src/lib/features/timeline/TimeLine.svelte
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
<script lang="ts">
|
||||
import { timelineStore } from './store';
|
||||
import { t } from '$i18n';
|
||||
|
||||
const SPEEDS = [0.5, 1, 2, 5, 10];
|
||||
|
||||
function cycleSpeed() {
|
||||
const i = SPEEDS.indexOf($timelineStore.speed);
|
||||
timelineStore.setSpeed(SPEEDS[(i + 1) % SPEEDS.length]);
|
||||
}
|
||||
|
||||
function onSeek(e: Event) {
|
||||
const v = parseFloat((e.currentTarget as HTMLInputElement).value);
|
||||
timelineStore.seek(v);
|
||||
}
|
||||
|
||||
let duration = $derived(Math.max(0, $timelineStore.max - $timelineStore.min));
|
||||
let elapsed = $derived(Math.max(0, $timelineStore.time - $timelineStore.min));
|
||||
|
||||
function fmtHms(ms: number): string {
|
||||
if (!isFinite(ms) || ms < 0) return '00:00:00';
|
||||
const s = Math.floor(ms / 1000);
|
||||
const hh = String(Math.floor(s / 3600)).padStart(2, '0');
|
||||
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0');
|
||||
const ss = String(s % 60).padStart(2, '0');
|
||||
return `${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
let hasData = $derived(duration > 0);
|
||||
</script>
|
||||
|
||||
<div class="timeline-container card shadow-sm" class:disabled={!hasData}>
|
||||
<div class="card-body p-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-primary"
|
||||
onclick={() => timelineStore.reset()}
|
||||
disabled={!hasData}
|
||||
title={$t('timeline.stop')}
|
||||
aria-label={$t('timeline.stop')}>
|
||||
<i class="bi bi-skip-start-fill"></i>
|
||||
</button>
|
||||
{#if $timelineStore.playing}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-warning"
|
||||
onclick={() => timelineStore.pause()}
|
||||
title={$t('timeline.pause')}
|
||||
aria-label={$t('timeline.pause')}>
|
||||
<i class="bi bi-pause-fill"></i>
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success"
|
||||
onclick={() => timelineStore.play()}
|
||||
disabled={!hasData}
|
||||
title={$t('timeline.play')}
|
||||
aria-label={$t('timeline.play')}>
|
||||
<i class="bi bi-play-fill"></i>
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
onclick={cycleSpeed}
|
||||
disabled={!hasData}
|
||||
title={$t('timeline.speed')}>
|
||||
{$timelineStore.speed}x
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-fill d-flex flex-column">
|
||||
<input
|
||||
type="range"
|
||||
class="form-range"
|
||||
min={$timelineStore.min}
|
||||
max={$timelineStore.max}
|
||||
step="1000"
|
||||
value={$timelineStore.time}
|
||||
oninput={onSeek}
|
||||
disabled={!hasData} />
|
||||
<div class="d-flex justify-content-between small font-monospace text-muted">
|
||||
<span>{fmtHms(elapsed)}</span>
|
||||
<span>{fmtHms(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.timeline-container {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
min-width: 500px;
|
||||
max-width: 720px;
|
||||
z-index: 1000;
|
||||
background: var(--bs-body-bg);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.timeline-container.disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.timeline-container {
|
||||
min-width: calc(100vw - 24px);
|
||||
max-width: calc(100vw - 24px);
|
||||
bottom: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
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 } from './store';
|
||||
export { default as TimeLine } from './TimeLine.svelte';
|
||||
93
src/lib/features/timeline/store.ts
Normal file
93
src/lib/features/timeline/store.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { writable } from 'svelte/store';
|
||||
|
||||
/**
|
||||
* Global playback clock.
|
||||
*
|
||||
* `time` is an absolute timestamp in ms (UTC). Each layer/workspace samples
|
||||
* its own trajectory against this clock, so multiple workspaces stay in sync.
|
||||
*
|
||||
* Consumers should treat the range `[min, max]` as the current domain; if
|
||||
* they own a trajectory spanning a different interval they can clamp
|
||||
* locally, but the UI slider always operates over the global range.
|
||||
*/
|
||||
|
||||
export interface TimelineState {
|
||||
time: number;
|
||||
min: number;
|
||||
max: number;
|
||||
speed: number;
|
||||
playing: boolean;
|
||||
}
|
||||
|
||||
const initial: TimelineState = {
|
||||
time: 0,
|
||||
min: 0,
|
||||
max: 0,
|
||||
speed: 1,
|
||||
playing: false,
|
||||
};
|
||||
|
||||
function createTimeline() {
|
||||
const store = writable<TimelineState>(initial);
|
||||
let frame: number | null = null;
|
||||
let lastTick = 0;
|
||||
|
||||
function tick(ts: number) {
|
||||
store.update((s) => {
|
||||
if (!s.playing) return s;
|
||||
if (!lastTick) lastTick = ts;
|
||||
const dt = (ts - lastTick) * s.speed;
|
||||
lastTick = ts;
|
||||
let next = s.time + dt;
|
||||
if (next >= s.max) {
|
||||
next = s.max;
|
||||
frame = null;
|
||||
return { ...s, time: next, playing: false };
|
||||
}
|
||||
frame = requestAnimationFrame(tick);
|
||||
return { ...s, time: next };
|
||||
});
|
||||
}
|
||||
|
||||
function play() {
|
||||
store.update((s) => {
|
||||
if (s.max <= s.min) return s;
|
||||
if (s.time >= s.max) return { ...s, time: s.min, playing: true };
|
||||
return { ...s, playing: true };
|
||||
});
|
||||
lastTick = 0;
|
||||
frame = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function pause() {
|
||||
if (frame !== null) cancelAnimationFrame(frame);
|
||||
frame = null;
|
||||
store.update((s) => ({ ...s, playing: false }));
|
||||
}
|
||||
|
||||
function reset() {
|
||||
pause();
|
||||
store.update((s) => ({ ...s, time: s.min }));
|
||||
}
|
||||
|
||||
function seek(time: number) {
|
||||
store.update((s) => ({ ...s, time: Math.max(s.min, Math.min(s.max, time)) }));
|
||||
}
|
||||
|
||||
function setSpeed(speed: number) {
|
||||
store.update((s) => ({ ...s, speed }));
|
||||
}
|
||||
|
||||
function setRange(min: number, max: number) {
|
||||
store.update((s) => {
|
||||
const nextMin = Number.isFinite(min) ? min : s.min;
|
||||
const nextMax = Number.isFinite(max) ? max : s.max;
|
||||
const t = Math.max(nextMin, Math.min(nextMax, s.time));
|
||||
return { ...s, min: nextMin, max: nextMax, time: t };
|
||||
});
|
||||
}
|
||||
|
||||
return { subscribe: store.subscribe, play, pause, reset, seek, setSpeed, setRange };
|
||||
}
|
||||
|
||||
export const timelineStore = createTimeline();
|
||||
36
src/lib/features/tracking/TelemetryPanel.svelte
Normal file
36
src/lib/features/tracking/TelemetryPanel.svelte
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<script lang="ts">
|
||||
import { FormGroup, Label, Input, InputGroup } from '@sveltestrap/sveltestrap';
|
||||
import { CollapsibleCard } from '$ui';
|
||||
import { t } from '$i18n';
|
||||
|
||||
// Placeholder — wire to the tracking telemetry store once the tracking
|
||||
// pipeline has a real data source.
|
||||
let telemetry: { latitude?: number; longitude?: number; altitude?: number } = $state({
|
||||
latitude: 56.3576,
|
||||
longitude: 39.8666,
|
||||
altitude: 1000,
|
||||
});
|
||||
</script>
|
||||
|
||||
<CollapsibleCard title={$t('nav.track')}>
|
||||
<FormGroup spacing="mb-2">
|
||||
<Label class="small">{$t('points.lat')}</Label>
|
||||
<InputGroup size="sm">
|
||||
<Input type="text" value={telemetry.latitude ?? 'N/A'} readonly />
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup spacing="mb-2">
|
||||
<Label class="small">{$t('points.lon')}</Label>
|
||||
<InputGroup size="sm">
|
||||
<Input type="text" value={telemetry.longitude ?? 'N/A'} readonly />
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup spacing="mb-2">
|
||||
<Label class="small">{$t('points.alt')}</Label>
|
||||
<InputGroup size="sm">
|
||||
<Input type="text" value={telemetry.altitude ?? 'N/A'} readonly />
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
</CollapsibleCard>
|
||||
1
src/lib/features/tracking/index.ts
Normal file
1
src/lib/features/tracking/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as TelemetryPanel } from './TelemetryPanel.svelte';
|
||||
132
src/lib/features/workspaces/WorkspaceRenderer.svelte
Normal file
132
src/lib/features/workspaces/WorkspaceRenderer.svelte
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { getMap, plotPrediction, plotAnimatedMarker } from '$map';
|
||||
import { timelineStore } from '$features/timeline/store';
|
||||
import { workspacesStore } from './store';
|
||||
import type { Workspace } from './types';
|
||||
import type { LatLngTuple } from '$domain';
|
||||
|
||||
/**
|
||||
* Renders every workspace onto the shared map. Each workspace gets its own
|
||||
* named scene (`ws/<id>`) so its layers can be cleared independently, plus
|
||||
* a `cursor/<id>` scene for the animated playback marker.
|
||||
*
|
||||
* The component is placed as a child of <Map /> so `getMap()` returns a
|
||||
* non-null instance via context.
|
||||
*/
|
||||
|
||||
const map = getMap();
|
||||
if (!map) throw new Error('WorkspaceRenderer must be a descendant of <Map />');
|
||||
|
||||
// Track the scenes we currently own so we can dispose the ones that
|
||||
// belong to workspaces which were removed from the store since last tick.
|
||||
const ownedPlotScenes = new Set<string>();
|
||||
const ownedCursorScenes = new Set<string>();
|
||||
|
||||
const sceneName = (w: Workspace) => `ws/${w.id}`;
|
||||
const cursorName = (w: Workspace) => `cursor/${w.id}`;
|
||||
|
||||
function updateGlobalRange(items: Workspace[]) {
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const w of items) {
|
||||
if (!w.visible || !w.result) continue;
|
||||
const l = w.result.launch.datetime.getTime();
|
||||
const r = w.result.landing.datetime.getTime();
|
||||
if (l < min) min = l;
|
||||
if (r > max) max = r;
|
||||
}
|
||||
if (!isFinite(min) || !isFinite(max)) {
|
||||
timelineStore.setRange(0, 0);
|
||||
} else {
|
||||
timelineStore.setRange(min, max);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAll(items: Workspace[]) {
|
||||
if (!map) return;
|
||||
|
||||
const live = new Set<string>();
|
||||
for (const w of items) {
|
||||
const name = sceneName(w);
|
||||
live.add(name);
|
||||
if (!w.visible || !w.result) {
|
||||
if (ownedPlotScenes.has(name)) {
|
||||
map.disposeScene(name);
|
||||
ownedPlotScenes.delete(name);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const scene = map.scene(name);
|
||||
plotPrediction(scene, w.result, { color: w.color, opacity: w.opacity });
|
||||
ownedPlotScenes.add(name);
|
||||
}
|
||||
|
||||
for (const name of Array.from(ownedPlotScenes)) {
|
||||
if (!live.has(name)) {
|
||||
map.disposeScene(name);
|
||||
ownedPlotScenes.delete(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function positionAt(path: LatLngTuple[], time: number, launchMs: number, landingMs: number) {
|
||||
if (path.length === 0) return null;
|
||||
if (launchMs === landingMs) return path[0];
|
||||
const t = Math.max(0, Math.min(1, (time - launchMs) / (landingMs - launchMs)));
|
||||
const idx = Math.min(path.length - 1, Math.floor(t * (path.length - 1)));
|
||||
return path[idx];
|
||||
}
|
||||
|
||||
function renderCursors(items: Workspace[], time: number) {
|
||||
if (!map) return;
|
||||
|
||||
const live = new Set<string>();
|
||||
for (const w of items) {
|
||||
const name = cursorName(w);
|
||||
live.add(name);
|
||||
if (!w.visible || !w.result) {
|
||||
if (ownedCursorScenes.has(name)) {
|
||||
map.disposeScene(name);
|
||||
ownedCursorScenes.delete(name);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const p = positionAt(
|
||||
w.result.flight_path,
|
||||
time,
|
||||
w.result.launch.datetime.getTime(),
|
||||
w.result.landing.datetime.getTime(),
|
||||
);
|
||||
if (!p) continue;
|
||||
const scene = map.scene(name);
|
||||
plotAnimatedMarker(scene, p[1], p[0]);
|
||||
ownedCursorScenes.add(name);
|
||||
}
|
||||
|
||||
for (const name of Array.from(ownedCursorScenes)) {
|
||||
if (!live.has(name)) {
|
||||
map.disposeScene(name);
|
||||
ownedCursorScenes.delete(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const items = $workspacesStore.items;
|
||||
renderAll(items);
|
||||
updateGlobalRange(items);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
renderCursors($workspacesStore.items, $timelineStore.time);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (!map) return;
|
||||
for (const name of ownedPlotScenes) map.disposeScene(name);
|
||||
for (const name of ownedCursorScenes) map.disposeScene(name);
|
||||
ownedPlotScenes.clear();
|
||||
ownedCursorScenes.clear();
|
||||
});
|
||||
</script>
|
||||
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;
|
||||
}
|
||||
23
src/lib/features/workspaces/types.ts
Normal file
23
src/lib/features/workspaces/types.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { FlightParameters, Prediction } from '$domain';
|
||||
|
||||
/** A "workspace" is an independently-configured prediction layer on the map. */
|
||||
export interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
opacity: number;
|
||||
visible: boolean;
|
||||
flightParameters: FlightParameters;
|
||||
launchDate: string;
|
||||
launchTime: string;
|
||||
result: Prediction | null;
|
||||
lastRunError?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceInit {
|
||||
name?: string;
|
||||
color?: string;
|
||||
flightParameters?: FlightParameters;
|
||||
launchDate?: string;
|
||||
launchTime?: string;
|
||||
}
|
||||
87
src/lib/i18n/index.ts
Normal file
87
src/lib/i18n/index.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { derived, writable, type Readable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
/**
|
||||
* Minimal i18n layer — no external deps.
|
||||
*
|
||||
* Lookup is key-based (`t('panel.title')`) against flat or nested JSON
|
||||
* dictionaries. Interpolation uses `{name}` placeholders. Missing keys fall
|
||||
* back to the key itself so screens remain functional during translation.
|
||||
*
|
||||
* Adding a locale
|
||||
* ---------------
|
||||
* 1. Drop a JSON file into `src/lib/i18n/locales/<code>.json`.
|
||||
* 2. Register it in `loaders` below.
|
||||
* 3. Expose it in the `Locale` type.
|
||||
*/
|
||||
|
||||
export type Locale = 'ru' | 'en';
|
||||
|
||||
export const DEFAULT_LOCALE: Locale = 'ru';
|
||||
export const SUPPORTED_LOCALES: Locale[] = ['ru', 'en'];
|
||||
const STORAGE_KEY = 'locale';
|
||||
|
||||
type Messages = Record<string, unknown>;
|
||||
|
||||
const loaders: Record<Locale, () => Promise<{ default: Messages }>> = {
|
||||
ru: () => import('./locales/ru.json'),
|
||||
en: () => import('./locales/en.json'),
|
||||
};
|
||||
|
||||
const messages = writable<Record<Locale, Messages>>({} as Record<Locale, Messages>);
|
||||
const locale = writable<Locale>(DEFAULT_LOCALE);
|
||||
|
||||
async function loadLocale(code: Locale): Promise<void> {
|
||||
const mod = await loaders[code]();
|
||||
messages.update((m) => ({ ...m, [code]: mod.default }));
|
||||
}
|
||||
|
||||
export async function setLocale(code: Locale): Promise<void> {
|
||||
if (!SUPPORTED_LOCALES.includes(code)) return;
|
||||
await loadLocale(code);
|
||||
locale.set(code);
|
||||
if (browser) localStorage.setItem(STORAGE_KEY, code);
|
||||
}
|
||||
|
||||
export async function initI18n(): Promise<void> {
|
||||
const stored = browser ? (localStorage.getItem(STORAGE_KEY) as Locale | null) : null;
|
||||
const next = stored && SUPPORTED_LOCALES.includes(stored) ? stored : DEFAULT_LOCALE;
|
||||
await setLocale(next);
|
||||
}
|
||||
|
||||
function lookup(dict: Messages, key: string): string | undefined {
|
||||
const parts = key.split('.');
|
||||
let node: unknown = dict;
|
||||
for (const p of parts) {
|
||||
if (node && typeof node === 'object' && p in (node as Messages)) {
|
||||
node = (node as Messages)[p];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return typeof node === 'string' ? node : undefined;
|
||||
}
|
||||
|
||||
function interpolate(template: string, values: Record<string, string | number>): string {
|
||||
return template.replace(/\{(\w+)\}/g, (_, name) =>
|
||||
name in values ? String(values[name]) : `{${name}}`,
|
||||
);
|
||||
}
|
||||
|
||||
export interface Translator {
|
||||
(key: string, values?: Record<string, string | number>): string;
|
||||
}
|
||||
|
||||
export const t: Readable<Translator> = derived(
|
||||
[locale, messages],
|
||||
([$locale, $messages]) => {
|
||||
const dict = $messages[$locale];
|
||||
return (key: string, values?: Record<string, string | number>) => {
|
||||
const str = dict ? lookup(dict, key) : undefined;
|
||||
if (str === undefined) return key;
|
||||
return values ? interpolate(str, values) : str;
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export const currentLocale: Readable<Locale> = { subscribe: locale.subscribe };
|
||||
172
src/lib/i18n/locales/en.json
Normal file
172
src/lib/i18n/locales/en.json
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
{
|
||||
"app": {
|
||||
"title": "Stratospheric Flights | YKS Ltd.",
|
||||
"company": "Yakutsk Space Systems Ltd."
|
||||
},
|
||||
"nav": {
|
||||
"predict": "Predict",
|
||||
"track": "Track",
|
||||
"login": "Log in",
|
||||
"logout": "Log out",
|
||||
"account": "Account",
|
||||
"scenarios": "Saved scenarios",
|
||||
"predictionHistory": "Prediction history",
|
||||
"trackingHistory": "Tracking history",
|
||||
"user": "User"
|
||||
},
|
||||
"login": {
|
||||
"heading": "Sign in to your account",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"submit": "Sign in",
|
||||
"submitting": "Signing in...",
|
||||
"back": "Back",
|
||||
"invalidCredentials": "Invalid credentials",
|
||||
"fieldsRequired": "Please enter a username and password"
|
||||
},
|
||||
"panel": {
|
||||
"scenario": "Scenario",
|
||||
"conditions": "Conditions",
|
||||
"about": "About",
|
||||
"layers": "Layers",
|
||||
"results": "Results",
|
||||
"settings": "Settings",
|
||||
"workspaces": "Workspaces"
|
||||
},
|
||||
"scenario": {
|
||||
"title": "Prediction scenario",
|
||||
"select": "Scenario",
|
||||
"placeholder": "New scenario...",
|
||||
"searchPlaceholder": "Search scenarios...",
|
||||
"apply": "Apply scenario",
|
||||
"applied": "Scenario applied",
|
||||
"appliedBody": "Scenario \"{name}\" successfully applied.",
|
||||
"notFound": "Scenario not found",
|
||||
"notFoundBody": "The selected scenario does not exist.",
|
||||
"updated": "Scenario updated",
|
||||
"updatedBody": "Scenario \"{name}\" successfully updated.",
|
||||
"updateError": "Scenario update error",
|
||||
"updateErrorBody": "Error updating scenario: {error}",
|
||||
"save": "Save scenario",
|
||||
"update": "Update scenario",
|
||||
"all": "All scenarios",
|
||||
"mode": "Scenario mode",
|
||||
"model": "Atmospheric model",
|
||||
"dataset": "Dataset",
|
||||
"datasetAuto": "Pick automatically",
|
||||
"modified": "modified",
|
||||
"export": "Export result",
|
||||
"exportBtn": "Export"
|
||||
},
|
||||
"predictionMode": {
|
||||
"single": "Single",
|
||||
"hourly": "Hourly",
|
||||
"ensemble": "Ensemble"
|
||||
},
|
||||
"profile": {
|
||||
"standard_profile": "Standard",
|
||||
"float_profile": "Float",
|
||||
"reverse_profile": "Reverse",
|
||||
"custom_profile": "Custom"
|
||||
},
|
||||
"conditions": {
|
||||
"title": "Prediction conditions",
|
||||
"startTime": "Launch time (UTC)",
|
||||
"startDate": "Launch date",
|
||||
"flightProfile": "Flight profile",
|
||||
"startPoint": "Launch point",
|
||||
"pointPlaceholder": "New point...",
|
||||
"pointSearchPlaceholder": "Search points...",
|
||||
"latLng": "Latitude/Longitude",
|
||||
"save": "Save",
|
||||
"launchAlt": "Launch altitude (m)",
|
||||
"burstAlt": "Burst altitude (m)",
|
||||
"ascentRate": "Ascent rate (m/s)",
|
||||
"descentRate": "Descent rate (m/s)",
|
||||
"run": "Run prediction",
|
||||
"profileEdit": "Ascent/descent profiles",
|
||||
"ascentStage": "Ascent stage",
|
||||
"descentStage": "Descent stage",
|
||||
"stageNone": "None",
|
||||
"stageStandard": "Standard",
|
||||
"stageCustom": "Custom",
|
||||
"openCurveEditor": "Open curve editor"
|
||||
},
|
||||
"workspaces": {
|
||||
"title": "Workspaces",
|
||||
"empty": "No workspaces yet.",
|
||||
"add": "Add",
|
||||
"rename": "Rename",
|
||||
"delete": "Delete",
|
||||
"visible": "Show/hide",
|
||||
"color": "Color",
|
||||
"opacity": "Opacity",
|
||||
"run": "Run",
|
||||
"running": "Running...",
|
||||
"edit": "Edit",
|
||||
"defaultName": "Workspace {n}",
|
||||
"deleteConfirm": "Delete workspace \"{name}\"?",
|
||||
"runError": "Run error: {error}"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Flight timeline",
|
||||
"time": "Time",
|
||||
"altitude": "Altitude",
|
||||
"position": "Position",
|
||||
"play": "Play",
|
||||
"pause": "Pause",
|
||||
"stop": "Reset",
|
||||
"speed": "Speed"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"language": "Language",
|
||||
"map": "Map",
|
||||
"baseLayer": "Base layer",
|
||||
"showScale": "Show scale",
|
||||
"showNavigation": "Navigation controls",
|
||||
"units": "Units",
|
||||
"metric": "Metric",
|
||||
"imperial": "Imperial",
|
||||
"saved": "Settings saved"
|
||||
},
|
||||
"editor": {
|
||||
"add": "Add",
|
||||
"edit": "Edit",
|
||||
"save": "Save",
|
||||
"update": "Update",
|
||||
"delete": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"searchPlaceholder": "Search..."
|
||||
},
|
||||
"points": {
|
||||
"item": "point",
|
||||
"itemGenitive": "point",
|
||||
"items": "points",
|
||||
"name": "Point name",
|
||||
"lat": "Latitude",
|
||||
"lon": "Longitude",
|
||||
"alt": "Altitude",
|
||||
"degrees": "Degrees",
|
||||
"metersAsl": "Meters ASL"
|
||||
},
|
||||
"common": {
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"warning": "Warning",
|
||||
"info": "Info",
|
||||
"yes": "Yes",
|
||||
"no": "No"
|
||||
},
|
||||
"selection": {
|
||||
"header": "Coordinate select mode",
|
||||
"body": "Click the map to pick coordinates"
|
||||
},
|
||||
"forecast": {
|
||||
"success": "Forecast request",
|
||||
"successBody": "Forecast request successful!",
|
||||
"error": "Forecast error",
|
||||
"errorBody": "Error running forecast: {error}"
|
||||
}
|
||||
}
|
||||
172
src/lib/i18n/locales/ru.json
Normal file
172
src/lib/i18n/locales/ru.json
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
{
|
||||
"app": {
|
||||
"title": "Стратосферные полеты | ООО ЯКС",
|
||||
"company": "ООО «Якутские Космические Системы»"
|
||||
},
|
||||
"nav": {
|
||||
"predict": "Прогнозирование",
|
||||
"track": "Слежение",
|
||||
"login": "Войти",
|
||||
"logout": "Выйти",
|
||||
"account": "Учетная запись",
|
||||
"scenarios": "Сохраненные сценарии",
|
||||
"predictionHistory": "История прогнозов",
|
||||
"trackingHistory": "История слежения",
|
||||
"user": "Пользователь"
|
||||
},
|
||||
"login": {
|
||||
"heading": "Вход в учетную запись",
|
||||
"username": "Имя пользователя",
|
||||
"password": "Пароль",
|
||||
"submit": "Войти",
|
||||
"submitting": "Вход...",
|
||||
"back": "Назад",
|
||||
"invalidCredentials": "Неверные учетные данные",
|
||||
"fieldsRequired": "Пожалуйста, введите имя пользователя и пароль"
|
||||
},
|
||||
"panel": {
|
||||
"scenario": "Сценарий",
|
||||
"conditions": "Условия",
|
||||
"about": "О проекте",
|
||||
"layers": "Слои",
|
||||
"results": "Результаты",
|
||||
"settings": "Настройки",
|
||||
"workspaces": "Рабочие области"
|
||||
},
|
||||
"scenario": {
|
||||
"title": "Сценарий прогнозирования",
|
||||
"select": "Сценарий",
|
||||
"placeholder": "Новый сценарий...",
|
||||
"searchPlaceholder": "Поиск сценариев...",
|
||||
"apply": "Применить сценарий",
|
||||
"applied": "Сценарий применен",
|
||||
"appliedBody": "Сценарий \"{name}\" успешно применен.",
|
||||
"notFound": "Сценарий не найден",
|
||||
"notFoundBody": "Выбранный сценарий не существует.",
|
||||
"updated": "Сценарий обновлен",
|
||||
"updatedBody": "Сценарий \"{name}\" успешно обновлен.",
|
||||
"updateError": "Ошибка обновления сценария",
|
||||
"updateErrorBody": "Ошибка при обновлении сценария: {error}",
|
||||
"save": "Сохранить сценарий",
|
||||
"update": "Обновить сценарий",
|
||||
"all": "Все сценарии",
|
||||
"mode": "Режим сценария",
|
||||
"model": "Модель атмосферы",
|
||||
"dataset": "Набор данных",
|
||||
"datasetAuto": "Выбрать автоматически",
|
||||
"modified": "изменено",
|
||||
"export": "Экспортировать результат",
|
||||
"exportBtn": "Экспорт"
|
||||
},
|
||||
"predictionMode": {
|
||||
"single": "Разовый",
|
||||
"hourly": "Почасовой",
|
||||
"ensemble": "Ансамблевый"
|
||||
},
|
||||
"profile": {
|
||||
"standard_profile": "Обычный",
|
||||
"float_profile": "Дрейф",
|
||||
"reverse_profile": "Реверсивный",
|
||||
"custom_profile": "Пользовательский"
|
||||
},
|
||||
"conditions": {
|
||||
"title": "Условия прогнозирования",
|
||||
"startTime": "Время старта (UTC)",
|
||||
"startDate": "Дата старта",
|
||||
"flightProfile": "Профиль полета",
|
||||
"startPoint": "Точка старта",
|
||||
"pointPlaceholder": "Новая точка...",
|
||||
"pointSearchPlaceholder": "Поиск по точкам...",
|
||||
"latLng": "Широта/Долгота",
|
||||
"save": "Сохранить",
|
||||
"launchAlt": "Высота старта (м)",
|
||||
"burstAlt": "Высота разрыва (м)",
|
||||
"ascentRate": "Скорость подъема (м/с)",
|
||||
"descentRate": "Скорость спуска (м/с)",
|
||||
"run": "Выполнить прогнозирование",
|
||||
"profileEdit": "Профили подъема и спуска",
|
||||
"ascentStage": "Стадия подъема",
|
||||
"descentStage": "Стадия спуска",
|
||||
"stageNone": "Нет",
|
||||
"stageStandard": "Стандартная",
|
||||
"stageCustom": "Пользовательская",
|
||||
"openCurveEditor": "Открыть редактор кривых"
|
||||
},
|
||||
"workspaces": {
|
||||
"title": "Рабочие области",
|
||||
"empty": "Нет созданных областей.",
|
||||
"add": "Добавить",
|
||||
"rename": "Переименовать",
|
||||
"delete": "Удалить",
|
||||
"visible": "Показать/скрыть",
|
||||
"color": "Цвет",
|
||||
"opacity": "Прозрачность",
|
||||
"run": "Рассчитать",
|
||||
"running": "Расчет...",
|
||||
"edit": "Редактировать",
|
||||
"defaultName": "Рабочая область {n}",
|
||||
"deleteConfirm": "Удалить рабочую область \"{name}\"?",
|
||||
"runError": "Ошибка расчета: {error}"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Временная шкала",
|
||||
"time": "Время",
|
||||
"altitude": "Высота",
|
||||
"position": "Позиция",
|
||||
"play": "Воспроизвести",
|
||||
"pause": "Пауза",
|
||||
"stop": "Сбросить",
|
||||
"speed": "Скорость"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Настройки",
|
||||
"language": "Язык",
|
||||
"map": "Карта",
|
||||
"baseLayer": "Базовый слой",
|
||||
"showScale": "Показывать масштаб",
|
||||
"showNavigation": "Навигационные элементы",
|
||||
"units": "Единицы измерения",
|
||||
"metric": "Метрические",
|
||||
"imperial": "Имперские",
|
||||
"saved": "Настройки сохранены"
|
||||
},
|
||||
"editor": {
|
||||
"add": "Добавить",
|
||||
"edit": "Редактировать",
|
||||
"save": "Сохранить",
|
||||
"update": "Обновить",
|
||||
"delete": "Удалить",
|
||||
"cancel": "Отмена",
|
||||
"close": "Закрыть",
|
||||
"searchPlaceholder": "Поиск..."
|
||||
},
|
||||
"points": {
|
||||
"item": "точка",
|
||||
"itemGenitive": "точки",
|
||||
"items": "точки",
|
||||
"name": "Название точки",
|
||||
"lat": "Широта",
|
||||
"lon": "Долгота",
|
||||
"alt": "Высота",
|
||||
"degrees": "Градусы",
|
||||
"metersAsl": "Метры над ур. моря"
|
||||
},
|
||||
"common": {
|
||||
"error": "Ошибка",
|
||||
"success": "Успех",
|
||||
"warning": "Предупреждение",
|
||||
"info": "Информация",
|
||||
"yes": "Да",
|
||||
"no": "Нет"
|
||||
},
|
||||
"selection": {
|
||||
"header": "Режим выбора координат",
|
||||
"body": "Кликните на карту, чтобы выбрать координаты"
|
||||
},
|
||||
"forecast": {
|
||||
"success": "Запрос прогноза",
|
||||
"successBody": "Запрос прогноза успешно выполнен!",
|
||||
"error": "Ошибка прогноза",
|
||||
"errorBody": "Ошибка при получении прогноза: {error}"
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
// place files you want to import through the `$lib` alias in this folder.
|
||||
77
src/lib/map/Map.svelte
Normal file
77
src/lib/map/Map.svelte
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy, type Snippet } from 'svelte';
|
||||
import type { IMap } from './core';
|
||||
import type { LngLatTuple } from '$domain';
|
||||
import { createMapLibreMap } from './maplibre';
|
||||
import { setMapContext } from './context';
|
||||
|
||||
interface Props {
|
||||
center?: LngLatTuple;
|
||||
zoom?: number;
|
||||
baseLayer?: 'osm' | 'satellite';
|
||||
showNavigationControl?: boolean;
|
||||
showScaleControl?: boolean;
|
||||
children?: Snippet;
|
||||
onReady?: (map: IMap) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
center = [129.1234, 62.1234],
|
||||
zoom = 4,
|
||||
baseLayer = 'osm',
|
||||
showNavigationControl = true,
|
||||
showScaleControl = true,
|
||||
children,
|
||||
onReady,
|
||||
}: Props = $props();
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let map: IMap | null = $state(null);
|
||||
/**
|
||||
* Children must not render until the map's first `load` event. MapLibre
|
||||
* throws if addSource/addLayer is called on an unloaded style, and this
|
||||
* component is the natural gate for that invariant.
|
||||
*/
|
||||
let ready = $state(false);
|
||||
|
||||
setMapContext(() => map);
|
||||
|
||||
export function getInstance(): IMap | null {
|
||||
return map;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
map = createMapLibreMap({
|
||||
container,
|
||||
center,
|
||||
zoom,
|
||||
baseLayer,
|
||||
showNavigationControl,
|
||||
showScaleControl,
|
||||
});
|
||||
map.ready.then(() => {
|
||||
ready = true;
|
||||
if (map) {
|
||||
onReady?.(map);
|
||||
if (import.meta.env.DEV) {
|
||||
// Debug handle for e2e tests and console inspection. Only exposed
|
||||
// in dev builds; trimmed from production bundles.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any)._lsvMap = map.getRawInstance();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
map?.dispose();
|
||||
map = null;
|
||||
ready = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="map-container" bind:this={container}>
|
||||
{#if ready && map}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
</div>
|
||||
17
src/lib/map/context.ts
Normal file
17
src/lib/map/context.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { getContext, setContext } from 'svelte';
|
||||
import type { IMap } from './core';
|
||||
|
||||
const KEY = Symbol('lsv-map');
|
||||
|
||||
/** Child components fetch the `IMap` instance via `getMap()`. */
|
||||
export interface MapContext {
|
||||
get(): IMap | null;
|
||||
}
|
||||
|
||||
export function setMapContext(getter: () => IMap | null): void {
|
||||
setContext<MapContext>(KEY, { get: getter });
|
||||
}
|
||||
|
||||
export function getMap(): IMap | null {
|
||||
return getContext<MapContext | undefined>(KEY)?.get() ?? null;
|
||||
}
|
||||
121
src/lib/map/core.ts
Normal file
121
src/lib/map/core.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import type { LatLngTuple, LngLatTuple } from '$domain';
|
||||
|
||||
/**
|
||||
* Map abstraction.
|
||||
*
|
||||
* Goals:
|
||||
* - Isolate all MapLibre-specific types inside src/lib/map/maplibre.ts.
|
||||
* - Expose a small, map-library-agnostic vocabulary (markers, polylines,
|
||||
* icons, events) so features (workspaces, timeline, tools) can be tested
|
||||
* against the interface alone.
|
||||
* - Support "scenes" — named collections of layers owned by a feature so
|
||||
* each workspace/tool can add/remove everything it owns atomically.
|
||||
*
|
||||
* If another library ever replaces MapLibre, implementing IMap is the only
|
||||
* file that changes.
|
||||
*/
|
||||
|
||||
export type MapEvent = 'click' | 'mousemove' | 'move' | 'zoom' | 'load';
|
||||
|
||||
export interface MapClickEvent {
|
||||
lngLat: { lat: number; lng: number };
|
||||
originalEvent: MouseEvent;
|
||||
}
|
||||
|
||||
export type MapEventPayload = {
|
||||
click: MapClickEvent;
|
||||
mousemove: MapClickEvent;
|
||||
move: { center: LngLatTuple; zoom: number };
|
||||
zoom: { zoom: number };
|
||||
load: undefined;
|
||||
};
|
||||
|
||||
export type MapEventHandler<E extends MapEvent> = (e: MapEventPayload[E]) => void;
|
||||
|
||||
export interface MarkerOptions {
|
||||
lngLat: LngLatTuple;
|
||||
iconUrl?: string;
|
||||
iconSize?: [number, number];
|
||||
className?: string;
|
||||
/** Optional HTML shown in a popup on hover. */
|
||||
popupHtml?: string;
|
||||
}
|
||||
|
||||
export interface LineOptions {
|
||||
coords: LatLngTuple[];
|
||||
color?: string;
|
||||
width?: number;
|
||||
opacity?: number;
|
||||
dashArray?: [number, number];
|
||||
}
|
||||
|
||||
export interface CircleOptions {
|
||||
center: LngLatTuple;
|
||||
/** Radius in pixels (visual; screen-space, matches MapLibre circle layers). */
|
||||
radiusPx?: number;
|
||||
color?: string;
|
||||
opacity?: number;
|
||||
strokeColor?: string;
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
export interface Marker {
|
||||
setLngLat(pos: LngLatTuple): void;
|
||||
remove(): void;
|
||||
}
|
||||
|
||||
export interface MapLayer {
|
||||
readonly id: string;
|
||||
remove(): void;
|
||||
}
|
||||
|
||||
export interface Scene {
|
||||
readonly name: string;
|
||||
addLine(id: string, options: LineOptions): MapLayer;
|
||||
addCircle(id: string, options: CircleOptions): MapLayer;
|
||||
addMarker(id: string, options: MarkerOptions): Marker;
|
||||
/** Remove an individual layer in this scene by its id. */
|
||||
remove(id: string): void;
|
||||
/** Remove everything added to this scene. */
|
||||
clear(): void;
|
||||
/** Called by IMap.dispose() to release resources. */
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface IMap {
|
||||
ready: Promise<void>;
|
||||
|
||||
on<E extends MapEvent>(event: E, handler: MapEventHandler<E>): () => void;
|
||||
|
||||
setCenter(pos: LngLatTuple, zoom?: number): void;
|
||||
panTo(pos: LngLatTuple, durationMs?: number): void;
|
||||
fitBounds(coords: LatLngTuple[], paddingPx?: number): void;
|
||||
getZoom(): number;
|
||||
setZoom(zoom: number): void;
|
||||
|
||||
setCursor(cursor: string | null): void;
|
||||
|
||||
/**
|
||||
* Get or create a named scene. Scenes are the unit of layer ownership:
|
||||
* a feature adds all its layers through a scene and calls `.clear()` to
|
||||
* remove them in one step.
|
||||
*/
|
||||
scene(name: string): Scene;
|
||||
disposeScene(name: string): void;
|
||||
|
||||
/** Underlying implementation instance, for code that needs library-specific APIs. Use sparingly. */
|
||||
getRawInstance(): unknown;
|
||||
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface MapInit {
|
||||
container: HTMLElement;
|
||||
center: LngLatTuple;
|
||||
zoom: number;
|
||||
baseLayer?: 'osm' | 'satellite';
|
||||
showNavigationControl?: boolean;
|
||||
showScaleControl?: boolean;
|
||||
}
|
||||
|
||||
export type MapFactory = (init: MapInit) => IMap;
|
||||
9
src/lib/map/index.ts
Normal file
9
src/lib/map/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export * from './core';
|
||||
export { createMapLibreMap } from './maplibre';
|
||||
export { plotPrediction, plotTelemetry, plotAnimatedMarker } from './layers';
|
||||
export type { TrajectoryStyle } from './layers';
|
||||
export { startCoordinateSelection } from './tools/selection';
|
||||
export { startMeasure } from './tools/measure';
|
||||
export type { MeasureHandle, MeasureOptions } from './tools/measure';
|
||||
export { setMapContext, getMap } from './context';
|
||||
export { default as Map } from './Map.svelte';
|
||||
124
src/lib/map/layers.ts
Normal file
124
src/lib/map/layers.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import type { Prediction, Telemetry } from '$domain';
|
||||
import { toLngLat } from '$domain';
|
||||
import type { IMap, Scene } from './core';
|
||||
|
||||
/**
|
||||
* Plot helpers for high-level domain objects. These live outside MapLibreMap
|
||||
* so they can be reused against any IMap implementation.
|
||||
*
|
||||
* Icons are served from /static; pass explicit overrides if a workspace
|
||||
* should use custom markers.
|
||||
*/
|
||||
|
||||
export interface TrajectoryStyle {
|
||||
color?: string;
|
||||
width?: number;
|
||||
opacity?: number;
|
||||
launchIcon?: string;
|
||||
landingIcon?: string;
|
||||
burstIcon?: string;
|
||||
iconSize?: [number, number];
|
||||
}
|
||||
|
||||
const DEFAULT_STYLE: Required<Omit<TrajectoryStyle, 'launchIcon' | 'landingIcon' | 'burstIcon'>> &
|
||||
Pick<TrajectoryStyle, 'launchIcon' | 'landingIcon' | 'burstIcon'> = {
|
||||
color: '#000000',
|
||||
width: 3,
|
||||
opacity: 1,
|
||||
iconSize: [12, 12],
|
||||
launchIcon: '/target-blue.png',
|
||||
landingIcon: '/target-red.png',
|
||||
burstIcon: '/pop-marker.png',
|
||||
};
|
||||
|
||||
export function plotPrediction(
|
||||
scene: Scene,
|
||||
prediction: Prediction,
|
||||
style: TrajectoryStyle = {},
|
||||
): void {
|
||||
const s = { ...DEFAULT_STYLE, ...style };
|
||||
scene.clear();
|
||||
|
||||
scene.addLine('path', {
|
||||
coords: prediction.flight_path,
|
||||
color: s.color,
|
||||
width: s.width,
|
||||
opacity: s.opacity,
|
||||
});
|
||||
|
||||
scene.addMarker('launch', {
|
||||
lngLat: toLngLat(prediction.launch.latlng),
|
||||
iconUrl: s.launchIcon,
|
||||
iconSize: s.iconSize,
|
||||
popupHtml: `<b>Launch</b><br>${prediction.launch.latlng.lat.toFixed(6)}, ${prediction.launch.latlng.lng.toFixed(6)}`,
|
||||
});
|
||||
|
||||
scene.addMarker('landing', {
|
||||
lngLat: toLngLat(prediction.landing.latlng),
|
||||
iconUrl: s.landingIcon,
|
||||
iconSize: s.iconSize,
|
||||
popupHtml: `<b>Landing</b><br>${prediction.landing.latlng.lat.toFixed(6)}, ${prediction.landing.latlng.lng.toFixed(6)}`,
|
||||
});
|
||||
|
||||
scene.addMarker('burst', {
|
||||
lngLat: toLngLat(prediction.burst.latlng),
|
||||
iconUrl: s.burstIcon,
|
||||
iconSize: [s.iconSize[0] + 4, s.iconSize[1] + 4],
|
||||
popupHtml: `<b>Burst</b><br>${prediction.burst.latlng.lat.toFixed(6)}, ${prediction.burst.latlng.lng.toFixed(6)}`,
|
||||
});
|
||||
}
|
||||
|
||||
export function plotTelemetry(
|
||||
map: IMap,
|
||||
scene: Scene,
|
||||
telemetry: Telemetry,
|
||||
style: TrajectoryStyle = {},
|
||||
): void {
|
||||
const s = { ...DEFAULT_STYLE, ...style };
|
||||
scene.clear();
|
||||
|
||||
scene.addLine('path', {
|
||||
coords: telemetry.flight_path,
|
||||
color: s.color,
|
||||
width: s.width,
|
||||
opacity: s.opacity,
|
||||
});
|
||||
|
||||
scene.addMarker('launch', {
|
||||
lngLat: toLngLat(telemetry.launch.latlng),
|
||||
iconUrl: s.launchIcon,
|
||||
iconSize: s.iconSize,
|
||||
});
|
||||
|
||||
telemetry.datapoints.forEach((p, i) => {
|
||||
scene.addMarker(`point-${i}`, {
|
||||
lngLat: [p.longitude, p.latitude],
|
||||
iconUrl: '/marker-sm-red.png',
|
||||
iconSize: [8, 8],
|
||||
popupHtml: `<b>${p.datetime}</b><br>${p.latitude.toFixed(6)}, ${p.longitude.toFixed(6)}`,
|
||||
});
|
||||
});
|
||||
|
||||
if (telemetry.flight_path.length > 0) {
|
||||
map.fitBounds(telemetry.flight_path, 50);
|
||||
}
|
||||
}
|
||||
|
||||
export function plotAnimatedMarker(scene: Scene, lng: number, lat: number): void {
|
||||
scene.clear();
|
||||
scene.addCircle('marker-ring', {
|
||||
center: [lng, lat],
|
||||
radiusPx: 14,
|
||||
color: '#FF6B6B',
|
||||
opacity: 0.3,
|
||||
strokeColor: '#FF1744',
|
||||
strokeWidth: 0,
|
||||
});
|
||||
scene.addCircle('marker-core', {
|
||||
center: [lng, lat],
|
||||
radiusPx: 6,
|
||||
color: '#FF1744',
|
||||
strokeColor: '#ffffff',
|
||||
strokeWidth: 2,
|
||||
});
|
||||
}
|
||||
316
src/lib/map/maplibre.ts
Normal file
316
src/lib/map/maplibre.ts
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
import maplibregl, {
|
||||
type Map as MLMap,
|
||||
type LngLatLike,
|
||||
type MarkerOptions as MLMarkerOptions,
|
||||
} from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
|
||||
import type {
|
||||
CircleOptions,
|
||||
IMap,
|
||||
LineOptions,
|
||||
MapEvent,
|
||||
MapEventHandler,
|
||||
MapEventPayload,
|
||||
MapInit,
|
||||
MapLayer,
|
||||
Marker,
|
||||
MarkerOptions,
|
||||
Scene,
|
||||
} from './core';
|
||||
import type { LatLngTuple, LngLatTuple } from '$domain';
|
||||
|
||||
/** Map common base-layer names to MapLibre style JSON. */
|
||||
const BASE_STYLES: Record<NonNullable<MapInit['baseLayer']>, maplibregl.StyleSpecification> = {
|
||||
osm: {
|
||||
version: 8,
|
||||
sources: {
|
||||
osm: {
|
||||
type: 'raster',
|
||||
tiles: ['https://a.tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
},
|
||||
},
|
||||
layers: [{ id: 'osm', type: 'raster', source: 'osm', minzoom: 0, maxzoom: 19 }],
|
||||
},
|
||||
satellite: {
|
||||
version: 8,
|
||||
sources: {
|
||||
sat: {
|
||||
type: 'raster',
|
||||
tiles: [
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
],
|
||||
tileSize: 256,
|
||||
attribution: 'Tiles © Esri',
|
||||
},
|
||||
},
|
||||
layers: [{ id: 'sat', type: 'raster', source: 'sat', minzoom: 0, maxzoom: 19 }],
|
||||
},
|
||||
};
|
||||
|
||||
class MapLibreScene implements Scene {
|
||||
private sources = new Set<string>();
|
||||
private layers = new Set<string>();
|
||||
private markers = new Map<string, maplibregl.Marker>();
|
||||
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
private map: MLMap,
|
||||
) {}
|
||||
|
||||
private scopeId(id: string): string {
|
||||
return `${this.name}__${id}`;
|
||||
}
|
||||
|
||||
addLine(id: string, options: LineOptions): MapLayer {
|
||||
const layerId = this.scopeId(id);
|
||||
const coords = options.coords.map<[number, number]>((c) => [c[1], c[0]]);
|
||||
|
||||
if (this.map.getSource(layerId)) this.remove(id);
|
||||
|
||||
this.map.addSource(layerId, {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: { type: 'LineString', coordinates: coords },
|
||||
},
|
||||
});
|
||||
this.map.addLayer({
|
||||
id: layerId,
|
||||
type: 'line',
|
||||
source: layerId,
|
||||
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||||
paint: {
|
||||
'line-color': options.color ?? '#000',
|
||||
'line-width': options.width ?? 3,
|
||||
'line-opacity': options.opacity ?? 1,
|
||||
...(options.dashArray ? { 'line-dasharray': options.dashArray } : {}),
|
||||
},
|
||||
});
|
||||
this.sources.add(layerId);
|
||||
this.layers.add(layerId);
|
||||
|
||||
return {
|
||||
id: layerId,
|
||||
remove: () => this.remove(id),
|
||||
};
|
||||
}
|
||||
|
||||
addCircle(id: string, options: CircleOptions): MapLayer {
|
||||
const layerId = this.scopeId(id);
|
||||
if (this.map.getSource(layerId)) this.remove(id);
|
||||
|
||||
this.map.addSource(layerId, {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: { type: 'Point', coordinates: options.center },
|
||||
},
|
||||
});
|
||||
this.map.addLayer({
|
||||
id: layerId,
|
||||
type: 'circle',
|
||||
source: layerId,
|
||||
paint: {
|
||||
'circle-radius': options.radiusPx ?? 6,
|
||||
'circle-color': options.color ?? '#0b5ed7',
|
||||
'circle-opacity': options.opacity ?? 1,
|
||||
'circle-stroke-color': options.strokeColor ?? '#ffffff',
|
||||
'circle-stroke-width': options.strokeWidth ?? 2,
|
||||
},
|
||||
});
|
||||
this.sources.add(layerId);
|
||||
this.layers.add(layerId);
|
||||
|
||||
return {
|
||||
id: layerId,
|
||||
remove: () => this.remove(id),
|
||||
};
|
||||
}
|
||||
|
||||
addMarker(id: string, options: MarkerOptions): Marker {
|
||||
const scoped = this.scopeId(id);
|
||||
const existing = this.markers.get(scoped);
|
||||
if (existing) existing.remove();
|
||||
|
||||
let mlOptions: MLMarkerOptions | undefined;
|
||||
if (options.iconUrl) {
|
||||
const el = document.createElement('div');
|
||||
el.className = options.className ?? 'lsv-marker';
|
||||
el.style.backgroundImage = `url(${options.iconUrl})`;
|
||||
const [w, h] = options.iconSize ?? [12, 12];
|
||||
el.style.width = `${w}px`;
|
||||
el.style.height = `${h}px`;
|
||||
el.style.backgroundSize = '100%';
|
||||
mlOptions = { element: el };
|
||||
}
|
||||
|
||||
const marker = new maplibregl.Marker(mlOptions).setLngLat(options.lngLat as LngLatLike);
|
||||
|
||||
if (options.popupHtml) {
|
||||
const popup = new maplibregl.Popup({ offset: 16, closeButton: false }).setHTML(
|
||||
options.popupHtml,
|
||||
);
|
||||
marker.setPopup(popup);
|
||||
marker.getElement().addEventListener('mouseenter', () => marker.togglePopup());
|
||||
marker.getElement().addEventListener('mouseleave', () => marker.togglePopup());
|
||||
}
|
||||
|
||||
marker.addTo(this.map);
|
||||
this.markers.set(scoped, marker);
|
||||
|
||||
return {
|
||||
setLngLat: (pos) => marker.setLngLat(pos as LngLatLike),
|
||||
remove: () => this.remove(id),
|
||||
};
|
||||
}
|
||||
|
||||
remove(id: string): void {
|
||||
const scoped = this.scopeId(id);
|
||||
if (this.map.getLayer(scoped)) this.map.removeLayer(scoped);
|
||||
if (this.map.getSource(scoped)) this.map.removeSource(scoped);
|
||||
this.layers.delete(scoped);
|
||||
this.sources.delete(scoped);
|
||||
const marker = this.markers.get(scoped);
|
||||
if (marker) {
|
||||
marker.remove();
|
||||
this.markers.delete(scoped);
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
for (const id of Array.from(this.layers)) {
|
||||
if (this.map.getLayer(id)) this.map.removeLayer(id);
|
||||
}
|
||||
for (const id of Array.from(this.sources)) {
|
||||
if (this.map.getSource(id)) this.map.removeSource(id);
|
||||
}
|
||||
for (const marker of this.markers.values()) marker.remove();
|
||||
this.layers.clear();
|
||||
this.sources.clear();
|
||||
this.markers.clear();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export class MapLibreMap implements IMap {
|
||||
private map: MLMap;
|
||||
private scenes = new Map<string, MapLibreScene>();
|
||||
public readonly ready: Promise<void>;
|
||||
|
||||
constructor(init: MapInit) {
|
||||
this.map = new maplibregl.Map({
|
||||
container: init.container,
|
||||
style: BASE_STYLES[init.baseLayer ?? 'osm'],
|
||||
center: init.center,
|
||||
zoom: init.zoom,
|
||||
});
|
||||
|
||||
if (init.showNavigationControl !== false) {
|
||||
this.map.addControl(new maplibregl.NavigationControl(), 'bottom-left');
|
||||
}
|
||||
if (init.showScaleControl !== false) {
|
||||
this.map.addControl(new maplibregl.ScaleControl({ maxWidth: 100, unit: 'metric' }), 'bottom-right');
|
||||
}
|
||||
|
||||
this.ready = new Promise((resolve) => this.map.once('load', () => resolve()));
|
||||
}
|
||||
|
||||
on<E extends MapEvent>(event: E, handler: MapEventHandler<E>): () => void {
|
||||
const wrapped = (e: unknown) => {
|
||||
switch (event) {
|
||||
case 'click':
|
||||
case 'mousemove': {
|
||||
const ev = e as maplibregl.MapMouseEvent;
|
||||
(handler as MapEventHandler<'click'>)({
|
||||
lngLat: { lat: ev.lngLat.lat, lng: ev.lngLat.lng },
|
||||
originalEvent: ev.originalEvent,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'move':
|
||||
(handler as MapEventHandler<'move'>)({
|
||||
center: [this.map.getCenter().lng, this.map.getCenter().lat],
|
||||
zoom: this.map.getZoom(),
|
||||
});
|
||||
break;
|
||||
case 'zoom':
|
||||
(handler as MapEventHandler<'zoom'>)({ zoom: this.map.getZoom() });
|
||||
break;
|
||||
case 'load':
|
||||
(handler as MapEventHandler<'load'>)(undefined as MapEventPayload['load']);
|
||||
break;
|
||||
}
|
||||
};
|
||||
this.map.on(event as 'click', wrapped);
|
||||
return () => this.map.off(event as 'click', wrapped);
|
||||
}
|
||||
|
||||
setCenter(pos: LngLatTuple, zoom?: number): void {
|
||||
this.map.setCenter(pos);
|
||||
if (zoom !== undefined) this.map.setZoom(zoom);
|
||||
}
|
||||
|
||||
panTo(pos: LngLatTuple, durationMs?: number): void {
|
||||
this.map.panTo(pos, durationMs ? { duration: durationMs } : undefined);
|
||||
}
|
||||
|
||||
fitBounds(coords: LatLngTuple[], paddingPx = 50): void {
|
||||
if (coords.length === 0) return;
|
||||
const first: [number, number] = [coords[0][1], coords[0][0]];
|
||||
const bounds = coords.reduce(
|
||||
(b, c) => b.extend([c[1], c[0]] as [number, number]),
|
||||
new maplibregl.LngLatBounds(first, first),
|
||||
);
|
||||
this.map.fitBounds(bounds, { padding: paddingPx });
|
||||
}
|
||||
|
||||
getZoom(): number {
|
||||
return this.map.getZoom();
|
||||
}
|
||||
|
||||
setZoom(zoom: number): void {
|
||||
this.map.setZoom(zoom);
|
||||
}
|
||||
|
||||
setCursor(cursor: string | null): void {
|
||||
this.map.getCanvas().style.cursor = cursor ?? '';
|
||||
}
|
||||
|
||||
scene(name: string): Scene {
|
||||
let scene = this.scenes.get(name);
|
||||
if (!scene) {
|
||||
scene = new MapLibreScene(name, this.map);
|
||||
this.scenes.set(name, scene);
|
||||
}
|
||||
return scene;
|
||||
}
|
||||
|
||||
disposeScene(name: string): void {
|
||||
const scene = this.scenes.get(name);
|
||||
if (!scene) return;
|
||||
scene.dispose();
|
||||
this.scenes.delete(name);
|
||||
}
|
||||
|
||||
getRawInstance(): MLMap {
|
||||
return this.map;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const s of this.scenes.values()) s.dispose();
|
||||
this.scenes.clear();
|
||||
this.map.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export function createMapLibreMap(init: MapInit): IMap {
|
||||
return new MapLibreMap(init);
|
||||
}
|
||||
75
src/lib/map/tools/measure.ts
Normal file
75
src/lib/map/tools/measure.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import type { IMap } from '../core';
|
||||
import type { LatLngTuple } from '$domain';
|
||||
import { distHaversine } from '$domain';
|
||||
|
||||
/**
|
||||
* Library-agnostic measure tool. Click to drop points; each new point draws
|
||||
* a segment and reports the running total distance in kilometers.
|
||||
*
|
||||
* Returns a disposer that removes all overlays and deactivates listeners.
|
||||
*/
|
||||
export interface MeasureHandle {
|
||||
dispose(): void;
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
export interface MeasureOptions {
|
||||
onUpdate?: (totalKm: number, points: LatLngTuple[]) => void;
|
||||
color?: string;
|
||||
width?: number;
|
||||
sceneName?: string;
|
||||
}
|
||||
|
||||
export function startMeasure(map: IMap, options: MeasureOptions = {}): MeasureHandle {
|
||||
const scene = map.scene(options.sceneName ?? 'measure');
|
||||
const points: LatLngTuple[] = [];
|
||||
let segmentCounter = 0;
|
||||
let pointCounter = 0;
|
||||
let total = 0;
|
||||
|
||||
map.setCursor('crosshair');
|
||||
|
||||
const off = map.on('click', (e) => {
|
||||
const latlng: LatLngTuple = [e.lngLat.lat, e.lngLat.lng];
|
||||
points.push(latlng);
|
||||
|
||||
scene.addMarker(`p-${pointCounter++}`, {
|
||||
lngLat: [e.lngLat.lng, e.lngLat.lat],
|
||||
iconSize: [8, 8],
|
||||
className: 'lsv-measure-dot',
|
||||
});
|
||||
|
||||
if (points.length > 1) {
|
||||
const prev = points[points.length - 2];
|
||||
total += distHaversine(
|
||||
{ lat: prev[0], lng: prev[1] },
|
||||
{ lat: latlng[0], lng: latlng[1] },
|
||||
);
|
||||
scene.addLine(`seg-${segmentCounter++}`, {
|
||||
coords: [prev, latlng],
|
||||
color: options.color ?? '#2563eb',
|
||||
width: options.width ?? 2,
|
||||
});
|
||||
}
|
||||
|
||||
options.onUpdate?.(total, [...points]);
|
||||
});
|
||||
|
||||
function reset() {
|
||||
scene.clear();
|
||||
points.length = 0;
|
||||
total = 0;
|
||||
segmentCounter = 0;
|
||||
pointCounter = 0;
|
||||
options.onUpdate?.(0, []);
|
||||
}
|
||||
|
||||
return {
|
||||
dispose() {
|
||||
off();
|
||||
map.setCursor(null);
|
||||
map.disposeScene(options.sceneName ?? 'measure');
|
||||
},
|
||||
reset,
|
||||
};
|
||||
}
|
||||
23
src/lib/map/tools/selection.ts
Normal file
23
src/lib/map/tools/selection.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { IMap } from '../core';
|
||||
|
||||
/**
|
||||
* One-shot coordinate selection tool: next click reports a lat/lng and the
|
||||
* tool disarms itself.
|
||||
*/
|
||||
export function startCoordinateSelection(
|
||||
map: IMap,
|
||||
onSelect: (coords: { lat: number; lng: number }) => void,
|
||||
): () => void {
|
||||
map.setCursor('crosshair');
|
||||
|
||||
const off = map.on('click', (e) => {
|
||||
map.setCursor(null);
|
||||
off();
|
||||
onSelect(e.lngLat);
|
||||
});
|
||||
|
||||
return () => {
|
||||
map.setCursor(null);
|
||||
off();
|
||||
};
|
||||
}
|
||||
2
src/lib/state/index.ts
Normal file
2
src/lib/state/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { persisted } from './persisted';
|
||||
export type { Serializer, PersistedOptions } from './persisted';
|
||||
131
src/lib/state/persisted.ts
Normal file
131
src/lib/state/persisted.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { writable, type Writable, type Updater } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
/**
|
||||
* Writable store backed by localStorage, synced across tabs via
|
||||
* BroadcastChannel. The store initializes with a snapshot from storage if
|
||||
* present and falls back to `initial` otherwise. Subsequent writes are
|
||||
* serialized to localStorage and broadcast to other tabs.
|
||||
*
|
||||
* Notes
|
||||
* -----
|
||||
* - Serialization uses JSON by default; pass custom `serializer` for Dates etc.
|
||||
* - Reads on the server (or before hydration) return `initial` — no storage
|
||||
* access happens until the `browser` flag is true.
|
||||
* - Removing the key from storage (via another tab or devtools) resets the
|
||||
* store to `initial` on the next notification.
|
||||
* - A per-store "suppress rebroadcast" flag prevents a ping-pong loop when a
|
||||
* remote update arrives: the incoming `set` must not trigger a new
|
||||
* broadcast or we'd echo forever.
|
||||
*/
|
||||
|
||||
export interface Serializer<T> {
|
||||
parse: (raw: string) => T;
|
||||
stringify: (value: T) => string;
|
||||
}
|
||||
|
||||
const json: Serializer<unknown> = { parse: JSON.parse, stringify: JSON.stringify };
|
||||
|
||||
export interface PersistedOptions<T> {
|
||||
serializer?: Serializer<T>;
|
||||
/**
|
||||
* Skip cross-tab sync if multiple stores must not share a channel or if a
|
||||
* store's value is too large to broadcast efficiently.
|
||||
*/
|
||||
syncTabs?: boolean;
|
||||
}
|
||||
|
||||
const CHANNEL = 'leaflet-svelte:store';
|
||||
|
||||
let channel: BroadcastChannel | null = null;
|
||||
function getChannel(): BroadcastChannel | null {
|
||||
if (!browser) return null;
|
||||
if (typeof BroadcastChannel === 'undefined') return null;
|
||||
channel ??= new BroadcastChannel(CHANNEL);
|
||||
return channel;
|
||||
}
|
||||
|
||||
export function persisted<T>(
|
||||
key: string,
|
||||
initial: T,
|
||||
options: PersistedOptions<T> = {},
|
||||
): Writable<T> {
|
||||
const serializer = (options.serializer ?? (json as Serializer<T>)) as Serializer<T>;
|
||||
const syncTabs = options.syncTabs ?? true;
|
||||
|
||||
const readFromStorage = (): T => {
|
||||
if (!browser) return initial;
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw === null) return initial;
|
||||
try {
|
||||
return serializer.parse(raw);
|
||||
} catch (e) {
|
||||
console.warn(`[persisted] failed to parse "${key}":`, e);
|
||||
return initial;
|
||||
}
|
||||
};
|
||||
|
||||
const store = writable<T>(readFromStorage());
|
||||
|
||||
const write = (value: T) => {
|
||||
if (!browser) return;
|
||||
try {
|
||||
localStorage.setItem(key, serializer.stringify(value));
|
||||
} catch (e) {
|
||||
console.warn(`[persisted] failed to write "${key}":`, e);
|
||||
}
|
||||
};
|
||||
|
||||
let firstTick = true;
|
||||
let remoteUpdate = false;
|
||||
|
||||
if (browser) {
|
||||
store.subscribe((value) => {
|
||||
// The first subscribe fires synchronously with the initial value. We
|
||||
// already read it from storage, so re-writing it is a no-op; skipping
|
||||
// also avoids a spurious broadcast to other tabs on every page load.
|
||||
if (firstTick) {
|
||||
firstTick = false;
|
||||
return;
|
||||
}
|
||||
write(value);
|
||||
if (syncTabs && !remoteUpdate) {
|
||||
getChannel()?.postMessage({ key, value });
|
||||
}
|
||||
});
|
||||
|
||||
if (syncTabs) {
|
||||
getChannel()?.addEventListener('message', (event) => {
|
||||
if (event.data?.key !== key) return;
|
||||
remoteUpdate = true;
|
||||
try {
|
||||
store.set(event.data.value);
|
||||
} finally {
|
||||
remoteUpdate = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.storageArea !== localStorage || event.key !== key) return;
|
||||
remoteUpdate = true;
|
||||
try {
|
||||
if (event.newValue === null) {
|
||||
store.set(initial);
|
||||
} else {
|
||||
store.set(serializer.parse(event.newValue));
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed writes from another tab
|
||||
} finally {
|
||||
remoteUpdate = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: store.subscribe,
|
||||
set: store.set,
|
||||
update: (fn: Updater<T>) => store.update(fn),
|
||||
};
|
||||
}
|
||||
60
src/lib/ui/CollapsibleCard.svelte
Normal file
60
src/lib/ui/CollapsibleCard.svelte
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<script lang="ts">
|
||||
import { Card, CardHeader, CardBody, Button, Icon } from '@sveltestrap/sveltestrap';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
/**
|
||||
* Standardized collapsible panel card used by every side-panel feature.
|
||||
* The header shows a title and a caret; clicking the header toggles body
|
||||
* visibility. The `initialCollapsed` prop seeds state but callers can
|
||||
* drive it externally via `bind:collapsed` if they want to persist state.
|
||||
*/
|
||||
interface Props {
|
||||
title?: string;
|
||||
collapsed?: boolean;
|
||||
headerClass?: string;
|
||||
bodyClass?: string;
|
||||
variant?: 'primary' | 'secondary' | 'light';
|
||||
headerExtra?: Snippet;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
title = '',
|
||||
collapsed = $bindable(false),
|
||||
headerClass = '',
|
||||
bodyClass = '',
|
||||
variant = 'primary',
|
||||
headerExtra,
|
||||
children,
|
||||
}: Props = $props();
|
||||
|
||||
function toggle() {
|
||||
collapsed = !collapsed;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card class="mb-2 collapsible-card">
|
||||
<CardHeader
|
||||
class="d-flex justify-content-between align-items-center card-header p-1 px-3 bg-{variant} text-white {headerClass}"
|
||||
style="cursor:pointer;">
|
||||
<button
|
||||
type="button"
|
||||
class="d-flex w-100 justify-content-between align-items-center bg-transparent border-0 p-0 text-white"
|
||||
style="width:100%;"
|
||||
aria-expanded={!collapsed}
|
||||
onclick={toggle}>
|
||||
<b class="card-title mb-0 text-white p-0">{title}</b>
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
{#if headerExtra}{@render headerExtra()}{/if}
|
||||
<Button class="p-0" size="sm" color={variant}>
|
||||
<Icon name={collapsed ? 'caret-left-fill' : 'caret-down-fill'} class="text-white" />
|
||||
</Button>
|
||||
</span>
|
||||
</button>
|
||||
</CardHeader>
|
||||
{#if !collapsed}
|
||||
<CardBody class={bodyClass}>
|
||||
{@render children?.()}
|
||||
</CardBody>
|
||||
{/if}
|
||||
</Card>
|
||||
49
src/lib/ui/ConfirmationPrompt.svelte
Normal file
49
src/lib/ui/ConfirmationPrompt.svelte
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<script lang="ts">
|
||||
import { Modal, ModalHeader, ModalBody, ModalFooter, Button } from '@sveltestrap/sveltestrap';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
isOpen?: boolean;
|
||||
title?: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
confirmVariant?: string;
|
||||
cancelVariant?: string;
|
||||
onconfirm?: () => void;
|
||||
oncancel?: () => void;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
isOpen = $bindable(false),
|
||||
title = 'Confirm',
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
confirmVariant = 'primary',
|
||||
cancelVariant = 'secondary',
|
||||
onconfirm,
|
||||
oncancel,
|
||||
children,
|
||||
}: Props = $props();
|
||||
|
||||
function handleConfirm() {
|
||||
onconfirm?.();
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
oncancel?.();
|
||||
isOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal {isOpen} toggle={handleCancel} fade={false} backdrop={true}>
|
||||
<ModalHeader toggle={handleCancel}>{title}</ModalHeader>
|
||||
<ModalBody>
|
||||
{#if children}{@render children()}{/if}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color={cancelVariant} on:click={handleCancel}>{cancelText}</Button>
|
||||
<Button color={confirmVariant} on:click={handleConfirm}>{confirmText}</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
56
src/lib/ui/EditableCell.svelte
Normal file
56
src/lib/ui/EditableCell.svelte
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
value?: number | null;
|
||||
onchange?: () => void;
|
||||
valuePrefix?: string;
|
||||
valueSuffix?: string;
|
||||
emptyValue?: number | null;
|
||||
emptyPlaceholder?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(),
|
||||
onchange = () => {},
|
||||
valuePrefix = '',
|
||||
valueSuffix = '',
|
||||
emptyValue = null,
|
||||
emptyPlaceholder = '-',
|
||||
}: Props = $props();
|
||||
|
||||
let editing = $state(false);
|
||||
let inputEl: HTMLInputElement | undefined = $state();
|
||||
|
||||
$effect(() => {
|
||||
if (editing && inputEl) inputEl.focus();
|
||||
});
|
||||
|
||||
function start() {
|
||||
editing = true;
|
||||
}
|
||||
|
||||
function stop() {
|
||||
editing = false;
|
||||
onchange();
|
||||
}
|
||||
|
||||
function keydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') stop();
|
||||
else if (event.key === 'Escape') editing = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<td onclick={start} onfocusin={start}>
|
||||
{#if editing}
|
||||
<input
|
||||
type="number"
|
||||
class="form-control form-control-sm border-0"
|
||||
bind:this={inputEl}
|
||||
bind:value
|
||||
onblur={stop}
|
||||
onkeydown={keydown} />
|
||||
{:else if value === emptyValue || value === null || value === undefined}
|
||||
<span class="text-muted">{emptyPlaceholder}</span>
|
||||
{:else}
|
||||
<span>{valuePrefix}{value}{valueSuffix}</span>
|
||||
{/if}
|
||||
</td>
|
||||
33
src/lib/ui/LabelGroup.svelte
Normal file
33
src/lib/ui/LabelGroup.svelte
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
id?: string;
|
||||
label?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { id, label = '', class: className = '', children, ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div {id} class="label-group {className}" {...rest}>
|
||||
<div class="btn btn-link p-0 d-flex align-items-center w-100 text-decoration-none label-header">
|
||||
<div class="border-top" style="width: 10px;"></div>
|
||||
<span class="small text-nowrap ms-2">{label}</span>
|
||||
<div class="flex-fill border-top ms-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="p-2 border border-top-0 label-content">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.label-header {
|
||||
margin-bottom: -0.75em;
|
||||
}
|
||||
.label-content {
|
||||
padding-top: 0.75em !important;
|
||||
}
|
||||
</style>
|
||||
38
src/lib/ui/PanelContainer.svelte
Normal file
38
src/lib/ui/PanelContainer.svelte
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
/**
|
||||
* Fixed-position side panel attached to the map. Stops mouse/touch
|
||||
* propagation so interactions inside the panel don't pan the map.
|
||||
*/
|
||||
interface Props {
|
||||
position?: 'left' | 'right';
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { position = 'left', children }: Props = $props();
|
||||
|
||||
let element: HTMLDivElement | null = $state(null);
|
||||
|
||||
export function getElement(): HTMLDivElement | null {
|
||||
return element;
|
||||
}
|
||||
|
||||
function stop(e: Event) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
bind:this={element}
|
||||
class="panel-container-{position}"
|
||||
onclick={stop}
|
||||
ondblclick={stop}
|
||||
onmousedown={stop}
|
||||
ontouchstart={stop}
|
||||
onwheel={stop}
|
||||
role="complementary">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
191
src/lib/ui/SelectSearchable.svelte
Normal file
191
src/lib/ui/SelectSearchable.svelte
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
<script lang="ts" generics="V">
|
||||
import { onMount } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
type Option = { value: V; label: string };
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
id?: string;
|
||||
options?: Option[];
|
||||
selected?: V | null;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
disabled?: boolean;
|
||||
clearable?: boolean;
|
||||
onChange?: (value: V | null) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
id = 'select-searchable',
|
||||
options = [],
|
||||
selected = $bindable<V | null>(null),
|
||||
placeholder = 'Select an option...',
|
||||
searchPlaceholder = 'Search...',
|
||||
disabled = false,
|
||||
clearable = false,
|
||||
onChange,
|
||||
class: className = '',
|
||||
...restProps
|
||||
}: Props = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
let searchTerm = $state('');
|
||||
let selectElement = $state<HTMLElement>();
|
||||
let dropdownElement = $state<HTMLElement>();
|
||||
let searchInput = $state<HTMLInputElement>();
|
||||
let dropdownStyle = $state('');
|
||||
|
||||
let filteredOptions = $derived(
|
||||
options.filter((o) => o.label.toLowerCase().includes(searchTerm.toLowerCase())),
|
||||
);
|
||||
|
||||
let selectedLabel = $derived(options.find((o) => o.value === selected)?.label ?? '');
|
||||
|
||||
function positionDropdown() {
|
||||
if (!selectElement || !dropdownElement) return;
|
||||
const rect = selectElement.getBoundingClientRect();
|
||||
const height = dropdownElement.offsetHeight;
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
const placeBelow = spaceBelow >= height || spaceBelow >= spaceAbove;
|
||||
dropdownStyle = [
|
||||
'position:fixed',
|
||||
`top:${placeBelow ? rect.bottom : 'auto'}${typeof rect.bottom === 'number' && placeBelow ? 'px' : ''}`,
|
||||
`bottom:${placeBelow ? 'auto' : `${window.innerHeight - rect.top}px`}`,
|
||||
`left:${rect.left}px`,
|
||||
`min-width:${rect.width}px`,
|
||||
].join(';');
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (disabled) return;
|
||||
isOpen = !isOpen;
|
||||
if (isOpen) {
|
||||
searchTerm = '';
|
||||
queueMicrotask(() => {
|
||||
positionDropdown();
|
||||
searchInput?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function pick(option: Option) {
|
||||
selected = option.value;
|
||||
isOpen = false;
|
||||
searchTerm = '';
|
||||
onChange?.(option.value);
|
||||
}
|
||||
|
||||
function clear(e: Event) {
|
||||
e.stopPropagation();
|
||||
selected = null;
|
||||
onChange?.(null);
|
||||
}
|
||||
|
||||
function handleOutside(e: MouseEvent) {
|
||||
if (selectElement && !selectElement.contains(e.target as Node)) isOpen = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!isOpen) return;
|
||||
const listener = () => positionDropdown();
|
||||
window.addEventListener('scroll', listener, true);
|
||||
window.addEventListener('resize', listener);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', listener, true);
|
||||
window.removeEventListener('resize', listener);
|
||||
};
|
||||
});
|
||||
|
||||
onMount(() => positionDropdown());
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleOutside} />
|
||||
|
||||
<div
|
||||
bind:this={selectElement}
|
||||
{id}
|
||||
class="form-control form-select select-container {className}"
|
||||
class:disabled
|
||||
class:show={isOpen}
|
||||
role="combobox"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
tabindex={disabled ? -1 : 0}
|
||||
onclick={toggle}
|
||||
onkeydown={(e) => e.key === 'Enter' && toggle()}
|
||||
{...restProps}>
|
||||
<span class:text-muted={selected == null}>{selectedLabel || placeholder}</span>
|
||||
|
||||
{#if clearable && selected != null}
|
||||
<button type="button" class="clear-btn" tabindex="-1" aria-label="Clear selection" onclick={clear}>
|
||||
✕
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if isOpen}
|
||||
<div class="pt-0 dropdown-menu show" bind:this={dropdownElement} style={dropdownStyle}>
|
||||
<div class="p-2">
|
||||
<input
|
||||
bind:this={searchInput}
|
||||
type="text"
|
||||
class="form-control form-control-sm"
|
||||
placeholder={searchPlaceholder}
|
||||
bind:value={searchTerm}
|
||||
onclick={(e) => e.stopPropagation()} />
|
||||
</div>
|
||||
<div class="options-list">
|
||||
{#each filteredOptions as option}
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-item small"
|
||||
class:active={option.value === selected}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
pick(option);
|
||||
}}
|
||||
role="option"
|
||||
aria-selected={option.value === selected}>
|
||||
{option.label}
|
||||
</button>
|
||||
{:else}
|
||||
<div class="dropdown-item text-muted disabled">No options found</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.select-container {
|
||||
position: relative;
|
||||
cursor: default;
|
||||
}
|
||||
.dropdown-menu {
|
||||
z-index: 1000;
|
||||
width: max-content;
|
||||
}
|
||||
.options-list {
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.clear-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 2rem;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: #2a2a2a;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.clear-btn:focus {
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
56
src/lib/ui/SpoilerGroup.svelte
Normal file
56
src/lib/ui/SpoilerGroup.svelte
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
id?: string;
|
||||
label?: string;
|
||||
expanded?: boolean;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
id,
|
||||
label = '',
|
||||
expanded = $bindable(false),
|
||||
class: className = '',
|
||||
children,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div {id} class="spoiler-group {className}" {...rest}>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-link p-0 d-flex align-items-center w-100 text-decoration-none spoiler-header"
|
||||
onclick={() => (expanded = !expanded)}
|
||||
aria-expanded={expanded}>
|
||||
<span class="font-monospace fs-5 ms-1 fw-bold text-muted spoiler-icon">{expanded ? '−' : '+'}</span>
|
||||
<span class="small text-nowrap ms-1">{label}</span>
|
||||
<div class="flex-fill border-top ms-1"></div>
|
||||
</button>
|
||||
|
||||
{#if expanded}
|
||||
<div class="p-2 border border-top-0 spoiler-content">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{:else}
|
||||
<div style="padding-top: 0.75em;"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.spoiler-header {
|
||||
margin-bottom: -0.75em;
|
||||
}
|
||||
.spoiler-content {
|
||||
padding-top: 0.75em !important;
|
||||
}
|
||||
.spoiler-icon {
|
||||
line-height: 1;
|
||||
padding-bottom: 0.1em;
|
||||
}
|
||||
.btn:hover .spoiler-icon {
|
||||
color: var(--bs-dark) !important;
|
||||
}
|
||||
</style>
|
||||
48
src/lib/ui/TabBar.svelte
Normal file
48
src/lib/ui/TabBar.svelte
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<script lang="ts" generics="T extends string">
|
||||
import { Icon } from '@sveltestrap/sveltestrap';
|
||||
|
||||
interface Tab {
|
||||
id: T;
|
||||
icon: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tabs: Tab[];
|
||||
active: T;
|
||||
justify?: 'start' | 'center' | 'end';
|
||||
}
|
||||
|
||||
let { tabs, active = $bindable(), justify = 'start' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="d-flex justify-content-{justify} mb-1 gap-1">
|
||||
{#each tabs as tab (tab.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="custom-tab btn btn-outline-primary d-flex flex-column align-items-center justify-content-center px-1"
|
||||
class:active={active === tab.id}
|
||||
onclick={() => (active = tab.id)}>
|
||||
<Icon name={tab.icon} class="custom-tab-icon" />
|
||||
<span class="custom-tab-label">{tab.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.custom-tab {
|
||||
width: 4.5rem;
|
||||
background: var(--bs-body-bg);
|
||||
}
|
||||
|
||||
.custom-tab.active,
|
||||
.custom-tab:hover {
|
||||
background-color: var(--bs-primary) !important;
|
||||
color: var(--bs-btn-active-color);
|
||||
}
|
||||
|
||||
.custom-tab-label {
|
||||
font-size: 0.66rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
40
src/lib/ui/Toast.svelte
Normal file
40
src/lib/ui/Toast.svelte
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<script lang="ts">
|
||||
import { Toast, ToastBody, ToastHeader, Icon } from '@sveltestrap/sveltestrap';
|
||||
import { toasts, removeToast, type ToastColor } from './toasts';
|
||||
|
||||
const ICONS: Record<ToastColor, string> = {
|
||||
primary: 'info-circle-fill',
|
||||
secondary: 'info-circle-fill',
|
||||
success: 'check-circle-fill',
|
||||
danger: 'exclamation-triangle-fill',
|
||||
warning: 'exclamation-circle-fill',
|
||||
info: 'info-circle-fill',
|
||||
light: 'lightbulb',
|
||||
dark: 'question',
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
{#each $toasts as toast (toast.id)}
|
||||
<Toast
|
||||
isOpen={true}
|
||||
autohide={!toast.persistent}
|
||||
delay={5000}
|
||||
color={toast.color}
|
||||
on:close={() => removeToast(toast.id)}>
|
||||
<ToastHeader toggle={() => removeToast(toast.id)} class={`text-${toast.color}`}>
|
||||
<Icon slot="icon" name={ICONS[toast.color]} class="me-2" color={toast.color} />
|
||||
{toast.header}
|
||||
</ToastHeader>
|
||||
<ToastBody>
|
||||
{toast.body}
|
||||
</ToastBody>
|
||||
</Toast>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toast-container {
|
||||
z-index: 1090;
|
||||
}
|
||||
</style>
|
||||
338
src/lib/ui/editor/ListEditor.svelte
Normal file
338
src/lib/ui/editor/ListEditor.svelte
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
<script module lang="ts">
|
||||
export interface ListEditorConfig {
|
||||
showTable?: boolean;
|
||||
closeOnSave?: boolean;
|
||||
closeOnDelete?: boolean;
|
||||
searchBy?: string[];
|
||||
labels?: {
|
||||
item?: string;
|
||||
itemGenitive?: string;
|
||||
items?: string;
|
||||
add?: string;
|
||||
edit?: string;
|
||||
save?: string;
|
||||
update?: string;
|
||||
delete?: string;
|
||||
cancel?: string;
|
||||
close?: string;
|
||||
searchPlaceholder?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ListEditorApi<T> {
|
||||
save: (item: T) => Promise<T>;
|
||||
update: (item: T) => Promise<T>;
|
||||
delete: (item: T) => Promise<void>;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts" generics="T extends { id: number; name: string }">
|
||||
import { TableHandler } from '@vincjo/datatables';
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Alert,
|
||||
Icon,
|
||||
Pagination,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
Input,
|
||||
InputGroup,
|
||||
} from '@sveltestrap/sveltestrap';
|
||||
import { addToast } from '../toasts';
|
||||
import ConfirmationPrompt from '../ConfirmationPrompt.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
isOpen?: boolean;
|
||||
items?: T[];
|
||||
itemFactory: () => T;
|
||||
api: ListEditorApi<T>;
|
||||
config?: ListEditorConfig;
|
||||
onClose?: () => void;
|
||||
onSave?: (item: T) => void;
|
||||
onSelect?: (item: T) => void;
|
||||
tableHeader: Snippet;
|
||||
tableRow: Snippet<[{ row: T }]>;
|
||||
formFields: Snippet<[{ item: T }]>;
|
||||
}
|
||||
|
||||
const DEFAULT_LABELS = {
|
||||
item: 'item',
|
||||
itemGenitive: 'item',
|
||||
items: 'items',
|
||||
add: 'Add',
|
||||
edit: 'Edit',
|
||||
save: 'Save',
|
||||
update: 'Update',
|
||||
delete: 'Delete',
|
||||
cancel: 'Cancel',
|
||||
close: 'Close',
|
||||
searchPlaceholder: 'Search...',
|
||||
};
|
||||
|
||||
let {
|
||||
isOpen = $bindable(false),
|
||||
items = $bindable([] as T[]),
|
||||
itemFactory,
|
||||
api,
|
||||
config = {},
|
||||
onClose = () => {},
|
||||
onSave = (_i: T) => {},
|
||||
onSelect = (_i: T) => {},
|
||||
tableHeader,
|
||||
tableRow,
|
||||
formFields,
|
||||
}: Props = $props();
|
||||
|
||||
const labels = $derived({ ...DEFAULT_LABELS, ...(config.labels ?? {}) });
|
||||
const searchBy = $derived((config.searchBy ?? ['name']) as (keyof T)[]);
|
||||
const showTableProp = $derived(config.showTable ?? false);
|
||||
|
||||
let isEditing = $state(false);
|
||||
let isAlertVisible = $state(false);
|
||||
let isConfirmationVisible = $state(false);
|
||||
let isTableVisible = $state(config.showTable ?? false);
|
||||
let alertText = $state('');
|
||||
let selectedItem = $state<T | null>(null);
|
||||
let currentItem = $state<T>(itemFactory());
|
||||
|
||||
const table = $derived(new TableHandler(items, { rowsPerPage: 10 }));
|
||||
const search = $derived(table.createSearch(searchBy));
|
||||
|
||||
$effect(() => {
|
||||
table.setRows(items);
|
||||
});
|
||||
|
||||
export function open(item: T | null = null, showTable: boolean = showTableProp) {
|
||||
if (item) {
|
||||
handleEdit(item);
|
||||
} else {
|
||||
resetForm(false);
|
||||
}
|
||||
isOpen = true;
|
||||
isTableVisible = showTable;
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen = false;
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleEdit(item: T) {
|
||||
selectedItem = item;
|
||||
currentItem = { ...item };
|
||||
isEditing = true;
|
||||
}
|
||||
|
||||
function resetForm(clearSelection = true) {
|
||||
if (clearSelection) selectedItem = null;
|
||||
currentItem = itemFactory();
|
||||
isEditing = false;
|
||||
hideAlert();
|
||||
}
|
||||
|
||||
function handleSelect(item: T) {
|
||||
onSelect(item);
|
||||
close();
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
if (isEditing && selectedItem) {
|
||||
const updated = await api.update(currentItem);
|
||||
items = items.map((i) => (i.id === updated.id ? updated : i));
|
||||
toastOk('updated', updated.name);
|
||||
onSave(updated);
|
||||
} else {
|
||||
const saved = await api.save(currentItem);
|
||||
items = [...items, saved];
|
||||
toastOk('saved', saved.name);
|
||||
onSave(saved);
|
||||
}
|
||||
resetForm();
|
||||
if (config.closeOnSave) close();
|
||||
} catch (error: unknown) {
|
||||
showAlert(`Error: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(item: T) {
|
||||
selectedItem = item;
|
||||
isConfirmationVisible = true;
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!selectedItem) return;
|
||||
try {
|
||||
await api.delete(selectedItem);
|
||||
items = items.filter((i) => i.id !== selectedItem!.id);
|
||||
toastOk('deleted', selectedItem.name);
|
||||
if (config.closeOnDelete) close();
|
||||
} catch (error: unknown) {
|
||||
showAlert(`Error: ${(error as Error).message}`);
|
||||
} finally {
|
||||
isConfirmationVisible = false;
|
||||
resetForm();
|
||||
}
|
||||
}
|
||||
|
||||
function showAlert(message: string) {
|
||||
isAlertVisible = true;
|
||||
alertText = message;
|
||||
}
|
||||
|
||||
function hideAlert() {
|
||||
isAlertVisible = false;
|
||||
alertText = '';
|
||||
}
|
||||
|
||||
function toastOk(action: string, name: string) {
|
||||
const itemLabel = labels.item.charAt(0).toUpperCase() + labels.item.slice(1);
|
||||
addToast({
|
||||
header: `${itemLabel} ${action}`,
|
||||
body: `${itemLabel} "${name}" ${action}.`,
|
||||
color: 'success',
|
||||
});
|
||||
}
|
||||
|
||||
const modalTitle = $derived(
|
||||
isEditing
|
||||
? `${labels.edit} ${labels.itemGenitive}`
|
||||
: isTableVisible
|
||||
? `${labels.items}`
|
||||
: `${labels.add} ${labels.itemGenitive}`,
|
||||
);
|
||||
|
||||
const submitButtonText = $derived(isEditing ? labels.update : labels.save);
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
{isOpen}
|
||||
toggle={close}
|
||||
size="lg"
|
||||
fade={false}
|
||||
backdrop={true}
|
||||
scrollable
|
||||
class={isConfirmationVisible ? 'modal-tinted' : ''}>
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{modalTitle}</h5>
|
||||
<button type="button" class="btn-close" onclick={close} aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{#if isTableVisible}
|
||||
<div class="position-relative mb-2">
|
||||
<Input
|
||||
type="text"
|
||||
class="form-control-sm pe-5"
|
||||
placeholder={labels.searchPlaceholder}
|
||||
bind:value={search.value}
|
||||
oninput={() => search.set()} />
|
||||
{#if search.value}
|
||||
<Button
|
||||
size="sm"
|
||||
color="white"
|
||||
class="position-absolute top-50 end-0 translate-middle-y me-2 rounded-circle d-flex align-items-center justify-content-center"
|
||||
style="width: 16px; height: 16px; border: none; background: var(--bs-secondary); color: var(--bs-white);"
|
||||
onclick={() => {
|
||||
search.value = '';
|
||||
search.set();
|
||||
}}>
|
||||
<Icon name="x" style="font-size: 16px;" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<div bind:this={table.element} class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
{@render tableHeader()}
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each table.rows as row (row.id)}
|
||||
<tr>
|
||||
{@render tableRow({ row: row as T })}
|
||||
<td class="fit">
|
||||
<InputGroup size="sm" class="d-flex gap-1 flex-nowrap">
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onclick={() => handleSelect(row as T)}
|
||||
class="p-0 border-0 bg-transparent text-success px-1">
|
||||
<Icon name="check-lg" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
onclick={() => handleEdit(row as T)}
|
||||
class="p-0 border-0 bg-transparent text-primary px-1">
|
||||
<Icon name="pencil" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="danger"
|
||||
onclick={() => confirmDelete(row as T)}
|
||||
class="p-0 border-0 bg-transparent text-danger px-1">
|
||||
<Icon name="trash" />
|
||||
</Button>
|
||||
</InputGroup>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination aria-label="Page navigation" size="sm">
|
||||
<PaginationItem>
|
||||
<PaginationLink previous onclick={() => table.setPage('previous')} />
|
||||
</PaginationItem>
|
||||
{#each table.pagesWithEllipsis as page}
|
||||
<PaginationItem active={table.currentPage === page}>
|
||||
<PaginationLink onclick={() => table.setPage(page)}>{page}</PaginationLink>
|
||||
</PaginationItem>
|
||||
{/each}
|
||||
<PaginationItem>
|
||||
<PaginationLink next onclick={() => table.setPage('next')} />
|
||||
</PaginationItem>
|
||||
</Pagination>
|
||||
<hr />
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<Alert color="danger" isOpen={isAlertVisible} toggle={hideAlert} fade={false} class="mb-2">
|
||||
<Icon name="exclamation-triangle" class="me-2" />
|
||||
{alertText}
|
||||
</Alert>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}}>
|
||||
{@render formFields({ item: currentItem })}
|
||||
<div class="d-grid gap-2 d-md-flex mt-3">
|
||||
<Button type="submit" color="success" size="sm">{submitButtonText}</Button>
|
||||
{#if isEditing}
|
||||
<Button size="sm" type="button" color="secondary" onclick={() => resetForm()}>
|
||||
{labels.cancel}
|
||||
</Button>
|
||||
<Button color="danger" size="sm" type="button" onclick={() => confirmDelete(currentItem)}>
|
||||
{labels.delete}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button color="secondary" size="sm" type="button" onclick={close}>{labels.close}</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmationPrompt
|
||||
bind:isOpen={isConfirmationVisible}
|
||||
title={`${labels.delete} ${labels.itemGenitive}`}
|
||||
confirmText={labels.delete}
|
||||
cancelText={labels.cancel}
|
||||
confirmVariant="danger"
|
||||
onconfirm={handleDelete}
|
||||
oncancel={() => (isConfirmationVisible = false)}>
|
||||
<p>Delete "{selectedItem?.name}"?</p>
|
||||
</ConfirmationPrompt>
|
||||
13
src/lib/ui/index.ts
Normal file
13
src/lib/ui/index.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export { default as CollapsibleCard } from './CollapsibleCard.svelte';
|
||||
export { default as PanelContainer } from './PanelContainer.svelte';
|
||||
export { default as TabBar } from './TabBar.svelte';
|
||||
export { default as Toast } from './Toast.svelte';
|
||||
export { default as ConfirmationPrompt } from './ConfirmationPrompt.svelte';
|
||||
export { default as SelectSearchable } from './SelectSearchable.svelte';
|
||||
export { default as SpoilerGroup } from './SpoilerGroup.svelte';
|
||||
export { default as LabelGroup } from './LabelGroup.svelte';
|
||||
export { default as EditableCell } from './EditableCell.svelte';
|
||||
export { default as ListEditor } from './editor/ListEditor.svelte';
|
||||
export type { ListEditorConfig, ListEditorApi } from './editor/ListEditor.svelte';
|
||||
export { addToast, removeToast, toasts } from './toasts';
|
||||
export type { Toast as ToastMessage, ToastColor, ToastInit } from './toasts';
|
||||
54
src/lib/ui/toasts.ts
Normal file
54
src/lib/ui/toasts.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { writable } from 'svelte/store';
|
||||
|
||||
export type ToastColor =
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'success'
|
||||
| 'danger'
|
||||
| 'warning'
|
||||
| 'info'
|
||||
| 'light'
|
||||
| 'dark';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
header: string;
|
||||
body: string;
|
||||
color: ToastColor;
|
||||
persistent: boolean;
|
||||
onRemove?: (id: string) => void;
|
||||
}
|
||||
|
||||
export interface ToastInit {
|
||||
header: string;
|
||||
body: string;
|
||||
color?: ToastColor;
|
||||
persistent?: boolean;
|
||||
onRemove?: (id: string) => void;
|
||||
}
|
||||
|
||||
export const toasts = writable<Toast[]>([]);
|
||||
|
||||
export function addToast(init: ToastInit): string {
|
||||
const id = crypto.randomUUID();
|
||||
toasts.update((all) => [
|
||||
...all,
|
||||
{
|
||||
id,
|
||||
header: init.header,
|
||||
body: init.body,
|
||||
color: init.color ?? 'info',
|
||||
persistent: init.persistent ?? false,
|
||||
onRemove: init.onRemove,
|
||||
},
|
||||
]);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function removeToast(id: string): void {
|
||||
toasts.update((all) => {
|
||||
const t = all.find((x) => x.id === id);
|
||||
t?.onRemove?.(id);
|
||||
return all.filter((x) => x.id !== id);
|
||||
});
|
||||
}
|
||||
22
src/routes/+layout.svelte
Normal file
22
src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { initI18n } from '$i18n';
|
||||
import { authStore } from '$auth/store';
|
||||
import ToastContainer from '$ui/Toast.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let ready = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
await initI18n();
|
||||
await authStore.refresh().catch(() => {});
|
||||
ready = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if ready}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
<ToastContainer />
|
||||
5
src/routes/+layout.ts
Normal file
5
src/routes/+layout.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Pure SPA: all rendering happens in the browser against a Django REST backend.
|
||||
// Disable SSR and prerender globally so every route is just a client-side chunk.
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
export const trailingSlash = 'ignore';
|
||||
|
|
@ -1,7 +1,22 @@
|
|||
<script>
|
||||
import Map from './leaflet.svelte';
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$auth';
|
||||
|
||||
onMount(() => {
|
||||
// Root route funnels users into the predict workspace by default. Once
|
||||
// we have a proper landing page this can render marketing content
|
||||
// instead of redirecting.
|
||||
const unsub = authStore.subscribe((state) => {
|
||||
if (state.status === 'authenticated') goto('/predict');
|
||||
else if (state.status === 'anonymous') goto('/login');
|
||||
});
|
||||
return () => unsub();
|
||||
});
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<Map />
|
||||
</main>
|
||||
<div class="d-flex justify-content-center align-items-center" style="height: 100vh;">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
export const ssr =false;
|
||||
|
|
@ -1,548 +0,0 @@
|
|||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import * as L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
/**
|
||||
* @type {{ removeLayer: (arg0: any) => void; setView: (arg0: number[], arg1: any) => void; getZoom: () => any; on: (arg0: string, arg1: (e: any) => void) => void; }}
|
||||
*/
|
||||
let map;
|
||||
let mouseLat = 0;
|
||||
let mouseLng = 0;
|
||||
/**
|
||||
* @type {null}
|
||||
*/
|
||||
let marker = null;
|
||||
|
||||
let startPoint = 'Custom';
|
||||
let startHeight = 0;
|
||||
let startTime = '13:13';
|
||||
let startDate = new Date(2025, 2, 24);
|
||||
let ascentRate = 5;
|
||||
let burstAltitude = 30000;
|
||||
let flightProfile = 'Normal';
|
||||
let descentRate = 5;
|
||||
let forecastMode = 'Single';
|
||||
let inputLat = '56.3576';
|
||||
let inputLng = '39.8666';
|
||||
|
||||
let showBurstCalculator = false;
|
||||
let payloadMass = 1500;
|
||||
let balloonMass = 1000;
|
||||
let desiredBurstAltitude = 33000;
|
||||
let desiredAscentRate = 2.33;
|
||||
|
||||
let burstAltitudeResult = 33000;
|
||||
let timeToBurst = 236;
|
||||
let initialVolume = 2.66;
|
||||
let ascentRateResult = 2.33;
|
||||
let liftForce = 1733;
|
||||
let volumeLiters = 2662;
|
||||
let volumeCubicFeet = 94.0;
|
||||
|
||||
const toggleBurstCalculator = () => {
|
||||
showBurstCalculator = !showBurstCalculator;
|
||||
};
|
||||
|
||||
const calculateBurst = () => {
|
||||
// In a real app, you would implement actual calculations here
|
||||
// These are just placeholder values matching your image
|
||||
burstAltitudeResult = desiredBurstAltitude;
|
||||
timeToBurst = 236;
|
||||
initialVolume = 2.66;
|
||||
ascentRateResult = desiredAscentRate;
|
||||
liftForce = 1733;
|
||||
volumeLiters = 2662;
|
||||
volumeCubicFeet = 94.0;
|
||||
};
|
||||
|
||||
const updateMapPosition = () => {
|
||||
const lat = parseFloat(inputLat);
|
||||
const lng = parseFloat(inputLng);
|
||||
|
||||
if (isNaN(lat)) {
|
||||
alert("Please enter a valid latitude");
|
||||
return;
|
||||
}
|
||||
if (isNaN(lng)) {
|
||||
alert("Please enter a valid longitude");
|
||||
return;
|
||||
}
|
||||
if (lat < -90 || lat > 90) {
|
||||
alert("Latitude must be between -90 and 90");
|
||||
return;
|
||||
}
|
||||
if (lng < -180 || lng > 180) {
|
||||
alert("Longitude must be between -180 and 180");
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove existing marker if it exists
|
||||
if (marker) {
|
||||
map.removeLayer(marker);
|
||||
}
|
||||
|
||||
// Create new marker
|
||||
marker = L.marker([lat, lng]).addTo(map)
|
||||
.bindPopup("Launch Point");
|
||||
|
||||
// Center map on new coordinates
|
||||
map.setView([lat, lng], map.getZoom());
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
map = L.map('map').setView([51.505, -0.09], 13);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
}).addTo(map);
|
||||
|
||||
map.on('mousemove', (e) => {
|
||||
mouseLat = e.latlng.lat.toFixed(6);
|
||||
mouseLng = e.latlng.lng.toFixed(6);
|
||||
});
|
||||
|
||||
marker = L.marker([56.3576, 39.8666]).addTo(map)
|
||||
.bindPopup(() => {
|
||||
return `
|
||||
<b>Launch Point</b><br>, Lat: ${marker.getLatLng().lat.toFixed(6)}<br>, Long: ${marker.getLatLng().lng.toFixed(6)}<br>
|
||||
`;
|
||||
});
|
||||
});
|
||||
|
||||
// Forecast request function
|
||||
const getForecast = async () => {
|
||||
// Create request object
|
||||
const request = {
|
||||
ascent_rate: parseFloat(ascentRate),
|
||||
burst_altitude: parseFloat(burstAltitude),
|
||||
dataset: new Date().toISOString(), // Current time as dataset timestamp
|
||||
descent_rate: parseFloat(descentRate),
|
||||
format: "json",
|
||||
launch_altitude: parseFloat(startHeight),
|
||||
launch_datetime: new Date(
|
||||
`${startDate.getFullYear()}-${startDate.getMonth() + 1}-${startDate.getDate()}T${startTime}:00Z`
|
||||
).toISOString(),
|
||||
launch_latitude: parseFloat(inputLat),
|
||||
launch_longitude: parseFloat(inputLng),
|
||||
profile: flightProfile === 'Normal' ? 'standard_profile' : 'custom_profile',
|
||||
version: 2
|
||||
};
|
||||
|
||||
console.log("Sending request:", request);
|
||||
|
||||
try {
|
||||
// Example POST request - replace with your actual API endpoint
|
||||
const response = await fetch('https://api.example.com/forecast', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("Forecast response:", data);
|
||||
alert("Forecast request successful!");
|
||||
// Handle the response data as needed
|
||||
} catch (error) {
|
||||
console.error("Error sending forecast request:", error);
|
||||
alert("Error getting forecast: " + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to format date as YYYY-MM-DD
|
||||
const formatDateForAPI = (date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="map-container">
|
||||
<div id="map"></div>
|
||||
<div class="coordinates-display">
|
||||
Lat: {mouseLat}, Long: {mouseLng}
|
||||
</div>
|
||||
<div class="control-panel">
|
||||
<div class="control-header">Launch Point</div>
|
||||
|
||||
<div class="control-row">
|
||||
<span>Start Point:</span>
|
||||
<select bind:value={startPoint}>
|
||||
<option>Custom</option>
|
||||
<option>Preset 1</option>
|
||||
<option>Preset 2</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="control-row">
|
||||
<span>Latitude/Longitude:</span>
|
||||
<div class="coordinate-inputs">
|
||||
<input type="text" bind:value={inputLat} placeholder="Latitude">
|
||||
<p>/</p>
|
||||
<input type="text" bind:value={inputLng} placeholder="Longitude">
|
||||
<button on:click={updateMapPosition} class="update-button">✓</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-row">
|
||||
<button class="map-button" on:click={() => {
|
||||
inputLat = mouseLat;
|
||||
inputLng = mouseLng;
|
||||
updateMapPosition();
|
||||
}}>Specify on map (click location)</button>
|
||||
</div>
|
||||
|
||||
<div class="control-row">
|
||||
<span>Launch Height (m):</span>
|
||||
<input type="number" bind:value={startHeight}>
|
||||
</div>
|
||||
|
||||
<div class="control-row">
|
||||
<span>Launch Time (UTC):</span>
|
||||
<input type="time" bind:value={startTime}>
|
||||
</div>
|
||||
|
||||
<div class="control-row">
|
||||
<span>Launch Date:</span>
|
||||
<input type="date" bind:value={startDate}>
|
||||
</div>
|
||||
|
||||
<div class="control-row">
|
||||
<span>Ascent Rate (m/s):</span>
|
||||
<input type="number" bind:value={ascentRate}>
|
||||
</div>
|
||||
|
||||
<div class="control-row">
|
||||
<span>Burst/Drift Altitude (m):</span>
|
||||
<input type="number" bind:value={burstAltitude}>
|
||||
</div>
|
||||
|
||||
<div class="control-row">
|
||||
<span>Flight Profile:</span>
|
||||
<select bind:value={flightProfile}>
|
||||
<option>Normal</option>
|
||||
<option>Drift</option>
|
||||
<option>Reversible (on the rise)</option>
|
||||
<option>Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="control-row buttons">
|
||||
<button on:click={toggleBurstCalculator}>Open Burst Calculator</button>
|
||||
<button>Set Custom Flight Profile</button>
|
||||
<button>Show Last Altitude Graph</button>
|
||||
</div>
|
||||
|
||||
<div class="control-row">
|
||||
<span>Descent Rate (m/s):</span>
|
||||
<input type="number" bind:value={descentRate}>
|
||||
</div>
|
||||
|
||||
<div class="control-row">
|
||||
<span>Forecast Mode (help):</span>
|
||||
<div class="radio-group">
|
||||
<label>
|
||||
<input type="radio" bind:group={forecastMode} value="Single"> Single
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" bind:group={forecastMode} value="Multiple"> Multiple
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-row">
|
||||
<button class="primary-button" on:click={getForecast}>Get Forecast</button>
|
||||
<button>Save Point</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Burst Calculator Modal -->
|
||||
{#if showBurstCalculator}
|
||||
<div class="modal-overlay" on:click|self={toggleBurstCalculator}>
|
||||
<div class="modal-content">
|
||||
<h2>Balloon Burst Calculation</h2>
|
||||
|
||||
<div class="calculator-grid">
|
||||
<div class="input-group">
|
||||
<label>Payload Mass (g)</label>
|
||||
<input type="number" bind:value={payloadMass}>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label>Balloon Mass (g)</label>
|
||||
<input type="number" bind:value={balloonMass}>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label>Desired Burst Altitude (m)</label>
|
||||
<input type="number" bind:value={desiredBurstAltitude}>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label>Desired Ascent Rate (m/s)</label>
|
||||
<input type="number" bind:value={desiredAscentRate} step="0.01">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results-section">
|
||||
<h3>Results</h3>
|
||||
<div class="result-row">
|
||||
<span>Burst Altitude:</span>
|
||||
<span>{burstAltitudeResult} m</span>
|
||||
</div>
|
||||
<div class="result-row">
|
||||
<span>Time to Burst:</span>
|
||||
<span>{timeToBurst} min</span>
|
||||
</div>
|
||||
<div class="result-row">
|
||||
<span>Initial Volume:</span>
|
||||
<span>{initialVolume} m³</span>
|
||||
</div>
|
||||
<div class="result-row">
|
||||
<span>Ascent Rate:</span>
|
||||
<span>{ascentRateResult} m/s</span>
|
||||
</div>
|
||||
<div class="result-row">
|
||||
<span>Lift Force at Launch:</span>
|
||||
<span>{liftForce} g</span>
|
||||
</div>
|
||||
<div class="result-row">
|
||||
<span>Volume:</span>
|
||||
<span>{volumeLiters} L ({volumeCubicFeet} ft³)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="secondary-button">Additional Settings</button>
|
||||
<button class="primary-button" on:click={calculateBurst}>Use Results</button>
|
||||
<button on:click={toggleBurstCalculator}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.map-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
#map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.coordinates-display {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
z-index: 1000; /* Ensure it's above the map */
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.control-panel {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
|
||||
width: 320px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.control-header {
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
font-size: 16px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.control-row {
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.control-row span {
|
||||
width: 160px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.control-row input[type="number"],
|
||||
.control-row input[type="date"],
|
||||
.control-row input[type="time"],
|
||||
.control-row select {
|
||||
width: 120px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.coordinate-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.coordinate-inputs input {
|
||||
width: 70px !important;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.update-button {
|
||||
padding: 3px 8px;
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.map-button {
|
||||
width: 100%;
|
||||
padding: 5px;
|
||||
background: #f0f0f0;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.buttons button {
|
||||
width: 100%;
|
||||
padding: 5px;
|
||||
margin-bottom: 5px;
|
||||
background: #f0f0f0;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
background: #4CAF50 !important;
|
||||
color: white;
|
||||
border: 1px solid #3e8e41 !important;
|
||||
}
|
||||
|
||||
.radio-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.radio-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
width: 500px;
|
||||
max-width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.calculator-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.results-section {
|
||||
background-color: #f5f5f5;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.results-section h3 {
|
||||
margin-top: 0;
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.result-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.result-row span:first-child {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
background: #f0f0f0;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
</style>
|
||||
5
src/routes/login/+page.svelte
Normal file
5
src/routes/login/+page.svelte
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { LoginForm } from '$features/auth';
|
||||
</script>
|
||||
|
||||
<LoginForm />
|
||||
113
src/routes/predict/+page.svelte
Normal file
113
src/routes/predict/+page.svelte
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Map as MapView } from '$map';
|
||||
import type { IMap } from '$map';
|
||||
import { startCoordinateSelection } from '$map';
|
||||
import { Navbar } from '$features/auth';
|
||||
import { PanelContainer, TabBar } from '$ui';
|
||||
import { addToast, removeToast } from '$ui';
|
||||
import { ControlPanel, ScenarioPanel } from '$features/prediction';
|
||||
import {
|
||||
WorkspacesPanel,
|
||||
WorkspaceRenderer,
|
||||
workspacesStore,
|
||||
} from '$features/workspaces';
|
||||
import { SettingsPanel } from '$features/settings';
|
||||
import { TimeLine } from '$features/timeline';
|
||||
import { t } from '$i18n';
|
||||
import { requireAuthenticated } from '$auth';
|
||||
|
||||
type LeftTab = 'scenario' | 'conditions' | 'about';
|
||||
type RightTab = 'results' | 'workspaces' | 'settings';
|
||||
|
||||
let mapComponent = $state<MapView | null>(null);
|
||||
let controlPanelRef = $state<ControlPanel | null>(null);
|
||||
let selectionToastId: string | null = null;
|
||||
let selectionDispose: (() => void) | null = null;
|
||||
|
||||
let leftTab = $state<LeftTab>('scenario');
|
||||
let rightTab = $state<RightTab>('workspaces');
|
||||
|
||||
onMount(async () => {
|
||||
const ok = await requireAuthenticated('/login');
|
||||
if (!ok) return;
|
||||
if ($workspacesStore.items.length === 0) {
|
||||
workspacesStore.add({ name: $t('workspaces.defaultName', { n: 1 }) });
|
||||
}
|
||||
});
|
||||
|
||||
function handleMapReady(_map: IMap) {
|
||||
// Map is ready; consumers inside <MapView /> already have access via context.
|
||||
}
|
||||
|
||||
function handleSelectOnMap() {
|
||||
const map = mapComponent?.getInstance();
|
||||
if (!map) return;
|
||||
|
||||
selectionDispose = startCoordinateSelection(map, ({ lat, lng }) => {
|
||||
controlPanelRef?.updateLaunchPosition(lat, lng);
|
||||
if (selectionToastId) {
|
||||
removeToast(selectionToastId);
|
||||
selectionToastId = null;
|
||||
}
|
||||
selectionDispose = null;
|
||||
});
|
||||
|
||||
selectionToastId = addToast({
|
||||
header: $t('selection.header'),
|
||||
body: $t('selection.body'),
|
||||
color: 'info',
|
||||
persistent: true,
|
||||
onRemove: () => {
|
||||
selectionDispose?.();
|
||||
selectionDispose = null;
|
||||
selectionToastId = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<Navbar />
|
||||
<div style="height: var(--navbar-height);"></div>
|
||||
<MapView bind:this={mapComponent} onReady={handleMapReady}>
|
||||
<WorkspaceRenderer />
|
||||
|
||||
<PanelContainer position="left">
|
||||
<TabBar
|
||||
tabs={[
|
||||
{ id: 'scenario', icon: 'file-earmark-play', label: $t('panel.scenario') },
|
||||
{ id: 'conditions', icon: 'sliders', label: $t('panel.conditions') },
|
||||
{ id: 'about', icon: 'info-circle', label: $t('panel.about') },
|
||||
]}
|
||||
bind:active={leftTab} />
|
||||
<div>
|
||||
{#if leftTab === 'scenario'}
|
||||
<ScenarioPanel />
|
||||
{:else if leftTab === 'conditions'}
|
||||
<ControlPanel bind:this={controlPanelRef} onSelectOnMapClick={handleSelectOnMap} />
|
||||
{/if}
|
||||
</div>
|
||||
</PanelContainer>
|
||||
|
||||
<PanelContainer position="right">
|
||||
<TabBar
|
||||
justify="end"
|
||||
tabs={[
|
||||
{ id: 'workspaces', icon: 'layers', label: $t('panel.workspaces') },
|
||||
{ id: 'results', icon: 'bar-chart-line', label: $t('panel.results') },
|
||||
{ id: 'settings', icon: 'gear', label: $t('panel.settings') },
|
||||
]}
|
||||
bind:active={rightTab} />
|
||||
<div>
|
||||
{#if rightTab === 'workspaces'}
|
||||
<WorkspacesPanel />
|
||||
{:else if rightTab === 'settings'}
|
||||
<SettingsPanel />
|
||||
{/if}
|
||||
</div>
|
||||
</PanelContainer>
|
||||
|
||||
<TimeLine />
|
||||
</MapView>
|
||||
</main>
|
||||
22
src/routes/track/+page.svelte
Normal file
22
src/routes/track/+page.svelte
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Map as MapView } from '$map';
|
||||
import { Navbar } from '$features/auth';
|
||||
import { PanelContainer } from '$ui';
|
||||
import { TelemetryPanel } from '$features/tracking';
|
||||
import { requireAuthenticated } from '$auth';
|
||||
|
||||
onMount(() => {
|
||||
requireAuthenticated('/login');
|
||||
});
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<Navbar />
|
||||
<div style="height: var(--navbar-height);"></div>
|
||||
<MapView>
|
||||
<PanelContainer position="left">
|
||||
<TelemetryPanel />
|
||||
</PanelContainer>
|
||||
</MapView>
|
||||
</main>
|
||||
84
src/routes/user/account/+page.svelte
Normal file
84
src/routes/user/account/+page.svelte
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Button,
|
||||
FormGroup,
|
||||
Label,
|
||||
Input,
|
||||
} from '@sveltestrap/sveltestrap';
|
||||
import { Navbar } from '$features/auth';
|
||||
import { Footer } from '$features/footer';
|
||||
import { ConfirmationPrompt } from '$ui';
|
||||
import { authStore, requireAuthenticated } from '$auth';
|
||||
import { goto } from '$app/navigation';
|
||||
import { t } from '$i18n';
|
||||
|
||||
let showConfirm = $state(false);
|
||||
let confirmTitle = $state('');
|
||||
let confirmBody = $state('');
|
||||
let confirmAction = $state<() => void>(() => {});
|
||||
|
||||
onMount(async () => {
|
||||
if (!(await requireAuthenticated('/login'))) return;
|
||||
});
|
||||
|
||||
function ask(title: string, body: string, action: () => void) {
|
||||
confirmTitle = title;
|
||||
confirmBody = body;
|
||||
confirmAction = action;
|
||||
showConfirm = true;
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
ask($t('nav.logout'), '', async () => {
|
||||
await authStore.logout();
|
||||
goto('/');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="force-page-height">
|
||||
<Navbar />
|
||||
<div style="height: var(--navbar-height);"></div>
|
||||
<div class="container my-4">
|
||||
<div class="row">
|
||||
<div class="col-md-3 col-lg-2 mb-4">
|
||||
<nav class="nav nav-pills flex-column">
|
||||
<a class="nav-link active" href="/user/account">{$t('nav.account')}</a>
|
||||
<a class="nav-link" href="/user/templates">{$t('nav.scenarios')}</a>
|
||||
<a class="nav-link" href="/user/predictions">{$t('nav.predictionHistory')}</a>
|
||||
<a class="nav-link" href="/user/flights">{$t('nav.trackingHistory')}</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="col-md-9 col-lg-10">
|
||||
<Card class="mb-4">
|
||||
<CardHeader><h5 class="mb-0">{$t('nav.account')}</h5></CardHeader>
|
||||
<CardBody>
|
||||
<FormGroup>
|
||||
<Label>{$t('login.username')}</Label>
|
||||
<Input value={$authStore.username ?? ''} readonly disabled />
|
||||
</FormGroup>
|
||||
<div class="d-grid gap-2 d-md-flex">
|
||||
<Button color="secondary" on:click={handleLogout}>{$t('nav.logout')}</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</main>
|
||||
|
||||
<ConfirmationPrompt
|
||||
bind:isOpen={showConfirm}
|
||||
title={confirmTitle}
|
||||
confirmText={$t('editor.save')}
|
||||
cancelText={$t('editor.cancel')}
|
||||
onconfirm={confirmAction}
|
||||
oncancel={() => (showConfirm = false)}>
|
||||
<p>{confirmBody}</p>
|
||||
</ConfirmationPrompt>
|
||||
36
src/routes/user/flights/+page.svelte
Normal file
36
src/routes/user/flights/+page.svelte
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Card, CardHeader, CardBody } from '@sveltestrap/sveltestrap';
|
||||
import { Navbar } from '$features/auth';
|
||||
import { Footer } from '$features/footer';
|
||||
import { requireAuthenticated } from '$auth';
|
||||
import { t } from '$i18n';
|
||||
|
||||
onMount(() => requireAuthenticated('/login'));
|
||||
</script>
|
||||
|
||||
<main class="force-page-height">
|
||||
<Navbar />
|
||||
<div style="height: var(--navbar-height);"></div>
|
||||
<div class="container my-4">
|
||||
<div class="row">
|
||||
<div class="col-md-3 col-lg-2 mb-4">
|
||||
<nav class="nav nav-pills flex-column">
|
||||
<a class="nav-link" href="/user/account">{$t('nav.account')}</a>
|
||||
<a class="nav-link" href="/user/templates">{$t('nav.scenarios')}</a>
|
||||
<a class="nav-link" href="/user/predictions">{$t('nav.predictionHistory')}</a>
|
||||
<a class="nav-link active" href="/user/flights">{$t('nav.trackingHistory')}</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="col-md-9 col-lg-10">
|
||||
<Card>
|
||||
<CardHeader><h5 class="mb-0">{$t('nav.trackingHistory')}</h5></CardHeader>
|
||||
<CardBody>
|
||||
<p class="text-muted small mb-0">TODO: wire to tracking history endpoint.</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</main>
|
||||
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