polish #14

Open
mikhailov.aa wants to merge 66 commits from polish into master
142 changed files with 15285 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.

41
docs/diagrams/README.md Normal file
View file

@ -0,0 +1,41 @@
# Диаграммы (PlantUML)
Исходники диаграмм для диссертации. Построены по фактическому коду
(`src/lib/api/*`, `src/lib/features/tracking/*`, `src/lib/features/wind/*`).
## Архитектура
| Файл | Что описывает |
|------|----------------|
| [architecture.puml](architecture.puml) | Слоистая архитектура клиента: routes → features → lib (api/map/ui/state/i18n/auth/domain), внешние сервисы (Backend, предсказатель) и библиотеки |
## Диаграммы последовательности (по всем запросам)
| Файл | Что описывает | Запросы |
|------|----------------|---------|
| [seq-request-csrf.puml](seq-request-csrf.puml) | Сквозная обёртка `request<T>()`: CSRF, cookie, обработка ошибок и 401 | `GET /api/csrf/` |
| [seq-auth.puml](seq-auth.puml) | Проверка сессии, вход, выход | `GET /api/session/`, `GET /api/whoami/`, `POST /api/login/`, `POST /api/logout/` |
| [seq-resource-crud.puml](seq-resource-crud.puml) | Единый CRUD-шаблон справочников | `/api/saved-points/`, `/api/saved-templates/`, `/api/saved-profiles/` (GET/POST/PUT/DELETE) |
| [seq-prediction.puml](seq-prediction.puml) | Запуск прогноза траектории | `POST /api/predictions/` |
| [seq-telemetry.puml](seq-telemetry.puml) | Загрузка истории и приём телеметрии | `GET /api/{id}/telemetry/`, `WS /api/ws/satellite/{id}/telemetry/` |
| [seq-wind.puml](seq-wind.puml) | Визуализация поля ветра (статика + синхронизация) | `GET /api/v1/wind/field`, `GET /api/v1/wind/meta` |
## Диаграмма потоков данных
| Файл | Что описывает |
|------|----------------|
| [dfd-telemetry.puml](dfd-telemetry.puml) | DFD подсистемы слежения: внешние сущности, процессы 1.05.0, хранилища D1D3 |
## Рендеринг в PNG/SVG
В системе есть Java, но нет CLI PlantUML. Один раз скачать jar и собрать все
диаграммы:
```sh
curl -L -o plantuml.jar https://github.com/plantuml/plantuml/releases/latest/download/plantuml.jar
java -jar plantuml.jar -tpng docs/diagrams/*.puml # PNG
java -jar plantuml.jar -tsvg docs/diagrams/*.puml # SVG (для печати)
```
Альтернатива без установки — онлайн-редактор <https://www.plantuml.com/plantuml>
или расширение PlantUML для VS Code (предпросмотр `Alt+D`).

View file

