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>

View file

@ -0,0 +1,310 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import type { Prediction } from "$lib/types";
import "bootstrap-icons/font/bootstrap-icons.css";
let { prediction }: { prediction: Prediction | null } = $props();
const dispatch = createEventDispatcher<{
timeUpdate: { index: number; lat: number; lng: number; alt: number; datetime: Date };
}>();
let isPlaying = $state(false);
let currentIndex = $state(0);
let playbackSpeed = $state(1);
let isCollapsed = $state(false);
let animationFrame: number | null = null;
let lastUpdateTime = 0;
$effect(() => {
if (prediction && currentIndex >= flightPathLength) {
currentIndex = 0;
}
});
const flightPathLength = $derived(prediction?.flight_path?.length || 0);
const progress = $derived(flightPathLength > 0 ? (currentIndex / flightPathLength) * 100 : 0);
const currentPosition = $derived.by(() => {
if (!prediction || !prediction.flight_path[currentIndex]) return null;
const point = prediction.flight_path[currentIndex];
let lat: number, lng: number, alt: number;
if (Array.isArray(point)) {
lat = point[0];
lng = point[1];
alt = point[2] || 0;
} else {
lat = point.lat;
lng = point.lng;
alt = point.alt || 0;
}
const totalTime = prediction.flight_time;
const timeProgress = (currentIndex / flightPathLength) * totalTime;
const launchTime = prediction.launch.datetime instanceof Date
? prediction.launch.datetime.getTime()
: new Date(prediction.launch.datetime).getTime();
const datetime = new Date(launchTime + timeProgress * 1000);
return { lat, lng, alt, datetime };
});
const timeElapsed = $derived.by(() => {
if (!prediction || !currentPosition) return "00:00:00";
const launchTime = prediction.launch.datetime instanceof Date
? prediction.launch.datetime.getTime()
: new Date(prediction.launch.datetime).getTime();
const totalSeconds = Math.floor(
(currentPosition.datetime.getTime() - launchTime) / 1000,
);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
});
function animate(timestamp: number) {
if (!isPlaying) return;
if (!lastUpdateTime) lastUpdateTime = timestamp;
const deltaTime = timestamp - lastUpdateTime;
if (deltaTime >= 50 / playbackSpeed) {
if (currentIndex < flightPathLength - 1) {
currentIndex++;
if (currentPosition) {
dispatch("timeUpdate", { ...currentPosition, index: currentIndex });
}
} else {
stop();
}
lastUpdateTime = timestamp;
}
animationFrame = requestAnimationFrame(animate);
}
function play() {
if (!prediction) return;
if (currentIndex >= flightPathLength - 1) {
currentIndex = 0;
}
isPlaying = true;
lastUpdateTime = 0;
animationFrame = requestAnimationFrame(animate);
}
function pause() {
isPlaying = false;
if (animationFrame !== null) {
cancelAnimationFrame(animationFrame);
animationFrame = null;
}
}
function stop() {
pause();
currentIndex = 0;
if (currentPosition) {
dispatch("timeUpdate", { ...currentPosition, index: currentIndex });
}
}
function handleSliderChange(event: Event) {
const target = event.target as HTMLInputElement;
currentIndex = parseInt(target.value);
if (currentPosition) {
dispatch("timeUpdate", { ...currentPosition, index: currentIndex });
}
}
function changeSpeed() {
const speeds = [1, 2, 5, 0.5];
const currentSpeedIndex = speeds.indexOf(playbackSpeed);
playbackSpeed = speeds[(currentSpeedIndex + 1) % speeds.length];
}
function handleToggleCollapse() {
isCollapsed = !isCollapsed;
}
$effect(() => {
return () => {
if (animationFrame !== null) {
cancelAnimationFrame(animationFrame);
}
};
});
</script>
<div class="timeline-container card shadow-sm">
<div
class="card-header bg-primary text-white d-flex justify-content-between align-items-center p-1 px-3"
style="cursor:pointer;"
onclick={handleToggleCollapse}
role="button"
tabindex="0"
onkeydown={(e) => e.key === 'Enter' && handleToggleCollapse()}
>
<span class="fw-bold mb-0">Flight Timeline</span>
<button
type="button"
class="btn btn-sm btn-primary p-0"
aria-label="Toggle timeline visibility"
>
<i class="bi {isCollapsed ? 'bi-caret-left-fill' : 'bi-caret-down-fill'}"></i>
</button>
</div>
{#if !isCollapsed}
<div class="card-body p-3">
<div class="timeline-info mb-2">
<div class="info-section">
<span class="form-label mb-1">Time:</span>
<span class="fw-bold font-monospace">{timeElapsed}</span>
</div>
{#if currentPosition}
<div class="info-section">
<span class="form-label mb-1">Altitude:</span>
<span class="fw-bold font-monospace">{Math.round(currentPosition.alt)} m</span>
</div>
<div class="info-section">
<span class="form-label mb-1">Position:</span>
<span class="fw-bold font-monospace"
>{currentPosition.lat.toFixed(4)}, {currentPosition.lng.toFixed(4)}</span
>
</div>
{/if}
</div>
<div class="timeline-controls">
<div class="btn-group me-2" role="group">
<button
type="button"
class="btn btn-sm btn-outline-primary"
onclick={stop}
disabled={!prediction || currentIndex === 0}
title="Reset to start"
aria-label="Reset to start"
>
<i class="bi bi-skip-start-fill"></i>
</button>
{#if !isPlaying}
<button
type="button"
class="btn btn-sm btn-success"
onclick={play}
disabled={!prediction}
title="Play animation"
aria-label="Play animation"
>
<i class="bi bi-play-fill"></i>
</button>
{:else}
<button
type="button"
class="btn btn-sm btn-warning"
onclick={pause}
title="Pause animation"
aria-label="Pause animation"
>
<i class="bi bi-pause-fill"></i>
</button>
{/if}
<button
type="button"
class="btn btn-sm btn-outline-secondary"
onclick={changeSpeed}
disabled={!prediction}
title="Change playback speed"
aria-label="Change playback speed"
>
{playbackSpeed}x
</button>
</div>
<div class="flex-grow-1 position-relative">
<input
type="range"
min="0"
max={flightPathLength - 1}
value={currentIndex}
oninput={handleSliderChange}
disabled={!prediction}
class="form-range timeline-slider"
/>
</div>
</div>
</div>
{/if}
</div>
<style>
.timeline-container {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
min-width: 500px;
max-width: 700px;
z-index: 1000;
background: var(--bs-body-bg, #fff);
backdrop-filter: blur(10px);
}
.timeline-info {
display: flex;
justify-content: space-around;
gap: 0.5rem;
}
.info-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.001rem;
flex: 1;
}
.timeline-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Custom range slider styling to match Bootstrap theme */
.timeline-slider::-webkit-slider-thumb {
background: var(--bs-primary, #007bff);
}
.timeline-slider::-moz-range-thumb {
background: var(--bs-primary, #007bff);
}
/* Responsive design */
@media (max-width: 767.98px) {
.timeline-container {
min-width: calc(100vw - 40px);
max-width: calc(100vw - 40px);
bottom: 10px;
}
.timeline-info {
flex-direction: column;
gap: 0.5rem;
}
.info-section {
flex-direction: row;
justify-content: space-between;
}
.btn-group {
flex-wrap: nowrap;
}
}
</style>

View file

@ -1,244 +1,64 @@
<script>
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
import "leaflet-velocity/dist/leaflet-velocity.css";
import "leaflet-velocity/dist/leaflet-velocity";
import "leaflet.heat";
import "leaflet-timedimension";
export let map; // принимаем карту из родительского компонента
export let windData;
// Props
let { map, windData }: { map: any; windData: any } = $props();
let timeDimension;
let timeDimensionControl;
let velocityLayer;
let heatLayer;
let legend;
// Состояние переключателей
let showHeatmap = true;
let showVectors = true;
let layerControl;
// Преобразование testVelo.json в формат timeData
const prepareTimeData = (windData) => {
if (!windData || windData.length < 2) return {};
// Используем дату из header или текущую дату, если не указана
const refTime = windData[0]?.header?.refTime || new Date().toISOString();
return {
[refTime]: {
u: windData[0].data, // U-компонента (первый объект в массиве)
v: windData[1].data, // V-компонента (второй объект)
},
};
};
// Функция для нормализации данных тепловой карты
const prepareHeatData = (windData) => {
if (!windData || windData.length < 2) {
console.warn("Invalid wind data structure");
return [];
}
// Получаем U и V компоненты
const uComponent = windData.find((item) => item.header.parameterNumber === 2);
const vComponent = windData.find((item) => item.header.parameterNumber === 3);
if (!uComponent || !vComponent) {
console.warn("Missing wind components");
return [];
}
const header = uComponent.header; // Используем header из U компоненты
const { lo1, la1, dx, dy, nx, ny } = header;
const heatData = [];
let maxSpeed = 0;
// Проверяем совпадение размеров данных
if (uComponent.data.length !== vComponent.data.length) {
console.warn("U and V components have different lengths");
return [];
}
// Собираем данные и находим максимальную скорость
for (let i = 0; i < uComponent.data.length; i++) {
const u = uComponent.data[i];
const v = vComponent.data[i];
const speed = Math.sqrt(u * u + v * v);
if (!isNaN(speed)) {
// Вычисляем координаты для текущей точки
const y = Math.floor(i / nx);
const x = i % nx;
let lat = la1 - y * dy;
let lng = lo1 + x * dx;
if (lng >= 180) lng -= 360;
heatData.push([lat, lng, speed]);
maxSpeed = Math.max(maxSpeed, speed);
}
}
console.log(`Prepared heat data: ${heatData.length} points, max speed: ${maxSpeed}`);
// Нормализуем значения интенсивности от 0 до 1
if (maxSpeed > 0) {
return heatData.map(([lat, lng, intensity]) => [lat, lng, intensity / maxSpeed]);
}
return heatData;
};
// Обновление слоев
const updateLayers = () => {
if (!map || !windData) return;
// Удаляем старые слои
if (velocityLayer) map.removeLayer(velocityLayer);
if (heatLayer) map.removeLayer(heatLayer);
if (legend) map.removeControl(legend);
// Создаем слой векторов ветра
if (showVectors) {
velocityLayer = L.velocityLayer({
displayValues: true,
displayOptions: {
velocityType: "Wind Speed",
position: "bottomright",
emptyString: "No wind data",
},
data: windData,
}).addTo(map);
}
// Обновляем контроль слоев
updateLayerControl();
};
const updateLayerControl = () => {
if (layerControl) {
map.removeControl(layerControl);
}
const overlays = {};
if (velocityLayer) {
overlays["Векторы ветра"] = velocityLayer;
}
if (heatLayer) {
overlays["Тепловая карта"] = heatLayer;
}
// layerControl = L.control
// .layers(null, overlays, {
// collapsed: false,
// position: "topright",
// })
// .addTo(map);
};
// Создание легенды с учетом максимальной скорости
// const createLegend = (maxSpeed) => {
// if (!map) return;
// legend = L.control({ position: "bottomright" });
// legend.onAdd = () => {
// const div = L.DomUtil.create("div", "wind-heat-legend");
// div.innerHTML = `
// <h4>Wind Speed (m/s)</h4>
// <div class="legend-scale">
// <div class="legend-color" style="background: #0000FF;"></div>
// <div class="legend-color" style="background: #00FFFF;"></div>
// <div class="legend-color" style="background: #00FF00;"></div>
// <div class="legend-color" style="background: #FFFF00;"></div>
// <div class="legend-color" style="background: #FF0000;"></div>
// </div>
// <div class="legend-labels">
// <span>0</span>
// <span>${(maxSpeed * 0.25).toFixed(1)}</span>
// <span>${(maxSpeed * 0.5).toFixed(1)}</span>
// <span>${(maxSpeed * 0.75).toFixed(1)}</span>
// <span>${maxSpeed.toFixed(1)}</span>
// </div>
// `;
// return div;
// };
// legend.addTo(map);
// };
// State for layer toggles
let showHeatmap = $state(false);
let showParticles = $state(false);
onMount(() => {
if (!map) return;
if (!map || !windData) {
console.warn('Map or wind data not available');
return;
}
// 1. Настройка TimeDimension (добавьте эти строки в начале)
// L.TimeDimension.Util.setProxy('https://your-proxy.com/?url='); // Для загрузки больших данных
L.TimeDimension.Util.setCacheLimit(10); // Лимит кэшированных кадров
console.log("WindVisualization component mounted");
console.log("Wind data available:", windData);
// 1. Подготовка данных
const timeData = prepareTimeData(windData);
const firstTime = Object.keys(timeData)[0];
// Инициализация TimeDimension
timeDimension = new L.TimeDimension({
period: "PT1H", // Интервал 1 час
timeInterval: "${firstTime}/${firstTime}",
});
// Добавляем контролы времени
timeDimensionControl = new L.Control.TimeDimension({
timeDimension,
position: "bottomleft",
// autoPlay: true,
playerOptions: {
// transitionTime: 1000,
loop: false,
minBufferReady: -1,
},
});
//map.addControl(timeDimensionControl);
// 4. Создание слоев
// const velocityLayer = L.timeDimension.layer
// .windVelocity({
// displayValues: true,
// data: timeData,
// displayOptions: {
// velocityType: "Wind Speed",
// position: "bottomleft",
// },
// })
// .addTo(map);
// NOTE: @sakitam-gis/maplibre-wind requires tile-based or image URL sources
// It does not support raw wind data arrays directly
//
// The library expects:
// - TileSource with URL template (e.g., 'https://tiles.example.com/{z}/{x}/{y}.png')
// - ImageSource with image URL and coordinates
//
// To use this library, we would need to:
// 1. Convert wind data to tiles or images
// 2. Serve them via a tile server
// 3. Use TileSource or ImageSource with the URLs
//
// Alternative approaches:
// 1. Use deck.gl with ParticleLayer for raw data visualization
// 2. Use MapLibre's native heatmap layers for color visualization
// 3. Create a custom WebGL layer for particle animation
// 4. Pre-process wind data into tiles/images server-side
});
onDestroy(() => {
if (map) {
if (velocityLayer) map.removeLayer(velocityLayer);
if (heatLayer) map.removeLayer(heatLayer);
if (legend) map.removeControl(legend);
}
console.log("WindVisualization component destroyed");
});
// Реактивность на изменение параметров
$: if (map && windData) {
updateLayers();
}
</script>
<!-- <div class="layer-controls">
<div class="control-group">
<label>
<input type="checkbox" bind:checked={showHeatmap} />
<input type="checkbox" bind:checked={showHeatmap} disabled />
Тепловая карта
</label>
<label>
<input type="checkbox" bind:checked={showVectors} />
Векторы ветра
<input type="checkbox" bind:checked={showParticles} disabled />
Частицы ветра
</label>
</div>
</div> -->
<small style="color: #666; font-size: 11px; margin-top: 8px; display: block;">
Wind visualization requires tile/image source
</small>
<small style="color: #999; font-size: 10px; margin-top: 4px; display: block;">
See WindVisualisation.svelte for implementation notes
</small>
</div>
<style>
.layer-controls {
@ -246,54 +66,37 @@
bottom: 30px;
left: 10px;
z-index: 1000;
background: rgba(255, 255, 255, 0.8);
padding: 10px;
background: rgba(255, 255, 255, 0.95);
padding: 10px 12px;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(4px);
}
.control-group {
display: flex;
flex-direction: column;
gap: 5px;
gap: 8px;
}
.control-group label {
display: flex;
align-items: center;
gap: 5px;
gap: 8px;
font-size: 14px;
cursor: pointer;
}
:global(.wind-heat-legend) {
padding: 8px 10px;
background: rgba(255, 255, 255, 0.9);
border-radius: 5px;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.2);
line-height: 1.2;
color: #333;
font-family: Arial, sans-serif;
cursor: not-allowed;
user-select: none;
opacity: 0.5;
}
:global(.wind-heat-legend h4) {
margin: 0 0 5px;
font-size: 14px;
font-weight: bold;
.control-group input[type="checkbox"] {
cursor: not-allowed;
width: 16px;
height: 16px;
}
:global(legend-scale) {
display: flex;
margin-bottom: 3px;
small {
font-style: italic;
opacity: 0.7;
}
:global(legend-color) {
height: 12px;
flex-grow: 1;
}
:global(.legend-labels) {
display: flex;
justify-content: space-between;
font-size: 11px;
}
</style>
</style> -->

View file

@ -1,286 +0,0 @@
import * as L from "leaflet";
import { distHaversine, bearingHaversine } from "$lib/mathutil";
// Define an interface for the control's options for type safety.
export interface RulerOptions extends L.ControlOptions {
events?: {
onToggle?: (isActive: boolean) => void;
};
circleMarker?: L.CircleMarkerOptions;
lineStyle?: L.PolylineOptions;
lengthUnit?: {
display?: string;
decimal?: number;
factor?: number | null;
label?: string;
};
angleUnit?: {
display?: string;
decimal?: number;
factor?: number | null;
label?: string;
};
}
// Define an interface for the measurement result.
interface MeasurementResult {
Bearing: number;
Distance: number;
}
// Use a modern TypeScript class that extends L.Control.
export class Ruler extends L.Control {
// Override the default options with our custom ones.
public options: RulerOptions = {
position: "topright",
events: {
onToggle: () => {},
},
circleMarker: {
color: "red",
radius: 2,
},
lineStyle: {
color: "red",
dashArray: "1,6",
},
lengthUnit: {
display: "km",
decimal: 2,
factor: null,
label: "Distance:",
},
angleUnit: {
display: "&deg;",
decimal: 2,
factor: null,
label: "Bearing:",
},
};
// Declare class properties with types.
private _lastClickTime = 0;
private _map?: L.Map;
private _container?: HTMLElement;
private _choice = false;
private _defaultCursor = "";
private _allLayers: L.LayerGroup = L.layerGroup();
private _clickedLatLong: L.LatLng | null = null;
private _clickedPoints: L.LatLng[] = [];
private _totalLength = 0;
private _clickCount = 0;
private _tempLine: L.FeatureGroup = L.featureGroup();
private _tempPoint: L.FeatureGroup = L.featureGroup();
private _pointLayer: L.FeatureGroup = L.featureGroup();
private _polylineLayer: L.FeatureGroup = L.featureGroup();
private _movingLatLong: L.LatLng | null = null;
private _result: MeasurementResult = { Bearing: 0, Distance: 0 };
private _addedLength = 0;
constructor(options?: RulerOptions) {
super(options);
L.Util.setOptions(this, options);
}
public isActive(): boolean {
return this._choice;
}
public onAdd(map: L.Map): HTMLElement {
this._map = map;
this._container = L.DomUtil.create("div", "leaflet-bar leaflet-ruler");
L.DomEvent.disableClickPropagation(this._container);
L.DomEvent.on(this._container, "click", this._toggleMeasure, this);
this._defaultCursor = this._map.getContainer().style.cursor;
this._allLayers = L.layerGroup();
return this._container;
}
public onRemove(): void {
if (this._container) {
L.DomEvent.off(this._container, "click", this._toggleMeasure, this);
}
if (this._choice) {
this._toggleMeasure(); // Turn off measurements
}
}
private _toggleMeasure(): void {
this._choice = !this._choice;
this.options.events?.onToggle?.(this._choice);
this._clickedLatLong = null;
this._clickedPoints = [];
this._totalLength = 0;
if (!this._map || !this._container) return;
const mapContainer = this._map.getContainer();
if (this._choice) {
this._map.doubleClickZoom.disable();
L.DomEvent.on(mapContainer, "keydown", this._escape, this);
L.DomEvent.on(mapContainer, "dblclick", this._closePath, this);
this._container.classList.add("leaflet-ruler-clicked");
this._clickCount = 0;
this._tempLine = L.featureGroup().addTo(this._allLayers);
this._tempPoint = L.featureGroup().addTo(this._allLayers);
this._pointLayer = L.featureGroup().addTo(this._allLayers);
this._polylineLayer = L.featureGroup().addTo(this._allLayers);
this._allLayers.addTo(this._map);
mapContainer.style.cursor = "crosshair";
this._map.on("click", this._clicked, this);
this._map.on("mousemove", this._moving, this);
} else {
this._map.doubleClickZoom.enable();
L.DomEvent.off(mapContainer, "keydown", this._escape, this);
L.DomEvent.off(mapContainer, "dblclick", this._closePath, this);
this._container.classList.remove("leaflet-ruler-clicked");
this._map.removeLayer(this._allLayers);
this._allLayers = L.layerGroup();
mapContainer.style.cursor = this._defaultCursor;
this._map.off("click", this._clicked, this);
this._map.off("mousemove", this._moving, this);
}
}
private _clicked(e: L.LeafletMouseEvent): void {
// hack to prevent adding the same point twice on double click
let clickTime = Date.now();
if (clickTime - this._lastClickTime < 200) {
this._closePath();
return;
}
this._lastClickTime = clickTime;
this._clickedLatLong = e.latlng;
this._clickedPoints.push(this._clickedLatLong);
L.circleMarker(this._clickedLatLong, this.options.circleMarker).addTo(this._pointLayer);
if (this._clickCount > 0 && !e.latlng.equals(this._clickedPoints[this._clickedPoints.length - 2], 0.0001)) {
if (this._movingLatLong) {
L.polyline(
[this._clickedPoints[this._clickCount - 1], this._movingLatLong],
this.options.lineStyle
).addTo(this._polylineLayer);
}
let text: string;
this._totalLength += this._result.Distance;
const angleUnit = this.options.angleUnit!;
const lengthUnit = this.options.lengthUnit!;
if (this._clickCount > 1) {
text = `<b>${angleUnit.label}</b>&nbsp;${this._result.Bearing.toFixed(angleUnit.decimal)}&nbsp;${
angleUnit.display
}<br><b>${lengthUnit.label}</b>&nbsp;${this._totalLength.toFixed(lengthUnit.decimal)}&nbsp;${
lengthUnit.display
}`;
} else {
text = `<b>${angleUnit.label}</b>&nbsp;${this._result.Bearing.toFixed(angleUnit.decimal)}&nbsp;${
angleUnit.display
}<br><b>${lengthUnit.label}</b>&nbsp;${this._result.Distance.toFixed(lengthUnit.decimal)}&nbsp;${
lengthUnit.display
}`;
}
L.circleMarker(this._clickedLatLong, this.options.circleMarker)
.bindTooltip(text, { permanent: true, className: "result-tooltip" })
.addTo(this._pointLayer)
.openTooltip();
}
this._clickCount++;
}
private _moving(e: L.LeafletMouseEvent): void {
if (this._clickedLatLong && this._map) {
this._movingLatLong = e.latlng;
this._tempLine.clearLayers();
this._tempPoint.clearLayers();
this._calculateBearingAndDistance();
this._addedLength = this._result.Distance + this._totalLength;
L.polyline([this._clickedLatLong, this._movingLatLong], this.options.lineStyle).addTo(this._tempLine);
const angleUnit = this.options.angleUnit!;
const lengthUnit = this.options.lengthUnit!;
let text: string;
if (this._clickCount > 1) {
text = `<b>${angleUnit.label}</b>&nbsp;${this._result.Bearing.toFixed(angleUnit.decimal)}&nbsp;${
angleUnit.display
}<br><b>${lengthUnit.label}</b>&nbsp;${this._addedLength.toFixed(lengthUnit.decimal)}&nbsp;${
lengthUnit.display
}<br><div class="plus-length">(+${this._result.Distance.toFixed(lengthUnit.decimal)})</div>`;
} else {
text = `<b>${angleUnit.label}</b>&nbsp;${this._result.Bearing.toFixed(angleUnit.decimal)}&nbsp;${
angleUnit.display
}<br><b>${lengthUnit.label}</b>&nbsp;${this._result.Distance.toFixed(lengthUnit.decimal)}&nbsp;${
lengthUnit.display
}`;
}
L.circleMarker(this._movingLatLong, this.options.circleMarker)
.bindTooltip(text, { sticky: true, offset: L.point(0, -40), className: "moving-tooltip" })
.addTo(this._tempPoint)
.openTooltip();
}
}
private _escape(e: Event): void {
if ((e as KeyboardEvent).key === "Escape") {
if (this._clickCount > 0) {
this._closePath();
} else {
this._toggleMeasure();
}
}
}
private _calculateBearingAndDistance(): void {
if (!this._clickedLatLong || !this._movingLatLong) return;
const f1 = this._clickedLatLong.lat;
const l1 = this._clickedLatLong.lng;
const f2 = this._movingLatLong.lat;
const l2 = this._movingLatLong.lng;
const angleUnit = this.options.angleUnit!;
const lengthUnit = this.options.lengthUnit!;
const brng = bearingHaversine({ lat: f1, lng: l1 }, { lat: f2, lng: l2 });
const distance = distHaversine({ lat: f1, lng: l1 }, { lat: f2, lng: l2 });
if (angleUnit.factor) {
this._result.Bearing = brng * angleUnit.factor;
} else {
this._result.Bearing = brng;
}
if (lengthUnit.factor) {
this._result.Distance = distance * lengthUnit.factor;
} else {
this._result.Distance = distance;
}
this._result = {
Bearing: brng,
Distance: distance,
};
}
private _closePath(): void {
if (!this._map || !this._container) return;
this._map.removeLayer(this._tempLine);
this._map.removeLayer(this._tempPoint);
this._choice = false;
this._toggleMeasure();
}
}
// Factory function for creating the control, maintaining the Leaflet convention.
export const ruler = (options?: RulerOptions) => {
return new Ruler(options);
};

View file

@ -1,6 +1,5 @@
import { writable } from "svelte/store";
import type { LatLngExpression } from "leaflet";
import L from "leaflet";
import type { LatLngExpression } from "./types";
import { getCsrfToken } from "./auth";
import type { PredictionStage, RawPrediction, Prediction, Point } from "./types";
@ -129,7 +128,7 @@ export function parsePrediction(prediction: PredictionStage[]): Prediction {
if (lon > 180.0) {
lon -= 360.0;
}
launch.latlng = L.latLng([launchObj.latitude, lon, launchObj.altitude]);
launch.latlng = { lat: launchObj.latitude, lng: lon, alt: launchObj.altitude };
launch.datetime = new Date(launchObj.datetime);
const burstObj = descent[0];
@ -137,7 +136,7 @@ export function parsePrediction(prediction: PredictionStage[]): Prediction {
if (lon > 180.0) {
lon -= 360.0;
}
burst.latlng = L.latLng([burstObj.latitude, lon, burstObj.altitude]);
burst.latlng = { lat: burstObj.latitude, lng: lon, alt: burstObj.altitude };
burst.datetime = new Date(burstObj.datetime);
const landingObj = descent[descent.length - 1];
@ -145,7 +144,7 @@ export function parsePrediction(prediction: PredictionStage[]): Prediction {
if (lon > 180.0) {
lon -= 360.0;
}
landing.latlng = L.latLng([landingObj.latitude, lon, landingObj.altitude]);
landing.latlng = { lat: landingObj.latitude, lng: lon, alt: landingObj.altitude };
landing.datetime = new Date(landingObj.datetime);
const profile = prediction[1].stage === "descent" ? "standard_profile" : "float_profile";

View file

@ -1,5 +1,4 @@
import { writable } from "svelte/store"
import L from "leaflet";
import type { TelemetryPoint, Telemetry } from "./types";
@ -11,7 +10,7 @@ export function parseTelemetry(telemetry: TelemetryPoint[]): Telemetry {
]);
const launch = {
latlng: L.latLng(telemetry[0].latitude, telemetry[0].longitude),
latlng: { lat: telemetry[0].latitude, lng: telemetry[0].longitude },
datetime: new Date(telemetry[0].datetime)
};

View file

@ -1,4 +1,11 @@
import type { LatLngExpression, LatLngLiteral } from "leaflet";
// Define coordinate types (previously from Leaflet)
export type LatLngTuple = [number, number] | [number, number, number]; // Support 2D and 3D coordinates
export interface LatLngLiteral {
lat: number;
lng: number;
alt?: number; // Optional altitude
}
export type LatLngExpression = LatLngTuple | LatLngLiteral;
export const PROFILE_MAP = {
"Обычный": "standard_profile",