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
@@ -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