Mikkel Manniche
Dansk English Deutsch

Widgets

Lens strip

Ein Bilderstreifen, der hinter einer festen Linse aus dickem Glas gleitet, die das Bild darin vergrößert, es am facettierten Rand mit leichten Farbsäumen bricht und in Bewegung etwas verwischt. WebGL mit DOM-Ersatz.

Vorschau

Verwendung

lens-strip-demo.tsx
import { glPictures } from '@/components/gl-pictures'
import { LensStrip } from '@/components/lens-strip'

export default function LensStripDemo() {
  return (
    <div className="mx-auto w-full max-w-6xl px-4 py-10 sm:px-8">
      <LensStrip images={glPictures} label="Landscapes" />
    </div>
  )
}

Quellcode

lens-strip.tsx
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { useCallback, useId, useLayoutEffect, useMemo, useRef, useState, type HTMLAttributes, type KeyboardEvent } from 'react'
import { useCarouselEngine } from '@/registry/manniche/hooks/use-carousel-engine'
import { glAtlas, glProgram, useGlStage, type GlAtlas, type GlImage, type GlScene, type GlSceneContext, type Rgb } from '@/registry/manniche/hooks/use-gl-stage'
import { useReducedMotion } from '@/registry/manniche/hooks/use-reduced-motion'
import { cn } from '@/lib/utils'

/*
 * A strip of pictures that slides behind a fixed lens of thick, clear glass. Through the lens the strip is magnified,
 * so the picture at rest fills it; near the rim a bevel bends the image inwards and splits it into faint colour
 * fringes, as real glass does. While the strip moves, its speed adds a little more dispersion and a slight sideways
 * smear inside the lens, and both settle back to clean glass as it comes to rest.
 *
 * One fragment shader draws the whole stage: each pixel maps to a point on the strip, and from there to the picture in
 * a texture atlas. Outside the lens that point is the pixel itself; inside it, the point is magnified about the lens's
 * centre and pushed along the bevel's slope, separately for red, green and blue. The engine drives the position, and
 * a frame is drawn only while something moves.
 *
 * Under reduced motion every move jumps and the lens shows one still, clean frame. Without WebGL, or when the GPU drops
 * the context, the pictures themselves are the carousel: the same strip in the DOM, with the picture in the middle
 * raised to the lens's size. They are always there for screen readers.
 */

/** A picture in the strip. `title` is shown under the lens. */
export type LensStripImage = GlImage & { title?: string }

export type LensStripProps = Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> & {
  /** The pictures, in order. Pictures from another origin must send CORS headers, or they show as empty cards. */
  images: LensStripImage[]
  /** Read by screen readers, e.g. “Landscapes”. */
  label: string
  /** Controlled: the picture in the lens. */
  index?: number
  /** The picture in the lens at first, when uncontrolled. */
  defaultIndex?: number
  /** Called when the strip heads for a new picture: on release, a key, a button or a click. */
  onIndexChange?: (index: number) => void
  /** Wrap round from the last picture to the first. */
  loop?: boolean
  /** Picture width over height. The lens takes the same shape. */
  aspect?: number
  /** How much larger the strip looks through the lens. */
  magnify?: number
  /** How strongly the glass splits light into colour at its rim, from 0 (none) to 1. */
  dispersion?: number
  labels?: Partial<Record<'previous' | 'next' | 'picture' | 'of', string>>
}

const EN = { previous: 'Previous picture', next: 'Next picture', picture: 'Picture', of: 'of' }
const TYPING = 'input, textarea, select, [contenteditable=""], [contenteditable="true"]'
const COLORS = ['var(--background)', 'var(--foreground)']
const CANVAS = 'pointer-events-none absolute inset-0 size-full opacity-0 transition-opacity duration-300 ease-out-quint data-ready:opacity-100 motion-reduce:transition-none'
// Parts of the stage's width over which the strip fades out at each side.
const FADE = 0.16
// How far the bevel bends the view at the rim, as a part of its width, and how much of that the picture covers: the
// rest shows the picture's own edge curving away, which is what makes the glass read as thick.
const BEND = 1
const COVER = 0.45

const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))
const mod = (v: number, n: number) => ((v % n) + n) % n
const pad = (v: number) => String(v).padStart(2, '0')

type Geometry = {
  width: number
  height: number
  /** A card in the strip, in CSS px. */
  cardW: number
  cardH: number
  cardR: number
  pitch: number
  /** The lens, in CSS px. */
  lensW: number
  lensH: number
  lensR: number
  bevel: number
  /** How far the view bends at the rim, in lens px. */
  bend: number
  /** Card to lens scale. */
  magnify: number
}

// Everything is sized from the lens: most of the stage's height, in the pictures' shape. The cards are the lens over
// `magnify`, plus most of the bevel's bend, so the picture at rest fills the lens almost to its rim.
function measure(width: number, aspect: number, magnify: number): Geometry {
  // 16:7 on wide screens; taller on narrow ones, so the lens keeps a useful size.
  const height = Math.round(Math.max((width * 7) / 16, Math.min(width * 0.72, 380)))
  let lensH = height * 0.76
  let lensW = lensH * aspect
  if (lensW > width * 0.5) {
    lensW = width * 0.5
    lensH = lensW / aspect
  }
  const short = Math.min(lensW, lensH)
  const bevel = short * 0.2
  const bend = bevel * BEND
  const cardW = (lensW + bend * COVER * 2) / magnify
  const cardH = cardW / aspect
  return {
    width,
    height,
    cardW,
    cardH,
    cardR: clamp(cardW * 0.07, 8, 14),
    pitch: cardW + Math.max(8, cardW * 0.075),
    lensW,
    lensH,
    lensR: Math.max(bevel, short * 0.11),
    bevel,
    bend,
    magnify,
  }
}

const VERTEX = `attribute vec2 a;void main(){gl_Position=vec4(a,0.,1.);}`

