Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions NOTICE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ It includes the following software and creative work:

* see package.json for the direct dependencies
* most images from [Google Fonts](https://fonts.google.com/icons) - Apache License 2.0
* `src/util/flexPolyline.ts` is vendored from [heremaps/flexible-polyline](https://github.com/heremaps/flexible-polyline) (Copyright 2019 HERE Europe B.V., MIT License)
Comment thread
karussell marked this conversation as resolved.
160 changes: 139 additions & 21 deletions src/NavBar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Dispatcher from '@/stores/Dispatcher'
import {
ClearPoints,
DisableCustomModel,
ErrorAction,
SelectMapLayer,
SetBBox,
SetCustomModel,
Expand All @@ -17,11 +18,27 @@ import { ApiImpl, getApi } from '@/api/Api'
import { AddressParseResult } from '@/pois/AddressParseResult'
import { getQueryStore } from '@/stores/Stores'
import { getBBoxFromCoord, getBBoxPoints } from '@/utils'
import { decodeCoords, encodeCoords } from '@/util/flexPolyline'
import { canCompress, deflateB64url, inflateB64url } from '@/util/urlCompress'
import { customModel2prettyString } from '@/sidebar/CustomModelExamples'

// Minimum number of (initialized) waypoints before we switch from the legible
// `point=lat,lng` format to the compact `fpolyline=` representation. For 1-3
// points the URL stays human-readable; the savings only become substantial
// once a route gets long enough to be tedious to read anyway.
const FPOLYLINE_THRESHOLD = 4

// Names delimiter inside the compressed `cnames` blob. Stripped from names on
// encode so the split on decode is unambiguous.
const NAMES_SEP = '*'

export default class NavBar {
private readonly queryStore: QueryStore
private readonly mapStore: MapOptionsStore
private ignoreStateUpdates = false
// Monotonic id so an older URL build can't clobber a newer one if their
// async compressions finish out of order.
private urlChangeId = 0

constructor(queryStore: QueryStore, mapStore: MapOptionsStore) {
this.queryStore = queryStore
Expand All @@ -31,23 +48,55 @@ export default class NavBar {

async startSyncingUrlWithAppState() {
// our first history entry shall be the one that we end up with when the app loads for the first time
window.history.replaceState(null, '', this.createUrlFromState())
const url = await this.createUrlFromState()
window.history.replaceState(null, '', url)
this.queryStore.register(() => this.updateUrlFromState())
this.mapStore.register(() => this.updateUrlFromState())
}
Comment on lines 49 to 55

private static createUrl(baseUrl: string, queryStoreState: QueryStoreState, mapState: MapOptionsStoreState) {
private static async createUrl(
baseUrl: string,
queryStoreState: QueryStoreState,
mapState: MapOptionsStoreState,
): Promise<URL> {
const result = new URL(baseUrl)
if (queryStoreState.queryPoints.filter(point => point.isInitialized).length > 0) {
queryStoreState.queryPoints
.map(point => (!point.isInitialized ? '' : NavBar.pointToParam(point)))
.forEach(pointAsString => result.searchParams.append('point', pointAsString))
const points = queryStoreState.queryPoints
const allInitialized = points.length > 0 && points.every(p => p.isInitialized)

// We only use the compact `fpolyline`/`cnames`/`cmodel` format when the
// browser exposes CompressionStream. Older browsers fall back to the legacy
// `point=` + `custom_model=` representation so they keep producing usable
// (just longer) share URLs.
const compressionAvailable = canCompress()

if (compressionAvailable && allInitialized && points.length >= FPOLYLINE_THRESHOLD) {
result.searchParams.append('fpolyline', encodeCoords(points.map(p => p.coordinate)))
const names = points.map(p => {
const coordText = coordinateToText(p.coordinate)
return p.queryText === coordText ? '' : p.queryText.split(NAMES_SEP).join('')
})
if (names.some(n => n.length > 0)) {
result.searchParams.append('cnames', await deflateB64url(names.join(NAMES_SEP)))
}
} else if (points.some(p => p.isInitialized)) {
// Legacy emission for short routes, mixed-init states, and old browsers.
// Only emits when at least one point is set so a clean app state doesn't
// produce an ugly `?point=&point=` URL.
points
.map(p => (!p.isInitialized ? '' : NavBar.pointToParam(p)))
.forEach(s => result.searchParams.append('point', s))
}

result.searchParams.append('profile', queryStoreState.routingProfile.name)
result.searchParams.append('layer', mapState.selectedStyle.name)
if (queryStoreState.customModelEnabled)
result.searchParams.append('custom_model', queryStoreState.customModelStr.replace(/\s+/g, ''))
result.searchParams.append('l', mapState.selectedStyle.shortName)
if (queryStoreState.customModelEnabled) {
const cm = queryStoreState.customModelStr.replace(/\s+/g, '')
if (compressionAvailable) {
result.searchParams.append('cmodel', await deflateB64url(cm))
} else {
result.searchParams.append('custom_model', cm)
}
}

return result
}
Expand All @@ -57,10 +106,44 @@ export default class NavBar {
return coordinate === point.queryText ? coordinate : coordinate + '_' + point.queryText
}

private static parsePoints(url: URL): QueryPoint[] {
private static async parsePoints(url: URL): Promise<QueryPoint[]> {
const fpolyline = url.searchParams.get('fpolyline')
if (fpolyline) {
let coords
try {
coords = decodeCoords(fpolyline)
} catch {
// Truncated, garbled, or future-version polyline — without coords
// there's no route at all, so let the user know rather than showing
// an empty map. cnames failures stay silent (names don't affect the
// route — just the labels).
Dispatcher.dispatch(
new ErrorAction(
'Could not decode the waypoints from the URL. The shared link may be truncated or malformed.',
),
)
return []
}
const cnames = url.searchParams.get('cnames')
let names: string[] = []
if (cnames && canCompress()) {
try {
names = (await inflateB64url(cnames)).split(NAMES_SEP)
} catch {
names = []
}
}
return coords.map((coordinate, idx) => ({
coordinate,
isInitialized: true,
id: idx,
queryText: names[idx] && names[idx].length > 0 ? names[idx] : coordinateToText(coordinate),
color: '',
type: QueryPointType.Via,
}))
}
return url.searchParams.getAll('point').map((parameter, idx) => {
const split = parameter.split('_')

const point = {
coordinate: { lat: 0, lng: 0 },
isInitialized: false,
Expand All @@ -82,6 +165,27 @@ export default class NavBar {
})
}

private static async parseCustomModel(url: URL): Promise<string | null> {
const compressed = url.searchParams.get('cmodel')
if (compressed) {
if (canCompress()) {
try {
return await inflateB64url(compressed)
} catch {}
}
// cmodel is authoritative when present — don't silently swap in a
// potentially-different `custom_model=` value as a fallback.
Dispatcher.dispatch(
new ErrorAction(
'Could not decode the custom routing model from the URL. ' +
'The route is being shown without the custom model and will differ from the original.',
),
)
return null
}
return url.searchParams.get('custom_model')
}

private static parseCoordinate(params: string) {
const coordinateParams = params.split(',')
if (coordinateParams.length !== 2) throw Error('Could not parse coordinate with value: "' + params[0] + '"')
Expand All @@ -100,7 +204,7 @@ export default class NavBar {
}

private static parseLayer(url: URL): string | null {
return url.searchParams.get('layer')
return url.searchParams.get('l') ?? url.searchParams.get('layer')
}

async updateStateFromUrl() {
Expand All @@ -114,7 +218,7 @@ export default class NavBar {
if (parsedProfileName)
// this won't trigger a route request because we just cleared the points
Dispatcher.dispatch(new SetVehicleProfile({ name: parsedProfileName }))
const parsedPoints = NavBar.parsePoints(url)
const parsedPoints = await NavBar.parsePoints(url)

// support legacy URLs without coordinates (not initialized) and only text, see #199
if (parsedPoints.some(p => !p.isInitialized && p.queryText.length > 0)) {
Expand Down Expand Up @@ -158,9 +262,18 @@ export default class NavBar {
const parsedLayer = NavBar.parseLayer(url)
if (parsedLayer) Dispatcher.dispatch(new SelectMapLayer(parsedLayer))

const customModelParam = url.searchParams.get('custom_model')
if (customModelParam != null) Dispatcher.dispatch(new SetCustomModel(customModelParam, false))
else Dispatcher.dispatch(new DisableCustomModel())
const customModelStr = await NavBar.parseCustomModel(url)
if (customModelStr) {
// Pretty-print so the editor shows readable JSON (URL form is minified).
// Mirrors QueryStore.getInitialState behaviour for the initial-load case.
let prettyStr = customModelStr
try {
prettyStr = customModel2prettyString(JSON.parse(customModelStr))
} catch {}
Dispatcher.dispatch(new SetCustomModel(prettyStr, true))
} else {
Dispatcher.dispatch(new DisableCustomModel())
}

this.ignoreStateUpdates = false
}
Expand All @@ -177,17 +290,22 @@ export default class NavBar {
return Dispatcher.dispatch(new SetQueryPoints(points))
}

public updateUrlFromState() {
public async updateUrlFromState() {
if (this.ignoreStateUpdates) return
const newHref = this.createUrlFromState()
const currentId = ++this.urlChangeId
const newHref = await this.createUrlFromState()
// A later state change started another build while we were awaiting
// compression — its result is strictly newer, so drop ours.
if (currentId !== this.urlChangeId) return
if (newHref !== window.location.href) window.history.pushState(null, '', newHref)
}

private createUrlFromState() {
return NavBar.createUrl(
private async createUrlFromState(): Promise<string> {
const url = await NavBar.createUrl(
window.location.origin + window.location.pathname,
this.queryStore.state,
this.mapStore.state,
).toString()
)
return url.toString()
}
}
18 changes: 17 additions & 1 deletion src/stores/MapOptionsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ export interface MapOptionsStoreState {

export interface StyleOption {
name: string
// Short code used in share URLs (`l=<shortName>`). Avoids leaking the full
// display name and brand strings into URLs.
shortName: string
type: 'raster' | 'vector'
url: string[] | string
attribution: string
Expand All @@ -53,19 +56,22 @@ const retina2x = isRetina ? '@2x' : ''

const mapTilerSatellite: VectorStyle = {
name: 'MapTiler Satellite',
shortName: 'maptlrsat',
type: 'vector',
url: 'https://api.maptiler.com/maps/hybrid/style.json?key=' + mapTilerKey,
attribution: osmAttribution + ', &copy; <a href="https://www.maptiler.com/copyright/" target="_blank">MapTiler</a>',
}
const osmOrg: RasterStyle = {
name: 'OpenStreetMap',
shortName: 'osm',
type: 'raster',
url: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
attribution: osmAttribution,
maxZoom: 19,
}
const osmCycl: RasterStyle = {
name: 'Cyclosm',
shortName: 'cyc',
type: 'raster',
url: [
'https://a.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png',
Expand All @@ -80,6 +86,7 @@ const osmCycl: RasterStyle = {

const omniscale: RasterStyle = {
name: 'Omniscale',
shortName: 'oms',
type: 'raster',
url: [
'https://maps.omniscale.net/v2/' + osApiKey + '/style.default/{z}/{x}/{y}.png' + (isRetina ? '?hq=true' : ''),
Expand All @@ -89,6 +96,7 @@ const omniscale: RasterStyle = {
}
const esriSatellite: RasterStyle = {
name: 'Esri Satellite',
shortName: 'esri',
type: 'raster',
url: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'],
attribution:
Expand All @@ -98,6 +106,7 @@ const esriSatellite: RasterStyle = {
}
const tfTransport: RasterStyle = {
name: 'TF Transport',
shortName: 'tftransp',
type: 'raster',
url: [
'https://a.tile.thunderforest.com/transport/{z}/{x}/{y}' + retina2x + '.png?apikey=' + thunderforestApiKey,
Expand All @@ -111,6 +120,7 @@ const tfTransport: RasterStyle = {
}
const tfCycle: RasterStyle = {
name: 'TF Cycle',
shortName: 'tfcyc',
type: 'raster',
url: [
'https://a.tile.thunderforest.com/cycle/{z}/{x}/{y}' + retina2x + '.png?apikey=' + thunderforestApiKey,
Expand All @@ -124,6 +134,7 @@ const tfCycle: RasterStyle = {
}
const tfOutdoors: RasterStyle = {
name: 'TF Outdoors',
shortName: 'tfout',
type: 'raster',
url: [
'https://a.tile.thunderforest.com/outdoors/{z}/{x}/{y}' + retina2x + '.png?apikey=' + thunderforestApiKey,
Expand All @@ -137,6 +148,7 @@ const tfOutdoors: RasterStyle = {
}
const mapillion: VectorStyle = {
name: 'Mapilion',
shortName: 'mpln',
type: 'vector',
url: 'https://tiles.mapilion.com/assets/osm-bright/style.json?key=' + kurvigerApiKey,
attribution:
Expand All @@ -145,6 +157,7 @@ const mapillion: VectorStyle = {
}
const wanderreitkarte: RasterStyle = {
name: 'WanderReitKarte',
shortName: 'wrk',
type: 'raster',
url: [
'https://topo.wanderreitkarte.de/topo/{z}/{x}/{y}.png',
Expand Down Expand Up @@ -192,7 +205,10 @@ export default class MapOptionsStore extends Store<MapOptionsStoreState> {

reduce(state: MapOptionsStoreState, action: Action): MapOptionsStoreState {
if (action instanceof SelectMapLayer) {
const styleOption = state.styleOptions.find(o => o.name === action.layer)
// Accept either the full name (legacy) or the short code used in share URLs.
const styleOption = state.styleOptions.find(
o => o.name === action.layer || o.shortName === action.layer,
)
if (styleOption)
return {
...state,
Expand Down
Loading