polish #14
8 changed files with 309 additions and 25 deletions
|
|
@ -1,4 +1,5 @@
|
|||
export { api, ApiError, API_BASE_URL, setUnauthorizedHandler } from './client';
|
||||
export { telemetryApi, buildWsUrl, type RawTelemetryPacket } from './telemetry';
|
||||
export { pointsApi } from './points';
|
||||
export { profilesApi } from './profiles';
|
||||
export { scenariosApi } from './scenarios';
|
||||
|
|
|
|||
35
src/lib/api/telemetry.ts
Normal file
35
src/lib/api/telemetry.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { api, API_BASE_URL } from './client';
|
||||
|
||||
export interface RawTelemetryPacket {
|
||||
id: string;
|
||||
timestamp: number; // unix seconds
|
||||
lat: number;
|
||||
lon: number;
|
||||
alt: number;
|
||||
payload: Record<string, unknown>;
|
||||
raw_data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Derives a WebSocket URL from the configured API base URL. */
|
||||
export function buildWsUrl(satelliteId: string): string {
|
||||
let base = API_BASE_URL;
|
||||
if (!base.startsWith('http')) {
|
||||
base = `${window.location.protocol}//${window.location.host}${base}`;
|
||||
}
|
||||
const url = new URL(base);
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${url.origin}${url.pathname}/ws/satellite/${satelliteId}/telemetry/`;
|
||||
}
|
||||
|
||||
export const telemetryApi = {
|
||||
fetchHistory: async (
|
||||
satelliteId: string,
|
||||
params?: { from?: number; till?: number },
|
||||
): Promise<RawTelemetryPacket[]> => {
|
||||
const res = await api.get<RawTelemetryPacket[] | { results: RawTelemetryPacket[] }>(
|
||||
`/${satelliteId}/telemetry/`,
|
||||
{ query: params },
|
||||
);
|
||||
return Array.isArray(res) ? res : res.results;
|
||||
},
|
||||
};
|
||||
|
|
@ -1,36 +1,100 @@
|
|||
<script lang="ts">
|
||||
import { FormGroup, Label, Input, InputGroup } from '@sveltestrap/sveltestrap';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { FormGroup, Label, Input, InputGroup, Button, Badge } from '@sveltestrap/sveltestrap';
|
||||
import { CollapsibleCard } from '$ui';
|
||||
import { t } from '$i18n';
|
||||
import { telemetryStore } from './telemetryStore.svelte';
|
||||
|
||||
// Placeholder — wire to the tracking telemetry store once the tracking
|
||||
// pipeline has a real data source.
|
||||
let telemetry: { latitude?: number; longitude?: number; altitude?: number } = $state({
|
||||
latitude: 56.3576,
|
||||
longitude: 39.8666,
|
||||
altitude: 1000,
|
||||
let satelliteInput = $state('');
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
idle: 'secondary',
|
||||
connecting: 'warning',
|
||||
connected: 'success',
|
||||
error: 'danger',
|
||||
};
|
||||
|
||||
function handleConnect() {
|
||||
const id = satelliteInput.trim();
|
||||
if (id) telemetryStore.connect(id);
|
||||
}
|
||||
|
||||
function handleDisconnect() {
|
||||
telemetryStore.disconnect();
|
||||
satelliteInput = '';
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
telemetryStore.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<CollapsibleCard title={$t('nav.track')}>
|
||||
<FormGroup spacing="mb-2">
|
||||
<Label class="small">{$t('tracking.satelliteId')}</Label>
|
||||
<div class="d-flex gap-1">
|
||||
<Input
|
||||
type="text"
|
||||
size="sm"
|
||||
bind:value={satelliteInput}
|
||||
placeholder={$t('tracking.satelliteIdPlaceholder')}
|
||||
disabled={telemetryStore.status !== 'idle'}
|
||||
/>
|
||||
{#if telemetryStore.status === 'idle'}
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
onclick={handleConnect}
|
||||
disabled={!satelliteInput.trim()}
|
||||
>
|
||||
{$t('tracking.connect')}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button size="sm" color="secondary" onclick={handleDisconnect}>
|
||||
{$t('tracking.disconnect')}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup spacing="mb-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<Label class="small mb-0">{$t('tracking.status')}</Label>
|
||||
<Badge color={STATUS_COLOR[telemetryStore.status]}>
|
||||
{$t(`tracking.status_${telemetryStore.status}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
{#if telemetryStore.error}
|
||||
<small class="text-danger">{telemetryStore.error}</small>
|
||||
{/if}
|
||||
</FormGroup>
|
||||
|
||||
{#if telemetryStore.latest}
|
||||
<FormGroup spacing="mb-2">
|
||||
<Label class="small">{$t('points.lat')}</Label>
|
||||
<InputGroup size="sm">
|
||||
<Input type="text" value={telemetry.latitude ?? 'N/A'} readonly />
|
||||
<Input type="text" value={telemetryStore.latest.latitude.toFixed(6)} readonly />
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup spacing="mb-2">
|
||||
<Label class="small">{$t('points.lon')}</Label>
|
||||
<InputGroup size="sm">
|
||||
<Input type="text" value={telemetry.longitude ?? 'N/A'} readonly />
|
||||
<Input type="text" value={telemetryStore.latest.longitude.toFixed(6)} readonly />
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup spacing="mb-2">
|
||||
<Label class="small">{$t('points.alt')}</Label>
|
||||
<InputGroup size="sm">
|
||||
<Input type="text" value={telemetry.altitude ?? 'N/A'} readonly />
|
||||
<Input type="text" value={telemetryStore.latest.altitude.toFixed(1)} readonly />
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
|
||||
<small class="text-muted">
|
||||
{$t('tracking.packetCount', { count: telemetryStore.points.length })}
|
||||
</small>
|
||||
{:else if telemetryStore.status !== 'idle'}
|
||||
<small class="text-muted">{$t('tracking.waitingData')}</small>
|
||||
{/if}
|
||||
</CollapsibleCard>
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
export { default as TelemetryPanel } from './TelemetryPanel.svelte';
|
||||
export { telemetryStore, type TrackingStatus } from './telemetryStore.svelte';
|
||||
|
|
|
|||
99
src/lib/features/tracking/telemetryStore.svelte.ts
Normal file
99
src/lib/features/tracking/telemetryStore.svelte.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { telemetryApi, buildWsUrl, type RawTelemetryPacket } from '$api/telemetry';
|
||||
import { parseTelemetry, type TelemetryPoint, type Telemetry } from '$domain';
|
||||
|
||||
export type TrackingStatus = 'idle' | 'connecting' | 'connected' | 'error';
|
||||
|
||||
function toPoint(p: RawTelemetryPacket): TelemetryPoint {
|
||||
return {
|
||||
latitude: p.lat,
|
||||
longitude: p.lon,
|
||||
altitude: p.alt,
|
||||
datetime: new Date(p.timestamp * 1000).toISOString(),
|
||||
payload: JSON.stringify(p.payload),
|
||||
};
|
||||
}
|
||||
|
||||
class TelemetryStore {
|
||||
satelliteId = $state('');
|
||||
status = $state<TrackingStatus>('idle');
|
||||
error = $state<string | null>(null);
|
||||
points = $state<TelemetryPoint[]>([]);
|
||||
|
||||
#ws: WebSocket | null = null;
|
||||
|
||||
get latest(): TelemetryPoint | null {
|
||||
return this.points[this.points.length - 1] ?? null;
|
||||
}
|
||||
|
||||
get telemetry(): Telemetry | null {
|
||||
if (this.points.length === 0) return null;
|
||||
try {
|
||||
return parseTelemetry(this.points);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async connect(id: string): Promise<void> {
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
if (!UUID_RE.test(id)) {
|
||||
this.status = 'error';
|
||||
this.error = `Invalid satellite ID — expected a UUID (e.g. 550e8400-e29b-41d4-a716-446655440000)`;
|
||||
return;
|
||||
}
|
||||
|
||||
this.disconnect();
|
||||
this.satelliteId = id;
|
||||
this.status = 'connecting';
|
||||
this.error = null;
|
||||
this.points = [];
|
||||
|
||||
// Load historical packets first — non-fatal if it fails
|
||||
try {
|
||||
const history = await telemetryApi.fetchHistory(id);
|
||||
// API returns newest-first; reverse to chronological order
|
||||
this.points = [...history].reverse().map(toPoint);
|
||||
} catch (e) {
|
||||
console.warn('[telemetry] history fetch failed:', e);
|
||||
}
|
||||
|
||||
const ws = new WebSocket(buildWsUrl(id));
|
||||
this.#ws = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
this.status = 'connected';
|
||||
};
|
||||
|
||||
ws.onmessage = ({ data }) => {
|
||||
try {
|
||||
const packet = JSON.parse(data) as { error?: string } & RawTelemetryPacket;
|
||||
if (!packet.error) {
|
||||
this.points = [...this.points, toPoint(packet)];
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed frames
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
this.status = 'error';
|
||||
this.error = 'WebSocket connection failed';
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (this.status !== 'idle') this.status = 'idle';
|
||||
this.#ws = null;
|
||||
};
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.#ws?.close();
|
||||
this.#ws = null;
|
||||
this.status = 'idle';
|
||||
this.satelliteId = '';
|
||||
this.points = [];
|
||||
this.error = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const telemetryStore = new TelemetryStore();
|
||||
|
|
@ -163,6 +163,19 @@
|
|||
"header": "Coordinate select mode",
|
||||
"body": "Click the map to pick coordinates"
|
||||
},
|
||||
"tracking": {
|
||||
"satelliteId": "Satellite ID",
|
||||
"satelliteIdPlaceholder": "Enter satellite UUID...",
|
||||
"connect": "Connect",
|
||||
"disconnect": "Disconnect",
|
||||
"status": "Status",
|
||||
"status_idle": "Idle",
|
||||
"status_connecting": "Connecting",
|
||||
"status_connected": "Connected",
|
||||
"status_error": "Error",
|
||||
"packetCount": "{count} packets received",
|
||||
"waitingData": "Waiting for data..."
|
||||
},
|
||||
"forecast": {
|
||||
"success": "Forecast request",
|
||||
"successBody": "Forecast request successful!",
|
||||
|
|
|
|||
|
|
@ -163,6 +163,19 @@
|
|||
"header": "Режим выбора координат",
|
||||
"body": "Кликните на карту, чтобы выбрать координаты"
|
||||
},
|
||||
"tracking": {
|
||||
"satelliteId": "ID спутника",
|
||||
"satelliteIdPlaceholder": "Введите UUID спутника...",
|
||||
"connect": "Подключиться",
|
||||
"disconnect": "Отключиться",
|
||||
"status": "Статус",
|
||||
"status_idle": "Ожидание",
|
||||
"status_connecting": "Подключение",
|
||||
"status_connected": "Подключено",
|
||||
"status_error": "Ошибка",
|
||||
"packetCount": "Получено пакетов: {count}",
|
||||
"waitingData": "Ожидание данных..."
|
||||
},
|
||||
"forecast": {
|
||||
"success": "Запрос прогноза",
|
||||
"successBody": "Запрос прогноза успешно выполнен!",
|
||||
|
|
|
|||
|
|
@ -1,20 +1,78 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Map as MapView } from '$map';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { Map as MapView, plotAnimatedMarker, type IMap } from '$map';
|
||||
import { Navbar } from '$features/auth';
|
||||
import { PanelContainer } from '$ui';
|
||||
import { TelemetryPanel } from '$features/tracking';
|
||||
import { TelemetryPanel, telemetryStore } from '$features/tracking';
|
||||
import { requireAuthenticated } from '$auth';
|
||||
import { parseTelemetry } from '$domain';
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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>
|
||||
<MapView {onMapReady}>
|
||||
<PanelContainer position="left">
|
||||
<TelemetryPanel />
|
||||
</PanelContainer>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue