tasks/sort-lanes.ex
Sort lanes
Task cubes sorted onto three isometric lanes, to do, in progress and done, with the one being worked on lit.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.tasks_sort_lanes animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
lanes |
list | [%{count: 3, name: "To do"}, %{count: 2, name: "In progress"}, ...] |
Three `%{name, count}`. A lane holds up to four cubes; names up to 11 characters. |
active |
integer | 1 |
Index of the lane whose front cube is lit in lime. A negative index lights none. |
lifted |
boolean | true |
Lifts the lit cube off its lane, as if it is being picked up. |
checked |
boolean | true |
Ticks the cubes on the last lane. |
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/tasks/sort-lanes.ex
defmodule EHVisuals.Tasks.SortLanes do
@moduledoc """
Tasks as cubes sorted onto three lanes: to do, in progress and done.
The scene is drawn in isometric projection. The lanes lie in one ground
plane, placed on screen by one matrix, so the plates, the slots and the lane
names are laid out in flat plan coordinates. A cube is three polygons whose
corners are projected from plan coordinates and a height.
"""
use EHVisuals.Composition,
name: "tasks/sort-lanes",
title: "Sort lanes",
description:
"Task cubes sorted onto three isometric lanes, to do, in progress and done, with the one being worked on lit."
# Screen position of the plan origin, the far corner of the first lane.
@origin_x 290
@origin_y 48
# Plan coordinates. A lane is 64 wide and 236 long; the lanes sit side by
# side along x. A cube is 36 square and stands in one of four slots.
@lane_w 64
@lane_gap 14
@lane_l 236
@thickness 8
@cube 36
@cube_h 24
@slots 4
@slot_step 46
@lift 14
attr(:lanes, :list,
default: [
%{name: "To do", count: 3},
%{name: "In progress", count: 2},
%{name: "Done", count: 4}
],
doc: "Three `%{name, count}`. A lane holds up to four cubes; names up to 11 characters."
)
attr(:active, :integer,
default: 1,
doc: "Index of the lane whose front cube is lit in lime. A negative index lights none."
)
attr(:lifted, :boolean,
default: true,
doc: "Lifts the lit cube off its lane, as if it is being picked up."
)
attr(:checked, :boolean, default: true, doc: "Ticks the cubes on the last lane.")
attr(:label, :string,
default:
"Task cubes on three isometric lanes, To do, In progress and Done, with one cube in progress lit"
)
frame_attrs(isometric: false)
@spec tasks_sort_lanes(map()) :: Phoenix.LiveView.Rendered.t()
def tasks_sort_lanes(assigns) do
lanes =
for {lane, index} <- assigns.lanes |> pad() |> Enum.with_index() do
x = index * (@lane_w + @lane_gap)
count = lane |> Map.get(:count, 0) |> max(0) |> min(@slots)
on = index == assigns.active
cubes =
for slot <- (count - 1)..0//-1 do
lit = on and slot == 0
y = slot_y(slot)
base = if lit and assigns.lifted, do: @lift, else: 0
cube(x + 14, y, base)
|> Map.merge(%{
lit: lit,
raised: base > 0,
check: assigns.checked and index == 2 and not lit,
stagger: min(index * 3 + (count - slot) + 2, 24)
})
end
%{
index: index,
x: x,
name: Map.get(lane, :name, ""),
on: on,
cubes: cubes,
slots: for(slot <- 0..(@slots - 1), do: slot_y(slot)),
left:
points([
{x, @lane_l, 0},
{x + @lane_w, @lane_l, 0},
{x + @lane_w, @lane_l, -@thickness},
{x, @lane_l, -@thickness}
]),
right:
points([
{x + @lane_w, 0, 0},
{x + @lane_w, @lane_l, 0},
{x + @lane_w, @lane_l, -@thickness},
{x + @lane_w, 0, -@thickness}
])
}
end
assigns =
assigns
|> assign(:lane_items, lanes)
|> assign(:plane, matrix())
|> assign(:lane, %{w: @lane_w, l: @lane_l, cube: @cube})
~H"""
<.frame name="tasks/sort-lanes" label={@label} {frame_opts(assigns)}>
<svg viewBox="0 0 560 322">
<g :for={lane <- @lane_items}>
<g class={"ehv-a-rise ehv-i-#{lane.index * 2}"}>
<polygon class="ehv-tasks-sort-lanes__side" points={lane.left} />
<polygon class="ehv-tasks-sort-lanes__side" points={lane.right} />
<g class="ehv-tasks-sort-lanes__plane" transform={@plane}>
<rect
class="ehv-tasks-sort-lanes__plate"
x={lane.x}
width={@lane.w}
height={@lane.l}
rx="5"
/>
<rect
:for={y <- lane.slots}
class="ehv-line-faint"
x={lane.x + 14}
y={y}
width={@lane.cube}
height={@lane.cube}
rx="2"
/>
<text
class={["ehv-mono", if(lane.on, do: "ehv-t-lime", else: "ehv-t-2")]}
x={lane.x + 8}
y={@lane.l - 12}
font-size="7"
>
{String.upcase(lane.name)}
</text>
</g>
</g>
<g :for={cube <- lane.cubes} class={"ehv-a-drop ehv-i-#{cube.stagger}"}>
<g :if={cube.raised} class="ehv-tasks-sort-lanes__plane" transform={@plane}>
<rect
class="ehv-line-lime ehv-dash"
x={cube.x}
y={cube.y}
width={@lane.cube}
height={@lane.cube}
rx="2"
/>
</g>
<g class={[cube.lit && "ehv-tasks-sort-lanes__carry"]}>
<g class={[cube.lit && "ehv-tasks-sort-lanes__float"]}>
<polygon
class={["ehv-tasks-sort-lanes__left", cube.lit && "ehv-tasks-sort-lanes__left--on"]}
points={cube.left}
/>
<polygon
class={[
"ehv-tasks-sort-lanes__right",
cube.lit && "ehv-tasks-sort-lanes__right--on"
]}
points={cube.right}
/>
<polygon
class={["ehv-tasks-sort-lanes__top", cube.lit && "ehv-tasks-sort-lanes__top--on"]}
points={cube.top}
/>
<path
:if={cube.check}
class="ehv-line-ink ehv-round"
d={"M#{cube.sx - 7} #{cube.sy} l5 3.5 l9.5 -7.5"}
/>
</g>
</g>
</g>
</g>
</svg>
</.frame>
"""
end
# Always three lanes: missing ones are drawn empty.
defp pad(lanes), do: Enum.take(lanes ++ List.duplicate(%{name: "", count: 0}, 3), 3)
# Slot 0 is the one at the front, next to the lane name.
defp slot_y(slot), do: 12 + (@slots - 1 - slot) * @slot_step
# A cube standing at plan {x, y}, its underside `base` above the lane.
defp cube(x, y, base) do
{x2, y2, z} = {x + @cube, y + @cube, base + @cube_h}
{sx, sy} = screen(x + @cube / 2, y + @cube / 2, z)
%{
x: x,
y: y,
sx: sx,
sy: sy,
top: points([{x, y, z}, {x2, y, z}, {x2, y2, z}, {x, y2, z}]),
left: points([{x, y2, z}, {x2, y2, z}, {x2, y2, base}, {x, y2, base}]),
right: points([{x2, y, z}, {x2, y2, z}, {x2, y2, base}, {x2, y, base}])
}
end
# Plan {x, y} at height z to screen.
defp screen(x, y, z) do
{Float.round(@origin_x + 1.1 * (x - y), 1), Float.round(@origin_y + 0.55 * (x + y) - z, 1)}
end
defp points(corners) do
Enum.map_join(corners, " ", fn {x, y, z} ->
{sx, sy} = screen(x, y, z)
"#{sx},#{sy}"
end)
end
defp matrix, do: "matrix(1.1 0.55 -1.1 0.55 #{@origin_x} #{@origin_y})"
end
assets/css/tasks/sort-lanes.css
/* tasks/sort-lanes */
.ehv-tasks-sort-lanes { --ehv-max-w: 620px; --ehv-step: 110ms; }
.ehv-tasks-sort-lanes .ehv__glow { background: radial-gradient(46% 40% at 50% 54%, var(--ehv-glow), transparent 72%); }
/* Hairlines stay one pixel wide in the skewed ground plane. */
.ehv-tasks-sort-lanes__plane * { vector-effect: non-scaling-stroke; }
.ehv-tasks-sort-lanes__plate {
fill: var(--ehv-surface-1);
stroke: var(--ehv-border-strong);
stroke-width: 1;
}
.ehv-tasks-sort-lanes__side {
fill: var(--ehv-surface-2);
stroke: var(--ehv-border-soft);
stroke-width: 1;
stroke-linejoin: round;
}
/* A cube: the top catches the light, the two visible sides step down. */
.ehv-tasks-sort-lanes__top,
.ehv-tasks-sort-lanes__left,
.ehv-tasks-sort-lanes__right {
stroke: var(--ehv-ink-3);
stroke-width: 1;
stroke-linejoin: round;
}
.ehv-tasks-sort-lanes__top { fill: var(--ehv-surface-3); }
.ehv-tasks-sort-lanes__left { fill: var(--ehv-surface-2); }
.ehv-tasks-sort-lanes__right { fill: var(--ehv-surface-1); }
.ehv-tasks-sort-lanes__top--on { fill: var(--ehv-lime-fill); stroke: var(--ehv-lime); }
.ehv-tasks-sort-lanes__left--on { stroke: var(--ehv-lime); }
.ehv-tasks-sort-lanes__right--on { stroke: var(--ehv-lime); }
/* The lit cube is held, not placed: it drifts, and rises further on hover. */
@keyframes ehv-tasks-sort-lanes-float { 50% { transform: translateY(-4px); } }
.ehv--animated .ehv-tasks-sort-lanes__float {
animation: ehv-tasks-sort-lanes-float 3.6s ease-in-out infinite;
animation-delay: calc(var(--ehv-dur) + 12 * var(--ehv-step));
}
.ehv--hover .ehv-tasks-sort-lanes__carry { transition: transform 300ms var(--ehv-ease); }
.ehv--hover:hover .ehv-tasks-sort-lanes__carry { transform: translateY(-8px); }