@ -0,0 +1,102 @@
@startuml architecture
title Архитектура клиентской части системы
skinparam shadowing false
skinparam defaultFontName Helvetica
skinparam packageStyle rectangle
skinparam rectangle {
BorderColor #2C5AA0
BackgroundColor #EAF1FB
}
skinparam package {
BorderColor #6B6B6B
BackgroundColor #FFFFFF
}
skinparam node {
BorderColor #444444
BackgroundColor #F5F5F5
}
node "Браузер (SPA) — SvelteKit 5 + Vite" as spa {
package "routes/ (страницы)" as routes {
rectangle "/login" as r_login
rectangle "/predict" as r_predict
rectangle "/track" as r_track
rectangle "/user/*" as r_user
}
package "features/ (функциональные модули)" as features {
rectangle "auth\nLoginForm, Navbar" as f_auth
rectangle "prediction\nControlPanel, ScenarioPanel,\nCurveEditor" as f_pred
rectangle "workspaces\nWorkspacesPanel,\nWorkspaceRenderer, store" as f_ws
rectangle "tracking\nTelemetryPanel,\nTelemetryStore" as f_track
rectangle "wind\nWindRenderer,\nParticleField, WindCache" as f_wind
rectangle "timeline\nTimeLine, store" as f_time
rectangle "settings\nSettingsPanel, schema, store" as f_set
rectangle "footer" as f_foot
}
package "lib/ (ядро)" as core {
rectangle "api/\nclient (HTTP+WS, CSRF),\npredictions, telemetry, wind,\npoints, scenarios, profiles" as l_api
rectangle "map/\nобёртка MapLibre GL JS,\nслои, инструменты" as l_map
rectangle "ui/\nнезависимые примитивы\n(CollapsibleCard, Toast, …)" as l_ui
rectangle "i18n/\nсловари ru / en" as l_i18n
rectangle "auth/\nguard, store" as l_auth
rectangle "state/\npersisted store\n(localStorage)" as l_state
rectangle "domain/\ngeo, math, telemetry,\nprediction, scenario, wind\n(чистые типы и функции)" as l_dom
}
}
' ── Внешние сервисы ───────────────────────────────────────────────
node "Backend (Django)" as be {
rectangle "REST API\n/api/*" as be_rest
rectangle "WebSocket\n/api/ws/satellite/{id}/telemetry/" as be_ws
}
node "Сервис предсказателя\n(127.0.0.1:8080)" as predictor {
rectangle "GET /api/v1/wind/*" as pred_wind
}
' ── Внешние библиотеки ───────────────────────────────────────────
package "Внешние библиотеки" as libs {
rectangle "Svelte 5 / SvelteKit / Vite" as lib_svelte
rectangle "MapLibre GL JS" as lib_map
rectangle "Bootstrap / Sveltestrap" as lib_bs
rectangle "Chart.js, Luxon, js-cookie" as lib_misc
}
' ── Связи: страницы → модули ─────────────────────────────────────
r_login --> f_auth
r_predict --> f_pred
r_predict --> f_ws
r_predict --> f_wind
r_predict --> f_time
r_predict --> f_set
r_track --> f_track
r_user --> f_pred
' ── Модули → ядро ────────────────────────────────────────────────
features --> l_api
features --> l_map
features --> l_ui
features --> l_state
features --> l_i18n
f_auth --> l_auth
' ── Ядро → домен (чистые типы) ───────────────────────────────────
l_api --> l_dom
l_map --> l_dom
l_state --> l_dom
' ── Ядро → внешние сервисы ───────────────────────────────────────
l_api --> be_rest : HTTP /api/*
f_track --> be_ws : WebSocket
f_wind --> pred_wind : HTTP (без CSRF)
' ── Использование внешних библиотек ──────────────────────────────
spa ..> lib_svelte
l_map ..> lib_map
l_ui ..> lib_bs
core ..> lib_misc
@enduml

View file

@ -0,0 +1,62 @@
@startuml dfd-telemetry
title Диаграмма потоков данных (DFD) подсистемы слежения (телеметрия)
skinparam shadowing false
skinparam defaultFontName Helvetica
skinparam rectangle {
BorderColor black
BackgroundColor #F5F5F5
}
skinparam usecase {
BorderColor #2C5AA0
BackgroundColor #E8F0FE
}
skinparam database {
BorderColor #6B6B6B
BackgroundColor #FFFFFF
}
' ── Внешние сущности ───────────────────────────────────────────────
rectangle "Стратосферный зонд\n(тестовый клиент)" as sat
rectangle "Оператор\n(браузер)" as op
' ── Процессы ──────────────────────────────────────────────────────
usecase "1.0\nПриём телеметрии\n(WebSocket onmessage)" as p1
usecase "2.0\nЗагрузка истории\n(REST fetchHistory)" as p2
usecase "3.0\nНормализация\nparseTelemetry" as p3
usecase "4.0\nОтрисовка на карте\n(MapLibre $effect)" as p4
usecase "5.0\nАнализ отклонений\ncomputeDeviations" as p5
' ── Хранилища данных ──────────────────────────────────────────────
database "D1 | БД телеметрии\n(Backend)" as d1
database "D2 | points[]\n(TelemetryStore, in-memory)" as d2
database "D3 | result\n(прогноз рабочей области)" as d3
' ── Потоки данных ─────────────────────────────────────────────────
sat --> p1 : пакет телеметрии\n(JSON: lat, lon, alt, ts)
p1 --> d1 : сохранение пакета
p1 --> d2 : TelemetryPoint\n(unix-сек → ISO 8601)
op --> p2 : UUID спутника
d1 --> p2 : RawTelemetryPacket[]\n(новые первыми)
p2 --> d2 : история (reverse → хронология)
d2 --> p3 : points[]
p3 --> p4 : Telemetry\n{flight_path[lat,lng,alt], launch}
p4 --> op : трек + маркеры + анимированный\nмаркер текущего положения
d2 --> p5 : фактические точки
d3 --> p5 : прогнозная траектория
p5 --> op : профиль высоты,\nгоризонтальное отклонение (Хаверсин),\nΔh, макс./текущее отклонение
' ── Текущие показатели (геттер latest) ────────────────────────────
d2 --> op : широта, долгота, высота,\nсчётчик пакетов
legend left
Нотация DFD (Йордан/ДеМарко):
▢ прямоугольник — внешняя сущность
◯ овал — процесс
▭ database — хранилище данных
→ — поток данных
endlegend
@enduml

View file

@ -0,0 +1,41 @@
@startuml seq-auth
title Аутентификация: проверка сессии, вход, выход
autonumber
actor "Пользователь" as user
participant "LoginForm /\nguard.ts" as ui
participant "authApi" as authapi
participant "client.ts\nrequest<T>()" as client
participant "Backend\n(Django)" as be
== Проверка сессии при открытии защищённой страницы ==
ui -> authapi : requireAuthenticated()
authapi -> client : session()
client -> be : GET /api/session/
be --> client : { isAuthenticated }
client --> authapi : SessionInfo
alt не аутентифицирован
authapi --> ui : goto('/login')
end
== Вход ==
user -> ui : ввод логина и пароля
ui -> authapi : login(username, password)
authapi -> client : post('/login/', {username, password})
client -> be : POST /api/login/
be --> client : 200 { detail } | 400/401 ApiError
client --> authapi : результат
authapi -> client : whoami()
client -> be : GET /api/whoami/
be --> client : { username }
client --> ui : WhoAmI
ui --> user : переход на рабочую страницу
== Выход ==
user -> ui : «Выйти»
ui -> authapi : logout()
authapi -> client : post('/logout/', {})
client -> be : POST /api/logout/
be --> client : 204
client --> ui : сброс состояния, goto('/login')
@enduml

View file

@ -0,0 +1,41 @@
@startuml seq-prediction
title Запуск прогноза траектории
autonumber
actor "Пользователь" as user
participant "ControlPanel /\nWorkspacesPanel" as ui
participant "workspacesStore" as store
participant "predictionsApi" as predapi
participant "client.ts" as client
participant "Backend\n(Django)" as be
participant "parsePrediction\n(domain)" as parse
participant "WorkspaceRenderer\n(карта)" as render
user -> ui : «Выполнить прогнозирование»
ui -> store : run(workspaceId)
activate store
store -> predapi : run(params, launchDateTime)
activate predapi
predapi -> predapi : buildLaunchDateTime(date, time)\n→ ISO 8601 (UTC, Z)
predapi -> predapi : getLatestDataset()\n→ актуальный слот GFS
predapi -> client : post('/predictions/', payload)
client -> be : POST /api/predictions/\n{FlightParameters, launch_datetime, dataset}
be --> client : { result: PredictionStage[] }
client --> predapi : PredictionResponse
predapi --> store : RawPrediction
deactivate predapi
store -> parse : parsePrediction(stages)
parse --> store : Prediction\n{flight_path[lat,lng,alt], timestamps[], launch/burst/landing}
store -> store : setResult(workspaceId, prediction)
deactivate store
store -> render : реактивное обновление
render -> render : линия трека + маркеры\n(старт, разрыв, приземление)
alt ошибка сервера/сети
store -> store : lastRunError = message
store --> ui : toast «Ошибка прогнозирования»
end
@enduml

View file

@ -0,0 +1,60 @@
@startuml seq-telemetry
title Слежение: загрузка истории и приём телеметрии в реальном времени
autonumber
actor "Пользователь" as user
participant "TelemetryPanel" as ui
participant "TelemetryStore\n(.svelte.ts)" as store
participant "telemetryApi" as tapi
participant "client.ts" as client
participant "Backend REST" as rest
participant "Backend\nWebSocket" as ws
participant "track/+page\n($effect)" as page
participant "MapLibre\n(карта)" as map
user -> ui : ввод UUID, «Подключиться»
ui -> store : connect(id)
activate store
store -> store : проверка UUID регулярным выражением
alt UUID невалиден
store -> store : status = 'error'
store --> ui : (выход без соединения)
end
store -> store : disconnect()\nзакрыть прежний WS, очистить points
store -> store : status = 'connecting'
== Загрузка истории (некритическая) ==
store -> tapi : fetchHistory(id)
tapi -> client : get('/{id}/telemetry/')
client -> rest : GET /api/{id}/telemetry/?from&till
rest --> client : RawTelemetryPacket[] | { results }
client --> tapi : массив пакетов
tapi --> store : RawTelemetryPacket[]
store -> store : reverse() → хронологический порядок,\npoints = [...history]
note right of store : сбой сети → console.warn,\nподключение не прерывается
== WebSocket-соединение ==
store -> tapi : buildWsUrl(id)
tapi --> store : ws(s)://host/api/ws/satellite/{id}/telemetry/
store -> ws : new WebSocket(url)
ws --> store : onopen → status = 'connected'
loop каждый новый пакет
ws --> store : onmessage(JSON)
store -> store : RawPacket → TelemetryPoint\n(unix-сек → ISO 8601),\npoints = [...points, point]
store --> page : реактивное изменение points (Svelte $state)
page -> map : scene.clear()
page -> map : addLine(трек) + addMarker(старт)\n+ plotAnimatedMarker(текущая точка)
page -> map : fitBounds() — однократно (fittedBounds)
end
== Отключение ==
user -> ui : «Отключиться»
ui -> store : disconnect()
store -> ws : close()
ws --> store : onclose → status = 'idle'
store -> store : points = [], satelliteId = null
deactivate store
@enduml

View file

@ -0,0 +1,54 @@
@startuml seq-wind
title Визуализация поля ветра: статический режим и синхронизация с траекторией
autonumber
participant "predict/+page\n<WindRenderer>" as render
participant "WindRenderer\n(.svelte)" as wr
participant "WindCache\n(in-memory)" as cache
participant "windApi" as wapi
participant "Сервис\nпредсказателя\n(127.0.0.1:8080)" as pred
participant "createWindInterpolator\n+ ParticleField" as pf
note over wapi, pred
Запросы идут НАПРЯМУЮ через fetch к сервису предсказателя,
минуя client.ts: без CSRF и сессионных cookie.
end note
alt Статический режим (ветер по умолчанию)
wr -> wr : параметры из активной области\n(высота старта, дата старта)
wr -> cache : get(ключ = JSON параметров)
alt промах кэша
wr -> wapi : field({ altitude, step, time })
wapi -> pred : GET /api/v1/wind/field?altitude&step&time
pred --> wapi : WindField [C_U, C_V]
wapi --> wr : WindField
wr -> cache : put(ключ, field)
end
wr -> pf : установить поле → анимация частиц
end
alt Режим синхронизации с траекторией
note over wr : активен прогноз И timeline.max > 0
wr -> wr : bbox траектории + проверки\n(F ≤ Fmax, сторона ≤ Dmax)
loop δ ∈ {0, ΔT, 2ΔT, …, F}
wr -> wr : T = t0 + δ; высота a_{k*} (бин. поиск)
wr -> cache : get(ключ кадра)
alt промах кэша
wr -> wapi : field({ altitude, step, time, min/max lat/lng })
wapi -> pred : GET /api/v1/wind/field?…(bbox)
pred --> wapi : WindField кадра
wapi --> wr : WindField
wr -> cache : put(ключ кадра, field)
end
end
loop при воспроизведении (timeline)
wr -> wr : fieldAtFlightTime(δ):\nлинейная интерполяция [u,v]\nмежду соседними кадрами
wr -> pf : currentField → плавная адвекция частиц
end
end
note over wapi, pred
windApi.meta() → GET /api/v1/wind/meta
(опорное время / параметры сетки, при необходимости)
end note
@enduml

395
docs/wind-vis-math.tex Normal file
View file

@ -0,0 +1,395 @@
\documentclass[11pt,a4paper]{article}
\usepackage{siunitx}
\usepackage{amsmath,amssymb,amsthm}
\usepackage{geometry}
\usepackage{hyperref}
\usepackage{booktabs}
\geometry{margin=2.5cm}
\title{Wind Visualisation -- Mathematical Reference\\
\large stratoflights-predictor frontend}
\author{stratoflights}
\date{}
\begin{document}
\maketitle
\tableofcontents
\bigskip
%---------------------------------------------------------------------------
\section{Wind Field Grid Format}
%---------------------------------------------------------------------------
The predictor's \texttt{GET /api/v1/wind/field} endpoint returns a
two-element JSON array $[C_U,\,C_V]$, each element having the shape
\begin{verbatim}
{ "header": { "nx", "ny", "lo1", "la1", "lo2", "la2",
"dx", "dy", "refTime", ... },
"data": [ float, ... ] // flat row-major array, length = nx * ny
}
\end{verbatim}
\noindent where $C_U$ contains the \emph{eastward} (zonal) component $u$
and $C_V$ contains the \emph{northward} (meridional) component $v$, both
in \si{m\,s^{-1}}.
\subsection{Grid coordinates}
Let $n_x$ and $n_y$ be the number of grid points along the longitude and
latitude axes respectively. The coordinate of grid cell $(i,\,j)$
($i = 0, \ldots, n_x-1$;\; $j = 0, \ldots, n_y-1$) is
\begin{align}
\lambda_{i} &= \operatorname{wrap}\!\left(\lambda_1 + i\,\Delta\lambda\right), \label{eq:lng}\\
\varphi_{j} &= \varphi_1 + j\,\Delta\varphi, \label{eq:lat}
\end{align}
\noindent where $\lambda_1 = \texttt{lo1}$ and $\varphi_1 = \texttt{la1}$.
\paragraph{Increments from the extent, not \texttt{dx}/\texttt{dy}.}
The predictor reports \texttt{dx} and \texttt{dy} as positive magnitudes
\emph{regardless of scan direction}, and emits longitudes in the
$[0,360)$ convention. The per-step increments are therefore derived from
the grid extent so the last row/column lands exactly on
$(\texttt{la2},\texttt{lo2})$ and the scan direction follows automatically:
\begin{equation}
\Delta\lambda = \frac{(\texttt{lo2}-\texttt{lo1}) \bmod 360}{n_x-1},
\qquad
\Delta\varphi = \frac{\texttt{la2}-\texttt{la1}}{n_y-1}.
\label{eq:increments}
\end{equation}
For the standard GFS global grid $\varphi_1 = 90^\circ$, $\varphi_2 = -90^\circ$,
so $\Delta\varphi = -10^\circ$ (rows scan southward) without any case
distinction. (When $n_x=1$ or $n_y=1$ the magnitude \texttt{dx}/\texttt{dy}
is used as a fallback.)
\paragraph{Longitude wrapping.}
Because longitudes are emitted in $[0,360)$ but the map renders in
$(-180,180]$, each longitude is wrapped:
\begin{equation}
\operatorname{wrap}(\lambda) = \big((\lambda + 180) \bmod 360\big) - 180.
\label{eq:wrap}
\end{equation}
Without \eqref{eq:wrap} a grid sampled near the prime meridian
(e.g.\ $\texttt{lo1}=358$ for a query at $-2^\circ$) would be rendered a
full $360^\circ$ away from the trajectory.
The flat data index for cell $(i,\,j)$ is
\begin{equation}
k = j\,n_x + i.
\label{eq:index}
\end{equation}
%---------------------------------------------------------------------------
\section{Bilinear Interpolation of Wind Components}
%---------------------------------------------------------------------------
The particle renderer samples the wind at arbitrary points (the unprojected
pixel positions of individual particles), so bilinear interpolation of the
$[u,v]$ grid is performed at run time for every particle, every frame.
Given a query point $(\lambda,\,\varphi)$ inside the grid, locate the
surrounding four cells
\begin{align*}
i_0 &= \left\lfloor \frac{\lambda - \lambda_1}{\Delta\lambda} \right\rfloor,
&
j_0 &= \left\lfloor \frac{\varphi - \varphi_1}{\Delta\varphi} \right\rfloor,
\end{align*}
and define the fractional offsets
\begin{equation*}
s = \frac{\lambda - \lambda_{i_0}}{\Delta\lambda},
\qquad
t = \frac{\varphi - \varphi_{j_0}}{\Delta\varphi},
\qquad
s,t \in [0,1].
\end{equation*}
The bilinearly interpolated value of any scalar field $f$ at $(\lambda,\varphi)$
is
\begin{equation}
f(\lambda,\varphi)
= (1-s)(1-t)\,f_{i_0,j_0}
+ s(1-t)\,f_{i_0+1,j_0}
+ (1-s)t\,f_{i_0,j_0+1}
+ st\,f_{i_0+1,j_0+1}.
\label{eq:bilinear}
\end{equation}
Applied independently to $u$ and $v$, equation~\eqref{eq:bilinear} gives
the interpolated wind vector at any interior point.
%---------------------------------------------------------------------------
\section{Particle Advection and Rendering}
%---------------------------------------------------------------------------
The wind is visualised as a dense field of particles that flow with the wind,
in the style of \texttt{leaflet-velocity} / cambecc's \emph{earth}. Particles
live in screen (CSS-pixel) space; each animation frame they are advected by
the local wind and drawn as short fading trails.
\subsection{Speed (magnitude)}
The wind speed used for colouring is the Euclidean norm of the horizontal
wind vector returned by the interpolator~\eqref{eq:bilinear}:
\begin{equation}
\lVert\mathbf{w}\rVert = \sqrt{u^2 + v^2}.
\label{eq:speed}
\end{equation}
\subsection{Advection through the map projection}
A particle at pixel position $\mathbf{x} = (x,y)$ is unprojected to geographic
coordinates $(\lambda,\varphi) = P^{-1}(\mathbf{x})$, where $P$ is the
MapLibre projection (Web Mercator composed with the current view transform).
The wind $\mathbf{w}=(u,v)$ in the east--north frame must be expressed in
pixel space; this is done with the local Jacobian of $P$, estimated by finite
differences with a small $\varepsilon$ (degrees):
\begin{align}
\mathbf{J}_\lambda &= \frac{P(\lambda+\varepsilon,\varphi) - \mathbf{x}}{\varepsilon},
&
\mathbf{J}_\varphi &= \frac{P(\lambda,\varphi+\varepsilon) - \mathbf{x}}{\varepsilon}.
\label{eq:jacobian}
\end{align}
\noindent The pixel-space velocity is the Jacobian applied to the wind vector,
scaled by a dimensionless speed factor $c$ (the configurable
\texttt{particleSpeed} times a base constant):
\begin{equation}
\dot{\mathbf{x}} = c\,\big(\mathbf{J}_\lambda\, u + \mathbf{J}_\varphi\, v\big).
\label{eq:pixel_velocity}
\end{equation}
Because $\mathbf{J}_\varphi$ points toward $-y$ (north is up), a northward wind
moves the particle up the screen, and the projection automatically supplies
the correct scale at every zoom level and the meridian-convergence distortion
near the poles. The particle is integrated one explicit Euler step per frame:
\begin{equation}
\mathbf{x}_{t+1} = \mathbf{x}_t + \dot{\mathbf{x}}.
\label{eq:euler}
\end{equation}
A particle whose position leaves the data grid (interpolator returns
\texttt{null}) or whose age exceeds $A_{\max}$ frames is respawned at a random
pixel that has wind, with a randomised initial age to desynchronise the
population.
\subsection{Colour mapping}
Speed is mapped to a 15-stop colour ramp (blue $\to$ red) by linear
quantisation into the range $[v_{\min}, v_{\max}]$:
\begin{equation}
k(\lVert\mathbf{w}\rVert) =
\operatorname{round}\!\left(
\frac{\lVert\mathbf{w}\rVert - v_{\min}}{v_{\max}-v_{\min}}\,(K-1)
\right),
\quad k \in \{0,\dots,K-1\},
\end{equation}
\noindent clamped to the ramp, where $K=15$ and $v_{\max}$ is the configurable
\texttt{maxVelocity}. Trails sharing a colour bucket are batched into a single
stroked path per frame.
\subsection{Trail fading}
Rather than clearing the canvas each frame, the previous frame is faded toward
\emph{transparency} (so the basemap stays visible) by compositing a translucent
rectangle with the \texttt{destination-in} operator:
\begin{equation}
\alpha_{t+1}(\mathbf{x}) = \rho\,\alpha_t(\mathbf{x}),
\qquad \rho \in [0,1),
\end{equation}
\noindent where $\rho$ is the configurable \texttt{trailPersistence}; an
existing trail therefore decays geometrically, retaining a visible tail of
length $\sim 1/(1-\rho)$ frames. New trail segments are then drawn with the
\texttt{source-over} operator.
\subsection{Particle count}
The particle population is proportional to the canvas area:
\begin{equation}
N = \min\!\big(N_{\max},\; W H \, \mu \, \texttt{density}\big),
\end{equation}
\noindent with base multiplier $\mu = 1/350$ particles per pixel, the
configurable \texttt{density} factor, and a hard cap $N_{\max}=6000$ to bound
per-frame cost. The field is re-seeded on resize and on map \texttt{moveend};
trails are cleared while the map is panning/zooming and resume afterwards.
%---------------------------------------------------------------------------
\section{Trajectory Bounding Box}
%---------------------------------------------------------------------------
Let the prediction trajectory be the sequence of points
$\{(\varphi_k, \lambda_k)\}_{k=0}^{N-1}$. The axis-aligned bounding box
with margin $m$ (degrees) is
\begin{align}
\varphi_{\min}' &= \min_k \varphi_k - m, &
\varphi_{\max}' &= \max_k \varphi_k + m, \notag\\
\lambda_{\min}' &= \min_k \lambda_k - m, &
\lambda_{\max}' &= \max_k \lambda_k + m.
\label{eq:bbox}
\end{align}
A wind field request is suppressed when either span exceeds the configured
maximum $D_{\max}$:
\begin{equation}
(\varphi_{\max}' - \varphi_{\min}') > D_{\max}
\quad\text{or}\quad
(\lambda_{\max}' - \lambda_{\min}') > D_{\max}.
\label{eq:bbox_guard}
\end{equation}
Default values: $m = 1^\circ$, $D_{\max} = 20^\circ$.
%---------------------------------------------------------------------------
\section{Trajectory Altitude Lookup}
%---------------------------------------------------------------------------
The prediction result contains two parallel arrays:
$\mathbf{p} = \{(\varphi_k, \lambda_k, a_k)\}$ (flight path with altitude
in metres) and $\mathbf{t} = \{t_k\}$ (absolute epoch timestamps in
milliseconds, $t_k < t_{k+1}$).
Given a flight-time offset $\delta$ (milliseconds from launch), the
absolute query time is $T = t_0 + \delta$. The index of the immediately
following trajectory point is found by binary search:
\begin{equation}
k^* = \min\{k \in \{0,\ldots,N-1\} : t_k \ge T\}.
\label{eq:binsearch}
\end{equation}
The trajectory altitude used for the wind query is then
$a_{k^*}$ (nearest-neighbour in time, no interpolation needed since the
pre-fetch frames are already spaced far apart relative to the step size).
%---------------------------------------------------------------------------
\section{Time Series Pre-Fetching}
%---------------------------------------------------------------------------
\subsection{Frame schedule}
Let $F$ be the total flight duration in milliseconds and $\Delta T$ the
pre-fetch interval (milliseconds, default $15 \times 60\,000$). The set
of pre-fetch time offsets is
\begin{equation}
\mathcal{S} = \{0,\;\Delta T,\;2\Delta T,\;\ldots,\;F\},
\label{eq:schedule}
\end{equation}
where the last element is clamped to $F$ (so the landing point is always
included). For each $\delta \in \mathcal{S}$ a wind field is fetched at
absolute time $T = t_0 + \delta$ and trajectory altitude $a_{k^*(\delta)}$
within the bounding box~\eqref{eq:bbox}.
\subsection{Guard conditions}
Pre-fetching is skipped entirely when
\begin{enumerate}
\item $F > F_{\max}$ (flight too long; default $F_{\max} = 4$\,h), or
\item either bounding-box dimension exceeds $D_{\max}$
(equation~\eqref{eq:bbox_guard}).
\end{enumerate}
Both limits are configurable in the application settings.
\subsection{Temporal interpolation between frames}
During playback at flight-time offset $\delta$, rather than snapping to the
nearest cached frame (which would make the wind jump every $\Delta T$), the
displayed field is \emph{linearly interpolated} between the two bracketing
frames $\delta_p \le \delta < \delta_{p+1}$ ($\delta_p,\delta_{p+1}\in\mathcal{S}$).
With blend factor
\begin{equation}
\alpha = \frac{\delta - \delta_p}{\delta_{p+1} - \delta_p} \in [0,1),
\label{eq:blend_alpha}
\end{equation}
each wind component is blended cell-for-cell:
\begin{equation}
u(\delta) = (1-\alpha)\,u^{(p)} + \alpha\,u^{(p+1)},
\qquad
v(\delta) = (1-\alpha)\,v^{(p)} + \alpha\,v^{(p+1)}.
\label{eq:time_lerp}
\end{equation}
This is valid because every frame is fetched over the \emph{same} bounding
box~\eqref{eq:bbox} and step, so the grids are cell-aligned ($n_x,n_y,
\lambda_1,\varphi_1$ identical) and~\eqref{eq:time_lerp} requires only an
element-wise blend of the two flat $[u,v]$ arrays. Note that the two frames
are sampled at slightly different altitudes $a_{k^*(\delta_p)}$ and
$a_{k^*(\delta_{p+1})}$; the blend therefore also interpolates across the
balloon's changing altitude, which is the desired behaviour. The result is
continuous wind evolution along the route while still requiring only
$|\mathcal{S}|$ network requests per trajectory.
The blended field~\eqref{eq:time_lerp} is handed to the particle renderer
(\S\,Particle Advection) as the interpolator swapped in each frame, so the
flowing particles advect through the smoothly time-evolving wind without any
visible stepping between pre-fetch frames.
%---------------------------------------------------------------------------
\section{Complexity and Performance Notes}
%---------------------------------------------------------------------------
\subsection{Grid size and render cost}
For a regional bounding box of $L_\varphi \times L_\lambda$ degrees and
step $h$ degrees, the number of grid cells fetched is
\begin{equation}
n = \left\lceil \frac{L_\varphi}{h} \right\rceil
\left\lceil \frac{L_\lambda}{h} \right\rceil.
\end{equation}
With the default trajectory step $h = 1^\circ$ and worst-case dimensions
$20^\circ \times 20^\circ$ this is $n = 400$ cells; the static global view at
$h = 2^\circ$ is $90 \times 180 = 16\,200$ cells. Crucially, the grid size
only affects the \emph{fetch} and the per-particle bilinear lookup, not the
render budget: the per-frame cost is governed by the particle count $N$
(eq.~for $N$), capped at $N_{\max}=6000$, independent of the grid resolution
or the geographic extent. Each particle costs one unprojection, one
interpolation, and two projections (for the Jacobian) per frame.
\subsection{Request count}
The total number of requests for one trajectory is
\begin{equation}
|\mathcal{S}| = \left\lfloor \frac{F}{\Delta T} \right\rfloor + 1
\;\le\; \frac{F_{\max}}{\Delta T} + 1.
\end{equation}
With the defaults $F_{\max} = 4$\,h and $\Delta T = 15$\,min:
$|\mathcal{S}| \le 17$. Requests are issued sequentially to avoid
bursty load on the predictor.
\subsection{Caching}
The in-memory cache (keyed by the full parameter tuple) ensures that
re-running a prediction with the same parameters, scrubbing the timeline
back and forth, or toggling the wind layer all reuse existing responses.
\end{document}

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

1168
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' }),
};

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

@ -0,0 +1,7 @@
export { api, ApiError, API_BASE_URL, setUnauthorizedHandler } from './client';
export { telemetryApi, buildWsUrl, type RawTelemetryPacket } from './telemetry';
export { pointsApi } from './points';
export { profilesApi } from './profiles';
export { scenariosApi } from './scenarios';
export { predictionsApi, getLatestDataset, buildLaunchDateTime } from './predictions';
export { windApi, type WindFieldParams } from './wind';

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

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}/`),
};

35
src/lib/api/telemetry.ts Normal file
View file

@ -0,0 +1,35 @@
import { api, API_BASE_URL } from './client';
export interface RawTelemetryPacket {
id: string;
timestamp: number; // unix seconds
lat: number;
lon: number;
alt: number;
payload: Record<string, unknown>;
raw_data: Record<string, unknown>;
}
/** Derives a WebSocket URL from the configured API base URL. */
export function buildWsUrl(satelliteId: string): string {
let base = API_BASE_URL;
if (!base.startsWith('http')) {
base = `${window.location.protocol}//${window.location.host}${base}`;
}
const url = new URL(base);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
return `${url.origin}${url.pathname}/ws/satellite/${satelliteId}/telemetry/`;
}
export const telemetryApi = {
fetchHistory: async (
satelliteId: string,
params?: { from?: number; till?: number },
): Promise<RawTelemetryPacket[]> => {
const res = await api.get<RawTelemetryPacket[] | { results: RawTelemetryPacket[] }>(
`/${satelliteId}/telemetry/`,
{ query: params },
);
return Array.isArray(res) ? res : res.results;
},
};

