charts/donut.ex
Donut chart
A donut chart card with the total in the centre, a legend of shares and one segment in lime.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.charts_donut animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
title |
string | "Orders by channel" |
|
caption |
string | "This month" |
|
total |
any | nil |
The text in the centre, up to 6 characters. `nil` writes the sum of the values. |
total_label |
string | "Orders" |
Under the total, up to 12 characters. |
segments |
list | [%{name: "EDI", value: 590}, %{name: "Email", value: 347}, ...] |
Two to six `%{name, value}`, drawn clockwise from the top. Names up to 14 characters. |
highlight |
integer | 0 |
Index of the lime segment. A negative index counts from the end. |
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/donut.ex
defmodule EHVisuals.Charts.Donut do
@moduledoc """
A donut chart card: the total in the centre, the shares in a legend, and one
segment picked out in lime.
"""
use EHVisuals.Composition,
name: "charts/donut",
title: "Donut chart",
description:
"A donut chart card with the total in the centre, a legend of shares and one segment in lime."
@segments [
%{name: "EDI", value: 590},
%{name: "Email", value: 347},
%{name: "Portal", value: 231},
%{name: "Phone", value: 116}
]
# The ring: centre, radius of the middle of the stroke, and the gap between
# two segments in degrees.
@cx 98
@cy 138
@r 55
@gap 3
attr(:title, :string, default: "Orders by channel")
attr(:caption, :string, default: "This month")
attr(:total, :any,
default: nil,
doc: "The text in the centre, up to 6 characters. `nil` writes the sum of the values."
)
attr(:total_label, :string, default: "Orders", doc: "Under the total, up to 12 characters.")
attr(:segments, :list,
default: @segments,
doc: "Two to six `%{name, value}`, drawn clockwise from the top. Names up to 14 characters."
)
attr(:highlight, :integer,
default: 0,
doc: "Index of the lime segment. A negative index counts from the end."
)
attr(:label, :string, default: "A donut chart of orders by channel, the largest share in lime")
frame_attrs()
@spec charts_donut(map()) :: Phoenix.LiveView.Rendered.t()
def charts_donut(assigns) do
segments = assigns.segments |> Enum.take(6) |> Enum.filter(&(&1.value > 0))
sum = segments |> Enum.map(& &1.value) |> Enum.sum() |> max(1)
assigns =
assigns
|> assign(:items, items(segments, sum, assigns.highlight))
|> assign(:centre, assigns.total || delimit(sum))
|> assign(:cx, @cx)
|> assign(:cy, @cy)
~H"""
<.frame name="charts/donut" label={@label} {frame_opts(assigns)}>
<svg viewBox="0 0 360 228">
<g class="ehv-h-lift">
<rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="359" height="227" 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-mono ehv-a-fade ehv-i-1" x="340" y="34" font-size="9" text-anchor="end">
{String.upcase(@caption)}
</text>
<path
:for={item <- @items}
class={["ehv-charts-donut__seg ehv-a-draw ehv-i-#{item.step}", item.tone]}
d={item.arc}
pathLength="1"
/>
<g class="ehv-a-fade ehv-i-4">
<text class="ehv-t-display" x={@cx} y={@cy + 5} font-size="22" text-anchor="middle">
{@centre}
</text>
<text class="ehv-mono" x={@cx} y={@cy + 19} font-size="7.5" text-anchor="middle">
{String.upcase(@total_label)}
</text>
</g>
<g :for={item <- @items} class={"ehv-a-rise ehv-i-#{item.step + 1}"}>
<line
:if={item.index > 0}
class="ehv-line-faint"
x1="188"
x2="340"
y1={item.y - item.pitch / 2}
y2={item.y - item.pitch / 2}
/>
<rect
class={["ehv-charts-donut__key", item.key]}
x="188"
y={item.y - 4}
width="8"
height="8"
rx="2.5"
/>
<text class={[!item.on && "ehv-t-2"]} x="205" y={item.y + 4} font-size="12">
{item.name}
</text>
<text
class={["ehv-mono", if(item.on, do: "ehv-t-lime", else: "ehv-t-2")]}
x="340"
y={item.y + 3.5}
font-size="9"
text-anchor="end"
>
{item.share}%
</text>
</g>
</g>
</svg>
</.frame>
"""
end
# One entry per segment: its arc, its tone and its legend row. The tones
# step down from the first neutral segment, the highlighted one is lime.
defp items(segments, sum, highlight) do
count = length(segments)
on = if count > 0, do: Integer.mod(highlight, count), else: -1
pitch = min(28, 148 / max(count, 1))
gap = if count > 1, do: @gap, else: 0
{items, _angle, _tone} =
segments
|> Enum.with_index()
|> Enum.reduce({[], 0.0, 1}, fn {segment, index}, {acc, angle, tone} ->
sweep = segment.value / sum * 360
from = angle + gap / 2
to = max(angle + sweep - gap / 2, from + 0.5)
item = %{
index: index,
name: segment.name,
share: round(segment.value / sum * 100),
on: index == on,
arc: arc(from, min(to, from + 359.5)),
tone:
if(index == on,
do: "ehv-charts-donut__seg--on",
else: "ehv-charts-donut__seg--#{tone}"
),
key:
if(index == on,
do: "ehv-charts-donut__key--on",
else: "ehv-charts-donut__key--#{tone}"
),
y: Float.round(@cy - (count - 1) * pitch / 2 + index * pitch, 1),
pitch: pitch,
step: min(index + 2, 24)
}
{[item | acc], angle + sweep, if(index == on, do: tone, else: tone + 1)}
end)
Enum.reverse(items)
end
# An arc of the ring between two angles, in degrees clockwise from the top.
defp arc(from, to) do
{x1, y1} = point(from)
{x2, y2} = point(to)
large = if to - from > 180, do: 1, else: 0
"M#{x1} #{y1} A#{@r} #{@r} 0 #{large} 1 #{x2} #{y2}"
end
defp point(degrees) do
radians = degrees * :math.pi() / 180
{Float.round(@cx + @r * :math.sin(radians), 2), Float.round(@cy - @r * :math.cos(radians), 2)}
end
defp delimit(number) do
number
|> round()
|> Integer.to_string()
|> String.replace(~r/(?<=\d)(?=(\d{3})+$)/, ",")
end
end
assets/css/charts/donut.css
/* charts/donut */
.ehv-charts-donut { --ehv-max-w: 390px; --ehv-step: 110ms; }
/* Segments are strokes. The neutral ones step down in ink, one is lime. */
.ehv-charts-donut__seg { fill: none; stroke: var(--ehv-ink); stroke-width: 17; }
.ehv-charts-donut__seg--1 { stroke-opacity: 0.6; }
.ehv-charts-donut__seg--2 { stroke-opacity: 0.38; }
.ehv-charts-donut__seg--3 { stroke-opacity: 0.24; }
.ehv-charts-donut__seg--4 { stroke-opacity: 0.15; }
.ehv-charts-donut__seg--5 { stroke-opacity: 0.09; }
.ehv-charts-donut__seg--6 { stroke-opacity: 0.06; }
.ehv-charts-donut__seg--on { stroke: var(--ehv-lime-fill); }
.ehv-charts-donut__key { fill: var(--ehv-ink); }
.ehv-charts-donut__key--1 { fill-opacity: 0.6; }
.ehv-charts-donut__key--2 { fill-opacity: 0.38; }
.ehv-charts-donut__key--3 { fill-opacity: 0.24; }
.ehv-charts-donut__key--4 { fill-opacity: 0.15; }
.ehv-charts-donut__key--5 { fill-opacity: 0.09; }
.ehv-charts-donut__key--6 { fill-opacity: 0.06; }
.ehv-charts-donut__key--on { fill: var(--ehv-lime-fill); }
.ehv--hover .ehv-charts-donut__seg { transition: stroke-width 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-charts-donut__seg--on { stroke-width: 22; }