// All lengths are CSS px from the stage's centre, with y going down like the atlas.
const FRAGMENT = `#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif
uniform sampler2D uTex;
uniform vec3 uView;  // half width, half height, device px per CSS px
uniform vec4 uLens;  // half width, half height, corner radius, bevel width
uniform vec4 uCard;  // half width, half height, corner radius, pitch
uniform vec4 uStrip; // position, count, loops (0 or 1), magnify
uniform vec4 uGlass; // bend at the rim, dispersion, sideways colour split, smear
uniform vec4 uCell;  // the first cell's corner and size in the atlas
uniform vec3 uGrid;  // step from cell to cell, columns
uniform vec3 uBody;  // the glass's own colour
uniform vec4 uShade; // shadow, rim line, dark inner edge, side fade

float box(vec2 p, vec2 b, float r) {
  vec2 q = abs(p) - b + r;
  return length(max(q, 0.)) + min(max(q.x, q.y), 0.) - r;
}

// The strip at a point: the card there as premultiplied colour, or nothing in a gap. aa is a device pixel in strip px.
vec4 strip(vec2 s, float aa) {
  float u = s.x / uCard.w + uStrip.x;
  float i = floor(u + .5);
  vec2 l = vec2((u - i) * uCard.w, s.y);
  float a = clamp(.5 - box(l, uCard.xy, uCard.z) / aa, 0., 1.);
  float k = i;
  if (uStrip.z > .5) k = mod(i, uStrip.y);
  else a *= step(-.5, i) * step(i, uStrip.y - .5);
  k = floor(k + .5);
  float row = floor((k + .5) / uGrid.z);
  vec2 f = clamp(l / (2. * uCard.xy) + .5, 0., 1.);
  return texture2D(uTex, uCell.xy + vec2(k - row * uGrid.z, row) * uGrid.xy + f * uCell.zw) * a;
}

// The strip seen through the glass at a lens point, laid over the glass's own colour, smeared sideways while moving.
vec3 seen(vec2 c, float aa) {
  vec4 s = strip(c, aa);
  if (abs(uGlass.w) > .01) s = s * .5 + (strip(c - vec2(uGlass.w, 0.), aa) + strip(c + vec2(uGlass.w, 0.), aa)) * .25;
  return s.rgb + uBody * (1. - s.a);
}

void main() {
  float px = 1. / uView.z;
  vec2 p = vec2(gl_FragCoord.x * px - uView.x, uView.y - gl_FragCoord.y * px);
  float d = box(p, uLens.xy, uLens.z);

  // Outside the lens: the plain strip, fading out towards the sides, under the lens's soft shadow.
  vec4 o = vec4(0.);
  if (d > -px) {
    o = strip(p, px) * smoothstep(0., uShade.w, uView.x - abs(p.x));
    float sh = box(p - vec2(0., uLens.w * .3), uLens.xy, uLens.z);
    float shadow = uShade.x * (1. - smoothstep(-uLens.w * .4, uLens.w * 1.2, sh));
    o = o * (1. - shadow) + vec4(0., 0., 0., shadow);
    if (d > px) {
      gl_FragColor = o;
      return;
    }
  }

  // Inside: t runs from 0 at the rim to 1 where the flat top starts; the bevel's slope falls smoothly to nothing.
  float t = clamp(-d / uLens.w, 0., 1.);
  float slope = (1. - t) * (1. - t) * (1. - t);
  vec2 q = abs(p) - uLens.xy + uLens.z;
  vec2 n = sign(p) * normalize(max(q, 0.) + .001);
  // The slope bends the view outwards, so the image curves in at the rim; blue bends more than red.
  vec2 bend = n * uGlass.x * slope;
  float aa = px / uStrip.w;
  vec2 side = vec2(uGlass.z, 0.);
  vec3 c = vec3(
    seen((p + bend * (1. - uGlass.y) - side) / uStrip.w, aa).r,
    seen((p + bend) / uStrip.w, aa).g,
    seen((p + bend * (1. + uGlass.y) + side) / uStrip.w, aa).b
  );

  // Light from the top left: a fine bright line along that part of the rim, a soft dark edge inside the opposite one.
  vec2 light = vec2(-.48, -.88);
  float lit = max(dot(n, light), 0.);
  float away = max(dot(n, -light), 0.);
  float rim = -d;
  c *= 1. - uShade.z * away * slope;
  c = mix(c, vec3(1.), .1 * lit * slope);
  c *= 1. - uShade.y * (1. - smoothstep(0., 1.2, rim));
  float line = (1. - smoothstep(.55, 1.5, rim)) * smoothstep(.0, .35, rim + .2);
  c = mix(c, vec3(1.), line * (.12 + .7 * lit * lit));

  gl_FragColor = mix(o, vec4(c, 1.), clamp(.5 - d / px, 0., 1.));
}`

/**
 * A strip of pictures that slides behind a fixed glass lens. The lens magnifies the picture in it, bends the image
 * at its bevelled rim with faint colour fringes, and smears it a little while the strip moves. Drag or swipe with
 * momentum, use a trackpad, the arrow keys, the buttons, or click a picture to bring it into the lens. The picture's
 * title and a counter sit under the lens. Uses one WebGL context; without WebGL the strip is drawn in the DOM.
 */
