empowered.house ↗

api/rate-limit.ex

Rate limit

Request cubes dropping into an isometric quota tray until it is full, the next one rejected with a 429.

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

<div class="h-96 rounded-xl border border-border">
  <.api_rate_limit animated />
</div>
default
room left · the next request is accepted
twelve slots · custom copy
four slots · hover · no glow
light surface
fade out · still

Props

Prop Type Default What it does
title string "Rate limit"
limit integer 8 Slots in the tray, 4 to 12. An odd number rounds up to the next even one.
used integer 8 Requests already in the tray. At the limit, the incoming request is rejected.
window string "per minute" Up to 12 characters.
code any nil Status of the incoming request. Left empty: `429` on a full tray, `200` otherwise.
reason any nil Left empty, it is written from the state. Up to 18 characters.
note any nil The line under the reason. Left empty, it is written from the state.
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/api/rate-limit.ex
rate-limit.ex
defmodule EHVisuals.Api.RateLimit do
  @moduledoc """
  A quota as an isometric tray: every request is a cube that drops into a
  slot, and the one that arrives after the tray is full is rejected.

  The scene is drawn in isometric projection. Everything is laid out in plan
  coordinates on the tray (x to the right, y towards the viewer, z up) and
  placed on screen by `screen/4`.
  """

  use EHVisuals.Composition,
    name: "api/rate-limit",
    title: "Rate limit",
    description:
      "Request cubes dropping into an isometric quota tray until it is full, the next one rejected with a 429."

  # Screen units per plan unit.
  @k 1.25
  # Plan sizes: a slot every 40 units, a cube of 32 by 32 by 28, walls 12 high.
  @pitch 40
  @cube 32
  @tall 28
  @wall 12
  @depth 88
  # How far above the floor the incoming request hovers.
  @hover 58
  # Screen y of the far corner of the tray floor.
  @top 100

  attr(:title, :string, default: "Rate limit")

  attr(:limit, :integer,
    default: 8,
    doc: "Slots in the tray, 4 to 12. An odd number rounds up to the next even one."
  )

  attr(:used, :integer,
    default: 8,
    doc: "Requests already in the tray. At the limit, the incoming request is rejected."
  )

  attr(:window, :string, default: "per minute", doc: "Up to 12 characters.")

  attr(:code, :any,
    default: nil,
    doc: "Status of the incoming request. Left empty: `429` on a full tray, `200` otherwise."
  )

  attr(:reason, :any,
    default: nil,
    doc: "Left empty, it is written from the state. Up to 18 characters."
  )

  attr(:note, :any,
    default: nil,
    doc: "The line under the reason. Left empty, it is written from the state."
  )

  attr(:label, :string,
    default: "A quota tray filled with eight request cubes and a ninth one rejected with a 429"
  )

  frame_attrs(isometric: false)

  @spec api_rate_limit(map()) :: Phoenix.LiveView.Rendered.t()
  def api_rate_limit(assigns) do
    cols = assigns.limit |> Kernel./(2) |> ceil() |> max(2) |> min(6)
    limit = cols * 2
    used = assigns.used |> max(0) |> min(limit)
    full = used == limit
    width = cols * @pitch + 8
    ox = 36 + @k * @depth
    view_w = 72 + @k * (width + @depth)
    view_h = @top + @k * 0.5 * (width + @depth) + 32

    slots = for index <- 0..(limit - 1), do: place(index)

    cubes =
      slots
      |> Enum.take(used)
      |> Enum.with_index()
      |> Enum.map(fn {{x, y}, index} ->
        %{index: index, newest: full and index == used - 1, depth: x + y}
        |> Map.merge(cube(x, y, 0, ox))
      end)
      |> Enum.sort_by(& &1.depth)

    {tx, ty} = if full, do: {8 + (cols - 1) * @pitch, 28}, else: Enum.at(slots, used)
    incoming = cube(tx, ty, @hover, ox)
    {pill_x, pill_y} = screen(tx + @cube, ty, @hover + @tall, ox)
    {drop_x, drop_top} = screen(tx + @cube / 2, ty + @cube / 2, @hover, ox)
    {_x, drop_bottom} = screen(tx + @cube / 2, ty + @cube / 2, 0, ox)
    code = assigns.code || if(full, do: "429", else: "200")
    pill_w = Float.round(16 + String.length(code) * 7.2, 1)

    assigns =
      assigns
      |> assign(:view_w, Float.round(view_w, 1))
      |> assign(:view_h, Float.round(view_h, 1))
      |> assign(:full, full)
      |> assign(:used_n, used)
      |> assign(:limit_n, limit)
      |> assign(:cubes, cubes)
      |> assign(:incoming, incoming)
      |> assign(:incoming_step, min(used + 4, 24))
      |> assign(:footprints, Enum.map(slots, fn {x, y} -> footprint(x, y, ox) end))
      |> assign(:tray, tray(width, ox))
      |> assign(:code_text, code)
      |> assign(:pill_w, pill_w)
      |> assign(:pill_x, Float.round(min(pill_x + 8, view_w - 20 - pill_w), 1))
      |> assign(:pill_y, Float.round(pill_y - 4, 1))
      |> assign(:drop, %{x: drop_x, y1: drop_top, y2: drop_bottom})
      |> assign(
        :reason_text,
        assigns.reason || if(full, do: "Too many requests", else: "Accepted")
      )
      |> assign(
        :note_text,
        assigns.note || if(full, do: "Retry in 12 s", else: "#{limit - used - 1} left")
      )

    ~H"""
    <.frame name="api/rate-limit" label={@label} {frame_opts(assigns)}>
      <svg viewBox={"0 0 #{@view_w} #{@view_h}"}>
        <g class="ehv-a-fade ehv-i-0">
          <text class="ehv-t-strong" x="20" y="30" font-size="14">{@title}</text>
          <text class="ehv-mono" x="20" y="46" font-size="8">
            {String.upcase("#{@limit_n} requests #{@window}")}
          </text>
        </g>

        <g class="ehv-a-rise ehv-i-1">
          <polygon class="ehv-api-rate-limit__floor" points={@tray.floor} />
          <polygon class="ehv-api-rate-limit__face--left" points={@tray.back_left} />
          <polygon class="ehv-api-rate-limit__face--right" points={@tray.back_right} />
          <polygon
            :for={points <- @footprints}
            class="ehv-line-soft ehv-dash"
            points={points}
          />
        </g>

        <g
          :for={cube <- @cubes}
          class={["ehv-api-rate-limit__cube", "ehv-i-#{min(cube.index + 3, 24)}"]}
        >
          <polygon class="ehv-api-rate-limit__face--left" points={cube.left} />
          <polygon class="ehv-api-rate-limit__face--right" points={cube.right} />
          <polygon
            class={
              if cube.newest,
                do: "ehv-api-rate-limit__top--lime",
                else: "ehv-api-rate-limit__top"
            }
            points={cube.top}
          />
        </g>

        <g class="ehv-a-rise ehv-i-1">
          <polygon class="ehv-api-rate-limit__face--left" points={@tray.front_left} />
          <polygon class="ehv-api-rate-limit__face--right" points={@tray.front_right} />
          <polygon class="ehv-line" points={@tray.rim} />
        </g>

        <line
          :if={not @full}
          class={"ehv-line-lime ehv-dash ehv-a-fade ehv-i-#{@incoming_step}"}
          x1={@drop.x}
          x2={@drop.x}
          y1={@drop.y1}
          y2={@drop.y2}
        />

        <g class={[
          "ehv-api-rate-limit__incoming",
          if(@full, do: "ehv-api-rate-limit__incoming--bounce", else: "ehv-api-rate-limit__cube"),
          "ehv-i-#{@incoming_step}"
        ]}>
          <g :for={face <- [@incoming.left, @incoming.right, @incoming.top]}>
            <polygon class="ehv-fill-canvas" points={face} />
            <polygon class={"ehv-api-rate-limit__in--#{tone(@full)}"} points={face} />
          </g>
          <rect
            class={if @full, do: "ehv-fill-coral-soft", else: "ehv-fill-lime-soft"}
            x={@pill_x}
            y={@pill_y}
            width={@pill_w}
            height="22"
            rx="6"
          />
          <text
            class={["ehv-mono", if(@full, do: "ehv-t-coral", else: "ehv-t-lime")]}
            x={@pill_x + @pill_w / 2}
            y={@pill_y + 15}
            font-size="10.5"
            text-anchor="middle"
          >
            {@code_text}
          </text>
        </g>

        <g class={"ehv-a-fade ehv-i-#{min(@incoming_step + 2, 24)}"}>
          <text class="ehv-t-display" x="20" y={@view_h - 24} font-size="24">
            {@used_n} / {@limit_n}
          </text>
          <text class="ehv-mono" x="20" y={@view_h - 8} font-size="7.5">
            {String.upcase("used #{@window}")}
          </text>
          <text
            class={["ehv-mono", if(@full, do: "ehv-t-coral", else: "ehv-t-lime")]}
            x={@view_w - 20}
            y={@view_h - 22}
            font-size="7.5"
            text-anchor="end"
          >
            {String.upcase(@reason_text)}
          </text>
          <text class="ehv-mono" x={@view_w - 20} y={@view_h - 8} font-size="7.5" text-anchor="end">
            {String.upcase(@note_text)}
          </text>
        </g>
      </svg>
    </.frame>
    """
  end

  defp tone(true), do: "coral"
  defp tone(false), do: "lime"

  # Slots fill column by column, far row first.
  defp place(index), do: {8 + div(index, 2) * @pitch, 8 + rem(index, 2) * @pitch}

  # Plan {x, y, z} to screen. `ox` is the screen x of the far corner.
  defp screen(x, y, z, ox) do
    {Float.round(ox + @k * (x - y), 1), Float.round(@top + @k * 0.5 * (x + y) - @k * z, 1)}
  end

  defp points(list, ox) do
    Enum.map_join(list, " ", fn {x, y, z} ->
      {sx, sy} = screen(x, y, z, ox)
      "#{sx},#{sy}"
    end)
  end

  # The three visible faces of a cube standing at {x, y}, its base at height z.
  defp cube(x, y, z, ox) do
    {x2, y2, z2} = {x + @cube, y + @cube, z + @tall}

    %{
      top: points([{x, y, z2}, {x2, y, z2}, {x2, y2, z2}, {x, y2, z2}], ox),
      left: points([{x, y2, z}, {x2, y2, z}, {x2, y2, z2}, {x, y2, z2}], ox),
      right: points([{x2, y, z}, {x2, y2, z}, {x2, y2, z2}, {x2, y, z2}], ox)
    }
  end

  defp footprint(x, y, ox) do
    points([{x, y, 0}, {x + @cube, y, 0}, {x + @cube, y + @cube, 0}, {x, y + @cube, 0}], ox)
  end

  # The tray: its floor, the two far walls seen from inside, the two near
  # walls seen from outside, and the rim.
  defp tray(w, ox) do
    d = @depth

    %{
      floor: points([{0, 0, 0}, {w, 0, 0}, {w, d, 0}, {0, d, 0}], ox),
      back_left: points([{0, 0, 0}, {0, d, 0}, {0, d, @wall}, {0, 0, @wall}], ox),
      back_right: points([{0, 0, 0}, {w, 0, 0}, {w, 0, @wall}, {0, 0, @wall}], ox),
      front_left: points([{0, d, -3}, {w, d, -3}, {w, d, @wall}, {0, d, @wall}], ox),
      front_right: points([{w, 0, -3}, {w, d, -3}, {w, d, @wall}, {w, 0, @wall}], ox),
      rim: points([{0, 0, @wall}, {w, 0, @wall}, {w, d, @wall}, {0, d, @wall}], ox)
    }
  end