58
src/lib/api/wind.ts Normal file
View file

@ -0,0 +1,58 @@
/**
* Client for the predictor's wind-visualization endpoints.
*
* These endpoints live on the predictor service (default 127.0.0.1:8080),
* not on the Django backend, so they bypass the shared `api` client and
* fetch directly. No CSRF or session cookies are needed.
*
* Set VITE_PREDICTOR_BASE_URL to point at a non-default predictor address.
*/
import type { WindField, WindMeta } from '$domain';
const PREDICTOR_URL = (import.meta.env.VITE_PREDICTOR_BASE_URL as string | undefined) ?? 'http://127.0.0.1:8080';
export interface WindFieldParams {
altitude?: number;
step?: number;
time?: string;
min_lat?: number;
max_lat?: number;
min_lng?: number;
max_lng?: number;
}
async function predictorFetch<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T> {
const q = new URLSearchParams();
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v !== undefined) q.set(k, String(v));
}
}
const qs = q.toString();
const url = `${PREDICTOR_URL}${path}${qs ? '?' + qs : ''}`;
const res = await fetch(url);
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
throw new Error(`Predictor ${path} failed: HTTP ${res.status} ${text}`);
}
return res.json() as Promise<T>;
}
export const windApi = {
field(params: WindFieldParams = {}): Promise<WindField> {
return predictorFetch<WindField>('/api/v1/wind/field', {
altitude: params.altitude,
step: params.step,
time: params.time,
min_lat: params.min_lat,
max_lat: params.max_lat,
min_lng: params.min_lng,
max_lng: params.max_lng,
});
},
meta(): Promise<WindMeta> {
return predictorFetch<WindMeta>('/api/v1/wind/meta');
},
};

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

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

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

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

@ -0,0 +1,99 @@
import type { LatLng } from './geo';
import type { TelemetryPoint } from './telemetry';
import type { Prediction } from './prediction';
const EARTH_RADIUS_KM = 6371;
const toRad = (deg: number): number => (deg * Math.PI) / 180;
const toDeg = (rad: number): number => (rad * 180) / Math.PI;
export function distHaversine(p1: LatLng, p2: LatLng, precision?: number): number {
const dLat = toRad(p2.lat - p1.lat);
const dLng = toRad(p2.lng - p1.lng);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(p1.lat)) * Math.cos(toRad(p2.lat)) * Math.sin(dLng / 2) ** 2;
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const d = EARTH_RADIUS_KM * c;
return precision !== undefined ? parseFloat(d.toFixed(precision)) : d;
}
export function bearingHaversine(p1: LatLng, p2: LatLng): number {
const dLng = toRad(p2.lng - p1.lng);
const y = Math.sin(dLng) * Math.cos(toRad(p2.lat));
const x =
Math.cos(toRad(p1.lat)) * Math.sin(toRad(p2.lat)) -
Math.sin(toRad(p1.lat)) * Math.cos(toRad(p2.lat)) * Math.cos(dLng);
return toDeg(Math.atan2(y, x));
}
export function toFixedNumber(num: number, digits: number): number {
const pow = 10 ** digits;
return Math.round(num * pow) / pow;
}
/** One compared sample: telemetry point matched against the closest-in-time prediction point. */
export interface DeviationPoint {
/** Epoch ms from the telemetry timestamp. */
timeMs: number;
/** Great-circle distance from actual position to predicted position, km. */
horizontal: number;
/** Altitude difference (actual predicted), m. Positive means actual is higher. */
vertical: number;
/** Actual altitude from telemetry, m. */
altActual: number;
/** Predicted altitude at the matched index, m. */
altPredicted: number;
}
/**
* For each telemetry point find the closest-in-time point in the prediction
* and compute horizontal (haversine) and vertical deviations.
*
* Telemetry points that fall outside the prediction's time window are skipped
* bisectClosest would clamp them to the boundary and produce misleading values.
*/
export function computeDeviations(
points: TelemetryPoint[],
prediction: Prediction,
): DeviationPoint[] {
if (points.length === 0 || prediction.timestamps.length === 0) return [];
const predStart = prediction.timestamps[0];
const predEnd = prediction.timestamps[prediction.timestamps.length - 1];
const result: DeviationPoint[] = [];
for (const p of points) {
const t = new Date(p.datetime).getTime();
if (t < predStart || t > predEnd) continue;
const i = bisectClosest(prediction.timestamps, t);
const fp = prediction.flight_path[i];
const predAlt = (fp[2] as number | undefined) ?? 0;
result.push({
timeMs: t,
horizontal: distHaversine({ lat: p.latitude, lng: p.longitude }, { lat: fp[0], lng: fp[1] }),
vertical: p.altitude - predAlt,
altActual: p.altitude,
altPredicted: predAlt,
});
}
return result;
}
/** Binary search: index of the element in `arr` closest to `target`. */
function bisectClosest(arr: number[], target: number): number {
let lo = 0;
let hi = arr.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (arr[mid] < target) lo = mid + 1;
else hi = mid;
}
if (lo > 0 && Math.abs(arr[lo - 1] - target) < Math.abs(arr[lo] - target)) return lo - 1;
return lo;
}

