empowered.house ↗

geo/pin-drop.ex

Pin drop

Location pins dropping onto a radar field with concentric range rings, one of them picked out in lime.

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

<div class="h-96 rounded-xl border border-border">
  <.geo_pin_drop animated />
</div>
default
isometric
another pin picked · custom copy
hover · no glow · six pins
light surface
none picked · fade out · still

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
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
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; }
}