end
assets/css/api/rate-limit.css
rate-limit.css
/* api/rate-limit */
.ehv-api-rate-limit { --ehv-max-w: 440px; --ehv-step: 110ms; }
.ehv-api-rate-limit .ehv__glow { background: radial-gradient(46% 40% at 50% 56%, var(--ehv-glow), transparent 72%); }

.ehv-api-rate-limit__floor { fill: var(--ehv-canvas); stroke: var(--ehv-border-soft); stroke-width: 1; stroke-linejoin: round; }
.ehv-api-rate-limit__face--left { fill: var(--ehv-surface-2); stroke: var(--ehv-border-strong); stroke-width: 1; stroke-linejoin: round; }
.ehv-api-rate-limit__face--right { fill: var(--ehv-surface-1); stroke: var(--ehv-border-strong); stroke-width: 1; stroke-linejoin: round; }
.ehv-api-rate-limit__top { fill: var(--ehv-surface-3); stroke: var(--ehv-border-strong); stroke-width: 1; stroke-linejoin: round; }
.ehv-api-rate-limit__top--lime { fill: var(--ehv-lime-fill); stroke: var(--ehv-lime); stroke-width: 1; stroke-linejoin: round; }

.ehv-api-rate-limit__in--coral { fill: var(--ehv-coral-soft); stroke: var(--ehv-coral); stroke-width: 1.25; stroke-linejoin: round; }
.ehv-api-rate-limit__in--lime { fill: var(--ehv-lime-soft); stroke: var(--ehv-lime); stroke-width: 1.25; stroke-linejoin: round; }

