Compare commits

..

60 commits

Author SHA1 Message Date
79e20ca37c feat: tests & bootstrap 2026-04-22 02:26:43 +09:00
4bd927bb4e feat: polish 2026-04-22 01:27:38 +09:00
Vasilisk9812
2e6177fe74 added mapcore.ts 2026-04-09 19:34:11 +09:00
ThePetrovich
e984b9730b update gitignore 2026-02-24 20:09:59 +08:00
Vasilisk9812
5657c9be8f new comm 2026-02-24 21:06:04 +09:00
ThePetrovich
f0aa28ec7c Fix css on panel containers 2025-12-14 18:13:41 +08:00
ThePetrovich
3be5d6c515 Merge branch 'components' of https://git.intra.yksa.space/mikhailov.aa/leaflet_svelte into components 2025-12-14 18:09:30 +08:00
ThePetrovich
8e3dfa54f9 Refactoring of various stuff
big mess, I don't remember what I was trying to accomplish there
2025-12-14 18:06:17 +08:00
Vasilisk9812
8e9f28a6ac added bootstrap styles to TimeLine.svelte 2025-12-14 19:05:57 +09:00
Vasilisk9812
eb7066ac6b added timeline with satellite tracking 2025-12-11 23:30:32 +09:00
Vasilisk9812
60fe848b0c added maplibre-wind lib and reworked windvisualisation 2025-12-10 17:19:50 +09:00
Vasilisk9812
6359ccf9ee replaced leaflet with map libre 2025-12-04 19:16:48 +09:00
ThePetrovich
ffb27c2e0a Initial implementation of custom profile editor + formatting 2025-07-09 20:14:47 +08:00
ThePetrovich
82b36f96d0 Fix store init 2025-07-06 20:55:19 +08:00
ThePetrovich
4360a54b58 christ 2025-07-06 20:51:35 +08:00
ThePetrovich
4bb7d214e8 cleanup 2025-07-06 19:16:17 +08:00
ThePetrovich
19f969c18c Scenario system & point editor rework 2025-07-05 23:04:29 +08:00
ThePetrovich
7d01fce094 control panel ux 2025-07-04 00:40:53 +08:00
ThePetrovich
a1d80eb984 use id for saved points 2025-07-03 21:03:52 +08:00
ThePetrovich
ac4af66cd5 unauthenticated redirect (temp) 2025-07-03 20:39:39 +08:00
ThePetrovich
551951827d Confirmations 2025-07-03 20:39:17 +08:00
ThePetrovich
cb67c5d93d Add profile and template pages (scaffolding) 2025-07-03 18:39:04 +08:00
ThePetrovich
41668498ea Search 2025-07-02 22:56:24 +08:00
ThePetrovich
162bd0813f Custom select 2025-07-02 22:56:14 +08:00
ThePetrovich
5a1a20df6c fix reactivity on saved point change 2025-07-02 21:36:53 +08:00
ThePetrovich
a5bfed73a1 Merge remote-tracking branch 'origin/velocity' into components 2025-07-02 19:56:12 +08:00
Vasilisk9812
e428f55580 Pre timeControl 2025-07-02 20:44:12 +09:00
ThePetrovich
0e4d5a8d47 Merge remote-tracking branch 'origin/velocity' into components 2025-07-02 19:17:45 +08:00
ThePetrovich
1e09b2d7ef Minimal error handling 2025-07-02 19:00:26 +08:00
ThePetrovich
1a89d49e8a Rewrite PointListModal with svelte5 runes. fixes reactivity 2025-07-02 18:09:46 +08:00
ThePetrovich
0f79cefdac Implement basic saved point editor 2025-07-02 15:32:46 +08:00
ThePetrovich
bb390d50dc Add ruler tool and fix types 2025-07-01 21:09:15 +08:00
ThePetrovich
329c1c2215 New panel layout 2025-06-30 19:23:46 +08:00
Vasilisk9812
aa0ff91a7d heatmap 2025-06-30 02:50:17 +09:00
ThePetrovich
87f0a53cb5 Merge branch 'components' of https://git.intra.yksa.space/mikhailov.aa/leaflet_svelte into components 2025-06-27 21:15:30 +08:00
Vasilisk9812
74340cf28e wind view checkbox 2025-06-27 22:15:02 +09:00
ThePetrovich
3d609771de Map coordinates selection 2025-06-27 21:14:14 +08:00
Vasilisk9812
f4b397043a pre heatmap 2025-06-27 21:44:08 +09:00
ThePetrovich
72c0d5e609 Fix navbar styles & map position 2025-06-27 20:07:54 +08:00
ThePetrovich
eb29cdc585 Prevent propagation on panel 2025-06-27 19:58:50 +08:00
ThePetrovich
52558ed3b2 Continue messing with stores 2025-06-27 19:27:19 +08:00
ThePetrovich
c7df38e6ce Refactor of map & other components 2025-06-27 18:23:50 +08:00
ThePetrovich
527d4417ff fix login & add sveltestrap 2025-06-26 19:15:33 +08:00
Vasilisk9812
a822fb1e36 wind-global 2025-06-21 18:18:15 +09:00
Vasilisk9812
79848ef36f login 2025-04-06 01:14:40 +09:00
ThePetrovich
19a8cdc1d6 Merge branch 'components' of https://git.intra.yksa.space/mikhailov.aa/leaflet_svelte into components 2025-04-05 23:12:38 +08:00
ThePetrovich
14132dfeb6 login page layout 2025-04-05 23:11:56 +08:00
Vasilisk9812
0b4f0fe6d8 authorization prework 2025-04-06 00:10:25 +09:00
Vasilisk9812
29d7480753 login navbar add 2025-04-05 23:54:58 +09:00
ThePetrovich
522202b89e Merge branch 'components' of https://git.intra.yksa.space/mikhailov.aa/leaflet_svelte into components 2025-04-05 22:45:58 +08:00
ThePetrovich
afc45cc9cc Add routes 2025-04-05 22:44:34 +08:00
Vasilisk9812
51dc62a68f login page placeholder 2025-04-05 23:37:38 +09:00
ThePetrovich
55295b84aa Add initial plotting 2025-04-05 14:43:23 +08:00
ThePetrovich
2db5d14202 Merge branch 'components' of https://git.intra.yksa.space/mikhailov.aa/leaflet_svelte into components 2025-04-05 01:23:40 +08:00
ThePetrovich
859966c48d add navbar 2025-04-05 01:23:03 +08:00
Vasilisk9812
6bd3a656f9 profile fix 2025-04-05 02:11:48 +09:00
ThePetrovich
cd98f04622 Merge branch 'components' of https://git.intra.yksa.space/mikhailov.aa/leaflet_svelte into components 2025-04-04 23:42:35 +08:00
ThePetrovich
68aae97597 add bootstrap 2025-04-04 23:42:30 +08:00
Vasilisk9812
e67a9c6455 request 2025-04-05 00:14:36 +09:00
Vasilisk9812
0f130c640c components 2025-04-04 22:57:35 +09:00
121 changed files with 8318 additions and 2175 deletions

