charts/block-stack.ex
Block stack
An isometric bar chart whose columns are stacks of blocks, with one column in lime.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.charts_block_stack animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
title |
string | "Orders shipped per week" |
|
value |
string | "620" |
|
caption |
string | "1 block · 100 orders" |
|
values |
list | [2, 3, 3, 4, 5, 6] |
Blocks per column, left to right. Three to eight columns of 0 to 8 blocks. |
labels |
list | ["W1", "W2", "W3", "W4", "W5", "W6"] |
One label per column, up to 4 characters. |
highlight |
integer | -1 |
Index of the lime column. A negative index counts from the end. |
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. |
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/block-stack.ex
defmodule EHVisuals.Charts.BlockStack do
@moduledoc """
An isometric bar chart: every column is a stack of blocks standing on a
plate, and one column is lime.
The scene is drawn in isometric projection. The plate is a rectangle in
plan coordinates: `u` runs along the row of columns, up and to the right
on screen, and `v` runs towards the viewer, down and to the right. A plan
point at height `z` lands on screen at `{ox + u + v, oy - u / 2 + v / 2 - z}`,
and the labels lie on the plate through the matching matrix.
"""
use EHVisuals.Composition,
name: "charts/block-stack",
title: "Block stack",
description:
"An isometric bar chart whose columns are stacks of blocks, with one column in lime."
# The plate in plan units, and the screen x of its left corner.
@plate_w 280
@plate_d 66
@thick 8
@ox 27
# The title and the value sit in the top left corner, above the near end
# of the plate. A column under them has to stay below @header.
@header 78
@header_right 190
# Columns stand `@back` from the far edge, inside a margin of `@margin`.
@margin 14
@back 14
@side 26
# One block and the air above it.
@block 15
@air 2.5
attr(:title, :string, default: "Orders shipped per week")
attr(:value, :string, default: "620")
attr(:caption, :string, default: "1 block · 100 orders")
attr(:values, :list,
default: [2, 3, 3, 4, 5, 6],
doc: "Blocks per column, left to right. Three to eight columns of 0 to 8 blocks."
)
attr(:labels, :list,
default: ~w(W1 W2 W3 W4 W5 W6),
doc: "One label per column, up to 4 characters."
)
attr(:highlight, :integer,
default: -1,
doc: "Index of the lime column. A negative index counts from the end."
)
attr(:label, :string,
default:
"An isometric bar chart of stacked blocks rising week over week, the last column in lime"
)
frame_attrs(isometric: false)
@spec charts_block_stack(map()) :: Phoenix.LiveView.Rendered.t()
def charts_block_stack(assigns) do
values = assigns.values |> Enum.take(8) |> Enum.map(&(&1 |> round() |> max(0) |> min(8)))
count = max(length(values), 1)
slot = (@plate_w - 2 * @margin) / count
side = Float.round(min(@side * 1.0, slot * 0.6), 1)
on = Integer.mod(assigns.highlight, count)
plans =
for {blocks, index} <- Enum.with_index(values) do
u = Float.round(@margin + index * slot + (slot - side) / 2, 1)
limit = if @ox + u + @back < @header_right, do: @header, else: 14
%{index: index, blocks: blocks, u: u, floor: limit + u / 2 - @back / 2 + height(blocks)}
end
# The plate starts as high as the tallest column and the header allow.
oy =
plans
|> Enum.map(& &1.floor)
|> Enum.max(fn -> 0 end)
|> max(@header + (@header_right - @ox) / 2)
|> max(@plate_w / 2 + 14)
|> Float.round(1)
columns =
for plan <- Enum.reverse(plans) do
%{
on: plan.index == on,
cu: Float.round(plan.u + side / 2, 1),
name: Enum.at(assigns.labels, plan.index, ""),
step: min(plan.index + 2, 24),
blocks:
for level <- 0..(plan.blocks - 1)//1 do
z = level * (@block + @air)
block(plan.u, @back, side, z, oy, min(plan.index + level + 2, 24))
end
}
end
height = round(oy + @plate_d / 2 + @thick + 16)
assigns =
assigns
|> assign(:columns, columns)
|> assign(:matrix, "matrix(1 -0.5 1 0.5 #{@ox} #{oy})")
|> assign(
:left,
face([{0, 0, 0}, {0, @plate_d, 0}, {0, @plate_d, -@thick}, {0, 0, -@thick}], oy)
)
|> assign(
:right,
face(
[
{0, @plate_d, 0},
{@plate_w, @plate_d, 0},
{@plate_w, @plate_d, -@thick},
{0, @plate_d, -@thick}
],
oy
)
)
|> assign(
:dividers,
for(index <- 1..(count - 1)//1, do: Float.round(@margin + index * slot, 1))
)
|> assign(:plate, %{
w: @plate_w,
d: @plate_d,
rail: @back + side + 5,
text: @back + side + 17
})
|> assign(:height, height)
~H"""
<.frame name="charts/block-stack" label={@label} {frame_opts(assigns)}>
<svg viewBox={"0 0 400 #{@height}"}>
<text class="ehv-t-2 ehv-a-fade ehv-i-1" x="28" y="30" font-size="12">{@title}</text>
<text class="ehv-t-display ehv-a-fade ehv-i-2" x="28" y="60" font-size="24">{@value}</text>
<text
class="ehv-mono ehv-a-fade ehv-i-2"
x="372"
y={@height - 12}
font-size="9"
text-anchor="end"
>
{String.upcase(@caption)}
</text>
<g class="ehv-a-rise ehv-i-0">
<polygon class="ehv-charts-block-stack__side" points={@left} />
<polygon class="ehv-charts-block-stack__side" points={@right} />
<g class="ehv-charts-block-stack__plane" transform={@matrix}>
<rect class="ehv-charts-block-stack__plate" width={@plate.w} height={@plate.d} rx="4" />
<line
:for={u <- @dividers}
class="ehv-line-faint"
x1={u}
x2={u}
y1="8"
y2={@plate.d - 8}
/>
<line
class="ehv-line-soft"
x1="14"
x2={@plate.w - 14}
y1={@plate.rail}
y2={@plate.rail}
/>
</g>
</g>
<g :for={column <- @columns}>
<g class={"ehv-charts-block-stack__plane ehv-a-fade ehv-i-#{column.step}"}>
<g transform={@matrix}>
<text
class={["ehv-mono", column.on && "ehv-t-ink"]}
x={column.cu}
y={@plate.text}
font-size="8.5"
text-anchor="middle"
>
{String.upcase(column.name)}
</text>
</g>
</g>
<g class={[
"ehv-charts-block-stack__column",
column.on && "ehv-charts-block-stack__column--on"
]}>
<g :for={block <- column.blocks} class={"ehv-a-drop ehv-i-#{block.step}"}>
<polygon class="ehv-charts-block-stack__left" points={block.left} />
<polygon class="ehv-charts-block-stack__right" points={block.right} />
<polygon class="ehv-charts-block-stack__top" points={block.top} />
</g>
</g>
</g>
</svg>
</.frame>
"""
end
# The height of a stack: its blocks and the air between them.
defp height(0), do: 0
defp height(blocks), do: blocks * @block + (blocks - 1) * @air
# The three visible faces of one block standing at plan {u, v}, `z` above
# the plate: the top, the face towards the near end of the row and the
# face towards the viewer.
defp block(u, v, side, z, oy, step) do
{u2, v2, z2} = {u + side, v + side, z + @block}
%{
step: step,
top: face([{u, v, z2}, {u2, v, z2}, {u2, v2, z2}, {u, v2, z2}], oy),
left: face([{u, v, z2}, {u, v2, z2}, {u, v2, z}, {u, v, z}], oy),
right: face([{u, v2, z2}, {u2, v2, z2}, {u2, v2, z}, {u, v2, z}], oy)
}
end
defp face(corners, oy) do
Enum.map_join(corners, " ", fn {u, v, z} ->
"#{Float.round(@ox + u + v + 0.0, 1)},#{Float.round(oy - u / 2 + v / 2 - z, 1)}"
end)
end
end
assets/css/charts/block-stack.css
/* charts/block-stack */
.ehv-charts-block-stack { --ehv-max-w: 450px; --ehv-step: 70ms; --ehv-dur: 600ms; }
.ehv-charts-block-stack .ehv__glow { background: radial-gradient(46% 40% at 52% 56%, var(--ehv-glow), transparent 72%); }
/* Hairlines stay one pixel wide in the skewed plane of the plate. */
.ehv-charts-block-stack__plane * { vector-effect: non-scaling-stroke; }
.ehv-charts-block-stack__plate {
fill: var(--ehv-surface-1);
stroke: var(--ehv-border-strong);
stroke-width: 1;
}
.ehv-charts-block-stack__side {
fill: var(--ehv-surface-2);
stroke: var(--ehv-border-soft);
stroke-width: 1;
stroke-linejoin: round;
}
/* A block: the top is the lightest face, the right one the darkest. */
.ehv-charts-block-stack__top,
.ehv-charts-block-stack__left,
.ehv-charts-block-stack__right {
stroke: var(--ehv-border-strong);
stroke-width: 1;
stroke-linejoin: round;
}
.ehv-charts-block-stack__top { fill: var(--ehv-surface-3); }
.ehv-charts-block-stack__left { fill: var(--ehv-surface-2); }
.ehv-charts-block-stack__right { fill: var(--ehv-surface-1); }
.ehv-charts-block-stack__column--on :is(.ehv-charts-block-stack__top, .ehv-charts-block-stack__left, .ehv-charts-block-stack__right) {
stroke: var(--ehv-on-lime);
stroke-opacity: 0.55;
}
.ehv-charts-block-stack__column--on .ehv-charts-block-stack__top { fill: var(--ehv-lime-fill); }
.ehv-charts-block-stack__column--on .ehv-charts-block-stack__left { fill: color-mix(in srgb, var(--ehv-lime-fill) 76%, var(--ehv-on-lime)); }
.ehv-charts-block-stack__column--on .ehv-charts-block-stack__right { fill: color-mix(in srgb, var(--ehv-lime-fill) 54%, var(--ehv-on-lime)); }
.ehv--hover .ehv-charts-block-stack__column { transition: transform 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-charts-block-stack__column--on { transform: translateY(-5px); }