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],
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue