charts/line.ex
Line chart
A line chart card with a soft area, a target line and a live last point.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.charts_line animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
title |
string | "Requests handled without a person" |
|
value |
string | "52%" |
|
caption |
string | "12 months" |
|
values |
list | [18, 22, 21, 27, 25, 31, ...] |
|
ticks |
list | ["Jan", "Apr", "Jul", "Oct"] |
Labels under the plot, spread evenly. |
target |
any | 45 |
A value on the series scale, drawn as a dashed lime line. `nil` hides it. |
target_label |
string | "Target" |
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/line.ex
defmodule EHVisuals.Charts.Line do
@moduledoc """
A line chart card with a soft area, a target line and a live last point.
"""
use EHVisuals.Composition,
name: "charts/line",
title: "Line chart",
description: "A line chart card with a soft area, a target line and a live last point."
attr(:title, :string, default: "Requests handled without a person")
attr(:value, :string, default: "52%")
attr(:caption, :string, default: "12 months")
attr(:values, :list, default: [18, 22, 21, 27, 25, 31, 30, 36, 41, 39, 46, 52])
attr(:ticks, :list, default: ~w(Jan Apr Jul Oct), doc: "Labels under the plot, spread evenly.")
attr(:target, :any,
default: 45,
doc: "A value on the series scale, drawn as a dashed lime line. `nil` hides it."
)
attr(:target_label, :string, default: "Target")
attr(:label, :string, default: "A line chart climbing past its target")
frame_attrs()
@spec charts_line(map()) :: Phoenix.LiveView.Rendered.t()
def charts_line(assigns) do
{points, target_y} = plot(assigns.values, assigns.target)
line = Enum.map_join(points, " ", fn {x, y} -> "#{x},#{y}" end)
assigns =
assigns
|> assign(:line, line)
|> assign(:area, "20,196 #{line} 340,196")
|> assign(:last, List.last(points))
|> assign(:target_y, target_y)
|> assign(:tick_xs, tick_xs(assigns.ticks))
~H"""
<.frame name="charts/line" label={@label} {frame_opts(assigns)}>
<svg viewBox="0 0 360 240">
<g class="ehv-h-lift">
<rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="359" height="239" rx="12" />
<text class="ehv-t-2 ehv-a-fade ehv-i-1" x="20" y="34" font-size="12">{@title}</text>
<text class="ehv-t-display ehv-a-fade ehv-i-2" x="20" y="64" font-size="24">{@value}</text>
<text class="ehv-mono ehv-a-fade ehv-i-2" x="340" y="64" font-size="9" text-anchor="end">
{String.upcase(@caption)}
</text>
<line
:for={y <- [88.5, 124.5, 160.5]}
class="ehv-line-faint"
x1="20"
x2="340"
y1={y}
y2={y}
/>
<line class="ehv-line-soft" x1="20" x2="340" y1="196.5" y2="196.5" />
<polygon class="ehv-charts-line__area ehv-a-fade ehv-i-8" points={@area} />
<g :if={@target_y} class="ehv-a-fade ehv-i-6">
<line class="ehv-line-lime ehv-dash" x1="20" x2="340" y1={@target_y} y2={@target_y} />
<text class="ehv-mono ehv-t-lime" x="22" y={@target_y - 6} font-size="8">
{String.upcase(@target_label)}
</text>
</g>
<polyline class="ehv-line-ink ehv-round ehv-a-draw ehv-i-3" points={@line} pathLength="1" />
<circle
class="ehv-fill-lime ehv-a-ping ehv-i-12"
cx={elem(@last, 0)}
cy={elem(@last, 1)}
r="4"
/>
<circle
class="ehv-charts-line__dot ehv-fill-lime ehv-a-pop ehv-i-12"
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="216"
font-size="8"
>
{String.upcase(tick)}
</text>
</g>
</svg>
</.frame>
"""
end
# The plot is 320 wide from x = 20, and runs from y = 92 down to y = 190,
# leaving air above the highest point and below the lowest.
defp plot(values, target) do
{min, max} = Enum.min_max(values)
{min, max} = if is_number(target), do: {min(min, target), max(max, target)}, else: {min, max}
range = if max == min, do: 1, else: max - min
step = 320 / max(length(values) - 1, 1)
y = fn value -> Float.round(190 - (value - min) / range * 98, 1) end
points =
values
|> Enum.with_index()
|> Enum.map(fn {value, index} -> {Float.round(20 + index * step, 1), y.(value)} end)
{points, if(is_number(target), do: y.(target))}
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/charts/line.css
/* charts/line */
.ehv-charts-line { --ehv-max-w: 380px; }
.ehv-charts-line__area { fill: var(--ehv-ink); opacity: 0.05; }
.ehv--hover .ehv-charts-line__dot { transition: r 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-charts-line__dot { r: 5.5; }