fix bounding box in tracking mode
This commit is contained in:
parent
48140f0f77
commit
a617d40777
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 './telemetry';
|
||||
export * from './wind';
|
||||
export * from './boundingBox';
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
<script lang="ts">
|
||||
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 { workspacesStore } from './store';
|
||||
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
|
||||
|
|
@ -22,6 +22,7 @@
|
|||
// belong to workspaces which were removed from the store since last tick.
|
||||
const ownedPlotScenes = 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.
|
||||
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.
|
||||
|
|
@ -29,6 +30,7 @@
|
|||
|
||||
const sceneName = (w: Workspace) => `ws/${w.id}`;
|
||||
const cursorName = (w: Workspace) => `cursor/${w.id}`;
|
||||
const bboxName = (w: Workspace) => `bbox/${w.id}`;
|
||||
|
||||
function updateGlobalRange(items: Workspace[]) {
|
||||
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 {
|
||||
if (path.length === 0) return null;
|
||||
if (durationMs === 0) return path[0];
|
||||
|
|
@ -150,6 +182,7 @@
|
|||
$effect(() => {
|
||||
const items = $workspacesStore.items;
|
||||
renderAll(items);
|
||||
renderBboxes(items);
|
||||
updateGlobalRange(items);
|
||||
});
|
||||
|
||||
|
|
@ -161,8 +194,10 @@
|
|||
if (!map) return;
|
||||
for (const name of ownedPlotScenes) map.disposeScene(name);
|
||||
for (const name of ownedCursorScenes) map.disposeScene(name);
|
||||
for (const name of ownedBboxScenes) map.disposeScene(name);
|
||||
ownedPlotScenes.clear();
|
||||
ownedCursorScenes.clear();
|
||||
ownedBboxScenes.clear();
|
||||
doneCursorScenes.clear();
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
import { t } from '$i18n';
|
||||
import { workspacesStore } from './store';
|
||||
import type { Workspace } from './types';
|
||||
import { computeBoundingBox, formatBoundingBox, DEFAULT_BBOX_MARGIN_KM } from '$domain';
|
||||
|
||||
let toDelete = $state<Workspace | null>(null);
|
||||
let busy = $state<Record<string, boolean>>({});
|
||||
|
|
@ -53,6 +54,44 @@
|
|||
workspacesStore.remove(toDelete.id);
|
||||
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>
|
||||
|
||||
<CollapsibleCard title={$t('workspaces.title')}>
|
||||
|
|
@ -134,6 +173,49 @@
|
|||
{#if w.lastRunError}
|
||||
<div class="text-danger small mt-1">{w.lastRunError}</div>
|
||||
{/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>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { get } from 'svelte/store';
|
||||
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 { predictionsApi } from '$api';
|
||||
import { parsePrediction } from '$domain';
|
||||
|
|
@ -35,6 +35,8 @@ function makeWorkspace(init: WorkspaceInit = {}, index = 0): Workspace {
|
|||
launchDate: init.launchDate ?? todayDate(),
|
||||
launchTime: init.launchTime ?? '12:00:00',
|
||||
result: null,
|
||||
bboxMargin: DEFAULT_BBOX_MARGIN_KM,
|
||||
bboxVisible: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ export interface Workspace {
|
|||
launchTime: string;
|
||||
result: Prediction | null;
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -108,6 +108,15 @@
|
|||
"deleteConfirm": "Delete workspace \"{name}\"?",
|
||||
"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": {
|
||||
"title": "Flight timeline",
|
||||
"time": "Time",
|
||||
|
|
|
|||
|
|
@ -108,6 +108,15 @@
|
|||
"deleteConfirm": "Удалить рабочую область \"{name}\"?",
|
||||
"runError": "Ошибка расчета: {error}"
|
||||
},
|
||||
"bbox": {
|
||||
"margin": "Отступ рамки (км)",
|
||||
"generate": "Построить рамку",
|
||||
"hide": "Скрыть рамку",
|
||||
"coords": "Координаты углов рамки",
|
||||
"copy": "Копировать",
|
||||
"copied": "Координаты рамки скопированы",
|
||||
"copyError": "Не удалось скопировать в буфер обмена"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Временная шкала",
|
||||
"time": "Время",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
export * from './core';
|
||||
export { createMapLibreMap } from './maplibre';
|
||||
export { plotPrediction, plotTelemetry, plotAnimatedMarker, plotEndMarker } from './layers';
|
||||
export type { TrajectoryStyle } from './layers';
|
||||
export {
|
||||
plotPrediction,
|
||||
plotTelemetry,
|
||||
plotAnimatedMarker,
|
||||
plotEndMarker,
|
||||
plotBoundingBox,
|
||||
} from './layers';
|
||||
export type { TrajectoryStyle, BoundingBoxStyle } from './layers';
|
||||
export { startCoordinateSelection } from './tools/selection';
|
||||
export { startMeasure } from './tools/measure';
|
||||
export type { MeasureHandle, MeasureOptions } from './tools/measure';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { Prediction, Telemetry } from '$domain';
|
||||
import { toLngLat } from '$domain';
|
||||
import type { Prediction, Telemetry, BoundingBox } from '$domain';
|
||||
import { toLngLat, boundingBoxRing } from '$domain';
|
||||
import type { IMap, Scene } from './core';
|
||||
|
||||
/**
|
||||
|
|
@ -131,3 +131,22 @@ export function plotAnimatedMarker(scene: Scene, lng: number, lat: number): void
|
|||
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">
|
||||
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 { 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 { parseTelemetry, computeBoundingBox, DEFAULT_BBOX_MARGIN_KM } 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,
|
||||
);
|
||||
const selectedWorkspace = $derived(workspacesWithResult.find((w) => w.id === selectedId) ?? null);
|
||||
const selectedPrediction = $derived<Prediction | null>(selectedWorkspace?.result ?? null);
|
||||
|
||||
let map = $state<IMap | null>(null);
|
||||
// Tracks whether we've already fitted the map to the initial history load.
|
||||
|
|
@ -28,6 +33,7 @@
|
|||
onDestroy(() => {
|
||||
map?.disposeScene('telemetry');
|
||||
map?.disposeScene('prediction');
|
||||
map?.disposeScene('bbox');
|
||||
});
|
||||
|
||||
$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) {
|
||||
map = m;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,13 +12,50 @@ Then tell stratoflights to use it:
|
|||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import copy
|
||||
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:
|
||||
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):
|
||||
try:
|
||||
launch_dt = datetime.fromisoformat(
|
||||
|
|
@ -81,11 +118,15 @@ def build_prediction(params):
|
|||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
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)
|
||||
return
|
||||
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_header("Content-Type", "application/json")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
|
|
@ -98,7 +139,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||
|
||||
|
||||
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)
|
||||
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()
|
||||
|
|
|
|||
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