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:
tajniak81andClaude Opus 4.8 committed 2026-07-14 00:07:12 +02:00
1 parent 150758b0bf
commit 94f6876024
20 files changed
+637 -71

No files matched your search

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