Files
StarPilot/openpilot/starpilot/galaxy/web/js/navigation-map.js
T
Prabhaav Pillai ee83b3010a (Another seventh) round of Galaxy UI bug fixes
Improved consistency across Galaxy pages
Reworked the Model Manager layout, filters, downloads, randomizer, and Jetlink controls.
Improved vehicle selection and the Galaxy pairing and installation flow.
Fixed settings-row rendering, Android Auto upload timeouts, and Sentry event ID validation.
Updated tests for current UI behavior - all 102 JavaScript and 797 Python Galaxy tests pass.
2026-10-22 03:39:05 -04:00

457 lines
22 KiB
JavaScript

import { GxNotice } from "./notice.js"
import { markRaw } from "../vendor/vue/vue.esm-browser.js"
const SIZE = 512
const locationDeadlines = new WeakMap()
export function project(point, zoom) {
const latitude = Math.max(-85.05112878, Math.min(85.05112878, point.latitude))
const sine = Math.sin(latitude * Math.PI / 180), world = SIZE * 2 ** zoom
return [(point.longitude + 180) / 360 * world, (0.5 - Math.log((1 + sine) / (1 - sine)) / (4 * Math.PI)) * world]
}
export function unproject(x, y, zoom) {
const world = SIZE * 2 ** zoom
return {
longitude: ((x / world * 360) % 360 + 360) % 360 - 180, latitude: Math.atan(Math.sinh(Math.PI * (1 - 2 * y / world))) * 180 / Math.PI
}
}
// Screen pixels per meter at a latitude: the circles for offline areas are true ground distances.
export function metersPerPixelInverse(latitude, zoom) {
return SIZE * 2 ** zoom / (40075016.686 * Math.max(0.01, Math.cos(latitude * Math.PI / 180)))
}
export function relativeX(x, center, world) {
return center + ((x - center + world / 2) % world + world) % world - world / 2
}
// Match the tile style while imagery loads, including during live theme changes.
const LAND = { light: '#f5f5f3', dark: '#191a1a' }
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;
this.changed = changed;
this.zoom = 2;
this.center = {
longitude: 0, latitude: 0
};
this.failed = new Set();
this.tiles = new Map();
this.pending = new Map();
this.closed = false;
this.theme = globalThis.document?.documentElement?.dataset.theme === 'light' ? 'light' : 'dark'
if (globalThis.MutationObserver && globalThis.document?.documentElement) {
this.themeObserver = new MutationObserver(() => this.setTheme(document.documentElement.dataset.theme))
this.themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
}
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.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' || 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) => {
if (this.onTap && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault()
this.onTap({ ...this.center })
return
}
const delta = {
ArrowLeft: [-80, 0], ArrowRight: [80, 0], ArrowUp: [0, -80], ArrowDown: [0, 80]
}
[event.key];
if (delta) {
event.preventDefault();
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) => {
event.preventDefault();
// Trackpads send dozens of small wheel events per gesture; one zoom level per 100px, at most every 120 ms.
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.zoomAt(Math.round(this.zoom) + (this.wheelDelta < 0 ? 1 : -1), ...this.local(event))
this.wheelDelta = 0
this.lastWheelZoom = now
}
for (const [name, listener] of [['pointerdown', this.down], ['pointermove', this.move], ['pointerup', this.up], ['pointercancel', this.up], ['wheel', this.wheel], ['keydown', this.key]]) canvas.addEventListener(name, listener, {
passive: false
})
}
setTheme(value) {
const theme = value === 'light' ? 'light' : 'dark'
if (this.closed || this.theme === theme) return
this.theme = theme
for (const controller of this.pending.values()) controller.abort()
this.pending.clear()
for (const image of this.tiles.values()) image.close()
this.tiles.clear()
this.failed.clear()
this.failures = 0
clearTimeout(this.retryTimer)
this.retryTimer = null
this.changed('')
this.draw()
}
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()
if (data && !locationDeadlines.has(data)) {
locationDeadlines.set(data, data.location?.expiresAt ?? now + (data.location?.validForMs ?? 0))
}
const remaining = Math.max(0, (data && locationDeadlines.get(data) || 0) - now)
this.locationFresh = !stale && remaining > 0
if (this.locationFresh) {
this.locationTimer = setTimeout(() => {
this.locationFresh = false;
this.changed();
this.draw()
}, remaining)
}
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
// 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 = { latitude: point.latitude, longitude: point.longitude };
this.zoom = this.lastLocation ? 15 : 2
}
this.changed()
this.draw()
}
requestDraw() {
this.frameScheduled ??= requestAnimationFrame(() => { this.frameScheduled = null; this.draw() })
}
// The nearest loaded ancestor of a missing tile, and the part of it that covers the tile.
placeholder(zoom, x, y) {
for (let up = 1; up <= 5 && zoom - up >= 0; up++) {
const image = this.tiles.get(`${zoom - up}/${x >> up}/${y >> up}`)
if (image) {
const span = SIZE / 2 ** up
return [image, (x - ((x >> up) << up)) * span, (y - ((y >> up) << up)) * span, span]
}
}
return null
}
changeZoom(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));
// 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[this.theme];
ctx.fillRect(0, 0, width, height)
// 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) / 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(tileZoom, x, ty)
if (cover) ctx.drawImage(cover[0], cover[1], cover[2], cover[3], cover[3], left, top, span, span)
}
}
}
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)
}
// 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]
}
for (const circle of this.overlays) {
const [x, y] = pixel(circle), radius = metersPerPixelInverse(circle.latitude, this.zoom) * circle.radiusKm * 1000
ctx.beginPath(); ctx.arc(x, y, Math.max(2, radius), 0, Math.PI * 2)
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
line(choice.geometry, '#727e8d', '#a9b4c2', 5)
}
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()
}
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) {
const controller = new AbortController();
this.pending.set(key, controller);
let timedOut = false
const timer = setTimeout(() => {
timedOut = true;
controller.abort()
}, 8000)
try {
const response = await fetch(`./api/navigation/map/tiles/${key}.png?theme=${this.theme}`, {
credentials: 'same-origin', signal: controller.signal
});
if (!response.ok) throw new Error('Map tiles unavailable')
const image = await createImageBitmap(await response.blob());
if (this.closed || controller.signal.aborted) {
image.close();
return
}
this.tiles.set(key, image);
this.failures = 0
while (this.tiles.size > TILE_BITMAPS) {
const oldest = this.tiles.keys().next().value;
this.tiles.get(oldest).close();
this.tiles.delete(oldest)
}
if (!this.failed.size) this.changed('')
} catch (error) {
if (this.pending.get(key) === controller && (timedOut || !controller.signal.aborted)) {
this.failed.add(key);
if (this.failed.size > 64) this.failed.delete(this.failed.values().next().value);
// A quick refusal (a busy moment) is retried quickly and silently; a hung request or a run of failures is reported.
this.failures = (this.failures || 0) + 1
const persistent = timedOut || this.failures >= 6
if (persistent) this.changed('Map tiles unavailable. Check the saved key and connection. Reconnecting automatically…')
this.retryTimer ??= setTimeout(() => { this.retryTimer = null; this.retry() }, persistent ? 5000 : 800)
}
} finally {
clearTimeout(timer);
if (this.pending.get(key) === controller) this.pending.delete(key);
if (!this.closed) {
/* Paint completed tiles without continuously retrying failures. */ this.paintScheduled ??= requestAnimationFrame(() => {
this.paintScheduled = null;
this.draw()
})
}
}
}
retry() {
this.failed.clear();
this.draw()
}
close() {
this.closed = true;
clearTimeout(this.locationTimer);
clearTimeout(this.retryTimer);
this.observer.disconnect();
this.themeObserver?.disconnect();
cancelAnimationFrame(this.paintScheduled);
if (this.frameScheduled) cancelAnimationFrame(this.frameScheduled);
for (const controller of this.pending.values()) controller.abort();
for (const image of this.tiles.values()) image.close();
this.tiles.clear();
for (const [name, listener] of [['pointerdown', this.down], ['pointermove', this.move], ['pointerup', this.up], ['pointercancel', this.up], ['wheel', this.wheel], ['keydown', this.key]]) this.canvas.removeEventListener(name, listener)
}
}
export const NavigationMap = {
components: { GxNotice },
props: ['data', 'stale'], data: () => ({
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)
},
beforeUnmount() {
this.map.close()
},
methods: {
overview() {
if (this.map?.fit(this.data?.route || [])) {
this.map.pause()
this.map.draw()
}
},
},
watch: {
data(value) {
this.map?.update(value, this.stale)
}, stale(value) {
this.map?.update(this.data, value)
}
},
template: `<section class="gx-navigation-map" aria-label="Interactive navigation 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">{{ data?.route?.length ? 'Preview from last saved location · GPS unavailable' : 'Last saved location · GPS unavailable' }}</div>
<div class="gx-navigation-map__controls">
<button v-if="data?.route?.length" type="button" class="gx-navigation-map__control" @click="overview" aria-label="Show whole route" title="Show whole route"><i class="bi bi-bounding-box" aria-hidden="true"></i></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">
</a>
<div class="gx-navigation-map__credits">
<a class="gx-link" href="https://www.mapbox.com/about/maps/" target="_blank" rel="noopener noreferrer">© Mapbox</a> · <a class="gx-link" href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">© OpenStreetMap</a> · <a class="gx-link" href="https://apps.mapbox.com/feedback/" target="_blank" rel="noopener noreferrer">Improve this map</a>
</div>
</section>`
}