mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-03 11:09:55 +08:00
Optimize Maps, No Frogs
This commit is contained in:
@@ -52,7 +52,7 @@ def _fresh_navigation(raw, path):
|
||||
if not isinstance(value, dict) or document['token'] != value.get('token'):
|
||||
raise ValueError('Navigation credential cannot be preserved safely')
|
||||
return canonical_json({'version': 1, 'revision': uuid.uuid4().hex, 'enabled': False, 'token': document['token'],
|
||||
'destination': None, 'favorites': [], 'routeChoice': 0})
|
||||
'destination': None, 'favorites': [], 'recents': [], 'routeChoice': 0})
|
||||
|
||||
|
||||
def reset_external_preferences(storage, model_root):
|
||||
@@ -82,7 +82,7 @@ def reset_external_preferences(storage, model_root):
|
||||
desired['navigation_settings'] = _fresh_navigation(original['navigation_settings'], paths['navigation_settings'])
|
||||
except (ValueError, UnicodeError, RecursionError):
|
||||
desired['navigation_settings'] = canonical_json({'version': 1, 'revision': uuid.uuid4().hex, 'enabled': False,
|
||||
'token': '', 'destination': None, 'favorites': [], 'routeChoice': 0})
|
||||
'token': '', 'destination': None, 'favorites': [], 'recents': [], 'routeChoice': 0})
|
||||
issues.append('Invalid navigation credential document archived; fresh navigation is disabled')
|
||||
journal = {'version': 2, 'snapshot': str(snapshot),
|
||||
'desired': {key: base64.b64encode(raw).decode() for key, raw in desired.items()}, 'issues': issues}
|
||||
|
||||
@@ -959,8 +959,13 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
|
||||
elif path == '/api/navigation/status':
|
||||
if not self.require_session():
|
||||
return
|
||||
# A poll that already holds the drawn route sends its key and skips the route lines.
|
||||
try:
|
||||
result = navigation_owner().snapshot()
|
||||
route_key = parse_qs(urlsplit(self.path).query, max_num_fields=1).get('routeKey', [''])[0]
|
||||
except ValueError:
|
||||
route_key = ''
|
||||
try:
|
||||
result = navigation_owner().snapshot(route_key if re.fullmatch(r'[0-9a-f]{16}', route_key) else None)
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
self.json(503, {'error': 'Navigation status is unavailable'})
|
||||
else:
|
||||
@@ -1641,15 +1646,16 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
|
||||
if 'searchId' in payload else navigation_owner().search(payload['query']))}
|
||||
else:
|
||||
fields = {'configure': {'patch'}, 'select': {'destination'}, 'selectPlace': {'id', 'searchId'},
|
||||
'cancelSearch': {'searchId'}, 'clear': set(),
|
||||
'favorite': {'destination'}, 'removeFavorite': {'id'}, 'selectRoute': {'index'}}
|
||||
'cancelSearch': {'searchId'}, 'clear': set(), 'favorite': {'destination'},
|
||||
'favoritePlace': {'id', 'searchId'}, 'removeFavorite': {'id'}, 'labelFavorite': {'id', 'label'},
|
||||
'removeRecent': {'id'}, 'clearRecents': set(), 'selectRoute': {'index'}}
|
||||
action = payload.get('action')
|
||||
if type(action) is not str or action not in fields or set(payload) != {'action', 'revision'} | fields[action]:
|
||||
raise ValidationError('Invalid navigation action')
|
||||
needs_park = action == 'configure' and isinstance(payload['patch'], dict) and 'token' in payload['patch']
|
||||
def authorized():
|
||||
return self.settings_session() == identity and (not needs_park or configuration_allowed())
|
||||
with (nullcontext() if action == 'selectPlace' else effect_lock):
|
||||
with (nullcontext() if action in ('selectPlace', 'favoritePlace') else effect_lock):
|
||||
if not authorized():
|
||||
raise PermissionError('Park your car before changing the Mapbox key' if needs_park else 'Sign in to Galaxy')
|
||||
nav = navigation_owner()
|
||||
@@ -1669,10 +1675,19 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
|
||||
result = nav.select_route(payload['index'], **keywords)
|
||||
elif action == 'favorite':
|
||||
result = nav.favorite(payload['destination'], **keywords)
|
||||
elif action == 'favoritePlace':
|
||||
result = nav.favorite_place(payload['id'], payload['searchId'], identity, **keywords)
|
||||
elif action == 'clearRecents':
|
||||
result = nav.clear_recents(**keywords)
|
||||
else:
|
||||
if type(payload['id']) is not str or not 1 <= len(payload['id']) <= 256:
|
||||
raise ValidationError('Choose a saved place')
|
||||
result = nav.remove_favorite(payload['id'], **keywords)
|
||||
if action == 'removeRecent':
|
||||
result = nav.remove_recent(payload['id'], **keywords)
|
||||
elif action == 'labelFavorite':
|
||||
result = nav.label_favorite(payload['id'], payload['label'], **keywords)
|
||||
else:
|
||||
result = nav.remove_favorite(payload['id'], **keywords)
|
||||
except ConflictError as error:
|
||||
self.json(409, {'error': str(error)})
|
||||
except ValidationError as error:
|
||||
|
||||
@@ -113,7 +113,7 @@ console.log("Navigation client: validation, route geometry, mutation ordering, t
|
||||
await start
|
||||
const search = client.search("Coffee")
|
||||
const sent = JSON.parse(requests[1].options.body)
|
||||
const poi = { id: "poi/id", name: "Coffee Shop", description: "123 Main St, Springfield", temporary: true, searchId: sent.searchId }
|
||||
const poi = { id: "poi/id", name: "Coffee Shop", description: "123 Main St, Springfield", searchId: sent.searchId }
|
||||
assert(validSearchResult(poi))
|
||||
assert(!validSearchResult({ ...poi, searchId: "bad" }))
|
||||
assert(!validSearchResult({ ...poi, description: "x".repeat(513) }))
|
||||
@@ -122,15 +122,69 @@ console.log("Navigation client: validation, route geometry, mutation ordering, t
|
||||
await search
|
||||
const choose = client.choose(poi)
|
||||
assert.deepEqual(JSON.parse(requests[2].options.body), { action: "selectPlace", revision: "a", id: "poi/id", searchId: sent.searchId })
|
||||
requests[2].resolve(response({ ...snapshot("poi"), destination: { ...place, temporary: true } }))
|
||||
requests[2].resolve(response({ ...snapshot("poi"), destination: { ...place, address: "123 Main St" }, recents: [place] }))
|
||||
await choose
|
||||
assert.equal(states.at(-1).data.destination.temporary, true)
|
||||
assert.equal(states.at(-1).data.destination.address, "123 Main St")
|
||||
assert.equal(states.at(-1).data.recents[0].name, "Library")
|
||||
assert.deepEqual(states.at(-1).results, [])
|
||||
client.stop()
|
||||
assert.equal(JSON.parse(requests.at(-1).options.body).action, "cancelSearch")
|
||||
}
|
||||
console.log("POI suggestions: validation, selected-only retrieval, destination state and cancellation passed")
|
||||
|
||||
{
|
||||
// Saved and recent places travel without their ids; suggestions are saved through the search that found them.
|
||||
const { client, requests } = setup()
|
||||
const start = client.start()
|
||||
requests[0].resolve(response({ ...snapshot(), favorites: [{ ...place, label: "home" }], recents: [{ ...place, id: "other", address: "1 Main" }] }))
|
||||
await start
|
||||
client.choose({ ...place, label: "home" })
|
||||
assert.deepEqual(JSON.parse(requests[1].options.body), { action: "select", revision: "a", destination: { name: "Library", latitude: 41, longitude: -88 } })
|
||||
requests[1].resolve(response(snapshot("b"))); await flush()
|
||||
client.save({ id: "poi/id", name: "Coffee", searchId: "0".repeat(36) })
|
||||
assert.deepEqual(JSON.parse(requests[2].options.body), { action: "favoritePlace", revision: "b", id: "poi/id", searchId: "0".repeat(36) })
|
||||
requests[2].resolve(response(snapshot("c"))); await flush()
|
||||
client.save({ ...place, address: "1 Main" })
|
||||
assert.deepEqual(JSON.parse(requests[3].options.body).destination, { name: "Library", latitude: 41, longitude: -88, address: "1 Main" })
|
||||
client.stop()
|
||||
assert(!validNavigation({ ...snapshot(), recents: [{ ...place, label: "gym" }] }))
|
||||
assert(!validNavigation({ ...snapshot(), recents: Array(11).fill(place) }))
|
||||
}
|
||||
console.log("Favorites and recents: choose, save and validation passed")
|
||||
|
||||
{
|
||||
// Polls send the held route key; an unchanged reply reuses the lines already drawn.
|
||||
const { client, requests, states } = setup()
|
||||
const line = [{ latitude: 41, longitude: -88 }, { latitude: 41.1, longitude: -88.1 }]
|
||||
const guiding = { ...snapshot(), destination: place, status: "routing", route: line, routeKey: "0123456789abcdef" }
|
||||
const start = client.start()
|
||||
assert.equal(requests[0].url, "./api/navigation/status")
|
||||
requests[0].resolve(response(guiding))
|
||||
await start
|
||||
const held = states.at(-1).data
|
||||
assert(Object.isFrozen(held.route))
|
||||
const poll = client.load()
|
||||
assert.equal(requests[1].url, "./api/navigation/status?routeKey=0123456789abcdef")
|
||||
const { route, ...light } = guiding
|
||||
requests[1].resolve(response({ ...light, routeUnchanged: true }))
|
||||
await poll
|
||||
assert.equal(states.at(-1).data, held, "an identical reply keeps the same object, so nothing re-renders")
|
||||
const moved = client.load()
|
||||
requests[2].resolve(response({ ...light, routeUnchanged: true, status: "guiding" }))
|
||||
await moved
|
||||
assert.equal(states.at(-1).data.status, "guiding")
|
||||
assert.equal(states.at(-1).data.route, held.route)
|
||||
const lost = client.load()
|
||||
requests[3].resolve(response({ ...light, routeKey: "fedcba9876543210", routeUnchanged: true }))
|
||||
await lost
|
||||
assert.deepEqual(states.at(-1).data.route, [])
|
||||
const again = client.load()
|
||||
assert.equal(requests[4].url, "./api/navigation/status", "a reply for lines the page does not hold asks for them again")
|
||||
requests[4].resolve(response(guiding)); await again
|
||||
client.stop()
|
||||
}
|
||||
console.log("Route key polling: light replies, reuse, and recovery passed")
|
||||
|
||||
{
|
||||
const descriptor = Object.getOwnPropertyDescriptor(globalThis, "crypto")
|
||||
const realCrypto = globalThis.crypto
|
||||
@@ -154,6 +208,6 @@ console.log("Local HTTP search UUID fallback passed")
|
||||
page.token = 'pk.replacement'; await NavigationPage.methods.saveKey.call(page, false)
|
||||
assert.deepEqual(actions[1], ['configure', { patch: { token: 'pk.replacement' } }])
|
||||
assert.ok(NavigationPage.template.includes('Save and enable'))
|
||||
assert.ok(NavigationPage.template.includes('v-if="data?.enabled && data?.hasKey"'))
|
||||
assert.ok(NavigationPage.template.includes('<NavigationMap v-if="ready"'))
|
||||
}
|
||||
await import("./test_navigation_map.mjs")
|
||||
|
||||
@@ -6,7 +6,7 @@ const home = { id: "h1", name: "12 Elm St", latitude: 40.1, longitude: -90.1 }
|
||||
const gym = { id: "g1", name: "Elm Fitness", latitude: 40.3, longitude: -90.3 }
|
||||
const status = { enabled: true, isMetric: false, hasKey: true, status: "noDestination", revision: "r1", destination: null,
|
||||
favorites: [home, gym], route: [], instruction: null, location: null }
|
||||
const suggestion = (id, searchId) => ({ id, name: "Coffee " + id, description: "Main St", searchId, temporary: true })
|
||||
const suggestion = (id, searchId) => ({ id, name: "Coffee " + id, description: "Main St", searchId })
|
||||
|
||||
assert.equal(validDestination(home), true)
|
||||
|
||||
@@ -75,16 +75,39 @@ assert.equal(stale, null)
|
||||
refuse = false
|
||||
assert.equal((await refused.suggest("coffee")).length, 1)
|
||||
|
||||
// Page logic: matching saved places show first in the dropdown.
|
||||
// Page logic: saved and recent places match first in the dropdown; an empty box lists them all.
|
||||
const computed = NavigationPage.computed
|
||||
const page = { data: status, query: "elm", suggestOpen: true, suggestions: [] }
|
||||
page.favoriteMatches = computed.favoriteMatches.call(page)
|
||||
assert.deepEqual(page.favoriteMatches.map((place) => place.id), ["h1", "g1"])
|
||||
assert.equal(computed.showSuggestions.call(page), true)
|
||||
assert.equal(computed.showSuggestions.call({ ...page, favoriteMatches: [], suggestions: [] }), false)
|
||||
const usage = { searchSessions: { used: 12, limit: 495, free: 500 }, directions: { used: 3, limit: 99000, free: 100000 }, staticTiles: { used: 0, limit: 198000, free: 200000 } }
|
||||
const recent = { id: "r1", name: "Elm Diner", address: "5 Oak Ave", latitude: 40.5, longitude: -90.5 }
|
||||
const evaluate = (state) => {
|
||||
const page = { suggestOpen: true, suggestions: [], results: [], ...state }
|
||||
for (const name of ["ready", "favorites", "savedIds", "recents", "shortcuts", "localMatches", "showQuick", "showSuggestions", "showShortcuts"])
|
||||
page[name] = computed[name].call(page)
|
||||
return page
|
||||
}
|
||||
let page = evaluate({ data: { ...status, recents: [recent, home] }, query: "elm" })
|
||||
assert.deepEqual(page.recents.map((place) => place.id), ["r1"], "recents leave out places that are already saved")
|
||||
assert.deepEqual(page.localMatches.map((place) => place.id), ["h1", "g1", "r1"])
|
||||
assert.equal(page.showSuggestions, true)
|
||||
assert.deepEqual(evaluate({ data: status, query: "oak" }).localMatches, [], "only this page's places are matched")
|
||||
assert.deepEqual(evaluate({ data: { ...status, recents: [recent] }, query: "oak" }).localMatches.map((place) => place.id), ["r1"], "addresses match too")
|
||||
assert.equal(evaluate({ data: status, query: "zzz" }).showSuggestions, false)
|
||||
page = evaluate({ data: { ...status, recents: [recent] }, query: "" })
|
||||
assert.equal(page.showQuick, true)
|
||||
assert.equal(page.showShortcuts, false, "one-tap places hide while the list is open")
|
||||
page = evaluate({ data: { ...status, recents: [recent] }, query: "", suggestOpen: false })
|
||||
assert.deepEqual([page.showQuick, page.showShortcuts], [false, true])
|
||||
assert.equal(evaluate({ data: { ...status, destination: home }, query: "", suggestOpen: false }).showShortcuts, false)
|
||||
const methods = NavigationPage.methods
|
||||
const labeled = { ...home, label: "home" }
|
||||
assert.deepEqual([methods.placeName(labeled), methods.placeDetail(labeled)], ["Home", "12 Elm St"])
|
||||
assert.equal(methods.placeIcon.call({ isFavorite: () => true }, labeled), "bi-house-fill")
|
||||
const actions = []
|
||||
await methods.setLabel.call({ client: { action: async (...args) => actions.push(args) } }, labeled, "home")
|
||||
assert.deepEqual(actions.at(-1), ["labelFavorite", { id: "h1", label: null }], "choosing the current label clears it")
|
||||
const usage = { searchSessions: { used: 12, limit: 495, free: 500 }, geocoding: { used: 4, limit: 99000, free: 100000 },
|
||||
directions: { used: 3, limit: 99000, free: 100000 }, staticTiles: { used: 0, limit: 198000, free: 200000 } }
|
||||
assert.deepEqual(computed.usageRows.call({ data: { mapboxUsage: usage } }).map((row) => [row.label, row.used, row.limit]),
|
||||
[["Searches", 12, 495], ["Routes", 3, 99000], ["Map views", 0, 198000]])
|
||||
[["Searches", 12, 495], ["Address lookups", 4, 99000], ["Routes", 3, 99000], ["Map views", 0, 198000]])
|
||||
|
||||
compile(NavigationPage.template, { decodeEntities: (value) => value.replaceAll("&", "&") })
|
||||
console.log("Navigation autocomplete: one session per typing burst, parallel polling, session reset, usage and template passed")
|
||||
|
||||
@@ -169,14 +169,45 @@ class NavigationHttpTest(unittest.TestCase):
|
||||
self.assertEqual(result['destination']['name'],'Coffee Shop')
|
||||
self.assertEqual(provider.call_count,2)
|
||||
self.assertEqual(provider.call_args_list[0].args[2]['session_token'],provider.call_args_list[1].args[2]['session_token'])
|
||||
unmarked = dict(result['destination'])
|
||||
unmarked.pop('temporary')
|
||||
self.assertEqual(self.action('favorite',destination=unmarked)[0],400)
|
||||
self.assertEqual(self.action('select',destination=unmarked)[0],400)
|
||||
self.assertIsNone(self.navigation.read()['destination'])
|
||||
self.assertNotIn('Coffee Shop',self.navigation.path.read_text())
|
||||
self.assertEqual(self.navigation.read()['destination']['name'],'Coffee Shop')
|
||||
self.assertEqual([row['name'] for row in result['recents']],['Coffee Shop'])
|
||||
self.assertNotIn('pk.synthetic',json.dumps(result))
|
||||
self.assertEqual(self.action('selectPlace',id=identity,searchId=search['searchId'])[0],400)
|
||||
status, saved, _ = self.action('favorite',destination=result['destination'])
|
||||
self.assertEqual(status,200)
|
||||
place = saved['favorites'][0]['id']
|
||||
status, labeled, _ = self.action('labelFavorite',id=place,label='home')
|
||||
self.assertEqual((status,labeled['favorites'][0]['label']),(200,'home'))
|
||||
self.assertEqual(self.action('labelFavorite',id=place,label='gym')[0],400)
|
||||
self.assertEqual(self.action('labelFavorite',id=place,label=None)[1]['favorites'][0].get('label'),None)
|
||||
self.assertEqual(self.action('removeRecent',id=place)[1]['recents'],[])
|
||||
self.assertEqual(self.action('clearRecents')[1]['recents'],[])
|
||||
self.assertEqual(self.action('removeFavorite',id=place)[1]['favorites'],[])
|
||||
|
||||
def test_search_result_can_be_saved_without_navigating(self):
|
||||
self.action('configure', patch={'token':'pk.synthetic','enabled':True})
|
||||
search = {'query':'coffee', 'searchId':str(uuid.uuid4()), 'clientId':str(uuid.uuid4())}
|
||||
suggestion = {'suggestions':[{'mapbox_id':'poi/id','name':'Coffee Shop','feature_type':'poi'}]}
|
||||
feature = {'features':[{'properties':{'mapbox_id':'poi/id','name':'Coffee Shop','full_address':'1 Main St'},
|
||||
'geometry':{'coordinates':[-90.,40.]}}]}
|
||||
with patch('openpilot.starpilot.navigation.owner.response_json',side_effect=[suggestion,feature,feature]):
|
||||
self.assertEqual(self.request('/api/navigation/search',payload=search,cookie=self.local_cookie)[0],200)
|
||||
status, saved, _ = self.action('favoritePlace',id='poi/id',searchId=search['searchId'])
|
||||
self.assertEqual(status,200)
|
||||
self.assertEqual((saved['favorites'][0]['name'],saved['favorites'][0]['address'],saved['destination']),('Coffee Shop','1 Main St',None))
|
||||
status, chosen, _ = self.action('selectPlace',id='poi/id',searchId=search['searchId'])
|
||||
self.assertEqual((status,chosen['destination']['name']),(200,'Coffee Shop'))
|
||||
|
||||
def test_status_poll_with_route_key_omits_unchanged_route(self):
|
||||
self.action('configure', patch={'token':'pk.synthetic','enabled':True})
|
||||
status, full, _ = self.request('/api/navigation/status', cookie=self.local_cookie)
|
||||
self.assertEqual(status,200)
|
||||
status, light, _ = self.request('/api/navigation/status?routeKey=' + full['routeKey'], cookie=self.local_cookie)
|
||||
self.assertEqual((status,light['routeUnchanged']),(200,True))
|
||||
self.assertNotIn('route',light)
|
||||
status, ignored, _ = self.request('/api/navigation/status?routeKey=bogus&x=1', cookie=self.local_cookie)
|
||||
self.assertEqual(status,200)
|
||||
self.assertIn('route',ignored)
|
||||
|
||||
def test_slow_poi_retrieve_does_not_block_other_settings_actions(self):
|
||||
self.action('configure',patch={'token':'pk.synthetic','enabled':True})
|
||||
|
||||
@@ -19,7 +19,10 @@ const ctx = new Proxy({}, { get: (obj, key) => obj[key] || (() => {}) })
|
||||
const canvas = { clientWidth: 400, clientHeight: 360, getContext: () => ctx, setPointerCapture() {}, addEventListener: (name, fn) => listeners.set(name, fn), removeEventListener: name => listeners.delete(name) }
|
||||
const map = new RasterMap(canvas)
|
||||
map.load = () => {}
|
||||
map.update({ destination: { latitude: 40, longitude: -90 }, route: [{ latitude: 40, longitude: -90 }, { latitude: 40.01, longitude: -89.99 }] }, false)
|
||||
map.update({ destination: { id: 'd', latitude: 40, longitude: -90 }, route: [{ latitude: 40, longitude: -90 }, { latitude: 40.01, longitude: -89.99 }], routeKey: '0123456789abcdef' }, false)
|
||||
const fitted = map.zoom
|
||||
assert.ok(fitted >= 10 && Number.isInteger(fitted) && map.follow === false, 'a new route is shown whole')
|
||||
assert.ok(Math.abs(map.center.latitude - 40.005) < 1e-6 && Math.abs(map.center.longitude + 89.995) < 1e-6)
|
||||
const before = project(map.center, map.zoom)
|
||||
listeners.get('pointerdown')({ clientX: 100, clientY: 100, pointerId: 1 })
|
||||
listeners.get('pointermove')({ clientX: 150, clientY: 120 })
|
||||
@@ -28,8 +31,8 @@ listeners.get('pointerup')({})
|
||||
const panned = { ...map.center }
|
||||
map.update(map.data, false)
|
||||
assert.deepEqual(map.center, panned)
|
||||
map.changeZoom(1); assert.equal(map.zoom, 3)
|
||||
map.recenter(); assert.equal(map.zoom, 2)
|
||||
map.changeZoom(1); assert.equal(map.zoom, fitted + 1)
|
||||
map.recenter(); assert.equal(map.zoom, 2); assert.equal(map.follow, true)
|
||||
map.close(); assert.equal(listeners.size, 0); assert.deepEqual(calls, ['disconnect'])
|
||||
console.log('Map projection, dateline, pan, zoom, recenter, poll stability and cleanup passed')
|
||||
const timers = new Map(); let next = 0
|
||||
@@ -102,3 +105,44 @@ measured.close(); assert.equal(frames.size, 0); assert.equal(closedImages, 3)
|
||||
globalThis.fetch = savedFetch; globalThis.createImageBitmap = savedBitmap
|
||||
globalThis.requestAnimationFrame = originalRaf; globalThis.cancelAnimationFrame = originalCancelRaf
|
||||
console.log('Tile completions coalesce into one frame; canvas allocation changes only on resize; close cancels paint')
|
||||
|
||||
{
|
||||
// Following keeps the car centered until a pan; pinch and double-tap zoom around the fingers and settle on whole levels.
|
||||
const gestures = new Map()
|
||||
const surface = { clientWidth: 400, clientHeight: 400, getContext: () => ctx, getBoundingClientRect: () => ({ left: 0, top: 0 }),
|
||||
addEventListener: (name, fn) => gestures.set(name, fn), removeEventListener: name => gestures.delete(name) }
|
||||
globalThis.requestAnimationFrame = fn => { fn(); return 1 }
|
||||
globalThis.cancelAnimationFrame = () => {}
|
||||
const live = new RasterMap(surface); live.load = () => {}
|
||||
live.update({ location: { latitude: 40, longitude: -90, validForMs: 2000 } }, false)
|
||||
assert.deepEqual([live.center.latitude, live.zoom, live.follow], [40, 15, true])
|
||||
live.update({ location: { latitude: 40.001, longitude: -90, validForMs: 2000 } }, false)
|
||||
assert.equal(live.center.latitude, 40.001, 'following moves with the car')
|
||||
live.changeZoom(1); assert.equal(live.center.latitude, 40.001, 'zoom buttons keep the car centered while following')
|
||||
const anchor = live.pointAt(300, 100)
|
||||
gestures.get('pointerdown')({ pointerId: 1, clientX: 280, clientY: 100 })
|
||||
gestures.get('pointerdown')({ pointerId: 2, clientX: 320, clientY: 100 })
|
||||
gestures.get('pointermove')({ pointerId: 2, clientX: 360, clientY: 100 })
|
||||
assert.ok(Math.abs(live.zoom - 17) < 1e-9 && live.follow === false, 'spreading two fingers to twice the distance zooms one level')
|
||||
const held = live.pointAt(320, 100)
|
||||
assert.ok(Math.abs(held.latitude - anchor.latitude) < 1e-9 && Math.abs(held.longitude - anchor.longitude) < 1e-9, 'the pinched place stays under the fingers')
|
||||
gestures.get('pointermove')({ pointerId: 2, clientX: 370, clientY: 100 })
|
||||
gestures.get('pointerup')({ pointerId: 2, type: 'pointerup', clientX: 370, clientY: 100 })
|
||||
assert.ok(Number.isInteger(live.zoom), 'a pinch settles on a whole zoom level')
|
||||
gestures.get('pointerup')({ pointerId: 1, type: 'pointerup', clientX: 280, clientY: 100 })
|
||||
live.update({ location: { latitude: 41, longitude: -90, validForMs: 2000 } }, false)
|
||||
assert.notEqual(live.center.latitude, 41, 'a paused map does not jump back to the car')
|
||||
const zoom = live.zoom, target = live.pointAt(100, 300)
|
||||
for (let tap = 0; tap < 2; tap++) {
|
||||
gestures.get('pointerdown')({ pointerId: 3, clientX: 100, clientY: 300 })
|
||||
gestures.get('pointerup')({ pointerId: 3, type: 'pointerup', clientX: 100, clientY: 300 })
|
||||
}
|
||||
assert.equal(live.zoom, zoom + 1, 'double-tap zooms in')
|
||||
const kept = live.pointAt(100, 300)
|
||||
assert.ok(Math.abs(kept.latitude - target.latitude) < 1e-9 && Math.abs(kept.longitude - target.longitude) < 1e-9)
|
||||
live.recenter()
|
||||
assert.deepEqual([live.center.latitude, live.zoom, live.follow], [41, 15, true])
|
||||
live.close()
|
||||
globalThis.requestAnimationFrame = originalRaf; globalThis.cancelAnimationFrame = originalCancelRaf
|
||||
}
|
||||
console.log('Follow, pinch and double-tap zoom passed')
|
||||
|
||||
@@ -1,45 +1,88 @@
|
||||
/* Map fills the viewport intentionally; its panels use the shared design tokens. */
|
||||
.gx-navigation--fullscreen {position:fixed;inset:0;z-index:15;padding:0!important;background:var(--background);overflow:hidden}
|
||||
.gx-navigation--fullscreen {position:fixed;inset:0;z-index:15;padding:0!important;background:#ece7df;overflow:hidden}
|
||||
.gx-app:has(.gx-navigation--fullscreen) .gx-appbar {background:none;box-shadow:none;pointer-events:none;transform:none}
|
||||
.gx-app:has(.gx-navigation--fullscreen) .gx-appbar__pill,.gx-app:has(.gx-navigation--fullscreen) .gx-theme-toggle {display:none}
|
||||
.gx-app:has(.gx-navigation--fullscreen) .gx-appbar__back,.gx-app:has(.gx-navigation--fullscreen) .gx-appbar__menu {position:fixed;top:auto;bottom:16px;pointer-events:auto;background:var(--glass-bg);border:1px solid var(--glass-border);border-radius:12px;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}
|
||||
.gx-app:has(.gx-navigation--fullscreen) .gx-appbar__back {left:auto;right:72px}
|
||||
.gx-app:has(.gx-navigation--fullscreen) .gx-appbar__menu {right:16px}
|
||||
@media(min-width:768px) {.gx-nav-pinned .gx-navigation--fullscreen {left:320px}}
|
||||
.gx-navigation--fullscreen>h2 {display:none}
|
||||
.gx-navigation--fullscreen>.gx-navigation__tabs {position:absolute;bottom:16px;left:16px;z-index:4;margin:0;gap:var(--sp-2);max-width:calc(100% - 120px)}
|
||||
.gx-navigation--fullscreen .gx-navigation-map {position:absolute;inset:0;margin:0;border-radius:0;background:var(--background)}
|
||||
.gx-navigation--fullscreen .gx-navigation-map canvas {height:100%;background:var(--background)}
|
||||
.gx-navigation__panel {position:absolute;top:16px;left:16px;width:min(420px,calc(100% - 104px));max-height:calc(100% - 88px);overflow:auto;z-index:3}
|
||||
.gx-navigation__panel .gx-navigation__section {max-width:500px;margin:8px 0;padding:8px}
|
||||
.gx-navigation__search {display:flex;flex-wrap:wrap;gap:var(--sp-2);margin:0 0 8px}
|
||||
.gx-navigation__search .gx-btn {flex:0 0 auto;}
|
||||
.gx-navigation__summary-title {margin:0 0 8px;padding:0;border-radius:var(--radius-sm);background:var(--surface-container-high);text-align:center;font-size:1.2rem;line-height:1.2}
|
||||
.gx-navigation__summary>div {display:grid;grid-template-columns:32px 96px 1fr;align-items:center;font-size:1rem;font-weight:550;min-height:32px}
|
||||
.gx-navigation__summary>div>span:first-child {text-align:center}
|
||||
.gx-navigation__route-actions {display:flex;justify-content:center;gap:8px;margin-top:8px}
|
||||
.gx-navigation__panel .gx-navigation__instruction {font-size:1rem;margin:8px 0}
|
||||
.gx-navigation--fullscreen>.gx-navigation__tabs {position:absolute;bottom:16px;left:16px;z-index:4;margin:0;gap:8px}
|
||||
.gx-navigation--fullscreen .gx-navigation-map {position:absolute;inset:0;margin:0;border-radius:0;background:#ece7df}
|
||||
.gx-navigation--fullscreen .gx-navigation-map canvas {height:100%;background:#ece7df}
|
||||
.gx-navigation__panel {position:absolute;top:16px;left:16px;width:min(420px,34%);max-height:calc(100% - 88px);overflow:auto;z-index:3;display:grid;gap:8px;align-content:start;overscroll-behavior:contain}
|
||||
.gx-navigation__panel>* {margin:0}
|
||||
.gx-navigation__panel .gx-card,.gx-navigation__suggestions,.gx-navigation__field {background:var(--glass-bg);color:var(--glass-text);border:1px solid var(--glass-border);box-shadow:var(--glass-shadow);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}
|
||||
.gx-navigation__panel .gx-card {border-radius:16px}
|
||||
.gx-navigation__panel .gx-navigation__section {padding:12px 14px}
|
||||
.gx-navigation__panel .gx-btn,.gx-navigation__tabs .gx-btn {border-radius:14px;font-size:.85rem;min-height:44px;padding:8px 16px}
|
||||
.gx-navigation__tabs .gx-btn {box-shadow:var(--glass-shadow)}
|
||||
.gx-navigation__search {display:flex;flex-wrap:nowrap;gap:8px}
|
||||
.gx-navigation__panel .gx-navigation__search {margin:0}
|
||||
.gx-navigation__field {flex:1;display:flex;align-items:center;gap:8px;min-width:0;height:50px;padding:0 6px 0 16px;border-radius:25px}
|
||||
.gx-navigation__field>i {opacity:.6}
|
||||
.gx-navigation__field input {flex:1;min-width:0;height:100%;border:0;background:none;color:inherit;font:inherit;font-size:1rem;outline:none}
|
||||
.gx-navigation__field:focus-within {border-color:var(--primary)}
|
||||
.gx-navigation__icon {display:inline-grid;place-items:center;flex:0 0 auto;width:44px;height:44px;border:0;border-radius:50%;background:none;color:inherit;font-size:1.05rem;cursor:pointer}
|
||||
.gx-navigation__icon:hover:not(:disabled),.gx-navigation__icon:focus-visible {background:var(--glass-active-bg);outline:none}
|
||||
.gx-navigation__icon:disabled {opacity:.45;cursor:default}
|
||||
.gx-navigation__icon[aria-pressed="true"] .bi-star-fill,.gx-navigation__save--on {color:#f5b400}
|
||||
.gx-navigation__suggestions {display:grid;gap:2px;padding:6px;border-radius:16px;max-height:55vh;overflow:auto}
|
||||
.gx-navigation__row {display:flex;align-items:center;gap:2px}
|
||||
.gx-navigation__suggestion {flex:1;display:flex;align-items:center;gap:12px;min-width:0;padding:10px 12px;border:0;border-radius:12px;background:none;color:inherit;text-align:left;cursor:pointer;font:inherit}
|
||||
.gx-navigation__suggestion:hover:not(:disabled),.gx-navigation__suggestion:focus-visible {background:var(--glass-active-bg);outline:none}
|
||||
.gx-navigation__suggestion i {color:var(--primary);font-size:1.05rem;flex:0 0 auto;width:20px;text-align:center}
|
||||
.gx-navigation__suggestion .bi-star-fill {color:#f5b400}
|
||||
.gx-navigation__suggestion span {display:grid;min-width:0}
|
||||
.gx-navigation__suggestion strong {font-weight:600;font-size:.92rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.gx-navigation__suggestion small {opacity:.7;font-size:.78rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.gx-navigation__group {display:flex;align-items:center;justify-content:space-between;padding:8px 12px 2px;font-size:.72rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;opacity:.7}
|
||||
.gx-navigation__link {border:0;background:none;color:var(--primary);font:inherit;letter-spacing:inherit;text-transform:inherit;cursor:pointer;padding:4px}
|
||||
.gx-navigation__shortcuts,.gx-navigation__labels {display:flex;gap:8px;overflow-x:auto;scrollbar-width:none;padding:2px}
|
||||
.gx-navigation__shortcuts::-webkit-scrollbar {display:none}
|
||||
.gx-navigation__chip {display:inline-flex;align-items:center;gap:8px;flex:0 0 auto;min-height:40px;max-width:220px;padding:0 14px;border-radius:20px;border:1px solid var(--glass-border);background:var(--glass-bg);color:var(--glass-text);box-shadow:var(--glass-shadow);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);font:inherit;font-size:.85rem;font-weight:600;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.gx-navigation__chip i {color:var(--primary)}
|
||||
.gx-navigation__chip .bi-star-fill {color:#f5b400}
|
||||
.gx-navigation__chip[aria-pressed="true"] {background:var(--primary);border-color:var(--primary);color:var(--on-primary)}
|
||||
.gx-navigation__chip[aria-pressed="true"] i {color:inherit}
|
||||
.gx-navigation__chip:disabled {opacity:.55;cursor:default}
|
||||
.gx-navigation__labels .gx-navigation__chip {box-shadow:none;backdrop-filter:none;-webkit-backdrop-filter:none;min-height:36px}
|
||||
.gx-navigation__usage {display:grid;grid-template-columns:auto 1fr;gap:4px 16px;margin:4px 0 0;font-size:.85rem}
|
||||
.gx-navigation__usage dt {opacity:.7}
|
||||
.gx-navigation__usage dd {margin:0}
|
||||
.gx-navigation__heading {display:flex;align-items:flex-start;gap:8px}
|
||||
.gx-navigation__heading>div {flex:1;min-width:0}
|
||||
.gx-navigation__heading .gx-note {margin:2px 0 0;font-size:.8rem;overflow-wrap:anywhere}
|
||||
.gx-navigation__summary-title {margin:0;font-size:1.15rem;line-height:1.25;overflow-wrap:anywhere}
|
||||
.gx-navigation__summary>div {display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 12px;margin:8px 0 0;font-size:.9rem}
|
||||
.gx-navigation__summary strong {font-size:1.35rem;color:#5bd17a}
|
||||
[data-theme="light"] .gx-navigation__summary strong {color:#1e8e3e}
|
||||
.gx-navigation__route-actions {display:flex;gap:8px;margin-top:10px;padding-bottom:0}
|
||||
.gx-navigation__route-actions .gx-btn {flex:1;background:#d93a4c;border:0;color:#fff;font-weight:600}
|
||||
.gx-navigation__labels {margin-top:10px}
|
||||
.gx-navigation__panel .gx-navigation__instruction {font-size:1rem;font-weight:650;margin:8px 0 0}
|
||||
.gx-navigation__panel .gx-note {font-size:.85rem}
|
||||
.gx-navigation__places {gap:8px;margin:8px 0}
|
||||
.gx-navigation__places>li {padding:16px;border:1px solid var(--glass-border);background:var(--surface-container-high);border-radius:var(--radius-md);gap:8px;font-size:.85rem}
|
||||
.gx-navigation__places .gx-note {display:block;font-size:.75rem}
|
||||
.gx-navigation__actions {gap:8px}
|
||||
.gx-navigation__alternatives {display:flex;flex-wrap:wrap;gap:8px;margin:8px 0}
|
||||
.gx-navigation-map__last {position:absolute;bottom:48px;right:16px;background:var(--glass-bg);border-radius:var(--radius-sm);padding:8px 12px;font-size:12px;color:var(--on-surface-variant)}
|
||||
.gx-navigation--fullscreen .gx-navigation-map__controls {top:16px;right:16px;flex-direction:column}
|
||||
.gx-navigation-map > .gx-alert {position:absolute;bottom:144px;left:var(--sp-4);width:calc(100% - 128px);max-width:320px;margin:0;z-index:2}
|
||||
.gx-navigation__tabs button {white-space:normal}
|
||||
.gx-navigation__busy {padding:8px 14px;border-radius:12px;background:var(--glass-bg);font-size:.85rem;justify-self:start}
|
||||
.gx-navigation__places {gap:6px}
|
||||
.gx-navigation__places>li {padding:10px 10px 10px 14px;gap:8px;font-size:.88rem}
|
||||
.gx-navigation__places>li>span {display:grid}
|
||||
.gx-navigation__places .gx-note {display:block;font-size:.76rem}
|
||||
.gx-navigation__actions {gap:4px}
|
||||
.gx-navigation__alternatives {display:flex;flex-wrap:wrap;gap:8px;margin:10px 0 0}
|
||||
.gx-navigation__alternatives .gx-btn {min-height:38px;padding:6px 12px}
|
||||
.gx-navigation-map__last {position:absolute;bottom:48px;right:16px;background:var(--glass-bg);color:var(--glass-text);border-radius:12px;padding:8px 12px;font-size:12px}
|
||||
.gx-navigation-map__controls {flex-direction:column}
|
||||
.gx-navigation--fullscreen .gx-navigation-map__controls {top:16px;right:16px}
|
||||
.gx-navigation-map__control {display:grid;place-items:center;width:44px;height:44px;border-radius:12px;border:1px solid var(--glass-border);background:var(--glass-bg);color:var(--glass-text);box-shadow:var(--glass-shadow);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);font-size:1.1rem;cursor:pointer}
|
||||
.gx-navigation-map__control--active {color:var(--primary)}
|
||||
.gx-navigation-map > p {position:absolute;bottom:72px;right:16px;max-width:320px;background:var(--glass-bg);border-radius:12px;padding:12px;font-size:12px}
|
||||
.gx-navigation__tabs button {white-space:nowrap}
|
||||
.gx-navigation--fullscreen .gx-navigation-map__logo {bottom:76px}
|
||||
@media(max-width:768px) and (orientation:portrait) {
|
||||
.gx-navigation__panel {left:8px;top:16px;width:calc(100% - 32px);max-height:55%}
|
||||
.gx-navigation--fullscreen .gx-navigation-map__controls {top:auto;bottom:92px}
|
||||
.gx-navigation__panel {left:8px;right:8px;top:12px;width:auto;max-height:58%}
|
||||
.gx-navigation--fullscreen .gx-navigation-map__controls {top:auto;bottom:92px;right:8px}
|
||||
.gx-navigation__tabs .gx-btn {font-size:12px;padding:8px}
|
||||
.gx-navigation__places>li {flex-direction:row;align-items:center}
|
||||
}
|
||||
|
||||
.gx-navigation-map > .gx-alert {position:absolute;bottom:144px;left:var(--sp-4);width:calc(100% - 128px);max-width:320px;margin:0;z-index:2}
|
||||
.gx-navigation__token-link { display: inline-block; margin-bottom: var(--sp-4); }
|
||||
|
||||
.gx-navigation__suggestions {display:grid;gap:2px;margin:-2px 0 8px;padding:6px;border-radius:16px;background:#0e0e1a;border:1px solid #343453;box-shadow:0 8px 20px #0007;max-height:50vh;overflow:auto}
|
||||
.gx-navigation__suggestion {display:flex;align-items:center;gap:12px;width:100%;padding:10px 12px;border:0;border-radius:12px;background:none;color:inherit;text-align:left;cursor:pointer;font:inherit}
|
||||
.gx-navigation__suggestion:hover,.gx-navigation__suggestion:focus-visible {background:#1d1d3a;outline:none}
|
||||
.gx-navigation__suggestion i {color:#b68cff;font-size:1.05rem;flex:0 0 auto}
|
||||
.gx-navigation__suggestion span {display:grid;min-width:0}
|
||||
.gx-navigation__suggestion strong {font-weight:600;font-size:.9rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.gx-navigation__suggestion small {opacity:.65;font-size:.78rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.gx-navigation__usage {display:grid;grid-template-columns:auto 1fr;gap:4px 16px;margin:4px 0 0;font-size:.85rem}
|
||||
.gx-navigation__usage dt {opacity:.7}
|
||||
.gx-navigation__usage dd {margin:0}
|
||||
|
||||
@@ -21,6 +21,11 @@ export function metersPerPixelInverse(latitude, zoom) {
|
||||
export function relativeX(x, center, world) {
|
||||
return center + ((x - center + world / 2) % world + world) % world - world / 2
|
||||
}
|
||||
// Mapbox Streets (the default style) background, shown before tiles arrive so loading never flashes dark.
|
||||
const LAND = '#ece7df'
|
||||
const TILE_BITMAPS = 64 // decoded 512 px tiles are ~1 MB each; the browser's HTTP cache keeps the rest
|
||||
const MAX_ZOOM = 18
|
||||
const clampZoom = (zoom) => Math.max(0, Math.min(MAX_ZOOM, zoom))
|
||||
export class RasterMap {
|
||||
constructor(canvas, changed = () => {}) {
|
||||
this.canvas = canvas;
|
||||
@@ -35,30 +40,60 @@ export class RasterMap {
|
||||
this.closed = false;
|
||||
this.data = {};
|
||||
this.initialized = false
|
||||
// Following keeps the car centered; panning or a new route overview pauses it until Recenter.
|
||||
this.follow = true
|
||||
// Circles drawn over the map ({ latitude, longitude, radiusKm, stroke, fill }) and a tap callback (latitude, longitude).
|
||||
this.overlays = [];
|
||||
this.onTap = null
|
||||
this.pointers = new Map()
|
||||
this.observer = new ResizeObserver(() => this.draw());
|
||||
this.observer.observe(canvas)
|
||||
this.down = (event) => {
|
||||
this.press = [event.clientX, event.clientY]
|
||||
this.userPanned = true
|
||||
this.drag = [event.clientX, event.clientY, ...project(this.center, this.zoom)];
|
||||
canvas.setPointerCapture(event.pointerId)
|
||||
this.pointers.set(event.pointerId, [event.clientX, event.clientY])
|
||||
canvas.setPointerCapture?.(event.pointerId)
|
||||
if (this.pointers.size === 1) {
|
||||
this.press = [event.clientX, event.clientY]
|
||||
this.drag = [event.clientX, event.clientY, ...project(this.center, this.zoom)];
|
||||
} else this.startPinch()
|
||||
}
|
||||
this.move = (event) => {
|
||||
if (this.pointers.has(event.pointerId)) this.pointers.set(event.pointerId, [event.clientX, event.clientY])
|
||||
if (this.pinch && this.pointers.size >= 2) {
|
||||
const [[ax, ay], [bx, by]] = this.pointers.values(), distance = Math.max(1, Math.hypot(ax - bx, ay - by))
|
||||
this.anchor(this.pinch.point, clampZoom(this.pinch.zoom + Math.log2(distance / this.pinch.distance)), (ax + bx) / 2, (ay + by) / 2)
|
||||
return
|
||||
}
|
||||
if (!this.drag) return;
|
||||
if (this.press && Math.hypot(event.clientX - this.press[0], event.clientY - this.press[1]) >= 6) this.pause()
|
||||
this.center = unproject(this.drag[2] - event.clientX + this.drag[0], this.drag[3] - event.clientY + this.drag[1], this.zoom);
|
||||
this.requestDraw()
|
||||
}
|
||||
this.up = (event) => {
|
||||
this.pointers.delete(event?.pointerId)
|
||||
if (this.pinch) {
|
||||
if (this.pointers.size < 2) {
|
||||
// Settle on a whole zoom level so tiles are drawn pixel for pixel.
|
||||
const [x, y] = this.pinch.screen
|
||||
this.pinch = this.press = null
|
||||
this.drag = null
|
||||
this.pointers.clear()
|
||||
this.zoomAt(Math.round(this.zoom), x, y)
|
||||
}
|
||||
return
|
||||
}
|
||||
this.drag = null
|
||||
const press = this.press
|
||||
this.press = null
|
||||
if (press && event?.type === 'pointerup' && this.onTap && Math.hypot(event.clientX - press[0], event.clientY - press[1]) < 6) {
|
||||
const rect = this.canvas.getBoundingClientRect(), [cx, cy] = project(this.center, this.zoom)
|
||||
this.onTap(unproject(cx - rect.width / 2 + event.clientX - rect.left, cy - rect.height / 2 + event.clientY - rect.top, this.zoom))
|
||||
if (!press || event?.type !== 'pointerup' || Math.hypot(event.clientX - press[0], event.clientY - press[1]) >= 6) return
|
||||
const now = performance.now(), last = this.lastTap
|
||||
if (last && now - last[0] < 300 && Math.hypot(event.clientX - last[1], event.clientY - last[2]) < 24) {
|
||||
this.lastTap = null
|
||||
this.pause()
|
||||
this.zoomAt(Math.round(this.zoom) + 1, ...this.local(event))
|
||||
return
|
||||
}
|
||||
this.lastTap = [now, event.clientX, event.clientY]
|
||||
if (this.onTap) this.onTap(this.pointAt(...this.local(event)))
|
||||
}
|
||||
this.key = (event) => {
|
||||
const delta = {
|
||||
@@ -67,10 +102,13 @@ export class RasterMap {
|
||||
[event.key];
|
||||
if (delta) {
|
||||
event.preventDefault();
|
||||
this.userPanned = true
|
||||
this.pause()
|
||||
const [x,y] = project(this.center,this.zoom);
|
||||
this.center = unproject(x+delta[0],y+delta[1],this.zoom);
|
||||
this.draw()
|
||||
} else if (event.key === '+' || event.key === '=' || event.key === '-') {
|
||||
event.preventDefault()
|
||||
this.changeZoom(event.key === '-' ? -1 : 1)
|
||||
}
|
||||
}
|
||||
this.wheel = (event) => {
|
||||
@@ -79,7 +117,7 @@ export class RasterMap {
|
||||
this.wheelDelta = (this.wheelDelta || 0) + (event.deltaMode === 1 ? event.deltaY * 33 : event.deltaY)
|
||||
const now = performance.now()
|
||||
if (Math.abs(this.wheelDelta) < 100 || now - (this.lastWheelZoom || 0) < 120) return
|
||||
this.changeZoom(this.wheelDelta < 0 ? 1 : -1)
|
||||
this.zoomAt(Math.round(this.zoom) + (this.wheelDelta < 0 ? 1 : -1), ...this.local(event))
|
||||
this.wheelDelta = 0
|
||||
this.lastWheelZoom = now
|
||||
}
|
||||
@@ -87,6 +125,44 @@ export class RasterMap {
|
||||
passive: false
|
||||
})
|
||||
}
|
||||
pause() {
|
||||
if (this.follow) { this.follow = false; this.changed() }
|
||||
}
|
||||
startPinch() {
|
||||
const [[ax, ay], [bx, by]] = this.pointers.values(), x = (ax + bx) / 2, y = (ay + by) / 2
|
||||
const [left, top] = this.offset()
|
||||
this.drag = this.press = null
|
||||
this.pause()
|
||||
this.pinch = { zoom: this.zoom, distance: Math.max(1, Math.hypot(ax - bx, ay - by)), point: this.pointAt(x - left, y - top), screen: [x - left, y - top] }
|
||||
}
|
||||
offset() {
|
||||
const rect = this.canvas.getBoundingClientRect?.()
|
||||
return rect ? [rect.left, rect.top] : [0, 0]
|
||||
}
|
||||
local(event) {
|
||||
const [left, top] = this.offset()
|
||||
return [event.clientX - left, event.clientY - top]
|
||||
}
|
||||
// The map coordinate under a point on the canvas.
|
||||
pointAt(x, y) {
|
||||
const [cx, cy] = project(this.center, this.zoom)
|
||||
return unproject(cx - this.canvas.clientWidth / 2 + x, cy - this.canvas.clientHeight / 2 + y, this.zoom)
|
||||
}
|
||||
// Show `point` under the canvas position (clientX, clientY) at `zoom` (pinch keeps the pinched place under the fingers).
|
||||
anchor(point, zoom, clientX, clientY) {
|
||||
const [left, top] = this.offset(), [px, py] = project(point, zoom)
|
||||
this.zoom = zoom
|
||||
this.pinch.screen = [clientX - left, clientY - top]
|
||||
this.center = unproject(px - (clientX - left) + this.canvas.clientWidth / 2, py - (clientY - top) + this.canvas.clientHeight / 2, zoom)
|
||||
this.requestDraw()
|
||||
}
|
||||
zoomAt(zoom, x, y) {
|
||||
zoom = clampZoom(zoom)
|
||||
const point = this.pointAt(x, y), [px, py] = project(point, zoom)
|
||||
this.zoom = zoom
|
||||
if (!this.follow || !this.lastLocation) this.center = unproject(px - x + this.canvas.clientWidth / 2, py - y + this.canvas.clientHeight / 2, zoom)
|
||||
this.draw()
|
||||
}
|
||||
update(data, stale) {
|
||||
clearTimeout(this.locationTimer)
|
||||
const now = performance.now()
|
||||
@@ -102,26 +178,46 @@ export class RasterMap {
|
||||
this.draw()
|
||||
}, remaining)
|
||||
}
|
||||
const firstLiveFix = this.locationFresh && this.lastLocation?.lastKnown === true
|
||||
if (data?.location && (this.locationFresh || data.location.lastKnown === true && !this.lastLocation)) {
|
||||
this.lastLocation = { ...data.location, bearing: Number.isFinite(data.location.bearing) ? data.location.bearing : this.lastLocation?.bearing }
|
||||
}
|
||||
this.data = data || {}
|
||||
this.stale = stale
|
||||
if (firstLiveFix && !this.userPanned) this.recenter()
|
||||
if (!this.initialized && (data?.location || data?.destination)) {
|
||||
// A newly chosen route is shown whole once; Recenter returns to following the car.
|
||||
const routeKey = this.data.destination && this.data.route?.length > 1 ? `${this.data.destination.id}|${this.data.routeKey ?? this.data.route.length}` : null
|
||||
if (routeKey && routeKey !== this.overviewKey) {
|
||||
this.overviewKey = routeKey
|
||||
if (this.fit([...this.data.route, ...(this.lastLocation ? [this.lastLocation] : [])])) { this.follow = false; this.initialized = true }
|
||||
} else if (!routeKey) this.overviewKey = null
|
||||
if (this.follow && this.lastLocation && (this.locationFresh || !this.initialized)) {
|
||||
this.center = { latitude: this.lastLocation.latitude, longitude: this.lastLocation.longitude }
|
||||
if (!this.initialized) this.zoom = 15
|
||||
this.initialized = true
|
||||
} else if (!this.initialized && data?.destination) {
|
||||
this.recenter()
|
||||
this.initialized = true
|
||||
}
|
||||
this.changed()
|
||||
this.draw()
|
||||
}
|
||||
// Frame these points with a margin, on a whole zoom level. False when there is nothing to frame.
|
||||
fit(points) {
|
||||
const width = this.canvas.clientWidth, height = this.canvas.clientHeight
|
||||
if (!points.length || width < 2 || height < 2) return false
|
||||
const xs = points.map((point) => project(point, 0)[0]), ys = points.map((point) => project(point, 0)[1])
|
||||
const spanX = Math.max(1e-9, Math.max(...xs) - Math.min(...xs)), spanY = Math.max(1e-9, Math.max(...ys) - Math.min(...ys))
|
||||
this.zoom = clampZoom(Math.floor(Math.min(16, Math.log2(width * 0.75 / spanX), Math.log2(height * 0.75 / spanY))))
|
||||
this.center = unproject((Math.max(...xs) + Math.min(...xs)) / 2 * 2 ** this.zoom, (Math.max(...ys) + Math.min(...ys)) / 2 * 2 ** this.zoom, this.zoom)
|
||||
return true
|
||||
}
|
||||
recenter() {
|
||||
this.follow = true
|
||||
const point = this.lastLocation || this.data.destination;
|
||||
if (point) {
|
||||
this.center = point;
|
||||
this.center = { latitude: point.latitude, longitude: point.longitude };
|
||||
this.zoom = this.lastLocation ? 15 : 2
|
||||
}
|
||||
this.changed()
|
||||
this.draw()
|
||||
}
|
||||
requestDraw() {
|
||||
@@ -139,36 +235,45 @@ export class RasterMap {
|
||||
return null
|
||||
}
|
||||
changeZoom(delta) {
|
||||
this.zoom = Math.max(0, Math.min(18, this.zoom + delta));
|
||||
this.zoom = clampZoom(Math.round(this.zoom) + delta);
|
||||
this.draw()
|
||||
}
|
||||
draw() {
|
||||
if (this.closed) return
|
||||
const width = Math.max(1, Math.round(this.canvas.clientWidth)), height = Math.max(1, Math.round(this.canvas.clientHeight));
|
||||
if (this.canvas.width !== width) this.canvas.width = width;
|
||||
if (this.canvas.height !== height) this.canvas.height = height
|
||||
const ctx = this.canvas.getContext('2d'), [cx, cy] = project(this.center, this.zoom), n = 2 ** this.zoom, world = SIZE * n, needed = new Set()
|
||||
ctx.fillStyle = '#0e0e1a';
|
||||
// Draw at the screen's pixel density so roads, labels and the route stay sharp on phones.
|
||||
const ratio = Math.min(3, Math.max(1, globalThis.devicePixelRatio || 1))
|
||||
if (this.canvas.width !== Math.round(width * ratio)) this.canvas.width = Math.round(width * ratio);
|
||||
if (this.canvas.height !== Math.round(height * ratio)) this.canvas.height = Math.round(height * ratio)
|
||||
const ctx = this.canvas.getContext('2d'), [cx, cy] = project(this.center, this.zoom), world = SIZE * 2 ** this.zoom, needed = []
|
||||
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
|
||||
ctx.imageSmoothingQuality = 'high'
|
||||
ctx.fillStyle = LAND;
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
for (let ty = Math.floor((cy - height / 2) / SIZE); ty <= Math.floor((cy + height / 2) / SIZE); ty++) {
|
||||
// Tiles come from the nearest whole zoom level, scaled while a pinch is between levels.
|
||||
const tileZoom = clampZoom(Math.round(this.zoom)), n = 2 ** tileZoom, span = SIZE * 2 ** (this.zoom - tileZoom)
|
||||
for (let ty = Math.floor((cy - height / 2) / span); ty <= Math.floor((cy + height / 2) / span); ty++) {
|
||||
if (ty < 0 || ty >= n) continue
|
||||
for (let tx = Math.floor((cx - width / 2) / SIZE); tx <= Math.floor((cx + width / 2) / SIZE); tx++) {
|
||||
const x = ((tx % n) + n) % n, key = `${this.zoom}/${x}/${ty}`;
|
||||
needed.add(key)
|
||||
const image = this.tiles.get(key), left = tx * SIZE - cx + width / 2, top = ty * SIZE - cy + height / 2
|
||||
if (image) ctx.drawImage(image, left, top, SIZE, SIZE)
|
||||
for (let tx = Math.floor((cx - width / 2) / span); tx <= Math.floor((cx + width / 2) / span); tx++) {
|
||||
const x = ((tx % n) + n) % n, key = `${tileZoom}/${x}/${ty}`, left = tx * span - cx + width / 2, top = ty * span - cy + height / 2
|
||||
needed.push([key, Math.hypot(left + span / 2 - width / 2, top + span / 2 - height / 2)])
|
||||
const image = this.tiles.get(key)
|
||||
if (image) ctx.drawImage(image, left, top, span, span)
|
||||
else {
|
||||
// A stretched coarser tile while this one loads, so zooming in never shows blank squares.
|
||||
const cover = this.placeholder(this.zoom, x, ty)
|
||||
if (cover) ctx.drawImage(cover[0], cover[1], cover[2], cover[3], cover[3], left, top, SIZE, SIZE)
|
||||
const cover = this.placeholder(tileZoom, x, ty)
|
||||
if (cover) ctx.drawImage(cover[0], cover[1], cover[2], cover[3], cover[3], left, top, span, span)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [key, controller] of this.pending) if (!needed.has(key)) {
|
||||
const keys = new Set(needed.map(([key]) => key))
|
||||
for (const [key, controller] of this.pending) if (!keys.has(key)) {
|
||||
controller.abort();
|
||||
this.pending.delete(key)
|
||||
}
|
||||
for (const key of needed) if (!this.tiles.has(key) && !this.pending.has(key) && !this.failed.has(key) && this.pending.size < 6) this.load(key)
|
||||
// The middle of the screen loads first.
|
||||
needed.sort((a, b) => a[1] - b[1])
|
||||
for (const [key] of needed) if (!this.tiles.has(key) && !this.pending.has(key) && !this.failed.has(key) && this.pending.size < 6) this.load(key)
|
||||
const pixel = (point) => {
|
||||
const [x, y] = project(point, this.zoom);
|
||||
return [relativeX(x, cx, world) - cx + width / 2, y - cy + height / 2]
|
||||
@@ -179,35 +284,35 @@ export class RasterMap {
|
||||
ctx.fillStyle = circle.fill; ctx.fill()
|
||||
ctx.setLineDash(circle.dash || []); ctx.strokeStyle = circle.stroke; ctx.lineWidth = circle.width || 2; ctx.stroke(); ctx.setLineDash([])
|
||||
}
|
||||
const line = (points, casing, color, size) => {
|
||||
ctx.beginPath();
|
||||
points.forEach((point, index) => { const xy = pixel(point); index ? ctx.lineTo(...xy) : ctx.moveTo(...xy) })
|
||||
ctx.lineJoin = ctx.lineCap = 'round'
|
||||
ctx.strokeStyle = casing; ctx.lineWidth = size + 3; ctx.stroke()
|
||||
ctx.strokeStyle = color; ctx.lineWidth = size; ctx.stroke()
|
||||
}
|
||||
for (const choice of this.data.alternatives || []) {
|
||||
if (choice.index === this.data.selectedRoute || !choice.geometry?.length) continue
|
||||
ctx.beginPath(); choice.geometry.forEach((point,index) => { const xy=pixel(point); index ? ctx.lineTo(...xy) : ctx.moveTo(...xy) });
|
||||
ctx.strokeStyle='#7885a6'; ctx.lineWidth=4; ctx.stroke()
|
||||
line(choice.geometry, '#727e8d', '#a9b4c2', 5)
|
||||
}
|
||||
if (this.data.route?.length) {
|
||||
ctx.beginPath();
|
||||
this.data.route.forEach((point, index) => {
|
||||
const xy = pixel(point);
|
||||
index ? ctx.lineTo(...xy) : ctx.moveTo(...xy)
|
||||
});
|
||||
ctx.strokeStyle = '#b68cff';
|
||||
ctx.lineWidth = 5;
|
||||
ctx.stroke()
|
||||
if (this.data.route?.length) line(this.data.route, '#2f7ac6', '#56a8fb', 6)
|
||||
if (this.data.destination) {
|
||||
// A pin whose tip marks the place.
|
||||
const [x, y] = pixel(this.data.destination)
|
||||
ctx.beginPath(); ctx.moveTo(x, y); ctx.bezierCurveTo(x - 4, y - 8, x - 11, y - 14, x - 11, y - 21)
|
||||
ctx.arc(x, y - 21, 11, Math.PI, 0); ctx.bezierCurveTo(x + 11, y - 14, x + 4, y - 8, x, y); ctx.closePath()
|
||||
ctx.fillStyle = '#e5484d'; ctx.fill(); ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.stroke()
|
||||
ctx.beginPath(); ctx.arc(x, y - 21, 4, 0, Math.PI * 2); ctx.fillStyle = '#fff'; ctx.fill()
|
||||
}
|
||||
for (const [point, color] of [[this.data.destination, '#ffbd59'], [this.lastLocation, this.locationFresh ? '#65c8ff' : '#9aa6bb']]) if (point) {
|
||||
const xy = pixel(point);
|
||||
ctx.beginPath();
|
||||
ctx.arc(...xy, 7, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke()
|
||||
if (point === this.lastLocation && Number.isFinite(point.bearing)) {
|
||||
ctx.save(); ctx.translate(...xy); ctx.rotate(point.bearing * Math.PI / 180);
|
||||
ctx.beginPath(); ctx.moveTo(0,-22); ctx.lineTo(-7,-11); ctx.lineTo(7,-11); ctx.closePath();
|
||||
if (this.lastLocation) {
|
||||
const xy = pixel(this.lastLocation), color = this.locationFresh ? '#1a73e8' : '#8a94a6'
|
||||
if (Number.isFinite(this.lastLocation.bearing)) {
|
||||
ctx.save(); ctx.translate(...xy); ctx.rotate(this.lastLocation.bearing * Math.PI / 180);
|
||||
ctx.beginPath(); ctx.moveTo(0, -22); ctx.lineTo(-8, -10); ctx.lineTo(8, -10); ctx.closePath();
|
||||
ctx.fillStyle = color; ctx.fill(); ctx.restore()
|
||||
}
|
||||
ctx.beginPath(); ctx.arc(...xy, 8, 0, Math.PI * 2)
|
||||
ctx.fillStyle = color; ctx.fill(); ctx.strokeStyle = '#fff'; ctx.lineWidth = 3; ctx.stroke()
|
||||
}
|
||||
}
|
||||
async load(key) {
|
||||
@@ -230,7 +335,7 @@ export class RasterMap {
|
||||
}
|
||||
this.tiles.set(key, image);
|
||||
this.failures = 0
|
||||
while (this.tiles.size > 160) {
|
||||
while (this.tiles.size > TILE_BITMAPS) {
|
||||
const oldest = this.tiles.keys().next().value;
|
||||
this.tiles.get(oldest).close();
|
||||
this.tiles.delete(oldest)
|
||||
@@ -277,13 +382,14 @@ export class RasterMap {
|
||||
export const NavigationMap = {
|
||||
components: { GxNotice },
|
||||
props: ['data', 'stale'], data: () => ({
|
||||
error: '', map: null, locationFresh: false, lastLocation: null
|
||||
error: '', map: null, locationFresh: false, lastLocation: null, following: true
|
||||
}),
|
||||
mounted() {
|
||||
this.map = markRaw(new RasterMap(this.$refs.canvas, (error) => {
|
||||
if (error !== undefined) this.error = error
|
||||
this.locationFresh = this.map?.locationFresh || false
|
||||
this.lastLocation = this.map?.lastLocation || null
|
||||
this.following = this.map?.follow ?? true
|
||||
}));
|
||||
this.map.update(this.data, this.stale)
|
||||
},
|
||||
@@ -298,12 +404,12 @@ export const NavigationMap = {
|
||||
}
|
||||
},
|
||||
template: `<section class="gx-navigation-map" aria-label="Interactive navigation map">
|
||||
<canvas tabindex="0" ref="canvas" aria-label="Drag to pan the map" />
|
||||
<canvas tabindex="0" ref="canvas" aria-label="Map. Drag to pan, pinch or double-tap to zoom" />
|
||||
<div v-if="!locationFresh && lastLocation" class="gx-navigation-map__last">Last known position · waiting for GPS</div>
|
||||
<div class="gx-navigation-map__controls">
|
||||
<button class="gx-btn" @click="map.changeZoom(1)" aria-label="Zoom in">+</button>
|
||||
<button class="gx-btn" @click="map.changeZoom(-1)" aria-label="Zoom out">−</button>
|
||||
<button class="gx-btn" @click="map.recenter()">Recenter</button>
|
||||
<button type="button" class="gx-navigation-map__control" @click="map.changeZoom(1)" aria-label="Zoom in"><i class="bi bi-plus-lg" aria-hidden="true"></i></button>
|
||||
<button type="button" class="gx-navigation-map__control" @click="map.changeZoom(-1)" aria-label="Zoom out"><i class="bi bi-dash-lg" aria-hidden="true"></i></button>
|
||||
<button type="button" class="gx-navigation-map__control" :class="{'gx-navigation-map__control--active':!following}" @click="map.recenter()" :aria-pressed="following" aria-label="Recenter and follow"><i class="bi bi-crosshair" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
<GxNotice v-if="error" tone="danger">{{error}}</GxNotice>
|
||||
<a class="gx-navigation-map__logo" href="https://www.mapbox.com/" target="_blank" rel="noopener noreferrer" aria-label="Mapbox">
|
||||
|
||||
@@ -20,13 +20,14 @@ export function remainingLocationLease(lease, requestStarted, now = performance.
|
||||
const STATUS = new Set(["disabled", "needsKey", "noDestination", "waitingForLocation", "routing", "guiding", "arrived", "routeUnavailable", "stale"])
|
||||
const coordinate = (value) => !!value && Number.isFinite(value.latitude) && Math.abs(value.latitude) <= 90 &&
|
||||
Number.isFinite(value.longitude) && Math.abs(value.longitude) <= 180
|
||||
export const validDestination = (value) => coordinate(value) && typeof value.id === "string" && value.id.length > 0 && value.id.length <= 256 &&
|
||||
typeof value.name === "string" && value.name.trim().length > 0 && value.name.length <= 256
|
||||
export const validSearchResult = (value) => validDestination(value) || !!value && value.temporary === true &&
|
||||
typeof value.id === "string" && value.id.length > 0 && value.id.length <= 256 &&
|
||||
typeof value.name === "string" && value.name.trim().length > 0 && value.name.length <= 256 &&
|
||||
const text = (value, limit) => typeof value === "string" && value.trim().length > 0 && value.length <= limit
|
||||
export const validDestination = (value) => coordinate(value) && text(value.id, 256) && text(value.name, 256) &&
|
||||
(value.address === undefined || text(value.address, 512)) && (value.label === undefined || ["home", "work"].includes(value.label))
|
||||
// Suggestions carry the search they came from; the comma looks up their coordinates when one is chosen or saved.
|
||||
export const validSuggestion = (value) => !!value && text(value.id, 256) && text(value.name, 256) &&
|
||||
(value.description === undefined || typeof value.description === "string" && value.description.length <= 512) &&
|
||||
typeof value.searchId === "string" && /^[0-9a-f-]{36}$/.test(value.searchId)
|
||||
export const validSearchResult = (value) => validDestination(value) || validSuggestion(value)
|
||||
export function validNavigation(value) {
|
||||
const instruction = value?.instruction
|
||||
if (value?.alternatives !== undefined && (!Array.isArray(value.alternatives) || value.alternatives.length > 3 ||
|
||||
@@ -36,6 +37,8 @@ export function validNavigation(value) {
|
||||
return !!value && typeof value.enabled === "boolean" && typeof value.isMetric === "boolean" && typeof value.hasKey === "boolean" && STATUS.has(value.status) &&
|
||||
typeof value.revision === "string" && value.revision.length > 0 && value.revision.length <= 128 &&
|
||||
(value.destination === null || validDestination(value.destination)) && Array.isArray(value.favorites) && value.favorites.length <= 100 &&
|
||||
(value.recents === undefined || Array.isArray(value.recents) && value.recents.length <= 10 && value.recents.every(validDestination)) &&
|
||||
(value.routeKey === undefined || typeof value.routeKey === "string" && /^[0-9a-f]{16}$/.test(value.routeKey)) &&
|
||||
(value.location == null || coordinate(value.location) && Number.isFinite(value.location.validForMs) && value.location.validForMs >= 0 && value.location.validForMs <= 2500) &&
|
||||
value.favorites.every(validDestination) && Array.isArray(value.route) && value.route.length <= 512 && value.route.every(coordinate) &&
|
||||
(instruction === null || !!instruction && ["text", "maneuverType", "maneuverModifier"].every((key) =>
|
||||
@@ -44,6 +47,13 @@ export function validNavigation(value) {
|
||||
Number.isFinite(instruction[key]) && instruction[key] >= 0))
|
||||
}
|
||||
|
||||
// Equal route keys mean equal route lines, so only the small remainder is compared.
|
||||
const withoutRoute = (value) => JSON.stringify({ ...value, route: undefined, alternatives: undefined })
|
||||
export const sameSnapshot = (a, b) => !!a && !!b && (a.routeKey !== undefined && a.routeKey === b.routeKey ?
|
||||
withoutRoute(a) === withoutRoute(b) : JSON.stringify(a) === JSON.stringify(b))
|
||||
// The place fields the comma stores; ids are recomputed from the coordinates.
|
||||
export const placeOf = (place) => Object.fromEntries(["name", "latitude", "longitude", "address"].filter((key) => place[key] !== undefined).map((key) => [key, place[key]]))
|
||||
|
||||
export function routePath(points) {
|
||||
if (!Array.isArray(points) || points.length < 2 || points.length > 512 || !points.every(coordinate)) return ""
|
||||
const latitude = points.reduce((sum, point) => sum + point.latitude, 0) / points.length
|
||||
@@ -103,11 +113,15 @@ export class NavigationClient {
|
||||
return this.run("search", { query: query.trim(), searchId: this.searchId, clientId: this.clientId })
|
||||
}
|
||||
choose(place) {
|
||||
const pending = place.temporary ? this.action("selectPlace", { id: place.id, searchId: place.searchId }) :
|
||||
this.action("select", { destination: place })
|
||||
const pending = place.searchId ? this.action("selectPlace", { id: place.id, searchId: place.searchId }) :
|
||||
this.action("select", { destination: placeOf(place) })
|
||||
this.endSuggestions()
|
||||
return pending
|
||||
}
|
||||
save(place) {
|
||||
return place.searchId ? this.action("favoritePlace", { id: place.id, searchId: place.searchId }) :
|
||||
this.action("favorite", { destination: placeOf(place) })
|
||||
}
|
||||
|
||||
// As-you-type suggestions run beside status polling and never cancel it. One typing session
|
||||
// shares a Mapbox search session on the comma until a place is chosen or a full search runs.
|
||||
@@ -167,7 +181,9 @@ export class NavigationClient {
|
||||
this.emit()
|
||||
const deadline = this.later(() => controller.abort(), operation === "search" ? 24000 : operation === "action" && body?.action === "selectPlace" ? 12000 : 4000)
|
||||
try {
|
||||
const response = await this.fetcher(`./api/navigation/${operation}`, {
|
||||
// Status polls name the route lines they already hold; the comma then leaves them out.
|
||||
const query = operation === "status" && this.data?.routeKey ? `?routeKey=${this.data.routeKey}` : ""
|
||||
const response = await this.fetcher(`./api/navigation/${operation}${query}`, {
|
||||
credentials: "same-origin", cache: "no-store", signal: controller.signal,
|
||||
...(body === null ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }),
|
||||
})
|
||||
@@ -186,12 +202,25 @@ export class NavigationClient {
|
||||
this.results = payload.results
|
||||
this.searched = true
|
||||
} else {
|
||||
if (payload?.routeUnchanged === true) {
|
||||
delete payload.routeUnchanged
|
||||
if (this.data?.routeKey === payload.routeKey) {
|
||||
payload.route = this.data.route
|
||||
if (this.data.alternatives) payload.alternatives = this.data.alternatives
|
||||
} else {
|
||||
payload.route = []
|
||||
delete payload.routeKey // the next poll asks for the lines again
|
||||
}
|
||||
}
|
||||
if (!validNavigation(payload)) throw new Error("Navigation status could not be read.")
|
||||
// Route lines are drawn, never edited: keep them out of Vue's deep reactivity.
|
||||
Object.freeze(payload.route)
|
||||
if (payload.alternatives) Object.freeze(payload.alternatives)
|
||||
if (payload.location) {
|
||||
payload.location.validForMs = remainingLocationLease(payload.location.validForMs, requestStarted)
|
||||
payload.location.expiresAt = performance.now() + payload.location.validForMs
|
||||
}
|
||||
if (JSON.stringify(payload) !== JSON.stringify(this.data)) this.data = payload
|
||||
if (!sameSnapshot(payload, this.data)) this.data = payload
|
||||
this.stale = false
|
||||
if (operation === "action" && ["select", "selectPlace", "clear"].includes(body.action)) { this.results = []; this.searched = false }
|
||||
}
|
||||
@@ -214,25 +243,38 @@ export class NavigationClient {
|
||||
}
|
||||
}
|
||||
|
||||
const LABELS = { home: { name: "Home", icon: "bi-house-fill" }, work: { name: "Work", icon: "bi-briefcase-fill" } }
|
||||
const matches = (place, text) => place.name.toLocaleLowerCase().includes(text) || !!place.address?.toLocaleLowerCase().includes(text)
|
||||
|
||||
export const NavigationPage = {
|
||||
name: "NavigationPage",
|
||||
components: { GxNotice, MapOperationsPanel, NavigationMap, OfflineRoadMapsPanel },
|
||||
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true },
|
||||
go: { type: Function, default: null } },
|
||||
data: () => ({ data: null, results: [], busy: false, error: "", stale: false, tab: "route", favoritesOpen: false, query: "", token: "", searched: false,
|
||||
suggestions: [], suggestOpen: false }),
|
||||
data: () => ({ data: null, results: [], busy: false, error: "", stale: false, tab: "route", query: "", token: "", searched: false,
|
||||
suggestions: [], suggestOpen: false, savedSearchIds: [] }),
|
||||
computed: {
|
||||
available() { return this.mode === "local" && !!this.data && !this.stale && !this.busy },
|
||||
favoriteMatches() {
|
||||
ready() { return !!this.data?.enabled && !!this.data?.hasKey },
|
||||
favorites() { return this.data?.favorites || [] },
|
||||
savedIds() { return new Set(this.favorites.map((place) => place.id)) },
|
||||
// Recent places that are not already saved, newest first.
|
||||
recents() { return (this.data?.recents || []).filter((place) => !this.savedIds.has(place.id)) },
|
||||
// Saved places as one-tap buttons: Home and Work first (the comma sends them in that order).
|
||||
shortcuts() { return this.favorites.slice(0, 8) },
|
||||
showShortcuts() { return this.ready && !this.data.destination && !this.suggestOpen && !this.results.length && this.shortcuts.length > 0 },
|
||||
localMatches() {
|
||||
const text = this.query.trim().toLocaleLowerCase()
|
||||
if (text.length < 2) return []
|
||||
return (this.data?.favorites || []).filter((place) => place.name.toLocaleLowerCase().includes(text)).slice(0, 4)
|
||||
if (!text) return []
|
||||
return [...this.favorites, ...this.recents].filter((place) => matches(place, text)).slice(0, 5)
|
||||
},
|
||||
showSuggestions() { return this.suggestOpen && (this.favoriteMatches.length > 0 || this.suggestions.length > 0) },
|
||||
showQuick() { return this.suggestOpen && !this.query.trim() && (this.favorites.length > 0 || this.recents.length > 0) },
|
||||
showSuggestions() { return this.suggestOpen && !!this.query.trim() && (this.localMatches.length > 0 || this.suggestions.length > 0) },
|
||||
destinationSaved() { return !!this.data?.destination && this.favorites.find((place) => place.id === this.data.destination.id) || null },
|
||||
usageRows() {
|
||||
const usage = this.data?.mapboxUsage
|
||||
if (!usage) return []
|
||||
return [["searchSessions", "Searches"], ["directions", "Routes"], ["staticTiles", "Map views"]]
|
||||
return [["searchSessions", "Searches"], ["geocoding", "Address lookups"], ["directions", "Routes"], ["staticTiles", "Map views"]]
|
||||
.filter(([key]) => usage[key]).map(([key, label]) => ({ key, label, ...usage[key] }))
|
||||
},
|
||||
path() { return routePath(this.data?.route) },
|
||||
@@ -259,19 +301,40 @@ export const NavigationPage = {
|
||||
async search() {
|
||||
clearTimeout(this.suggestTimer)
|
||||
this.suggestOpen = false
|
||||
this.savedSearchIds = []
|
||||
await this.client.search(this.query)
|
||||
},
|
||||
typed() {
|
||||
clearTimeout(this.suggestTimer)
|
||||
this.suggestOpen = true
|
||||
if (!this.query.trim()) { this.client.endSuggestions(); return }
|
||||
this.suggestTimer = setTimeout(() => this.client.suggest(this.query), 300)
|
||||
},
|
||||
closeSuggestions() { this.suggestOpen = false; clearTimeout(this.suggestTimer) },
|
||||
clearQuery() {
|
||||
this.query = ""
|
||||
this.savedSearchIds = []
|
||||
this.results = []
|
||||
this.searched = false
|
||||
this.client.endSuggestions()
|
||||
this.$refs.search?.focus()
|
||||
},
|
||||
async pick(place) {
|
||||
this.closeSuggestions()
|
||||
this.query = ""
|
||||
this.$refs.search?.blur()
|
||||
await this.client.choose(place)
|
||||
},
|
||||
// Search suggestions get their saved id from the comma, so the ones saved here are remembered by suggestion id.
|
||||
isFavorite(place) { return this.savedIds.has(place.id) || !!place.searchId && this.savedSearchIds.includes(place.id) },
|
||||
async save(place) {
|
||||
if (await this.client.save(place) && place.searchId) this.savedSearchIds = [...this.savedSearchIds, place.id]
|
||||
},
|
||||
toggleSave(place) { return this.isFavorite(place) ? this.client.action("removeFavorite", { id: place.id }) : this.client.save(place) },
|
||||
setLabel(place, label) { return this.client.action("labelFavorite", { id: place.id, label: place.label === label ? null : label }) },
|
||||
placeIcon(place) { return LABELS[place.label]?.icon || (this.isFavorite(place) ? "bi-star-fill" : place.searchId ? "bi-geo-alt-fill" : "bi-clock-history") },
|
||||
placeName(place) { return LABELS[place.label]?.name || place.name },
|
||||
placeDetail(place) { return place.label ? place.name : place.address || place.description || "" },
|
||||
async saveKey(enable = false) { const token = this.token.trim(); this.token = ""; await this.client.action("configure", { patch: enable ? { token, enabled: true } : { token } }) },
|
||||
distance(value) {
|
||||
const metric = this.data?.isMetric !== false
|
||||
@@ -279,7 +342,6 @@ export const NavigationPage = {
|
||||
value < 160.9344 ? `${Math.round(value / 0.3048 / 10) * 10} ft` : `${(value / 1609.344).toFixed(1)} mi`
|
||||
},
|
||||
duration(value) { const minutes = Math.max(1, Math.round(value / 60)); return minutes >= 60 ? `${Math.floor(minutes / 60)} hr ${minutes % 60} min` : `${minutes} min` },
|
||||
isFavorite(place) { return this.data?.favorites.some((favorite) => favorite.id === place.id) },
|
||||
},
|
||||
template: `
|
||||
<div class="gx-view gx-navigation" :class="{'gx-navigation--fullscreen':tab==='route'}">
|
||||
@@ -292,94 +354,123 @@ export const NavigationPage = {
|
||||
<OfflineRoadMapsPanel :mode="mode" :unauthorized="unauthorized" :has-key="!!data?.hasKey" :metric="data?.isMetric !== false" />
|
||||
<MapOperationsPanel :mode="mode" :unauthorized="unauthorized" />
|
||||
</div>
|
||||
<template v-else-if="tab === 'setup'">
|
||||
<p v-if="mode !== 'local'" class="gx-note">Connect to your comma to set up navigation and choose a destination.</p>
|
||||
<GxNotice tone="danger" v-if="error">{{ error }}</GxNotice>
|
||||
<section class="gx-card gx-navigation__section">
|
||||
<h3>Navigation</h3>
|
||||
<p>Show directions on your comma. Navigation works with every driving model. Route guidance helps prepare for turns; steering and speed control follow your normal engagement settings.</p>
|
||||
<button v-if="data" type="button" class="gx-btn" :class="{'gx-btn--tonal':data.enabled}" :disabled="!available"
|
||||
@click="client.action('configure',{patch:{enabled:!data.enabled}})">{{ data.enabled ? 'Turn off navigation' : 'Turn on navigation' }}</button>
|
||||
</section>
|
||||
<section class="gx-card gx-navigation__section">
|
||||
<h3>Mapbox</h3>
|
||||
<p>One public Mapbox access token (starting with pk.) handles the map, place and address search, and routes. A separate secret token is not needed.</p>
|
||||
<p>Save your token while parked. Galaxy keeps it on your comma and never displays it after saving. Favorites and recent destinations are also kept on your comma; going to one skips search, so it uses none of your search allowance. URL-restricted keys may reject map requests.</p>
|
||||
<p class="gx-note">Typing suggestions and offline road maps stop just short of Mapbox's free monthly allowance and resume on the 1st. Searches, address lookups, routes and map views are counted here but not limited.</p>
|
||||
<dl v-if="usageRows.length" class="gx-navigation__usage" aria-label="Free Mapbox use this month">
|
||||
<template v-for="row in usageRows" :key="row.key"><dt>{{ row.label }}</dt><dd>{{ row.used.toLocaleString() }} of {{ row.limit.toLocaleString() }} free this month</dd></template>
|
||||
</dl>
|
||||
<a class="gx-navigation__token-link" href="https://account.mapbox.com/access-tokens/" target="_blank" rel="noopener noreferrer">Get a Mapbox access token</a>
|
||||
<p v-if="data?.hasKey" class="gx-note">A Mapbox key is saved.</p>
|
||||
<div class="gx-field-group"><label for="navigation-token">Public Mapbox access token</label>
|
||||
<form @submit.prevent="saveKey(false)" class="gx-navigation__search">
|
||||
<input id="navigation-token" class="gx-field" type="password" autocomplete="off" v-model="token" placeholder="pk.…" maxlength="2048" required :disabled="!available">
|
||||
<button class="gx-btn" type="submit" :disabled="!available || !token.trim()">{{ data?.hasKey ? 'Replace key' : 'Save key' }}</button>
|
||||
</form></div>
|
||||
</section>
|
||||
</template>
|
||||
<template v-else>
|
||||
<NavigationMap v-if="tab === 'route' && data?.enabled && data?.hasKey" :data="data" :stale="stale" />
|
||||
<div :class="tab === 'route' ? 'gx-navigation__panel' : 'gx-stack'">
|
||||
<p v-if="mode !== 'local'" class="gx-note">Connect to your comma to set up navigation and choose a destination.</p>
|
||||
<NavigationMap v-if="ready" :data="data" :stale="stale" />
|
||||
<div class="gx-navigation__panel">
|
||||
<p v-if="mode !== 'local'" class="gx-card gx-navigation__section gx-note">Connect to your comma to set up navigation and choose a destination.</p>
|
||||
<GxNotice tone="danger" v-if="error">{{ error }}</GxNotice>
|
||||
<p v-if="stale && data" class="gx-note">Showing the last received route. Reconnecting before accepting changes.</p>
|
||||
<template v-if="tab === 'setup'">
|
||||
<section class="gx-card gx-navigation__section">
|
||||
<h3>Navigation</h3>
|
||||
<p>Show directions on your comma. Navigation works with every driving model. Route guidance helps prepare for turns; steering and speed control follow your normal engagement settings.</p>
|
||||
|
||||
</section>
|
||||
<section class="gx-card gx-navigation__section">
|
||||
<h3>Mapbox</h3>
|
||||
<p>One public Mapbox access token (starting with pk.) handles maps, address search, place search and routes. A separate secret token is not needed.</p>
|
||||
<p>Save your token while parked. Galaxy keeps it on your comma and never displays it after saving. Saving address favorites requires permanent geocoding: a payment method on file or an enterprise agreement with Mapbox. Place-search results are available for the current route only. Map tiles use your Mapbox quota; URL-restricted keys may reject these requests.</p>
|
||||
<p class="gx-note">Typing suggestions and offline road maps stop just short of Mapbox's free monthly allowance and resume on the 1st. Searches, routes and map views are counted here but not limited.</p>
|
||||
<dl v-if="usageRows.length" class="gx-navigation__usage" aria-label="Free Mapbox use this month">
|
||||
<template v-for="row in usageRows" :key="row.key"><dt>{{ row.label }}</dt><dd>{{ row.used.toLocaleString() }} of {{ row.limit.toLocaleString() }} free this month</dd></template>
|
||||
</dl>
|
||||
<a class="gx-navigation__token-link" href="https://account.mapbox.com/access-tokens/" target="_blank" rel="noopener noreferrer">Get a Mapbox access token</a>
|
||||
<p v-if="data?.hasKey" class="gx-note">A Mapbox key is saved.</p>
|
||||
<div class="gx-field-group"><label for="navigation-token">Public Mapbox access token</label>
|
||||
<form @submit.prevent="saveKey(false)" class="gx-navigation__search">
|
||||
<input id="navigation-token" class="gx-field" type="password" autocomplete="off" v-model="token" placeholder="pk.…" maxlength="2048" required :disabled="!available">
|
||||
<button class="gx-btn" type="submit" :disabled="!available || !token.trim()">{{ data?.hasKey ? 'Replace key' : 'Save key' }}</button>
|
||||
</form></div>
|
||||
</section>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button v-if="data?.enabled" type="button" class="gx-btn gx-btn--tonal" :disabled="!available" @click="client.action('configure',{patch:{enabled:false}})">Turn off navigation</button>
|
||||
|
||||
<form v-if="data?.enabled && data?.hasKey" @submit.prevent="search" class="gx-navigation__search">
|
||||
<label for="navigation-search" class="gx-sr-only">Search destinations</label>
|
||||
<input id="navigation-search" class="gx-field" v-model="query" placeholder="Search here" minlength="2" maxlength="200" required
|
||||
autocomplete="off" :disabled="!available" role="combobox" :aria-expanded="showSuggestions" aria-controls="navigation-suggestions"
|
||||
@input="typed" @focus="suggestOpen = true" @keydown.escape="closeSuggestions">
|
||||
<button class="gx-sr-only" type="submit" :disabled="!available || query.trim().length < 2">Search</button>
|
||||
<button v-if="data?.favorites.length" class="gx-btn gx-btn--tonal" type="button" @click="favoritesOpen=!favoritesOpen" :aria-expanded="favoritesOpen">♥ Favorites</button>
|
||||
</form>
|
||||
<div v-if="showSuggestions" id="navigation-suggestions" class="gx-navigation__suggestions" role="listbox" aria-label="Suggestions">
|
||||
<button v-for="place in favoriteMatches" :key="'saved-' + place.id" type="button" role="option" class="gx-navigation__suggestion" :disabled="!available" @click="pick(place)">
|
||||
<i class="bi bi-star-fill" aria-hidden="true"></i><span><strong>{{ place.name }}</strong><small>Saved place</small></span></button>
|
||||
<button v-for="place in suggestions" :key="place.id" type="button" role="option" class="gx-navigation__suggestion" :disabled="!available" @click="pick(place)">
|
||||
<i class="bi bi-geo-alt" aria-hidden="true"></i><span><strong>{{ place.name }}</strong><small v-if="place.description">{{ place.description }}</small></span></button>
|
||||
<p v-if="stale && data" class="gx-card gx-navigation__section gx-note">Showing the last received route. Reconnecting before accepting changes.</p>
|
||||
<form v-if="ready" @submit.prevent="search" class="gx-navigation__search" role="search">
|
||||
<div class="gx-navigation__field">
|
||||
<i class="bi bi-search" aria-hidden="true"></i>
|
||||
<label for="navigation-search" class="gx-sr-only">Search destinations</label>
|
||||
<input id="navigation-search" ref="search" v-model="query" placeholder="Search here" minlength="2" maxlength="200" required
|
||||
autocomplete="off" enterkeyhint="search" :disabled="!available" role="combobox" :aria-expanded="showQuick || showSuggestions" aria-controls="navigation-suggestions"
|
||||
@input="typed" @focus="suggestOpen = true" @blur="closeSuggestions" @keydown.escape="closeSuggestions">
|
||||
<button v-if="query || results.length" type="button" class="gx-navigation__icon" aria-label="Clear search" @mousedown.prevent @click="clearQuery"><i class="bi bi-x-lg" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
<section v-if="data?.destination" class="gx-card gx-navigation__section" :class="{'gx-navigation--stale':stale}">
|
||||
<h3 class="gx-navigation__summary-title">{{ data.destination.name }}</h3>
|
||||
<div v-if="summary" class="gx-navigation__summary">
|
||||
<div><span>🛣️</span><span>Distance:</span><span>{{distance(summary.distance)}}</span></div>
|
||||
<div><span>⌛</span><span>Duration:</span><span>{{duration(summary.duration)}}</span></div>
|
||||
<div><span>🕗</span><span>ETA:</span><span>{{new Date(Date.now()+summary.duration*1000).toLocaleTimeString([], {hour:'numeric',minute:'2-digit'})}}</span></div>
|
||||
<button class="gx-sr-only" type="submit" :disabled="!available || query.trim().length < 2">Search</button>
|
||||
</form>
|
||||
<div v-if="showQuick || showSuggestions" id="navigation-suggestions" class="gx-navigation__suggestions" @mousedown.prevent>
|
||||
<template v-if="showQuick">
|
||||
<div v-if="favorites.length" class="gx-navigation__group"><span>Saved</span></div>
|
||||
<div v-for="place in favorites" :key="'saved-' + place.id" class="gx-navigation__row">
|
||||
<button type="button" class="gx-navigation__suggestion" :disabled="!available" @click="pick(place)">
|
||||
<i class="bi" :class="placeIcon(place)" aria-hidden="true"></i><span><strong>{{ placeName(place) }}</strong><small v-if="placeDetail(place)">{{ placeDetail(place) }}</small></span></button>
|
||||
<button type="button" class="gx-navigation__icon" :aria-label="'Remove ' + place.name + ' from saved places'" :disabled="!available" @click="client.action('removeFavorite',{id:place.id})"><i class="bi bi-x-lg" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
<p v-else class="gx-note">{{statusLabel}}</p>
|
||||
<template v-if="data.instruction && !stale">
|
||||
<p class="gx-navigation__instruction">{{ data.instruction.text }}</p>
|
||||
<p>{{ distance(data.instruction.distanceMeters) }} · {{ duration(data.instruction.remainingDurationSeconds) }} remaining · {{ distance(data.instruction.remainingDistanceMeters) }}</p>
|
||||
</template>
|
||||
<div v-if="data.alternatives?.length > 1" class="gx-navigation__alternatives gx-actions" aria-label="Alternative routes">
|
||||
<button v-for="choice in data.alternatives" :key="choice.index" class="gx-btn" :class="choice.index===data.selectedRoute ? '' : 'gx-btn--tonal'" :aria-pressed="choice.index===data.selectedRoute" :disabled="!available" @click="client.action('selectRoute',{index:choice.index})">Route {{choice.index+1}} · {{duration(choice.durationSeconds)}} · {{distance(choice.distanceMeters)}}</button>
|
||||
<div v-if="recents.length" class="gx-navigation__group"><span>Recent</span>
|
||||
<button type="button" class="gx-navigation__link" :disabled="!available" @click="client.action('clearRecents')">Clear</button></div>
|
||||
<div v-for="place in recents" :key="'recent-' + place.id" class="gx-navigation__row">
|
||||
<button type="button" class="gx-navigation__suggestion" :disabled="!available" @click="pick(place)">
|
||||
<i class="bi bi-clock-history" aria-hidden="true"></i><span><strong>{{ place.name }}</strong><small v-if="place.address">{{ place.address }}</small></span></button>
|
||||
<button type="button" class="gx-navigation__icon" :aria-label="'Save ' + place.name" :disabled="!available" @click="client.save(place)"><i class="bi bi-star" aria-hidden="true"></i></button>
|
||||
<button type="button" class="gx-navigation__icon" :aria-label="'Remove ' + place.name + ' from recent places'" :disabled="!available" @click="client.action('removeRecent',{id:place.id})"><i class="bi bi-x-lg" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
<div class="gx-navigation__route-actions gx-actions">
|
||||
<button type="button" class="gx-btn" :disabled="!available" @click="client.action('clear')">Cancel Navigation</button>
|
||||
<button v-if="!data.destination.temporary" class="gx-btn" :disabled="!available" @click="isFavorite(data.destination) ? client.action('removeFavorite',{id:data.destination.id}) : client.action('favorite',{destination:data.destination})">{{isFavorite(data.destination) ? '♥ Unfavorite' : '♥ Favorite'}}</button>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="data && !data.hasKey" class="gx-card gx-navigation__section"><h3>Connect Mapbox</h3><p>Add your public Mapbox key to search for places and plan a route. Your key stays on your comma.</p>
|
||||
<form @submit.prevent="saveKey(true)" class="gx-navigation__search"><label for="navigation-inline-token" class="gx-sr-only">Public Mapbox access token</label><input id="navigation-inline-token" class="gx-field" type="password" autocomplete="off" v-model="token" placeholder="pk.…" maxlength="2048" required :disabled="!available"><button type="submit" class="gx-btn" :disabled="!available || !token.trim()">Save and enable</button></form><button type="button" class="gx-btn gx-btn--tonal" @click="tab='setup'">Key details</button>
|
||||
</section>
|
||||
<section v-else-if="data && !data.enabled" class="gx-card gx-navigation__section"><h3>Navigation is off</h3><button type="button" class="gx-btn" :disabled="!available" @click="client.action('configure',{patch:{enabled:true}})">Enable Navigation</button></section>
|
||||
<template v-else>
|
||||
<p v-if="busy" role="status">Working…</p>
|
||||
<p v-if="searched && !busy && !error && results.length === 0" class="gx-note">No places found. Try a nearby town or a more specific address.</p>
|
||||
<ul v-if="results.length" class="gx-navigation__places" aria-label="Search results">
|
||||
<li v-for="place in results" :key="place.id" class="gx-card">
|
||||
<span>{{ place.name }}<small v-if="place.description" class="gx-note">{{ place.description }}</small><small v-if="place.temporary" class="gx-note"> For this route only; cannot be saved.</small></span><div class="gx-navigation__actions gx-actions">
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!available || place.temporary || isFavorite(place)" @click="client.action('favorite',{destination:place})">{{ isFavorite(place) ? 'Saved' : 'Save' }}</button>
|
||||
<button type="button" class="gx-btn" :disabled="!available" @click="client.choose(place)">Go</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<ul v-if="favoritesOpen && data?.favorites.length" class="gx-navigation__places" aria-label="Saved places">
|
||||
<li v-for="place in data.favorites" :key="place.id" class="gx-card"><span>{{ place.name }}<small v-if="place.description" class="gx-note">{{ place.description }}</small><small v-if="place.temporary" class="gx-note"> For this route only; cannot be saved.</small></span><div class="gx-navigation__actions gx-actions">
|
||||
<button type="button" class="gx-icon-btn" :aria-label="'Remove '+place.name" :disabled="!available" @click="client.action('removeFavorite',{id:place.id})">Remove</button>
|
||||
<button type="button" class="gx-btn" :disabled="!available" @click="client.choose(place)">Go</button>
|
||||
</div></li>
|
||||
</ul>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-for="place in localMatches" :key="'local-' + place.id" class="gx-navigation__row">
|
||||
<button type="button" class="gx-navigation__suggestion" :disabled="!available" @click="pick(place)">
|
||||
<i class="bi" :class="placeIcon(place)" aria-hidden="true"></i><span><strong>{{ placeName(place) }}</strong><small>{{ placeDetail(place) || (isFavorite(place) ? 'Saved place' : 'Recent') }}</small></span></button>
|
||||
</div>
|
||||
<div v-for="place in suggestions" :key="place.id" class="gx-navigation__row">
|
||||
<button type="button" class="gx-navigation__suggestion" :disabled="!available" @click="pick(place)">
|
||||
<i class="bi bi-geo-alt-fill" aria-hidden="true"></i><span><strong>{{ place.name }}</strong><small v-if="place.description">{{ place.description }}</small></span></button>
|
||||
<button type="button" class="gx-navigation__icon" :aria-label="'Save ' + place.name" :aria-pressed="isFavorite(place)" :disabled="!available || isFavorite(place)" @click="save(place)"><i class="bi" :class="isFavorite(place) ? 'bi-star-fill' : 'bi-star'" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="showShortcuts" class="gx-navigation__shortcuts" aria-label="Saved places">
|
||||
<button v-for="place in shortcuts" :key="place.id" type="button" class="gx-navigation__chip" :disabled="!available" @click="pick(place)">
|
||||
<i class="bi" :class="placeIcon(place)" aria-hidden="true"></i>{{ placeName(place) }}</button>
|
||||
</div>
|
||||
<section v-if="data?.destination" class="gx-card gx-navigation__section gx-navigation__destination" :class="{'gx-navigation--stale':stale}">
|
||||
<div class="gx-navigation__heading">
|
||||
<div><h3 class="gx-navigation__summary-title">{{ data.destination.name }}</h3><p v-if="data.destination.address" class="gx-note">{{ data.destination.address }}</p></div>
|
||||
<button type="button" class="gx-navigation__icon gx-navigation__save" :class="{'gx-navigation__save--on':destinationSaved}" :aria-pressed="!!destinationSaved"
|
||||
:aria-label="destinationSaved ? 'Remove from saved places' : 'Save this place'" :disabled="!available" @click="toggleSave(data.destination)">
|
||||
<i class="bi" :class="destinationSaved ? 'bi-star-fill' : 'bi-star'" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
<div v-if="summary" class="gx-navigation__summary">
|
||||
<div><strong>{{duration(summary.duration)}}</strong><span>{{distance(summary.distance)}}</span><span>Arrive {{new Date(Date.now()+summary.duration*1000).toLocaleTimeString([], {hour:'numeric',minute:'2-digit'})}}</span></div>
|
||||
</div>
|
||||
<p v-else class="gx-note">{{statusLabel}}</p>
|
||||
<template v-if="data.instruction && !stale">
|
||||
<p class="gx-navigation__instruction">{{ data.instruction.text }}</p>
|
||||
<p class="gx-note">In {{ distance(data.instruction.distanceMeters) }}</p>
|
||||
</template>
|
||||
<div v-if="data.alternatives?.length > 1" class="gx-navigation__alternatives gx-actions" aria-label="Alternative routes">
|
||||
<button v-for="choice in data.alternatives" :key="choice.index" type="button" class="gx-btn" :class="choice.index===data.selectedRoute ? '' : 'gx-btn--tonal'" :aria-pressed="choice.index===data.selectedRoute" :disabled="!available" @click="client.action('selectRoute',{index:choice.index})">{{duration(choice.durationSeconds)}} · {{distance(choice.distanceMeters)}}</button>
|
||||
</div>
|
||||
<div v-if="destinationSaved" class="gx-navigation__labels" aria-label="Mark this saved place">
|
||||
<button v-for="(label, key) in {home:'Home', work:'Work'}" :key="key" type="button" class="gx-navigation__chip" :aria-pressed="destinationSaved.label === key" :disabled="!available" @click="setLabel(destinationSaved, key)">
|
||||
<i class="bi" :class="destinationSaved.label === key ? (key === 'home' ? 'bi-house-fill' : 'bi-briefcase-fill') : (key === 'home' ? 'bi-house' : 'bi-briefcase')" aria-hidden="true"></i>{{ label }}</button>
|
||||
</div>
|
||||
<div class="gx-navigation__route-actions gx-actions">
|
||||
<button type="button" class="gx-btn" :disabled="!available" @click="client.action('clear')">End Navigation</button>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="data && !data.hasKey" class="gx-card gx-navigation__section"><h3>Connect Mapbox</h3><p>Add your public Mapbox key to search for places and plan a route. Your key stays on your comma.</p>
|
||||
<form @submit.prevent="saveKey(true)" class="gx-navigation__search"><label for="navigation-inline-token" class="gx-sr-only">Public Mapbox access token</label><input id="navigation-inline-token" class="gx-field" type="password" autocomplete="off" v-model="token" placeholder="pk.…" maxlength="2048" required :disabled="!available"><button type="submit" class="gx-btn" :disabled="!available || !token.trim()">Save and enable</button></form><button type="button" class="gx-btn gx-btn--tonal" @click="tab='setup'">Key details</button>
|
||||
</section>
|
||||
<section v-else-if="data && !data.enabled" class="gx-card gx-navigation__section"><h3>Navigation is off</h3><button type="button" class="gx-btn" :disabled="!available" @click="client.action('configure',{patch:{enabled:true}})">Turn on navigation</button></section>
|
||||
<template v-else>
|
||||
<p v-if="busy" role="status" class="gx-navigation__busy">Working…</p>
|
||||
<p v-if="searched && !busy && !error && results.length === 0" class="gx-card gx-navigation__section gx-note">No places found. Try a nearby town or a more specific address.</p>
|
||||
<ul v-if="results.length" class="gx-navigation__places" aria-label="Search results">
|
||||
<li v-for="place in results" :key="place.id" class="gx-card">
|
||||
<span><strong>{{ place.name }}</strong><small v-if="place.address || place.description" class="gx-note">{{ place.address || place.description }}</small></span><div class="gx-navigation__actions gx-actions">
|
||||
<button type="button" class="gx-navigation__icon" :aria-label="(isFavorite(place) ? 'Saved: ' : 'Save ') + place.name" :aria-pressed="isFavorite(place)" :disabled="!available || isFavorite(place)" @click="save(place)"><i class="bi" :class="isFavorite(place) ? 'bi-star-fill' : 'bi-star'" aria-hidden="true"></i></button>
|
||||
<button type="button" class="gx-btn" :disabled="!available" @click="pick(place)">Go</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -20,12 +20,13 @@ from pathlib import Path
|
||||
# Free monthly allowances (mapbox.com/pricing, 2026-10).
|
||||
FREE = {
|
||||
"searchSessions": 500, # Search Box API sessions (typing and searching)
|
||||
"geocoding": 100_000, # Temporary Geocoding API (address search)
|
||||
"staticTiles": 200_000, # Static Tiles API (Galaxy's map pictures)
|
||||
"directions": 100_000, # Directions API (routes)
|
||||
"vectorTiles": 200_000, # Vector Tiles API (offline road maps)
|
||||
}
|
||||
STOP_FRACTION = 0.99
|
||||
LABELS = {"searchSessions": "searches", "staticTiles": "map views", "directions": "routes", "vectorTiles": "offline map tiles"}
|
||||
LABELS = {"searchSessions": "searches", "geocoding": "address lookups", "staticTiles": "map views", "directions": "routes", "vectorTiles": "offline map tiles"}
|
||||
|
||||
|
||||
class BudgetExhausted(RuntimeError):
|
||||
@@ -112,7 +113,7 @@ def read_usage(root: Path | None = None, clock=time.time) -> dict: # noqa: TID2
|
||||
root = Path(root if root is not None else usage_root())
|
||||
month = _month(clock())
|
||||
result = {}
|
||||
for service in ("searchSessions", "staticTiles", "directions"):
|
||||
for service in ("searchSessions", "geocoding", "staticTiles", "directions"):
|
||||
used = 0
|
||||
try:
|
||||
value = json.loads((root / f"{service}.json").read_text())
|
||||
|
||||
@@ -2,12 +2,12 @@ from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
from contextlib import contextmanager
|
||||
import copy
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import subprocess
|
||||
from urllib.parse import quote
|
||||
@@ -16,7 +16,6 @@ import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from itertools import islice
|
||||
|
||||
import requests
|
||||
|
||||
@@ -35,6 +34,11 @@ TILE_SLOTS = 6
|
||||
TILE_SLOT_WAIT_S = 3.0 # a panned-away request still holds its slot until Mapbox answers; wait instead of failing
|
||||
AUTOCOMPLETE_TYPES = {'poi', 'address', 'street', 'place', 'neighborhood', 'locality'}
|
||||
SUGGESTS_PER_SESSION = 50 # Mapbox starts a new billable session after this many suggestions
|
||||
MAX_FAVORITES = 100
|
||||
MAX_RECENTS = 10
|
||||
FAVORITE_LABELS = ('home', 'work')
|
||||
# Mapbox's default style. Static Tiles stay inside the free 200k monthly map views.
|
||||
MAP_STYLE = 'mapbox/streets-v12'
|
||||
|
||||
|
||||
class ValidationError(ValueError):
|
||||
@@ -56,7 +60,29 @@ def destination(value: dict) -> dict:
|
||||
not -90 <= latitude <= 90 or not -180 <= longitude <= 180):
|
||||
raise ValidationError('Destination name and coordinates are invalid')
|
||||
identity = hashlib.sha256(f'{latitude:.6f},{longitude:.6f}'.encode()).hexdigest()[:20]
|
||||
return {'id': identity, 'name': name.strip(), 'latitude': float(latitude), 'longitude': float(longitude)}
|
||||
result = {'id': identity, 'name': name.strip(), 'latitude': float(latitude), 'longitude': float(longitude)}
|
||||
address = value.get('address')
|
||||
if isinstance(address, str) and address.strip() and address.strip() != result['name']:
|
||||
result['address'] = address.strip()[:512]
|
||||
return result
|
||||
|
||||
|
||||
def favorite_place(value: dict) -> dict:
|
||||
result = destination(value)
|
||||
if value.get('label') in FAVORITE_LABELS:
|
||||
result['label'] = value['label']
|
||||
return result
|
||||
|
||||
|
||||
def remember(recents: list[dict], selected: dict) -> list[dict]:
|
||||
"""Most recent first; choosing a place again moves it to the top."""
|
||||
return ([{key: selected[key] for key in ('id', 'name', 'latitude', 'longitude', 'address') if key in selected}] +
|
||||
[row for row in recents if row['id'] != selected['id']])[:MAX_RECENTS]
|
||||
|
||||
|
||||
def rounded(points):
|
||||
# About 10 cm: plenty for drawing, and a third of the bytes of full float precision.
|
||||
return [dict(point, latitude=round(point['latitude'], 6), longitude=round(point['longitude'], 6)) for point in points]
|
||||
|
||||
|
||||
def response_json(session, url: str, params: dict) -> dict:
|
||||
@@ -92,12 +118,15 @@ class NavigationOwner:
|
||||
from openpilot.starpilot.navigation.mapbox_budget import MonthlyBudget
|
||||
self.search_budget = MonthlyBudget('searchSessions', self.root / 'mapbox-usage')
|
||||
self.tile_budget = MonthlyBudget('staticTiles', self.root / 'mapbox-usage', flush_every=25)
|
||||
self.geocode_budget = MonthlyBudget('geocoding', self.root / 'mapbox-usage')
|
||||
self._tile_lock = threading.Lock()
|
||||
self._tiles = OrderedDict()
|
||||
self._tile_key = None
|
||||
self._tile_bytes = 0
|
||||
self._lock = threading.RLock()
|
||||
self._searches = {}
|
||||
self._read_cache = None
|
||||
self._params = None
|
||||
identity = hashlib.sha256(str(self.root.resolve()).encode()).hexdigest()[:24]
|
||||
if transient_root is not None:
|
||||
self.transient_root = Path(transient_root)
|
||||
@@ -109,11 +138,20 @@ class NavigationOwner:
|
||||
self.transient_root = Path("/tmp") / ("starpilot-navigation-" + boot_id + "-" + identity)
|
||||
|
||||
def read(self) -> dict:
|
||||
# Galaxy polls and every map tile read settings; reparse only after a write replaced the file.
|
||||
try:
|
||||
info = os.stat(self.path)
|
||||
except FileNotFoundError:
|
||||
return {'version': 1, 'revision': '0', 'enabled': False, 'token': '', 'destination': None, 'favorites': [], 'recents': []}
|
||||
key = (info.st_ino, info.st_mtime_ns, info.st_size)
|
||||
cached = getattr(self, '_read_cache', None)
|
||||
if cached is not None and cached[0] == key:
|
||||
return copy.deepcopy(cached[1])
|
||||
try:
|
||||
with self.path.open('rb') as source:
|
||||
raw = source.read(MAX_DOCUMENT + 1)
|
||||
except FileNotFoundError:
|
||||
return {'version': 1, 'revision': '0', 'enabled': False, 'token': '', 'destination': None, 'favorites': []}
|
||||
return {'version': 1, 'revision': '0', 'enabled': False, 'token': '', 'destination': None, 'favorites': [], 'recents': []}
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
if (len(raw) > MAX_DOCUMENT or not isinstance(value, dict) or value.get('version') != 1 or
|
||||
@@ -122,14 +160,17 @@ class NavigationOwner:
|
||||
any(ch not in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-' for ch in value['revision']) or
|
||||
len(value['token']) > 2048 or 'destination' not in value or
|
||||
type(value.get('routeChoice', 0)) is not int or not 0 <= value.get('routeChoice', 0) <= 2 or
|
||||
not isinstance(value.get('favorites'), list) or len(value['favorites']) > 100):
|
||||
not isinstance(value.get('favorites'), list) or len(value['favorites']) > MAX_FAVORITES or
|
||||
not isinstance(value.get('recents', []), list)):
|
||||
raise ValueError
|
||||
if value.get('destination') is not None:
|
||||
value['destination'] = destination(value['destination'])
|
||||
value['favorites'] = [destination(item) for item in value['favorites']]
|
||||
return value
|
||||
value['favorites'] = [favorite_place(item) for item in value['favorites']]
|
||||
value['recents'] = [destination(item) for item in value.get('recents', [])][:MAX_RECENTS]
|
||||
except (ValueError, TypeError, KeyError):
|
||||
raise ValidationError('Saved navigation settings could not be read') from None
|
||||
self._read_cache = (key, copy.deepcopy(value))
|
||||
return value
|
||||
|
||||
def _temporary_directory(self):
|
||||
self.transient_root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
@@ -140,71 +181,6 @@ class NavigationOwner:
|
||||
raise ValidationError('Navigation temporary storage is unavailable')
|
||||
return fd
|
||||
|
||||
def _sweep_active(self, current):
|
||||
directory = self._temporary_directory()
|
||||
try:
|
||||
with os.scandir(directory) as entries:
|
||||
for entry in islice(entries, MAX_SEARCHES + 1):
|
||||
if ((entry.name.endswith('.json') and entry.name != current['revision'] + '.json') or
|
||||
entry.name.startswith('.active-')):
|
||||
os.unlink(entry.name, dir_fd=directory)
|
||||
finally:
|
||||
os.close(directory)
|
||||
|
||||
def _read_active(self, current):
|
||||
directory = self._temporary_directory()
|
||||
try:
|
||||
try:
|
||||
fd = os.open(current['revision'] + '.json', os.O_RDONLY | os.O_NOFOLLOW, dir_fd=directory)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
with os.fdopen(fd, 'rb') as source:
|
||||
info = os.fstat(source.fileno())
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077:
|
||||
raise ValidationError('Navigation temporary storage is unavailable')
|
||||
raw = source.read(4097)
|
||||
value = json.loads(raw)
|
||||
if (len(raw) > 4096 or set(value) != {'version', 'revision', 'expires', 'destination'} or type(value['version']) is not int or value['version'] != 1 or
|
||||
not isinstance(value['revision'], str) or type(value['expires']) not in (float, int) or
|
||||
not math.isfinite(value['expires'])):
|
||||
raise ValueError
|
||||
selected = destination(value['destination'])
|
||||
if value['revision'] != current['revision'] or not time.monotonic() < value['expires'] <= time.monotonic() + ACTIVE_TTL:
|
||||
os.unlink(current['revision'] + '.json', dir_fd=directory)
|
||||
return None
|
||||
return dict(value, destination=selected)
|
||||
except (ValueError, TypeError, KeyError):
|
||||
raise ValidationError('Navigation temporary destination could not be read') from None
|
||||
finally:
|
||||
os.close(directory)
|
||||
|
||||
def _write_active(self, value):
|
||||
directory = self._temporary_directory()
|
||||
name = '.active-' + uuid.uuid4().hex
|
||||
try:
|
||||
fd = os.open(name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=directory)
|
||||
with os.fdopen(fd, 'w') as out:
|
||||
json.dump(value, out, allow_nan=False, separators=(',', ':'))
|
||||
out.flush()
|
||||
os.fsync(out.fileno())
|
||||
os.replace(name, value['revision'] + '.json', src_dir_fd=directory, dst_dir_fd=directory)
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(name, dir_fd=directory)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
os.close(directory)
|
||||
|
||||
def read_routing(self):
|
||||
with self._exclusive():
|
||||
current = self.read()
|
||||
self._sweep_active(current)
|
||||
active = self._read_active(current)
|
||||
if active is not None:
|
||||
current['destination'] = dict(active['destination'], temporary=True)
|
||||
return current
|
||||
|
||||
@contextmanager
|
||||
def _exclusive(self):
|
||||
with self._lock:
|
||||
@@ -213,22 +189,15 @@ class NavigationOwner:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
yield
|
||||
|
||||
def _change(self, transform, expected_revision: str, authorized, *, active_destination=None, preserve_active=False) -> dict:
|
||||
def _change(self, transform, expected_revision: str, authorized) -> dict:
|
||||
with self._exclusive():
|
||||
current = self.read()
|
||||
if not isinstance(expected_revision, str) or current['revision'] != expected_revision:
|
||||
raise ConflictError('Navigation changed; refresh and try again')
|
||||
self._sweep_active(current)
|
||||
active = self._read_active(current) if preserve_active else None
|
||||
transform(current)
|
||||
if not (authorized() if callable(authorized) else authorized is True):
|
||||
raise PermissionError('Navigation changes are not available right now')
|
||||
current['revision'] = uuid.uuid4().hex
|
||||
if active_destination is not None:
|
||||
active = {'version':1, 'destination':active_destination, 'expires':time.monotonic() + ACTIVE_TTL}
|
||||
if active is not None:
|
||||
self._write_active(dict(active, revision=current['revision']))
|
||||
committed = False
|
||||
fd, temporary = tempfile.mkstemp(dir=self.root, prefix='.settings-')
|
||||
try:
|
||||
with os.fdopen(fd, 'w') as out:
|
||||
@@ -238,15 +207,6 @@ class NavigationOwner:
|
||||
if not (authorized() if callable(authorized) else authorized is True):
|
||||
raise PermissionError('Navigation changes are not available right now')
|
||||
os.replace(temporary, self.path)
|
||||
committed = True
|
||||
temporary_directory = self._temporary_directory()
|
||||
try:
|
||||
with os.scandir(temporary_directory) as entries:
|
||||
for stale in islice(entries, MAX_SEARCHES + 1):
|
||||
if stale.name.endswith('.json') and stale.name != current['revision'] + '.json':
|
||||
os.unlink(stale.name, dir_fd=temporary_directory)
|
||||
finally:
|
||||
os.close(temporary_directory)
|
||||
directory = os.open(self.root, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(directory)
|
||||
@@ -254,32 +214,31 @@ class NavigationOwner:
|
||||
os.close(directory)
|
||||
finally:
|
||||
Path(temporary).unlink(missing_ok=True)
|
||||
if not committed and active is not None:
|
||||
directory = self._temporary_directory()
|
||||
try:
|
||||
os.unlink(current['revision'] + '.json', dir_fd=directory)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
finally:
|
||||
os.close(directory)
|
||||
return self.snapshot()
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
def snapshot(self, route_key: str | None = None) -> dict:
|
||||
with self._lock:
|
||||
return self._snapshot()
|
||||
return self._snapshot(route_key)
|
||||
|
||||
def _snapshot(self) -> dict:
|
||||
document = self.read_routing()
|
||||
def _is_metric(self) -> bool:
|
||||
# One Params handle for the 1 Hz status polls instead of a new one per request.
|
||||
from openpilot.common.params import Params
|
||||
is_metric = Params().get_bool('IsMetric')
|
||||
if self._params is None or self._params[0] is not Params:
|
||||
self._params = (Params, Params())
|
||||
return self._params[1].get_bool('IsMetric')
|
||||
|
||||
def _snapshot(self, route_key: str | None = None) -> dict:
|
||||
document = self.read()
|
||||
status = ('disabled' if not document['enabled'] else 'needsKey' if not document['token'] else
|
||||
'noDestination' if document['destination'] is None else 'waitingForLocation')
|
||||
result = {key: document[key] for key in ('enabled', 'destination', 'favorites', 'revision')}
|
||||
result = {key: document[key] for key in ('enabled', 'destination', 'recents', 'revision')}
|
||||
# Home and Work lead, then places in the order they were saved.
|
||||
result['favorites'] = sorted(document['favorites'], key=lambda row: FAVORITE_LABELS.index(row['label']) if 'label' in row else 2)
|
||||
from openpilot.starpilot.navigation.mapbox_budget import read_usage
|
||||
result['mapboxUsage'] = read_usage(self.root / 'mapbox-usage')
|
||||
result['alternatives'] = self.route_options(document['revision'])
|
||||
result['alternatives'] = [dict(row, geometry=rounded(row['geometry'])) for row in self.route_options(document['revision'])]
|
||||
result['selectedRoute'] = document.get('routeChoice', 0)
|
||||
result.update(hasKey=bool(document['token']), status=status, instruction=None, route=[], isMetric=is_metric, location=None)
|
||||
result.update(hasKey=bool(document['token']), status=status, instruction=None, route=[], isMetric=self._is_metric(), location=None)
|
||||
if document['enabled'] and document['token']:
|
||||
if self.runtime_source is None:
|
||||
from openpilot.starpilot.navigation.status import NavigationStatusSource
|
||||
@@ -301,6 +260,12 @@ class NavigationOwner:
|
||||
if document['destination'] and isinstance(state, dict) and state.get('revision') == document['revision']:
|
||||
for key in ('status', 'instruction', 'route'):
|
||||
result[key] = state[key]
|
||||
result['route'] = rounded(result['route'])
|
||||
# Route lines are most of each status poll; a client that already holds them gets only the key.
|
||||
result['routeKey'] = hashlib.sha256(json.dumps([result['route'], result['alternatives']], separators=(',', ':')).encode()).hexdigest()[:16]
|
||||
if route_key is not None and route_key == result['routeKey']:
|
||||
del result['route'], result['alternatives']
|
||||
result['routeUnchanged'] = True
|
||||
return result
|
||||
|
||||
def route_options(self, revision):
|
||||
@@ -332,7 +297,7 @@ class NavigationOwner:
|
||||
rows = self.route_options(expected_revision)
|
||||
if type(index) is not int or not 0 <= index < len(rows):
|
||||
raise ValidationError('This route is no longer available; refresh and try again')
|
||||
result = self._change(lambda doc: doc.update(routeChoice=index), expected_revision, authorized, preserve_active=True)
|
||||
result = self._change(lambda doc: doc.update(routeChoice=index), expected_revision, authorized)
|
||||
# Retain choices across the preference revision without requesting another route.
|
||||
with self._exclusive():
|
||||
value = {'revision': result['revision'], 'expires': time.monotonic() + ACTIVE_TTL, 'routes': rows}
|
||||
@@ -361,7 +326,7 @@ class NavigationOwner:
|
||||
raise ValidationError('Map is busy; try again')
|
||||
try:
|
||||
started = time.monotonic()
|
||||
with self.session.get(f'https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/512/{z}/{x}/{y}.png',
|
||||
with self.session.get(f'https://api.mapbox.com/styles/v1/{MAP_STYLE}/tiles/512/{z}/{x}/{y}.png',
|
||||
params={'access_token': token}, timeout=(2, 2), stream=True, allow_redirects=False) as response:
|
||||
if response.status_code != 200 or response.headers.get('Content-Type', '').split(';')[0] != 'image/png':
|
||||
raise ValidationError('Map tiles are unavailable')
|
||||
@@ -429,8 +394,10 @@ class NavigationOwner:
|
||||
token = self.read()['token']
|
||||
if not token:
|
||||
raise ValidationError('Add your Mapbox access token first')
|
||||
# Temporary geocoding is inside Mapbox's free monthly allowance; results are kept only as this comma's own places.
|
||||
self.geocode_budget.spend(enforce=False)
|
||||
data = response_json(self.session, 'https://api.mapbox.com/search/geocode/v6/forward',
|
||||
{'q': query.strip(), 'access_token': token, 'limit': 8, 'autocomplete': 'false', 'permanent': 'true'})
|
||||
{'q': query.strip(), 'access_token': token, 'limit': 8, 'autocomplete': 'false', **self._search_context()})
|
||||
results = []
|
||||
features = data.get('features')
|
||||
if not isinstance(features, list):
|
||||
@@ -439,8 +406,9 @@ class NavigationOwner:
|
||||
try:
|
||||
properties = feature['properties']
|
||||
coordinates = feature['geometry']['coordinates']
|
||||
name = properties.get('full_address') or properties.get('name') or properties.get('name_preferred')
|
||||
results.append(destination({'name': name, 'longitude': coordinates[0], 'latitude': coordinates[1]}))
|
||||
address = properties.get('full_address') or properties.get('place_formatted')
|
||||
name = properties.get('name_preferred') or properties.get('name') or address
|
||||
results.append(destination({'name': name, 'address': address, 'longitude': coordinates[0], 'latitude': coordinates[1]}))
|
||||
except (ValueError, TypeError, KeyError, IndexError, AttributeError):
|
||||
continue
|
||||
return results
|
||||
@@ -544,7 +512,7 @@ class NavigationOwner:
|
||||
isinstance(name, str) and 1 <= len(name.strip()) <= 256):
|
||||
description = item.get('full_address') or item.get('place_formatted') or ''
|
||||
description = description.strip()[:512] if isinstance(description, str) else ''
|
||||
results.append({'id':identity, 'name':name.strip(), 'description':description, 'searchId':search_id, 'temporary':True})
|
||||
results.append({'id':identity, 'name':name.strip(), 'description':description, 'searchId':search_id})
|
||||
with self._lock:
|
||||
if (self._searches.get(key) is not entry or entry['expires'] <= time.monotonic() or
|
||||
self.read()['token'] != current['token']):
|
||||
@@ -559,18 +527,19 @@ class NavigationOwner:
|
||||
self.read()['token'] != current['token']):
|
||||
raise ValidationError('Start a new destination search') from None
|
||||
if autocomplete:
|
||||
return [] # suggestions are best effort and never fall back to paid geocoding
|
||||
return [] # suggestions are best effort; typing never spends address lookups
|
||||
self.cancel_search(caller, search_id)
|
||||
# Existing permanently storable address search remains available if POI search is unavailable.
|
||||
# Address search remains available if place search is unavailable.
|
||||
return self.search(query)
|
||||
self.cancel_search(caller, search_id)
|
||||
return self.search(query)
|
||||
|
||||
def select_place(self, identity, search_id, caller, expected_revision, authorized):
|
||||
def _retrieve(self, identity, search_id, caller, expected_revision, authorized, *, keep: bool) -> dict:
|
||||
"""Coordinates for a search suggestion. Saving (keep) leaves the other results usable."""
|
||||
if not isinstance(identity, str) or not isinstance(search_id, str):
|
||||
raise ValidationError('Choose a place from search results')
|
||||
with self._lock:
|
||||
entry = self._searches.pop((caller, search_id), None)
|
||||
entry = self._searches.get((caller, search_id)) if keep else self._searches.pop((caller, search_id), None)
|
||||
current = self.read()
|
||||
if (entry is None or entry['expires'] <= time.monotonic() or identity not in entry['ids'] or
|
||||
current['revision'] != expected_revision or entry['revision'] != expected_revision or
|
||||
@@ -578,32 +547,55 @@ class NavigationOwner:
|
||||
raise ValidationError('Search again before choosing this place')
|
||||
if not (authorized() if callable(authorized) else authorized is True):
|
||||
raise PermissionError('Navigation changes are not available right now')
|
||||
with self._lock:
|
||||
session = entry['session']
|
||||
# A retrieve ends the Mapbox session; a later retrieve from the same results is billed as its own session.
|
||||
if entry.get('retrieved'):
|
||||
self.search_budget.spend(enforce=False)
|
||||
entry['retrieved'] = True
|
||||
entry['session'] = str(uuid.uuid4())
|
||||
data = response_json(self.session, 'https://api.mapbox.com/search/searchbox/v1/retrieve/' + quote(identity, safe=''),
|
||||
{'access_token':current['token'], 'session_token':entry['session']})
|
||||
{'access_token':current['token'], 'session_token':session})
|
||||
try:
|
||||
feature = data['features'][0]
|
||||
properties = feature['properties']
|
||||
if properties.get('mapbox_id') != identity:
|
||||
raise ValueError
|
||||
coordinates = feature['geometry']['coordinates']
|
||||
selected = destination({'name':properties.get('name'), 'longitude':coordinates[0], 'latitude':coordinates[1]})
|
||||
return destination({'name':properties.get('name'), 'address':properties.get('full_address') or properties.get('place_formatted'),
|
||||
'longitude':coordinates[0], 'latitude':coordinates[1]})
|
||||
except (ValueError, TypeError, KeyError, IndexError):
|
||||
raise ValidationError('The map service returned an invalid place') from None
|
||||
return self._change(lambda doc:doc.update(destination=None, routeChoice=0), expected_revision, authorized, active_destination=selected)
|
||||
|
||||
def _reject_temporary_promotion(self, doc, selected):
|
||||
active = self._read_active(doc)
|
||||
if active is not None and active['destination']['id'] == selected['id']:
|
||||
raise ValidationError('This place can be used for a route but cannot be saved')
|
||||
def select_place(self, identity, search_id, caller, expected_revision, authorized):
|
||||
selected = self._retrieve(identity, search_id, caller, expected_revision, authorized, keep=False)
|
||||
return self._change(lambda doc: self._navigate(doc, selected), expected_revision, authorized)
|
||||
|
||||
def favorite_place(self, identity, search_id, caller, expected_revision, authorized):
|
||||
selected = self._retrieve(identity, search_id, caller, expected_revision, authorized, keep=True)
|
||||
result = self._change(lambda doc: self._add_favorite(doc, selected), expected_revision, authorized)
|
||||
with self._lock:
|
||||
entry = self._searches.get((caller, search_id))
|
||||
if entry is not None and entry['revision'] == expected_revision:
|
||||
entry['revision'] = result['revision'] # the other results stay choosable after saving one
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _navigate(doc, selected):
|
||||
doc.update(destination=selected, routeChoice=0, recents=remember(doc.get('recents', []), selected))
|
||||
|
||||
@staticmethod
|
||||
def _add_favorite(doc, selected):
|
||||
existing = next((row for row in doc['favorites'] if row['id'] == selected['id']), None)
|
||||
if existing is not None:
|
||||
return
|
||||
if len(doc['favorites']) >= MAX_FAVORITES:
|
||||
raise ValidationError('Remove a saved place before adding another')
|
||||
doc['favorites'] = doc['favorites'] + [selected]
|
||||
|
||||
def select(self, value: dict, expected_revision: str, authorized) -> dict:
|
||||
if isinstance(value, dict) and value.get('temporary'):
|
||||
raise ValidationError('This place can be used for a route but cannot be saved')
|
||||
selected = destination(value)
|
||||
def update(doc):
|
||||
self._reject_temporary_promotion(doc, selected)
|
||||
doc.update(destination=selected, routeChoice=0)
|
||||
return self._change(update, expected_revision, authorized)
|
||||
return self._change(lambda doc: self._navigate(doc, selected), expected_revision, authorized)
|
||||
|
||||
def clear(self, expected_revision: str, authorized) -> dict:
|
||||
with self._lock:
|
||||
@@ -611,20 +603,33 @@ class NavigationOwner:
|
||||
return self._change(lambda doc: doc.update(destination=None, routeChoice=0), expected_revision, authorized)
|
||||
|
||||
def favorite(self, value: dict, expected_revision: str, authorized) -> dict:
|
||||
if isinstance(value, dict) and value.get('temporary'):
|
||||
raise ValidationError('This place can be used for a route but cannot be saved')
|
||||
selected = destination(value)
|
||||
def update(doc):
|
||||
self._reject_temporary_promotion(doc, selected)
|
||||
favorites = [row for row in doc['favorites'] if row['id'] != selected['id']]
|
||||
if len(favorites) >= 100:
|
||||
raise ValidationError('Remove a saved place before adding another')
|
||||
doc['favorites'] = favorites + [selected]
|
||||
return self._change(update, expected_revision, authorized, preserve_active=True)
|
||||
return self._change(lambda doc: self._add_favorite(doc, selected), expected_revision, authorized)
|
||||
|
||||
def remove_favorite(self, identity: str, expected_revision: str, authorized) -> dict:
|
||||
return self._change(lambda doc: doc.update(favorites=[row for row in doc['favorites'] if row['id'] != identity]),
|
||||
expected_revision, authorized, preserve_active=True)
|
||||
expected_revision, authorized)
|
||||
|
||||
def label_favorite(self, identity: str, label, expected_revision: str, authorized) -> dict:
|
||||
"""Mark a saved place as Home or Work (one each), or clear its label with None."""
|
||||
if label is not None and label not in FAVORITE_LABELS:
|
||||
raise ValidationError('Choose Home or Work')
|
||||
def update(doc):
|
||||
if not any(row['id'] == identity for row in doc['favorites']):
|
||||
raise ValidationError('Choose a saved place')
|
||||
for row in doc['favorites']:
|
||||
if row['id'] == identity or label is not None and row.get('label') == label:
|
||||
row.pop('label', None)
|
||||
if row['id'] == identity and label is not None:
|
||||
row['label'] = label
|
||||
return self._change(update, expected_revision, authorized)
|
||||
|
||||
def remove_recent(self, identity: str, expected_revision: str, authorized) -> dict:
|
||||
return self._change(lambda doc: doc.update(recents=[row for row in doc['recents'] if row['id'] != identity]),
|
||||
expected_revision, authorized)
|
||||
|
||||
def clear_recents(self, expected_revision: str, authorized) -> dict:
|
||||
return self._change(lambda doc: doc.update(recents=[]), expected_revision, authorized)
|
||||
|
||||
def close(self):
|
||||
with self._lock:
|
||||
|
||||
@@ -47,7 +47,7 @@ class RouteRuntime:
|
||||
self.owner.position_store.record({'longitude': coordinates[0], 'latitude': coordinates[1], 'bearing': bearing})
|
||||
if position is None:
|
||||
self.owner.position_store.flush()
|
||||
settings = self.owner.read_routing()
|
||||
settings = self.owner.read()
|
||||
selected = settings['destination']
|
||||
destination_key = None if selected is None else (selected['id'], selected['longitude'], selected['latitude'])
|
||||
key = settings['enabled'], hashlib.sha256(settings['token'].encode()).digest(), destination_key, drive_id
|
||||
|
||||
@@ -71,3 +71,28 @@ def test_response_limits_and_private_failures():
|
||||
with patch('openpilot.starpilot.navigation.owner.time.monotonic', side_effect=[0, 11]):
|
||||
with pytest.raises(ValidationError, match='too long'):
|
||||
response_json(session, 'https://api.mapbox.com', {})
|
||||
|
||||
|
||||
def test_status_polls_skip_route_lines_the_client_already_holds(owner):
|
||||
saved = owner.configure({'enabled': True, 'token': 'pk.test'}, '0', True)
|
||||
selected = owner.select(PLACE, saved['revision'], True)
|
||||
line = [{'latitude': 40.123456789, 'longitude': -90.987654321}, {'latitude': 40.2, 'longitude': -90.1}]
|
||||
owner.runtime_source = lambda: {'revision': selected['revision'], 'status': 'guiding', 'instruction': None, 'route': line}
|
||||
full = owner.snapshot()
|
||||
assert full['route'][0] == {'latitude': 40.123457, 'longitude': -90.987654}, "drawn lines are sent at ~10 cm precision"
|
||||
light = owner.snapshot(full['routeKey'])
|
||||
assert light['routeUnchanged'] is True and 'route' not in light and 'alternatives' not in light
|
||||
assert light['routeKey'] == full['routeKey'] and light['destination'] == full['destination']
|
||||
line.append({'latitude': 40.3, 'longitude': -90.})
|
||||
changed = owner.snapshot(full['routeKey'])
|
||||
assert len(changed['route']) == 3 and changed['routeKey'] != full['routeKey'] and 'routeUnchanged' not in changed
|
||||
|
||||
|
||||
def test_reads_reparse_only_after_a_write(owner):
|
||||
saved = owner.configure({'enabled': True, 'token': 'pk.test'}, '0', True)
|
||||
first = owner.read()
|
||||
first['favorites'].append('mutated by a caller')
|
||||
with patch('openpilot.starpilot.navigation.owner.json.loads', side_effect=AssertionError('reparsed')):
|
||||
assert owner.read()['favorites'] == [], "cached copies are independent of what callers change"
|
||||
assert owner.select(PLACE, saved['revision'], True)['destination']['name'] == 'Home'
|
||||
assert owner.read()['destination']['name'] == 'Home'
|
||||
|
||||
@@ -88,4 +88,4 @@ def test_budget_counts_persist_and_reset_monthly(tmp_path):
|
||||
now[0] += 40 * 86400
|
||||
assert budget.remaining() == 99_000
|
||||
with pytest.raises(ValueError):
|
||||
MonthlyBudget('geocoding', tmp_path)
|
||||
MonthlyBudget('mapMatching', tmp_path)
|
||||
|
||||
@@ -34,19 +34,17 @@ def choose(owner, result, caller='caller', authorized=True):
|
||||
return value, provider
|
||||
|
||||
|
||||
def test_selected_poi_routes_without_persistent_coordinates_or_token(owner):
|
||||
def test_selected_place_is_kept_with_its_address_and_becomes_recent(owner):
|
||||
result, session, _ = suggest(owner)
|
||||
assert 'latitude' not in result
|
||||
assert 'latitude' not in result and 'temporary' not in result
|
||||
assert result['description'] == '123 Main St, Springfield'
|
||||
status, provider = choose(owner, result)
|
||||
assert provider.call_args.args[2]['session_token'] == session
|
||||
assert status['destination']['name'] == 'Coffee Shop'
|
||||
assert status['destination']['temporary'] is True
|
||||
assert owner.read()['destination'] is None
|
||||
assert 'Coffee Shop' not in owner.path.read_text()
|
||||
temporary = list(owner.transient_root.glob('*.json'))
|
||||
assert len(temporary) == 1 and temporary[0].stat().st_mode & 0o777 == 0o600
|
||||
assert 'pk.synthetic' not in temporary[0].read_text()
|
||||
assert status['destination']['address'] == '123 Main St, Springfield'
|
||||
assert owner.read()['destination'] == status['destination']
|
||||
assert [row['name'] for row in status['recents']] == ['Coffee Shop', 'Old address']
|
||||
assert not list(owner.transient_root.glob('*.json'))
|
||||
second = NavigationOwner(owner.root, runtime_source=lambda:None, transient_root=owner.transient_root)
|
||||
executor = Executor()
|
||||
runtime = RouteRuntime(second, engine=type('Engine',(),{'fetch':None})(), executor=executor)
|
||||
@@ -54,37 +52,72 @@ def test_selected_poi_routes_without_persistent_coordinates_or_token(owner):
|
||||
assert len(executor.jobs) == 1
|
||||
with pytest.raises(ValidationError):
|
||||
choose(owner, result)
|
||||
with pytest.raises(ValidationError, match='cannot be saved'):
|
||||
owner.favorite(status['destination'], status['revision'], True)
|
||||
saved = owner.favorite(status['destination'], status['revision'], True)
|
||||
assert saved['favorites'] == [status['destination']]
|
||||
|
||||
|
||||
def test_expiry_clear_keychange_and_permanent_select_never_restore_old_route(owner):
|
||||
def test_recents_move_to_top_dedupe_and_are_editable(owner):
|
||||
result, _, _ = suggest(owner)
|
||||
choose(owner, result)
|
||||
with patch('openpilot.starpilot.navigation.owner.time.monotonic', return_value=time.monotonic()+50000):
|
||||
assert owner.read_routing()['destination'] is None
|
||||
revision = owner.read()['revision']
|
||||
changed = owner.configure({'token':'pk.new'}, revision, True)
|
||||
assert changed['destination'] is None
|
||||
result, _, _ = suggest(owner)
|
||||
choose(owner,result)
|
||||
cleared = owner.clear(owner.read()['revision'], True)
|
||||
assert cleared['destination'] is None
|
||||
result, _, _ = suggest(owner)
|
||||
choose(owner,result)
|
||||
selected = owner.select(ADDRESS, owner.read()['revision'], True)
|
||||
assert selected['destination']['name'] == 'Old address'
|
||||
assert not list(owner.transient_root.glob('*.json'))
|
||||
assert cleared['destination'] is None and len(cleared['recents']) == 2
|
||||
again = owner.select(ADDRESS, cleared['revision'], True)
|
||||
assert [row['name'] for row in again['recents']] == ['Old address', 'Coffee Shop']
|
||||
removed = owner.remove_recent(again['recents'][1]['id'], again['revision'], True)
|
||||
assert [row['name'] for row in removed['recents']] == ['Old address']
|
||||
assert owner.clear_recents(removed['revision'], True)['recents'] == []
|
||||
revision = owner.read()['revision']
|
||||
for index in range(12):
|
||||
revision = owner.select({'name': f'Place {index}', 'latitude': 30. + index, 'longitude': -90.}, revision, True)['revision']
|
||||
recents = owner.read()['recents']
|
||||
assert len(recents) == 10 and recents[0]['name'] == 'Place 11'
|
||||
|
||||
|
||||
def test_favorite_only_revision_keeps_temporary_route_and_existing_favorites(owner):
|
||||
def test_favorite_edits_keep_route_and_labels_are_unique(owner):
|
||||
result, _, _ = suggest(owner)
|
||||
status, _ = choose(owner,result)
|
||||
saved = owner.favorite(ADDRESS,status['revision'],True)
|
||||
saved = owner.favorite(status['destination'],saved['revision'],True)
|
||||
assert saved['destination']['name'] == 'Coffee Shop'
|
||||
assert saved['favorites'][0]['name'] == 'Old address'
|
||||
removed = owner.remove_favorite(saved['favorites'][0]['id'],saved['revision'],True)
|
||||
assert removed['destination']['name'] == 'Coffee Shop'
|
||||
assert [row['name'] for row in saved['favorites']] == ['Old address', 'Coffee Shop']
|
||||
coffee, old = saved['favorites'][1]['id'], saved['favorites'][0]['id']
|
||||
home = owner.label_favorite(coffee, 'home', saved['revision'], True)
|
||||
assert [(row['name'], row.get('label')) for row in home['favorites']] == [('Coffee Shop', 'home'), ('Old address', None)]
|
||||
moved = owner.label_favorite(old, 'home', home['revision'], True)
|
||||
assert [(row['name'], row.get('label')) for row in moved['favorites']] == [('Old address', 'home'), ('Coffee Shop', None)]
|
||||
work = owner.label_favorite(coffee, 'work', moved['revision'], True)
|
||||
assert [row.get('label') for row in work['favorites']] == ['home', 'work']
|
||||
unlabeled = owner.label_favorite(coffee, None, work['revision'], True)
|
||||
assert 'label' not in unlabeled['favorites'][1]
|
||||
with pytest.raises(ValidationError):
|
||||
owner.label_favorite(coffee, 'gym', unlabeled['revision'], True)
|
||||
with pytest.raises(ValidationError):
|
||||
owner.label_favorite('missing', 'home', unlabeled['revision'], True)
|
||||
removed = owner.remove_favorite(old,unlabeled['revision'],True)
|
||||
assert removed['destination']['name'] == 'Coffee Shop' and [row['name'] for row in removed['favorites']] == ['Coffee Shop']
|
||||
|
||||
|
||||
def test_saving_a_suggestion_keeps_the_other_results_choosable(owner):
|
||||
result, session, _ = suggest(owner)
|
||||
revision = owner.read()['revision']
|
||||
with patch('openpilot.starpilot.navigation.owner.response_json', return_value=FEATURE) as provider:
|
||||
saved = owner.favorite_place(result['id'], result['searchId'], 'caller', revision, True)
|
||||
assert provider.call_args.args[2]['session_token'] == session
|
||||
assert saved['favorites'][0]['name'] == 'Coffee Shop' and saved['destination']['name'] == 'Old address'
|
||||
used = owner.search_budget.used
|
||||
status, provider = choose(owner, result)
|
||||
assert provider.call_args.args[2]['session_token'] != session, "a retrieve ends a Mapbox session"
|
||||
assert owner.search_budget.used == used + 1, "so choosing afterwards counts as its own session"
|
||||
assert status['destination']['name'] == 'Coffee Shop'
|
||||
|
||||
|
||||
def test_legacy_settings_without_recents_still_load(owner):
|
||||
import json
|
||||
value = json.loads(owner.path.read_text())
|
||||
del value['recents']
|
||||
owner.path.write_text(json.dumps(value))
|
||||
assert owner.read()['recents'] == []
|
||||
assert owner.snapshot()['destination']['name'] == 'Old address'
|
||||
|
||||
|
||||
def test_rejected_authority_and_interrupted_commit_keep_previous_route(owner):
|
||||
@@ -145,10 +178,9 @@ def test_address_fallback_and_symlink_safe_ephemeral_state(owner,tmp_path):
|
||||
patch.object(owner,'search',return_value=[ADDRESS]) as fallback:
|
||||
assert owner.search_places('100 Main','caller',str(uuid.uuid4()),str(uuid.uuid4())) == [ADDRESS]
|
||||
fallback.assert_called_once_with('100 Main')
|
||||
owner.transient_root.rmdir()
|
||||
owner.transient_root.symlink_to(tmp_path)
|
||||
with pytest.raises(OSError):
|
||||
owner.read_routing()
|
||||
owner.record_routes(owner.read()['revision'], [])
|
||||
|
||||
|
||||
def test_cancel_during_provider_does_not_start_address_fallback(owner):
|
||||
@@ -163,16 +195,6 @@ def test_cancel_during_provider_does_not_start_address_fallback(owner):
|
||||
fallback.assert_not_called()
|
||||
|
||||
|
||||
def test_expired_and_crash_orphan_coordinates_are_deleted(owner):
|
||||
result, _, _ = suggest(owner)
|
||||
choose(owner,result)
|
||||
orphan = owner.transient_root/'orphan.json'
|
||||
orphan.write_text('synthetic orphan')
|
||||
with patch('openpilot.starpilot.navigation.owner.time.monotonic',return_value=time.monotonic()+50000):
|
||||
assert owner.read_routing()['destination'] is None
|
||||
assert not list(owner.transient_root.glob('*.json'))
|
||||
|
||||
|
||||
def test_slow_retrieve_allows_keychange_and_rejects_stale_result(owner):
|
||||
import threading
|
||||
result, _, _ = suggest(owner)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import ast
|
||||
import copy
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -24,10 +26,11 @@ def load(name, path):
|
||||
migration = load('external_reset_state_helpers', ROOT / 'state_migration.py')
|
||||
owner_ast = ast.parse((ROOT / 'navigation' / 'owner.py').read_text())
|
||||
selected: list[ast.stmt] = [node for node in owner_ast.body if isinstance(node, (ast.FunctionDef, ast.ClassDef))
|
||||
and node.name in ('ValidationError', 'destination')]
|
||||
and node.name in ('ValidationError', 'destination', 'favorite_place')]
|
||||
owner_class = next(node for node in owner_ast.body if isinstance(node, ast.ClassDef) and node.name == 'NavigationOwner')
|
||||
selected.append(next(node for node in owner_class.body if isinstance(node, ast.FunctionDef) and node.name == 'read'))
|
||||
namespace: dict = {'json': json, 'math': math, 'hashlib': hashlib, 'MAX_DOCUMENT': 256 * 1024}
|
||||
namespace: dict = {'json': json, 'math': math, 'hashlib': hashlib, 'os': os, 'copy': copy, 'MAX_DOCUMENT': 256 * 1024,
|
||||
'MAX_FAVORITES': 100, 'MAX_RECENTS': 10, 'FAVORITE_LABELS': ('home', 'work')}
|
||||
exec(compile(ast.Module(body=selected, type_ignores=[]), str(ROOT / 'navigation' / 'owner.py'), 'exec'), namespace)
|
||||
NavigationOwner = type('NavigationOwner', (), {'read': namespace['read']})
|
||||
with patch.dict(sys.modules, {'openpilot.starpilot.state_migration': migration}):
|
||||
|
||||
Reference in New Issue
Block a user