This commit is contained in:
gili8420 2026-08-14 00:01:49 +09:00
commit b43955e0d9
162 changed files with 15425 additions and 0 deletions

6
src/app.d.ts vendored Normal file
View file

@ -0,0 +1,6 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
declare global {
namespace App {}
}
export {};

13
src/app.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<!-- The app is light-only: the theme is pinned here, never switched at runtime. -->
<html lang="ru" data-bs-theme="light">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

62
src/app.scss Normal file
View file

@ -0,0 +1,62 @@
// Bootstrap, compiled from source so the theme in scss/_theme.scss applies.
// Order matters: functions provide shade-color() to the theme, the theme sets
// its !default values before Bootstrap declares its own, and theme-overrides
// patches what 5.3 no longer derives from variables.
@import 'bootstrap/scss/functions';
@import 'scss/theme';
@import 'bootstrap/scss/bootstrap';
@import 'scss/theme-overrides';
// bootstrap-icons is imported from +layout.svelte, not here: its @font-face
// uses `url(./fonts/)`, and Vite only rebases that to an emitted asset when
// the stylesheet goes through the CSS pipeline rather than the Sass one.
:root {
--navbar-height: 56px;
}
html,
body {
height: 100%;
}
/* Centered auth surface used by the login and register screens. */
.auth-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.auth-card {
width: 100%;
max-width: 26rem;
}
.auth-logo {
width: 220px;
max-width: 70%;
height: auto;
}
/*
* Keyboard focus must stay visible over the map, where panels sit on a busy
* background and Bootstrap's default ring can wash out.
*/
:focus-visible {
outline: 2px solid var(--bs-primary);
outline-offset: 2px;
}
/* Respect reduced-motion: the wind particles and spinners are the only motion. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}

30
src/lib/api/admin.ts Normal file
View file

@ -0,0 +1,30 @@
import { api } from './client';
/** Staff-only user management. Non-staff callers get 403 from the backend. */
export interface AdminUser {
id: number;
username: string;
email: string;
is_active: boolean;
is_staff: boolean;
date_joined: string;
last_login: string | null;
}
export interface AdminUserPage {
total: number;
limit: number;
skip: number;
users: AdminUser[];
}
export const adminApi = {
users: (q = '', limit = 20, skip = 0) => {
const params = new URLSearchParams({ limit: String(limit), skip: String(skip) });
if (q) params.set('q', q);
return api.get<AdminUserPage>(`/admin/users/?${params}`);
},
setActive: (id: number, is_active: boolean) =>
api.patch<AdminUser>(`/admin/users/${id}/`, { is_active })
};

19
src/lib/api/auth.ts Normal file
View file

@ -0,0 +1,19 @@
import { api } from './client';
export interface SessionInfo {
isAuthenticated: boolean;
}
export interface WhoAmI {
username: string;
is_staff: boolean;
}
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/', {}),
register: (username: string, email: string, password: string) =>
api.post<{ detail?: string }>('/register/', { username, email, password })
};

105
src/lib/api/client.ts Normal file
View file

@ -0,0 +1,105 @@
import Cookies from 'js-cookie';
const BASE = import.meta.env.VITE_API_BASE_URL || '/api';
/** Exposed so the telemetry layer can derive its WebSocket origin from it. */
export const API_BASE_URL = BASE;
export class ApiError extends Error {
status: number;
detail: string;
body: unknown;
constructor(status: number, detail: string, body: unknown) {
super(detail);
this.name = 'ApiError';
this.status = status;
this.detail = detail;
this.body = body;
}
}
/**
* Collect human-readable strings out of an arbitrarily shaped error payload.
*
* Bare strings pass through, arrays are walked, and objects contribute their
* `msg` field the key Pydantic and DMR's response validator both use.
* Anything else contributes nothing, so the caller can fall back.
*/
function messagesFrom(value: unknown): string[] {
if (typeof value === 'string') return value.trim() ? [value] : [];
if (Array.isArray(value)) return value.flatMap(messagesFrom);
if (value && typeof value === 'object') {
const { msg } = value as { msg?: unknown };
if (typeof msg === 'string') return [msg];
}
return [];
}
/**
* Reduce an error body to one line to show the user.
*
* The API answers in several shapes: `{detail: "…"}` from most endpoints,
* `{detail: [{msg}]}` when DMR's response validation rejects an undeclared
* status code, a bare list of Pydantic errors, and DRF-style `{field: [...]}`
* maps. `String()` on the non-string ones renders "[object Object]".
*/
function errorDetail(parsed: unknown, status: number): string {
const fallback = `Request failed (${status})`;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const record = parsed as Record<string, unknown>;
// `detail` is authoritative when present — do not fall through to the
// other keys, which would surface internal fields as if they were copy.
const source = 'detail' in record ? record.detail : Object.values(record);
const found = messagesFrom(source);
return found.length ? found.join('; ') : fallback;
}
const found = messagesFrom(parsed);
return found.length ? found.join('; ') : fallback;
}
let onUnauthorized: (() => void) | null = null;
export function setUnauthorizedHandler(fn: () => void) {
onUnauthorized = fn;
}
let csrfPrimed = false;
async function ensureCsrf(): Promise<void> {
if (csrfPrimed || Cookies.get('csrftoken')) {
csrfPrimed = true;
return;
}
await fetch(`${BASE}/csrf/`, { credentials: 'include' });
csrfPrimed = true;
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
if (method !== 'GET') await ensureCsrf();
const headers: Record<string, string> = { 'content-type': 'application/json' };
const token = Cookies.get('csrftoken');
if (token && method !== 'GET') headers['X-CSRFToken'] = token;
const res = await fetch(`${BASE}${path}`, {
method,
credentials: 'include',
headers,
body: body === undefined ? undefined : JSON.stringify(body)
});
if (res.status === 401 && onUnauthorized) onUnauthorized();
const text = await res.text();
const parsed = text ? JSON.parse(text) : null;
if (!res.ok) {
throw new ApiError(res.status, errorDetail(parsed, res.status), parsed);
}
return parsed as T;
}
export const api = {
get: <T>(path: string) => request<T>('GET', path),
post: <T>(path: string, body: unknown) => request<T>('POST', path, body),
put: <T>(path: string, body: unknown) => request<T>('PUT', path, body),
patch: <T>(path: string, body: unknown) => request<T>('PATCH', path, body),
del: <T>(path: string, body?: unknown) => request<T>('DELETE', path, body)
};

32
src/lib/api/flights.ts Normal file
View file

@ -0,0 +1,32 @@
import { api } from './client';
export type FlightPrivacy = 'public' | 'unlisted' | 'private';
export interface FlightPosition {
timestamp: number;
lat: number;
lon: number;
alt: number;
}
export interface Flight {
id: string;
name: string;
privacy: FlightPrivacy;
created_at: string;
is_owner: boolean;
last_position: FlightPosition | null;
packet_count: number;
}
/**
* Flight discovery and resolution. `public/` and `GET <id>/` are anonymous by
* design a shared link must open without a session; the backend hides private
* flights behind a 404.
*/
export const flightsApi = {
public: () => api.get<{ flights: Flight[] }>('/flights/public/'),
get: (id: string) => api.get<Flight>(`/flights/${id}/`),
update: (id: string, patch: { name?: string; privacy?: FlightPrivacy }) =>
api.patch<Flight>(`/flights/${id}/`, patch)
};

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

@ -0,0 +1,10 @@
export * from './client';
export * from './auth';
export * from './profile';
export * from './predictions';
export * from './points';
export * from './templates';
export * from './wind';
export * from './telemetry';
export * from './admin';
export * from './flights';

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: Omit<SavedPoint, 'id'>) => api.post<SavedPoint>(base, p),
update: (p: SavedPoint) => api.put<SavedPoint>(`${base}${p.id}/`, p),
delete: (id: number) => api.del<void>(`${base}${id}/`)
};

129
src/lib/api/predictions.ts Normal file
View file

@ -0,0 +1,129 @@
import { api } from './client';
import type { FlightParameters, RawPrediction, V2Request } from '$domain';
/**
* GFS datasets are published every 6 hours with a ~6 hour processing lag.
* Round down to the most recent available slot (UTC).
*/
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().slice(0, 19) + 'Z';
}
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;
}
/** One stored prediction as returned by `predictions/list_user/`. */
export interface StoredPrediction {
id: string;
created_at: string;
updated_at: string;
result: RawPrediction | null;
}
/** `predictions/<id>/detail/` adds status, error and the original request. */
export interface StoredPredictionDetail extends StoredPrediction {
status: 'pending' | 'running' | 'complete' | 'error';
error: string | null;
request: (FlightParameters & { launch_datetime?: string }) | null;
start_point: number | null;
template: number | null;
rate_profile: number | null;
}
/** `predictions/v2/` returns the freshly stored run plus its raw result. */
export interface PredictionCreateOut {
id: string;
created_at: string;
result: RawPrediction;
}
/** 202 response from the async/ensemble endpoints. */
export interface AsyncPredictionCreateOut {
id: string;
status: 'pending' | 'running' | 'complete' | 'error';
created_at: string;
}
/** Poll shape from `predictions/<id>/status/`. */
export interface PredictionStatus {
id: string;
status: 'pending' | 'running' | 'complete' | 'error';
result: unknown;
error: string | null;
created_at: string;
updated_at: string;
}
/** Limit/offset envelope used by the paginated history endpoint. */
export interface PredictionPage {
total: number;
limit: number;
skip: number;
predictions: StoredPrediction[];
}
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);
},
/** Run a profile-driven (custom stages) prediction synchronously. */
runV2: (request: V2Request) => api.post<PredictionCreateOut>('/predictions/v2/', request),
/** Paginated history for the current user (limit/skip envelope). */
page: (limit = 10, skip = 0) =>
api.get<PredictionPage>(`/predictions/list_user/?limit=${limit}&skip=${skip}`),
detail: (id: string) => api.get<StoredPredictionDetail>(`/predictions/${id}/detail/`),
remove: (id: string) => api.del<void>(`/predictions/${id}/delete/`),
/** Enqueue a GEFS ensemble run; poll `status` until it completes. */
runEnsemble: (params: FlightParameters, launchDateTime: string) =>
api.post<AsyncPredictionCreateOut>('/predictions/ensemble/', {
...params,
dataset: params.dataset || getLatestDataset(),
launch_datetime: launchDateTime,
source: 'gefs-0p50-3h'
}),
status: (id: string) => api.get<PredictionStatus>(`/predictions/${id}/status/`),
/** Mint (or re-fetch) a share token for a stored prediction. Owner only. */
share: (id: string) => api.post<ShareOut>(`/predictions/${id}/share/`, {}),
/** Revoke the share link. */
unshare: (id: string) => api.del<ShareOut>(`/predictions/${id}/share/`),
/** Read a shared prediction by token — works without a session. */
shared: (token: string) => api.get<SharedPrediction>(`/predictions/shared/${token}/`)
};
/** Token envelope returned by share/unshare. */
export interface ShareOut {
id: string;
share_token: string | null;
}
/** Read-only projection served to anyone holding a share link. */
export interface SharedPrediction {
id: string;
created_at: string;
status: string;
result: RawPrediction | null;
}

20
src/lib/api/profile.ts Normal file
View file

@ -0,0 +1,20 @@
import { api } from './client';
export interface UserProfile {
username: string;
email: string;
first_name: string;
last_name: string;
}
export type ProfilePatch = Partial<Pick<UserProfile, 'email' | 'first_name' | 'last_name'>>;
export const profileApi = {
get: () => api.get<UserProfile>('/profile/'),
update: (patch: ProfilePatch) => api.patch<UserProfile>('/profile/', patch),
changePassword: (old_password: string, new_password: string) =>
api.post<{ detail: string }>('/profile/change-password/', { old_password, new_password }),
deleteData: () => api.del<{ detail: string }>('/profile/delete-data/'),
deleteAccount: (password: string) =>
api.del<{ detail: string }>('/profile/delete-account/', { password })
};

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

@ -0,0 +1,39 @@
import { api, API_BASE_URL } from './client';
/** Packet shape as stored/served by the backend. */
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 the read-only satellite WebSocket URL from the configured API base. */
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 q = new URLSearchParams();
if (params?.from !== undefined) q.set('from', String(params.from));
if (params?.till !== undefined) q.set('till', String(params.till));
const qs = q.toString();
const res = await api.get<RawTelemetryPacket[] | { results: RawTelemetryPacket[] }>(
`/${satelliteId}/telemetry/${qs ? `?${qs}` : ''}`
);
return Array.isArray(res) ? res : res.results;
}
};

11
src/lib/api/templates.ts Normal file
View file

@ -0,0 +1,11 @@
import { api } from './client';
import type { Template } from '$domain';
const base = '/saved-templates/';
export const templatesApi = {
list: () => api.get<Template[]>(base),
create: (t: Omit<Template, 'id'>) => api.post<Template>(base, t),
update: (t: Template) => api.put<Template>(`${base}${t.id}/`, t),
delete: (id: number) => api.del<void>(`${base}${id}/`)
};

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

@ -0,0 +1,35 @@
import { api } from './client';
import type { WindField, WindMeta } from '$domain';
/**
* Wind visualization endpoints. These are served by the Django backend as
* authenticated proxies to the predictor (`/api/wind/field/`, `/api/wind/meta/`),
* so they go through the shared client and reuse the session no direct
* predictor address or CORS handling on the frontend.
*/
export interface WindFieldParams {
min_lat: number;
max_lat: number;
min_lng: number;
max_lng: number;
/** Grid resolution in degrees; the backend enforces >= 0.25. */
step?: number;
altitude?: number;
time?: string;
}
function qs(params: Record<string, string | number | undefined>): string {
const q = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v !== undefined) q.set(k, String(v));
}
const s = q.toString();
return s ? `?${s}` : '';
}
export const windApi = {
field: (params: WindFieldParams) =>
api.get<WindField>(`/wind/field/${qs({ ...params })}`),
meta: () => api.get<WindMeta>('/wind/meta/')
};

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

@ -0,0 +1,22 @@
import { goto } from '$app/navigation';
import { authStore } from './store';
/** Call in a page's onMount. Redirects anonymous users to `redirectTo`. */
export async function requireAuthenticated(redirectTo = '/login'): Promise<boolean> {
const state = await authStore.refresh();
if (state.status !== 'authenticated') {
await goto(redirectTo);
return false;
}
return true;
}
/** Like requireAuthenticated but also requires staff. */
export async function requireStaff(redirectTo = '/app'): Promise<boolean> {
const state = await authStore.refresh();
if (state.status !== 'authenticated' || !state.isStaff) {
await goto(redirectTo);
return false;
}
return true;
}

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

@ -0,0 +1,2 @@
export * from './store';
export * from './guard';

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

@ -0,0 +1,70 @@
import { writable, get } from 'svelte/store';
import { goto } from '$app/navigation';
import { setUnauthorizedHandler, authApi } from '$api';
export type AuthStatus = 'unknown' | 'anonymous' | 'authenticated';
export interface AuthState {
status: AuthStatus;
username: string | null;
isStaff: boolean;
}
const initial: AuthState = { status: 'unknown', username: null, isStaff: false };
function createAuthStore() {
const { subscribe, set } = writable<AuthState>(initial);
async function refresh(): Promise<AuthState> {
try {
const session = await authApi.session();
if (!session.isAuthenticated) {
const next: AuthState = { status: 'anonymous', username: null, isStaff: false };
set(next);
return next;
}
const me = await authApi.whoami();
const next: AuthState = {
status: 'authenticated',
username: me.username,
isStaff: me.is_staff
};
set(next);
return next;
} catch {
const next: AuthState = { status: 'anonymous', username: null, isStaff: false };
set(next);
return next;
}
}
async function login(username: string, password: string) {
await authApi.login(username, password);
await refresh();
}
async function register(username: string, email: string, password: string) {
await authApi.register(username, email, password);
await refresh();
}
async function logout() {
try {
await authApi.logout();
} finally {
set({ status: 'anonymous', username: null, isStaff: false });
}
}
return { subscribe, refresh, login, register, logout, snapshot: () => get({ subscribe }) };
}
export const authStore = createAuthStore();
// Route API 401s back through the store so an expired session redirects once.
let redirecting = false;
setUnauthorizedHandler(() => {
if (redirecting || typeof window === 'undefined') return;
if (window.location.pathname.startsWith('/login')) return;
redirecting = true;
goto('/login').finally(() => (redirecting = false));
});

View file

@ -0,0 +1,69 @@
import type { LatLngTuple } from './geo';
/**
* Axis-aligned geographic bounding box around a flight path, with an optional
* margin so recovery teams get a box that clears the trajectory by a set
* distance rather than hugging its extreme points.
*/
export interface BoundingBox {
south: number;
west: number;
north: number;
east: number;
}
/** Default clearance (km) between the box edge and the nearest trajectory point. */
export const DEFAULT_BBOX_MARGIN_KM = 5;
// Mean length of one degree of latitude. Longitude degrees shrink toward the
// poles, handled below via cos(latitude).
const KM_PER_DEG_LAT = 111.32;
/**
* Compute the bounding box of a flight path, expanded outward by `marginKm`.
* Returns null for an empty path (callers treat that as "nothing to draw").
*/
export function computeBoundingBox(
path: LatLngTuple[],
marginKm = DEFAULT_BBOX_MARGIN_KM
): BoundingBox | null {
if (path.length === 0) return null;
let south = Infinity;
let north = -Infinity;
let west = Infinity;
let east = -Infinity;
for (const [lat, lng] of path) {
if (lat < south) south = lat;
if (lat > north) north = lat;
if (lng < west) west = lng;
if (lng > east) east = lng;
}
const margin = Math.max(0, marginKm);
const dLat = margin / KM_PER_DEG_LAT;
// Flat-earth degree conversion — fine at flight scales (<1000 km). Size the
// longitude margin at the latitude nearest a pole so the box never comes in
// tighter than requested along the whole band.
const maxAbsLat = Math.max(Math.abs(south), Math.abs(north));
const kmPerDegLng = KM_PER_DEG_LAT * Math.cos((maxAbsLat * Math.PI) / 180);
const dLng = kmPerDegLng > 0 ? margin / kmPerDegLng : 0;
return {
south: south - dLat,
north: north + dLat,
west: west - dLng,
east: east + dLng
};
}
/** Closed ring (corners + repeated start) for drawing the box as a polyline. */
export function boundingBoxRing(box: BoundingBox): LatLngTuple[] {
return [
[box.south, box.west],
[box.north, box.west],
[box.north, box.east],
[box.south, box.east],
[box.south, box.west]
];
}

117
src/lib/domain/curve.ts Normal file
View file

@ -0,0 +1,117 @@
/**
* Custom ascent/descent curves the "Custom curve" solver's data.
*
* Curves are pure data: an ordered list of (time, altitude) constraints with an
* optional explicit rate. Editing happens in the curve editor (the single leaf
* modal, guidelines §6.5); these helpers hold the validation and conversion
* logic so it is testable without Svelte.
*
* Note: the backend exposes no saved-profiles endpoint yet (the SavedRateProfile
* model has no controller), so curves currently live inside the scenario/template
* they belong to rather than in a server-side library.
*/
export interface RateCurvePoint {
order: number;
/** Seconds since launch. */
time_constraint: number;
/** Altitude in metres at that time. */
alt_constraint: number;
/** Explicit vertical rate (m/s); 0 means "derive from neighbours". */
rate: number;
}
export interface SavedFlightProfile {
id: number;
name: string;
type?: string;
rate_profile_data: RateCurvePoint[];
}
/** Sort by `order`, then renumber so the sequence stays 0..n-1. */
export function normalizeCurve(points: RateCurvePoint[]): RateCurvePoint[] {
return [...points]
.sort((a, b) => a.order - b.order)
.map((p, i) => ({ ...p, order: i }));
}
/**
* Validate a curve. Returns human-readable problem keys (i18n keys) an empty
* array means the curve is usable.
*/
export function validateCurve(points: RateCurvePoint[]): string[] {
const problems: string[] = [];
if (points.length < 2) {
problems.push('curve.errTooFewPoints');
return problems;
}
const ordered = normalizeCurve(points);
// Time must strictly increase: a flat or backwards step makes the implied
// rate infinite/negative-time and the solver cannot advance through it.
for (let i = 1; i < ordered.length; i++) {
if (ordered[i].time_constraint <= ordered[i - 1].time_constraint) {
problems.push('curve.errNonMonotonicTime');
break;
}
}
if (ordered.some((p) => p.time_constraint < 0)) problems.push('curve.errNegativeTime');
if (ordered.some((p) => p.alt_constraint < 0)) problems.push('curve.errNegativeAltitude');
return problems;
}
export interface ImpliedRate {
fromOrder: number;
toOrder: number;
/** Vertical rate between the two points, m/s (positive = ascending). */
rate: number;
}
/** Vertical rate implied by each consecutive pair — the editor's readout. */
export function impliedRates(points: RateCurvePoint[]): ImpliedRate[] {
const ordered = normalizeCurve(points);
const out: ImpliedRate[] = [];
for (let i = 1; i < ordered.length; i++) {
const dt = ordered[i].time_constraint - ordered[i - 1].time_constraint;
if (dt <= 0) continue; // guarded by validateCurve; skip rather than divide by zero
out.push({
fromOrder: ordered[i - 1].order,
toOrder: ordered[i].order,
rate: (ordered[i].alt_constraint - ordered[i - 1].alt_constraint) / dt
});
}
return out;
}
/**
* Parse CSV of `time,altitude[,rate]` rows. A header line is tolerated, as are
* blank lines and both `,` and `;` separators.
*/
export function parseCurveCsv(text: string): RateCurvePoint[] {
const points: RateCurvePoint[] = [];
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
const cells = trimmed.split(/[;,]/).map((c) => c.trim());
const time = Number(cells[0]);
const alt = Number(cells[1]);
// Skip anything non-numeric (header rows, stray text).
if (!Number.isFinite(time) || !Number.isFinite(alt)) continue;
const rate = Number(cells[2]);
points.push({
order: points.length,
time_constraint: time,
alt_constraint: alt,
rate: Number.isFinite(rate) ? rate : 0
});
}
return points;
}
export function curveToCsv(points: RateCurvePoint[]): string {
const rows = normalizeCurve(points).map(
(p) => `${p.time_constraint},${p.alt_constraint},${p.rate}`
);
return ['time_s,altitude_m,rate_ms', ...rows].join('\n');
}

View file

@ -0,0 +1,78 @@
import type { LatLngTuple } from './geo';
/**
* GEFS ensemble results: one landing per member plus the aggregated footprint
* the backend computed. Pure helpers only the map layer turns these into
* scenes.
*/
export interface EnsembleMember {
member: number;
landing: { lat: number; lng: number; alt: number } | null;
}
export interface EnsembleFootprint {
count: number;
mean: { lat: number; lng: number };
/** Mean member distance from the centre, km. */
radius_km: number;
ellipse: {
semi_major_km: number;
semi_minor_km: number;
/** Bearing of the major axis, degrees clockwise from north. */
bearing_deg: number;
};
}
export interface EnsembleResult {
members: EnsembleMember[];
failed: { member: number; error: string }[];
footprint: EnsembleFootprint | null;
}
/** Narrow a stored prediction result to an ensemble payload, or null. */
export function parseEnsemble(result: unknown): EnsembleResult | null {
if (!result || typeof result !== 'object') return null;
const ensemble = (result as { ensemble?: unknown }).ensemble;
if (!ensemble || typeof ensemble !== 'object') return null;
const e = ensemble as Partial<EnsembleResult>;
if (!Array.isArray(e.members)) return null;
return {
members: e.members,
failed: Array.isArray(e.failed) ? e.failed : [],
footprint: e.footprint ?? null
};
}
const KM_PER_DEG_LAT = 111.32;
/**
* Build the 95% confidence ellipse as a closed ring of lat/lng points.
*
* The ellipse is defined in a local km plane (semi-axes + bearing), so it is
* converted back to degrees here, with the longitude scale corrected by
* cos(latitude) otherwise the ellipse would be visibly too wide at high
* latitudes, which is exactly where these flights happen.
*/
export function ellipseRing(footprint: EnsembleFootprint, segments = 64): LatLngTuple[] {
const { mean, ellipse } = footprint;
const kmPerDegLng = KM_PER_DEG_LAT * Math.cos((mean.lat * Math.PI) / 180);
if (kmPerDegLng === 0) return [];
// Bearing is clockwise from north; rotate the parametric ellipse to match.
const theta = (ellipse.bearing_deg * Math.PI) / 180;
const cosT = Math.cos(theta);
const sinT = Math.sin(theta);
const ring: LatLngTuple[] = [];
for (let i = 0; i <= segments; i++) {
const t = (i / segments) * 2 * Math.PI;
// Local frame: major axis along +y (north) before rotation.
const a = ellipse.semi_major_km * Math.cos(t);
const b = ellipse.semi_minor_km * Math.sin(t);
const east = b * cosT + a * sinT;
const north = a * cosT - b * sinT;
ring.push([mean.lat + north / KM_PER_DEG_LAT, mean.lng + east / kmPerDegLng]);
}
return ring;
}

104
src/lib/domain/export.ts Normal file
View file

