metrics/comparison.ex
Comparison
Two or three metrics before and after a change, each as a pair of bars with its delta.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.metrics_comparison animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
title |
string | "Order intake on the platform" |
|
caption |
string | "Jan vs Sep" |
|
before_label |
string | "Before" |
|
after_label |
string | "On the platform" |
|
metrics |
list | [%{name: "Time to process an order", unit: " min", after: 12, before: 45, better: "lower"}, ...] |
One to four `%{name, before, after}` with numbers. Optional `unit` is written after each value, optional `better` is `higher` (default) or `lower` and decides whether the delta is lime or coral. |
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/comparison.ex
defmodule EHVisuals.Metrics.Comparison do
@moduledoc """
Two or three metrics before and after a change, each as a pair of bars with
the difference next to it.
"""
use EHVisuals.Composition,
name: "metrics/comparison",
title: "Comparison",
description:
"Two or three metrics before and after a change, each as a pair of bars with its delta."
@metrics [
%{name: "Time to process an order", before: 45, after: 12, unit: " min", better: "lower"},
%{name: "Orders corrected by hand", before: 18, after: 4, unit: "%", better: "lower"},
%{name: "Orders per person a day", before: 60, after: 140, better: "higher"}
]
# Bars start at x = 20 and the longer bar of a pair is @bar wide.
@bar 228
@first 92
@pitch 62
attr(:title, :string, default: "Order intake on the platform")
attr(:caption, :string, default: "Jan vs Sep")
attr(:before_label, :string, default: "Before")
attr(:after_label, :string, default: "On the platform")
attr(:metrics, :list,
default: @metrics,
doc:
"One to four `%{name, before, after}` with numbers. Optional `unit` is written after each value, optional `better` is `higher` (default) or `lower` and decides whether the delta is lime or coral."
)
attr(:label, :string,
default: "Three metrics before and after a workflow moved onto the platform, all improved"
)
frame_attrs()
@spec metrics_comparison(map()) :: Phoenix.LiveView.Rendered.t()
def metrics_comparison(assigns) do
rows = assigns.metrics |> Enum.take(4) |> Enum.with_index() |> Enum.map(&row/1)
assigns =
assigns
|> assign(:rows, rows)
|> assign(:after_x, Float.round(34 + String.length(assigns.before_label) * 5.5 + 16, 1))
|> assign(:height, @first + max(length(rows) - 1, 0) * @pitch + 46 + 18)
~H"""
<.frame name="metrics/comparison" 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" />
<text class="ehv-t-strong ehv-a-fade ehv-i-1" x="20" y="35" font-size="14">{@title}</text>
<text class="ehv-mono ehv-a-fade ehv-i-1" x="340" y="35" font-size="9" text-anchor="end">
{String.upcase(@caption)}
</text>
<line class="ehv-line-faint" x1="20" x2="340" y1="52.5" y2="52.5" />
<g class="ehv-a-fade ehv-i-2">
<rect class="ehv-metrics-comparison__before" x="20" y="65" width="8" height="8" rx="2.5" />
<text class="ehv-mono" x="34" y="72.5" font-size="8">{String.upcase(@before_label)}</text>
<rect class="ehv-fill-lime" x={@after_x} y="65" width="8" height="8" rx="2.5" />
<text class="ehv-mono ehv-t-2" x={@after_x + 14} y="72.5" font-size="8">
{String.upcase(@after_label)}
</text>
</g>
<g :for={row <- @rows}>
<line
:if={row.index > 0}
class="ehv-line-faint"
x1="20"
x2="340"
y1={row.y - 8.5}
y2={row.y - 8.5}
/>
<g class={"ehv-a-fade ehv-i-#{row.step}"}>
<text x="20" y={row.y + 13} font-size="12.5">{row.name}</text>
<rect
class={"ehv-metrics-comparison__badge--#{row.tone}"}
x={340 - row.badge_w}
y={row.y - 1}
width={row.badge_w}
height="20"
rx="6"
/>
<path
:if={row.arrow}
class={"ehv-metrics-comparison__arrow--#{row.tone}"}
d={row.arrow}
/>
<text
class={["ehv-mono", "ehv-metrics-comparison__delta--#{row.tone}"]}
x="332"
y={row.y + 12.5}
font-size="9"
text-anchor="end"
>
{row.delta}
</text>
</g>
<g class={"ehv-a-fade ehv-i-#{row.step + 1}"}>
<rect
class={"ehv-metrics-comparison__before ehv-a-grow-x ehv-i-#{row.step + 1}"}
x="20"
y={row.y + 26}
width={row.before_w}
height="6"
rx="3"
/>
<text class="ehv-t-3" x={28 + row.before_w} y={row.y + 32.5} font-size="10.5">
{row.before}
</text>
</g>
<g class={"ehv-a-fade ehv-i-#{row.step + 2}"}>
<rect
class={"ehv-fill-lime ehv-a-grow-x ehv-i-#{row.step + 2}"}
x="20"
y={row.y + 40}
width={row.after_w}
height="6"
rx="3"
/>
<text class="ehv-t-strong" x={28 + row.after_w} y={row.y + 46.5} font-size="10.5">
{row.after}
</text>
</g>
</g>
</g>
</svg>
</.frame>
"""
end
defp row({metric, index}) do
{before, later} = {metric.before, metric.after}
top = max(max(before, later), 1)
unit = Map.get(metric, :unit, "")
y = @first + index * @pitch
change =
if before == 0, do: 0, else: round((later - before) / abs(before) * 100)
delta = if change > 0, do: "+#{change}%", else: "#{change}%"
badge_w = Float.round(26 + String.length(delta) * 5.9, 1)
tip = 340 - badge_w + 9
%{
index: index,
name: metric.name,
y: y,
before: "#{before}#{unit}",
after: "#{later}#{unit}",
before_w: Float.round(max(before / top * @bar, 4), 1),
after_w: Float.round(max(later / top * @bar, 4), 1),
delta: delta,
badge_w: badge_w,
tone: tone(change, Map.get(metric, :better, "higher")),
arrow:
cond do
change > 0 -> "M#{tip} #{y + 12} h7 l-3.5 -5.5 z"
change < 0 -> "M#{tip} #{y + 6.5} h7 l-3.5 5.5 z"
true -> nil
end,
step: min(index * 2 + 3, 22)
}
end
defp tone(0, _better), do: "flat"
defp tone(change, "lower"), do: if(change < 0, do: "good", else: "bad")
defp tone(change, _better), do: if(change > 0, do: "good", else: "bad")
end
assets/css/metrics/comparison.css
/* metrics/comparison */
.ehv-metrics-comparison { --ehv-max-w: 390px; }
.ehv-metrics-comparison__before { fill: var(--ehv-ink-4); }
.ehv-metrics-comparison__badge--good { fill: var(--ehv-lime-soft); }
.ehv-metrics-comparison__badge--bad { fill: var(--ehv-coral-soft); }
.ehv-metrics-comparison__badge--flat { fill: var(--ehv-surface-3); }
.ehv-metrics-comparison__arrow--good { fill: var(--ehv-lime); }
.ehv-metrics-comparison__arrow--bad { fill: var(--ehv-coral); }
.ehv svg .ehv-mono.ehv-metrics-comparison__delta--good { fill: var(--ehv-lime); }
.ehv svg .ehv-mono.ehv-metrics-comparison__delta--bad { fill: var(--ehv-coral); }
.ehv svg .ehv-mono.ehv-metrics-comparison__delta--flat { fill: var(--ehv-ink-2); }