18
.env.example Normal file
View file

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

4
.gitignore vendored
View file

@ -21,3 +21,7 @@ Thumbs.db
# Vite # Vite
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# AI tools
.claude
tmpclaude*

8
.prettierrc Normal file
View file

@ -0,0 +1,8 @@
{
"tabWidth": 4,
"endOfLine": "lf",
"printWidth": 120,
"useTabs": true,
"htmlWhitespaceSensitivity": "ignore",
"bracketSameLine": true
}

View file

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

390
build.js
View file

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

152
docs/ADDING_A_FEATURE.md Normal file
View file

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

105
docs/ARCHITECTURE.md Normal file
View file

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

132
docs/CONVENTIONS.md Normal file
View file

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

120
docs/TESTING.md Normal file
View 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 3060 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.

View file

@ -3,6 +3,7 @@
"compilerOptions": { "compilerOptions": {
"allowJs": true, "allowJs": true,
"checkJs": true, "checkJs": true,
"allowImportingTsExtensions": true,
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"resolveJsonModule": true, "resolveJsonModule": true,

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

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

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

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

View file

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

200
mocks/vitePlugin.ts Normal file
View file

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

1166
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
{ {
"name": "project", "name": "app4",
"private": true, "private": true,
"version": "0.0.1", "version": "0.0.1",
"type": "module", "type": "module",
@ -9,19 +9,34 @@
"preview": "vite preview", "preview": "vite preview",
"prepare": "svelte-kit sync || echo ''", "prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch" "check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^4.0.0", "@playwright/test": "^1.59.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.16.0", "@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"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", "svelte-check": "^4.0.0",
"typescript": "^5.0.0", "typescript": "^5.0.0",
"vite": "^6.0.0" "vite": "^6.2.5"
}, },
"dependencies": { "dependencies": {
"leaflet": "^1.9.4", "@sakitam-gis/maplibre-wind": "^2.0.3",
"svelte-map-leaflet": "^0.5.0" "@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
View 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
View file

@ -0,0 +1 @@
claude --resume 0ed44ce1-31ba-4b4e-b975-387a4b2ae13d

68
scripts/run-stack.sh Executable file
View 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
View 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

View file

@ -1,3 +1,153 @@
@tailwind base; /* Custom YKS brand-themed Bootstrap 5.2.3 is served from /css/bootstrap.min.css
@tailwind components; * (see static/css/bootstrap.min.css). It carries the brand palette
@tailwind utilities; * (--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
View file

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

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

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

11
src/lib/api/points.ts Normal file
View 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}/`),
};

View file

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

11
src/lib/api/profiles.ts Normal file
View 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
View 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
View file

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

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

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

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

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

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

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

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

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

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

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

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

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

View file

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

View file

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

View file

@ -0,0 +1,41 @@
import type { LatLngTuple } from './geo';
import type { Point } from './prediction';
export interface TelemetryPoint {
altitude: number;
datetime: string;
latitude: number;
longitude: number;
payload: string;
}
export interface TelemetryMetadata {
complete_datetime: string;
start_datetime: string;
}
export interface RawTelemetry {
metadata: TelemetryMetadata;
telemetry: TelemetryPoint[];
}
export interface Telemetry {
flight_path: LatLngTuple[];
launch: Point;
datapoints: TelemetryPoint[];
}
export function parseTelemetry(points: TelemetryPoint[]): Telemetry {
if (points.length === 0) {
throw new Error('Telemetry requires at least one datapoint');
}
const flight_path: LatLngTuple[] = points.map((p) => [p.latitude, p.longitude, p.altitude]);
const launch: Point = {
latlng: { lat: points[0].latitude, lng: points[0].longitude, alt: points[0].altitude },
datetime: new Date(points[0].datetime),
};
return { flight_path, launch, datapoints: points };
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,25 @@
<script lang="ts">
import { t } from '$i18n';
</script>
<footer class="bg-dark text-bg-dark mt-auto">
<div class="container pt-5">
<div class="row gy-5">
<div class="col-lg-3 mw-lg-2">
<div class="mb-4">
<a class="navbar-brand" href="/">
<img src="/logo-full-ru-dark.svg" class="img-fluid" alt={$t('app.company')} width="250" />
</a>
</div>
</div>
<div class="col-lg-8 offset-lg-1"></div>
</div>
</div>
<div class="container pb-4">
<div class="row">
<div class="col-6 small">
<div>Copyright © 2024 {$t('app.company')}</div>
</div>
</div>
</div>
</footer>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

77
src/lib/map/Map.svelte Normal file
View 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
View file

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

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

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

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

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

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

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

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

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

View file

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

View file

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

@ -0,0 +1,40 @@
<script lang="ts">
import { Toast, ToastBody, ToastHeader, Icon } from '@sveltestrap/sveltestrap';
import { toasts, removeToast, type ToastColor } from './toasts';
const ICONS: Record<ToastColor, string> = {
primary: 'info-circle-fill',
secondary: 'info-circle-fill',
success: 'check-circle-fill',
danger: 'exclamation-triangle-fill',
warning: 'exclamation-circle-fill',
info: 'info-circle-fill',
light: 'lightbulb',
dark: 'question',
};
</script>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
{#each $toasts as toast (toast.id)}
<Toast
isOpen={true}
autohide={!toast.persistent}
delay={5000}
color={toast.color}
on:close={() => removeToast(toast.id)}>
<ToastHeader toggle={() => removeToast(toast.id)} class={`text-${toast.color}`}>
<Icon slot="icon" name={ICONS[toast.color]} class="me-2" color={toast.color} />
{toast.header}
</ToastHeader>
<ToastBody>
{toast.body}
</ToastBody>
</Toast>
{/each}
</div>
<style>
.toast-container {
z-index: 1090;
}
</style>

View file

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

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

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

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

22
src/routes/+layout.svelte Normal file
View 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
View 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';

View file

@ -1,7 +1,22 @@
<script> <script lang="ts">
import Map from './leaflet.svelte'; 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> </script>
<main> <div class="d-flex justify-content-center align-items-center" style="height: 100vh;">
<Map /> <div class="spinner-border text-primary" role="status">
</main> <span class="visually-hidden">Loading...</span>
</div>
</div>

View file

@ -1 +0,0 @@
export const ssr =false;

View file

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

View file

@ -0,0 +1,5 @@
<script lang="ts">
import { LoginForm } from '$features/auth';
</script>
<LoginForm />

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

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

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

View 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