Initial
This commit is contained in:
commit
b43955e0d9
162 changed files with 15425 additions and 0 deletions
3
.env.example
Normal file
3
.env.example
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
VITE_API_BASE_URL=/api
|
||||
VITE_API_PROXY_TARGET=http://localhost:8000
|
||||
VITE_USE_MOCK_API=false
|
||||
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Dependencies
|
||||
node_modules
|
||||
|
||||
# Build output
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# Playwright
|
||||
/test-results
|
||||
/playwright-report
|
||||
/blob-report
|
||||
/playwright/.cache
|
||||
|
||||
# Vitest
|
||||
/coverage
|
||||
|
||||
# Environments — .env.example is the documented template and stays in the repo
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Editors
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
8
.prettierrc
Normal file
8
.prettierrc
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
||||
14
jsconfig.json
Normal file
14
jsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
607
mocks/handlers.ts
Normal file
607
mocks/handlers.ts
Normal file
|
|
@ -0,0 +1,607 @@
|
|||
// In-memory backend used when VITE_USE_MOCK_API=true, so the frontend runs and
|
||||
// e2e-tests with no Django. The session is carried by a cookie (set via the
|
||||
// `session` field below), so each browser context is independent — no global
|
||||
// session state to leak between tests.
|
||||
type Json = Record<string, unknown>;
|
||||
interface MockUser {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
is_staff: boolean;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
const users = new Map<string, MockUser>([
|
||||
['demo', { username: 'demo', email: 'demo@example.com', password: 'demo', is_staff: false }],
|
||||
['admin', { username: 'admin', email: 'admin@example.com', password: 'admin', is_staff: true }]
|
||||
]);
|
||||
|
||||
// Library items (saved points / templates), seeded so the pickers have content.
|
||||
let nextId = 100;
|
||||
const points: Array<Json & { id: number }> = [
|
||||
{ id: 1, name: 'sjsa-start', lat: 62.1234, lon: 129.1234, alt: 100 },
|
||||
{ id: 2, name: 'Тикси', lat: 71.6372, lon: 128.8686, alt: 10 }
|
||||
];
|
||||
const templates: Array<Json & { id: number }> = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Высотный',
|
||||
description: '',
|
||||
prediction_mode: 'single',
|
||||
model: '',
|
||||
dataset: '',
|
||||
flight_parameters: {
|
||||
ascent_rate: 5,
|
||||
burst_altitude: 33000,
|
||||
dataset: '',
|
||||
descent_rate: 6,
|
||||
format: 'json',
|
||||
launch_altitude: 100,
|
||||
launch_latitude: 62.1234,
|
||||
launch_longitude: 129.1234,
|
||||
profile: 'standard_profile',
|
||||
version: 2
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export interface HandleResult {
|
||||
status: number;
|
||||
body: Json;
|
||||
/** undefined = leave the session cookie unchanged; string = set; null = clear. */
|
||||
session?: string | null;
|
||||
}
|
||||
|
||||
const ok = (body: Json, status = 200): HandleResult => ({ status, body });
|
||||
const err = (status: number, detail: string): HandleResult => ({ status, body: { detail } });
|
||||
|
||||
/** Build a plausible ascent+descent trajectory from the request params. */
|
||||
function synthPrediction(body: Json): Json {
|
||||
const lat0 = Number(body.launch_latitude ?? 62.1234);
|
||||
const lon0 = Number(body.launch_longitude ?? 129.1234);
|
||||
const alt0 = Number(body.launch_altitude ?? 0);
|
||||
const ascentRate = Math.max(Number(body.ascent_rate ?? 5), 0.1);
|
||||
const burstAlt = Number(body.burst_altitude ?? 30000);
|
||||
const descentRate = Math.max(Number(body.descent_rate ?? 5), 0.1);
|
||||
const startIso = String(body.launch_datetime ?? new Date().toISOString());
|
||||
const start = new Date(startIso).getTime();
|
||||
|
||||
const N = 24;
|
||||
const windLat = 0.004;
|
||||
const windLon = 0.01;
|
||||
const ascentDur = ((burstAlt - alt0) / ascentRate) * 1000; // ms
|
||||
const descentDur = (burstAlt / descentRate) * 1000; // ms
|
||||
|
||||
const ascent = [];
|
||||
for (let i = 0; i <= N; i++) {
|
||||
const f = i / N;
|
||||
ascent.push({
|
||||
altitude: alt0 + (burstAlt - alt0) * f,
|
||||
datetime: new Date(start + ascentDur * f).toISOString(),
|
||||
latitude: lat0 + windLat * i,
|
||||
longitude: lon0 + windLon * i
|
||||
});
|
||||
}
|
||||
const burstLat = lat0 + windLat * N;
|
||||
const burstLon = lon0 + windLon * N;
|
||||
const descent = [];
|
||||
for (let i = 0; i <= N; i++) {
|
||||
const f = i / N;
|
||||
descent.push({
|
||||
altitude: burstAlt * (1 - f),
|
||||
datetime: new Date(start + ascentDur + descentDur * f).toISOString(),
|
||||
latitude: burstLat + windLat * i * 0.6,
|
||||
longitude: burstLon + windLon * i * 0.6
|
||||
});
|
||||
}
|
||||
return {
|
||||
metadata: {
|
||||
start_datetime: startIso,
|
||||
complete_datetime: new Date(start + ascentDur + descentDur).toISOString()
|
||||
},
|
||||
prediction: [
|
||||
{ stage: 'ascent', trajectory: ascent },
|
||||
{ stage: 'descent', trajectory: descent }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthetic [U, V] wind grid in the wind-js-server format, matching the real
|
||||
* predictor's conventions: longitudes in 0..360 and a north→south scan
|
||||
* (la1 > la2) with positive dy.
|
||||
*/
|
||||
function synthWindField(q: URLSearchParams): Json[] {
|
||||
const minLat = Number(q.get('min_lat') ?? 55);
|
||||
const maxLat = Number(q.get('max_lat') ?? 70);
|
||||
const minLng = Number(q.get('min_lng') ?? 120);
|
||||
const maxLng = Number(q.get('max_lng') ?? 140);
|
||||
const step = Math.max(0.25, Number(q.get('step') ?? 1));
|
||||
|
||||
const nx = Math.max(2, Math.floor((maxLng - minLng) / step) + 1);
|
||||
const ny = Math.max(2, Math.floor((maxLat - minLat) / step) + 1);
|
||||
const to360 = (lng: number) => ((lng % 360) + 360) % 360;
|
||||
|
||||
const u: number[] = [];
|
||||
const v: number[] = [];
|
||||
for (let j = 0; j < ny; j++) {
|
||||
const lat = maxLat - j * step; // north → south
|
||||
for (let i = 0; i < nx; i++) {
|
||||
const lng = minLng + i * step;
|
||||
// Smooth jet-like flow with a wide speed spread (~2–30 m/s) so the
|
||||
// colour ramp and trail density are representative in mock mode.
|
||||
u.push(14 + 13 * Math.sin((lat * Math.PI) / 9) * Math.cos((lng * Math.PI) / 14));
|
||||
v.push(7 * Math.cos((lng * Math.PI) / 11) + 5 * Math.sin((lat * Math.PI) / 7));
|
||||
}
|
||||
}
|
||||
|
||||
const header = (name: string, unit: string) => ({
|
||||
parameterUnit: unit,
|
||||
parameterNumberName: name,
|
||||
nx,
|
||||
ny,
|
||||
lo1: to360(minLng),
|
||||
la1: maxLat,
|
||||
lo2: to360(minLng + (nx - 1) * step),
|
||||
la2: maxLat - (ny - 1) * step,
|
||||
dx: step,
|
||||
dy: step,
|
||||
refTime: '2026-01-01T00:00:00Z'
|
||||
});
|
||||
|
||||
return [
|
||||
{ header: header('eastward_wind', 'm.s-1'), data: u },
|
||||
{ header: header('northward_wind', 'm.s-1'), data: v }
|
||||
];
|
||||
}
|
||||
|
||||
/** Deterministic ascent track for a satellite id, newest-first like the backend. */
|
||||
export function synthTelemetryHistory(id: string, count = 20): Json[] {
|
||||
// Seed from the id so a given satellite always yields the same track.
|
||||
let seed = 0;
|
||||
for (const ch of id) seed = (seed * 31 + ch.charCodeAt(0)) % 997;
|
||||
const lat0 = 62 + (seed % 100) / 1000;
|
||||
const lon0 = 129 + (seed % 137) / 1000;
|
||||
const startSec = Math.floor(Date.parse('2026-01-01T00:00:00Z') / 1000);
|
||||
|
||||
const packets: Json[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
packets.push({
|
||||
id: `${id}-${i}`,
|
||||
timestamp: startSec + i * 60,
|
||||
lat: lat0 + i * 0.004,
|
||||
lon: lon0 + i * 0.01,
|
||||
alt: i * 900,
|
||||
payload: { temp: -5 - i, volt: 4.2 - i * 0.01 },
|
||||
raw_data: {}
|
||||
});
|
||||
}
|
||||
return packets.reverse(); // newest-first
|
||||
}
|
||||
|
||||
interface MockFlight {
|
||||
id: string;
|
||||
name: string;
|
||||
privacy: 'public' | 'unlisted' | 'private';
|
||||
owner: string;
|
||||
}
|
||||
const flights: MockFlight[] = [
|
||||
{ id: '550e8400-e29b-41d4-a716-446655440000', name: 'ЯКС-1', privacy: 'public', owner: 'demo' },
|
||||
{ id: '550e8400-e29b-41d4-a716-446655440001', name: 'Тестовый', privacy: 'unlisted', owner: 'demo' },
|
||||
{ id: '550e8400-e29b-41d4-a716-446655440002', name: 'Секретный', privacy: 'private', owner: 'admin' }
|
||||
];
|
||||
|
||||
function flightOut(f: MockFlight, me: MockUser | null): Json {
|
||||
const history = synthTelemetryHistory(f.id);
|
||||
const last = history[0] as { timestamp: number; lat: number; lon: number; alt: number };
|
||||
return {
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
privacy: f.privacy,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
is_owner: !!me && me.username === f.owner,
|
||||
last_position: { timestamp: last.timestamp, lat: last.lat, lon: last.lon, alt: last.alt },
|
||||
packet_count: history.length
|
||||
};
|
||||
}
|
||||
|
||||
const flightVisible = (f: MockFlight, me: MockUser | null) =>
|
||||
f.privacy !== 'private' || (!!me && me.username === f.owner);
|
||||
|
||||
interface StoredRun {
|
||||
id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
result: Json;
|
||||
request: Json;
|
||||
/** Set only once the owner shares the run; access is by token, never by id. */
|
||||
share_token?: string | null;
|
||||
}
|
||||
const runs: StoredRun[] = [];
|
||||
let runSeq = 0;
|
||||
|
||||
export function handle(
|
||||
method: string,
|
||||
path: string,
|
||||
body: Json,
|
||||
sessionUser: string | null,
|
||||
query: URLSearchParams = new URLSearchParams()
|
||||
): HandleResult {
|
||||
const me = sessionUser ? (users.get(sessionUser) ?? null) : null;
|
||||
|
||||
if (path === '/api/csrf/') return ok({ detail: 'CSRF cookie set' });
|
||||
if (path === '/api/session/') return ok({ isAuthenticated: me !== null });
|
||||
|
||||
if (path === '/api/whoami/') {
|
||||
if (!me) return err(401, 'Unauthorized');
|
||||
return ok({ username: me.username, is_staff: me.is_staff });
|
||||
}
|
||||
|
||||
if (path === '/api/login/' && method === 'POST') {
|
||||
const u = users.get(String(body.username ?? ''));
|
||||
if (u && u.password === String(body.password ?? ''))
|
||||
return { status: 200, body: { detail: 'Successfully logged in.' }, session: u.username };
|
||||
return err(400, 'Invalid credentials.');
|
||||
}
|
||||
|
||||
if (path === '/api/logout/' && method === 'POST')
|
||||
return { status: 200, body: { detail: 'Successfully logged out.' }, session: null };
|
||||
|
||||
if (path === '/api/register/' && method === 'POST') {
|
||||
const username = String(body.username ?? '').trim();
|
||||
const email = String(body.email ?? '').trim();
|
||||
const password = String(body.password ?? '');
|
||||
if (username.length < 3) return err(400, 'Username must be at least 3 characters');
|
||||
if (users.has(username)) return err(400, 'Username already taken.');
|
||||
if ([...users.values()].some((u) => u.email.toLowerCase() === email.toLowerCase()))
|
||||
return err(400, 'Email already registered.');
|
||||
users.set(username, { username, email, password, is_staff: false });
|
||||
return { status: 201, body: { detail: 'Account created.' }, session: username };
|
||||
}
|
||||
|
||||
if (path === '/api/profile/') {
|
||||
if (!me) return err(401, 'Unauthorized');
|
||||
if (method === 'GET')
|
||||
return ok({ username: me.username, email: me.email, first_name: '', last_name: '' });
|
||||
if (method === 'PATCH') {
|
||||
if (typeof body.email === 'string') me.email = body.email;
|
||||
return ok({ username: me.username, email: me.email, first_name: '', last_name: '' });
|
||||
}
|
||||
}
|
||||
|
||||
if (path === '/api/profile/change-password/' && method === 'POST') {
|
||||
if (!me) return err(401, 'Unauthorized');
|
||||
if (me.password !== String(body.old_password ?? '')) return err(400, 'Old password is incorrect');
|
||||
me.password = String(body.new_password ?? '');
|
||||
return ok({ detail: 'Password changed successfully' });
|
||||
}
|
||||
|
||||
if (path === '/api/profile/delete-data/' && method === 'DELETE') {
|
||||
if (!me) return err(401, 'Unauthorized');
|
||||
return ok({ detail: 'All user data deleted successfully' });
|
||||
}
|
||||
|
||||
if (path === '/api/profile/delete-account/' && method === 'DELETE') {
|
||||
if (!me) return err(401, 'Unauthorized');
|
||||
if (me.password !== String(body.password ?? '')) return err(400, 'Incorrect password');
|
||||
users.delete(me.username);
|
||||
return { status: 200, body: { detail: 'Account deleted successfully' }, session: null };
|
||||
}
|
||||
|
||||
if (path === '/api/flights/public/' && method === 'GET') {
|
||||
return ok({
|
||||
flights: flights.filter((f) => f.privacy === 'public').map((f) => flightOut(f, me))
|
||||
} as unknown as Json);
|
||||
}
|
||||
|
||||
const flightMatch = path.match(/^\/api\/flights\/([0-9a-f-]{36})\/$/i);
|
||||
if (flightMatch) {
|
||||
const f = flights.find((x) => x.id === flightMatch[1]);
|
||||
if (method === 'GET') {
|
||||
// A private flight is indistinguishable from a missing one.
|
||||
if (!f || !flightVisible(f, me)) return err(404, 'Not found');
|
||||
return ok(flightOut(f, me));
|
||||
}
|
||||
if (method === 'PATCH') {
|
||||
if (!me) return err(401, 'Authentication required');
|
||||
if (!f) return err(404, 'Not found');
|
||||
if (f.owner !== me.username) return err(403, 'Not your flight');
|
||||
if (typeof body.privacy === 'string') {
|
||||
if (!['public', 'unlisted', 'private'].includes(body.privacy))
|
||||
return err(400, 'privacy must be one of: public, unlisted, private');
|
||||
f.privacy = body.privacy as MockFlight['privacy'];
|
||||
}
|
||||
if (typeof body.name === 'string') f.name = body.name;
|
||||
return ok(flightOut(f, me));
|
||||
}
|
||||
}
|
||||
|
||||
// Telemetry history: /api/<uuid>/telemetry/ (newest-first, like the backend).
|
||||
const telemetryMatch = path.match(/^\/api\/([0-9a-f-]{36})\/telemetry\/$/i);
|
||||
if (telemetryMatch && method === 'GET') {
|
||||
// Public/unlisted tracks are readable anonymously; private ones are not.
|
||||
const f = flights.find((x) => x.id === telemetryMatch[1]);
|
||||
if (f && !flightVisible(f, me)) return err(404, 'Not found');
|
||||
return ok(synthTelemetryHistory(telemetryMatch[1]) as unknown as Json);
|
||||
}
|
||||
|
||||
if (path === '/api/wind/field/' && method === 'GET') {
|
||||
if (!me) return err(401, 'Unauthorized');
|
||||
return ok(synthWindField(query) as unknown as Json);
|
||||
}
|
||||
|
||||
if (path === '/api/wind/meta/' && method === 'GET') {
|
||||
if (!me) return err(401, 'Unauthorized');
|
||||
return ok({
|
||||
source: 'gfs-0p50-3h',
|
||||
epoch: '2026-01-01T00:00:00Z',
|
||||
altitudes: [0, 5000, 10000, 20000, 30000],
|
||||
bbox: { min_lat: -90, max_lat: 90, min_lng: -180, max_lng: 180 }
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/admin/users/' && method === 'GET') {
|
||||
if (!me) return err(401, 'Unauthorized');
|
||||
if (!me.is_staff) return err(403, 'Staff only');
|
||||
const q = (query.get('q') ?? '').toLowerCase();
|
||||
const limit = Number(query.get('limit') ?? 20);
|
||||
const skip = Number(query.get('skip') ?? 0);
|
||||
const all = [...users.values()]
|
||||
.map((u, i) => ({
|
||||
id: i + 1,
|
||||
username: u.username,
|
||||
email: u.email,
|
||||
is_active: u.is_active ?? true,
|
||||
is_staff: u.is_staff,
|
||||
date_joined: '2026-01-01T00:00:00Z',
|
||||
last_login: null
|
||||
}))
|
||||
.filter(
|
||||
(u) => !q || u.username.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)
|
||||
);
|
||||
return ok({ total: all.length, limit, skip, users: all.slice(skip, skip + limit) } as unknown as Json);
|
||||
}
|
||||
|
||||
const adminUserMatch = path.match(/^\/api\/admin\/users\/(\d+)\/$/);
|
||||
if (adminUserMatch && method === 'PATCH') {
|
||||
if (!me) return err(401, 'Unauthorized');
|
||||
if (!me.is_staff) return err(403, 'Staff only');
|
||||
const idx = Number(adminUserMatch[1]) - 1;
|
||||
const target = [...users.values()][idx];
|
||||
if (!target) return err(404, 'Not found');
|
||||
if (target.username === me.username && body.is_active === false)
|
||||
return err(400, 'You cannot deactivate your own account.');
|
||||
target.is_active = Boolean(body.is_active);
|
||||
return ok({
|
||||
id: idx + 1,
|
||||
username: target.username,
|
||||
email: target.email,
|
||||
is_active: target.is_active,
|
||||
is_staff: target.is_staff,
|
||||
date_joined: '2026-01-01T00:00:00Z',
|
||||
last_login: null
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/predictions/' && method === 'POST') {
|
||||
if (!me) return err(401, 'Unauthorized');
|
||||
const result = synthPrediction(body);
|
||||
// Mirror the backend: every run is persisted and shows up in history.
|
||||
runSeq += 1;
|
||||
const stamp = new Date(Date.parse('2026-01-01T00:00:00Z') + runSeq * 60000).toISOString();
|
||||
runs.unshift({
|
||||
id: `00000000-0000-4000-8000-${String(runSeq).padStart(12, '0')}`,
|
||||
created_at: stamp,
|
||||
updated_at: stamp,
|
||||
result,
|
||||
request: body
|
||||
});
|
||||
return ok({ result });
|
||||
}
|
||||
|
||||
if (path === '/api/predictions/v2/' && method === 'POST') {
|
||||
if (!me) return err(401, 'Unauthorized');
|
||||
// Flatten the v2 stage request into the params synthPrediction understands,
|
||||
// so a custom profile still yields a plausible ascent+descent track.
|
||||
const launch = (body.launch ?? {}) as Record<string, unknown>;
|
||||
const profile = (body.profile ?? []) as Array<Record<string, any>>;
|
||||
const ascent = profile.find((st) => st.model?.type === 'constant_rate');
|
||||
const descent = profile.find((st) => st.model?.type === 'parachute_descent');
|
||||
const burst = ascent?.constraints?.find((c: any) => c.type === 'altitude')?.limit;
|
||||
const result = synthPrediction({
|
||||
launch_latitude: launch.latitude,
|
||||
launch_longitude: launch.longitude,
|
||||
launch_altitude: launch.altitude,
|
||||
launch_datetime: launch.time,
|
||||
ascent_rate: ascent?.model?.rate ?? 5,
|
||||
descent_rate: descent?.model?.sea_level_rate ?? 5,
|
||||
burst_altitude: burst ?? 30000
|
||||
});
|
||||
|
||||
runSeq += 1;
|
||||
const id = `00000000-0000-4000-8000-${String(runSeq).padStart(12, '0')}`;
|
||||
const stamp = new Date(Date.parse('2026-01-01T00:00:00Z') + runSeq * 60000).toISOString();
|
||||
runs.unshift({ id, created_at: stamp, updated_at: stamp, request: body, result });
|
||||
return ok({ id, created_at: stamp, result });
|
||||
}
|
||||
|
||||
if (path === '/api/predictions/ensemble/' && method === 'POST') {
|
||||
if (!me) return err(401, 'Unauthorized');
|
||||
// Members scatter around the deterministic single-run landing.
|
||||
const base = synthPrediction(body) as { prediction: Array<{ trajectory: Json[] }> };
|
||||
const descent = base.prediction[1].trajectory;
|
||||
const last = descent[descent.length - 1] as unknown as { latitude: number; longitude: number };
|
||||
const members = Array.from({ length: 21 }, (_, i) => ({
|
||||
member: i,
|
||||
landing: {
|
||||
lat: last.latitude + Math.sin(i * 1.7) * 0.05,
|
||||
lng: last.longitude + Math.cos(i * 2.3) * 0.09,
|
||||
alt: 0
|
||||
}
|
||||
}));
|
||||
const n = members.length;
|
||||
const meanLat = members.reduce((a, m) => a + m.landing.lat, 0) / n;
|
||||
const meanLng = members.reduce((a, m) => a + m.landing.lng, 0) / n;
|
||||
const kmLat = 111.32;
|
||||
const kmLng = 111.32 * Math.cos((meanLat * Math.PI) / 180);
|
||||
const radius =
|
||||
members.reduce(
|
||||
(a, m) =>
|
||||
a +
|
||||
Math.hypot((m.landing.lng - meanLng) * kmLng, (m.landing.lat - meanLat) * kmLat),
|
||||
0
|
||||
) / n;
|
||||
|
||||
runSeq += 1;
|
||||
const id = `00000000-0000-4000-8000-${String(runSeq).padStart(12, '0')}`;
|
||||
const stamp = new Date(Date.parse('2026-01-01T00:00:00Z') + runSeq * 60000).toISOString();
|
||||
runs.unshift({
|
||||
id,
|
||||
created_at: stamp,
|
||||
updated_at: stamp,
|
||||
request: body,
|
||||
result: {
|
||||
ensemble: {
|
||||
members,
|
||||
failed: [],
|
||||
footprint: {
|
||||
count: n,
|
||||
mean: { lat: meanLat, lng: meanLng },
|
||||
radius_km: radius,
|
||||
ellipse: { semi_major_km: radius * 1.6, semi_minor_km: radius * 1.1, bearing_deg: 35 }
|
||||
}
|
||||
}
|
||||
} as unknown as Json
|
||||
});
|
||||
return ok({ id, status: 'pending', created_at: stamp }, 202);
|
||||
}
|
||||
|
||||
const statusMatch = path.match(/^\/api\/predictions\/([0-9a-f-]{36})\/status\/$/i);
|
||||
if (statusMatch && method === 'GET') {
|
||||
if (!me) return err(401, 'Unauthorized');
|
||||
const run = runs.find((r) => r.id === statusMatch[1]);
|
||||
if (!run) return err(404, 'Not found');
|
||||
return ok({
|
||||
id: run.id,
|
||||
status: 'complete',
|
||||
result: run.result,
|
||||
error: null,
|
||||
created_at: run.created_at,
|
||||
updated_at: run.updated_at
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/predictions/list_user/' && method === 'GET') {
|
||||
if (!me) return err(401, 'Unauthorized');
|
||||
const limit = Number(query.get('limit') ?? 10);
|
||||
const skip = Number(query.get('skip') ?? 0);
|
||||
return ok({
|
||||
total: runs.length,
|
||||
limit,
|
||||
skip,
|
||||
predictions: runs.slice(skip, skip + limit)
|
||||
} as unknown as Json);
|
||||
}
|
||||
|
||||
const shareMatch = path.match(/^\/api\/predictions\/([0-9a-f-]{36})\/share\/$/i);
|
||||
if (shareMatch) {
|
||||
if (!me) return err(401, 'Unauthorized');
|
||||
const run = runs.find((r) => r.id === shareMatch[1]);
|
||||
if (!run) return err(404, 'Not found');
|
||||
if (method === 'POST') {
|
||||
// Re-sharing keeps the existing token so circulated links keep working.
|
||||
run.share_token =
|
||||
run.share_token ?? `11111111-2222-4333-8444-${String(runs.indexOf(run)).padStart(12, '0')}`;
|
||||
return ok({ id: run.id, share_token: run.share_token }, 201);
|
||||
}
|
||||
if (method === 'DELETE') {
|
||||
run.share_token = null;
|
||||
return ok({ id: run.id, share_token: null });
|
||||
}
|
||||
}
|
||||
|
||||
const sharedMatch = path.match(/^\/api\/predictions\/shared\/([0-9a-f-]{36})\/$/i);
|
||||
if (sharedMatch && method === 'GET') {
|
||||
// Resolved by token only — anonymous, and an id grants nothing.
|
||||
const run = runs.find((r) => r.share_token && r.share_token === sharedMatch[1]);
|
||||
if (!run) return err(404, 'Not found');
|
||||
return ok({
|
||||
id: run.id,
|
||||
created_at: run.created_at,
|
||||
status: 'complete',
|
||||
result: run.result
|
||||
});
|
||||
}
|
||||
|
||||
const detailMatch = path.match(/^\/api\/predictions\/([0-9a-f-]{36})\/detail\/$/i);
|
||||
if (detailMatch && method === 'GET') {
|
||||
if (!me) return err(401, 'Unauthorized');
|
||||
const run = runs.find((r) => r.id === detailMatch[1]);
|
||||
if (!run) return err(404, 'Not found');
|
||||
return ok({
|
||||
...run,
|
||||
status: 'complete',
|
||||
error: null,
|
||||
start_point: null,
|
||||
template: null,
|
||||
rate_profile: null
|
||||
} as unknown as Json);
|
||||
}
|
||||
|
||||
const deleteMatch = path.match(/^\/api\/predictions\/([0-9a-f-]{36})\/delete\/$/i);
|
||||
if (deleteMatch && method === 'DELETE') {
|
||||
if (!me) return err(401, 'Unauthorized');
|
||||
const idx = runs.findIndex((r) => r.id === deleteMatch[1]);
|
||||
if (idx === -1) return err(404, 'Not found');
|
||||
runs.splice(idx, 1);
|
||||
return { status: 204, body: {} };
|
||||
}
|
||||
|
||||
if (path.startsWith('/api/saved-points/')) {
|
||||
if (!me) return err(401, 'Unauthorized');
|
||||
if (path === '/api/saved-points/') {
|
||||
if (method === 'GET') return ok(points as unknown as Json);
|
||||
if (method === 'POST') {
|
||||
const created = { ...body, id: nextId++ };
|
||||
points.push(created);
|
||||
return ok(created as Json, 201);
|
||||
}
|
||||
}
|
||||
const id = Number(path.split('/').filter(Boolean).pop());
|
||||
const idx = points.findIndex((p) => p.id === id);
|
||||
if (idx === -1) return err(404, 'Not found');
|
||||
if (method === 'PUT') {
|
||||
points[idx] = { ...points[idx], ...body, id };
|
||||
return ok(points[idx] as Json);
|
||||
}
|
||||
if (method === 'DELETE') {
|
||||
points.splice(idx, 1);
|
||||
return ok({ detail: 'deleted' });
|
||||
}
|
||||
}
|
||||
|
||||
if (path.startsWith('/api/saved-templates/')) {
|
||||
if (!me) return err(401, 'Unauthorized');
|
||||
if (path === '/api/saved-templates/') {
|
||||
if (method === 'GET') return ok(templates as unknown as Json);
|
||||
if (method === 'POST') {
|
||||
const created = { ...body, id: nextId++ };
|
||||
templates.push(created);
|
||||
return ok(created as Json, 201);
|
||||
}
|
||||
}
|
||||
const id = Number(path.split('/').filter(Boolean).pop());
|
||||
const idx = templates.findIndex((tpl) => tpl.id === id);
|
||||
if (idx === -1) return err(404, 'Not found');
|
||||
if (method === 'PUT') {
|
||||
templates[idx] = { ...templates[idx], ...body, id };
|
||||
return ok(templates[idx] as Json);
|
||||
}
|
||||
if (method === 'DELETE') {
|
||||
templates.splice(idx, 1);
|
||||
return ok({ detail: 'deleted' });
|
||||
}
|
||||
}
|
||||
|
||||
return err(404, 'Not found');
|
||||
}
|
||||
83
mocks/plugin.ts
Normal file
83
mocks/plugin.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import type { Plugin } from 'vite';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { handle, synthTelemetryHistory } from './handlers';
|
||||
|
||||
const COOKIE = 'mocksid';
|
||||
const WS_PATH = /^\/api\/ws\/satellite\/([0-9a-f-]{36})\/telemetry\/$/i;
|
||||
|
||||
function readCookie(header: string | undefined, name: string): string | null {
|
||||
if (!header) return null;
|
||||
for (const part of header.split(';')) {
|
||||
const [k, ...v] = part.trim().split('=');
|
||||
if (k === name) return decodeURIComponent(v.join('='));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Dev-only middleware that answers /api/* from the in-memory mock backend. */
|
||||
export function mockApi(): Plugin {
|
||||
return {
|
||||
name: 'mock-api',
|
||||
configureServer(server) {
|
||||
// Live telemetry: a real WebSocket so the tracking flow is exercised
|
||||
// end-to-end (the read-only satellite consumer in the real backend).
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
server.httpServer?.on('upgrade', (req, socket, head) => {
|
||||
const url = req.url ?? '';
|
||||
const match = url.split('?')[0].match(WS_PATH);
|
||||
if (!match) return; // let Vite's own HMR socket handle it
|
||||
wss.handleUpgrade(req, socket as never, head, (ws) => {
|
||||
// Continue the satellite's synthetic track from where history ended.
|
||||
const history = synthTelemetryHistory(match[1]);
|
||||
let next = history.length;
|
||||
const first = history[0] as { timestamp: number; lat: number; lon: number };
|
||||
const timer = setInterval(() => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
id: `${match[1]}-live-${next}`,
|
||||
timestamp: first.timestamp + (next - history.length + 1) * 60,
|
||||
lat: first.lat + (next - history.length + 1) * 0.004,
|
||||
lon: first.lon + (next - history.length + 1) * 0.01,
|
||||
alt: 18000 + (next - history.length + 1) * 900,
|
||||
payload: { live: true },
|
||||
raw_data: {}
|
||||
})
|
||||
);
|
||||
next += 1;
|
||||
}, 1000);
|
||||
ws.on('close', () => clearInterval(timer));
|
||||
});
|
||||
});
|
||||
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (!req.url?.startsWith('/api/')) return next();
|
||||
let raw = '';
|
||||
req.on('data', (c) => (raw += c));
|
||||
req.on('end', () => {
|
||||
const body = raw ? JSON.parse(raw) : {};
|
||||
const sessionUser = readCookie(req.headers.cookie, COOKIE);
|
||||
const [pathname, search = ''] = req.url!.split('?');
|
||||
const result = handle(
|
||||
req.method ?? 'GET',
|
||||
pathname,
|
||||
body,
|
||||
sessionUser,
|
||||
new URLSearchParams(search)
|
||||
);
|
||||
res.statusCode = result.status;
|
||||
res.setHeader('content-type', 'application/json');
|
||||
res.setHeader('x-csrftoken', 'mock-csrf');
|
||||
if (result.session !== undefined) {
|
||||
res.setHeader(
|
||||
'set-cookie',
|
||||
result.session === null
|
||||
? `${COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`
|
||||
: `${COOKIE}=${encodeURIComponent(result.session)}; Path=/; SameSite=Lax`
|
||||
);
|
||||
}
|
||||
res.end(JSON.stringify(result.body));
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
3368
package-lock.json
generated
Normal file
3368
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
46
package.json
Normal file
46
package.json
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
"name": "app5",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
|
||||
"test:unit": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.16.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@types/luxon": "^3.7.2",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"happy-dom": "^20.11.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"prettier": "^3.9.6",
|
||||
"prettier-plugin-svelte": "^4.1.1",
|
||||
"sass": "^1.102.0",
|
||||
"svelte": "^5.34.8",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^6.2.5",
|
||||
"vitest": "^4.1.10",
|
||||
"ws": "^8.21.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"bootstrap": "^5.3.3",
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-adapter-luxon": "^1.3.1",
|
||||
"js-cookie": "^3.0.8",
|
||||
"luxon": "^3.7.2",
|
||||
"maplibre-gl": "^4.7.1"
|
||||
}
|
||||
}
|
||||
15
playwright.config.ts
Normal file
15
playwright.config.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: 'tests/e2e',
|
||||
// The mock backend keeps session/user state in dev-server module globals, so
|
||||
// specs must not run concurrently against it — one worker keeps state deterministic.
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
webServer: {
|
||||
command: 'VITE_USE_MOCK_API=true npm run dev',
|
||||
port: 5173,
|
||||
reuseExistingServer: !process.env.CI
|
||||
},
|
||||
use: { baseURL: 'http://localhost:5173' }
|
||||
});
|
||||
6
src/app.d.ts
vendored
Normal file
6
src/app.d.ts
vendored
Normal 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
13
src/app.html
Normal 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
62
src/app.scss
Normal 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
30
src/lib/api/admin.ts
Normal 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
19
src/lib/api/auth.ts
Normal 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
105
src/lib/api/client.ts
Normal 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
32
src/lib/api/flights.ts
Normal 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
10
src/lib/api/index.ts
Normal 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
11
src/lib/api/points.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { api } from './client';
|
||||
import type { SavedPoint } from '$domain';
|
||||
|
||||
const base = '/saved-points/';
|
||||
|
||||
export const pointsApi = {
|
||||
list: () => api.get<SavedPoint[]>(base),
|
||||
create: (p: 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
129
src/lib/api/predictions.ts
Normal 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
20
src/lib/api/profile.ts
Normal 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
39
src/lib/api/telemetry.ts
Normal 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
11
src/lib/api/templates.ts
Normal 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
35
src/lib/api/wind.ts
Normal 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
22
src/lib/auth/guard.ts
Normal 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
2
src/lib/auth/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './store';
|
||||
export * from './guard';
|
||||
70
src/lib/auth/store.ts
Normal file
70
src/lib/auth/store.ts
Normal 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));
|
||||
});
|
||||
69
src/lib/domain/boundingBox.ts
Normal file
69
src/lib/domain/boundingBox.ts
Normal 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
117
src/lib/domain/curve.ts
Normal 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');
|
||||
}
|
||||
78
src/lib/domain/ensemble.ts
Normal file
78
src/lib/domain/ensemble.ts
Normal 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
104
src/lib/domain/export.ts
Normal 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) =>
|
||||
({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' })[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);
|
||||
}
|
||||
67
src/lib/domain/flightParameters.ts
Normal file
67
src/lib/domain/flightParameters.ts
Normal 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
47
src/lib/domain/geo.ts
Normal 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
68
src/lib/domain/history.ts
Normal 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
15
src/lib/domain/index.ts
Normal 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
100
src/lib/domain/math.ts
Normal 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;
|
||||
}
|
||||
78
src/lib/domain/prediction.ts
Normal file
78
src/lib/domain/prediction.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import type { LatLngTuple, LatLng } from './geo';
|
||||
import { normalizeLng } from './geo';
|
||||
|
||||
export interface Point {
|
||||
latlng: LatLng;
|
||||
datetime: Date;
|
||||
}
|
||||
|
||||
export interface TrajectoryPoint {
|
||||
altitude: number;
|
||||
datetime: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface PredictionStage {
|
||||
stage: string;
|
||||
trajectory: TrajectoryPoint[];
|
||||
}
|
||||
|
||||
export interface PredictionMetadata {
|
||||
complete_datetime: string;
|
||||
start_datetime: string;
|
||||
}
|
||||
|
||||
export interface RawPrediction {
|
||||
metadata: PredictionMetadata;
|
||||
prediction: PredictionStage[];
|
||||
}
|
||||
|
||||
export interface Prediction {
|
||||
flight_path: LatLngTuple[];
|
||||
/** Epoch-ms timestamp for each point in flight_path (parallel array). */
|
||||
timestamps: number[];
|
||||
launch: Point;
|
||||
burst: Point;
|
||||
landing: Point;
|
||||
profile: string;
|
||||
flight_time: number;
|
||||
}
|
||||
|
||||
function pointFromTrajectory(tp: TrajectoryPoint): Point {
|
||||
return {
|
||||
latlng: { lat: tp.latitude, lng: normalizeLng(tp.longitude), alt: tp.altitude },
|
||||
datetime: new Date(tp.datetime)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a raw prediction response (stages → trajectory) into a flat Prediction
|
||||
* with derived launch/burst/landing markers and a single flight_path array.
|
||||
*/
|
||||
export function parsePrediction(stages: PredictionStage[]): Prediction {
|
||||
if (stages.length < 2) {
|
||||
throw new Error('Prediction requires at least ascent and descent stages');
|
||||
}
|
||||
|
||||
const ascent = stages[0].trajectory;
|
||||
const descent = stages[1].trajectory;
|
||||
const all = [...ascent, ...descent];
|
||||
|
||||
const flight_path: LatLngTuple[] = all.map((p) => [
|
||||
p.latitude,
|
||||
normalizeLng(p.longitude),
|
||||
p.altitude
|
||||
]);
|
||||
|
||||
const timestamps: number[] = all.map((p) => new Date(p.datetime).getTime());
|
||||
|
||||
const launch = pointFromTrajectory(ascent[0]);
|
||||
const burst = pointFromTrajectory(descent[0]);
|
||||
const landing = pointFromTrajectory(descent[descent.length - 1]);
|
||||
|
||||
const profile = stages[1].stage === 'descent' ? 'standard_profile' : 'float_profile';
|
||||
const flight_time = (landing.datetime.getTime() - launch.datetime.getTime()) / 1000;
|
||||
|
||||
return { flight_path, timestamps, launch, burst, landing, profile, flight_time };
|
||||
}
|
||||
238
src/lib/domain/profile.ts
Normal file
238
src/lib/domain/profile.ts
Normal 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();
|
||||
}
|
||||
37
src/lib/domain/provenance.ts
Normal file
37
src/lib/domain/provenance.ts
Normal 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]));
|
||||
}
|
||||
42
src/lib/domain/scenario.ts
Normal file
42
src/lib/domain/scenario.ts
Normal 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;
|
||||
}
|
||||
43
src/lib/domain/telemetry.ts
Normal file
43
src/lib/domain/telemetry.ts
Normal 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
55
src/lib/domain/units.ts
Normal 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
192
src/lib/domain/wind.ts
Normal 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 north→south (la1 = 90,
|
||||
* la2 = -90), which would otherwise send `la1 + j·dy` past the pole.
|
||||
*
|
||||
* Stepping from the first point toward the last (la1→la2, lo1→lo2) and wrapping
|
||||
* longitudes into (-180, 180] places every 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];
|
||||
};
|
||||
}
|
||||
163
src/lib/features/account/AccountPanel.svelte
Normal file
163
src/lib/features/account/AccountPanel.svelte
Normal 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}
|
||||
266
src/lib/features/account/HistoryPanel.svelte
Normal file
266
src/lib/features/account/HistoryPanel.svelte
Normal 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}
|
||||
20
src/lib/features/account/UserNav.svelte
Normal file
20
src/lib/features/account/UserNav.svelte
Normal 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>
|
||||
3
src/lib/features/account/index.ts
Normal file
3
src/lib/features/account/index.ts
Normal 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';
|
||||
171
src/lib/features/admin/AdminUsersPanel.svelte
Normal file
171
src/lib/features/admin/AdminUsersPanel.svelte
Normal 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>
|
||||
1
src/lib/features/admin/index.ts
Normal file
1
src/lib/features/admin/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as AdminUsersPanel } from './AdminUsersPanel.svelte';
|
||||
16
src/lib/features/auth/AuthShell.svelte
Normal file
16
src/lib/features/auth/AuthShell.svelte
Normal 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>
|
||||
73
src/lib/features/auth/LoginForm.svelte
Normal file
73
src/lib/features/auth/LoginForm.svelte
Normal 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>
|
||||
173
src/lib/features/auth/Navbar.svelte
Normal file
173
src/lib/features/auth/Navbar.svelte
Normal 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>
|
||||
107
src/lib/features/auth/RegisterForm.svelte
Normal file
107
src/lib/features/auth/RegisterForm.svelte
Normal 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>
|
||||
4
src/lib/features/auth/index.ts
Normal file
4
src/lib/features/auth/index.ts
Normal 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';
|
||||
499
src/lib/features/prediction/ControlPanel.svelte
Normal file
499
src/lib/features/prediction/ControlPanel.svelte
Normal 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}
|
||||
66
src/lib/features/prediction/CurveChart.svelte
Normal file
66
src/lib/features/prediction/CurveChart.svelte
Normal 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>
|
||||
248
src/lib/features/prediction/CurveEditor.svelte
Normal file
248
src/lib/features/prediction/CurveEditor.svelte
Normal 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>
|
||||
38
src/lib/features/prediction/PickOnMap.svelte
Normal file
38
src/lib/features/prediction/PickOnMap.svelte
Normal 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>
|
||||
373
src/lib/features/prediction/ProfileBuilder.svelte
Normal file
373
src/lib/features/prediction/ProfileBuilder.svelte
Normal 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)} />
|
||||
5
src/lib/features/prediction/index.ts
Normal file
5
src/lib/features/prediction/index.ts
Normal 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';
|
||||
4
src/lib/features/prediction/pickStore.ts
Normal file
4
src/lib/features/prediction/pickStore.ts
Normal 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);
|
||||
34
src/lib/features/prediction/pointsStore.ts
Normal file
34
src/lib/features/prediction/pointsStore.ts
Normal 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));
|
||||
}
|
||||
};
|
||||
34
src/lib/features/prediction/templatesStore.ts
Normal file
34
src/lib/features/prediction/templatesStore.ts
Normal 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));
|
||||
}
|
||||
};
|
||||
84
src/lib/features/scenarios/ComparePanel.svelte
Normal file
84
src/lib/features/scenarios/ComparePanel.svelte
Normal 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>
|
||||
159
src/lib/features/scenarios/ScenarioPanel.svelte
Normal file
159
src/lib/features/scenarios/ScenarioPanel.svelte
Normal 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>
|
||||
190
src/lib/features/scenarios/ScenarioRenderer.svelte
Normal file
190
src/lib/features/scenarios/ScenarioRenderer.svelte
Normal 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>
|
||||
5
src/lib/features/scenarios/index.ts
Normal file
5
src/lib/features/scenarios/index.ts
Normal 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';
|
||||
217
src/lib/features/scenarios/store.ts
Normal file
217
src/lib/features/scenarios/store.ts
Normal 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;
|
||||
}
|
||||
69
src/lib/features/settings/SettingsPanel.svelte
Normal file
69
src/lib/features/settings/SettingsPanel.svelte
Normal 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>
|
||||
5
src/lib/features/settings/index.ts
Normal file
5
src/lib/features/settings/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export { settingsStore, DEFAULT_SETTINGS, 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';
|
||||
96
src/lib/features/settings/schema.ts
Normal file
96
src/lib/features/settings/schema.ts
Normal 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' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
53
src/lib/features/settings/store.ts
Normal file
53
src/lib/features/settings/store.ts
Normal 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;
|
||||
}
|
||||
188
src/lib/features/timeline/TimeLine.svelte
Normal file
188
src/lib/features/timeline/TimeLine.svelte
Normal 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>
|
||||
3
src/lib/features/timeline/index.ts
Normal file
3
src/lib/features/timeline/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { timelineStore, positionAt } from './store';
|
||||
export type { TimelineState, TimelineMarker } from './store';
|
||||
export { default as TimeLine } from './TimeLine.svelte';
|
||||
131
src/lib/features/timeline/store.ts
Normal file
131
src/lib/features/timeline/store.ts
Normal 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];
|
||||
}
|
||||
197
src/lib/features/tracking/DeviationChart.svelte
Normal file
197
src/lib/features/tracking/DeviationChart.svelte
Normal 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>
|
||||
128
src/lib/features/tracking/TelemetryPanel.svelte
Normal file
128
src/lib/features/tracking/TelemetryPanel.svelte
Normal 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>
|
||||
43
src/lib/features/tracking/TrackRenderer.svelte
Normal file
43
src/lib/features/tracking/TrackRenderer.svelte
Normal 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>
|
||||
5
src/lib/features/tracking/index.ts
Normal file
5
src/lib/features/tracking/index.ts
Normal 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';
|
||||
107
src/lib/features/tracking/telemetryStore.svelte.ts
Normal file
107
src/lib/features/tracking/telemetryStore.svelte.ts
Normal 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();
|
||||
308
src/lib/features/wind/ParticleField.ts
Normal file
308
src/lib/features/wind/ParticleField.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
95
src/lib/features/wind/WindPanel.svelte
Normal file
95
src/lib/features/wind/WindPanel.svelte
Normal 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>
|
||||
120
src/lib/features/wind/WindRenderer.svelte
Normal file
120
src/lib/features/wind/WindRenderer.svelte
Normal 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>
|
||||
5
src/lib/features/wind/index.ts
Normal file
5
src/lib/features/wind/index.ts
Normal 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';
|
||||
66
src/lib/features/wind/store.ts
Normal file
66
src/lib/features/wind/store.ts
Normal 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
58
src/lib/i18n/index.ts
Normal 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);
|
||||
}
|
||||
324
src/lib/i18n/locales/en.json
Normal file
324
src/lib/i18n/locales/en.json
Normal 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 M3–M4."
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
324
src/lib/i18n/locales/ru.json
Normal file
324
src/lib/i18n/locales/ru.json
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
{
|
||||
"app": {
|
||||
"title": "StratoFlights",
|
||||
"subtitle": "Прогнозирование и трекинг траекторий метеозондов",
|
||||
"loggedInAs": "Вы вошли как {name}",
|
||||
"foundationNote": "Каркас готов. Карта и прогноз появятся на этапах M3–M4."
|
||||
},
|
||||
"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
83
src/lib/map/Map.svelte
Normal 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
17
src/lib/map/context.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { getContext, setContext } from 'svelte';
|
||||
import type { IMap } from './core';
|
||||
|
||||
const KEY = Symbol('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
120
src/lib/map/core.ts
Normal 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
5
src/lib/map/index.ts
Normal 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
185
src/lib/map/layers.ts
Normal 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
322
src/lib/map/maplibre.ts
Normal 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:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
}
|
||||
},
|
||||
layers: [{ id: 'osm', type: 'raster', source: 'osm', minzoom: 0, maxzoom: 19 }]
|
||||
},
|
||||
satellite: {
|
||||
version: 8,
|
||||
sources: {
|
||||
sat: {
|
||||
type: 'raster',
|
||||
tiles: [
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'
|
||||
],
|
||||
tileSize: 256,
|
||||
attribution: 'Tiles © Esri'
|
||||
}
|
||||
},
|
||||
layers: [{ id: 'sat', type: 'raster', source: 'sat', minzoom: 0, maxzoom: 19 }]
|
||||
}
|
||||
};
|
||||
|
||||
class MapLibreScene implements Scene {
|
||||
private sources = new Set<string>();
|
||||
private layers = new Set<string>();
|
||||
private markers = new Map<string, maplibregl.Marker>();
|
||||
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
private map: MLMap
|
||||
) {}
|
||||
|
||||
private scopeId(id: string): string {
|
||||
return `${this.name}__${id}`;
|
||||
}
|
||||
|
||||
addLine(id: string, options: LineOptions): MapLayer {
|
||||
const layerId = this.scopeId(id);
|
||||
const coords = options.coords.map<[number, number]>((c) => [c[1], c[0]]);
|
||||
|
||||
if (this.map.getSource(layerId)) this.remove(id);
|
||||
|
||||
this.map.addSource(layerId, {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: { type: 'LineString', coordinates: coords }
|
||||
}
|
||||
});
|
||||
this.map.addLayer({
|
||||
id: layerId,
|
||||
type: 'line',
|
||||
source: layerId,
|
||||
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||||
paint: {
|
||||
'line-color': options.color ?? '#000',
|
||||
'line-width': options.width ?? 3,
|
||||
'line-opacity': options.opacity ?? 1,
|
||||
...(options.dashArray ? { 'line-dasharray': options.dashArray } : {})
|
||||
}
|
||||
});
|
||||
this.sources.add(layerId);
|
||||
this.layers.add(layerId);
|
||||
|
||||
return { id: layerId, remove: () => this.remove(id) };
|
||||
}
|
||||
|
||||
addCircle(id: string, options: CircleOptions): MapLayer {
|
||||
const layerId = this.scopeId(id);
|
||||
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
1
src/lib/state/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './persisted';
|
||||
66
src/lib/state/persisted.ts
Normal file
66
src/lib/state/persisted.ts
Normal 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;
|
||||
}
|
||||
69
src/lib/ui/SpoilerGroup.svelte
Normal file
69
src/lib/ui/SpoilerGroup.svelte
Normal 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
24
src/lib/ui/Toast.svelte
Normal 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
12
src/lib/ui/download.ts
Normal 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
4
src/lib/ui/index.ts
Normal 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
23
src/lib/ui/toasts.ts
Normal 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));
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue