activity/timeline.ex
Timeline
A vertical activity timeline of events and status changes grouped under day headings.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.activity_timeline animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
title |
string | "Activity" |
|
caption |
string | "Order SO-1042" |
|
days |
list | [%{name: "Today", events: [%{time: "14:32", text: "Invoice INV-884 posted to the ERP", ...}, ...]}, ...] |
Up to three `%{name, events}`, newest first, eight events in all. An event is `%{time, text}` with text up to 34 characters. Optional `from` and `to` (up to 10 characters each) draw a status change under it, optional `tone: "risk"` turns it 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/activity/timeline.ex
defmodule EHVisuals.Activity.Timeline do
@moduledoc """
A vertical activity timeline: events and status changes on a rail, grouped
under day headings, the newest one first.
"""
use EHVisuals.Composition,
name: "activity/timeline",
title: "Timeline",
description:
"A vertical activity timeline of events and status changes grouped under day headings."
@days [
%{
name: "Today",
events: [
%{
time: "14:32",
text: "Invoice INV-884 posted to the ERP",
from: "Approved",
to: "Posted"
},
%{time: "13:05", text: "Marta Nowak approved the order"},
%{time: "09:41", text: "Intake agent matched the contract"}
]
},
%{
name: "Yesterday",
events: [
%{
time: "16:20",
text: "Stock check failed for 2 lines",
from: "Checking",
to: "Blocked",
tone: "risk"
},
%{time: "11:02", text: "Order SO-1042 received by mail"}
]
}
]
# Vertical rhythm: a day heading, an event of one line, an event that also
# carries a status change.
@heading 26
@plain 30
@changed 48
attr(:title, :string, default: "Activity")
attr(:caption, :string, default: "Order SO-1042")
attr(:days, :list,
default: @days,
doc:
"Up to three `%{name, events}`, newest first, eight events in all. An event is `%{time, text}` with text up to 34 characters. Optional `from` and `to` (up to 10 characters each) draw a status change under it, optional `tone: \"risk\"` turns it coral."
)
attr(:label, :string,
default: "A timeline of an order over two days, ending with its invoice posted to the ERP"
)
frame_attrs()
@spec activity_timeline(map()) :: Phoenix.LiveView.Rendered.t()
def activity_timeline(assigns) do
{groups, bottom} = layout(assigns.days)
assigns =
assigns
|> assign(:groups, groups)
|> assign(:height, bottom + 12)
~H"""
<.frame name="activity/timeline" label={@label} {frame_opts(assigns)}>
<svg viewBox={"0 0 340 #{@height}"}>
<g class="ehv-h-lift">
<rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="339" 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="320" y="35" font-size="9" text-anchor="end">
{String.upcase(@caption)}
</text>
<line class="ehv-line-faint" x1="20" x2="320" y1="52.5" y2="52.5" />
<g :for={group <- @groups}>
<g class={"ehv-a-fade ehv-i-#{group.step}"}>
<text class="ehv-mono ehv-t-2" x="20" y={group.y + 16} font-size="8.5">
{String.upcase(group.name)}
</text>
<line
class="ehv-line-faint"
x1={group.rule}
x2="320"
y1={group.y + 12.5}
y2={group.y + 12.5}
/>
</g>
<g :for={event <- group.events}>
<line
:if={event.rail}
class={"ehv-line-soft ehv-a-fade ehv-i-#{event.step + 1}"}
x1="26"
x2="26"
y1={event.y + 22}
y2={event.rail}
/>
<g class={"ehv-a-rise ehv-i-#{event.step}"}>
<g :if={event.newest}>
<circle class="ehv-fill-lime ehv-a-ping" cx="26" cy={event.y + 14} r="4.5" />
<circle class="ehv-fill-lime" cx="26" cy={event.y + 14} r="4.5" />
</g>
<circle
:if={!event.newest and event.risk}
class="ehv-fill-coral"
cx="26"
cy={event.y + 14}
r="4"
/>
<circle
:if={!event.newest and !event.risk}
class="ehv-node-soft"
cx="26"
cy={event.y + 14}
r="4"
/>
<text class={[!event.newest && "ehv-t-2"]} x="44" y={event.y + 18} font-size="12.5">
{event.text}
</text>
<text class="ehv-mono" x="320" y={event.y + 17.5} font-size="8.5" text-anchor="end">
{event.time}
</text>
<g :if={event.change}>
<rect
class="ehv-activity-timeline__chip"
x="44"
y={event.y + 26}
width={event.change.from_w}
height="16"
rx="5"
/>
<text
class="ehv-mono ehv-t-2"
x={44 + event.change.from_w / 2}
y={event.y + 37}
font-size="7.5"
text-anchor="middle"
>
{String.upcase(event.change.from)}
</text>
<path
class="ehv-line-ink ehv-round"
d={"M#{event.change.arrow} #{event.y + 34} h8 m-3 -3 l3 3 l-3 3"}
/>
<rect
class={[
"ehv-activity-timeline__chip",
"ehv-activity-timeline__chip--#{event.change.tone}"
]}
x={event.change.to_x}
y={event.y + 26}
width={event.change.to_w}
height="16"
rx="5"
/>
<text
class={["ehv-mono", "ehv-activity-timeline__state--#{event.change.tone}"]}
x={event.change.to_x + event.change.to_w / 2}
y={event.y + 37}
font-size="7.5"
text-anchor="middle"
>
{String.upcase(event.change.to)}
</text>
</g>
</g>
</g>
</g>
</g>
</svg>
</.frame>
"""
end
# Walks the days top to bottom and gives every heading and event its y, so
# the height of the card follows from the data.
defp layout(days) do
{groups, {bottom, _count}} =
days
|> Enum.take(3)
|> Enum.map_reduce({60, 0}, fn day, {y, count} ->
events = day |> Map.get(:events, []) |> Enum.take(max(8 - count, 0))
heights = Enum.map(events, &if(change?(&1), do: @changed, else: @plain))
tops = Enum.scan([y + @heading | heights], &(&1 + &2)) |> Enum.take(length(events))
last = length(events) - 1
rows =
for {{event, top}, index} <- events |> Enum.zip(tops) |> Enum.with_index() do
%{
y: top,
time: Map.get(event, :time, ""),
text: event.text,
newest: count + index == 0,
risk: Map.get(event, :tone) == "risk",
rail: if(index < last, do: Enum.at(tops, index + 1) + 6),
change: change(event),
step: min(count + index + 3, 23)
}
end
group = %{
name: day.name,
y: y,
rule: Float.round(20 + String.length(day.name) * 5.9 + 10, 1),
step: min(count + 2, 23),
events: rows
}
{group, {y + @heading + Enum.sum(heights) + 4, count + length(events)}}
end)
{groups, bottom}
end
defp change?(event), do: is_binary(Map.get(event, :from)) and is_binary(Map.get(event, :to))
# The two chips of a status change, sized from their text.
defp change(event) do
if change?(event) do
from_w = Float.round(14 + String.length(event.from) * 5.1, 1)
%{
from: event.from,
to: event.to,
from_w: from_w,
arrow: 44 + from_w + 6,
to_x: 44 + from_w + 20,
to_w: Float.round(14 + String.length(event.to) * 5.1, 1),
tone: if(Map.get(event, :tone) == "risk", do: "risk", else: "ok")
}
end
end
end
assets/css/activity/timeline.css
/* activity/timeline */
.ehv-activity-timeline { --ehv-max-w: 360px; --ehv-step: 110ms; }
/* The state an event left is neutral, the state it reached is lime or coral. */
.ehv-activity-timeline__chip { fill: var(--ehv-surface-3); stroke: var(--ehv-border-soft); stroke-width: 1; }
.ehv-activity-timeline__chip--ok { fill: var(--ehv-lime-soft); stroke: none; }
.ehv-activity-timeline__chip--risk { fill: var(--ehv-coral-soft); stroke: none; }
.ehv svg .ehv-mono.ehv-activity-timeline__state--ok { fill: var(--ehv-lime); }
.ehv svg .ehv-mono.ehv-activity-timeline__state--risk { fill: var(--ehv-coral); }