empowered.house ↗

charts/block-stack.ex

Block stack

An isometric bar chart whose columns are stacks of blocks, with one column in lime.

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

<div class="h-96 rounded-xl border border-border">
  <.charts_block_stack animated />
</div>
default
highlight a middle column · custom copy
eight columns · a tall far column
hover · no glow
light surface
three columns · fade out · still

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