empowered.house ↗

metrics/comparison.ex

Comparison

Two or three metrics before and after a change, each as a pair of bars with its delta.

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

<div class="h-96 rounded-xl border border-border">
  <.metrics_comparison animated />
</div>
default
isometric
two metrics · custom copy
one got worse · hover · no glow
light surface
isometric · fade out · still

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