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
+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.
// 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)
}
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>
<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>
+9 -7
View File
@@ -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 {
+65 -1
View File
@@ -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
+29 -14
View File
@@ -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>
+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
// 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',