metrics/trend.ex
Trend
One metric with its trend line and the change against the previous period, drawn as a dashed line.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.metrics_trend animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
title |
string | "Orders handled a day" |
|
value |
string | "62" |
|
change |
string | "+31.9%" |
The change against the previous period. A leading minus turns it coral. |
periods |
list | ["This month", "Last month"] |
The names of the two periods, the current one first. Up to 12 characters each. |
values |
list | [31, 34, 33, 38, 37, 41, ...] |
The current period, oldest first. |
previous |
list | [29, 30, 33, 32, 35, 34, ...] |
The previous period on the same scale, drawn dashed. An empty list hides it. |
ticks |
list | ["1", "8", "15", "22"] |
Labels under the plot, spread evenly. |
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/metrics/trend.ex
defmodule EHVisuals.Metrics.Trend do
@moduledoc """
One metric over a period: the value, the change against the period before,
and both periods as lines, the earlier one dashed.
"""
use EHVisuals.Composition,
name: "metrics/trend",
title: "Trend",
description:
"One metric with its trend line and the change against the previous period, drawn as a dashed line."
@values [31, 34, 33, 38, 37, 41, 44, 43, 47, 52, 50, 55, 58, 62]
@previous [29, 30, 33, 32, 35, 34, 37, 39, 38, 41, 43, 42, 45, 47]
attr(:title, :string, default: "Orders handled a day")
attr(:value, :string, default: "62")
attr(:change, :string,
default: "+31.9%",
doc: "The change against the previous period. A leading minus turns it coral."
)
attr(:periods, :list,
default: ["This month", "Last month"],
doc: "The names of the two periods, the current one first. Up to 12 characters each."
)
attr(:values, :list, default: @values, doc: "The current period, oldest first.")
attr(:previous, :list,
default: @previous,
doc: "The previous period on the same scale, drawn dashed. An empty list hides it."
)
attr(:ticks, :list, default: ~w(1 8 15 22), doc: "Labels under the plot, spread evenly.")
attr(:label, :string,
default: "A metric rising through the month, above the dashed line of the month before"
)
frame_attrs()
@spec metrics_trend(map()) :: Phoenix.LiveView.Rendered.t()
def metrics_trend(assigns) do
{current, previous} = plot(assigns.values, assigns.previous)
[now, before] = assigns.periods |> Enum.concat(["", ""]) |> Enum.take(2)
before_w = String.length(before) * 5.5
now_w = String.length(now) * 5.5
badge_w = Float.round(30 + String.length(assigns.change) * 6.3, 1)
assigns =
assigns
|> assign(:down, String.starts_with?(assigns.change, "-"))
|> assign(:line, path(current))
|> assign(:dashed, path(previous))
|> assign(:last, List.last(current))
|> assign(:last_before, List.last(previous))
|> assign(:now, now)
|> assign(:before, before)
|> assign(:before_x, Float.round(340 - before_w - 18, 1))
|> assign(
:now_x,
Float.round(340 - now_w - 18 - if(previous == [], do: 0, else: before_w + 32), 1)
)
|> assign(:badge_w, badge_w)
|> assign(:tick_xs, tick_xs(assigns.ticks))
~H"""
<.frame name="metrics/trend" label={@label} {frame_opts(assigns)}>
<svg viewBox="0 0 360 236">
<g class="ehv-h-lift">
<rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="359" height="235" rx="12" />
<text class="ehv-t-2 ehv-a-fade ehv-i-1" x="20" y="34" font-size="12">{@title}</text>
<g class="ehv-a-fade ehv-i-1">
<line
class={[tone(@down), "ehv-round"]}
x1={@now_x}
x2={@now_x + 12}
y1="30.5"
y2="30.5"
/>
<text class="ehv-mono ehv-t-2" x={@now_x + 18} y="33.5" font-size="8">
{String.upcase(@now)}
</text>
<g :if={@dashed}>
<line
class="ehv-metrics-trend__previous"
x1={@before_x}
x2={@before_x + 12}
y1="30.5"
y2="30.5"
/>
<text class="ehv-mono" x="340" y="33.5" font-size="8" text-anchor="end">
{String.upcase(@before)}
</text>
</g>
</g>
<text class="ehv-t-display ehv-a-fade ehv-i-2" x="20" y="70" font-size="30">{@value}</text>
<g class="ehv-a-fade ehv-i-3">
<rect
class={if @down, do: "ehv-fill-coral-soft", else: "ehv-fill-lime-soft"}
x="20"
y="82"
width={@badge_w}
height="22"
rx="6"
/>
<path
class={if @down, do: "ehv-fill-coral", else: "ehv-metrics-trend__up"}
d={if @down, do: "M29 90 h8 l-4 6 z", else: "M29 96 h8 l-4 -6 z"}
/>
<text
class={["ehv-mono", if(@down, do: "ehv-t-coral", else: "ehv-t-lime")]}
x="43"
y="97"
font-size="10"
>
{@change}
</text>
<text :if={@dashed} class="ehv-t-3" x={28 + @badge_w} y="97" font-size="11.5">
vs {lower_first(@before)}
</text>
</g>
<line
:for={y <- [124.5, 148.5, 172.5]}
class="ehv-line-faint"
x1="20"
x2="340"
y1={y}
y2={y}
/>
<line class="ehv-line-soft" x1="20" x2="340" y1="200.5" y2="200.5" />
<polyline
:if={@dashed}
class="ehv-metrics-trend__previous ehv-a-fade ehv-i-4"
points={@dashed}
/>
<line
:if={@dashed}
class={[tone(@down), "ehv-dash ehv-a-fade ehv-i-14"]}
x1="340"
x2="340"
y1={elem(@last, 1)}
y2={elem(@last_before, 1)}
/>
<circle
:if={@dashed}
class="ehv-node-soft ehv-a-fade ehv-i-4"
cx={elem(@last_before, 0)}
cy={elem(@last_before, 1)}
r="3"
/>
<polyline
class={["ehv-metrics-trend__line ehv-round ehv-a-draw ehv-i-5", tone(@down)]}
points={@line}
pathLength="1"
/>
<circle
class={[
"ehv-metrics-trend__dot ehv-a-pop ehv-i-13",
if(@down, do: "ehv-fill-coral", else: "ehv-fill-lime")
]}
cx={elem(@last, 0)}
cy={elem(@last, 1)}
r="4"
/>
<text
:for={{tick, x} <- Enum.zip(@ticks, @tick_xs)}
class="ehv-mono ehv-a-fade ehv-i-4"
x={x}
y="219"
font-size="8"
>
{String.upcase(tick)}
</text>
</g>
</svg>
</.frame>
"""
end
# "Last month" reads "vs last month", "Q2" stays "vs Q2".
defp lower_first(name) do
case String.graphemes(name) do
[first, second | rest] ->
if second == String.downcase(second) and second != String.upcase(second),
do: Enum.join([String.downcase(first), second | rest]),
else: name
_short ->
name
end
end
defp tone(true), do: "ehv-line-coral"
defp tone(false), do: "ehv-line-lime"
# Both periods share one scale. The plot is 320 wide from x = 20 and runs
# from y = 122 down to y = 194.
defp plot(values, previous) do
all = values ++ previous
{min, max} = Enum.min_max(all, fn -> {0, 1} end)
range = if max == min, do: 1, else: max - min
points = fn series ->
step = 320 / max(length(series) - 1, 1)
series
|> Enum.with_index()
|> Enum.map(fn {value, index} ->
{Float.round(20 + index * step, 1), Float.round(194 - (value - min) / range * 72, 1)}
end)
end
{points.(values), points.(previous)}
end
defp path([]), do: nil
defp path(points), do: Enum.map_join(points, " ", fn {x, y} -> "#{x},#{y}" end)
defp tick_xs(ticks) do
step = 320 / max(length(ticks), 1)
for index <- 0..(length(ticks) - 1)//1, do: Float.round(20 + index * step, 1)
end
end
assets/css/metrics/trend.css
/* metrics/trend */
.ehv-metrics-trend { --ehv-max-w: 390px; }
.ehv-metrics-trend__line { stroke-width: 1.6; }
/* The period before: same scale, dashed and a tone back. */
.ehv-metrics-trend__previous {
fill: none;
stroke: var(--ehv-ink-3);
stroke-width: 1.25;
stroke-dasharray: 3 4;
stroke-linecap: round;
stroke-linejoin: round;
}
.ehv--hover .ehv-metrics-trend__dot { transition: r 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-metrics-trend__dot { r: 5.5; }
/* The arrow in the badge is drawn in the line colour, so it holds on cream. */
.ehv-metrics-trend__up { fill: var(--ehv-lime); }