empowered.house ↗

geo/globe.ex

Globe

A globe of dotted continents with location markers and connection arcs between them.

Use it
# in the module that renders the template
use EHVisuals

<div class="h-96 rounded-xl border border-border">
  <.geo_globe animated />
</div>
default
turned to Europe and Africa
turned to the Americas · custom copy
hover · no glow
light surface
turned to Asia · fade out · still

Props

Prop Type Default What it does
caption string "Connected sites"
locations list [%{name: "Poznan", lat: 52.41, lon: 16.93}, %{name: "New York", ...}, ...] Up to eight `%{name, lat, lon}` in degrees. The first is home and is drawn in lime. A location on the far side of the globe is left out. Names take 12 characters.
links list [[0, 1], [0, 2], ...] Arcs between locations, as pairs of indexes into `locations`, for example `[[0, 1], [0, 2]]`.
lat integer 26 Latitude at the centre of the view, -60 to 60.
lon integer 16 Longitude at the centre of the view.
Frame props, shared by every composition
Prop Type Default What it does
animated boolean false Turns motion on. Without it the composition renders in its final, still state.
trigger "mount" | "in-view" | "in-view-repeat" "in-view" When the entrance plays: on page load, once when scrolled into view, or every time it comes back into view.
hover boolean false Enables the hover micro-interactions the composition supports.
glow boolean true The warm incandescent halo behind the composition.
fade_out boolean false Fades the bottom edge into the surface underneath.
theme "dark" | "light" "dark" `dark` for the site canvas, `light` for the cream reversed surface.
class string nil Extra classes for the frame.
rest global — Any other HTML attribute, set on the frame.

Source

Two files. Copy them into your app if you would rather own the code than depend on the library.

