This commit is contained in:
ThePetrovich 2025-12-14 18:09:30 +08:00
commit 3be5d6c515
13 changed files with 1131 additions and 679 deletions

View file

@ -1,9 +1,8 @@
<script lang="ts">
import { onMount, createEventDispatcher } from "svelte";
import * as L from "leaflet";
import { ruler, Ruler } from "$lib/ext/leaflet-ruler/leaflet-ruler";
import type { Map as LeafletMap, LayerGroup } from "leaflet";
import "leaflet/dist/leaflet.css";
import maplibregl from "maplibre-gl";
import type { Map as MapLibreMap, Marker, LngLatBoundsLike } from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import WindVisualization from "$lib/components/WindVisualisation.svelte";
import { distHaversine } from "$lib/mathutil";
import type { Prediction, Telemetry } from "$lib/types";
@ -11,9 +10,10 @@
export let mode: "prediction" | "telemetry" = "prediction";
export let data: Prediction | Telemetry | null = null;
let map: LeafletMap;
let map: MapLibreMap;
let mapContainer: HTMLDivElement;
let plotLayerGroup: LayerGroup;
let markers: Marker[] = [];
let animatedMarker: Marker | null = null;
let mouseLat = 0;
let mouseLng = 0;
let isSelecting = false;
@ -25,30 +25,50 @@
onMount(async () => {
if (!mapContainer) return;
map = L.map(mapContainer, { zoomControl: false }).setView([51.505, -0.09], 13);
L.control.zoom({ position: "bottomleft" }).addTo(map);
map = new maplibregl.Map({
container: mapContainer,
style: {
version: 8,
sources: {
osm: {
type: "raster",
tiles: ["https://a.tile.openstreetmap.org/{z}/{x}/{y}.png"],
tileSize: 256,
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
},
},
layers: [
{
id: "osm",
type: "raster",
source: "osm",
minzoom: 0,
maxzoom: 19,
},
],
},
center: [-0.09, 51.505],
zoom: 13,
});
plotLayerGroup = L.layerGroup().addTo(map);
// Add navigation control (zoom buttons)
map.addControl(new maplibregl.NavigationControl(), "bottom-left");
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map);
ruler({
position: "bottomright",
}).addTo(map);
// Add scale control
map.addControl(new maplibregl.ScaleControl({ maxWidth: 100, unit: "metric" }), "bottom-right");
const response = await fetch("src/routes/testVelo.json");
windData = await response.json();
map.on("mousemove", (e: any) => {
mouseLat = e.latlng.lat;
mouseLng = e.latlng.lng;
map.on("mousemove", (e: maplibregl.MapMouseEvent) => {
mouseLat = e.lngLat.lat;
mouseLng = e.lngLat.lng;
});
map.on("click", (e: any) => {
map.on("click", (e: maplibregl.MapMouseEvent) => {
if (isSelecting) {
dispatch("coordinatesSelected", { lat: e.latlng.lat, lng: e.latlng.lng });
dispatch("coordinatesSelected", { lat: e.lngLat.lat, lng: e.lngLat.lng });
stopSelection();
}
});
@ -79,15 +99,99 @@
};
export const clearMapLayers = () => {
plotLayerGroup?.clearLayers();
// Remove all markers
markers.forEach((marker) => marker.remove());
markers = [];
// Remove animated marker
removeAnimatedMarker();
// Remove all layers and sources related to flight paths
if (map && map.getLayer("flight-path")) {
map.removeLayer("flight-path");
}
if (map && map.getSource("flight-path")) {
map.removeSource("flight-path");
}
if (map && map.getLayer("telemetry-path")) {
map.removeLayer("telemetry-path");
}
if (map && map.getSource("telemetry-path")) {
map.removeSource("telemetry-path");
}
};
const launchIcon = L.icon({ iconUrl: "target-blue.png", iconSize: [10, 10], iconAnchor: [5, 5] });
const landIcon = L.icon({ iconUrl: "target-red.png", iconSize: [10, 10], iconAnchor: [5, 5] });
const burstIcon = L.icon({ iconUrl: "pop-marker.png", iconSize: [16, 16], iconAnchor: [8, 8] });
const telemetryIcon = L.icon({ iconUrl: "marker-sm-red.png", iconSize: [10, 10], iconAnchor: [5, 5] });
const createMarker = (
lng: number,
lat: number,
color: string,
iconUrl: string,
title: string,
) => {
const el = document.createElement("div");
el.className = "custom-marker";
el.style.backgroundImage = `url(${iconUrl})`;
el.style.width = "10px";
el.style.height = "10px";
el.style.backgroundSize = "100%";
el.title = title;
// Create popup with coordinates
const popup = new maplibregl.Popup({ offset: 25, closeButton: false }).setHTML(
`<b>${title}</b><br>Lat: ${lat.toFixed(6)}<br>Lng: ${lng.toFixed(6)}`,
);
const marker = new maplibregl.Marker({ element: el })
.setLngLat([lng, lat])
.setPopup(popup)
.addTo(map);
// Show popup on hover
el.addEventListener("mouseenter", () => {
marker.togglePopup();
});
el.addEventListener("mouseleave", () => {
marker.togglePopup();
});
markers.push(marker);
return marker;
};
const createBurstMarker = (lng: number, lat: number, title: string) => {
const el = document.createElement("div");
el.className = "custom-marker";
el.style.backgroundImage = `url(pop-marker.png)`;
el.style.width = "16px";
el.style.height = "16px";
el.style.backgroundSize = "100%";
el.title = title;
// Create popup with coordinates
const popup = new maplibregl.Popup({ offset: 25, closeButton: false }).setHTML(
`<b>${title}</b><br>Lat: ${lat.toFixed(6)}<br>Lng: ${lng.toFixed(6)}`,
);
const marker = new maplibregl.Marker({ element: el })
.setLngLat([lng, lat])
.setPopup(popup)
.addTo(map);
// Show popup on hover
el.addEventListener("mouseenter", () => {
marker.togglePopup();
});
el.addEventListener("mouseleave", () => {
marker.togglePopup();
});
markers.push(marker);
return marker;
};
const plotPrediction = (prediction: Prediction) => {
clearMapLayers();
const { launch, landing, burst, flight_path, flight_time } = prediction;
const range = distHaversine(launch.latlng, landing.latlng, 1);
@ -97,49 +201,193 @@
.padStart(2, "0");
const flighttime = `${f_hours}hr${f_minutes}`;
L.marker(launch.latlng, { title: `Launch`, icon: launchIcon }).addTo(plotLayerGroup);
L.marker(landing.latlng, { title: `Landing`, icon: landIcon }).addTo(plotLayerGroup);
L.marker(burst.latlng, { title: `Burst`, icon: burstIcon }).addTo(plotLayerGroup);
// Helper to extract lat/lng from either format
const getLat = (latlng: any) => (Array.isArray(latlng) ? latlng[0] : latlng.lat);
const getLng = (latlng: any) => (Array.isArray(latlng) ? latlng[1] : latlng.lng);
L.polyline(flight_path, { weight: 3, color: "#000000" }).addTo(plotLayerGroup);
// Create markers (MapLibre uses [lng, lat] order)
createMarker(getLng(launch.latlng), getLat(launch.latlng), "#0000ff", "target-blue.png", "Launch");
createMarker(getLng(landing.latlng), getLat(landing.latlng), "#ff0000", "target-red.png", "Landing");
createBurstMarker(getLng(burst.latlng), getLat(burst.latlng), "Burst");
map?.fitBounds(L.latLngBounds(flight_path));
// Add flight path as a line (convert [lat, lng] to [lng, lat] for MapLibre)
const coordinates = flight_path.map((coord) => {
if (Array.isArray(coord)) {
return [coord[1], coord[0]]; // [lat, lng, alt?] -> [lng, lat]
} else {
return [coord.lng, coord.lat]; // {lat, lng} -> [lng, lat]
}
});
map.addSource("flight-path", {
type: "geojson",
data: {
type: "Feature",
properties: {},
geometry: {
type: "LineString",
coordinates: coordinates,
},
},
});
map.addLayer({
id: "flight-path",
type: "line",
source: "flight-path",
layout: {
"line-join": "round",
"line-cap": "round",
},
paint: {
"line-color": "#000000",
"line-width": 3,
},
});
// Fit bounds to show entire path
const bounds = coordinates.reduce(
(bounds, coord) => {
return bounds.extend(coord as [number, number]);
},
new maplibregl.LngLatBounds(coordinates[0] as [number, number], coordinates[0] as [number, number]),
);
map.fitBounds(bounds as LngLatBoundsLike, { padding: 50 });
};
const plotTelemetry = (telemetry: Telemetry) => {
L.marker(telemetry.launch.latlng, { title: `Launch`, icon: launchIcon }).addTo(plotLayerGroup);
clearMapLayers();
// Helper to extract lat/lng from either format
const getLat = (latlng: any) => (Array.isArray(latlng) ? latlng[0] : latlng.lat);
const getLng = (latlng: any) => (Array.isArray(latlng) ? latlng[1] : latlng.lng);
// Launch marker (MapLibre uses [lng, lat] order)
createMarker(
getLng(telemetry.launch.latlng),
getLat(telemetry.launch.latlng),
"#0000ff",
"target-blue.png",
"Launch",
);
// Telemetry point markers with popups
telemetry.datapoints.forEach((point) => {
L.marker([point.latitude, point.longitude], {
title: `Telemetry at ${point.datetime}`,
icon: telemetryIcon,
})
.bindPopup(
`<b>Telemetry Point</b><br>Lat: ${point.latitude.toFixed(6)}<br>Lon: ${point.longitude.toFixed(6)}`,
)
.addTo(plotLayerGroup);
const el = document.createElement("div");
el.className = "custom-marker";
el.style.backgroundImage = `url(marker-sm-red.png)`;
el.style.width = "10px";
el.style.height = "10px";
el.style.backgroundSize = "100%";
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(
`<b>Telemetry Point</b><br>Lat: ${point.latitude.toFixed(6)}<br>Lon: ${point.longitude.toFixed(6)}`,
);
const marker = new maplibregl.Marker({ element: el })
.setLngLat([point.longitude, point.latitude])
.setPopup(popup)
.addTo(map);
markers.push(marker);
});
L.polyline(telemetry.flight_path, { weight: 3, color: "#000000" }).addTo(plotLayerGroup);
// Add flight path as a line (convert [lat, lng] to [lng, lat] for MapLibre)
const coordinates = telemetry.flight_path.map((coord) => {
if (Array.isArray(coord)) {
return [coord[1], coord[0]]; // [lat, lng, alt?] -> [lng, lat]
} else {
return [coord.lng, coord.lat]; // {lat, lng} -> [lng, lat]
}
});
map?.fitBounds(L.latLngBounds(telemetry.flight_path));
map.addSource("telemetry-path", {
type: "geojson",
data: {
type: "Feature",
properties: {},
geometry: {
type: "LineString",
coordinates: coordinates,
},
},
});
map.addLayer({
id: "telemetry-path",
type: "line",
source: "telemetry-path",
layout: {
"line-join": "round",
"line-cap": "round",
},
paint: {
"line-color": "#000000",
"line-width": 3,
},
});
// Fit bounds to show entire path
const bounds = coordinates.reduce(
(bounds, coord) => {
return bounds.extend(coord as [number, number]);
},
new maplibregl.LngLatBounds(coordinates[0] as [number, number], coordinates[0] as [number, number]),
);
map.fitBounds(bounds as LngLatBoundsLike, { padding: 50 });
};
export const panTo = (lat: number, lng: number) => {
if (map) {
map.setView([lat, lng], map.getZoom());
map.setCenter([lng, lat]);
}
};
export const zoomTo = (lat: number, lng: number, zoomLevel: number) => {
if (map) {
map.setView([lat, lng], zoomLevel);
map.setCenter([lng, lat]);
map.setZoom(zoomLevel);
}
};
export const getMap = () => {
return map;
};
export const updateAnimatedMarker = (lat: number, lng: number) => {
if (!map) return;
if (!animatedMarker) {
// Create animated marker
const el = document.createElement("div");
el.className = "animated-marker";
el.innerHTML = `
<svg width="32" height="32" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="14" fill="#FF6B6B" opacity="0.3" class="pulse-ring"/>
<circle cx="16" cy="16" r="8" fill="#FF1744" stroke="white" stroke-width="2"/>
</svg>
`;
animatedMarker = new maplibregl.Marker({ element: el, anchor: "center" })
.setLngLat([lng, lat])
.addTo(map);
} else {
// Update position
animatedMarker.setLngLat([lng, lat]);
}
// Pan to marker
map.panTo([lng, lat], { duration: 100 });
};
export const removeAnimatedMarker = () => {
if (animatedMarker) {
animatedMarker.remove();
animatedMarker = null;
}
};
</script>
<div class="map-container" bind:this={mapContainer}>
@ -156,3 +404,25 @@
<WindVisualization {map} {windData} />
{/if}
</div>
<style>
:global(.animated-marker) {
cursor: pointer;
}
:global(.animated-marker .pulse-ring) {
animation: pulse 2s ease-out infinite;
transform-origin: center;
}
@keyframes :global(pulse) {
0% {
r: 8;
opacity: 0.8;
}
100% {
r: 14;
opacity: 0;
}
}
</style>