export function LensStrip({
  images,
  label,
  index,
  defaultIndex,
  onIndexChange,
  loop = true,
  aspect = 4 / 5,
  magnify = 1.6,
  dispersion = 0.5,
  labels = {},
  className,
  onKeyDown,
  ...rest
}: LensStripProps) {
  const t = { ...EN, ...labels }
  const n = images.length
  const wraps = loop && n > 2
  const id = useId()
  const reduce = useReducedMotion()

  const stage = useRef<HTMLDivElement | null>(null)
  const cards = useRef<(HTMLDivElement | null)[]>([])
  const title = useRef<HTMLSpanElement | null>(null)
  const [width, setWidth] = useState(0)
  const geo = useMemo(() => (width ? measure(width, aspect, Math.max(1, magnify)) : null), [width, aspect, magnify])

  // What the frame loop and the shader read, kept out of React so a frame never renders.
  const live = useRef({ geo, position: 0, velocity: 0, dispersion, wraps, n })
  useLayoutEffect(() => {
    Object.assign(live.current, { geo, dispersion, wraps, n })
  })
  // The picture nearest the lens, which the caption shows; it changes mid-drag, not only when the strip settles.
  const [near, setNear] = useState<number | null>(null)
  const nearRef = useRef<number | null>(null)

  const key = `${aspect}|${images.map((im) => im.src).join('\n')}`

  const build = (ctx: GlSceneContext): GlScene | null => buildScene(ctx, images, aspect, live.current)
  const gl = useGlStage(stage, build, key, { colors: COLORS, className: CANVAS })
  const { redraw } = gl
  const drawn = gl.status === 'ready'

  const paint = useCallback(
    (p: number, velocity: number) => {
      const s = live.current
      s.position = p
      s.velocity = velocity
      redraw()
      const g = s.geo
      if (!g) return
      // The DOM strip: the same cards in the same places, the one in the lens raised to the lens's size. It is the
      // picture when WebGL is missing, and what clicks land on when it is not.
      const raise = g.magnify
      const reach = g.width / 2 + g.pitch
      for (let i = 0; i < s.n; i++) {
        const el = cards.current[i]
        if (!el) continue
        let d = i - p
        if (s.wraps) d = mod(d + s.n / 2, s.n) - s.n / 2
        const x = d * g.pitch
        if (Math.abs(x) > reach) {
          el.style.visibility = 'hidden'
          continue
        }
        const k = Math.max(0, 1 - Math.abs(d))
        const lift = k * k * (3 - 2 * k)
        el.style.visibility = 'visible'
        el.style.transform = `translate3d(${x.toFixed(2)}px, 0, 0) scale(${(1 + (raise - 1) * lift).toFixed(4)})`
        el.style.zIndex = String(Math.round(lift * 100) + 1)
      }
      // Without wrapping, the strip can be pulled past an end; the caption stays on the end picture.
      const r = s.wraps ? mod(Math.round(p), Math.max(s.n, 1)) : clamp(Math.round(p), 0, Math.max(s.n - 1, 0))
      if (r !== nearRef.current) {
        nearRef.current = r
        setNear(r)
      }
    },
    [redraw],
  )

  const engine = useCarouselEngine({ count: n, loop: wraps, index, defaultIndex, onIndexChange, step: geo?.pitch ?? 200, onFrame: paint })
  const current = engine.index
  const shown = near ?? current

  // The stage only exists while there are pictures, so watch it again when the first ones arrive.
  const empty = !n
  useLayoutEffect(() => {
    const el = stage.current
    if (!el) return
    const ro = new ResizeObserver(([e]) => setWidth(Math.round(e.contentRect.width)))
    ro.observe(el)
    return () => ro.disconnect()
  }, [empty])

  // A new size or set of pictures: draw the current frame again.
  const { draw } = engine
  useLayoutEffect(() => {
    draw()
  }, [draw, paint, geo, n, wraps, dispersion])

  // The caption's title eases in when it changes.
  const first = useRef(true)
  useLayoutEffect(() => {
    if (first.current || reduce) {
      first.current = false
      return
    }
    title.current?.animate([{ opacity: 0, transform: 'translateY(3px)' }, { opacity: 1, transform: 'none' }], { duration: 220, easing: 'cubic-bezier(0.23, 1, 0.32, 1)' })
  }, [shown, reduce])

  const keys = (e: KeyboardEvent<HTMLDivElement>) => {
    onKeyDown?.(e)
    if (e.defaultPrevented || (e.target as HTMLElement).closest(TYPING)) return
    if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') engine.move(e.key === 'ArrowRight' ? 1 : -1)
    else if (e.key === 'Home') engine.go(0)
    else if (e.key === 'End') engine.go(n - 1)
    else return
    e.preventDefault()
  }

  if (!n) return null

  const atStart = !wraps && current === 0
  const atEnd = !wraps && current === n - 1
  const caption = images[shown]?.title
  const button =
    'grid size-11 shrink-0 cursor-pointer place-items-center rounded-full bg-muted text-foreground [-webkit-tap-highlight-color:transparent] hover:bg-[color-mix(in_oklab,var(--foreground)_8%,var(--muted))] ' +
    'transition-[opacity,transform] duration-150 ease-out-quint active:scale-[0.96] aria-disabled:cursor-default aria-disabled:opacity-35 aria-disabled:active:scale-100 motion-reduce:transition-none ' +
    'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'

  return (
    <div
      role="group"
      aria-roledescription="carousel"
      aria-label={label}
      tabIndex={0}
      onKeyDown={keys}
      className={cn('group/lens w-full outline-none', className)}
      {...rest}
    >
      <div
        {...engine.bind}
        ref={(el) => {
          stage.current = el
          engine.bind.ref(el)
        }}
        id={id}
        style={{ height: geo?.height ?? 320 }}
        className={cn(
          'relative w-full touch-pan-y overflow-x-clip rounded-[calc(var(--radius)*2+2px)] select-none [-webkit-tap-highlight-color:transparent]',
          n > 1 && 'cursor-grab data-dragging:cursor-grabbing',
          'group-focus-visible/lens:outline-2 group-focus-visible/lens:outline-offset-4 group-focus-visible/lens:outline-ring',
        )}
      >
        <div
          className={cn(
            'absolute inset-0 transition-opacity duration-300 ease-out-quint motion-reduce:transition-none',
            '[mask-image:linear-gradient(to_right,transparent,#000_16%,#000_84%,transparent)]',
            drawn && 'opacity-0',
          )}
        >
          {geo &&
            images.map((im, i) => {
              const on = i === current
              return (
                <div
                  key={i}
                  ref={(el) => {
                    cards.current[i] = el
                  }}
                  role="group"
                  aria-roledescription="slide"
                  aria-label={`${t.picture} ${i + 1} ${t.of} ${n}`}
                  aria-hidden={!on}
                  onClick={on ? undefined : () => engine.go(i)}
                  style={{ width: geo.cardW, height: geo.cardH, marginLeft: -geo.cardW / 2, marginTop: -geo.cardH / 2, borderRadius: geo.cardR }}
                  className={cn(
                    'absolute top-1/2 left-1/2 overflow-hidden bg-muted will-change-transform',
                    'shadow-[0_1px_2px_rgb(0_0_0/0.06),0_14px_28px_-16px_rgb(0_0_0/0.4)]',
                    !on && 'invisible cursor-pointer',
                  )}
                >
                  <div inert={!on} className="absolute inset-0">
                    <img src={im.src} alt={im.alt} draggable={false} decoding="async" className="size-full object-cover [-webkit-user-drag:none]" />
                  </div>
                  <span aria-hidden className="pointer-events-none absolute inset-0 rounded-[inherit] shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_9%,transparent)]" />
                </div>
              )
            })}
        </div>
      </div>

      <div className="mx-auto mt-3 flex w-full max-w-xs items-center justify-between gap-3">
        {n > 1 ? (
          // aria-disabled, not disabled: a button that has focus keeps it when it reaches the end.
          <button type="button" aria-label={t.previous} aria-controls={id} aria-disabled={atStart} onClick={() => atStart || engine.move(-1)} className={button}>
            <ChevronLeft className="size-[18px]" strokeWidth={2} aria-hidden />
          </button>
        ) : null}
        <p aria-hidden className="flex min-w-0 flex-1 flex-col items-center gap-1.5 text-center">
          {caption ? (
            <span ref={title} className="max-w-full truncate text-[14px] leading-none font-medium tracking-[-0.01em] text-foreground">
              {caption}
            </span>
          ) : null}
          <span className="font-mono text-[11px] leading-none font-medium tracking-[0.04em] text-muted-foreground tabular-nums">
            {pad(shown + 1)} / {pad(n)}
          </span>
        </p>
        {n > 1 ? (
          <button type="button" aria-label={t.next} aria-controls={id} aria-disabled={atEnd} onClick={() => atEnd || engine.move(1)} className={button}>
            <ChevronRight className="size-[18px]" strokeWidth={2} aria-hidden />
          </button>
        ) : null}
      </div>
      <p aria-live="polite" className="sr-only">
        {`${images[current]?.title ? `${images[current].title}, ` : ''}${t.picture} ${current + 1} ${t.of} ${n}`}
      </p>
    </div>
  )
}

type Live = { geo: Geometry | null; position: number; velocity: number; dispersion: number; wraps: boolean; n: number }

