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.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.geo_edge_network animated />
</div>
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
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
/* 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); }