empowered.house ↗

metrics/trend.ex

Trend

One metric with its trend line and the change against the previous period, drawn as a dashed line.

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

<div class="h-96 rounded-xl border border-border">
  <.metrics_trend animated />
</div>
default
isometric
negative change · custom copy
hover · no glow
light surface
no previous period · isometric · fade out · still

Props

Prop Type Default What it does
title string "Orders handled a day"
value string "62"
change string "+31.9%" The change against the previous period. A leading minus turns it coral.
periods list ["This month", "Last month"] The names of the two periods, the current one first. Up to 12 characters each.
values list [31, 34, 33, 38, 37, 41, ...] The current period, oldest first.
previous list [29, 30, 33, 32, 35, 34, ...] The previous period on the same scale, drawn dashed. An empty list hides it.
ticks list ["1", "8", "15", "22"] Labels under the plot, spread evenly.
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/trend.ex
trend.ex
defmodule EHVisuals.Metrics.Trend do
  @moduledoc """
  One metric over a period: the value, the change against the period before,
  and both periods as lines, the earlier one dashed.
  """

  use EHVisuals.Composition,
    name: "metrics/trend",
    title: "Trend",
    description:
      "One metric with its trend line and the change against the previous period, drawn as a dashed line."

  @values [31, 34, 33, 38, 37, 41, 44, 43, 47, 52, 50, 55, 58, 62]
  @previous [29, 30, 33, 32, 35, 34, 37, 39, 38, 41, 43, 42, 45, 47]

  attr(:title, :string, default: "Orders handled a day")
  attr(:value, :string, default: "62")

  attr(:change, :string,
    default: "+31.9%",
    doc: "The change against the previous period. A leading minus turns it coral."
  )

  attr(:periods, :list,
    default: ["This month", "Last month"],
    doc: "The names of the two periods, the current one first. Up to 12 characters each."
  )

  attr(:values, :list, default: @values, doc: "The current period, oldest first.")

  attr(:previous, :list,
    default: @previous,
    doc: "The previous period on the same scale, drawn dashed. An empty list hides it."
  )

  attr(:ticks, :list, default: ~w(1 8 15 22), doc: "Labels under the plot, spread evenly.")

  attr(:label, :string,
    default: "A metric rising through the month, above the dashed line of the month before"
  )

  frame_attrs()

  @spec metrics_trend(map()) :: Phoenix.LiveView.Rendered.t()
  def metrics_trend(assigns) do
    {current, previous} = plot(assigns.values, assigns.previous)
    [now, before] = assigns.periods |> Enum.concat(["", ""]) |> Enum.take(2)
    before_w = String.length(before) * 5.5
    now_w = String.length(now) * 5.5
    badge_w = Float.round(30 + String.length(assigns.change) * 6.3, 1)

    assigns =
      assigns
      |> assign(:down, String.starts_with?(assigns.change, "-"))
      |> assign(:line, path(current))
      |> assign(:dashed, path(previous))
      |> assign(:last, List.last(current))
      |> assign(:last_before, List.last(previous))
      |> assign(:now, now)
      |> assign(:before, before)
      |> assign(:before_x, Float.round(340 - before_w - 18, 1))
      |> assign(
        :now_x,
        Float.round(340 - now_w - 18 - if(previous == [], do: 0, else: before_w + 32), 1)
      )
      |> assign(:badge_w, badge_w)
      |> assign(:tick_xs, tick_xs(assigns.ticks))

    ~H"""
    <.frame name="metrics/trend" label={@label} {frame_opts(assigns)}>
      <svg viewBox="0 0 360 236">
        <g class="ehv-h-lift">
          <rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="359" height="235" rx="12" />
          <text class="ehv-t-2 ehv-a-fade ehv-i-1" x="20" y="34" font-size="12">{@title}</text>
          <g class="ehv-a-fade ehv-i-1">
            <line
              class={[tone(@down), "ehv-round"]}
              x1={@now_x}
              x2={@now_x + 12}
              y1="30.5"
              y2="30.5"
            />
            <text class="ehv-mono ehv-t-2" x={@now_x + 18} y="33.5" font-size="8">
              {String.upcase(@now)}
            </text>
            <g :if={@dashed}>
              <line
                class="ehv-metrics-trend__previous"
                x1={@before_x}
                x2={@before_x + 12}
                y1="30.5"
                y2="30.5"
              />
              <text class="ehv-mono" x="340" y="33.5" font-size="8" text-anchor="end">
                {String.upcase(@before)}
              </text>
            </g>
          </g>
          <text class="ehv-t-display ehv-a-fade ehv-i-2" x="20" y="70" font-size="30">{@value}</text>
          <g class="ehv-a-fade ehv-i-3">
            <rect
              class={if @down, do: "ehv-fill-coral-soft", else: "ehv-fill-lime-soft"}
              x="20"
              y="82"
              width={@badge_w}
              height="22"
              rx="6"
            />
            <path
              class={if @down, do: "ehv-fill-coral", else: "ehv-metrics-trend__up"}
              d={if @down, do: "M29 90 h8 l-4 6 z", else: "M29 96 h8 l-4 -6 z"}
            />
            <text
              class={["ehv-mono", if(@down, do: "ehv-t-coral", else: "ehv-t-lime")]}
              x="43"
              y="97"
              font-size="10"
            >
              {@change}
            </text>
            <text :if={@dashed} class="ehv-t-3" x={28 + @badge_w} y="97" font-size="11.5">
              vs {lower_first(@before)}
            </text>
          </g>
          <line
            :for={y <- [124.5, 148.5, 172.5]}
            class="ehv-line-faint"
            x1="20"
            x2="340"
            y1={y}
            y2={y}
          />
          <line class="ehv-line-soft" x1="20" x2="340" y1="200.5" y2="200.5" />
          <polyline
            :if={@dashed}
            class="ehv-metrics-trend__previous ehv-a-fade ehv-i-4"
            points={@dashed}
          />
          <line
            :if={@dashed}
            class={[tone(@down), "ehv-dash ehv-a-fade ehv-i-14"]}
            x1="340"
            x2="340"
            y1={elem(@last, 1)}
            y2={elem(@last_before, 1)}
          />
          <circle
            :if={@dashed}
            class="ehv-node-soft ehv-a-fade ehv-i-4"
            cx={elem(@last_before, 0)}
            cy={elem(@last_before, 1)}
            r="3"
          />
          <polyline
            class={["ehv-metrics-trend__line ehv-round ehv-a-draw ehv-i-5", tone(@down)]}
            points={@line}
            pathLength="1"
          />
          <circle
            class={[
              "ehv-metrics-trend__dot ehv-a-pop ehv-i-13",
              if(@down, do: "ehv-fill-coral", else: "ehv-fill-lime")
            ]}
            cx={elem(@last, 0)}
            cy={elem(@last, 1)}
            r="4"
          />
          <text
            :for={{tick, x} <- Enum.zip(@ticks, @tick_xs)}
            class="ehv-mono ehv-a-fade ehv-i-4"
            x={x}
            y="219"
            font-size="8"
          >
            {String.upcase(tick)}
          </text>
        </g>
      </svg>
    </.frame>
    """
  end

  # "Last month" reads "vs last month", "Q2" stays "vs Q2".
  defp lower_first(name) do
    case String.graphemes(name) do
      [first, second | rest] ->
        if second == String.downcase(second) and second != String.upcase(second),
          do: Enum.join([String.downcase(first), second | rest]),
          else: name

      _short ->
        name
    end
  end

  defp tone(true), do: "ehv-line-coral"
  defp tone(false), do: "ehv-line-lime"

  # Both periods share one scale. The plot is 320 wide from x = 20 and runs
  # from y = 122 down to y = 194.
  defp plot(values, previous) do
    all = values ++ previous
    {min, max} = Enum.min_max(all, fn -> {0, 1} end)
    range = if max == min, do: 1, else: max - min

    points = fn series ->
      step = 320 / max(length(series) - 1, 1)

      series
      |> Enum.with_index()
      |> Enum.map(fn {value, index} ->
        {Float.round(20 + index * step, 1), Float.round(194 - (value - min) / range * 72, 1)}
      end)
    end

    {points.(values), points.(previous)}
  end

  defp path([]), do: nil
  defp path(points), do: Enum.map_join(points, " ", fn {x, y} -> "#{x},#{y}" end)

  defp tick_xs(ticks) do
    step = 320 / max(length(ticks), 1)
    for index <- 0..(length(ticks) - 1)//1, do: Float.round(20 + index * step, 1)
  end
end
assets/css/metrics/trend.css
trend.css
/* metrics/trend */
.ehv-metrics-trend { --ehv-max-w: 390px; }

.ehv-metrics-trend__line { stroke-width: 1.6; }

/* The period before: same scale, dashed and a tone back. */
.ehv-metrics-trend__previous {
  fill: none;
  stroke: var(--ehv-ink-3);
  stroke-width: 1.25;
  stroke-dasharray: 3 4;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.ehv--hover .ehv-metrics-trend__dot { transition: r 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-metrics-trend__dot { r: 5.5; }

/* The arrow in the badge is drawn in the line colour, so it holds on cream. */
.ehv-metrics-trend__up { fill: var(--ehv-lime); }