fix bounding box in tracking mode

This commit is contained in:
Anatoly Antonov 2026-07-04 04:19:16 +09:00
parent 48140f0f77
commit a617d40777
13 changed files with 392 additions and 16 deletions

View 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');
}

View file

@ -4,3 +4,4 @@ export * from './scenario';
export * from './prediction';
export * from './telemetry';
export * from './wind';
export * from './boundingBox';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -108,6 +108,15 @@
"deleteConfirm": "Удалить рабочую область \"{name}\"?",
"runError": "Ошибка расчета: {error}"
},
"bbox": {
"margin": "Отступ рамки (км)",
"generate": "Построить рамку",
"hide": "Скрыть рамку",
"coords": "Координаты углов рамки",
"copy": "Копировать",
"copied": "Координаты рамки скопированы",
"copyError": "Не удалось скопировать в буфер обмена"
},
"timeline": {
"title": "Временная шкала",
"time": "Время",

View file

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

View file

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