compare panel, docs update, wind visualisation

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

View file

@ -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';

View file

@ -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
View file

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

View file

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

View file

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

View file

@ -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
View file

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

View file

@ -1,5 +1,5 @@
export { settingsStore, DEFAULT_SETTINGS } from './store';
export 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';

View file

@ -83,4 +83,90 @@ export const SETTINGS_SCHEMA: SettingsSection[] = [
},
],
},
{
titleKey: 'settings.wind',
fields: [
{ kind: 'boolean', path: 'wind.enabled', labelKey: 'settings.windEnabled' },
{
kind: 'number',
path: 'wind.step',
labelKey: 'settings.windStep',
min: 0.25,
max: 10,
step: 0.25,
},
{
kind: 'number',
path: 'wind.trajectoryStep',
labelKey: 'settings.windTrajectoryStep',
min: 0.25,
max: 5,
step: 0.25,
},
{
kind: 'number',
path: 'wind.prefetchIntervalMinutes',
labelKey: 'settings.windPrefetchInterval',
min: 5,
max: 60,
step: 5,
},
{
kind: 'number',
path: 'wind.maxFlightDurationHours',
labelKey: 'settings.windMaxDuration',
min: 1,
max: 8,
step: 0.5,
},
{
kind: 'number',
path: 'wind.maxRegionDegrees',
labelKey: 'settings.windMaxRegion',
min: 5,
max: 60,
step: 5,
},
{
kind: 'number',
path: 'wind.trajectoryMarginDegrees',
labelKey: 'settings.windMargin',
min: 0.5,
max: 5,
step: 0.5,
},
{
kind: 'number',
path: 'wind.particleDensity',
labelKey: 'settings.windParticleDensity',
min: 0.25,
max: 3,
step: 0.25,
},
{
kind: 'number',
path: 'wind.particleSpeed',
labelKey: 'settings.windParticleSpeed',
min: 0.25,
max: 4,
step: 0.25,
},
{
kind: 'number',
path: 'wind.trailPersistence',
labelKey: 'settings.windTrailPersistence',
min: 0.7,
max: 0.98,
step: 0.02,
},
{
kind: 'number',
path: 'wind.maxVelocity',
labelKey: 'settings.windMaxVelocity',
min: 10,
max: 80,
step: 5,
},
],
},
];

View file

@ -1,5 +1,8 @@
import { persisted } from '$state';
import 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);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -128,7 +128,19 @@
"units": "Units",
"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",

View file

@ -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": "Запрос прогноза",

View file

@ -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

View file

@ -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>