Add location-aware automatic default bounding box

The OpenSky "Default bounding box" now follows where flying happens.
A new "Automatic" picker mode (the default) resolves the live-map area
from a location cascade — drone telemetry → phone GPS → browser
geolocation → the user's Region country → Europe — instead of a fixed
box. Manual presets and Custom coordinates still work.

- Web App: new shared countries.js dataset (all countries + bbox,
  offline point→country); the bbox picker gains all European countries
  and an Automatic option (client pref prefs.autoBbox); the Region
  setting expands from 6 locale entries to all countries; the live map
  resolves the cascade each poll and sends it as ?bbox=.
- API Server: the states endpoint accepts and validates a ?bbox=
  override (validBBox); the Web App BFF forwards the query; the hub
  relays new phoneLatitude/phoneLongitude telemetry to the Web App.
- Fly App: reports the phone's own GPS (geolocator) alongside
  telemetry, used as the "your location" fallback.
- API panel: the OpenSky bbox picker lists all European countries.

Builds verified across web, panel, both Go modules and the Fly App
APK. Region list, Automatic default and the cascade ?bbox= override
verified in the browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-14 00:07:12 +02:00
co-authored by Claude Opus 4.8
parent 150758b0bf
commit 94f6876024
20 changed files with 637 additions and 71 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0F1E3D" /> <meta name="theme-color" content="#0F1E3D" />
<title>PilotVault · API Server</title> <title>PilotVault · API Server</title>
<script type="module" crossorigin src="/assets/index--4fMszzi.js"></script> <script type="module" crossorigin src="/assets/index-CwiTp2Sb.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CVh8EDzq.css"> <link rel="stylesheet" crossorigin href="/assets/index-CVh8EDzq.css">
</head> </head>
<body> <body>
+34 -1
View File
@@ -7,9 +7,34 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"strconv"
"strings" "strings"
) )
// validBBox reports whether s is a well-formed "lamin,lomin,lamax,lomax" bounding
// box: four numbers, latitudes in [-90,90], longitudes in [-180,180], and min < max
// on each axis. Used to vet the Live map's client-supplied ?bbox= override before
// it reaches OpenSky.
func validBBox(s string) bool {
parts := strings.Split(s, ",")
if len(parts) != 4 {
return false
}
n := make([]float64, 4)
for i, p := range parts {
v, err := strconv.ParseFloat(strings.TrimSpace(p), 64)
if err != nil {
return false
}
n[i] = v
}
laMin, loMin, laMax, loMax := n[0], n[1], n[2], n[3]
if laMin < -90 || laMax > 90 || loMin < -180 || loMax > 180 {
return false
}
return laMin < laMax && loMin < loMax
}
// Integrations exposes the OpenSky plugin's settings to end users under a // Integrations exposes the OpenSky plugin's settings to end users under a
// three-layer cascade (superadmin/global → organization → user). Each of the // three-layer cascade (superadmin/global → organization → user). Each of the
// four settings resolves independently, top wins, and a blank field falls // four settings resolves independently, top wins, and a blank field falls
@@ -586,11 +611,19 @@ func (s *Server) handleOpenSkyStates(w http.ResponseWriter, r *http.Request) {
return return
} }
// The Live map may request a specific area (auto cascade: drone → device →
// region). Honour a well-formed ?bbox= override; otherwise use the resolved
// config bbox. Malformed input is ignored rather than erroring.
bbox := res.eff.Bbox
if q := strings.TrimSpace(r.URL.Query().Get("bbox")); q != "" && validBBox(q) {
bbox = q
}
cfg := map[string]string{ cfg := map[string]string{
"clientId": res.eff.ClientID, "clientId": res.eff.ClientID,
"clientSecret": res.eff.ClientSecret, "clientSecret": res.eff.ClientSecret,
"plan": res.eff.Plan, "plan": res.eff.Plan,
"bbox": res.eff.Bbox, "bbox": bbox,
"allowAnonymous": boolStr(res.allowAnon), "allowAnonymous": boolStr(res.allowAnon),
} }
raw, err := s.plugins.InvokeWith(r.Context(), openSkyPlugin, cfg, "states.bbox", nil) raw, err := s.plugins.InvokeWith(r.Context(), openSkyPlugin, cfg, "states.bbox", nil)
+10
View File
@@ -11,6 +11,10 @@ type Telemetry struct {
Altitude *float64 `json:"altitude,omitempty"` Altitude *float64 `json:"altitude,omitempty"`
Latitude *float64 `json:"latitude,omitempty"` Latitude *float64 `json:"latitude,omitempty"`
Longitude *float64 `json:"longitude,omitempty"` Longitude *float64 `json:"longitude,omitempty"`
// Phone's own GPS (reported by the Fly App), distinct from the drone's fix
// above — used as a location fallback for the Web App's auto bounding box.
PhoneLatitude *float64 `json:"phoneLatitude,omitempty"`
PhoneLongitude *float64 `json:"phoneLongitude,omitempty"`
VelocityX *float64 `json:"velocityX,omitempty"` VelocityX *float64 `json:"velocityX,omitempty"`
VelocityY *float64 `json:"velocityY,omitempty"` VelocityY *float64 `json:"velocityY,omitempty"`
VelocityZ *float64 `json:"velocityZ,omitempty"` VelocityZ *float64 `json:"velocityZ,omitempty"`
@@ -100,6 +104,12 @@ func applyTelemetry(t *Telemetry, raw map[string]any) {
if v, ok := toFloat(raw["longitude"]); ok { if v, ok := toFloat(raw["longitude"]); ok {
t.Longitude = &v t.Longitude = &v
} }
if v, ok := toFloat(raw["phoneLatitude"]); ok {
t.PhoneLatitude = &v
}
if v, ok := toFloat(raw["phoneLongitude"]); ok {
t.PhoneLongitude = &v
}
if v, ok := toFloat(raw["velocityX"]); ok { if v, ok := toFloat(raw["velocityX"]); ok {
t.VelocityX = &v t.VelocityX = &v
} }
+52 -6
View File
@@ -190,15 +190,61 @@ const BBOX_GROUPS = [
{ value: "-56,-82,13,-34", label: "South America" }, { value: "-56,-82,13,-34", label: "South America" },
{ value: "-48,110,-10,180", label: "Oceania" }, { value: "-48,110,-10,180", label: "Oceania" },
] }, ] },
{ label: "Countries", options: [ // European countries (mirrors Web App/web/src/countries.js — keep in sync).
{ value: "49,14.1,54.9,24.2", label: "Poland" }, { label: "European countries", options: [
{ value: "50.5,3.2,53.7,7.3", label: "Netherlands" }, { value: "39.6,19.3,42.7,21.1", label: "Albania" },
{ value: "47.2,5.8,55.1,15.1", label: "Germany" }, { value: "42.4,1.4,42.7,1.8", label: "Andorra" },
{ value: "46.4,9.5,49.0,17.2", label: "Austria" },
{ value: "51.2,23.2,56.2,32.8", label: "Belarus" },
{ value: "49.5,2.5,51.5,6.4", label: "Belgium" },
{ value: "42.6,15.7,45.3,19.6", label: "Bosnia and Herzegovina" },
{ value: "41.2,22.4,44.2,28.6", label: "Bulgaria" },
{ value: "42.4,13.5,46.6,19.4", label: "Croatia" },
{ value: "34.6,32.3,35.7,34.6", label: "Cyprus" },
{ value: "48.6,12.1,51.1,18.9", label: "Czechia" },
{ value: "54.6,8.1,57.8,12.7", label: "Denmark" },
{ value: "57.5,21.8,59.7,28.2", label: "Estonia" },
{ value: "59.8,20.6,70.1,31.6", label: "Finland" },
{ value: "41.3,-5.2,51.1,9.6", label: "France" }, { value: "41.3,-5.2,51.1,9.6", label: "France" },
{ value: "49.9,-8.7,59,1.8", label: "United Kingdom" }, { value: "47.2,5.8,55.1,15.1", label: "Germany" },
{ value: "35.9,-9.6,43.8,3.4", label: "Spain" }, { value: "34.8,19.4,41.8,28.3", label: "Greece" },
{ value: "45.7,16.1,48.6,22.9", label: "Hungary" },
{ value: "63.3,-24.6,66.6,-13.5", label: "Iceland" },
{ value: "51.4,-10.6,55.4,-6.0", label: "Ireland" },
{ value: "36.6,6.6,47.1,18.6", label: "Italy" }, { value: "36.6,6.6,47.1,18.6", label: "Italy" },
{ value: "41.8,20.0,43.3,21.8", label: "Kosovo" },
{ value: "55.7,20.9,58.1,28.2", label: "Latvia" },
{ value: "47.0,9.4,47.3,9.6", label: "Liechtenstein" },
{ value: "53.9,20.9,56.5,26.9", label: "Lithuania" },
{ value: "49.4,5.7,50.2,6.5", label: "Luxembourg" },
{ value: "35.8,14.1,36.1,14.6", label: "Malta" },
{ value: "45.4,26.6,48.5,30.2", label: "Moldova" },
{ value: "43.72,7.40,43.75,7.44", label: "Monaco" },
{ value: "41.8,18.4,43.6,20.4", label: "Montenegro" },
{ value: "50.7,3.3,53.7,7.2", label: "Netherlands" },
{ value: "40.8,20.4,42.4,23.0", label: "North Macedonia" },
{ value: "57.9,4.6,71.2,31.1", label: "Norway" },
{ value: "49.0,14.1,54.9,24.2", label: "Poland" },
{ value: "36.9,-9.5,42.2,-6.2", label: "Portugal" },
{ value: "43.6,20.2,48.3,29.7", label: "Romania" },
{ value: "41.2,19.6,81.9,180", label: "Russia" },
{ value: "43.89,12.40,43.99,12.52", label: "San Marino" },
{ value: "42.2,18.8,46.2,23.0", label: "Serbia" },
{ value: "47.7,16.8,49.6,22.6", label: "Slovakia" },
{ value: "45.4,13.4,46.9,16.6", label: "Slovenia" },
{ value: "35.9,-9.4,43.8,3.4", label: "Spain" },
{ value: "55.3,11.1,69.1,24.2", label: "Sweden" },
{ value: "45.8,5.9,47.8,10.5", label: "Switzerland" },
{ value: "35.8,25.7,42.3,44.8", label: "Turkey" },
{ value: "44.4,22.1,52.4,40.2", label: "Ukraine" },
{ value: "49.9,-8.7,60.9,1.8", label: "United Kingdom" },
{ value: "41.900,12.445,41.908,12.458", label: "Vatican City" },
] },
{ label: "Other countries", options: [
{ value: "24,-125,49.5,-66.5", label: "United States" }, { value: "24,-125,49.5,-66.5", label: "United States" },
{ value: "41.7,-141,83.1,-52.6", label: "Canada" },
{ value: "-43.6,113.3,-10.7,153.6", label: "Australia" },
{ value: "24,122.9,45.5,145.8", label: "Japan" },
] }, ] },
]; ];
const BBOX_FLAT = BBOX_GROUPS.flatMap((g) => g.options); const BBOX_FLAT = BBOX_GROUPS.flatMap((g) => g.options);
+4
View File
@@ -21,6 +21,10 @@ class FlightModel extends ChangeNotifier {
double? altitude; double? altitude;
double? latitude; double? latitude;
double? longitude; double? longitude;
// Phone's own GPS (independent of the drone's fix above); streamed to the
// server as a location fallback for the Web App's automatic bounding box.
double? phoneLatitude;
double? phoneLongitude;
int? batteryPercent; int? batteryPercent;
UploadStatus upload = UploadStatus.disabled; UploadStatus upload = UploadStatus.disabled;
+42
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:geolocator/geolocator.dart';
import 'dji_service.dart'; import 'dji_service.dart';
import 'flight_model.dart'; import 'flight_model.dart';
@@ -46,6 +47,7 @@ class _HomePageState extends State<HomePage> {
final FlightModel _model = FlightModel(); final FlightModel _model = FlightModel();
StreamSubscription<Map<String, dynamic>>? _sub; StreamSubscription<Map<String, dynamic>>? _sub;
StreamSubscription<AuthStatus>? _authSub; StreamSubscription<AuthStatus>? _authSub;
StreamSubscription<Position>? _phoneLocSub;
// Streams telemetry to the API Server and receives commands back. // Streams telemetry to the API Server and receives commands back.
late final ServerUploader _uploader; late final ServerUploader _uploader;
@@ -69,6 +71,43 @@ class _HomePageState extends State<HomePage> {
if (mounted) setState(() {}); if (mounted) setState(() {});
}); });
_init(); _init();
_startPhoneLocation();
}
/// Streams the phone's own GPS (coarse, low-frequency) and reports it to the
/// server as a telemetry field — a location fallback for the Web App's auto
/// bounding box when the drone has no fix. Best-effort: silently gives up if
/// location services or permission are unavailable.
Future<void> _startPhoneLocation() async {
try {
if (!await Geolocator.isLocationServiceEnabled()) return;
LocationPermission perm = await Geolocator.checkPermission();
if (perm == LocationPermission.denied) {
perm = await Geolocator.requestPermission();
}
if (perm == LocationPermission.denied || perm == LocationPermission.deniedForever) {
return;
}
_phoneLocSub = Geolocator.getPositionStream(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.low, // country-level is all the bbox needs
distanceFilter: 1000, // metres — infrequent updates
),
).listen((Position pos) {
_model.phoneLatitude = pos.latitude;
_model.phoneLongitude = pos.longitude;
_model.bump();
// Report to the server (rides the existing telemetry channel; only the
// phone fields are present, so it never disturbs drone telemetry).
_uploader.onEvent(<String, dynamic>{
'type': 'telemetry',
'phoneLatitude': pos.latitude,
'phoneLongitude': pos.longitude,
});
});
} catch (_) {
// Location plugin/permission unavailable — non-fatal.
}
} }
Future<void> _init() async { Future<void> _init() async {
@@ -195,6 +234,8 @@ class _HomePageState extends State<HomePage> {
if (_model.altitude != null) tel['altitude'] = _model.altitude; if (_model.altitude != null) tel['altitude'] = _model.altitude;
if (_model.latitude != null) tel['latitude'] = _model.latitude; if (_model.latitude != null) tel['latitude'] = _model.latitude;
if (_model.longitude != null) tel['longitude'] = _model.longitude; if (_model.longitude != null) tel['longitude'] = _model.longitude;
if (_model.phoneLatitude != null) tel['phoneLatitude'] = _model.phoneLatitude;
if (_model.phoneLongitude != null) tel['phoneLongitude'] = _model.phoneLongitude;
if (tel.length > 1) events.add(tel); if (tel.length > 1) events.add(tel);
return events; return events;
} }
@@ -235,6 +276,7 @@ class _HomePageState extends State<HomePage> {
void dispose() { void dispose() {
_sub?.cancel(); _sub?.cancel();
_authSub?.cancel(); _authSub?.cancel();
_phoneLocSub?.cancel();
_uploadSub?.cancel(); _uploadSub?.cancel();
_uploader.dispose(); _uploader.dispose();
_serverHost.dispose(); _serverHost.dispose();
+80
View File
@@ -73,6 +73,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -105,6 +113,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.1" version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -144,6 +160,54 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
geolocator:
dependency: "direct main"
description:
name: geolocator
sha256: f62bcd90459e63210bbf9c35deb6a51c521f992a78de19a1fe5c11704f9530e2
url: "https://pub.dev"
source: hosted
version: "13.0.4"
geolocator_android:
dependency: transitive
description:
name: geolocator_android
sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d
url: "https://pub.dev"
source: hosted
version: "4.6.2"
geolocator_apple:
dependency: transitive
description:
name: geolocator_apple
sha256: "853803d6bb1713c094e935b4a5ae5f19c0308acf81da13fa9ff84fb4c70c0b73"
url: "https://pub.dev"
source: hosted
version: "2.3.14"
geolocator_platform_interface:
dependency: transitive
description:
name: geolocator_platform_interface
sha256: cdb082e4f048b69da244117b7914cc60d2a8897546ffaa4f2529c786ded7aee2
url: "https://pub.dev"
source: hosted
version: "4.2.8"
geolocator_web:
dependency: transitive
description:
name: geolocator_web
sha256: "19e485a0f8d6a88abcf9c53cba3a4105e14b7435ed8ac1c108c067b938fe8429"
url: "https://pub.dev"
source: hosted
version: "4.1.4"
geolocator_windows:
dependency: transitive
description:
name: geolocator_windows
sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6"
url: "https://pub.dev"
source: hosted
version: "0.2.5"
image: image:
dependency: transitive dependency: transitive
description: description:
@@ -437,6 +501,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.11" version: "0.7.11"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
uuid:
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
+4
View File
@@ -43,6 +43,10 @@ dependencies:
# the platform BiometricPrompt). Requires FlutterFragmentActivity on Android. # the platform BiometricPrompt). Requires FlutterFragmentActivity on Android.
local_auth: ^2.3.0 local_auth: ^2.3.0
# Phone GPS — reported alongside telemetry as a location fallback for the Web
# App's automatic bounding box when the drone has no fix.
geolocator: ^13.0.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
+6 -1
View File
@@ -237,7 +237,12 @@ func (a *App) handleOpenSkyHealth(w http.ResponseWriter, r *http.Request) {
// GET /bff/integrations/opensky/states → API Server /api/integrations/opensky/states. // GET /bff/integrations/opensky/states → API Server /api/integrations/opensky/states.
// Live aircraft positions for the Live map. // Live aircraft positions for the Live map.
func (a *App) handleOpenSkyStates(w http.ResponseWriter, r *http.Request) { func (a *App) handleOpenSkyStates(w http.ResponseWriter, r *http.Request) {
req, _ := http.NewRequest(http.MethodGet, a.apiBaseFor(r)+"/api/integrations/opensky/states", nil) target := a.apiBaseFor(r) + "/api/integrations/opensky/states"
// Forward the optional ?bbox= override the Live map sends in auto mode.
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
req, _ := http.NewRequest(http.MethodGet, target, nil)
req.Header.Set("Authorization", tokenOf(r)) req.Header.Set("Authorization", tokenOf(r))
a.doRelay(w, req) a.doRelay(w, req)
} }
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -35,8 +35,8 @@
})() })()
</script> </script>
<title>PilotVault — Control Panel</title> <title>PilotVault — Control Panel</title>
<script type="module" crossorigin src="./assets/index-CznFJTz4.js"></script> <script type="module" crossorigin src="./assets/index-BSx6Lf8i.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-D0ghlckk.css"> <link rel="stylesheet" crossorigin href="./assets/index-FLz21UuE.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+9 -7
View File
@@ -166,14 +166,16 @@ export async function testOpenSky() {
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) } return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
} }
// Live aircraft positions (OpenSky state vectors) within the caller's resolved // Live aircraft positions (OpenSky state vectors) for plotting on the Live map.
// bounding box, for plotting on the Live map. Returns { states, unavailable?, // Pass an optional `bbox` ("lamin,lomin,lamax,lomax") to override the caller's
// detail?, plan, recommendedInterval } — an empty list with `unavailable` when // configured area — used by the auto cascade (drone/device/region location).
// OpenSky is off for the caller. `recommendedInterval` (seconds) is derived from // Returns { states, unavailable?, detail?, plan, recommendedInterval } — an empty
// the resolved plan and drives the "Auto" refresh cadence. // list with `unavailable` when OpenSky is off for the caller. `recommendedInterval`
export async function getOpenSkyStates() { // (seconds) is derived from the resolved plan and drives the "Auto" refresh cadence.
export async function getOpenSkyStates(bbox) {
try { try {
const r = await fetch('/bff/integrations/opensky/states') const qs = bbox ? `?bbox=${encodeURIComponent(bbox)}` : ''
const r = await fetch(`/bff/integrations/opensky/states${qs}`)
if (!r.ok) return { states: [], unavailable: true, detail: 'OpenSky unavailable' } if (!r.ok) return { states: [], unavailable: true, detail: 'OpenSky unavailable' }
const d = await r.json() const d = await r.json()
return { return {
+65 -1
View File
@@ -9,6 +9,7 @@ import Documents from './Documents.vue'
import Toggle from './settings/Toggle.vue' import Toggle from './settings/Toggle.vue'
import { getDevices, sendCommand, getOpenSkyStates } from '../api.js' import { getDevices, sendCommand, getOpenSkyStates } from '../api.js'
import { formatTime, prefs } from '../prefs.js' import { formatTime, prefs } from '../prefs.js'
import { countryForPoint, bboxForCountry } from '../countries.js'
const props = defineProps({ const props = defineProps({
email: { type: String, default: '' }, email: { type: String, default: '' },
@@ -50,9 +51,72 @@ const airIntervalSeconds = computed(() => {
return Number.isFinite(n) && n > 0 ? n : 30 return Number.isFinite(n) && n > 0 ? n : 30
}) })
// Europe — the ultimate fallback for the auto cascade (matches the server default).
const EUROPE_BBOX = '34,-25,72,45'
// Cached browser geolocation: { lat, lng } once granted, false once denied/failed,
// null before we've asked. Requested lazily and at most once per session.
const browserGeo = ref(null)
let browserGeoPending = false
function requestBrowserGeo() {
if (browserGeoPending || browserGeo.value !== null) return
if (typeof navigator === 'undefined' || !navigator.geolocation) { browserGeo.value = false; return }
browserGeoPending = true
navigator.geolocation.getCurrentPosition(
(pos) => { browserGeo.value = { lat: pos.coords.latitude, lng: pos.coords.longitude }; browserGeoPending = false },
() => { browserGeo.value = false; browserGeoPending = false }, // denied/unavailable — fall through to Region
{ timeout: 8000, maximumAge: 600000 },
)
}
// A device's position from telemetry, or null. `key` picks drone vs phone GPS.
function devicePoint(d, latKey, lngKey) {
const t = (d && d.telemetry) || {}
const lat = t[latKey]
const lng = t[lngKey]
return typeof lat === 'number' && typeof lng === 'number' && (lat || lng) ? { lat, lng } : null
}
// Resolve the live-map bounding box from the location cascade (auto mode):
// 1. drone GPS (selected device, else any device with a fix)
// 2. user location: phone GPS (from the Fly App) else browser geolocation
// 3. Region country from User Settings
// 4. Europe
// Each location step maps a point to its country's bbox via countries.js.
function resolveAutoBbox() {
// 1. Drone telemetry position.
const drone =
devicePoint(sel.value, 'latitude', 'longitude') ||
ids.value.map((id) => devicePoint(devices[id], 'latitude', 'longitude')).find(Boolean)
if (drone) {
const c = countryForPoint(drone.lat, drone.lng)
if (c) return c.bbox
}
// 2a. Phone GPS reported by the Fly App (rides on telemetry).
const phone =
devicePoint(sel.value, 'phoneLatitude', 'phoneLongitude') ||
ids.value.map((id) => devicePoint(devices[id], 'phoneLatitude', 'phoneLongitude')).find(Boolean)
if (phone) {
const c = countryForPoint(phone.lat, phone.lng)
if (c) return c.bbox
}
// 2b. Browser geolocation (asks once; ignored until granted).
requestBrowserGeo()
if (browserGeo.value) {
const c = countryForPoint(browserGeo.value.lat, browserGeo.value.lng)
if (c) return c.bbox
}
// 3. Region country fallback.
const regionBbox = bboxForCountry(prefs.region)
if (regionBbox) return regionBbox
// 4. Europe.
return EUROPE_BBOX
}
async function refreshAirspace() { async function refreshAirspace() {
if (!prefs.showAirTraffic) return if (!prefs.showAirTraffic) return
const { states, unavailable, detail, plan, recommendedInterval } = await getOpenSkyStates() const bbox = prefs.autoBbox ? resolveAutoBbox() : undefined
const { states, unavailable, detail, plan, recommendedInterval } = await getOpenSkyStates(bbox)
aircraft.value = states aircraft.value = states
airspace.unavailable = unavailable airspace.unavailable = unavailable
airspace.detail = detail airspace.detail = detail
+29 -14
View File
@@ -6,6 +6,7 @@ import Segmented from './settings/Segmented.vue'
import Row from './settings/Row.vue' import Row from './settings/Row.vue'
import { themeMode, setThemeMode } from '../theme.js' import { themeMode, setThemeMode } from '../theme.js'
import { prefs, formatDateTime, importPrefs, applyFontSize, applyReduceMotion } from '../prefs.js' import { prefs, formatDateTime, importPrefs, applyFontSize, applyReduceMotion } from '../prefs.js'
import { europeanCountries, regionOptions } from '../countries.js'
import { import {
getUsers, createUser, updateUser, deleteUser, getUsers, createUser, updateUser, deleteUser,
getOrgs, createOrg, updateOrg, deleteOrg, getOrgs, createOrg, updateOrg, deleteOrg,
@@ -112,9 +113,10 @@ const TIME_OPTS = [
const LANGS = [ const LANGS = [
['en', 'English'], ['es', 'Español'], ['de', 'Deutsch'], ['fr', 'Français'], ['pl', 'Polski'], ['ja', '日本語'], ['en', 'English'], ['es', 'Español'], ['de', 'Deutsch'], ['fr', 'Français'], ['pl', 'Polski'], ['ja', '日本語'],
] ]
const REGIONS = [ // All countries of the world (also the final fallback for the auto bounding box).
['US', 'United States'], ['GB', 'United Kingdom'], ['EU', 'European Union'], ['CA', 'Canada'], ['AU', 'Australia'], ['JP', 'Japan'], const REGIONS = regionOptions()
] // Display name of the currently selected Region (for the auto-bbox helper text).
const regionName = computed(() => (REGIONS.find(([v]) => v === prefs.region) || [null, prefs.region])[1])
const DATE_FMTS = [ const DATE_FMTS = [
['MDY', 'MM/DD/YYYY'], ['DMY', 'DD/MM/YYYY'], ['YMD', 'YYYY/MM/DD'], ['ISO', 'YYYY-MM-DD'], ['MDY', 'MM/DD/YYYY'], ['DMY', 'DD/MM/YYYY'], ['YMD', 'YYYY/MM/DD'], ['ISO', 'YYYY-MM-DD'],
] ]
@@ -207,8 +209,8 @@ const OS_SCOPE_OPTS = [
] ]
// Predefined bounding boxes (lamin,lomin,lamax,lomax). The picker offers these // Predefined bounding boxes (lamin,lomin,lamax,lomax). The picker offers these
// plus a "Custom…" option that reveals the free-text field for manual entry. // plus "Automatic" (the location cascade) and "Custom…" (manual coordinates).
// Europe is the default (kept in sync with the API Server's defaultBBox). // European countries come from the shared dataset in countries.js.
const OS_BBOX_GROUPS = [ const OS_BBOX_GROUPS = [
{ label: 'World', options: [ { label: 'World', options: [
{ value: '-90,-180,90,180', label: 'World' }, { value: '-90,-180,90,180', label: 'World' },
@@ -221,15 +223,12 @@ const OS_BBOX_GROUPS = [
{ value: '-56,-82,13,-34', label: 'South America' }, { value: '-56,-82,13,-34', label: 'South America' },
{ value: '-48,110,-10,180', label: 'Oceania' }, { value: '-48,110,-10,180', label: 'Oceania' },
] }, ] },
{ label: 'Countries', options: [ { label: 'European countries', options: europeanCountries() },
{ value: '49,14.1,54.9,24.2', label: 'Poland' }, { label: 'Other countries', options: [
{ value: '50.5,3.2,53.7,7.3', label: 'Netherlands' },
{ value: '47.2,5.8,55.1,15.1', label: 'Germany' },
{ value: '41.3,-5.2,51.1,9.6', label: 'France' },
{ value: '49.9,-8.7,59,1.8', label: 'United Kingdom' },
{ value: '35.9,-9.6,43.8,3.4', label: 'Spain' },
{ value: '36.6,6.6,47.1,18.6', label: 'Italy' },
{ value: '24,-125,49.5,-66.5', label: 'United States' }, { value: '24,-125,49.5,-66.5', label: 'United States' },
{ value: '41.7,-141,83.1,-52.6', label: 'Canada' },
{ value: '-43.6,113.3,-10.7,153.6', label: 'Australia' },
{ value: '24,122.9,45.5,145.8', label: 'Japan' },
] }, ] },
] ]
const OS_BBOX_FLAT = OS_BBOX_GROUPS.flatMap((g) => g.options) const OS_BBOX_FLAT = OS_BBOX_GROUPS.flatMap((g) => g.options)
@@ -253,20 +252,32 @@ function osBboxLabel(v) {
// Manual-entry toggle: sticky once the user picks "Custom…", and implied when // Manual-entry toggle: sticky once the user picks "Custom…", and implied when
// the current value doesn't match any preset. // the current value doesn't match any preset.
const osBboxCustom = ref(false) const osBboxCustom = ref(false)
// The picker's value. "__auto__" is the location cascade (a personal client
// preference, prefs.autoBbox — not part of the server config, so it's only
// offered in the personal scope). "__custom__" reveals the coordinate field.
const osBboxPreset = computed({ const osBboxPreset = computed({
get() { get() {
if (!osEditingOrg.value && prefs.autoBbox) return '__auto__'
if (osBboxCustom.value) return '__custom__' if (osBboxCustom.value) return '__custom__'
const norm = osNormBbox(osForm.bbox) const norm = osNormBbox(osForm.bbox)
const match = norm && OS_BBOX_FLAT.find((o) => osNormBbox(o.value) === norm) const match = norm && OS_BBOX_FLAT.find((o) => osNormBbox(o.value) === norm)
return match ? match.value : '__custom__' return match ? match.value : '__custom__'
}, },
set(v) { set(v) {
if (v === '__auto__') {
if (!osEditingOrg.value) prefs.autoBbox = true
osBboxCustom.value = false
return
}
// Any explicit choice leaves auto mode (personal scope only).
if (!osEditingOrg.value) prefs.autoBbox = false
if (v === '__custom__') { osBboxCustom.value = true; return } if (v === '__custom__') { osBboxCustom.value = true; return }
osBboxCustom.value = false osBboxCustom.value = false
osForm.bbox = v osForm.bbox = v
}, },
}) })
const osBboxManual = computed(() => osBboxPreset.value === '__custom__') const osBboxManual = computed(() => osBboxPreset.value === '__custom__')
const osBboxIsAuto = computed(() => osBboxPreset.value === '__auto__')
// Superadmin edits the global layer in the API panel — read-only in the Web App. // Superadmin edits the global layer in the API panel — read-only in the Web App.
const osReadOnly = computed(() => os.isSuperadmin) const osReadOnly = computed(() => os.isSuperadmin)
@@ -1497,7 +1508,7 @@ onBeforeUnmount(() => {
</Row> </Row>
<!-- bounding box --> <!-- bounding box -->
<Row title="Default bounding box" desc="Pick a region, or choose Custom to enter lamin,lomin,lamax,lomax by hand — used for live queries and the health probe." keywords="bounding box bbox area region country continent world europe custom coordinates"> <Row title="Default bounding box" desc="Automatic follows your location; or pick a region, or enter lamin,lomin,lamax,lomax by hand." keywords="bounding box bbox area region country continent world europe custom coordinates automatic location drone">
<template v-if="osLocked('bbox')"> <template v-if="osLocked('bbox')">
<span class="inline-flex items-center gap-2 font-mono text-sm text-ink"> <span class="inline-flex items-center gap-2 font-mono text-sm text-ink">
{{ osBboxLabel(osField('bbox').effective) || osField('bbox').effective || '' }} {{ osBboxLabel(osField('bbox').effective) || osField('bbox').effective || '' }}
@@ -1506,11 +1517,15 @@ onBeforeUnmount(() => {
</template> </template>
<div v-else class="flex flex-col items-end gap-2"> <div v-else class="flex flex-col items-end gap-2">
<select v-model="osBboxPreset" class="field w-64"> <select v-model="osBboxPreset" class="field w-64">
<option v-if="!osEditingOrg" value="__auto__">Automatic (by location)</option>
<optgroup v-for="g in OS_BBOX_GROUPS" :key="g.label" :label="g.label"> <optgroup v-for="g in OS_BBOX_GROUPS" :key="g.label" :label="g.label">
<option v-for="o in g.options" :key="o.value" :value="o.value">{{ o.label }}</option> <option v-for="o in g.options" :key="o.value" :value="o.value">{{ o.label }}</option>
</optgroup> </optgroup>
<option value="__custom__">Custom…</option> <option value="__custom__">Custom…</option>
</select> </select>
<p v-if="osBboxIsAuto" class="w-64 text-right text-[11px] leading-snug text-ink-muted">
Live map follows drone location → your device location → your Region ({{ regionName }}).
</p>
<input v-if="osBboxManual" v-model="osForm.bbox" class="field w-64 font-mono" placeholder="50.5,3.2,53.7,7.3" /> <input v-if="osBboxManual" v-model="osForm.bbox" class="field w-64 font-mono" placeholder="50.5,3.2,53.7,7.3" />
</div> </div>
</Row> </Row>
+257
View File
@@ -0,0 +1,257 @@
// PilotVault country reference data.
//
// One record per country: ISO 3166-1 alpha-2 `code`, display `name`, `continent`
// (EU | AS | AF | NA | SA | OC), and a `bbox` string "lamin,lomin,lamax,lomax"
// (min lat, min lon, max lat, max lon). Bounding boxes are approximate — good
// enough to pick a default map area and to resolve a point to a country offline,
// without any external geocoding call.
//
// Consumed by:
// - the OpenSky "Default bounding box" picker (European presets),
// - the User Settings "Region" list (all countries),
// - the live-map auto cascade (point -> country -> bbox).
export const COUNTRIES = [
// ---- Europe ----
{ code: 'AL', name: 'Albania', continent: 'EU', bbox: '39.6,19.3,42.7,21.1' },
{ code: 'AD', name: 'Andorra', continent: 'EU', bbox: '42.4,1.4,42.7,1.8' },
{ code: 'AT', name: 'Austria', continent: 'EU', bbox: '46.4,9.5,49.0,17.2' },
{ code: 'BY', name: 'Belarus', continent: 'EU', bbox: '51.2,23.2,56.2,32.8' },
{ code: 'BE', name: 'Belgium', continent: 'EU', bbox: '49.5,2.5,51.5,6.4' },
{ code: 'BA', name: 'Bosnia and Herzegovina', continent: 'EU', bbox: '42.6,15.7,45.3,19.6' },
{ code: 'BG', name: 'Bulgaria', continent: 'EU', bbox: '41.2,22.4,44.2,28.6' },
{ code: 'HR', name: 'Croatia', continent: 'EU', bbox: '42.4,13.5,46.6,19.4' },
{ code: 'CY', name: 'Cyprus', continent: 'EU', bbox: '34.6,32.3,35.7,34.6' },
{ code: 'CZ', name: 'Czechia', continent: 'EU', bbox: '48.6,12.1,51.1,18.9' },
{ code: 'DK', name: 'Denmark', continent: 'EU', bbox: '54.6,8.1,57.8,12.7' },
{ code: 'EE', name: 'Estonia', continent: 'EU', bbox: '57.5,21.8,59.7,28.2' },
{ code: 'FI', name: 'Finland', continent: 'EU', bbox: '59.8,20.6,70.1,31.6' },
{ code: 'FR', name: 'France', continent: 'EU', bbox: '41.3,-5.2,51.1,9.6' },
{ code: 'DE', name: 'Germany', continent: 'EU', bbox: '47.2,5.8,55.1,15.1' },
{ code: 'GR', name: 'Greece', continent: 'EU', bbox: '34.8,19.4,41.8,28.3' },
{ code: 'HU', name: 'Hungary', continent: 'EU', bbox: '45.7,16.1,48.6,22.9' },
{ code: 'IS', name: 'Iceland', continent: 'EU', bbox: '63.3,-24.6,66.6,-13.5' },
{ code: 'IE', name: 'Ireland', continent: 'EU', bbox: '51.4,-10.6,55.4,-6.0' },
{ code: 'IT', name: 'Italy', continent: 'EU', bbox: '36.6,6.6,47.1,18.6' },
{ code: 'XK', name: 'Kosovo', continent: 'EU', bbox: '41.8,20.0,43.3,21.8' },
{ code: 'LV', name: 'Latvia', continent: 'EU', bbox: '55.7,20.9,58.1,28.2' },
{ code: 'LI', name: 'Liechtenstein', continent: 'EU', bbox: '47.0,9.4,47.3,9.6' },
{ code: 'LT', name: 'Lithuania', continent: 'EU', bbox: '53.9,20.9,56.5,26.9' },
{ code: 'LU', name: 'Luxembourg', continent: 'EU', bbox: '49.4,5.7,50.2,6.5' },
{ code: 'MT', name: 'Malta', continent: 'EU', bbox: '35.8,14.1,36.1,14.6' },
{ code: 'MD', name: 'Moldova', continent: 'EU', bbox: '45.4,26.6,48.5,30.2' },
{ code: 'MC', name: 'Monaco', continent: 'EU', bbox: '43.72,7.40,43.75,7.44' },
{ code: 'ME', name: 'Montenegro', continent: 'EU', bbox: '41.8,18.4,43.6,20.4' },
{ code: 'NL', name: 'Netherlands', continent: 'EU', bbox: '50.7,3.3,53.7,7.2' },
{ code: 'MK', name: 'North Macedonia', continent: 'EU', bbox: '40.8,20.4,42.4,23.0' },
{ code: 'NO', name: 'Norway', continent: 'EU', bbox: '57.9,4.6,71.2,31.1' },
{ code: 'PL', name: 'Poland', continent: 'EU', bbox: '49.0,14.1,54.9,24.2' },
{ code: 'PT', name: 'Portugal', continent: 'EU', bbox: '36.9,-9.5,42.2,-6.2' },
{ code: 'RO', name: 'Romania', continent: 'EU', bbox: '43.6,20.2,48.3,29.7' },
{ code: 'SM', name: 'San Marino', continent: 'EU', bbox: '43.89,12.40,43.99,12.52' },
{ code: 'RS', name: 'Serbia', continent: 'EU', bbox: '42.2,18.8,46.2,23.0' },
{ code: 'SK', name: 'Slovakia', continent: 'EU', bbox: '47.7,16.8,49.6,22.6' },
{ code: 'SI', name: 'Slovenia', continent: 'EU', bbox: '45.4,13.4,46.9,16.6' },
{ code: 'ES', name: 'Spain', continent: 'EU', bbox: '35.9,-9.4,43.8,3.4' },
{ code: 'SE', name: 'Sweden', continent: 'EU', bbox: '55.3,11.1,69.1,24.2' },
{ code: 'CH', name: 'Switzerland', continent: 'EU', bbox: '45.8,5.9,47.8,10.5' },
{ code: 'UA', name: 'Ukraine', continent: 'EU', bbox: '44.4,22.1,52.4,40.2' },
{ code: 'GB', name: 'United Kingdom', continent: 'EU', bbox: '49.9,-8.7,60.9,1.8' },
{ code: 'VA', name: 'Vatican City', continent: 'EU', bbox: '41.900,12.445,41.908,12.458' },
{ code: 'RU', name: 'Russia', continent: 'EU', bbox: '41.2,19.6,81.9,180' },
{ code: 'TR', name: 'Turkey', continent: 'EU', bbox: '35.8,25.7,42.3,44.8' },
// ---- Asia ----
{ code: 'AF', name: 'Afghanistan', continent: 'AS', bbox: '29.4,60.5,38.5,74.9' },
{ code: 'AM', name: 'Armenia', continent: 'AS', bbox: '38.8,43.4,41.3,46.6' },
{ code: 'AZ', name: 'Azerbaijan', continent: 'AS', bbox: '38.4,44.8,41.9,50.4' },
{ code: 'BH', name: 'Bahrain', continent: 'AS', bbox: '25.8,50.4,26.3,50.7' },
{ code: 'BD', name: 'Bangladesh', continent: 'AS', bbox: '20.7,88.0,26.6,92.7' },
{ code: 'BT', name: 'Bhutan', continent: 'AS', bbox: '26.7,88.7,28.3,92.1' },
{ code: 'BN', name: 'Brunei', continent: 'AS', bbox: '4.0,114.0,5.1,115.4' },
{ code: 'KH', name: 'Cambodia', continent: 'AS', bbox: '10.4,102.3,14.7,107.6' },
{ code: 'CN', name: 'China', continent: 'AS', bbox: '18.2,73.5,53.6,134.8' },
{ code: 'GE', name: 'Georgia', continent: 'AS', bbox: '41.0,40.0,43.6,46.7' },
{ code: 'IN', name: 'India', continent: 'AS', bbox: '6.7,68.1,35.5,97.4' },
{ code: 'ID', name: 'Indonesia', continent: 'AS', bbox: '-11.0,95.0,6.1,141.0' },
{ code: 'IR', name: 'Iran', continent: 'AS', bbox: '25.0,44.0,39.8,63.3' },
{ code: 'IQ', name: 'Iraq', continent: 'AS', bbox: '29.1,38.8,37.4,48.6' },
{ code: 'IL', name: 'Israel', continent: 'AS', bbox: '29.5,34.2,33.3,35.9' },
{ code: 'JP', name: 'Japan', continent: 'AS', bbox: '24.0,122.9,45.5,145.8' },
{ code: 'JO', name: 'Jordan', continent: 'AS', bbox: '29.2,34.9,33.4,39.3' },
{ code: 'KZ', name: 'Kazakhstan', continent: 'AS', bbox: '40.6,46.5,55.4,87.3' },
{ code: 'KW', name: 'Kuwait', continent: 'AS', bbox: '28.5,46.5,30.1,48.4' },
{ code: 'KG', name: 'Kyrgyzstan', continent: 'AS', bbox: '39.2,69.3,43.3,80.3' },
{ code: 'LA', name: 'Laos', continent: 'AS', bbox: '13.9,100.1,22.5,107.7' },
{ code: 'LB', name: 'Lebanon', continent: 'AS', bbox: '33.0,35.1,34.7,36.6' },
{ code: 'MY', name: 'Malaysia', continent: 'AS', bbox: '0.9,99.6,7.4,119.3' },
{ code: 'MV', name: 'Maldives', continent: 'AS', bbox: '-0.7,72.7,7.1,73.7' },
{ code: 'MN', name: 'Mongolia', continent: 'AS', bbox: '41.6,87.7,52.1,119.9' },
{ code: 'MM', name: 'Myanmar', continent: 'AS', bbox: '9.8,92.2,28.5,101.2' },
{ code: 'NP', name: 'Nepal', continent: 'AS', bbox: '26.3,80.1,30.4,88.2' },
{ code: 'KP', name: 'North Korea', continent: 'AS', bbox: '37.7,124.2,43.0,130.7' },
{ code: 'OM', name: 'Oman', continent: 'AS', bbox: '16.6,52.0,26.4,59.8' },
{ code: 'PK', name: 'Pakistan', continent: 'AS', bbox: '23.7,60.9,37.1,77.8' },
{ code: 'PH', name: 'Philippines', continent: 'AS', bbox: '4.6,116.9,21.1,126.6' },
{ code: 'QA', name: 'Qatar', continent: 'AS', bbox: '24.5,50.7,26.2,51.6' },
{ code: 'SA', name: 'Saudi Arabia', continent: 'AS', bbox: '16.4,34.6,32.2,55.7' },
{ code: 'SG', name: 'Singapore', continent: 'AS', bbox: '1.2,103.6,1.5,104.1' },
{ code: 'KR', name: 'South Korea', continent: 'AS', bbox: '33.1,125.9,38.6,129.6' },
{ code: 'LK', name: 'Sri Lanka', continent: 'AS', bbox: '5.9,79.7,9.8,81.9' },
{ code: 'SY', name: 'Syria', continent: 'AS', bbox: '32.3,35.7,37.3,42.4' },
{ code: 'TW', name: 'Taiwan', continent: 'AS', bbox: '21.9,120.0,25.3,122.0' },
{ code: 'TJ', name: 'Tajikistan', continent: 'AS', bbox: '36.7,67.4,41.0,75.2' },
{ code: 'TH', name: 'Thailand', continent: 'AS', bbox: '5.6,97.3,20.5,105.6' },
{ code: 'TL', name: 'Timor-Leste', continent: 'AS', bbox: '-9.5,124.0,-8.1,127.3' },
{ code: 'TM', name: 'Turkmenistan', continent: 'AS', bbox: '35.1,52.4,42.8,66.7' },
{ code: 'AE', name: 'United Arab Emirates', continent: 'AS', bbox: '22.6,51.5,26.1,56.4' },
{ code: 'UZ', name: 'Uzbekistan', continent: 'AS', bbox: '37.2,55.9,45.6,73.1' },
{ code: 'VN', name: 'Vietnam', continent: 'AS', bbox: '8.2,102.1,23.4,109.5' },
{ code: 'YE', name: 'Yemen', continent: 'AS', bbox: '12.1,42.5,19.0,54.5' },
// ---- Africa ----
{ code: 'DZ', name: 'Algeria', continent: 'AF', bbox: '18.9,-8.7,37.1,12.0' },
{ code: 'AO', name: 'Angola', continent: 'AF', bbox: '-18.0,11.6,-4.4,24.1' },
{ code: 'BJ', name: 'Benin', continent: 'AF', bbox: '6.2,0.8,12.4,3.9' },
{ code: 'BW', name: 'Botswana', continent: 'AF', bbox: '-26.9,20.0,-17.8,29.4' },
{ code: 'BF', name: 'Burkina Faso', continent: 'AF', bbox: '9.4,-5.5,15.1,2.4' },
{ code: 'BI', name: 'Burundi', continent: 'AF', bbox: '-4.5,29.0,-2.3,30.8' },
{ code: 'CV', name: 'Cabo Verde', continent: 'AF', bbox: '14.8,-25.4,17.2,-22.7' },
{ code: 'CM', name: 'Cameroon', continent: 'AF', bbox: '1.7,8.5,13.1,16.2' },
{ code: 'CF', name: 'Central African Republic', continent: 'AF', bbox: '2.2,14.4,11.0,27.5' },
{ code: 'TD', name: 'Chad', continent: 'AF', bbox: '7.4,13.5,23.4,24.0' },
{ code: 'KM', name: 'Comoros', continent: 'AF', bbox: '-12.4,43.2,-11.4,44.5' },
{ code: 'CG', name: 'Congo', continent: 'AF', bbox: '-5.0,11.1,3.7,18.6' },
{ code: 'CD', name: 'DR Congo', continent: 'AF', bbox: '-13.5,12.2,5.4,31.3' },
{ code: 'DJ', name: 'Djibouti', continent: 'AF', bbox: '10.9,41.7,12.7,43.4' },
{ code: 'EG', name: 'Egypt', continent: 'AF', bbox: '22.0,25.0,31.7,36.9' },
{ code: 'GQ', name: 'Equatorial Guinea', continent: 'AF', bbox: '0.9,9.3,3.8,11.4' },
{ code: 'ER', name: 'Eritrea', continent: 'AF', bbox: '12.4,36.4,18.0,43.1' },
{ code: 'SZ', name: 'Eswatini', continent: 'AF', bbox: '-27.3,30.8,-25.7,32.1' },
{ code: 'ET', name: 'Ethiopia', continent: 'AF', bbox: '3.4,33.0,14.9,48.0' },
{ code: 'GA', name: 'Gabon', continent: 'AF', bbox: '-4.0,8.7,2.3,14.5' },
{ code: 'GM', name: 'Gambia', continent: 'AF', bbox: '13.1,-16.8,13.8,-13.8' },
{ code: 'GH', name: 'Ghana', continent: 'AF', bbox: '4.7,-3.3,11.2,1.2' },
{ code: 'GN', name: 'Guinea', continent: 'AF', bbox: '7.2,-15.1,12.7,-7.6' },
{ code: 'GW', name: 'Guinea-Bissau', continent: 'AF', bbox: '10.9,-16.7,12.7,-13.6' },
{ code: 'CI', name: 'Ivory Coast', continent: 'AF', bbox: '4.4,-8.6,10.7,-2.5' },
{ code: 'KE', name: 'Kenya', continent: 'AF', bbox: '-4.7,33.9,5.5,41.9' },
{ code: 'LS', name: 'Lesotho', continent: 'AF', bbox: '-30.7,27.0,-28.6,29.5' },
{ code: 'LR', name: 'Liberia', continent: 'AF', bbox: '4.3,-11.5,8.6,-7.4' },
{ code: 'LY', name: 'Libya', continent: 'AF', bbox: '19.5,9.3,33.2,25.2' },
{ code: 'MG', name: 'Madagascar', continent: 'AF', bbox: '-25.6,43.2,-11.9,50.5' },
{ code: 'MW', name: 'Malawi', continent: 'AF', bbox: '-17.1,32.7,-9.4,35.9' },
{ code: 'ML', name: 'Mali', continent: 'AF', bbox: '10.1,-12.3,25.0,4.3' },
{ code: 'MR', name: 'Mauritania', continent: 'AF', bbox: '14.7,-17.1,27.3,-4.8' },
{ code: 'MU', name: 'Mauritius', continent: 'AF', bbox: '-20.5,57.3,-19.9,57.8' },
{ code: 'MA', name: 'Morocco', continent: 'AF', bbox: '27.7,-13.2,35.9,-1.0' },
{ code: 'MZ', name: 'Mozambique', continent: 'AF', bbox: '-26.9,30.2,-10.5,40.8' },
{ code: 'NA', name: 'Namibia', continent: 'AF', bbox: '-28.9,11.7,-16.9,25.3' },
{ code: 'NE', name: 'Niger', continent: 'AF', bbox: '11.7,0.2,23.5,16.0' },
{ code: 'NG', name: 'Nigeria', continent: 'AF', bbox: '4.3,2.7,13.9,14.7' },
{ code: 'RW', name: 'Rwanda', continent: 'AF', bbox: '-2.8,28.9,-1.1,30.9' },
{ code: 'SN', name: 'Senegal', continent: 'AF', bbox: '12.3,-17.5,16.7,-11.4' },
{ code: 'SL', name: 'Sierra Leone', continent: 'AF', bbox: '6.9,-13.3,10.0,-10.3' },
{ code: 'SO', name: 'Somalia', continent: 'AF', bbox: '-1.7,40.9,12.0,51.4' },
{ code: 'ZA', name: 'South Africa', continent: 'AF', bbox: '-34.8,16.5,-22.1,32.9' },
{ code: 'SS', name: 'South Sudan', continent: 'AF', bbox: '3.5,24.1,12.2,35.9' },
{ code: 'SD', name: 'Sudan', continent: 'AF', bbox: '8.7,21.8,22.2,38.6' },
{ code: 'TZ', name: 'Tanzania', continent: 'AF', bbox: '-11.7,29.3,-1.0,40.4' },
{ code: 'TG', name: 'Togo', continent: 'AF', bbox: '6.1,-0.1,11.1,1.8' },
{ code: 'TN', name: 'Tunisia', continent: 'AF', bbox: '30.2,7.5,37.5,11.6' },
{ code: 'UG', name: 'Uganda', continent: 'AF', bbox: '-1.5,29.6,4.2,35.0' },
{ code: 'ZM', name: 'Zambia', continent: 'AF', bbox: '-18.1,21.9,-8.2,33.7' },
{ code: 'ZW', name: 'Zimbabwe', continent: 'AF', bbox: '-22.4,25.2,-15.6,33.1' },
// ---- North America ----
{ code: 'CA', name: 'Canada', continent: 'NA', bbox: '41.7,-141.0,83.1,-52.6' },
{ code: 'US', name: 'United States', continent: 'NA', bbox: '24.4,-125.0,49.4,-66.9' },
{ code: 'MX', name: 'Mexico', continent: 'NA', bbox: '14.5,-118.4,32.7,-86.7' },
{ code: 'GT', name: 'Guatemala', continent: 'NA', bbox: '13.7,-92.2,17.8,-88.2' },
{ code: 'BZ', name: 'Belize', continent: 'NA', bbox: '15.9,-89.2,18.5,-87.8' },
{ code: 'SV', name: 'El Salvador', continent: 'NA', bbox: '13.1,-90.1,14.4,-87.7' },
{ code: 'HN', name: 'Honduras', continent: 'NA', bbox: '12.9,-89.4,16.5,-83.1' },
{ code: 'NI', name: 'Nicaragua', continent: 'NA', bbox: '10.7,-87.7,15.0,-83.1' },
{ code: 'CR', name: 'Costa Rica', continent: 'NA', bbox: '8.0,-85.9,11.2,-82.5' },
{ code: 'PA', name: 'Panama', continent: 'NA', bbox: '7.2,-83.1,9.6,-77.2' },
{ code: 'CU', name: 'Cuba', continent: 'NA', bbox: '19.8,-85.0,23.3,-74.1' },
{ code: 'DO', name: 'Dominican Republic', continent: 'NA', bbox: '17.5,-72.0,19.9,-68.3' },
{ code: 'HT', name: 'Haiti', continent: 'NA', bbox: '18.0,-74.5,20.1,-71.6' },
{ code: 'JM', name: 'Jamaica', continent: 'NA', bbox: '17.7,-78.4,18.5,-76.2' },
{ code: 'BS', name: 'Bahamas', continent: 'NA', bbox: '20.9,-79.0,27.3,-72.7' },
{ code: 'TT', name: 'Trinidad and Tobago', continent: 'NA', bbox: '10.0,-61.9,11.4,-60.5' },
// ---- South America ----
{ code: 'AR', name: 'Argentina', continent: 'SA', bbox: '-55.1,-73.6,-21.8,-53.6' },
{ code: 'BO', name: 'Bolivia', continent: 'SA', bbox: '-22.9,-69.6,-9.7,-57.5' },
{ code: 'BR', name: 'Brazil', continent: 'SA', bbox: '-33.8,-74.0,5.3,-34.8' },
{ code: 'CL', name: 'Chile', continent: 'SA', bbox: '-55.9,-75.6,-17.5,-66.4' },
{ code: 'CO', name: 'Colombia', continent: 'SA', bbox: '-4.2,-79.0,12.5,-66.9' },
{ code: 'EC', name: 'Ecuador', continent: 'SA', bbox: '-5.0,-81.1,1.4,-75.2' },
{ code: 'GY', name: 'Guyana', continent: 'SA', bbox: '1.2,-61.4,8.6,-56.5' },
{ code: 'PY', name: 'Paraguay', continent: 'SA', bbox: '-27.6,-62.6,-19.3,-54.3' },
{ code: 'PE', name: 'Peru', continent: 'SA', bbox: '-18.4,-81.3,0.0,-68.7' },
{ code: 'SR', name: 'Suriname', continent: 'SA', bbox: '1.8,-58.1,6.0,-54.0' },
{ code: 'UY', name: 'Uruguay', continent: 'SA', bbox: '-35.0,-58.4,-30.1,-53.1' },
{ code: 'VE', name: 'Venezuela', continent: 'SA', bbox: '0.6,-73.4,12.2,-59.8' },
// ---- Oceania ----
{ code: 'AU', name: 'Australia', continent: 'OC', bbox: '-43.6,113.3,-10.7,153.6' },
{ code: 'NZ', name: 'New Zealand', continent: 'OC', bbox: '-47.3,166.4,-34.4,178.6' },
{ code: 'PG', name: 'Papua New Guinea', continent: 'OC', bbox: '-11.7,140.8,-1.3,155.9' },
{ code: 'FJ', name: 'Fiji', continent: 'OC', bbox: '-19.2,177.0,-16.0,180.0' },
]
// Quick lookups.
const BY_CODE = new Map(COUNTRIES.map((c) => [c.code, c]))
// Parse a "lamin,lomin,lamax,lomax" bbox into numbers, or null if malformed.
function parseBbox(s) {
const p = String(s || '').split(',').map((x) => Number(x.trim()))
if (p.length !== 4 || p.some((n) => Number.isNaN(n))) return null
return p // [lamin, lomin, lamax, lomax]
}
// bbox string for an ISO country code, or '' when unknown.
export function bboxForCountry(code) {
const c = BY_CODE.get(code)
return c ? c.bbox : ''
}
// Resolve a lat/lon point to the country whose bbox contains it with the
// smallest area — this disambiguates overlapping boxes (e.g. Vatican inside
// Italy) by preferring the tighter, more specific one. Returns the record or null.
export function countryForPoint(lat, lon) {
if (typeof lat !== 'number' || typeof lon !== 'number' || Number.isNaN(lat) || Number.isNaN(lon)) {
return null
}
let best = null
let bestArea = Infinity
for (const c of COUNTRIES) {
const b = parseBbox(c.bbox)
if (!b) continue
const [laMin, loMin, laMax, loMax] = b
if (lat < laMin || lat > laMax || lon < loMin || lon > loMax) continue
const area = Math.abs(laMax - laMin) * Math.abs(loMax - loMin)
if (area < bestArea) {
bestArea = area
best = c
}
}
return best
}
// European countries as picker options ({ value: bbox, label: name }), A→Z.
export function europeanCountries() {
return COUNTRIES.filter((c) => c.continent === 'EU')
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map((c) => ({ value: c.bbox, label: c.name }))
}
// All countries as [code, name] pairs for the Region <select>, A→Z.
export function regionOptions() {
return COUNTRIES.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map((c) => [c.code, c.name])
}
+4
View File
@@ -29,6 +29,10 @@ const defaults = {
// Live map: overlay live OpenSky air traffic (client-side toggle; the OpenSky // Live map: overlay live OpenSky air traffic (client-side toggle; the OpenSky
// integration itself is still gated in Settings → Integrations). // integration itself is still gated in Settings → Integrations).
showAirTraffic: true, showAirTraffic: true,
// Auto bounding box: when true, the live map resolves its area from the
// location cascade (drone telemetry → phone/browser location → Region country
// → Europe) instead of the fixed OpenSky config bbox. See countries.js.
autoBbox: true,
// Air-traffic refresh cadence: 'auto' follows the plan-recommended interval // Air-traffic refresh cadence: 'auto' follows the plan-recommended interval
// (from the server), or a fixed number of seconds (15 | 30 | 60 | 120). // (from the server), or a fixed number of seconds (15 | 30 | 60 | 120).
airTrafficInterval: 'auto', airTrafficInterval: 'auto',