geo/globe.ex
Globe
A globe of dotted continents with location markers and connection arcs between them.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.geo_globe animated />
</div>
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
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
/* 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); }