empowered.house ↗

geo/edge-network.ex

Edge network

A dotted world map where a request hops from a visitor to the nearest edge node, with the latency in a label.

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

<div class="h-96 rounded-xl border border-border">
  <.geo_edge_network animated />
</div>
default
isometric
another visitor · set latency
hover · no comparison · no glow
light surface · four nodes
fade out · still

Props

Prop Type Default What it does
caption string "Edge network"
visitor map %{name: "Nairobi", lat: -1.29, lon: 36.82} Where the request starts, as `%{name, lat, lon}` in degrees. The name takes 12 characters.
edges list [%{name: "SFO", lat: 37.62, lon: -122.38}, %{name: "IAD", ...}, ...] Up to sixteen edge nodes as `%{name, lat, lon}`. Names are short codes. The nearest one to the visitor takes the request.
latency any nil The latency of the hop, for example `"31 ms"`. Left as `nil`, it is estimated from the distance.
runners integer 2 How many of the next nearest nodes are shown for comparison, 0 to 3.
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/edge-network.ex
edge-network.ex
defmodule EHVisuals.Geo.EdgeNetwork do
  @moduledoc """
  A dotted world map of edge nodes, with one request hopping from a visitor
  to the nearest node and the latency of that hop.
  """

  use EHVisuals.Composition,
    name: "geo/edge-network",
    title: "Edge network",
    description:
      "A dotted world map where a request hops from a visitor to the nearest edge node, with the latency in a label."

  alias EHVisuals.WorldDots

  @visitor %{name: "Nairobi", lat: -1.29, lon: 36.82}

  @edges [
    %{name: "SFO", lat: 37.62, lon: -122.38},
    %{name: "IAD", lat: 38.95, lon: -77.46},
    %{name: "GRU", lat: -23.43, lon: -46.47},
    %{name: "LHR", lat: 51.47, lon: -0.45},
    %{name: "FRA", lat: 50.04, lon: 8.56},
    %{name: "JNB", lat: -26.13, lon: 28.24},
    %{name: "DXB", lat: 25.25, lon: 55.36},
    %{name: "BOM", lat: 19.09, lon: 72.87},
    %{name: "SIN", lat: 1.36, lon: 103.99},
    %{name: "NRT", lat: 35.77, lon: 140.39},
    %{name: "SYD", lat: -33.94, lon: 151.18}
  ]

  # 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: "Edge network")

  attr(:visitor, :map,
    default: @visitor,
    doc:
      "Where the request starts, as `%{name, lat, lon}` in degrees. The name takes 12 characters."
  )

  attr(:edges, :list,
    default: @edges,
    doc:
      "Up to sixteen edge nodes as `%{name, lat, lon}`. Names are short codes. The nearest one to the visitor takes the request."
  )

  attr(:latency, :any,
    default: nil,
    doc:
      "The latency of the hop, for example `\"31 ms\"`. Left as `nil`, it is estimated from the distance."
  )

  attr(:runners, :integer,
    default: 2,
    doc: "How many of the next nearest nodes are shown for comparison, 0 to 3."
  )

  attr(:label, :string,
    default: "A world map of edge nodes with a request going from Nairobi to the nearest node"
  )

  frame_attrs()

  @spec geo_edge_network(map()) :: Phoenix.LiveView.Rendered.t()
  def geo_edge_network(assigns) do
    {vx, vy} = spot(assigns.visitor)

    ranked =
      assigns.edges
      |> Enum.take(16)
      |> Enum.map(fn edge ->
        {x, y} = spot(edge)
        km = distance(assigns.visitor, edge)
        %{name: String.upcase(edge.name), x: x, y: y, km: km, ms: "#{round(km / 100) + 2} ms"}
      end)
      |> Enum.sort_by(& &1.km)

    {nearest, rest} = List.pop_at(ranked, 0)
    runners = assigns.runners |> max(0) |> min(3)
    {compared, others} = Enum.split(rest, runners)
    points = [{vx, vy} | Enum.map(ranked, &{&1.x, &1.y})]

    # Labels are placed in order of importance. Each one avoids the markers
    # and the labels placed before it.
    {chip, boxes} =
      if nearest do
        text = String.upcase("#{nearest.name} · #{assigns.latency || nearest.ms}")
        away = if nearest.x <= vx, do: :left, else: :right
        size = {Float.round(16 + String.length(text) * 6.1, 1), 19}
        box = place({nearest.x, nearest.y}, size, 11, [away, :below, :above], points, [])
        {Map.put(box, :text, text), [box]}
      else
        {nil, []}
      end

    origin_text = String.upcase("Visitor · #{assigns.visitor.name}")
    origin_side = if nearest && nearest.x > vx, do: :left, else: :right
    origin_size = {Float.round(6 + String.length(origin_text) * 6.1, 1), 14}

    origin =
      {vx, vy}
      |> place(origin_size, 7, [origin_side, :above, :below, :right, :left], points, boxes)
      |> Map.put(:text, origin_text)

    {compared, _boxes} =
      compared
      |> Enum.with_index()
      |> Enum.map_reduce([origin | boxes], fn {edge, index}, placed ->
        text = String.upcase("#{edge.name} #{edge.ms}")
        size = {Float.round(6 + String.length(text) * 6.1, 1), 14}
        box = place({edge.x, edge.y}, size, 7, [:right, :left, :below, :above], points, placed)
        {Map.merge(edge, %{index: index, tag: Map.put(box, :text, text)}), [box | placed]}
      end)

    assigns =
      assigns
      |> assign(:bands, bands())
      |> assign(:vx, vx)
      |> assign(:vy, vy)
      |> assign(
        :nearest,
        nearest && Map.put(nearest, :route, route(vx, vy, nearest.x, nearest.y))
      )
      |> assign(:chip, chip)
      |> assign(:compared, compared)
      |> assign(:others, Enum.with_index(others))
      |> assign(:origin, origin)
      |> assign(:count, count(length(ranked)))

    ~H"""
    <.frame name="geo/edge-network" 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(@count)}
          </text>
          <path
            :for={{d, index} <- @bands}
            class={"ehv-geo-edge-network__dots ehv-a-fade ehv-i-#{index}"}
            d={d}
          />
          <g :for={{edge, index} <- @others} class={"ehv-a-pop ehv-i-#{min(index + 8, 18)}"}>
            <rect
              class="ehv-geo-edge-network__edge"
              x={edge.x - 3.5}
              y={edge.y - 3.5}
              width="7"
              height="7"
              rx="2"
            />
          </g>
          <g :for={edge <- @compared}>
            <line
              class={"ehv-geo-edge-network__spoke ehv-a-fade ehv-i-#{edge.index + 14}"}
              x1={@vx}
              y1={@vy}
              x2={edge.x}
              y2={edge.y}
            />
            <g class={"ehv-a-pop ehv-i-#{edge.index + 13}"}>
              <rect
                class="ehv-geo-edge-network__edge"
                x={edge.x - 3.5}
                y={edge.y - 3.5}
                width="7"
                height="7"
                rx="2"
              />
            </g>
            <g class={"ehv-a-fade ehv-i-#{edge.index + 15}"}>
              <rect
                class="ehv-fill-canvas"
                x={edge.tag.x}
                y={edge.tag.y}
                width={edge.tag.w}
                height={edge.tag.h}
                rx="4"
              />
              <text
                class="ehv-mono"
                x={edge.tag.x + edge.tag.w / 2}
                y={edge.tag.y + 10.2}
                font-size="9"
                text-anchor="middle"
              >
                {edge.tag.text}
              </text>
            </g>
          </g>
          <g :if={@nearest}>
            <path
              class="ehv-geo-edge-network__route ehv-round ehv-a-draw ehv-i-17"
              d={@nearest.route}
              pathLength="1"
            />
            <g :if={@animated} class="ehv-smil ehv-a-fade ehv-i-22">
              <circle class="ehv-fill-lime" r="3">
                <animateMotion dur="1.8s" repeatCount="indefinite" path={@nearest.route} />
                <animate
                  attributeName="opacity"
                  values="0;1;1;0"
                  keyTimes="0;0.15;0.8;1"
                  dur="1.8s"
                  repeatCount="indefinite"
                />
              </circle>
            </g>
            <rect
              class="ehv-fill-lime ehv-a-ping ehv-i-20"
              x={@nearest.x - 4.5}
              y={@nearest.y - 4.5}
              width="9"
              height="9"
              rx="3"
            />
            <g class="ehv-a-pop ehv-i-19">
              <rect
                class="ehv-fill-canvas"
                x={@nearest.x - 6.5}
                y={@nearest.y - 6.5}
                width="13"
                height="13"
                rx="4"
              />
              <rect
                class="ehv-fill-lime"
                x={@nearest.x - 4.5}
                y={@nearest.y - 4.5}
                width="9"
                height="9"
                rx="3"
              />
            </g>
            <g class="ehv-a-fade ehv-i-20">
              <rect
                class="ehv-card-lime"
                x={@chip.x}
                y={@chip.y}
                width={@chip.w}
                height={@chip.h}
                rx="6"
              />
              <text
                class="ehv-mono ehv-t-lime"
                x={@chip.x + @chip.w / 2}
                y={@chip.y + 12.8}
                font-size="9"
                text-anchor="middle"
              >
                {@chip.text}
              </text>
            </g>
          </g>
          <circle class="ehv-fill-ink ehv-a-ping ehv-i-12" cx={@vx} cy={@vy} r="4" />
          <g class="ehv-a-pop ehv-i-11">
            <circle class="ehv-fill-canvas" cx={@vx} cy={@vy} r="6" />
            <circle class="ehv-node" cx={@vx} cy={@vy} r="3.4" />
          </g>
          <g class="ehv-a-fade ehv-i-12">
            <rect
              class="ehv-fill-canvas"
              x={@origin.x}
              y={@origin.y}
              width={@origin.w}
              height={@origin.h}
              rx="4"
            />
            <text
              class="ehv-mono ehv-t-2"
              x={@origin.x + @origin.w / 2}
              y={@origin.y + 10.2}
              font-size="9"
              text-anchor="middle"
            >
              {@origin.text}
            </text>
          </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

  # A quadratic curve that bows upwards between the two points.
  defp route(x1, y1, x2, y2) do
    {dx, dy} = {x2 - x1, y2 - y1}
    {nx, ny} = if dx >= 0, do: {dy, -dx}, else: {-dy, dx}
    cx = Float.round((x1 + x2) / 2 + nx * 0.28, 1)
    cy = Float.round((y1 + y2) / 2 + ny * 0.28, 1)

    "M#{x1} #{y1}Q#{cx} #{cy} #{x2} #{y2}"
  end

  # Puts a label of `{w, h}` next to a marker, on the first of `sides` where it
  # stays on the map, covers no other marker and meets no label placed earlier.
  defp place({x, y} = marker, {w, h}, gap, sides, points, placed) do
    others = List.delete(points, marker)

    boxes =
      for side <- sides do
        {bx, by} =
          case side do
            :right -> {x + gap, y - h / 2}
            :left -> {x - gap - w, y - h / 2}
            :below -> {(x - w / 2) |> max(4) |> min(556 - w), y + gap}
            :above -> {(x - w / 2) |> max(4) |> min(556 - w), y - gap - h}
          end

        %{x: Float.round(bx * 1.0, 1), y: Float.round(by * 1.0, 1), w: w, h: h}
      end

    inside =
      Enum.filter(
        boxes,
        &(&1.x >= 4 and &1.x + &1.w <= 556 and &1.y >= 26 and &1.y + &1.h <= 250)
      )

    Enum.find(inside, fn box ->
      not Enum.any?(others, &covers?(box, &1)) and not Enum.any?(placed, &overlap?(box, &1))
    end) || List.first(inside) || List.first(boxes)
  end

  defp covers?(box, {px, py}) do
    px > box.x - 5 and px < box.x + box.w + 5 and py > box.y - 5 and py < box.y + box.h + 5
  end

  defp overlap?(a, b) do
    a.x < b.x + b.w + 2 and b.x < a.x + a.w + 2 and a.y < b.y + b.h + 2 and b.y < a.y + a.h + 2
  end

  # Great-circle distance in kilometres.
  defp distance(a, b) do
    rad = :math.pi() / 180
    {lat1, lat2} = {a.lat * rad, b.lat * rad}
    dlat = lat2 - lat1
    dlon = (b.lon - a.lon) * rad

    h =
      :math.pow(:math.sin(dlat / 2), 2) +
        :math.cos(lat1) * :math.cos(lat2) * :math.pow(:math.sin(dlon / 2), 2)

    2 * 6371 * :math.asin(:math.sqrt(h))
  end

  defp count(1), do: "1 edge node"
  defp count(n), do: "#{n} edge nodes"
end
assets/css/geo/edge-network.css
edge-network.css
/* geo/edge-network */
.ehv-geo-edge-network { --ehv-max-w: 640px; --ehv-iso-scale: 0.82; }

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

.ehv-geo-edge-network__edge { fill: var(--ehv-canvas); stroke: var(--ehv-ink-2); stroke-width: 1.25; }
.ehv-geo-edge-network__spoke { fill: none; stroke: var(--ehv-ink-3); stroke-width: 1; stroke-dasharray: 2 3; }
.ehv-geo-edge-network__route { fill: none; stroke: var(--ehv-lime); stroke-width: 1.75; }

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