function buildScene({ gl, ready, fail, signal }: GlSceneContext, images: GlImage[], aspect: number, live: Live): GlScene | null {
  const program = glProgram(gl, VERTEX, FRAGMENT)
  if (!program) return null
  gl.useProgram(program)
  // One triangle that covers the whole canvas.
  const buffer = gl.createBuffer()
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW)
  const at = gl.getAttribLocation(program, 'a')
  gl.enableVertexAttribArray(at)
  gl.vertexAttribPointer(at, 2, gl.FLOAT, false, 0, 0)
  const u = (name: string) => gl.getUniformLocation(program, name)
  const uView = u('uView')
  const uLens = u('uLens')
  const uCard = u('uCard')
  const uStrip = u('uStrip')
  const uGlass = u('uGlass')
  const uCell = u('uCell')
  const uGrid = u('uGrid')
  const uBody = u('uBody')
  const uShade = u('uShade')
  gl.uniform1i(u('uTex'), 0)

  let atlas: GlAtlas | null = null
  let size = [1, 1, 1]
  let body: Rgb = [0.5, 0.5, 0.5]
  let dark = false

  glAtlas(gl, images, { aspect, signal }).then((a) => {
    if (signal.aborted) return
    if (!a) return fail()
    atlas = a
    // The grid of cells, read back from the corners glAtlas reports.
    const [u0, v0, u1, v1] = a.rects[0]
    const cols = Math.max(1, a.rects.filter((r) => r[1] === v0).length)
    const su = a.rects[1] && cols > 1 ? a.rects[1][0] - u0 : 1
    const sv = a.rects[cols] ? a.rects[cols][1] - v0 : 1
    gl.activeTexture(gl.TEXTURE0)
    gl.bindTexture(gl.TEXTURE_2D, a.texture)
    gl.uniform4f(uCell, u0, v0, u1 - u0, v1 - v0)
    gl.uniform3f(uGrid, su, sv, cols)
    ready()
  })

  return {
    resize: (w, h, dpr) => {
      size = [w / dpr / 2, h / dpr / 2, dpr]
    },
    theme: ([bg, fg]) => {
      dark = 0.2126 * bg[0] + 0.7152 * bg[1] + 0.0722 * bg[2] < 0.5
      // The glass's own colour: the background, a breath towards the foreground.
      body = [0, 1, 2].map((i) => bg[i] + (fg[i] - bg[i]) * 0.035) as Rgb
    },
    draw: () => {
      const g = live.geo
      if (!atlas || !g) return
      const n = Math.max(live.n, 1)
      const position = live.wraps ? mod(live.position, n) : live.position
      // The speed in CSS px per second, eased into 0–1: more colour and a slight smear while the strip moves.
      const speed = Math.abs(live.velocity) * g.pitch
      const motion = 1 - Math.exp(-speed / 1800)
      const disp = clamp(live.dispersion, 0, 1)
      gl.uniform3f(uView, size[0], size[1], size[2])
      gl.uniform4f(uLens, g.lensW / 2, g.lensH / 2, g.lensR, g.bevel)
      gl.uniform4f(uCard, g.cardW / 2, g.cardH / 2, g.cardR, g.pitch)
      gl.uniform4f(uStrip, position, n, live.wraps ? 1 : 0, g.magnify)
      gl.uniform4f(
        uGlass,
        g.bend,
        disp * 0.06 + motion * 0.2 * (0.4 + disp),
        Math.sign(live.velocity) * motion * 1.1 * (0.4 + disp),
        Math.sign(live.velocity) * motion * 3.2,
      )
      gl.uniform3f(uBody, body[0], body[1], body[2])
      gl.uniform4f(uShade, dark ? 0.3 : 0.07, dark ? 0.28 : 0.14, dark ? 0.32 : 0.2, g.width * FADE)
      gl.drawArrays(gl.TRIANGLES, 0, 3)
    },
    dispose: () => {
      if (atlas) gl.deleteTexture(atlas.texture)
      gl.deleteBuffer(buffer)
      gl.deleteProgram(program)
    },
  }
}
use-carousel-engine.ts
import { useEffect, useLayoutEffect, useState, type MouseEvent, type PointerEvent } from 'react'
import { useReducedMotion } from '@/registry/manniche/hooks/use-reduced-motion'

export type CarouselEngineOptions = {
  /** How many slides there are. */
  count: number
  /** Wrap around from the last slide to the first. */
  loop?: boolean
  /** Controlled: the current slide. The carousel shows it, and goes back to it if `onIndexChange` does not update it. */
  index?: number
  /** The slide shown first, when uncontrolled. */
  defaultIndex?: number
  /** Called when the carousel heads for a new slide: on release, a key or a button, not when the glide ends. */
  onIndexChange?: (index: number) => void
  /** The drag direction. `x` also takes sideways wheel and trackpad swipes. */
  axis?: 'x' | 'y'
  /** Pixels of drag that move the carousel one slide. Negative turns the drag round, e.g. pull down for the next slide. */
  step: number
  /** Come to rest on a whole slide. Off, a fling glides to a stop anywhere. */
  snap?: boolean
  /** Draws a frame: the position in slides (fractional while moving) and the speed in slides per second. */
  onFrame: (position: number, velocity: number) => void
}

export type CarouselEngine = {
  /** The slide the carousel is on or heading to. */
  index: number
  /** Go to a slide. In a loop it takes the short way round. */
  go: (index: number) => void
  /** Move by a number of slides from where the carousel is heading. */
  move: (by: number) => void
  /** Draw the current frame again, e.g. after a resize. */
  draw: () => void
  /** Spread on the element that takes the drag. */
  bind: {
    ref: (el: HTMLElement | null) => void
    onPointerDown: (e: PointerEvent<HTMLElement>) => void
    onPointerMove: (e: PointerEvent<HTMLElement>) => void
    onPointerUp: (e: PointerEvent<HTMLElement>) => void
    onPointerCancel: (e: PointerEvent<HTMLElement>) => void
    onClickCapture: (e: MouseEvent<HTMLElement>) => void
  }
}

type Settings = Required<Pick<CarouselEngineOptions, 'loop' | 'axis' | 'snap' | 'step' | 'onFrame'>> &
  Pick<CarouselEngineOptions, 'onIndexChange'> & { n: number; reduce: boolean }

const SLOP = 6 // px before a press becomes a drag
const THROW = 0.32 // s: how far a fling carries, as speed × this
const SAMPLE = 90 // ms of pointer history used for the release speed
const mod = (v: number, n: number) => ((v % n) + n) % n
const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))
// Pixels per slide, kept away from zero but with its sign.
const per = (step: number) => (step < 0 ? -1 : 1) * Math.max(Math.abs(step), 1)
// Past an end the carousel gives less and less, and never more than one slide.
const rubber = (over: number) => 1 - 1 / (over * 0.55 + 1)

/**
 * The motion behind the carousels: one position, in slides, that drag, fling, wheel, keys and buttons all move.
 * It runs a frame loop only while something moves and hands each frame to `onFrame`, which writes transforms
 * straight to the DOM, so React renders only when the slide changes. Glides use ease-out-quint: 300 ms for a
 * step, and after a fling a duration that starts at the finger's speed. Under reduced motion every move jumps.
 */