@ -0,0 +1,104 @@
import type { Prediction } from './prediction';
/**
* Trajectory exporters. Pure string builders so they can be unit-tested without
* a DOM: the caller turns the result into a download.
*
* All three formats carry the same data time, position and altitude in the
* conventions each expects: GPX/KML use decimal degrees and metres, and KML
* orders coordinates longitude-first.
*/
const escapeXml = (s: string) =>
s.replace(/[<>&'"]/g, (c) =>
({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' })[c] as string
);
/** Trajectory points paired with their timestamps. */
function samples(prediction: Prediction) {
return prediction.flight_path.map((p, i) => ({
lat: p[0],
lng: p[1],
alt: (p[2] as number | undefined) ?? 0,
time: new Date(prediction.timestamps[i] ?? 0).toISOString()
}));
}
/** GPX 1.1 track — the format recovery teams load into handheld GPS units. */
export function toGpx(prediction: Prediction, name = 'StratoFlights prediction'): string {
const pts = samples(prediction)
.map(
(s) =>
` <trkpt lat="${s.lat.toFixed(6)}" lon="${s.lng.toFixed(6)}">` +
`<ele>${s.alt.toFixed(1)}</ele><time>${s.time}</time></trkpt>`
)
.join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="StratoFlights" xmlns="http://www.topografix.com/GPX/1/1">
<metadata><name>${escapeXml(name)}</name></metadata>
<trk>
<name>${escapeXml(name)}</name>
<trkseg>
${pts}
</trkseg>
</trk>
</gpx>`;
}
/** KML 2.2 — opens directly in Google Earth. Coordinates are lon,lat,alt. */
export function toKml(prediction: Prediction, name = 'StratoFlights prediction'): string {
const coords = samples(prediction)
.map((s) => `${s.lng.toFixed(6)},${s.lat.toFixed(6)},${s.alt.toFixed(1)}`)
.join('\n ');
const marker = (label: string, point: { lat: number; lng: number; alt?: number }) =>
` <Placemark><name>${escapeXml(label)}</name><Point><coordinates>` +
`${point.lng.toFixed(6)},${point.lat.toFixed(6)},${(point.alt ?? 0).toFixed(1)}` +
`</coordinates></Point></Placemark>`;
return `<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
<name>${escapeXml(name)}</name>
${marker('Launch', prediction.launch.latlng)}
${marker('Burst', prediction.burst.latlng)}
${marker('Landing', prediction.landing.latlng)}
<Placemark>
<name>${escapeXml(name)}</name>
<LineString>
<altitudeMode>absolute</altitudeMode>
<coordinates>
${coords}
</coordinates>
</LineString>
</Placemark>
</Document>
</kml>`;
}
/** CSV — the format that lands in a spreadsheet without conversion. */
export function toCsv(prediction: Prediction): string {
const rows = samples(prediction).map(
(s) => `${s.time},${s.lat.toFixed(6)},${s.lng.toFixed(6)},${s.alt.toFixed(1)}`
);
return ['datetime_utc,latitude,longitude,altitude_m', ...rows].join('\n');
}
export type ExportFormat = 'gpx' | 'kml' | 'csv';
export const EXPORT_MIME: Record<ExportFormat, string> = {
gpx: 'application/gpx+xml',
kml: 'application/vnd.google-earth.kml+xml',
csv: 'text/csv'
};
export function exportPrediction(
prediction: Prediction,
format: ExportFormat,
name = 'prediction'
): string {
if (format === 'gpx') return toGpx(prediction, name);
if (format === 'kml') return toKml(prediction, name);
return toCsv(prediction);
}

View file

@ -0,0 +1,67 @@
/**
* Prediction parameters and saved library items (backend API contract).
*
* Field names (`ascent_rate`, `launch_latitude`, ) mirror the backend/predictor
* payload and must not be renamed. Profile identifiers are canonical; localized
* labels live in i18n under `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;
}
/** A saved launch point (library item). */
export interface SavedPoint {
id: number;
name: string;
lat: number;
lon: number;
alt: number;
}
/** A saved, named parameter bundle (library item). Was "SavedScenario". */
export interface Template {
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
};

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

@ -0,0 +1,47 @@
export interface LatLon {
lat: number;
lon: number;
}
const R = 6_371_000; // mean Earth radius, metres
const rad = (deg: number) => (deg * Math.PI) / 180;
/** Great-circle distance in metres between two WGS84 points. */
export function haversineMeters(a: LatLon, b: LatLon): number {
const dLat = rad(b.lat - a.lat);
const dLon = rad(b.lon - a.lon);
const s =
Math.sin(dLat / 2) ** 2 +
Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(s));
}
/**
* Map-facing geo primitives. The `lng` convention (and `LngLatTuple`,
* longitude-first) matches MapLibre; `LatLngTuple` is latitude-first for API
* payloads. Used by the map layer.
*/
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;
}
/** API occasionally returns longitudes in the 0..360 range. */
export function normalizeLng(lng: number): number {
return lng > 180 ? lng - 360 : lng;
}

68
src/lib/domain/history.ts Normal file
View file

@ -0,0 +1,68 @@
import type { RawPrediction } from './prediction';
import { parsePrediction } from './prediction';
import { parseEnsemble } from './ensemble';
/**
* Display projection of a stored prediction. The list endpoint returns the raw
* predictor payload, so the summary a history row needs (landing point, flight
* time) has to be derived here kept pure so it is testable without Svelte.
*/
export interface HistoryRow {
id: string;
createdAt: Date;
/** Ensemble runs store a footprint instead of a single trajectory. */
kind: 'single' | 'ensemble';
/** Member count for an ensemble run; 0 otherwise. */
members: number;
/** Null when the run failed or produced no usable trajectory. */
landing: { lat: number; lng: number } | null;
/** Flight duration in seconds; 0 when unknown. */
flightTime: number;
/** True when the stored result could not be interpreted as a trajectory. */
broken: boolean;
}
export function toHistoryRow(stored: {
id: string;
created_at: string;
result: RawPrediction | null | Record<string, unknown>;
}): HistoryRow {
const base = {
id: stored.id,
createdAt: new Date(stored.created_at),
kind: 'single' as const,
members: 0
};
// An ensemble run has no single trajectory: summarise it by its footprint.
const ensemble = parseEnsemble(stored.result);
if (ensemble) {
const mean = ensemble.footprint?.mean ?? null;
return {
...base,
kind: 'ensemble',
members: ensemble.footprint?.count ?? ensemble.members.length,
landing: mean ? { lat: mean.lat, lng: mean.lng } : null,
flightTime: 0,
broken: !mean
};
}
const stages = (stored.result as RawPrediction | null)?.prediction;
if (!Array.isArray(stages) || stages.length < 2) {
return { ...base, landing: null, flightTime: 0, broken: true };
}
try {
const p = parsePrediction(stages);
return {
...base,
landing: { lat: p.landing.latlng.lat, lng: p.landing.latlng.lng },
flightTime: p.flight_time,
broken: false
};
} catch {
// A stored result can be truncated or from an older predictor version.
return { ...base, landing: null, flightTime: 0, broken: true };
}
}

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

@ -0,0 +1,15 @@
export * from './geo';
export * from './math';
export * from './boundingBox';
export * from './prediction';
export * from './history';
export * from './ensemble';
export * from './export';
export * from './flightParameters';
export * from './curve';
export * from './profile';
export * from './units';
export * from './provenance';
export * from './scenario';
export * from './telemetry';
export * from './wind';

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

@ -0,0 +1,100 @@
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;
/** Great-circle distance in **kilometres** (metres live in geo.haversineMeters). */
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 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 };
}

238
src/lib/domain/profile.ts Normal file
View file

@ -0,0 +1,238 @@
import type { FlightParameters } from './flightParameters';
import type { RateCurvePoint } from './curve';
/**
* Custom flight profiles: an ordered list of stages, each a propagator with
* constraints that decide when it ends and what happens next (guidelines §6.4).
*
* The shapes mirror the predictor's v2 request exactly `constant_rate`,
* `parachute_descent`, `piecewise` and `wind` solvers, and constraints with a
* `stop` / `fallback` / `clip` action so building the request is a plain
* mapping rather than a translation.
*/
export const SOLVER_TYPES = ['constant_rate', 'parachute_descent', 'piecewise', 'wind'] as const;
export type SolverType = (typeof SOLVER_TYPES)[number];
export const CONSTRAINT_TYPES = ['altitude', 'time', 'terrain_contact', 'polygon'] as const;
export type ConstraintType = (typeof CONSTRAINT_TYPES)[number];
export const OPERATORS = ['<', '<=', '>', '>=', '=='] as const;
export type Operator = (typeof OPERATORS)[number];
export type ConstraintAction = 'stop' | 'fallback' | 'clip';
export interface ConstraintSpec {
type: ConstraintType;
op?: Operator;
limit?: number;
action: ConstraintAction;
/** Name of the stage to hand control to when `action` is `fallback`. */
fallback?: string;
polygon?: [number, number][];
}
export interface StageSolver {
type: SolverType;
/** constant_rate: vertical rate (m/s). */
rate?: number;
/** parachute_descent: descent rate at sea level (m/s). */
sea_level_rate?: number;
/** piecewise: the curve editor's points, mapped to segments on submit. */
segments?: RateCurvePoint[];
include_wind: boolean;
}
export interface ProfileStage {
/** Unique within a profile; fallbacks reference it by name. */
name: string;
solver: StageSolver;
/** Normal exits. */
advanceWhen: ConstraintSpec[];
/** Abort exits — trip to a fallback stage instead of the next one. */
abortIf: ConstraintSpec[];
}
/** The standard ascent → burst → descent profile, as explicit stages. */
export function standardStages(params: FlightParameters): ProfileStage[] {
return [
{
name: 'ascent',
solver: { type: 'constant_rate', rate: params.ascent_rate, include_wind: true },
advanceWhen: [
{ type: 'altitude', op: '>=', limit: params.burst_altitude, action: 'stop' }
],
abortIf: []
},
{
name: 'descent',
solver: { type: 'parachute_descent', sea_level_rate: params.descent_rate, include_wind: true },
advanceWhen: [{ type: 'terrain_contact', action: 'stop' }],
abortIf: []
}
];
}
/** Float: climb, then hold altitude until a time cap. */
export function floatStages(params: FlightParameters): ProfileStage[] {
return [
{
name: 'ascent',
solver: { type: 'constant_rate', rate: params.ascent_rate, include_wind: true },
advanceWhen: [
{ type: 'altitude', op: '>=', limit: params.burst_altitude, action: 'stop' }
],
abortIf: []
},
{
name: 'float',
solver: { type: 'wind', include_wind: true },
advanceWhen: [{ type: 'time', op: '>=', limit: 6 * 3600, action: 'stop' }],
abortIf: []
}
];
}
export const PROFILE_PRESETS = {
standard: standardStages,
float: floatStages
} as const;
/**
* Validate a profile. Returns i18n keys empty means the profile can be run.
*
* The two failures that actually break a run are a stage with no exit (it would
* integrate forever) and a fallback pointing at a stage that does not exist.
*/
export function validateProfile(stages: ProfileStage[]): string[] {
const problems: string[] = [];
if (stages.length === 0) return ['profileBuilder.errNoStages'];
const names = stages.map((s) => s.name.trim());
if (names.some((n) => !n)) problems.push('profileBuilder.errUnnamedStage');
if (new Set(names).size !== names.length) problems.push('profileBuilder.errDuplicateName');
for (const stage of stages) {
if (stage.advanceWhen.length === 0 && stage.abortIf.length === 0) {
problems.push('profileBuilder.errNoExit');
break;
}
}
for (const stage of stages) {
for (const c of [...stage.advanceWhen, ...stage.abortIf]) {
// Scalar comparisons need both halves; terrain/polygon do not.
if ((c.type === 'altitude' || c.type === 'time') && (!c.op || c.limit === undefined)) {
problems.push('profileBuilder.errIncompleteConstraint');
}
if (c.action === 'fallback' && (!c.fallback || !names.includes(c.fallback))) {
problems.push('profileBuilder.errMissingFallback');
}
}
}
return [...new Set(problems)];
}
/** Map a stage's solver to the predictor's discriminated `model` object. */
function toModel(solver: StageSolver): Record<string, unknown> {
switch (solver.type) {
case 'constant_rate':
return { type: 'constant_rate', rate: solver.rate ?? 0, include_wind: solver.include_wind };
case 'parachute_descent':
return {
type: 'parachute_descent',
sea_level_rate: solver.sea_level_rate ?? 0,
include_wind: solver.include_wind
};
case 'piecewise':
return {
type: 'piecewise',
// The curve editor works in (time, altitude); the predictor consumes
// segments carrying the same pair plus its reference frame.
segments: (solver.segments ?? []).map((p) => ({
reference: 'profile_start',
time: p.time_constraint,
altitude: p.alt_constraint,
rate: p.rate
})),
include_wind: solver.include_wind
};
case 'wind':
return { type: 'wind', include_wind: solver.include_wind };
}
}
const toConstraint = (c: ConstraintSpec) => ({
type: c.type,
...(c.op !== undefined ? { op: c.op } : {}),
...(c.limit !== undefined ? { limit: c.limit } : {}),
action: c.action,
...(c.fallback ? { fallback: c.fallback } : {}),
...(c.polygon ? { polygon: c.polygon } : {})
});
export interface V2Request {
launch: { time: string; latitude: number; longitude: number; altitude: number };
direction: 'forward' | 'reverse';
profile: Array<{
name: string;
model: Record<string, unknown>;
constraints: ReturnType<typeof toConstraint>[];
}>;
globals: ReturnType<typeof toConstraint>[];
source?: string;
dataset?: string;
}
/** Build the predictor v2 request for a custom profile. */
export function toV2Request(
stages: ProfileStage[],
params: FlightParameters,
launchIso: string,
options: { source?: string; dataset?: string; direction?: 'forward' | 'reverse' } = {}
): V2Request {
return {
launch: {
time: launchIso,
latitude: params.launch_latitude,
longitude: params.launch_longitude,
altitude: params.launch_altitude
},
direction: options.direction ?? 'forward',
profile: stages.map((s) => ({
name: s.name,
model: toModel(s.solver),
// Both blocks are constraints to the predictor; the action is what makes
// an abort different from a normal advance.
constraints: [...s.advanceWhen, ...s.abortIf].map(toConstraint)
})),
globals: [],
...(options.source ? { source: options.source } : {}),
...(options.dataset ? { dataset: options.dataset } : {})
};
}
/** One-line summary for a collapsed stage card (e.g. `Ascent · 5 m/s → 30000 m`). */
export function stageSummary(stage: ProfileStage): string {
const s = stage.solver;
const rate =
s.type === 'constant_rate'
? `${s.rate ?? 0} m/s`
: s.type === 'parachute_descent'
? `${s.sea_level_rate ?? 0} m/s`
: s.type === 'piecewise'
? `${(s.segments ?? []).length} pts`
: 'wind';
const exit = stage.advanceWhen[0];
const until = exit
? exit.type === 'terrain_contact'
? '→ ground'
: exit.limit !== undefined
? `${exit.limit}${exit.type === 'time' ? ' s' : ' m'}`
: ''
: '';
return `${stage.name} · ${rate} ${until}`.trim();
}

View file

@ -0,0 +1,37 @@
import type { FlightParameters, SavedPoint } from './flightParameters';
/**
* Saved-vs-live state machine (guidelines §3). A field loaded from a library
* item is Custom (no source), Linked-clean (equals its source), or Linked-dirty
* (edited since load). These pure helpers derive the "dirty" bit; the UI maps it
* to the provenance chip and Save affordances.
*/
const eq = (a: number, b: number, dp: number) => a.toFixed(dp) === b.toFixed(dp);
/** True if the launch coordinates diverge from the saved point they were loaded from. */
export function pointDirty(
params: Pick<FlightParameters, 'launch_latitude' | 'launch_longitude' | 'launch_altitude'>,
point: Pick<SavedPoint, 'lat' | 'lon' | 'alt'>
): boolean {
return (
!eq(params.launch_latitude, point.lat, 6) ||
!eq(params.launch_longitude, point.lon, 6) ||
!eq(params.launch_altitude, point.alt, 2)
);
}
const TEMPLATE_KEYS: (keyof FlightParameters)[] = [
'ascent_rate',
'burst_altitude',
'descent_rate',
'launch_altitude',
'launch_latitude',
'launch_longitude',
'profile'
];
/** True if the live parameters diverge from the template they were applied from. */
export function templateDirty(params: FlightParameters, tpl: FlightParameters): boolean {
return TEMPLATE_KEYS.some((k) => String(params[k]) !== String(tpl[k]));
}

View file

@ -0,0 +1,42 @@
import type { FlightParameters } from './flightParameters';
import type { Prediction } from './prediction';
import type { RateCurvePoint } from './curve';
import type { ProfileStage } from './profile';
import type { EnsembleResult } from './ensemble';
/**
* A Scenario is an independently-configured, live prediction instance drawn on
* the map (was "Workspace"). Parameters + computed result + display style.
*/
export interface Scenario {
id: string;
name: string;
color: string; // identity, not chrome
opacity: number; // 0..1
visible: boolean;
flightParameters: FlightParameters;
launchDate: string; // YYYY-MM-DD
launchTime: string; // HH:mm[:ss] (UTC)
/**
* Custom-curve data for the "custom_profile" solver. Lives on the scenario
* because the backend has no saved-profiles endpoint yet.
*/
curve?: RateCurvePoint[];
/** Stage list for the custom profile builder (guidelines §6.4). */
stages?: ProfileStage[];
result: Prediction | null;
runStatus: 'idle' | 'running' | 'done' | 'error';
lastRunError?: string;
/** Landing footprint from a GEFS ensemble run, when one was requested. */
ensemble?: EnsembleResult | null;
/** Id of the in-flight async job, used to poll and to cancel. */
jobId?: string | null;
}
export interface ScenarioInit {
name?: string;
color?: string;
flightParameters?: FlightParameters;
launchDate?: string;
launchTime?: string;
}

View file

@ -0,0 +1,43 @@
import type { LatLngTuple } from './geo';
import type { Point } from './prediction';
/** A single telemetry sample, normalized from the backend packet shape. */
export interface TelemetryPoint {
altitude: number;
datetime: string; // ISO 8601 UTC
latitude: number;
longitude: number;
payload: string;
}
export interface TelemetryMetadata {
complete_datetime: string;
start_datetime: string;
}
export interface RawTelemetry {
metadata: TelemetryMetadata;
telemetry: TelemetryPoint[];
}
/** A real tracked flight: the actual path flown, distinct from a Prediction. */
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 };
}

55
src/lib/domain/units.ts Normal file
View file

