From 236c794eff4ecb8d96b5eb5a7ff4b987a57f56da Mon Sep 17 00:00:00 2001 From: Danny Date: Wed, 7 Oct 2026 12:21:41 -0700 Subject: [PATCH] Optimize Maps, No Frogs --- .../starpilot/external_preferences_reset.py | 4 +- openpilot/starpilot/galaxy/server.py | 25 +- .../galaxy/tests/test_navigation.mjs | 62 +++- .../tests/test_navigation_autocomplete.mjs | 41 ++- .../galaxy/tests/test_navigation_http.py | 43 ++- .../galaxy/tests/test_navigation_map.mjs | 50 ++- .../starpilot/galaxy/web/css/navigation.css | 115 ++++--- .../starpilot/galaxy/web/js/navigation-map.js | 220 +++++++++---- .../starpilot/galaxy/web/js/navigation.js | 289 ++++++++++++------ .../starpilot/navigation/mapbox_budget.py | 5 +- openpilot/starpilot/navigation/owner.py | 281 ++++++++--------- openpilot/starpilot/navigation/runtime.py | 2 +- .../starpilot/navigation/tests/test_owner.py | 25 ++ .../tests/test_search_autocomplete.py | 2 +- .../navigation/tests/test_search_places.py | 102 ++++--- .../tests/test_external_preferences_reset.py | 7 +- 16 files changed, 868 insertions(+), 405 deletions(-) diff --git a/openpilot/starpilot/external_preferences_reset.py b/openpilot/starpilot/external_preferences_reset.py index 0993501e0a..dc2ec31242 100644 --- a/openpilot/starpilot/external_preferences_reset.py +++ b/openpilot/starpilot/external_preferences_reset.py @@ -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} diff --git a/openpilot/starpilot/galaxy/server.py b/openpilot/starpilot/galaxy/server.py index f8bdfb96d1..00887e05d2 100644 --- a/openpilot/starpilot/galaxy/server.py +++ b/openpilot/starpilot/galaxy/server.py @@ -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: diff --git a/openpilot/starpilot/galaxy/tests/test_navigation.mjs b/openpilot/starpilot/galaxy/tests/test_navigation.mjs index d30affc8f3..a118f2e4eb 100644 --- a/openpilot/starpilot/galaxy/tests/test_navigation.mjs +++ b/openpilot/starpilot/galaxy/tests/test_navigation.mjs @@ -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(' ({ 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") diff --git a/openpilot/starpilot/galaxy/tests/test_navigation_http.py b/openpilot/starpilot/galaxy/tests/test_navigation_http.py index 0bc4b89670..a92da01b88 100644 --- a/openpilot/starpilot/galaxy/tests/test_navigation_http.py +++ b/openpilot/starpilot/galaxy/tests/test_navigation_http.py @@ -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}) diff --git a/openpilot/starpilot/galaxy/tests/test_navigation_map.mjs b/openpilot/starpilot/galaxy/tests/test_navigation_map.mjs index 57b77b62c6..6d5743b162 100644 --- a/openpilot/starpilot/galaxy/tests/test_navigation_map.mjs +++ b/openpilot/starpilot/galaxy/tests/test_navigation_map.mjs @@ -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') diff --git a/openpilot/starpilot/galaxy/web/css/navigation.css b/openpilot/starpilot/galaxy/web/css/navigation.css index 1fcbed6dbd..eaf30d269f 100644 --- a/openpilot/starpilot/galaxy/web/css/navigation.css +++ b/openpilot/starpilot/galaxy/web/css/navigation.css @@ -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} diff --git a/openpilot/starpilot/galaxy/web/js/navigation-map.js b/openpilot/starpilot/galaxy/web/js/navigation-map.js index 46f76c0da0..844657e4d4 100644 --- a/openpilot/starpilot/galaxy/web/js/navigation-map.js +++ b/openpilot/starpilot/galaxy/web/js/navigation-map.js @@ -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: `
- +
Last known position · waiting for GPS
- - - + + +
{{error}}