export function useCarouselEngine(options: CarouselEngineOptions): CarouselEngine {
  const { count, loop = false, index, defaultIndex = 0, axis = 'x', snap = true, step, onFrame, onIndexChange } = options
  const n = Math.max(count, 1)
  const reduce = useReducedMotion()
  const [current, setCurrent] = useState(() => clamp(index ?? defaultIndex, 0, n - 1))
  const [m] = useState(() => machine(current, setCurrent))
  const [surface, setSurface] = useState<HTMLElement | null>(null)

  // The latest options, for the handlers and the frame loop, which outlive a render.
  useLayoutEffect(() => {
    m.configure({ n, loop, axis, snap, step, onFrame, onIndexChange, reduce })
  })

  // A new count, or a loop turned on or off, puts the carousel back on a slide it has.
  useEffect(() => m.reseat(), [n, loop, m])
  // Controlled: follow the index from outside, and go back to it when a move was not taken up. Neither calls
  // onIndexChange, as the parent already knows.
  const controlled = index !== undefined
  const target = clamp(index ?? 0, 0, n - 1)
  useEffect(() => {
    if (controlled && target !== m.committed) m.go(target, false)
  })

  useEffect(() => (surface && axis === 'x' ? m.listenWheel(surface) : undefined), [surface, axis, m])
  useEffect(() => m.stop, [m])

  return { index: controlled ? target : current, go: m.go, move: m.move, draw: m.draw, bind: { ref: setSurface, ...m.handlers } }
}

type Tween = { from: number; to: number; start: number; dur: number }
type Drag = { id: number; x: number; y: number; p0: number; active: boolean; samples: [number, number][]; resume: number | null }

// The state and behaviour, made once per carousel and kept for its lifetime.
function machine(start: number, setCurrent: (i: number) => void) {
  let want = start // where a drag or the wheel puts the carousel
  let drawn = start // the position on screen
  let velocity = 0
  let tween: Tween | null = null
  let drag: Drag | null = null
  let raf = 0
  let last = 0
  let swallowClick = false
  let wheelTimer = 0

  const m = {
    set: null as Settings | null,
    committed: start,
    configure: (settings: Settings) => {
      m.set = settings
    },
    go,
    move,
    reseat,
    draw,
    stop,
    listenWheel,
    handlers: {
      onPointerDown,
      onPointerMove,
      onPointerUp: (e: PointerEvent<HTMLElement>) => release(e, false),
      onPointerCancel: (e: PointerEvent<HTMLElement>) => release(e, true),
      onClickCapture,
    },
  }

  function draw() {
    m.set?.onFrame(drawn, velocity)
  }

  function tick(now: number) {
    raf = 0
    const o = m.set
    if (!o) return
    let p = want
    if (tween) {
      const u = clamp((now - tween.start) / tween.dur, 0, 1)
      p = tween.from + (tween.to - tween.from) * (1 - (1 - u) ** 5)
      if (u >= 1) tween = null
    }
    const dt = Math.max(now - last, 1) / 1000
    last = now
    // Smoothed over about 60 ms, so a layout can lean into the speed without jitter.
    velocity = o.reduce ? 0 : velocity + ((p - drawn) / dt - velocity) * (1 - Math.exp(-dt / 0.06))
    drawn = p
    const moving = tween || drag?.active || Math.abs(velocity) > 0.01
    if (!moving) velocity = 0
    o.onFrame(p, velocity)
    if (moving) raf = requestAnimationFrame(tick)
  }

  function run() {
    if (raf) return
    last = performance.now()
    raf = requestAnimationFrame(tick)
  }

  function commit(to: number, notify = true) {
    const o = m.set
    if (!o) return
    const i = mod(Math.round(to), o.n)
    if (i === m.committed) return
    m.committed = i
    setCurrent(i)
    if (notify) o.onIndexChange?.(i)
  }

  // Glide to a position. With a release speed (slides/s) in the same direction, the glide starts at that speed.
  function glide(to: number, speed = 0) {
    want = to
    const dist = to - drawn
    if (m.set?.reduce || Math.abs(dist) < 1e-4) tween = null
    else {
      const dur = speed && Math.sign(speed) === Math.sign(dist) ? clamp(((5 * Math.abs(dist)) / Math.abs(speed)) * 1000, 220, 900) : 300
      tween = { from: drawn, to, start: performance.now(), dur }
    }
    run()
  }

  function go(i: number, notify = true) {
    const o = m.set
    if (!o || mod(i, o.n) === m.committed) return
    let to: number
    if (o.loop) {
      const at = Math.round(tween?.to ?? want)
      to = at + mod(i - at + o.n / 2, o.n) - o.n / 2
      // An even count puts the far slide exactly half way round: go forwards.
      if (to < at && mod(i - at, o.n) === o.n / 2) to = at + o.n / 2
    } else to = clamp(i, 0, o.n - 1)
    glide(to)
    commit(to, notify)
  }

  // After the count or the loop changes: jump to the current slide, or the last one if it is gone. A position
  // wound past the ends of a loop means nothing in the new count, so it is unwound too.
  function reseat() {
    const o = m.set
    if (!o) return
    const i = clamp(m.committed, 0, o.n - 1)
    if (want === i && drawn === i && !tween) return
    tween = null
    velocity = 0
    want = drawn = i
    commit(i)
    draw()
  }

  function move(by: number) {
    const o = m.set
    if (!o) return
    let to = Math.round(tween?.to ?? want) + by
    if (!o.loop) to = clamp(to, 0, o.n - 1)
    glide(to)
    commit(to)
  }

  function stop() {
    cancelAnimationFrame(raf)
    clearTimeout(wheelTimer)
    raf = 0
    tween = null
    drag = null
    velocity = 0
    // Land where it was heading, so a remount starts settled.
    drawn = want
  }

  // Where a position lands once the ends of a carousel that does not loop push back.
  function give(raw: number) {
    const o = m.set!
    if (o.loop) return raw
    if (raw < 0) return -rubber(-raw)
    if (raw > o.n - 1) return o.n - 1 + rubber(raw - (o.n - 1))
    return raw
  }

  function settle(speed: number, from: number) {
    const o = m.set!
    let to = drawn + speed * THROW
    if (o.snap) {
      to = Math.round(to)
      // A quick flick always moves at least one slide.
      if (Math.abs(speed) > 0.8 && to === Math.round(from)) to += Math.sign(speed)
    }
    if (!o.loop) to = clamp(to, 0, o.n - 1)
    glide(to, speed)
    commit(to)
  }

  function onPointerDown(e: PointerEvent<HTMLElement>) {
    swallowClick = false
    // A press that left the carousel before it became a drag never got its pointerup.
    if (drag && !drag.active) drop(drag)
    if (!e.isPrimary || e.button !== 0 || drag || !m.set || m.set.n < 2) return
    const resume = tween ? tween.to : null
    // Catch a moving carousel where it is.
    if (tween) {
      tween = null
      want = drawn
    }
    drag = { id: e.pointerId, x: e.clientX, y: e.clientY, p0: drawn, active: false, samples: [[e.timeStamp, drawn]], resume }
  }

  function onPointerMove(e: PointerEvent<HTMLElement>) {
    const d = drag
    const o = m.set
    if (!d || !o || e.pointerId !== d.id) return
    // The mouse came back with its button up, so it was let go outside the carousel.
    if (!d.active && !(e.buttons & 1)) return drop(d)
    const dx = e.clientX - d.x
    const dy = e.clientY - d.y
    const along = o.axis === 'x' ? dx : dy
    const across = o.axis === 'x' ? dy : dx
    if (!d.active) {
      if (Math.abs(along) < SLOP && Math.abs(across) < SLOP) return
      // Moving across the carousel: leave it to the page.
      if (Math.abs(across) >= Math.abs(along)) return drop(d)
      d.active = true
      // Count from here, so the slop does not make the slides jump.
      if (o.axis === 'x') d.x += Math.sign(dx) * SLOP
      else d.y += Math.sign(dy) * SLOP
      e.currentTarget.setPointerCapture(e.pointerId)
      e.currentTarget.dataset.dragging = ''
    }
    const p = give(d.p0 - (o.axis === 'x' ? e.clientX - d.x : e.clientY - d.y) / per(o.step))
    want = p
    d.samples.push([e.timeStamp, p])
    while (d.samples.length > 2 && e.timeStamp - d.samples[0][0] > SAMPLE) d.samples.shift()
    run()
  }

  function release(e: PointerEvent<HTMLElement>, cancelled: boolean) {
    const d = drag
    if (!d || e.pointerId !== d.id) return
    delete e.currentTarget.dataset.dragging
    if (!d.active) return drop(d)
    drag = null
    swallowClick = true
    let speed = 0
    const [t0, p0] = d.samples[0]
    const [t1, p1] = d.samples[d.samples.length - 1]
    // A finger that stopped before letting go throws nothing.
    if (!cancelled && t1 > t0 && e.timeStamp - t1 < 60) speed = ((p1 - p0) / (t1 - t0)) * 1000
    settle(speed, d.p0)
  }

  // Let go of a press that never became a drag, and finish any glide it caught.
  function drop(d: Drag) {
    drag = null
    if (d.resume !== null) glide(d.resume)
  }

  // A drag ends with a click on whatever was under the pointer; that click is not meant.
  function onClickCapture(e: MouseEvent<HTMLElement>) {
    if (!swallowClick) return
    swallowClick = false
    e.preventDefault()
    e.stopPropagation()
  }

  // Sideways wheel and trackpad swipes. A vertical wheel is left to the page.
  function listenWheel(el: HTMLElement) {
    let base: number | null = null
    const onWheel = (e: WheelEvent) => {
      const o = m.set
      const dx = e.deltaX || (e.shiftKey ? e.deltaY : 0)
      if (!o || o.n < 2 || Math.abs(dx) <= Math.abs(e.shiftKey ? 0 : e.deltaY)) return
      e.preventDefault()
      if (drag?.active) return
      tween = null
      base = (base ?? drawn) + (dx * (e.deltaMode === 1 ? 16 : 1)) / per(o.step)
      if (!o.loop) base = clamp(base, -1.5, o.n + 0.5)
      want = give(base)
      run()
      clearTimeout(wheelTimer)
      wheelTimer = window.setTimeout(() => {
        base = null
        settle(0, drawn)
      }, 140)
    }
    el.addEventListener('wheel', onWheel, { passive: false })
    return () => {
      el.removeEventListener('wheel', onWheel)
      clearTimeout(wheelTimer)
    }
  }

  return m
}
use-gl-stage.ts
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react'
import { useReducedMotion } from '@/registry/manniche/hooks/use-reduced-motion'

