From 48140f0f770abd790c31809b7e89defdc0a80f0f Mon Sep 17 00:00:00 2001 From: Vasilisk9812 Date: Wed, 17 Jun 2026 00:20:55 +0900 Subject: [PATCH] compare panel, docs update, wind visualisation --- docs/diagrams/README.md | 41 ++ docs/diagrams/architecture.puml | 102 +++++ docs/diagrams/dfd-telemetry.puml | 62 +++ docs/diagrams/seq-auth.puml | 41 ++ docs/diagrams/seq-prediction.puml | 41 ++ docs/diagrams/seq-telemetry.puml | 60 +++ docs/diagrams/seq-wind.puml | 54 +++ docs/wind-vis-math.tex | 395 ++++++++++++++++++ src/lib/api/index.ts | 1 + src/lib/api/predictions.ts | 9 +- src/lib/api/wind.ts | 58 +++ src/lib/domain/index.ts | 1 + src/lib/domain/math.ts | 65 +++ src/lib/domain/prediction.ts | 9 +- src/lib/domain/wind.ts | 214 ++++++++++ src/lib/features/settings/index.ts | 2 +- src/lib/features/settings/schema.ts | 86 ++++ src/lib/features/settings/store.ts | 5 + .../features/tracking/DeviationChart.svelte | 203 +++++++++ src/lib/features/tracking/index.ts | 1 + src/lib/features/wind/ParticleField.ts | 335 +++++++++++++++ src/lib/features/wind/WindRenderer.svelte | 332 +++++++++++++++ src/lib/features/wind/index.ts | 3 + src/lib/features/wind/store.ts | 61 +++ src/lib/i18n/locales/en.json | 25 +- src/lib/i18n/locales/ru.json | 25 +- src/routes/predict/+page.svelte | 2 + src/routes/track/+page.svelte | 45 +- test_client/client.py | 59 +-- 29 files changed, 2299 insertions(+), 38 deletions(-) create mode 100644 docs/diagrams/README.md create mode 100644 docs/diagrams/architecture.puml create mode 100644 docs/diagrams/dfd-telemetry.puml create mode 100644 docs/diagrams/seq-auth.puml create mode 100644 docs/diagrams/seq-prediction.puml create mode 100644 docs/diagrams/seq-telemetry.puml create mode 100644 docs/diagrams/seq-wind.puml create mode 100644 docs/wind-vis-math.tex create mode 100644 src/lib/api/wind.ts create mode 100644 src/lib/domain/wind.ts create mode 100644 src/lib/features/tracking/DeviationChart.svelte create mode 100644 src/lib/features/wind/ParticleField.ts create mode 100644 src/lib/features/wind/WindRenderer.svelte create mode 100644 src/lib/features/wind/index.ts create mode 100644 src/lib/features/wind/store.ts diff --git a/docs/diagrams/README.md b/docs/diagrams/README.md new file mode 100644 index 0000000..d564003 --- /dev/null +++ b/docs/diagrams/README.md @@ -0,0 +1,41 @@ +# Диаграммы (PlantUML) + +Исходники диаграмм для диссертации. Построены по фактическому коду +(`src/lib/api/*`, `src/lib/features/tracking/*`, `src/lib/features/wind/*`). + +## Архитектура + +| Файл | Что описывает | +|------|----------------| +| [architecture.puml](architecture.puml) | Слоистая архитектура клиента: routes → features → lib (api/map/ui/state/i18n/auth/domain), внешние сервисы (Backend, предсказатель) и библиотеки | + +## Диаграммы последовательности (по всем запросам) + +| Файл | Что описывает | Запросы | +|------|----------------|---------| +| [seq-request-csrf.puml](seq-request-csrf.puml) | Сквозная обёртка `request()`: CSRF, cookie, обработка ошибок и 401 | `GET /api/csrf/` | +| [seq-auth.puml](seq-auth.puml) | Проверка сессии, вход, выход | `GET /api/session/`, `GET /api/whoami/`, `POST /api/login/`, `POST /api/logout/` | +| [seq-resource-crud.puml](seq-resource-crud.puml) | Единый CRUD-шаблон справочников | `/api/saved-points/`, `/api/saved-templates/`, `/api/saved-profiles/` (GET/POST/PUT/DELETE) | +| [seq-prediction.puml](seq-prediction.puml) | Запуск прогноза траектории | `POST /api/predictions/` | +| [seq-telemetry.puml](seq-telemetry.puml) | Загрузка истории и приём телеметрии | `GET /api/{id}/telemetry/`, `WS /api/ws/satellite/{id}/telemetry/` | +| [seq-wind.puml](seq-wind.puml) | Визуализация поля ветра (статика + синхронизация) | `GET /api/v1/wind/field`, `GET /api/v1/wind/meta` | + +## Диаграмма потоков данных + +| Файл | Что описывает | +|------|----------------| +| [dfd-telemetry.puml](dfd-telemetry.puml) | DFD подсистемы слежения: внешние сущности, процессы 1.0–5.0, хранилища D1–D3 | + +## Рендеринг в PNG/SVG + +В системе есть Java, но нет CLI PlantUML. Один раз скачать jar и собрать все +диаграммы: + +```sh +curl -L -o plantuml.jar https://github.com/plantuml/plantuml/releases/latest/download/plantuml.jar +java -jar plantuml.jar -tpng docs/diagrams/*.puml # PNG +java -jar plantuml.jar -tsvg docs/diagrams/*.puml # SVG (для печати) +``` + +Альтернатива без установки — онлайн-редактор +или расширение PlantUML для VS Code (предпросмотр `Alt+D`). diff --git a/docs/diagrams/architecture.puml b/docs/diagrams/architecture.puml new file mode 100644 index 0000000..aad8c19 --- /dev/null +++ b/docs/diagrams/architecture.puml @@ -0,0 +1,102 @@ +@startuml architecture +title Архитектура клиентской части системы + +skinparam shadowing false +skinparam defaultFontName Helvetica +skinparam packageStyle rectangle +skinparam rectangle { + BorderColor #2C5AA0 + BackgroundColor #EAF1FB +} +skinparam package { + BorderColor #6B6B6B + BackgroundColor #FFFFFF +} +skinparam node { + BorderColor #444444 + BackgroundColor #F5F5F5 +} + +node "Браузер (SPA) — SvelteKit 5 + Vite" as spa { + + package "routes/ (страницы)" as routes { + rectangle "/login" as r_login + rectangle "/predict" as r_predict + rectangle "/track" as r_track + rectangle "/user/*" as r_user + } + + package "features/ (функциональные модули)" as features { + rectangle "auth\nLoginForm, Navbar" as f_auth + rectangle "prediction\nControlPanel, ScenarioPanel,\nCurveEditor" as f_pred + rectangle "workspaces\nWorkspacesPanel,\nWorkspaceRenderer, store" as f_ws + rectangle "tracking\nTelemetryPanel,\nTelemetryStore" as f_track + rectangle "wind\nWindRenderer,\nParticleField, WindCache" as f_wind + rectangle "timeline\nTimeLine, store" as f_time + rectangle "settings\nSettingsPanel, schema, store" as f_set + rectangle "footer" as f_foot + } + + package "lib/ (ядро)" as core { + rectangle "api/\nclient (HTTP+WS, CSRF),\npredictions, telemetry, wind,\npoints, scenarios, profiles" as l_api + rectangle "map/\nобёртка MapLibre GL JS,\nслои, инструменты" as l_map + rectangle "ui/\nнезависимые примитивы\n(CollapsibleCard, Toast, …)" as l_ui + rectangle "i18n/\nсловари ru / en" as l_i18n + rectangle "auth/\nguard, store" as l_auth + rectangle "state/\npersisted store\n(localStorage)" as l_state + rectangle "domain/\ngeo, math, telemetry,\nprediction, scenario, wind\n(чистые типы и функции)" as l_dom + } +} + +' ── Внешние сервисы ─────────────────────────────────────────────── +node "Backend (Django)" as be { + rectangle "REST API\n/api/*" as be_rest + rectangle "WebSocket\n/api/ws/satellite/{id}/telemetry/" as be_ws +} +node "Сервис предсказателя\n(127.0.0.1:8080)" as predictor { + rectangle "GET /api/v1/wind/*" as pred_wind +} + +' ── Внешние библиотеки ─────────────────────────────────────────── +package "Внешние библиотеки" as libs { + rectangle "Svelte 5 / SvelteKit / Vite" as lib_svelte + rectangle "MapLibre GL JS" as lib_map + rectangle "Bootstrap / Sveltestrap" as lib_bs + rectangle "Chart.js, Luxon, js-cookie" as lib_misc +} + +' ── Связи: страницы → модули ───────────────────────────────────── +r_login --> f_auth +r_predict --> f_pred +r_predict --> f_ws +r_predict --> f_wind +r_predict --> f_time +r_predict --> f_set +r_track --> f_track +r_user --> f_pred + +' ── Модули → ядро ──────────────────────────────────────────────── +features --> l_api +features --> l_map +features --> l_ui +features --> l_state +features --> l_i18n +f_auth --> l_auth + +' ── Ядро → домен (чистые типы) ─────────────────────────────────── +l_api --> l_dom +l_map --> l_dom +l_state --> l_dom + +' ── Ядро → внешние сервисы ─────────────────────────────────────── +l_api --> be_rest : HTTP /api/* +f_track --> be_ws : WebSocket +f_wind --> pred_wind : HTTP (без CSRF) + +' ── Использование внешних библиотек ────────────────────────────── +spa ..> lib_svelte +l_map ..> lib_map +l_ui ..> lib_bs +core ..> lib_misc + +@enduml diff --git a/docs/diagrams/dfd-telemetry.puml b/docs/diagrams/dfd-telemetry.puml new file mode 100644 index 0000000..483cbc1 --- /dev/null +++ b/docs/diagrams/dfd-telemetry.puml @@ -0,0 +1,62 @@ +@startuml dfd-telemetry +title Диаграмма потоков данных (DFD) подсистемы слежения (телеметрия) + +skinparam shadowing false +skinparam defaultFontName Helvetica +skinparam rectangle { + BorderColor black + BackgroundColor #F5F5F5 +} +skinparam usecase { + BorderColor #2C5AA0 + BackgroundColor #E8F0FE +} +skinparam database { + BorderColor #6B6B6B + BackgroundColor #FFFFFF +} + +' ── Внешние сущности ─────────────────────────────────────────────── +rectangle "Стратосферный зонд\n(тестовый клиент)" as sat +rectangle "Оператор\n(браузер)" as op + +' ── Процессы ────────────────────────────────────────────────────── +usecase "1.0\nПриём телеметрии\n(WebSocket onmessage)" as p1 +usecase "2.0\nЗагрузка истории\n(REST fetchHistory)" as p2 +usecase "3.0\nНормализация\nparseTelemetry" as p3 +usecase "4.0\nОтрисовка на карте\n(MapLibre $effect)" as p4 +usecase "5.0\nАнализ отклонений\ncomputeDeviations" as p5 + +' ── Хранилища данных ────────────────────────────────────────────── +database "D1 | БД телеметрии\n(Backend)" as d1 +database "D2 | points[]\n(TelemetryStore, in-memory)" as d2 +database "D3 | result\n(прогноз рабочей области)" as d3 + +' ── Потоки данных ───────────────────────────────────────────────── +sat --> p1 : пакет телеметрии\n(JSON: lat, lon, alt, ts) +p1 --> d1 : сохранение пакета +p1 --> d2 : TelemetryPoint\n(unix-сек → ISO 8601) + +op --> p2 : UUID спутника +d1 --> p2 : RawTelemetryPacket[]\n(новые первыми) +p2 --> d2 : история (reverse → хронология) + +d2 --> p3 : points[] +p3 --> p4 : Telemetry\n{flight_path[lat,lng,alt], launch} +p4 --> op : трек + маркеры + анимированный\nмаркер текущего положения + +d2 --> p5 : фактические точки +d3 --> p5 : прогнозная траектория +p5 --> op : профиль высоты,\nгоризонтальное отклонение (Хаверсин),\nΔh, макс./текущее отклонение + +' ── Текущие показатели (геттер latest) ──────────────────────────── +d2 --> op : широта, долгота, высота,\nсчётчик пакетов + +legend left + Нотация DFD (Йордан/ДеМарко): + ▢ прямоугольник — внешняя сущность + ◯ овал — процесс + ▭ database — хранилище данных + → — поток данных +endlegend +@enduml diff --git a/docs/diagrams/seq-auth.puml b/docs/diagrams/seq-auth.puml new file mode 100644 index 0000000..a95f1d9 --- /dev/null +++ b/docs/diagrams/seq-auth.puml @@ -0,0 +1,41 @@ +@startuml seq-auth +title Аутентификация: проверка сессии, вход, выход +autonumber + +actor "Пользователь" as user +participant "LoginForm /\nguard.ts" as ui +participant "authApi" as authapi +participant "client.ts\nrequest()" as client +participant "Backend\n(Django)" as be + +== Проверка сессии при открытии защищённой страницы == +ui -> authapi : requireAuthenticated() +authapi -> client : session() +client -> be : GET /api/session/ +be --> client : { isAuthenticated } +client --> authapi : SessionInfo +alt не аутентифицирован + authapi --> ui : goto('/login') +end + +== Вход == +user -> ui : ввод логина и пароля +ui -> authapi : login(username, password) +authapi -> client : post('/login/', {username, password}) +client -> be : POST /api/login/ +be --> client : 200 { detail } | 400/401 ApiError +client --> authapi : результат +authapi -> client : whoami() +client -> be : GET /api/whoami/ +be --> client : { username } +client --> ui : WhoAmI +ui --> user : переход на рабочую страницу + +== Выход == +user -> ui : «Выйти» +ui -> authapi : logout() +authapi -> client : post('/logout/', {}) +client -> be : POST /api/logout/ +be --> client : 204 +client --> ui : сброс состояния, goto('/login') +@enduml diff --git a/docs/diagrams/seq-prediction.puml b/docs/diagrams/seq-prediction.puml new file mode 100644 index 0000000..3a2665d --- /dev/null +++ b/docs/diagrams/seq-prediction.puml @@ -0,0 +1,41 @@ +@startuml seq-prediction +title Запуск прогноза траектории +autonumber + +actor "Пользователь" as user +participant "ControlPanel /\nWorkspacesPanel" as ui +participant "workspacesStore" as store +participant "predictionsApi" as predapi +participant "client.ts" as client +participant "Backend\n(Django)" as be +participant "parsePrediction\n(domain)" as parse +participant "WorkspaceRenderer\n(карта)" as render + +user -> ui : «Выполнить прогнозирование» +ui -> store : run(workspaceId) +activate store + +store -> predapi : run(params, launchDateTime) +activate predapi +predapi -> predapi : buildLaunchDateTime(date, time)\n→ ISO 8601 (UTC, Z) +predapi -> predapi : getLatestDataset()\n→ актуальный слот GFS +predapi -> client : post('/predictions/', payload) +client -> be : POST /api/predictions/\n{FlightParameters, launch_datetime, dataset} +be --> client : { result: PredictionStage[] } +client --> predapi : PredictionResponse +predapi --> store : RawPrediction +deactivate predapi + +store -> parse : parsePrediction(stages) +parse --> store : Prediction\n{flight_path[lat,lng,alt], timestamps[], launch/burst/landing} +store -> store : setResult(workspaceId, prediction) +deactivate store + +store -> render : реактивное обновление +render -> render : линия трека + маркеры\n(старт, разрыв, приземление) + +alt ошибка сервера/сети + store -> store : lastRunError = message + store --> ui : toast «Ошибка прогнозирования» +end +@enduml diff --git a/docs/diagrams/seq-telemetry.puml b/docs/diagrams/seq-telemetry.puml new file mode 100644 index 0000000..ebc323e --- /dev/null +++ b/docs/diagrams/seq-telemetry.puml @@ -0,0 +1,60 @@ +@startuml seq-telemetry +title Слежение: загрузка истории и приём телеметрии в реальном времени +autonumber + +actor "Пользователь" as user +participant "TelemetryPanel" as ui +participant "TelemetryStore\n(.svelte.ts)" as store +participant "telemetryApi" as tapi +participant "client.ts" as client +participant "Backend REST" as rest +participant "Backend\nWebSocket" as ws +participant "track/+page\n($effect)" as page +participant "MapLibre\n(карта)" as map + +user -> ui : ввод UUID, «Подключиться» +ui -> store : connect(id) +activate store + +store -> store : проверка UUID регулярным выражением +alt UUID невалиден + store -> store : status = 'error' + store --> ui : (выход без соединения) +end + +store -> store : disconnect()\nзакрыть прежний WS, очистить points +store -> store : status = 'connecting' + +== Загрузка истории (некритическая) == +store -> tapi : fetchHistory(id) +tapi -> client : get('/{id}/telemetry/') +client -> rest : GET /api/{id}/telemetry/?from&till +rest --> client : RawTelemetryPacket[] | { results } +client --> tapi : массив пакетов +tapi --> store : RawTelemetryPacket[] +store -> store : reverse() → хронологический порядок,\npoints = [...history] +note right of store : сбой сети → console.warn,\nподключение не прерывается + +== WebSocket-соединение == +store -> tapi : buildWsUrl(id) +tapi --> store : ws(s)://host/api/ws/satellite/{id}/telemetry/ +store -> ws : new WebSocket(url) +ws --> store : onopen → status = 'connected' + +loop каждый новый пакет + ws --> store : onmessage(JSON) + store -> store : RawPacket → TelemetryPoint\n(unix-сек → ISO 8601),\npoints = [...points, point] + store --> page : реактивное изменение points (Svelte $state) + page -> map : scene.clear() + page -> map : addLine(трек) + addMarker(старт)\n+ plotAnimatedMarker(текущая точка) + page -> map : fitBounds() — однократно (fittedBounds) +end + +== Отключение == +user -> ui : «Отключиться» +ui -> store : disconnect() +store -> ws : close() +ws --> store : onclose → status = 'idle' +store -> store : points = [], satelliteId = null +deactivate store +@enduml diff --git a/docs/diagrams/seq-wind.puml b/docs/diagrams/seq-wind.puml new file mode 100644 index 0000000..4b6a71c --- /dev/null +++ b/docs/diagrams/seq-wind.puml @@ -0,0 +1,54 @@ +@startuml seq-wind +title Визуализация поля ветра: статический режим и синхронизация с траекторией +autonumber + +participant "predict/+page\n" as render +participant "WindRenderer\n(.svelte)" as wr +participant "WindCache\n(in-memory)" as cache +participant "windApi" as wapi +participant "Сервис\nпредсказателя\n(127.0.0.1:8080)" as pred +participant "createWindInterpolator\n+ ParticleField" as pf + +note over wapi, pred + Запросы идут НАПРЯМУЮ через fetch к сервису предсказателя, + минуя client.ts: без CSRF и сессионных cookie. +end note + +alt Статический режим (ветер по умолчанию) + wr -> wr : параметры из активной области\n(высота старта, дата старта) + wr -> cache : get(ключ = JSON параметров) + alt промах кэша + wr -> wapi : field({ altitude, step, time }) + wapi -> pred : GET /api/v1/wind/field?altitude&step&time + pred --> wapi : WindField [C_U, C_V] + wapi --> wr : WindField + wr -> cache : put(ключ, field) + end + wr -> pf : установить поле → анимация частиц +end + +alt Режим синхронизации с траекторией + note over wr : активен прогноз И timeline.max > 0 + wr -> wr : bbox траектории + проверки\n(F ≤ Fmax, сторона ≤ Dmax) + loop δ ∈ {0, ΔT, 2ΔT, …, F} + wr -> wr : T = t0 + δ; высота a_{k*} (бин. поиск) + wr -> cache : get(ключ кадра) + alt промах кэша + wr -> wapi : field({ altitude, step, time, min/max lat/lng }) + wapi -> pred : GET /api/v1/wind/field?…(bbox) + pred --> wapi : WindField кадра + wapi --> wr : WindField + wr -> cache : put(ключ кадра, field) + end + end + loop при воспроизведении (timeline) + wr -> wr : fieldAtFlightTime(δ):\nлинейная интерполяция [u,v]\nмежду соседними кадрами + wr -> pf : currentField → плавная адвекция частиц + end +end + +note over wapi, pred + windApi.meta() → GET /api/v1/wind/meta + (опорное время / параметры сетки, при необходимости) +end note +@enduml diff --git a/docs/wind-vis-math.tex b/docs/wind-vis-math.tex new file mode 100644 index 0000000..652023c --- /dev/null +++ b/docs/wind-vis-math.tex @@ -0,0 +1,395 @@ +\documentclass[11pt,a4paper]{article} +\usepackage{siunitx} +\usepackage{amsmath,amssymb,amsthm} +\usepackage{geometry} +\usepackage{hyperref} +\usepackage{booktabs} +\geometry{margin=2.5cm} + +\title{Wind Visualisation -- Mathematical Reference\\ + \large stratoflights-predictor frontend} +\author{stratoflights} +\date{} + +\begin{document} +\maketitle +\tableofcontents +\bigskip + +%--------------------------------------------------------------------------- +\section{Wind Field Grid Format} +%--------------------------------------------------------------------------- + +The predictor's \texttt{GET /api/v1/wind/field} endpoint returns a +two-element JSON array $[C_U,\,C_V]$, each element having the shape + +\begin{verbatim} +{ "header": { "nx", "ny", "lo1", "la1", "lo2", "la2", + "dx", "dy", "refTime", ... }, + "data": [ float, ... ] // flat row-major array, length = nx * ny +} +\end{verbatim} + +\noindent where $C_U$ contains the \emph{eastward} (zonal) component $u$ +and $C_V$ contains the \emph{northward} (meridional) component $v$, both +in \si{m\,s^{-1}}. + +\subsection{Grid coordinates} + +Let $n_x$ and $n_y$ be the number of grid points along the longitude and +latitude axes respectively. The coordinate of grid cell $(i,\,j)$ +($i = 0, \ldots, n_x-1$;\; $j = 0, \ldots, n_y-1$) is + +\begin{align} + \lambda_{i} &= \operatorname{wrap}\!\left(\lambda_1 + i\,\Delta\lambda\right), \label{eq:lng}\\ + \varphi_{j} &= \varphi_1 + j\,\Delta\varphi, \label{eq:lat} +\end{align} + +\noindent where $\lambda_1 = \texttt{lo1}$ and $\varphi_1 = \texttt{la1}$. + +\paragraph{Increments from the extent, not \texttt{dx}/\texttt{dy}.} +The predictor reports \texttt{dx} and \texttt{dy} as positive magnitudes +\emph{regardless of scan direction}, and emits longitudes in the +$[0,360)$ convention. The per-step increments are therefore derived from +the grid extent so the last row/column lands exactly on +$(\texttt{la2},\texttt{lo2})$ and the scan direction follows automatically: + +\begin{equation} + \Delta\lambda = \frac{(\texttt{lo2}-\texttt{lo1}) \bmod 360}{n_x-1}, + \qquad + \Delta\varphi = \frac{\texttt{la2}-\texttt{la1}}{n_y-1}. + \label{eq:increments} +\end{equation} + +For the standard GFS global grid $\varphi_1 = 90^\circ$, $\varphi_2 = -90^\circ$, +so $\Delta\varphi = -10^\circ$ (rows scan southward) without any case +distinction. (When $n_x=1$ or $n_y=1$ the magnitude \texttt{dx}/\texttt{dy} +is used as a fallback.) + +\paragraph{Longitude wrapping.} +Because longitudes are emitted in $[0,360)$ but the map renders in +$(-180,180]$, each longitude is wrapped: +\begin{equation} + \operatorname{wrap}(\lambda) = \big((\lambda + 180) \bmod 360\big) - 180. + \label{eq:wrap} +\end{equation} +Without \eqref{eq:wrap} a grid sampled near the prime meridian +(e.g.\ $\texttt{lo1}=358$ for a query at $-2^\circ$) would be rendered a +full $360^\circ$ away from the trajectory. + +The flat data index for cell $(i,\,j)$ is + +\begin{equation} + k = j\,n_x + i. + \label{eq:index} +\end{equation} + +%--------------------------------------------------------------------------- +\section{Bilinear Interpolation of Wind Components} +%--------------------------------------------------------------------------- + +The particle renderer samples the wind at arbitrary points (the unprojected +pixel positions of individual particles), so bilinear interpolation of the +$[u,v]$ grid is performed at run time for every particle, every frame. + +Given a query point $(\lambda,\,\varphi)$ inside the grid, locate the +surrounding four cells + +\begin{align*} + i_0 &= \left\lfloor \frac{\lambda - \lambda_1}{\Delta\lambda} \right\rfloor, + & + j_0 &= \left\lfloor \frac{\varphi - \varphi_1}{\Delta\varphi} \right\rfloor, +\end{align*} + +and define the fractional offsets + +\begin{equation*} + s = \frac{\lambda - \lambda_{i_0}}{\Delta\lambda}, + \qquad + t = \frac{\varphi - \varphi_{j_0}}{\Delta\varphi}, + \qquad + s,t \in [0,1]. +\end{equation*} + +The bilinearly interpolated value of any scalar field $f$ at $(\lambda,\varphi)$ +is + +\begin{equation} + f(\lambda,\varphi) + = (1-s)(1-t)\,f_{i_0,j_0} + + s(1-t)\,f_{i_0+1,j_0} + + (1-s)t\,f_{i_0,j_0+1} + + st\,f_{i_0+1,j_0+1}. + \label{eq:bilinear} +\end{equation} + +Applied independently to $u$ and $v$, equation~\eqref{eq:bilinear} gives +the interpolated wind vector at any interior point. + +%--------------------------------------------------------------------------- +\section{Particle Advection and Rendering} +%--------------------------------------------------------------------------- + +The wind is visualised as a dense field of particles that flow with the wind, +in the style of \texttt{leaflet-velocity} / cambecc's \emph{earth}. Particles +live in screen (CSS-pixel) space; each animation frame they are advected by +the local wind and drawn as short fading trails. + +\subsection{Speed (magnitude)} + +The wind speed used for colouring is the Euclidean norm of the horizontal +wind vector returned by the interpolator~\eqref{eq:bilinear}: + +\begin{equation} + \lVert\mathbf{w}\rVert = \sqrt{u^2 + v^2}. + \label{eq:speed} +\end{equation} + +\subsection{Advection through the map projection} + +A particle at pixel position $\mathbf{x} = (x,y)$ is unprojected to geographic +coordinates $(\lambda,\varphi) = P^{-1}(\mathbf{x})$, where $P$ is the +MapLibre projection (Web Mercator composed with the current view transform). +The wind $\mathbf{w}=(u,v)$ in the east--north frame must be expressed in +pixel space; this is done with the local Jacobian of $P$, estimated by finite +differences with a small $\varepsilon$ (degrees): + +\begin{align} + \mathbf{J}_\lambda &= \frac{P(\lambda+\varepsilon,\varphi) - \mathbf{x}}{\varepsilon}, + & + \mathbf{J}_\varphi &= \frac{P(\lambda,\varphi+\varepsilon) - \mathbf{x}}{\varepsilon}. + \label{eq:jacobian} +\end{align} + +\noindent The pixel-space velocity is the Jacobian applied to the wind vector, +scaled by a dimensionless speed factor $c$ (the configurable +\texttt{particleSpeed} times a base constant): + +\begin{equation} + \dot{\mathbf{x}} = c\,\big(\mathbf{J}_\lambda\, u + \mathbf{J}_\varphi\, v\big). + \label{eq:pixel_velocity} +\end{equation} + +Because $\mathbf{J}_\varphi$ points toward $-y$ (north is up), a northward wind +moves the particle up the screen, and the projection automatically supplies +the correct scale at every zoom level and the meridian-convergence distortion +near the poles. The particle is integrated one explicit Euler step per frame: + +\begin{equation} + \mathbf{x}_{t+1} = \mathbf{x}_t + \dot{\mathbf{x}}. + \label{eq:euler} +\end{equation} + +A particle whose position leaves the data grid (interpolator returns +\texttt{null}) or whose age exceeds $A_{\max}$ frames is respawned at a random +pixel that has wind, with a randomised initial age to desynchronise the +population. + +\subsection{Colour mapping} + +Speed is mapped to a 15-stop colour ramp (blue $\to$ red) by linear +quantisation into the range $[v_{\min}, v_{\max}]$: + +\begin{equation} + k(\lVert\mathbf{w}\rVert) = + \operatorname{round}\!\left( + \frac{\lVert\mathbf{w}\rVert - v_{\min}}{v_{\max}-v_{\min}}\,(K-1) + \right), + \quad k \in \{0,\dots,K-1\}, +\end{equation} + +\noindent clamped to the ramp, where $K=15$ and $v_{\max}$ is the configurable +\texttt{maxVelocity}. Trails sharing a colour bucket are batched into a single +stroked path per frame. + +\subsection{Trail fading} + +Rather than clearing the canvas each frame, the previous frame is faded toward +\emph{transparency} (so the basemap stays visible) by compositing a translucent +rectangle with the \texttt{destination-in} operator: + +\begin{equation} + \alpha_{t+1}(\mathbf{x}) = \rho\,\alpha_t(\mathbf{x}), + \qquad \rho \in [0,1), +\end{equation} + +\noindent where $\rho$ is the configurable \texttt{trailPersistence}; an +existing trail therefore decays geometrically, retaining a visible tail of +length $\sim 1/(1-\rho)$ frames. New trail segments are then drawn with the +\texttt{source-over} operator. + +\subsection{Particle count} + +The particle population is proportional to the canvas area: + +\begin{equation} + N = \min\!\big(N_{\max},\; W H \, \mu \, \texttt{density}\big), +\end{equation} + +\noindent with base multiplier $\mu = 1/350$ particles per pixel, the +configurable \texttt{density} factor, and a hard cap $N_{\max}=6000$ to bound +per-frame cost. The field is re-seeded on resize and on map \texttt{moveend}; +trails are cleared while the map is panning/zooming and resume afterwards. + +%--------------------------------------------------------------------------- +\section{Trajectory Bounding Box} +%--------------------------------------------------------------------------- + +Let the prediction trajectory be the sequence of points +$\{(\varphi_k, \lambda_k)\}_{k=0}^{N-1}$. The axis-aligned bounding box +with margin $m$ (degrees) is + +\begin{align} + \varphi_{\min}' &= \min_k \varphi_k - m, & + \varphi_{\max}' &= \max_k \varphi_k + m, \notag\\ + \lambda_{\min}' &= \min_k \lambda_k - m, & + \lambda_{\max}' &= \max_k \lambda_k + m. + \label{eq:bbox} +\end{align} + +A wind field request is suppressed when either span exceeds the configured +maximum $D_{\max}$: + +\begin{equation} + (\varphi_{\max}' - \varphi_{\min}') > D_{\max} + \quad\text{or}\quad + (\lambda_{\max}' - \lambda_{\min}') > D_{\max}. + \label{eq:bbox_guard} +\end{equation} + +Default values: $m = 1^\circ$, $D_{\max} = 20^\circ$. + +%--------------------------------------------------------------------------- +\section{Trajectory Altitude Lookup} +%--------------------------------------------------------------------------- + +The prediction result contains two parallel arrays: +$\mathbf{p} = \{(\varphi_k, \lambda_k, a_k)\}$ (flight path with altitude +in metres) and $\mathbf{t} = \{t_k\}$ (absolute epoch timestamps in +milliseconds, $t_k < t_{k+1}$). + +Given a flight-time offset $\delta$ (milliseconds from launch), the +absolute query time is $T = t_0 + \delta$. The index of the immediately +following trajectory point is found by binary search: + +\begin{equation} + k^* = \min\{k \in \{0,\ldots,N-1\} : t_k \ge T\}. + \label{eq:binsearch} +\end{equation} + +The trajectory altitude used for the wind query is then +$a_{k^*}$ (nearest-neighbour in time, no interpolation needed since the +pre-fetch frames are already spaced far apart relative to the step size). + +%--------------------------------------------------------------------------- +\section{Time Series Pre-Fetching} +%--------------------------------------------------------------------------- + +\subsection{Frame schedule} + +Let $F$ be the total flight duration in milliseconds and $\Delta T$ the +pre-fetch interval (milliseconds, default $15 \times 60\,000$). The set +of pre-fetch time offsets is + +\begin{equation} + \mathcal{S} = \{0,\;\Delta T,\;2\Delta T,\;\ldots,\;F\}, + \label{eq:schedule} +\end{equation} + +where the last element is clamped to $F$ (so the landing point is always +included). For each $\delta \in \mathcal{S}$ a wind field is fetched at +absolute time $T = t_0 + \delta$ and trajectory altitude $a_{k^*(\delta)}$ +within the bounding box~\eqref{eq:bbox}. + +\subsection{Guard conditions} + +Pre-fetching is skipped entirely when + +\begin{enumerate} + \item $F > F_{\max}$ (flight too long; default $F_{\max} = 4$\,h), or + \item either bounding-box dimension exceeds $D_{\max}$ + (equation~\eqref{eq:bbox_guard}). +\end{enumerate} + +Both limits are configurable in the application settings. + +\subsection{Temporal interpolation between frames} + +During playback at flight-time offset $\delta$, rather than snapping to the +nearest cached frame (which would make the wind jump every $\Delta T$), the +displayed field is \emph{linearly interpolated} between the two bracketing +frames $\delta_p \le \delta < \delta_{p+1}$ ($\delta_p,\delta_{p+1}\in\mathcal{S}$). +With blend factor + +\begin{equation} + \alpha = \frac{\delta - \delta_p}{\delta_{p+1} - \delta_p} \in [0,1), + \label{eq:blend_alpha} +\end{equation} + +each wind component is blended cell-for-cell: + +\begin{equation} + u(\delta) = (1-\alpha)\,u^{(p)} + \alpha\,u^{(p+1)}, + \qquad + v(\delta) = (1-\alpha)\,v^{(p)} + \alpha\,v^{(p+1)}. + \label{eq:time_lerp} +\end{equation} + +This is valid because every frame is fetched over the \emph{same} bounding +box~\eqref{eq:bbox} and step, so the grids are cell-aligned ($n_x,n_y, +\lambda_1,\varphi_1$ identical) and~\eqref{eq:time_lerp} requires only an +element-wise blend of the two flat $[u,v]$ arrays. Note that the two frames +are sampled at slightly different altitudes $a_{k^*(\delta_p)}$ and +$a_{k^*(\delta_{p+1})}$; the blend therefore also interpolates across the +balloon's changing altitude, which is the desired behaviour. The result is +continuous wind evolution along the route while still requiring only +$|\mathcal{S}|$ network requests per trajectory. + +The blended field~\eqref{eq:time_lerp} is handed to the particle renderer +(\S\,Particle Advection) as the interpolator swapped in each frame, so the +flowing particles advect through the smoothly time-evolving wind without any +visible stepping between pre-fetch frames. + +%--------------------------------------------------------------------------- +\section{Complexity and Performance Notes} +%--------------------------------------------------------------------------- + +\subsection{Grid size and render cost} + +For a regional bounding box of $L_\varphi \times L_\lambda$ degrees and +step $h$ degrees, the number of grid cells fetched is + +\begin{equation} + n = \left\lceil \frac{L_\varphi}{h} \right\rceil + \left\lceil \frac{L_\lambda}{h} \right\rceil. +\end{equation} + +With the default trajectory step $h = 1^\circ$ and worst-case dimensions +$20^\circ \times 20^\circ$ this is $n = 400$ cells; the static global view at +$h = 2^\circ$ is $90 \times 180 = 16\,200$ cells. Crucially, the grid size +only affects the \emph{fetch} and the per-particle bilinear lookup, not the +render budget: the per-frame cost is governed by the particle count $N$ +(eq.~for $N$), capped at $N_{\max}=6000$, independent of the grid resolution +or the geographic extent. Each particle costs one unprojection, one +interpolation, and two projections (for the Jacobian) per frame. + +\subsection{Request count} + +The total number of requests for one trajectory is + +\begin{equation} + |\mathcal{S}| = \left\lfloor \frac{F}{\Delta T} \right\rfloor + 1 + \;\le\; \frac{F_{\max}}{\Delta T} + 1. +\end{equation} + +With the defaults $F_{\max} = 4$\,h and $\Delta T = 15$\,min: +$|\mathcal{S}| \le 17$. Requests are issued sequentially to avoid +bursty load on the predictor. + +\subsection{Caching} + +The in-memory cache (keyed by the full parameter tuple) ensures that +re-running a prediction with the same parameters, scrubbing the timeline +back and forth, or toggling the wind layer all reuse existing responses. + +\end{document} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 29b43d2..82b37f9 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -4,3 +4,4 @@ export { pointsApi } from './points'; export { profilesApi } from './profiles'; export { scenariosApi } from './scenarios'; export { predictionsApi, getLatestDataset, buildLaunchDateTime } from './predictions'; +export { windApi, type WindFieldParams } from './wind'; diff --git a/src/lib/api/predictions.ts b/src/lib/api/predictions.ts index 6ad9f12..566577c 100644 --- a/src/lib/api/predictions.ts +++ b/src/lib/api/predictions.ts @@ -6,10 +6,11 @@ import type { FlightParameters, RawPrediction } from '$domain'; * Round down to the most recent available slot. */ export function getLatestDataset(now: Date = new Date()): string { - const rounded = new Date(now); - rounded.setUTCHours(Math.floor(rounded.getUTCHours() / 6) * 6, 0, 0, 0); - rounded.setUTCHours(rounded.getUTCHours() - 6); - return rounded.toISOString(); + // const rounded = new Date(now); + // rounded.setUTCHours(Math.floor(rounded.getUTCHours() / 6) * 6, 0, 0, 0); + // rounded.setUTCHours(rounded.getUTCHours() - 6); + // return rounded.toISOString(); + return "2025-04-06T00:00:00Z"; } export function buildLaunchDateTime(date: string, time: string): string { diff --git a/src/lib/api/wind.ts b/src/lib/api/wind.ts new file mode 100644 index 0000000..ea2d29f --- /dev/null +++ b/src/lib/api/wind.ts @@ -0,0 +1,58 @@ +/** + * Client for the predictor's wind-visualization endpoints. + * + * These endpoints live on the predictor service (default 127.0.0.1:8080), + * not on the Django backend, so they bypass the shared `api` client and + * fetch directly. No CSRF or session cookies are needed. + * + * Set VITE_PREDICTOR_BASE_URL to point at a non-default predictor address. + */ + +import type { WindField, WindMeta } from '$domain'; + +const PREDICTOR_URL = (import.meta.env.VITE_PREDICTOR_BASE_URL as string | undefined) ?? 'http://127.0.0.1:8080'; + +export interface WindFieldParams { + altitude?: number; + step?: number; + time?: string; + min_lat?: number; + max_lat?: number; + min_lng?: number; + max_lng?: number; +} + +async function predictorFetch(path: string, params?: Record): Promise { + const q = new URLSearchParams(); + if (params) { + for (const [k, v] of Object.entries(params)) { + if (v !== undefined) q.set(k, String(v)); + } + } + const qs = q.toString(); + const url = `${PREDICTOR_URL}${path}${qs ? '?' + qs : ''}`; + const res = await fetch(url); + if (!res.ok) { + const text = await res.text().catch(() => res.statusText); + throw new Error(`Predictor ${path} failed: HTTP ${res.status} ${text}`); + } + return res.json() as Promise; +} + +export const windApi = { + field(params: WindFieldParams = {}): Promise { + return predictorFetch('/api/v1/wind/field', { + altitude: params.altitude, + step: params.step, + time: params.time, + min_lat: params.min_lat, + max_lat: params.max_lat, + min_lng: params.min_lng, + max_lng: params.max_lng, + }); + }, + + meta(): Promise { + return predictorFetch('/api/v1/wind/meta'); + }, +}; diff --git a/src/lib/domain/index.ts b/src/lib/domain/index.ts index 7c86976..686a415 100644 --- a/src/lib/domain/index.ts +++ b/src/lib/domain/index.ts @@ -3,3 +3,4 @@ export * from './math'; export * from './scenario'; export * from './prediction'; export * from './telemetry'; +export * from './wind'; diff --git a/src/lib/domain/math.ts b/src/lib/domain/math.ts index 21a75d3..3c88c11 100644 --- a/src/lib/domain/math.ts +++ b/src/lib/domain/math.ts @@ -1,4 +1,6 @@ import type { LatLng } from './geo'; +import type { TelemetryPoint } from './telemetry'; +import type { Prediction } from './prediction'; const EARTH_RADIUS_KM = 6371; @@ -32,3 +34,66 @@ export function toFixedNumber(num: number, digits: number): number { const pow = 10 ** digits; return Math.round(num * pow) / pow; } + +/** One compared sample: telemetry point matched against the closest-in-time prediction point. */ +export interface DeviationPoint { + /** Epoch ms from the telemetry timestamp. */ + timeMs: number; + /** Great-circle distance from actual position to predicted position, km. */ + horizontal: number; + /** Altitude difference (actual − predicted), m. Positive means actual is higher. */ + vertical: number; + /** Actual altitude from telemetry, m. */ + altActual: number; + /** Predicted altitude at the matched index, m. */ + altPredicted: number; +} + +/** + * For each telemetry point find the closest-in-time point in the prediction + * and compute horizontal (haversine) and vertical deviations. + * + * Telemetry points that fall outside the prediction's time window are skipped — + * bisectClosest would clamp them to the boundary and produce misleading values. + */ +export function computeDeviations( + points: TelemetryPoint[], + prediction: Prediction, +): DeviationPoint[] { + if (points.length === 0 || prediction.timestamps.length === 0) return []; + + const predStart = prediction.timestamps[0]; + const predEnd = prediction.timestamps[prediction.timestamps.length - 1]; + + const result: DeviationPoint[] = []; + for (const p of points) { + const t = new Date(p.datetime).getTime(); + if (t < predStart || t > predEnd) continue; + + const i = bisectClosest(prediction.timestamps, t); + const fp = prediction.flight_path[i]; + const predAlt = (fp[2] as number | undefined) ?? 0; + + result.push({ + timeMs: t, + horizontal: distHaversine({ lat: p.latitude, lng: p.longitude }, { lat: fp[0], lng: fp[1] }), + vertical: p.altitude - predAlt, + altActual: p.altitude, + altPredicted: predAlt, + }); + } + return result; +} + +/** Binary search: index of the element in `arr` closest to `target`. */ +function bisectClosest(arr: number[], target: number): number { + let lo = 0; + let hi = arr.length - 1; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (arr[mid] < target) lo = mid + 1; + else hi = mid; + } + if (lo > 0 && Math.abs(arr[lo - 1] - target) < Math.abs(arr[lo] - target)) return lo - 1; + return lo; +} diff --git a/src/lib/domain/prediction.ts b/src/lib/domain/prediction.ts index 4ce9775..91f86b9 100644 --- a/src/lib/domain/prediction.ts +++ b/src/lib/domain/prediction.ts @@ -30,6 +30,8 @@ export interface RawPrediction { 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; @@ -55,13 +57,16 @@ export function parsePrediction(stages: PredictionStage[]): Prediction { const ascent = stages[0].trajectory; const descent = stages[1].trajectory; + const all = [...ascent, ...descent]; - const flight_path: LatLngTuple[] = [...ascent, ...descent].map((p) => [ + 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]); @@ -69,5 +74,5 @@ export function parsePrediction(stages: PredictionStage[]): Prediction { const profile = stages[1].stage === 'descent' ? 'standard_profile' : 'float_profile'; const flight_time = (landing.datetime.getTime() - launch.datetime.getTime()) / 1000; - return { flight_path, launch, burst, landing, profile, flight_time }; + return { flight_path, timestamps, launch, burst, landing, profile, flight_time }; } diff --git a/src/lib/domain/wind.ts b/src/lib/domain/wind.ts new file mode 100644 index 0000000..46455bc --- /dev/null +++ b/src/lib/domain/wind.ts @@ -0,0 +1,214 @@ +/** + * Wind field types matching the wind-js-server / leaflet-velocity format + * produced by the predictor's GET /api/v1/wind/field endpoint. + * + * The response is a two-element array [U, V] where U is the eastward and V + * the northward wind component, each stored as a regular lat/lng grid + * described by a GRIB-style header. + */ + +export interface WindHeader { + parameterUnit: string; + parameterNumberName: string; + /** Grid points in the longitude direction. */ + nx: number; + /** Grid points in the latitude direction. */ + ny: number; + lo1: number; // longitude of first grid point (degrees) + la1: number; // latitude of first grid point (degrees) + lo2: number; // longitude of last grid point + la2: number; // latitude of last grid point + /** + * Grid increments in degrees. Both are reported as positive magnitudes by + * the predictor regardless of scan direction, so the scan direction must be + * inferred from the extent (la1/la2, lo1/lo2) — see decodeWindField. + */ + dx: number; + dy: number; + refTime: string; // ISO 8601 reference time +} + +export interface WindComponent { + header: WindHeader; + /** Flat row-major array: data[j * nx + i] = value at row j, column i. */ + data: number[]; +} + +/** [U-component (eastward m/s), V-component (northward m/s)] */ +export type WindField = [WindComponent, WindComponent]; + +export interface WindMeta { + source: string; + epoch: string; + altitudes: number[]; + bbox: { + min_lat: number; + max_lat: number; + min_lng: number; + max_lng: number; + }; +} + +/** Decoded wind vector at a single grid cell. */ +export interface WindVector { + lat: number; + lng: number; + u: number; // eastward component (m/s) + v: number; // northward component (m/s) + speed: number; // magnitude (m/s) + /** + * Direction the wind blows TO, degrees clockwise from north. + * 0° = northward, 90° = eastward. Used directly as MapLibre icon-rotate. + * + * Derivation: bearing = atan2(U, V) (see docs/wind-vis-math.tex §3). + */ + bearing: number; +} + +export interface WindSettings { + /** Master toggle — off by default. */ + enabled: boolean; + /** Grid resolution for static display (degrees). */ + step: number; + /** Grid resolution when synced to a trajectory (degrees). */ + trajectoryStep: number; + /** Time interval between pre-fetched trajectory frames (minutes). */ + prefetchIntervalMinutes: number; + /** Trajectory sync is skipped when flight duration exceeds this (hours). */ + maxFlightDurationHours: number; + /** + * Trajectory sync is skipped when the bounding box exceeds this in either + * dimension (degrees). + */ + maxRegionDegrees: number; + /** Padding added to the trajectory bounding box on each side (degrees). */ + trajectoryMarginDegrees: number; + /** Particle count scalar (particles per screen pixel). Higher = denser. */ + particleDensity: number; + /** Advection speed multiplier — how fast particles flow. */ + particleSpeed: number; + /** Trail persistence in [0,1): fraction of each trail kept per frame. */ + trailPersistence: number; + /** Wind speed (m/s) mapped to the top of the colour scale. */ + maxVelocity: number; +} + +export const DEFAULT_WIND_SETTINGS: WindSettings = { + enabled: false, + step: 2.0, + trajectoryStep: 1.0, + prefetchIntervalMinutes: 15, + maxFlightDurationHours: 4, + maxRegionDegrees: 20, + trajectoryMarginDegrees: 1.0, + particleDensity: 1.0, + particleSpeed: 1.0, + trailPersistence: 0.92, + maxVelocity: 30, +}; + +/** Wrap a longitude into the (-180, 180] range MapLibre renders. */ +function wrapLng(lng: number): number { + let x = ((lng + 180) % 360) - 180; + if (x <= -180) x += 360; + return x; +} + +/** + * Rasterize a WindField into an array of wind vectors — one per grid cell. + * + * Coordinate handling is derived from the grid extent (la1/la2, lo1/lo2) + * rather than the raw dx/dy increments, because the predictor reports: + * • longitudes in the 0..360 range (e.g. lo1 = 358 for a query at -2°), and + * • a *positive* dy even when the grid scans 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 arrow at its true + * geographic position regardless of scan direction or longitude convention. + */ +export function decodeWindField(field: WindField): WindVector[] { + const [uComp, vComp] = field; + const { nx, ny, lo1, la1, lo2, la2, dx, dy } = uComp.header; + const vectors: WindVector[] = []; + + // Per-step deltas taken from the grid extent so the last row/column lands + // exactly on la2/lo2. Longitude span is taken the short way around the + // globe to stay correct for boxes that cross the 0/360 seam. + const lonSpan = ((lo2 - lo1) % 360 + 360) % 360; + const lngDelta = nx > 1 ? lonSpan / (nx - 1) : dx; + const latDelta = ny > 1 ? (la2 - la1) / (ny - 1) : -Math.abs(dy); + + for (let j = 0; j < ny; j++) { + const lat = la1 + j * latDelta; + for (let i = 0; i < nx; i++) { + const idx = j * nx + i; + const u = uComp.data[idx]; + const v = vComp.data[idx]; + if (!Number.isFinite(u) || !Number.isFinite(v)) continue; + const lng = wrapLng(lo1 + i * lngDelta); + const speed = Math.sqrt(u * u + v * v); + const bearing = (Math.atan2(u, v) * 180) / Math.PI; + vectors.push({ lat, lng, u, v, speed, bearing }); + } + } + + return vectors; +} + +/** Samples the wind field at an arbitrary lng/lat. Returns null outside the grid. */ +export type WindInterpolator = (lng: number, lat: number) => [number, number] | null; + +/** + * Build a bilinear interpolator over a WindField. Used by the particle + * renderer to advect points through a continuous [u, v] field. + * + * Coordinate handling mirrors decodeWindField: longitudes are taken in the + * grid's native 0..360 frame (so a query lng is brought into that frame), + * and the per-step increments come from the grid extent so scan direction is + * handled implicitly. + */ +export function createWindInterpolator(field: WindField): WindInterpolator { + const [uComp, vComp] = field; + const { nx, ny, lo1, la1, lo2, la2, dx, dy } = uComp.header; + const u = uComp.data; + const v = vComp.data; + + const lonSpan = (((lo2 - lo1) % 360) + 360) % 360; + const lngDelta = nx > 1 ? lonSpan / (nx - 1) : dx; + const latDelta = ny > 1 ? (la2 - la1) / (ny - 1) : -Math.abs(dy); + + return (lng, lat) => { + if (lngDelta === 0 || latDelta === 0) return null; + + const rj = (lat - la1) / latDelta; + if (rj < 0 || rj > ny - 1) return null; + + // Eastward offset from lo1 in the grid's 0..360 frame. + const dLon = (((lng - lo1) % 360) + 360) % 360; + const ci = dLon / lngDelta; + if (ci < 0 || ci > nx - 1) return null; + + const i0 = Math.floor(ci); + const j0 = Math.floor(rj); + const i1 = Math.min(i0 + 1, nx - 1); + const j1 = Math.min(j0 + 1, ny - 1); + const fi = ci - i0; + const fj = rj - j0; + + const a = (1 - fi) * (1 - fj); + const b = fi * (1 - fj); + const c = (1 - fi) * fj; + const d = fi * fj; + + const k00 = j0 * nx + i0; + const k10 = j0 * nx + i1; + const k01 = j1 * nx + i0; + const k11 = j1 * nx + i1; + + const ui = u[k00] * a + u[k10] * b + u[k01] * c + u[k11] * d; + const vi = v[k00] * a + v[k10] * b + v[k01] * c + v[k11] * d; + if (!Number.isFinite(ui) || !Number.isFinite(vi)) return null; + return [ui, vi]; + }; +} diff --git a/src/lib/features/settings/index.ts b/src/lib/features/settings/index.ts index e78744c..1deb343 100644 --- a/src/lib/features/settings/index.ts +++ b/src/lib/features/settings/index.ts @@ -1,5 +1,5 @@ export { settingsStore, DEFAULT_SETTINGS } from './store'; -export type { AppSettings, MapSettings, UnitsSettings } from './store'; +export type { AppSettings, MapSettings, UnitsSettings, WindSettings } from './store'; export { default as SettingsPanel } from './SettingsPanel.svelte'; export { SETTINGS_SCHEMA } from './schema'; export type { SettingsField, SettingsSection } from './schema'; diff --git a/src/lib/features/settings/schema.ts b/src/lib/features/settings/schema.ts index 7fb4446..22265ef 100644 --- a/src/lib/features/settings/schema.ts +++ b/src/lib/features/settings/schema.ts @@ -83,4 +83,90 @@ export const SETTINGS_SCHEMA: SettingsSection[] = [ }, ], }, + { + titleKey: 'settings.wind', + fields: [ + { kind: 'boolean', path: 'wind.enabled', labelKey: 'settings.windEnabled' }, + { + kind: 'number', + path: 'wind.step', + labelKey: 'settings.windStep', + min: 0.25, + max: 10, + step: 0.25, + }, + { + kind: 'number', + path: 'wind.trajectoryStep', + labelKey: 'settings.windTrajectoryStep', + min: 0.25, + max: 5, + step: 0.25, + }, + { + kind: 'number', + path: 'wind.prefetchIntervalMinutes', + labelKey: 'settings.windPrefetchInterval', + min: 5, + max: 60, + step: 5, + }, + { + kind: 'number', + path: 'wind.maxFlightDurationHours', + labelKey: 'settings.windMaxDuration', + min: 1, + max: 8, + step: 0.5, + }, + { + kind: 'number', + path: 'wind.maxRegionDegrees', + labelKey: 'settings.windMaxRegion', + min: 5, + max: 60, + step: 5, + }, + { + kind: 'number', + path: 'wind.trajectoryMarginDegrees', + labelKey: 'settings.windMargin', + min: 0.5, + max: 5, + step: 0.5, + }, + { + kind: 'number', + path: 'wind.particleDensity', + labelKey: 'settings.windParticleDensity', + min: 0.25, + max: 3, + step: 0.25, + }, + { + kind: 'number', + path: 'wind.particleSpeed', + labelKey: 'settings.windParticleSpeed', + min: 0.25, + max: 4, + step: 0.25, + }, + { + kind: 'number', + path: 'wind.trailPersistence', + labelKey: 'settings.windTrailPersistence', + min: 0.7, + max: 0.98, + step: 0.02, + }, + { + kind: 'number', + path: 'wind.maxVelocity', + labelKey: 'settings.windMaxVelocity', + min: 10, + max: 80, + step: 5, + }, + ], + }, ]; diff --git a/src/lib/features/settings/store.ts b/src/lib/features/settings/store.ts index 41e5cc2..c13a866 100644 --- a/src/lib/features/settings/store.ts +++ b/src/lib/features/settings/store.ts @@ -1,5 +1,8 @@ import { persisted } from '$state'; import type { Locale } from '$i18n'; +import { type WindSettings, DEFAULT_WIND_SETTINGS } from '$domain'; + +export type { WindSettings }; export interface MapSettings { baseLayer: 'osm' | 'satellite'; @@ -15,12 +18,14 @@ export interface AppSettings { locale: Locale; map: MapSettings; units: UnitsSettings; + wind: WindSettings; } export const DEFAULT_SETTINGS: AppSettings = { locale: 'ru', map: { baseLayer: 'osm', showScale: true, showNavigation: true }, units: { system: 'metric' }, + wind: { ...DEFAULT_WIND_SETTINGS }, }; export const settingsStore = persisted('settings', DEFAULT_SETTINGS); diff --git a/src/lib/features/tracking/DeviationChart.svelte b/src/lib/features/tracking/DeviationChart.svelte new file mode 100644 index 0000000..06c8a77 --- /dev/null +++ b/src/lib/features/tracking/DeviationChart.svelte @@ -0,0 +1,203 @@ + + + + + +{#if !hasData} +

