feat: polish #13
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 { profilesApi } from './profiles';
|
||||||
export { scenariosApi } from './scenarios';
|
export { scenariosApi } from './scenarios';
|
||||||
export { predictionsApi, getLatestDataset, buildLaunchDateTime } from './predictions';
|
export { predictionsApi, getLatestDataset, buildLaunchDateTime } from './predictions';
|
||||||
|
export { windApi, type WindFieldParams } from './wind';
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,11 @@ import type { FlightParameters, RawPrediction } from '$domain';
|
||||||
* Round down to the most recent available slot.
|
* Round down to the most recent available slot.
|
||||||
*/
|
*/
|
||||||
export function getLatestDataset(now: Date = new Date()): string {
|
export function getLatestDataset(now: Date = new Date()): string {
|
||||||
const rounded = new Date(now);
|
// const rounded = new Date(now);
|
||||||
rounded.setUTCHours(Math.floor(rounded.getUTCHours() / 6) * 6, 0, 0, 0);
|
// rounded.setUTCHours(Math.floor(rounded.getUTCHours() / 6) * 6, 0, 0, 0);
|
||||||
rounded.setUTCHours(rounded.getUTCHours() - 6);
|
// rounded.setUTCHours(rounded.getUTCHours() - 6);
|
||||||
return rounded.toISOString();
|
// return rounded.toISOString();
|
||||||
|
return "2025-04-06T00:00:00Z";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildLaunchDateTime(date: string, time: string): string {
|
export function buildLaunchDateTime(date: string, time: string): string {
|
||||||
|
|
|
||||||
58
src/lib/api/wind.ts
Normal file
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 './scenario';
|
||||||
export * from './prediction';
|
export * from './prediction';
|
||||||
export * from './telemetry';
|
export * from './telemetry';
|
||||||
|
export * from './wind';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import type { LatLng } from './geo';
|
import type { LatLng } from './geo';
|
||||||
|
import type { TelemetryPoint } from './telemetry';
|
||||||
|
import type { Prediction } from './prediction';
|
||||||
|
|
||||||
const EARTH_RADIUS_KM = 6371;
|
const EARTH_RADIUS_KM = 6371;
|
||||||
|
|
||||||
|
|
@ -32,3 +34,66 @@ export function toFixedNumber(num: number, digits: number): number {
|
||||||
const pow = 10 ** digits;
|
const pow = 10 ** digits;
|
||||||
return Math.round(num * pow) / pow;
|
return Math.round(num * pow) / pow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One compared sample: telemetry point matched against the closest-in-time prediction point. */
|
||||||
|
export interface DeviationPoint {
|
||||||
|
/** Epoch ms from the telemetry timestamp. */
|
||||||
|
timeMs: number;
|
||||||
|
/** Great-circle distance from actual position to predicted position, km. */
|
||||||
|
horizontal: number;
|
||||||
|
/** Altitude difference (actual − predicted), m. Positive means actual is higher. */
|
||||||
|
vertical: number;
|
||||||
|
/** Actual altitude from telemetry, m. */
|
||||||
|
altActual: number;
|
||||||
|
/** Predicted altitude at the matched index, m. */
|
||||||
|
altPredicted: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For each telemetry point find the closest-in-time point in the prediction
|
||||||
|
* and compute horizontal (haversine) and vertical deviations.
|
||||||
|
*
|
||||||
|
* Telemetry points that fall outside the prediction's time window are skipped —
|
||||||
|
* bisectClosest would clamp them to the boundary and produce misleading values.
|
||||||
|
*/
|
||||||
|
export function computeDeviations(
|
||||||
|
points: TelemetryPoint[],
|
||||||
|
prediction: Prediction,
|
||||||
|
): DeviationPoint[] {
|
||||||
|
if (points.length === 0 || prediction.timestamps.length === 0) return [];
|
||||||
|
|
||||||
|
const predStart = prediction.timestamps[0];
|
||||||
|
const predEnd = prediction.timestamps[prediction.timestamps.length - 1];
|
||||||
|
|
||||||
|
const result: DeviationPoint[] = [];
|
||||||
|
for (const p of points) {
|
||||||
|
const t = new Date(p.datetime).getTime();
|
||||||
|
if (t < predStart || t > predEnd) continue;
|
||||||
|
|
||||||
|
const i = bisectClosest(prediction.timestamps, t);
|
||||||
|
const fp = prediction.flight_path[i];
|
||||||
|
const predAlt = (fp[2] as number | undefined) ?? 0;
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
timeMs: t,
|
||||||
|
horizontal: distHaversine({ lat: p.latitude, lng: p.longitude }, { lat: fp[0], lng: fp[1] }),
|
||||||
|
vertical: p.altitude - predAlt,
|
||||||
|
altActual: p.altitude,
|
||||||
|
altPredicted: predAlt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Binary search: index of the element in `arr` closest to `target`. */
|
||||||
|
function bisectClosest(arr: number[], target: number): number {
|
||||||
|
let lo = 0;
|
||||||
|
let hi = arr.length - 1;
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
if (arr[mid] < target) lo = mid + 1;
|
||||||
|
else hi = mid;
|
||||||
|
}
|
||||||
|
if (lo > 0 && Math.abs(arr[lo - 1] - target) < Math.abs(arr[lo] - target)) return lo - 1;
|
||||||
|
return lo;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ export interface RawPrediction {
|
||||||
|
|
||||||
export interface Prediction {
|
export interface Prediction {
|
||||||
flight_path: LatLngTuple[];
|
flight_path: LatLngTuple[];
|
||||||
|
/** Epoch-ms timestamp for each point in flight_path (parallel array). */
|
||||||
|
timestamps: number[];
|
||||||
launch: Point;
|
launch: Point;
|
||||||
burst: Point;
|
burst: Point;
|
||||||
landing: Point;
|
landing: Point;
|
||||||
|
|
@ -55,13 +57,16 @@ export function parsePrediction(stages: PredictionStage[]): Prediction {
|
||||||
|
|
||||||
const ascent = stages[0].trajectory;
|
const ascent = stages[0].trajectory;
|
||||||
const descent = stages[1].trajectory;
|
const descent = stages[1].trajectory;
|
||||||
|
const all = [...ascent, ...descent];
|
||||||
|
|
||||||
const flight_path: LatLngTuple[] = [...ascent, ...descent].map((p) => [
|
const flight_path: LatLngTuple[] = all.map((p) => [
|
||||||
p.latitude,
|
p.latitude,
|
||||||
normalizeLng(p.longitude),
|
normalizeLng(p.longitude),
|
||||||
p.altitude,
|
p.altitude,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const timestamps: number[] = all.map((p) => new Date(p.datetime).getTime());
|
||||||
|
|
||||||
const launch = pointFromTrajectory(ascent[0]);
|
const launch = pointFromTrajectory(ascent[0]);
|
||||||
const burst = pointFromTrajectory(descent[0]);
|
const burst = pointFromTrajectory(descent[0]);
|
||||||
const landing = pointFromTrajectory(descent[descent.length - 1]);
|
const landing = pointFromTrajectory(descent[descent.length - 1]);
|
||||||
|
|
@ -69,5 +74,5 @@ export function parsePrediction(stages: PredictionStage[]): Prediction {
|
||||||
const profile = stages[1].stage === 'descent' ? 'standard_profile' : 'float_profile';
|
const profile = stages[1].stage === 'descent' ? 'standard_profile' : 'float_profile';
|
||||||
const flight_time = (landing.datetime.getTime() - launch.datetime.getTime()) / 1000;
|
const flight_time = (landing.datetime.getTime() - launch.datetime.getTime()) / 1000;
|
||||||
|
|
||||||
return { flight_path, launch, burst, landing, profile, flight_time };
|
return { flight_path, timestamps, launch, burst, landing, profile, flight_time };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
214
src/lib/domain/wind.ts
Normal file
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 { settingsStore, DEFAULT_SETTINGS } from './store';
|
||||||
export type { AppSettings, MapSettings, UnitsSettings } from './store';
|
export type { AppSettings, MapSettings, UnitsSettings, WindSettings } from './store';
|
||||||
export { default as SettingsPanel } from './SettingsPanel.svelte';
|
export { default as SettingsPanel } from './SettingsPanel.svelte';
|
||||||
export { SETTINGS_SCHEMA } from './schema';
|
export { SETTINGS_SCHEMA } from './schema';
|
||||||
export type { SettingsField, SettingsSection } from './schema';
|
export type { SettingsField, SettingsSection } from './schema';
|
||||||
|
|
|
||||||
|
|
@ -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 { persisted } from '$state';
|
||||||
import type { Locale } from '$i18n';
|
import type { Locale } from '$i18n';
|
||||||
|
import { type WindSettings, DEFAULT_WIND_SETTINGS } from '$domain';
|
||||||
|
|
||||||
|
export type { WindSettings };
|
||||||
|
|
||||||
export interface MapSettings {
|
export interface MapSettings {
|
||||||
baseLayer: 'osm' | 'satellite';
|
baseLayer: 'osm' | 'satellite';
|
||||||
|
|
@ -15,12 +18,14 @@ export interface AppSettings {
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
map: MapSettings;
|
map: MapSettings;
|
||||||
units: UnitsSettings;
|
units: UnitsSettings;
|
||||||
|
wind: WindSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: AppSettings = {
|
export const DEFAULT_SETTINGS: AppSettings = {
|
||||||
locale: 'ru',
|
locale: 'ru',
|
||||||
map: { baseLayer: 'osm', showScale: true, showNavigation: true },
|
map: { baseLayer: 'osm', showScale: true, showNavigation: true },
|
||||||
units: { system: 'metric' },
|
units: { system: 'metric' },
|
||||||
|
wind: { ...DEFAULT_WIND_SETTINGS },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const settingsStore = persisted<AppSettings>('settings', DEFAULT_SETTINGS);
|
export const settingsStore = persisted<AppSettings>('settings', DEFAULT_SETTINGS);
|
||||||
|
|
|
||||||
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 TelemetryPanel } from './TelemetryPanel.svelte';
|
||||||
|
export { default as DeviationChart } from './DeviationChart.svelte';
|
||||||
export { telemetryStore, type TrackingStatus } from './telemetryStore.svelte';
|
export { telemetryStore, type TrackingStatus } from './telemetryStore.svelte';
|
||||||
|
|
|
||||||
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",
|
"units": "Units",
|
||||||
"metric": "Metric",
|
"metric": "Metric",
|
||||||
"imperial": "Imperial",
|
"imperial": "Imperial",
|
||||||
"saved": "Settings saved"
|
"saved": "Settings saved",
|
||||||
|
"wind": "Wind visualization",
|
||||||
|
"windEnabled": "Show wind layer",
|
||||||
|
"windStep": "Grid resolution (°)",
|
||||||
|
"windTrajectoryStep": "Trajectory grid res. (°)",
|
||||||
|
"windPrefetchInterval": "Pre-fetch interval (min)",
|
||||||
|
"windMaxDuration": "Max sync duration (h)",
|
||||||
|
"windMaxRegion": "Max region size (°)",
|
||||||
|
"windMargin": "Trajectory margin (°)",
|
||||||
|
"windParticleDensity": "Particle density",
|
||||||
|
"windParticleSpeed": "Particle speed",
|
||||||
|
"windTrailPersistence": "Trail length",
|
||||||
|
"windMaxVelocity": "Max wind speed (m/s)"
|
||||||
},
|
},
|
||||||
"editor": {
|
"editor": {
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
|
|
@ -174,7 +186,16 @@
|
||||||
"status_connected": "Connected",
|
"status_connected": "Connected",
|
||||||
"status_error": "Error",
|
"status_error": "Error",
|
||||||
"packetCount": "{count} packets received",
|
"packetCount": "{count} packets received",
|
||||||
"waitingData": "Waiting for data..."
|
"waitingData": "Waiting for data...",
|
||||||
|
"deviation": "Compare with forecast",
|
||||||
|
"selectForecast": "Reference forecast",
|
||||||
|
"noForecast": "— No forecast —",
|
||||||
|
"noData": "No telemetry data",
|
||||||
|
"altProfile": "Altitude profile",
|
||||||
|
"selectPrediction": "Select a forecast to show deviations",
|
||||||
|
"horizontalDev": "Horizontal deviation",
|
||||||
|
"devMax": "Max:",
|
||||||
|
"devCurrent": "Current:"
|
||||||
},
|
},
|
||||||
"forecast": {
|
"forecast": {
|
||||||
"success": "Forecast request",
|
"success": "Forecast request",
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,19 @@
|
||||||
"units": "Единицы измерения",
|
"units": "Единицы измерения",
|
||||||
"metric": "Метрические",
|
"metric": "Метрические",
|
||||||
"imperial": "Имперские",
|
"imperial": "Имперские",
|
||||||
"saved": "Настройки сохранены"
|
"saved": "Настройки сохранены",
|
||||||
|
"wind": "Визуализация ветра",
|
||||||
|
"windEnabled": "Показывать слой ветра",
|
||||||
|
"windStep": "Шаг сетки (°)",
|
||||||
|
"windTrajectoryStep": "Шаг сетки по траектории (°)",
|
||||||
|
"windPrefetchInterval": "Интервал предзагрузки (мин)",
|
||||||
|
"windMaxDuration": "Макс. длительность синхронизации (ч)",
|
||||||
|
"windMaxRegion": "Макс. размер региона (°)",
|
||||||
|
"windMargin": "Отступ вокруг траектории (°)",
|
||||||
|
"windParticleDensity": "Плотность частиц",
|
||||||
|
"windParticleSpeed": "Скорость частиц",
|
||||||
|
"windTrailPersistence": "Длина следа",
|
||||||
|
"windMaxVelocity": "Макс. скорость ветра (м/с)"
|
||||||
},
|
},
|
||||||
"editor": {
|
"editor": {
|
||||||
"add": "Добавить",
|
"add": "Добавить",
|
||||||
|
|
@ -174,7 +186,16 @@
|
||||||
"status_connected": "Подключено",
|
"status_connected": "Подключено",
|
||||||
"status_error": "Ошибка",
|
"status_error": "Ошибка",
|
||||||
"packetCount": "Получено пакетов: {count}",
|
"packetCount": "Получено пакетов: {count}",
|
||||||
"waitingData": "Ожидание данных..."
|
"waitingData": "Ожидание данных...",
|
||||||
|
"deviation": "Сравнение с прогнозом",
|
||||||
|
"selectForecast": "Прогноз для сравнения",
|
||||||
|
"noForecast": "— Без прогноза —",
|
||||||
|
"noData": "Нет данных телеметрии",
|
||||||
|
"altProfile": "Высотный профиль",
|
||||||
|
"selectPrediction": "Выберите прогноз для отображения отклонений",
|
||||||
|
"horizontalDev": "Горизонтальное отклонение",
|
||||||
|
"devMax": "Макс.:",
|
||||||
|
"devCurrent": "Текущее:"
|
||||||
},
|
},
|
||||||
"forecast": {
|
"forecast": {
|
||||||
"success": "Запрос прогноза",
|
"success": "Запрос прогноза",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
WorkspaceRenderer,
|
WorkspaceRenderer,
|
||||||
workspacesStore,
|
workspacesStore,
|
||||||
} from '$features/workspaces';
|
} from '$features/workspaces';
|
||||||
|
import { WindRenderer } from '$features/wind';
|
||||||
import { SettingsPanel } from '$features/settings';
|
import { SettingsPanel } from '$features/settings';
|
||||||
import { TimeLine } from '$features/timeline';
|
import { TimeLine } from '$features/timeline';
|
||||||
import { t } from '$i18n';
|
import { t } from '$i18n';
|
||||||
|
|
@ -72,6 +73,7 @@
|
||||||
<div style="height: var(--navbar-height);"></div>
|
<div style="height: var(--navbar-height);"></div>
|
||||||
<MapView bind:this={mapComponent} onReady={handleMapReady}>
|
<MapView bind:this={mapComponent} onReady={handleMapReady}>
|
||||||
<WorkspaceRenderer />
|
<WorkspaceRenderer />
|
||||||
|
<WindRenderer />
|
||||||
|
|
||||||
<PanelContainer position="left">
|
<PanelContainer position="left">
|
||||||
<TabBar
|
<TabBar
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,20 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { Map as MapView, plotAnimatedMarker, type IMap } from '$map';
|
import { Map as MapView, plotAnimatedMarker, plotPrediction, type IMap } from '$map';
|
||||||
import { Navbar } from '$features/auth';
|
import { Navbar } from '$features/auth';
|
||||||
import { PanelContainer } from '$ui';
|
import { PanelContainer, CollapsibleCard } from '$ui';
|
||||||
import { TelemetryPanel, telemetryStore } from '$features/tracking';
|
import { TelemetryPanel, DeviationChart, telemetryStore } from '$features/tracking';
|
||||||
|
import { workspacesStore } from '$features/workspaces';
|
||||||
|
import { t } from '$i18n';
|
||||||
import { requireAuthenticated } from '$auth';
|
import { requireAuthenticated } from '$auth';
|
||||||
import { parseTelemetry } from '$domain';
|
import { parseTelemetry } from '$domain';
|
||||||
|
import type { Prediction } from '$domain';
|
||||||
|
|
||||||
|
let selectedId = $state('');
|
||||||
|
const workspacesWithResult = $derived($workspacesStore.items.filter((w) => w.result !== null));
|
||||||
|
const selectedPrediction = $derived<Prediction | null>(
|
||||||
|
workspacesWithResult.find((w) => w.id === selectedId)?.result ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
let map = $state<IMap | null>(null);
|
let map = $state<IMap | null>(null);
|
||||||
// Tracks whether we've already fitted the map to the initial history load.
|
// Tracks whether we've already fitted the map to the initial history load.
|
||||||
|
|
@ -18,6 +27,17 @@
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
map?.disposeScene('telemetry');
|
map?.disposeScene('telemetry');
|
||||||
|
map?.disposeScene('prediction');
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!map) return;
|
||||||
|
const scene = map.scene('prediction');
|
||||||
|
if (selectedPrediction) {
|
||||||
|
plotPrediction(scene, selectedPrediction, { color: '#1565C0', opacity: 0.7 });
|
||||||
|
} else {
|
||||||
|
scene.clear();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function onMapReady(m: IMap) {
|
function onMapReady(m: IMap) {
|
||||||
|
|
@ -76,5 +96,24 @@
|
||||||
<PanelContainer position="left">
|
<PanelContainer position="left">
|
||||||
<TelemetryPanel />
|
<TelemetryPanel />
|
||||||
</PanelContainer>
|
</PanelContainer>
|
||||||
|
<PanelContainer position="right">
|
||||||
|
<CollapsibleCard title={$t('tracking.deviation')}>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label for="forecast-select" class="form-label small mb-1">{$t('tracking.selectForecast')}</label>
|
||||||
|
<select
|
||||||
|
id="forecast-select"
|
||||||
|
class="form-select form-select-sm"
|
||||||
|
bind:value={selectedId}
|
||||||
|
disabled={workspacesWithResult.length === 0}
|
||||||
|
>
|
||||||
|
<option value="">{$t('tracking.noForecast')}</option>
|
||||||
|
{#each workspacesWithResult as w (w.id)}
|
||||||
|
<option value={w.id}>{w.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<DeviationChart points={telemetryStore.points} prediction={selectedPrediction} />
|
||||||
|
</CollapsibleCard>
|
||||||
|
</PanelContainer>
|
||||||
</MapView>
|
</MapView>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,9 @@ import sys
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 2025-04-06 03:00:00 UTC
|
||||||
|
BASE_TS = 1743908400
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Trajectory loaders
|
# Trajectory loaders
|
||||||
|
|
@ -94,7 +97,7 @@ def load_csv(path: Path) -> list[dict]:
|
||||||
"lat": float(row["lat"]),
|
"lat": float(row["lat"]),
|
||||||
"lon": float(row["lon"]),
|
"lon": float(row["lon"]),
|
||||||
"alt": float(row["alt"]),
|
"alt": float(row["alt"]),
|
||||||
"timestamp": int(row.get("timestamp", time.time())),
|
"timestamp": int(row.get("timestamp", BASE_TS + len(points) * 10)),
|
||||||
})
|
})
|
||||||
return points
|
return points
|
||||||
|
|
||||||
|
|
@ -111,7 +114,7 @@ def load_log(path: Path) -> list[dict]:
|
||||||
Duplicate positions (identical lat/lon/alt) are silently dropped.
|
Duplicate positions (identical lat/lon/alt) are silently dropped.
|
||||||
Points with no GPS fix (lat_raw == lon_raw == 0) are also dropped.
|
Points with no GPS fix (lat_raw == lon_raw == 0) are also dropped.
|
||||||
"""
|
"""
|
||||||
base_ts = int(time.time())
|
base_ts = BASE_TS
|
||||||
points: list[dict] = []
|
points: list[dict] = []
|
||||||
seen: set[tuple] = set()
|
seen: set[tuple] = set()
|
||||||
|
|
||||||
|
|
@ -192,7 +195,7 @@ def deduplicate(points: list[dict]) -> list[dict]:
|
||||||
def generate_sample(path: Path, n: int = 50) -> None:
|
def generate_sample(path: Path, n: int = 50) -> None:
|
||||||
"""Generate a simple ascending balloon trajectory."""
|
"""Generate a simple ascending balloon trajectory."""
|
||||||
base_lat, base_lon = 62.0, 129.5
|
base_lat, base_lon = 62.0, 129.5
|
||||||
base_ts = int(time.time())
|
base_ts = BASE_TS
|
||||||
points = []
|
points = []
|
||||||
for i in range(n):
|
for i in range(n):
|
||||||
angle = i * 0.05
|
angle = i * 0.05
|
||||||
|
|
@ -221,25 +224,28 @@ async def run_ws(server: str, satellite: str, token: str,
|
||||||
print(f"Connecting to {url}")
|
print(f"Connecting to {url}")
|
||||||
|
|
||||||
iteration = 0
|
iteration = 0
|
||||||
|
abs_idx = 0 # never resets across loop iterations — drives advancing timestamps
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
async with websockets.connect(url) as ws:
|
async with websockets.connect(url) as ws:
|
||||||
print("Connected.")
|
print("Connected.")
|
||||||
for i, point in enumerate(points):
|
for point in points:
|
||||||
packet = {
|
packet = {
|
||||||
"lat": float(point.get("lat", 0)),
|
"lat": float(point.get("lat", 0)),
|
||||||
"lon": float(point.get("lon", 0)),
|
"lon": float(point.get("lon", 0)),
|
||||||
"alt": float(point.get("alt", 0)),
|
"alt": float(point.get("alt", 0)),
|
||||||
"timestamp": int(point.get("timestamp", time.time())),
|
"timestamp": BASE_TS + abs_idx * int(interval),
|
||||||
"payload": point.get("payload", {}),
|
"payload": point.get("payload", {}),
|
||||||
"raw_data": point.get("raw_data", {}),
|
"raw_data": point.get("raw_data", {}),
|
||||||
}
|
}
|
||||||
|
abs_idx += 1
|
||||||
await ws.send(json.dumps(packet))
|
await ws.send(json.dumps(packet))
|
||||||
print(
|
print(
|
||||||
f"[{i+1}/{len(points)}] "
|
f"[{abs_idx}/{len(points)}] "
|
||||||
f"lat={packet['lat']:.5f} "
|
f"lat={packet['lat']:.5f} "
|
||||||
f"lon={packet['lon']:.5f} "
|
f"lon={packet['lon']:.5f} "
|
||||||
f"alt={packet['alt']:.1f} m "
|
f"alt={packet['alt']:.1f} m "
|
||||||
|
f"ts={packet['timestamp']}"
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -250,7 +256,6 @@ async def run_ws(server: str, satellite: str, token: str,
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
pass # server only responds on errors
|
pass # server only responds on errors
|
||||||
|
|
||||||
if i < len(points) - 1:
|
|
||||||
await asyncio.sleep(interval)
|
await asyncio.sleep(interval)
|
||||||
|
|
||||||
print("All points sent.")
|
print("All points sent.")
|
||||||
|
|
@ -288,6 +293,7 @@ async def run_rest(server: str, satellite: str,
|
||||||
"lat": float(point.get("lat", 0)),
|
"lat": float(point.get("lat", 0)),
|
||||||
"lon": float(point.get("lon", 0)),
|
"lon": float(point.get("lon", 0)),
|
||||||
"alt": float(point.get("alt", 0)),
|
"alt": float(point.get("alt", 0)),
|
||||||
|
"timestamp": int(point.get("timestamp", BASE_TS + i * int(interval))),
|
||||||
"payload": point.get("payload", {}),
|
"payload": point.get("payload", {}),
|
||||||
}
|
}
|
||||||
async with session.post(url, json=packet) as resp:
|
async with session.post(url, json=packet) as resp:
|
||||||
|
|
@ -313,7 +319,8 @@ def main() -> None:
|
||||||
help="Base server URL (default: ws://localhost:8000)")
|
help="Base server URL (default: ws://localhost:8000)")
|
||||||
parser.add_argument("--satellite", help="Satellite UUID")
|
parser.add_argument("--satellite", help="Satellite UUID")
|
||||||
parser.add_argument("--token", help="Auth token (required for WebSocket mode)")
|
parser.add_argument("--token", help="Auth token (required for WebSocket mode)")
|
||||||
parser.add_argument("--file", help="Trajectory file (.json, .csv, or .log)")
|
parser.add_argument("--file", nargs='+', metavar="FILE",
|
||||||
|
help="One or more trajectory files (.json, .csv, or .log); merged in order")
|
||||||
parser.add_argument("--interval", type=float, default=5.0,
|
parser.add_argument("--interval", type=float, default=5.0,
|
||||||
help="Seconds between packets (default: 10)")
|
help="Seconds between packets (default: 10)")
|
||||||
parser.add_argument("--mode", choices=["ws", "rest"], default="ws",
|
parser.add_argument("--mode", choices=["ws", "rest"], default="ws",
|
||||||
|
|
@ -335,18 +342,22 @@ def main() -> None:
|
||||||
if args.mode == "ws" and not args.token:
|
if args.mode == "ws" and not args.token:
|
||||||
parser.error("--token is required for WebSocket mode")
|
parser.error("--token is required for WebSocket mode")
|
||||||
|
|
||||||
path = Path(args.file)
|
merged: list[dict] = []
|
||||||
|
for f in args.file:
|
||||||
|
path = Path(f)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
sys.exit(f"File not found: {path}")
|
sys.exit(f"File not found: {path}")
|
||||||
|
loaded = load_trajectory(path)
|
||||||
|
print(f" {path}: {len(loaded)} points")
|
||||||
|
merged.extend(loaded)
|
||||||
|
|
||||||
points = load_trajectory(path)
|
before = len(merged)
|
||||||
if not points:
|
points = deduplicate(merged)
|
||||||
sys.exit(f"No valid points found in {path}")
|
|
||||||
before = len(points)
|
|
||||||
points = deduplicate(points)
|
|
||||||
removed = before - len(points)
|
removed = before - len(points)
|
||||||
print(f"Loaded {before} points from {path}" +
|
if not points:
|
||||||
(f", removed {removed} duplicates → {len(points)} unique" if removed else f" ({len(points)} points)"))
|
sys.exit("No valid points found in the provided files")
|
||||||
|
print(f"Total: {before} points" +
|
||||||
|
(f", removed {removed} duplicates → {len(points)} unique" if removed else f" ({len(points)} unique)"))
|
||||||
|
|
||||||
if args.mode == "ws":
|
if args.mode == "ws":
|
||||||
# Swap http(s) → ws(s) if the user passed an HTTP URL
|
# Swap http(s) → ws(s) if the user passed an HTTP URL
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue