empowered.house ↗

geo/geofence.ex

Geofence

An abstract street map with a drawn zone, tracked vehicles inside it and one that has left marked in coral.

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

<div class="h-96 rounded-xl border border-border">
  <.geo_geofence animated />
</div>
default
isometric
all inside · custom copy
hover · no glow · two outside
light surface
fade out · still

Props

Prop Type Default What it does
caption string "Geofence"
zone_name string "Depot zone" The tag on the zone, up to 14 characters.
zone list [{20, 24}, {56, 13}, ...] The corners of the zone as `{x, y}`, in map coordinates from 0 to 100, in drawing order.
vehicles list [%{id: "VAN-03", x: 33, y: 37}, %{id: "VAN-07", ...}, ...] Up to eight `%{id, x, y}` in map coordinates. A vehicle outside the zone turns coral. Ids take 8 characters.
updated string "Updated 14:02"
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/geofence.ex
geofence.ex
defmodule EHVisuals.Geo.Geofence do
  @moduledoc """
  An abstract street map with a drawn zone, the vehicles tracked inside it
  and, in coral, any vehicle that has left.
  """

  use EHVisuals.Composition,
    name: "geo/geofence",
    title: "Geofence",
    description:
      "An abstract street map with a drawn zone, tracked vehicles inside it and one that has left marked in coral."

  @zone [{20, 24}, {56, 13}, {77, 33}, {72, 68}, {40, 82}, {17, 58}]

  @vehicles [
    %{id: "VAN-03", x: 33, y: 37},
    %{id: "VAN-07", x: 56, y: 29},
    %{id: "VAN-11", x: 55, y: 53},
    %{id: "VAN-02", x: 37, y: 66},
    %{id: "VAN-09", x: 89, y: 60}
  ]

  # The map well in the viewBox. Map coordinates run from 0 to 100 both ways.
  @map_x 20
  @map_y 46
  @map_w 360
  @map_h 212

  # Streets as `{axis, position, weight}` in map coordinates.
  @streets [
    {:v, 9, :minor},
    {:v, 20, :minor},
    {:v, 29, :minor},
    {:v, 43, :major},
    {:v, 53, :minor},
    {:v, 64, :minor},
    {:v, 73, :major},
    {:v, 85, :minor},
    {:v, 93, :minor},
    {:h, 14, :minor},
    {:h, 28, :minor},
    {:h, 43, :major},
    {:h, 58, :minor},
    {:h, 72, :major},
    {:h, 87, :minor}
  ]

  # Blocks that are filled in: parks and yards.
  @blocks [{20, 14, 29, 28}, {53, 58, 64, 72}, {85, 72, 93, 87}, {73, 14, 85, 28}]

  attr(:caption, :string, default: "Geofence")

  attr(:zone_name, :string,
    default: "Depot zone",
    doc: "The tag on the zone, up to 14 characters."
  )

  attr(:zone, :list,
    default: @zone,
    doc:
      "The corners of the zone as `{x, y}`, in map coordinates from 0 to 100, in drawing order."
  )

  attr(:vehicles, :list,
    default: @vehicles,
    doc:
      "Up to eight `%{id, x, y}` in map coordinates. A vehicle outside the zone turns coral. Ids take 8 characters."
  )

  attr(:updated, :string, default: "Updated 14:02")

  attr(:label, :string,
    default: "A street map with a zone drawn on it, four vehicles inside and one outside"
  )

  frame_attrs()

  @spec geo_geofence(map()) :: Phoenix.LiveView.Rendered.t()
  def geo_geofence(assigns) do
    corners = Enum.map(assigns.zone, &place/1)

    vehicles =
      for {vehicle, index} <- assigns.vehicles |> Enum.take(8) |> Enum.with_index() do
        {x, y} = place({vehicle.x, vehicle.y})
        id = String.upcase(vehicle.id)
        inside = inside?({x, y}, corners)
        text = if inside, do: id, else: "#{id} · OUTSIDE"
        text_w = String.length(text) * 5.2

        vehicle = %{index: index, id: id, x: x, y: y, inside: inside, text: text}

        if inside do
          left = x + 12 + text_w > @map_x + @map_w - 4

          Map.merge(vehicle, %{
            text_x: if(left, do: x - 10, else: x + 10),
            anchor: if(left, do: "end", else: "start")
          })
        else
          chip_w = Float.round(14 + text_w, 1)
          chip_x = (x - chip_w / 2) |> max(@map_x + 4) |> min(@map_x + @map_w - 4 - chip_w)
          chip_y = if y - 30 < @map_y + 4, do: y + 11, else: y - 28

          Map.merge(vehicle, %{
            chip_w: chip_w,
            chip_x: Float.round(chip_x * 1.0, 1),
            chip_y: Float.round(chip_y * 1.0, 1),
            exit: nearest({x, y}, corners)
          })
        end
      end

    outside = Enum.count(vehicles, &(not &1.inside))

    {tag_x, tag_y} =
      Enum.min_by(corners, fn {x, y} -> y * 1000 + x end, fn -> {@map_x, @map_y} end)

    tag = String.upcase(assigns.zone_name)

    assigns =
      assigns
      |> assign(:map, %{x: @map_x, y: @map_y, w: @map_w, h: @map_h})
      |> assign(:streets, streets())
      |> assign(:blocks, blocks())
      |> assign(:avenue, avenue())
      |> assign(:corners, Enum.with_index(corners))
      |> assign(:outline, outline(corners))
      |> assign(:items, vehicles)
      |> assign(:outside, outside)
      |> assign(:status, status(outside))
      |> assign(:pill_w, Float.round(18 + String.length(status(outside)) * 5.8, 1))
      |> assign(:tag, %{
        text: tag,
        x: tag_x,
        y: tag_y,
        w: Float.round(12 + String.length(tag) * 5.2, 1)
      })
      |> assign(:summary, "#{length(vehicles)} tracked · #{length(vehicles) - outside} inside")

    ~H"""
    <.frame name="geo/geofence" label={@label} {frame_opts(assigns)}>
      <svg viewBox="0 0 400 296">
        <g class="ehv-h-lift">
          <rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="399" height="295" rx="12" />
          <text class="ehv-mono ehv-a-fade ehv-i-1" x="20" y="31" font-size="9">
            {String.upcase(@caption)}
          </text>
          <g class="ehv-a-fade ehv-i-9">
            <rect
              class={if @outside > 0, do: "ehv-fill-coral-soft", else: "ehv-fill-lime-soft"}
              x={380 - @pill_w}
              y="17"
              width={@pill_w}
              height="20"
              rx="6"
            />
            <text
              class={["ehv-mono", if(@outside > 0, do: "ehv-t-coral", else: "ehv-t-lime")]}
              x={380 - @pill_w / 2}
              y="30.2"
              font-size="8.5"
              text-anchor="middle"
            >
              {String.upcase(@status)}
            </text>
          </g>
          <rect
            class="ehv-well ehv-a-fade ehv-i-1"
            x={@map.x}
            y={@map.y}
            width={@map.w}
            height={@map.h}
            rx="8"
          />
          <g class="ehv-a-fade ehv-i-2">
            <rect
              :for={block <- @blocks}
              class="ehv-geo-geofence__block"
              x={block.x}
              y={block.y}
              width={block.w}
              height={block.h}
              rx="2"
            />
            <line
              :for={street <- @streets}
              class={"ehv-geo-geofence__street ehv-geo-geofence__street--#{street.weight}"}
              x1={street.x1}
              y1={street.y1}
              x2={street.x2}
              y2={street.y2}
            />
            <line
              class="ehv-geo-geofence__street ehv-geo-geofence__street--major"
              x1={@avenue.x1}
              y1={@avenue.y1}
              x2={@avenue.x2}
              y2={@avenue.y2}
            />
          </g>
          <path class="ehv-geo-geofence__zone ehv-a-fade ehv-i-4" d={@outline} />
          <path class="ehv-line-lime ehv-round ehv-a-draw ehv-i-3" d={@outline} pathLength="1" />
          <g :for={{{x, y}, index} <- @corners} class={"ehv-a-pop ehv-i-#{min(index + 4, 12)}"}>
            <rect class="ehv-node-lime" x={x - 2.5} y={y - 2.5} width="5" height="5" rx="1.5" />
          </g>
          <g class="ehv-a-fade ehv-i-6">
            <rect class="ehv-fill-lime" x={@tag.x} y={@tag.y - 19} width={@tag.w} height="14" rx="4" />
            <text
              class="ehv-mono ehv-t-on-lime"
              x={@tag.x + @tag.w / 2}
              y={@tag.y - 9.2}
              font-size="7.5"
              text-anchor="middle"
            >
              {@tag.text}
            </text>
          </g>
          <g :for={item <- @items} class="ehv-geo-geofence__vehicle">
            <g :if={item.inside}>
              <g class={"ehv-a-pop ehv-i-#{item.index + 7}"}>
                <circle class="ehv-fill-canvas" cx={item.x} cy={item.y} r="6.5" />
                <circle class="ehv-fill-ink ehv-geo-geofence__dot" cx={item.x} cy={item.y} r="4" />
              </g>
              <text
                class={"ehv-mono ehv-t-2 ehv-a-fade ehv-i-#{item.index + 8}"}
                x={item.text_x}
                y={item.y + 2.8}
                font-size="7.5"
                text-anchor={item.anchor}
              >
                {item.text}
              </text>
            </g>
            <g :if={!item.inside}>
              <line
                class={"ehv-line-coral ehv-dash ehv-a-fade ehv-i-#{item.index + 8}"}
                x1={elem(item.exit, 0)}
                y1={elem(item.exit, 1)}
                x2={item.x}
                y2={item.y}
              />
              <circle class="ehv-fill-coral ehv-a-ping ehv-i-14" cx={item.x} cy={item.y} r="5" />
              <g class={"ehv-a-pop ehv-i-#{item.index + 7}"}>
                <circle class="ehv-fill-canvas" cx={item.x} cy={item.y} r="6.5" />
                <circle class="ehv-fill-coral ehv-geo-geofence__dot" cx={item.x} cy={item.y} r="4" />
              </g>
              <g class={"ehv-a-fade ehv-i-#{item.index + 9}"}>
                <rect
                  class="ehv-geo-geofence__alert"
                  x={item.chip_x}
                  y={item.chip_y}
                  width={item.chip_w}
                  height="17"
                  rx="5"
                />
                <text
                  class="ehv-mono ehv-t-coral"
                  x={item.chip_x + item.chip_w / 2}
                  y={item.chip_y + 11.3}
                  font-size="7.5"
                  text-anchor="middle"
                >
                  {item.text}
                </text>
              </g>
            </g>
          </g>
          <text class="ehv-mono ehv-t-2 ehv-a-fade ehv-i-10" x="20" y="279" font-size="8.5">
            {String.upcase(@summary)}
          </text>
          <text
            class="ehv-mono ehv-t-4 ehv-a-fade ehv-i-10"
            x="380"
            y="279"
            font-size="8.5"
            text-anchor="end"
          >
            {String.upcase(@updated)}
          </text>
        </g>
      </svg>
    </.frame>
    """
  end

  # Map coordinates (0 to 100) to the viewBox, clamped to the well.
  defp place({x, y}) do
    {
      Float.round(@map_x + (x |> max(2) |> min(98)) * @map_w / 100, 1),
      Float.round(@map_y + (y |> max(3) |> min(97)) * @map_h / 100, 1)
    }
  end

  defp streets do
    for {axis, at, weight} <- @streets do
      case axis do
        :v ->
          x = Float.round(@map_x + at * @map_w / 100, 1)
          %{x1: x, x2: x, y1: @map_y + 1, y2: @map_y + @map_h - 1, weight: weight}

        :h ->
          y = Float.round(@map_y + at * @map_h / 100, 1)
          %{x1: @map_x + 1, x2: @map_x + @map_w - 1, y1: y, y2: y, weight: weight}
      end
    end
  end

  defp blocks do
    for {x1, y1, x2, y2} <- @blocks do
      %{
        x: Float.round(@map_x + x1 * @map_w / 100 + 2, 1),
        y: Float.round(@map_y + y1 * @map_h / 100 + 2, 1),
        w: Float.round((x2 - x1) * @map_w / 100 - 4, 1),
        h: Float.round((y2 - y1) * @map_h / 100 - 4, 1)
      }
    end
  end

  # One avenue that cuts across the grid.
  defp avenue do
    %{
      x1: @map_x + 1,
      y1: Float.round(@map_y + 0.92 * @map_h, 1),
      x2: @map_x + @map_w - 1,
      y2: Float.round(@map_y + 0.36 * @map_h, 1)
    }
  end

  defp outline([]), do: ""

  defp outline([{x, y} | rest]) do
    "M#{x} #{y}" <> Enum.map_join(rest, fn {px, py} -> "L#{px} #{py}" end) <> "z"
  end

  defp edges([]), do: []
  defp edges([first | rest] = corners), do: Enum.zip(corners, rest ++ [first])

  # Ray casting: a point is inside when a ray to the right crosses the
  # outline an odd number of times.
  defp inside?({px, py}, corners) do
    corners
    |> edges()
    |> Enum.count(fn {{x1, y1}, {x2, y2}} ->
      y1 > py != y2 > py and px < (x2 - x1) * (py - y1) / (y2 - y1) + x1
    end)
    |> rem(2) == 1
  end

  # The point on the outline closest to `{px, py}`: where the vehicle left.
  defp nearest({px, py} = point, corners) do
    corners
    |> edges()
    |> Enum.map(fn {{x1, y1}, {x2, y2}} ->
      {dx, dy} = {x2 - x1, y2 - y1}
      length = dx * dx + dy * dy
      t = if length == 0, do: 0.0, else: ((px - x1) * dx + (py - y1) * dy) / length
      t = t |> max(0.0) |> min(1.0)
      {Float.round(x1 + t * dx, 1), Float.round(y1 + t * dy, 1)}
    end)
    |> Enum.min_by(fn {x, y} -> (x - px) * (x - px) + (y - py) * (y - py) end, fn -> point end)
  end

  defp status(0), do: "All inside"
  defp status(count), do: "#{count} outside"
end
assets/css/geo/geofence.css
geofence.css
/* geo/geofence */
.ehv-geo-geofence { --ehv-max-w: 440px; }

.ehv-geo-geofence__street { fill: none; stroke-width: 1; }
.ehv-geo-geofence__street--minor { stroke: var(--ehv-border-soft); }
.ehv-geo-geofence__street--major { stroke: var(--ehv-border-strong); stroke-width: 2; }
.ehv-geo-geofence__block { fill: var(--ehv-border-faint); }

.ehv-geo-geofence__zone { fill: var(--ehv-lime-soft); }
.ehv-geo-geofence__alert { fill: var(--ehv-canvas); stroke: var(--ehv-coral); stroke-width: 1; }

.ehv--hover .ehv-geo-geofence__dot { transition: r 200ms var(--ehv-ease); }
.ehv--hover .ehv-geo-geofence__vehicle:hover .ehv-geo-geofence__dot { r: 5.5; }