{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "gallery-ascii-gradient-orientation",
  "title": "Gallery ASCII Gradient Orientation",
  "description": "A gallery grid where each tile runs its own procedural 3-octave value-noise field and a 3x3 Sobel operator selects glyphs by the field's own gradient ANGLE, bucketed into '-', '/', '|', '\\\\', keeping only the adaptively top-12%-by-magnitude cells each frame so the field reads as a moving edge sketch.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/gallery-ascii-gradient-orientation/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// GalleryAsciiGradientOrientation — a gallery grid whose tiles stand in for\n// photographic content with self-generated imagery: each tile runs its own\n// 3-octave value-noise loop (same wave-octave family as background-ascii-\n// plasma) into a small offscreen scalar buffer, mulberry32-seeded per tile\n// index so every tile settles into a distinct, phase-locked drifting field\n// instead of all nine looking identical. A 3x3 SOBEL operator on that raw\n// buffer — never on rendered pixels — gives every glyph cell a gradient\n// ANGLE, bucketed into '-', '/', '|', '\\' the same 4-way slope split\n// background-ascii-flow uses for velocity heading. This is a categorically\n// different input: flow keys the alphabet to a simulated field's direction\n// of TRAVEL, this keys it to a generated image's own gradient NORMAL,\n// recomputed from the noise scalar every frame. Only the strongest ~12% of\n// cells by magnitude survive an adaptive per-frame percentile cut (never a\n// fixed threshold — sparsity has to track the scene's own shifting local\n// contrast), so the field reads as a moving edge sketch, not a wash. The\n// buffer is supersampled ~3x the glyph grid's own resolution so the Sobel\n// estimate isn't degenerate at 1:1 with the glyph pitch. Color never\n// touches the buffer: every cell's selection and angle are decided on the\n// bare 0..1 scalar array, and --foreground is assigned only at the final\n// glyph-draw step — so edge selection is byte-identical between themes.\n// ---------------------------------------------------------------------------\n\nconst DIR_CHARS = [\"-\", \"/\", \"|\", \"\\\\\"] as const;\nconst SUPERSAMPLE = 3; // buffer cells per glyph cell, each axis\nconst TOP_PERCENT = 0.12; // fraction of cells kept after the magnitude cut\nconst FIELD_SPEED = 0.5; // t units / s\n\nfunction mulberry32(seed: number) {\n  let a = seed >>> 0;\n  return () => {\n    a |= 0;\n    a = (a + 0x6d2b79f5) | 0;\n    let t = Math.imul(a ^ (a >>> 15), 1 | a);\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\n// three summed sine octaves, same family as background-ascii-plasma's\n// fieldValue: a slow isotropic swell, a mid-frequency crossing band, a fast\n// fine ripple — offset per axis by a mulberry32-drawn phase so each tile's\n// field is a distinct variant of the same recipe, never a copy.\nfunction fieldValue(x: number, y: number, t: number, px: number, py: number, pt: number): number {\n  const xx = x + px;\n  const yy = y + py;\n  const tt = t + pt;\n  const a = Math.sin(xx * 0.5 + tt * 0.7) + Math.sin(yy * 0.46 - tt * 0.55) + Math.sin((xx - yy) * 0.33 + tt * 0.4);\n  const b = Math.sin(xx * 1.35 - tt * 1.4) + Math.sin(yy * 1.1 + tt * 1.1);\n  const c = Math.sin(xx * 2.5 + yy * 2.1 + tt * 2.6) + Math.sin((xx + yy) * 2.8 - tt * 3.1);\n  const v = a * 0.5 + b * 0.32 + c * 0.18;\n  return v / 5 + 0.5; // rough-normalize to ~0..1\n}\n\nfunction dirChar(gx: number, gy: number): string {\n  // angle of the gradient VECTOR itself, folded to [0, PI) since an edge's\n  // orientation is undirected — same fold+bucket background-ascii-flow uses\n  // for velocity heading, applied here to a gradient normal instead.\n  let a = Math.atan2(gy, gx) % Math.PI;\n  if (a < 0) a += Math.PI;\n  const idx = Math.round(a / (Math.PI / 4)) % 4;\n  return DIR_CHARS[idx]!;\n}\n\ninterface Tile {\n  seed: number;\n  px: number;\n  py: number;\n  pt: number;\n}\n\ninterface TileGeom {\n  cellW: number;\n  cellH: number;\n  cols: number;\n  rows: number;\n  bufW: number;\n  bufH: number;\n  field: Float32Array;\n  gx: Float32Array;\n  gy: Float32Array;\n  mag: Float32Array;\n  sizedW: number;\n  sizedH: number;\n}\n\nexport interface GalleryAsciiGradientOrientationProps {\n  /** number of generated tiles */\n  tileCount?: number;\n  /** grid columns at the default breakpoint */\n  columns?: number;\n  /** glyph grid cell size in px */\n  cellSize?: number;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function GalleryAsciiGradientOrientation({\n  tileCount = 9,\n  columns = 3,\n  cellSize = 9,\n  className = \"\",\n}: GalleryAsciiGradientOrientationProps) {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const canvasRefs = useRef<(HTMLCanvasElement | null)[]>([]);\n\n  useEffect(() => {\n    const container = containerRef.current;\n    if (!container) return;\n\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n    let fg = \"currentColor\";\n    let disposed = false;\n    let ready = false;\n    let raf = 0;\n    let last = 0;\n    let t = 0;\n\n    const tiles: Tile[] = Array.from({ length: tileCount }, (_, i) => {\n      // mulberry32 seeded per tile index -> the phase draw itself is the\n      // \"per tile position\" seed: distinct tiles never share a field.\n      const rand = mulberry32(0x9e3779b1 ^ (i * 0x2545f491 + 1));\n      return {\n        seed: i,\n        px: rand() * 1000,\n        py: rand() * 1000,\n        pt: rand() * 1000,\n      };\n    });\n\n    const geoms: (TileGeom | null)[] = new Array(tileCount).fill(null);\n\n    const readTokens = () => {\n      fg = getComputedStyle(document.documentElement).getPropertyValue(\"--foreground\").trim() || fg;\n    };\n\n    // measured post-fonts.ready, off-DOM — a fallback-font advance width\n    // bakes in the wrong grid ratio otherwise.\n    const measureCell = () => {\n      const off = document.createElement(\"canvas\");\n      const octx = off.getContext(\"2d\");\n      if (!octx) return { cellW: cellSize, cellH: cellSize };\n      const fontFamily = getComputedStyle(container).fontFamily || \"ui-monospace, monospace\";\n      octx.font = `${cellSize}px ${fontFamily}`;\n      const cellW = Math.max(3, octx.measureText(\"MMMMMMMMMM\").width / 10);\n      return { cellW, cellH: cellSize };\n    };\n\n    const buildGeom = (canvas: HTMLCanvasElement): TileGeom | null => {\n      const rect = canvas.getBoundingClientRect();\n      const width = rect.width;\n      const height = rect.height;\n      if (width < 4 || height < 4) return null;\n      const { cellW, cellH } = measureCell();\n      const cols = Math.max(2, Math.floor(width / cellW));\n      const rows = Math.max(2, Math.floor(height / cellH));\n      const bufW = cols * SUPERSAMPLE + 2; // +2 border for the Sobel kernel\n      const bufH = rows * SUPERSAMPLE + 2;\n      return {\n        cellW,\n        cellH,\n        cols,\n        rows,\n        bufW,\n        bufH,\n        field: new Float32Array(bufW * bufH),\n        gx: new Float32Array(cols * rows),\n        gy: new Float32Array(cols * rows),\n        mag: new Float32Array(cols * rows),\n        sizedW: width,\n        sizedH: height,\n      };\n    };\n\n    const sizeCanvas = (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D, geom: TileGeom) => {\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = Math.max(1, Math.round(geom.sizedW * dpr));\n      canvas.height = Math.max(1, Math.round(geom.sizedH * dpr));\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      const fontFamily = getComputedStyle(canvas).fontFamily || \"ui-monospace, monospace\";\n      ctx.font = `${cellSize}px ${fontFamily}`;\n      ctx.textAlign = \"center\";\n      ctx.textBaseline = \"middle\";\n    };\n\n    const resizeAll = () => {\n      let anySized = false;\n      canvasRefs.current.forEach((canvas, i) => {\n        if (!canvas) return;\n        const geom = buildGeom(canvas);\n        geoms[i] = geom;\n        if (!geom) return;\n        const ctx = canvas.getContext(\"2d\");\n        if (!ctx) return;\n        sizeCanvas(canvas, ctx, geom);\n        anySized = true;\n      });\n      return anySized;\n    };\n\n    // Sobel + adaptive-threshold + draw for one tile at time `tt`. Every\n    // step reads the raw Float32Array noise buffer directly — never\n    // ctx.getImageData — so the two themes select byte-identical cells;\n    // color is assigned only in the final fillText loop.\n    const renderTile = (canvas: HTMLCanvasElement, tile: Tile, geom: TileGeom, tt: number) => {\n      const ctx = canvas.getContext(\"2d\");\n      if (!ctx) return;\n      const { cellW, cellH, cols, rows, bufW, bufH, field, gx, gy, mag } = geom;\n\n      // fill the raw noise scalar buffer, one field sample per supersampled\n      // cell (buffer coords run -1..cols*SUPERSAMPLE so the Sobel kernel has\n      // a real neighbor on every edge cell it evaluates).\n      for (let by = 0; by < bufH; by++) {\n        const ny = ((by - 1) / (rows * SUPERSAMPLE)) * rows;\n        for (let bx = 0; bx < bufW; bx++) {\n          const nx = ((bx - 1) / (cols * SUPERSAMPLE)) * cols;\n          field[by * bufW + bx] = fieldValue(nx, ny, tt, tile.px, tile.py, tile.pt);\n        }\n      }\n\n      // 3x3 Sobel over the raw scalar buffer, aggregated (vector-summed) per\n      // glyph cell's SUPERSAMPLE x SUPERSAMPLE block so the gradient\n      // estimate isn't degenerate at 1:1 with the glyph pitch.\n      let maxMag = 1e-6;\n      for (let gyi = 0; gyi < rows; gyi++) {\n        for (let gxi = 0; gxi < cols; gxi++) {\n          let sumGx = 0;\n          let sumGy = 0;\n          for (let dy = 0; dy < SUPERSAMPLE; dy++) {\n            for (let dx = 0; dx < SUPERSAMPLE; dx++) {\n              // +1 shifts into the padded buffer; the 3x3 kernel then always\n              // has a real neighbor in every direction.\n              const cx = gxi * SUPERSAMPLE + dx + 1;\n              const cy = gyi * SUPERSAMPLE + dy + 1;\n              const tl = field[(cy - 1) * bufW + (cx - 1)]!;\n              const tc = field[(cy - 1) * bufW + cx]!;\n              const tr = field[(cy - 1) * bufW + (cx + 1)]!;\n              const ml = field[cy * bufW + (cx - 1)]!;\n              const mr = field[cy * bufW + (cx + 1)]!;\n              const bl = field[(cy + 1) * bufW + (cx - 1)]!;\n              const bc = field[(cy + 1) * bufW + cx]!;\n              const br = field[(cy + 1) * bufW + (cx + 1)]!;\n              sumGx += -tl + tr - 2 * ml + 2 * mr - bl + br;\n              sumGy += -tl - 2 * tc - tr + bl + 2 * bc + br;\n            }\n          }\n          const idx = gyi * cols + gxi;\n          gx[idx] = sumGx;\n          gy[idx] = sumGy;\n          const m = Math.hypot(sumGx, sumGy);\n          mag[idx] = m;\n          if (m > maxMag) maxMag = m;\n        }\n      }\n\n      // adaptive percentile cut: recomputed every frame over this tile's own\n      // magnitude distribution, so sparsity tracks the field's own shifting\n      // local contrast rather than a fixed cutoff.\n      const cellCount = cols * rows;\n      const sorted = Array.from(mag).sort((a, b) => a - b);\n      const cutIdx = Math.min(cellCount - 1, Math.floor(cellCount * (1 - TOP_PERCENT)));\n      const threshold = sorted[cutIdx] ?? 0;\n\n      ctx.clearRect(0, 0, geom.sizedW, geom.sizedH);\n      ctx.fillStyle = fg;\n      for (let gyi = 0; gyi < rows; gyi++) {\n        for (let gxi = 0; gxi < cols; gxi++) {\n          const idx = gyi * cols + gxi;\n          if (mag[idx]! < threshold) continue;\n          const ch = dirChar(gx[idx]!, gy[idx]!);\n          ctx.fillText(ch, gxi * cellW + cellW / 2, gyi * cellH + cellH / 2);\n        }\n      }\n    };\n\n    const drawAll = (tt: number) => {\n      canvasRefs.current.forEach((canvas, i) => {\n        const geom = geoms[i];\n        if (!canvas || !geom) return;\n        renderTile(canvas, tiles[i]!, geom, tt);\n      });\n    };\n\n    let resizeTimer: ReturnType<typeof setTimeout> | null = null;\n    const onResize = () => {\n      if (resizeTimer) clearTimeout(resizeTimer);\n      resizeTimer = setTimeout(() => {\n        resizeTimer = null;\n        if (resizeAll() && reduced) drawAll(0);\n      }, 150);\n    };\n\n    const loop = (now: number) => {\n      const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;\n      last = now;\n      t += dt * FIELD_SPEED;\n      drawAll(t);\n      if (!document.hidden) raf = requestAnimationFrame(loop);\n    };\n\n    const onVis = () => {\n      if (!document.hidden && !reduced && ready) {\n        last = 0;\n        raf = requestAnimationFrame(loop);\n      }\n    };\n\n    const mo = new MutationObserver(() => {\n      readTokens();\n      if (reduced) drawAll(0);\n    });\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    const ro = new ResizeObserver(() => onResize());\n    ro.observe(container);\n\n    document.fonts.ready.then(() => {\n      if (disposed) return;\n      readTokens();\n      resizeAll();\n      ready = true;\n      if (reduced) {\n        // freeze at t=0: the mulberry32 seed guarantees per-tile structure\n        // from frame one, so t=0 is never a degenerate all-flat frame.\n        drawAll(0);\n      } else {\n        raf = requestAnimationFrame(loop);\n      }\n    });\n\n    window.addEventListener(\"resize\", onResize);\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(raf);\n      if (resizeTimer) clearTimeout(resizeTimer);\n      mo.disconnect();\n      ro.disconnect();\n      window.removeEventListener(\"resize\", onResize);\n      document.removeEventListener(\"visibilitychange\", onVis);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [tileCount, cellSize]);\n\n  return (\n    <div\n      ref={containerRef}\n      className={`grid gap-3 font-mono ${className}`}\n      style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}\n    >\n      {Array.from({ length: tileCount }, (_, i) => (\n        <button\n          key={i}\n          type=\"button\"\n          aria-label={`Gallery item ${i + 1}: generative edge-sketch pattern`}\n          className=\"relative aspect-square overflow-hidden rounded-md border border-border outline-none transition-colors hover:border-ns-accent focus-visible:border-ns-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\"\n        >\n          <canvas\n            ref={(el) => {\n              canvasRefs.current[i] = el;\n            }}\n            aria-hidden\n            className=\"block h-full w-full\"\n          />\n        </button>\n      ))}\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/gallery-ascii-gradient-orientation.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-accent": "#006bff"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "gallery",
      "ascii",
      "canvas",
      "noise",
      "sobel",
      "grid"
    ],
    "instruction": "Build <GalleryAsciiGradientOrientation tileCount? columns? cellSize? className?> as a gallery grid of generated tiles, never external image assets. Each tile owns a small offscreen scalar buffer running its own 3-octave value-noise loop (same summed sine-octave family as background-ascii-plasma: a slow isotropic swell, a mid-frequency crossing band, a fast fine ripple), mulberry32-seeded per tile index so every tile settles into a distinct, phase-locked drift instead of nine copies of the same field. SOBEL, NOT PIXELS: a 3x3 Sobel operator (Gx, Gy) runs directly on that raw 0..1 Float32Array noise scalar array, supersampled ~3x the glyph grid's own resolution (vector-summed per glyph cell's supersample block) so the gradient estimate isn't degenerate at 1:1 with the glyph pitch — never on ctx.getImageData pixels, and never after any token or color is applied. magnitude = hypot(Gx, Gy); angle = atan2(Gy, Gx) mod PI (edges are undirected), bucketed into 45-degree wedges onto '-' (near 0deg), '/' (near 45deg), '|' (near 90deg), '\\\\' (near 135deg) — the same 4-glyph alphabet background-ascii-flow keys to a simulated velocity field's heading, but here keyed to a generated image's own gradient normal instead, recomputed every frame. ADAPTIVE SPARSITY: each frame recomputes the top ~12% of that tile's own cells by magnitude via a percentile cut over its own distribution (never a fixed threshold), so which cells survive tracks the scene's own shifting local contrast as the field drifts. Color is assigned only at the final glyph-draw step in --foreground, strictly after cell selection is complete, so edge selection is byte-identical between light and dark themes. Each tile is a real <button> with an accessible name describing it as generated content and its index (e.g. \"Gallery item 3: generative edge-sketch pattern\"), never \"image 3\" since the visual is procedural, not photographic; the canvas itself is aria-hidden. --ns-accent appears only on the tile's hover/focus ring (border and outline), never inside the generated content. prefers-reduced-motion freezes every tile's field at t=0 (the mulberry32 seed guarantees per-tile structure from frame one, so t=0 is never a degenerate all-flat frame), running Sobel and the threshold cut exactly once and skipping the rAF loop entirely. Cell metrics measured via an offscreen canvas after document.fonts.ready; a ResizeObserver on the grid re-measures and rebuilds every tile's buffer on reflow. Props: tileCount (default 9), columns (default 3), cellSize (default 9), className. Zero dependencies."
  },
  "type": "registry:ui"
}