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.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.api_rate_limit animated />
</div>
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
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
/* 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); }