compare panel, docs update, wind visualisation
This commit is contained in:
parent
b7f7ec8dc5
commit
48140f0f77
29 changed files with 2299 additions and 38 deletions
41
docs/diagrams/README.md
Normal file
41
docs/diagrams/README.md
Normal 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.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 (для печати)
|
||||
```
|
||||
|
||||
Альтернатива без установки — онлайн-редактор <https://www.plantuml.com/plantuml>
|
||||
или расширение PlantUML для VS Code (предпросмотр `Alt+D`).
|
||||
102
docs/diagrams/architecture.puml
Normal file
102
docs/diagrams/architecture.puml
Normal 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
|
||||
62
docs/diagrams/dfd-telemetry.puml
Normal file
62
docs/diagrams/dfd-telemetry.puml
Normal 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
|
||||
41
docs/diagrams/seq-auth.puml
Normal file
41
docs/diagrams/seq-auth.puml
Normal 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
|
||||
41
docs/diagrams/seq-prediction.puml
Normal file
41
docs/diagrams/seq-prediction.puml
Normal 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
|
||||
60
docs/diagrams/seq-telemetry.puml
Normal file
60
docs/diagrams/seq-telemetry.puml
Normal 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
|
||||
54
docs/diagrams/seq-wind.puml
Normal file
54
docs/diagrams/seq-wind.puml
Normal 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
395
docs/wind-vis-math.tex
Normal 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}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
58
src/lib/api/wind.ts
Normal file
58
src/lib/api/wind.ts
Normal 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');
|
||||
},
|
||||
};
|
||||
|
|
@ -3,3 +3,4 @@ export * from './math';
|
|||
export * from './scenario';
|
||||
export * from './prediction';
|
||||
export * from './telemetry';
|
||||
export * from './wind';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
214
src/lib/domain/wind.ts
Normal file
214
src/lib/domain/wind.ts
Normal 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 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];
|
||||
};
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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<AppSettings>('settings', DEFAULT_SETTINGS);
|
||||
|
|
|
|||
203
src/lib/features/tracking/DeviationChart.svelte
Normal file
203
src/lib/features/tracking/DeviationChart.svelte
Normal 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>
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
335
src/lib/features/wind/ParticleField.ts
Normal file
335
src/lib/features/wind/ParticleField.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
332
src/lib/features/wind/WindRenderer.svelte
Normal file
332
src/lib/features/wind/WindRenderer.svelte
Normal 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 > {windSettings.maxFlightDurationHours}h
|
||||
{:else}
|
||||
Wind sync skipped: region > {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>
|
||||
3
src/lib/features/wind/index.ts
Normal file
3
src/lib/features/wind/index.ts
Normal 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';
|
||||
61
src/lib/features/wind/store.ts
Normal file
61
src/lib/features/wind/store.ts
Normal 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();
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Запрос прогноза",
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<div style="height: var(--navbar-height);"></div>
|
||||
<MapView bind:this={mapComponent} onReady={handleMapReady}>
|
||||
<WorkspaceRenderer />
|
||||
<WindRenderer />
|
||||
|
||||
<PanelContainer position="left">
|
||||
<TabBar
|
||||
|
|
|
|||
|
|
@ -1,11 +1,20 @@
|
|||
<script lang="ts">
|
||||
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<Prediction | null>(
|
||||
workspacesWithResult.find((w) => w.id === selectedId)?.result ?? null,
|
||||
);
|
||||
|
||||
let map = $state<IMap | null>(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 @@
|
|||
<PanelContainer position="left">
|
||||
<TelemetryPanel />
|
||||
</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>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -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"ts={packet['timestamp']}"
|
||||
)
|
||||
|
||||
try:
|
||||
|
|
@ -250,7 +256,6 @@ 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)
|
||||
|
||||
print("All points sent.")
|
||||
|
|
@ -288,6 +293,7 @@ async def run_rest(server: str, satellite: str,
|
|||
"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:
|
||||
|
|
@ -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)
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue