empowered.house ↗

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.

Use it
# in the module that renders the template
use EHVisuals

<div class="h-96 rounded-xl border border-border">
  <.tasks_sort_lanes animated />
</div>
default
the next task picked up
custom lanes · hover
all sorted · nothing lit
light surface · fade out
lit cube on the lane · no glow · still

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
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
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); }