empowered.house ↗

charts/donut.ex

Donut chart

A donut chart card with the total in the centre, a legend of shares and one segment in lime.

Use it
# in the module that renders the template
use EHVisuals

<div class="h-96 rounded-xl border border-border">
  <.charts_donut animated />
</div>
default
isometric
six segments · highlight the third · custom copy
two segments · hover · no glow
light surface
isometric · fade out · still

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
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
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; }