/* Cubes fall into their slot. The rejected one hits the pile and bounces back up. */
@keyframes ehv-api-rate-limit-fall { from { opacity: 0; transform: translateY(-48px); } }
@keyframes ehv-api-rate-limit-bounce {
  0% { opacity: 0; transform: translateY(-44px); }
  55% { opacity: 1; transform: translateY(40px); }
  100% { transform: translateY(0); }
}

.ehv-api-rate-limit__cube,
.ehv-api-rate-limit__incoming { transform-box: fill-box; }

.ehv--animated .ehv-api-rate-limit__cube,
.ehv--animated .ehv-api-rate-limit__incoming--bounce {
  animation-duration: var(--ehv-dur);
  animation-timing-function: var(--ehv-ease);
  animation-fill-mode: backwards;
  animation-delay: calc(var(--ehv-delay, 0ms) + var(--ehv-i, 0) * var(--ehv-step));
}
.ehv--animated .ehv-api-rate-limit__cube { animation-name: ehv-api-rate-limit-fall; }
.ehv--animated .ehv-api-rate-limit__incoming--bounce {
  animation-name: ehv-api-rate-limit-bounce;
  animation-duration: calc(var(--ehv-dur) * 1.4);
}

.ehv--hover .ehv-api-rate-limit__incoming { transition: transform 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-api-rate-limit__incoming { transform: translateY(-8px); }