View file

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

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

214
src/lib/domain/wind.ts Normal file
View file

@ -0,0 +1,214 @@
/**
* Wind field types matching the wind-js-server / leaflet-velocity format
* produced by the predictor's GET /api/v1/wind/field endpoint.
*
* The response is a two-element array [U, V] where U is the eastward and V
* the northward wind component, each stored as a regular lat/lng grid
* described by a GRIB-style header.
*/
export interface WindHeader {
parameterUnit: string;
parameterNumberName: string;
/** Grid points in the longitude direction. */
nx: number;
/** Grid points in the latitude direction. */
ny: number;
lo1: number; // longitude of first grid point (degrees)
la1: number; // latitude of first grid point (degrees)
lo2: number; // longitude of last grid point
la2: number; // latitude of last grid point
/**
* Grid increments in degrees. Both are reported as positive magnitudes by
* the predictor regardless of scan direction, so the scan direction must be
* inferred from the extent (la1/la2, lo1/lo2) see decodeWindField.
*/
dx: number;
dy: number;
refTime: string; // ISO 8601 reference time
}
export interface WindComponent {
header: WindHeader;
/** Flat row-major array: data[j * nx + i] = value at row j, column i. */
data: number[];
}
/** [U-component (eastward m/s), V-component (northward m/s)] */
export type WindField = [WindComponent, WindComponent];
export interface WindMeta {
source: string;
epoch: string;
altitudes: number[];
bbox: {
min_lat: number;
max_lat: number;
min_lng: number;
max_lng: number;
};
}
/** Decoded wind vector at a single grid cell. */
export interface WindVector {
lat: number;
lng: number;
u: number; // eastward component (m/s)
v: number; // northward component (m/s)
speed: number; // magnitude (m/s)
/**
* Direction the wind blows TO, degrees clockwise from north.
* 0° = northward, 90° = eastward. Used directly as MapLibre icon-rotate.
*
* Derivation: bearing = atan2(U, V) (see docs/wind-vis-math.tex §3).
*/
bearing: number;
}
export interface WindSettings {
/** Master toggle — off by default. */
enabled: boolean;
/** Grid resolution for static display (degrees). */
step: number;
/** Grid resolution when synced to a trajectory (degrees). */
trajectoryStep: number;
/** Time interval between pre-fetched trajectory frames (minutes). */
prefetchIntervalMinutes: number;
/** Trajectory sync is skipped when flight duration exceeds this (hours). */
maxFlightDurationHours: number;
/**
* Trajectory sync is skipped when the bounding box exceeds this in either
* dimension (degrees).
*/
maxRegionDegrees: number;
/** Padding added to the trajectory bounding box on each side (degrees). */
trajectoryMarginDegrees: number;
/** Particle count scalar (particles per screen pixel). Higher = denser. */
particleDensity: number;
/** Advection speed multiplier — how fast particles flow. */
particleSpeed: number;
/** Trail persistence in [0,1): fraction of each trail kept per frame. */
trailPersistence: number;
/** Wind speed (m/s) mapped to the top of the colour scale. */
maxVelocity: number;
}
export const DEFAULT_WIND_SETTINGS: WindSettings = {
enabled: false,
step: 2.0,
trajectoryStep: 1.0,
prefetchIntervalMinutes: 15,
maxFlightDurationHours: 4,
maxRegionDegrees: 20,
trajectoryMarginDegrees: 1.0,
particleDensity: 1.0,
particleSpeed: 1.0,
trailPersistence: 0.92,
maxVelocity: 30,
};
/** Wrap a longitude into the (-180, 180] range MapLibre renders. */
function wrapLng(lng: number): number {
let x = ((lng + 180) % 360) - 180;
if (x <= -180) x += 360;
return x;
}
/**
* Rasterize a WindField into an array of wind vectors one per grid cell.
*
* Coordinate handling is derived from the grid extent (la1/la2, lo1/lo2)
* rather than the raw dx/dy increments, because the predictor reports:
* longitudes in the 0..360 range (e.g. lo1 = 358 for a query at -2°), and
* a *positive* dy even when the grid scans northsouth (la1 = 90,
* la2 = -90), which would otherwise send `la1 + j·dy` past the pole.
*
* Stepping from the first point toward the last (la1la2, lo1lo2) and
* wrapping longitudes into (-180, 180] places every arrow at its true
* geographic position regardless of scan direction or longitude convention.
*/
export function decodeWindField(field: WindField): WindVector[] {
const [uComp, vComp] = field;
const { nx, ny, lo1, la1, lo2, la2, dx, dy } = uComp.header;
const vectors: WindVector[] = [];
// Per-step deltas taken from the grid extent so the last row/column lands
// exactly on la2/lo2. Longitude span is taken the short way around the
// globe to stay correct for boxes that cross the 0/360 seam.
const lonSpan = ((lo2 - lo1) % 360 + 360) % 360;
const lngDelta = nx > 1 ? lonSpan / (nx - 1) : dx;
const latDelta = ny > 1 ? (la2 - la1) / (ny - 1) : -Math.abs(dy);
for (let j = 0; j < ny; j++) {
const lat = la1 + j * latDelta;
for (let i = 0; i < nx; i++) {
const idx = j * nx + i;
const u = uComp.data[idx];
const v = vComp.data[idx];
if (!Number.isFinite(u) || !Number.isFinite(v)) continue;
const lng = wrapLng(lo1 + i * lngDelta);
const speed = Math.sqrt(u * u + v * v);
const bearing = (Math.atan2(u, v) * 180) / Math.PI;
vectors.push({ lat, lng, u, v, speed, bearing });
}
}
return vectors;
}
/** Samples the wind field at an arbitrary lng/lat. Returns null outside the grid. */
export type WindInterpolator = (lng: number, lat: number) => [number, number] | null;
/**
* Build a bilinear interpolator over a WindField. Used by the particle
* renderer to advect points through a continuous [u, v] field.
*
* Coordinate handling mirrors decodeWindField: longitudes are taken in the
* grid's native 0..360 frame (so a query lng is brought into that frame),
* and the per-step increments come from the grid extent so scan direction is
* handled implicitly.
*/
export function createWindInterpolator(field: WindField): WindInterpolator {
const [uComp, vComp] = field;
const { nx, ny, lo1, la1, lo2, la2, dx, dy } = uComp.header;
const u = uComp.data;
const v = vComp.data;
const lonSpan = (((lo2 - lo1) % 360) + 360) % 360;
const lngDelta = nx > 1 ? lonSpan / (nx - 1) : dx;
const latDelta = ny > 1 ? (la2 - la1) / (ny - 1) : -Math.abs(dy);
return (lng, lat) => {
if (lngDelta === 0 || latDelta === 0) return null;
const rj = (lat - la1) / latDelta;
if (rj < 0 || rj > ny - 1) return null;
// Eastward offset from lo1 in the grid's 0..360 frame.
const dLon = (((lng - lo1) % 360) + 360) % 360;
const ci = dLon / lngDelta;
if (ci < 0 || ci > nx - 1) return null;
const i0 = Math.floor(ci);
const j0 = Math.floor(rj);
const i1 = Math.min(i0 + 1, nx - 1);
const j1 = Math.min(j0 + 1, ny - 1);
const fi = ci - i0;
const fj = rj - j0;
const a = (1 - fi) * (1 - fj);
const b = fi * (1 - fj);
const c = (1 - fi) * fj;
const d = fi * fj;
const k00 = j0 * nx + i0;
const k10 = j0 * nx + i1;
const k01 = j1 * nx + i0;
const k11 = j1 * nx + i1;
const ui = u[k00] * a + u[k10] * b + u[k01] * c + u[k11] * d;
const vi = v[k00] * a + v[k10] * b + v[k01] * c + v[k11] * d;
if (!Number.isFinite(ui) || !Number.isFinite(vi)) return null;
return [ui, vi];
};
}

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,147 @@
<script lang="ts">
import { page } from '$app/stores';
import { authStore } from '$auth';
import { goto } from '$app/navigation';
import { t } from '$i18n';
let isNavOpen = $state(false);
let isDropdownOpen = $state(false);
async function handleLogout() {
await authStore.logout();
await goto('/');
}
function closeAll() {
isNavOpen = false;
isDropdownOpen = false;
}
</script>
<svelte:window onclick={() => { if (isDropdownOpen) isDropdownOpen = false; }} />
<style>
/* Stretch the container chain so every nav item reaches the full navbar height */
.container-fluid {
height: 100%;
align-items: stretch;
}
.navbar-collapse {
align-self: stretch;
align-items: stretch;
}
.navbar-nav {
align-self: stretch;
align-items: stretch;
}
.nav-item {
display: flex;
align-items: stretch;
}
/* Normalize <button> to match <a> nav-links exactly */
button.nav-link {
appearance: none;
-webkit-appearance: none;
color: inherit;
cursor: pointer;
border-radius: 0;
font-size: inherit;
font-family: inherit;
background-color: white;
}
button.nav-link:hover {
color: white !important;
background-color: var(--bs-primary);
}
/* Keep dropdown within viewport — clip vertically, guard horizontal edge */
.dropdown-menu {
max-height: calc(100vh - var(--navbar-height) - 4px);
overflow-y: auto;
right: 0;
left: auto;
max-width: calc(100vw - 1.5rem);
}
</style>
<nav class="navbar navbar-expand-lg navbar-light fixed-top custom-navbar border-bottom">
<div class="container-fluid px-3">
<a class="navbar-brand nav-full-height" href="/">
<img src="/logo.svg" alt="Logo" height="34" class="d-inline-block align-text-top" />
</a>
<button
type="button"
class="navbar-toggler"
aria-controls="mainNav"
aria-expanded={isNavOpen}
aria-label="Toggle navigation"
onclick={() => (isNavOpen = !isNavOpen)}>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" class:show={isNavOpen} id="mainNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a
href="/predict"
class="nav-link nav-full-height border border-top-0"
class:active={$page.url.pathname === '/predict'}
onclick={closeAll}>
{$t('nav.predict')}
</a>
</li>
<li class="nav-item">
<a
href="/track"
class="nav-link nav-full-height border border-top-0"
class:active={$page.url.pathname === '/track'}
onclick={closeAll}>
{$t('nav.track')}
</a>
</li>
</ul>
<ul class="navbar-nav">
{#if $authStore.status === 'authenticated' && $authStore.username}
<li class="nav-item dropdown" class:show={isDropdownOpen}>
<button
type="button"
class="nav-link nav-full-height border border-top-0 dropdown-toggle"
aria-expanded={isDropdownOpen}
onclick={(e) => { e.stopPropagation(); isDropdownOpen = !isDropdownOpen; }}>
{$authStore.username}
</button>
<ul class="dropdown-menu dropdown-menu-end" class:show={isDropdownOpen}>
<li><a class="dropdown-item" href="/user/account" onclick={closeAll}>{$t('nav.account')}</a></li>
<li><a class="dropdown-item" href="/user/templates" onclick={closeAll}>{$t('nav.scenarios')}</a></li>
<li><a class="dropdown-item" href="/user/predictions" onclick={closeAll}>{$t('nav.predictionHistory')}</a></li>
<li><a class="dropdown-item" href="/user/flights" onclick={closeAll}>{$t('nav.trackingHistory')}</a></li>
<li><hr class="dropdown-divider" /></li>
<li>
<button type="button" class="dropdown-item" onclick={handleLogout}>
{$t('nav.logout')}
</button>
</li>
</ul>
</li>
{:else if $authStore.status === 'anonymous'}
<li class="nav-item">
<a
href="/login"
class="nav-link nav-full-height border border-top-0"
class:active={$page.url.pathname === '/login'}
onclick={closeAll}>
{$t('nav.login')}
</a>
</li>
{/if}
</ul>
</div>
</div>
</nav>

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, WindSettings } 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,172 @@
/**
* Declarative settings schema. Each `SettingsField` describes a single setting
* that renders as a labeled form control in the Settings panel. Keep this
* independent of Svelte so the same schema can drive future serializers
* (export/import settings, URL state, etc.).
*/
export type FieldKind = 'boolean' | 'select' | 'number' | 'string';
export interface BaseField<K extends FieldKind> {
kind: K;
/** Dot-separated path into AppSettings (e.g. `'map.baseLayer'`). */
path: string;
labelKey: string;
descriptionKey?: string;
}
export interface BooleanField extends BaseField<'boolean'> {}
export interface NumberField extends BaseField<'number'> {
min?: number;
max?: number;
step?: number;
}
export interface StringField extends BaseField<'string'> {
placeholder?: string;
}
export interface SelectField extends BaseField<'select'> {
options: { value: string; labelKey: string }[];
}
export type SettingsField = BooleanField | NumberField | StringField | SelectField;
export interface SettingsSection {
titleKey: string;
fields: SettingsField[];
}
export const SETTINGS_SCHEMA: SettingsSection[] = [
{
titleKey: 'settings.language',
fields: [
{
kind: 'select',
path: 'locale',
labelKey: 'settings.language',
options: [
{ value: 'ru', labelKey: 'common.yes' },
{ value: 'en', labelKey: 'common.yes' },
],
},
],
},
{
titleKey: 'settings.map',
fields: [
{
kind: 'select',
path: 'map.baseLayer',
labelKey: 'settings.baseLayer',
options: [
{ value: 'osm', labelKey: 'settings.baseLayer' },
{ value: 'satellite', labelKey: 'settings.baseLayer' },
],
},
{ kind: 'boolean', path: 'map.showScale', labelKey: 'settings.showScale' },
{ kind: 'boolean', path: 'map.showNavigation', labelKey: 'settings.showNavigation' },
],
},
{
titleKey: 'settings.units',
fields: [
{
kind: 'select',
path: 'units.system',
labelKey: 'settings.units',
options: [
{ value: 'metric', labelKey: 'settings.metric' },
{ value: 'imperial', labelKey: 'settings.imperial' },
],
},
],
},
{
titleKey: 'settings.wind',
fields: [
{ kind: 'boolean', path: 'wind.enabled', labelKey: 'settings.windEnabled' },
{
kind: 'number',
path: 'wind.step',
labelKey: 'settings.windStep',
min: 0.25,
max: 10,
step: 0.25,
},
{
kind: 'number',
path: 'wind.trajectoryStep',
labelKey: 'settings.windTrajectoryStep',
min: 0.25,
max: 5,
step: 0.25,
},
{
kind: 'number',
path: 'wind.prefetchIntervalMinutes',
labelKey: 'settings.windPrefetchInterval',
min: 5,
max: 60,
step: 5,
},
{
kind: 'number',
path: 'wind.maxFlightDurationHours',
labelKey: 'settings.windMaxDuration',
min: 1,
max: 8,
step: 0.5,
},
{
kind: 'number',
path: 'wind.maxRegionDegrees',
labelKey: 'settings.windMaxRegion',
min: 5,
max: 60,
step: 5,
},
{
kind: 'number',
path: 'wind.trajectoryMarginDegrees',
labelKey: 'settings.windMargin',
min: 0.5,
max: 5,
step: 0.5,
},
{
kind: 'number',
path: 'wind.particleDensity',
labelKey: 'settings.windParticleDensity',
min: 0.25,
max: 3,
step: 0.25,
},
{
kind: 'number',
path: 'wind.particleSpeed',
labelKey: 'settings.windParticleSpeed',
min: 0.25,
max: 4,
step: 0.25,
},
{
kind: 'number',
path: 'wind.trailPersistence',
labelKey: 'settings.windTrailPersistence',
min: 0.7,
max: 0.98,
step: 0.02,
},
{
kind: 'number',
path: 'wind.maxVelocity',
labelKey: 'settings.windMaxVelocity',
min: 10,
max: 80,
step: 5,
},
],
},
];

