compare panel, docs update, wind visualisation

This commit is contained in:
Vasilisk9812 2026-06-17 00:20:55 +09:00
parent b7f7ec8dc5
commit 48140f0f77
29 changed files with 2299 additions and 38 deletions

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

@ -4,3 +4,4 @@ export { pointsApi } from './points';
export { profilesApi } from './profiles'; export { profilesApi } from './profiles';
export { scenariosApi } from './scenarios'; export { scenariosApi } from './scenarios';
export { predictionsApi, getLatestDataset, buildLaunchDateTime } from './predictions'; export { predictionsApi, getLatestDataset, buildLaunchDateTime } from './predictions';
export { windApi, type WindFieldParams } from './wind';

View file

@ -6,10 +6,11 @@ import type { FlightParameters, RawPrediction } from '$domain';
* Round down to the most recent available slot. * Round down to the most recent available slot.
*/ */
export function getLatestDataset(now: Date = new Date()): string { export function getLatestDataset(now: Date = new Date()): string {
const rounded = new Date(now); // const rounded = new Date(now);
rounded.setUTCHours(Math.floor(rounded.getUTCHours() / 6) * 6, 0, 0, 0); // rounded.setUTCHours(Math.floor(rounded.getUTCHours() / 6) * 6, 0, 0, 0);
rounded.setUTCHours(rounded.getUTCHours() - 6); // rounded.setUTCHours(rounded.getUTCHours() - 6);
return rounded.toISOString(); // return rounded.toISOString();
return "2025-04-06T00:00:00Z";
} }
export function buildLaunchDateTime(date: string, time: string): string { export function buildLaunchDateTime(date: string, time: string): string {

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

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

View file

@ -3,3 +3,4 @@ export * from './math';
export * from './scenario'; export * from './scenario';
export * from './prediction'; export * from './prediction';
export * from './telemetry'; export * from './telemetry';
export * from './wind';

View file

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

View file

@ -30,6 +30,8 @@ export interface RawPrediction {
export interface Prediction { export interface Prediction {
flight_path: LatLngTuple[]; flight_path: LatLngTuple[];
/** Epoch-ms timestamp for each point in flight_path (parallel array). */
timestamps: number[];
launch: Point; launch: Point;
burst: Point; burst: Point;
landing: Point; landing: Point;
@ -55,13 +57,16 @@ export function parsePrediction(stages: PredictionStage[]): Prediction {
const ascent = stages[0].trajectory; const ascent = stages[0].trajectory;
const descent = stages[1].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, p.latitude,
normalizeLng(p.longitude), normalizeLng(p.longitude),
p.altitude, p.altitude,
]); ]);
const timestamps: number[] = all.map((p) => new Date(p.datetime).getTime());
const launch = pointFromTrajectory(ascent[0]); const launch = pointFromTrajectory(ascent[0]);
const burst = pointFromTrajectory(descent[0]); const burst = pointFromTrajectory(descent[0]);
const landing = pointFromTrajectory(descent[descent.length - 1]); 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 profile = stages[1].stage === 'descent' ? 'standard_profile' : 'float_profile';
const flight_time = (landing.datetime.getTime() - launch.datetime.getTime()) / 1000; 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 };
} }

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

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

View file

@ -1,5 +1,5 @@
export { settingsStore, DEFAULT_SETTINGS } from './store'; 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 { default as SettingsPanel } from './SettingsPanel.svelte';
export { SETTINGS_SCHEMA } from './schema'; export { SETTINGS_SCHEMA } from './schema';
export type { SettingsField, SettingsSection } from './schema'; export type { SettingsField, SettingsSection } from './schema';

View file

@ -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,
},
],
},
]; ];

View file