lib/eh_visuals/geo/globe.ex
globe.ex
defmodule EHVisuals.Geo.Globe do
  @moduledoc """
  A globe of dotted continents with location markers and arcs between them.
  """

  use EHVisuals.Composition,
    name: "geo/globe",
    title: "Globe",
    description:
      "A globe of dotted continents with location markers and connection arcs between them."

  alias EHVisuals.WorldDots

  @locations [
    %{name: "Poznan", lat: 52.41, lon: 16.93},
    %{name: "New York", lat: 40.71, lon: -74.01},
    %{name: "Lagos", lat: 6.52, lon: 3.38},
    %{name: "Dubai", lat: 25.2, lon: 55.27},
    %{name: "Mumbai", lat: 19.08, lon: 72.88}
  ]

  @links [[0, 1], [0, 2], [0, 3], [3, 4]]

  # The sphere in the viewBox, and the spacing of the dots in degrees.
  @cx 190
  @cy 190
  @radius 152
  @grid 3
  @rad :math.pi() / 180

  attr(:caption, :string, default: "Connected sites")

  attr(:locations, :list,
    default: @locations,
    doc:
      "Up to eight `%{name, lat, lon}` in degrees. The first is home and is drawn in lime. A location on the far side of the globe is left out. Names take 12 characters."
  )

  attr(:links, :list,
    default: @links,
    doc:
      "Arcs between locations, as pairs of indexes into `locations`, for example `[[0, 1], [0, 2]]`."
  )

  attr(:lat, :integer, default: 26, doc: "Latitude at the centre of the view, -60 to 60.")
  attr(:lon, :integer, default: 16, doc: "Longitude at the centre of the view.")

  attr(:label, :string,
    default: "A dotted globe with five locations and arcs connecting them to Poznan"
  )

  # A sphere has no plane to tilt: the isometric prop would only squash it.
  frame_attrs(isometric: false)

  @spec geo_globe(map()) :: Phoenix.LiveView.Rendered.t()
  def geo_globe(assigns) do
    view = {(assigns.lat |> max(-60) |> min(60)) * @rad, assigns.lon * @rad}
    {front, rim} = land(view)

    visible =
      for {location, index} <- assigns.locations |> Enum.take(8) |> Enum.with_index(),
          {x, y, z} = project(vector(location.lat, location.lon), view),
          z > 0.08 do
        %{index: index, name: String.upcase(location.name), x: x, y: y}
      end

    points = Enum.map(visible, &{&1.x, &1.y})
    spots = Enum.map(visible, &Map.put(&1, :tag, tag(&1, points)))

    {home, others} = Enum.split_with(spots, &(&1.index == 0))
    shown = MapSet.new(spots, & &1.index)

    by_index =
      assigns.locations |> Enum.take(8) |> Enum.with_index() |> Map.new(fn {l, i} -> {i, l} end)

    arcs =
      for {[from, to], index} <- Enum.with_index(assigns.links),
          from in shown and to in shown and from != to do
        %{index: index, d: arc(by_index[from], by_index[to], view)}
      end

    home = List.first(home)

    assigns =
      assigns
      |> assign(:cx, @cx)
      |> assign(:cy, @cy)
      |> assign(:radius, @radius)
      |> assign(:front, WorldDots.path(front))
      |> assign(:rim, WorldDots.path(rim))
      |> assign(:graticule, graticule(view))
      |> assign(:others, others)
      |> assign(:home, home && chip(home))
      |> assign(:arcs, arcs)
      |> assign(:count, count(length(spots)))

    ~H"""
    <.frame name="geo/globe" label={@label} {frame_opts(assigns)}>
      <svg viewBox="0 0 380 356">
        <g class="ehv-h-lift">
          <text class="ehv-mono ehv-a-fade" x="6" y="16" font-size="9">
            {String.upcase(@caption)}
          </text>
          <text class="ehv-mono ehv-t-4 ehv-a-fade" x="374" y="16" font-size="9" text-anchor="end">
            {String.upcase(@count)}
          </text>
          <circle class="ehv-card ehv-a-fade" cx={@cx} cy={@cy} r={@radius} />
          <path class="ehv-line-faint ehv-a-fade ehv-i-1" d={@graticule} />
          <path class="ehv-geo-globe__dots ehv-geo-globe__dots--rim ehv-a-fade ehv-i-4" d={@rim} />
          <path class="ehv-geo-globe__dots ehv-a-fade ehv-i-2" d={@front} />
          <path
            :for={arc <- @arcs}
            class={"ehv-geo-globe__arc ehv-round ehv-a-draw ehv-i-#{min(arc.index + 8, 20)}"}
            d={arc.d}
            pathLength="1"
          />
          <g :if={@animated} class="ehv-smil ehv-a-fade ehv-i-16">
            <circle :for={arc <- @arcs} class="ehv-fill-lime" r="2.5">
              <animateMotion
                dur="2.6s"
                begin={"#{arc.index * 0.5}s"}
                repeatCount="indefinite"
                path={arc.d}
              />
              <animate
                attributeName="opacity"
                values="0;1;1;0"
                keyTimes="0;0.15;0.8;1"
                dur="2.6s"
                begin={"#{arc.index * 0.5}s"}
                repeatCount="indefinite"
              />
            </circle>
          </g>
          <g :for={spot <- @others} class="ehv-geo-globe__spot">
            <g class={"ehv-a-pop ehv-i-#{min(spot.index + 9, 20)}"}>
              <circle class="ehv-fill-canvas" cx={spot.x} cy={spot.y} r="5.5" />
              <circle class="ehv-node ehv-geo-globe__node" cx={spot.x} cy={spot.y} r="3.2" />
            </g>
            <g class={"ehv-a-fade ehv-i-#{min(spot.index + 10, 21)}"}>
              <rect
                class="ehv-fill-canvas"
                x={spot.tag.x}
                y={spot.tag.y}
                width={spot.tag.w}
                height="14"
                rx="4"
              />
              <text
                class="ehv-mono ehv-t-2"
                x={spot.tag.x + spot.tag.w / 2}
                y={spot.tag.y + 10}
                font-size="8.5"
                text-anchor="middle"
              >
                {spot.name}
              </text>
            </g>
          </g>
          <g :if={@home}>
            <circle class="ehv-fill-lime ehv-a-ping ehv-i-12" cx={@home.x} cy={@home.y} r="5" />
            <g class="ehv-a-fade ehv-i-9">
              <line
                class="ehv-line-lime"
                x1={@home.x}
                x2={@home.x}
                y1={@home.y - 6}
                y2={@home.y - 14}
              />
              <rect
                class="ehv-card-lime"
                x={@home.chip_x}
                y={@home.y - 32}
                width={@home.chip_w}
                height="18"
                rx="6"
              />
              <text
                class="ehv-mono ehv-t-lime"
                x={@home.chip_x + @home.chip_w / 2}
                y={@home.y - 20}
                font-size="8.5"
                text-anchor="middle"
              >
                {@home.name}
              </text>
            </g>
            <g class="ehv-a-pop ehv-i-8">
              <circle class="ehv-fill-canvas" cx={@home.x} cy={@home.y} r="6.5" />
              <circle class="ehv-fill-lime" cx={@home.x} cy={@home.y} r="4.5" />
            </g>
          </g>
        </g>
      </svg>
    </.frame>
    """
  end

  # The land as dots on the near side of the sphere: `{front, rim}`. Each
  # parallel carries fewer samples towards the poles, so the dots stay evenly
  # spaced on the surface. The rim is drawn fainter.
  defp land(view) do
    dots =
      for step <- 0..div(180, @grid),
          lat = -90 + (step + 0.5) * @grid,
          count = max(round(360 * :math.cos(lat * @rad) / @grid), 1),
          index <- 0..(count - 1),
          lon = -180 + (index + 0.5) * 360 / count,
          WorldDots.land?(lat, lon),
          {x, y, z} = project(vector(lat, lon), view),
          z > 0.06 do
        {x, y, z}
      end

    {front, rim} = Enum.split_with(dots, fn {_x, _y, z} -> z > 0.42 end)
    {Enum.map(front, &flat/1), Enum.map(rim, &flat/1)}
  end

  defp flat({x, y, _z}), do: {x, y}

  # Parallels every 30 degrees and meridians every 30, where they face us.
  defp graticule(view) do
    parallels = for lat <- [-60, -30, 0, 30, 60], do: for(lon <- -180..180//4, do: {lat, lon})
    meridians = for lon <- -180..150//30, do: for(lat <- -84..84//4, do: {lat, lon})

    Enum.map_join(parallels ++ meridians, fn line ->
      line
      |> Enum.map(fn {lat, lon} -> project(vector(lat, lon), view) end)
      |> polyline(0.0)
    end)
  end

  # A great circle between two places, lifted off the surface in the middle.
  defp arc(from, to, view) do
    a = vector(from.lat, from.lon)
    b = vector(to.lat, to.lon)
    omega = :math.acos(dot(a, b) |> max(-1.0) |> min(1.0))
    lift = 0.1 + 0.32 * omega / :math.pi()

    0..32
    |> Enum.map(fn step ->
      t = step / 32
      scale = 1 + lift * :math.sin(:math.pi() * t)
      project(slerp(a, b, omega, t), view, scale)
    end)
    |> polyline(-9.0)
  end

  defp slerp(a, _b, omega, _t) when omega < 1.0e-6, do: a

  defp slerp({ax, ay, az}, {bx, by, bz}, omega, t) do
    wa = :math.sin((1 - t) * omega) / :math.sin(omega)
    wb = :math.sin(t * omega) / :math.sin(omega)
    {wa * ax + wb * bx, wa * ay + wb * by, wa * az + wb * bz}
  end

  # Joins projected points into a path, leaving out the ones behind `horizon`.
  defp polyline(points, horizon) do
    points
    |> Enum.chunk_by(fn {_x, _y, z} -> z > horizon end)
    |> Enum.filter(fn [{_x, _y, z} | _rest] = run -> z > horizon and length(run) > 1 end)
    |> Enum.map_join(fn [{x, y, _z} | rest] ->
      "M#{x} #{y}" <> Enum.map_join(rest, fn {px, py, _pz} -> "L#{px} #{py}" end)
    end)
  end

  # A place on the unit sphere: x to the east, y to the north, z out of 0, 0.
  defp vector(lat, lon) do
    {phi, lambda} = {lat * @rad, lon * @rad}
    {:math.cos(phi) * :math.sin(lambda), :math.sin(phi), :math.cos(phi) * :math.cos(lambda)}
  end

  defp dot({ax, ay, az}, {bx, by, bz}), do: ax * bx + ay * by + az * bz

  # Orthographic projection: turns the sphere so the view centre faces us and
  # returns the position in the viewBox with the depth, 1 nearest, as `z`.
  defp project({x, y, z}, {lat0, lon0}, scale \\ 1) do
    {sin_l, cos_l} = {:math.sin(lon0), :math.cos(lon0)}
    {sin_p, cos_p} = {:math.sin(lat0), :math.cos(lat0)}
    east = x * cos_l - z * sin_l
    depth = x * sin_l + z * cos_l
    north = y * cos_p - depth * sin_p
    near = y * sin_p + depth * cos_p

    {
      Float.round(@cx + @radius * scale * east, 1),
      Float.round(@cy - @radius * scale * north, 1),
      Float.round(near * 1.0, 3)
    }
  end

  # The name of a location: outwards from the middle of the globe, unless
  # another marker is in the way, then on the other side, below or above.
  defp tag(spot, points) do
    w = Float.round(6 + String.length(spot.name) * 5.8, 1)

    sides =
      if spot.x < @cx, do: [:left, :right, :below, :above], else: [:right, :left, :below, :above]

    others = List.delete(points, {spot.x, spot.y})

    boxes =
      for side <- sides do
        {x, y} =
          case side do
            :right -> {spot.x + 8, spot.y - 7}
            :left -> {spot.x - 8 - w, spot.y - 7}
            :below -> {spot.x - w / 2, spot.y + 8}
            :above -> {spot.x - w / 2, spot.y - 22}
          end

        %{x: x |> max(2) |> min(378 - w) |> Float.round(1), y: Float.round(y * 1.0, 1), w: w}
      end

    Enum.find(boxes, List.first(boxes), fn box ->
      not Enum.any?(others, fn {px, py} ->
        px > box.x - 6 and px < box.x + box.w + 6 and py > box.y - 6 and py < box.y + 20
      end)
    end)
  end

  defp chip(home) do
    chip_w = Float.round(16 + String.length(home.name) * 5.8, 1)
    chip_x = (home.x - chip_w / 2) |> max(2) |> min(378 - chip_w) |> Float.round(1)
    Map.merge(home, %{chip_w: chip_w, chip_x: chip_x})
  end

  defp count(1), do: "1 site"
  defp count(n), do: "#{n} sites"
end
assets/css/geo/globe.css
globe.css
/* geo/globe */
.ehv-geo-globe { --ehv-max-w: 420px; }

/* One path of zero-length strokes: the round caps are the dots. */
.ehv-geo-globe__dots {
  fill: none;
  stroke: var(--ehv-ink-3);
  stroke-width: 3;
  stroke-linecap: round;
  opacity: 0.8;
}
.ehv-geo-globe__dots--rim { stroke: var(--ehv-ink-4); stroke-width: 2.4; }

.ehv-geo-globe__arc { fill: none; stroke: var(--ehv-ink-2); stroke-width: 1.25; }

.ehv--hover .ehv-geo-globe__node { transition: stroke 200ms var(--ehv-ease); }
.ehv--hover .ehv-geo-globe__spot:hover .ehv-geo-globe__node { stroke: var(--ehv-lime); }