@ -0,0 +1,55 @@
/**
* Display conversions. Storage is always SI (m, m/s) and coordinates are always
* decimal degrees these helpers are presentation-only and never round-trip
* back into stored values (guidelines §10).
*/
export type UnitSystem = 'metric' | 'imperial';
export type CoordFormat = 'dd' | 'dms';
export type TimeDisplay = 'utc' | 'local';
const M_TO_FT = 3.280839895;
const MS_TO_FTMIN = 196.8503937;
/** Format a stored altitude (metres) for display. */
export function formatAltitude(metres: number, system: UnitSystem): string {
if (system === 'imperial') return `${Math.round(metres * M_TO_FT)} ft`;
return `${Math.round(metres)} m`;
}
/** Format a stored vertical rate (m/s) for display. */
export function formatRate(ms: number, system: UnitSystem): string {
if (system === 'imperial') return `${Math.round(ms * MS_TO_FTMIN)} ft/min`;
return `${ms.toFixed(1)} m/s`;
}
/** Degrees → degrees/minutes/seconds with a hemisphere suffix. */
export function toDms(value: number, axis: 'lat' | 'lon'): string {
const hemi = axis === 'lat' ? (value >= 0 ? 'N' : 'S') : value >= 0 ? 'E' : 'W';
const abs = Math.abs(value);
const deg = Math.floor(abs);
const minFloat = (abs - deg) * 60;
const min = Math.floor(minFloat);
const sec = (minFloat - min) * 60;
return `${deg}°${String(min).padStart(2, '0')}'${sec.toFixed(2).padStart(5, '0')}"${hemi}`;
}
/** Format a stored decimal-degree coordinate pair for display. */
export function formatCoords(lat: number, lon: number, format: CoordFormat): string {
if (format === 'dms') return `${toDms(lat, 'lat')} ${toDms(lon, 'lon')}`;
return `${lat.toFixed(4)}, ${lon.toFixed(4)}`;
}
/**
* Format an instant for display. UTC is canonical and always labelled; local
* time is an opt-in view.
*/
export function formatTime(date: Date, display: TimeDisplay): string {
if (display === 'local') {
const hh = String(date.getHours()).padStart(2, '0');
const mm = String(date.getMinutes()).padStart(2, '0');
const ss = String(date.getSeconds()).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
return `${date.toISOString().slice(11, 19)} UTC`;
}

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

@ -0,0 +1,192 @@
/**
* Wind field types matching the wind-js-server / leaflet-velocity format
* produced by the predictor's wind/field endpoint (proxied by the backend).
*
* 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. Derivation: bearing = atan2(U, V).
*/
bearing: number;
}
export interface WindSettings {
/** Master toggle — off by default. */
enabled: boolean;
/** Grid resolution for display (degrees). */
step: 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;
/** Altitude (m) the field is sampled at. */
altitude: number;
}
export const DEFAULT_WIND_SETTINGS: WindSettings = {
enabled: false,
step: 1.0,
particleDensity: 1.0,
particleSpeed: 1.0,
trailPersistence: 0.92,
maxVelocity: 30,
altitude: 10000
};
/** 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 vector 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,163 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { profileApi, type UserProfile } from '$api';
import { authStore } from '$auth';
import { addToast } from '$ui';
import { t } from '$i18n';
let profile = $state<UserProfile | null>(null);
let loadError = $state('');
let email = $state('');
let oldPassword = $state('');
let newPassword = $state('');
let deletePassword = $state('');
let confirmingDeleteData = $state(false);
let confirmingDeleteAccount = $state(false);
onMount(async () => {
try {
profile = await profileApi.get();
email = profile.email;
} catch (err) {
loadError = (err as Error).message;
}
});
async function saveEmail() {
try {
profile = await profileApi.update({ email });
addToast('success', $t('account.emailSaved'));
} catch (err) {
addToast('error', (err as Error).message || $t('account.failed'));
}
}
async function changePassword() {
try {
await profileApi.changePassword(oldPassword, newPassword);
oldPassword = '';
newPassword = '';
addToast('success', $t('account.passwordChanged'));
} catch (err) {
addToast('error', (err as Error).message || $t('account.failed'));
}
}
async function deleteData() {
try {
await profileApi.deleteData();
confirmingDeleteData = false;
addToast('success', $t('account.deleteDataDone'));
} catch (err) {
addToast('error', (err as Error).message || $t('account.failed'));
}
}
async function deleteAccount() {
try {
await profileApi.deleteAccount(deletePassword);
await authStore.logout();
goto('/login');
} catch (err) {
addToast('error', (err as Error).message || $t('account.failed'));
}
}
</script>
{#if loadError}
<div class="alert alert-danger" role="alert">{loadError}</div>
{:else if !profile}
<div class="spinner-border" role="status"><span class="visually-hidden"></span></div>
{:else}
<div class="card mb-4">
<div class="card-body">
<div class="mb-3">
<span class="text-muted small">{$t('account.username')}:</span>
<strong>{profile.username}</strong>
</div>
<label class="form-label" for="ac-email">{$t('account.email')}</label>
<div class="input-group" style="max-width: 28rem">
<input id="ac-email" type="email" class="form-control" bind:value={email} />
<button class="btn btn-primary" onclick={saveEmail} disabled={email === profile.email}>
{$t('account.saveEmail')}
</button>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-body" style="max-width: 28rem">
<h6 class="card-title">{$t('account.changePassword')}</h6>
<input
type="password"
class="form-control mb-2"
placeholder={$t('account.oldPassword')}
bind:value={oldPassword} />
<input
type="password"
class="form-control mb-3"
placeholder={$t('account.newPassword')}
bind:value={newPassword} />
<button
class="btn btn-primary"
onclick={changePassword}
disabled={!oldPassword || !newPassword}>
{$t('account.changePasswordSubmit')}
</button>
</div>
</div>
<div class="card border-danger">
<div class="card-body">
<h6 class="card-title text-danger">{$t('account.dangerZone')}</h6>
<div class="mb-3">
{#if confirmingDeleteData}
<button
class="btn btn-sm btn-outline-secondary me-2"
onclick={() => (confirmingDeleteData = false)}>
{$t('account.cancel')}
</button>
<button
class="btn btn-sm btn-danger"
data-testid="confirm-delete-data"
onclick={deleteData}>
{$t('account.confirm')}
</button>
{:else}
<button
class="btn btn-sm btn-outline-danger"
onclick={() => (confirmingDeleteData = true)}>
{$t('account.deleteData')}
</button>
{/if}
</div>
<hr />
{#if confirmingDeleteAccount}
<label class="form-label small" for="ac-delpw">{$t('account.deleteAccountConfirm')}</label>
<div class="input-group mb-2" style="max-width: 28rem">
<input id="ac-delpw" type="password" class="form-control" bind:value={deletePassword} />
<button
class="btn btn-outline-secondary"
onclick={() => (confirmingDeleteAccount = false)}>
{$t('account.cancel')}
</button>
<button
class="btn btn-danger"
data-testid="confirm-delete-account"
onclick={deleteAccount}
disabled={!deletePassword}>
{$t('account.confirm')}
</button>
</div>
{:else}
<button class="btn btn-sm btn-danger" onclick={() => (confirmingDeleteAccount = true)}>
{$t('account.deleteAccount')}
</button>
{/if}
</div>
</div>
{/if}

View file

@ -0,0 +1,266 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { predictionsApi, type StoredPrediction, type StoredPredictionDetail } from '$api';
import { toHistoryRow, formatCoords, type HistoryRow } from '$domain';
import { settingsStore } from '$features/settings';
import { scenariosStore } from '$features/scenarios';
import { addToast } from '$ui';
import { t } from '$i18n';
const PAGE_SIZE = 10;
let rows = $state<HistoryRow[]>([]);
let total = $state(0);
let skip = $state(0);
let status = $state<'loading' | 'empty' | 'error' | 'loaded'>('loading');
let error = $state('');
let detail = $state<StoredPredictionDetail | null>(null);
let detailLoading = $state(false);
/** Removed rows are held here until the undo window closes. */
let pendingDelete = $state<{ row: HistoryRow; timer: ReturnType<typeof setTimeout> } | null>(null);
async function load(nextSkip = skip) {
status = 'loading';
error = '';
try {
const page = await predictionsApi.page(PAGE_SIZE, nextSkip);
total = page.total;
skip = page.skip;
rows = page.predictions.map((p: StoredPrediction) => toHistoryRow(p));
status = rows.length === 0 ? 'empty' : 'loaded';
} catch (err) {
error = (err as Error).message;
status = 'error';
}
}
onMount(load);
async function openDetail(id: string) {
detailLoading = true;
try {
detail = await predictionsApi.detail(id);
} catch (err) {
addToast('error', (err as Error).message);
} finally {
detailLoading = false;
}
}
/**
* Delete optimistically with an undo window (guidelines §9: prefer undo over a
* confirmation dialog). The server call only fires once the window closes.
*/
function remove(row: HistoryRow) {
rows = rows.filter((r) => r.id !== row.id);
total -= 1;
if (detail?.id === row.id) detail = null;
const timer = setTimeout(async () => {
try {
await predictionsApi.remove(row.id);
} catch (err) {
addToast('error', (err as Error).message);
await load();
} finally {
pendingDelete = null;
}
}, 5000);
pendingDelete = { row, timer };
}
function undo() {
if (!pendingDelete) return;
clearTimeout(pendingDelete.timer);
pendingDelete = null;
load();
}
/** Recreate a past run as a live scenario, using its stored request. */
async function loadIntoScenario(id: string) {
try {
const full = detail?.id === id ? detail : await predictionsApi.detail(id);
if (!full?.request) {
addToast('error', $t('history.noRequest'));
return;
}
const { launch_datetime, ...params } = full.request;
const launch = launch_datetime ? new Date(launch_datetime) : new Date();
scenariosStore.add({
name: `${$t('history.fromHistory')} ${full.id.slice(0, 8)}`,
flightParameters: params,
launchDate: launch.toISOString().slice(0, 10),
launchTime: launch.toISOString().slice(11, 19)
});
addToast('success', $t('history.loadedIntoScenario'));
goto('/app');
} catch (err) {
addToast('error', (err as Error).message);
}
}
/** Mint a share link for a stored run and put it on the clipboard. */
async function share(id: string) {
try {
const { share_token } = await predictionsApi.share(id);
if (!share_token) return;
const url = `${window.location.origin}/p/${share_token}/`;
try {
await navigator.clipboard.writeText(url);
addToast('success', $t('flights.linkCopied'));
} catch {
// Clipboard can be blocked; the link is still useful on screen.
addToast('info', url);
}
} catch (err) {
addToast('error', (err as Error).message);
}
}
const fmtTime = (sec: number) => {
const m = Math.round(sec / 60);
return `${Math.floor(m / 60)}${$t('scenarios.hour')} ${m % 60}${$t('scenarios.min')}`;
};
const fmtDate = (d: Date) => `${d.toISOString().slice(0, 10)} ${d.toISOString().slice(11, 16)} UTC`;
</script>
{#if pendingDelete}
<div class="alert alert-secondary d-flex align-items-center py-2" data-testid="undo-bar">
<span class="me-auto small">{$t('history.deleted')}</span>
<button class="btn btn-sm btn-outline-secondary" data-testid="undo-delete" onclick={undo}>
{$t('history.undo')}
</button>
</div>
{/if}
{#if status === 'loading'}
<div class="spinner-border" role="status"><span class="visually-hidden"></span></div>
{:else if status === 'error'}
<div class="alert alert-danger" role="alert">
<div>{error}</div>
<button class="btn btn-sm btn-link p-0" data-testid="history-retry" onclick={() => load()}>
{$t('wind.retry')}
</button>
</div>
{:else if status === 'empty'}
<p class="text-muted" data-testid="history-empty">{$t('history.empty')}</p>
{:else}
<div class="table-responsive">
<table class="table table-sm align-middle" data-testid="history-table">
<thead>
<tr class="small text-muted">
<th>{$t('history.date')}</th>
<th>{$t('scenarios.landing')}</th>
<th>{$t('scenarios.flightTime')}</th>
<th></th>
</tr>
</thead>
<tbody>
{#each rows as row (row.id)}
<tr>
<td class="small font-monospace">{fmtDate(row.createdAt)}</td>
<td class="small font-monospace">
{#if row.landing}
{formatCoords(row.landing.lat, row.landing.lng, $settingsStore.format.coords)}
{:else}
<span class="badge text-bg-warning">{$t('history.broken')}</span>
{/if}
</td>
<td class="small">
{#if row.kind === 'ensemble'}
<span class="badge text-bg-info">{$t('ensemble.run')} · {row.members}</span>
{:else if row.broken}
{:else}
{fmtTime(row.flightTime)}
{/if}
</td>
<td class="text-end text-nowrap">
<button
class="btn btn-sm btn-link p-0 me-2"
data-testid="history-detail"
onclick={() => openDetail(row.id)}>
{$t('history.details')}
</button>
<button
class="btn btn-sm btn-link p-0 me-2"
data-testid="history-load"
onclick={() => loadIntoScenario(row.id)}>
{$t('history.loadIntoScenario')}
</button>
<button
class="btn btn-sm btn-link p-0 me-2"
data-testid="history-share"
onclick={() => share(row.id)}>
{$t('history.share')}
</button>
<button
class="btn btn-sm btn-link p-0 text-danger"
data-testid="history-delete"
aria-label={$t('scenarios.delete')}
onclick={() => remove(row)}>
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div class="d-flex align-items-center gap-2">
<button
class="btn btn-sm btn-outline-secondary"
data-testid="history-prev"
disabled={skip === 0}
onclick={() => load(Math.max(0, skip - PAGE_SIZE))}>
{$t('history.prev')}
</button>
<span class="small text-muted" data-testid="history-range">
{skip + 1}{Math.min(skip + PAGE_SIZE, total)} / {total}
</span>
<button
class="btn btn-sm btn-outline-secondary"
data-testid="history-next"
disabled={skip + PAGE_SIZE >= total}
onclick={() => load(skip + PAGE_SIZE)}>
{$t('history.next')}
</button>
</div>
{/if}
{#if detailLoading}
<div class="mt-3 spinner-border spinner-border-sm" role="status"></div>
{:else if detail}
<div class="card mt-3" data-testid="history-detail-card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<h6 class="mb-2">{$t('history.details')}</h6>
<button
class="btn-close"
aria-label={$t('account.cancel')}
onclick={() => (detail = null)}></button>
</div>
<dl class="row mb-0 small">
<dt class="col-4 fw-normal text-muted">ID</dt>
<dd class="col-8 font-monospace">{detail.id}</dd>
<dt class="col-4 fw-normal text-muted">{$t('history.status')}</dt>
<dd class="col-8">{detail.status}</dd>
{#if detail.request}
<dt class="col-4 fw-normal text-muted">{$t('predict.ascentRate')}</dt>
<dd class="col-8">{detail.request.ascent_rate}</dd>
<dt class="col-4 fw-normal text-muted">{$t('predict.burstAltitude')}</dt>
<dd class="col-8">{detail.request.burst_altitude}</dd>
<dt class="col-4 fw-normal text-muted">{$t('predict.descentRate')}</dt>
<dd class="col-8">{detail.request.descent_rate}</dd>
{/if}
</dl>
{#if detail.error}
<div class="alert alert-danger py-1 px-2 small mt-2 mb-0">{detail.error}</div>
{/if}
</div>
</div>
{/if}

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { page } from '$app/state';
import { t } from '$i18n';
const LINKS = [
{ href: '/user/account/', labelKey: 'account.heading' },
{ href: '/user/predictions/', labelKey: 'history.title' }
];
</script>
<nav class="nav nav-pills flex-column mb-3" aria-label={$t('account.heading')}>
{#each LINKS as link (link.href)}
<a
class="nav-link {page.url.pathname === link.href ? 'active' : ''}"
aria-current={page.url.pathname === link.href ? 'page' : undefined}
href={link.href}>
{$t(link.labelKey)}
</a>
{/each}
</nav>

View file

@ -0,0 +1,3 @@
export { default as AccountPanel } from './AccountPanel.svelte';
export { default as UserNav } from './UserNav.svelte';
export { default as HistoryPanel } from './HistoryPanel.svelte';

View file

@ -0,0 +1,171 @@
<script lang="ts">
import { onMount } from 'svelte';
import { adminApi, type AdminUser } from '$api';
import { authStore } from '$auth';
import { addToast } from '$ui';
import { t } from '$i18n';
const PAGE_SIZE = 20;
let users = $state<AdminUser[]>([]);
let total = $state(0);
let skip = $state(0);
let query = $state('');
let status = $state<'loading' | 'empty' | 'error' | 'loaded'>('loading');
let error = $state('');
/** Row awaiting an inline confirm before being deactivated. */
let confirming = $state<number | null>(null);
let debounce: ReturnType<typeof setTimeout> | null = null;
async function load(nextSkip = skip) {
status = 'loading';
error = '';
try {
const page = await adminApi.users(query.trim(), PAGE_SIZE, nextSkip);
total = page.total;
skip = page.skip;
users = page.users;
status = users.length === 0 ? 'empty' : 'loaded';
} catch (err) {
error = (err as Error).message;
status = 'error';
}
}
onMount(load);
function search() {
if (debounce) clearTimeout(debounce);
debounce = setTimeout(() => load(0), 250);
}
async function setActive(user: AdminUser, active: boolean) {
confirming = null;
try {
const updated = await adminApi.setActive(user.id, active);
users = users.map((u) => (u.id === updated.id ? updated : u));
addToast('success', active ? $t('admin.activated') : $t('admin.deactivated'));
} catch (err) {
addToast('error', (err as Error).message);
}
}
const fmtDate = (iso: string | null) => (iso ? iso.slice(0, 10) : '—');
</script>
<div class="card shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0">{$t('admin.users')}</h6>
<input
type="search"
class="form-control form-control-sm"
style="max-width: 16rem"
data-testid="admin-search"
placeholder={$t('admin.searchPlaceholder')}
aria-label={$t('admin.searchPlaceholder')}
bind:value={query}
oninput={search} />
</div>
<!-- Four async states: loading / empty / error+retry / loaded -->
{#if status === 'loading'}
<div class="spinner-border" role="status"><span class="visually-hidden"></span></div>
{:else if status === 'error'}
<div class="alert alert-danger" role="alert" data-testid="admin-error">
<div>{error}</div>
<button class="btn btn-sm btn-link p-0" onclick={() => load()}>{$t('wind.retry')}</button>
</div>
{:else if status === 'empty'}
<p class="text-muted mb-0" data-testid="admin-empty">{$t('admin.noUsers')}</p>
{:else}
<div class="table-responsive">
<table class="table table-sm align-middle" data-testid="admin-table">
<thead>
<tr class="small text-muted">
<th>{$t('admin.username')}</th>
<th>{$t('account.email')}</th>
<th>{$t('admin.joined')}</th>
<th>{$t('admin.state')}</th>
<th></th>
</tr>
</thead>
<tbody>
{#each users as u (u.id)}
<tr data-testid="admin-row">
<td class="small">
{u.username}
{#if u.is_staff}
<span class="badge text-bg-secondary ms-1">{$t('admin.staff')}</span>
{/if}
{#if u.username === $authStore.username}
<span class="badge text-bg-info ms-1">{$t('admin.you')}</span>
{/if}
</td>
<td class="small">{u.email || '—'}</td>
<td class="small font-monospace">{fmtDate(u.date_joined)}</td>
<td>
<span
class="badge {u.is_active ? 'text-bg-success' : 'text-bg-secondary'}"
data-testid="admin-state">
{u.is_active ? $t('admin.active') : $t('admin.inactive')}
</span>
</td>
<td class="text-end text-nowrap">
{#if u.username === $authStore.username}
<!-- Deactivating yourself would lock you out; the API refuses it too. -->
<span class="small text-muted"></span>
{:else if !u.is_active}
<button
class="btn btn-sm btn-outline-success"
data-testid="admin-activate"
onclick={() => setActive(u, true)}>
{$t('admin.activate')}
</button>
{:else if confirming === u.id}
<button
class="btn btn-sm btn-outline-secondary me-1"
onclick={() => (confirming = null)}>
{$t('account.cancel')}
</button>
<button
class="btn btn-sm btn-danger"
data-testid="admin-confirm-deactivate"
onclick={() => setActive(u, false)}>
{$t('account.confirm')}
</button>
{:else}
<button
class="btn btn-sm btn-outline-danger"
data-testid="admin-deactivate"
onclick={() => (confirming = u.id)}>
{$t('admin.deactivate')}
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div class="d-flex align-items-center gap-2">
<button
class="btn btn-sm btn-outline-secondary"
disabled={skip === 0}
onclick={() => load(Math.max(0, skip - PAGE_SIZE))}>
{$t('history.prev')}
</button>
<span class="small text-muted" data-testid="admin-range">
{skip + 1}{Math.min(skip + PAGE_SIZE, total)} / {total}
</span>
<button
class="btn btn-sm btn-outline-secondary"
disabled={skip + PAGE_SIZE >= total}
onclick={() => load(skip + PAGE_SIZE)}>
{$t('history.next')}
</button>
</div>
{/if}
</div>
</div>

View file

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

View file

@ -0,0 +1,16 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { t } from '$i18n';
let { children }: { children: Snippet } = $props();
</script>
<main class="auth-shell">
<div class="text-center mb-4">
<img src="/logo-lg.svg" alt={$t('app.title')} class="auth-logo rounded-3" />
<p class="text-muted mt-3 mb-0">{$t('app.subtitle')}</p>
</div>
<div class="auth-card">
{@render children()}
</div>
</main>

View file

@ -0,0 +1,73 @@
<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 handleSubmit(e: Event) {
e.preventDefault();
error = '';
if (!username || !password) {
error = $t('login.fieldsRequired');
return;
}
isLoading = true;
try {
await authStore.login(username, password);
goto('/app');
} catch (err) {
error = (err as Error).message || $t('login.invalidCredentials');
} finally {
isLoading = false;
}
}
</script>
<div class="card shadow-sm">
<div class="card-body p-4">
<h5 class="card-title mb-4">{$t('login.heading')}</h5>
{#if error}
<div class="alert alert-danger py-2" role="alert" data-testid="login-error">{error}</div>
{/if}
<form onsubmit={handleSubmit} novalidate>
<div class="form-floating mb-3">
<input
type="text"
class="form-control"
id="lg-username"
placeholder={$t('login.username')}
autocomplete="username"
bind:value={username} />
<label for="lg-username">{$t('login.username')}</label>
</div>
<div class="form-floating mb-3">
<input
type="password"
class="form-control"
id="lg-password"
placeholder={$t('login.password')}
autocomplete="current-password"
bind:value={password} />
<label for="lg-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 me-2" role="status"></span>
{$t('login.submitting')}
{:else}
{$t('login.submit')}
{/if}
</button>
</form>
<p class="text-center text-muted small mt-4 mb-0">
{$t('login.noAccount')} <a href="/register">{$t('login.registerLink')}</a>
</p>
</div>
</div>

View file

@ -0,0 +1,173 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { authStore } from '$auth';
import { t } from '$i18n';
/**
* The header carries exactly one decision — which mode the user is in — and
* hides everything else behind the burger. Account, history and admin are
* places you visit and leave, not a permanent link list competing with the
* map for the top of the screen (guidelines §1).
*/
const MODES = [
{
id: 'predict',
href: '/app',
labelKey: 'nav.predict',
icon: 'bi-graph-up-arrow',
owns: (path: string) => path.startsWith('/app')
},
{
id: 'track',
href: '/flights/',
labelKey: 'nav.track',
icon: 'bi-broadcast',
owns: (path: string) => path.startsWith('/flights') || path.startsWith('/track')
}
] as const;
let menuOpen = $state(false);
let menuRoot = $state<HTMLElement | null>(null);
/**
* Closing on an outside click is a containment test, not a stopPropagation
* race: Svelte delegates element handlers, so a `stopPropagation` on the
* toggle does not reliably beat a window-level listener to the punch.
*/
function handleWindowClick(e: MouseEvent) {
if (menuOpen && menuRoot && !menuRoot.contains(e.target as Node)) menuOpen = false;
}
const close = () => (menuOpen = false);
async function handleLogout() {
close();
await authStore.logout();
goto('/login');
}
</script>
<!-- No Bootstrap JS in this build, so the burger is opened and closed here. -->
<svelte:window
onclick={handleWindowClick}
onkeydown={(e) => {
if (e.key === 'Escape') menuOpen = false;
}} />
<nav class="navbar bg-body-tertiary border-bottom fixed-top" style="height: var(--navbar-height)">
<div class="container-fluid gap-2">
<a class="navbar-brand d-flex align-items-center p-0 m-0" href="/" aria-label={$t('app.title')}>
<img class="nav-logo" src="/logo.svg" alt={$t('app.title')} />
</a>
<ul class="nav nav-pills flex-nowrap gap-1" data-testid="nav-modes">
{#each MODES as mode (mode.id)}
{@const active = mode.owns(page.url.pathname)}
<li class="nav-item">
<a
class="nav-link py-1 px-2 small d-flex align-items-center gap-1 {active ? 'active' : ''}"
data-testid={`mode-${mode.id}`}
href={mode.href}
aria-current={active ? 'page' : undefined}
title={$t(mode.labelKey)}>
<i class="bi {mode.icon}"></i>
<!-- The label folds away before the bar can crowd the logo. -->
<span class="d-none d-md-inline">{$t(mode.labelKey)}</span>
</a>
</li>
{/each}
</ul>
<div class="dropdown ms-auto" bind:this={menuRoot}>
<button
type="button"
class="btn btn-sm btn-outline-secondary nav-burger"
data-testid="nav-menu"
aria-controls="nav-menu-list"
aria-expanded={menuOpen}
aria-label={$t('nav.menu')}
title={$t('nav.menu')}
onclick={() => (menuOpen = !menuOpen)}>
<i class="bi bi-list fs-5"></i>
</button>
<!-- Every item dismisses the menu on its way out. -->
<ul id="nav-menu-list" class="dropdown-menu dropdown-menu-end" class:show={menuOpen}>
{#if $authStore.status === 'authenticated'}
<li class="dropdown-header fw-semibold" data-testid="app-heading">
{$authStore.username}
</li>
<li>
<a class="dropdown-item" href="/user/account/" onclick={close}>
{$t('nav.account')}
</a>
</li>
<li>
<a class="dropdown-item" href="/user/predictions/" onclick={close}>
{$t('history.title')}
</a>
</li>
{#if $authStore.isStaff}
<li>
<a class="dropdown-item" data-testid="nav-admin" href="/admin/" onclick={close}>
{$t('admin.title')}
</a>
</li>
{/if}
<li><hr class="dropdown-divider" /></li>
<li>
<button type="button" class="dropdown-item" onclick={handleLogout}>
{$t('nav.logout')}
</button>
</li>
{:else}
<li><a class="dropdown-item" href="/login" onclick={close}>{$t('nav.login')}</a></li>
<li>
<a class="dropdown-item" href="/register" onclick={close}>
{$t('login.registerLink')}
</a>
</li>
{/if}
</ul>
</div>
</div>
</nav>
<style>
.nav-logo {
height: 34px;
width: auto;
}
.nav-burger {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
padding: 0;
line-height: 1;
}
/*
* Bootstrap 5.3 only places a dropdown once Popper has stamped
* `data-bs-popper` on it, and this build ships no Bootstrap JS — so the
* placement is stated here. Capped too: the menu hangs over the map.
*/
.dropdown-menu {
top: 100%;
right: 0;
left: auto;
margin-top: 0.25rem;
max-height: calc(100vh - var(--navbar-height) - 0.5rem);
max-width: calc(100vw - 1.5rem);
overflow-y: auto;
}
@media (max-width: 575.98px) {
.nav-logo {
height: 26px;
}
}
</style>

View file

@ -0,0 +1,107 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore } from '$auth';
import { t } from '$i18n';
let username = $state('');
let email = $state('');
let password = $state('');
let passwordConfirm = $state('');
let error = $state('');
let isLoading = $state(false);
const emailOk = (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
function validate(): string {
if (!username || !email || !password || !passwordConfirm) return $t('register.fieldsRequired');
if (username.trim().length < 3) return $t('register.usernameShort');
if (!emailOk(email)) return $t('register.emailInvalid');
if (password.length < 8) return $t('register.passwordShort');
if (password !== passwordConfirm) return $t('register.passwordMismatch');
return '';
}
async function handleSubmit(e: Event) {
e.preventDefault();
error = '';
const msg = validate();
if (msg) {
error = msg;
return;
}
isLoading = true;
try {
await authStore.register(username, email, password);
goto('/app');
} catch (err) {
error = (err as Error).message || $t('register.failed');
} finally {
isLoading = false;
}
}
</script>
<div class="card shadow-sm">
<div class="card-body p-4">
<h5 class="card-title mb-4">{$t('register.heading')}</h5>
{#if error}
<div class="alert alert-danger py-2" role="alert" data-testid="register-error">{error}</div>
{/if}
<form onsubmit={handleSubmit} novalidate>
<div class="form-floating mb-3">
<input
type="text"
class="form-control"
id="rg-username"
placeholder={$t('register.username')}
autocomplete="username"
bind:value={username} />
<label for="rg-username">{$t('register.username')}</label>
</div>
<div class="form-floating mb-3">
<input
type="email"
class="form-control"
id="rg-email"
placeholder={$t('register.email')}
autocomplete="email"
bind:value={email} />
<label for="rg-email">{$t('register.email')}</label>
</div>
<div class="form-floating mb-3">
<input
type="password"
class="form-control"
id="rg-password"
placeholder={$t('register.password')}
autocomplete="new-password"
bind:value={password} />
<label for="rg-password">{$t('register.password')}</label>
</div>
<div class="form-floating mb-3">
<input
type="password"
class="form-control"
id="rg-password2"
placeholder={$t('register.passwordConfirm')}
autocomplete="new-password"
bind:value={passwordConfirm} />
<label for="rg-password2">{$t('register.passwordConfirm')}</label>
</div>
<button type="submit" class="btn btn-primary w-100" disabled={isLoading}>
{#if isLoading}
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
{$t('register.submitting')}
{:else}
{$t('register.submit')}
{/if}
</button>
</form>
<p class="text-center text-muted small mt-4 mb-0">
{$t('register.haveAccount')} <a href="/login">{$t('register.loginLink')}</a>
</p>
</div>
</div>

View file

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

View file

@ -0,0 +1,499 @@
<script lang="ts">
import { onMount } from 'svelte';
import { scenariosStore, getActiveScenario } from '$features/scenarios';
import {
PROFILE_IDENTIFIERS,
pointDirty,
templateDirty,
type FlightParameters,
type ProfileIdentifier
} from '$domain';
import { addToast, SpoilerGroup } from '$ui';
import { t } from '$i18n';
import { pointsStore } from './pointsStore';
import { templatesStore } from './templatesStore';
import { armPick } from './pickStore';
import CurveEditor from './CurveEditor.svelte';
import ProfileBuilder from './ProfileBuilder.svelte';
import { standardStages, type ProfileStage, type RateCurvePoint } from '$domain';
/** The builder is desktop-only (guidelines §6.4); narrow screens get a note. */
let wideEnough = $state(true);
onMount(() => {
const mq = window.matchMedia('(min-width: 1024px)');
wideEnough = mq.matches;
const onChange = (e: MediaQueryListEvent) => (wideEnough = e.matches);
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
});
function setStages(stages: ProfileStage[]) {
if (!active) return;
scenariosStore.patch(active.id, { stages });
}
let curveOpen = $state(false);
function applyCurve(pts: RateCurvePoint[]) {
if (!active) return;
scenariosStore.patch(active.id, { curve: pts });
}
const active = $derived(getActiveScenario($scenariosStore));
const params = $derived(active?.flightParameters ?? null);
// --- provenance (guidelines §3): Custom / Linked-clean / Linked-dirty ------
const currentPoint = $derived(
params?.start_point ? ($pointsStore.find((p) => p.id === params.start_point) ?? null) : null
);
const isPointDirty = $derived(!!params && !!currentPoint && pointDirty(params, currentPoint));
const currentTemplate = $derived(
params?.template ? ($templatesStore.find((x) => x.id === params.template) ?? null) : null
);
const isTemplateDirty = $derived(
!!params && !!currentTemplate && templateDirty(params, currentTemplate.flight_parameters)
);
let newPointName = $state('');
let savingPoint = $state(false);
let newTemplateName = $state('');
let savingTemplate = $state(false);
onMount(async () => {
try {
await Promise.all([pointsStore.load(), templatesStore.load()]);
} catch (err) {
addToast('error', (err as Error).message);
}
});
function patch(p: Partial<FlightParameters>) {
if (!active || !params) return;
scenariosStore.setFlightParameters(active.id, { ...params, ...p });
}
function setLaunch(field: 'launchDate' | 'launchTime', value: string) {
if (!active) return;
scenariosStore.patch(active.id, { [field]: value });
}
const num = (e: Event) => Number((e.currentTarget as HTMLInputElement).value);
const str = (e: Event) => (e.currentTarget as HTMLInputElement | HTMLSelectElement).value;
// --- saved points ---------------------------------------------------------
function selectPoint(idRaw: string) {
const id = Number(idRaw);
if (!id) {
patch({ start_point: undefined }); // → Custom
return;
}
const p = $pointsStore.find((x) => x.id === id);
if (!p) return;
// load(source): copy values, never hold a live reference.
patch({
start_point: p.id,
launch_latitude: p.lat,
launch_longitude: p.lon,
launch_altitude: p.alt
});
}
async function updatePoint() {
if (!currentPoint || !params) return;
try {
const saved = await pointsStore.save({
...currentPoint,
lat: params.launch_latitude,
lon: params.launch_longitude,
alt: params.launch_altitude
});
addToast('success', `${$t('predict.pointUpdated')}: ${saved.name}`);
} catch (err) {
addToast('error', (err as Error).message);
}
}
async function saveNewPoint() {
if (!params || !newPointName.trim()) return;
try {
const saved = await pointsStore.add({
name: newPointName.trim(),
lat: params.launch_latitude,
lon: params.launch_longitude,
alt: params.launch_altitude
});
patch({ start_point: saved.id }); // → Linked-clean on the new item
newPointName = '';
savingPoint = false;
addToast('success', `${$t('predict.pointSaved')}: ${saved.name}`);
} catch (err) {
addToast('error', (err as Error).message);
}
}
// --- templates ------------------------------------------------------------
function applyTemplate(idRaw: string) {
const id = Number(idRaw);
if (!id) {
patch({ template: undefined });
return;
}
const tpl = $templatesStore.find((x) => x.id === id);
if (!tpl || !active) return;
scenariosStore.setFlightParameters(active.id, {
...tpl.flight_parameters,
template: tpl.id,
start_point: undefined
});
}
async function updateTemplate() {
if (!currentTemplate || !params) return;
try {
const saved = await templatesStore.save({ ...currentTemplate, flight_parameters: params });
addToast('success', `${$t('predict.templateUpdated')}: ${saved.name}`);
} catch (err) {
addToast('error', (err as Error).message);
}
}
async function saveNewTemplate() {
if (!params || !newTemplateName.trim()) return;
try {
const saved = await templatesStore.add({
name: newTemplateName.trim(),
description: '',
prediction_mode: 'single',
model: '',
dataset: params.dataset,
flight_parameters: params
});
patch({ template: saved.id });
newTemplateName = '';
savingTemplate = false;
addToast('success', `${$t('predict.templateSaved')}: ${saved.name}`);
} catch (err) {
addToast('error', (err as Error).message);
}
}
async function run() {
if (!active) return;
try {
await scenariosStore.run(active.id);
} catch {
/* error surfaced on the scenario card */
}
}
async function runEnsemble() {
if (!active) return;
try {
await scenariosStore.runEnsemble(active.id);
} catch {
/* error surfaced on the scenario card */
}
}
</script>
{#if !active || !params}
<div class="card shadow-sm">
<div class="card-body p-3 text-muted small">{$t('predict.noActive')}</div>
</div>
{:else}
<div class="card shadow-sm">
<div class="card-body p-3">
<h6 class="mb-3">{$t('predict.conditions')}</h6>
<!-- Template ------------------------------------------------------- -->
<div class="mb-1 d-flex align-items-center justify-content-between">
<label class="form-label small mb-0" for="cp-template">{$t('predict.template')}</label>
{#if currentTemplate}
<span
class="badge {isTemplateDirty ? 'text-bg-warning' : 'text-bg-secondary'}"
data-testid="template-chip">
{currentTemplate.name}{isTemplateDirty ? ` · ${$t('predict.modified')}` : ''}
</span>
{/if}
</div>
<select
id="cp-template"
class="form-select form-select-sm mb-2"
value={params.template ?? ''}
onchange={(e) => applyTemplate(str(e))}>
<option value="">{$t('predict.templateNone')}</option>
{#each $templatesStore as tpl (tpl.id)}
<option value={tpl.id}>{tpl.name}</option>
{/each}
</select>
<div class="d-flex gap-2 mb-2">
<button
class="btn btn-sm btn-outline-secondary flex-fill"
disabled={!currentTemplate || !isTemplateDirty}
onclick={updateTemplate}>
{$t('predict.update')}
</button>
<button
class="btn btn-sm btn-outline-secondary flex-fill"
data-testid="tpl-save-as"
onclick={() => (savingTemplate = !savingTemplate)}>
{$t('predict.saveAsNew')}
</button>
</div>
{#if savingTemplate}
<div class="input-group input-group-sm mb-2">
<input
class="form-control"
data-testid="tpl-name"
placeholder={$t('predict.name')}
bind:value={newTemplateName} />
<button class="btn btn-primary" data-testid="tpl-save" onclick={saveNewTemplate}>
{$t('predict.save')}
</button>
</div>
{/if}
<!-- Launch time ----------------------------------------------------- -->
<div class="d-flex gap-2">
<div class="flex-fill w-50 mb-2">
<label class="form-label small mb-1" for="cp-time">{$t('predict.startTime')}</label>
<input
id="cp-time"
type="time"
step="1"
class="form-control form-control-sm"
value={active.launchTime}
oninput={(e) => setLaunch('launchTime', str(e))} />
</div>
<div class="flex-fill w-50 mb-2">
<label class="form-label small mb-1" for="cp-date">{$t('predict.startDate')}</label>
<input
id="cp-date"
type="date"
class="form-control form-control-sm"
value={active.launchDate}
oninput={(e) => setLaunch('launchDate', str(e))} />
</div>
</div>
<!-- Flight profile -------------------------------------------------- -->
<div class="mb-2">
<label class="form-label small mb-1" for="cp-profile">{$t('predict.flightProfile')}</label>
<select
id="cp-profile"
class="form-select form-select-sm"
value={params.profile}
onchange={(e) => patch({ profile: str(e) as ProfileIdentifier })}>
{#each PROFILE_IDENTIFIERS as p (p)}
<option value={p}>{$t(`profile.${p}`)}</option>
{/each}
</select>
</div>
<!-- Launch site ----------------------------------------------------- -->
<SpoilerGroup label={$t('predict.launchSite')} testid="group-launch-site">
<div class="mb-1 d-flex align-items-center justify-content-between">
<label class="form-label small mb-0" for="cp-point">{$t('predict.launchPoint')}</label>
<span
class="badge {isPointDirty ? 'text-bg-warning' : 'text-bg-secondary'}"
data-testid="point-chip">
{#if currentPoint}
{currentPoint.name}{isPointDirty ? ` · ${$t('predict.modified')}` : ''}
{:else}
{$t('predict.custom')}
{/if}
</span>
</div>
<select
id="cp-point"
class="form-select form-select-sm mb-2"
value={params.start_point ?? ''}
onchange={(e) => selectPoint(str(e))}>
<option value="">{$t('predict.pointNone')}</option>
{#each $pointsStore as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
</select>
<label class="form-label small mb-1" for="cp-lat">{$t('predict.latLng')}</label>
<div class="input-group input-group-sm mb-2">
<input
id="cp-lat"
type="number"
step="0.000001"
class="form-control"
aria-label={$t('predict.latitude')}
value={params.launch_latitude}
oninput={(e) => patch({ launch_latitude: num(e) })} />
<span class="input-group-text px-2">/</span>
<input
id="cp-lon"
type="number"
step="0.000001"
class="form-control"
aria-label={$t('predict.longitude')}
value={params.launch_longitude}
oninput={(e) => patch({ launch_longitude: num(e) })} />
<!-- Pick-on-map is a mode, so the armed state is carried on the control
itself as well as by the map banner (guidelines §6.1). -->
<button
class="btn {$armPick ? 'btn-warning' : 'btn-outline-secondary'}"
data-testid="pick-on-map"
aria-pressed={$armPick}
aria-label={$armPick ? $t('predict.pickCancel') : $t('predict.pickOnMap')}
title={$armPick ? $t('predict.pickCancel') : $t('predict.pickOnMap')}
onclick={() => armPick.set(!$armPick)}>
<i class="bi bi-geo-alt-fill"></i>
</button>
</div>
<div class="d-flex gap-2">
<button
class="btn btn-sm btn-primary flex-fill"
data-testid="point-update"
disabled={!currentPoint || !isPointDirty}
onclick={updatePoint}>
{$t('predict.update')}
<i class="bi bi-floppy2-fill ms-1"></i>
</button>
<button
class="btn btn-sm btn-outline-secondary flex-fill"
data-testid="point-save-as"
onclick={() => (savingPoint = !savingPoint)}>
{$t('predict.saveAsNew')}
</button>
</div>
{#if savingPoint}
<div class="input-group input-group-sm mt-2">
<input
class="form-control"
data-testid="point-name"
placeholder={$t('predict.name')}
bind:value={newPointName} />
<button class="btn btn-primary" data-testid="point-save" onclick={saveNewPoint}>
{$t('predict.save')}
</button>
</div>
{/if}
</SpoilerGroup>
<!-- Altitudes ------------------------------------------------------- -->
<div class="d-flex gap-2">
<div class="flex-fill w-50 mb-2">
<label class="form-label small mb-1" for="cp-alt">{$t('predict.launchAlt')}</label>
<input
id="cp-alt"
type="number"
class="form-control form-control-sm"
value={params.launch_altitude}
oninput={(e) => patch({ launch_altitude: num(e) })} />
</div>
<div class="flex-fill w-50 mb-2">
<label class="form-label small mb-1" for="cp-burst">{$t('predict.burstAlt')}</label>
<input
id="cp-burst"
type="number"
step="100"
class="form-control form-control-sm"
value={params.burst_altitude}
oninput={(e) => patch({ burst_altitude: num(e) })} />
</div>
</div>
<!-- Ascent / descent profiles --------------------------------------- -->
<SpoilerGroup label={$t('predict.profileEdit')} testid="group-profiles">
<label class="form-label small mb-1" for="cp-ascent">{$t('predict.ascentStage')}</label>
<div class="input-group input-group-sm mb-2">
<input
id="cp-ascent"
type="number"
step="0.1"
class="form-control"
value={params.ascent_rate}
oninput={(e) => patch({ ascent_rate: num(e) })} />
<span class="input-group-text">{$t('predict.metresPerSecond')}</span>
</div>
<label class="form-label small mb-1" for="cp-descent">{$t('predict.descentStage')}</label>
<div class="input-group input-group-sm mb-2">
<input
id="cp-descent"
type="number"
step="0.1"
class="form-control"
value={params.descent_rate}
oninput={(e) => patch({ descent_rate: num(e) })} />
<span class="input-group-text">{$t('predict.metresPerSecond')}</span>
</div>
<button
class="btn btn-sm btn-outline-secondary w-100"
data-testid="open-curve-editor"
onclick={() => (curveOpen = true)}>
{$t('predict.openCurveEditor')}
<i class="bi bi-graph-up-arrow ms-1"></i>
</button>
</SpoilerGroup>
{#if params.profile === 'custom_profile' && !wideEnough}
<div class="alert alert-secondary py-1 px-2 small" data-testid="builder-desktop-only">
{$t('profileBuilder.desktopOnly')}
</div>
{/if}
<!-- Run ------------------------------------------------------------- -->
<button
class="btn btn-outline-primary w-100 mb-2"
data-testid="run-ensemble-btn"
disabled={active.runStatus === 'running'}
onclick={runEnsemble}>
<i class="bi bi-diagram-3"></i>
{$t('ensemble.run')}
</button>
{#if active.runStatus === 'running' && active.jobId}
<button
class="btn btn-outline-secondary w-100 mb-2"
data-testid="cancel-run-btn"
onclick={() => scenariosStore.cancelRun(active.id)}>
{$t('ensemble.cancel')}
</button>
{/if}
{#if active.ensemble?.footprint}
<div class="alert alert-secondary py-1 px-2 small" data-testid="ensemble-summary">
{$t('ensemble.members')}: {active.ensemble.footprint.count}
· {$t('ensemble.spread')}: {active.ensemble.footprint.radius_km.toFixed(1)} км
{#if active.ensemble.failed.length}
· <span class="text-warning"
>{$t('ensemble.failed')}: {active.ensemble.failed.length}</span>
{/if}
</div>
{/if}
<button
class="btn btn-primary w-100"
data-testid="run-btn"
disabled={active.runStatus === 'running'}
onclick={run}>
{#if active.runStatus === 'running'}
<span class="spinner-border spinner-border-sm me-2"></span>
{$t('predict.running')}
{:else}
{$t('predict.runPrediction')}
{/if}
</button>
</div>
</div>
<CurveEditor bind:open={curveOpen} points={active.curve ?? []} onApply={applyCurve} />
<!-- Structured items are authored inline, not in a modal (guidelines §4.4). -->
{#if params.profile === 'custom_profile' && wideEnough}
<ProfileBuilder
stages={active.stages ?? standardStages(params)}
{params}
onChange={setStages} />
{/if}
{/if}

View file

@ -0,0 +1,66 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Chart as ChartJS, type ChartDataset } from 'chart.js/auto';
import { normalizeCurve, type RateCurvePoint } from '$domain';
import { t } from '$i18n';
interface Props {
points: RateCurvePoint[];
}
let { points }: Props = $props();
let canvas: HTMLCanvasElement;
let chart: ChartJS | null = null;
const series = $derived(
normalizeCurve(points).map((p) => ({ x: p.time_constraint, y: p.alt_constraint }))
);
onMount(() => {
chart = new ChartJS(canvas.getContext('2d')!, {
type: 'line',
data: {
datasets: [
{
label: $t('curve.altitude'),
data: [],
borderColor: '#0d6efd',
backgroundColor: 'rgba(13,110,253,0.12)',
fill: true,
pointRadius: 4,
borderWidth: 2
} as ChartDataset<'line'>
]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: { legend: { display: false } },
scales: {
x: {
type: 'linear',
title: { display: true, text: $t('curve.timeAxis'), font: { size: 10 } },
ticks: { font: { size: 9 } }
},
y: {
title: { display: true, text: $t('curve.altAxis'), font: { size: 10 } },
ticks: { font: { size: 9 } }
}
}
}
});
});
$effect(() => {
if (!chart) return;
chart.data.datasets[0].data = series;
chart.update('none');
});
onDestroy(() => chart?.destroy());
</script>
<div style="position: relative; height: 200px;">
<canvas bind:this={canvas} data-testid="curve-chart"></canvas>
</div>

View file

@ -0,0 +1,248 @@
<script lang="ts">
import {
normalizeCurve,
validateCurve,
impliedRates,
parseCurveCsv,
curveToCsv,
type RateCurvePoint
} from '$domain';
import { addToast } from '$ui';
import { t } from '$i18n';
import CurveChart from './CurveChart.svelte';
/**
* The curve editor is the single permitted *leaf* modal (guidelines §4.4): it
* opens over a non-modal surface, so only one floating layer is ever on screen.
*/
interface Props {
open?: boolean;
points?: RateCurvePoint[];
onClose?: () => void;
onApply?: (points: RateCurvePoint[]) => void;
}
let { open = $bindable(false), points = [], onClose = () => {}, onApply = () => {} }: Props =
$props();
let draft = $state<RateCurvePoint[]>([]);
let csvText = $state('');
let showCsv = $state(false);
// Reload the draft each time the modal opens so an abandoned edit is discarded.
$effect(() => {
if (open) draft = normalizeCurve(points.length ? points : defaultCurve());
});
function defaultCurve(): RateCurvePoint[] {
return [
{ order: 0, time_constraint: 0, alt_constraint: 0, rate: 0 },
{ order: 1, time_constraint: 3600, alt_constraint: 18000, rate: 5 }
];
}
const problems = $derived(validateCurve(draft));
const rates = $derived(impliedRates(draft));
const isValid = $derived(problems.length === 0);
function addPoint() {
const last = draft[draft.length - 1];
draft = normalizeCurve([
...draft,
{
order: draft.length,
time_constraint: (last?.time_constraint ?? 0) + 600,
alt_constraint: (last?.alt_constraint ?? 0) + 3000,
rate: 0
}
]);
}
function removePoint(order: number) {
draft = normalizeCurve(draft.filter((p) => p.order !== order));
}
function setField(order: number, field: keyof RateCurvePoint, value: number) {
draft = draft.map((p) => (p.order === order ? { ...p, [field]: value } : p));
}
function importCsv() {
const parsed = parseCurveCsv(csvText);
if (parsed.length === 0) {
addToast('error', $t('curve.csvEmpty'));
return;
}
draft = parsed;
showCsv = false;
addToast('success', $t('curve.csvImported', { n: parsed.length }));
}
function exportCsv() {
csvText = curveToCsv(draft);
showCsv = true;
}
function apply() {
if (!isValid) return;
onApply(normalizeCurve(draft));
close();
}
function close() {
open = false;
onClose();
}
</script>
<!-- Esc closes the leaf modal; the listener must sit outside the {#if} block. -->
<svelte:window onkeydown={(e) => open && e.key === 'Escape' && close()} />
{#if open}
<!-- Backdrop + dialog: one floating layer. -->
<div class="curve-backdrop" role="presentation" onclick={close}></div>
<div
class="curve-modal card shadow-lg"
role="dialog"
aria-modal="true"
aria-label={$t('curve.title')}
data-testid="curve-modal">
<div class="card-header d-flex justify-content-between align-items-center py-2">
<strong class="small">{$t('curve.title')}</strong>
<button
class="btn-close"
aria-label={$t('account.cancel')}
data-testid="curve-close"
onclick={close}></button>
</div>
<div class="card-body p-3">
<CurveChart points={draft} />
{#if problems.length > 0}
<div class="alert alert-warning py-1 px-2 small mt-2 mb-2" data-testid="curve-problems">
{#each problems as p (p)}
<div>{$t(p)}</div>
{/each}
</div>
{/if}
<div class="table-responsive mt-2" style="max-height: 190px; overflow-y: auto;">
<table class="table table-sm table-hover align-middle mb-1">
<thead>
<tr class="small text-muted">
<th>{$t('curve.time')}</th>
<th>{$t('curve.alt')}</th>
<th>{$t('curve.rate')}</th>
<th></th>
</tr>
</thead>
<tbody data-testid="curve-rows">
{#each normalizeCurve(draft) as p (p.order)}
<tr>
<td>
<input
type="number"
class="form-control form-control-sm"
value={p.time_constraint}
oninput={(e) =>
setField(p.order, 'time_constraint', Number(e.currentTarget.value))} />
</td>
<td>
<input
type="number"
class="form-control form-control-sm"
value={p.alt_constraint}
oninput={(e) =>
setField(p.order, 'alt_constraint', Number(e.currentTarget.value))} />
</td>
<td>
<input
type="number"
step="0.1"
class="form-control form-control-sm"
value={p.rate}
oninput={(e) => setField(p.order, 'rate', Number(e.currentTarget.value))} />
</td>
<td class="text-end">
<button
class="btn btn-sm btn-link text-danger p-0"
aria-label={$t('scenarios.delete')}
onclick={() => removePoint(p.order)}>
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if rates.length > 0}
<div class="small text-muted mb-2" data-testid="curve-rates">
{$t('curve.impliedRates')}:
{#each rates as r (`${r.fromOrder}-${r.toOrder}`)}
<span class="badge text-bg-secondary me-1">
{r.fromOrder}{r.toOrder}: {r.rate.toFixed(2)} m/s
</span>
{/each}
</div>
{/if}
<div class="d-flex gap-2 mb-2">
<button class="btn btn-sm btn-outline-secondary" data-testid="curve-add" onclick={addPoint}>
{$t('curve.addPoint')}
</button>
<button
class="btn btn-sm btn-outline-secondary"
data-testid="curve-csv-toggle"
onclick={() => (showCsv = !showCsv)}>
{$t('curve.importCsv')}
</button>
<button class="btn btn-sm btn-outline-secondary" onclick={exportCsv}>
{$t('curve.exportCsv')}
</button>
</div>
{#if showCsv}
<textarea
class="form-control form-control-sm font-monospace mb-2"
rows="4"
data-testid="curve-csv"
placeholder="time_s,altitude_m,rate_ms"
bind:value={csvText}></textarea>
<button class="btn btn-sm btn-primary mb-2" data-testid="curve-csv-import" onclick={importCsv}>
{$t('curve.import')}
</button>
{/if}
</div>
<div class="card-footer d-flex justify-content-end gap-2 py-2">
<button class="btn btn-sm btn-outline-secondary" onclick={close}>
{$t('account.cancel')}
</button>
<button
class="btn btn-sm btn-primary"
data-testid="curve-apply"
disabled={!isValid}
onclick={apply}>
{$t('curve.apply')}
</button>
</div>
</div>
{/if}
<style>
.curve-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 1050;
}
.curve-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(560px, calc(100vw - 2rem));
max-height: calc(100vh - 3rem);
overflow-y: auto;
z-index: 1055;
}
</style>

View file

@ -0,0 +1,38 @@
<script lang="ts">
import { get } from 'svelte/store';
import { getMap } from '$map';
import { scenariosStore, getActiveScenario } from '$features/scenarios';
import { armPick } from './pickStore';
// Armed "pick launch point on map" tool. Must be a descendant of <Map />.
const map = getMap();
if (!map) throw new Error('PickOnMap must be a descendant of <Map />');
let off: (() => void) | null = null;
$effect(() => {
if (!map) return;
if ($armPick) {
map.setCursor('crosshair');
off = map.on('click', (e) => {
const active = getActiveScenario(get(scenariosStore));
if (active) {
scenariosStore.setFlightParameters(active.id, {
...active.flightParameters,
launch_latitude: Number(e.lngLat.lat.toFixed(6)),
launch_longitude: Number(e.lngLat.lng.toFixed(6))
});
}
armPick.set(false);
});
} else {
map.setCursor(null);
off?.();
off = null;
}
return () => {
off?.();
off = null;
};
});
</script>

View file

@ -0,0 +1,373 @@
<script lang="ts">
import {
SOLVER_TYPES,
CONSTRAINT_TYPES,
OPERATORS,
validateProfile,
stageSummary,
standardStages,
type ProfileStage,
type ConstraintSpec,
type SolverType,
type ConstraintType,
type ConstraintAction,
type Operator,
type FlightParameters,
type RateCurvePoint
} from '$domain';
import { t } from '$i18n';
import CurveEditor from './CurveEditor.svelte';
/**
* Inline stage builder (guidelines §6.4): a vertical list, executed top to
* bottom. Not a node graph, and not a modal — it is revealed in the left
* column so the curve editor can open above it as the single leaf modal.
*/
interface Props {
stages: ProfileStage[];
params: FlightParameters;
onChange: (stages: ProfileStage[]) => void;
}
let { stages, params, onChange }: Props = $props();
let collapsed = $state<Record<string, boolean>>({});
let curveFor = $state<number | null>(null);
const problems = $derived(validateProfile(stages));
function update(next: ProfileStage[]) {
onChange(next);
}
function patchStage(i: number, patch: Partial<ProfileStage>) {
update(stages.map((s, idx) => (idx === i ? { ...s, ...patch } : s)));
}
function addStage() {
update([
...stages,
{
name: `stage-${stages.length + 1}`,
solver: { type: 'constant_rate', rate: 5, include_wind: true },
advanceWhen: [{ type: 'altitude', op: '>=', limit: 10000, action: 'stop' }],
abortIf: []
}
]);
}
function removeStage(i: number) {
update(stages.filter((_, idx) => idx !== i));
}
/** Reordering is by buttons rather than drag: keyboard-reachable and testable. */
function move(i: number, delta: number) {
const j = i + delta;
if (j < 0 || j >= stages.length) return;
const next = [...stages];
[next[i], next[j]] = [next[j], next[i]];
update(next);
}
function setSolver(i: number, type: SolverType) {
const base = { include_wind: stages[i].solver.include_wind };
const solver =
type === 'constant_rate'
? { type, rate: stages[i].solver.rate ?? 5, ...base }
: type === 'parachute_descent'
? { type, sea_level_rate: stages[i].solver.sea_level_rate ?? 5, ...base }
: type === 'piecewise'
? { type, segments: stages[i].solver.segments ?? [], ...base }
: { type, ...base };
patchStage(i, { solver });
}
function patchConstraint(
i: number,
block: 'advanceWhen' | 'abortIf',
ci: number,
patch: Partial<ConstraintSpec>
) {
patchStage(i, {
[block]: stages[i][block].map((c, idx) => (idx === ci ? { ...c, ...patch } : c))
});
}
function addConstraint(i: number, block: 'advanceWhen' | 'abortIf') {
const c: ConstraintSpec =
block === 'advanceWhen'
? { type: 'altitude', op: '>=', limit: 10000, action: 'stop' }
: { type: 'time', op: '>=', limit: 7200, action: 'fallback', fallback: stages[0]?.name };
patchStage(i, { [block]: [...stages[i][block], c] });
}
function removeConstraint(i: number, block: 'advanceWhen' | 'abortIf', ci: number) {
patchStage(i, { [block]: stages[i][block].filter((_, idx) => idx !== ci) });
}
function applyCurve(points: RateCurvePoint[]) {
if (curveFor === null) return;
patchStage(curveFor, {
solver: { ...stages[curveFor].solver, type: 'piecewise', segments: points }
});
curveFor = null;
}
const needsScalar = (c: ConstraintSpec) => c.type === 'altitude' || c.type === 'time';
</script>
<div class="card shadow-sm mt-2" data-testid="profile-builder">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">{$t('profileBuilder.title')}</h6>
<button
class="btn btn-sm btn-outline-secondary"
data-testid="profile-reset"
onclick={() => update(standardStages(params))}>
{$t('profileBuilder.reset')}
</button>
</div>
{#if problems.length > 0}
<div class="alert alert-warning py-1 px-2 small" data-testid="profile-problems">
{#each problems as p (p)}
<div>{$t(p)}</div>
{/each}
</div>
{/if}
<div class="d-grid gap-2">
{#each stages as stage, i (stage.name + i)}
<div class="border rounded p-2" data-testid="stage-card">
<div class="d-flex align-items-center gap-1">
<button
class="btn btn-sm btn-link p-0 text-muted"
aria-label={$t('profileBuilder.collapse')}
onclick={() => (collapsed[stage.name] = !collapsed[stage.name])}>
<i class="bi {collapsed[stage.name] ? 'bi-chevron-right' : 'bi-chevron-down'}"></i>
</button>
<input
class="form-control form-control-sm border-0 flex-grow-1 px-1"
value={stage.name}
aria-label={$t('profileBuilder.stageName')}
data-testid="stage-name"
oninput={(e) => patchStage(i, { name: e.currentTarget.value })} />
<button
class="btn btn-sm btn-link p-0 text-muted"
aria-label={$t('profileBuilder.moveUp')}
data-testid="stage-up"
disabled={i === 0}
onclick={() => move(i, -1)}>
<i class="bi bi-arrow-up"></i>
</button>
<button
class="btn btn-sm btn-link p-0 text-muted"
aria-label={$t('profileBuilder.moveDown')}
data-testid="stage-down"
disabled={i === stages.length - 1}
onclick={() => move(i, 1)}>
<i class="bi bi-arrow-down"></i>
</button>
<button
class="btn btn-sm btn-link p-0 text-danger"
aria-label={$t('scenarios.delete')}
data-testid="stage-delete"
onclick={() => removeStage(i)}>
<i class="bi bi-trash"></i>
</button>
</div>
{#if collapsed[stage.name]}
<div class="small text-muted px-1" data-testid="stage-summary">
{stageSummary(stage)}
</div>
{:else}
<!-- 1. Solver + parameters -->
<div class="mt-2">
<label class="form-label small mb-0 fw-semibold" for={`sv-${i}`}>
{$t('profileBuilder.solver')}
</label>
<select
id={`sv-${i}`}
class="form-select form-select-sm"
data-testid="stage-solver"
value={stage.solver.type}
onchange={(e) => setSolver(i, e.currentTarget.value as SolverType)}>
{#each SOLVER_TYPES as st (st)}
<option value={st}>{$t(`profileBuilder.solver_${st}`)}</option>
{/each}
</select>
{#if stage.solver.type === 'constant_rate'}
<input
type="number"
step="0.1"
class="form-control form-control-sm mt-1"
data-testid="stage-rate"
aria-label={$t('profileBuilder.rate')}
value={stage.solver.rate}
oninput={(e) =>
patchStage(i, {
solver: { ...stage.solver, rate: Number(e.currentTarget.value) }
})} />
{:else if stage.solver.type === 'parachute_descent'}
<input
type="number"
step="0.1"
class="form-control form-control-sm mt-1"
data-testid="stage-rate"
aria-label={$t('profileBuilder.seaLevelRate')}
value={stage.solver.sea_level_rate}
oninput={(e) =>
patchStage(i, {
solver: { ...stage.solver, sea_level_rate: Number(e.currentTarget.value) }
})} />
{:else if stage.solver.type === 'piecewise'}
<button
class="btn btn-sm btn-outline-secondary w-100 mt-1"
data-testid="stage-open-curve"
onclick={() => (curveFor = i)}>
<i class="bi bi-graph-up-arrow"></i>
{$t('curve.open')}
<span class="badge text-bg-secondary ms-1">
{(stage.solver.segments ?? []).length}
</span>
</button>
{/if}
</div>
<!-- 2. Advance when — the normal exit -->
<div class="mt-2">
<div class="d-flex justify-content-between align-items-center">
<span class="small fw-semibold">{$t('profileBuilder.advanceWhen')}</span>
<button
class="btn btn-sm btn-link p-0"
data-testid="add-advance"
onclick={() => addConstraint(i, 'advanceWhen')}>
+
</button>
</div>
{#each stage.advanceWhen as c, ci (ci)}
<div class="input-group input-group-sm mt-1" data-testid="advance-row">
<select
class="form-select"
aria-label={$t('profileBuilder.constraintType')}
value={c.type}
onchange={(e) =>
patchConstraint(i, 'advanceWhen', ci, {
type: e.currentTarget.value as ConstraintType
})}>
{#each CONSTRAINT_TYPES as ct (ct)}
<option value={ct}>{$t(`profileBuilder.constraint_${ct}`)}</option>
{/each}
</select>
{#if needsScalar(c)}
<select
class="form-select"
style="max-width: 4rem"
aria-label={$t('profileBuilder.operator')}
value={c.op}
onchange={(e) =>
patchConstraint(i, 'advanceWhen', ci, {
op: e.currentTarget.value as Operator
})}>
{#each OPERATORS as op (op)}
<option value={op}>{op}</option>
{/each}
</select>
<input
type="number"
class="form-control"
data-testid="advance-limit"
aria-label={$t('profileBuilder.limit')}
value={c.limit}
oninput={(e) =>
patchConstraint(i, 'advanceWhen', ci, {
limit: Number(e.currentTarget.value)
})} />
{/if}
<button
class="btn btn-outline-danger"
aria-label={$t('scenarios.delete')}
onclick={() => removeConstraint(i, 'advanceWhen', ci)}>
<i class="bi bi-x"></i>
</button>
</div>
{/each}
</div>
<!-- 3. Abort if — a different exit, to a fallback stage -->
<div class="mt-2">
<div class="d-flex justify-content-between align-items-center">
<span class="small fw-semibold text-danger">{$t('profileBuilder.abortIf')}</span>
<button
class="btn btn-sm btn-link p-0"
data-testid="add-abort"
onclick={() => addConstraint(i, 'abortIf')}>
+
</button>
</div>
{#each stage.abortIf as c, ci (ci)}
<div class="input-group input-group-sm mt-1" data-testid="abort-row">
<select
class="form-select"
aria-label={$t('profileBuilder.constraintType')}
value={c.type}
onchange={(e) =>
patchConstraint(i, 'abortIf', ci, {
type: e.currentTarget.value as ConstraintType
})}>
{#each CONSTRAINT_TYPES as ct (ct)}
<option value={ct}>{$t(`profileBuilder.constraint_${ct}`)}</option>
{/each}
</select>
{#if needsScalar(c)}
<input
type="number"
class="form-control"
aria-label={$t('profileBuilder.limit')}
value={c.limit}
oninput={(e) =>
patchConstraint(i, 'abortIf', ci, { limit: Number(e.currentTarget.value) })} />
{/if}
<select
class="form-select"
data-testid="abort-fallback"
aria-label={$t('profileBuilder.fallbackTo')}
value={c.fallback ?? ''}
onchange={(e) =>
patchConstraint(i, 'abortIf', ci, {
action: 'fallback' as ConstraintAction,
fallback: e.currentTarget.value
})}>
<option value=""></option>
{#each stages.filter((s2) => s2.name !== stage.name) as target (target.name)}
<option value={target.name}>{target.name}</option>
{/each}
</select>
<button
class="btn btn-outline-danger"
aria-label={$t('scenarios.delete')}
onclick={() => removeConstraint(i, 'abortIf', ci)}>
<i class="bi bi-x"></i>
</button>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
<button class="btn btn-sm btn-outline-primary w-100 mt-2" data-testid="add-stage" onclick={addStage}>
<i class="bi bi-plus-lg"></i>
{$t('profileBuilder.addStage')}
</button>
</div>
</div>
<!-- The curve editor is the single leaf modal, opened from a stage. -->
<CurveEditor
open={curveFor !== null}
points={curveFor !== null ? (stages[curveFor].solver.segments ?? []) : []}
onApply={applyCurve}
onClose={() => (curveFor = null)} />

View file

@ -0,0 +1,5 @@
export { default as ControlPanel } from './ControlPanel.svelte';
export { default as PickOnMap } from './PickOnMap.svelte';
export { pointsStore } from './pointsStore';
export { templatesStore } from './templatesStore';
export { armPick } from './pickStore';

View file

@ -0,0 +1,4 @@
import { writable } from 'svelte/store';
/** When true, the next map click sets the active scenario's launch point. */
export const armPick = writable(false);

View file

@ -0,0 +1,34 @@
import { writable } from 'svelte/store';
import type { SavedPoint } from '$domain';
import { pointsApi } from '$api';
const store = writable<SavedPoint[]>([]);
let loaded = false;
/** Library of saved launch points, backed by the API. */
export const pointsStore = {
subscribe: store.subscribe,
async load(force = false): Promise<void> {
if (loaded && !force) return;
store.set(await pointsApi.list());
loaded = true;
},
async add(p: Omit<SavedPoint, 'id'>): Promise<SavedPoint> {
const saved = await pointsApi.create(p);
store.update((list) => [...list, saved]);
return saved;
},
async save(p: SavedPoint): Promise<SavedPoint> {
const saved = await pointsApi.update(p);
store.update((list) => list.map((x) => (x.id === saved.id ? saved : x)));
return saved;
},
async remove(id: number): Promise<void> {
await pointsApi.delete(id);
store.update((list) => list.filter((x) => x.id !== id));
}
};

View file

@ -0,0 +1,34 @@
import { writable } from 'svelte/store';
import type { Template } from '$domain';
import { templatesApi } from '$api';
const store = writable<Template[]>([]);
let loaded = false;
/** Library of saved parameter templates, backed by the API. */
export const templatesStore = {
subscribe: store.subscribe,
async load(force = false): Promise<void> {
if (loaded && !force) return;
store.set(await templatesApi.list());
loaded = true;
},
async add(t: Omit<Template, 'id'>): Promise<Template> {
const saved = await templatesApi.create(t);
store.update((list) => [...list, saved]);
return saved;
},
async save(t: Template): Promise<Template> {
const saved = await templatesApi.update(t);
store.update((list) => list.map((x) => (x.id === saved.id ? saved : x)));
return saved;
},
async remove(id: number): Promise<void> {
await templatesApi.delete(id);
store.update((list) => list.filter((x) => x.id !== id));
}
};

View file

@ -0,0 +1,84 @@
<script lang="ts">
import { distHaversine, formatCoords } from '$domain';
import { settingsStore } from '$features/settings';
import { t } from '$i18n';
import { scenariosStore } from './store';
/**
* Compares the visible scenarios' outcomes. Colour is scenario identity (§6.3),
* so rows are keyed by it; red/blue stay reserved for actual/predicted.
*/
const rows = $derived(
$scenariosStore.items
.filter((s) => s.visible && s.result)
.map((s) => ({
id: s.id,
name: s.name,
color: s.color,
landing: s.result!.landing.latlng,
flightTime: s.result!.flight_time
}))
);
/** Greatest great-circle distance between any two landing points, km. */
const spreadKm = $derived.by(() => {
let max = 0;
for (let i = 0; i < rows.length; i++) {
for (let j = i + 1; j < rows.length; j++) {
const d = distHaversine(rows[i].landing, rows[j].landing);
if (d > max) max = d;
}
}
return max;
});
const fmtTime = (sec: number) => {
const m = Math.round(sec / 60);
return `${Math.floor(m / 60)}${$t('scenarios.hour')} ${m % 60}${$t('scenarios.min')}`;
};
</script>
<div class="card shadow-sm">
<div class="card-body p-3">
<h6 class="mb-2">{$t('compare.title')}</h6>
{#if rows.length === 0}
<p class="text-muted small mb-0">{$t('compare.empty')}</p>
{:else}
<div class="table-responsive">
<table class="table table-sm align-middle mb-1" data-testid="compare-table">
<thead>
<tr class="small text-muted">
<th></th>
<th>{$t('compare.scenario')}</th>
<th>{$t('scenarios.landing')}</th>
<th>{$t('scenarios.flightTime')}</th>
</tr>
</thead>
<tbody>
{#each rows as r (r.id)}
<tr class="small">
<td>
<span
class="d-inline-block rounded-circle"
style="width: .7rem; height: .7rem; background: {r.color}"></span>
</td>
<td class="text-truncate" style="max-width: 8rem">{r.name}</td>
<td class="font-monospace">
{formatCoords(r.landing.lat, r.landing.lng, $settingsStore.format.coords)}
</td>
<td>{fmtTime(r.flightTime)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if rows.length > 1}
<div class="small text-muted" data-testid="compare-spread">
{$t('compare.spread')}: <span class="fw-semibold text-body">{spreadKm.toFixed(2)} км</span>
</div>
{/if}
{/if}
</div>
</div>

View file

@ -0,0 +1,159 @@
<script lang="ts">
import { scenariosStore } from './store';
import { t } from '$i18n';
import { exportPrediction, EXPORT_MIME, type ExportFormat, type Scenario } from '$domain';
import { downloadText } from '$ui';
function exportScenario(s: Scenario, format: ExportFormat) {
if (!s.result) return;
const safeName = (s.name || 'prediction').replace(/[^\w.-]+/g, '_');
downloadText(
`${safeName}.${format}`,
EXPORT_MIME[format],
exportPrediction(s.result, format, s.name)
);
}
function formatFlightTime(sec: number): string {
const m = Math.round(sec / 60);
if (m < 60) return `${m} ${$t('scenarios.min')}`;
return `${Math.floor(m / 60)} ${$t('scenarios.hour')} ${m % 60} ${$t('scenarios.min')}`;
}
async function run(id: string) {
try {
await scenariosStore.run(id);
} catch {
/* error surfaced on the card via runStatus */
}
}
function landingText(s: Scenario): string {
if (!s.result) return '';
const { lat, lng } = s.result.landing.latlng;
return `${lat.toFixed(4)}, ${lng.toFixed(4)}`;
}
</script>
<div class="card shadow-sm">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">{$t('scenarios.title')}</h6>
<button
class="btn btn-sm btn-primary"
data-testid="add-scenario"
onclick={() => scenariosStore.add()}>
<i class="bi bi-plus-lg"></i>
{$t('scenarios.add')}
</button>
</div>
{#if $scenariosStore.items.length === 0}
<p class="text-muted small mb-0">{$t('scenarios.empty')}</p>
{/if}
<div class="d-grid gap-2">
{#each $scenariosStore.items as s (s.id)}
<!--
Exactly one scenario is active (the Conditions form's target), so the
selector is a radio group rather than a clickable card: a card with
role="button" would nest its own buttons and confuse its accessible name.
-->
<div
class="border rounded p-2 scenario-item"
class:border-primary={$scenariosStore.activeId === s.id}>
<div class="d-flex align-items-center gap-2">
<input
class="form-check-input mt-0 flex-shrink-0"
type="radio"
name="active-scenario"
data-testid="select-scenario"
checked={$scenariosStore.activeId === s.id}
aria-label={$t('scenarios.makeActive')}
onchange={() => scenariosStore.setActive(s.id)} />
<input
type="color"
class="form-control form-control-color p-0 border-0"
style="width: 1.4rem; height: 1.4rem"
value={s.color}
oninput={(e) => scenariosStore.patch(s.id, { color: e.currentTarget.value })}
aria-label={$t('scenarios.color')} />
<input
type="text"
class="form-control form-control-sm border-0 flex-grow-1 px-1"
value={s.name}
aria-label={$t('scenarios.name')}
oninput={(e) => scenariosStore.patch(s.id, { name: e.currentTarget.value })} />
<button
class="btn btn-sm btn-link p-0 text-muted"
title={$t('scenarios.visible')}
aria-label={$t('scenarios.visible')}
data-testid="toggle-visibility"
onclick={() => scenariosStore.patch(s.id, { visible: !s.visible })}>
<i class="bi {s.visible ? 'bi-eye' : 'bi-eye-slash'}"></i>
</button>
<button
class="btn btn-sm btn-link p-0 text-danger"
title={$t('scenarios.delete')}
aria-label={$t('scenarios.delete')}
data-testid="delete-scenario"
onclick={() => scenariosStore.remove(s.id)}>
<i class="bi bi-trash"></i>
</button>
</div>
<div class="d-flex align-items-center gap-2 mt-2">
<button
class="btn btn-sm btn-outline-primary"
data-testid="run-scenario"
disabled={s.runStatus === 'running'}
onclick={() => run(s.id)}>
{#if s.runStatus === 'running'}
<span class="spinner-border spinner-border-sm"></span>
{$t('scenarios.running')}
{:else}
<i class="bi bi-play-fill"></i>
{$t('predict.run')}
{/if}
</button>
{#if s.runStatus === 'done' && s.result}
<span class="small text-muted" data-testid="flight-time">
{$t('scenarios.flightTime')}: {formatFlightTime(s.result.flight_time)}
</span>
{/if}
</div>
{#if s.result}
<div class="btn-group btn-group-sm mt-2 w-100" data-testid="export-group">
{#each ['gpx', 'kml', 'csv'] as const as fmt (fmt)}
<button
class="btn btn-outline-secondary"
data-testid={`export-${fmt}`}
onclick={() => exportScenario(s, fmt)}>
{fmt.toUpperCase()}
</button>
{/each}
</div>
{/if}
{#if s.runStatus === 'error'}
<div class="alert alert-danger py-1 px-2 small mt-2 mb-0" role="alert">
{s.lastRunError || $t('scenarios.error')}
</div>
{:else if s.runStatus === 'done' && s.result}
<div class="small text-muted mt-1">
{$t('scenarios.landing')}: {landingText(s)}
</div>
{/if}
</div>
{/each}
</div>
</div>
</div>
<style>
.scenario-item {
background: var(--bs-body-bg);
}
</style>

View file

@ -0,0 +1,190 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { getMap, plotPrediction, plotAnimatedMarker, plotEndMarker, plotEnsemble } from '$map';
import { timelineStore, positionAt } from '$features/timeline';
import { scenariosStore } from './store';
import type { Scenario } from '$domain';
/**
* Renders every scenario onto the shared map. Each scenario gets its own named
* scene (`sc/<id>`) for its trajectory and a `cursor/<id>` scene for the
* playback marker, so both clear independently. Must be a descendant of <Map />.
*/
const map = getMap();
if (!map) throw new Error('ScenarioRenderer must be a descendant of <Map />');
const ownedPlots = new Set<string>();
const ownedCursors = new Set<string>();
const ownedEnsembles = new Set<string>();
const plotCache = new Map<string, { result: unknown; color: string; opacity: number }>();
/** Cursor scenes that reached their flight end and show a static end marker. */
const doneCursors = new Set<string>();
const sceneName = (s: Scenario) => `sc/${s.id}`;
const cursorName = (s: Scenario) => `cursor/${s.id}`;
const ensembleName = (s: Scenario) => `ens/${s.id}`;
/** Member scatter + 95% confidence ellipse, drawn in the scenario's colour. */
function renderEnsembles(items: Scenario[]) {
if (!map) return;
const live = new Set<string>();
for (const s of items) {
const name = ensembleName(s);
live.add(name);
if (!s.visible || !s.ensemble) {
if (ownedEnsembles.has(name)) {
map.disposeScene(name);
ownedEnsembles.delete(name);
}
continue;
}
plotEnsemble(map.scene(name), s.ensemble, s.color);
ownedEnsembles.add(name);
}
for (const name of Array.from(ownedEnsembles)) {
if (!live.has(name)) {
map.disposeScene(name);
ownedEnsembles.delete(name);
}
}
}
const durationOf = (s: Scenario) =>
s.result ? s.result.landing.datetime.getTime() - s.result.launch.datetime.getTime() : 0;
function renderAll(items: Scenario[]) {
if (!map) return;
const live = new Set<string>();
for (const s of items) {
const name = sceneName(s);
live.add(name);
if (!s.visible || !s.result) {
if (ownedPlots.has(name)) {
map.disposeScene(name);
ownedPlots.delete(name);
plotCache.delete(name);
}
continue;
}
const cached = plotCache.get(name);
if (
!cached ||
cached.result !== s.result ||
cached.color !== s.color ||
cached.opacity !== s.opacity
) {
const isNewResult = !cached || cached.result !== s.result;
plotPrediction(map.scene(name), s.result, { color: s.color, opacity: s.opacity });
ownedPlots.add(name);
plotCache.set(name, { result: s.result, color: s.color, opacity: s.opacity });
// Frame the freshly-computed trajectory once (not on style-only changes).
if (isNewResult) map.fitBounds(s.result.flight_path, 60);
}
}
for (const name of Array.from(ownedPlots)) {
if (!live.has(name)) {
map.disposeScene(name);
ownedPlots.delete(name);
plotCache.delete(name);
}
}
}
/**
* The global clock domain is the union of every visible flight's duration.
* Scenarios that end earlier than the max get an end-of-flight tick.
*/
function updateGlobalRange(items: Scenario[]) {
let maxDuration = 0;
const entries: Array<{ duration: number; color: string }> = [];
for (const s of items) {
if (!s.visible || !s.result) continue;
const duration = durationOf(s);
if (duration > maxDuration) maxDuration = duration;
entries.push({ duration, color: s.color });
}
timelineStore.setRange(0, maxDuration);
const seen = new Set<number>();
timelineStore.setMarkers(
entries
.filter(({ duration }) => {
if (duration >= maxDuration || seen.has(duration)) return false;
seen.add(duration);
return true;
})
.map(({ duration, color }) => ({ time: duration, color }))
);
}
function renderCursors(items: Scenario[], time: number) {
if (!map) return;
const live = new Set<string>();
for (const s of items) {
const name = cursorName(s);
live.add(name);
if (!s.visible || !s.result) {
if (ownedCursors.has(name)) {
map.disposeScene(name);
ownedCursors.delete(name);
doneCursors.delete(name);
}
continue;
}
const durationMs = durationOf(s);
const p = positionAt(s.result.flight_path, time, durationMs);
if (!p) continue;
const scene = map.scene(name);
const done = time >= durationMs;
if (done) {
if (!doneCursors.has(name)) {
// Transition into done state: swap to a static end marker.
scene.clear();
plotEndMarker(scene, p[1], p[0]);
doneCursors.add(name);
}
// Position is clamped to landing — nothing more to update.
} else {
if (doneCursors.has(name)) {
// Transition back to active (the user seeked backwards).
scene.clear();
doneCursors.delete(name);
}
plotAnimatedMarker(scene, p[1], p[0]);
}
ownedCursors.add(name);
}
for (const name of Array.from(ownedCursors)) {
if (!live.has(name)) {
map.disposeScene(name);
ownedCursors.delete(name);
doneCursors.delete(name);
}
}
}
$effect(() => {
const items = $scenariosStore.items;
renderAll(items);
renderEnsembles(items);
updateGlobalRange(items);
});
$effect(() => {
renderCursors($scenariosStore.items, $timelineStore.time);
});
onDestroy(() => {
if (!map) return;
for (const n of ownedPlots) map.disposeScene(n);
for (const n of ownedCursors) map.disposeScene(n);
for (const n of ownedEnsembles) map.disposeScene(n);
ownedEnsembles.clear();
ownedPlots.clear();
ownedCursors.clear();
plotCache.clear();
doneCursors.clear();
});
</script>

View file

@ -0,0 +1,5 @@
export { scenariosStore, getActiveScenario } from './store';
export type { ScenarioSlice } from './store';
export { default as ScenarioPanel } from './ScenarioPanel.svelte';
export { default as ComparePanel } from './ComparePanel.svelte';
export { default as ScenarioRenderer } from './ScenarioRenderer.svelte';

View file

@ -0,0 +1,217 @@
import { get } from 'svelte/store';
import { persisted } from '$state';
import {
DEFAULT_FLIGHT_PARAMETERS,
parsePrediction,
parseEnsemble,
toV2Request,
standardStages
} from '$domain';
import type { Scenario, ScenarioInit, FlightParameters } from '$domain';
import { predictionsApi, buildLaunchDateTime } from '$api';
const STORAGE_KEY = 'scenarios';
const DEFAULT_COLORS = [
'#0d6efd',
'#dc3545',
'#198754',
'#fd7e14',
'#6f42c1',
'#20c997',
'#d63384',
'#0dcaf0'
];
function todayDate(): string {
return new Date().toISOString().split('T')[0];
}
function makeScenario(init: ScenarioInit = {}, index = 0): Scenario {
return {
id: crypto.randomUUID(),
name: init.name ?? `Scenario ${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,
runStatus: 'idle',
ensemble: null,
jobId: null
};
}
export interface ScenarioSlice {
items: Scenario[];
activeId: string | null;
}
const initial: ScenarioSlice = { items: [], activeId: null };
/**
* Don't persist prediction results — they're large, transient, and re-fetchable.
* The serializer strips `result`/`runStatus` before writing to localStorage.
*/
const scenariosPersisted = persisted<ScenarioSlice>(STORAGE_KEY, initial, {
serializer: {
stringify: (value) =>
JSON.stringify({
...value,
items: value.items.map((s) => ({
...s,
result: null,
ensemble: null,
jobId: null,
runStatus: 'idle' as const,
lastRunError: undefined
}))
}),
parse: (raw) => JSON.parse(raw) as ScenarioSlice
}
});
function update(fn: (s: ScenarioSlice) => ScenarioSlice): void {
scenariosPersisted.update(fn);
}
export const scenariosStore = {
subscribe: scenariosPersisted.subscribe,
add(init: ScenarioInit = {}): Scenario {
let created: Scenario | null = null;
update((s) => {
const w = makeScenario(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<Scenario>): 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 {
scenariosStore.patch(id, { flightParameters: params });
},
/**
* Enqueue a GEFS ensemble run and poll until it settles. Async because 21
* member runs take far longer than one request should stay open.
*/
async runEnsemble(id: string, pollMs = 1000): Promise<void> {
const slice = get(scenariosPersisted);
const w = slice.items.find((x) => x.id === id);
if (!w) return;
scenariosStore.patch(id, {
runStatus: 'running',
lastRunError: undefined,
ensemble: null
});
try {
const launchDatetime = buildLaunchDateTime(w.launchDate, w.launchTime);
const job = await predictionsApi.runEnsemble(w.flightParameters, launchDatetime);
scenariosStore.patch(id, { jobId: job.id });
// Poll until the job settles; a cancelled scenario stops the loop.
for (;;) {
await new Promise((r) => setTimeout(r, pollMs));
const current = get(scenariosPersisted).items.find((x) => x.id === id);
if (!current || current.jobId !== job.id) return; // cancelled or removed
const status = await predictionsApi.status(job.id);
if (status.status === 'complete') {
scenariosStore.patch(id, {
ensemble: parseEnsemble(status.result),
runStatus: 'done',
jobId: null
});
return;
}
if (status.status === 'error') {
scenariosStore.patch(id, {
runStatus: 'error',
lastRunError: status.error ?? 'Ensemble failed',
jobId: null
});
return;
}
}
} catch (err: unknown) {
scenariosStore.patch(id, {
runStatus: 'error',
lastRunError: (err as Error).message,
jobId: null
});
throw err;
}
},
/** Stop polling and ask the backend to drop the still-queued job. */
async cancelRun(id: string): Promise<void> {
const w = get(scenariosPersisted).items.find((x) => x.id === id);
const jobId = w?.jobId;
scenariosStore.patch(id, { runStatus: 'idle', jobId: null });
if (!jobId) return;
try {
await predictionsApi.remove(jobId);
} catch {
/* already gone or finished — nothing to clean up */
}
},
async run(id: string): Promise<void> {
const slice = get(scenariosPersisted);
const w = slice.items.find((x) => x.id === id);
if (!w) return;
scenariosStore.patch(id, { runStatus: 'running', lastRunError: undefined });
try {
const launchDatetime = buildLaunchDateTime(w.launchDate, w.launchTime);
// A custom profile is a stage list, which only the v2 endpoint accepts;
// everything else keeps the flat legacy request.
const isCustom = w.flightParameters.profile === 'custom_profile';
const response = isCustom
? await predictionsApi.runV2(
toV2Request(
w.stages ?? standardStages(w.flightParameters),
w.flightParameters,
launchDatetime,
{ dataset: w.flightParameters.dataset || undefined }
)
)
: await predictionsApi.run(w.flightParameters, launchDatetime);
const prediction = parsePrediction(response.result.prediction);
scenariosStore.patch(id, { result: prediction, runStatus: 'done' });
} catch (err: unknown) {
scenariosStore.patch(id, {
result: null,
runStatus: 'error',
lastRunError: (err as Error).message
});
throw err;
}
}
};
export function getActiveScenario(slice: ScenarioSlice): Scenario | null {
if (!slice.activeId) return slice.items[0] ?? null;
return slice.items.find((w) => w.id === slice.activeId) ?? null;
}

View file

@ -0,0 +1,69 @@
<script lang="ts">
import { t, setLocale, type Locale } from '$i18n';
import { settingsStore, getPath, setPath } from './store';
import { SETTINGS_SCHEMA } from './schema';
/** The panel renders whatever the schema declares — no per-field markup. */
function update(path: string, value: unknown) {
settingsStore.update((s) => setPath(s, path, value));
// Locale is the one setting with an immediate side effect: swap dictionaries.
if (path === 'locale') setLocale(value as Locale);
}
</script>
<div class="card shadow-sm">
<div class="card-body p-3">
<h6 class="mb-2">{$t('settings.title')}</h6>
{#each SETTINGS_SCHEMA as section (section.titleKey)}
<div class="mb-3">
<div class="text-muted small fw-semibold border-bottom pb-1 mb-2">
{$t(section.titleKey)}
</div>
{#each section.fields as field (field.path)}
{@const value = getPath($settingsStore, field.path)}
<div class="mb-2">
<label class="form-label small mb-0" for={`set-${field.path}`}>
{$t(field.labelKey)}
</label>
{#if field.kind === 'select'}
<select
id={`set-${field.path}`}
class="form-select form-select-sm"
data-testid={`set-${field.path}`}
value={String(value)}
onchange={(e) => update(field.path, e.currentTarget.value)}>
{#each field.options as opt (opt.value)}
<option value={opt.value}>{$t(opt.labelKey)}</option>
{/each}
</select>
{:else if field.kind === 'boolean'}
<div class="form-check form-switch">
<input
id={`set-${field.path}`}
class="form-check-input"
type="checkbox"
data-testid={`set-${field.path}`}
checked={Boolean(value)}
onchange={(e) => update(field.path, e.currentTarget.checked)} />
</div>
{:else}
<input
id={`set-${field.path}`}
class="form-control form-control-sm"
type="number"
data-testid={`set-${field.path}`}
min={field.min}
max={field.max}
step={field.step}
value={Number(value)}
onchange={(e) => update(field.path, Number(e.currentTarget.value))} />
{/if}
</div>
{/each}
</div>
{/each}
</div>
</div>

View file

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

View file

@ -0,0 +1,96 @@
/**
* Declarative settings schema. Each field describes one setting that the panel
* renders as a labeled control. Kept free of Svelte so the same schema can drive
* other consumers later (export/import, URL state).
*/
export type FieldKind = 'boolean' | 'select' | 'number';
export interface BaseField<K extends FieldKind> {
kind: K;
/** Dot-separated path into AppSettings (e.g. `'map.baseLayer'`). */
path: string;
labelKey: string;
}
export type BooleanField = BaseField<'boolean'>;
export interface NumberField extends BaseField<'number'> {
min?: number;
max?: number;
step?: number;
}
export interface SelectField extends BaseField<'select'> {
options: { value: string; labelKey: string }[];
}
export type SettingsField = BooleanField | NumberField | SelectField;
export interface SettingsSection {
titleKey: string;
fields: SettingsField[];
}
export const SETTINGS_SCHEMA: SettingsSection[] = [
{
titleKey: 'settings.general',
fields: [
{
kind: 'select',
path: 'locale',
labelKey: 'settings.language',
options: [
{ value: 'ru', labelKey: 'settings.lang_ru' },
{ value: 'en', labelKey: 'settings.lang_en' }
]
}
]
},
{
titleKey: 'settings.map',
fields: [
{
kind: 'select',
path: 'map.baseLayer',
labelKey: 'settings.baseLayer',
options: [
{ value: 'osm', labelKey: 'settings.baseLayer_osm' },
{ value: 'satellite', labelKey: 'settings.baseLayer_satellite' }
]
}
]
},
{
titleKey: 'settings.format',
fields: [
{
kind: 'select',
path: 'format.units',
labelKey: 'settings.units',
options: [
{ value: 'metric', labelKey: 'settings.units_metric' },
{ value: 'imperial', labelKey: 'settings.units_imperial' }
]
},
{
kind: 'select',
path: 'format.coords',
labelKey: 'settings.coords',
options: [
{ value: 'dd', labelKey: 'settings.coords_dd' },
{ value: 'dms', labelKey: 'settings.coords_dms' }
]
},
{
kind: 'select',
path: 'format.time',
labelKey: 'settings.time',
options: [
{ value: 'utc', labelKey: 'settings.time_utc' },
{ value: 'local', labelKey: 'settings.time_local' }
]
}
]
}
];

View file

@ -0,0 +1,53 @@
import { persisted } from '$state';
import type { Locale } from '$i18n';
import type { CoordFormat, TimeDisplay, UnitSystem } from '$domain';
export interface MapSettings {
baseLayer: 'osm' | 'satellite';
}
export interface FormatSettings {
units: UnitSystem;
coords: CoordFormat;
time: TimeDisplay;
}
export interface AppSettings {
locale: Locale;
map: MapSettings;
format: FormatSettings;
}
export const DEFAULT_SETTINGS: AppSettings = {
locale: 'ru',
map: { baseLayer: 'osm' },
// UTC and SI are canonical; these are display choices only (guidelines §10).
format: { units: 'metric', coords: 'dd', time: 'utc' }
};
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);
}
/** Immutably set `'a.b.c'` on a copy of `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,188 @@
<script lang="ts">
import { timelineStore } from './store';
import { t } from '$i18n';
const SPEEDS = [0.5, 1, 2, 5, 10, 30];
function cycleSpeed() {
const i = SPEEDS.indexOf($timelineStore.speed);
timelineStore.setSpeed(SPEEDS[(i + 1) % SPEEDS.length]);
}
function onSeek(e: Event) {
timelineStore.seek(parseFloat((e.currentTarget as HTMLInputElement).value));
}
const duration = $derived(Math.max(0, $timelineStore.max - $timelineStore.min));
const elapsed = $derived(Math.max(0, $timelineStore.time - $timelineStore.min));
const hasData = $derived(duration > 0);
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}`;
}
</script>
<div class="timeline-container card shadow-sm" class:disabled={!hasData} data-testid="timeline">
<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"
data-testid="tl-pause"
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"
data-testid="tl-play"
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">
<!-- step="any", not a 1s step: flight durations are not whole seconds, so a
stepped slider stops short of max and the cursor would never reach its
landed state when scrubbed to the end. -->
<input
type="range"
class="form-range"
data-testid="tl-range"
aria-label={$t('timeline.scrub')}
min={$timelineStore.min}
max={$timelineStore.max}
step="any"
value={$timelineStore.time}
oninput={onSeek}
disabled={!hasData} />
{#if hasData && $timelineStore.markers.length > 0}
<div class="marker-ticks">
{#each $timelineStore.markers as m (m.time)}
{@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 data-testid="tl-elapsed">{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: 6;
background: var(--bs-body-bg);
}
.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;
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, positionAt } from './store';
export type { TimelineState, TimelineMarker } from './store';
export { default as TimeLine } from './TimeLine.svelte';

View file

@ -0,0 +1,131 @@
import { writable } from 'svelte/store';
/**
* Global playback clock.
*
* `time` is **elapsed milliseconds since launch**, and the domain `[min, max]`
* is the union of every visible Scenario's flight duration (min is normally 0).
* Each Scenario samples its own trajectory against this one clock, which is what
* keeps multiple overlaid flights in sync while they have different durations.
*/
export interface TimelineMarker {
time: number;
color: string;
}
export interface TimelineState {
time: number;
min: number;
max: number;
speed: number;
playing: boolean;
/** End-of-flight ticks for scenarios shorter than the global max. */
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() {
let started = false;
store.update((s) => {
if (s.max <= s.min) return s;
started = true;
// Restarting from the end rewinds rather than sitting still.
if (s.time >= s.max) return { ...s, time: s.min, playing: true };
return { ...s, playing: true };
});
if (!started) return;
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();
/**
* Position along a flight path at `elapsed` ms, linearly interpolating between
* the two bracketing samples. Returns null for an empty path.
*/
export function positionAt(
path: [number, number, ...number[]][],
elapsed: number,
durationMs: number
): [number, number] | null {
if (path.length === 0) return null;
if (durationMs <= 0) return [path[0][0], path[0][1]];
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) {
const last = path[path.length - 1];
return [last[0], last[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];
}

View file

@ -0,0 +1,197 @@
<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 the 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);
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 } } }
}
};
onMount(() => {
altChart = new ChartJS(altCanvas.getContext('2d')!, {
type: 'line',
data: {
datasets: [
{
label: $t('tracking.actual'),
data: [],
borderColor: '#FF1744', // actual = red
backgroundColor: 'rgba(255,23,68,0.08)',
fill: false,
pointRadius: 0,
borderWidth: 2
} as ChartDataset<'line'>,
{
label: $t('tracking.predicted'),
data: [],
borderColor: '#1565C0', // predicted = blue
backgroundColor: 'transparent',
fill: false,
pointRadius: 0,
borderWidth: 2,
borderDash: [6, 3]
} as ChartDataset<'line'>
]
},
options: {
...commonOptions,
scales: {
x: timeAxis,
y: {
title: { display: true, text: $t('tracking.altitudeAxis'), font: { size: 10 } },
ticks: { font: { size: 9 } }
}
}
}
});
devChart = new ChartJS(devCanvas.getContext('2d')!, {
type: 'line',
data: {
datasets: [
{
label: $t('tracking.deviationAxis'),
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: $t('tracking.deviationAxis'), font: { size: 10 } },
ticks: { font: { size: 9 } }
}
},
plugins: { ...commonOptions.plugins, legend: { display: false } }
}
});
});
$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');
});
$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 stay in the DOM so the Chart.js instances created in onMount
always have a valid canvas. Sections are hidden with d-none; Chart.js picks up
the dimension change via ResizeObserver when they become visible again.
-->
{#if !hasData}
<p class="text-muted small text-center py-3 mb-0">{$t('tracking.noData')}</p>
{/if}
<div class:d-none={!hasData}>
<p class="small fw-semibold mb-1">{$t('tracking.altProfile')}</p>
<div style="position: relative; height: 150px;">
<canvas bind:this={altCanvas}></canvas>
</div>
{#if !hasDeviation}
<p class="small text-muted mt-2 mb-0">{$t('tracking.selectPrediction')}</p>
{/if}
</div>
<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: 120px;">
<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" data-testid="deviation-summary">
<small class="text-muted">
{$t('tracking.devMax')}
<span class="fw-semibold text-body">{maxDev.toFixed(2)} {$t('tracking.km')}</span>
</small>
<small class="text-muted">
{$t('tracking.devCurrent')}
<span class="fw-semibold text-body">{last.horizontal.toFixed(2)} {$t('tracking.km')}</span>
</small>
<small class="text-muted">
Δh:
<span class="fw-semibold text-body">
{last.vertical > 0 ? '+' : ''}{last.vertical.toFixed(0)} {$t('tracking.m')}
</span>
</small>
</div>
{/if}
</div>

View file

@ -0,0 +1,128 @@
<script lang="ts">
import { t } from '$i18n';
import { telemetryStore } from './telemetryStore.svelte';
import DeviationChart from './DeviationChart.svelte';
import { scenariosStore, getActiveScenario } from '$features/scenarios';
let satelliteInput = $state('');
let showChart = $state(false);
const STATUS_CLASS: Record<string, string> = {
idle: 'text-bg-secondary',
connecting: 'text-bg-warning',
connected: 'text-bg-success',
error: 'text-bg-danger'
};
// Compare against the active scenario's result, when it has one.
const prediction = $derived(getActiveScenario($scenariosStore)?.result ?? null);
function handleConnect() {
const id = satelliteInput.trim();
if (id) telemetryStore.connect(id);
}
function handleDisconnect() {
telemetryStore.disconnect();
satelliteInput = '';
}
// Deliberately no disconnect-on-unmount: the panel lives in a tab, and the
// track keeps drawing on the map while the user looks at another tab.
// Disconnecting is an explicit user action.
// Restore the field when returning to the tab mid-flight.
$effect(() => {
if (telemetryStore.satelliteId && !satelliteInput) satelliteInput = telemetryStore.satelliteId;
});
</script>
<div class="card shadow-sm">
<div class="card-body p-3">
<h6 class="mb-2">{$t('tracking.title')}</h6>
<!-- Raw-id entry stays for unlisted/private flights, but is no longer the
primary path (guidelines §7.2) — the public list beside this panel and
shared links are. -->
<details class="mb-2" data-testid="tr-advanced">
<summary class="small text-muted">{$t('flights.followByIdAdvanced')}</summary>
<label class="form-label small mb-0 mt-2" for="tr-id">{$t('tracking.satelliteId')}</label>
<div class="input-group input-group-sm">
<input
id="tr-id"
type="text"
class="form-control"
data-testid="tr-id"
placeholder="uuid"
bind:value={satelliteInput}
disabled={telemetryStore.status !== 'idle'} />
{#if telemetryStore.status === 'idle'}
<button
class="btn btn-primary"
data-testid="tr-connect"
onclick={handleConnect}
disabled={!satelliteInput.trim()}>
{$t('tracking.connect')}
</button>
{:else}
<button class="btn btn-secondary" data-testid="tr-disconnect" onclick={handleDisconnect}>
{$t('tracking.disconnect')}
</button>
{/if}
</div>
</details>
<div class="d-flex align-items-center gap-2 mb-2">
<span class="small text-muted">{$t('tracking.status')}</span>
<span class="badge {STATUS_CLASS[telemetryStore.status]}" data-testid="tr-status">
{$t(`tracking.status_${telemetryStore.status}`)}
</span>
</div>
{#if telemetryStore.error}
<div class="alert alert-danger py-1 px-2 small" role="alert" data-testid="tr-error">
{telemetryStore.error}
</div>
{/if}
<!-- Four async states: connecting / empty / error / loaded -->
{#if telemetryStore.latest}
{@const p = telemetryStore.latest}
<dl class="row row-cols-2 g-1 small mb-2" data-testid="tr-readout">
<div class="col">
<dt class="text-muted fw-normal">{$t('predict.latitude')}</dt>
<dd class="mb-0 font-monospace">{p.latitude.toFixed(6)}</dd>
</div>
<div class="col">
<dt class="text-muted fw-normal">{$t('predict.longitude')}</dt>
<dd class="mb-0 font-monospace">{p.longitude.toFixed(6)}</dd>
</div>
<div class="col">
<dt class="text-muted fw-normal">{$t('predict.altitude')}</dt>
<dd class="mb-0 font-monospace">{p.altitude.toFixed(1)}</dd>
</div>
<div class="col">
<dt class="text-muted fw-normal">{$t('tracking.packets')}</dt>
<dd class="mb-0 font-monospace">{telemetryStore.points.length}</dd>
</div>
</dl>
<button
class="btn btn-sm btn-outline-secondary w-100"
data-testid="tr-chart-toggle"
onclick={() => (showChart = !showChart)}>
{showChart ? $t('tracking.hideCharts') : $t('tracking.showCharts')}
</button>
{#if showChart}
<hr class="my-2" />
<DeviationChart points={telemetryStore.points} {prediction} />
{/if}
{:else if telemetryStore.status === 'connecting'}
<div class="small text-muted">
<span class="spinner-border spinner-border-sm me-1"></span>
{$t('tracking.connecting')}
</div>
{:else if telemetryStore.status !== 'idle' && telemetryStore.historyLoaded}
<div class="small text-muted">{$t('tracking.waitingData')}</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,43 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { getMap, plotTelemetry } from '$map';
import { telemetryStore } from './telemetryStore.svelte';
/**
* Draws the live telemetry track in its own scene. Must be a descendant of
* <Map />. The first fix frames the map; later packets only extend the track
* so the user's own panning is not fought.
*/
const map = getMap();
if (!map) throw new Error('TrackRenderer must be a descendant of <Map />');
const SCENE = 'telemetry';
let owned = false;
let framed = false;
$effect(() => {
if (!map) return;
const telemetry = telemetryStore.telemetry;
if (!telemetry) {
if (owned) {
map.disposeScene(SCENE);
owned = false;
}
framed = false;
return;
}
plotTelemetry(map.scene(SCENE), telemetry);
owned = true;
if (!framed && telemetry.flight_path.length > 1) {
map.fitBounds(telemetry.flight_path, 60);
framed = true;
}
});
onDestroy(() => {
if (map && owned) map.disposeScene(SCENE);
});
</script>

View file

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

View file

@ -0,0 +1,107 @@
import { telemetryApi, buildWsUrl, type RawTelemetryPacket } from '$api';
import { parseTelemetry, type TelemetryPoint, type Telemetry } from '$domain';
export type TrackingStatus = 'idle' | 'connecting' | 'connected' | 'error';
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
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)
};
}
/**
* Live telemetry for one satellite: historical packets are loaded over REST,
* then new ones stream in over the read-only WebSocket consumer.
*/
class TelemetryStore {
satelliteId = $state('');
status = $state<TrackingStatus>('idle');
error = $state<string | null>(null);
points = $state<TelemetryPoint[]>([]);
/** History finished loading (so an empty track can be reported as "no data"). */
historyLoaded = $state(false);
#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> {
if (!UUID_RE.test(id)) {
this.status = 'error';
this.error = 'Invalid satellite ID — expected a UUID';
return;
}
this.disconnect();
this.satelliteId = id;
this.status = 'connecting';
this.error = null;
this.points = [];
this.historyLoaded = false;
// History is non-critical: a live-only flight still tracks fine without it.
try {
const history = await telemetryApi.fetchHistory(id);
// The API returns newest-first; reverse into chronological order.
this.points = [...history].reverse().map(toPoint);
} catch {
/* ignore — the socket may still deliver live packets */
}
this.historyLoaded = true;
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;
this.historyLoaded = false;
}
}
export const telemetryStore = new TelemetryStore();

View file

@ -0,0 +1,308 @@
/**
* 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.
*
* This is the one place that touches maplibre-gl outside `$map` it needs
* project/unproject and the canvas container, which the IMap vocabulary
* deliberately does not expose. The renderer passes it via getRawInstance().
*/
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;
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;
p.x = p.xt = x;
p.y = p.yt = y;
if (!this.interp) break;
const ll = this.map.unproject([x, y]);
if (this.interp(ll.lng, ll.lat)) break;
}
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);
}
for (let i = 0; i < buckets.length; i++) {
const bucket = buckets[i];
if (bucket.length === 0) continue;
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();
}
// 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,95 @@
<script lang="ts">
import { t } from '$i18n';
import { windSettings, windState } from './store';
interface Props {
/** Retry hook wired to the renderer's reload(). */
onRetry?: () => void;
}
let { onRetry = () => {} }: Props = $props();
function set<K extends keyof typeof $windSettings>(key: K, value: (typeof $windSettings)[K]) {
windSettings.update((s) => ({ ...s, [key]: value }));
}
</script>
<div class="card shadow-sm">
<div class="card-body p-3">
<div class="form-check form-switch mb-0">
<input
class="form-check-input"
type="checkbox"
id="wind-toggle"
data-testid="wind-toggle"
checked={$windSettings.enabled}
onchange={(e) => set('enabled', e.currentTarget.checked)} />
<label class="form-check-label fw-semibold" for="wind-toggle">{$t('wind.title')}</label>
</div>
{#if $windSettings.enabled}
<!-- Four async states: loading / empty / error+retry / loaded -->
<div class="small mt-2" data-testid="wind-status">
{#if $windState.status === 'loading'}
<span class="text-muted">
<span class="spinner-border spinner-border-sm me-1"></span>
{$t('wind.loading')}
</span>
{:else if $windState.status === 'empty'}
<span class="text-muted">{$t('wind.empty')}</span>
{:else if $windState.status === 'error'}
<div class="alert alert-danger py-1 px-2 mb-0">
<div>{$windState.error}</div>
<button class="btn btn-sm btn-link p-0" data-testid="wind-retry" onclick={onRetry}>
{$t('wind.retry')}
</button>
</div>
{:else if $windState.status === 'loaded'}
<span class="text-success">{$t('wind.loaded', { n: $windState.points })}</span>
{/if}
</div>
<hr class="my-2" />
<label class="form-label small mb-0" for="wind-alt">{$t('wind.altitude')}</label>
<input
id="wind-alt"
type="number"
step="500"
class="form-control form-control-sm mb-2"
value={$windSettings.altitude}
onchange={(e) => set('altitude', Number(e.currentTarget.value))} />
<label class="form-label small mb-0" for="wind-step">{$t('wind.step')}</label>
<input
id="wind-step"
type="number"
step="0.25"
min="0.25"
class="form-control form-control-sm mb-2"
value={$windSettings.step}
onchange={(e) => set('step', Math.max(0.25, Number(e.currentTarget.value)))} />
<label class="form-label small mb-0" for="wind-density">{$t('wind.density')}</label>
<input
id="wind-density"
type="range"
min="0.25"
max="3"
step="0.25"
class="form-range"
value={$windSettings.particleDensity}
oninput={(e) => set('particleDensity', Number(e.currentTarget.value))} />
<label class="form-label small mb-0" for="wind-speed">{$t('wind.speed')}</label>
<input
id="wind-speed"
type="range"
min="0.25"
max="3"
step="0.25"
class="form-range"
value={$windSettings.particleSpeed}
oninput={(e) => set('particleSpeed', Number(e.currentTarget.value))} />
{/if}
</div>
</div>

View file

@ -0,0 +1,120 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import type { Map as MLMap } from 'maplibre-gl';
import { getMap } from '$map';
import { createWindInterpolator } from '$domain';
import { windSettings, windState, windCache } from './store';
import { ParticleField } from './ParticleField';
/**
* Fetches the wind field for the current viewport and drives the particle
* overlay. Refetches (debounced) when the map settles on a new view or when
* the layer settings change. Must be a descendant of <Map />.
*/
const map = getMap();
if (!map) throw new Error('WindRenderer must be a descendant of <Map />');
// The particle layer needs project/unproject + the canvas container, which the
// IMap vocabulary intentionally does not expose (see ParticleField docs).
const raw = map.getRawInstance() as MLMap;
let field: ParticleField | null = null;
let debounce: ReturnType<typeof setTimeout> | null = null;
let generation = 0;
function round(n: number): number {
return Math.round(n * 100) / 100;
}
async function load() {
if (!map) return;
const bounds = raw.getBounds();
const settings = $windSettings;
const gen = ++generation;
windState.set({ status: 'loading', error: null, points: 0 });
try {
const data = await windCache.fetch({
min_lat: round(Math.max(-90, bounds.getSouth())),
max_lat: round(Math.min(90, bounds.getNorth())),
min_lng: round(bounds.getWest()),
max_lng: round(bounds.getEast()),
step: settings.step,
altitude: settings.altitude
});
if (gen !== generation) return; // superseded by a newer request
const points = data?.[0]?.data?.length ?? 0;
if (!points) {
field?.setField(null);
windState.set({ status: 'empty', error: null, points: 0 });
return;
}
field?.setField(createWindInterpolator(data));
windState.set({ status: 'loaded', error: null, points });
} catch (err) {
if (gen !== generation) return;
field?.setField(null);
windState.set({ status: 'error', error: (err as Error).message, points: 0 });
}
}
function scheduleLoad(delay = 400) {
if (debounce) clearTimeout(debounce);
debounce = setTimeout(load, delay);
}
/** Retry entry point for the panel's error state. */
export function reload() {
scheduleLoad(0);
}
// Enable/disable the whole layer, and refetch when the viewport settles.
$effect(() => {
if (!$windSettings.enabled) {
field?.destroy();
field = null;
windState.set({ status: 'idle', error: null, points: 0 });
return;
}
if (!field) {
field = new ParticleField(raw, {
density: $windSettings.particleDensity,
speed: $windSettings.particleSpeed,
trailPersistence: $windSettings.trailPersistence,
maxVelocity: $windSettings.maxVelocity
});
field.start();
}
scheduleLoad(0);
const onMoveEnd = () => scheduleLoad();
raw.on('moveend', onMoveEnd);
return () => raw.off('moveend', onMoveEnd);
});
// Grid resolution / altitude change the request itself — refetch.
$effect(() => {
const { enabled, step, altitude } = $windSettings;
void step;
void altitude;
if (enabled) scheduleLoad(0);
});
// Live-apply cosmetic settings without refetching.
$effect(() => {
field?.setOptions({
density: $windSettings.particleDensity,
speed: $windSettings.particleSpeed,
trailPersistence: $windSettings.trailPersistence,
maxVelocity: $windSettings.maxVelocity
});
});
onDestroy(() => {
if (debounce) clearTimeout(debounce);
field?.destroy();
field = null;
});
</script>

View file

@ -0,0 +1,5 @@
export { default as WindRenderer } from './WindRenderer.svelte';
export { default as WindPanel } from './WindPanel.svelte';
export { windSettings, windState, windCache } from './store';
export type { WindStatus, WindState } from './store';
export { ParticleField } from './ParticleField';

View file

@ -0,0 +1,66 @@
import { writable } from 'svelte/store';
import { persisted } from '$state';
import { DEFAULT_WIND_SETTINGS, type WindField, type WindSettings } from '$domain';
import { windApi, type WindFieldParams } from '$api';
/** User-facing wind layer settings (persisted, synced across tabs). */
export const windSettings = persisted<WindSettings>('wind-settings', DEFAULT_WIND_SETTINGS);
/** Every async surface has four states (guidelines §9). */
export type WindStatus = 'idle' | 'loading' | 'empty' | 'error' | 'loaded';
export interface WindState {
status: WindStatus;
error: string | null;
/** Number of grid points in the last loaded field (0 => empty). */
points: number;
}
export const windState = writable<WindState>({ status: 'idle', error: null, points: 0 });
function cacheKey(p: WindFieldParams): string {
return JSON.stringify([p.min_lat, p.max_lat, p.min_lng, p.max_lng, p.step, p.altitude, p.time]);
}
/**
* Thin cache over wind field responses: the same (bbox, step, altitude, time)
* combination is fetched once per session, and concurrent requests share one
* in-flight promise. The predictor's dataset does not change mid-session, so
* entries are never invalidated.
*/
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();

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

@ -0,0 +1,58 @@
import { writable, derived, get } from 'svelte/store';
export type Locale = 'ru' | 'en';
type Dict = Record<string, unknown>;
const loaders: Record<Locale, () => Promise<{ default: Dict }>> = {
ru: () => import('./locales/ru.json'),
en: () => import('./locales/en.json')
};
const LOCALE_KEY = 'locale';
export const locale = writable<Locale>('ru');
const messages = writable<Dict>({});
function lookup(dict: Dict, path: string): string {
const val = path
.split('.')
.reduce<unknown>((acc, k) => (acc && typeof acc === 'object' ? (acc as Dict)[k] : undefined), dict);
return typeof val === 'string' ? val : path;
}
function interpolate(s: string, params?: Record<string, string | number>): string {
return params ? s.replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? `{${k}}`)) : s;
}
/** Reactive translator: `$t('login.heading')`. Falls back to the key when missing. */
export const t = derived(
messages,
($m) =>
(key: string, params?: Record<string, string | number>) =>
interpolate(lookup($m, key), params)
);
export async function initI18n(): Promise<void> {
let loc: Locale = 'ru';
try {
const saved = localStorage.getItem(LOCALE_KEY);
if (saved === 'ru' || saved === 'en') loc = saved;
} catch {
/* localStorage unavailable — keep default */
}
await setLocale(loc);
}
export async function setLocale(loc: Locale): Promise<void> {
const mod = await loaders[loc]();
messages.set(mod.default);
locale.set(loc);
try {
localStorage.setItem(LOCALE_KEY, loc);
} catch {
/* ignore */
}
}
export function currentLocale(): Locale {
return get(locale);
}

View file

@ -0,0 +1,324 @@
{
"app": {
"title": "StratoFlights",
"subtitle": "Weather-balloon trajectory prediction & tracking",
"loggedInAs": "Logged in as {name}",
"foundationNote": "Foundation ready. Map & prediction land in M3M4."
},
"nav": {
"predict": "Prediction",
"track": "Tracking",
"menu": "Menu",
"account": "Account",
"login": "Log in",
"logout": "Log out"
},
"login": {
"heading": "Sign in",
"username": "Username",
"password": "Password",
"submit": "Log in",
"submitting": "Signing in…",
"noAccount": "No account?",
"registerLink": "Sign up",
"fieldsRequired": "Please fill in all fields",
"invalidCredentials": "Invalid credentials"
},
"register": {
"heading": "Create account",
"username": "Username",
"email": "Email",
"password": "Password",
"passwordConfirm": "Repeat password",
"submit": "Sign up",
"submitting": "Creating…",
"haveAccount": "Already have an account?",
"loginLink": "Log in",
"fieldsRequired": "Please fill in all fields",
"usernameShort": "Username must be at least 3 characters",
"emailInvalid": "Invalid email address",
"passwordShort": "Password must be at least 8 characters",
"passwordMismatch": "Passwords do not match",
"failed": "Registration failed"
},
"account": {
"heading": "Account",
"username": "Username",
"email": "Email",
"saveEmail": "Save",
"emailSaved": "Email updated",
"changePassword": "Change password",
"oldPassword": "Current password",
"newPassword": "New password",
"changePasswordSubmit": "Change password",
"passwordChanged": "Password changed",
"dangerZone": "Danger zone",
"deleteData": "Delete my data",
"deleteDataDone": "Data deleted",
"deleteAccount": "Delete account",
"deleteAccountConfirm": "Enter your password to confirm",
"confirm": "Confirm",
"cancel": "Cancel",
"failed": "Something went wrong"
},
"predict": {
"conditions": "Launch parameters",
"launchPoint": "Launch point",
"latitude": "Latitude",
"longitude": "Longitude",
"altitude": "Altitude, m",
"launchDate": "Date (UTC)",
"launchTime": "Time (UTC)",
"profile": "Profile",
"ascentRate": "Ascent, m/s",
"burstAltitude": "Burst altitude, m",
"descentRate": "Descent, m/s",
"run": "Run",
"running": "Running…",
"noActive": "Add a scenario to set parameters",
"template": "Template",
"templateNone": "— no template —",
"pointNone": "— custom point —",
"custom": "Custom",
"modified": "modified",
"update": "Update",
"saveAsNew": "Save as new…",
"save": "Save",
"name": "Name",
"pickOnMap": "Pick on map",
"pickCancel": "Cancel pick",
"pickBanner": "Click the map to set the launch point (Esc to cancel)",
"pointSaved": "Point saved",
"pointUpdated": "Point updated",
"templateSaved": "Template saved",
"templateUpdated": "Template updated",
"startTime": "Start time (UTC)",
"startDate": "Start date",
"flightProfile": "Flight profile",
"launchSite": "Launch site",
"latLng": "Latitude/Longitude",
"launchAlt": "Launch altitude (m)",
"burstAlt": "Burst altitude (m)",
"profileEdit": "Ascent and descent profiles",
"ascentStage": "Ascent stage",
"descentStage": "Descent stage",
"openCurveEditor": "Open curve editor",
"runPrediction": "Run prediction",
"metresPerSecond": "m/s"
},
"scenarios": {
"title": "Scenarios",
"add": "Add",
"empty": "No scenarios",
"visible": "Visibility",
"delete": "Delete",
"running": "Running…",
"flightTime": "Flight time",
"landing": "Landing",
"error": "Prediction failed",
"min": "min",
"hour": "h",
"name": "Name",
"color": "Track colour",
"makeActive": "Make active"
},
"settings": {
"title": "Settings",
"general": "General",
"language": "Language",
"lang_ru": "Русский",
"lang_en": "English",
"map": "Map",
"baseLayer": "Base layer",
"baseLayer_osm": "Street (OSM)",
"baseLayer_satellite": "Satellite",
"format": "Format",
"units": "Units",
"units_metric": "Metric (SI)",
"units_imperial": "Imperial",
"coords": "Coordinates",
"coords_dd": "Decimal degrees (DD)",
"coords_dms": "Degrees/minutes (DMS)",
"time": "Time",
"time_utc": "UTC",
"time_local": "Local"
},
"curve": {
"title": "Curve editor",
"open": "Curve editor",
"time": "Time, s",
"alt": "Altitude, m",
"rate": "Rate, m/s",
"timeAxis": "Time, s",
"altAxis": "Altitude, m",
"altitude": "Altitude",
"addPoint": "Add point",
"importCsv": "CSV",
"exportCsv": "Export",
"import": "Import",
"apply": "Apply",
"impliedRates": "Implied rates",
"csvEmpty": "No points found in the CSV",
"csvImported": "Imported {n} points",
"errTooFewPoints": "At least two points are required",
"errNonMonotonicTime": "Time must strictly increase",
"errNegativeTime": "Time cannot be negative",
"errNegativeAltitude": "Altitude cannot be negative"
},
"compare": {
"title": "Comparison",
"empty": "Run at least one visible scenario",
"scenario": "Scenario",
"spread": "Landing spread"
},
"tracking": {
"title": "Tracking",
"satelliteId": "Satellite ID",
"connect": "Follow",
"disconnect": "Disconnect",
"status": "Status",
"status_idle": "not connected",
"status_connecting": "connecting",
"status_connected": "live",
"status_error": "error",
"connecting": "Connecting…",
"waitingData": "Waiting for packets…",
"packets": "Packets",
"showCharts": "Show charts",
"hideCharts": "Hide charts",
"noData": "No telemetry data",
"altProfile": "Altitude profile",
"horizontalDev": "Horizontal deviation",
"selectPrediction": "Run a scenario to compare against the prediction",
"devMax": "Max:",
"devCurrent": "Current:",
"actual": "Actual, m",
"predicted": "Predicted, m",
"altitudeAxis": "Altitude, m",
"deviationAxis": "Deviation, km",
"km": "km",
"m": "m"
},
"timeline": {
"play": "Play",
"pause": "Pause",
"stop": "Rewind",
"speed": "Speed",
"scrub": "Scrub"
},
"wind": {
"title": "Wind",
"loading": "Loading wind field…",
"empty": "No wind data for this area",
"retry": "Retry",
"loaded": "Field loaded ({n} points)",
"altitude": "Altitude, m",
"step": "Grid step, °",
"density": "Particle density",
"speed": "Animation speed"
},
"profile": {
"standard_profile": "Standard",
"float_profile": "Float",
"reverse_profile": "Reverse",
"custom_profile": "Custom"
},
"panels": {
"conditions": "Launch parameters",
"panels": "Panels"
},
"history": {
"title": "Prediction history",
"empty": "No predictions yet",
"date": "Date (UTC)",
"details": "Details",
"status": "Status",
"loadIntoScenario": "To scenario",
"loadedIntoScenario": "Loaded into a new scenario",
"fromHistory": "From history",
"noRequest": "This record has no stored parameters",
"deleted": "Prediction deleted",
"undo": "Undo",
"broken": "No data",
"prev": "Previous",
"next": "Next",
"share": "Share",
"sharedTitle": "Shared prediction",
"sharedNotFound": "This link is invalid or has been revoked"
},
"admin": {
"title": "Admin",
"users": "Users",
"username": "User",
"searchPlaceholder": "Search by name or email",
"noUsers": "No users found",
"joined": "Joined",
"state": "State",
"active": "active",
"inactive": "disabled",
"activate": "Activate",
"deactivate": "Deactivate",
"activated": "User activated",
"deactivated": "User deactivated",
"staff": "staff",
"you": "you"
},
"flights": {
"liveTitle": "Live flights",
"filter": "Filter by name",
"noneLive": "No public flights",
"untitled": "Untitled",
"loading": "Loading flight…",
"notFound": "Flight not found",
"backToList": "Back to flights",
"privacy": "Access",
"privacyUpdated": "Privacy updated",
"copyLink": "Copy link",
"linkCopied": "Link copied",
"linkCopyFailed": "Could not copy",
"tier_public": "public",
"tier_unlisted": "unlisted",
"tier_private": "private",
"followByIdAdvanced": "Follow by ID (advanced)"
},
"ensemble": {
"run": "GEFS ensemble",
"cancel": "Cancel run",
"members": "Members",
"spread": "Spread",
"failed": "Failed"
},
"profileBuilder": {
"title": "Flight profile",
"reset": "Reset",
"addStage": "Add stage",
"stageName": "Stage name",
"collapse": "Collapse",
"moveUp": "Move up",
"moveDown": "Move down",
"solver": "Solver",
"solver_constant_rate": "Constant rate",
"solver_parachute_descent": "Parachute descent",
"solver_piecewise": "Piecewise curve",
"solver_wind": "Wind drift",
"rate": "Rate, m/s",
"seaLevelRate": "Sea-level rate, m/s",
"advanceWhen": "Advance when",
"abortIf": "Abort if",
"fallbackTo": "Fall back to",
"constraintType": "Condition",
"operator": "Operator",
"limit": "Value",
"constraint_altitude": "Altitude",
"constraint_time": "Time, s",
"constraint_terrain_contact": "Terrain contact",
"constraint_polygon": "Polygon",
"desktopOnly": "The profile builder is available on wide screens only",
"errNoStages": "At least one stage is required",
"errNoExit": "A stage has no exit condition — it would never finish",
"errMissingFallback": "The fallback stage does not exist",
"errIncompleteConstraint": "A condition is missing its operator or value",
"errDuplicateName": "Stage names must be unique",
"errUnnamedStage": "A stage has a blank name"
}
}

View file

@ -0,0 +1,324 @@
{
"app": {
"title": "StratoFlights",
"subtitle": "Прогнозирование и трекинг траекторий метеозондов",
"loggedInAs": "Вы вошли как {name}",
"foundationNote": "Каркас готов. Карта и прогноз появятся на этапах M3M4."
},
"nav": {
"predict": "Прогнозирование",
"track": "Трекинг",
"menu": "Меню",
"account": "Аккаунт",
"login": "Вход",
"logout": "Выход"
},
"login": {
"heading": "Вход в систему",
"username": "Имя пользователя",
"password": "Пароль",
"submit": "Войти",
"submitting": "Вход…",
"noAccount": "Нет аккаунта?",
"registerLink": "Регистрация",
"fieldsRequired": "Заполните все поля",
"invalidCredentials": "Неверные учётные данные"
},
"register": {
"heading": "Регистрация",
"username": "Имя пользователя",
"email": "Электронная почта",
"password": "Пароль",
"passwordConfirm": "Повторите пароль",
"submit": "Зарегистрироваться",
"submitting": "Регистрация…",
"haveAccount": "Уже есть аккаунт?",
"loginLink": "Войти",
"fieldsRequired": "Заполните все поля",
"usernameShort": "Имя пользователя не короче 3 символов",
"emailInvalid": "Некорректный адрес почты",
"passwordShort": "Пароль не короче 8 символов",
"passwordMismatch": "Пароли не совпадают",
"failed": "Не удалось зарегистрироваться"
},
"account": {
"heading": "Аккаунт",
"username": "Имя пользователя",
"email": "Электронная почта",
"saveEmail": "Сохранить",
"emailSaved": "Почта обновлена",
"changePassword": "Смена пароля",
"oldPassword": "Текущий пароль",
"newPassword": "Новый пароль",
"changePasswordSubmit": "Изменить пароль",
"passwordChanged": "Пароль изменён",
"dangerZone": "Опасная зона",
"deleteData": "Удалить мои данные",
"deleteDataDone": "Данные удалены",
"deleteAccount": "Удалить аккаунт",
"deleteAccountConfirm": "Введите пароль для подтверждения",
"confirm": "Подтвердить",
"cancel": "Отмена",
"failed": "Ошибка"
},
"predict": {
"conditions": "Параметры запуска",
"launchPoint": "Точка старта",
"latitude": "Широта",
"longitude": "Долгота",
"altitude": "Высота, м",
"launchDate": "Дата (UTC)",
"launchTime": "Время (UTC)",
"profile": "Профиль",
"ascentRate": "Подъём, м/с",
"burstAltitude": "Высота разрыва, м",
"descentRate": "Спуск, м/с",
"run": "Рассчитать",
"running": "Расчёт…",
"noActive": "Добавьте сценарий, чтобы задать параметры",
"template": "Шаблон",
"templateNone": "— без шаблона —",
"pointNone": "— своя точка —",
"custom": "Своя точка",
"modified": "изменено",
"update": "Обновить",
"saveAsNew": "Сохранить как…",
"save": "Сохранить",
"name": "Название",
"pickOnMap": "Указать на карте",
"pickCancel": "Отменить выбор",
"pickBanner": "Кликните по карте, чтобы задать точку старта (Esc — отмена)",
"pointSaved": "Точка сохранена",
"pointUpdated": "Точка обновлена",
"templateSaved": "Шаблон сохранён",
"templateUpdated": "Шаблон обновлён",
"startTime": "Время старта (UTC)",
"startDate": "Дата старта",
"flightProfile": "Профиль полета",
"launchSite": "Место старта",
"latLng": "Широта/Долгота",
"launchAlt": "Высота старта (м)",
"burstAlt": "Высота разрыва (м)",
"profileEdit": "Профили подъема и спуска",
"ascentStage": "Стадия подъема",
"descentStage": "Стадия спуска",
"openCurveEditor": "Открыть редактор кривых",
"runPrediction": "Выполнить прогнозирование",
"metresPerSecond": "м/с"
},
"scenarios": {
"title": "Сценарии",
"add": "Добавить",
"empty": "Нет сценариев",
"visible": "Видимость",
"delete": "Удалить",
"running": "Расчёт…",
"flightTime": "Время полёта",
"landing": "Приземление",
"error": "Ошибка расчёта",
"min": "мин",
"hour": "ч",
"name": "Название",
"color": "Цвет трека",
"makeActive": "Сделать активным"
},
"settings": {
"title": "Настройки",
"general": "Общие",
"language": "Язык",
"lang_ru": "Русский",
"lang_en": "English",
"map": "Карта",
"baseLayer": "Подложка",
"baseLayer_osm": "Схема (OSM)",
"baseLayer_satellite": "Спутник",
"format": "Формат",
"units": "Единицы",
"units_metric": "Метрические (СИ)",
"units_imperial": "Имперские",
"coords": "Координаты",
"coords_dd": "Градусы (DD)",
"coords_dms": "Градусы/минуты (DMS)",
"time": "Время",
"time_utc": "UTC",
"time_local": "Местное"
},
"curve": {
"title": "Редактор кривой",
"open": "Редактор кривой",
"time": "Время, с",
"alt": "Высота, м",
"rate": "Скорость, м/с",
"timeAxis": "Время, с",
"altAxis": "Высота, м",
"altitude": "Высота",
"addPoint": "Добавить точку",
"importCsv": "CSV",
"exportCsv": "Экспорт",
"import": "Импортировать",
"apply": "Применить",
"impliedRates": "Расчётные скорости",
"csvEmpty": "Не найдено ни одной точки в CSV",
"csvImported": "Импортировано точек: {n}",
"errTooFewPoints": "Нужно минимум две точки",
"errNonMonotonicTime": "Время должно строго возрастать",
"errNegativeTime": "Время не может быть отрицательным",
"errNegativeAltitude": "Высота не может быть отрицательной"
},
"compare": {
"title": "Сравнение",
"empty": "Рассчитайте хотя бы один видимый сценарий",
"scenario": "Сценарий",
"spread": "Разброс приземлений"
},
"tracking": {
"title": "Трекинг",
"satelliteId": "ID аппарата",
"connect": "Следить",
"disconnect": "Отключить",
"status": "Статус",
"status_idle": "не подключено",
"status_connecting": "подключение",
"status_connected": "на связи",
"status_error": "ошибка",
"connecting": "Подключение…",
"waitingData": "Ожидание пакетов…",
"packets": "Пакетов",
"showCharts": "Показать графики",
"hideCharts": "Скрыть графики",
"noData": "Нет данных телеметрии",
"altProfile": "Профиль высоты",
"horizontalDev": "Отклонение по горизонтали",
"selectPrediction": "Рассчитайте сценарий, чтобы сравнить с прогнозом",
"devMax": "Макс.:",
"devCurrent": "Текущее:",
"actual": "Факт, м",
"predicted": "Прогноз, м",
"altitudeAxis": "Высота, м",
"deviationAxis": "Откл., км",
"km": "км",
"m": "м"
},
"timeline": {
"play": "Воспроизвести",
"pause": "Пауза",
"stop": "В начало",
"speed": "Скорость",
"scrub": "Перемотка"
},
"wind": {
"title": "Ветер",
"loading": "Загрузка поля ветра…",
"empty": "Нет данных о ветре для этой области",
"retry": "Повторить",
"loaded": "Поле загружено ({n} узлов)",
"altitude": "Высота, м",
"step": "Шаг сетки, °",
"density": "Плотность частиц",
"speed": "Скорость анимации"
},
"profile": {
"standard_profile": "Стандартный",
"float_profile": "Флоат",
"reverse_profile": "Обратный",
"custom_profile": "Кастомный"
},
"panels": {
"conditions": "Параметры запуска",
"panels": "Панели"
},
"history": {
"title": "История прогнозов",
"empty": "Прогнозов пока нет",
"date": "Дата (UTC)",
"details": "Детали",
"status": "Статус",
"loadIntoScenario": "В сценарий",
"loadedIntoScenario": "Загружено в новый сценарий",
"fromHistory": "Из истории",
"noRequest": "У записи нет сохранённых параметров",
"deleted": "Прогноз удалён",
"undo": "Отменить",
"broken": "Нет данных",
"prev": "Назад",
"next": "Вперёд",
"share": "Поделиться",
"sharedTitle": "Общий прогноз",
"sharedNotFound": "Ссылка недействительна или отозвана"
},
"admin": {
"title": "Админка",
"users": "Пользователи",
"username": "Пользователь",
"searchPlaceholder": "Поиск по имени или почте",
"noUsers": "Пользователи не найдены",
"joined": "Регистрация",
"state": "Состояние",
"active": "активен",
"inactive": "отключён",
"activate": "Включить",
"deactivate": "Отключить",
"activated": "Пользователь включён",
"deactivated": "Пользователь отключён",
"staff": "staff",
"you": "вы"
},
"flights": {
"liveTitle": "Активные полёты",
"filter": "Фильтр по названию",
"noneLive": "Нет публичных полётов",
"untitled": "Без названия",
"loading": "Загрузка полёта…",
"notFound": "Полёт не найден",
"backToList": "К списку полётов",
"privacy": "Доступ",
"privacyUpdated": "Уровень доступа изменён",
"copyLink": "Скопировать ссылку",
"linkCopied": "Ссылка скопирована",
"linkCopyFailed": "Не удалось скопировать",
"tier_public": "публичный",
"tier_unlisted": "по ссылке",
"tier_private": "приватный",
"followByIdAdvanced": "Следить по ID (расширенное)"
},
"ensemble": {
"run": "Ансамбль GEFS",
"cancel": "Отменить расчёт",
"members": "Участников",
"spread": "Разброс",
"failed": "Ошибок"
},
"profileBuilder": {
"title": "Профиль полёта",
"reset": "Сбросить",
"addStage": "Добавить стадию",
"stageName": "Название стадии",
"collapse": "Свернуть",
"moveUp": "Выше",
"moveDown": "Ниже",
"solver": "Солвер",
"solver_constant_rate": "Постоянная скорость",
"solver_parachute_descent": "Спуск с парашютом",
"solver_piecewise": "Кусочная кривая",
"solver_wind": "Снос ветром",
"rate": "Скорость, м/с",
"seaLevelRate": "Скорость у земли, м/с",
"advanceWhen": "Переход при",
"abortIf": "Прервать если",
"fallbackTo": "Перейти к",
"constraintType": "Условие",
"operator": "Оператор",
"limit": "Значение",
"constraint_altitude": "Высота",
"constraint_time": "Время, с",
"constraint_terrain_contact": "Касание земли",
"constraint_polygon": "Полигон",
"desktopOnly": "Редактор профиля доступен только на широком экране",
"errNoStages": "Нужна хотя бы одна стадия",
"errNoExit": "У стадии нет условия выхода — она никогда не завершится",
"errMissingFallback": "Резервная стадия не найдена",
"errIncompleteConstraint": "У условия не задан оператор или значение",
"errDuplicateName": "Названия стадий должны быть уникальны",
"errUnnamedStage": "У стадии пустое название"
}
}

83
src/lib/map/Map.svelte Normal file
View file

@ -0,0 +1,83 @@
<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; dev builds only.
(window as unknown as { _sfMap?: unknown })._sfMap = map.getRawInstance();
}
}
});
});
onDestroy(() => {
map?.dispose();
map = null;
ready = false;
});
</script>
<div class="map-container" bind:this={container}>
{#if ready && map}
{@render children?.()}
{/if}
</div>
<style>
.map-container {
position: relative;
width: 100%;
height: 100%;
}
</style>

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('sf-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;
}

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

@ -0,0 +1,120 @@
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,
* circles, events) so features can be tested against the interface alone.
* - Support "scenes" named collections of layers owned by a feature so each
* scenario/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 (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 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;

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

@ -0,0 +1,5 @@
export * from './core';
export * from './context';
export * from './layers';
export { createMapLibreMap, MapLibreMap } from './maplibre';
export { default as Map } from './Map.svelte';

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

@ -0,0 +1,185 @@
import type { BoundingBox, Prediction, Telemetry, EnsembleResult } from '$domain';
import { boundingBoxRing, toLngLat, ellipseRing } from '$domain';
import type { Scene } from './core';
/**
* Plot helpers for high-level domain objects, built on Scene so they work
* against any IMap implementation.
*/
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'
};
/** Draw a prediction (path + launch/burst/landing markers) into its scene. */
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)}`
});
}
/**
* Draw a real tracked flight. Actual is **red** by convention (predicted is
* blue) see guidelines §7.4; scenario identity colours use a third palette.
*/
export function plotTelemetry(scene: Scene, telemetry: Telemetry, color = '#FF1744'): void {
scene.clear();
scene.addLine('path', { coords: telemetry.flight_path, color, width: 3 });
scene.addMarker('launch', {
lngLat: toLngLat(telemetry.launch.latlng),
iconUrl: '/target-blue.png',
iconSize: [12, 12],
popupHtml: `<b>Launch</b><br>${telemetry.launch.latlng.lat.toFixed(6)}, ${telemetry.launch.latlng.lng.toFixed(6)}`
});
const last = telemetry.datapoints[telemetry.datapoints.length - 1];
if (last) {
// Current position gets a live marker rather than one dot per sample, so
// long flights stay cheap to render.
scene.addCircle('current-ring', {
center: [last.longitude, last.latitude],
radiusPx: 13,
color,
opacity: 0.3,
strokeWidth: 0
});
scene.addCircle('current', {
center: [last.longitude, last.latitude],
radiusPx: 6,
color,
strokeColor: '#ffffff',
strokeWidth: 2
});
}
}
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
});
}
export interface BoundingBoxStyle {
color?: string;
width?: number;
opacity?: number;
dashArray?: [number, number];
}
/** Draw a bounding box as a closed dotted polyline (blue by default). */
export function plotBoundingBox(scene: Scene, box: BoundingBox, style: BoundingBoxStyle = {}): void {
scene.clear();
scene.addLine('box', {
coords: boundingBoxRing(box),
color: style.color ?? '#0d6efd',
width: style.width ?? 3,
opacity: style.opacity ?? 1,
dashArray: style.dashArray ?? [2, 2]
});
}
/**
* Draw a probabilistic landing footprint: one dot per ensemble member, the mean
* landing point, and the 95% confidence ellipse around them.
*/
export function plotEnsemble(scene: Scene, ensemble: EnsembleResult, color = '#6f42c1'): void {
scene.clear();
const footprint = ensemble.footprint;
if (footprint) {
const ring = ellipseRing(footprint);
if (ring.length > 0) {
scene.addLine('ellipse', { coords: ring, color, width: 2, opacity: 0.9, dashArray: [2, 2] });
}
scene.addCircle('mean', {
center: [footprint.mean.lng, footprint.mean.lat],
radiusPx: 7,
color,
strokeColor: '#ffffff',
strokeWidth: 2
});
}
for (const m of ensemble.members) {
if (!m.landing) continue;
scene.addCircle(`m-${m.member}`, {
center: [m.landing.lng, m.landing.lat],
radiusPx: 4,
color,
opacity: 0.55,
strokeWidth: 0
});
}
}

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

@ -0,0 +1,322 @@
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 { LngLatTuple, LatLngTuple } 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 ?? 'sf-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);
}

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

@ -0,0 +1 @@
export * from './persisted';

View file

@ -0,0 +1,66 @@
import { writable, type Writable } from 'svelte/store';
export interface PersistedOptions<T> {
/** Custom (de)serialization — e.g. to strip transient fields before writing. */
serializer?: {
stringify: (value: T) => string;
parse: (raw: string) => T;
};
}
/**
* localStorage-backed writable that also syncs across tabs via BroadcastChannel.
* Reads are guarded so it is safe to construct under jsdom / SSR-off.
*/
export function persisted<T>(
key: string,
initial: T,
options: PersistedOptions<T> = {}
): Writable<T> {
const stringify = options.serializer?.stringify ?? ((v: T) => JSON.stringify(v));
const parse = options.serializer?.parse ?? ((raw: string) => JSON.parse(raw) as T);
const read = (): T => {
try {
const raw = localStorage.getItem(key);
return raw === null ? initial : parse(raw);
} catch {
return initial;
}
};
const store = writable<T>(read());
const channel =
typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel(`persist:${key}`) : null;
// Guards against two cross-tab hazards:
// - re-broadcasting a value that arrived from another tab (endless echo,
// since object values never compare equal and always re-notify), and
// - broadcasting the value read at construction, which would let a newly
// opened tab push its stale snapshot over tabs that had moved on.
let applyingRemote = false;
let firstNotification = true;
if (channel) {
channel.onmessage = (e) => {
applyingRemote = true;
store.set(e.data as T);
applyingRemote = false;
};
}
store.subscribe((value) => {
if (firstNotification) {
firstNotification = false;
return;
}
try {
localStorage.setItem(key, stringify(value));
if (!applyingRemote) channel?.postMessage(value);
} catch {
/* storage unavailable — keep in-memory only */
}
});
return store;
}

View file

@ -0,0 +1,69 @@
<script lang="ts">
import type { Snippet } from 'svelte';
/**
* Form subsection: the label doubles as the collapse toggle, and the rule that
* runs out of it is the top edge of the box holding the fields — a fieldset
* with a legend, built from Bootstrap borders.
*
* Grouping is by cognitive chunk and never nests deeper than one level inside
* a panel (guidelines §8), so this takes no nested-group affordances.
*/
interface Props {
label: string;
/** Sections start open: the panel is the primary work surface, not a menu. */
expanded?: boolean;
testid?: string;
children: Snippet;
}
let { label, expanded = $bindable(true), testid, children }: Props = $props();
</script>
<div class="spoiler mb-2" data-testid={testid}>
<button
type="button"
class="spoiler-header btn btn-link d-flex align-items-center w-100 p-0 text-decoration-none"
aria-expanded={expanded}
aria-label={label}
onclick={() => (expanded = !expanded)}>
<!-- aria-expanded already carries the state, so the glyph is decoration. -->
<span class="spoiler-icon font-monospace fs-5 fw-bold text-muted ms-1" aria-hidden="true">
{expanded ? '' : '+'}
</span>
<span class="small text-nowrap ms-1">{label}</span>
<span class="spoiler-rule flex-fill border-top ms-2"></span>
</button>
{#if expanded}
<div class="spoiler-body border border-top-0 px-2 pb-2">
{@render children()}
</div>
{:else}
<div class="spoiler-spacer"></div>
{/if}
</div>
<style>
/*
* The header overlaps the body by half its height so the rule beside the
* label lands exactly on the box's missing top border; the body pays the
* overlap back as top padding.
*/
.spoiler-header {
margin-bottom: -0.75em;
}
.spoiler-body {
padding-top: 0.75em;
}
.spoiler-spacer {
padding-top: 0.75em;
}
.spoiler-icon {
line-height: 1;
padding-bottom: 0.1em;
}
.spoiler-header:hover .spoiler-icon {
color: var(--bs-body-color) !important;
}
</style>

24
src/lib/ui/Toast.svelte Normal file
View file

@ -0,0 +1,24 @@
<script lang="ts">
import { toasts, dismissToast } from './toasts';
const kindClass: Record<string, string> = {
success: 'text-bg-success',
error: 'text-bg-danger',
info: 'text-bg-secondary'
};
</script>
<div class="toast-container position-fixed bottom-0 end-0 p-3" style="z-index: 1080">
{#each $toasts as toast (toast.id)}
<div class="toast show align-items-center border-0 {kindClass[toast.kind]}" role="alert">
<div class="d-flex">
<div class="toast-body">{toast.message}</div>
<button
type="button"
class="btn-close btn-close-white me-2 m-auto"
aria-label="close"
onclick={() => dismissToast(toast.id)}></button>
</div>
</div>
{/each}
</div>

12
src/lib/ui/download.ts Normal file
View file

@ -0,0 +1,12 @@
/** Trigger a browser download for generated text (exports). */
export function downloadText(filename: string, mime: string, text: string): void {
const url = URL.createObjectURL(new Blob([text], { type: mime }));
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
// Revoke on the next tick so the click has already started the download.
setTimeout(() => URL.revokeObjectURL(url), 0);
}

4
src/lib/ui/index.ts Normal file
View file

@ -0,0 +1,4 @@
export { default as Toast } from './Toast.svelte';
export { default as SpoilerGroup } from './SpoilerGroup.svelte';
export * from './toasts';
export * from './download';

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

@ -0,0 +1,23 @@
import { writable } from 'svelte/store';
export type ToastKind = 'success' | 'error' | 'info';
export interface Toast {
id: number;
kind: ToastKind;
message: string;
}
const store = writable<Toast[]>([]);
let seq = 0;
export const toasts = { subscribe: store.subscribe };
export function addToast(kind: ToastKind, message: string, ttl = 4000): void {
const id = ++seq;
store.update((list) => [...list, { id, kind, message }]);
if (ttl > 0) setTimeout(() => dismissToast(id), ttl);
}
export function dismissToast(id: number): void {
store.update((list) => list.filter((t) => t.id !== id));
}

22
src/routes/+layout.svelte Normal file
View file

@ -0,0 +1,22 @@
<script lang="ts">
import 'bootstrap-icons/font/bootstrap-icons.css';
import '../app.scss';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { setLocale } from '$i18n';
import { Toast } from '$ui';
import { settingsStore } from '$features/settings';
let ready = $state(false);
let { children } = $props();
onMount(() => {
// Settings are the single source of truth for locale.
setLocale(get(settingsStore).locale).then(() => (ready = true));
});
</script>
{#if ready}
{@render children()}
<Toast />
{/if}

3
src/routes/+layout.ts Normal file
View file

@ -0,0 +1,3 @@
export const ssr = false;
export const prerender = false;
export const trailingSlash = 'always';

14
src/routes/+page.svelte Normal file
View file

@ -0,0 +1,14 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$auth';
onMount(async () => {
const s = await authStore.refresh();
goto(s.status === 'authenticated' ? '/app' : '/login');
});
</script>
<div class="d-flex justify-content-center align-items-center vh-100">
<div class="spinner-border" role="status"><span class="visually-hidden"></span></div>
</div>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Navbar } from '$features/auth';
import { AdminUsersPanel } from '$features/admin';
import { requireStaff } from '$auth';
import { t } from '$i18n';
let ok = $state(false);
// Non-staff (and anonymous) visitors never see the panel: the guard redirects,
// and the API refuses them anyway.
onMount(async () => {
ok = await requireStaff('/app');
});
</script>
<Navbar />
<div style="height: var(--navbar-height)"></div>
{#if ok}
<main class="container py-4">
<h3 class="mb-4">{$t('admin.title')}</h3>
<AdminUsersPanel />
</main>
{/if}

219
src/routes/app/+page.svelte Normal file
View file

@ -0,0 +1,219 @@
<script lang="ts">
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { Navbar } from '$features/auth';
import { Map } from '$map';
import { requireAuthenticated } from '$auth';
import { ControlPanel, PickOnMap, armPick } from '$features/prediction';
import {
ScenarioPanel,
ComparePanel,
ScenarioRenderer,
scenariosStore
} from '$features/scenarios';
import { WindPanel, WindRenderer } from '$features/wind';
import { SettingsPanel, settingsStore } from '$features/settings';
import { TimeLine } from '$features/timeline';
import { persisted } from '$state';
import { t } from '$i18n';
let ok = $state(false);
let windRendererRef = $state<ReturnType<typeof WindRenderer> | null>(null);
/**
* Prediction only: tracking is its own mode, reached from the header and
* living on /flights/. Mixing the two here made the right column a grab bag
* of unrelated work (guidelines §1).
*/
const TABS = [
{ id: 'scenarios', labelKey: 'scenarios.title' },
{ id: 'compare', labelKey: 'compare.title' },
{ id: 'wind', labelKey: 'wind.title' },
{ id: 'settings', labelKey: 'settings.title' }
] as const;
let tab = $state<(typeof TABS)[number]['id']>('scenarios');
/**
* The map is the document: panels are transient chrome over it, so each column
* collapses and remembers its state for the session (guidelines §1, §5).
* The two columns are independent at every width — below 1024px they share the
* viewport instead of excluding each other (see `.both-open` in the styles).
*/
const panels = persisted('panels-open', { left: true, right: true });
const bothOpen = $derived($panels.left && $panels.right);
function toggle(side: 'left' | 'right') {
panels.update((p) => ({ ...p, [side]: !p[side] }));
}
onMount(() => {
void (async () => {
ok = await requireAuthenticated('/login');
if (ok && get(scenariosStore).items.length === 0) scenariosStore.add();
})();
});
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') armPick.set(false);
}
</script>
<svelte:window onkeydown={handleKeydown} />
<Navbar />
{#if ok}
<div class="app-map" class:both-open={bothOpen}>
<!--
Swapping the base layer means swapping the MapLibre style, which would drop
every scene's sources/layers. Remounting instead lets the renderers repaint
from their stores; the cost is a view reset on a rarely-changed setting.
-->
{#key $settingsStore.map.baseLayer}
<Map center={[129.7, 62.0]} zoom={5} baseLayer={$settingsStore.map.baseLayer}>
<ScenarioRenderer />
<PickOnMap />
<WindRenderer bind:this={windRendererRef} />
</Map>
{/key}
{#if $armPick}
<div class="pick-banner alert alert-warning py-1 px-3 shadow-sm" role="status">
{$t('predict.pickBanner')}
<button class="btn btn-sm btn-link p-0 ms-2" onclick={() => armPick.set(false)}>
{$t('account.cancel')}
</button>
</div>
{/if}
<!-- Collapse handles stay visible even when a column is closed. -->
<button
class="panel-toggle panel-toggle-left btn btn-sm btn-light shadow-sm"
class:shifted={$panels.left}
data-testid="toggle-left"
aria-expanded={$panels.left}
aria-label={$t('panels.conditions')}
title={$t('panels.conditions')}
onclick={() => toggle('left')}>
<i class="bi {$panels.left ? 'bi-chevron-left' : 'bi-sliders'}"></i>
</button>
<button
class="panel-toggle panel-toggle-right btn btn-sm btn-light shadow-sm"
class:shifted={$panels.right}
data-testid="toggle-right"
aria-expanded={$panels.right}
aria-label={$t('panels.panels')}
title={$t('panels.panels')}
onclick={() => toggle('right')}>
<i class="bi {$panels.right ? 'bi-chevron-right' : 'bi-layers'}"></i>
</button>
{#if $panels.left}
<div class="app-panel app-panel-left" data-testid="panel-left"><ControlPanel /></div>
{/if}
{#if $panels.right}
<div class="app-panel app-panel-right" data-testid="panel-right">
<ul class="nav nav-pills nav-fill bg-body-tertiary rounded p-1 mb-2 small shadow-sm">
{#each TABS as item (item.id)}
<li class="nav-item">
<button
class="nav-link py-1 px-2 {tab === item.id ? 'active' : ''}"
data-testid={`tab-${item.id}`}
aria-current={tab === item.id ? 'page' : undefined}
onclick={() => (tab = item.id)}>
{$t(item.labelKey)}
</button>
</li>
{/each}
</ul>
{#if tab === 'scenarios'}
<ScenarioPanel />
{:else if tab === 'compare'}
<ComparePanel />
{:else if tab === 'wind'}
<WindPanel onRetry={() => windRendererRef?.reload()} />
{:else if tab === 'settings'}
<SettingsPanel />
{/if}
</div>
{/if}
<TimeLine />
</div>
{/if}
<style>
.app-map {
position: absolute;
inset: var(--navbar-height) 0 0 0;
}
.app-panel {
position: absolute;
top: 0.75rem;
width: var(--panel-w);
max-height: calc(100% - 6rem);
overflow-y: auto;
z-index: 5;
}
.app-panel-left {
left: 0.75rem;
}
.app-panel-right {
right: 0.75rem;
}
.panel-toggle {
position: absolute;
top: 0.75rem;
z-index: 7;
width: 2rem;
height: 2rem;
padding: 0;
}
.panel-toggle-left {
left: 0.75rem;
}
.panel-toggle-right {
right: 0.75rem;
}
/* When its column is open, the handle sits just outside the panel edge. */
.panel-toggle-left.shifted {
left: calc(var(--panel-w) + 1.25rem);
}
.panel-toggle-right.shifted {
right: calc(var(--panel-w) + 1.25rem);
}
:global(:root) {
--panel-w: 320px;
}
/* Tablet and below: a column may not swallow the map. */
@media (max-width: 1023.98px) {
:global(:root) {
--panel-w: min(320px, calc(100vw - 4.5rem));
}
/*
* With both columns open the width is split between them: 3.5rem of slack
* per side leaves a strip of map in the middle wide enough for the two
* collapse handles to sit side by side without touching.
*/
.app-map.both-open {
--panel-w: min(320px, calc(50vw - 3.5rem));
}
.app-panel {
max-height: calc(100% - 7rem);
}
}
.pick-banner {
position: absolute;
top: 3.25rem;
left: 50%;
transform: translateX(-50%);
z-index: 10;
margin: 0;
}
</style>

View file

@ -0,0 +1,162 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Map, getMap } from '$map';
import { Navbar } from '$features/auth';
import { flightsApi, type Flight } from '$api';
import { authStore } from '$auth';
import { settingsStore } from '$features/settings';
import { TelemetryPanel, TrackRenderer } from '$features/tracking';
import { formatCoords } from '$domain';
import { t } from '$i18n';
import FlightMarkers from './FlightMarkers.svelte';
/**
* Tracking mode: the public live map on the left, the telemetry readout for
* whatever is being followed on the right. Anonymous by design — discovery is
* the whole point of the public tier.
*/
let flights = $state<Flight[]>([]);
let status = $state<'loading' | 'empty' | 'error' | 'loaded'>('loading');
let error = $state('');
let filter = $state('');
let poll: ReturnType<typeof setInterval> | null = null;
const shown = $derived(
flights.filter((f) => !filter || f.name.toLowerCase().includes(filter.toLowerCase()))
);
async function load() {
try {
const res = await flightsApi.public();
flights = res.flights;
status = flights.length === 0 ? 'empty' : 'loaded';
} catch (err) {
error = (err as Error).message;
status = 'error';
}
}
onMount(() => {
authStore.refresh();
load();
// The list is a live view; refresh it periodically rather than per-flight sockets.
poll = setInterval(load, 15000);
return () => {
if (poll) clearInterval(poll);
};
});
onDestroy(() => {
if (poll) clearInterval(poll);
});
</script>
<Navbar />
<div class="flights-page">
<Map center={[129.7, 62.0]} zoom={4}>
<FlightMarkers flights={shown} />
<TrackRenderer />
</Map>
<div class="flights-panels">
<div class="flights-panel flights-panel-left">
<div class="card shadow-sm">
<div class="card-body p-3">
<h6 class="mb-2">{$t('flights.liveTitle')}</h6>
<input
type="search"
class="form-control form-control-sm mb-2"
data-testid="flights-filter"
placeholder={$t('flights.filter')}
aria-label={$t('flights.filter')}
bind:value={filter} />
{#if status === 'loading'}
<div class="spinner-border spinner-border-sm" role="status"></div>
{:else if status === 'error'}
<div class="alert alert-danger py-1 px-2 small mb-0">
<div>{error}</div>
<button class="btn btn-sm btn-link p-0" onclick={load}>{$t('wind.retry')}</button>
</div>
{:else if status === 'empty'}
<p class="small text-muted mb-0" data-testid="flights-empty">
{$t('flights.noneLive')}
</p>
{:else}
<div class="list-group list-group-flush" data-testid="flights-list">
{#each shown as f (f.id)}
<a class="list-group-item list-group-item-action px-2" href={`/track/${f.id}/`}>
<div class="d-flex justify-content-between">
<span class="small fw-semibold">{f.name || $t('flights.untitled')}</span>
<span class="badge text-bg-success align-self-start">
{f.packet_count}
</span>
</div>
{#if f.last_position}
<div class="small text-muted font-monospace">
{formatCoords(
f.last_position.lat,
f.last_position.lon,
$settingsStore.format.coords
)}
· {Math.round(f.last_position.alt)} m
</div>
{/if}
</a>
{/each}
</div>
{/if}
</div>
</div>
</div>
<div class="flights-panel flights-panel-right">
<TelemetryPanel />
</div>
</div>
</div>
<style>
.flights-page {
position: absolute;
inset: var(--navbar-height) 0 0 0;
}
/*
* Narrow: one scrolling column carries the list and the readout, because two
* 320px columns would leave no map between them.
*/
.flights-panels {
position: absolute;
top: 0.75rem;
left: 0.75rem;
width: min(320px, calc(100vw - 1.5rem));
max-height: calc(100% - 1.5rem);
overflow-y: auto;
z-index: 5;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* Wide: the wrapper steps out of the box model and the panels flank the map. */
@media (min-width: 768px) {
.flights-panels {
display: contents;
}
.flights-panel {
position: absolute;
top: 0.75rem;
width: min(320px, calc(50vw - 1.5rem));
max-height: calc(100% - 1.5rem);
overflow-y: auto;
z-index: 5;
}
.flights-panel-left {
left: 0.75rem;
}
.flights-panel-right {
right: 0.75rem;
}
}
</style>

View file

@ -0,0 +1,40 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { getMap } from '$map';
import type { Flight } from '$api';
interface Props {
flights: Flight[];
}
let { flights }: Props = $props();
/** Draws one marker per public flight at its last known fix. */
const map = getMap();
if (!map) throw new Error('FlightMarkers must be a descendant of <Map />');
const SCENE = 'public-flights';
let owned = false;
$effect(() => {
if (!map) return;
const withFix = flights.filter((f) => f.last_position);
const scene = map.scene(SCENE);
scene.clear();
owned = true;
for (const f of withFix) {
const p = f.last_position!;
scene.addCircle(`f-${f.id}`, {
center: [p.lon, p.lat],
radiusPx: 7,
color: '#FF1744', // actual position — red, per the tracking convention
strokeColor: '#ffffff',
strokeWidth: 2
});
}
});
onDestroy(() => {
if (map && owned) map.disposeScene(SCENE);
});
</script>

View file

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

View file

@ -0,0 +1,125 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { Map, getMap } from '$map';
import { predictionsApi, type SharedPrediction } from '$api';
import {
parsePrediction,
formatCoords,
exportPrediction,
EXPORT_MIME,
type Prediction,
type ExportFormat
} from '$domain';
import { settingsStore } from '$features/settings';
import { downloadText } from '$ui';
import { t } from '$i18n';
import SharedRenderer from './SharedRenderer.svelte';
/**
* Read-only view of a shared prediction: `/p/<token>`. Anonymous by design —
* the token is the credential, and the backend resolves nothing without it.
*/
const token = $derived(page.params.token ?? '');
let shared = $state<SharedPrediction | null>(null);
let prediction = $state<Prediction | null>(null);
let status = $state<'loading' | 'error' | 'loaded'>('loading');
onMount(async () => {
try {
shared = await predictionsApi.shared(token);
const stages = shared.result?.prediction;
prediction = Array.isArray(stages) && stages.length >= 2 ? parsePrediction(stages) : null;
status = 'loaded';
} catch {
status = 'error';
}
});
function download(format: ExportFormat) {
if (!prediction) return;
downloadText(
`shared-prediction.${format}`,
EXPORT_MIME[format],
exportPrediction(prediction, format, 'Shared prediction')
);
}
const fmtTime = (sec: number) => {
const m = Math.round(sec / 60);
return `${Math.floor(m / 60)}${$t('scenarios.hour')} ${m % 60}${$t('scenarios.min')}`;
};
</script>
<div class="shared-page">
<Map center={[129.7, 62.0]} zoom={5}>
<SharedRenderer {prediction} />
</Map>
<div class="shared-panel">
<div class="card shadow-sm">
<div class="card-body">
{#if status === 'loading'}
<div class="d-flex align-items-center gap-2">
<span class="spinner-border spinner-border-sm"></span>
<span class="small text-muted">{$t('flights.loading')}</span>
</div>
{:else if status === 'error'}
<h6 class="text-danger mb-1">{$t('history.sharedNotFound')}</h6>
<p class="small text-muted mb-0" data-testid="shared-error">
{$t('history.sharedTitle')}
</p>
{:else}
<h6 class="mb-2" data-testid="shared-title">{$t('history.sharedTitle')}</h6>
{#if prediction}
<dl class="row row-cols-2 g-1 small mb-2" data-testid="shared-summary">
<div class="col">
<dt class="text-muted fw-normal">{$t('scenarios.landing')}</dt>
<dd class="mb-0 font-monospace">
{formatCoords(
prediction.landing.latlng.lat,
prediction.landing.latlng.lng,
$settingsStore.format.coords
)}
</dd>
</div>
<div class="col">
<dt class="text-muted fw-normal">{$t('scenarios.flightTime')}</dt>
<dd class="mb-0">{fmtTime(prediction.flight_time)}</dd>
</div>
</dl>
<!-- A shared link is read-only, but the data is still exportable. -->
<div class="btn-group btn-group-sm w-100">
{#each ['gpx', 'kml', 'csv'] as const as fmt (fmt)}
<button
class="btn btn-outline-secondary"
data-testid={`shared-export-${fmt}`}
onclick={() => download(fmt)}>
{fmt.toUpperCase()}
</button>
{/each}
</div>
{:else}
<p class="small text-muted mb-0">{$t('history.broken')}</p>
{/if}
{/if}
</div>
</div>
</div>
</div>
<style>
.shared-page {
position: absolute;
inset: 0;
}
.shared-panel {
position: absolute;
top: 0.75rem;
left: 0.75rem;
width: min(320px, calc(100vw - 1.5rem));
z-index: 5;
}
</style>

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