/** A picture for a WebGL carousel. The alt text is what screen readers read; the canvas itself is hidden from them. */
export type GlImage = { src: string; alt: string }

export type Rgb = [number, number, number]

export type GlSceneContext = {
  gl: WebGLRenderingContext
  canvas: HTMLCanvasElement
  /** Call once the scene has what it needs for its first frame, e.g. after its pictures load. The canvas fades in. */
  ready: () => void
  /** Draw again, e.g. once a texture has loaded. */
  redraw: () => void
  /** Call when the scene cannot run after all, e.g. its pictures could not be uploaded. The stage reports `failed`. */
  fail: () => void
  /** The theme's colours when the scene is built, in the order of `colors`, e.g. for a texture's empty cells. */
  colors: Rgb[]
  /** Set when the stage is torn down, so work that finishes later can stop. */
  signal: AbortSignal
}

export type GlScene = {
  /** Draws a frame. `time` is in seconds; it only moves while the stage animates on its own and stands still under reduced motion. */
  draw: (time: number) => void
  /** The canvas has a new size in device pixels. The viewport is already set. */
  resize?: (width: number, height: number, dpr: number) => void
  /** The theme's colours, in the order of `colors`, as 0–1 RGB. Called before the first frame and on every theme change. */
  theme?: (colors: Rgb[]) => void
  /** Frees what the scene made. The context is released right after. */
  dispose?: () => void
}

export type GlStageOptions = {
  /** Upper bound on device pixels per CSS pixel. */
  maxDpr?: number
  /** Upper bound on the canvas's device pixels; past it the canvas renders smaller and the browser scales it up. */
  maxPixels?: number
  /** A frame loop of its own while on screen, for motion that does not come from the carousel. Off under reduced motion. */
  animate?: boolean
  /** CSS colours the scene needs, e.g. `var(--background)`. Read from the host, so they follow the theme. */
  colors?: string[]
  /** Classes for the canvas, which goes first in the host. */
  className?: string
  /** Context attributes on top of the defaults (no antialias, low power). */
  attributes?: WebGLContextAttributes
}

export type GlStage = {
  /** `pending` until the scene is ready, `ready` once it has drawn, `failed` without WebGL or after the GPU drops the context. */
  status: 'pending' | 'ready' | 'failed'
  /** Draw a frame now, e.g. from a carousel's `onFrame`. Does nothing before the scene exists. */
  redraw: () => void
}

// Seconds of animation that a still frame shows under reduced motion, so it is not the plain first frame.
const STILL = 4

/**
 * Runs a WebGL scene in a canvas inside `host`: a fresh canvas each run, sized to the host with a cap on pixels,
 * the theme's colours read live, its own frame loop only while on screen, and the context released on teardown or
 * failure, so a page never holds more than it uses. `build` makes the scene; it runs again when `key` changes, so put
 * everything the scene is built from in it, e.g. the pictures' addresses. Return null from `build` when the scene cannot
 * run, and the stage reports `failed`.
 */
