Non-destructive avatar cropping, Apple Photos style.
A crop is data, never pixels: custom_fields["avatar_crop"] holds a focal
point, a zoom, and the image's aspect ratio, while the original file — and
every generated variant of it — stays exactly as uploaded. Adjusting the
crop rewrites four numbers; resetting it drops the key; nothing is ever
re-encoded, so there is no quality loss and no way to crop yourself into a
corner.
Rendering applies the stored geometry as an inline style on the <img>
inside the avatar's (square, overflow-hidden) frame. Because the storage
module's image variants are aspect-preserving scales of the original, the
same normalized numbers are valid against any of them — a page full of
avatars keeps loading the small variants it always loaded, and the crop
costs a few floats that were already in the user record. No extra request,
no imaging job, no per-crop file.
The stored map
%{"x" => 0.5, "y" => 0.35, "zoom" => 2.0, "ar" => 1.5}x,y— the focal point (what the person centered), as fractions of the image's width and height.0.5/0.5is the image center.zoom— magnification relative to the cover fit.1.0shows exactly whatobject-fit: coverwould show; the ceiling is 8.0.ar— the image's width/height ratio, captured when the crop was made so rendering never needs to look dimensions up (avatars appear dozens to a page; a per-avatar dimension query would be the N+1 this design exists to avoid).
Geometry
Inside a square frame taken as 100%: the cover fit makes the image's short
side match the frame, so a landscape image is 100·zoom tall and
100·zoom·ar wide (portrait mirrored). The image is then offset so the
focal point sits at the frame's center, clamped so the frame never shows
past an edge — the same clamp the editor applies, so what was saved is
what renders.
Summary
Functions
Nil for a crop that shows exactly what no crop would show.
The user's stored crop, or nil when there is none (or it is invalid).
The inline style that seats a cropped image in its frame.
The image's placement inside its square frame, as percentages of the
frame: %{width:, height:, left:, top:}.
The zoom ceiling, shared with the editor UI.
Clamp raw crop params into a storable map, or nil if they do not describe a crop at all.
The cheapest stored variant that still renders sharply: a zoomed crop
shows 1/zoom of the source, so a box of box_px CSS pixels needs
box_px · 2 (retina) · zoom source pixels along the frame edge.
Functions
Nil for a crop that shows exactly what no crop would show.
Centered at cover fit is the identity: storing it as numbers would make every future render do geometry for nothing, and (worse) pin the avatar to the aspect ratio recorded at save time. Tolerances absorb the float noise a drag leaves behind.
The user's stored crop, or nil when there is none (or it is invalid).
The inline style that seats a cropped image in its frame.
The frame supplies position: relative; overflow: hidden (the avatar
component already has both); max-width: none undoes Tailwind's global
img { max-width: 100% }, without which every percentage below is
re-clamped and the crop silently collapses to a stretch.
The image's placement inside its square frame, as percentages of the
frame: %{width:, height:, left:, top:}.
Mirrored by avatarCropLayout in priv/static/assets/phoenix_kit.js —
the editor previews with the same math that later renders, so the saved
crop cannot drift from the preview. Change one, change both.
The zoom ceiling, shared with the editor UI.
Clamp raw crop params into a storable map, or nil if they do not describe a crop at all.
Accepts string or atom keys and numbers or numeric strings — the values arrive from a JS hook. Out-of-range values are clamped rather than refused: a drag that overshoots an edge is intent to reach the edge.
The cheapest stored variant that still renders sharply: a zoomed crop
shows 1/zoom of the source, so a box of box_px CSS pixels needs
box_px · 2 (retina) · zoom source pixels along the frame edge.
The frame edge is covered by the image's SHORT side, while variants are
scaled by width — so a landscape image needs its width to be ar times
the frame requirement before the short side suffices. Portrait images'
width is their short side, so their factor is 1.
Without a crop, zoom is 1.0 and this degrades to plain size-based
selection. Capped at "large": past it only the original upload could
help, and serving an unbounded file into an avatar circle buys marginal
sharpness at arbitrary transfer cost.