Offline-capable geomap, meant for storing location bookmarks
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add geocoding

+942 -1
+1 -1
deno.json
··· 22 22 "@civility/store": "jsr:@civility/store@^0.3.1", 23 23 "@civility/sync": "jsr:@civility/sync@^0.1.1", 24 24 "@civility/ui": "jsr:@civility/ui@^0.2.6", 25 - "@civility/workers": "jsr:@civility/workers@^0.2.4", 25 + "@civility/workers": "jsr:@civility/workers@^0.2.5", 26 26 "@zod/zod": "jsr:@zod/zod@^4.3.6", 27 27 "lit": "npm:lit@^3.3.2", 28 28 "maplibre-gl": "npm:maplibre-gl@^5.21.0",
+1
www/index.ts
··· 8 8 import './routes/bookmarks.ts' 9 9 10 10 client.init() 11 + client.watchForUpdates() 11 12 12 13 interface NavMeta { 13 14 title?: string
+8
www/routes/search.ts
··· 1 1 import { html, LitElement, type TemplateResult } from 'lit' 2 2 import app from '../models/app.ts' 3 3 import { setMapNav } from '../utils/nav.ts' 4 + import { coordsFromDirectInput } from '../utils/geocode.ts' 4 5 5 6 interface NominatimResult { 6 7 place_id: number ··· 62 63 } 63 64 64 65 #search = async (query: string) => { 66 + const direct = coordsFromDirectInput(query) 67 + if (direct) { 68 + setMapNav({ ...direct, marker: true, name: query }) 69 + location.hash = '#!/' 70 + return 71 + } 72 + 65 73 this.loading = true 66 74 this.error = null 67 75 this.results = []
+154
www/utils/geocode.ts
··· 1 + import OpenLocationCode from './open_location_code.ts' 2 + import parseGeoUri from './parse_geouri.ts' 3 + 4 + export type DirectCoords = { lat: number; lng: number; zoom: number } 5 + 6 + export function coordsFromDirectInput(query: string): DirectCoords | null { 7 + return coordsFromGeoUri(query) ?? coordsFromPlusCode(query) ?? 8 + coordsFromMapUrl(query) 9 + } 10 + 11 + /** 12 + * Get coordinates from a google Use nominatim to get additional data about a location 13 + * @reference https://developers.google.com/maps/documentation/urls/get-started 14 + * @reference https://nominatim.org/release-docs/develop/api/Reverse/ 15 + * 16 + * If it has lat lon in the url, use that: 17 + * @example https://www.google.com/maps/place/Glorieta+de+los+Insurgentes/@19.4181529,-99.1652013,16z/data=!4m15!1m8!3m7!1s0x85d1ff39f2a2e4cd:0x2d9a33e867c32532!2sRoma+Nte.,+06700+Mexico+City,+CDMX!3b1!8m2!3d19.4195256!4d-99.162549!16s%2Fg%2F12hncjcx3!3m5!1s0x85d1ff36da5b09df:0x70f1041a2c6dd42e!8m2!3d19.4236852!4d-99.1629528!16s%2Fg%2F122152f5 18 + * 19 + * If it's a short-link, check if pluscode is in resp, and use that: 20 + * @reference https://github.com/google/open-location-code/wiki/Supporting-plus-codes-in-your-app 21 + * @example https://maps.app.goo.gl/x6uX5K2eXKDENxx56 22 + * 23 + * Known issues: 24 + * - If a place isn't specific coordinates (an area), google doesn't assign pluscode 25 + */ 26 + 27 + const baseNom = 'https://nominatim.openstreetmap.org/reverse' 28 + 29 + export default async function getDataFromMapsUrl(url: string) { 30 + let resp 31 + 32 + try { 33 + resp = getCoordsFromGoogleMapsLongUrl(url) 34 + } catch { /* didn't work */ } 35 + 36 + if (!resp?.lon || !resp?.lat) { 37 + try { 38 + resp = getCoordsFromOSMUrl(url) 39 + } catch { /* didn't work */ } 40 + } 41 + 42 + if (!resp?.lon || !resp?.lat) { 43 + try { 44 + resp = getCoordsFromAppleMapsUrl(url) 45 + } catch { /* didn't work */ } 46 + } 47 + 48 + if (resp?.lon && resp?.lat && !resp?.display_name?.length) { 49 + const nomurl = `${baseNom}?lat=${resp.lat}&lon=${resp.lon}&format=json` 50 + return await (await fetch(nomurl, { 51 + headers: { 'Accept-Language': navigator.language || 'en' }, 52 + })).json() 53 + } 54 + 55 + return resp 56 + } 57 + 58 + const matchAppleCoords = 59 + /(?:\?|&)ll=(-?\d{1,2}(?:\.\d+)?),(-?\d{1,3}(?:\.\d+)?)/ 60 + const matchAppleAddress = /(?:\?|&)address=([^&]+)/ 61 + 62 + function getCoordsFromAppleMapsUrl(url: string) { 63 + const [_coords, lat, lon] = url.match(matchAppleCoords) || [null, null, null] 64 + const [_address, address] = url.match(matchAppleAddress) || [null, null] 65 + if (lon && lat) { 66 + return { 67 + lon: parseFloat(lon), 68 + lat: parseFloat(lat), 69 + display_name: address?.length ? decodeAddress(address) : '', 70 + } 71 + } 72 + } 73 + 74 + // Regex for matching lat lon from a url 75 + const matchGooglemapsCoords = 76 + /@([-+]?\d{1,2}(?:\.\d+)?),([-+]?\d{1,3}(?:\.\d+)?)/ 77 + const matchGoogleAddress = /(?:\?|&)address=([^&]+)/ 78 + 79 + function getCoordsFromGoogleMapsLongUrl(url: string) { 80 + const [_, lat, lon] = url.match(matchGooglemapsCoords) || [null, null, null] 81 + const [_address, address] = url.match(matchGoogleAddress) || [null, null] 82 + 83 + if (lon && lat) { 84 + return { 85 + lon: parseFloat(lon), 86 + lat: parseFloat(lat), 87 + display_name: address?.length ? decodeAddress(address) : '', 88 + } 89 + } 90 + } 91 + 92 + function getCoordsFromOSMUrl(url: string) { 93 + const matchLat = /mlat=([-+]?\d{1,2}(?:\.\d+)?)/ 94 + const matchLon = /mlon=([-+]?\d{1,3}(?:\.\d+)?)/ 95 + 96 + const latMatch = url.match(matchLat) 97 + const lonMatch = url.match(matchLon) 98 + 99 + if (latMatch && lonMatch) { 100 + const lat = parseFloat(latMatch[1]) 101 + const lon = parseFloat(lonMatch[1]) 102 + return { lat, lon, display_name: '' } 103 + } else { 104 + return null 105 + } 106 + } 107 + 108 + function decodeAddress(encodedAddress = '') { 109 + const decodedAddress = decodeURIComponent(encodedAddress) 110 + const readableAddress = decodedAddress.replace(/\+/g, ' ') 111 + return readableAddress 112 + } 113 + 114 + function coordsFromGeoUri(query: string): DirectCoords | null { 115 + const coords = parseGeoUri(query) 116 + if (!coords) return null 117 + let zoom = 15 118 + if (coords.accuracy != null) { 119 + if (coords.accuracy > 50000) zoom = 7 120 + else if (coords.accuracy > 10000) zoom = 9 121 + else if (coords.accuracy > 1000) zoom = 13 122 + } 123 + return { lat: coords.latitude, lng: coords.longitude, zoom } 124 + } 125 + 126 + function coordsFromPlusCode(query: string): DirectCoords | null { 127 + const trimmed = query.trim() 128 + if (!OpenLocationCode.isFull(trimmed)) return null 129 + const area = OpenLocationCode.decode(trimmed) 130 + let zoom = 16 131 + if (area.codeLength <= 4) zoom = 5 132 + else if (area.codeLength <= 6) zoom = 9 133 + else if (area.codeLength <= 8) zoom = 13 134 + return { lat: area.latitudeCenter, lng: area.longitudeCenter, zoom } 135 + } 136 + 137 + function coordsFromMapUrl(query: string): DirectCoords | null { 138 + let result 139 + try { 140 + result = getCoordsFromGoogleMapsLongUrl(query) 141 + } catch { /* */ } 142 + if (!result?.lat || !result?.lon) { 143 + try { 144 + result = getCoordsFromOSMUrl(query) 145 + } catch { /* */ } 146 + } 147 + if (!result?.lat || !result?.lon) { 148 + try { 149 + result = getCoordsFromAppleMapsUrl(query) 150 + } catch { /* */ } 151 + } 152 + if (!result?.lat || !result?.lon) return null 153 + return { lat: result.lat, lng: result.lon, zoom: 15 } 154 + }
+722
www/utils/open_location_code.ts
··· 1 + /** 2 + * From: https://github.com/tspoke/typescript-open-location-code 3 + */ 4 + 5 + const LATITUDE_MAX = 90 6 + const LONGITUDE_MAX = 180 7 + 8 + /** 9 + * Coordinates of a decoded Open Location Code. 10 + * 11 + * The coordinates include the latitude and longitude of the lower left and 12 + * upper right corners and the center of the bounding box for the area the 13 + * code represents. 14 + * 15 + * @constructor 16 + */ 17 + export class CodeArea { 18 + /** 19 + * The latitude of the center in degrees. 20 + */ 21 + public latitudeCenter: number 22 + /** 23 + * The longitude of the center in degrees. 24 + */ 25 + public longitudeCenter: number 26 + 27 + constructor( 28 + public latitudeLo: number, 29 + public longitudeLo: number, 30 + public latitudeHi: number, 31 + public longitudeHi: number, 32 + public codeLength: number, 33 + ) { 34 + this.latitudeCenter = Math.min( 35 + latitudeLo + (latitudeHi - latitudeLo) / 2, 36 + LATITUDE_MAX, 37 + ) 38 + this.longitudeCenter = Math.min( 39 + longitudeLo + (longitudeHi - longitudeLo) / 2, 40 + LONGITUDE_MAX, 41 + ) 42 + } 43 + 44 + public getLatitudeHeight(): number { 45 + return this.latitudeHi - this.latitudeLo 46 + } 47 + 48 + public getLongitudeWidth(): number { 49 + return this.longitudeHi - this.longitudeLo 50 + } 51 + } 52 + 53 + /** 54 + * Open Location Code implementation for TypeScript 55 + */ 56 + export default class OpenLocationCode { 57 + public constructor(public code: string) { 58 + } 59 + 60 + public getCode(): string { 61 + return this.code 62 + } 63 + 64 + /** 65 + * Returns whether this {@link OpenLocationCode} is a padded Open Location Code, meaning that it 66 + * contains less than 8 valid digits. 67 + */ 68 + public isPadded(): boolean { 69 + return this.code.indexOf(OpenLocationCode.PADDING_CHARACTER_) >= 0 70 + } 71 + 72 + private static readonly CODE_PRECISION_NORMAL = 10 73 + private static readonly CODE_PRECISION_EXTRA = 11 74 + private static readonly MAX_DIGIT_COUNT_ = 15 75 + 76 + // A separator used to break the code into two parts to aid memorability. 77 + private static readonly SEPARATOR_ = '+' 78 + 79 + // The number of characters to place before the separator. 80 + private static readonly SEPARATOR_POSITION_ = 8 81 + 82 + // The character used to pad codes. 83 + private static readonly PADDING_CHARACTER_ = '0' 84 + 85 + // The character set used to encode the values. 86 + private static readonly CODE_ALPHABET_ = '23456789CFGHJMPQRVWX' 87 + 88 + // The base to use to convert numbers to/from. 89 + private static readonly ENCODING_BASE_ = 90 + OpenLocationCode.CODE_ALPHABET_.length 91 + 92 + // The maximum value for latitude in degrees. 93 + static readonly LATITUDE_MAX_ = LATITUDE_MAX 94 + 95 + // The maximum value for longitude in degrees. 96 + static readonly LONGITUDE_MAX_ = LONGITUDE_MAX 97 + 98 + // Maximum code length using lat/lng pair encoding. The area of such a 99 + // code is approximately 13x13 meters (at the equator), and should be suitable 100 + // for identifying buildings. This excludes prefix and separator characters. 101 + private static readonly PAIR_CODE_LENGTH_ = 10 102 + 103 + // The resolution values in degrees for each position in the lat/lng pair 104 + // encoding. These give the place value of each position, and therefore the 105 + // dimensions of the resulting area. 106 + private static readonly PAIR_RESOLUTIONS_ = [20.0, 1.0, .05, .0025, .000125] 107 + 108 + // First place value of the pairs (if the last pair value is 1). 109 + private static readonly PAIR_FIRST_PLACE_VALUE_ = Math.pow( 110 + OpenLocationCode.ENCODING_BASE_, 111 + OpenLocationCode.PAIR_CODE_LENGTH_ / 2 - 1, 112 + ) 113 + 114 + // Inverse of the precision of the pair section of the code. 115 + private static readonly PAIR_PRECISION_ = Math.pow( 116 + OpenLocationCode.ENCODING_BASE_, 117 + 3, 118 + ) 119 + 120 + // Number of digits in the grid precision part of the code. 121 + private static readonly GRID_CODE_LENGTH_ = 122 + OpenLocationCode.MAX_DIGIT_COUNT_ - OpenLocationCode.PAIR_CODE_LENGTH_ 123 + 124 + // Number of columns in the grid refinement method. 125 + private static readonly GRID_COLUMNS_ = 4 126 + 127 + // Number of rows in the grid refinement method. 128 + private static readonly GRID_ROWS_ = 5 129 + 130 + // First place value of the latitude grid (if the last place is 1). 131 + private static readonly GRID_LAT_FIRST_PLACE_VALUE_ = Math.pow( 132 + OpenLocationCode.GRID_ROWS_, 133 + OpenLocationCode.GRID_CODE_LENGTH_ - 1, 134 + ) 135 + 136 + // First place value of the longitude grid (if the last place is 1). 137 + private static readonly GRID_LNG_FIRST_PLACE_VALUE_ = Math.pow( 138 + OpenLocationCode.GRID_COLUMNS_, 139 + OpenLocationCode.GRID_CODE_LENGTH_ - 1, 140 + ) 141 + 142 + // Multiply latitude by this much to make it a multiple of the finest 143 + // precision. 144 + private static readonly FINAL_LAT_PRECISION_ = 145 + OpenLocationCode.PAIR_PRECISION_ * 146 + Math.pow( 147 + OpenLocationCode.GRID_ROWS_, 148 + OpenLocationCode.MAX_DIGIT_COUNT_ - OpenLocationCode.PAIR_CODE_LENGTH_, 149 + ) 150 + 151 + // Multiply longitude by this much to make it a multiple of the finest 152 + // precision. 153 + private static readonly FINAL_LNG_PRECISION_ = 154 + OpenLocationCode.PAIR_PRECISION_ * 155 + Math.pow( 156 + OpenLocationCode.GRID_COLUMNS_, 157 + OpenLocationCode.MAX_DIGIT_COUNT_ - OpenLocationCode.PAIR_CODE_LENGTH_, 158 + ) 159 + 160 + // Minimum length of a code that can be shortened. 161 + private static readonly MIN_TRIMMABLE_CODE_LEN_ = 6 162 + 163 + /** 164 + * Determines if a code is valid. 165 + * 166 + * To be valid, all characters must be from the Open Location Code character 167 + * set with at most one separator. The separator can be in any even-numbered 168 + * position up to the eighth digit. 169 + * 170 + * @param {string} code The string to check. 171 + * @return {boolean} True if the string is a valid code. 172 + */ 173 + public static isValid(code: string): boolean { 174 + if (!code) { 175 + return false 176 + } 177 + // The separator is required. 178 + if (code.indexOf(OpenLocationCode.SEPARATOR_) === -1) { 179 + return false 180 + } 181 + if ( 182 + code.indexOf(OpenLocationCode.SEPARATOR_) !== 183 + code.lastIndexOf(OpenLocationCode.SEPARATOR_) 184 + ) { 185 + return false 186 + } 187 + // Is it the only character? 188 + if (code.length === 1) { 189 + return false 190 + } 191 + // Is it in an illegal position? 192 + if ( 193 + code.indexOf(OpenLocationCode.SEPARATOR_) > 194 + OpenLocationCode.SEPARATOR_POSITION_ || 195 + code.indexOf(OpenLocationCode.SEPARATOR_) % 2 === 1 196 + ) { 197 + return false 198 + } 199 + // We can have an even number of padding characters before the separator, 200 + // but then it must be the final character. 201 + if (code.indexOf(OpenLocationCode.PADDING_CHARACTER_) > -1) { 202 + // Short codes cannot have padding 203 + if ( 204 + code.indexOf(OpenLocationCode.SEPARATOR_) < 205 + OpenLocationCode.SEPARATOR_POSITION_ 206 + ) { 207 + return false 208 + } 209 + // Not allowed to start with them! 210 + if (code.indexOf(OpenLocationCode.PADDING_CHARACTER_) === 0) { 211 + return false 212 + } 213 + // There can only be one group and it must have even length. 214 + const padMatch = code.match( 215 + new RegExp('(' + OpenLocationCode.PADDING_CHARACTER_ + '+)', 'g'), 216 + ) 217 + if ( 218 + (padMatch?.length || 0) > 1 || (padMatch?.[0]?.length || 0) % 2 === 1 || 219 + (padMatch?.[0]?.length || 0) > OpenLocationCode.SEPARATOR_POSITION_ - 2 220 + ) { 221 + return false 222 + } 223 + // If the code is long enough to end with a separator, make sure it does. 224 + if (code.charAt(code.length - 1) !== OpenLocationCode.SEPARATOR_) { 225 + return false 226 + } 227 + } 228 + // If there are characters after the separator, make sure there isn't just 229 + // one of them (not legal). 230 + if (code.length - code.indexOf(OpenLocationCode.SEPARATOR_) - 1 === 1) { 231 + return false 232 + } 233 + 234 + // Strip the separator and any padding characters. 235 + const strippedCode = code.replace( 236 + new RegExp('\\' + OpenLocationCode.SEPARATOR_ + '+'), 237 + '', 238 + ) 239 + .replace(new RegExp(OpenLocationCode.PADDING_CHARACTER_ + '+'), '') 240 + // Check the code contains only valid characters. 241 + for (let i = 0, len = strippedCode.length; i < len; i++) { 242 + const character = strippedCode.charAt(i).toUpperCase() 243 + if ( 244 + character !== OpenLocationCode.SEPARATOR_ && 245 + OpenLocationCode.CODE_ALPHABET_.indexOf(character) === -1 246 + ) { 247 + return false 248 + } 249 + } 250 + return true 251 + } 252 + 253 + /** 254 + * Returns whether the provided Open Location Code is a padded Open Location Code, meaning that it 255 + * contains less than 8 valid digits. 256 + */ 257 + public static isPadded(code: string): boolean { 258 + return new OpenLocationCode(code).isPadded() 259 + } 260 + 261 + /** 262 + * Determines if a code is a valid short code. 263 + * 264 + * @param {string} code The string to check. 265 + * @return {boolean} True if the string can be produced by removing four or 266 + * more characters from the start of a valid code. 267 + */ 268 + public static isShort(code: string): boolean { 269 + if (!OpenLocationCode.isValid(code)) { 270 + return false 271 + } 272 + // If there are less characters than expected before the SEPARATOR. 273 + return code.indexOf(OpenLocationCode.SEPARATOR_) >= 0 && 274 + code.indexOf(OpenLocationCode.SEPARATOR_) < 275 + OpenLocationCode.SEPARATOR_POSITION_ 276 + } 277 + 278 + /** 279 + * Determines if a code is a valid full Open Location Code. 280 + * 281 + * @param {string} code The string to check. 282 + * @return {boolean} True if the code represents a valid latitude and longitude combination. 283 + */ 284 + public static isFull(code: string): boolean { 285 + if (!OpenLocationCode.isValid(code)) { 286 + return false 287 + } 288 + // If it's short, it's not full. 289 + if (OpenLocationCode.isShort(code)) { 290 + return false 291 + } 292 + 293 + // Work out what the first latitude character indicates for latitude. 294 + const firstLatValue = 295 + OpenLocationCode.CODE_ALPHABET_.indexOf(code.charAt(0).toUpperCase()) * 296 + OpenLocationCode.ENCODING_BASE_ 297 + if (firstLatValue >= OpenLocationCode.LATITUDE_MAX_ * 2) { 298 + return false // The code would decode to a latitude of >= 90 degrees. 299 + } 300 + if (code.length > 1) { 301 + // Work out what the first longitude character indicates for longitude. 302 + const firstLngValue = 303 + OpenLocationCode.CODE_ALPHABET_.indexOf(code.charAt(1).toUpperCase()) * 304 + OpenLocationCode.ENCODING_BASE_ 305 + if (firstLngValue >= OpenLocationCode.LONGITUDE_MAX_ * 2) { 306 + return false // The code would decode to a longitude of >= 180 degrees. 307 + } 308 + } 309 + return true 310 + } 311 + 312 + public contains(latitude: number, longitude: number): boolean { 313 + const codeArea = OpenLocationCode.decode(this.getCode()) 314 + return codeArea.latitudeLo <= latitude && 315 + latitude < codeArea.latitudeHi && 316 + codeArea.longitudeLo <= longitude && 317 + longitude < codeArea.longitudeHi 318 + } 319 + 320 + /** 321 + * Encode a location into an Open Location Code. 322 + * 323 + * @param {number} latitude The latitude in signed decimal degrees. It will 324 + * be clipped to the range -90 to 90. 325 + * @param {number} longitude The longitude in signed decimal degrees. Will be 326 + * normalised to the range -180 to 180. 327 + * @param {?number} codeLength The length of the code to generate. If 328 + * omitted, the value OpenLocationCode.CODE_PRECISION_NORMAL will be used. 329 + * For a more precise result, OpenLocationCode.CODE_PRECISION_EXTRA is 330 + * recommended. 331 + * @return {string} The code. 332 + * @throws {Exception} if any of the input values are not numbers. 333 + */ 334 + public static encode( 335 + latitude: number, 336 + longitude: number, 337 + codeLength: number = OpenLocationCode.CODE_PRECISION_NORMAL, 338 + ): string { 339 + if ( 340 + codeLength < 2 || 341 + (codeLength < OpenLocationCode.PAIR_CODE_LENGTH_ && codeLength % 2 === 1) 342 + ) { 343 + throw new Error( 344 + 'IllegalArgumentException: Invalid Open Location Code length', 345 + ) 346 + } 347 + 348 + const editedCodeLength = Math.min( 349 + OpenLocationCode.MAX_DIGIT_COUNT_, 350 + codeLength, 351 + ) 352 + 353 + // Ensure that latitude and longitude are valid. 354 + let editedLatitude = OpenLocationCode.clipLatitude(latitude) 355 + const editedLongitude = OpenLocationCode.normalizeLongitude(longitude) 356 + // Latitude 90 needs to be adjusted to be just less, so the returned code 357 + // can also be decoded. 358 + if (editedLatitude === 90) { 359 + editedLatitude = editedLatitude - 360 + OpenLocationCode.computeLatitudePrecision(editedCodeLength) 361 + } 362 + let code = '' 363 + 364 + // Compute the code. 365 + // This approach converts each value to an integer after multiplying it by 366 + // the final precision. This allows us to use only integer operations, so 367 + // avoiding any accumulation of floating point representation errors. 368 + 369 + // Multiply values by their precision and convert to positive. 370 + // Force to integers so the division operations will have integer results. 371 + // Note: JavaScript requires rounding before truncating to ensure precision! 372 + let latVal = Math.floor( 373 + Math.round( 374 + (editedLatitude + OpenLocationCode.LATITUDE_MAX_) * 375 + OpenLocationCode.FINAL_LAT_PRECISION_ * 1e6, 376 + ) / 1e6, 377 + ) 378 + let lngVal = Math.floor( 379 + Math.round( 380 + (editedLongitude + OpenLocationCode.LONGITUDE_MAX_) * 381 + OpenLocationCode.FINAL_LNG_PRECISION_ * 1e6, 382 + ) / 1e6, 383 + ) 384 + 385 + // Compute the grid part of the code if necessary. 386 + if (editedCodeLength > OpenLocationCode.PAIR_CODE_LENGTH_) { 387 + for ( 388 + let i = 0; 389 + i < 390 + OpenLocationCode.MAX_DIGIT_COUNT_ - 391 + OpenLocationCode.PAIR_CODE_LENGTH_; 392 + i++ 393 + ) { 394 + const latDigit = latVal % OpenLocationCode.GRID_ROWS_ 395 + const lngDigit = lngVal % OpenLocationCode.GRID_COLUMNS_ 396 + const ndx = latDigit * OpenLocationCode.GRID_COLUMNS_ + lngDigit 397 + code = OpenLocationCode.CODE_ALPHABET_.charAt(ndx) + code 398 + // Note! Integer division. 399 + latVal = Math.floor(latVal / OpenLocationCode.GRID_ROWS_) 400 + lngVal = Math.floor(lngVal / OpenLocationCode.GRID_COLUMNS_) 401 + } 402 + } else { 403 + latVal = Math.floor( 404 + latVal / 405 + Math.pow( 406 + OpenLocationCode.GRID_ROWS_, 407 + OpenLocationCode.GRID_CODE_LENGTH_, 408 + ), 409 + ) 410 + lngVal = Math.floor( 411 + lngVal / 412 + Math.pow( 413 + OpenLocationCode.GRID_COLUMNS_, 414 + OpenLocationCode.GRID_CODE_LENGTH_, 415 + ), 416 + ) 417 + } 418 + // Compute the pair section of the code. 419 + for (let i = 0; i < OpenLocationCode.PAIR_CODE_LENGTH_ / 2; i++) { 420 + code = OpenLocationCode.CODE_ALPHABET_.charAt( 421 + lngVal % OpenLocationCode.ENCODING_BASE_, 422 + ) + code 423 + code = OpenLocationCode.CODE_ALPHABET_.charAt( 424 + latVal % OpenLocationCode.ENCODING_BASE_, 425 + ) + code 426 + latVal = Math.floor(latVal / OpenLocationCode.ENCODING_BASE_) 427 + lngVal = Math.floor(lngVal / OpenLocationCode.ENCODING_BASE_) 428 + } 429 + 430 + // Add the separator character. 431 + code = code.substring(0, OpenLocationCode.SEPARATOR_POSITION_) + 432 + OpenLocationCode.SEPARATOR_ + 433 + code.substring(OpenLocationCode.SEPARATOR_POSITION_) 434 + 435 + // If we don't need to pad the code, return the requested section. 436 + if (editedCodeLength >= OpenLocationCode.SEPARATOR_POSITION_) { 437 + return code.substring(0, editedCodeLength + 1) 438 + } 439 + // Pad and return the code. 440 + return code.substring(0, editedCodeLength) + 441 + Array(OpenLocationCode.SEPARATOR_POSITION_ - editedCodeLength + 1).join( 442 + OpenLocationCode.PADDING_CHARACTER_, 443 + ) + OpenLocationCode.SEPARATOR_ 444 + } 445 + 446 + /** 447 + * Decodes an Open Location Code into its location coordinates. 448 + * 449 + * Returns a CodeArea object that includes the coordinates of the bounding 450 + * box - the lower left, center and upper right. 451 + * 452 + * @param {string} code The code to decode. 453 + * @return {CodeArea} An object with the coordinates of the 454 + * area of the code. 455 + * @throws {Exception} If the code is not valid. 456 + */ 457 + public static decode(code: string): CodeArea { 458 + // This calculates the values for the pair and grid section separately, using 459 + // integer arithmetic. Only at the final step are they converted to floating 460 + // point and combined. 461 + if (!OpenLocationCode.isFull(code)) { 462 + throw new Error( 463 + 'IllegalArgumentException: Passed Open Location Code is not a valid full code: ' + 464 + code, 465 + ) 466 + } 467 + // Strip the '+' and '0' characters from the code and convert to upper case. 468 + const editedCode = code.replace(OpenLocationCode.SEPARATOR_, '') 469 + .replace( 470 + new RegExp( 471 + OpenLocationCode.PADDING_CHARACTER_ + OpenLocationCode.SEPARATOR_, 472 + ), 473 + '', 474 + ) 475 + .toUpperCase() 476 + 477 + // Initialise the values for each section. We work them out as integers and 478 + // convert them to floats at the end. 479 + let normalLat = -OpenLocationCode.LATITUDE_MAX_ * 480 + OpenLocationCode.PAIR_PRECISION_ 481 + let normalLng = -OpenLocationCode.LONGITUDE_MAX_ * 482 + OpenLocationCode.PAIR_PRECISION_ 483 + let gridLat = 0 484 + let gridLng = 0 485 + // How many digits do we have to process? 486 + let digits = Math.min(editedCode.length, OpenLocationCode.PAIR_CODE_LENGTH_) 487 + // Define the place value for the most significant pair. 488 + let pv = OpenLocationCode.PAIR_FIRST_PLACE_VALUE_ 489 + // Decode the paired digits. 490 + for (let position = 0; position < digits; position += 2) { 491 + normalLat += 492 + OpenLocationCode.CODE_ALPHABET_.indexOf(editedCode.charAt(position)) * 493 + pv 494 + normalLng += OpenLocationCode.CODE_ALPHABET_.indexOf( 495 + editedCode.charAt(position + 1), 496 + ) * pv 497 + if (position < digits - 2) { 498 + pv /= OpenLocationCode.ENCODING_BASE_ 499 + } 500 + } 501 + // Convert the place value to a float in degrees. 502 + let latPrecision = pv / OpenLocationCode.PAIR_PRECISION_ 503 + let lngPrecision = pv / OpenLocationCode.PAIR_PRECISION_ 504 + // Process any extra precision digits. 505 + if (editedCode.length > OpenLocationCode.PAIR_CODE_LENGTH_) { 506 + // Initialise the place values for the grid. 507 + let rowpv = OpenLocationCode.GRID_LAT_FIRST_PLACE_VALUE_ 508 + let colpv = OpenLocationCode.GRID_LNG_FIRST_PLACE_VALUE_ 509 + // How many digits do we have to process? 510 + digits = Math.min(editedCode.length, OpenLocationCode.MAX_DIGIT_COUNT_) 511 + for (let i = OpenLocationCode.PAIR_CODE_LENGTH_; i < digits; i++) { 512 + const digitVal = OpenLocationCode.CODE_ALPHABET_.indexOf( 513 + editedCode.charAt(i), 514 + ) 515 + const row = Math.floor(digitVal / OpenLocationCode.GRID_COLUMNS_) 516 + const col = digitVal % OpenLocationCode.GRID_COLUMNS_ 517 + gridLat += row * rowpv 518 + gridLng += col * colpv 519 + if (i < digits - 1) { 520 + rowpv /= OpenLocationCode.GRID_ROWS_ 521 + colpv /= OpenLocationCode.GRID_COLUMNS_ 522 + } 523 + } 524 + // Adjust the precisions from the integer values to degrees. 525 + latPrecision = rowpv / OpenLocationCode.FINAL_LAT_PRECISION_ 526 + lngPrecision = colpv / OpenLocationCode.FINAL_LNG_PRECISION_ 527 + } 528 + // Merge the values from the normal and extra precision parts of the code. 529 + const lat = normalLat / OpenLocationCode.PAIR_PRECISION_ + 530 + gridLat / OpenLocationCode.FINAL_LAT_PRECISION_ 531 + const lng = normalLng / OpenLocationCode.PAIR_PRECISION_ + 532 + gridLng / OpenLocationCode.FINAL_LNG_PRECISION_ 533 + // Multiple values by 1e14, round and then divide. This reduces errors due 534 + // to floating point precision. 535 + return new CodeArea( 536 + Math.round(lat * 1e14) / 1e14, 537 + Math.round(lng * 1e14) / 1e14, 538 + Math.round((lat + latPrecision) * 1e14) / 1e14, 539 + Math.round((lng + lngPrecision) * 1e14) / 1e14, 540 + Math.min(editedCode.length, OpenLocationCode.MAX_DIGIT_COUNT_), 541 + ) 542 + } 543 + 544 + /** 545 + * Recover the nearest matching code to a specified location. 546 + * 547 + * Given a valid short Open Location Code this recovers the nearest matching 548 + * full code to the specified location. 549 + * 550 + * @param {string} shortCode A valid short code. 551 + * @param {number} latitude The latitude to use for the reference 552 + * location. 553 + * @param {number} longitude The longitude to use for the reference 554 + * location. 555 + * @return {string} The nearest matching full code to the reference location. 556 + * @throws {Exception} if the short code is not valid, or the reference 557 + * position values are not numbers. 558 + */ 559 + public static recoverNearest( 560 + shortCode: string, 561 + latitude: number, 562 + longitude: number, 563 + ): string { 564 + if (!OpenLocationCode.isShort(shortCode)) { 565 + if (OpenLocationCode.isFull(shortCode)) { 566 + return shortCode 567 + } else { 568 + throw new Error( 569 + 'ValueError: Passed short code is not valid: ' + shortCode, 570 + ) 571 + } 572 + } 573 + 574 + const referenceLatitude = OpenLocationCode.clipLatitude(latitude) 575 + const referenceLongitude = OpenLocationCode.normalizeLongitude(longitude) 576 + const shortCodeUpper = shortCode.toUpperCase() // Clean up the passed code. 577 + // Compute the number of digits we need to recover. 578 + const paddingLength = OpenLocationCode.SEPARATOR_POSITION_ - 579 + shortCodeUpper.indexOf(OpenLocationCode.SEPARATOR_) 580 + const resolution = Math.pow(20, 2 - (paddingLength / 2)) // The resolution (height and width) of the padded area in degrees. 581 + const halfResolution = resolution / 2.0 // Distance from the center to an edge (in degrees). 582 + 583 + // Use the reference location to pad the supplied short code and decode it. 584 + const codeArea = OpenLocationCode.decode( 585 + OpenLocationCode.encode(referenceLatitude, referenceLongitude).substr( 586 + 0, 587 + paddingLength, 588 + ) + shortCodeUpper, 589 + ) 590 + // How many degrees latitude is the code from the reference? If it is more 591 + // than half the resolution, we need to move it north or south but keep it 592 + // within -90 to 90 degrees. 593 + if ( 594 + referenceLatitude + halfResolution < codeArea.latitudeCenter && 595 + codeArea.latitudeCenter - resolution >= -OpenLocationCode.LATITUDE_MAX_ 596 + ) { 597 + // If the proposed code is more than half a cell north of the reference location, 598 + // it's too far, and the best match will be one cell south. 599 + codeArea.latitudeCenter -= resolution 600 + } else if ( 601 + referenceLatitude - halfResolution > codeArea.latitudeCenter && 602 + codeArea.latitudeCenter + resolution <= OpenLocationCode.LATITUDE_MAX_ 603 + ) { 604 + // If the proposed code is more than half a cell south of the reference location, 605 + // it's too far, and the best match will be one cell north. 606 + codeArea.latitudeCenter += resolution 607 + } 608 + 609 + // How many degrees longitude is the code from the reference? 610 + if (referenceLongitude + halfResolution < codeArea.longitudeCenter) { 611 + codeArea.longitudeCenter -= resolution 612 + } else if (referenceLongitude - halfResolution > codeArea.longitudeCenter) { 613 + codeArea.longitudeCenter += resolution 614 + } 615 + 616 + return OpenLocationCode.encode( 617 + codeArea.latitudeCenter, 618 + codeArea.longitudeCenter, 619 + codeArea.codeLength, 620 + ) 621 + } 622 + 623 + /** 624 + * Remove characters from the start of an OLC code. 625 + * 626 + * This uses a reference location to determine how many initial characters 627 + * can be removed from the OLC code. The number of characters that can be 628 + * removed depends on the distance between the code center and the reference 629 + * location. 630 + * 631 + * @param {string} code The full code to shorten. 632 + * @param {number} latitude The latitude to use for the reference location. 633 + * @param {number} longitude The longitude to use for the reference location. 634 + * @return {string} The code, shortened as much as possible that it is still 635 + * the closest matching code to the reference location. 636 + * @throws {Exception} if the passed code is not a valid full code or the 637 + * reference location values are not numbers. 638 + */ 639 + public static shorten( 640 + code: string, 641 + latitude: number, 642 + longitude: number, 643 + ): string { 644 + if (!OpenLocationCode.isFull(code)) { 645 + throw new Error('ValueError: Passed code is not valid and full: ' + code) 646 + } 647 + if (code.indexOf(OpenLocationCode.PADDING_CHARACTER_) !== -1) { 648 + throw new Error('ValueError: Cannot shorten padded codes: ' + code) 649 + } 650 + 651 + const codeUpper = code.toUpperCase() 652 + const codeArea = OpenLocationCode.decode(codeUpper) 653 + if (codeArea.codeLength < OpenLocationCode.MIN_TRIMMABLE_CODE_LEN_) { 654 + throw new Error( 655 + 'ValueError: Code length must be at least ' + 656 + OpenLocationCode.MIN_TRIMMABLE_CODE_LEN_, 657 + ) 658 + } 659 + 660 + const latitudeClipped = OpenLocationCode.clipLatitude(latitude) 661 + const longitudeClipped = OpenLocationCode.normalizeLongitude(longitude) 662 + 663 + // How close are the latitude and longitude to the code center. 664 + const range = Math.max( 665 + Math.abs(codeArea.latitudeCenter - latitudeClipped), 666 + Math.abs(codeArea.longitudeCenter - longitudeClipped), 667 + ) 668 + for (let i = OpenLocationCode.PAIR_RESOLUTIONS_.length - 2; i >= 1; i--) { 669 + // Check if we're close enough to shorten. The range must be less than 1/2 670 + // the resolution to shorten at all, and we want to allow some safety, so 671 + // use 0.3 instead of 0.5 as a multiplier. 672 + if (range < (OpenLocationCode.PAIR_RESOLUTIONS_[i] * 0.3)) { 673 + // Trim it. 674 + return codeUpper.substring((i + 1) * 2) 675 + } 676 + } 677 + return codeUpper 678 + } 679 + 680 + /** 681 + * Clip a latitude into the range -90 to 90. 682 + * 683 + * @param {number} latitude 684 + * @return {number} The latitude value clipped to be in the range. 685 + */ 686 + private static clipLatitude(latitude: number): number { 687 + return Math.min(90, Math.max(-90, latitude)) 688 + } 689 + 690 + /** 691 + * Compute the latitude precision value for a given code length. 692 + * Lengths <= 10 have the same precision for latitude and longitude, but 693 + * lengths > 10 have different precisions due to the grid method having 694 + * fewer columns than rows. 695 + * @param {number} codeLength 696 + * @return {number} The latitude precision in degrees. 697 + */ 698 + private static computeLatitudePrecision(codeLength: number): number { 699 + if (codeLength <= 10) { 700 + return Math.pow(20, Math.floor(codeLength / -2 + 2)) 701 + } 702 + return Math.pow(20, -3) / 703 + Math.pow(OpenLocationCode.GRID_ROWS_, codeLength - 10) 704 + } 705 + 706 + /** 707 + * Normalize a longitude into the range -180 to 180, not including 180. 708 + * 709 + * @param {number} longitude 710 + * @return {number} Normalized into the range -180 to 180. 711 + */ 712 + private static normalizeLongitude(longitude: number): number { 713 + let longitudeOutput = longitude 714 + while (longitudeOutput < -180) { 715 + longitudeOutput = longitudeOutput + 360 716 + } 717 + while (longitudeOutput >= 180) { 718 + longitudeOutput = longitudeOutput - 360 719 + } 720 + return longitudeOutput 721 + } 722 + }
+56
www/utils/parse_geouri.ts
··· 1 + /* 2 + Copyright 2022 The Matrix.org Foundation C.I.C. 3 + 4 + Licensed under the Apache License, Version 2.0 (the "License"); 5 + you may not use this file except in compliance with the License. 6 + You may obtain a copy of the License at 7 + 8 + http://www.apache.org/licenses/LICENSE-2.0 9 + 10 + Unless required by applicable law or agreed to in writing, software 11 + distributed under the License is distributed on an "AS IS" BASIS, 12 + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 + See the License for the specific language governing permissions and 14 + limitations under the License. 15 + */ 16 + export default ( 17 + uri: string, 18 + ): GeolocationCoordinates | undefined => { 19 + function parse(s: string): number | null { 20 + const ret = parseFloat(s) 21 + if (Number.isNaN(ret)) { 22 + return null 23 + } else { 24 + return ret 25 + } 26 + } 27 + 28 + const m = uri.match(/^\s*geo:(.*?)\s*$/) 29 + if (!m) return 30 + const parts = m[1].split(';') 31 + const coords = parts[0].split(',') 32 + let uncertainty: number | null | undefined = undefined 33 + for (const param of parts.slice(1)) { 34 + const m = param.match(/u=(.*)/) 35 + if (m) uncertainty = parse(m[1]) 36 + } 37 + const latitude = parse(coords[0]) 38 + const longitude = parse(coords[1]) 39 + 40 + if (latitude === null || longitude === null) { 41 + return 42 + } 43 + 44 + return { 45 + latitude: latitude!, 46 + longitude: longitude!, 47 + altitude: parse(coords[2]), 48 + accuracy: uncertainty!, 49 + altitudeAccuracy: null, 50 + heading: null, 51 + speed: null, 52 + toJSON() { 53 + return JSON.stringify(this) 54 + }, 55 + } 56 + }