export function useGlStage(
  host: RefObject<HTMLElement | null>,
  build: (ctx: GlSceneContext) => GlScene | null,
  key: string,
  { maxDpr = 2, maxPixels = 2_400_000, animate = false, colors = [], className = '', attributes }: GlStageOptions = {},
): GlStage {
  const reduce = useReducedMotion()
  const [status, setStatus] = useState<GlStage['status']>('pending')
  // The live scene's draw, for redraw(); null between runs.
  const live = useRef<(() => void) | null>(null)
  const palette = colors.join('|')
  const attrs = JSON.stringify(attributes ?? {})
  // The latest build, so a new function each render does not rebuild the scene; `key` does.
  const make = useRef(build)
  useLayoutEffect(() => {
    make.current = build
  })

  useEffect(() => {
    const wrap = host.current
    if (!wrap) return
    const abort = new AbortController()
    // Reported after the effect, and only while this run is the live one.
    const report = (s: GlStage['status']) => queueMicrotask(() => abort.signal.aborted || setStatus(s))
    report('pending')
    // A fresh canvas per run: a canvas whose context was lost cannot hand out a new one.
    const el = document.createElement('canvas')
    el.setAttribute('aria-hidden', 'true')
    el.className = className
    wrap.prepend(el)

    const options: WebGLContextAttributes = { antialias: false, alpha: true, premultipliedAlpha: true, powerPreference: 'low-power', ...JSON.parse(attrs) }
    const gl = el.getContext('webgl', options)
    if (!gl) {
      el.remove()
      report('failed')
      return () => abort.abort()
    }

    let scene: GlScene | null = null
    let dead = false
    let shown = false
    let clock = STILL
    let raf = 0
    let last = 0
    let visible = false
    const looping = animate && !reduce

    const frame = () => {
      if (!dead && scene) scene.draw(clock)
    }
    let released = false
    const release = () => {
      if (released) return
      released = true
      dead = true
      live.current = null
      cancelAnimationFrame(raf)
      abort.abort()
      try {
        scene?.dispose?.()
      } catch {
        // The context may already be gone; there is nothing left to free.
      }
      gl.getExtension('WEBGL_lose_context')?.loseContext()
      el.remove()
    }

    // The theme's colours, read through a 1 px canvas so any CSS colour (oklch, color-mix, var) comes out as RGB.
    const probe = document.createElement('canvas').getContext('2d', { willReadFrequently: true })
    const wanted = palette ? palette.split('|') : []
    const read = () => {
      const style = getComputedStyle(wrap)
      return wanted.map((c): Rgb => {
        const value = c.startsWith('var(') ? style.getPropertyValue(c.slice(4, -1).split(',')[0].trim()).trim() || '#808080' : c
        if (!probe) return [0.5, 0.5, 0.5]
        probe.clearRect(0, 0, 1, 1)
        probe.fillStyle = '#808080'
        probe.fillStyle = value
        probe.fillRect(0, 0, 1, 1)
        const [r, g, b] = probe.getImageData(0, 0, 1, 1).data
        return [r / 255, g / 255, b / 255]
      })
    }

    const ctx: GlSceneContext = {
      gl,
      canvas: el,
      signal: abort.signal,
      colors: read(),
      fail: () => {
        if (dead) return
        release()
        queueMicrotask(() => setStatus('failed'))
      },
      redraw: () => {
        // A running loop draws on its next frame anyway.
        if (!(looping && visible)) frame()
      },
      ready: () => {
        if (dead || shown) return
        shown = true
        frame()
        // Wait a frame so the canvas fades in instead of popping.
        requestAnimationFrame(() => {
          if (dead) return
          el.dataset.ready = ''
          report('ready')
        })
      },
    }

    try {
      scene = make.current(ctx)
    } catch {
      scene = null
    }
    // Free the context straight away, so a scene that cannot run does not hold one of the page's few.
    if (!scene) {
      release()
      queueMicrotask(() => setStatus('failed'))
      return
    }
    live.current = ctx.redraw

    const theme = () => {
      if (dead || !scene?.theme || !wanted.length) return
      scene.theme(read())
      ctx.redraw()
    }
    theme()
    // The theme changes through a class or inline variables on <html>, or the system's colour scheme.
    const mo = new MutationObserver(theme)
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] })
    const scheme = window.matchMedia('(prefers-color-scheme: dark)')
    scheme.addEventListener('change', theme)

    const resize = () => {
      if (dead) return
      const w = el.clientWidth
      const h = el.clientHeight
      let dpr = Math.min(maxDpr, window.devicePixelRatio || 1)
      if (w * h * dpr * dpr > maxPixels) dpr = Math.sqrt(maxPixels / Math.max(1, w * h))
      el.width = Math.max(1, Math.round(w * dpr))
      el.height = Math.max(1, Math.round(h * dpr))
      gl.viewport(0, 0, el.width, el.height)
      scene?.resize?.(el.width, el.height, dpr)
      frame()
    }
    const ro = new ResizeObserver(resize)
    ro.observe(el)
    resize()

    const loop = (now: number) => {
      // Cap the step so a stalled tab does not jump the motion ahead.
      clock += Math.min(50, now - (last || now)) / 1000
      last = now
      frame()
      raf = requestAnimationFrame(loop)
    }
    // Only animate while on screen.
    const io = new IntersectionObserver(([entry]) => {
      if (dead || !looping || entry.isIntersecting === visible) return
      visible = entry.isIntersecting
      last = 0
      if (visible) raf = requestAnimationFrame(loop)
      else cancelAnimationFrame(raf)
    })
    io.observe(el)

    // If the GPU drops the context, the component shows its fallback again.
    const lost = (e: Event) => {
      e.preventDefault()
      dead = true
      live.current = null
      cancelAnimationFrame(raf)
      delete el.dataset.ready
      report('failed')
    }
    el.addEventListener('webglcontextlost', lost)

    return () => {
      ro.disconnect()
      io.disconnect()
      mo.disconnect()
      scheme.removeEventListener('change', theme)
      el.removeEventListener('webglcontextlost', lost)
      release()
    }
  }, [host, key, reduce, animate, maxDpr, maxPixels, palette, className, attrs])

  const redraw = useCallback(() => live.current?.(), [])
  return { status, redraw }
}

/**
 * Compiles and links a program, or returns null so the caller can fall back; the reason goes to the console unless the
 * context was lost. A uniform used in both shaders must have the same precision in each, or linking fails.
 */
export function glProgram(gl: WebGLRenderingContext, vertex: string, fragment: string) {
  const program = gl.createProgram()
  if (!program) return null
  const log: string[] = []
  for (const [type, src] of [
    [gl.VERTEX_SHADER, vertex],
    [gl.FRAGMENT_SHADER, fragment],
  ] as const) {
    const s = gl.createShader(type)
    if (!s) {
      gl.deleteProgram(program)
      return null
    }
    gl.shaderSource(s, src)
    gl.compileShader(s)
    if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) log.push(gl.getShaderInfoLog(s) ?? '')
    gl.attachShader(program, s)
    // Flagged for deletion; it lives until the program goes.
    gl.deleteShader(s)
  }
  gl.linkProgram(program)
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    if (!gl.isContextLost()) console.warn('WebGL program failed:', log.join('\n') || gl.getProgramInfoLog(program))
    gl.deleteProgram(program)
    return null
  }
  return program
}