@ -1,5 +1,8 @@
import { persisted } from '$state'; import { persisted } from '$state';
import type { Locale } from '$i18n'; import type { Locale } from '$i18n';
import { type WindSettings, DEFAULT_WIND_SETTINGS } from '$domain';
export type { WindSettings };
export interface MapSettings { export interface MapSettings {
baseLayer: 'osm' | 'satellite'; baseLayer: 'osm' | 'satellite';
@ -15,12 +18,14 @@ export interface AppSettings {
locale: Locale; locale: Locale;
map: MapSettings; map: MapSettings;
units: UnitsSettings; units: UnitsSettings;
wind: WindSettings;
} }
export const DEFAULT_SETTINGS: AppSettings = { export const DEFAULT_SETTINGS: AppSettings = {
locale: 'ru', locale: 'ru',
map: { baseLayer: 'osm', showScale: true, showNavigation: true }, map: { baseLayer: 'osm', showScale: true, showNavigation: true },
units: { system: 'metric' }, units: { system: 'metric' },
wind: { ...DEFAULT_WIND_SETTINGS },
}; };
export const settingsStore = persisted<AppSettings>('settings', DEFAULT_SETTINGS); export const settingsStore = persisted<AppSettings>('settings', DEFAULT_SETTINGS);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -128,7 +128,19 @@
"units": "Units", "units": "Units",
"metric": "Metric", "metric": "Metric",
"imperial": "Imperial", "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": { "editor": {
"add": "Add", "add": "Add",
@ -174,7 +186,16 @@
"status_connected": "Connected", "status_connected": "Connected",
"status_error": "Error", "status_error": "Error",
"packetCount": "{count} packets received", "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": { "forecast": {
"success": "Forecast request", "success": "Forecast request",

View file

@ -128,7 +128,19 @@
"units": "Единицы измерения", "units": "Единицы измерения",
"metric": "Метрические", "metric": "Метрические",
"imperial": "Имперские", "imperial": "Имперские",
"saved": "Настройки сохранены" "saved": "Настройки сохранены",
"wind": "Визуализация ветра",
"windEnabled": "Показывать слой ветра",
"windStep": "Шаг сетки (°)",
"windTrajectoryStep": "Шаг сетки по траектории (°)",
"windPrefetchInterval": "Интервал предзагрузки (мин)",
"windMaxDuration": "Макс. длительность синхронизации (ч)",
"windMaxRegion": "Макс. размер региона (°)",
"windMargin": "Отступ вокруг траектории (°)",
"windParticleDensity": "Плотность частиц",
"windParticleSpeed": "Скорость частиц",
"windTrailPersistence": "Длина следа",
"windMaxVelocity": "Макс. скорость ветра (м/с)"
}, },
"editor": { "editor": {
"add": "Добавить", "add": "Добавить",
@ -174,7 +186,16 @@
"status_connected": "Подключено", "status_connected": "Подключено",
"status_error": "Ошибка", "status_error": "Ошибка",
"packetCount": "Получено пакетов: {count}", "packetCount": "Получено пакетов: {count}",
"waitingData": "Ожидание данных..." "waitingData": "Ожидание данных...",
"deviation": "Сравнение с прогнозом",
"selectForecast": "Прогноз для сравнения",
"noForecast": "— Без прогноза —",
"noData": "Нет данных телеметрии",
"altProfile": "Высотный профиль",
"selectPrediction": "Выберите прогноз для отображения отклонений",
"horizontalDev": "Горизонтальное отклонение",
"devMax": "Макс.:",
"devCurrent": "Текущее:"
}, },
"forecast": { "forecast": {
"success": "Запрос прогноза", "success": "Запрос прогноза",

View file

@ -12,6 +12,7 @@
WorkspaceRenderer, WorkspaceRenderer,
workspacesStore, workspacesStore,
} from '$features/workspaces'; } from '$features/workspaces';
import { WindRenderer } from '$features/wind';
import { SettingsPanel } from '$features/settings'; import { SettingsPanel } from '$features/settings';
import { TimeLine } from '$features/timeline'; import { TimeLine } from '$features/timeline';
import { t } from '$i18n'; import { t } from '$i18n';
@ -72,6 +73,7 @@
<div style="height: var(--navbar-height);"></div> <div style="height: var(--navbar-height);"></div>
<MapView bind:this={mapComponent} onReady={handleMapReady}> <MapView bind:this={mapComponent} onReady={handleMapReady}>
<WorkspaceRenderer /> <WorkspaceRenderer />
<WindRenderer />
<PanelContainer position="left"> <PanelContainer position="left">
<TabBar <TabBar

View file

@ -1,11 +1,20 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; 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 { Navbar } from '$features/auth';
import { PanelContainer } from '$ui'; import { PanelContainer, CollapsibleCard } from '$ui';
import { TelemetryPanel, telemetryStore } from '$features/tracking'; import { TelemetryPanel, DeviationChart, telemetryStore } from '$features/tracking';
import { workspacesStore } from '$features/workspaces';
import { t } from '$i18n';
import { requireAuthenticated } from '$auth'; import { requireAuthenticated } from '$auth';
import { parseTelemetry } from '$domain'; 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<Prediction | null>(
workspacesWithResult.find((w) => w.id === selectedId)?.result ?? null,
);
let map = $state<IMap | null>(null); let map = $state<IMap | null>(null);
// Tracks whether we've already fitted the map to the initial history load. // Tracks whether we've already fitted the map to the initial history load.
@ -18,6 +27,17 @@
onDestroy(() => { onDestroy(() => {
map?.disposeScene('telemetry'); 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) { function onMapReady(m: IMap) {
@ -76,5 +96,24 @@
<PanelContainer position="left"> <PanelContainer position="left">
<TelemetryPanel /> <TelemetryPanel />
</PanelContainer> </PanelContainer>
<PanelContainer position="right">
<CollapsibleCard title={$t('tracking.deviation')}>
<div class="mb-2">
<label for="forecast-select" class="form-label small mb-1">{$t('tracking.selectForecast')}</label>
<select
id="forecast-select"
class="form-select form-select-sm"
bind:value={selectedId}
disabled={workspacesWithResult.length === 0}
>
<option value="">{$t('tracking.noForecast')}</option>
{#each workspacesWithResult as w (w.id)}
<option value={w.id}>{w.name}</option>
{/each}
</select>
</div>
<DeviationChart points={telemetryStore.points} prediction={selectedPrediction} />
</CollapsibleCard>
</PanelContainer>
</MapView> </MapView>
</main> </main>

View file

@ -61,6 +61,9 @@ import sys
import time import time
from pathlib import Path from pathlib import Path
# 2025-04-06 03:00:00 UTC
BASE_TS = 1743908400
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Trajectory loaders # Trajectory loaders
@ -94,7 +97,7 @@ def load_csv(path: Path) -> list[dict]:
"lat": float(row["lat"]), "lat": float(row["lat"]),
"lon": float(row["lon"]), "lon": float(row["lon"]),
"alt": float(row["alt"]), "alt": float(row["alt"]),
"timestamp": int(row.get("timestamp", time.time())), "timestamp": int(row.get("timestamp", BASE_TS + len(points) * 10)),
}) })
return points return points
@ -111,7 +114,7 @@ def load_log(path: Path) -> list[dict]:
Duplicate positions (identical lat/lon/alt) are silently dropped. Duplicate positions (identical lat/lon/alt) are silently dropped.
Points with no GPS fix (lat_raw == lon_raw == 0) are also 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] = [] points: list[dict] = []
seen: set[tuple] = set() seen: set[tuple] = set()
@ -192,7 +195,7 @@ def deduplicate(points: list[dict]) -> list[dict]:
def generate_sample(path: Path, n: int = 50) -> None: def generate_sample(path: Path, n: int = 50) -> None:
"""Generate a simple ascending balloon trajectory.""" """Generate a simple ascending balloon trajectory."""
base_lat, base_lon = 62.0, 129.5 base_lat, base_lon = 62.0, 129.5
base_ts = int(time.time()) base_ts = BASE_TS
points = [] points = []
for i in range(n): for i in range(n):
angle = i * 0.05 angle = i * 0.05
@ -221,25 +224,28 @@ async def run_ws(server: str, satellite: str, token: str,
print(f"Connecting to {url}") print(f"Connecting to {url}")
iteration = 0 iteration = 0
abs_idx = 0 # never resets across loop iterations — drives advancing timestamps
while True: while True:
try: try:
async with websockets.connect(url) as ws: async with websockets.connect(url) as ws:
print("Connected.") print("Connected.")
for i, point in enumerate(points): for point in points:
packet = { packet = {
"lat": float(point.get("lat", 0)), "lat": float(point.get("lat", 0)),
"lon": float(point.get("lon", 0)), "lon": float(point.get("lon", 0)),
"alt": float(point.get("alt", 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", {}), "payload": point.get("payload", {}),
"raw_data": point.get("raw_data", {}), "raw_data": point.get("raw_data", {}),
} }
abs_idx += 1
await ws.send(json.dumps(packet)) await ws.send(json.dumps(packet))
print( print(
f"[{i+1}/{len(points)}] " f"[{abs_idx}/{len(points)}] "
f"lat={packet['lat']:.5f} " f"lat={packet['lat']:.5f} "
f"lon={packet['lon']:.5f} " f"lon={packet['lon']:.5f} "
f"alt={packet['alt']:.1f} m " f"alt={packet['alt']:.1f} m "
f"ts={packet['timestamp']}"
) )
try: try:
@ -250,7 +256,6 @@ async def run_ws(server: str, satellite: str, token: str,
except asyncio.TimeoutError: except asyncio.TimeoutError:
pass # server only responds on errors pass # server only responds on errors
if i < len(points) - 1:
await asyncio.sleep(interval) await asyncio.sleep(interval)
print("All points sent.") print("All points sent.")
@ -288,6 +293,7 @@ async def run_rest(server: str, satellite: str,
"lat": float(point.get("lat", 0)), "lat": float(point.get("lat", 0)),
"lon": float(point.get("lon", 0)), "lon": float(point.get("lon", 0)),
"alt": float(point.get("alt", 0)), "alt": float(point.get("alt", 0)),
"timestamp": int(point.get("timestamp", BASE_TS + i * int(interval))),
"payload": point.get("payload", {}), "payload": point.get("payload", {}),
} }
async with session.post(url, json=packet) as resp: async with session.post(url, json=packet) as resp:
@ -313,7 +319,8 @@ def main() -> None:
help="Base server URL (default: ws://localhost:8000)") help="Base server URL (default: ws://localhost:8000)")
parser.add_argument("--satellite", help="Satellite UUID") parser.add_argument("--satellite", help="Satellite UUID")
parser.add_argument("--token", help="Auth token (required for WebSocket mode)") 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, parser.add_argument("--interval", type=float, default=5.0,
help="Seconds between packets (default: 10)") help="Seconds between packets (default: 10)")
parser.add_argument("--mode", choices=["ws", "rest"], default="ws", parser.add_argument("--mode", choices=["ws", "rest"], default="ws",
@ -335,18 +342,22 @@ def main() -> None:
if args.mode == "ws" and not args.token: if args.mode == "ws" and not args.token:
parser.error("--token is required for WebSocket mode") parser.error("--token is required for WebSocket mode")
path = Path(args.file) merged: list[dict] = []
for f in args.file:
path = Path(f)
if not path.exists(): if not path.exists():
sys.exit(f"File not found: {path}") sys.exit(f"File not found: {path}")
loaded = load_trajectory(path)
print(f" {path}: {len(loaded)} points")
merged.extend(loaded)
points = load_trajectory(path) before = len(merged)
if not points: points = deduplicate(merged)
sys.exit(f"No valid points found in {path}")
before = len(points)
points = deduplicate(points)
removed = before - len(points) removed = before - len(points)
print(f"Loaded {before} points from {path}" + if not points:
(f", removed {removed} duplicates → {len(points)} unique" if removed else f" ({len(points)} 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": if args.mode == "ws":
# Swap http(s) → ws(s) if the user passed an HTTP URL # Swap http(s) → ws(s) if the user passed an HTTP URL