Packages

GridSquare calculator for encoding/decoding between latitude/longitude and Maidenhead Locator System grid references

Current section

5 Versions

Jump to

Compare versions

3 files changed
+128 additions
-136 deletions
  @@ -2,7 +2,7 @@
2 2 [{<<"GitHub">>,<<"https://github.com/gmcintire/gridsquare">>},
3 3 {<<"HexDocs">>,<<"https://hexdocs.pm/gridsquare">>}]}.
4 4 {<<"name">>,<<"gridsquare">>}.
5 - {<<"version">>,<<"0.2.0">>}.
5 + {<<"version">>,<<"0.3.0">>}.
6 6 {<<"description">>,
7 7 <<"GridSquare calculator for encoding/decoding between latitude/longitude and Maidenhead Locator System grid references">>}.
8 8 {<<"elixir">>,<<"~> 1.16">>}.
  @@ -124,10 +124,11 @@ defmodule Gridsquare do
124 124 %Gridsquare.EncodeResult{grid_reference: "DN40bi", subsquare: "dn40bi"}
125 125
126 126 iex> Gridsquare.encode(-111.866785, 40.363840, 10)
127 - %Gridsquare.EncodeResult{grid_reference: "DN40BI00OR", subsquare: "dn40bi"}
127 + %Gridsquare.EncodeResult{grid_reference: "DN40BI57XH", subsquare: "dn40bi"}
128 128 """
129 129 @spec encode(longitude(), latitude(), precision()) :: encode_result()
130 - def encode(lon, lat, precision \\ 6) when precision >= 6 and precision <= 20 do
130 + def encode(lon, lat, precision \\ 6)
131 + when precision >= 6 and precision <= 20 and rem(precision, 2) == 0 do
131 132 {normalized_lon, normalized_lat} = normalize_coordinates(lon, lat)
132 133 {field_lon, field_lat} = calculate_fields(normalized_lon, normalized_lat)
133 134
  @@ -207,15 +208,23 @@ defmodule Gridsquare do
207 208 subsquare_lon = from_base24(String.at(grid_reference, 4))
208 209 subsquare_lat = from_base24(String.at(grid_reference, 5))
209 210
210 - # Calculate center coordinates (Ruby gem logic)
211 - lon = -180 + field_lon * 20 + square_lon * 2 + subsquare_lon * (2 / 24) + 2 / 24 / 2
212 - lat = -90 + field_lat * 10 + square_lat + subsquare_lat * (1 / 24) + 1 / 24 / 2
211 + # Calculate base offset (without centering)
212 + base_lon = -180 + field_lon * 20 + square_lon * 2 + subsquare_lon * (2 / 24)
213 + base_lat = -90 + field_lat * 10 + square_lat + subsquare_lat * (1 / 24)
213 214
214 - # Calculate dimensions
215 - # 5 minutes
216 - width = 2 / 24
217 - # 2.5 minutes
218 - height = 1 / 24
215 + # Starting divisors at subsquare level
216 + base_lon_div = 2 / 24
217 + base_lat_div = 1 / 24
218 +
219 + # Parse extended precision pairs if present
220 + {lon, lat, width, height} =
221 + decode_extended_pairs(
222 + grid_reference,
223 + base_lon,
224 + base_lat,
225 + base_lon_div,
226 + base_lat_div
227 + )
219 228
220 229 %DecodeResult{latitude: lat, longitude: lon, width: width, height: height}
221 230 end
  @@ -284,17 +293,22 @@ defmodule Gridsquare do
284 293 """
285 294 @spec distance_between(grid_reference(), grid_reference()) :: distance_result()
286 295 def distance_between(grid_ref1, grid_ref2) when is_binary(grid_ref1) and is_binary(grid_ref2) do
296 + # Use full-precision centers for distance calculation
287 297 grid1 = new(grid_ref1)
288 298 grid2 = new(grid_ref2)
289 299
290 300 c1 = grid1.center
291 301 c2 = grid2.center
292 - w_deg = grid1.width
293 - h_deg = grid1.height
294 302
295 - # Calculate deltas in longitude and latitude (in grid units)
296 - lon_delta = abs(c1.longitude - c2.longitude)
297 - lat_delta = abs(c1.latitude - c2.latitude)
303 + # Normalize to 6-char subsquare level for adjacency detection
304 + sub1 = new(String.slice(grid_ref1, 0, 6))
305 + sub2 = new(String.slice(grid_ref2, 0, 6))
306 + w_deg = sub1.width
307 + h_deg = sub1.height
308 +
309 + # Calculate deltas using subsquare-level centers for adjacency detection
310 + lon_delta = abs(sub1.center.longitude - sub2.center.longitude)
311 + lat_delta = abs(sub1.center.latitude - sub2.center.latitude)
298 312
299 313 calculate_distance_result(c1, c2, lon_delta, lat_delta, w_deg, h_deg)
300 314 end
  @@ -423,32 +437,7 @@ defmodule Gridsquare do
