119 lines
3.3 KiB
Svelte
119 lines
3.3 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { Map as MapView, plotAnimatedMarker, plotPrediction, type IMap } from '$map';
|
|
import { Navbar } from '$features/auth';
|
|
import { PanelContainer, CollapsibleCard } from '$ui';
|
|
import { TelemetryPanel, DeviationChart, telemetryStore } from '$features/tracking';
|
|
import { workspacesStore } from '$features/workspaces';
|
|
import { t } from '$i18n';
|
|
import { requireAuthenticated } from '$auth';
|
|
import { parseTelemetry } from '$domain';
|
|
import type { Prediction } from '$domain';
|
|
|
|
let selectedId = $state('');
|
|
const workspacesWithResult = $derived($workspacesStore.items.filter((w) => w.result !== null));
|
|
const selectedPrediction = $derived<Prediction | null>(
|
|
workspacesWithResult.find((w) => w.id === selectedId)?.result ?? null,
|
|
);
|
|
|
|
let map = $state<IMap | null>(null);
|
|
// Tracks whether we've already fitted the map to the initial history load.
|
|
// Not reactive — written as a side effect inside $effect to avoid re-triggering.
|
|
let fittedBounds = false;
|
|
|
|
onMount(() => {
|
|
requireAuthenticated('/login');
|
|
});
|
|
|
|
onDestroy(() => {
|
|
map?.disposeScene('telemetry');
|
|
map?.disposeScene('prediction');
|
|
});
|
|
|
|
$effect(() => {
|
|
if (!map) return;
|
|
const scene = map.scene('prediction');
|
|
if (selectedPrediction) {
|
|
plotPrediction(scene, selectedPrediction, { color: '#1565C0', opacity: 0.7 });
|
|
} else {
|
|
scene.clear();
|
|
}
|
|
});
|
|
|
|
function onMapReady(m: IMap) {
|
|
map = m;
|
|
}
|
|
|
|
$effect(() => {
|
|
if (!map) return;
|
|
|
|
const points = telemetryStore.points;
|
|
const scene = map.scene('telemetry');
|
|
|
|
if (points.length === 0) {
|
|
scene.clear();
|
|
fittedBounds = false;
|
|
return;
|
|
}
|
|
|
|
let telemetry;
|
|
try {
|
|
telemetry = parseTelemetry(points);
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
const latest = points[points.length - 1];
|
|
|
|
scene.clear();
|
|
|
|
scene.addLine('path', {
|
|
coords: telemetry.flight_path,
|
|
color: '#FF1744',
|
|
width: 3,
|
|
});
|
|
|
|
scene.addMarker('launch', {
|
|
lngLat: [telemetry.launch.latlng.lng, telemetry.launch.latlng.lat],
|
|
iconUrl: '/target-blue.png',
|
|
iconSize: [12, 12],
|
|
popupHtml: `<b>Launch</b><br>${telemetry.launch.latlng.lat.toFixed(6)}, ${telemetry.launch.latlng.lng.toFixed(6)}`,
|
|
});
|
|
|
|
plotAnimatedMarker(scene, latest.longitude, latest.latitude);
|
|
|
|
if (!fittedBounds && telemetry.flight_path.length > 0) {
|
|
map.fitBounds(telemetry.flight_path, 50);
|
|
fittedBounds = true;
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<main>
|
|
<Navbar />
|
|
<div style="height: var(--navbar-height);"></div>
|
|
<MapView onReady={onMapReady}>
|
|
<PanelContainer position="left">
|
|
<TelemetryPanel />
|
|
</PanelContainer>
|
|
<PanelContainer position="right">
|
|
<CollapsibleCard title={$t('tracking.deviation')}>
|
|
<div class="mb-2">
|
|
<label for="forecast-select" class="form-label small mb-1">{$t('tracking.selectForecast')}</label>
|
|
<select
|
|
id="forecast-select"
|
|
class="form-select form-select-sm"
|
|
bind:value={selectedId}
|
|
disabled={workspacesWithResult.length === 0}
|
|
>
|
|
<option value="">{$t('tracking.noForecast')}</option>
|
|
{#each workspacesWithResult as w (w.id)}
|
|
<option value={w.id}>{w.name}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
<DeviationChart points={telemetryStore.points} prediction={selectedPrediction} />
|
|
</CollapsibleCard>
|
|
</PanelContainer>
|
|
</MapView>
|
|
</main>
|