feat: polish #13
13 changed files with 392 additions and 16 deletions
80
src/lib/domain/boundingBox.ts
Normal file
80
src/lib/domain/boundingBox.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import type { LatLngTuple } from './geo';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Axis-aligned geographic bounding box around a flight path, with an optional
|
||||||
|
* margin so recovery teams get a box that clears the trajectory by a set
|
||||||
|
* distance rather than hugging its extreme points.
|
||||||
|
*/
|
||||||
|
export interface BoundingBox {
|
||||||
|
south: number;
|
||||||
|
west: number;
|
||||||
|
north: number;
|
||||||
|
east: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default clearance (km) between the box edge and the nearest trajectory point. */
|
||||||
|
export const DEFAULT_BBOX_MARGIN_KM = 5;
|
||||||
|
|
||||||
|
// Mean length of one degree of latitude. Longitude degrees shrink toward the
|
||||||
|
// poles, handled below via cos(latitude).
|
||||||
|
const KM_PER_DEG_LAT = 111.32;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the bounding box of a flight path, expanded outward by `marginKm`.
|
||||||
|
* Returns null for an empty path (callers treat that as "nothing to draw").
|
||||||
|
*/
|
||||||
|
export function computeBoundingBox(
|
||||||
|
path: LatLngTuple[],
|
||||||
|
marginKm = DEFAULT_BBOX_MARGIN_KM,
|
||||||
|
): BoundingBox | null {
|
||||||
|
if (path.length === 0) return null;
|
||||||
|
|
||||||
|
let south = Infinity;
|
||||||
|
let north = -Infinity;
|
||||||
|
let west = Infinity;
|
||||||
|
let east = -Infinity;
|
||||||
|
for (const [lat, lng] of path) {
|
||||||
|
if (lat < south) south = lat;
|
||||||
|
if (lat > north) north = lat;
|
||||||
|
if (lng < west) west = lng;
|
||||||
|
if (lng > east) east = lng;
|
||||||
|
}
|
||||||
|
|
||||||
|
const margin = Math.max(0, marginKm);
|
||||||
|
const dLat = margin / KM_PER_DEG_LAT;
|
||||||
|
// ponytail: flat-earth degree conversion — fine at flight scales (<1000 km).
|
||||||
|
// Size the longitude margin at the latitude nearest a pole so the box never
|
||||||
|
// comes in tighter than requested along the whole band.
|
||||||
|
const maxAbsLat = Math.max(Math.abs(south), Math.abs(north));
|
||||||
|
const kmPerDegLng = KM_PER_DEG_LAT * Math.cos((maxAbsLat * Math.PI) / 180);
|
||||||
|
const dLng = kmPerDegLng > 0 ? margin / kmPerDegLng : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
south: south - dLat,
|
||||||
|
north: north + dLat,
|
||||||
|
west: west - dLng,
|
||||||
|
east: east + dLng,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closed ring (corners + repeated start) for drawing the box as a polyline. */
|
||||||
|
export function boundingBoxRing(box: BoundingBox): LatLngTuple[] {
|
||||||
|
return [
|
||||||
|
[box.south, box.west],
|
||||||
|
[box.north, box.west],
|
||||||
|
[box.north, box.east],
|
||||||
|
[box.south, box.east],
|
||||||
|
[box.south, box.west],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Corner coordinates as copyable "lat, lng" lines (NW, NE, SE, SW). */
|
||||||
|
export function formatBoundingBox(box: BoundingBox): string {
|
||||||
|
const fmt = (lat: number, lng: number) => `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
|
||||||
|
return [
|
||||||
|
fmt(box.north, box.west),
|
||||||
|
fmt(box.north, box.east),
|
||||||
|
fmt(box.south, box.east),
|
||||||
|
fmt(box.south, box.west),
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
@ -4,3 +4,4 @@ export * from './scenario';
|
||||||
export * from './prediction';
|
export * from './prediction';
|
||||||
export * from './telemetry';
|
export * from './telemetry';
|
||||||
export * from './wind';
|
export * from './wind';
|
||||||
|
export * from './boundingBox';
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import { getMap, plotPrediction, plotAnimatedMarker, plotEndMarker } from '$map';
|
import { getMap, plotPrediction, plotAnimatedMarker, plotEndMarker, plotBoundingBox } from '$map';
|
||||||
import { timelineStore } from '$features/timeline/store';
|
import { timelineStore } from '$features/timeline/store';
|
||||||
import { workspacesStore } from './store';
|
import { workspacesStore } from './store';
|
||||||
import type { Workspace } from './types';
|
import type { Workspace } from './types';
|
||||||
import type { LatLngTuple } from '$domain';
|
import { computeBoundingBox, DEFAULT_BBOX_MARGIN_KM, type LatLngTuple } from '$domain';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders every workspace onto the shared map. Each workspace gets its own
|
* Renders every workspace onto the shared map. Each workspace gets its own
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
// belong to workspaces which were removed from the store since last tick.
|
// belong to workspaces which were removed from the store since last tick.
|
||||||
const ownedPlotScenes = new Set<string>();
|
const ownedPlotScenes = new Set<string>();
|
||||||
const ownedCursorScenes = new Set<string>();
|
const ownedCursorScenes = new Set<string>();
|
||||||
|
const ownedBboxScenes = new Set<string>();
|
||||||
// Last-rendered state per workspace scene — skip re-plot when nothing changed.
|
// Last-rendered state per workspace scene — skip re-plot when nothing changed.
|
||||||
const plotCache = new Map<string, { result: unknown; color: string; opacity: number }>();
|
const plotCache = new Map<string, { result: unknown; color: string; opacity: number }>();
|
||||||
// Cursor scenes that have reached their flight end and show a static end marker.
|
// Cursor scenes that have reached their flight end and show a static end marker.
|
||||||
|
|
@ -29,6 +30,7 @@
|
||||||
|
|
||||||
const sceneName = (w: Workspace) => `ws/${w.id}`;
|
const sceneName = (w: Workspace) => `ws/${w.id}`;
|
||||||
const cursorName = (w: Workspace) => `cursor/${w.id}`;
|
const cursorName = (w: Workspace) => `cursor/${w.id}`;
|
||||||
|
const bboxName = (w: Workspace) => `bbox/${w.id}`;
|
||||||
|
|
||||||
function updateGlobalRange(items: Workspace[]) {
|
function updateGlobalRange(items: Workspace[]) {
|
||||||
let maxDuration = 0;
|
let maxDuration = 0;
|
||||||
|
|
@ -85,6 +87,36 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderBboxes(items: Workspace[]) {
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const live = new Set<string>();
|
||||||
|
for (const w of items) {
|
||||||
|
const name = bboxName(w);
|
||||||
|
live.add(name);
|
||||||
|
const box =
|
||||||
|
w.visible && w.result && w.bboxVisible
|
||||||
|
? computeBoundingBox(w.result.flight_path, w.bboxMargin ?? DEFAULT_BBOX_MARGIN_KM)
|
||||||
|
: null;
|
||||||
|
if (!box) {
|
||||||
|
if (ownedBboxScenes.has(name)) {
|
||||||
|
map.disposeScene(name);
|
||||||
|
ownedBboxScenes.delete(name);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
plotBoundingBox(map.scene(name), box);
|
||||||
|
ownedBboxScenes.add(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name of Array.from(ownedBboxScenes)) {
|
||||||
|
if (!live.has(name)) {
|
||||||
|
map.disposeScene(name);
|
||||||
|
ownedBboxScenes.delete(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function positionAt(path: LatLngTuple[], elapsed: number, durationMs: number): LatLngTuple | null {
|
function positionAt(path: LatLngTuple[], elapsed: number, durationMs: number): LatLngTuple | null {
|
||||||
if (path.length === 0) return null;
|
if (path.length === 0) return null;
|
||||||
if (durationMs === 0) return path[0];
|
if (durationMs === 0) return path[0];
|
||||||
|
|
@ -150,6 +182,7 @@
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const items = $workspacesStore.items;
|
const items = $workspacesStore.items;
|
||||||
renderAll(items);
|
renderAll(items);
|
||||||
|
renderBboxes(items);
|
||||||
updateGlobalRange(items);
|
updateGlobalRange(items);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -161,8 +194,10 @@
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
for (const name of ownedPlotScenes) map.disposeScene(name);
|
for (const name of ownedPlotScenes) map.disposeScene(name);
|
||||||
for (const name of ownedCursorScenes) map.disposeScene(name);
|
for (const name of ownedCursorScenes) map.disposeScene(name);
|
||||||
|
for (const name of ownedBboxScenes) map.disposeScene(name);
|
||||||
ownedPlotScenes.clear();
|
ownedPlotScenes.clear();
|
||||||
ownedCursorScenes.clear();
|
ownedCursorScenes.clear();
|
||||||
|
ownedBboxScenes.clear();
|
||||||
doneCursorScenes.clear();
|
doneCursorScenes.clear();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
import { t } from '$i18n';
|
import { t } from '$i18n';
|
||||||
import { workspacesStore } from './store';
|
import { workspacesStore } from './store';
|
||||||
import type { Workspace } from './types';
|
import type { Workspace } from './types';
|
||||||
|
import { computeBoundingBox, formatBoundingBox, DEFAULT_BBOX_MARGIN_KM } from '$domain';
|
||||||
|
|
||||||
let toDelete = $state<Workspace | null>(null);
|
let toDelete = $state<Workspace | null>(null);
|
||||||
let busy = $state<Record<string, boolean>>({});
|
let busy = $state<Record<string, boolean>>({});
|
||||||
|
|
@ -53,6 +54,44 @@
|
||||||
workspacesStore.remove(toDelete.id);
|
workspacesStore.remove(toDelete.id);
|
||||||
toDelete = null;
|
toDelete = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleBboxMargin(w: Workspace, km: number) {
|
||||||
|
if (!Number.isFinite(km) || km < 0) return;
|
||||||
|
workspacesStore.patch(w.id, { bboxMargin: km });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleBbox(w: Workspace) {
|
||||||
|
workspacesStore.patch(w.id, { bboxVisible: !w.bboxVisible });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopyBbox(text: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
addToast({ header: $t('common.success'), body: $t('bbox.copied'), color: 'success' });
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// Fall through to the execCommand fallback (older browsers / blocked API).
|
||||||
|
}
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.opacity = '0';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.focus();
|
||||||
|
ta.select();
|
||||||
|
let ok = false;
|
||||||
|
try {
|
||||||
|
ok = document.execCommand('copy');
|
||||||
|
} catch {
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
ta.remove();
|
||||||
|
addToast(
|
||||||
|
ok
|
||||||
|
? { header: $t('common.success'), body: $t('bbox.copied'), color: 'success' }
|
||||||
|
: { header: $t('common.error'), body: $t('bbox.copyError'), color: 'danger' },
|
||||||
|
);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CollapsibleCard title={$t('workspaces.title')}>
|
<CollapsibleCard title={$t('workspaces.title')}>
|
||||||
|
|
@ -134,6 +173,49 @@
|
||||||
{#if w.lastRunError}
|
{#if w.lastRunError}
|
||||||
<div class="text-danger small mt-1">{w.lastRunError}</div>
|
<div class="text-danger small mt-1">{w.lastRunError}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if w.result}
|
||||||
|
{@const box = computeBoundingBox(w.result.flight_path, w.bboxMargin ?? DEFAULT_BBOX_MARGIN_KM)}
|
||||||
|
<div class="mt-2 pt-2 border-top">
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-2 small">
|
||||||
|
<span class="text-nowrap">{$t('bbox.margin')}</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
class="form-control-sm"
|
||||||
|
style="max-width: 6rem;"
|
||||||
|
min="0"
|
||||||
|
step="0.5"
|
||||||
|
value={w.bboxMargin ?? DEFAULT_BBOX_MARGIN_KM}
|
||||||
|
oninput={(e) =>
|
||||||
|
handleBboxMargin(w, parseFloat((e.currentTarget as HTMLInputElement).value))} />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color={w.bboxVisible ? 'primary' : 'outline-primary'}
|
||||||
|
class="w-100"
|
||||||
|
onclick={() => handleToggleBbox(w)}>
|
||||||
|
<Icon name="bounding-box" />
|
||||||
|
{w.bboxVisible ? $t('bbox.hide') : $t('bbox.generate')}
|
||||||
|
</Button>
|
||||||
|
{#if w.bboxVisible && box}
|
||||||
|
{@const coords = formatBoundingBox(box)}
|
||||||
|
<textarea
|
||||||
|
class="form-control form-control-sm mt-2 font-monospace"
|
||||||
|
style="resize: none;"
|
||||||
|
rows="4"
|
||||||
|
readonly
|
||||||
|
aria-label={$t('bbox.coords')}>{coords}</textarea>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="secondary"
|
||||||
|
class="w-100 mt-1"
|
||||||
|
onclick={() => handleCopyBbox(coords)}>
|
||||||
|
<Icon name="clipboard" />
|
||||||
|
{$t('bbox.copy')}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { persisted } from '$state';
|
import { persisted } from '$state';
|
||||||
import { DEFAULT_FLIGHT_PARAMETERS, type FlightParameters } from '$domain';
|
import { DEFAULT_FLIGHT_PARAMETERS, DEFAULT_BBOX_MARGIN_KM, type FlightParameters } from '$domain';
|
||||||
import type { Prediction } from '$domain';
|
import type { Prediction } from '$domain';
|
||||||
import { predictionsApi } from '$api';
|
import { predictionsApi } from '$api';
|
||||||
import { parsePrediction } from '$domain';
|
import { parsePrediction } from '$domain';
|
||||||
|
|
@ -35,6 +35,8 @@ function makeWorkspace(init: WorkspaceInit = {}, index = 0): Workspace {
|
||||||
launchDate: init.launchDate ?? todayDate(),
|
launchDate: init.launchDate ?? todayDate(),
|
||||||
launchTime: init.launchTime ?? '12:00:00',
|
launchTime: init.launchTime ?? '12:00:00',
|
||||||
result: null,
|
result: null,
|
||||||
|
bboxMargin: DEFAULT_BBOX_MARGIN_KM,
|
||||||
|
bboxVisible: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@ export interface Workspace {
|
||||||
launchTime: string;
|
launchTime: string;
|
||||||
result: Prediction | null;
|
result: Prediction | null;
|
||||||
lastRunError?: string;
|
lastRunError?: string;
|
||||||
|
/** Margin (km) between the generated bounding box and the nearest trajectory point. */
|
||||||
|
bboxMargin?: number;
|
||||||
|
/** Whether the bounding box is currently generated/shown for this workspace. */
|
||||||
|
bboxVisible?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceInit {
|
export interface WorkspaceInit {
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,15 @@
|
||||||
"deleteConfirm": "Delete workspace \"{name}\"?",
|
"deleteConfirm": "Delete workspace \"{name}\"?",
|
||||||
"runError": "Run error: {error}"
|
"runError": "Run error: {error}"
|
||||||
},
|
},
|
||||||
|
"bbox": {
|
||||||
|
"margin": "Bounding box margin (km)",
|
||||||
|
"generate": "Generate bounding box",
|
||||||
|
"hide": "Hide bounding box",
|
||||||
|
"coords": "Bounding box corner coordinates",
|
||||||
|
"copy": "Copy",
|
||||||
|
"copied": "Bounding box coordinates copied",
|
||||||
|
"copyError": "Could not copy to clipboard"
|
||||||
|
},
|
||||||
"timeline": {
|
"timeline": {
|
||||||
"title": "Flight timeline",
|
"title": "Flight timeline",
|
||||||
"time": "Time",
|
"time": "Time",
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,15 @@
|
||||||
"deleteConfirm": "Удалить рабочую область \"{name}\"?",
|
"deleteConfirm": "Удалить рабочую область \"{name}\"?",
|
||||||
"runError": "Ошибка расчета: {error}"
|
"runError": "Ошибка расчета: {error}"
|
||||||
},
|
},
|
||||||
|
"bbox": {
|
||||||
|
"margin": "Отступ рамки (км)",
|
||||||
|
"generate": "Построить рамку",
|
||||||
|
"hide": "Скрыть рамку",
|
||||||
|
"coords": "Координаты углов рамки",
|
||||||
|
"copy": "Копировать",
|
||||||
|
"copied": "Координаты рамки скопированы",
|
||||||
|
"copyError": "Не удалось скопировать в буфер обмена"
|
||||||
|
},
|
||||||
"timeline": {
|
"timeline": {
|
||||||
"title": "Временная шкала",
|
"title": "Временная шкала",
|
||||||
"time": "Время",
|
"time": "Время",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
export * from './core';
|
export * from './core';
|
||||||
export { createMapLibreMap } from './maplibre';
|
export { createMapLibreMap } from './maplibre';
|
||||||
export { plotPrediction, plotTelemetry, plotAnimatedMarker, plotEndMarker } from './layers';
|
export {
|
||||||
export type { TrajectoryStyle } from './layers';
|
plotPrediction,
|
||||||
|
plotTelemetry,
|
||||||
|
plotAnimatedMarker,
|
||||||
|
plotEndMarker,
|
||||||
|
plotBoundingBox,
|
||||||
|
} from './layers';
|
||||||
|
export type { TrajectoryStyle, BoundingBoxStyle } from './layers';
|
||||||
export { startCoordinateSelection } from './tools/selection';
|
export { startCoordinateSelection } from './tools/selection';
|
||||||
export { startMeasure } from './tools/measure';
|
export { startMeasure } from './tools/measure';
|
||||||
export type { MeasureHandle, MeasureOptions } from './tools/measure';
|
export type { MeasureHandle, MeasureOptions } from './tools/measure';
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Prediction, Telemetry } from '$domain';
|
import type { Prediction, Telemetry, BoundingBox } from '$domain';
|
||||||
import { toLngLat } from '$domain';
|
import { toLngLat, boundingBoxRing } from '$domain';
|
||||||
import type { IMap, Scene } from './core';
|
import type { IMap, Scene } from './core';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -131,3 +131,22 @@ export function plotAnimatedMarker(scene: Scene, lng: number, lat: number): void
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BoundingBoxStyle {
|
||||||
|
color?: string;
|
||||||
|
width?: number;
|
||||||
|
opacity?: number;
|
||||||
|
dashArray?: [number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draw a bounding box as a closed dotted polyline (blue by default). */
|
||||||
|
export function plotBoundingBox(scene: Scene, box: BoundingBox, style: BoundingBoxStyle = {}): void {
|
||||||
|
scene.clear();
|
||||||
|
scene.addLine('box', {
|
||||||
|
coords: boundingBoxRing(box),
|
||||||
|
color: style.color ?? '#0d6efd',
|
||||||
|
width: style.width ?? 3,
|
||||||
|
opacity: style.opacity ?? 1,
|
||||||
|
dashArray: style.dashArray ?? [2, 2],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,25 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { Map as MapView, plotAnimatedMarker, plotPrediction, type IMap } from '$map';
|
import {
|
||||||
|
Map as MapView,
|
||||||
|
plotAnimatedMarker,
|
||||||
|
plotPrediction,
|
||||||
|
plotBoundingBox,
|
||||||
|
type IMap,
|
||||||
|
} from '$map';
|
||||||
import { Navbar } from '$features/auth';
|
import { Navbar } from '$features/auth';
|
||||||
import { PanelContainer, CollapsibleCard } from '$ui';
|
import { PanelContainer, CollapsibleCard } from '$ui';
|
||||||
import { TelemetryPanel, DeviationChart, telemetryStore } from '$features/tracking';
|
import { TelemetryPanel, DeviationChart, telemetryStore } from '$features/tracking';
|
||||||
import { workspacesStore } from '$features/workspaces';
|
import { workspacesStore } from '$features/workspaces';
|
||||||
import { t } from '$i18n';
|
import { t } from '$i18n';
|
||||||
import { requireAuthenticated } from '$auth';
|
import { requireAuthenticated } from '$auth';
|
||||||
import { parseTelemetry } from '$domain';
|
import { parseTelemetry, computeBoundingBox, DEFAULT_BBOX_MARGIN_KM } from '$domain';
|
||||||
import type { Prediction } from '$domain';
|
import type { Prediction } from '$domain';
|
||||||
|
|
||||||
let selectedId = $state('');
|
let selectedId = $state('');
|
||||||
const workspacesWithResult = $derived($workspacesStore.items.filter((w) => w.result !== null));
|
const workspacesWithResult = $derived($workspacesStore.items.filter((w) => w.result !== null));
|
||||||
const selectedPrediction = $derived<Prediction | null>(
|
const selectedWorkspace = $derived(workspacesWithResult.find((w) => w.id === selectedId) ?? null);
|
||||||
workspacesWithResult.find((w) => w.id === selectedId)?.result ?? null,
|
const selectedPrediction = $derived<Prediction | null>(selectedWorkspace?.result ?? null);
|
||||||
);
|
|
||||||
|
|
||||||
let map = $state<IMap | null>(null);
|
let map = $state<IMap | null>(null);
|
||||||
// Tracks whether we've already fitted the map to the initial history load.
|
// Tracks whether we've already fitted the map to the initial history load.
|
||||||
|
|
@ -28,6 +33,7 @@
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
map?.disposeScene('telemetry');
|
map?.disposeScene('telemetry');
|
||||||
map?.disposeScene('prediction');
|
map?.disposeScene('prediction');
|
||||||
|
map?.disposeScene('bbox');
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|
@ -40,6 +46,23 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mirror the predict page: the bounding box is a per-workspace overlay drawn
|
||||||
|
// only by WorkspaceRenderer (predict-only). Redraw it here for the selected
|
||||||
|
// forecast so it doesn't vanish once tracking starts.
|
||||||
|
$effect(() => {
|
||||||
|
if (!map) return;
|
||||||
|
const scene = map.scene('bbox');
|
||||||
|
const box =
|
||||||
|
selectedWorkspace?.result && selectedWorkspace.bboxVisible
|
||||||
|
? computeBoundingBox(
|
||||||
|
selectedWorkspace.result.flight_path,
|
||||||
|
selectedWorkspace.bboxMargin ?? DEFAULT_BBOX_MARGIN_KM,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
if (box) plotBoundingBox(scene, box);
|
||||||
|
else scene.clear();
|
||||||
|
});
|
||||||
|
|
||||||
function onMapReady(m: IMap) {
|
function onMapReady(m: IMap) {
|
||||||
map = m;
|
map = m;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,50 @@ Then tell stratoflights to use it:
|
||||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import copy
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Optional: serve a canned prediction (stratoflights Prediction dump / Tawhiri
|
||||||
|
# JSON) instead of synthesizing one. Point datetimes are shifted so the
|
||||||
|
# trajectory starts at the requested launch_datetime.
|
||||||
|
TRAJ_FILE = os.environ.get("FAKE_TAWHIRI_TRAJECTORY")
|
||||||
|
|
||||||
|
|
||||||
def _iso(dt: datetime) -> str:
|
def _iso(dt: datetime) -> str:
|
||||||
return dt.isoformat().replace("+00:00", "Z")
|
return dt.isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse(dt: str) -> datetime:
|
||||||
|
return datetime.fromisoformat(dt.replace("Z", "+00:00"))
|
||||||
|
|
||||||
|
|
||||||
|
def build_from_file(params):
|
||||||
|
d = json.load(open(TRAJ_FILE))
|
||||||
|
res = d.get("result") or d
|
||||||
|
stages = copy.deepcopy(res["prediction"])
|
||||||
|
try:
|
||||||
|
launch_dt = _parse(params.get("launch_datetime"))
|
||||||
|
except Exception:
|
||||||
|
launch_dt = datetime.now(timezone.utc)
|
||||||
|
t0 = _parse(stages[0]["trajectory"][0]["datetime"])
|
||||||
|
delta = launch_dt - t0
|
||||||
|
last = None
|
||||||
|
for stage in stages:
|
||||||
|
for p in stage["trajectory"]:
|
||||||
|
p["datetime"] = _iso(_parse(p["datetime"]) + delta)
|
||||||
|
last = p
|
||||||
|
return {
|
||||||
|
"metadata": {
|
||||||
|
"start_datetime": _iso(launch_dt - timedelta(hours=1)),
|
||||||
|
"complete_datetime": last["datetime"],
|
||||||
|
},
|
||||||
|
"prediction": stages,
|
||||||
|
"request": res.get("request", {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_prediction(params):
|
def build_prediction(params):
|
||||||
try:
|
try:
|
||||||
launch_dt = datetime.fromisoformat(
|
launch_dt = datetime.fromisoformat(
|
||||||
|
|
@ -81,11 +118,15 @@ def build_prediction(params):
|
||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
parsed = urlparse(self.path)
|
parsed = urlparse(self.path)
|
||||||
if not parsed.path.rstrip("/").endswith("/api/v2"):
|
path = parsed.path.rstrip("/")
|
||||||
|
# /api/v2 — legacy e2e endpoint; /api/v1/prediction — what
|
||||||
|
# stratoflights' TawhiriClient (Go predictor URL) actually calls.
|
||||||
|
if not (path.endswith("/api/v2") or path.endswith("/api/v1/prediction")):
|
||||||
self.send_error(404)
|
self.send_error(404)
|
||||||
return
|
return
|
||||||
params = {k: v[0] for k, v in parse_qs(parsed.query).items()}
|
params = {k: v[0] for k, v in parse_qs(parsed.query).items()}
|
||||||
body = json.dumps(build_prediction(params)).encode()
|
builder = build_from_file if TRAJ_FILE else build_prediction
|
||||||
|
body = json.dumps(builder(params)).encode()
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
self.send_header("Content-Type", "application/json")
|
self.send_header("Content-Type", "application/json")
|
||||||
self.send_header("Access-Control-Allow-Origin", "*")
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
|
@ -98,7 +139,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
port = 8001
|
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8001
|
||||||
server = HTTPServer(("127.0.0.1", port), Handler)
|
server = HTTPServer(("127.0.0.1", port), Handler)
|
||||||
print(f"fake-tawhiri listening on http://127.0.0.1:{port}/api/v2/")
|
src = f"file {TRAJ_FILE}" if TRAJ_FILE else "synthetic"
|
||||||
|
print(f"fake-tawhiri listening on http://127.0.0.1:{port}/ ({src})")
|
||||||
server.serve_forever()
|
server.serve_forever()
|
||||||
|
|
|
||||||
64
tests/e2e/track.spec.ts
Normal file
64
tests/e2e/track.spec.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { test, expect, openPredict, login } from './fixtures';
|
||||||
|
|
||||||
|
test.beforeEach(async ({ context, page }) => {
|
||||||
|
await login(context);
|
||||||
|
await page.goto('/');
|
||||||
|
await page.evaluate(() => localStorage.removeItem('workspaces'));
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Count map layers whose scoped id belongs to a bounding-box scene. */
|
||||||
|
function bboxLayerCount(page: import('@playwright/test').Page) {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const map: any = (window as any)._lsvMap;
|
||||||
|
if (!map) return 0;
|
||||||
|
return map.getStyle().layers.filter((l: { id: string }) => l.id.startsWith('bbox')).length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression: the bounding box is drawn only by WorkspaceRenderer (predict-only).
|
||||||
|
// Selecting the same forecast on /track must redraw its box, otherwise it
|
||||||
|
// "disappears" the moment the user switches into tracking mode.
|
||||||
|
test('selected forecast bounding box is drawn on the tracking page', async ({ page }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
await openPredict(page);
|
||||||
|
|
||||||
|
const panel = page.locator('.panel-container-right');
|
||||||
|
const runBtn = panel
|
||||||
|
.locator('.workspace-row')
|
||||||
|
.first()
|
||||||
|
.getByRole('button', { name: /Рассчитать|Run/ });
|
||||||
|
await runBtn.click();
|
||||||
|
|
||||||
|
// Wait for the run to complete (workspace scene appears).
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
() =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const map: any = (window as any)._lsvMap;
|
||||||
|
if (!map) return 0;
|
||||||
|
return map
|
||||||
|
.getStyle()
|
||||||
|
.layers.filter((l: { id: string }) => l.id.startsWith('ws/')).length;
|
||||||
|
}),
|
||||||
|
{ timeout: 75_000, intervals: [1000, 2000, 3000] },
|
||||||
|
)
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Enable the bounding box for this forecast.
|
||||||
|
await panel.getByRole('button', { name: /Построить рамку|Generate bounding box/ }).click();
|
||||||
|
await expect.poll(() => bboxLayerCount(page), { timeout: 10_000 }).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Client-side navigate to tracking (a full reload would drop the in-memory
|
||||||
|
// prediction result, which is intentionally not persisted).
|
||||||
|
await page.getByRole('link', { name: /Слежение|Track/ }).click();
|
||||||
|
await page
|
||||||
|
.locator('.map-container canvas')
|
||||||
|
.first()
|
||||||
|
.waitFor({ state: 'attached', timeout: 15_000 });
|
||||||
|
|
||||||
|
// Select the forecast to track against — its box must appear on this map.
|
||||||
|
await page.locator('#forecast-select').selectOption({ index: 1 });
|
||||||
|
await expect.poll(() => bboxLayerCount(page), { timeout: 10_000 }).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue