src/geokit@mercator.erl

-module(geokit@mercator).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/geokit/mercator.gleam").
-export([zoom/1, x/1, y/1, from_quadkey/1, new/3, tile/3, to_quadkey/1, to_lat_lng/1, bounds/1, from_lat_lng/2]).
-export_type([tile/0, mercator_error/0]).

-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.

?MODULEDOC(
    " Web Mercator (EPSG:3857) tile and quadkey conversion.\n"
    "\n"
    " Web Mercator is the projection used by every slippy map service:\n"
    " Google Maps, OpenStreetMap, Bing Maps, Mapbox. The world at zoom\n"
    " level `z` is divided into `2 ^ z` × `2 ^ z` square tiles indexed\n"
    " by `(x, y)` with `(0, 0)` at the top-left (north-west) corner.\n"
    "\n"
    " Tiles outside the supported latitude range (`±85.05112878°`,\n"
    " where the projection clips to keep the map square) are not\n"
    " representable; [`from_lat_lng`](#from_lat_lng) clamps latitude\n"
    " rather than reporting an error.\n"
    "\n"
    " Quadkeys are Bing Maps' single-string encoding of `(zoom, x, y)`.\n"
).

-opaque tile() :: {tile, integer(), integer(), integer()}.

-type mercator_error() :: {zoom_out_of_range, integer()} |
    {tile_coord_out_of_range, integer(), integer(), integer()} |
    {invalid_quadkey_char, binary(), integer()} |
    empty_quadkey.

-file("src/geokit/mercator.gleam", 73).
?DOC(" Zoom level of `tile`.\n").
-spec zoom(tile()) -> integer().
zoom(Tile) ->
    erlang:element(2, Tile).

-file("src/geokit/mercator.gleam", 78).
?DOC(" Tile `x` coordinate.\n").
-spec x(tile()) -> integer().
x(Tile) ->
    erlang:element(3, Tile).

-file("src/geokit/mercator.gleam", 83).
?DOC(" Tile `y` coordinate.\n").
-spec y(tile()) -> integer().
y(Tile) ->
    erlang:element(4, Tile).

-file("src/geokit/mercator.gleam", 187).
-spec from_quadkey_loop(list(binary()), integer(), integer(), integer()) -> {ok,
        {integer(), integer(), integer()}} |
    {error, mercator_error()}.
from_quadkey_loop(Chars, Position, X, Y) ->
    case Chars of
        [] ->
            {ok, {X, Y, Position}};

        [Head | Tail] ->
            Digit_result = case Head of
                <<"0"/utf8>> ->
                    {ok, {0, 0}};

                <<"1"/utf8>> ->
                    {ok, {1, 0}};

                <<"2"/utf8>> ->
                    {ok, {0, 1}};

                <<"3"/utf8>> ->
                    {ok, {1, 1}};

                _ ->
                    {error, {invalid_quadkey_char, Head, Position}}
            end,
            gleam@result:'try'(
                Digit_result,
                fun(_use0) ->
                    {Bit_x, Bit_y} = _use0,
                    from_quadkey_loop(
                        Tail,
                        Position + 1,
                        (X * 2) + Bit_x,
                        (Y * 2) + Bit_y
                    )
                end
            )
    end.

-file("src/geokit/mercator.gleam", 171).
?DOC(
    " Decode a quadkey to a [`Tile`](#Tile). The resulting `zoom` equals\n"
    " the length of the quadkey, which must be in `[1, 30]` to mirror\n"
    " the range accepted by [`new`](#new).\n"
).
-spec from_quadkey(binary()) -> {ok, tile()} | {error, mercator_error()}.
from_quadkey(Quadkey) ->
    gleam@bool:guard(
        Quadkey =:= <<""/utf8>>,
        {error, empty_quadkey},
        fun() ->
            Length = string:length(Quadkey),
            gleam@bool:guard(
                Length > 30,
                {error, {zoom_out_of_range, Length}},
                fun() ->
                    gleam@result:'try'(
                        from_quadkey_loop(
                            gleam@string:to_graphemes(Quadkey),
                            0,
                            0,
                            0
                        ),
                        fun(_use0) ->
                            {X_int, Y_int, Parsed_length} = _use0,
                            {ok, {tile, Parsed_length, X_int, Y_int}}
                        end
                    )
                end
            )
        end
    ).

