feat(globe): port to CeliumJS

This commit is contained in:
gili8420 2026-08-03 22:12:32 +09:00
parent ec03425067
commit eb7698e034
51 changed files with 3521 additions and 1992 deletions

View file

@ -9,7 +9,7 @@
* Global application styles.
*
* Keep this file focused on cross-feature concerns: the navbar chrome, the
* panel-container geometry, and overrides for third-party libs (MapLibre,
* panel-container geometry, and overrides for third-party libs (Cesium,
* Bootstrap). Feature-specific styles live in the relevant Svelte component.
*/
@ -93,27 +93,6 @@ body {
right: var(--panel-left);
}
.maplibregl-ctrl-group {
border: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important;
border-radius: var(--bs-border-radius) !important;
}
.maplibregl-popup-tip {
border-top-color: var(--bs-border-color) !important;
}
.maplibregl-popup-content {
background-color: var(--bs-body-bg) !important;
border: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important;
border-radius: var(--bs-border-radius) !important;
color: var(--bs-body-color);
box-shadow: none !important;
}
.maplibregl-popup-close-button {
color: var(--bs-body-color);
}
.modal-backdrop {
opacity: var(--bs-backdrop-opacity) !important;
}

View file

@ -4,6 +4,11 @@
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Cesium resolves Workers/Assets/Widgets at runtime from this base.
Populated by scripts/copy-cesium.js; must be set before Cesium loads. -->
<script>
window.CESIUM_BASE_URL = '/cesium/';
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

View file

@ -4,4 +4,3 @@ 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

@ -1,58 +0,0 @@
/**
* 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

@ -1,26 +1,134 @@
import type { LatLngTuple } from './geo';
/**
* Axis-aligned geographic bounding box around a flight path, with an optional
* margin so recovery teams get a box that clears the trajectory by a set
* distance rather than hugging its extreme points.
* The restricted area filed for a flight: a rectangle in kilometres, axis-aligned
* to east/north at its own centre, with four lat/lon corners joined by great
* circles.
*
* Deliberately not a rectangle in degrees. That form cannot be made to work near
* a pole: every meridian passes through the pole, so any lat/lon rectangle that
* contains one spans all 360 degrees of longitude. On a real launch from 89.99 N
* it produced the entire cap north of 88.93 44 200 km^2 in place of the
* 13.7 x 123.4 km corridor the balloon actually flies. There is no latitude
* threshold below which the degree form is safe: it either contains the pole and
* balloons, or it fails to contain the trajectory.
*
* Working in kilometres removes the whole class of problem rather than the
* instance. Nothing here divides by cos(latitude), clamps to a pole, or branches
* on how far north it is.
*/
export interface BoundingBox {
south: number;
west: number;
north: number;
east: number;
/**
* The filed corners, joined by great circles, in the order the operator reads
* them: NW, NE, SE, SW of the centre's own east/north frame.
*/
corners: [LatLngTuple, LatLngTuple, LatLngTuple, LatLngTuple];
centre: LatLngTuple;
/** East-west size in km, including the margin on both sides. */
widthKm: number;
/** North-south size in km, including the margin on both sides. */
heightKm: number;
}
/** Default clearance (km) between the box edge and the nearest trajectory point. */
export const DEFAULT_BBOX_MARGIN_KM = 5;
// Mean length of one degree of latitude. Longitude degrees shrink toward the
// poles, handled below via cos(latitude).
const KM_PER_DEG_LAT = 111.32;
const R_KM = 6371;
const D2R = Math.PI / 180;
type Vec = [number, number, number];
const dot = (a: Vec, b: Vec) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const clamp1 = (v: number) => (v > 1 ? 1 : v < -1 ? -1 : v);
function toVec([lat, lng]: LatLngTuple): Vec {
const p = lat * D2R;
const l = lng * D2R;
return [Math.cos(p) * Math.cos(l), Math.cos(p) * Math.sin(l), Math.sin(p)];
}
function toLatLng(v: Vec): LatLngTuple {
return [Math.asin(clamp1(v[2])) / D2R, Math.atan2(v[1], v[0]) / D2R];
}
/**
* Compute the bounding box of a flight path, expanded outward by `marginKm`.
* A local frame: the point itself plus earth-centred east and north unit vectors.
*
* All three are unit length and mutually orthogonal at every latitude, the poles
* included at 90 N, east is (-sin l, cos l, 0) and north is (-cos l, -sin l, 0),
* both still unit. This is the same construction the predictor's integrator uses
* (internal/numerics/spherical.go) and for the same reason: it is where the
* cos(latitude) singularity would otherwise live.
*/
function frame(origin: Vec) {
const [lat, lng] = toLatLng(origin);
const p = lat * D2R;
const l = lng * D2R;
return {
origin,
east: [-Math.sin(l), Math.cos(l), 0] as Vec,
north: [-Math.sin(p) * Math.cos(l), -Math.sin(p) * Math.sin(l), Math.cos(p)] as Vec,
};
}
type Frame = ReturnType<typeof frame>;
/**
* Azimuthal equidistant offsets of `p` from the frame origin, in km east and
* north. Distance from the origin is exact at any range; only the shape of
* something far from the origin is distorted, and a flight is never far.
*/
function project(f: Frame, p: Vec): [number, number] {
const e = dot(p, f.east);
const n = dot(p, f.north);
const t = Math.hypot(e, n);
if (t === 0) return [0, 0]; // p is the origin itself, or its antipode
const r = R_KM * Math.acos(clamp1(dot(p, f.origin)));
return [(r * e) / t, (r * n) / t];
}
/** Inverse of project: walk `x` km east and `y` km north of the origin. */
function unproject(f: Frame, x: number, y: number): Vec {
const r = Math.hypot(x, y);
if (r === 0) return f.origin;
const a = r / R_KM;
const c = Math.cos(a);
const s = Math.sin(a);
return [0, 1, 2].map(
(i) => f.origin[i] * c + ((x / r) * f.east[i] + (y / r) * f.north[i]) * s,
) as Vec;
}
/** Centre and half-sizes, in km, of the smallest axis-aligned box in this frame. */
function extent(f: Frame, points: Vec[]) {
let xMin = Infinity;
let xMax = -Infinity;
let yMin = Infinity;
let yMax = -Infinity;
for (const p of points) {
const [x, y] = project(f, p);
if (x < xMin) xMin = x;
if (x > xMax) xMax = x;
if (y < yMin) yMin = y;
if (y > yMax) yMax = y;
}
return {
cx: (xMin + xMax) / 2,
cy: (yMin + yMax) / 2,
hx: (xMax - xMin) / 2,
hy: (yMax - yMin) / 2,
};
}
/** Mean direction of the points. Falls back to the first point if they cancel. */
function centroid(points: Vec[]): Vec {
const sum = points.reduce<Vec>((a, p) => [a[0] + p[0], a[1] + p[1], a[2] + p[2]], [0, 0, 0]);
const len = Math.hypot(...sum);
return len < 1e-9 ? points[0] : (sum.map((c) => c / len) as Vec);
}
/**
* Compute the restricted area around a flight path, clearing it by `marginKm`.
* Returns null for an empty path (callers treat that as "nothing to draw").
*/
export function computeBoundingBox(
@ -29,52 +137,71 @@ export function computeBoundingBox(
): BoundingBox | null {
if (path.length === 0) return null;
let south = Infinity;
let north = -Infinity;
let west = Infinity;
let east = -Infinity;
for (const [lat, lng] of path) {
if (lat < south) south = lat;
if (lat > north) north = lat;
if (lng < west) west = lng;
if (lng > east) east = lng;
}
const points = path.map(toVec);
const margin = Math.max(0, marginKm);
const dLat = margin / KM_PER_DEG_LAT;
// ponytail: flat-earth degree conversion — fine at flight scales (<1000 km).
// Size the longitude margin at the latitude nearest a pole so the box never
// comes in tighter than requested along the whole band.
const maxAbsLat = Math.max(Math.abs(south), Math.abs(north));
const kmPerDegLng = KM_PER_DEG_LAT * Math.cos((maxAbsLat * Math.PI) / 180);
const dLng = kmPerDegLng > 0 ? margin / kmPerDegLng : 0;
// Two passes. The first, framed on the track's mean direction, only locates
// the box centre; the second frames on that centre, which makes the four
// corners symmetric about it and keeps the projection error smallest where
// the corners actually are.
const first = frame(centroid(points));
const rough = extent(first, points);
const f = frame(unproject(first, rough.cx, rough.cy));
const { cx, cy, hx, hy } = extent(f, points);
const ex = hx + margin;
const ey = hy + margin;
// A great-circle edge bows away from the frame origin relative to its chord
// in this projection, so the filed quad contains the box measured here. The
// margin is a floor, never eaten.
const corner = (sx: number, sy: number) => toLatLng(unproject(f, cx + sx * ex, cy + sy * ey));
return {
south: south - dLat,
north: north + dLat,
west: west - dLng,
east: east + dLng,
corners: [corner(-1, 1), corner(1, 1), corner(1, -1), corner(-1, -1)],
centre: toLatLng(unproject(f, cx, cy)),
widthKm: 2 * ex,
heightKm: 2 * ey,
};
}
/** Closed ring (corners + repeated start) for drawing the box as a polyline. */
/** Samples per edge when drawing. Keeps segments short enough for any renderer. */
const RING_STEPS_PER_EDGE = 16;
/** Point a fraction `t` along the great circle from `a` to `b`. */
function slerp(a: Vec, b: Vec, t: number): Vec {
const w = Math.acos(clamp1(dot(a, b)));
const s = Math.sin(w);
if (s < 1e-12) return a;
const wa = Math.sin((1 - t) * w) / s;
const wb = Math.sin(t * w) / s;
return [0, 1, 2].map((i) => a[i] * wa + b[i] * wb) as Vec;
}
/**
* Closed ring for drawing the box, sampled along each great-circle edge.
*
* Corners alone are not enough. The drawn shape would then depend on the
* renderer's interpolation mode rather than on the filed geometry, and a long
* single segment landing on the antimeridian is what previously stopped Cesium's
* render loop: its splitLongitude pass emitted mismatched attribute lists and
* threw "All attribute lists must have the same number of attributes". Short
* explicit samples have neither problem.
*/
export function boundingBoxRing(box: BoundingBox): LatLngTuple[] {
return [
[box.south, box.west],
[box.north, box.west],
[box.north, box.east],
[box.south, box.east],
[box.south, box.west],
];
const v = box.corners.map(toVec);
const ring: LatLngTuple[] = [];
for (let e = 0; e < 4; e++) {
const from = v[e];
const to = v[(e + 1) % 4];
for (let i = 0; i < RING_STEPS_PER_EDGE; i++) {
ring.push(toLatLng(slerp(from, to, i / RING_STEPS_PER_EDGE)));
}
}
ring.push(ring[0]);
return ring;
}
/** Corner coordinates as copyable "lat, lng" lines (NW, NE, SE, SW). */
export function formatBoundingBox(box: BoundingBox): string {
const fmt = (lat: number, lng: number) => `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
return [
fmt(box.north, box.west),
fmt(box.north, box.east),
fmt(box.south, box.east),
fmt(box.south, box.west),
].join('\n');
return box.corners.map(([lat, lng]) => `${lat.toFixed(6)}, ${lng.toFixed(6)}`).join('\n');
}

148
src/lib/domain/export.ts Normal file
View file

@ -0,0 +1,148 @@
import type { Prediction } from './prediction';
/**
* Serialize a prediction for download.
*
* Pure string builders no DOM, no fetch so they stay testable and the
* component only has to hand the result to a Blob.
*
* Note the coordinate order differs by format, which is the usual source of
* silently mirrored tracks: CSV/JSON are latitude-first (matching the API and
* the rest of this codebase), KML is longitude-first (per the OGC spec).
*/
export type ExportFormat = 'JSON' | 'CSV' | 'KML';
export const EXPORT_FORMATS: ExportFormat[] = ['JSON', 'CSV', 'KML'];
const MIME: Record<ExportFormat, string> = {
JSON: 'application/json',
CSV: 'text/csv',
KML: 'application/vnd.google-earth.kml+xml',
};
const EXT: Record<ExportFormat, string> = { JSON: 'json', CSV: 'csv', KML: 'kml' };
interface Row {
datetime: string;
latitude: number;
longitude: number;
altitude: number;
}
/** Flatten flight_path + its parallel timestamps into plain rows. */
function rows(p: Prediction): Row[] {
return p.flight_path.map((c, i) => ({
datetime: new Date(p.timestamps[i]).toISOString(),
latitude: c[0],
longitude: c[1],
altitude: c.length === 3 ? c[2] : 0,
}));
}
function pointOut(pt: Prediction['launch']) {
return {
latitude: pt.latlng.lat,
longitude: pt.latlng.lng,
altitude: pt.latlng.alt ?? 0,
datetime: pt.datetime.toISOString(),
};
}
export function predictionToJson(p: Prediction): string {
return JSON.stringify(
{
profile: p.profile,
flight_time: p.flight_time,
launch: pointOut(p.launch),
burst: pointOut(p.burst),
landing: pointOut(p.landing),
trajectory: rows(p),
},
null,
2,
);
}
export function predictionToCsv(p: Prediction): string {
const head = 'datetime,latitude,longitude,altitude';
const body = rows(p).map(
(r) => `${r.datetime},${r.latitude},${r.longitude},${r.altitude}`,
);
return [head, ...body].join('\n') + '\n';
}
/** Minimal XML text escaping for the few interpolated strings. */
function xml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export function predictionToKml(p: Prediction): string {
// lon,lat,alt — KML's order, the reverse of everywhere else here.
const track = rows(p)
.map((r) => `${r.longitude},${r.latitude},${r.altitude}`)
.join('\n\t\t\t\t');
const mark = (name: string, pt: Prediction['launch']) => `
<Placemark>
<name>${name}</name>
<description>${xml(pt.datetime.toISOString())} ${pt.latlng.alt ?? 0} m</description>
<Point>
<altitudeMode>absolute</altitudeMode>
<coordinates>${pt.latlng.lng},${pt.latlng.lat},${pt.latlng.alt ?? 0}</coordinates>
</Point>
</Placemark>`;
// The track Placemark comes first so the flight line is the document's
// primary feature rather than one of the event markers.
return `<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
<name>${xml(`Flight ${p.launch.datetime.toISOString()}`)}</name>
<Style id="track">
<LineStyle><color>ff0000ff</color><width>2</width></LineStyle>
</Style>
<Placemark>
<name>Flight path</name>
<styleUrl>#track</styleUrl>
<LineString>
<!-- absolute: a balloon track is not a ground feature; without this
Google Earth drapes the 30 km arc onto the terrain. -->
<altitudeMode>absolute</altitudeMode>
<coordinates>
${track}
</coordinates>
</LineString>
</Placemark>${mark('Launch', p.launch)}${mark('Burst', p.burst)}${mark('Landing', p.landing)}
</Document>
</kml>
`;
}
export function serializePrediction(p: Prediction, format: ExportFormat): string {
switch (format) {
case 'CSV':
return predictionToCsv(p);
case 'KML':
return predictionToKml(p);
default:
return predictionToJson(p);
}
}
export function exportMimeType(format: ExportFormat): string {
return MIME[format];
}
/** e.g. `prediction-2026-08-03T1200Z-89.0N-68.0E.csv` */
export function exportFilename(p: Prediction, format: ExportFormat): string {
const t = p.launch.datetime.toISOString().slice(0, 16).replace(/[:-]/g, '').replace('T', 'T');
const { lat, lng } = p.launch.latlng;
const ns = `${Math.abs(lat).toFixed(1)}${lat >= 0 ? 'N' : 'S'}`;
const ew = `${Math.abs(lng).toFixed(1)}${lng >= 0 ? 'E' : 'W'}`;
return `prediction-${t}Z-${ns}-${ew}.${EXT[format]}`;
}

View file

@ -1,8 +1,9 @@
/**
* Geographic primitives used by map layers and predictions.
*
* LngLat convention matches MapLibre (longitude first) for on-map work;
* LatLng is preserved for API payloads and legacy Leaflet-era code paths.
* LngLat convention is longitude-first for on-map work, matching Cesium's
* Cartesian3.fromDegrees(lng, lat); LatLng is preserved for API payloads and
* legacy Leaflet-era code paths.
*/
export interface LatLng {
@ -11,6 +12,45 @@ export interface LatLng {
alt?: number;
}
/**
* The pole. This is geographic reality, not a workaround.
*
* An earlier revision capped launches at 89.999° because the predictor's
* longitude rate went as 1/cos(lat) against a fixed step and became
* unreproducible near the pole. That formulation is gone it now integrates
* along great circles and handles 90° exactly so the cap is the real limit.
*/
export const MAX_LAUNCH_LATITUDE = 90;
/**
* Constrain a latitude to ±90. NaN passes through so a half-typed input does
* not jump under the user's cursor.
*
* Latitude clamps rather than wraps: a pole is a barrier, and 91°N is not a
* place. Longitude is the opposite case see wrapLongitude.
*/
export function clampLaunchLatitude(lat: number): number {
if (!Number.isFinite(lat)) return lat;
return Math.min(MAX_LAUNCH_LATITUDE, Math.max(-MAX_LAUNCH_LATITUDE, lat));
}
/**
* Wrap a longitude into [-180, 180).
*
* Wraps rather than clamps because a meridian is not a barrier: 200°E is the
* same place as -160°, so wrapping keeps the point the user meant, while
* clamping to 180 would silently move it 20° away.
*
* Distinct from normalizeLng, which only folds the 0..360 convention the API
* sometimes returns and is not a general wrap.
*/
export function wrapLongitude(lng: number): number {
if (!Number.isFinite(lng)) return lng;
const wrapped = ((lng + 180) % 360 + 360) % 360 - 180;
// -180 and 180 are the same meridian; pick the lower bound consistently.
return wrapped === -0 ? 0 : wrapped;
}
export type LatLngTuple = [lat: number, lng: number] | [lat: number, lng: number, alt: number];
export type LatLngExpression = LatLng | LatLngTuple;

View file

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

View file

@ -1,214 +0,0 @@
/**
* 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

@ -0,0 +1,77 @@
<script lang="ts">
/**
* Map chrome driven by user settings: the lat/lon graticule and the base
* imagery layer.
*
* Lives in features/ rather than map/ because `map/` must not depend on
* settings (see docs/ARCHITECTURE.md). Place it as a child of <Map />, the
* same way WorkspaceRenderer is.
*/
import { onDestroy } from 'svelte';
import { getMap } from '$map';
import type { LatLngTuple } from '$domain';
import { settingsStore } from '$features/settings';
const map = getMap();
if (!map) throw new Error('MapChrome must be a descendant of <Map />');
const SCENE = 'graticule';
// Deliberately sparse: few enough lines to stay readable at any zoom.
const MERIDIAN_STEP_DEG = 30;
const PARALLEL_STEP_DEG = 15;
/**
* Meridians run the full pole to pole, so they all meet at a single point at
* each end. Their ground spacing does shrink toward the pole, but what you
* see depends on zoom — closing in on the pole spreads them back across the
* screen, so convergence reads as a clean star rather than a smear.
*/
const MERIDIAN_LIMIT_LAT = 90;
/** Parallels stop short: nearer the pole they shrink to invisible circles. */
const PARALLEL_LIMIT_LAT = 75;
/** Vertex spacing along each line, in degrees. */
const SAMPLE_DEG = 5;
// Mid-tone so it stays legible on both the light OSM map and dark imagery.
const COLOR = '#8a97a5';
const OPACITY = 0.35;
const WIDTH = 1;
function drawGraticule(): void {
const scene = map!.scene(SCENE);
scene.clear();
for (let lng = -180; lng < 180; lng += MERIDIAN_STEP_DEG) {
const coords: LatLngTuple[] = [];
for (let lat = -MERIDIAN_LIMIT_LAT; lat <= MERIDIAN_LIMIT_LAT; lat += SAMPLE_DEG) {
coords.push([lat, lng]);
}
scene.addLine(`m${lng}`, { coords, color: COLOR, width: WIDTH, opacity: OPACITY });
}
for (let lat = -PARALLEL_LIMIT_LAT; lat <= PARALLEL_LIMIT_LAT; lat += PARALLEL_STEP_DEG) {
const coords: LatLngTuple[] = [];
// A parallel is not a geodesic, so it needs dense sampling: two
// endpoints alone would be joined by a great-circle arc bowing poleward.
for (let lng = -180; lng <= 180; lng += SAMPLE_DEG) {
coords.push([lat, lng]);
}
scene.addLine(`p${lat}`, { coords, color: COLOR, width: WIDTH, opacity: OPACITY });
}
}
$effect(() => {
map!.setBaseLayer($settingsStore.map.baseLayer);
});
$effect(() => {
// `persisted` does not merge defaults, so settings stored before the
// graticule existed have no key at all — treat that as on.
if ($settingsStore.map.graticule ?? true) {
drawGraticule();
} else {
map!.disposeScene(SCENE);
}
});
onDestroy(() => map?.disposeScene(SCENE));
</script>

View file

@ -0,0 +1 @@
export { default as MapChrome } from './MapChrome.svelte';

View file

@ -27,7 +27,10 @@
import { pointsApi } from '$api';
import {
DEFAULT_FLIGHT_PARAMETERS,
MAX_LAUNCH_LATITUDE,
PROFILE_IDENTIFIERS,
clampLaunchLatitude,
wrapLongitude,
toFixedNumber,
type FlightParameters,
type ProfileIdentifier,
@ -75,7 +78,17 @@
function patchActive(patch: Partial<FlightParameters>) {
if (!active) return;
workspacesStore.setFlightParameters(active.id, { ...active.flightParameters, ...patch });
// Single choke point for launch latitude: typing it, clicking the map
// (updateLaunchPosition) and picking a saved point all land here, so one
// clamp covers every path.
const safe = { ...patch };
if (safe.launch_latitude !== undefined) {
safe.launch_latitude = clampLaunchLatitude(safe.launch_latitude);
}
if (safe.launch_longitude !== undefined) {
safe.launch_longitude = wrapLongitude(safe.launch_longitude);
}
workspacesStore.setFlightParameters(active.id, { ...active.flightParameters, ...safe });
}
function handlePointSelection(newPointId: number | null) {
@ -225,6 +238,8 @@
<Input
type="number"
step="0.000001"
min={-MAX_LAUNCH_LATITUDE}
max={MAX_LAUNCH_LATITUDE}
value={params.launch_latitude}
oninput={(e) =>
patchActive({

View file

@ -10,7 +10,15 @@
} from '@sveltestrap/sveltestrap';
import { CollapsibleCard, SelectSearchable, addToast } from '$ui';
import { scenariosApi } from '$api';
import { PREDICTION_MODES, type SavedScenario } from '$domain';
import {
EXPORT_FORMATS,
PREDICTION_MODES,
exportFilename,
exportMimeType,
serializePrediction,
type ExportFormat,
type SavedScenario,
} from '$domain';
import { workspacesStore, getActiveWorkspace } from '$features/workspaces';
import { t } from '$i18n';
import { scenariosStore } from './pointsStore';
@ -18,8 +26,32 @@
let selectedScenarioId = $state<number>(-1);
let editorRef: ScenarioEditor | null = $state(null);
let exportFormat = $state<ExportFormat>('JSON');
let active = $derived(getActiveWorkspace($workspacesStore));
function handleExport() {
const result = active?.result;
if (!result) {
addToast({
header: $t('scenario.export'),
body: $t('scenario.exportNoResult'),
color: 'warning',
});
return;
}
const text = serializePrediction(result, exportFormat);
const url = URL.createObjectURL(
new Blob([text], { type: exportMimeType(exportFormat) }),
);
const a = document.createElement('a');
a.href = url;
a.download = exportFilename(result, exportFormat);
a.click();
// Revoking immediately can abort the download in some browsers; let the
// navigation start first.
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
let scenarioUnsaved = $derived.by(() => {
if (!active) return false;
const saved = $scenariosStore.find((s) => s.id === selectedScenarioId);
@ -172,12 +204,12 @@
<FormGroup spacing="mb-0">
<Label class="form-label">{$t('scenario.export')}</Label>
<InputGroup size="sm">
<Input type="select" class="form-control-sm">
<option>JSON</option>
<option>CSV</option>
<option>KML</option>
<Input type="select" class="form-control-sm" bind:value={exportFormat}>
{#each EXPORT_FORMATS as f (f)}
<option value={f}>{f}</option>
{/each}
</Input>
<Button color="primary">
<Button color="primary" disabled={!active?.result} onclick={handleExport}>
<span>{$t('scenario.exportBtn')}</span>
<Icon name="file-earmark-arrow-down" />
</Button>

View file

@ -1,5 +1,5 @@
export { settingsStore, DEFAULT_SETTINGS } from './store';
export type { AppSettings, MapSettings, UnitsSettings, WindSettings } from './store';
export type { AppSettings, MapSettings, UnitsSettings } from './store';
export { default as SettingsPanel } from './SettingsPanel.svelte';
export { SETTINGS_SCHEMA } from './schema';
export type { SettingsField, SettingsSection } from './schema';

View file

@ -61,10 +61,12 @@ export const SETTINGS_SCHEMA: SettingsSection[] = [
path: 'map.baseLayer',
labelKey: 'settings.baseLayer',
options: [
{ value: 'osm', labelKey: 'settings.baseLayer' },
{ value: 'satellite', labelKey: 'settings.baseLayer' },
{ value: 'osm', labelKey: 'settings.baseLayerOsm' },
{ value: 'satellite', labelKey: 'settings.baseLayerSatellite' },
{ value: 'polar', labelKey: 'settings.baseLayerPolar' },
],
},
{ kind: 'boolean', path: 'map.graticule', labelKey: 'settings.graticule' },
{ kind: 'boolean', path: 'map.showScale', labelKey: 'settings.showScale' },
{ kind: 'boolean', path: 'map.showNavigation', labelKey: 'settings.showNavigation' },
],
@ -83,90 +85,4 @@ 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,13 +1,17 @@
import { persisted } from '$state';
import type { Locale } from '$i18n';
import { type WindSettings, DEFAULT_WIND_SETTINGS } from '$domain';
export type { WindSettings };
import type { BaseLayerId } from '$map';
export interface MapSettings {
baseLayer: 'osm' | 'satellite';
baseLayer: BaseLayerId;
showScale: boolean;
showNavigation: boolean;
/**
* Optional because `persisted` does not merge defaults into an existing
* stored payload settings saved before the graticule existed simply lack
* the key, so read sites default it to on.
*/
graticule?: boolean;
}
export interface UnitsSettings {
@ -18,14 +22,12 @@ 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 },
map: { baseLayer: 'osm', showScale: true, showNavigation: true, graticule: true },
units: { system: 'metric' },
wind: { ...DEFAULT_WIND_SETTINGS },
};
export const settingsStore = persisted<AppSettings>('settings', DEFAULT_SETTINGS);

View file

@ -1,335 +0,0 @@
/**
* 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

@ -1,332 +0,0 @@
<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

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

View file

@ -1,61 +0,0 @@
/**
* 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

@ -73,6 +73,15 @@
if (!cached || cached.result !== w.result || cached.color !== w.color || cached.opacity !== w.opacity) {
const scene = map.scene(name);
plotPrediction(scene, w.result, { color: w.color, opacity: w.opacity });
// Frame a freshly-arrived result. A flight spans only tens of km,
// which is a few dozen pixels at the default camera height — the
// track ends up completely hidden under its own launch/burst/
// landing markers and reads as a single dot. Deliberately not done
// for colour/opacity edits, so tweaking one workspace while
// comparing several does not yank the camera around.
if (!cached || cached.result !== w.result) {
map.fitBounds(w.result.flight_path, 50);
}
ownedPlotScenes.add(name);
plotCache.set(name, { result: w.result, color: w.color, opacity: w.opacity });
}

View file

@ -199,6 +199,15 @@
</Button>
{#if w.bboxVisible && box}
{@const coords = formatBoundingBox(box)}
<!-- Size and centre, because near a pole the corners alone are
unreadable: a box whose north edge passes over the pole comes
back down the far side, so its two north corners land ~180
degrees away in longitude. -->
<div class="small text-muted mt-2 font-monospace">
{box.widthKm.toFixed(1)} × {box.heightKm.toFixed(1)} km @ {box.centre[0].toFixed(
4,
)}, {box.centre[1].toFixed(4)}
</div>
<textarea
class="form-control form-control-sm mt-2 font-monospace"
style="resize: none;"

View file

@ -56,7 +56,8 @@
"datasetAuto": "Pick automatically",
"modified": "modified",
"export": "Export result",
"exportBtn": "Export"
"exportBtn": "Export",
"exportNoResult": "Run a prediction first — there is nothing to export."
},
"predictionMode": {
"single": "Single",
@ -138,18 +139,10 @@
"metric": "Metric",
"imperial": "Imperial",
"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)"
"baseLayerOsm": "Map (OpenStreetMap)",
"baseLayerSatellite": "Satellite (Esri)",
"graticule": "Lat/lon grid",
"baseLayerPolar": "Polar map (offline, covers the poles)"
},
"editor": {
"add": "Add",

View file

@ -56,7 +56,8 @@
"datasetAuto": "Выбрать автоматически",
"modified": "изменено",
"export": "Экспортировать результат",
"exportBtn": "Экспорт"
"exportBtn": "Экспорт",
"exportNoResult": "Сначала выполните расчёт — экспортировать нечего."
},
"predictionMode": {
"single": "Разовый",
@ -138,18 +139,10 @@
"metric": "Метрические",
"imperial": "Имперские",
"saved": "Настройки сохранены",
"wind": "Визуализация ветра",
"windEnabled": "Показывать слой ветра",
"windStep": "Шаг сетки (°)",
"windTrajectoryStep": "Шаг сетки по траектории (°)",
"windPrefetchInterval": "Интервал предзагрузки (мин)",
"windMaxDuration": "Макс. длительность синхронизации (ч)",
"windMaxRegion": "Макс. размер региона (°)",
"windMargin": "Отступ вокруг траектории (°)",
"windParticleDensity": "Плотность частиц",
"windParticleSpeed": "Скорость частиц",
"windTrailPersistence": "Длина следа",
"windMaxVelocity": "Макс. скорость ветра (м/с)"
"baseLayerOsm": "Карта (OpenStreetMap)",
"baseLayerSatellite": "Спутник (Esri)",
"graticule": "Сетка координат",
"baseLayerPolar": "Полярная карта (офлайн, до полюсов)"
},
"editor": {
"add": "Добавить",

View file

@ -1,14 +1,14 @@
<script lang="ts">
import { onMount, onDestroy, type Snippet } from 'svelte';
import type { IMap } from './core';
import type { BaseLayerId, IMap } from './core';
import type { LngLatTuple } from '$domain';
import { createMapLibreMap } from './maplibre';
import { createCesiumMap } from './cesium';
import { setMapContext } from './context';
interface Props {
center?: LngLatTuple;
zoom?: number;
baseLayer?: 'osm' | 'satellite';
baseLayer?: BaseLayerId;
showNavigationControl?: boolean;
showScaleControl?: boolean;
children?: Snippet;
@ -28,9 +28,9 @@
let container: HTMLDivElement;
let map: IMap | null = $state(null);
/**
* Children must not render until the map's first `load` event. MapLibre
* throws if addSource/addLayer is called on an unloaded style, and this
* component is the natural gate for that invariant.
* Children must not render until `map.ready` resolves. Cesium's Viewer is
* usable synchronously, but features still expect this gate, so it stays as
* the single place that decides when the map may be drawn on.
*/
let ready = $state(false);
@ -41,7 +41,7 @@
}
onMount(() => {
map = createMapLibreMap({
map = createCesiumMap({
container,
center,
zoom,

166
src/lib/map/cesium-scene.ts Normal file
View file

@ -0,0 +1,166 @@
import {
Cartesian3,
Color,
ConstantPositionProperty,
Entity,
HorizontalOrigin,
PolylineDashMaterialProperty,
VerticalOrigin,
type Viewer,
} from 'cesium';
import type {
CircleOptions,
LineOptions,
MapLayer,
Marker,
MarkerOptions,
Scene,
} from './core';
/**
* Cesium implementation of the Scene contract.
*
* Every position here goes through `Cartesian3.fromDegrees(lng, lat)`, which
* converts geographic degrees straight to earth-centred coordinates. Nothing
* passes through a Mercator tiler, so latitudes above 85.051129° where the
* MapLibre implementation silently clamped every vertex render correctly.
* That is the whole reason this file exists.
*/
/** '#rrggbb' + opacity -> Cesium Color. Falls back to black on an unparseable string. */
function toColor(css: string | undefined, opacity = 1): Color {
const parsed = Color.fromCssColorString(css ?? '#000');
// fromCssColorString returns undefined for garbage rather than throwing.
return (parsed ?? Color.BLACK).withAlpha(opacity);
}
export class CesiumScene implements Scene {
private entities = new Map<string, Entity>();
constructor(
public readonly name: string,
private viewer: Viewer,
) {}
private scopeId(id: string): string {
return `${this.name}__${id}`;
}
private add(id: string, entity: Entity): Entity {
this.remove(id);
const added = this.viewer.entities.add(entity);
this.entities.set(id, added);
return added;
}
addLine(id: string, options: LineOptions): MapLayer {
// LatLngTuple is [lat, lng] or [lat, lng, alt]; Cesium wants lng first.
// Swapping these is the classic bug here.
// `c.length === 3` is what narrows the LatLngTuple union for TypeScript;
// a `> 2` comparison does not.
const heights: number[] = options.coords.map((c) => (c.length === 3 ? c[2] : 0));
// Only lift the line into 3D when it actually has altitude. A track whose
// every vertex sits at ground level (a bounding-box ring, or telemetry
// before launch) must stay draped: at height 0 a polyline is coplanar
// with the ellipsoid, z-fights it, and disappears entirely.
const use3d = Math.max(...heights) > 1;
const positions = use3d
? Cartesian3.fromDegreesArrayHeights(
options.coords.flatMap((c, i) => [c[1], c[0], heights[i]]),
)
: Cartesian3.fromDegreesArray(options.coords.flatMap((c) => [c[1], c[0]]));
const color = toColor(options.color, options.opacity ?? 1);
this.add(
id,
new Entity({
id: this.scopeId(id),
polyline: {
positions,
width: options.width ?? 3,
material: options.dashArray
? new PolylineDashMaterialProperty({
color,
dashLength: options.dashArray[0] + options.dashArray[1],
})
: color,
// arcType is left at its default, GEODESIC.
// Draped only for ground-level geometry (see use3d above); a
// real flight is drawn at its own altitude.
clampToGround: !use3d,
},
}),
);
return { id, remove: () => this.remove(id) };
}
addCircle(id: string, options: CircleOptions): MapLayer {
// radiusPx is screen-space, matching the MapLibre circle-layer semantics
// the callers were written against; PointGraphics.pixelSize is the direct
// equivalent (diameter, hence the doubling).
this.add(
id,
new Entity({
id: this.scopeId(id),
position: Cartesian3.fromDegrees(options.center[0], options.center[1]),
point: {
pixelSize: (options.radiusPx ?? 5) * 2,
color: toColor(options.color, options.opacity ?? 1),
outlineColor: toColor(options.strokeColor, 1),
outlineWidth: options.strokeWidth ?? 0,
},
}),
);
return { id, remove: () => this.remove(id) };
}
addMarker(id: string, options: MarkerOptions): Marker {
const alt = options.altitude ?? 0;
const entity = this.add(
id,
new Entity({
id: this.scopeId(id),
position: Cartesian3.fromDegrees(options.lngLat[0], options.lngLat[1], alt),
...(options.iconUrl
? {
billboard: {
image: options.iconUrl,
width: options.iconSize?.[0],
height: options.iconSize?.[1],
horizontalOrigin: HorizontalOrigin.CENTER,
verticalOrigin: VerticalOrigin.BOTTOM,
},
}
: { point: { pixelSize: 10, color: Color.CRIMSON } }),
// Shown by Cesium's own selection UI; the MapLibre build used a
// hover popup, which has no direct Cesium equivalent.
...(options.popupHtml ? { description: options.popupHtml } : {}),
}),
);
return {
setLngLat: (pos) => {
// LngLatTuple has no altitude, so a moved marker keeps the one it
// was created with.
entity.position = new ConstantPositionProperty(
Cartesian3.fromDegrees(pos[0], pos[1], alt),
);
},
remove: () => this.remove(id),
};
}
remove(id: string): void {
const e = this.entities.get(id);
if (!e) return;
this.viewer.entities.remove(e);
this.entities.delete(id);
}
clear(): void {
for (const e of this.entities.values()) this.viewer.entities.remove(e);
this.entities.clear();
}
dispose(): void {
this.clear();
}
}

303
src/lib/map/cesium.ts Normal file
View file

@ -0,0 +1,303 @@
import {
ArcGisMapServerImageryProvider,
Cartesian2,
Cartesian3,
EllipsoidTerrainProvider,
Math as CesiumMath,
OpenStreetMapImageryProvider,
Rectangle,
ScreenSpaceEventHandler,
ScreenSpaceEventType,
Viewer,
TileMapServiceImageryProvider,
type ImageryProvider,
} from 'cesium';
import 'cesium/Build/Cesium/Widgets/widgets.css';
import type {
BaseLayerId,
IMap,
MapClickEvent,
MapEvent,
MapEventHandler,
MapInit,
Scene as MapScene,
} from './core';
import type { LatLngTuple, LngLatTuple } from '$domain';
import { CesiumScene } from './cesium-scene';
/**
* CesiumJS implementation of IMap.
*
* Replaces the MapLibre/Mercator renderer so polar trajectories render: Cesium
* projects geographic degrees directly onto an ellipsoid, with no Mercator tile
* pyramid to clamp vertices at ±85.051129°.
*
* No Cesium Ion account is used imagery comes from the same tile URLs the
* MapLibre build used, and terrain is a plain ellipsoid. Any Ion code path
* (createWorldTerrainAsync, IonImageryProvider) would require a token.
*/
/** Equatorial circumference in metres — reference for the zoom<->height bridge. */
const EQUATOR_M = 40075017;
/**
* Wheel-zoom sensitivity. Cesium computes each step as
* `zoomFactor * heightAboveEllipsoid * rangeWindowRatio`, so the step scales
* with altitude and its default of 5.0 overshoots badly when zoomed out one
* notch throws the camera into space, one back slams it into the ground.
* Lower is gentler. Tune here.
*/
const ZOOM_FACTOR = 2.0;
/**
* Cesium has no discrete zoom levels, only camera height. These two functions
* bridge the IMap vocabulary to it using the Web-Mercator-equivalent relation
* at the equator. Nothing outside this file calls getZoom/setZoom, so an exact
* match to MapLibre's scale is not required only monotonicity.
*/
export function zoomToHeight(zoom: number): number {
return EQUATOR_M / Math.pow(2, zoom);
}
export function heightToZoom(height: number): number {
return Math.log2(EQUATOR_M / Math.max(height, 1));
}
/**
* Base imagery, each built by Cesium's provider for that actual protocol.
*
* Deliberately NOT hand-written URL templates. A template makes us restate
* facts the service already publishes row convention, max level, extent,
* tiling scheme and getting any of them wrong fails silently. A mistaken
* `{reverseY}` here previously mirrored every Esri tile into the wrong latitude
* band. These providers read those facts from the service instead.
*/
const BASE_LAYERS: Record<BaseLayerId, () => ImageryProvider | Promise<ImageryProvider>> = {
// Knows the slippy-map convention, zoom range and attribution.
osm: () => new OpenStreetMapImageryProvider({ url: 'https://tile.openstreetmap.org/' }),
// fromUrl() fetches the MapServer's own metadata (?f=json) and configures
// tiling scheme, levels and extent from it. No token needed for a public
// MapServer — that is only for Esri-hosted basemaps.
satellite: () =>
ArcGisMapServerImageryProvider.fromUrl(
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer',
),
// Natural Earth II, shipped inside the Cesium package and copied to
// static/cesium by scripts/copy-cesium.js. EPSG:4326, so it covers ±90° —
// the poles are real imagery instead of the blank blue that Web Mercator
// leaves above 85.0511°.
//
// fromUrl() reads the set's own tilemapresource.xml for the SRS, extent,
// tile size, zoom levels and format, so there is nothing here for us to
// declare wrongly. Coarse (3 levels, ~19 km/px) but it always renders.
//
// NASA GIBS was tried first and rejected: its EPSG:4326 WMTS does serve the
// poles, but it throttled us hard enough that tiles stalled mid-download,
// leaving Cesium's navy base colour in wedges. A basemap that silently
// degrades to blank is worse for flight planning than a coarse one that
// works, so it is not wired in.
polar: () =>
TileMapServiceImageryProvider.fromUrl('/cesium/Assets/Textures/NaturalEarthII'),
};
class CesiumMap implements IMap {
readonly ready: Promise<void>;
private viewer: Viewer;
private handler: ScreenSpaceEventHandler;
private scenes = new Map<string, CesiumScene>();
private baseLayerSeq = 0;
constructor(init: MapInit) {
this.viewer = new Viewer(init.container, {
baseLayer: false, // valid because baseLayerPicker is false; avoids Ion
terrainProvider: new EllipsoidTerrainProvider(),
baseLayerPicker: false,
geocoder: false,
homeButton: false,
sceneModePicker: false,
navigationHelpButton: false,
animation: false,
timeline: false,
fullscreenButton: false,
infoBox: false,
selectionIndicator: false,
navigationInstructionsInitiallyVisible: false,
});
this.setBaseLayer(init.baseLayer ?? 'osm');
this.viewer.scene.screenSpaceCameraController.zoomFactor = ZOOM_FACTOR;
this.setCenter(init.center, init.zoom);
this.handler = new ScreenSpaceEventHandler(this.viewer.canvas);
// The Viewer is usable synchronously; imagery streams in afterwards.
// Map.svelte gates children on this promise, so it must resolve.
this.ready = Promise.resolve();
}
/** Screen point -> lng/lat on the globe, or null if the pick missed the globe. */
private pick(position: Cartesian2): { lat: number; lng: number } | null {
const ray = this.viewer.camera.getPickRay(position);
if (!ray) return null;
const hit = this.viewer.scene.globe.pick(ray, this.viewer.scene);
if (!hit) return null;
const c = this.viewer.scene.globe.ellipsoid.cartesianToCartographic(hit);
return { lat: CesiumMath.toDegrees(c.latitude), lng: CesiumMath.toDegrees(c.longitude) };
}
on<E extends MapEvent>(event: E, handler: MapEventHandler<E>): () => void {
if (event === 'load') {
// Cesium needs no style-load gate; fire immediately so callers proceed.
(handler as MapEventHandler<'load'>)(undefined);
return () => {};
}
if (event === 'click' || event === 'mousemove') {
const type =
event === 'click' ? ScreenSpaceEventType.LEFT_CLICK : ScreenSpaceEventType.MOUSE_MOVE;
const cb = (e: { position?: Cartesian2; endPosition?: Cartesian2 }) => {
const pos = e.position ?? e.endPosition;
if (!pos) return;
const lngLat = this.pick(pos);
// Off-globe pointer events (empty space around the sphere) have no
// coordinate. MapLibre never had this case; drop them.
if (!lngLat) return;
(handler as MapEventHandler<'click'>)({
lngLat,
originalEvent: new MouseEvent(event === 'click' ? 'click' : 'mousemove'),
} as MapClickEvent);
};
this.handler.setInputAction(cb, type);
return () => this.handler.removeInputAction(type);
}
// 'move' | 'zoom' — both ride Cesium's camera.changed event.
const listener = () => {
const c = this.viewer.camera.positionCartographic;
const zoom = heightToZoom(c.height);
if (event === 'move') {
(handler as MapEventHandler<'move'>)({
center: [CesiumMath.toDegrees(c.longitude), CesiumMath.toDegrees(c.latitude)],
zoom,
});
} else {
(handler as MapEventHandler<'zoom'>)({ zoom });
}
};
this.viewer.camera.changed.addEventListener(listener);
return () => this.viewer.camera.changed.removeEventListener(listener);
}
setCenter(pos: LngLatTuple, zoom?: number): void {
this.viewer.camera.setView({
destination: Cartesian3.fromDegrees(pos[0], pos[1], zoomToHeight(zoom ?? this.getZoom())),
});
}
panTo(pos: LngLatTuple, durationMs = 500): void {
this.viewer.camera.flyTo({
destination: Cartesian3.fromDegrees(
pos[0],
pos[1],
this.viewer.camera.positionCartographic.height,
),
duration: durationMs / 1000,
});
}
fitBounds(coords: LatLngTuple[], paddingPx = 50): void {
if (coords.length === 0) return;
// coords are [lat, lng]; Rectangle.fromDegrees takes (west, south, east, north).
const lats = coords.map((c) => c[0]);
const lngs = coords.map((c) => c[1]);
const rect = Rectangle.fromDegrees(
Math.min(...lngs),
Math.min(...lats),
Math.max(...lngs),
Math.max(...lats),
);
// A zero-area rectangle (single point, or a track that never moved) makes
// Cesium fly to the centre of the earth. Pad so there is always area.
if (rect.width === 0 || rect.height === 0) {
const pad = CesiumMath.toRadians(0.05);
rect.west -= pad;
rect.east += pad;
rect.south -= pad;
rect.north += pad;
}
this.viewer.camera.flyTo({ destination: rect, duration: 0.5 });
// ponytail: Cesium frames the rectangle itself; there is no pixel-padding
// knob. Accepted and ignored to keep the IMap signature unchanged.
void paddingPx;
}
getZoom(): number {
return heightToZoom(this.viewer.camera.positionCartographic.height);
}
setZoom(zoom: number): void {
const c = this.viewer.camera.positionCartographic;
this.viewer.camera.setView({
destination: Cartesian3.fromDegrees(
CesiumMath.toDegrees(c.longitude),
CesiumMath.toDegrees(c.latitude),
zoomToHeight(zoom),
),
});
}
setCursor(cursor: string | null): void {
this.viewer.canvas.style.cursor = cursor ?? '';
}
setBaseLayer(layer: BaseLayerId): void {
// Some providers resolve asynchronously (they fetch service metadata), so
// stamp each switch and let only the newest win — otherwise a slow earlier
// request can land after a faster later one and show the wrong basemap.
const seq = ++this.baseLayerSeq;
void Promise.resolve((BASE_LAYERS[layer] ?? BASE_LAYERS.osm)())
.then((provider) => {
if (seq !== this.baseLayerSeq || this.viewer.isDestroyed()) return;
// Replace rather than add: imageryLayers stack, so adding would leave
// the previous basemap underneath and leak a layer on every change.
this.viewer.imageryLayers.removeAll();
this.viewer.imageryLayers.addImageryProvider(provider);
})
.catch((e: unknown) => {
// Surfaced, not swallowed: a basemap that silently fails to appear is
// the same failure mode as a wind field that silently reads zero.
// eslint-disable-next-line no-console
console.error(`[map] base layer "${layer}" failed to load`, e);
});
}
scene(name: string): MapScene {
let s = this.scenes.get(name);
if (!s) {
s = new CesiumScene(name, this.viewer);
this.scenes.set(name, s);
}
return s;
}
disposeScene(name: string): void {
const s = this.scenes.get(name);
if (!s) return;
s.dispose();
this.scenes.delete(name);
}
getRawInstance(): Viewer {
return this.viewer;
}
dispose(): void {
for (const s of this.scenes.values()) s.dispose();
this.scenes.clear();
this.handler.destroy();
this.viewer.destroy();
}
}
export function createCesiumMap(init: MapInit): IMap {
return new CesiumMap(init);
}

View file

@ -4,14 +4,14 @@ import type { LatLngTuple, LngLatTuple } from '$domain';
* Map abstraction.
*
* Goals:
* - Isolate all MapLibre-specific types inside src/lib/map/maplibre.ts.
* - Isolate all Cesium-specific types inside src/lib/map/cesium.ts.
* - Expose a small, map-library-agnostic vocabulary (markers, polylines,
* icons, events) so features (workspaces, timeline, tools) can be tested
* against the interface alone.
* - Support "scenes" named collections of layers owned by a feature so
* each workspace/tool can add/remove everything it owns atomically.
*
* If another library ever replaces MapLibre, implementing IMap is the only
* If another library ever replaces Cesium, implementing IMap is the only
* file that changes.
*/
@ -34,6 +34,11 @@ export type MapEventHandler<E extends MapEvent> = (e: MapEventPayload[E]) => voi
export interface MarkerOptions {
lngLat: LngLatTuple;
/**
* Metres above the ellipsoid. Renderers that cannot show altitude ignore it.
* Used so the burst marker sits at the apex instead of on the ground under it.
*/
altitude?: number;
iconUrl?: string;
iconSize?: [number, number];
className?: string;
@ -42,6 +47,7 @@ export interface MarkerOptions {
}
export interface LineOptions {
/** Joined by great circles. Callers that need another curve sample it themselves. */
coords: LatLngTuple[];
color?: string;
width?: number;
@ -95,6 +101,12 @@ export interface IMap {
setCursor(cursor: string | null): void;
/**
* Swap the base imagery in place, keeping the current camera. Needed because
* the base layer is a user setting that can change after the map is built.
*/
setBaseLayer(layer: NonNullable<MapInit['baseLayer']>): void;
/**
* Get or create a named scene. Scenes are the unit of layer ownership:
* a feature adds all its layers through a scene and calls `.clear()` to
@ -109,11 +121,20 @@ export interface IMap {
dispose(): void;
}
/**
* Available base imagery.
*
* `satellite` (Esri) is Web Mercator: high resolution but no tiles above
* 85.0511°, so the poles are blank. `polar` trades resolution for a geographic
* (EPSG:4326) source that covers ±90°.
*/
export type BaseLayerId = 'osm' | 'satellite' | 'polar';
export interface MapInit {
container: HTMLElement;
center: LngLatTuple;
zoom: number;
baseLayer?: 'osm' | 'satellite';
baseLayer?: BaseLayerId;
showNavigationControl?: boolean;
showScaleControl?: boolean;
}

View file

@ -1,5 +1,5 @@
export * from './core';
export { createMapLibreMap } from './maplibre';
export { createCesiumMap } from './cesium';
export {
plotPrediction,
plotTelemetry,

View file

@ -3,8 +3,8 @@ import { toLngLat, boundingBoxRing } from '$domain';
import type { IMap, Scene } from './core';
/**
* Plot helpers for high-level domain objects. These live outside MapLibreMap
* so they can be reused against any IMap implementation.
* Plot helpers for high-level domain objects. These live outside the concrete
* map class so they can be reused against any IMap implementation.
*
* Icons are served from /static; pass explicit overrides if a workspace
* should use custom markers.
@ -62,6 +62,9 @@ export function plotPrediction(
scene.addMarker('burst', {
lngLat: toLngLat(prediction.burst.latlng),
// Burst happens at ~30 km; on a globe the marker belongs at the apex of
// the track, not on the ground beneath it.
altitude: prediction.burst.latlng.alt,
iconUrl: s.burstIcon,
iconSize: [s.iconSize[0] + 4, s.iconSize[1] + 4],
popupHtml: `<b>Burst</b><br>${prediction.burst.latlng.lat.toFixed(6)}, ${prediction.burst.latlng.lng.toFixed(6)}`,
@ -143,6 +146,8 @@ export interface BoundingBoxStyle {
export function plotBoundingBox(scene: Scene, box: BoundingBox, style: BoundingBoxStyle = {}): void {
scene.clear();
scene.addLine('box', {
// Already sampled along its great-circle edges, so the drawn shape is the
// filed shape and does not depend on the renderer's interpolation.
coords: boundingBoxRing(box),
color: style.color ?? '#0d6efd',
width: style.width ?? 3,

View file

@ -1,324 +0,0 @@
import maplibregl, {
type Map as MLMap,
type LngLatLike,
type MarkerOptions as MLMarkerOptions,
} from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import type {
CircleOptions,
IMap,
LineOptions,
MapEvent,
MapEventHandler,
MapEventPayload,
MapInit,
MapLayer,
Marker,
MarkerOptions,
Scene,
} from './core';
import type { LatLngTuple, LngLatTuple } from '$domain';
/** Map common base-layer names to MapLibre style JSON. */
const BASE_STYLES: Record<NonNullable<MapInit['baseLayer']>, maplibregl.StyleSpecification> = {
osm: {
version: 8,
sources: {
osm: {
type: 'raster',
tiles: ['https://a.tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
},
},
layers: [{ id: 'osm', type: 'raster', source: 'osm', minzoom: 0, maxzoom: 19 }],
},
satellite: {
version: 8,
sources: {
sat: {
type: 'raster',
tiles: [
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
],
tileSize: 256,
attribution: 'Tiles &copy; Esri',
},
},
layers: [{ id: 'sat', type: 'raster', source: 'sat', minzoom: 0, maxzoom: 19 }],
},
};
class MapLibreScene implements Scene {
private sources = new Set<string>();
private layers = new Set<string>();
private markers = new Map<string, maplibregl.Marker>();
constructor(
public readonly name: string,
private map: MLMap,
) {}
private scopeId(id: string): string {
return `${this.name}__${id}`;
}
addLine(id: string, options: LineOptions): MapLayer {
const layerId = this.scopeId(id);
const coords = options.coords.map<[number, number]>((c) => [c[1], c[0]]);
if (this.map.getSource(layerId)) this.remove(id);
this.map.addSource(layerId, {
type: 'geojson',
data: {
type: 'Feature',
properties: {},
geometry: { type: 'LineString', coordinates: coords },
},
});
this.map.addLayer({
id: layerId,
type: 'line',
source: layerId,
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: {
'line-color': options.color ?? '#000',
'line-width': options.width ?? 3,
'line-opacity': options.opacity ?? 1,
...(options.dashArray ? { 'line-dasharray': options.dashArray } : {}),
},
});
this.sources.add(layerId);
this.layers.add(layerId);
return {
id: layerId,
remove: () => this.remove(id),
};
}
addCircle(id: string, options: CircleOptions): MapLayer {
const layerId = this.scopeId(id);
const existing = this.map.getSource(layerId) as maplibregl.GeoJSONSource | undefined;
if (existing) {
existing.setData({
type: 'Feature',
properties: {},
geometry: { type: 'Point', coordinates: options.center },
});
return { id: layerId, remove: () => this.remove(id) };
}
this.map.addSource(layerId, {
type: 'geojson',
data: {
type: 'Feature',
properties: {},
geometry: { type: 'Point', coordinates: options.center },
},
});
this.map.addLayer({
id: layerId,
type: 'circle',
source: layerId,
paint: {
'circle-radius': options.radiusPx ?? 6,
'circle-color': options.color ?? '#0b5ed7',
'circle-opacity': options.opacity ?? 1,
'circle-stroke-color': options.strokeColor ?? '#ffffff',
'circle-stroke-width': options.strokeWidth ?? 2,
},
});
this.sources.add(layerId);
this.layers.add(layerId);
return {
id: layerId,
remove: () => this.remove(id),
};
}
addMarker(id: string, options: MarkerOptions): Marker {
const scoped = this.scopeId(id);
const existing = this.markers.get(scoped);
if (existing) existing.remove();
let mlOptions: MLMarkerOptions | undefined;
if (options.iconUrl) {
const el = document.createElement('div');
el.className = options.className ?? 'lsv-marker';
el.style.backgroundImage = `url(${options.iconUrl})`;
const [w, h] = options.iconSize ?? [12, 12];
el.style.width = `${w}px`;
el.style.height = `${h}px`;
el.style.backgroundSize = '100%';
mlOptions = { element: el };
}
const marker = new maplibregl.Marker(mlOptions).setLngLat(options.lngLat as LngLatLike);
if (options.popupHtml) {
const popup = new maplibregl.Popup({ offset: 16, closeButton: false }).setHTML(
options.popupHtml,
);
marker.setPopup(popup);
marker.getElement().addEventListener('mouseenter', () => marker.togglePopup());
marker.getElement().addEventListener('mouseleave', () => marker.togglePopup());
}
marker.addTo(this.map);
this.markers.set(scoped, marker);
return {
setLngLat: (pos) => marker.setLngLat(pos as LngLatLike),
remove: () => this.remove(id),
};
}
remove(id: string): void {
const scoped = this.scopeId(id);
if (this.map.getLayer(scoped)) this.map.removeLayer(scoped);
if (this.map.getSource(scoped)) this.map.removeSource(scoped);
this.layers.delete(scoped);
this.sources.delete(scoped);
const marker = this.markers.get(scoped);
if (marker) {
marker.remove();
this.markers.delete(scoped);
}
}
clear(): void {
for (const id of Array.from(this.layers)) {
if (this.map.getLayer(id)) this.map.removeLayer(id);
}
for (const id of Array.from(this.sources)) {
if (this.map.getSource(id)) this.map.removeSource(id);
}
for (const marker of this.markers.values()) marker.remove();
this.layers.clear();
this.sources.clear();
this.markers.clear();
}
dispose(): void {
this.clear();
}
}
export class MapLibreMap implements IMap {
private map: MLMap;
private scenes = new Map<string, MapLibreScene>();
public readonly ready: Promise<void>;
constructor(init: MapInit) {
this.map = new maplibregl.Map({
container: init.container,
style: BASE_STYLES[init.baseLayer ?? 'osm'],
center: init.center,
zoom: init.zoom,
});
if (init.showNavigationControl !== false) {
this.map.addControl(new maplibregl.NavigationControl(), 'bottom-left');
}
if (init.showScaleControl !== false) {
this.map.addControl(new maplibregl.ScaleControl({ maxWidth: 100, unit: 'metric' }), 'bottom-right');
}
this.ready = new Promise((resolve) => this.map.once('load', () => resolve()));
}
on<E extends MapEvent>(event: E, handler: MapEventHandler<E>): () => void {
const wrapped = (e: unknown) => {
switch (event) {
case 'click':
case 'mousemove': {
const ev = e as maplibregl.MapMouseEvent;
(handler as MapEventHandler<'click'>)({
lngLat: { lat: ev.lngLat.lat, lng: ev.lngLat.lng },
originalEvent: ev.originalEvent,
});
break;
}
case 'move':
(handler as MapEventHandler<'move'>)({
center: [this.map.getCenter().lng, this.map.getCenter().lat],
zoom: this.map.getZoom(),
});
break;
case 'zoom':
(handler as MapEventHandler<'zoom'>)({ zoom: this.map.getZoom() });
break;
case 'load':
(handler as MapEventHandler<'load'>)(undefined as MapEventPayload['load']);
break;
}
};
this.map.on(event as 'click', wrapped);
return () => this.map.off(event as 'click', wrapped);
}
setCenter(pos: LngLatTuple, zoom?: number): void {
this.map.setCenter(pos);
if (zoom !== undefined) this.map.setZoom(zoom);
}
panTo(pos: LngLatTuple, durationMs?: number): void {
this.map.panTo(pos, durationMs ? { duration: durationMs } : undefined);
}
fitBounds(coords: LatLngTuple[], paddingPx = 50): void {
if (coords.length === 0) return;
const first: [number, number] = [coords[0][1], coords[0][0]];
const bounds = coords.reduce(
(b, c) => b.extend([c[1], c[0]] as [number, number]),
new maplibregl.LngLatBounds(first, first),
);
this.map.fitBounds(bounds, { padding: paddingPx });
}
getZoom(): number {
return this.map.getZoom();
}
setZoom(zoom: number): void {
this.map.setZoom(zoom);
}
setCursor(cursor: string | null): void {
this.map.getCanvas().style.cursor = cursor ?? '';
}
scene(name: string): Scene {
let scene = this.scenes.get(name);
if (!scene) {
scene = new MapLibreScene(name, this.map);
this.scenes.set(name, scene);
}
return scene;
}
disposeScene(name: string): void {
const scene = this.scenes.get(name);
if (!scene) return;
scene.dispose();
this.scenes.delete(name);
}
getRawInstance(): MLMap {
return this.map;
}
dispose(): void {
for (const s of this.scenes.values()) s.dispose();
this.scenes.clear();
this.map.remove();
}
}
export function createMapLibreMap(init: MapInit): IMap {
return new MapLibreMap(init);
}

View file

@ -4,6 +4,7 @@
import type { IMap } from '$map';
import { startCoordinateSelection } from '$map';
import { Navbar } from '$features/auth';
import { MapChrome } from '$features/mapchrome';
import { PanelContainer, TabBar } from '$ui';
import { addToast, removeToast } from '$ui';
import { ControlPanel, ScenarioPanel } from '$features/prediction';
@ -12,7 +13,6 @@
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,8 +72,8 @@
<Navbar />
<div style="height: var(--navbar-height);"></div>
<MapView bind:this={mapComponent} onReady={handleMapReady}>
<MapChrome />
<WorkspaceRenderer />
<WindRenderer />
<PanelContainer position="left">
<TabBar

View file

@ -8,6 +8,7 @@
type IMap,
} from '$map';
import { Navbar } from '$features/auth';
import { MapChrome } from '$features/mapchrome';
import { PanelContainer, CollapsibleCard } from '$ui';
import { TelemetryPanel, DeviationChart, telemetryStore } from '$features/tracking';
import { workspacesStore } from '$features/workspaces';
@ -116,6 +117,7 @@
<Navbar />
<div style="height: var(--navbar-height);"></div>
<MapView onReady={onMapReady}>
<MapChrome />
<PanelContainer position="left">
<TelemetryPanel />
</PanelContainer>