423 437 end
424 438
425 439 @spec from_base18(base18_char()) :: field_index()
426 - defp from_base18(char)
427 - when char in [
428 - "A",
429 - "B",
430 - "C",
431 - "D",
432 - "E",
433 - "F",
434 - "G",
435 - "H",
436 - "I",
437 - "J",
438 - "K",
439 - "L",
440 - "M",
441 - "N",
442 - "O",
443 - "P",
444 - "Q",
445 - "R"
446 - ] do
447 - Enum.find_index(
448 - ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R"],
449 - &(&1 == char)
450 - )
451 - end
440 + defp from_base18(<<c>>) when c in ?A..?R, do: c - ?A
452 441
453 442 @spec to_base24(subsquare_index()) :: base24_char()
454 443 defp to_base24(n) when n >= 0 and n < 24 do
  @@ -456,63 +445,7 @@ defmodule Gridsquare do
456 445 end
457 446
458 447 @spec from_base24(base24_char()) :: subsquare_index()
459 - defp from_base24(char)
460 - when char in [
461 - "A",
462 - "B",
463 - "C",
464 - "D",
465 - "E",
466 - "F",
467 - "G",
468 - "H",
469 - "I",
470 - "J",
471 - "K",
472 - "L",
473 - "M",
474 - "N",
475 - "O",
476 - "P",
477 - "Q",
478 - "R",
479 - "S",
480 - "T",
481 - "U",
482 - "V",
483 - "W",
484 - "X"
485 - ] do
486 - Enum.find_index(
487 - [
488 - "A",
489 - "B",
490 - "C",
491 - "D",
492 - "E",
493 - "F",
494 - "G",
495 - "H",
496 - "I",
497 - "J",
498 - "K",
499 - "L",
500 - "M",
501 - "N",
502 - "O",
503 - "P",
504 - "Q",
505 - "R",
506 - "S",
507 - "T",
508 - "U",
509 - "V",
510 - "W",
511 - "X"
512 - ],
513 - &(&1 == char)
514 - )
515 - end
448 + defp from_base24(<<c>>) when c in ?A..?X, do: c - ?A
516 449
517 450 @spec add_extended_precision(coordinates(), precision()) :: grid_reference()
518 451 defp add_extended_precision(
  @@ -538,41 +471,99 @@ defmodule Gridsquare do
538 471 current_lon = lon - (-180 + field_lon * 20 + square_lon * 2 + subsquare_lon * (2 / 24))
539 472 current_lat = lat - (-90 + field_lat * 10 + square_lat + subsquare_lat * (1 / 24))
540 473
541 - {extended, _} =
542 - Enum.reduce(1..remaining_pairs, {base_reference, {current_lon, current_lat}}, fn i,
543 - {acc,
544 - {curr_lon,
545 - curr_lat}} ->
546 - if rem(i, 2) == 1 do
547 - # Base 10 pair
548 - lon_divisor = 2 / (24 * :math.pow(10, div(i - 1, 2)))
549 - lat_divisor = 1 / (24 * :math.pow(10, div(i - 1, 2)))
474 + # Start with subsquare divisors and chain through each pair
475 + init_lon_div = 2 / 24
476 + init_lat_div = 1 / 24
550 477
551 - extended_lon = trunc(curr_lon / lon_divisor)
552 - extended_lat = trunc(curr_lat / lat_divisor)
478 + {extended, _, _} =
479 + Enum.reduce(
480 + 1..remaining_pairs,
481 + {base_reference, {current_lon, current_lat}, {init_lon_div, init_lat_div}},
482 + fn i, {acc, {curr_lon, curr_lat}, {prev_lon_div, prev_lat_div}} ->
483 + if rem(i, 2) == 1 do
484 + # Base 10 pair - subdivide previous level by 10
485 + lon_divisor = prev_lon_div / 10
486 + lat_divisor = prev_lat_div / 10
553 487
554 - new_lon = curr_lon - extended_lon * lon_divisor
555 - new_lat = curr_lat - extended_lat * lat_divisor
488 + extended_lon = min(trunc(curr_lon / lon_divisor), 9)
489 + extended_lat = min(trunc(curr_lat / lat_divisor), 9)
556 490
557 - {acc <> "#{extended_lon}#{extended_lat}", {new_lon, new_lat}}
558 - else
559 - # Base 24 pair
560 - lon_divisor = 2 / (24 * :math.pow(10, div(i - 1, 2)) * 24)
561 - lat_divisor = 1 / (24 * :math.pow(10, div(i - 1, 2)) * 24)
491 + new_lon = curr_lon - extended_lon * lon_divisor
492 + new_lat = curr_lat - extended_lat * lat_divisor
562 493
563 - extended_lon = trunc(curr_lon / lon_divisor)
564 - extended_lat = trunc(curr_lat / lat_divisor)
494 + {acc <> "#{extended_lon}#{extended_lat}", {new_lon, new_lat},
495 + {lon_divisor, lat_divisor}}
496 + else
497 + # Base 24 pair - subdivide previous level by 24
498 + lon_divisor = prev_lon_div / 24
499 + lat_divisor = prev_lat_div / 24
565 500
566 - new_lon = curr_lon - extended_lon * lon_divisor
567 - new_lat = curr_lat - extended_lat * lat_divisor
501 + extended_lon = min(trunc(curr_lon / lon_divisor), 23)
502 + extended_lat = min(trunc(curr_lat / lat_divisor), 23)
568 503
569 - {acc <> "#{to_base24(extended_lon)}#{to_base24(extended_lat)}", {new_lon, new_lat}}
504 + new_lon = curr_lon - extended_lon * lon_divisor
505 + new_lat = curr_lat - extended_lat * lat_divisor
506 +
507 + {acc <> "#{to_base24(extended_lon)}#{to_base24(extended_lat)}", {new_lon, new_lat},
508 + {lon_divisor, lat_divisor}}
509 + end
570 510 end
571 - end)
511 + )
572 512
573 513 extended
574 514 end
575 515
516 + @spec decode_extended_pairs(String.t(), float(), float(), float(), float()) ::
517 + {float(), float(), float(), float()}
518 + defp decode_extended_pairs(grid_reference, base_lon, base_lat, lon_div, lat_div) do
519 + len = String.length(grid_reference)
520 +
521 + if len <= 6 do
522 + # No extended pairs - center within subsquare
523 + {base_lon + lon_div / 2, base_lat + lat_div / 2, lon_div, lat_div}
524 + else
525 + remaining = String.slice(grid_reference, 6, len - 6)
526 +
527 + pairs =
528 + for i <- 0..(div(String.length(remaining), 2) - 1),
529 + do: String.slice(remaining, i * 2, 2)
530 +
531 + {final_lon, final_lat, final_lon_div, final_lat_div} =
532 + Enum.reduce(
533 + Enum.with_index(pairs, 1),
534 + {base_lon, base_lat, lon_div, lat_div},
535 + &decode_pair/2
536 + )
537 +
538 + # Center within the final grid cell
539 + {final_lon + final_lon_div / 2, final_lat + final_lat_div / 2, final_lon_div,
540 + final_lat_div}
541 + end
542 + end
543 +
544 + defp decode_pair({pair, i}, {curr_lon, curr_lat, prev_lon_div, prev_lat_div})
545 + when rem(i, 2) == 1 do
546 + # Base 10 pair
547 + curr_lon_div = prev_lon_div / 10
548 + curr_lat_div = prev_lat_div / 10
549 + val_lon = String.to_integer(String.at(pair, 0))
550 + val_lat = String.to_integer(String.at(pair, 1))
551 +
552 + {curr_lon + val_lon * curr_lon_div, curr_lat + val_lat * curr_lat_div, curr_lon_div,
553 + curr_lat_div}
554 + end
555 +
556 + defp decode_pair({pair, _i}, {curr_lon, curr_lat, prev_lon_div, prev_lat_div}) do
557 + # Base 24 pair
558 + curr_lon_div = prev_lon_div / 24
559 + curr_lat_div = prev_lat_div / 24
560 + val_lon = from_base24(String.at(pair, 0))
561 + val_lat = from_base24(String.at(pair, 1))
562 +
563 + {curr_lon + val_lon * curr_lon_div, curr_lat + val_lat * curr_lat_div, curr_lon_div,
564 + curr_lat_div}
565 + end
566 +
576 567 @spec format_grid_reference(grid_reference()) :: grid_reference()
577 568 defp format_grid_reference(extended_reference) do
578 569 if String.length(extended_reference) == 6 do
  @@ -586,23 +577,24 @@ defmodule Gridsquare do
586 577 @spec normalize_coordinates(longitude(), latitude()) ::
587 578 {normalized_longitude(), normalized_latitude()}
588 579 defp normalize_coordinates(lon, lat) do
589 - # Normalize longitude to -180 to 180 range
590 - cond_result =
591 - cond do
592 - lon < -180 -> lon + 360
593 - lon > 180 -> lon - 360
594 - true -> lon
595 - end
580 + # Normalize longitude to -180 to 180 range using modular arithmetic
581 + temp = :math.fmod(lon + 180, 360)
582 + temp = if temp < 0, do: temp + 360, else: temp
583 + normalized_lon = temp - 180
596 584
585 + # fmod maps both 180.0 and -180.0 to -180.0; preserve the convention
586 + # that positive 180.0 input maps to 179.999999 (eastern edge)
597 587 normalized_lon =
598 - then(cond_result, fn l ->
599 - if l == 180, do: 179.999999, else: l
600 - end)
588 + cond do
589 + normalized_lon == -180.0 and lon > 0 -> 179.999999
590 + normalized_lon == 180.0 -> 179.999999
591 + true -> normalized_lon
592 + end
601 593
602 594 # Clamp latitude to just below 90
603 595 normalized_lat =
604 596 cond do
605 - lat < -90 -> -90
597 + lat < -90 -> -90.0
606 598 lat >= 90 -> 89.999999
607 599 true -> lat
608 600 end
  @@ -1,7 +1,7 @@
1 1 defmodule Gridsquare.MixProject do
2 2 use Mix.Project
3 3
4 - @version "0.2.0"
4 + @version "0.3.0"
5 5 @source_url "https://github.com/gmcintire/gridsquare"
6 6
7 7 def project do
  @@ -42,7 +42,7 @@ defmodule Gridsquare.MixProject do
42 42 # Run "mix help deps" to learn about dependencies.
43 43 defp deps do
44 44 [
45 - {:styler, "~> 1.4.2", only: [:dev, :test], runtime: false},
45 + {:styler, "~> 1.10", only: [:dev, :test], runtime: false},
46 46 {:mix_test_watch, "~> 1.1", only: [:dev, :test]},
47 47 {:dialyxir, "~> 1.0", only: :dev, runtime: false},
48 48 {:credo, "~> 1.7", only: [:dev, :test], runtime: false},