compare panel, docs update, wind visualisation
This commit is contained in:
parent
b7f7ec8dc5
commit
48140f0f77
29 changed files with 2299 additions and 38 deletions
|
|
@ -4,3 +4,4 @@ export { pointsApi } from './points';
|
|||
export { profilesApi } from './profiles';
|
||||
export { scenariosApi } from './scenarios';
|
||||
export { predictionsApi, getLatestDataset, buildLaunchDateTime } from './predictions';
|
||||
export { windApi, type WindFieldParams } from './wind';
|
||||
|
|
|
|||
|
|
@ -6,10 +6,11 @@ import type { FlightParameters, RawPrediction } from '$domain';
|
|||
* Round down to the most recent available slot.
|
||||
*/
|
||||
export function getLatestDataset(now: Date = new Date()): string {
|
||||
const rounded = new Date(now);
|
||||
rounded.setUTCHours(Math.floor(rounded.getUTCHours() / 6) * 6, 0, 0, 0);
|
||||
rounded.setUTCHours(rounded.getUTCHours() - 6);
|
||||
return rounded.toISOString();
|
||||
// const rounded = new Date(now);
|
||||
// rounded.setUTCHours(Math.floor(rounded.getUTCHours() / 6) * 6, 0, 0, 0);
|
||||
// rounded.setUTCHours(rounded.getUTCHours() - 6);
|
||||
// return rounded.toISOString();
|
||||
return "2025-04-06T00:00:00Z";
|
||||
}
|
||||
|
||||
export function buildLaunchDateTime(date: string, time: string): string {
|
||||
|
|
|
|||
58
src/lib/api/wind.ts
Normal file
58
src/lib/api/wind.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* Client for the predictor's wind-visualization endpoints.
|
||||
*
|
||||
* These endpoints live on the predictor service (default 127.0.0.1:8080),
|
||||
* not on the Django backend, so they bypass the shared `api` client and
|
||||
* fetch directly. No CSRF or session cookies are needed.
|
||||
*
|
||||
* Set VITE_PREDICTOR_BASE_URL to point at a non-default predictor address.
|
||||
*/
|
||||
|
||||
import type { WindField, WindMeta } from '$domain';
|
||||
|
||||
const PREDICTOR_URL = (import.meta.env.VITE_PREDICTOR_BASE_URL as string | undefined) ?? 'http://127.0.0.1:8080';
|
||||
|
||||
export interface WindFieldParams {
|
||||
altitude?: number;
|
||||
step?: number;
|
||||
time?: string;
|
||||
min_lat?: number;
|
||||
max_lat?: number;
|
||||
min_lng?: number;
|
||||
max_lng?: number;
|
||||
}
|
||||
|
||||
async function predictorFetch<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T> {
|
||||
const q = new URLSearchParams();
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined) q.set(k, String(v));
|
||||
}
|
||||
}
|
||||
const qs = q.toString();
|
||||
const url = `${PREDICTOR_URL}${path}${qs ? '?' + qs : ''}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText);
|
||||
throw new Error(`Predictor ${path} failed: HTTP ${res.status} ${text}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const windApi = {
|
||||
field(params: WindFieldParams = {}): Promise<WindField> {
|
||||
return predictorFetch<WindField>('/api/v1/wind/field', {
|
||||
altitude: params.altitude,
|
||||
step: params.step,
|
||||
time: params.time,
|
||||
min_lat: params.min_lat,
|
||||
max_lat: params.max_lat,
|
||||
min_lng: params.min_lng,
|
||||
max_lng: params.max_lng,
|
||||
});
|
||||
},
|
||||
|
||||
meta(): Promise<WindMeta> {
|
||||
return predictorFetch<WindMeta>('/api/v1/wind/meta');
|
||||
},
|
||||
};
|
||||
|
|
@ -3,3 +3,4 @@ export * from './math';
|
|||
export * from './scenario';
|
||||
export * from './prediction';
|
||||
export * from './telemetry';
|
||||
export * from './wind';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import type { LatLng } from './geo';
|
||||
import type { TelemetryPoint } from './telemetry';
|
||||
import type { Prediction } from './prediction';
|
||||
|
||||
const EARTH_RADIUS_KM = 6371;
|
||||
|
||||
|
|
@ -32,3 +34,66 @@ export function toFixedNumber(num: number, digits: number): number {
|
|||
const pow = 10 ** digits;
|
||||
return Math.round(num * pow) / pow;
|
||||
}
|
||||
|
||||
/** One compared sample: telemetry point matched against the closest-in-time prediction point. */
|
||||
export interface DeviationPoint {
|
||||
/** Epoch ms from the telemetry timestamp. */
|
||||
timeMs: number;
|
||||
/** Great-circle distance from actual position to predicted position, km. */
|
||||
horizontal: number;
|
||||
/** Altitude difference (actual − predicted), m. Positive means actual is higher. */
|
||||
vertical: number;
|
||||
/** Actual altitude from telemetry, m. */
|
||||
altActual: number;
|
||||
/** Predicted altitude at the matched index, m. */
|
||||
altPredicted: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* For each telemetry point find the closest-in-time point in the prediction
|
||||
* and compute horizontal (haversine) and vertical deviations.
|
||||
*
|
||||
* Telemetry points that fall outside the prediction's time window are skipped —
|
||||
* bisectClosest would clamp them to the boundary and produce misleading values.
|
||||
*/
|
||||
export function computeDeviations(
|
||||
points: TelemetryPoint[],
|
||||
prediction: Prediction,
|
||||
): DeviationPoint[] {
|
||||
if (points.length === 0 || prediction.timestamps.length === 0) return [];
|
||||
|
||||
const predStart = prediction.timestamps[0];
|
||||
const predEnd = prediction.timestamps[prediction.timestamps.length - 1];
|
||||
|
||||
const result: DeviationPoint[] = [];
|
||||
for (const p of points) {
|
||||
const t = new Date(p.datetime).getTime();
|
||||
if (t < predStart || t > predEnd) continue;
|
||||
|
||||
const i = bisectClosest(prediction.timestamps, t);
|
||||
const fp = prediction.flight_path[i];
|
||||
const predAlt = (fp[2] as number | undefined) ?? 0;
|
||||
|
||||
result.push({
|
||||
timeMs: t,
|
||||
horizontal: distHaversine({ lat: p.latitude, lng: p.longitude }, { lat: fp[0], lng: fp[1] }),
|
||||
vertical: p.altitude - predAlt,
|
||||
altActual: p.altitude,
|
||||
altPredicted: predAlt,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Binary search: index of the element in `arr` closest to `target`. */
|
||||
function bisectClosest(arr: number[], target: number): number {
|
||||
let lo = 0;
|
||||
let hi = arr.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (arr[mid] < target) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
if (lo > 0 && Math.abs(arr[lo - 1] - target) < Math.abs(arr[lo] - target)) return lo - 1;
|
||||
return lo;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ export interface RawPrediction {
|
|||
|
||||
export interface Prediction {
|
||||
flight_path: LatLngTuple[];
|
||||
/** Epoch-ms timestamp for each point in flight_path (parallel array). */
|
||||
timestamps: number[];
|
||||
launch: Point;
|
||||
burst: Point;
|
||||
landing: Point;
|
||||
|
|
@ -55,13 +57,16 @@ export function parsePrediction(stages: PredictionStage[]): Prediction {
|
|||
|
||||
const ascent = stages[0].trajectory;
|
||||
const descent = stages[1].trajectory;
|
||||
const all = [...ascent, ...descent];
|
||||
|
||||
const flight_path: LatLngTuple[] = [...ascent, ...descent].map((p) => [
|
||||
const flight_path: LatLngTuple[] = all.map((p) => [
|
||||
p.latitude,
|
||||
normalizeLng(p.longitude),
|
||||
p.altitude,
|
||||
]);
|
||||
|
||||
const timestamps: number[] = all.map((p) => new Date(p.datetime).getTime());
|
||||
|
||||
const launch = pointFromTrajectory(ascent[0]);
|
||||
const burst = pointFromTrajectory(descent[0]);
|
||||
const landing = pointFromTrajectory(descent[descent.length - 1]);
|
||||
|
|
@ -69,5 +74,5 @@ export function parsePrediction(stages: PredictionStage[]): Prediction {
|
|||
const profile = stages[1].stage === 'descent' ? 'standard_profile' : 'float_profile';
|
||||
const flight_time = (landing.datetime.getTime() - launch.datetime.getTime()) / 1000;
|
||||
|
||||
return { flight_path, launch, burst, landing, profile, flight_time };
|
||||
return { flight_path, timestamps, launch, burst, landing, profile, flight_time };
|
||||
}
|
||||
|
|
|
|||
214
src/lib/domain/wind.ts
Normal file
214
src/lib/domain/wind.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
/**
|
||||
* Wind field types matching the wind-js-server / leaflet-velocity format
|
||||
* produced by the predictor's GET /api/v1/wind/field endpoint.
|
||||
*
|
||||
* The response is a two-element array [U, V] where U is the eastward and V
|
||||
* the northward wind component, each stored as a regular lat/lng grid
|
||||
* described by a GRIB-style header.
|
||||
*/
|
||||
|
||||
export interface WindHeader {
|
||||
parameterUnit: string;
|
||||
parameterNumberName: string;
|
||||
/** Grid points in the longitude direction. */
|
||||
nx: number;
|
||||
/** Grid points in the latitude direction. */
|
||||
ny: number;
|
||||
lo1: number; // longitude of first grid point (degrees)
|
||||
la1: number; // latitude of first grid point (degrees)
|
||||
lo2: number; // longitude of last grid point
|
||||
la2: number; // latitude of last grid point
|
||||
/**
|
||||
* Grid increments in degrees. Both are reported as positive magnitudes by
|
||||
* the predictor regardless of scan direction, so the scan direction must be
|
||||
* inferred from the extent (la1/la2, lo1/lo2) — see decodeWindField.
|
||||
*/
|
||||
dx: number;
|
||||
dy: number;
|
||||
refTime: string; // ISO 8601 reference time
|
||||
}
|
||||
|
||||
export interface WindComponent {
|
||||
header: WindHeader;
|
||||
/** Flat row-major array: data[j * nx + i] = value at row j, column i. */
|
||||
data: number[];
|
||||
}
|
||||
|
||||
/** [U-component (eastward m/s), V-component (northward m/s)] */
|
||||
export type WindField = [WindComponent, WindComponent];
|
||||
|
||||
export interface WindMeta {
|
||||
source: string;
|
||||
epoch: string;
|
||||
altitudes: number[];
|
||||
bbox: {
|
||||
min_lat: number;
|
||||
max_lat: number;
|
||||
min_lng: number;
|
||||
max_lng: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Decoded wind vector at a single grid cell. */
|
||||
export interface WindVector {
|
||||
lat: number;
|
||||
lng: number;
|
||||
u: number; // eastward component (m/s)
|
||||
v: number; // northward component (m/s)
|
||||
speed: number; // magnitude (m/s)
|
||||
/**
|
||||
* Direction the wind blows TO, degrees clockwise from north.
|
||||
* 0° = northward, 90° = eastward. Used directly as MapLibre icon-rotate.
|
||||
*
|
||||
* Derivation: bearing = atan2(U, V) (see docs/wind-vis-math.tex §3).
|
||||
*/
|
||||
bearing: number;
|
||||
}
|
||||
|
||||
export interface WindSettings {
|
||||
/** Master toggle — off by default. */
|
||||
enabled: boolean;
|
||||
/** Grid resolution for static display (degrees). */
|
||||
step: number;
|
||||
/** Grid resolution when synced to a trajectory (degrees). */
|
||||
trajectoryStep: number;
|
||||
/** Time interval between pre-fetched trajectory frames (minutes). */
|
||||
prefetchIntervalMinutes: number;
|
||||
/** Trajectory sync is skipped when flight duration exceeds this (hours). */
|
||||
maxFlightDurationHours: number;
|
||||
/**
|
||||
* Trajectory sync is skipped when the bounding box exceeds this in either
|
||||
* dimension (degrees).
|
||||
*/
|
||||
maxRegionDegrees: number;
|
||||
/** Padding added to the trajectory bounding box on each side (degrees). */
|
||||
trajectoryMarginDegrees: number;
|
||||
/** Particle count scalar (particles per screen pixel). Higher = denser. */
|
||||
particleDensity: number;
|
||||
/** Advection speed multiplier — how fast particles flow. */
|
||||
particleSpeed: number;
|
||||
/** Trail persistence in [0,1): fraction of each trail kept per frame. */
|
||||
trailPersistence: number;
|
||||
/** Wind speed (m/s) mapped to the top of the colour scale. */
|
||||
maxVelocity: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_WIND_SETTINGS: WindSettings = {
|
||||
enabled: false,
|
||||
step: 2.0,
|
||||
trajectoryStep: 1.0,
|
||||
prefetchIntervalMinutes: 15,
|
||||
maxFlightDurationHours: 4,
|
||||
maxRegionDegrees: 20,
|
||||
trajectoryMarginDegrees: 1.0,
|
||||
particleDensity: 1.0,
|
||||
particleSpeed: 1.0,
|
||||
trailPersistence: 0.92,
|
||||
maxVelocity: 30,
|
||||
};
|
||||
|
||||
/** Wrap a longitude into the (-180, 180] range MapLibre renders. */
|
||||
function wrapLng(lng: number): number {
|
||||
let x = ((lng + 180) % 360) - 180;
|
||||
if (x <= -180) x += 360;
|
||||
return x;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rasterize a WindField into an array of wind vectors — one per grid cell.
|
||||
*
|
||||
* Coordinate handling is derived from the grid extent (la1/la2, lo1/lo2)
|
||||
* rather than the raw dx/dy increments, because the predictor reports:
|
||||
* • longitudes in the 0..360 range (e.g. lo1 = 358 for a query at -2°), and
|
||||
* • a *positive* dy even when the grid scans north→south (la1 = 90,
|
||||
* la2 = -90), which would otherwise send `la1 + j·dy` past the pole.
|
||||
*
|
||||
* Stepping from the first point toward the last (la1→la2, lo1→lo2) and
|
||||
* wrapping longitudes into (-180, 180] places every arrow at its true
|
||||
* geographic position regardless of scan direction or longitude convention.
|
||||
*/
|
||||
export function decodeWindField(field: WindField): WindVector[] {
|
||||
const [uComp, vComp] = field;
|
||||
const { nx, ny, lo1, la1, lo2, la2, dx, dy } = uComp.header;
|
||||
const vectors: WindVector[] = [];
|
||||
|
||||
// Per-step deltas taken from the grid extent so the last row/column lands
|
||||
// exactly on la2/lo2. Longitude span is taken the short way around the
|
||||
// globe to stay correct for boxes that cross the 0/360 seam.
|
||||
const lonSpan = ((lo2 - lo1) % 360 + 360) % 360;
|
||||
const lngDelta = nx > 1 ? lonSpan / (nx - 1) : dx;
|
||||
const latDelta = ny > 1 ? (la2 - la1) / (ny - 1) : -Math.abs(dy);
|
||||
|
||||
for (let j = 0; j < ny; j++) {
|
||||
const lat = la1 + j * latDelta;
|
||||
for (let i = 0; i < nx; i++) {
|
||||
const idx = j * nx + i;
|
||||
const u = uComp.data[idx];
|
||||
const v = vComp.data[idx];
|
||||
if (!Number.isFinite(u) || !Number.isFinite(v)) continue;
|
||||
const lng = wrapLng(lo1 + i * lngDelta);
|
||||
const speed = Math.sqrt(u * u + v * v);
|
||||
const bearing = (Math.atan2(u, v) * 180) / Math.PI;
|
||||
vectors.push({ lat, lng, u, v, speed, bearing });
|
||||
}
|
||||
}
|
||||
|
||||
return vectors;
|
||||
}
|
||||
|
||||
/** Samples the wind field at an arbitrary lng/lat. Returns null outside the grid. */
|
||||
export type WindInterpolator = (lng: number, lat: number) => [number, number] | null;
|
||||
|
||||
/**
|
||||
* Build a bilinear interpolator over a WindField. Used by the particle
|
||||
* renderer to advect points through a continuous [u, v] field.
|
||||
*
|
||||
* Coordinate handling mirrors decodeWindField: longitudes are taken in the
|
||||
* grid's native 0..360 frame (so a query lng is brought into that frame),
|
||||
* and the per-step increments come from the grid extent so scan direction is
|
||||
* handled implicitly.
|
||||
*/
|
||||
export function createWindInterpolator(field: WindField): WindInterpolator {
|
||||
const [uComp, vComp] = field;
|
||||
const { nx, ny, lo1, la1, lo2, la2, dx, dy } = uComp.header;
|
||||
const u = uComp.data;
|
||||
const v = vComp.data;
|
||||
|
||||
const lonSpan = (((lo2 - lo1) % 360) + 360) % 360;
|
||||
const lngDelta = nx > 1 ? lonSpan / (nx - 1) : dx;
|
||||
const latDelta = ny > 1 ? (la2 - la1) / (ny - 1) : -Math.abs(dy);
|
||||
|
||||
return (lng, lat) => {
|
||||
if (lngDelta === 0 || latDelta === 0) return null;
|
||||
|
||||
const rj = (lat - la1) / latDelta;
|
||||
if (rj < 0 || rj > ny - 1) return null;
|
||||
|
||||
// Eastward offset from lo1 in the grid's 0..360 frame.
|
||||
const dLon = (((lng - lo1) % 360) + 360) % 360;
|
||||
const ci = dLon / lngDelta;
|
||||
if (ci < 0 || ci > nx - 1) return null;
|
||||
|
||||
const i0 = Math.floor(ci);
|
||||
const j0 = Math.floor(rj);
|
||||
const i1 = Math.min(i0 + 1, nx - 1);
|
||||
const j1 = Math.min(j0 + 1, ny - 1);
|
||||
const fi = ci - i0;
|
||||
const fj = rj - j0;
|
||||
|
||||
const a = (1 - fi) * (1 - fj);
|
||||
const b = fi * (1 - fj);
|
||||
const c = (1 - fi) * fj;
|
||||
const d = fi * fj;
|
||||
|
||||
const k00 = j0 * nx + i0;
|
||||
const k10 = j0 * nx + i1;
|
||||
const k01 = j1 * nx + i0;
|
||||
const k11 = j1 * nx + i1;
|
||||
|
||||
const ui = u[k00] * a + u[k10] * b + u[k01] * c + u[k11] * d;
|
||||
const vi = v[k00] * a + v[k10] * b + v[k01] * c + v[k11] * d;
|
||||
if (!Number.isFinite(ui) || !Number.isFinite(vi)) return null;
|
||||
return [ui, vi];
|
||||
};
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
export { settingsStore, DEFAULT_SETTINGS } from './store';
|
||||
export type { AppSettings, MapSettings, UnitsSettings } from './store';
|
||||
export type { AppSettings, MapSettings, UnitsSettings, WindSettings } from './store';
|
||||
export { default as SettingsPanel } from './SettingsPanel.svelte';
|
||||
export { SETTINGS_SCHEMA } from './schema';
|
||||
export type { SettingsField, SettingsSection } from './schema';
|
||||
|
|
|
|||
|
|
@ -83,4 +83,90 @@ export const SETTINGS_SCHEMA: SettingsSection[] = [
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
titleKey: 'settings.wind',
|
||||
fields: [
|
||||
{ kind: 'boolean', path: 'wind.enabled', labelKey: 'settings.windEnabled' },
|
||||
{
|
||||
kind: 'number',
|
||||
path: 'wind.step',
|
||||
labelKey: 'settings.windStep',
|
||||
min: 0.25,
|
||||
max: 10,
|
||||
step: 0.25,
|
||||
},
|
||||
{
|
||||
kind: 'number',
|
||||
path: 'wind.trajectoryStep',
|
||||
labelKey: 'settings.windTrajectoryStep',
|
||||
min: 0.25,
|
||||
max: 5,
|
||||
step: 0.25,
|
||||
},
|
||||
{
|
||||
kind: 'number',
|
||||
path: 'wind.prefetchIntervalMinutes',
|
||||
labelKey: 'settings.windPrefetchInterval',
|
||||
min: 5,
|
||||
max: 60,
|
||||
step: 5,
|
||||
},
|
||||
{
|
||||
kind: 'number',
|
||||
path: 'wind.maxFlightDurationHours',
|
||||
labelKey: 'settings.windMaxDuration',
|
||||
min: 1,
|
||||
max: 8,
|
||||
step: 0.5,
|
||||
},
|
||||
{
|
||||
kind: 'number',
|
||||
path: 'wind.maxRegionDegrees',
|
||||
labelKey: 'settings.windMaxRegion',
|
||||
min: 5,
|
||||
max: 60,
|
||||
step: 5,
|
||||
},
|
||||
{
|
||||
kind: 'number',
|
||||
path: 'wind.trajectoryMarginDegrees',
|
||||
labelKey: 'settings.windMargin',
|
||||
min: 0.5,
|
||||
max: 5,
|
||||
step: 0.5,
|
||||
},
|
||||
{
|
||||
kind: 'number',
|
||||
path: 'wind.particleDensity',
|
||||
labelKey: 'settings.windParticleDensity',
|
||||
min: 0.25,
|
||||
max: 3,
|
||||
step: 0.25,
|
||||
},
|
||||
{
|
||||
kind: 'number',
|
||||
path: 'wind.particleSpeed',
|
||||
labelKey: 'settings.windParticleSpeed',
|
||||
min: 0.25,
|
||||
max: 4,
|
||||
step: 0.25,
|
||||
},
|
||||
{
|
||||
kind: 'number',
|
||||
path: 'wind.trailPersistence',
|
||||
labelKey: 'settings.windTrailPersistence',
|
||||
min: 0.7,
|
||||
max: 0.98,
|
||||
step: 0.02,
|
||||
},
|
||||
{
|
||||
kind: 'number',
|
||||
path: 'wind.maxVelocity',
|
||||
labelKey: 'settings.windMaxVelocity',
|
||||
min: 10,
|
||||
max: 80,
|
||||
step: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { persisted } from '$state';
|
||||
import type { Locale } from '$i18n';
|
||||
import { type WindSettings, DEFAULT_WIND_SETTINGS } from '$domain';
|
||||
|
||||
export type { WindSettings };
|
||||
|
||||
export interface MapSettings {
|
||||
baseLayer: 'osm' | 'satellite';
|
||||
|
|
@ -15,12 +18,14 @@ export interface AppSettings {
|
|||
locale: Locale;
|
||||
map: MapSettings;
|
||||
units: UnitsSettings;
|
||||
wind: WindSettings;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: AppSettings = {
|
||||
locale: 'ru',
|
||||
map: { baseLayer: 'osm', showScale: true, showNavigation: true },
|
||||
units: { system: 'metric' },
|
||||
wind: { ...DEFAULT_WIND_SETTINGS },
|
||||
};
|
||||
|
||||
export const settingsStore = persisted<AppSettings>('settings', DEFAULT_SETTINGS);
|
||||
|
|
|
|||
203
src/lib/features/tracking/DeviationChart.svelte
Normal file
203
src/lib/features/tracking/DeviationChart.svelte
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { Chart as ChartJS, type ChartDataset } from 'chart.js/auto';
|
||||
import 'chartjs-adapter-luxon';
|
||||
import { computeDeviations, type TelemetryPoint, type Prediction } from '$domain';
|
||||
import { t } from '$i18n';
|
||||
|
||||
interface Props {
|
||||
points: TelemetryPoint[];
|
||||
prediction?: Prediction | null;
|
||||
}
|
||||
|
||||
let { points, prediction = null }: Props = $props();
|
||||
|
||||
let altCanvas: HTMLCanvasElement;
|
||||
let devCanvas: HTMLCanvasElement;
|
||||
let altChart: ChartJS | null = null;
|
||||
let devChart: ChartJS | null = null;
|
||||
|
||||
const deviations = $derived(
|
||||
prediction && points.length > 0 ? computeDeviations(points, prediction) : null,
|
||||
);
|
||||
|
||||
// Full prediction altitude series — drawn independently of telemetry sample rate.
|
||||
const predAltData = $derived(
|
||||
prediction
|
||||
? prediction.timestamps.map((tsMs, idx) => ({
|
||||
x: tsMs,
|
||||
y: (prediction.flight_path[idx][2] as number | undefined) ?? 0,
|
||||
}))
|
||||
: [],
|
||||
);
|
||||
|
||||
const hasData = $derived(points.length > 0);
|
||||
const hasDeviation = $derived(!!deviations && deviations.length > 0);
|
||||
|
||||
// ── shared axis options ─────────────────────────────────────────────────
|
||||
const timeAxis = {
|
||||
type: 'time' as const,
|
||||
time: {
|
||||
unit: 'minute' as const,
|
||||
displayFormats: { minute: 'HH:mm' },
|
||||
tooltipFormat: 'HH:mm:ss',
|
||||
},
|
||||
adapters: { date: { zone: 'UTC' } },
|
||||
title: { display: true, text: 'UTC', font: { size: 10 } },
|
||||
ticks: { font: { size: 9 }, maxRotation: 0 },
|
||||
};
|
||||
|
||||
const commonOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false as const,
|
||||
interaction: { mode: 'index' as const, intersect: false },
|
||||
plugins: {
|
||||
legend: { position: 'top' as const, labels: { boxWidth: 10, font: { size: 10 } } },
|
||||
},
|
||||
};
|
||||
|
||||
// ── chart creation ──────────────────────────────────────────────────────
|
||||
onMount(() => {
|
||||
altChart = new ChartJS(altCanvas.getContext('2d')!, {
|
||||
type: 'line',
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
label: 'Фактическая, м',
|
||||
data: [],
|
||||
borderColor: '#FF1744',
|
||||
backgroundColor: 'rgba(255,23,68,0.08)',
|
||||
fill: false,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2,
|
||||
} as ChartDataset<'line'>,
|
||||
{
|
||||
label: 'Прогноз, м',
|
||||
data: [],
|
||||
borderColor: '#1565C0',
|
||||
backgroundColor: 'transparent',
|
||||
fill: false,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2,
|
||||
borderDash: [6, 3],
|
||||
} as ChartDataset<'line'>,
|
||||
],
|
||||
},
|
||||
options: {
|
||||
...commonOptions,
|
||||
scales: {
|
||||
x: timeAxis,
|
||||
y: {
|
||||
title: { display: true, text: 'Высота, м', font: { size: 10 } },
|
||||
ticks: { font: { size: 9 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
devChart = new ChartJS(devCanvas.getContext('2d')!, {
|
||||
type: 'line',
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
label: 'Откл., км',
|
||||
data: [],
|
||||
borderColor: '#F57F17',
|
||||
backgroundColor: 'rgba(245,127,23,0.15)',
|
||||
fill: true,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2,
|
||||
} as ChartDataset<'line'>,
|
||||
],
|
||||
},
|
||||
options: {
|
||||
...commonOptions,
|
||||
scales: {
|
||||
x: timeAxis,
|
||||
y: {
|
||||
min: 0,
|
||||
title: { display: true, text: 'Откл., км', font: { size: 10 } },
|
||||
ticks: { font: { size: 9 } },
|
||||
},
|
||||
},
|
||||
plugins: { ...commonOptions.plugins, legend: { display: false } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ── altitude chart: update on every telemetry or prediction change ──────
|
||||
$effect(() => {
|
||||
if (!altChart) return;
|
||||
altChart.data.datasets[0].data = points.map((p) => ({
|
||||
x: new Date(p.datetime).getTime(),
|
||||
y: p.altitude,
|
||||
}));
|
||||
altChart.data.datasets[1].data = predAltData;
|
||||
altChart.update('none');
|
||||
});
|
||||
|
||||
// ── deviation chart: update when computed deviations change ────────────
|
||||
$effect(() => {
|
||||
if (!devChart) return;
|
||||
devChart.data.datasets[0].data =
|
||||
deviations?.map((d) => ({ x: d.timeMs, y: d.horizontal })) ?? [];
|
||||
devChart.update('none');
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
altChart?.destroy();
|
||||
devChart?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Both canvases are ALWAYS in the DOM so Chart.js instances created in
|
||||
onMount always have a valid canvas reference. Sections are shown/hidden
|
||||
via d-none; Chart.js v3+ ResizeObserver picks up dimension changes when
|
||||
display:none is removed and re-renders at the correct size.
|
||||
-->
|
||||
|
||||
<!-- ── No-data placeholder ─────────────────────────────────────────────── -->
|
||||
{#if !hasData}
|
||||
<p class="text-muted small text-center py-3 mb-0">{$t('tracking.noData')}</p>
|
||||
{/if}
|
||||
|
||||
<!-- ── Altitude profile (always rendered, hidden while no data) ─────────── -->
|
||||
<div class:d-none={!hasData}>
|
||||
<p class="small fw-semibold mb-1">{$t('tracking.altProfile')}</p>
|
||||
<div style="position: relative; height: 170px;">
|
||||
<canvas bind:this={altCanvas}></canvas>
|
||||
</div>
|
||||
|
||||
{#if !hasDeviation}
|
||||
<p class="small text-muted mt-2 mb-0">{$t('tracking.selectPrediction')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Horizontal deviation (always rendered, hidden while no prediction) ── -->
|
||||
<div class:d-none={!hasDeviation}>
|
||||
<hr class="my-2" />
|
||||
<p class="small fw-semibold mb-1">{$t('tracking.horizontalDev')}</p>
|
||||
<div style="position: relative; height: 130px;">
|
||||
<canvas bind:this={devCanvas}></canvas>
|
||||
</div>
|
||||
|
||||
{#if deviations && deviations.length > 0}
|
||||
{@const maxDev = Math.max(...deviations.map((d) => d.horizontal))}
|
||||
{@const last = deviations[deviations.length - 1]}
|
||||
<div class="d-flex gap-3 mt-2 flex-wrap">
|
||||
<small class="text-muted">
|
||||
{$t('tracking.devMax')} <span class="fw-semibold text-body">{maxDev.toFixed(2)} км</span>
|
||||
</small>
|
||||
<small class="text-muted">
|
||||
{$t('tracking.devCurrent')} <span class="fw-semibold text-body">{last.horizontal.toFixed(2)} км</span>
|
||||
</small>
|
||||
<small class="text-muted">
|
||||
Δh: <span class="fw-semibold text-body">
|
||||
{last.vertical > 0 ? '+' : ''}{last.vertical.toFixed(0)} м
|
||||
</span>
|
||||
</small>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
export { default as TelemetryPanel } from './TelemetryPanel.svelte';
|
||||
export { default as DeviationChart } from './DeviationChart.svelte';
|
||||
export { telemetryStore, type TrackingStatus } from './telemetryStore.svelte';
|
||||
|
|
|
|||
335
src/lib/features/wind/ParticleField.ts
Normal file
335
src/lib/features/wind/ParticleField.ts
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
/**
|
||||
* ParticleField — an animated wind-flow layer rendered to a 2D canvas
|
||||
* overlaid on the MapLibre container, in the spirit of leaflet-velocity /
|
||||
* cambecc's "earth".
|
||||
*
|
||||
* Particles live in CSS-pixel space. Each frame, every particle is unprojected
|
||||
* to lng/lat, the wind [u, v] there is sampled, and that vector is pushed
|
||||
* through the map projection's local Jacobian to obtain a pixel-space velocity
|
||||
* (so motion is correct at any zoom/latitude). Trails are faded by compositing
|
||||
* a translucent clear over the previous frame, leaving the basemap visible.
|
||||
*
|
||||
* The wind field can change every frame (the renderer interpolates between
|
||||
* pre-fetched trajectory frames over time); only the lightweight interpolator
|
||||
* closure is swapped, so particle motion stays continuous. See
|
||||
* docs/wind-vis-math.tex §"Particle Advection".
|
||||
*/
|
||||
|
||||
import type { Map as MLMap } from 'maplibre-gl';
|
||||
import type { WindInterpolator } from '$domain';
|
||||
|
||||
export interface ParticleOptions {
|
||||
/** Particles per screen pixel (scaled by the base multiplier). */
|
||||
density: number;
|
||||
/** Advection speed multiplier. */
|
||||
speed: number;
|
||||
/** Trail persistence in [0,1): fraction of the trail kept each frame. */
|
||||
trailPersistence: number;
|
||||
/** Max frames a particle lives before it is respawned. */
|
||||
maxAge: number;
|
||||
/** Trail line width (CSS px). */
|
||||
lineWidth: number;
|
||||
/** Wind speed (m/s) at the bottom / top of the colour scale. */
|
||||
minVelocity: number;
|
||||
maxVelocity: number;
|
||||
/** Target frame rate (the field is re-evaluated at most this often). */
|
||||
frameRate: number;
|
||||
/** Colour ramp from slow → fast wind. */
|
||||
colorScale: string[];
|
||||
}
|
||||
|
||||
export const DEFAULT_COLOR_SCALE = [
|
||||
'rgb(36,104,180)',
|
||||
'rgb(60,157,194)',
|
||||
'rgb(128,205,193)',
|
||||
'rgb(151,218,168)',
|
||||
'rgb(198,231,181)',
|
||||
'rgb(238,247,217)',
|
||||
'rgb(255,238,159)',
|
||||
'rgb(252,217,125)',
|
||||
'rgb(255,182,100)',
|
||||
'rgb(252,150,75)',
|
||||
'rgb(250,112,52)',
|
||||
'rgb(245,64,32)',
|
||||
'rgb(237,45,28)',
|
||||
'rgb(220,24,32)',
|
||||
'rgb(180,0,35)',
|
||||
];
|
||||
|
||||
export const DEFAULT_PARTICLE_OPTIONS: ParticleOptions = {
|
||||
density: 1.0,
|
||||
speed: 1.0,
|
||||
trailPersistence: 0.92,
|
||||
maxAge: 100,
|
||||
lineWidth: 1.4,
|
||||
minVelocity: 0,
|
||||
maxVelocity: 30,
|
||||
frameRate: 30,
|
||||
colorScale: DEFAULT_COLOR_SCALE,
|
||||
};
|
||||
|
||||
/** Base particle count = pixels × this (kept modest for performance). */
|
||||
const PARTICLE_MULTIPLIER = 1 / 350;
|
||||
const MAX_PARTICLES = 6000;
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
xt: number;
|
||||
yt: number;
|
||||
age: number;
|
||||
speed: number;
|
||||
}
|
||||
|
||||
export class ParticleField {
|
||||
private map: MLMap;
|
||||
private host: HTMLElement;
|
||||
private canvas: HTMLCanvasElement;
|
||||
private ctx: CanvasRenderingContext2D;
|
||||
private opts: ParticleOptions;
|
||||
private interp: WindInterpolator | null = null;
|
||||
|
||||
private particles: Particle[] = [];
|
||||
private raf = 0;
|
||||
private then = 0;
|
||||
private moving = false;
|
||||
private width = 0;
|
||||
private height = 0;
|
||||
private debugLogged = false;
|
||||
|
||||
constructor(map: MLMap, opts: Partial<ParticleOptions> = {}) {
|
||||
this.map = map;
|
||||
this.opts = { ...DEFAULT_PARTICLE_OPTIONS, ...opts };
|
||||
|
||||
// Mount inside the MapLibre canvas container so the overlay sits above
|
||||
// the basemap but below the control container and the app's panels.
|
||||
this.host = map.getCanvasContainer();
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.className = 'wind-particles';
|
||||
canvas.style.position = 'absolute';
|
||||
canvas.style.top = '0';
|
||||
canvas.style.left = '0';
|
||||
canvas.style.pointerEvents = 'none';
|
||||
canvas.style.zIndex = '3';
|
||||
this.host.appendChild(canvas);
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d')!;
|
||||
|
||||
this.map.on('movestart', this.onMoveStart);
|
||||
this.map.on('moveend', this.onMoveEnd);
|
||||
this.map.on('resize', this.onResize);
|
||||
this.resize();
|
||||
}
|
||||
|
||||
setOptions(opts: Partial<ParticleOptions>): void {
|
||||
const densityChanged = opts.density !== undefined && opts.density !== this.opts.density;
|
||||
this.opts = { ...this.opts, ...opts };
|
||||
if (densityChanged) this.seedParticles();
|
||||
}
|
||||
|
||||
/** Swap the wind field. Pass null to clear the flow. */
|
||||
setField(interp: WindInterpolator | null): void {
|
||||
this.interp = interp;
|
||||
if (interp && this.particles.length === 0) this.seedParticles();
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.raf) return;
|
||||
this.then = performance.now();
|
||||
this.raf = requestAnimationFrame(this.frame);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.raf) cancelAnimationFrame(this.raf);
|
||||
this.raf = 0;
|
||||
this.clear();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.stop();
|
||||
this.map.off('movestart', this.onMoveStart);
|
||||
this.map.off('moveend', this.onMoveEnd);
|
||||
this.map.off('resize', this.onResize);
|
||||
this.canvas.remove();
|
||||
}
|
||||
|
||||
// ── Internals ─────────────────────────────────────────────────────────────
|
||||
|
||||
private onMoveStart = (): void => {
|
||||
this.moving = true;
|
||||
this.clear();
|
||||
};
|
||||
|
||||
private onMoveEnd = (): void => {
|
||||
this.moving = false;
|
||||
this.seedParticles();
|
||||
};
|
||||
|
||||
private onResize = (): void => {
|
||||
this.resize();
|
||||
};
|
||||
|
||||
private resize(): void {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
// Size from the gl canvas: it always reports the true viewport size,
|
||||
// whereas the canvas-container wrapper can measure 0 in some layouts.
|
||||
const glCanvas = this.map.getCanvas();
|
||||
const w = glCanvas.clientWidth || this.map.getContainer().clientWidth;
|
||||
const h = glCanvas.clientHeight || this.map.getContainer().clientHeight;
|
||||
if (!w || !h) return;
|
||||
this.width = w;
|
||||
this.height = h;
|
||||
this.canvas.style.width = `${w}px`;
|
||||
this.canvas.style.height = `${h}px`;
|
||||
this.canvas.width = Math.round(w * dpr);
|
||||
this.canvas.height = Math.round(h * dpr);
|
||||
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // draw in CSS-pixel space
|
||||
this.seedParticles();
|
||||
}
|
||||
|
||||
private particleCount(): number {
|
||||
const n = this.width * this.height * PARTICLE_MULTIPLIER * this.opts.density;
|
||||
return Math.max(0, Math.min(MAX_PARTICLES, Math.round(n)));
|
||||
}
|
||||
|
||||
private seedParticles(): void {
|
||||
const count = this.particleCount();
|
||||
this.particles = new Array(count);
|
||||
for (let i = 0; i < count; i++) {
|
||||
this.particles[i] = { x: 0, y: 0, xt: 0, yt: 0, age: 0, speed: 0 };
|
||||
this.respawn(this.particles[i]);
|
||||
this.particles[i].age = Math.floor(Math.random() * this.opts.maxAge);
|
||||
}
|
||||
}
|
||||
|
||||
/** Place a particle at a random pixel that has wind (a few retries). */
|
||||
private respawn(p: Particle): void {
|
||||
for (let attempt = 0; attempt < 8; attempt++) {
|
||||
const x = Math.random() * this.width;
|
||||
const y = Math.random() * this.height;
|
||||
if (!this.interp) {
|
||||
p.x = p.xt = x;
|
||||
p.y = p.yt = y;
|
||||
break;
|
||||
}
|
||||
const ll = this.map.unproject([x, y]);
|
||||
if (this.interp(ll.lng, ll.lat)) {
|
||||
p.x = p.xt = x;
|
||||
p.y = p.yt = y;
|
||||
break;
|
||||
}
|
||||
p.x = p.xt = x;
|
||||
p.y = p.yt = y;
|
||||
}
|
||||
p.age = 0;
|
||||
p.speed = 0;
|
||||
}
|
||||
|
||||
private clear(): void {
|
||||
this.ctx.clearRect(0, 0, this.width, this.height);
|
||||
}
|
||||
|
||||
private colorIndex(speed: number): number {
|
||||
const { minVelocity, maxVelocity, colorScale } = this.opts;
|
||||
const f = (speed - minVelocity) / (maxVelocity - minVelocity);
|
||||
return Math.max(0, Math.min(colorScale.length - 1, Math.round(f * (colorScale.length - 1))));
|
||||
}
|
||||
|
||||
private evolve(): void {
|
||||
const interp = this.interp;
|
||||
if (!interp) return;
|
||||
const scale = 0.06 * this.opts.speed; // pixel velocity = Jacobian·wind·scale
|
||||
const eps = 0.02; // degrees, for the projection Jacobian
|
||||
|
||||
for (const p of this.particles) {
|
||||
if (p.age >= this.opts.maxAge) {
|
||||
this.respawn(p);
|
||||
continue;
|
||||
}
|
||||
const ll = this.map.unproject([p.x, p.y]);
|
||||
const wind = interp(ll.lng, ll.lat);
|
||||
if (!wind) {
|
||||
p.age = this.opts.maxAge; // escaped the field → respawn next tick
|
||||
continue;
|
||||
}
|
||||
const [u, v] = wind;
|
||||
|
||||
// Local projection Jacobian: pixel deltas per degree at this point.
|
||||
const east = this.map.project([ll.lng + eps, ll.lat]);
|
||||
const north = this.map.project([ll.lng, ll.lat + eps]);
|
||||
const jxLng = (east.x - p.x) / eps;
|
||||
const jyLng = (east.y - p.y) / eps;
|
||||
const jxLat = (north.x - p.x) / eps;
|
||||
const jyLat = (north.y - p.y) / eps;
|
||||
|
||||
p.xt = p.x + (jxLng * u + jxLat * v) * scale;
|
||||
p.yt = p.y + (jyLng * u + jyLat * v) * scale;
|
||||
p.speed = Math.sqrt(u * u + v * v);
|
||||
p.age += 1;
|
||||
}
|
||||
}
|
||||
|
||||
private draw(): void {
|
||||
const ctx = this.ctx;
|
||||
|
||||
// Fade existing trails toward transparent (keeps the basemap visible).
|
||||
ctx.globalCompositeOperation = 'destination-in';
|
||||
ctx.fillStyle = `rgba(0,0,0,${this.opts.trailPersistence})`;
|
||||
ctx.fillRect(0, 0, this.width, this.height);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
|
||||
// Draw new trail segments, grouped by colour bucket.
|
||||
const { colorScale } = this.opts;
|
||||
ctx.lineWidth = this.opts.lineWidth;
|
||||
const buckets: Particle[][] = colorScale.map(() => []);
|
||||
for (const p of this.particles) {
|
||||
if (p.age >= this.opts.maxAge || p.speed === 0) continue;
|
||||
buckets[this.colorIndex(p.speed)].push(p);
|
||||
}
|
||||
let drawn = 0;
|
||||
for (let i = 0; i < buckets.length; i++) {
|
||||
const bucket = buckets[i];
|
||||
if (bucket.length === 0) continue;
|
||||
drawn += bucket.length;
|
||||
ctx.strokeStyle = colorScale[i];
|
||||
ctx.beginPath();
|
||||
for (const p of bucket) {
|
||||
ctx.moveTo(p.x, p.y);
|
||||
ctx.lineTo(p.xt, p.yt);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV && !this.debugLogged) {
|
||||
this.debugLogged = true;
|
||||
// One-shot diagnostic: confirms field, canvas size, and that segments
|
||||
// are actually being drawn. Remove once the layer is verified.
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug('[wind] first draw', {
|
||||
hasInterp: !!this.interp,
|
||||
canvas: `${this.width}x${this.height}`,
|
||||
backing: `${this.canvas.width}x${this.canvas.height}`,
|
||||
particles: this.particles.length,
|
||||
drawnSegments: drawn,
|
||||
host: this.host.className,
|
||||
});
|
||||
}
|
||||
|
||||
// Advance positions for the next frame.
|
||||
for (const p of this.particles) {
|
||||
p.x = p.xt;
|
||||
p.y = p.yt;
|
||||
}
|
||||
}
|
||||
|
||||
private frame = (now: number): void => {
|
||||
this.raf = requestAnimationFrame(this.frame);
|
||||
if (this.moving || !this.interp) return;
|
||||
const frameTime = 1000 / this.opts.frameRate;
|
||||
if (now - this.then < frameTime) return;
|
||||
this.then = now - ((now - this.then) % frameTime);
|
||||
this.evolve();
|
||||
this.draw();
|
||||
};
|
||||
}
|
||||
332
src/lib/features/wind/WindRenderer.svelte
Normal file
332
src/lib/features/wind/WindRenderer.svelte
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* WindRenderer — renderless component that drives an animated particle-flow
|
||||
* wind layer (ParticleField) over the shared MapLibre map.
|
||||
*
|
||||
* Two display modes:
|
||||
*
|
||||
* Static – shown whenever wind is enabled but no trajectory is available.
|
||||
* Fetches the global wind field at the active workspace's launch
|
||||
* altitude and datetime.
|
||||
*
|
||||
* Trajectory sync – activated once the active workspace has a prediction
|
||||
* result AND the timeline has a non-zero range. Pre-fetches one
|
||||
* wind field per `prefetchIntervalMinutes` along the flight path
|
||||
* (altitude matches the trajectory at each time step), then
|
||||
* linearly interpolates [u, v] between the two bracketing frames
|
||||
* as the timeline scrubs, so the flow evolves smoothly.
|
||||
*
|
||||
* Sanity guards (all configurable in settings → Wind):
|
||||
* • Flight duration > maxFlightDurationHours → trajectory sync disabled.
|
||||
* • Bounding box > maxRegionDegrees in either axis → skipped.
|
||||
* • Minimum step clamped to 0.25° (API limit).
|
||||
*
|
||||
* The actual particle rendering lives in ParticleField (a 2D canvas overlay);
|
||||
* getRawInstance() is used here deliberately because that overlay needs the
|
||||
* raw MapLibre projection/container, which the IMap/Scene abstraction does
|
||||
* not expose. See docs/wind-vis-math.tex for the advection math.
|
||||
*/
|
||||
|
||||
import { onDestroy } from 'svelte';
|
||||
import type { Map as MLMap } from 'maplibre-gl';
|
||||
import { getMap } from '$map';
|
||||
import { settingsStore } from '$features/settings';
|
||||
import { workspacesStore, getActiveWorkspace } from '$features/workspaces';
|
||||
import { timelineStore } from '$features/timeline/store';
|
||||
import {
|
||||
createWindInterpolator,
|
||||
DEFAULT_WIND_SETTINGS,
|
||||
type WindField,
|
||||
type WindComponent,
|
||||
type WindSettings,
|
||||
} from '$domain';
|
||||
import type { Prediction, LatLngTuple } from '$domain';
|
||||
import { windCache } from './store';
|
||||
import { ParticleField, type ParticleOptions } from './ParticleField';
|
||||
|
||||
// ── Map handle ───────────────────────────────────────────────────────────
|
||||
|
||||
const map = getMap();
|
||||
if (!map) throw new Error('WindRenderer must be a descendant of <Map />');
|
||||
const mlMap = map.getRawInstance() as MLMap;
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface WindFrame {
|
||||
flightTimeMs: number;
|
||||
field: WindField;
|
||||
}
|
||||
|
||||
let particleField: ParticleField | null = null;
|
||||
|
||||
let currentField = $state<WindField | null>(null);
|
||||
let trajectoryFrames = $state<WindFrame[]>([]);
|
||||
let prefetchKey: string | null = null; // non-reactive — tracks last pre-fetch identity
|
||||
let staticFetchSeq = 0; // monotonically incremented to cancel stale static fetches
|
||||
let prefetchSkipReason = $state<string | null>(null);
|
||||
|
||||
// ── Derived reactive values ───────────────────────────────────────────────
|
||||
|
||||
const windSettings = $derived<WindSettings>({
|
||||
...DEFAULT_WIND_SETTINGS,
|
||||
...($settingsStore.wind ?? {}),
|
||||
});
|
||||
|
||||
const activeWorkspace = $derived(getActiveWorkspace($workspacesStore));
|
||||
const activePrediction = $derived(activeWorkspace?.result ?? null);
|
||||
|
||||
const inTrajectoryMode = $derived(
|
||||
windSettings.enabled && activePrediction !== null && $timelineStore.max > 0,
|
||||
);
|
||||
|
||||
// ── Particle field ────────────────────────────────────────────────────────
|
||||
|
||||
function particleOptions(s: WindSettings): Partial<ParticleOptions> {
|
||||
return {
|
||||
density: s.particleDensity,
|
||||
speed: s.particleSpeed,
|
||||
trailPersistence: s.trailPersistence,
|
||||
maxVelocity: s.maxVelocity,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureField(): ParticleField {
|
||||
if (!particleField) {
|
||||
particleField = new ParticleField(mlMap, particleOptions(windSettings));
|
||||
}
|
||||
return particleField;
|
||||
}
|
||||
|
||||
// ── Trajectory helpers ────────────────────────────────────────────────────
|
||||
|
||||
function trajectoryBBox(path: LatLngTuple[], marginDeg: number) {
|
||||
let minLat = Infinity,
|
||||
maxLat = -Infinity,
|
||||
minLng = Infinity,
|
||||
maxLng = -Infinity;
|
||||
for (const p of path) {
|
||||
if (p[0] < minLat) minLat = p[0];
|
||||
if (p[0] > maxLat) maxLat = p[0];
|
||||
if (p[1] < minLng) minLng = p[1];
|
||||
if (p[1] > maxLng) maxLng = p[1];
|
||||
}
|
||||
return {
|
||||
min_lat: minLat - marginDeg,
|
||||
max_lat: maxLat + marginDeg,
|
||||
min_lng: minLng - marginDeg,
|
||||
max_lng: maxLng + marginDeg,
|
||||
};
|
||||
}
|
||||
|
||||
/** Binary-search the trajectory for the altitude at a given flight-time offset. */
|
||||
function altAtFlightTime(prediction: Prediction, flightTimeMs: number): number {
|
||||
const { flight_path, timestamps } = prediction;
|
||||
if (!flight_path.length) return 0;
|
||||
const targetMs = timestamps[0] + flightTimeMs;
|
||||
let lo = 0,
|
||||
hi = timestamps.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (timestamps[mid] < targetMs) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
const p = flight_path[Math.min(lo, flight_path.length - 1)];
|
||||
return p[2] ?? 0;
|
||||
}
|
||||
|
||||
/** Linearly blend one wind component (u or v) of two aligned grids. */
|
||||
function lerpComponent(a: WindComponent, b: WindComponent, f: number): WindComponent {
|
||||
if (a.data.length !== b.data.length) return f < 0.5 ? a : b;
|
||||
const data = new Array<number>(a.data.length);
|
||||
for (let k = 0; k < data.length; k++) data[k] = a.data[k] + (b.data[k] - a.data[k]) * f;
|
||||
return { header: a.header, data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wind field at flight-time `t`, linearly interpolated between the two
|
||||
* bracketing pre-fetched frames so the field evolves smoothly as the
|
||||
* timeline scrubs. Frames share the same bbox/step, so their grids align
|
||||
* cell-for-cell and the [u,v] arrays can be blended directly.
|
||||
*/
|
||||
function fieldAtFlightTime(t: number): WindField | null {
|
||||
// trajectoryFrames is $state — reading it here creates a reactive dependency
|
||||
const frames = trajectoryFrames;
|
||||
if (!frames.length) return null;
|
||||
if (frames.length === 1 || t <= frames[0].flightTimeMs) return frames[0].field;
|
||||
const last = frames[frames.length - 1];
|
||||
if (t >= last.flightTimeMs) return last.field;
|
||||
|
||||
let hi = 1;
|
||||
while (hi < frames.length && frames[hi].flightTimeMs < t) hi++;
|
||||
const f0 = frames[hi - 1];
|
||||
const f1 = frames[hi];
|
||||
const span = f1.flightTimeMs - f0.flightTimeMs;
|
||||
const a = span > 0 ? (t - f0.flightTimeMs) / span : 0;
|
||||
if (a <= 0) return f0.field;
|
||||
if (a >= 1) return f1.field;
|
||||
return [lerpComponent(f0.field[0], f1.field[0], a), lerpComponent(f0.field[1], f1.field[1], a)];
|
||||
}
|
||||
|
||||
function makePrefetchKey(prediction: Prediction, s: WindSettings): string {
|
||||
return [
|
||||
prediction.timestamps[0],
|
||||
prediction.flight_time,
|
||||
s.trajectoryStep,
|
||||
s.prefetchIntervalMinutes,
|
||||
s.maxFlightDurationHours,
|
||||
s.maxRegionDegrees,
|
||||
s.trajectoryMarginDegrees,
|
||||
].join('|');
|
||||
}
|
||||
|
||||
async function prefetchTrajectory(prediction: Prediction, settings: WindSettings): Promise<void> {
|
||||
const key = makePrefetchKey(prediction, settings);
|
||||
if (key === prefetchKey) return; // nothing changed
|
||||
|
||||
const flightMs = prediction.flight_time * 1000;
|
||||
|
||||
if (flightMs > settings.maxFlightDurationHours * 3_600_000) {
|
||||
prefetchKey = key;
|
||||
trajectoryFrames = [];
|
||||
prefetchSkipReason = `wind.skippedLong`;
|
||||
return;
|
||||
}
|
||||
|
||||
const bbox = trajectoryBBox(prediction.flight_path, settings.trajectoryMarginDegrees);
|
||||
const latSpan = bbox.max_lat - bbox.min_lat;
|
||||
const lngSpan = bbox.max_lng - bbox.min_lng;
|
||||
if (latSpan > settings.maxRegionDegrees || lngSpan > settings.maxRegionDegrees) {
|
||||
prefetchKey = key;
|
||||
trajectoryFrames = [];
|
||||
prefetchSkipReason = `wind.skippedLarge`;
|
||||
return;
|
||||
}
|
||||
|
||||
prefetchKey = key; // claim before async to prevent concurrent duplicate starts
|
||||
prefetchSkipReason = null;
|
||||
const frames: WindFrame[] = [];
|
||||
const intervalMs = settings.prefetchIntervalMinutes * 60_000;
|
||||
const launchMs = prediction.timestamps[0];
|
||||
const step = Math.max(settings.trajectoryStep, 0.25);
|
||||
|
||||
// Frame offsets: every interval, plus the landing point exactly once.
|
||||
const offsets: number[] = [];
|
||||
for (let t = 0; t < flightMs; t += intervalMs) offsets.push(t);
|
||||
offsets.push(flightMs);
|
||||
|
||||
// Sequential fetches so the cache warms predictably; concurrent bursts
|
||||
// could overwhelm the predictor.
|
||||
for (const offset of offsets) {
|
||||
const altitude = altAtFlightTime(prediction, offset);
|
||||
const time = new Date(launchMs + offset).toISOString();
|
||||
try {
|
||||
const field = await windCache.fetch({ time, altitude, step, ...bbox });
|
||||
frames.push({ flightTimeMs: offset, field });
|
||||
} catch {
|
||||
// Skip this frame and continue with others
|
||||
}
|
||||
}
|
||||
|
||||
trajectoryFrames = frames; // triggers the trajectory render effect
|
||||
}
|
||||
|
||||
// ── Effects ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Pre-fetch trajectory wind frames when prediction or relevant settings change.
|
||||
$effect(() => {
|
||||
const prediction = activePrediction;
|
||||
const settings = windSettings;
|
||||
if (!settings.enabled || !prediction || $timelineStore.max === 0) {
|
||||
trajectoryFrames = [];
|
||||
prefetchKey = null;
|
||||
return;
|
||||
}
|
||||
// Fire-and-forget; prefetchKey prevents duplicate starts.
|
||||
prefetchTrajectory(prediction, settings);
|
||||
});
|
||||
|
||||
// Trajectory mode: keep currentField in sync with the scrubbing timeline.
|
||||
$effect(() => {
|
||||
if (!inTrajectoryMode) return;
|
||||
// Reading trajectoryFrames ($state) makes this effect re-run when frames arrive.
|
||||
currentField = fieldAtFlightTime($timelineStore.time);
|
||||
});
|
||||
|
||||
// Static mode: fetch wind field for the active workspace's launch parameters.
|
||||
$effect(() => {
|
||||
if (!windSettings.enabled || inTrajectoryMode) {
|
||||
staticFetchSeq++; // cancel any in-flight static request
|
||||
return;
|
||||
}
|
||||
const ws = activeWorkspace;
|
||||
if (!ws) {
|
||||
currentField = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const seq = ++staticFetchSeq;
|
||||
const step = Math.max(windSettings.step, 0.25);
|
||||
const { launch_altitude } = ws.flightParameters;
|
||||
const time = new Date(`${ws.launchDate}T${ws.launchTime}Z`).toISOString();
|
||||
|
||||
windCache
|
||||
.fetch({ altitude: launch_altitude, time, step })
|
||||
.then((field) => {
|
||||
if (seq !== staticFetchSeq) return; // superseded
|
||||
currentField = field;
|
||||
})
|
||||
.catch(() => {
|
||||
if (seq !== staticFetchSeq) return;
|
||||
currentField = null;
|
||||
});
|
||||
});
|
||||
|
||||
// Drive the particle field from currentField + settings.
|
||||
$effect(() => {
|
||||
const s = windSettings;
|
||||
const field = currentField;
|
||||
if (!s.enabled || !field) {
|
||||
particleField?.setField(null);
|
||||
particleField?.stop();
|
||||
return;
|
||||
}
|
||||
const pf = ensureField();
|
||||
pf.setOptions(particleOptions(s));
|
||||
pf.setField(createWindInterpolator(field));
|
||||
pf.start();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
staticFetchSeq++; // cancel any pending static callback
|
||||
particleField?.destroy();
|
||||
particleField = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if windSettings.enabled && prefetchSkipReason}
|
||||
<div class="wind-skip-notice">
|
||||
<i class="bi bi-wind"></i>
|
||||
{#if prefetchSkipReason === 'wind.skippedLong'}
|
||||
Wind sync skipped: flight > {windSettings.maxFlightDurationHours}h
|
||||
{:else}
|
||||
Wind sync skipped: region > {windSettings.maxRegionDegrees}°
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.wind-skip-notice {
|
||||
position: absolute;
|
||||
bottom: 90px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
pointer-events: none;
|
||||
z-index: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
3
src/lib/features/wind/index.ts
Normal file
3
src/lib/features/wind/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { default as WindRenderer } from './WindRenderer.svelte';
|
||||
export { windCache } from './store';
|
||||
export { ParticleField, DEFAULT_PARTICLE_OPTIONS, type ParticleOptions } from './ParticleField';
|
||||
61
src/lib/features/wind/store.ts
Normal file
61
src/lib/features/wind/store.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* Thin cache layer for wind field responses.
|
||||
*
|
||||
* Each unique set of request parameters is keyed by a stable JSON string so
|
||||
* that the same (time, altitude, bbox, step) combination is fetched only once
|
||||
* per session even if multiple effects request it concurrently. The cache is
|
||||
* intentionally never invalidated during a session — the predictor's dataset
|
||||
* does not change while the user is working.
|
||||
*/
|
||||
|
||||
import { windApi, type WindFieldParams } from '$api';
|
||||
import type { WindField } from '$domain';
|
||||
|
||||
function cacheKey(params: WindFieldParams): string {
|
||||
return JSON.stringify({
|
||||
altitude: params.altitude ?? null,
|
||||
step: params.step ?? null,
|
||||
time: params.time ?? null,
|
||||
min_lat: params.min_lat ?? null,
|
||||
max_lat: params.max_lat ?? null,
|
||||
min_lng: params.min_lng ?? null,
|
||||
max_lng: params.max_lng ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
class WindCache {
|
||||
private readonly hits = new Map<string, WindField>();
|
||||
private readonly pending = new Map<string, Promise<WindField>>();
|
||||
|
||||
fetch(params: WindFieldParams): Promise<WindField> {
|
||||
const key = cacheKey(params);
|
||||
|
||||
const hit = this.hits.get(key);
|
||||
if (hit) return Promise.resolve(hit);
|
||||
|
||||
const existing = this.pending.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const promise = windApi
|
||||
.field(params)
|
||||
.then((field) => {
|
||||
this.hits.set(key, field);
|
||||
this.pending.delete(key);
|
||||
return field;
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
this.pending.delete(key);
|
||||
throw err;
|
||||
});
|
||||
|
||||
this.pending.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.hits.clear();
|
||||
this.pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const windCache = new WindCache();
|
||||
|
|
@ -128,7 +128,19 @@
|
|||
"units": "Units",
|
||||
"metric": "Metric",
|
||||
"imperial": "Imperial",
|
||||
"saved": "Settings saved"
|
||||
"saved": "Settings saved",
|
||||
"wind": "Wind visualization",
|
||||
"windEnabled": "Show wind layer",
|
||||
"windStep": "Grid resolution (°)",
|
||||
"windTrajectoryStep": "Trajectory grid res. (°)",
|
||||
"windPrefetchInterval": "Pre-fetch interval (min)",
|
||||
"windMaxDuration": "Max sync duration (h)",
|
||||
"windMaxRegion": "Max region size (°)",
|
||||
"windMargin": "Trajectory margin (°)",
|
||||
"windParticleDensity": "Particle density",
|
||||
"windParticleSpeed": "Particle speed",
|
||||
"windTrailPersistence": "Trail length",
|
||||
"windMaxVelocity": "Max wind speed (m/s)"
|
||||
},
|
||||
"editor": {
|
||||
"add": "Add",
|
||||
|
|
@ -174,7 +186,16 @@
|
|||
"status_connected": "Connected",
|
||||
"status_error": "Error",
|
||||
"packetCount": "{count} packets received",
|
||||
"waitingData": "Waiting for data..."
|
||||
"waitingData": "Waiting for data...",
|
||||
"deviation": "Compare with forecast",
|
||||
"selectForecast": "Reference forecast",
|
||||
"noForecast": "— No forecast —",
|
||||
"noData": "No telemetry data",
|
||||
"altProfile": "Altitude profile",
|
||||
"selectPrediction": "Select a forecast to show deviations",
|
||||
"horizontalDev": "Horizontal deviation",
|
||||
"devMax": "Max:",
|
||||
"devCurrent": "Current:"
|
||||
},
|
||||
"forecast": {
|
||||
"success": "Forecast request",
|
||||
|
|
|
|||
|
|
@ -128,7 +128,19 @@
|
|||
"units": "Единицы измерения",
|
||||
"metric": "Метрические",
|
||||
"imperial": "Имперские",
|
||||
"saved": "Настройки сохранены"
|
||||
"saved": "Настройки сохранены",
|
||||
"wind": "Визуализация ветра",
|
||||
"windEnabled": "Показывать слой ветра",
|
||||
"windStep": "Шаг сетки (°)",
|
||||
"windTrajectoryStep": "Шаг сетки по траектории (°)",
|
||||
"windPrefetchInterval": "Интервал предзагрузки (мин)",
|
||||
"windMaxDuration": "Макс. длительность синхронизации (ч)",
|
||||
"windMaxRegion": "Макс. размер региона (°)",
|
||||
"windMargin": "Отступ вокруг траектории (°)",
|
||||
"windParticleDensity": "Плотность частиц",
|
||||
"windParticleSpeed": "Скорость частиц",
|
||||
"windTrailPersistence": "Длина следа",
|
||||
"windMaxVelocity": "Макс. скорость ветра (м/с)"
|
||||
},
|
||||
"editor": {
|
||||
"add": "Добавить",
|
||||
|
|
@ -174,7 +186,16 @@
|
|||
"status_connected": "Подключено",
|
||||
"status_error": "Ошибка",
|
||||
"packetCount": "Получено пакетов: {count}",
|
||||
"waitingData": "Ожидание данных..."
|
||||
"waitingData": "Ожидание данных...",
|
||||
"deviation": "Сравнение с прогнозом",
|
||||
"selectForecast": "Прогноз для сравнения",
|
||||
"noForecast": "— Без прогноза —",
|
||||
"noData": "Нет данных телеметрии",
|
||||
"altProfile": "Высотный профиль",
|
||||
"selectPrediction": "Выберите прогноз для отображения отклонений",
|
||||
"horizontalDev": "Горизонтальное отклонение",
|
||||
"devMax": "Макс.:",
|
||||
"devCurrent": "Текущее:"
|
||||
},
|
||||
"forecast": {
|
||||
"success": "Запрос прогноза",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
WorkspaceRenderer,
|
||||
workspacesStore,
|
||||
} from '$features/workspaces';
|
||||
import { WindRenderer } from '$features/wind';
|
||||
import { SettingsPanel } from '$features/settings';
|
||||
import { TimeLine } from '$features/timeline';
|
||||
import { t } from '$i18n';
|
||||
|
|
@ -72,6 +73,7 @@
|
|||
<div style="height: var(--navbar-height);"></div>
|
||||
<MapView bind:this={mapComponent} onReady={handleMapReady}>
|
||||
<WorkspaceRenderer />
|
||||
<WindRenderer />
|
||||
|
||||
<PanelContainer position="left">
|
||||
<TabBar
|
||||
|
|
|
|||
|
|
@ -1,11 +1,20 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { Map as MapView, plotAnimatedMarker, type IMap } from '$map';
|
||||
import { Map as MapView, plotAnimatedMarker, plotPrediction, type IMap } from '$map';
|
||||
import { Navbar } from '$features/auth';
|
||||
import { PanelContainer } from '$ui';
|
||||
import { TelemetryPanel, telemetryStore } from '$features/tracking';
|
||||
import { PanelContainer, CollapsibleCard } from '$ui';
|
||||
import { TelemetryPanel, DeviationChart, telemetryStore } from '$features/tracking';
|
||||
import { workspacesStore } from '$features/workspaces';
|
||||
import { t } from '$i18n';
|
||||
import { requireAuthenticated } from '$auth';
|
||||
import { parseTelemetry } from '$domain';
|
||||
import type { Prediction } from '$domain';
|
||||
|
||||
let selectedId = $state('');
|
||||
const workspacesWithResult = $derived($workspacesStore.items.filter((w) => w.result !== null));
|
||||
const selectedPrediction = $derived<Prediction | null>(
|
||||
workspacesWithResult.find((w) => w.id === selectedId)?.result ?? null,
|
||||
);
|
||||
|
||||
let map = $state<IMap | null>(null);
|
||||
// Tracks whether we've already fitted the map to the initial history load.
|
||||
|
|
@ -18,6 +27,17 @@
|
|||
|
||||
onDestroy(() => {
|
||||
map?.disposeScene('telemetry');
|
||||
map?.disposeScene('prediction');
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!map) return;
|
||||
const scene = map.scene('prediction');
|
||||
if (selectedPrediction) {
|
||||
plotPrediction(scene, selectedPrediction, { color: '#1565C0', opacity: 0.7 });
|
||||
} else {
|
||||
scene.clear();
|
||||
}
|
||||
});
|
||||
|
||||
function onMapReady(m: IMap) {
|
||||
|
|
@ -76,5 +96,24 @@
|
|||
<PanelContainer position="left">
|
||||
<TelemetryPanel />
|
||||
</PanelContainer>
|
||||
<PanelContainer position="right">
|
||||
<CollapsibleCard title={$t('tracking.deviation')}>
|
||||
<div class="mb-2">
|
||||
<label for="forecast-select" class="form-label small mb-1">{$t('tracking.selectForecast')}</label>
|
||||
<select
|
||||
id="forecast-select"
|
||||
class="form-select form-select-sm"
|
||||
bind:value={selectedId}
|
||||
disabled={workspacesWithResult.length === 0}
|
||||
>
|
||||
<option value="">{$t('tracking.noForecast')}</option>
|
||||
{#each workspacesWithResult as w (w.id)}
|
||||
<option value={w.id}>{w.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<DeviationChart points={telemetryStore.points} prediction={selectedPrediction} />
|
||||
</CollapsibleCard>
|
||||
</PanelContainer>
|
||||
</MapView>
|
||||
</main>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue