added tracking feature(not tested)

This commit is contained in:
Vasilisk9812 2026-05-23 13:04:06 +09:00
parent 7a2278a42e
commit ea61e157ab
8 changed files with 309 additions and 25 deletions

View file

@ -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
View 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;
},
};

View file

@ -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('points.lat')}</Label>
<InputGroup size="sm">
<Input type="text" value={telemetry.latitude ?? 'N/A'} readonly />
</InputGroup>
<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">
<Label class="small">{$t('points.lon')}</Label>
<InputGroup size="sm">
<Input type="text" value={telemetry.longitude ?? 'N/A'} readonly />
</InputGroup>
<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>
<FormGroup spacing="mb-2">
<Label class="small">{$t('points.alt')}</Label>
<InputGroup size="sm">
<Input type="text" value={telemetry.altitude ?? 'N/A'} readonly />
</InputGroup>
</FormGroup>
{#if telemetryStore.latest}
<FormGroup spacing="mb-2">
<Label class="small">{$t('points.lat')}</Label>
<InputGroup size="sm">
<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={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={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>

View file

@ -1 +1,2 @@
export { default as TelemetryPanel } from './TelemetryPanel.svelte';
export { telemetryStore, type TrackingStatus } from './telemetryStore.svelte';

View 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();

View file

@ -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!",

View file

@ -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": "Запрос прогноза успешно выполнен!",

View file

@ -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>