empowered.house ↗

geo/world-map.ex

World map

A dotted world map with the home city in lime and a few other locations pinging.

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

<div class="h-96 rounded-xl border border-border">
  <.geo_world_map animated />
</div>
default
isometric
another home · custom copy
hover · no names · no glow
light surface
fade out · still

Props

Prop Type Default What it does
caption string "Where the work runs"
home map %{name: "Poznan", lat: 52.41, lon: 16.93} The highlighted city as `%{name, lat, lon}`, in degrees. The name takes 14 characters.
locations list [%{name: "New York", lat: 40.71, lon: -74.01}, %{name: "Sao Paulo", ...}, ...] Up to eight `%{name, lat, lon}`. Names take 12 characters.
summary any nil The line on the right of the header. Left as `nil`, it counts the locations.
names boolean true Shows the name next to every location.
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.
isometric boolean false Tilts the composition into the isometric plane of the platform stack.
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/world-map.ex
world-map.ex
defmodule EHVisuals.Geo.WorldMap do
  @moduledoc """
  A dotted world map with the home city in lime and other locations pinging.
  """

  use EHVisuals.Composition,
    name: "geo/world-map",
    title: "World map",
    description:
      "A dotted world map with the home city in lime and a few other locations pinging."

  alias EHVisuals.WorldDots

  @home %{name: "Poznan", lat: 52.41, lon: 16.93}

  @locations [
    %{name: "New York", lat: 40.71, lon: -74.01},
    %{name: "Sao Paulo", lat: -23.55, lon: -46.63},
    %{name: "Dubai", lat: 25.2, lon: 55.27},
    %{name: "Singapore", lat: 1.35, lon: 103.82},
    %{name: "Sydney", lat: -33.87, lon: 151.21}
  ]

  # The map: 72 by 28 cells of 7.4, inside a 560 wide viewBox.
  @pitch 7.4
  @map_x 13.6
  @map_y 36

  attr(:caption, :string, default: "Where the work runs")

  attr(:home, :map,
    default: @home,
    doc: "The highlighted city as `%{name, lat, lon}`, in degrees. The name takes 14 characters."
  )

  attr(:locations, :list,
    default: @locations,
    doc: "Up to eight `%{name, lat, lon}`. Names take 12 characters."
  )

  attr(:summary, :any,
    default: nil,
    doc: "The line on the right of the header. Left as `nil`, it counts the locations."
  )

  attr(:names, :boolean, default: true, doc: "Shows the name next to every location.")

  attr(:label, :string,
    default: "A dotted world map with Poznan highlighted and five other locations"
  )

  frame_attrs()

  @spec geo_world_map(map()) :: Phoenix.LiveView.Rendered.t()
  def geo_world_map(assigns) do
    {home_x, home_y} = spot(assigns.home)
    home_name = String.upcase(assigns.home.name)
    chip_w = Float.round(16 + String.length(home_name) * 6.1, 1)

    spots =
      for {location, index} <- assigns.locations |> Enum.take(8) |> Enum.with_index() do
        {x, y} = spot(location)
        name = String.upcase(location.name)
        text_w = String.length(name) * 6.1

        # The name sits on the right, unless it would leave the map or run into home.
        left =
          x + 12 + text_w > 556 or
            (abs(y - home_y) < 12 and home_x > x and home_x - x < text_w + 26)

        %{
          index: index,
          name: name,
          x: x,
          y: y,
          left: left,
          text_x: if(left, do: x - 10, else: x + 10),
          back_x: Float.round(if(left, do: x - 13 - text_w, else: x + 7), 1),
          back_w: Float.round(6 + text_w, 1)
        }
      end

    assigns =
      assigns
      |> assign(:bands, bands())
      |> assign(:spots, spots)
      |> assign(:home_x, home_x)
      |> assign(:home_y, home_y)
      |> assign(:home_name, home_name)
      |> assign(:chip_w, chip_w)
      |> assign(:chip_x, chip_x(home_x, chip_w))
      |> assign(:line, assigns.summary || count(length(spots)))

    ~H"""
    <.frame name="geo/world-map" label={@label} {frame_opts(assigns)}>
      <svg viewBox="0 0 560 254">
        <g class="ehv-h-lift">
          <text class="ehv-mono ehv-a-fade" x="14" y="18" font-size="9.5">
            {String.upcase(@caption)}
          </text>
          <text class="ehv-mono ehv-t-4 ehv-a-fade" x="546" y="18" font-size="9.5" text-anchor="end">
            {String.upcase(@line)}
          </text>
          <path
            :for={{d, index} <- @bands}
            class={"ehv-geo-world-map__dots ehv-a-fade ehv-i-#{index}"}
            d={d}
          />
          <g :for={spot <- @spots} class="ehv-geo-world-map__spot">
            <circle
              class={"ehv-fill-ink ehv-a-ping ehv-i-#{min(spot.index * 5, 24)}"}
              cx={spot.x}
              cy={spot.y}
              r="4"
            />
            <g class={"ehv-a-pop ehv-i-#{spot.index + 9}"}>
              <circle class="ehv-geo-world-map__ring" cx={spot.x} cy={spot.y} r="7.5" />
              <circle class="ehv-node" cx={spot.x} cy={spot.y} r="3.2" />
            </g>
            <g :if={@names} class={"ehv-a-fade ehv-i-#{spot.index + 10}"}>
              <rect
                class="ehv-fill-canvas"
                x={spot.back_x}
                y={spot.y - 7}
                width={spot.back_w}
                height="14"
                rx="4"
              />
              <text
                class="ehv-mono ehv-t-2"
                x={spot.text_x}
                y={spot.y + 3.2}
                font-size="9"
                text-anchor={if spot.left, do: "end", else: "start"}
              >
                {spot.name}
              </text>
            </g>
          </g>
          <circle class="ehv-fill-lime ehv-a-ping ehv-i-16" cx={@home_x} cy={@home_y} r="5" />
          <g class="ehv-a-fade ehv-i-15">
            <line class="ehv-line-lime" x1={@home_x} x2={@home_x} y1={@home_y - 6} y2={@home_y - 15} />
            <rect
              class="ehv-card-lime"
              x={@chip_x}
              y={@home_y - 34}
              width={@chip_w}
              height="19"
              rx="6"
            />
            <text
              class="ehv-mono ehv-t-lime"
              x={@chip_x + @chip_w / 2}
              y={@home_y - 21.2}
              font-size="9"
              text-anchor="middle"
            >
              {@home_name}
            </text>
          </g>
          <g class="ehv-a-pop ehv-i-14">
            <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>
      </svg>
    </.frame>
    """
  end

  defp spot(%{lat: lat, lon: lon}), do: WorldDots.spot(lat, lon, @map_x, @map_y, @pitch)

  # The land in twelve bands of six columns, so the map appears west to east.
  defp bands do
    @map_x
    |> WorldDots.dots(@map_y, @pitch)
    |> Enum.group_by(fn {x, _y} -> min(trunc((x - @map_x) / (@pitch * 6)), 11) end)
    |> Enum.sort()
    |> Enum.map(fn {band, points} -> {WorldDots.path(points), band} end)
  end

  defp chip_x(home_x, chip_w) do
    (home_x - chip_w / 2) |> max(4) |> min(556 - chip_w) |> Float.round(1)
  end

  defp count(1), do: "1 location"
  defp count(n), do: "#{n} locations"
end
assets/css/geo/world-map.css
world-map.css
/* geo/world-map */
.ehv-geo-world-map { --ehv-max-w: 640px; --ehv-iso-scale: 0.82; }

/* One path of zero-length strokes: the round caps are the dots. */
.ehv-geo-world-map__dots {
  fill: none;
  stroke: var(--ehv-ink-3);
  stroke-width: 3.4;
  opacity: 0.62;
  stroke-linecap: round;
}

.ehv-geo-world-map__ring { fill: none; stroke: var(--ehv-border-strong); stroke-width: 1; }

.ehv--hover .ehv-geo-world-map__ring { transition: stroke 200ms var(--ehv-ease); }
.ehv--hover .ehv-geo-world-map__spot:hover .ehv-geo-world-map__ring { stroke: var(--ehv-lime); }