empowered.house ↗

charts/funnel.ex

Funnel

A conversion funnel of tapering stage slabs with the count per stage and the drop-off between stages.

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

<div class="h-96 rounded-xl border border-border">
  <.charts_funnel animated />
</div>
default
isometric
three stages · custom copy
six stages · hover · no glow
light surface
isometric · fade out · still

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