geo/geofence.ex
Geofence
An abstract street map with a drawn zone, tracked vehicles inside it and one that has left marked in coral.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.geo_geofence animated />
</div>
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
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
/* 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; }