charts/funnel.ex
Funnel
A conversion funnel of tapering stage slabs with the count per stage and the drop-off between stages.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.charts_funnel animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
title |
string | "Order to cash" |
|
value |
any | nil |
The big number. `nil` writes the last stage as a percentage of the first. |
caption |
string | "This quarter" |
|
stages |
list | [%{count: 1284, name: "Orders received"}, %{count: 1102, name: "Stock confirmed"}, ...] |
Two to six `%{name, count}`, widest first. The last stage is lime. Names up to 16 characters. |
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/charts/funnel.ex
defmodule EHVisuals.Charts.Funnel do
@moduledoc """
A conversion funnel: one tapering slab per stage, the count of each stage
and the drop-off between two stages.
"""
use EHVisuals.Composition,
name: "charts/funnel",
title: "Funnel",
description:
"A conversion funnel of tapering stage slabs with the count per stage and the drop-off between stages."
@stages [
%{name: "Orders received", count: 1284},
%{name: "Stock confirmed", count: 1102},
%{name: "Shipped in 48 h", count: 968},
%{name: "Paid on time", count: 742}
]
# The funnel is centred on @cx and @full wide at the first stage.
@cx 96
@full 152
@top 86
@slab 30
@pitch 40
attr(:title, :string, default: "Order to cash")
attr(:value, :any,
default: nil,
doc: "The big number. `nil` writes the last stage as a percentage of the first."
)
attr(:caption, :string, default: "This quarter")
attr(:stages, :list,
default: @stages,
doc:
"Two to six `%{name, count}`, widest first. The last stage is lime. Names up to 16 characters."
)
attr(:label, :string,
default: "A funnel from orders received to paid on time, narrowing at each stage"
)
frame_attrs()
@spec charts_funnel(map()) :: Phoenix.LiveView.Rendered.t()
def charts_funnel(assigns) do
rows = rows(Enum.take(assigns.stages, 6))
assigns =
assigns
|> assign(:rows, rows)
|> assign(:overall, assigns.value || overall(rows))
|> assign(:height, @top + max(length(rows) - 1, 0) * @pitch + @slab + 20)
~H"""
<.frame name="charts/funnel" 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-2 ehv-a-fade ehv-i-1" x="20" y="34" font-size="12">{@title}</text>
<text class="ehv-t-display ehv-a-fade ehv-i-2" x="20" y="64" font-size="24">
{@overall}
</text>
<text class="ehv-mono ehv-a-fade ehv-i-2" x="340" y="34" font-size="9" text-anchor="end">
{String.upcase(@caption)}
</text>
<g :for={row <- @rows}>
<line
class={"ehv-line-soft ehv-a-fade ehv-i-#{row.step + 1}"}
x1={row.edge}
x2="184"
y1={row.y + 15.5}
y2={row.y + 15.5}
/>
<g class={"ehv-a-drop ehv-i-#{row.step}"}>
<polygon
class={["ehv-charts-funnel__slab", row.last && "ehv-charts-funnel__slab--on"]}
points={row.points}
/>
</g>
<g class={"ehv-a-fade ehv-i-#{row.step + 1}"}>
<text class={[!row.last && "ehv-t-2"]} x="192" y={row.y + 19} font-size="12.5">
{row.name}
</text>
<text
class={["ehv-mono", if(row.last, do: "ehv-t-lime", else: "ehv-t-ink")]}
x="340"
y={row.y + 18.5}
font-size="9.5"
text-anchor="end"
>
{row.count}
</text>
<g :if={row.drop}>
<path class="ehv-fill-3" d={"M#{340 - row.drop_w - 9} #{row.y + 33} h6 l-3 4.5 z"} />
<text class="ehv-mono" x="340" y={row.y + 38.5} font-size="7.5" text-anchor="end">
{row.drop}
</text>
</g>
</g>
</g>
</g>
</svg>
</.frame>
"""
end
# A slab is as wide as its stage at the top and as wide as the next stage
# at the bottom, so the sides of the funnel run on from slab to slab.
defp rows(stages) do
first = stages |> List.first(%{count: 1}) |> Map.get(:count) |> max(1)
widths = Enum.map(stages, &width(&1.count, first))
last = length(stages) - 1
for {stage, index} <- Enum.with_index(stages) do
y = @top + index * @pitch
top = Enum.at(widths, index)
bottom = Enum.at(widths, index + 1, Float.round(top * 0.84, 1))
next = Enum.at(stages, index + 1)
drop = next && "-#{round((stage.count - next.count) / max(stage.count, 1) * 100)}%"
%{
name: stage.name,
count: delimit(stage.count),
y: y,
last: index == last,
points:
"#{@cx - top / 2},#{y} #{@cx + top / 2},#{y} #{@cx + bottom / 2},#{y + @slab} #{@cx - bottom / 2},#{y + @slab}",
raw: stage.count,
edge: Float.round(@cx + (top + bottom) / 4 + 6, 1),
drop: drop,
drop_w: if(drop, do: Float.round(String.length(drop) * 5.1, 1), else: 0),
step: min(index + 2, 23)
}
end
end
defp width(count, first), do: Float.round(max(count / first * @full, 36.0), 1)
defp overall([]), do: "0%"
defp overall(rows) do
"#{round(List.last(rows).raw / max(List.first(rows).raw, 1) * 100)}%"
end
defp delimit(number) do
number
|> round()
|> Integer.to_string()
|> String.replace(~r/(?<=\d)(?=(\d{3})+$)/, ",")
end
end
assets/css/charts/funnel.css
/* charts/funnel */
.ehv-charts-funnel { --ehv-max-w: 390px; --ehv-step: 110ms; }
.ehv-charts-funnel__slab {
fill: var(--ehv-surface-3);
stroke: var(--ehv-border-strong);
stroke-width: 1;
stroke-linejoin: round;
}
.ehv-charts-funnel__slab--on { fill: var(--ehv-lime-fill); stroke: var(--ehv-lime-fill); }
.ehv--hover .ehv-charts-funnel__slab { transition: fill 200ms var(--ehv-ease); }
.ehv--hover .ehv-charts-funnel__slab:not(.ehv-charts-funnel__slab--on):hover { fill: var(--ehv-ink-4); }