empowered.house ↗

dashboard/widget-grid.ex

Widget grid

A dashboard of four widgets in one frame: a stat, a small bar chart, a short list and a progress ring.

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

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

Props

Prop Type Default What it does
title string "Operations"
caption string "Live · 14:32"
stat map %{label: "Open orders", value: "318", values: [22, 24, 23, ...], ...} `%{label, value, change, values}`. The value takes up to 6 characters, a leading minus in `change` turns it coral, `values` is the sparkline.
bars map %{label: "Shipped per day", value: "142 today", values: [96, 118, 104, ...], ...} `%{label, value, values, labels}`. Three to ten bars, the last one lime, with one short label each.
list map %{label: "Waiting for a person", items: [%{meta: "2 h", text: "Approve PO-2291", tone: "wait"}, ...]} `%{label, items}` with up to three `%{text, meta, tone}`. Text up to 20 characters. Tone is `wait`, `risk` or anything else for neutral.
progress map %{label: "Month-end close", value: "17 of 25", percent: 68, note: "tasks done"} `%{label, percent, value, note}`. Percent is 0 to 100, `value` takes up to 9 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/dashboard/widget-grid.ex
widget-grid.ex
defmodule EHVisuals.Dashboard.WidgetGrid do
  @moduledoc """
  A dashboard in one frame: a stat with its sparkline, a small bar chart, a
  short list and a progress ring.
  """

  use EHVisuals.Composition,
    name: "dashboard/widget-grid",
    title: "Widget grid",
    description:
      "A dashboard of four widgets in one frame: a stat, a small bar chart, a short list and a progress ring."

  @stat %{
    label: "Open orders",
    value: "318",
    change: "+4.2%",
    values: [22, 24, 23, 27, 26, 30, 29, 33, 36]
  }

  @bars %{
    label: "Shipped per day",
    value: "142 today",
    values: [96, 118, 104, 127, 121, 88, 142],
    labels: ~w(M T W T F S S)
  }

  @list %{
    label: "Waiting for a person",
    items: [
      %{text: "Approve PO-2291", meta: "2 h", tone: "wait"},
      %{text: "Confirm stock count", meta: "5 h", tone: "wait"},
      %{text: "Check invoice INV-884", meta: "1 d", tone: "risk"}
    ]
  }

  @progress %{label: "Month-end close", percent: 68, value: "17 of 25", note: "tasks done"}

  attr(:title, :string, default: "Operations")
  attr(:caption, :string, default: "Live · 14:32")

  attr(:stat, :map,
    default: @stat,
    doc:
      "`%{label, value, change, values}`. The value takes up to 6 characters, a leading minus in `change` turns it coral, `values` is the sparkline."
  )

  attr(:bars, :map,
    default: @bars,
    doc:
      "`%{label, value, values, labels}`. Three to ten bars, the last one lime, with one short label each."
  )

  attr(:list, :map,
    default: @list,
    doc:
      "`%{label, items}` with up to three `%{text, meta, tone}`. Text up to 20 characters. Tone is `wait`, `risk` or anything else for neutral."
  )

  attr(:progress, :map,
    default: @progress,
    doc:
      "`%{label, percent, value, note}`. Percent is 0 to 100, `value` takes up to 9 characters."
  )

  attr(:label, :string,
    default:
      "An operations dashboard with open orders, shipments per day, a list of waiting tasks and the progress of the month-end close"
  )

  frame_attrs()

  @spec dashboard_widget_grid(map()) :: Phoenix.LiveView.Rendered.t()
  def dashboard_widget_grid(assigns) do
    stat = assigns.stat
    change = Map.get(stat, :change, "")
    spark = points(Map.get(stat, :values, []), 132, 100, 76, 46)
    percent = assigns.progress |> Map.get(:percent, 0) |> max(0) |> min(100)
    items = assigns.list |> Map.get(:items, []) |> Enum.take(3) |> Enum.with_index()

    assigns =
      assigns
      |> assign(:down, String.starts_with?(change, "-"))
      |> assign(:change, change)
      |> assign(:badge_w, Float.round(27 + String.length(change) * 5.7, 1))
      |> assign(:spark, Enum.map_join(spark, " ", fn {x, y} -> "#{x},#{y}" end))
      |> assign(:spark_end, List.last(spark))
      |> assign(:columns, columns(assigns.bars))
      |> assign(:items, items)
      |> assign(:waiting, length(items))
      |> assign(:percent, percent)
      |> assign(:ring, ring(percent))
      |> assign(:caption_w, Float.round(String.length(assigns.caption) * 5.9, 1))

    ~H"""
    <.frame name="dashboard/widget-grid" label={@label} {frame_opts(assigns)}>
      <svg viewBox="0 0 460 326">
        <g class="ehv-h-lift">
          <rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="459" height="325" rx="12" />
          <g class="ehv-a-fade ehv-i-1">
            <rect class="ehv-fill-lime" x="20" y="21" width="9" height="9" rx="3" />
            <text class="ehv-t-strong" x="38" y="30.5" font-size="14">{@title}</text>
            <circle class="ehv-fill-lime ehv-a-pulse" cx={440 - @caption_w - 9} cy="26" r="2.5" />
            <text class="ehv-mono" x="440" y="29" font-size="8.5" text-anchor="end">
              {String.upcase(@caption)}
            </text>
            <line class="ehv-line-soft" x1="0.5" x2="459.5" y1="49.5" y2="49.5" />
          </g>

          <g class="ehv-dashboard-widget-grid__widget ehv-a-rise ehv-i-2">
            <rect
              class="ehv-dashboard-widget-grid__tile"
              x="16"
              y="62"
              width="208"
              height="118"
              rx="9"
            />
            <text class="ehv-mono" x="30" y="85" font-size="8">
              {String.upcase(Map.get(@stat, :label, ""))}
            </text>
            <text class="ehv-t-display" x="30" y="125" font-size="28">
              {Map.get(@stat, :value, "")}
            </text>
            <g :if={@change != ""}>
              <rect
                class={if @down, do: "ehv-fill-coral-soft", else: "ehv-fill-lime-soft"}
                x="30"
                y="141"
                width={@badge_w}
                height="20"
                rx="6"
              />
              <path
                class={if @down, do: "ehv-fill-coral", else: "ehv-dashboard-widget-grid__up"}
                d={if @down, do: "M38 148.5 h7 l-3.5 5.5 z", else: "M38 154 h7 l-3.5 -5.5 z"}
              />
              <text
                class={["ehv-mono", if(@down, do: "ehv-t-coral", else: "ehv-t-lime")]}
                x="50"
                y="154.5"
                font-size="9"
              >
                {@change}
              </text>
            </g>
            <g :if={@spark_end}>
              <line class="ehv-line-faint" x1="132" x2="208" y1="160.5" y2="160.5" />
              <polyline
                class="ehv-line-ink ehv-round ehv-a-draw ehv-i-4"
                points={@spark}
                pathLength="1"
              />
              <circle
                class={[
                  "ehv-a-pop ehv-i-12",
                  if(@down, do: "ehv-fill-coral", else: "ehv-fill-lime")
                ]}
                cx={elem(@spark_end, 0)}
                cy={elem(@spark_end, 1)}
                r="3"
              />
            </g>
          </g>

          <g class="ehv-dashboard-widget-grid__widget ehv-a-rise ehv-i-3">
            <rect
              class="ehv-dashboard-widget-grid__tile"
              x="236"
              y="62"
              width="208"
              height="118"
              rx="9"
            />
            <text class="ehv-mono" x="250" y="85" font-size="8">
              {String.upcase(Map.get(@bars, :label, ""))}
            </text>
            <text class="ehv-mono ehv-t-ink" x="430" y="85" font-size="8" text-anchor="end">
              {String.upcase(Map.get(@bars, :value, ""))}
            </text>
            <line class="ehv-line-soft" x1="250" x2="430" y1="154.5" y2="154.5" />
            <g :for={column <- @columns}>
              <rect
                class={[
                  "ehv-dashboard-widget-grid__bar ehv-a-grow-y ehv-i-#{column.step}",
                  column.on && "ehv-dashboard-widget-grid__bar--on"
                ]}
                x={column.x}
                y={column.y}
                width={column.w}
                height={column.h}
                rx="2.5"
              />
              <text
                class={["ehv-mono", column.on && "ehv-t-ink"]}
                x={column.cx}
                y="168"
                font-size="7"
                text-anchor="middle"
              >
                {String.upcase(column.name)}
              </text>
            </g>
          </g>

          <g class="ehv-dashboard-widget-grid__widget ehv-a-rise ehv-i-4">
            <rect
              class="ehv-dashboard-widget-grid__tile"
              x="16"
              y="192"
              width="208"
              height="118"
              rx="9"
            />
            <text class="ehv-mono" x="30" y="215" font-size="8">
              {String.upcase(Map.get(@list, :label, ""))}
            </text>
            <text class="ehv-mono ehv-t-ink" x="210" y="215" font-size="8" text-anchor="end">
              {@waiting}
            </text>
            <g :for={{item, index} <- @items} class={"ehv-a-fade ehv-i-#{index + 6}"}>
              <line
                class="ehv-line-faint"
                x1="30"
                x2="210"
                y1={226.5 + index * 26}
                y2={226.5 + index * 26}
              />
              <circle class={dot(Map.get(item, :tone))} cx="34" cy={240 + index * 26} r="3" />
              <text x="46" y={244 + index * 26} font-size="11.5">{item.text}</text>
              <text
                class={["ehv-mono", Map.get(item, :tone) == "risk" && "ehv-t-coral"]}
                x="210"
                y={243.5 + index * 26}
                font-size="8"
                text-anchor="end"
              >
                {String.upcase(Map.get(item, :meta, ""))}
              </text>
            </g>
          </g>

          <g class="ehv-dashboard-widget-grid__widget ehv-a-rise ehv-i-5">
            <rect
              class="ehv-dashboard-widget-grid__tile"
              x="236"
              y="192"
              width="208"
              height="118"
              rx="9"
            />
            <text class="ehv-mono" x="250" y="215" font-size="8">
              {String.upcase(Map.get(@progress, :label, ""))}
            </text>
            <circle class="ehv-dashboard-widget-grid__track" cx="284" cy="264" r="27" />
            <path
              :if={@ring}
              class="ehv-dashboard-widget-grid__ring ehv-a-draw ehv-i-7"
              d={@ring}
              pathLength="1"
            />
            <text class="ehv-mono ehv-t-ink" x="284" y="267.5" font-size="10" text-anchor="middle">
              {@percent}%
            </text>
            <text class="ehv-t-display" x="330" y="263" font-size="19">
              {Map.get(@progress, :value, "")}
            </text>
            <text class="ehv-t-3" x="330" y="281" font-size="11.5">
              {Map.get(@progress, :note, "")}
            </text>
          </g>
        </g>
      </svg>
    </.frame>
    """
  end

  defp dot("wait"), do: "ehv-fill-lime"
  defp dot("risk"), do: "ehv-fill-coral"
  defp dot(_tone), do: "ehv-fill-4"

  # Scales a series into the box at {x, y} of size {w, h}, y axis flipped.
  defp points([], _x, _y, _w, _h), do: []

  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

  # The bars stand on y = 154 between x = 250 and x = 430, up to 52 tall.
  defp columns(bars) do
    values = bars |> Map.get(:values, []) |> Enum.take(10)
    names = Map.get(bars, :labels, [])
    count = max(length(values), 1)
    top = max(Enum.max(values, fn -> 1 end), 1)
    slot = 180 / count
    width = Float.round(slot * 0.56, 1)

    for {value, index} <- Enum.with_index(values) do
      height = Float.round(max(value / top * 52, 2), 1)

      %{
        x: Float.round(250 + index * slot + (slot - width) / 2, 1),
        y: Float.round(154 - height, 1),
        w: width,
        h: height,
        cx: Float.round(250 + index * slot + slot / 2, 1),
        on: index == count - 1,
        name: Enum.at(names, index, ""),
        step: min(index + 4, 24)
      }
    end
  end

  # The progress arc, clockwise from the top of the ring.
  defp ring(percent) when percent <= 0, do: nil

  defp ring(percent) do
    angle = min(percent, 99.9) / 100 * 2 * :math.pi()
    x = Float.round(284 + 27 * :math.sin(angle), 2)
    y = Float.round(264 - 27 * :math.cos(angle), 2)
    "M284 237 A27 27 0 #{if percent > 50, do: 1, else: 0} 1 #{x} #{y}"
  end
end
assets/css/dashboard/widget-grid.css
widget-grid.css
/* dashboard/widget-grid */
.ehv-dashboard-widget-grid { --ehv-max-w: 510px; --ehv-step: 80ms; }

.ehv-dashboard-widget-grid__tile {
  fill: var(--ehv-surface-2);
  stroke: var(--ehv-border-soft);
  stroke-width: 1;
}

.ehv-dashboard-widget-grid__bar {
  fill: var(--ehv-surface-3);
  stroke: var(--ehv-border-strong);
  stroke-width: 1;
}
.ehv-dashboard-widget-grid__bar--on { fill: var(--ehv-lime-fill); stroke: none; }

.ehv-dashboard-widget-grid__track { fill: none; stroke: var(--ehv-border-soft); stroke-width: 7; }
.ehv-dashboard-widget-grid__ring {
  fill: none;
  stroke: var(--ehv-lime-fill);
  stroke-width: 7;
  stroke-linecap: round;
}

.ehv--hover .ehv-dashboard-widget-grid__tile { transition: stroke 200ms var(--ehv-ease); }
.ehv--hover .ehv-dashboard-widget-grid__widget:hover .ehv-dashboard-widget-grid__tile { stroke: var(--ehv-lime); }

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