diff --git a/src/lib/domain/boundingBox.ts b/src/lib/domain/boundingBox.ts new file mode 100644 index 0000000..bac560a --- /dev/null +++ b/src/lib/domain/boundingBox.ts @@ -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'); +} diff --git a/src/lib/domain/index.ts b/src/lib/domain/index.ts index 686a415..d3705ad 100644 --- a/src/lib/domain/index.ts +++ b/src/lib/domain/index.ts @@ -4,3 +4,4 @@ export * from './scenario'; export * from './prediction'; export * from './telemetry'; export * from './wind'; +export * from './boundingBox'; diff --git a/src/lib/features/workspaces/WorkspaceRenderer.svelte b/src/lib/features/workspaces/WorkspaceRenderer.svelte index 961d7d7..1f2d333 100644 --- a/src/lib/features/workspaces/WorkspaceRenderer.svelte +++ b/src/lib/features/workspaces/WorkspaceRenderer.svelte @@ -1,10 +1,10 @@ diff --git a/src/lib/features/workspaces/WorkspacesPanel.svelte b/src/lib/features/workspaces/WorkspacesPanel.svelte index 1290c45..eed4ccb 100644 --- a/src/lib/features/workspaces/WorkspacesPanel.svelte +++ b/src/lib/features/workspaces/WorkspacesPanel.svelte @@ -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(null); let busy = $state>({}); @@ -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' }, + ); + } @@ -134,6 +173,49 @@ {#if w.lastRunError}
{w.lastRunError}
{/if} + + {#if w.result} + {@const box = computeBoundingBox(w.result.flight_path, w.bboxMargin ?? DEFAULT_BBOX_MARGIN_KM)} +
+
+ {$t('bbox.margin')} + + handleBboxMargin(w, parseFloat((e.currentTarget as HTMLInputElement).value))} /> +
+ + {#if w.bboxVisible && box} + {@const coords = formatBoundingBox(box)} + + + {/if} +
+ {/if} {/each} diff --git a/src/lib/features/workspaces/store.ts b/src/lib/features/workspaces/store.ts index 98d82f4..a1c7348 100644 --- a/src/lib/features/workspaces/store.ts +++ b/src/lib/features/workspaces/store.ts @@ -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, }; } diff --git a/src/lib/features/workspaces/types.ts b/src/lib/features/workspaces/types.ts index 1ffe281..4f62388 100644 --- a/src/lib/features/workspaces/types.ts +++ b/src/lib/features/workspaces/types.ts @@ -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 { diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json index 16bd6a5..a223e84 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -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", diff --git a/src/lib/i18n/locales/ru.json b/src/lib/i18n/locales/ru.json index 6441dbb..bfdbb48 100644 --- a/src/lib/i18n/locales/ru.json +++ b/src/lib/i18n/locales/ru.json @@ -108,6 +108,15 @@ "deleteConfirm": "Удалить рабочую область \"{name}\"?", "runError": "Ошибка расчета: {error}" }, + "bbox": { + "margin": "Отступ рамки (км)", + "generate": "Построить рамку", + "hide": "Скрыть рамку", + "coords": "Координаты углов рамки", + "copy": "Копировать", + "copied": "Координаты рамки скопированы", + "copyError": "Не удалось скопировать в буфер обмена" + }, "timeline": { "title": "Временная шкала", "time": "Время", diff --git a/src/lib/map/index.ts b/src/lib/map/index.ts index 80af90b..a1d7910 100644 --- a/src/lib/map/index.ts +++ b/src/lib/map/index.ts @@ -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'; diff --git a/src/lib/map/layers.ts b/src/lib/map/layers.ts index 4f79848..7bda42c 100644 --- a/src/lib/map/layers.ts +++ b/src/lib/map/layers.ts @@ -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], + }); +} diff --git a/src/routes/track/+page.svelte b/src/routes/track/+page.svelte index d4115b2..6c0cfcf 100644 --- a/src/routes/track/+page.svelte +++ b/src/routes/track/+page.svelte @@ -1,20 +1,25 @@