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.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.dashboard_widget_grid animated />
</div>
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
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
/* 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); }