{$t('tracking.noData')}

+{/if} + + +
+

{$t('tracking.altProfile')}

+
+ +
+ + {#if !hasDeviation} +

{$t('tracking.selectPrediction')}

+ {/if} +
+ + +
+
+

{$t('tracking.horizontalDev')}

+
+ +
+ + {#if deviations && deviations.length > 0} + {@const maxDev = Math.max(...deviations.map((d) => d.horizontal))} + {@const last = deviations[deviations.length - 1]} +
+ + {$t('tracking.devMax')} {maxDev.toFixed(2)} км + + + {$t('tracking.devCurrent')} {last.horizontal.toFixed(2)} км + + + Δh: + {last.vertical > 0 ? '+' : ''}{last.vertical.toFixed(0)} м + + +
+ {/if} +
diff --git a/src/lib/features/tracking/index.ts b/src/lib/features/tracking/index.ts index 2ee3723..4e5685c 100644 --- a/src/lib/features/tracking/index.ts +++ b/src/lib/features/tracking/index.ts @@ -1,2 +1,3 @@ export { default as TelemetryPanel } from './TelemetryPanel.svelte'; +export { default as DeviationChart } from './DeviationChart.svelte'; export { telemetryStore, type TrackingStatus } from './telemetryStore.svelte'; diff --git a/src/lib/features/wind/ParticleField.ts b/src/lib/features/wind/ParticleField.ts new file mode 100644 index 0000000..3d47d44 --- /dev/null +++ b/src/lib/features/wind/ParticleField.ts @@ -0,0 +1,335 @@ +/** + * ParticleField — an animated wind-flow layer rendered to a 2D canvas + * overlaid on the MapLibre container, in the spirit of leaflet-velocity / + * cambecc's "earth". + * + * Particles live in CSS-pixel space. Each frame, every particle is unprojected + * to lng/lat, the wind [u, v] there is sampled, and that vector is pushed + * through the map projection's local Jacobian to obtain a pixel-space velocity + * (so motion is correct at any zoom/latitude). Trails are faded by compositing + * a translucent clear over the previous frame, leaving the basemap visible. + * + * The wind field can change every frame (the renderer interpolates between + * pre-fetched trajectory frames over time); only the lightweight interpolator + * closure is swapped, so particle motion stays continuous. See + * docs/wind-vis-math.tex §"Particle Advection". + */ + +import type { Map as MLMap } from 'maplibre-gl'; +import type { WindInterpolator } from '$domain'; + +export interface ParticleOptions { + /** Particles per screen pixel (scaled by the base multiplier). */ + density: number; + /** Advection speed multiplier. */ + speed: number; + /** Trail persistence in [0,1): fraction of the trail kept each frame. */ + trailPersistence: number; + /** Max frames a particle lives before it is respawned. */ + maxAge: number; + /** Trail line width (CSS px). */ + lineWidth: number; + /** Wind speed (m/s) at the bottom / top of the colour scale. */ + minVelocity: number; + maxVelocity: number; + /** Target frame rate (the field is re-evaluated at most this often). */ + frameRate: number; + /** Colour ramp from slow → fast wind. */ + colorScale: string[]; +} + +export const DEFAULT_COLOR_SCALE = [ + 'rgb(36,104,180)', + 'rgb(60,157,194)', + 'rgb(128,205,193)', + 'rgb(151,218,168)', + 'rgb(198,231,181)', + 'rgb(238,247,217)', + 'rgb(255,238,159)', + 'rgb(252,217,125)', + 'rgb(255,182,100)', + 'rgb(252,150,75)', + 'rgb(250,112,52)', + 'rgb(245,64,32)', + 'rgb(237,45,28)', + 'rgb(220,24,32)', + 'rgb(180,0,35)', +]; + +export const DEFAULT_PARTICLE_OPTIONS: ParticleOptions = { + density: 1.0, + speed: 1.0, + trailPersistence: 0.92, + maxAge: 100, + lineWidth: 1.4, + minVelocity: 0, + maxVelocity: 30, + frameRate: 30, + colorScale: DEFAULT_COLOR_SCALE, +}; + +/** Base particle count = pixels × this (kept modest for performance). */ +const PARTICLE_MULTIPLIER = 1 / 350; +const MAX_PARTICLES = 6000; + +interface Particle { + x: number; + y: number; + xt: number; + yt: number; + age: number; + speed: number; +} + +export class ParticleField { + private map: MLMap; + private host: HTMLElement; + private canvas: HTMLCanvasElement; + private ctx: CanvasRenderingContext2D; + private opts: ParticleOptions; + private interp: WindInterpolator | null = null; + + private particles: Particle[] = []; + private raf = 0; + private then = 0; + private moving = false; + private width = 0; + private height = 0; + private debugLogged = false; + + constructor(map: MLMap, opts: Partial = {}) { + 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): void { + const densityChanged = opts.density !== undefined && opts.density !== this.opts.density; + this.opts = { ...this.opts, ...opts }; + if (densityChanged) this.seedParticles(); + } + + /** Swap the wind field. Pass null to clear the flow. */ + setField(interp: WindInterpolator | null): void { + this.interp = interp; + if (interp && this.particles.length === 0) this.seedParticles(); + } + + start(): void { + if (this.raf) return; + this.then = performance.now(); + this.raf = requestAnimationFrame(this.frame); + } + + stop(): void { + if (this.raf) cancelAnimationFrame(this.raf); + this.raf = 0; + this.clear(); + } + + destroy(): void { + this.stop(); + this.map.off('movestart', this.onMoveStart); + this.map.off('moveend', this.onMoveEnd); + this.map.off('resize', this.onResize); + this.canvas.remove(); + } + + // ── Internals ───────────────────────────────────────────────────────────── + + private onMoveStart = (): void => { + this.moving = true; + this.clear(); + }; + + private onMoveEnd = (): void => { + this.moving = false; + this.seedParticles(); + }; + + private onResize = (): void => { + this.resize(); + }; + + private resize(): void { + const dpr = window.devicePixelRatio || 1; + // Size from the gl canvas: it always reports the true viewport size, + // whereas the canvas-container wrapper can measure 0 in some layouts. + const glCanvas = this.map.getCanvas(); + const w = glCanvas.clientWidth || this.map.getContainer().clientWidth; + const h = glCanvas.clientHeight || this.map.getContainer().clientHeight; + if (!w || !h) return; + this.width = w; + this.height = h; + this.canvas.style.width = `${w}px`; + this.canvas.style.height = `${h}px`; + this.canvas.width = Math.round(w * dpr); + this.canvas.height = Math.round(h * dpr); + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // draw in CSS-pixel space + this.seedParticles(); + } + + private particleCount(): number { + const n = this.width * this.height * PARTICLE_MULTIPLIER * this.opts.density; + return Math.max(0, Math.min(MAX_PARTICLES, Math.round(n))); + } + + private seedParticles(): void { + const count = this.particleCount(); + this.particles = new Array(count); + for (let i = 0; i < count; i++) { + this.particles[i] = { x: 0, y: 0, xt: 0, yt: 0, age: 0, speed: 0 }; + this.respawn(this.particles[i]); + this.particles[i].age = Math.floor(Math.random() * this.opts.maxAge); + } + } + + /** Place a particle at a random pixel that has wind (a few retries). */ + private respawn(p: Particle): void { + for (let attempt = 0; attempt < 8; attempt++) { + const x = Math.random() * this.width; + const y = Math.random() * this.height; + if (!this.interp) { + p.x = p.xt = x; + p.y = p.yt = y; + break; + } + const ll = this.map.unproject([x, y]); + if (this.interp(ll.lng, ll.lat)) { + p.x = p.xt = x; + p.y = p.yt = y; + break; + } + p.x = p.xt = x; + p.y = p.yt = y; + } + p.age = 0; + p.speed = 0; + } + + private clear(): void { + this.ctx.clearRect(0, 0, this.width, this.height); + } + + private colorIndex(speed: number): number { + const { minVelocity, maxVelocity, colorScale } = this.opts; + const f = (speed - minVelocity) / (maxVelocity - minVelocity); + return Math.max(0, Math.min(colorScale.length - 1, Math.round(f * (colorScale.length - 1)))); + } + + private evolve(): void { + const interp = this.interp; + if (!interp) return; + const scale = 0.06 * this.opts.speed; // pixel velocity = Jacobian·wind·scale + const eps = 0.02; // degrees, for the projection Jacobian + + for (const p of this.particles) { + if (p.age >= this.opts.maxAge) { + this.respawn(p); + continue; + } + const ll = this.map.unproject([p.x, p.y]); + const wind = interp(ll.lng, ll.lat); + if (!wind) { + p.age = this.opts.maxAge; // escaped the field → respawn next tick + continue; + } + const [u, v] = wind; + + // Local projection Jacobian: pixel deltas per degree at this point. + const east = this.map.project([ll.lng + eps, ll.lat]); + const north = this.map.project([ll.lng, ll.lat + eps]); + const jxLng = (east.x - p.x) / eps; + const jyLng = (east.y - p.y) / eps; + const jxLat = (north.x - p.x) / eps; + const jyLat = (north.y - p.y) / eps; + + p.xt = p.x + (jxLng * u + jxLat * v) * scale; + p.yt = p.y + (jyLng * u + jyLat * v) * scale; + p.speed = Math.sqrt(u * u + v * v); + p.age += 1; + } + } + + private draw(): void { + const ctx = this.ctx; + + // Fade existing trails toward transparent (keeps the basemap visible). + ctx.globalCompositeOperation = 'destination-in'; + ctx.fillStyle = `rgba(0,0,0,${this.opts.trailPersistence})`; + ctx.fillRect(0, 0, this.width, this.height); + ctx.globalCompositeOperation = 'source-over'; + + // Draw new trail segments, grouped by colour bucket. + const { colorScale } = this.opts; + ctx.lineWidth = this.opts.lineWidth; + const buckets: Particle[][] = colorScale.map(() => []); + for (const p of this.particles) { + if (p.age >= this.opts.maxAge || p.speed === 0) continue; + buckets[this.colorIndex(p.speed)].push(p); + } + let drawn = 0; + for (let i = 0; i < buckets.length; i++) { + const bucket = buckets[i]; + if (bucket.length === 0) continue; + drawn += bucket.length; + ctx.strokeStyle = colorScale[i]; + ctx.beginPath(); + for (const p of bucket) { + ctx.moveTo(p.x, p.y); + ctx.lineTo(p.xt, p.yt); + } + ctx.stroke(); + } + + if (import.meta.env.DEV && !this.debugLogged) { + this.debugLogged = true; + // One-shot diagnostic: confirms field, canvas size, and that segments + // are actually being drawn. Remove once the layer is verified. + // eslint-disable-next-line no-console + console.debug('[wind] first draw', { + hasInterp: !!this.interp, + canvas: `${this.width}x${this.height}`, + backing: `${this.canvas.width}x${this.canvas.height}`, + particles: this.particles.length, + drawnSegments: drawn, + host: this.host.className, + }); + } + + // Advance positions for the next frame. + for (const p of this.particles) { + p.x = p.xt; + p.y = p.yt; + } + } + + private frame = (now: number): void => { + this.raf = requestAnimationFrame(this.frame); + if (this.moving || !this.interp) return; + const frameTime = 1000 / this.opts.frameRate; + if (now - this.then < frameTime) return; + this.then = now - ((now - this.then) % frameTime); + this.evolve(); + this.draw(); + }; +} diff --git a/src/lib/features/wind/WindRenderer.svelte b/src/lib/features/wind/WindRenderer.svelte new file mode 100644 index 0000000..1e3c15a --- /dev/null +++ b/src/lib/features/wind/WindRenderer.svelte @@ -0,0 +1,332 @@ + + +{#if windSettings.enabled && prefetchSkipReason} +
+ + {#if prefetchSkipReason === 'wind.skippedLong'} + Wind sync skipped: flight > {windSettings.maxFlightDurationHours}h + {:else} + Wind sync skipped: region > {windSettings.maxRegionDegrees}° + {/if} +
+{/if} + + diff --git a/src/lib/features/wind/index.ts b/src/lib/features/wind/index.ts new file mode 100644 index 0000000..b70b760 --- /dev/null +++ b/src/lib/features/wind/index.ts @@ -0,0 +1,3 @@ +export { default as WindRenderer } from './WindRenderer.svelte'; +export { windCache } from './store'; +export { ParticleField, DEFAULT_PARTICLE_OPTIONS, type ParticleOptions } from './ParticleField'; diff --git a/src/lib/features/wind/store.ts b/src/lib/features/wind/store.ts new file mode 100644 index 0000000..5e34494 --- /dev/null +++ b/src/lib/features/wind/store.ts @@ -0,0 +1,61 @@ +/** + * Thin cache layer for wind field responses. + * + * Each unique set of request parameters is keyed by a stable JSON string so + * that the same (time, altitude, bbox, step) combination is fetched only once + * per session even if multiple effects request it concurrently. The cache is + * intentionally never invalidated during a session — the predictor's dataset + * does not change while the user is working. + */ + +import { windApi, type WindFieldParams } from '$api'; +import type { WindField } from '$domain'; + +function cacheKey(params: WindFieldParams): string { + return JSON.stringify({ + altitude: params.altitude ?? null, + step: params.step ?? null, + time: params.time ?? null, + min_lat: params.min_lat ?? null, + max_lat: params.max_lat ?? null, + min_lng: params.min_lng ?? null, + max_lng: params.max_lng ?? null, + }); +} + +class WindCache { + private readonly hits = new Map(); + private readonly pending = new Map>(); + + fetch(params: WindFieldParams): Promise { + 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(); diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json index 6c8dd5a..16bd6a5 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -128,7 +128,19 @@ "units": "Units", "metric": "Metric", "imperial": "Imperial", - "saved": "Settings saved" + "saved": "Settings saved", + "wind": "Wind visualization", + "windEnabled": "Show wind layer", + "windStep": "Grid resolution (°)", + "windTrajectoryStep": "Trajectory grid res. (°)", + "windPrefetchInterval": "Pre-fetch interval (min)", + "windMaxDuration": "Max sync duration (h)", + "windMaxRegion": "Max region size (°)", + "windMargin": "Trajectory margin (°)", + "windParticleDensity": "Particle density", + "windParticleSpeed": "Particle speed", + "windTrailPersistence": "Trail length", + "windMaxVelocity": "Max wind speed (m/s)" }, "editor": { "add": "Add", @@ -174,7 +186,16 @@ "status_connected": "Connected", "status_error": "Error", "packetCount": "{count} packets received", - "waitingData": "Waiting for data..." + "waitingData": "Waiting for data...", + "deviation": "Compare with forecast", + "selectForecast": "Reference forecast", + "noForecast": "— No forecast —", + "noData": "No telemetry data", + "altProfile": "Altitude profile", + "selectPrediction": "Select a forecast to show deviations", + "horizontalDev": "Horizontal deviation", + "devMax": "Max:", + "devCurrent": "Current:" }, "forecast": { "success": "Forecast request", diff --git a/src/lib/i18n/locales/ru.json b/src/lib/i18n/locales/ru.json index d86c1e8..6441dbb 100644 --- a/src/lib/i18n/locales/ru.json +++ b/src/lib/i18n/locales/ru.json @@ -128,7 +128,19 @@ "units": "Единицы измерения", "metric": "Метрические", "imperial": "Имперские", - "saved": "Настройки сохранены" + "saved": "Настройки сохранены", + "wind": "Визуализация ветра", + "windEnabled": "Показывать слой ветра", + "windStep": "Шаг сетки (°)", + "windTrajectoryStep": "Шаг сетки по траектории (°)", + "windPrefetchInterval": "Интервал предзагрузки (мин)", + "windMaxDuration": "Макс. длительность синхронизации (ч)", + "windMaxRegion": "Макс. размер региона (°)", + "windMargin": "Отступ вокруг траектории (°)", + "windParticleDensity": "Плотность частиц", + "windParticleSpeed": "Скорость частиц", + "windTrailPersistence": "Длина следа", + "windMaxVelocity": "Макс. скорость ветра (м/с)" }, "editor": { "add": "Добавить", @@ -174,7 +186,16 @@ "status_connected": "Подключено", "status_error": "Ошибка", "packetCount": "Получено пакетов: {count}", - "waitingData": "Ожидание данных..." + "waitingData": "Ожидание данных...", + "deviation": "Сравнение с прогнозом", + "selectForecast": "Прогноз для сравнения", + "noForecast": "— Без прогноза —", + "noData": "Нет данных телеметрии", + "altProfile": "Высотный профиль", + "selectPrediction": "Выберите прогноз для отображения отклонений", + "horizontalDev": "Горизонтальное отклонение", + "devMax": "Макс.:", + "devCurrent": "Текущее:" }, "forecast": { "success": "Запрос прогноза", diff --git a/src/routes/predict/+page.svelte b/src/routes/predict/+page.svelte index eb06ae4..6ed31c6 100644 --- a/src/routes/predict/+page.svelte +++ b/src/routes/predict/+page.svelte @@ -12,6 +12,7 @@ WorkspaceRenderer, workspacesStore, } from '$features/workspaces'; + import { WindRenderer } from '$features/wind'; import { SettingsPanel } from '$features/settings'; import { TimeLine } from '$features/timeline'; import { t } from '$i18n'; @@ -72,6 +73,7 @@
+ import { onMount, onDestroy } from 'svelte'; - import { Map as MapView, plotAnimatedMarker, type IMap } from '$map'; + import { Map as MapView, plotAnimatedMarker, plotPrediction, type IMap } from '$map'; import { Navbar } from '$features/auth'; - import { PanelContainer } from '$ui'; - import { TelemetryPanel, telemetryStore } from '$features/tracking'; + import { PanelContainer, CollapsibleCard } from '$ui'; + import { TelemetryPanel, DeviationChart, telemetryStore } from '$features/tracking'; + import { workspacesStore } from '$features/workspaces'; + import { t } from '$i18n'; import { requireAuthenticated } from '$auth'; import { parseTelemetry } from '$domain'; + import type { Prediction } from '$domain'; + + let selectedId = $state(''); + const workspacesWithResult = $derived($workspacesStore.items.filter((w) => w.result !== null)); + const selectedPrediction = $derived( + workspacesWithResult.find((w) => w.id === selectedId)?.result ?? null, + ); let map = $state(null); // Tracks whether we've already fitted the map to the initial history load. @@ -18,6 +27,17 @@ onDestroy(() => { map?.disposeScene('telemetry'); + map?.disposeScene('prediction'); + }); + + $effect(() => { + if (!map) return; + const scene = map.scene('prediction'); + if (selectedPrediction) { + plotPrediction(scene, selectedPrediction, { color: '#1565C0', opacity: 0.7 }); + } else { + scene.clear(); + } }); function onMapReady(m: IMap) { @@ -76,5 +96,24 @@ + + +
+ + +
+ +
+
diff --git a/test_client/client.py b/test_client/client.py index e34224b..c85637e 100644 --- a/test_client/client.py +++ b/test_client/client.py @@ -61,6 +61,9 @@ import sys import time from pathlib import Path +# 2025-04-06 03:00:00 UTC +BASE_TS = 1743908400 + # --------------------------------------------------------------------------- # Trajectory loaders @@ -94,7 +97,7 @@ def load_csv(path: Path) -> list[dict]: "lat": float(row["lat"]), "lon": float(row["lon"]), "alt": float(row["alt"]), - "timestamp": int(row.get("timestamp", time.time())), + "timestamp": int(row.get("timestamp", BASE_TS + len(points) * 10)), }) return points @@ -111,7 +114,7 @@ def load_log(path: Path) -> list[dict]: Duplicate positions (identical lat/lon/alt) are silently dropped. Points with no GPS fix (lat_raw == lon_raw == 0) are also dropped. """ - base_ts = int(time.time()) + base_ts = BASE_TS points: list[dict] = [] seen: set[tuple] = set() @@ -192,7 +195,7 @@ def deduplicate(points: list[dict]) -> list[dict]: def generate_sample(path: Path, n: int = 50) -> None: """Generate a simple ascending balloon trajectory.""" base_lat, base_lon = 62.0, 129.5 - base_ts = int(time.time()) + base_ts = BASE_TS points = [] for i in range(n): angle = i * 0.05 @@ -221,25 +224,28 @@ async def run_ws(server: str, satellite: str, token: str, print(f"Connecting to {url}") iteration = 0 + abs_idx = 0 # never resets across loop iterations — drives advancing timestamps while True: try: async with websockets.connect(url) as ws: print("Connected.") - for i, point in enumerate(points): + for point in points: packet = { "lat": float(point.get("lat", 0)), "lon": float(point.get("lon", 0)), "alt": float(point.get("alt", 0)), - "timestamp": int(point.get("timestamp", time.time())), + "timestamp": BASE_TS + abs_idx * int(interval), "payload": point.get("payload", {}), "raw_data": point.get("raw_data", {}), } + abs_idx += 1 await ws.send(json.dumps(packet)) print( - f"[{i+1}/{len(points)}] " + f"[{abs_idx}/{len(points)}] " f"lat={packet['lat']:.5f} " f"lon={packet['lon']:.5f} " - f"alt={packet['alt']:.1f} m" + f"alt={packet['alt']:.1f} m " + f"ts={packet['timestamp']}" ) try: @@ -250,8 +256,7 @@ async def run_ws(server: str, satellite: str, token: str, except asyncio.TimeoutError: pass # server only responds on errors - if i < len(points) - 1: - await asyncio.sleep(interval) + await asyncio.sleep(interval) print("All points sent.") iteration += 1 @@ -285,10 +290,11 @@ async def run_rest(server: str, satellite: str, while True: for i, point in enumerate(points): packet = { - "lat": float(point.get("lat", 0)), - "lon": float(point.get("lon", 0)), - "alt": float(point.get("alt", 0)), - "payload": point.get("payload", {}), + "lat": float(point.get("lat", 0)), + "lon": float(point.get("lon", 0)), + "alt": float(point.get("alt", 0)), + "timestamp": int(point.get("timestamp", BASE_TS + i * int(interval))), + "payload": point.get("payload", {}), } async with session.post(url, json=packet) as resp: print( @@ -313,7 +319,8 @@ def main() -> None: help="Base server URL (default: ws://localhost:8000)") parser.add_argument("--satellite", help="Satellite UUID") parser.add_argument("--token", help="Auth token (required for WebSocket mode)") - parser.add_argument("--file", help="Trajectory file (.json, .csv, or .log)") + parser.add_argument("--file", nargs='+', metavar="FILE", + help="One or more trajectory files (.json, .csv, or .log); merged in order") parser.add_argument("--interval", type=float, default=5.0, help="Seconds between packets (default: 10)") parser.add_argument("--mode", choices=["ws", "rest"], default="ws", @@ -335,18 +342,22 @@ def main() -> None: if args.mode == "ws" and not args.token: parser.error("--token is required for WebSocket mode") - path = Path(args.file) - if not path.exists(): - sys.exit(f"File not found: {path}") + merged: list[dict] = [] + for f in args.file: + path = Path(f) + if not path.exists(): + sys.exit(f"File not found: {path}") + loaded = load_trajectory(path) + print(f" {path}: {len(loaded)} points") + merged.extend(loaded) - points = load_trajectory(path) - if not points: - sys.exit(f"No valid points found in {path}") - before = len(points) - points = deduplicate(points) + before = len(merged) + points = deduplicate(merged) removed = before - len(points) - print(f"Loaded {before} points from {path}" + - (f", removed {removed} duplicates → {len(points)} unique" if removed else f" ({len(points)} points)")) + if not points: + sys.exit("No valid points found in the provided files") + print(f"Total: {before} points" + + (f", removed {removed} duplicates → {len(points)} unique" if removed else f" ({len(points)} unique)")) if args.mode == "ws": # Swap http(s) → ws(s) if the user passed an HTTP URL