-file("src/geokit/mercator.gleam", 216).
-spec pow_2(integer()) -> integer().
pow_2(Exponent) ->
    gleam@bool:guard(Exponent =< 0, 1, fun() -> 2 * pow_2(Exponent - 1) end).

-file("src/geokit/mercator.gleam", 52).
?DOC(
    " Build a [`Tile`](#Tile). `zoom` must be in `[0, 30]`; `x` and `y`\n"
    " must each be in `[0, 2^zoom)`.\n"
    "\n"
    " Matches the constructor naming convention used by every other\n"
    " opaque type in geokit (e.g. [`latlng.new`](../latlng.html#new)).\n"
).
-spec new(integer(), integer(), integer()) -> {ok, tile()} |
    {error, mercator_error()}.
new(Zoom, X, Y) ->
    gleam@bool:guard(
        (Zoom < 0) orelse (Zoom > 30),
        {error, {zoom_out_of_range, Zoom}},
        fun() ->
            Max_coord = pow_2(Zoom),
            gleam@bool:guard(
                (((X < 0) orelse (Y < 0)) orelse (X >= Max_coord)) orelse (Y >= Max_coord),
                {error, {tile_coord_out_of_range, Zoom, X, Y}},
                fun() -> {ok, {tile, Zoom, X, Y}} end
            )
        end
    ).

-file("src/geokit/mercator.gleam", 68).
?DOC(
    " Build a [`Tile`](#Tile). Alias for [`new`](#new) kept for backward\n"
    " compatibility; new code should call [`new`](#new) instead.\n"
).
-spec tile(integer(), integer(), integer()) -> {ok, tile()} |
    {error, mercator_error()}.
tile(Zoom, X, Y) ->
    new(Zoom, X, Y).

-file("src/geokit/mercator.gleam", 153).
-spec to_quadkey_loop(tile(), integer(), binary()) -> binary().
to_quadkey_loop(Tile, Level, Acc) ->
    gleam@bool:guard(
        Level =< 0,
        Acc,
        fun() ->
            Mask = pow_2(Level - 1),
            Bit_x = (case Mask of
                0 -> 0;
                Gleam@denominator -> erlang:element(3, Tile) div Gleam@denominator
            end) rem 2,
            Bit_y = (case Mask of
                0 -> 0;
                Gleam@denominator@1 -> erlang:element(4, Tile) div Gleam@denominator@1
            end) rem 2,
            Digit = Bit_x + (2 * Bit_y),
            Char = case Digit of
                0 ->
                    <<"0"/utf8>>;

                1 ->
                    <<"1"/utf8>>;

                2 ->
                    <<"2"/utf8>>;

                _ ->
                    <<"3"/utf8>>
            end,
            to_quadkey_loop(Tile, Level - 1, <<Acc/binary, Char/binary>>)
        end
    ).

-file("src/geokit/mercator.gleam", 149).
?DOC(
    " Encode `tile` as a Bing-style quadkey. The length of the result\n"
    " equals `zoom(tile)`.\n"
).
-spec to_quadkey(tile()) -> binary().
to_quadkey(Tile) ->
    to_quadkey_loop(Tile, erlang:element(2, Tile), <<""/utf8>>).

-file("src/geokit/mercator.gleam", 221).
-spec float_to_int(float()) -> integer().
float_to_int(Value) ->
    erlang:trunc(Value).

-file("src/geokit/mercator.gleam", 225).
-spec int_to_float(integer()) -> float().
int_to_float(Value) ->
    erlang:float(Value).

