empowered.house ↗

charts/sparkline.ex

Sparkline

A stat card with an inline sparkline, for tight spaces and metric rows.

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

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

Props

Prop Type Default What it does
title string "Orders handled"
value string "1,284"
change string "+12.5%" A leading minus turns the badge coral.
values list [12, 14, 13, 17, 16, 21, ...] The series, oldest first.
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/sparkline.ex
sparkline.ex
defmodule EHVisuals.Charts.Sparkline do
  @moduledoc """
  A stat card with an inline sparkline, for tight spaces and metric rows.
  """

  use EHVisuals.Composition,
    name: "charts/sparkline",
    title: "Sparkline",
    description: "A stat card with an inline sparkline, for tight spaces and metric rows."

  @default_values [12, 14, 13, 17, 16, 21, 19, 24, 23, 28, 27, 33]

  attr(:title, :string, default: "Orders handled")
  attr(:value, :string, default: "1,284")
  attr(:change, :string, default: "+12.5%", doc: "A leading minus turns the badge coral.")
  attr(:values, :list, default: @default_values, doc: "The series, oldest first.")
  attr(:label, :string, default: "A stat card with a rising sparkline")
  frame_attrs()

  @spec charts_sparkline(map()) :: Phoenix.LiveView.Rendered.t()
  def charts_sparkline(assigns) do
    points = points(assigns.values, 172, 40, 124, 52)

    assigns =
      assigns
      |> assign(:down, String.starts_with?(assigns.change, "-"))
      |> assign(:line, Enum.map_join(points, " ", fn {x, y} -> "#{x},#{y}" end))
      |> assign(:last, List.last(points))
      |> assign(:badge_w, 30 + String.length(assigns.change) * 6.3)

    ~H"""
    <.frame name="charts/sparkline" label={@label} {frame_opts(assigns)}>
      <svg viewBox="0 0 320 132">
        <g class="ehv-h-lift">
          <rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="319" height="131" rx="12" />
          <text class="ehv-t-2 ehv-a-fade ehv-i-2" x="20" y="34" font-size="12">{@title}</text>
          <text class="ehv-t-display ehv-a-fade ehv-i-3" x="20" y="72" font-size="30">{@value}</text>
          <g class="ehv-a-fade ehv-i-5">
            <rect
              class={if @down, do: "ehv-fill-coral-soft", else: "ehv-fill-lime-soft"}
              x="20"
              y="90"
              width={@badge_w}
              height="22"
              rx="6"
            />
            <path
              class={if @down, do: "ehv-fill-coral", else: "ehv-fill-lime"}
              d={if @down, do: "M29 98 h8 l-4 6 z", else: "M29 104 h8 l-4 -6 z"}
            />
            <text
              class={["ehv-mono", if(@down, do: "ehv-t-coral", else: "ehv-t-lime")]}
              x="43"
              y="105"
              font-size="10"
            >
              {@change}
            </text>
          </g>
          <line class="ehv-line-faint ehv-a-fade ehv-i-4" x1="172" y1="92.5" x2="296" y2="92.5" />
          <polyline
            class="ehv-line-ink ehv-round ehv-a-draw ehv-i-4"
            points={@line}
            pathLength="1"
          />
          <circle
            class={[
              "ehv-charts-sparkline__dot ehv-a-pop ehv-i-12",
              if(@down, do: "ehv-fill-coral", else: "ehv-fill-lime")
            ]}
            cx={elem(@last, 0)}
            cy={elem(@last, 1)}
            r="3.5"
          />
        </g>
      </svg>
    </.frame>
    """
  end

  # Scales the series into the box at {x, y} of size {w, h}, y axis flipped.
  defp points(values, x, y, w, h) do
    {min, max} = Enum.min_max(values)
    range = if max == min, do: 1, else: max - min
    step = w / max(length(values) - 1, 1)

    values
    |> Enum.with_index()
    |> Enum.map(fn {value, index} ->
      {Float.round(x + index * step, 1), Float.round(y + h - (value - min) / range * h, 1)}
    end)
  end
end
assets/css/charts/sparkline.css
sparkline.css
/* charts/sparkline */
.ehv-charts-sparkline { --ehv-max-w: 340px; }

.ehv--hover .ehv-charts-sparkline__dot { transition: r 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-charts-sparkline__dot { r: 5; }