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:
co-authored by
Claude Opus 4.8
parent
150758b0bf
commit
94f6876024
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -6,7 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0F1E3D" />
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -7,9 +7,34 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"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
|
||||
// three-layer cascade (superadmin/global → organization → user). Each of the
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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{
|
||||
"clientId": res.eff.ClientID,
|
||||
"clientSecret": res.eff.ClientSecret,
|
||||
"plan": res.eff.Plan,
|
||||
"bbox": res.eff.Bbox,
|
||||
"bbox": bbox,
|
||||
"allowAnonymous": boolStr(res.allowAnon),
|
||||
}
|
||||
raw, err := s.plugins.InvokeWith(r.Context(), openSkyPlugin, cfg, "states.bbox", nil)
|
||||
|
||||
@@ -11,6 +11,10 @@ type Telemetry struct {
|
||||
Altitude *float64 `json:"altitude,omitempty"`
|
||||
Latitude *float64 `json:"latitude,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"`
|
||||
VelocityY *float64 `json:"velocityY,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 {
|
||||
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 {
|
||||
t.VelocityX = &v
|
||||
}
|
||||
|
||||
@@ -190,15 +190,61 @@ const BBOX_GROUPS = [
|
||||
{ value: "-56,-82,13,-34", label: "South America" },
|
||||
{ value: "-48,110,-10,180", label: "Oceania" },
|
||||
] },
|
||||
{ label: "Countries", options: [
|
||||
{ value: "49,14.1,54.9,24.2", label: "Poland" },
|
||||
{ value: "50.5,3.2,53.7,7.3", label: "Netherlands" },
|
||||
{ value: "47.2,5.8,55.1,15.1", label: "Germany" },
|
||||
// European countries (mirrors Web App/web/src/countries.js — keep in sync).
|
||||
{ label: "European countries", options: [
|
||||
{ value: "39.6,19.3,42.7,21.1", label: "Albania" },
|
||||
{ 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: "49.9,-8.7,59,1.8", label: "United Kingdom" },
|
||||
{ value: "35.9,-9.6,43.8,3.4", label: "Spain" },
|
||||
{ value: "47.2,5.8,55.1,15.1", label: "Germany" },
|
||||
{ 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: "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: "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);
|
||||
|
||||
@@ -21,6 +21,10 @@ class FlightModel extends ChangeNotifier {
|
||||
double? altitude;
|
||||
double? latitude;
|
||||
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;
|
||||
|
||||
UploadStatus upload = UploadStatus.disabled;
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
import 'dji_service.dart';
|
||||
import 'flight_model.dart';
|
||||
@@ -46,6 +47,7 @@ class _HomePageState extends State<HomePage> {
|
||||
final FlightModel _model = FlightModel();
|
||||
StreamSubscription<Map<String, dynamic>>? _sub;
|
||||
StreamSubscription<AuthStatus>? _authSub;
|
||||
StreamSubscription<Position>? _phoneLocSub;
|
||||
|
||||
// Streams telemetry to the API Server and receives commands back.
|
||||
late final ServerUploader _uploader;
|
||||
@@ -69,6 +71,43 @@ class _HomePageState extends State<HomePage> {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
_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 {
|
||||
@@ -195,6 +234,8 @@ class _HomePageState extends State<HomePage> {
|
||||
if (_model.altitude != null) tel['altitude'] = _model.altitude;
|
||||
if (_model.latitude != null) tel['latitude'] = _model.latitude;
|
||||
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);
|
||||
return events;
|
||||
}
|
||||
@@ -235,6 +276,7 @@ class _HomePageState extends State<HomePage> {
|
||||
void dispose() {
|
||||
_sub?.cancel();
|
||||
_authSub?.cancel();
|
||||
_phoneLocSub?.cancel();
|
||||
_uploadSub?.cancel();
|
||||
_uploader.dispose();
|
||||
_serverHost.dispose();
|
||||
|
||||
@@ -73,6 +73,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -105,6 +113,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -144,6 +160,54 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -437,6 +501,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -43,6 +43,10 @@ dependencies:
|
||||
# the platform BiometricPrompt). Requires FlutterFragmentActivity on Android.
|
||||
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:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
@@ -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.
|
||||
// Live aircraft positions for the Live map.
|
||||
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))
|
||||
a.doRelay(w, req)
|
||||
}
|
||||
|
||||
+20
File diff suppressed because one or more lines are too long
-20
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -35,8 +35,8 @@
|
||||
})()
|
||||
</script>
|
||||
<title>PilotVault — Control Panel</title>
|
||||
<script type="module" crossorigin src="./assets/index-CznFJTz4.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-D0ghlckk.css">
|
||||
<script type="module" crossorigin src="./assets/index-BSx6Lf8i.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-FLz21UuE.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -166,14 +166,16 @@ export async function testOpenSky() {
|
||||
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
|
||||
}
|
||||
|
||||
// Live aircraft positions (OpenSky state vectors) within the caller's resolved
|
||||
// bounding box, for plotting on the Live map. Returns { states, unavailable?,
|
||||
// detail?, plan, recommendedInterval } — an empty list with `unavailable` when
|
||||
// OpenSky is off for the caller. `recommendedInterval` (seconds) is derived from
|
||||
// the resolved plan and drives the "Auto" refresh cadence.
|
||||
export async function getOpenSkyStates() {
|
||||
// Live aircraft positions (OpenSky state vectors) for plotting on the Live map.
|
||||
// Pass an optional `bbox` ("lamin,lomin,lamax,lomax") to override the caller's
|
||||
// configured area — used by the auto cascade (drone/device/region location).
|
||||
// Returns { states, unavailable?, detail?, plan, recommendedInterval } — an empty
|
||||
// list with `unavailable` when OpenSky is off for the caller. `recommendedInterval`
|
||||
// (seconds) is derived from the resolved plan and drives the "Auto" refresh cadence.
|
||||
export async function getOpenSkyStates(bbox) {
|
||||
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' }
|
||||
const d = await r.json()
|
||||
return {
|
||||
|
||||
@@ -9,6 +9,7 @@ import Documents from './Documents.vue'
|
||||
import Toggle from './settings/Toggle.vue'
|
||||
import { getDevices, sendCommand, getOpenSkyStates } from '../api.js'
|
||||
import { formatTime, prefs } from '../prefs.js'
|
||||
import { countryForPoint, bboxForCountry } from '../countries.js'
|
||||
|
||||
const props = defineProps({
|
||||
email: { type: String, default: '' },
|
||||
@@ -50,9 +51,72 @@ const airIntervalSeconds = computed(() => {
|
||||
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() {
|
||||
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
|
||||
airspace.unavailable = unavailable
|
||||
airspace.detail = detail
|
||||
|
||||
@@ -6,6 +6,7 @@ import Segmented from './settings/Segmented.vue'
|
||||
import Row from './settings/Row.vue'
|
||||
import { themeMode, setThemeMode } from '../theme.js'
|
||||
import { prefs, formatDateTime, importPrefs, applyFontSize, applyReduceMotion } from '../prefs.js'
|
||||
import { europeanCountries, regionOptions } from '../countries.js'
|
||||
import {
|
||||
getUsers, createUser, updateUser, deleteUser,
|
||||
getOrgs, createOrg, updateOrg, deleteOrg,
|
||||
@@ -112,9 +113,10 @@ const TIME_OPTS = [
|
||||
const LANGS = [
|
||||
['en', 'English'], ['es', 'Español'], ['de', 'Deutsch'], ['fr', 'Français'], ['pl', 'Polski'], ['ja', '日本語'],
|
||||
]
|
||||
const REGIONS = [
|
||||
['US', 'United States'], ['GB', 'United Kingdom'], ['EU', 'European Union'], ['CA', 'Canada'], ['AU', 'Australia'], ['JP', 'Japan'],
|
||||
]
|
||||
// All countries of the world (also the final fallback for the auto bounding box).
|
||||
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 = [
|
||||
['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
|
||||
// plus a "Custom…" option that reveals the free-text field for manual entry.
|
||||
// Europe is the default (kept in sync with the API Server's defaultBBox).
|
||||
// plus "Automatic" (the location cascade) and "Custom…" (manual coordinates).
|
||||
// European countries come from the shared dataset in countries.js.
|
||||
const OS_BBOX_GROUPS = [
|
||||
{ label: 'World', options: [
|
||||
{ value: '-90,-180,90,180', label: 'World' },
|
||||
@@ -221,15 +223,12 @@ const OS_BBOX_GROUPS = [
|
||||
{ value: '-56,-82,13,-34', label: 'South America' },
|
||||
{ value: '-48,110,-10,180', label: 'Oceania' },
|
||||
] },
|
||||
{ label: 'Countries', options: [
|
||||
{ value: '49,14.1,54.9,24.2', label: 'Poland' },
|
||||
{ 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' },
|
||||
{ label: 'European countries', options: europeanCountries() },
|
||||
{ label: 'Other countries', options: [
|
||||
{ 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)
|
||||
@@ -253,20 +252,32 @@ function osBboxLabel(v) {
|
||||
// Manual-entry toggle: sticky once the user picks "Custom…", and implied when
|
||||
// the current value doesn't match any preset.
|
||||
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({
|
||||
get() {
|
||||
if (!osEditingOrg.value && prefs.autoBbox) return '__auto__'
|
||||
if (osBboxCustom.value) return '__custom__'
|
||||
const norm = osNormBbox(osForm.bbox)
|
||||
const match = norm && OS_BBOX_FLAT.find((o) => osNormBbox(o.value) === norm)
|
||||
return match ? match.value : '__custom__'
|
||||
},
|
||||
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 }
|
||||
osBboxCustom.value = false
|
||||
osForm.bbox = v
|
||||
},
|
||||
})
|
||||
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.
|
||||
const osReadOnly = computed(() => os.isSuperadmin)
|
||||
@@ -1497,7 +1508,7 @@ onBeforeUnmount(() => {
|
||||
</Row>
|
||||
|
||||
<!-- 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')">
|
||||
<span class="inline-flex items-center gap-2 font-mono text-sm text-ink">
|
||||
{{ osBboxLabel(osField('bbox').effective) || osField('bbox').effective || '—' }}
|
||||
@@ -1506,11 +1517,15 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
<div v-else class="flex flex-col items-end gap-2">
|
||||
<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">
|
||||
<option v-for="o in g.options" :key="o.value" :value="o.value">{{ o.label }}</option>
|
||||
</optgroup>
|
||||
<option value="__custom__">Custom…</option>
|
||||
</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" />
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -29,6 +29,10 @@ const defaults = {
|
||||
// Live map: overlay live OpenSky air traffic (client-side toggle; the OpenSky
|
||||
// integration itself is still gated in Settings → Integrations).
|
||||
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
|
||||
// (from the server), or a fixed number of seconds (15 | 30 | 60 | 120).
|
||||
airTrafficInterval: 'auto',
|
||||
|
||||
Reference in New Issue
Block a user