lib/kino/maplibre.ex

defmodule Kino.MapLibre do
  @moduledoc """
  This Kino allows rendering a regular MapLibre map and then adds an initial support for the
  [Evented](https://maplibre.org/maplibre-gl-js-docs/api/events/#evented) API to update the map
  with event capabilities.

  There are two types of maps: static and dynamic. Essentially, a dynamic map can be updated on
  the fly without having to be re-evaluated. To make a map dynamic you need to wrap it in `Kino.MapLibre.new/1`

  All functions are available for both map types.

  ## Examples

      map =
        Ml.new(center: {-68.13734351262877, 45.137451890638886}, zoom: 3)
        # This makes the map dynamic
        |> Kino.MapLibre.new()

      # These markers will be added with no need to re-evaluate the map
      Kino.MapLibre.add_marker(map, {-68, 45}, color: "red", draggable: true)
      Kino.MapLibre.add_marker(map, {-69, 50})

      # This is a static map and the markers will be added on evaluation
      Ml.new(center: {-68.13734351262877, 45.137451890638886}, zoom: 3)
      |> Kino.MapLibre.add_marker({-68, 45}, color: "red", draggable: true)
      |> Kino.MapLibre.add_marker({-69, 50})
  """

  use Kino.JS, assets_path: "lib/assets/maplibre"
  use Kino.JS.Live

  defstruct spec: %{}, events: %{}

  @type t :: Kino.JS.Live.t()

  @type location :: {number(), number()}

  @type maplibre :: t() | MapLibre.t() | Kino.JS.Live.t()

  @doc """
  Creates a new kino with the given MapLibre style.
  """
  @spec new(MapLibre.t()) :: t()
  def new(%MapLibre{} = ml) do
    ml = %{spec: ml.spec, events: %{}}
    Kino.JS.Live.new(__MODULE__, ml)
  end

  def new(%__MODULE__{} = ml) do
    Kino.JS.Live.new(__MODULE__, ml)
  end

  @doc false
  def static(%__MODULE__{} = ml) do
    data = %{spec: ml.spec, events: ml.events}
    Kino.JS.new(__MODULE__, data, export_info_string: "maplibre")
  end

  def static(%MapLibre{} = ml) do
    data = %{spec: ml.spec, events: %{}}
    Kino.JS.new(__MODULE__, data, export_info_string: "maplibre")
  end

  @doc """
  Adds a marker to the map at the given location

  ## Options

    * `:element` - DOM element to use as a marker. The default is a light blue, droplet-shaped SVG
      marker.

    * `:anchor` - A string indicating the part of the Marker that should be positioned closest to
      the coordinate set via Marker#setLngLat. Options are  "center",  "top", "bottom",  "left",
      "right",  "top-left",  "top-right",  "bottom-left" and  "bottom-right". Default: "center"

    * `:offset` - The offset in pixels as a
      [PointLike](https://maplibre.org/maplibre-gl-js-docs/api/geography/#pointlike) object to
      apply relative to the element"s center. Negatives indicate left and up.

    * `:color` - The color to use for the default marker if `:element` is not provided. The
      default is light blue. Default: "#3FB1CE"

    * `:scale` - The scale to use for the default marker if `:element` is not provided. The
      default scale corresponds to a height of 41px and a width of 27px. Default: 1

    * `:draggable` - A boolean indicating whether or not a marker is able to be dragged to a new
      position on the map. Default: `false`

    * `:click_tolerance` - The max number of pixels a user can shift the mouse pointer during a
      click on the marker for it to be considered a valid click (as opposed to a marker drag). The
      default is to inherit map"s `:click_tolerance`. Default: 0

    * `:rotation` - The rotation angle of the marker in degrees, relative to its respective
      `:rotation_alignment` setting. A positive value will rotate the marker clockwise. Default: 0

    * `:pitch_alignment` - "map" aligns the marker to the plane of the map. "viewport" aligns the
      marker to the plane of the viewport. "auto" automatically matches the value of
      `:rotation_alignment`. Default: "auto"

    * `:rotation_alignment` - "map" aligns the  marker"s rotation relative to the map, maintaining
      a bearing as the map rotates. "viewport" aligns the  marker"s rotation relative to the
      viewport, agnostic to map rotations. "auto" is equivalent to viewport. Default: "auto"

    See [the docs](https://maplibre.org/maplibre-gl-js-docs/api/markers/#marker) for more details.
  """
  @spec add_marker(maplibre(), location(), keyword()) ::
          :ok | %__MODULE__{}
  def add_marker(map, location, opts \\ []) do
    marker = %{location: normalize_location(location), options: normalize_opts(opts)}
    update_events(map, :markers, marker)
  end

  @doc """
  Receives a list of markers and adds them to the map

  ## Examples

      markers = [
        [{0, 0}, color: "red", draggable: true],
        [{-32, 2}, color: "green"],
        [{-45, 23}]
      ]

      Ml.new(center: {-68.13734351262877, 45.137451890638886}, zoom: 3)
      |> Kino.MapLibre.add_markers(markers)
  """
  @spec add_markers(maplibre(), list()) :: :ok | %__MODULE__{}
  def add_markers(map, []), do: map

  def add_markers(map, markers) do
    markers =
      Enum.map(markers, fn [location | opts] ->
        %{location: normalize_location(location), options: normalize_opts(opts)}
      end)

    update_events(map, :markers, markers)
  end

  @doc """
  Adds a navigation control to the map. A navigation control contains zoom buttons and a compass.

  ## Options

    * `:show_compass` - If true the compass button is included. Default: `true`

    * `:show_zoom` - If true the zoom-in and zoom-out buttons are included. Default: `true`

    * `:visualize_pitch` - If true the pitch is visualized by rotating X-axis of compass. Default:
      `false`

    * `:position` - The position on the map to which the control will be added. Valid values are
      "top-left" , "top-right" ,  "bottom-left" , and  "bottom-right" . Defaults to  "top-right".
      Default: "top-right"

    You can add multiple controls separately to have granular options over positioning and
    appearance

  ## Examples

        Kino.MapLibre.add_nav_controls(map, show_compass: false)
        Kino.MapLibre.add_nav_controls(map, show_zoom: false, position: "top-left")
  """
  @spec add_nav_controls(maplibre(), keyword()) :: :ok | %__MODULE__{}
  def add_nav_controls(map, opts \\ []) do
    position = Keyword.get(opts, :position, "top-right")
    control = %{position: position, options: normalize_opts(opts)}
    update_events(map, :controls, control)
  end

  @doc """
  A helper function to allow inspect a cluster on click. Receives the ID of the clusters layer
  ## Examples

        Kino.MapLibre.clusters_expansion(map, "earthquakes-clusters")
  """
  @spec clusters_expansion(maplibre(), String.t()) :: :ok | %__MODULE__{}
  def clusters_expansion(map, clusters_id) do
    update_events(map, :clusters, clusters_id)
  end

  @doc """
  A helper function to create a per feature hover effect. Receives the ID of the layer where the
  effect should be enabled. It uses events and feature states to create the effect.

  ## Examples

        Kino.MapLibre.add_hover(map, "state-fills")

  See [the docs](https://maplibre.org/maplibre-gl-js-docs/api/map/#map#setfeaturestate) for more
  details.
  """
  @spec add_hover(maplibre(), String.t()) :: :ok | %__MODULE__{}
  def add_hover(map, layer_id) do
    update_events(map, :hover, layer_id)
  end

  @doc """
  A helper function that adds the event of centering to coordinates when clicking on a symbol.
  Receives the ID of the symbols layer and adds the event to all the symbols present in that layer
  """
  @spec center_on_click(maplibre(), String.t()) :: :ok | %__MODULE__{}
  def center_on_click(map, symbols_id) do
    update_events(map, :center, symbols_id)
  end

  @doc """
  A helper function that adds the event to show the information of a given property on click.
  Receives the layer ID and the name of the property to show.
  """
  @spec info_on_click(maplibre(), String.t(), String.t()) :: :ok | %__MODULE__{}
  def info_on_click(map, layer_id, property) do
    info = %{layer: layer_id, property: property}
    update_events(map, :info, info)
  end

  @doc """
  Adds an image to the style. This image can be displayed on the map like any other icon in the
  style's sprite using its ID
  """
  @spec add_custom_image(maplibre(), String.t(), String.t()) ::
          :ok | %__MODULE__{}
  def add_custom_image(map, image_name, image_url, opts \\ []) do
    image = %{name: image_name, url: image_url, options: normalize_opts(opts)}
    update_events(map, :images, image)
  end

  @doc """
  Jumps to a given location using an animated transition
  """
  @spec jump_to(t(), location(), keyword()) :: :ok
  def jump_to(map, location, opts \\ []) do
    jump = %{location: location, options: normalize_opts(opts)}
    update_events(map, :jumps, jump)
  end

  @doc """
  Fits the map to the rectangle given by the 2 vertices in `bounds`
  """
  def fit_bounds(map, bounds, opts \\ []) do
    fit_bounds = %{bounds: bounds, options: normalize_opts(opts)}
    update_events(map, :fit_bounds, fit_bounds)
  end

  @impl true
  def init(ml, ctx) do
    {:ok, assign(ctx, spec: ml.spec, events: ml.events)}
  end

  @impl true
  def handle_connect(ctx) do
    data = %{spec: ctx.assigns.spec, events: ctx.assigns.events}
    {:ok, data, ctx}
  end

  @impl true
  def handle_cast({:markers, markers}, ctx) when is_list(markers) do
    broadcast_event(ctx, "add_markers", markers)
    ctx = update_assigned_events(ctx, :markers, markers)
    {:noreply, ctx}
  end

  def handle_cast({:markers, marker}, ctx) do
    broadcast_event(ctx, "add_marker", marker)
    ctx = update_assigned_events(ctx, :markers, marker)
    {:noreply, ctx}
  end

  def handle_cast({:clusters, clusters}, ctx) do
    broadcast_event(ctx, "clusters_expansion", clusters)
    ctx = update_assigned_events(ctx, :clusters, clusters)
    {:noreply, ctx}
  end

  def handle_cast({:controls, control}, ctx) do
    broadcast_event(ctx, "add_nav_controls", control)
    ctx = update_assigned_events(ctx, :controls, control)
    {:noreply, ctx}
  end

  def handle_cast({:hover, layer}, ctx) do
    broadcast_event(ctx, "add_hover", layer)
    ctx = update_assigned_events(ctx, :hover, layer)
    {:noreply, ctx}
  end

  def handle_cast({:center, symbols}, ctx) do
    broadcast_event(ctx, "center_on_click", symbols)
    ctx = update_assigned_events(ctx, :center, symbols)
    {:noreply, ctx}
  end

  def handle_cast({:info, info}, ctx) do
    broadcast_event(ctx, "info_on_click", info)
    ctx = update_assigned_events(ctx, :info, info)
    {:noreply, ctx}
  end

  def handle_cast({:images, image}, ctx) do
    broadcast_event(ctx, "add_custom_image", image)
    ctx = update_assigned_events(ctx, :images, image)
    {:noreply, ctx}
  end

  def handle_cast({:jumps, jump}, ctx) do
    broadcast_event(ctx, "jump_to", jump)
    ctx = update_assigned_events(ctx, :jumps, jump)
    {:noreply, ctx}
  end

  def handle_cast({:fit_bounds, bounds}, ctx) do
    broadcast_event(ctx, "fit_bounds", bounds)
    ctx = update_assigned_events(ctx, :fit_bounds, bounds)
    {:noreply, ctx}
  end

  defp update_events(%MapLibre{} = ml, key, value) do
    update_events(%__MODULE__{spec: ml.spec}, key, value)
  end

  defp update_events(%__MODULE__{} = ml, key, value) do
    update_in(ml.events, fn events ->
      Map.update(events, key, List.flatten([value]), &List.flatten([value | &1]))
    end)
  end

  defp update_events(kino, key, value) do
    Kino.JS.Live.cast(kino, {key, value})
  end

  defp update_assigned_events(ctx, key, value) do
    update_in(ctx.assigns.events, fn events ->
      Map.update(events, key, List.flatten([value]), &List.flatten([value | &1]))
    end)
  end

  defp normalize_location({lng, lat}), do: [lng, lat]

  defp normalize_opts(opts) do
    Map.new(opts, fn {key, value} ->
      {snake_to_camel(key), value}
    end)
  end

  defp snake_to_camel(atom) do
    string = Atom.to_string(atom)
    [part | parts] = String.split(string, "_")
    Enum.join([String.downcase(part, :ascii) | Enum.map(parts, &String.capitalize(&1, :ascii))])
  end
end