geo/pin-drop.ex
Pin drop
Location pins dropping onto a radar field with concentric range rings, one of them picked out in lime.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.geo_pin_drop animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
caption |
string | "Deliveries near the depot" |
|
pins |
list | [%{name: "Northwind", distance: 8, bearing: 40}, %{name: "Acme Tools", ...}, ...] |
Up to six `%{name, distance, bearing}`. Distance is in `unit`, bearing in degrees clockwise from north. A pin beyond `range` is drawn dimmed outside the last ring. Names take 14 characters. |
active |
integer | 0 |
Index of the pin in lime. A negative index marks none. |
range |
integer | 30 |
The distance of the outer ring. |
unit |
string | "km" |
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/pin-drop.ex
defmodule EHVisuals.Geo.PinDrop do
@moduledoc """
Location pins dropped onto a radar field with range rings around a centre.
"""
use EHVisuals.Composition,
name: "geo/pin-drop",
title: "Pin drop",
description:
"Location pins dropping onto a radar field with concentric range rings, one of them picked out in lime."
@pins [
%{name: "Northwind", distance: 8, bearing: 40},
%{name: "Acme Tools", distance: 17, bearing: 300},
%{name: "Globex", distance: 24, bearing: 165},
%{name: "Initech", distance: 36, bearing: 75}
]
# The radar: centre and the radius of the outer ring.
@cx 134
@cy 176
@radius 96
@rad :math.pi() / 180
attr(:caption, :string, default: "Deliveries near the depot")
attr(:pins, :list,
default: @pins,
doc:
"Up to six `%{name, distance, bearing}`. Distance is in `unit`, bearing in degrees clockwise from north. A pin beyond `range` is drawn dimmed outside the last ring. Names take 14 characters."
)
attr(:active, :integer,
default: 0,
doc: "Index of the pin in lime. A negative index marks none."
)
attr(:range, :integer, default: 30, doc: "The distance of the outer ring.")
attr(:unit, :string, default: "km")
attr(:label, :string,
default:
"Four numbered location pins on a radar field, three of them within thirty kilometres"
)
frame_attrs()
@spec geo_pin_drop(map()) :: Phoenix.LiveView.Rendered.t()
def geo_pin_drop(assigns) do
range = max(assigns.range, 1)
pins =
for {pin, index} <- assigns.pins |> Enum.take(6) |> Enum.with_index() do
far = pin.distance > range
r = if far, do: @radius * 1.12, else: pin.distance / range * @radius
x = Float.round(@cx + r * :math.sin(pin.bearing * @rad), 1)
y = Float.round(@cy - r * :math.cos(pin.bearing * @rad), 1)
%{
index: index,
number: Integer.to_string(index + 1),
name: pin.name,
detail: String.upcase("#{pin.distance} #{assigns.unit} · #{compass(pin.bearing)}"),
far: far,
active: index == assigns.active,
x: x,
y: y,
row_y: 46 + index * 36,
d: "M#{x} #{y}c-1.5 -5.5 -9 -10 -9 -18.5a9 9 0 1 1 18 0c0 8.5 -7.5 13 -9 18.5z"
}
end
rings =
for step <- 1..3 do
r = Float.round(@radius * step / 3, 1)
text = String.upcase("#{format(range * step / 3)} #{assigns.unit}")
%{index: step, r: r, text: text, w: Float.round(8 + String.length(text) * 5.2, 1)}
end
in_range = Enum.count(pins, &(not &1.far))
assigns =
assigns
|> assign(:cx, @cx)
|> assign(:cy, @cy)
|> assign(:radius, @radius)
|> assign(:rings, rings)
|> assign(:rows, pins)
|> assign(:items, Enum.sort_by(pins, &{&1.active, &1.y}))
|> assign(:count, "#{in_range} in range")
|> assign(:sweep, sweep(322, 30))
~H"""
<.frame name="geo/pin-drop" label={@label} {frame_opts(assigns)}>
<svg viewBox="0 0 420 300">
<g class="ehv-h-lift">
<rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="419" height="299" rx="12" />
<text class="ehv-mono ehv-a-fade ehv-i-1" x="20" y="31" font-size="9">
{String.upcase(@caption)}
</text>
<text
class="ehv-mono ehv-t-2 ehv-a-fade ehv-i-1"
x="400"
y="31"
font-size="9"
text-anchor="end"
>
{String.upcase(@count)}
</text>
<g class="ehv-a-fade ehv-i-2">
<line class="ehv-line-faint" x1={@cx - @radius} x2={@cx + @radius} y1={@cy} y2={@cy} />
<line class="ehv-line-faint" x1={@cx} x2={@cx} y1={@cy - @radius} y2={@cy + @radius} />
</g>
<g class="ehv-a-fade ehv-i-6">
<g class="ehv-geo-pin-drop__sweep">
<circle class="ehv-fill-none" cx={@cx} cy={@cy} r={@radius} />
<path class="ehv-geo-pin-drop__wedge" d={@sweep.wedge} />
<line class="ehv-line" x1={@cx} y1={@cy} x2={@sweep.x} y2={@sweep.y} />
</g>
</g>
<g :for={ring <- @rings} class={"ehv-a-pop ehv-i-#{ring.index + 1}"}>
<circle
class={if ring.index == 3, do: "ehv-line", else: "ehv-line-soft"}
cx={@cx}
cy={@cy}
r={ring.r}
/>
</g>
<g :for={ring <- @rings} class={"ehv-a-fade ehv-i-#{ring.index + 3}"}>
<rect
class="ehv-geo-pin-drop__gap"
x={@cx - ring.w / 2}
y={@cy + ring.r - 6}
width={ring.w}
height="12"
/>
<text
class="ehv-mono ehv-t-4"
x={@cx}
y={@cy + ring.r + 2.8}
font-size="8"
text-anchor="middle"
>
{ring.text}
</text>
</g>
<g class="ehv-a-pop ehv-i-3">
<rect class="ehv-node" x={@cx - 4.5} y={@cy - 4.5} width="9" height="9" rx="3" />
</g>
<g :for={pin <- @items} class="ehv-geo-pin-drop__spot">
<circle
:if={pin.active}
class="ehv-fill-lime ehv-a-ping ehv-i-14"
cx={pin.x}
cy={pin.y}
r="4.5"
/>
<ellipse
class={[
"ehv-a-fade ehv-i-#{pin.index + 7}",
if(pin.active, do: "ehv-fill-lime-soft", else: "ehv-fill-strong")
]}
cx={pin.x}
cy={pin.y}
rx={if pin.active, do: 10, else: 6}
ry={if pin.active, do: 3.6, else: 2.2}
/>
<g class={"ehv-a-drop ehv-i-#{pin.index + 6}"}>
<g class="ehv-geo-pin-drop__pin">
<path class={["ehv-geo-pin-drop__body", tone(pin)]} d={pin.d} />
<text
class={["ehv-mono ehv-geo-pin-drop__number", tone(pin)]}
x={pin.x}
y={pin.y - 15.4}
font-size="8.5"
text-anchor="middle"
>
{pin.number}
</text>
</g>
</g>
</g>
<line class="ehv-line-faint ehv-a-fade ehv-i-2" x1="258.5" x2="258.5" y1="46" y2="280" />
<g :for={row <- @rows} class={"ehv-a-rise ehv-i-#{row.index + 6}"}>
<rect
:if={row.active}
class="ehv-card-lime"
x="268.5"
y={row.row_y + 0.5}
width="131"
height="31"
rx="8"
/>
<circle class={["ehv-geo-pin-drop__body", tone(row)]} cx="286" cy={row.row_y + 16} r="9" />
<text
class={["ehv-mono ehv-geo-pin-drop__number", tone(row)]}
x="286"
y={row.row_y + 19.1}
font-size="8.5"
text-anchor="middle"
>
{row.number}
</text>
<text class={[row.far && "ehv-t-3"]} x="302" y={row.row_y + 14.5} font-size="11.5">
{row.name}
</text>
<text
class={["ehv-mono", row.far && "ehv-t-4"]}
x="302"
y={row.row_y + 25.5}
font-size="7.5"
>
{if row.far, do: row.detail <> " · OUT", else: row.detail}
</text>
</g>
</g>
</svg>
</.frame>
"""
end
defp tone(%{active: true}), do: "ehv-geo-pin-drop--active"
defp tone(%{far: true}), do: "ehv-geo-pin-drop--far"
defp tone(_pin), do: nil
# The sweep arm at `angle` degrees from north, with a wedge trailing it.
defp sweep(angle, trail) do
{x, y} = polar(angle)
{tx, ty} = polar(angle - trail)
%{
x: x,
y: y,
wedge: "M#{@cx} #{@cy}L#{tx} #{ty}A#{@radius} #{@radius} 0 0 1 #{x} #{y}z"
}
end
defp polar(angle) do
{
Float.round(@cx + @radius * :math.sin(angle * @rad), 1),
Float.round(@cy - @radius * :math.cos(angle * @rad), 1)
}
end
defp compass(bearing) do
index = rem(round(:math.fmod(bearing, 360) / 45) + 8, 8)
Enum.at(~w(N NE E SE S SW W NW), index)
end
defp format(value) do
rounded = Float.round(value * 1.0, 1)
if rounded == trunc(rounded),
do: Integer.to_string(trunc(rounded)),
else: Float.to_string(rounded)
end
end
assets/css/geo/pin-drop.css
/* geo/pin-drop */
.ehv-geo-pin-drop { --ehv-max-w: 460px; }
/* Breaks a ring behind its distance label. */
.ehv-geo-pin-drop__gap { fill: var(--ehv-surface-1); }
.ehv-geo-pin-drop__wedge { fill: var(--ehv-border-faint); }
.ehv-geo-pin-drop__body { fill: var(--ehv-surface-3); stroke: var(--ehv-ink-2); stroke-width: 1.25; stroke-linejoin: round; }
.ehv-geo-pin-drop__body.ehv-geo-pin-drop--active { fill: var(--ehv-lime-fill); stroke: var(--ehv-lime); }
.ehv-geo-pin-drop__body.ehv-geo-pin-drop--far { fill: var(--ehv-surface-1); stroke: var(--ehv-ink-4); }
.ehv svg .ehv-mono.ehv-geo-pin-drop__number { fill: var(--ehv-ink); letter-spacing: 0; }
.ehv svg .ehv-mono.ehv-geo-pin-drop__number.ehv-geo-pin-drop--active { fill: var(--ehv-on-lime); }
.ehv svg .ehv-mono.ehv-geo-pin-drop__number.ehv-geo-pin-drop--far { fill: var(--ehv-ink-4); }
/* The sweep arm turns around the centre of the field: the group holds an
unpainted circle of the field's size, so its box is centred there. */
@keyframes ehv-geo-pin-drop-sweep { to { transform: rotate(360deg); } }
.ehv-geo-pin-drop__sweep { transform-box: fill-box; transform-origin: center; }
.ehv--animated .ehv-geo-pin-drop__sweep { animation: ehv-geo-pin-drop-sweep 6s linear infinite; }
.ehv--hover .ehv-geo-pin-drop__pin { transition: transform 200ms var(--ehv-ease); }
.ehv--hover .ehv-geo-pin-drop__spot:hover .ehv-geo-pin-drop__pin { transform: translateY(-4px); }
@media (prefers-reduced-motion: reduce) {
.ehv--hover .ehv-geo-pin-drop__pin { transition: none; }
}