View file

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

View file

@ -0,0 +1,185 @@
<script lang="ts">
import { timelineStore } from './store';
import { t } from '$i18n';
const SPEEDS = [0.5, 1, 2, 5, 10];
function cycleSpeed() {
const i = SPEEDS.indexOf($timelineStore.speed);
timelineStore.setSpeed(SPEEDS[(i + 1) % SPEEDS.length]);
}
function onSeek(e: Event) {
const v = parseFloat((e.currentTarget as HTMLInputElement).value);
timelineStore.seek(v);
}
let duration = $derived(Math.max(0, $timelineStore.max - $timelineStore.min));
let elapsed = $derived(Math.max(0, $timelineStore.time - $timelineStore.min));
function fmtHms(ms: number): string {
if (!isFinite(ms) || ms < 0) return '00:00:00';
const s = Math.floor(ms / 1000);
const hh = String(Math.floor(s / 3600)).padStart(2, '0');
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0');
const ss = String(s % 60).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
let hasData = $derived(duration > 0);
</script>
<div class="timeline-container card shadow-sm" class:disabled={!hasData}>
<div class="card-body p-2">
<div class="d-flex align-items-center gap-2">
<div class="btn-group btn-group-sm" role="group">
<button
type="button"
class="btn btn-outline-primary"
onclick={() => timelineStore.reset()}
disabled={!hasData}
title={$t('timeline.stop')}
aria-label={$t('timeline.stop')}>
<i class="bi bi-skip-start-fill"></i>
</button>
{#if $timelineStore.playing}
<button
type="button"
class="btn btn-warning"
onclick={() => timelineStore.pause()}
title={$t('timeline.pause')}
aria-label={$t('timeline.pause')}>
<i class="bi bi-pause-fill"></i>
</button>
{:else}
<button
type="button"
class="btn btn-success"
onclick={() => timelineStore.play()}
disabled={!hasData}
title={$t('timeline.play')}
aria-label={$t('timeline.play')}>
<i class="bi bi-play-fill"></i>
</button>
{/if}
<button
type="button"
class="btn btn-outline-secondary"
onclick={cycleSpeed}
disabled={!hasData}
title={$t('timeline.speed')}>
{$timelineStore.speed}x
</button>
</div>
<div class="flex-fill d-flex flex-column">
<div class="range-wrapper">
<input
type="range"
class="form-range"
min={$timelineStore.min}
max={$timelineStore.max}
step="1000"
value={$timelineStore.time}
oninput={onSeek}
disabled={!hasData} />
{#if hasData && $timelineStore.markers.length > 0}
<div class="marker-ticks">
{#each $timelineStore.markers as m}
{@const pct = $timelineStore.max > 0 ? m.time / $timelineStore.max : 0}
<span
class="marker-tick"
style="left: calc({pct} * (100% - 1rem) + 0.5rem); --tick-color: {m.color}">
<span class="marker-tooltip">{fmtHms(m.time)}</span>
</span>
{/each}
</div>
{/if}
</div>
<div class="d-flex justify-content-between small font-monospace text-muted">
<span>{fmtHms(elapsed)}</span>
<span>{fmtHms(duration)}</span>
</div>
</div>
</div>
</div>
</div>
<style>
.timeline-container {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
min-width: 500px;
max-width: 720px;
z-index: 1000;
background: var(--bs-body-bg);
backdrop-filter: blur(10px);
}
.timeline-container.disabled {
opacity: 0.7;
}
.range-wrapper {
position: relative;
}
.marker-ticks {
position: absolute;
inset: 0;
pointer-events: none;
}
.marker-tick {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 3px;
height: 14px;
background: var(--tick-color, #dc3545);
border-radius: 1px;
pointer-events: auto;
cursor: default;
}
.marker-tooltip {
display: none;
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: var(--tick-color, #dc3545);
color: #fff;
font-size: 0.7rem;
font-family: monospace;
padding: 2px 7px;
border-radius: 4px;
white-space: nowrap;
pointer-events: none;
z-index: 1010;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
}
.marker-tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 4px solid transparent;
border-top-color: var(--tick-color, #dc3545);
}
.marker-tick:hover .marker-tooltip {
display: block;
}
@media (max-width: 767.98px) {
.timeline-container {
min-width: calc(100vw - 24px);
max-width: calc(100vw - 24px);
bottom: 10px;
}
}
</style>

View file

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

View file

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

View file

@ -0,0 +1,203 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Chart as ChartJS, type ChartDataset } from 'chart.js/auto';
import 'chartjs-adapter-luxon';
import { computeDeviations, type TelemetryPoint, type Prediction } from '$domain';
import { t } from '$i18n';
interface Props {
points: TelemetryPoint[];
prediction?: Prediction | null;
}
let { points, prediction = null }: Props = $props();
let altCanvas: HTMLCanvasElement;
let devCanvas: HTMLCanvasElement;
let altChart: ChartJS | null = null;
let devChart: ChartJS | null = null;
const deviations = $derived(
prediction && points.length > 0 ? computeDeviations(points, prediction) : null,
);
// Full prediction altitude series — drawn independently of telemetry sample rate.
const predAltData = $derived(
prediction
? prediction.timestamps.map((tsMs, idx) => ({
x: tsMs,
y: (prediction.flight_path[idx][2] as number | undefined) ?? 0,
}))
: [],
);
const hasData = $derived(points.length > 0);
const hasDeviation = $derived(!!deviations && deviations.length > 0);
// ── shared axis options ─────────────────────────────────────────────────
const timeAxis = {
type: 'time' as const,
time: {
unit: 'minute' as const,
displayFormats: { minute: 'HH:mm' },
tooltipFormat: 'HH:mm:ss',
},
adapters: { date: { zone: 'UTC' } },
title: { display: true, text: 'UTC', font: { size: 10 } },
ticks: { font: { size: 9 }, maxRotation: 0 },
};
const commonOptions = {
responsive: true,
maintainAspectRatio: false,
animation: false as const,
interaction: { mode: 'index' as const, intersect: false },
plugins: {
legend: { position: 'top' as const, labels: { boxWidth: 10, font: { size: 10 } } },
},
};
// ── chart creation ──────────────────────────────────────────────────────
onMount(() => {
altChart = new ChartJS(altCanvas.getContext('2d')!, {
type: 'line',
data: {
datasets: [
{
label: 'Фактическая, м',
data: [],
borderColor: '#FF1744',
backgroundColor: 'rgba(255,23,68,0.08)',
fill: false,
pointRadius: 0,
borderWidth: 2,
} as ChartDataset<'line'>,
{
label: 'Прогноз, м',
data: [],
borderColor: '#1565C0',
backgroundColor: 'transparent',
fill: false,
pointRadius: 0,
borderWidth: 2,
borderDash: [6, 3],
} as ChartDataset<'line'>,
],
},
options: {
...commonOptions,
scales: {
x: timeAxis,
y: {
title: { display: true, text: 'Высота, м', font: { size: 10 } },
ticks: { font: { size: 9 } },
},
},
},
});
devChart = new ChartJS(devCanvas.getContext('2d')!, {
type: 'line',
data: {
datasets: [
{
label: 'Откл., км',
data: [],
borderColor: '#F57F17',
backgroundColor: 'rgba(245,127,23,0.15)',
fill: true,
pointRadius: 0,
borderWidth: 2,
} as ChartDataset<'line'>,
],
},
options: {
...commonOptions,
scales: {
x: timeAxis,
y: {
min: 0,
title: { display: true, text: 'Откл., км', font: { size: 10 } },
ticks: { font: { size: 9 } },
},
},
plugins: { ...commonOptions.plugins, legend: { display: false } },
},
});
});
// ── altitude chart: update on every telemetry or prediction change ──────
$effect(() => {
if (!altChart) return;
altChart.data.datasets[0].data = points.map((p) => ({
x: new Date(p.datetime).getTime(),
y: p.altitude,
}));
altChart.data.datasets[1].data = predAltData;
altChart.update('none');
});
// ── deviation chart: update when computed deviations change ────────────
$effect(() => {
if (!devChart) return;
devChart.data.datasets[0].data =
deviations?.map((d) => ({ x: d.timeMs, y: d.horizontal })) ?? [];
devChart.update('none');
});
onDestroy(() => {
altChart?.destroy();
devChart?.destroy();
});
</script>
<!--
Both canvases are ALWAYS in the DOM so Chart.js instances created in
onMount always have a valid canvas reference. Sections are shown/hidden
via d-none; Chart.js v3+ ResizeObserver picks up dimension changes when
display:none is removed and re-renders at the correct size.
-->
<!-- ── No-data placeholder ─────────────────────────────────────────────── -->
{#if !hasData}
<p class="text-muted small text-center py-3 mb-0">{$t('tracking.noData')}</p>
{/if}
<!-- ── Altitude profile (always rendered, hidden while no data) ─────────── -->
<div class:d-none={!hasData}>
<p class="small fw-semibold mb-1">{$t('tracking.altProfile')}</p>
<div style="position: relative; height: 170px;">
<canvas bind:this={altCanvas}></canvas>
</div>
{#if !hasDeviation}
<p class="small text-muted mt-2 mb-0">{$t('tracking.selectPrediction')}</p>
{/if}
</div>
<!-- ── Horizontal deviation (always rendered, hidden while no prediction) ── -->
<div class:d-none={!hasDeviation}>
<hr class="my-2" />
<p class="small fw-semibold mb-1">{$t('tracking.horizontalDev')}</p>
<div style="position: relative; height: 130px;">
<canvas bind:this={devCanvas}></canvas>
</div>
{#if deviations && deviations.length > 0}
{@const maxDev = Math.max(...deviations.map((d) => d.horizontal))}
{@const last = deviations[deviations.length - 1]}
<div class="d-flex gap-3 mt-2 flex-wrap">
<small class="text-muted">
{$t('tracking.devMax')} <span class="fw-semibold text-body">{maxDev.toFixed(2)} км</span>
</small>
<small class="text-muted">
{$t('tracking.devCurrent')} <span class="fw-semibold text-body">{last.horizontal.toFixed(2)} км</span>
</small>
<small class="text-muted">
Δh: <span class="fw-semibold text-body">
{last.vertical > 0 ? '+' : ''}{last.vertical.toFixed(0)} м
</span>
</small>
</div>
{/if}
</div>

View file

@ -0,0 +1,100 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { FormGroup, Label, Input, InputGroup, Button, Badge } from '@sveltestrap/sveltestrap';
import { CollapsibleCard } from '$ui';
import { t } from '$i18n';
import { telemetryStore } from './telemetryStore.svelte';
let satelliteInput = $state('');
const STATUS_COLOR: Record<string, string> = {
idle: 'secondary',
connecting: 'warning',
connected: 'success',
error: 'danger',
};
function handleConnect() {
const id = satelliteInput.trim();
if (id) telemetryStore.connect(id);
}
function handleDisconnect() {
telemetryStore.disconnect();
satelliteInput = '';
}
onDestroy(() => {
telemetryStore.disconnect();
});
</script>
<CollapsibleCard title={$t('nav.track')}>
<FormGroup spacing="mb-2">
<Label class="small">{$t('tracking.satelliteId')}</Label>
<div class="d-flex gap-1">
<Input
type="text"
size="sm"
bind:value={satelliteInput}
placeholder={$t('tracking.satelliteIdPlaceholder')}
disabled={telemetryStore.status !== 'idle'}
/>
{#if telemetryStore.status === 'idle'}
<Button
size="sm"
color="primary"
onclick={handleConnect}
disabled={!satelliteInput.trim()}
>
{$t('tracking.connect')}
</Button>
{:else}
<Button size="sm" color="secondary" onclick={handleDisconnect}>
{$t('tracking.disconnect')}
</Button>
{/if}
</div>
</FormGroup>
<FormGroup spacing="mb-2">
<div class="d-flex align-items-center gap-2">
<Label class="small mb-0">{$t('tracking.status')}</Label>
<Badge color={STATUS_COLOR[telemetryStore.status]}>
{$t(`tracking.status_${telemetryStore.status}`)}
</Badge>
</div>
{#if telemetryStore.error}
<small class="text-danger">{telemetryStore.error}</small>
{/if}
</FormGroup>
{#if telemetryStore.latest}
<FormGroup spacing="mb-2">
<Label class="small">{$t('points.lat')}</Label>
<InputGroup size="sm">
<Input type="text" value={telemetryStore.latest.latitude.toFixed(6)} readonly />
</InputGroup>
</FormGroup>
<FormGroup spacing="mb-2">
<Label class="small">{$t('points.lon')}</Label>
<InputGroup size="sm">
<Input type="text" value={telemetryStore.latest.longitude.toFixed(6)} readonly />
</InputGroup>
</FormGroup>
<FormGroup spacing="mb-2">
<Label class="small">{$t('points.alt')}</Label>
<InputGroup size="sm">
<Input type="text" value={telemetryStore.latest.altitude.toFixed(1)} readonly />
</InputGroup>
</FormGroup>
<small class="text-muted">
{$t('tracking.packetCount', { count: telemetryStore.points.length })}
</small>
{:else if telemetryStore.status !== 'idle'}
<small class="text-muted">{$t('tracking.waitingData')}</small>
{/if}
</CollapsibleCard>

View file

@ -0,0 +1,3 @@
export { default as TelemetryPanel } from './TelemetryPanel.svelte';
export { default as DeviationChart } from './DeviationChart.svelte';
export { telemetryStore, type TrackingStatus } from './telemetryStore.svelte';

View file

@ -0,0 +1,99 @@
import { telemetryApi, buildWsUrl, type RawTelemetryPacket } from '$api/telemetry';
import { parseTelemetry, type TelemetryPoint, type Telemetry } from '$domain';
export type TrackingStatus = 'idle' | 'connecting' | 'connected' | 'error';
function toPoint(p: RawTelemetryPacket): TelemetryPoint {
return {
latitude: p.lat,
longitude: p.lon,
altitude: p.alt,
datetime: new Date(p.timestamp * 1000).toISOString(),
payload: JSON.stringify(p.payload),
};
}
class TelemetryStore {
satelliteId = $state('');
status = $state<TrackingStatus>('idle');
error = $state<string | null>(null);
points = $state<TelemetryPoint[]>([]);
#ws: WebSocket | null = null;
get latest(): TelemetryPoint | null {
return this.points[this.points.length - 1] ?? null;
}
get telemetry(): Telemetry | null {
if (this.points.length === 0) return null;
try {
return parseTelemetry(this.points);
} catch {
return null;
}
}
async connect(id: string): Promise<void> {
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!UUID_RE.test(id)) {
this.status = 'error';
this.error = `Invalid satellite ID — expected a UUID (e.g. 550e8400-e29b-41d4-a716-446655440000)`;
return;
}
this.disconnect();
this.satelliteId = id;
this.status = 'connecting';
this.error = null;
this.points = [];
// Load historical packets first — non-fatal if it fails
try {
const history = await telemetryApi.fetchHistory(id);
// API returns newest-first; reverse to chronological order
this.points = [...history].reverse().map(toPoint);
} catch (e) {
console.warn('[telemetry] history fetch failed:', e);
}
const ws = new WebSocket(buildWsUrl(id));
this.#ws = ws;
ws.onopen = () => {
this.status = 'connected';
};
ws.onmessage = ({ data }) => {
try {
const packet = JSON.parse(data) as { error?: string } & RawTelemetryPacket;
if (!packet.error) {
this.points = [...this.points, toPoint(packet)];
}
} catch {
// ignore malformed frames
}
};
ws.onerror = () => {
this.status = 'error';
this.error = 'WebSocket connection failed';
};
ws.onclose = () => {
if (this.status !== 'idle') this.status = 'idle';
this.#ws = null;
};
}
disconnect(): void {
this.#ws?.close();
this.#ws = null;
this.status = 'idle';
this.satelliteId = '';
this.points = [];
this.error = null;
}
}
export const telemetryStore = new TelemetryStore();

View file

@ -0,0 +1,335 @@
/**
* ParticleField an animated wind-flow layer rendered to a 2D canvas
* overlaid on the MapLibre container, in the spirit of leaflet-velocity /
* cambecc's "earth".
*
* Particles live in CSS-pixel space. Each frame, every particle is unprojected
* to lng/lat, the wind [u, v] there is sampled, and that vector is pushed
* through the map projection's local Jacobian to obtain a pixel-space velocity
* (so motion is correct at any zoom/latitude). Trails are faded by compositing
* a translucent clear over the previous frame, leaving the basemap visible.
*
* The wind field can change every frame (the renderer interpolates between
* pre-fetched trajectory frames over time); only the lightweight interpolator
* closure is swapped, so particle motion stays continuous. See
* docs/wind-vis-math.tex §"Particle Advection".
*/
import type { Map as MLMap } from 'maplibre-gl';
import type { WindInterpolator } from '$domain';
export interface ParticleOptions {
/** Particles per screen pixel (scaled by the base multiplier). */
density: number;
/** Advection speed multiplier. */
speed: number;
/** Trail persistence in [0,1): fraction of the trail kept each frame. */
trailPersistence: number;
/** Max frames a particle lives before it is respawned. */
maxAge: number;
/** Trail line width (CSS px). */
lineWidth: number;
/** Wind speed (m/s) at the bottom / top of the colour scale. */
minVelocity: number;
maxVelocity: number;
/** Target frame rate (the field is re-evaluated at most this often). */
frameRate: number;
/** Colour ramp from slow → fast wind. */
colorScale: string[];
}
export const DEFAULT_COLOR_SCALE = [
'rgb(36,104,180)',
'rgb(60,157,194)',
'rgb(128,205,193)',
'rgb(151,218,168)',
'rgb(198,231,181)',
'rgb(238,247,217)',
'rgb(255,238,159)',
'rgb(252,217,125)',
'rgb(255,182,100)',
'rgb(252,150,75)',
'rgb(250,112,52)',
'rgb(245,64,32)',
'rgb(237,45,28)',
'rgb(220,24,32)',
'rgb(180,0,35)',
];
export const DEFAULT_PARTICLE_OPTIONS: ParticleOptions = {
density: 1.0,
speed: 1.0,
trailPersistence: 0.92,
maxAge: 100,
lineWidth: 1.4,
minVelocity: 0,
maxVelocity: 30,
frameRate: 30,
colorScale: DEFAULT_COLOR_SCALE,
};
/** Base particle count = pixels × this (kept modest for performance). */
const PARTICLE_MULTIPLIER = 1 / 350;
const MAX_PARTICLES = 6000;
interface Particle {
x: number;
y: number;
xt: number;
yt: number;
age: number;
speed: number;
}
export class ParticleField {
private map: MLMap;
private host: HTMLElement;
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private opts: ParticleOptions;
private interp: WindInterpolator | null = null;
private particles: Particle[] = [];
private raf = 0;
private then = 0;
private moving = false;
private width = 0;
private height = 0;
private debugLogged = false;
constructor(map: MLMap, opts: Partial<ParticleOptions> = {}) {
this.map = map;
this.opts = { ...DEFAULT_PARTICLE_OPTIONS, ...opts };
// Mount inside the MapLibre canvas container so the overlay sits above
// the basemap but below the control container and the app's panels.
this.host = map.getCanvasContainer();
const canvas = document.createElement('canvas');
canvas.className = 'wind-particles';
canvas.style.position = 'absolute';
canvas.style.top = '0';
canvas.style.left = '0';
canvas.style.pointerEvents = 'none';
canvas.style.zIndex = '3';
this.host.appendChild(canvas);
this.canvas = canvas;
this.ctx = canvas.getContext('2d')!;
this.map.on('movestart', this.onMoveStart);
this.map.on('moveend', this.onMoveEnd);
this.map.on('resize', this.onResize);
this.resize();
}
setOptions(opts: Partial<ParticleOptions>): void {
const densityChanged = opts.density !== undefined && opts.density !== this.opts.density;
this.opts = { ...this.opts, ...opts };
if (densityChanged) this.seedParticles();
}
/** Swap the wind field. Pass null to clear the flow. */
setField(interp: WindInterpolator | null): void {
this.interp = interp;
if (interp && this.particles.length === 0) this.seedParticles();
}
start(): void {
if (this.raf) return;
this.then = performance.now();
this.raf = requestAnimationFrame(this.frame);
}
stop(): void {
if (this.raf) cancelAnimationFrame(this.raf);
this.raf = 0;
this.clear();
}
destroy(): void {
this.stop();
this.map.off('movestart', this.onMoveStart);
this.map.off('moveend', this.onMoveEnd);
this.map.off('resize', this.onResize);
this.canvas.remove();
}
// ── Internals ─────────────────────────────────────────────────────────────
private onMoveStart = (): void => {
this.moving = true;
this.clear();
};
private onMoveEnd = (): void => {
this.moving = false;
this.seedParticles();
};
private onResize = (): void => {
this.resize();
};
private resize(): void {
const dpr = window.devicePixelRatio || 1;
// Size from the gl canvas: it always reports the true viewport size,
// whereas the canvas-container wrapper can measure 0 in some layouts.
const glCanvas = this.map.getCanvas();
const w = glCanvas.clientWidth || this.map.getContainer().clientWidth;
const h = glCanvas.clientHeight || this.map.getContainer().clientHeight;
if (!w || !h) return;
this.width = w;
this.height = h;
this.canvas.style.width = `${w}px`;
this.canvas.style.height = `${h}px`;
this.canvas.width = Math.round(w * dpr);
this.canvas.height = Math.round(h * dpr);
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // draw in CSS-pixel space
this.seedParticles();
}
private particleCount(): number {
const n = this.width * this.height * PARTICLE_MULTIPLIER * this.opts.density;
return Math.max(0, Math.min(MAX_PARTICLES, Math.round(n)));
}
private seedParticles(): void {
const count = this.particleCount();
this.particles = new Array(count);
for (let i = 0; i < count; i++) {
this.particles[i] = { x: 0, y: 0, xt: 0, yt: 0, age: 0, speed: 0 };
this.respawn(this.particles[i]);
this.particles[i].age = Math.floor(Math.random() * this.opts.maxAge);
}
}
/** Place a particle at a random pixel that has wind (a few retries). */
private respawn(p: Particle): void {
for (let attempt = 0; attempt < 8; attempt++) {
const x = Math.random() * this.width;
const y = Math.random() * this.height;
if (!this.interp) {
p.x = p.xt = x;
p.y = p.yt = y;
break;
}
const ll = this.map.unproject([x, y]);
if (this.interp(ll.lng, ll.lat)) {
p.x = p.xt = x;
p.y = p.yt = y;
break;
}
p.x = p.xt = x;
p.y = p.yt = y;
}
p.age = 0;
p.speed = 0;
}
private clear(): void {
this.ctx.clearRect(0, 0, this.width, this.height);
}
private colorIndex(speed: number): number {
const { minVelocity, maxVelocity, colorScale } = this.opts;
const f = (speed - minVelocity) / (maxVelocity - minVelocity);
return Math.max(0, Math.min(colorScale.length - 1, Math.round(f * (colorScale.length - 1))));
}
private evolve(): void {
const interp = this.interp;
if (!interp) return;
const scale = 0.06 * this.opts.speed; // pixel velocity = Jacobian·wind·scale
const eps = 0.02; // degrees, for the projection Jacobian
for (const p of this.particles) {
if (p.age >= this.opts.maxAge) {
this.respawn(p);
continue;
}
const ll = this.map.unproject([p.x, p.y]);
const wind = interp(ll.lng, ll.lat);
if (!wind) {
p.age = this.opts.maxAge; // escaped the field → respawn next tick
continue;
}
const [u, v] = wind;
// Local projection Jacobian: pixel deltas per degree at this point.
const east = this.map.project([ll.lng + eps, ll.lat]);
const north = this.map.project([ll.lng, ll.lat + eps]);
const jxLng = (east.x - p.x) / eps;
const jyLng = (east.y - p.y) / eps;
const jxLat = (north.x - p.x) / eps;
const jyLat = (north.y - p.y) / eps;
p.xt = p.x + (jxLng * u + jxLat * v) * scale;
p.yt = p.y + (jyLng * u + jyLat * v) * scale;
p.speed = Math.sqrt(u * u + v * v);
p.age += 1;
}
}
private draw(): void {
const ctx = this.ctx;
// Fade existing trails toward transparent (keeps the basemap visible).
ctx.globalCompositeOperation = 'destination-in';
ctx.fillStyle = `rgba(0,0,0,${this.opts.trailPersistence})`;
ctx.fillRect(0, 0, this.width, this.height);
ctx.globalCompositeOperation = 'source-over';
// Draw new trail segments, grouped by colour bucket.
const { colorScale } = this.opts;
ctx.lineWidth = this.opts.lineWidth;
const buckets: Particle[][] = colorScale.map(() => []);
for (const p of this.particles) {
if (p.age >= this.opts.maxAge || p.speed === 0) continue;
buckets[this.colorIndex(p.speed)].push(p);
}
let drawn = 0;
for (let i = 0; i < buckets.length; i++) {
const bucket = buckets[i];
if (bucket.length === 0) continue;
drawn += bucket.length;
ctx.strokeStyle = colorScale[i];
ctx.beginPath();
for (const p of bucket) {
ctx.moveTo(p.x, p.y);
ctx.lineTo(p.xt, p.yt);
}
ctx.stroke();
}
if (import.meta.env.DEV && !this.debugLogged) {
this.debugLogged = true;
// One-shot diagnostic: confirms field, canvas size, and that segments
// are actually being drawn. Remove once the layer is verified.
// eslint-disable-next-line no-console
console.debug('[wind] first draw', {
hasInterp: !!this.interp,
canvas: `${this.width}x${this.height}`,
backing: `${this.canvas.width}x${this.canvas.height}`,
particles: this.particles.length,
drawnSegments: drawn,
host: this.host.className,
});
}
// Advance positions for the next frame.
for (const p of this.particles) {
p.x = p.xt;
p.y = p.yt;
}
}
private frame = (now: number): void => {
this.raf = requestAnimationFrame(this.frame);
if (this.moving || !this.interp) return;
const frameTime = 1000 / this.opts.frameRate;
if (now - this.then < frameTime) return;
this.then = now - ((now - this.then) % frameTime);
this.evolve();
this.draw();
};
}

View file

@ -0,0 +1,332 @@
<script lang="ts">
/**
* WindRenderer — renderless component that drives an animated particle-flow
* wind layer (ParticleField) over the shared MapLibre map.
*
* Two display modes:
*
* Static shown whenever wind is enabled but no trajectory is available.
* Fetches the global wind field at the active workspace's launch
* altitude and datetime.
*
* Trajectory sync activated once the active workspace has a prediction
* result AND the timeline has a non-zero range. Pre-fetches one
* wind field per `prefetchIntervalMinutes` along the flight path
* (altitude matches the trajectory at each time step), then
* linearly interpolates [u, v] between the two bracketing frames
* as the timeline scrubs, so the flow evolves smoothly.
*
* Sanity guards (all configurable in settings → Wind):
* • Flight duration > maxFlightDurationHours → trajectory sync disabled.
* • Bounding box > maxRegionDegrees in either axis → skipped.
* • Minimum step clamped to 0.25° (API limit).
*
* The actual particle rendering lives in ParticleField (a 2D canvas overlay);
* getRawInstance() is used here deliberately because that overlay needs the
* raw MapLibre projection/container, which the IMap/Scene abstraction does
* not expose. See docs/wind-vis-math.tex for the advection math.
*/
import { onDestroy } from 'svelte';
import type { Map as MLMap } from 'maplibre-gl';
import { getMap } from '$map';
import { settingsStore } from '$features/settings';
import { workspacesStore, getActiveWorkspace } from '$features/workspaces';
import { timelineStore } from '$features/timeline/store';
import {
createWindInterpolator,
DEFAULT_WIND_SETTINGS,
type WindField,
type WindComponent,
type WindSettings,
} from '$domain';
import type { Prediction, LatLngTuple } from '$domain';
import { windCache } from './store';
import { ParticleField, type ParticleOptions } from './ParticleField';
// ── Map handle ───────────────────────────────────────────────────────────
const map = getMap();
if (!map) throw new Error('WindRenderer must be a descendant of <Map />');
const mlMap = map.getRawInstance() as MLMap;
// ── State ─────────────────────────────────────────────────────────────────
interface WindFrame {
flightTimeMs: number;
field: WindField;
}
let particleField: ParticleField | null = null;
let currentField = $state<WindField | null>(null);
let trajectoryFrames = $state<WindFrame[]>([]);
let prefetchKey: string | null = null; // non-reactive — tracks last pre-fetch identity
let staticFetchSeq = 0; // monotonically incremented to cancel stale static fetches
let prefetchSkipReason = $state<string | null>(null);
// ── Derived reactive values ───────────────────────────────────────────────
const windSettings = $derived<WindSettings>({
...DEFAULT_WIND_SETTINGS,
...($settingsStore.wind ?? {}),
});
const activeWorkspace = $derived(getActiveWorkspace($workspacesStore));
const activePrediction = $derived(activeWorkspace?.result ?? null);
const inTrajectoryMode = $derived(
windSettings.enabled && activePrediction !== null && $timelineStore.max > 0,
);
// ── Particle field ────────────────────────────────────────────────────────
function particleOptions(s: WindSettings): Partial<ParticleOptions> {
return {
density: s.particleDensity,
speed: s.particleSpeed,
trailPersistence: s.trailPersistence,
maxVelocity: s.maxVelocity,
};
}
function ensureField(): ParticleField {
if (!particleField) {
particleField = new ParticleField(mlMap, particleOptions(windSettings));
}
return particleField;
}
// ── Trajectory helpers ────────────────────────────────────────────────────
function trajectoryBBox(path: LatLngTuple[], marginDeg: number) {
let minLat = Infinity,
maxLat = -Infinity,
minLng = Infinity,
maxLng = -Infinity;
for (const p of path) {
if (p[0] < minLat) minLat = p[0];
if (p[0] > maxLat) maxLat = p[0];
if (p[1] < minLng) minLng = p[1];
if (p[1] > maxLng) maxLng = p[1];
}
return {
min_lat: minLat - marginDeg,
max_lat: maxLat + marginDeg,
min_lng: minLng - marginDeg,
max_lng: maxLng + marginDeg,
};
}
/** Binary-search the trajectory for the altitude at a given flight-time offset. */
function altAtFlightTime(prediction: Prediction, flightTimeMs: number): number {
const { flight_path, timestamps } = prediction;
if (!flight_path.length) return 0;
const targetMs = timestamps[0] + flightTimeMs;
let lo = 0,
hi = timestamps.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (timestamps[mid] < targetMs) lo = mid + 1;
else hi = mid;
}
const p = flight_path[Math.min(lo, flight_path.length - 1)];
return p[2] ?? 0;
}
/** Linearly blend one wind component (u or v) of two aligned grids. */
function lerpComponent(a: WindComponent, b: WindComponent, f: number): WindComponent {
if (a.data.length !== b.data.length) return f < 0.5 ? a : b;
const data = new Array<number>(a.data.length);
for (let k = 0; k < data.length; k++) data[k] = a.data[k] + (b.data[k] - a.data[k]) * f;
return { header: a.header, data };
}
/**
* Wind field at flight-time `t`, linearly interpolated between the two
* bracketing pre-fetched frames so the field evolves smoothly as the
* timeline scrubs. Frames share the same bbox/step, so their grids align
* cell-for-cell and the [u,v] arrays can be blended directly.
*/
function fieldAtFlightTime(t: number): WindField | null {
// trajectoryFrames is $state — reading it here creates a reactive dependency
const frames = trajectoryFrames;
if (!frames.length) return null;
if (frames.length === 1 || t <= frames[0].flightTimeMs) return frames[0].field;
const last = frames[frames.length - 1];
if (t >= last.flightTimeMs) return last.field;
let hi = 1;
while (hi < frames.length && frames[hi].flightTimeMs < t) hi++;
const f0 = frames[hi - 1];
const f1 = frames[hi];
const span = f1.flightTimeMs - f0.flightTimeMs;
const a = span > 0 ? (t - f0.flightTimeMs) / span : 0;
if (a <= 0) return f0.field;
if (a >= 1) return f1.field;
return [lerpComponent(f0.field[0], f1.field[0], a), lerpComponent(f0.field[1], f1.field[1], a)];
}
function makePrefetchKey(prediction: Prediction, s: WindSettings): string {
return [
prediction.timestamps[0],
prediction.flight_time,
s.trajectoryStep,
s.prefetchIntervalMinutes,
s.maxFlightDurationHours,
s.maxRegionDegrees,
s.trajectoryMarginDegrees,
].join('|');
}
async function prefetchTrajectory(prediction: Prediction, settings: WindSettings): Promise<void> {
const key = makePrefetchKey(prediction, settings);
if (key === prefetchKey) return; // nothing changed
const flightMs = prediction.flight_time * 1000;
if (flightMs > settings.maxFlightDurationHours * 3_600_000) {
prefetchKey = key;
trajectoryFrames = [];
prefetchSkipReason = `wind.skippedLong`;
return;
}
const bbox = trajectoryBBox(prediction.flight_path, settings.trajectoryMarginDegrees);
const latSpan = bbox.max_lat - bbox.min_lat;
const lngSpan = bbox.max_lng - bbox.min_lng;
if (latSpan > settings.maxRegionDegrees || lngSpan > settings.maxRegionDegrees) {
prefetchKey = key;
trajectoryFrames = [];
prefetchSkipReason = `wind.skippedLarge`;
return;
}
prefetchKey = key; // claim before async to prevent concurrent duplicate starts
prefetchSkipReason = null;
const frames: WindFrame[] = [];
const intervalMs = settings.prefetchIntervalMinutes * 60_000;
const launchMs = prediction.timestamps[0];
const step = Math.max(settings.trajectoryStep, 0.25);
// Frame offsets: every interval, plus the landing point exactly once.
const offsets: number[] = [];
for (let t = 0; t < flightMs; t += intervalMs) offsets.push(t);
offsets.push(flightMs);
// Sequential fetches so the cache warms predictably; concurrent bursts
// could overwhelm the predictor.
for (const offset of offsets) {
const altitude = altAtFlightTime(prediction, offset);
const time = new Date(launchMs + offset).toISOString();
try {
const field = await windCache.fetch({ time, altitude, step, ...bbox });
frames.push({ flightTimeMs: offset, field });
} catch {
// Skip this frame and continue with others
}
}
trajectoryFrames = frames; // triggers the trajectory render effect
}
// ── Effects ───────────────────────────────────────────────────────────────
// Pre-fetch trajectory wind frames when prediction or relevant settings change.
$effect(() => {
const prediction = activePrediction;
const settings = windSettings;
if (!settings.enabled || !prediction || $timelineStore.max === 0) {
trajectoryFrames = [];
prefetchKey = null;
return;
}
// Fire-and-forget; prefetchKey prevents duplicate starts.
prefetchTrajectory(prediction, settings);
});
// Trajectory mode: keep currentField in sync with the scrubbing timeline.
$effect(() => {
if (!inTrajectoryMode) return;
// Reading trajectoryFrames ($state) makes this effect re-run when frames arrive.
currentField = fieldAtFlightTime($timelineStore.time);
});
// Static mode: fetch wind field for the active workspace's launch parameters.
$effect(() => {
if (!windSettings.enabled || inTrajectoryMode) {
staticFetchSeq++; // cancel any in-flight static request
return;
}
const ws = activeWorkspace;
if (!ws) {
currentField = null;
return;
}
const seq = ++staticFetchSeq;
const step = Math.max(windSettings.step, 0.25);
const { launch_altitude } = ws.flightParameters;
const time = new Date(`${ws.launchDate}T${ws.launchTime}Z`).toISOString();
windCache
.fetch({ altitude: launch_altitude, time, step })
.then((field) => {
if (seq !== staticFetchSeq) return; // superseded
currentField = field;
})
.catch(() => {
if (seq !== staticFetchSeq) return;
currentField = null;
});
});
// Drive the particle field from currentField + settings.
$effect(() => {
const s = windSettings;
const field = currentField;
if (!s.enabled || !field) {
particleField?.setField(null);
particleField?.stop();
return;
}
const pf = ensureField();
pf.setOptions(particleOptions(s));
pf.setField(createWindInterpolator(field));
pf.start();
});
onDestroy(() => {
staticFetchSeq++; // cancel any pending static callback
particleField?.destroy();
particleField = null;
});
</script>
{#if windSettings.enabled && prefetchSkipReason}
<div class="wind-skip-notice">
<i class="bi bi-wind"></i>
{#if prefetchSkipReason === 'wind.skippedLong'}
Wind sync skipped: flight &gt; {windSettings.maxFlightDurationHours}h
{:else}
Wind sync skipped: region &gt; {windSettings.maxRegionDegrees}°
{/if}
</div>
{/if}
<style>
.wind-skip-notice {
position: absolute;
bottom: 90px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.65);
color: #fff;
font-size: 0.75rem;
padding: 4px 10px;
border-radius: 4px;
pointer-events: none;
z-index: 900;
white-space: nowrap;
}
</style>

View file

@ -0,0 +1,3 @@
export { default as WindRenderer } from './WindRenderer.svelte';
export { windCache } from './store';
export { ParticleField, DEFAULT_PARTICLE_OPTIONS, type ParticleOptions } from './ParticleField';

View file

@ -0,0 +1,61 @@
/**
* Thin cache layer for wind field responses.
*
* Each unique set of request parameters is keyed by a stable JSON string so
* that the same (time, altitude, bbox, step) combination is fetched only once
* per session even if multiple effects request it concurrently. The cache is
* intentionally never invalidated during a session the predictor's dataset
* does not change while the user is working.
*/
import { windApi, type WindFieldParams } from '$api';
import type { WindField } from '$domain';
function cacheKey(params: WindFieldParams): string {
return JSON.stringify({
altitude: params.altitude ?? null,
step: params.step ?? null,
time: params.time ?? null,
min_lat: params.min_lat ?? null,
max_lat: params.max_lat ?? null,
min_lng: params.min_lng ?? null,
max_lng: params.max_lng ?? null,
});
}
class WindCache {
private readonly hits = new Map<string, WindField>();
private readonly pending = new Map<string, Promise<WindField>>();
fetch(params: WindFieldParams): Promise<WindField> {
const key = cacheKey(params);
const hit = this.hits.get(key);
if (hit) return Promise.resolve(hit);
const existing = this.pending.get(key);
if (existing) return existing;
const promise = windApi
.field(params)
.then((field) => {
this.hits.set(key, field);
this.pending.delete(key);
return field;
})
.catch((err: unknown) => {
this.pending.delete(key);
throw err;
});
this.pending.set(key, promise);
return promise;
}
clear(): void {
this.hits.clear();
this.pending.clear();
}
}
export const windCache = new WindCache();

View file

@ -0,0 +1,168 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { getMap, plotPrediction, plotAnimatedMarker, plotEndMarker } from '$map';
import { timelineStore } from '$features/timeline/store';
import { workspacesStore } from './store';
import type { Workspace } from './types';
import type { LatLngTuple } from '$domain';
/**
* Renders every workspace onto the shared map. Each workspace gets its own
* named scene (`ws/<id>`) so its layers can be cleared independently, plus
* a `cursor/<id>` scene for the animated playback marker.
*
* The component is placed as a child of <Map /> so `getMap()` returns a
* non-null instance via context.
*/
const map = getMap();
if (!map) throw new Error('WorkspaceRenderer must be a descendant of <Map />');
// Track the scenes we currently own so we can dispose the ones that
// belong to workspaces which were removed from the store since last tick.
const ownedPlotScenes = new Set<string>();
const ownedCursorScenes = new Set<string>();
// Last-rendered state per workspace scene — skip re-plot when nothing changed.
const plotCache = new Map<string, { result: unknown; color: string; opacity: number }>();
// Cursor scenes that have reached their flight end and show a static end marker.
const doneCursorScenes = new Set<string>();
const sceneName = (w: Workspace) => `ws/${w.id}`;
const cursorName = (w: Workspace) => `cursor/${w.id}`;
function updateGlobalRange(items: Workspace[]) {
let maxDuration = 0;
const entries: Array<{ duration: number; color: string }> = [];
for (const w of items) {
if (!w.visible || !w.result) continue;
const duration =
w.result.landing.datetime.getTime() - w.result.launch.datetime.getTime();
if (duration > maxDuration) maxDuration = duration;
entries.push({ duration, color: w.color });
}
timelineStore.setRange(0, maxDuration);
const seen = new Set<number>();
const markers = entries
.filter(({ duration }) => {
if (duration >= maxDuration || seen.has(duration)) return false;
seen.add(duration);
return true;
})
.map(({ duration, color }) => ({ time: duration, color }));
timelineStore.setMarkers(markers);
}
function renderAll(items: Workspace[]) {
if (!map) return;
const live = new Set<string>();
for (const w of items) {
const name = sceneName(w);
live.add(name);
if (!w.visible || !w.result) {
if (ownedPlotScenes.has(name)) {
map.disposeScene(name);
ownedPlotScenes.delete(name);
plotCache.delete(name);
}
continue;
}
const cached = plotCache.get(name);
if (!cached || cached.result !== w.result || cached.color !== w.color || cached.opacity !== w.opacity) {
const scene = map.scene(name);
plotPrediction(scene, w.result, { color: w.color, opacity: w.opacity });
ownedPlotScenes.add(name);
plotCache.set(name, { result: w.result, color: w.color, opacity: w.opacity });
}
}
for (const name of Array.from(ownedPlotScenes)) {
if (!live.has(name)) {
map.disposeScene(name);
ownedPlotScenes.delete(name);
plotCache.delete(name);
}
}
}
function positionAt(path: LatLngTuple[], elapsed: number, durationMs: number): LatLngTuple | null {
if (path.length === 0) return null;
if (durationMs === 0) return path[0];
const t = Math.max(0, Math.min(1, elapsed / durationMs));
const raw = t * (path.length - 1);
const idx = Math.floor(raw);
if (idx >= path.length - 1) return path[path.length - 1];
const frac = raw - idx;
const a = path[idx];
const b = path[idx + 1];
return [a[0] + (b[0] - a[0]) * frac, a[1] + (b[1] - a[1]) * frac] as LatLngTuple;
}
function renderCursors(items: Workspace[], time: number) {
if (!map) return;
const live = new Set<string>();
for (const w of items) {
const name = cursorName(w);
live.add(name);
if (!w.visible || !w.result) {
if (ownedCursorScenes.has(name)) {
map.disposeScene(name);
ownedCursorScenes.delete(name);
doneCursorScenes.delete(name);
}
continue;
}
const durationMs =
w.result.landing.datetime.getTime() - w.result.launch.datetime.getTime();
const p = positionAt(w.result.flight_path, time, durationMs);
if (!p) continue;
const scene = map.scene(name);
const done = time >= durationMs;
if (done) {
if (!doneCursorScenes.has(name)) {
// Transition into done state: swap to static end marker.
scene.clear();
plotEndMarker(scene, p[1], p[0]);
doneCursorScenes.add(name);
}
// Position is clamped to landing — nothing more to update.
} else {
if (doneCursorScenes.has(name)) {
// Transition back to active (user seeked backwards).
scene.clear();
doneCursorScenes.delete(name);
}
plotAnimatedMarker(scene, p[1], p[0]);
}
ownedCursorScenes.add(name);
}
for (const name of Array.from(ownedCursorScenes)) {
if (!live.has(name)) {
map.disposeScene(name);
ownedCursorScenes.delete(name);
doneCursorScenes.delete(name);
}
}
}
$effect(() => {
const items = $workspacesStore.items;
renderAll(items);
updateGlobalRange(items);
});
$effect(() => {
renderCursors($workspacesStore.items, $timelineStore.time);
});
onDestroy(() => {
if (!map) return;
for (const name of ownedPlotScenes) map.disposeScene(name);
for (const name of ownedCursorScenes) map.disposeScene(name);
ownedPlotScenes.clear();
ownedCursorScenes.clear();
doneCursorScenes.clear();
});
</script>

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,206 @@
{
"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",
"wind": "Wind visualization",
"windEnabled": "Show wind layer",
"windStep": "Grid resolution (°)",
"windTrajectoryStep": "Trajectory grid res. (°)",
"windPrefetchInterval": "Pre-fetch interval (min)",
"windMaxDuration": "Max sync duration (h)",
"windMaxRegion": "Max region size (°)",
"windMargin": "Trajectory margin (°)",
"windParticleDensity": "Particle density",
"windParticleSpeed": "Particle speed",
"windTrailPersistence": "Trail length",
"windMaxVelocity": "Max wind speed (m/s)"
},
"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"
},
"tracking": {
"satelliteId": "Satellite ID",
"satelliteIdPlaceholder": "Enter satellite UUID...",
"connect": "Connect",
"disconnect": "Disconnect",
"status": "Status",
"status_idle": "Idle",
"status_connecting": "Connecting",
"status_connected": "Connected",
"status_error": "Error",
"packetCount": "{count} packets received",
"waitingData": "Waiting for data...",
"deviation": "Compare with forecast",
"selectForecast": "Reference forecast",
"noForecast": "— No forecast —",
"noData": "No telemetry data",
"altProfile": "Altitude profile",
"selectPrediction": "Select a forecast to show deviations",
"horizontalDev": "Horizontal deviation",
"devMax": "Max:",
"devCurrent": "Current:"
},
"forecast": {
"success": "Forecast request",
"successBody": "Forecast request successful!",
"error": "Forecast error",
"errorBody": "Error running forecast: {error}"
}
}

View file

@ -0,0 +1,206 @@
{
"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": "Настройки сохранены",
"wind": "Визуализация ветра",
"windEnabled": "Показывать слой ветра",
"windStep": "Шаг сетки (°)",
"windTrajectoryStep": "Шаг сетки по траектории (°)",
"windPrefetchInterval": "Интервал предзагрузки (мин)",
"windMaxDuration": "Макс. длительность синхронизации (ч)",
"windMaxRegion": "Макс. размер региона (°)",
"windMargin": "Отступ вокруг траектории (°)",
"windParticleDensity": "Плотность частиц",
"windParticleSpeed": "Скорость частиц",
"windTrailPersistence": "Длина следа",
"windMaxVelocity": "Макс. скорость ветра (м/с)"
},
"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": "Кликните на карту, чтобы выбрать координаты"
},
"tracking": {
"satelliteId": "ID спутника",
"satelliteIdPlaceholder": "Введите UUID спутника...",
"connect": "Подключиться",
"disconnect": "Отключиться",
"status": "Статус",
"status_idle": "Ожидание",
"status_connecting": "Подключение",
"status_connected": "Подключено",
"status_error": "Ошибка",
"packetCount": "Получено пакетов: {count}",
"waitingData": "Ожидание данных...",
"deviation": "Сравнение с прогнозом",
"selectForecast": "Прогноз для сравнения",
"noForecast": "— Без прогноза —",
"noData": "Нет данных телеметрии",
"altProfile": "Высотный профиль",
"selectPrediction": "Выберите прогноз для отображения отклонений",
"horizontalDev": "Горизонтальное отклонение",
"devMax": "Макс.:",
"devCurrent": "Текущее:"
},
"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, plotEndMarker } 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';

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

@ -0,0 +1,133 @@
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 plotEndMarker(scene: Scene, lng: number, lat: number): void {
scene.addCircle('marker-core', {
center: [lng, lat],
radiusPx: 7,
color: '#6c757d',
strokeColor: '#ffffff',
strokeWidth: 2,
});
}
export function plotAnimatedMarker(scene: Scene, lng: number, lat: number): void {
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,
});
}

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

@ -0,0 +1,324 @@
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);
const existing = this.map.getSource(layerId) as maplibregl.GeoJSONSource | undefined;
if (existing) {
existing.setData({
type: 'Feature',
properties: {},
geometry: { type: 'Point', coordinates: options.center },
});
return { id: layerId, remove: () => 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>

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