export type GlAtlas = {
  /** The texture, power-of-two sized, with mipmaps. */
  texture: WebGLTexture
  /** Each picture's corner in the atlas as [u0, v0, u1, v1], with v growing downwards (the texture is not flipped). */
  rects: [number, number, number, number][]
  /** False for a picture that did not load; its cell shows `empty` instead. */
  loaded: boolean[]
  /** A cell's width and height in texels, without its gutter. */
  cell: [number, number]
  /** The texture's width and height in texels. */
  size: [number, number]
}

const pow2 = (v: number) => 2 ** Math.ceil(Math.log2(Math.max(1, v)))

function load(src: string, signal: AbortSignal) {
  return new Promise<HTMLImageElement | null>((resolve) => {
    const img = new Image()
    // A picture from another origin must send CORS headers to be drawn into WebGL; without them it shows as empty.
    if (!/^(data|blob):/.test(src)) img.crossOrigin = 'anonymous'
    img.decoding = 'async'
    img.onload = () => resolve(img)
    img.onerror = () => resolve(null)
    signal.addEventListener('abort', () => resolve(null), { once: true })
    img.src = src
  })
}

export type GlAtlasOptions = {
  /** The pictures' width over height; each is cropped from the middle to fill a cell of this shape. */
  aspect: number
  /** The longer side of a cell in texels, at most. Cells shrink to keep the texture within `limit`. */
  cell?: number
  /** The texture's largest side in texels. A 2048 atlas with mipmaps takes about 22 MB of GPU memory, a 4096 one four times that. */
  limit?: number
  /** The colour of a cell whose picture did not load. */
  empty?: Rgb
  signal: AbortSignal
}

/**
 * Loads pictures and paints them, cropped to fill a cell of `aspect` (width over height), into one texture. Each cell
 * has a gutter of its own edge pixels, so mipmaps and filtering never bleed one picture into the next. Resolves to
 * null if the stage was torn down meanwhile, or if the pictures could not be uploaded.
 */
export async function glAtlas(
  gl: WebGLRenderingContext,
  images: GlImage[],
  { aspect, cell = 1024, limit = 2048, empty = [0.5, 0.5, 0.5], signal }: GlAtlasOptions,
): Promise<GlAtlas | null> {
  const n = Math.max(images.length, 1)
  const cols = Math.ceil(Math.sqrt(n * aspect) / aspect) || 1
  const rows = Math.ceil(n / cols)
  const gutter = 4
  // The largest cell in the picture's shape that fits the grid within the limit; the limit is a power of two, so the
  // texture never rounds up past it.
  const max = Math.min(pow2(limit), gl.getParameter(gl.MAX_TEXTURE_SIZE) as number)
  const w = aspect >= 1 ? 1 : aspect
  const h = aspect >= 1 ? 1 / aspect : 1
  const size = Math.max(16, Math.floor(Math.min(cell, (max / cols - gutter * 2) / w, (max / rows - gutter * 2) / h)))
  const cw = Math.max(1, Math.floor(size * w))
  const ch = Math.max(1, Math.floor(size * h))
  const W = pow2((cw + gutter * 2) * cols)
  const H = pow2((ch + gutter * 2) * rows)

  const pictures = await Promise.all(images.map((im) => load(im.src, signal)))
  if (signal.aborted) return null

  const canvas = document.createElement('canvas')
  canvas.width = W
  canvas.height = H
  const c = canvas.getContext('2d')
  if (!c) return null
  c.imageSmoothingQuality = 'high'
  const fill = `rgb(${empty.map((v) => Math.round(v * 255)).join(' ')})`
  const rects: GlAtlas['rects'] = []
  const loaded: boolean[] = []

  pictures.forEach((img, i) => {
    const x = (i % cols) * (cw + gutter * 2) + gutter
    const y = Math.floor(i / cols) * (ch + gutter * 2) + gutter
    rects.push([x / W, y / H, (x + cw) / W, (y + ch) / H])
    const ok = !!img && img.naturalWidth > 0
    loaded.push(ok)
    if (!ok) {
      c.fillStyle = fill
      c.fillRect(x - gutter, y - gutter, cw + gutter * 2, ch + gutter * 2)
      return
    }
    // Crop to fill the cell, from the middle.
    const k = Math.max(cw / img.naturalWidth, ch / img.naturalHeight)
    const sw = cw / k
    const sh = ch / k
    const sx = (img.naturalWidth - sw) / 2
    const sy = (img.naturalHeight - sh) / 2
    c.drawImage(img, sx, sy, sw, sh, x, y, cw, ch)
    // The gutter repeats the cell's outermost pixels.
    c.drawImage(canvas, x, y, cw, 1, x, y - gutter, cw, gutter)
    c.drawImage(canvas, x, y + ch - 1, cw, 1, x, y + ch, cw, gutter)
    c.drawImage(canvas, x, y - gutter, 1, ch + gutter * 2, x - gutter, y - gutter, gutter, ch + gutter * 2)
    c.drawImage(canvas, x + cw - 1, y - gutter, 1, ch + gutter * 2, x + cw, y - gutter, gutter, ch + gutter * 2)
  })

  const texture = gl.createTexture()
  if (!texture || gl.isContextLost()) return null
  gl.bindTexture(gl.TEXTURE_2D, texture)
  gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true)
  try {
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas)
  } catch {
    // A picture tainted the canvas; nothing can be uploaded.
    gl.deleteTexture(texture)
    return null
  }
  gl.generateMipmap(gl.TEXTURE_2D)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
  const aniso = gl.getExtension('EXT_texture_filter_anisotropic')
  if (aniso) gl.texParameterf(gl.TEXTURE_2D, aniso.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(8, gl.getParameter(aniso.MAX_TEXTURE_MAX_ANISOTROPY_EXT)))
  return { texture, rects, loaded, cell: [cw, ch], size: [W, H] }
}
use-reduced-motion.ts
import { useSyncExternalStore } from 'react'

const QUERY = '(prefers-reduced-motion: reduce)'

function subscribe(onChange: () => void) {
  const mq = window.matchMedia(QUERY)
  mq.addEventListener('change', onChange)
  return () => mq.removeEventListener('change', onChange)
}

/** True when the visitor has asked the system for less motion. */
export function useReducedMotion() {
  return useSyncExternalStore(
    subscribe,
    () => window.matchMedia(QUERY).matches,
    () => false,
  )
}

Installieren

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/lens-strip.json

Mit eingetragener Registry (components.json)

npx shadcn@latest add @manniche/lens-strip

Abhängigkeiten

Dateien

  • lens-strip.tsx
  • use-carousel-engine.ts
  • use-gl-stage.ts
  • use-reduced-motion.ts

Manniche Lab · Open Source

Kostenlose Werkzeuge für KI-Agenten und Webentwicklung.