geo/world-map.ex
World map
A dotted world map with the home city in lime and a few other locations pinging.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.geo_world_map animated />
</div>
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
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
/* 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); }