@@ -146,6 +146,68 @@ export function fmtLatLon(lat, lng) {
146146 return dm ( lat , 2 ) + ( lat >= 0 ? "N" : "S" ) + " " + dm ( x , 3 ) + ( x >= 0 ? "E" : "W" ) ;
147147}
148148
149+ // parseLatLon — the lenient inverse of fmtLatLon: turn a typed coordinate into
150+ // { lat, lng }, or null if it doesn't look like one. Forgiving about notation so
151+ // users can paste whatever their other kit prints:
152+ // • decimal degrees "-32.4943, 60.931" "32.4943 S 60.931 E"
153+ // • degrees-decimal-minutes "32°29.66'S, 060°55.86'E" "39°27.6′N 104°39.6′W"
154+ // • degrees-minutes-seconds "32 29 40 S 60 55 52 E"
155+ // °/′/″ marks optional (plain ' and " accepted), hemisphere by N/S/E/W (either
156+ // case) or a leading −; lat,lon separated by comma/semicolon or whitespace. The
157+ // first value is latitude. Returns null on anything out of range or ambiguous.
158+ export function parseLatLon ( input ) {
159+ if ( typeof input !== "string" ) return null ;
160+ // Normalise degree/minute/second marks to spaces; keep digits, signs, NSEW, separators.
161+ const s = input . trim ( )
162+ . replace ( / [ ° º ∘ ] / g, " " )
163+ . replace ( / [ ’ ′ ʹ ` ' ] / g, " " )
164+ . replace ( / [ ” ″ " ] / g, " " )
165+ . replace ( / \s + / g, " " )
166+ . trim ( ) ;
167+ if ( ! s ) return null ;
168+
169+ // Candidate splits into [latToken, lonToken], tried in order of confidence.
170+ const tries = [ ] ;
171+ const csv = s . split ( / \s * [ , ; ] \s * / ) ;
172+ if ( csv . length === 2 ) tries . push ( csv ) ; // explicit separator
173+ const hemi = s . match ( / ^ ( .* ?[ N S n s ] ) \s + ( .* ) $ / ) ; // split after the latitude hemisphere
174+ if ( hemi && / [ E W e w ] / . test ( hemi [ 2 ] ) ) tries . push ( [ hemi [ 1 ] , hemi [ 2 ] ] ) ;
175+ if ( ! / [ N S E W n s e w ] / . test ( s ) ) { // no letters: split the numbers down the middle
176+ const nums = s . match ( / [ + - ] ? \d + (?: \. \d + ) ? / g) || [ ] ;
177+ if ( nums . length >= 2 && nums . length % 2 === 0 ) {
178+ const h = nums . length / 2 ;
179+ tries . push ( [ nums . slice ( 0 , h ) . join ( " " ) , nums . slice ( h ) . join ( " " ) ] ) ;
180+ }
181+ }
182+
183+ for ( const [ a , b ] of tries ) {
184+ const lat = parseCoordComponent ( a , "NS" ) ;
185+ const lng = parseCoordComponent ( b , "EW" ) ;
186+ if ( lat != null && lng != null && lat >= - 90 && lat <= 90 && lng >= - 180 && lng <= 180 ) {
187+ return { lat, lng } ;
188+ }
189+ }
190+ return null ;
191+ }
192+
193+ // Parse one coordinate value (already mark-normalised to spaces) into a signed
194+ // decimal degree. `axis` is "NS" (latitude) or "EW" (longitude); a hemisphere
195+ // letter for the wrong axis rejects. Numbers are taken in order as deg[, min[,
196+ // sec]] so decimal/DMM/DMS all fall out of the same path.
197+ function parseCoordComponent ( token , axis ) {
198+ if ( ! token ) return null ;
199+ const hemiMatch = token . match ( / [ N S E W n s e w ] / ) ;
200+ const hemi = hemiMatch ? hemiMatch [ 0 ] . toUpperCase ( ) : null ;
201+ if ( hemi && ! axis . includes ( hemi ) ) return null ; // e.g. an E in the latitude slot
202+ const nums = token . match ( / [ + - ] ? \d + (?: \. \d + ) ? / g) ;
203+ if ( ! nums || nums . length === 0 || nums . length > 3 ) return null ;
204+ let val = Math . abs ( parseFloat ( nums [ 0 ] ) ) ;
205+ if ( nums . length >= 2 ) val += Math . abs ( parseFloat ( nums [ 1 ] ) ) / 60 ;
206+ if ( nums . length >= 3 ) val += Math . abs ( parseFloat ( nums [ 2 ] ) ) / 3600 ;
207+ const negative = hemi === "S" || hemi === "W" || / ^ - / . test ( nums [ 0 ] ) ;
208+ return negative ? - val : val ;
209+ }
210+
149211// True when the page was opened as a snapshot share link (<origin>/#share or
150212// ?share) — boot() then reconstructs the publisher's scene from /api/share.
151213export function isShareUrl ( ) {
0 commit comments