payments/usage-meter.ex
Usage meter
A metered billing summary: usage against a quota, the itemised meters and the projected invoice.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.payments_usage_meter animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
eyebrow |
string | "Usage this period" |
|
period |
string | "Day 23 of 31" |
|
used |
integer | 18420 |
Above the quota the bar and the share turn coral. |
quota |
integer | 25000 |
|
unit |
string | "runs" |
What is counted, lower case, up to 10 characters. |
meters |
list | [%{name: "Intake agent", value: 9210, amount: "€184.20"}, %{name: "Month-end close agent", ...}, ...] |
Up to four `%{name, value, amount}`. Names up to 22 characters, `value` is a whole number. |
projected |
string | "€496.50" |
The invoice at the current pace. |
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/payments/usage-meter.ex
defmodule EHVisuals.Payments.UsageMeter do
@moduledoc """
Metered billing at a glance: usage against the quota, the meters that make
it up and the invoice it is heading for.
"""
use EHVisuals.Composition,
name: "payments/usage-meter",
title: "Usage meter",
description:
"A metered billing summary: usage against a quota, the itemised meters and the projected invoice."
@meters [
%{name: "Intake agent", value: 9210, amount: "€184.20"},
%{name: "Month-end close agent", value: 6480, amount: "€129.60"},
%{name: "Stock agent", value: 2730, amount: "€54.60"}
]
attr(:eyebrow, :string, default: "Usage this period")
attr(:period, :string, default: "Day 23 of 31")
attr(:used, :integer, default: 18_420, doc: "Above the quota the bar and the share turn coral.")
attr(:quota, :integer, default: 25_000)
attr(:unit, :string, default: "runs", doc: "What is counted, lower case, up to 10 characters.")
attr(:meters, :list,
default: @meters,
doc:
"Up to four `%{name, value, amount}`. Names up to 22 characters, `value` is a whole number."
)
attr(:projected, :string, default: "€496.50", doc: "The invoice at the current pace.")
attr(:label, :string,
default:
"A usage meter at 74 percent of the quota, three itemised meters and the projected invoice"
)
frame_attrs()
@spec payments_usage_meter(map()) :: Phoenix.LiveView.Rendered.t()
def payments_usage_meter(assigns) do
meters = Enum.take(assigns.meters, 4)
top = meters |> Enum.map(& &1.value) |> Enum.max(fn -> 1 end) |> max(1)
share = assigns.used / max(assigns.quota, 1)
footer_y = 117 + length(meters) * 38 + 6
rows =
for {meter, index} <- Enum.with_index(meters) do
%{
name: meter.name,
amount: meter.amount,
quantity: "#{number(meter.value)} #{assigns.unit}",
index: index,
y: 117 + index * 38,
bar_w: Float.round(max(meter.value / top * 84, 3), 1)
}
end
assigns =
assigns
|> assign(:rows, rows)
|> assign(:over, share > 1)
|> assign(:percent, "#{round(share * 100)}% used")
|> assign(:bar_w, Float.round(min(max(share, 0.02), 1.0) * 320, 1))
|> assign(:used_text, number(assigns.used))
|> assign(:quota_text, "of #{number(assigns.quota)} #{assigns.unit}")
|> assign(:footer_y, footer_y)
|> assign(:height, footer_y + 54)
~H"""
<.frame name="payments/usage-meter" label={@label} {frame_opts(assigns)}>
<svg viewBox={"0 0 360 #{@height}"}>
<g class="ehv-h-lift">
<rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="359" height={@height - 1} rx="12" />
<g class="ehv-a-fade ehv-i-1">
<text class="ehv-mono" x="20" y="30" font-size="9">{String.upcase(@eyebrow)}</text>
<text class="ehv-t-display" x="20" y="66" font-size="28">{@used_text}</text>
<text class="ehv-t-2" x="340" y="66" font-size="12.5" text-anchor="end">
{@quota_text}
</text>
</g>
<g class="ehv-a-fade ehv-i-2">
<rect class="ehv-fill-soft" x="20" y="78" width="320" height="8" rx="4" />
<rect
class={[
"ehv-a-grow-x ehv-i-3",
if(@over, do: "ehv-fill-coral", else: "ehv-fill-lime")
]}
x="20"
y="78"
width={@bar_w}
height="8"
rx="4"
/>
<text
class={["ehv-mono", if(@over, do: "ehv-t-coral", else: "ehv-t-lime")]}
x="20"
y="103"
font-size="8.5"
>
{String.upcase(@percent)}
</text>
<text class="ehv-mono" x="340" y="103" font-size="8.5" text-anchor="end">
{String.upcase(@period)}
</text>
</g>
<line class="ehv-line-faint" x1="20" x2="340" y1="116.5" y2="116.5" />
<g :for={row <- @rows} class={"ehv-a-rise ehv-i-#{row.index + 4}"}>
<text x="20" y={row.y + 18} font-size="12.5">{row.name}</text>
<text class="ehv-mono" x="20" y={row.y + 31} font-size="8">
{String.upcase(row.quantity)}
</text>
<rect class="ehv-fill-soft" x="176" y={row.y + 18} width="84" height="4" rx="2" />
<rect
class={"ehv-fill-2 ehv-a-grow-x ehv-i-#{row.index + 6}"}
x="176"
y={row.y + 18}
width={row.bar_w}
height="4"
rx="2"
/>
<text x="340" y={row.y + 24} font-size="12.5" text-anchor="end">{row.amount}</text>
</g>
<g class="ehv-a-fade ehv-i-9">
<line class="ehv-line-faint" x1="20" x2="340" y1={@footer_y + 0.5} y2={@footer_y + 0.5} />
<text class="ehv-t-strong" x="20" y={@footer_y + 24} font-size="12.5">
Projected invoice
</text>
<text class="ehv-mono" x="20" y={@footer_y + 38} font-size="8">AT THE CURRENT PACE</text>
<text
class="ehv-t-display"
x="340"
y={@footer_y + 34}
font-size="20"
text-anchor="end"
>
{@projected}
</text>
</g>
</g>
</svg>
</.frame>
"""
end
# 18420 -> "18,420"
defp number(value) do
value
|> Integer.to_string()
|> String.reverse()
|> String.replace(~r/(\d{3})(?=\d)/, "\\1,")
|> String.reverse()
end
end
assets/css/payments/usage-meter.css
/* payments/usage-meter */
.ehv-payments-usage-meter { --ehv-max-w: 390px; }