feat(globe): port to CeliumJS
This commit is contained in:
parent
ec03425067
commit
eb7698e034
51 changed files with 3521 additions and 1992 deletions
23
src/app.css
23
src/app.css
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
};
|
||||
|
|
@ -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
148
src/lib/domain/export.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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]}`;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 north→south (la1 = 90,
|
||||
* la2 = -90), which would otherwise send `la1 + j·dy` past the pole.
|
||||
*
|
||||
* Stepping from the first point toward the last (la1→la2, lo1→lo2) and
|
||||
* wrapping longitudes into (-180, 180] places every arrow at its true
|
||||
* geographic position regardless of scan direction or longitude convention.
|
||||
*/
|
||||
export function decodeWindField(field: WindField): WindVector[] {
|
||||
const [uComp, vComp] = field;
|
||||
const { nx, ny, lo1, la1, lo2, la2, dx, dy } = uComp.header;
|
||||
const vectors: WindVector[] = [];
|
||||
|
||||
// Per-step deltas taken from the grid extent so the last row/column lands
|
||||
// exactly on la2/lo2. Longitude span is taken the short way around the
|
||||
// globe to stay correct for boxes that cross the 0/360 seam.
|
||||
const lonSpan = ((lo2 - lo1) % 360 + 360) % 360;
|
||||
const lngDelta = nx > 1 ? lonSpan / (nx - 1) : dx;
|
||||
const latDelta = ny > 1 ? (la2 - la1) / (ny - 1) : -Math.abs(dy);
|
||||
|
||||
for (let j = 0; j < ny; j++) {
|
||||
const lat = la1 + j * latDelta;
|
||||
for (let i = 0; i < nx; i++) {
|
||||
const idx = j * nx + i;
|
||||
const u = uComp.data[idx];
|
||||
const v = vComp.data[idx];
|
||||
if (!Number.isFinite(u) || !Number.isFinite(v)) continue;
|
||||
const lng = wrapLng(lo1 + i * lngDelta);
|
||||
const speed = Math.sqrt(u * u + v * v);
|
||||
const bearing = (Math.atan2(u, v) * 180) / Math.PI;
|
||||
vectors.push({ lat, lng, u, v, speed, bearing });
|
||||
}
|
||||
}
|
||||
|
||||
return vectors;
|
||||
}
|
||||
|
||||
/** Samples the wind field at an arbitrary lng/lat. Returns null outside the grid. */
|
||||
export type WindInterpolator = (lng: number, lat: number) => [number, number] | null;
|
||||
|
||||
/**
|
||||
* Build a bilinear interpolator over a WindField. Used by the particle
|
||||
* renderer to advect points through a continuous [u, v] field.
|
||||
*
|
||||
* Coordinate handling mirrors decodeWindField: longitudes are taken in the
|
||||
* grid's native 0..360 frame (so a query lng is brought into that frame),
|
||||
* and the per-step increments come from the grid extent so scan direction is
|
||||
* handled implicitly.
|
||||
*/
|
||||
export function createWindInterpolator(field: WindField): WindInterpolator {
|
||||
const [uComp, vComp] = field;
|
||||
const { nx, ny, lo1, la1, lo2, la2, dx, dy } = uComp.header;
|
||||
const u = uComp.data;
|
||||
const v = vComp.data;
|
||||
|
||||
const lonSpan = (((lo2 - lo1) % 360) + 360) % 360;
|
||||
const lngDelta = nx > 1 ? lonSpan / (nx - 1) : dx;
|
||||
const latDelta = ny > 1 ? (la2 - la1) / (ny - 1) : -Math.abs(dy);
|
||||
|
||||
return (lng, lat) => {
|
||||
if (lngDelta === 0 || latDelta === 0) return null;
|
||||
|
||||
const rj = (lat - la1) / latDelta;
|
||||
if (rj < 0 || rj > ny - 1) return null;
|
||||
|
||||
// Eastward offset from lo1 in the grid's 0..360 frame.
|
||||
const dLon = (((lng - lo1) % 360) + 360) % 360;
|
||||
const ci = dLon / lngDelta;
|
||||
if (ci < 0 || ci > nx - 1) return null;
|
||||
|
||||
const i0 = Math.floor(ci);
|
||||
const j0 = Math.floor(rj);
|
||||
const i1 = Math.min(i0 + 1, nx - 1);
|
||||
const j1 = Math.min(j0 + 1, ny - 1);
|
||||
const fi = ci - i0;
|
||||
const fj = rj - j0;
|
||||
|
||||
const a = (1 - fi) * (1 - fj);
|
||||
const b = fi * (1 - fj);
|
||||
const c = (1 - fi) * fj;
|
||||
const d = fi * fj;
|
||||
|
||||
const k00 = j0 * nx + i0;
|
||||
const k10 = j0 * nx + i1;
|
||||
const k01 = j1 * nx + i0;
|
||||
const k11 = j1 * nx + i1;
|
||||
|
||||
const ui = u[k00] * a + u[k10] * b + u[k01] * c + u[k11] * d;
|
||||
const vi = v[k00] * a + v[k10] * b + v[k01] * c + v[k11] * d;
|
||||
if (!Number.isFinite(ui) || !Number.isFinite(vi)) return null;
|
||||
return [ui, vi];
|
||||
};
|
||||
}
|
||||
77
src/lib/features/mapchrome/MapChrome.svelte
Normal file
77
src/lib/features/mapchrome/MapChrome.svelte
Normal 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>
|
||||
1
src/lib/features/mapchrome/index.ts
Normal file
1
src/lib/features/mapchrome/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as MapChrome } from './MapChrome.svelte';
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
}
|
||||
|
|
@ -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 > {windSettings.maxFlightDurationHours}h
|
||||
{:else}
|
||||
Wind sync skipped: region > {windSettings.maxRegionDegrees}°
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.wind-skip-notice {
|
||||
position: absolute;
|
||||
bottom: 90px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
pointer-events: none;
|
||||
z-index: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export { default as WindRenderer } from './WindRenderer.svelte';
|
||||
export { windCache } from './store';
|
||||
export { ParticleField, DEFAULT_PARTICLE_OPTIONS, type ParticleOptions } from './ParticleField';
|
||||
|
|
@ -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();
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Добавить",
|
||||
|
|
|
|||
|
|
@ -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
166
src/lib/map/cesium-scene.ts
Normal 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
303
src/lib/map/cesium.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export * from './core';
|
||||
export { createMapLibreMap } from './maplibre';
|
||||
export { createCesiumMap } from './cesium';
|
||||
export {
|
||||
plotPrediction,
|
||||
plotTelemetry,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: '© <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 © 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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue