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 { api, ApiError, API_BASE_URL, setUnauthorizedHandler } from './client';
|
||||||
|
export { telemetryApi, buildWsUrl, type RawTelemetryPacket } from './telemetry';
|
||||||
export { pointsApi } from './points';
|
export { pointsApi } from './points';
|
||||||
export { profilesApi } from './profiles';
|
export { profilesApi } from './profiles';
|
||||||
export { scenariosApi } from './scenarios';
|
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">
|
<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 { CollapsibleCard } from '$ui';
|
||||||
import { t } from '$i18n';
|
import { t } from '$i18n';
|
||||||
|
import { telemetryStore } from './telemetryStore.svelte';
|
||||||
|
|
||||||
// Placeholder — wire to the tracking telemetry store once the tracking
|
let satelliteInput = $state('');
|
||||||
// pipeline has a real data source.
|
|
||||||
let telemetry: { latitude?: number; longitude?: number; altitude?: number } = $state({
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
latitude: 56.3576,
|
idle: 'secondary',
|
||||||
longitude: 39.8666,
|
connecting: 'warning',
|
||||||
altitude: 1000,
|
connected: 'success',
|
||||||
|
error: 'danger',
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleConnect() {
|
||||||
|
const id = satelliteInput.trim();
|
||||||
|
if (id) telemetryStore.connect(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDisconnect() {
|
||||||
|
telemetryStore.disconnect();
|
||||||
|
satelliteInput = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
telemetryStore.disconnect();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CollapsibleCard title={$t('nav.track')}>
|
<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">
|
<FormGroup spacing="mb-2">
|
||||||
<Label class="small">{$t('points.lat')}</Label>
|
<Label class="small">{$t('points.lat')}</Label>
|
||||||
<InputGroup size="sm">
|
<InputGroup size="sm">
|
||||||
<Input type="text" value={telemetry.latitude ?? 'N/A'} readonly />
|
<Input type="text" value={telemetryStore.latest.latitude.toFixed(6)} readonly />
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|
||||||
<FormGroup spacing="mb-2">
|
<FormGroup spacing="mb-2">
|
||||||
<Label class="small">{$t('points.lon')}</Label>
|
<Label class="small">{$t('points.lon')}</Label>
|
||||||
<InputGroup size="sm">
|
<InputGroup size="sm">
|
||||||
<Input type="text" value={telemetry.longitude ?? 'N/A'} readonly />
|
<Input type="text" value={telemetryStore.latest.longitude.toFixed(6)} readonly />
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|
||||||
<FormGroup spacing="mb-2">
|
<FormGroup spacing="mb-2">
|
||||||
<Label class="small">{$t('points.alt')}</Label>
|
<Label class="small">{$t('points.alt')}</Label>
|
||||||
<InputGroup size="sm">
|
<InputGroup size="sm">
|
||||||
<Input type="text" value={telemetry.altitude ?? 'N/A'} readonly />
|
<Input type="text" value={telemetryStore.latest.altitude.toFixed(1)} readonly />
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
</FormGroup>
|
</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>
|
</CollapsibleCard>
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
export { default as TelemetryPanel } from './TelemetryPanel.svelte';
|
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",
|
"header": "Coordinate select mode",
|
||||||
"body": "Click the map to pick coordinates"
|
"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": {
|
"forecast": {
|
||||||
"success": "Forecast request",
|
"success": "Forecast request",
|
||||||
"successBody": "Forecast request successful!",
|
"successBody": "Forecast request successful!",
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,19 @@
|
||||||
"header": "Режим выбора координат",
|
"header": "Режим выбора координат",
|
||||||
"body": "Кликните на карту, чтобы выбрать координаты"
|
"body": "Кликните на карту, чтобы выбрать координаты"
|
||||||
},
|
},
|
||||||
|
"tracking": {
|
||||||
|
"satelliteId": "ID спутника",
|
||||||
|
"satelliteIdPlaceholder": "Введите UUID спутника...",
|
||||||
|
"connect": "Подключиться",
|
||||||
|
"disconnect": "Отключиться",
|
||||||
|
"status": "Статус",
|
||||||
|
"status_idle": "Ожидание",
|
||||||
|
"status_connecting": "Подключение",
|
||||||
|
"status_connected": "Подключено",
|
||||||
|
"status_error": "Ошибка",
|
||||||
|
"packetCount": "Получено пакетов: {count}",
|
||||||
|
"waitingData": "Ожидание данных..."
|
||||||
|
},
|
||||||
"forecast": {
|
"forecast": {
|
||||||
"success": "Запрос прогноза",
|
"success": "Запрос прогноза",
|
||||||
"successBody": "Запрос прогноза успешно выполнен!",
|
"successBody": "Запрос прогноза успешно выполнен!",
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,78 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { Map as MapView } from '$map';
|
import { Map as MapView, plotAnimatedMarker, type IMap } from '$map';
|
||||||
import { Navbar } from '$features/auth';
|
import { Navbar } from '$features/auth';
|
||||||
import { PanelContainer } from '$ui';
|
import { PanelContainer } from '$ui';
|
||||||
import { TelemetryPanel } from '$features/tracking';
|
import { TelemetryPanel, telemetryStore } from '$features/tracking';
|
||||||
import { requireAuthenticated } from '$auth';
|
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(() => {
|
onMount(() => {
|
||||||
requireAuthenticated('/login');
|
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>
|
</script>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<div style="height: var(--navbar-height);"></div>
|
<div style="height: var(--navbar-height);"></div>
|
||||||
<MapView>
|
<MapView {onMapReady}>
|
||||||
<PanelContainer position="left">
|
<PanelContainer position="left">
|
||||||
<TelemetryPanel />
|
<TelemetryPanel />
|
||||||
</PanelContainer>
|
</PanelContainer>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue