428 lines
11 KiB
Svelte
428 lines
11 KiB
Svelte
<script lang="ts">
|
|
import { onMount, createEventDispatcher } from "svelte";
|
|
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";
|
|
|
|
export let mode: "prediction" | "telemetry" = "prediction";
|
|
export let data: Prediction | Telemetry | null = null;
|
|
|
|
let map: MapLibreMap;
|
|
let mapContainer: HTMLDivElement;
|
|
let markers: Marker[] = [];
|
|
let animatedMarker: Marker | null = null;
|
|
let mouseLat = 0;
|
|
let mouseLng = 0;
|
|
let isSelecting = false;
|
|
|
|
let windData: any;
|
|
|
|
const dispatch = createEventDispatcher<{ coordinatesSelected: { lat: number; lng: number } }>();
|
|
|
|
onMount(async () => {
|
|
if (!mapContainer) return;
|
|
|
|
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:
|
|
'© <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,
|
|
});
|
|
|
|
// Add navigation control (zoom buttons)
|
|
map.addControl(new maplibregl.NavigationControl(), "bottom-left");
|
|
|
|
// 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: maplibregl.MapMouseEvent) => {
|
|
mouseLat = e.lngLat.lat;
|
|
mouseLng = e.lngLat.lng;
|
|
});
|
|
|
|
map.on("click", (e: maplibregl.MapMouseEvent) => {
|
|
if (isSelecting) {
|
|
dispatch("coordinatesSelected", { lat: e.lngLat.lat, lng: e.lngLat.lng });
|
|
stopSelection();
|
|
}
|
|
});
|
|
});
|
|
|
|
$: if (map && data) {
|
|
plotData(data);
|
|
} else if (map) {
|
|
clearMapLayers();
|
|
}
|
|
|
|
export const startSelection = () => {
|
|
isSelecting = true;
|
|
if (mapContainer) mapContainer.style.cursor = "crosshair";
|
|
};
|
|
|
|
export const stopSelection = () => {
|
|
isSelecting = false;
|
|
if (mapContainer) mapContainer.style.cursor = "";
|
|
};
|
|
|
|
export const plotData = (plotData: Prediction | Telemetry) => {
|
|
if (mode === "prediction") {
|
|
plotPrediction(plotData as Prediction);
|
|
} else if (mode === "telemetry") {
|
|
plotTelemetry(plotData as Telemetry);
|
|
}
|
|
};
|
|
|
|
export const clearMapLayers = () => {
|
|
// 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 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);
|
|
const f_hours = Math.floor(flight_time / 3600);
|
|
const f_minutes = Math.floor((flight_time % 3600) / 60)
|
|
.toString()
|
|
.padStart(2, "0");
|
|
const flighttime = `${f_hours}hr${f_minutes}`;
|
|
|
|
// 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);
|
|
|
|
// 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");
|
|
|
|
// 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) => {
|
|
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) => {
|
|
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);
|
|
});
|
|
|
|
// 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.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.setCenter([lng, lat]);
|
|
}
|
|
};
|
|
|
|
export const zoomTo = (lat: number, lng: number, zoomLevel: number) => {
|
|
if (map) {
|
|
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}>
|
|
<!-- <div class="card coordinates-display">
|
|
<p class="card-text">
|
|
<b>Lat:</b>
|
|
{mouseLat.toFixed(6)},
|
|
<b>Lon:</b>
|
|
{mouseLng.toFixed(6)}
|
|
</p>
|
|
</div> -->
|
|
<slot />
|
|
{#if map && windData}
|
|
<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>
|