coordinates.js (15451B)
1 import L from 'leaflet'; 2 3 const reInteger = '\\d+'; 4 const reFractional = '\\d+(?:\\.\\d+)?'; 5 const reSignedFractional = '-?\\d+(?:\\.\\d+)?'; 6 const reHemisphere = '[NWSE]'; 7 8 class Coordinates { 9 getLatitudeLetter() { 10 return this.latIsSouth ? 'S' : 'N'; 11 } 12 13 getLongitudeLetter() { 14 return this.lonIsWest ? 'W' : 'E'; 15 } 16 17 static parseHemispheres(h1, h2, h3, allowEmpty = false) { 18 function isLat(h) { 19 return h === 'N' || h === 'S'; 20 } 21 let swapLatLon = false; 22 let hLat, hLon; 23 if (h1 && h2 && !h3) { 24 hLat = h1.trim(); 25 hLon = h2.trim(); 26 } else if (h1 && !h2 && h3) { 27 hLat = h1.trim(); 28 hLon = h3.trim(); 29 } else if (!h1 && h2 && h3) { 30 hLat = h2.trim(); 31 hLon = h3.trim(); 32 } else if (allowEmpty && !h1 && !h2 && !h3) { 33 return {empty: true}; 34 } else { 35 return {error: true}; 36 } 37 if (isLat(hLat) === isLat(hLon)) { 38 return {error: true}; 39 } 40 if (isLat(hLon)) { 41 [hLat, hLon] = [hLon, hLat]; 42 swapLatLon = true; 43 } 44 const latIsSouth = hLat === 'S'; 45 const lonIsWest = hLon === 'W'; 46 return {swapLatLon, latIsSouth, lonIsWest}; 47 } 48 } 49 50 class CoordinatesDMS extends Coordinates { 51 static regexp = new RegExp( 52 // eslint-disable-next-line max-len 53 `^(${reHemisphere} )?(${reInteger}) (${reInteger}) (${reFractional}) (${reHemisphere} )?(${reInteger}) (${reInteger}) (${reFractional})( ${reHemisphere})?$`, 54 'u' 55 ); 56 57 constructor(latDeg, latMin, latSec, latIsSouth, lonDeg, lonMin, lonSec, lonIsWest) { 58 super(); 59 Object.assign(this, {latDeg, latMin, latSec, latIsSouth, lonDeg, lonMin, lonSec, lonIsWest}); 60 } 61 62 equalTo(other) { 63 return ( 64 this.latDeg === other.latDeg && 65 this.latMin === other.latMin && 66 this.latSec === other.latSec && 67 this.latIsSouth === other.latIsSouth && 68 this.lonDeg === other.lonDeg && 69 this.lonMin === other.lonMin && 70 this.lonSec === other.lonSec && 71 this.lonIsWest === other.lonIsWest 72 ); 73 } 74 75 format() { 76 return { 77 latitude: `${this.getLatitudeLetter()} ${this.latDeg}°${this.latMin}′${this.latSec}″`, 78 longitude: `${this.getLongitudeLetter()} ${this.lonDeg}°${this.lonMin}′${this.lonSec}″`, 79 }; 80 } 81 82 isValid() { 83 return ( 84 this.latDeg >= 0 && 85 this.latDeg <= 90 && 86 this.latMin >= 0 && 87 this.latMin <= 59 && 88 this.latSec >= 0 && 89 this.latSec < 60 && 90 this.lonDeg >= 0 && 91 this.lonDeg <= 180 && 92 this.lonMin >= 0 && 93 this.lonMin <= 59 && 94 this.lonSec >= 0 && 95 this.lonSec < 60 && 96 (this.latDeg <= 89 || (this.latMin === 0 && this.latSec === 0)) && 97 (this.lonDeg <= 179 || (this.lonMin === 0 && this.lonSec === 0)) 98 ); 99 } 100 101 getLatLng() { 102 let lat = this.latDeg + this.latMin / 60 + this.latSec / 3600; 103 if (this.latIsSouth) { 104 lat = -lat; 105 } 106 let lon = this.lonDeg + this.lonMin / 60 + this.lonSec / 3600; 107 if (this.lonIsWest) { 108 lon = -lon; 109 } 110 return L.latLng(lat, lon); 111 } 112 113 static fromString(s) { 114 const m = s.match(CoordinatesDMS.regexp); 115 if (!m) { 116 return {error: true}; 117 } 118 const [h1, d1Str, m1Str, s1Str, h2, d2Str, m2Str, s2Str, h3] = m.slice(1); 119 const hemispheres = CoordinatesDMS.parseHemispheres(h1, h2, h3, true); 120 if (hemispheres.error) { 121 return {error: true}; 122 } 123 let [d1, m1, s1, d2, m2, s2] = [d1Str, m1Str, s1Str, d2Str, m2Str, s2Str].map(parseFloat); 124 const coords = []; 125 if (hemispheres.empty) { 126 const coord1 = new CoordinatesDMS(d1, m1, s1, false, d2, m2, s2, false); 127 const coord2 = new CoordinatesDMS(d2, m2, s2, false, d1, m1, s1, false); 128 if (coord1.isValid()) { 129 coords.push(coord1); 130 } 131 if (!coord1.equalTo(coord2) && coord2.isValid()) { 132 coords.push(coord2); 133 } 134 } else { 135 if (hemispheres.swapLatLon) { 136 [d1, m1, s1, d2, m2, s2] = [d2, m2, s2, d1, m1, s1]; 137 } 138 const coord = new CoordinatesDMS(d1, m1, s1, hemispheres.latIsSouth, d2, m2, s2, hemispheres.lonIsWest); 139 if (coord.isValid()) { 140 coords.push(coord); 141 } 142 } 143 if (coords.length > 0) { 144 return {coordinates: coords}; 145 } 146 return {error: true}; 147 } 148 } 149 150 class CoordinatesDM extends Coordinates { 151 static regexp = new RegExp( 152 `^(${reHemisphere} )?(${reInteger}) (${reFractional}) (${reHemisphere} )?(${reInteger}) (${reFractional})( ${reHemisphere})?$`, // eslint-disable-line max-len 153 'u' 154 ); 155 156 constructor(latDeg, latMin, latIsSouth, lonDeg, lonMin, lonIsWest) { 157 super(); 158 Object.assign(this, {latDeg, latMin, latIsSouth, lonDeg, lonMin, lonIsWest}); 159 } 160 161 equalTo(other) { 162 return ( 163 this.latDeg === other.latDeg && 164 this.latMin === other.latMin && 165 this.latIsSouth === other.latIsSouth && 166 this.lonDeg === other.lonDeg && 167 this.lonMin === other.lonMin && 168 this.lonIsWest === other.lonIsWest 169 ); 170 } 171 172 format() { 173 return { 174 latitude: `${this.getLatitudeLetter()} ${this.latDeg}°${this.latMin}′`, 175 longitude: `${this.getLongitudeLetter()} ${this.lonDeg}°${this.lonMin}′`, 176 }; 177 } 178 179 isValid() { 180 return ( 181 this.latDeg >= 0 && 182 this.latDeg <= 90 && 183 this.latMin >= 0 && 184 this.latMin < 60 && 185 this.lonDeg >= 0 && 186 this.lonDeg <= 180 && 187 this.lonMin >= 0 && 188 this.lonMin < 60 && 189 (this.latDeg <= 89 || this.latMin === 0) && 190 (this.lonDeg <= 179 || this.lonMin === 0) 191 ); 192 } 193 194 getLatLng() { 195 let lat = this.latDeg + this.latMin / 60; 196 if (this.latIsSouth) { 197 lat = -lat; 198 } 199 let lon = this.lonDeg + this.lonMin / 60; 200 if (this.lonIsWest) { 201 lon = -lon; 202 } 203 return L.latLng(lat, lon); 204 } 205 206 static fromString(s) { 207 const m = s.match(CoordinatesDM.regexp); 208 if (!m) { 209 return {error: true}; 210 } 211 const [h1, d1Str, m1Str, h2, d2Str, m2Str, h3] = m.slice(1); 212 const hemispheres = CoordinatesDM.parseHemispheres(h1, h2, h3, true); 213 if (hemispheres.error) { 214 return {error: true}; 215 } 216 let [d1, m1, d2, m2] = [d1Str, m1Str, d2Str, m2Str].map(parseFloat); 217 const coords = []; 218 if (hemispheres.empty) { 219 const coord1 = new CoordinatesDM(d1, m1, false, d2, m2, false); 220 const coord2 = new CoordinatesDM(d2, m2, false, d1, m1, false); 221 if (coord1.isValid()) { 222 coords.push(coord1); 223 } 224 if (!coord1.equalTo(coord2) && coord2.isValid()) { 225 coords.push(coord2); 226 } 227 } else { 228 if (hemispheres.swapLatLon) { 229 [d1, m1, d2, m2] = [d2, m2, d1, m1]; 230 } 231 const coord = new CoordinatesDM(d1, m1, hemispheres.latIsSouth, d2, m2, hemispheres.lonIsWest); 232 if (coord.isValid()) { 233 coords.push(coord); 234 } 235 } 236 if (coords.length > 0) { 237 return {coordinates: coords}; 238 } 239 return {error: true}; 240 } 241 } 242 243 class CoordinatesD extends Coordinates { 244 static regexp = new RegExp( 245 `^(${reHemisphere} )?(${reFractional}) (${reHemisphere} )?(${reFractional})( ${reHemisphere})?$`, 246 'u' 247 ); 248 249 constructor(latDeg, latIsSouth, lonDeg, lonIsWest) { 250 super(); 251 Object.assign(this, {latDeg, latIsSouth, lonDeg, lonIsWest}); 252 } 253 254 format() { 255 return { 256 latitude: `${this.getLatitudeLetter()} ${this.latDeg}°`, 257 longitude: `${this.getLongitudeLetter()} ${this.lonDeg}°`, 258 }; 259 } 260 261 equalTo(other) { 262 return ( 263 this.latDeg === other.latDeg && 264 this.latIsSouth === other.latIsSouth && 265 this.lonDeg === other.lonDeg && 266 this.lonIsWest === other.lonIsWest 267 ); 268 } 269 270 isValid() { 271 return this.latDeg >= 0 && this.latDeg <= 90 && this.lonDeg >= 0 && this.lonDeg <= 180; 272 } 273 274 getLatLng() { 275 let lat = this.latDeg; 276 if (this.latIsSouth) { 277 lat = -lat; 278 } 279 let lon = this.lonDeg; 280 if (this.lonIsWest) { 281 lon = -lon; 282 } 283 return L.latLng(lat, lon); 284 } 285 286 static fromString(s) { 287 const m = s.match(CoordinatesD.regexp); 288 if (!m) { 289 return {error: true}; 290 } 291 const [h1, d1Str, h2, d2Str, h3] = m.slice(1); 292 const hemispheres = CoordinatesD.parseHemispheres(h1, h2, h3); 293 if (hemispheres.error) { 294 return {error: true}; 295 } 296 let [d1, d2] = [d1Str, d2Str].map(parseFloat); 297 if (hemispheres.swapLatLon) { 298 [d1, d2] = [d2, d1]; 299 } 300 const coord = new CoordinatesD(d1, hemispheres.latIsSouth, d2, hemispheres.lonIsWest); 301 if (coord.isValid()) { 302 return { 303 coordinates: [coord], 304 }; 305 } 306 return {error: true}; 307 } 308 } 309 310 class CoordinatesDSigned extends Coordinates { 311 static regexp = new RegExp(`^(${reSignedFractional}) (${reSignedFractional})$`, 'u'); 312 313 constructor(latDegSigned, lonDegSigned) { 314 super(); 315 Object.assign(this, {latDegSigned, lonDegSigned}); 316 } 317 318 equalTo(other) { 319 return this.latDegSigned === other.latDegSigned && this.lonDegSigned === other.lonDegSigned; 320 } 321 322 isValid() { 323 return ( 324 this.latDegSigned >= -90 && this.latDegSigned <= 90 && this.lonDegSigned >= -180 && this.lonDegSigned <= 180 325 ); 326 } 327 328 format() { 329 return { 330 latitude: `${this.latDegSigned}°`, 331 longitude: `${this.lonDegSigned}°`, 332 }; 333 } 334 335 getLatLng() { 336 return L.latLng(this.latDegSigned, this.lonDegSigned); 337 } 338 339 static fromString(s) { 340 const m = s.match(CoordinatesDSigned.regexp); 341 if (!m) { 342 return {error: true}; 343 } 344 const coords = []; 345 const [d1, d2] = m.slice(1).map(parseFloat); 346 const coord1 = new CoordinatesDSigned(d1, d2); 347 if (coord1.isValid()) { 348 coords.push(coord1); 349 } 350 const coord2 = new CoordinatesDSigned(d2, d1); 351 if (!coord1.equalTo(coord2)) { 352 if (coord2.isValid()) { 353 coords.push(coord2); 354 } 355 } 356 if (coords.length === 0) { 357 return {error: true}; 358 } 359 return { 360 coordinates: coords, 361 }; 362 } 363 } 364 365 class CoordinatesProvider { 366 name = 'Coordinates'; 367 368 static regexps = { 369 // This regexp wag generated using script at https://gist.github.com/wladich/3d15edc8fcd8b735ac883ef60fe10bfe 370 // It matches all unicode characters except Lu (Uppercase Letter), Ll (Lowercase Letter) 371 // and [0123456789,.-]. It ignores unassigned code points (Cn) and characters that are removed after NFKC 372 // normalization. 373 // Manually added: "oO" (lat), "оО" (rus) 374 // eslint-disable-next-line max-len, no-control-regex, no-misleading-character-class, prettier/prettier 375 symbols: /[OoОо\u0000-\u002b\u002f\u003a-\u0040\u005b-\u0060\u007b-\u00bf\u00d7\u00f7\u01bb\u01c0-\u01cc\u0294\u02b9-\u036f\u0375\u03f6\u0482-\u0489\u0559-\u055f\u0589-\u109f\u10fb\u10fc\u1100-\u139f\u1400-\u1c7f\u1cc0-\u1cff\u1d2f-\u1d6a\u1dc0-\u1dff\u1f88-\u1f8f\u1f98-\u1f9f\u1fa8-\u1faf\u1fbc-\u1fc1\u1fcc-\u1fcf\u1ffc-\u2131\u213a-\u214d\u214f-\u2182\u2185-\u2bff\u2ce5-\u2cea\u2cef-\u2cf1\u2cf9-\u2cff\u2d30-\ua63f\ua66e-\ua67f\ua69e-\ua721\ua788-\ua78a\ua78f\ua7f7-\ua7f9\ua7fb-\uab2f\uab5b-\uab5f\uabc0-\uffff]/gu, 376 northernHemishphere: /[Nn]|[СсCc] *[Шш]?/gu, 377 southernHemishphere: /[Ss]|[Юю] *[Шш]?/gu, 378 westernHemishphere: /[Ww]|[Зз] *[Дд]?/gu, 379 easternHemishphere: /[EeЕе]|[ВвB] *[Дд]?/gu, // second Ее is cyrillic 380 }; 381 382 static parsers = [CoordinatesDMS, CoordinatesDM, CoordinatesD, CoordinatesDSigned]; 383 384 normalizeInput(inp) { 385 let s = inp.normalize('NFKC'); // convert subscripts and superscripts to normal chars 386 s = ' ' + s + ' '; 387 // replace everything that is not letter, number, minus, dot or comma to space 388 s = s.replace(CoordinatesProvider.regexps.symbols, ' '); 389 // remove all dots and commas if they are not between digits 390 s = s.replace(/[,.](?=\D)/gu, ' '); 391 s = s.replace(/(\D)[,.]/gu, '$1 '); // lookbehind is not supported in all browsers 392 // if dot is likely to be used as decimal separator, remove all commas 393 if (s.includes('.')) { 394 s = s.replace(/,/gu, ' '); 395 } else { 396 // otherwise comma is decimal separator 397 s = s.replace(/,/gu, '.'); 398 } 399 s = s.replace(/-(?=\D)/gu, ' '); // remove all minuses that are not in the beginning of number 400 s = s.replace(/([^ ])-/gu, '$1 '); // lookbehind is not supported in all browsers 401 402 s = s.replace(CoordinatesProvider.regexps.northernHemishphere, ' N '); 403 s = s.replace(CoordinatesProvider.regexps.southernHemishphere, ' S '); 404 s = s.replace(CoordinatesProvider.regexps.westernHemishphere, ' W '); 405 s = s.replace(CoordinatesProvider.regexps.easternHemishphere, ' E '); 406 407 s = s.replace(/ +/gu, ' '); // compress whitespaces 408 s = s.trim(); 409 return s; 410 } 411 412 isOurQuery(query) { 413 const coordFieldRe = new RegExp(`^((${reHemisphere})|(${reSignedFractional}))$`, 'u'); 414 const coordNumbersFieldRe = new RegExp(`^(${reSignedFractional})$`, 'u'); 415 const fields = this.normalizeInput(query).split(' '); 416 return ( 417 fields.length > 1 && 418 fields.every((field) => field.match(coordFieldRe)) && 419 fields.some((field) => field.match(coordNumbersFieldRe)) 420 ); 421 } 422 423 async search(query) { 424 const s = this.normalizeInput(query); 425 for (const parser of CoordinatesProvider.parsers) { 426 const result = parser.fromString(s); 427 if (!result.error) { 428 const resultItems = result.coordinates.map((it) => { 429 const coordStrings = it.format(); 430 return { 431 title: `${coordStrings.latitude} ${coordStrings.longitude}`, 432 latlng: it.getLatLng(), 433 zoom: 17, 434 category: 'Coordinates', 435 address: null, 436 icon: null, 437 }; 438 }); 439 return { 440 results: resultItems, 441 }; 442 } 443 } 444 return {error: 'Invalid coordinates'}; 445 } 446 } 447 448 export {CoordinatesProvider};