-file("src/geokit/mercator.gleam", 136).
-spec corner_lat_lng(integer(), integer(), integer()) -> geokit@latlng:lat_lng().
corner_lat_lng(Zoom, X, Y) ->
    N = int_to_float(pow_2(Zoom)),
    Lng = ((case N of
        +0.0 -> +0.0;
        -0.0 -> -0.0;
        Gleam@denominator -> int_to_float(X) / Gleam@denominator
    end) * 360.0) - 180.0,
    Lat_rad = gleam_community@maths:atan(
        gleam_community@maths:sinh(
            gleam_community@maths:pi() * (1.0 - (case N of
                +0.0 -> +0.0;
                -0.0 -> -0.0;
                Gleam@denominator@1 -> 2.0 * int_to_float(Y) / Gleam@denominator@1
            end))
        )
    ),
    Lat = gleam_community@maths:radians_to_degrees(Lat_rad),
    geokit@latlng:wrap(Lat, Lng).

-file("src/geokit/mercator.gleam", 122).
?DOC(
    " Top-left (north-west) corner of `tile` as a\n"
    " [`LatLng`](../latlng.html#LatLng).\n"
).
-spec to_lat_lng(tile()) -> geokit@latlng:lat_lng().
to_lat_lng(Tile) ->
    corner_lat_lng(
        erlang:element(2, Tile),
        erlang:element(3, Tile),
        erlang:element(4, Tile)
    ).

-file("src/geokit/mercator.gleam", 128).
?DOC(
    " The south-west and north-east corners of `tile`, returned as\n"
    " `#(sw, ne)`.\n"
).
-spec bounds(tile()) -> {geokit@latlng:lat_lng(), geokit@latlng:lat_lng()}.
bounds(Tile) ->
    Nw = corner_lat_lng(
        erlang:element(2, Tile),
        erlang:element(3, Tile),
        erlang:element(4, Tile)
    ),
    Se = corner_lat_lng(
        erlang:element(2, Tile),
        erlang:element(3, Tile) + 1,
        erlang:element(4, Tile) + 1
    ),
    Sw = geokit@latlng:wrap(geokit@latlng:lat(Se), geokit@latlng:lng(Nw)),
    Ne = geokit@latlng:wrap(geokit@latlng:lat(Nw), geokit@latlng:lng(Se)),
    {Sw, Ne}.

-file("src/geokit/mercator.gleam", 229).
-spec clamp_int(integer(), integer(), integer()) -> integer().
clamp_int(Value, Min, Max) ->
    gleam@bool:guard(
        Value < Min,
        Min,
        fun() -> gleam@bool:guard(Value > Max, Max, fun() -> Value end) end
    ).

-file("src/geokit/mercator.gleam", 95).
?DOC(
    " Convert a [`LatLng`](../latlng.html#LatLng) to the [`Tile`](#Tile)\n"
    " that contains it at the given zoom level.\n"
    "\n"
    " Latitude is clamped to `±85.05112878°` (the Web Mercator pole\n"
    " limit) before projection, so any input `LatLng` produces a valid\n"
    " tile.\n"
).
-spec from_lat_lng(geokit@latlng:lat_lng(), integer()) -> {ok, tile()} |
    {error, mercator_error()}.
from_lat_lng(Point, Zoom) ->
    gleam@bool:guard(
        (Zoom < 0) orelse (Zoom > 30),
        {error, {zoom_out_of_range, Zoom}},
        fun() ->
            Pole_limit = 85.05112878,
            Lat = gleam@float:clamp(
                geokit@latlng:lat(Point),
                +0.0 - Pole_limit,
                Pole_limit
            ),
            Lng = geokit@latlng:lng(Point),
            N = int_to_float(pow_2(Zoom)),
            Lat_rad = gleam_community@maths:degrees_to_radians(Lat),
            X_float = ((Lng + 180.0) / 360.0) * N,
            Y_float = ((1.0 - (case gleam_community@maths:pi() of
                +0.0 -> +0.0;
                -0.0 -> -0.0;
                Gleam@denominator -> gleam_community@maths:asinh(
                    gleam_community@maths:tan(Lat_rad)
                )
                / Gleam@denominator
            end)) / 2.0) * N,
            Max_coord = pow_2(Zoom),
            X_int = clamp_int(float_to_int(X_float), 0, Max_coord - 1),
            Y_int = clamp_int(float_to_int(Y_float), 0, Max_coord - 1),
            {ok, {tile, Zoom, X_int, Y_int}}
        end
    ).