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:
1 parent
150758b0bf
commit
94f6876024
20 files changed
+637
-71
No files matched your search
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user