{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"A DitherChart component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useMemo, useRef } from \"react\";\n\n/* -------------------------------------------------------------------------- */\n/* Public types                                                               */\n/* -------------------------------------------------------------------------- */\n\nexport const DITHER_CHART_VARIANTS = [\n  \"donut\",\n  \"line\",\n  \"bar\",\n  \"stacked\",\n  \"gauge\",\n  \"funnel\",\n  \"heatmap\",\n  \"bubbles\",\n] as const;\n\nexport type DitherChartVariant = (typeof DITHER_CHART_VARIANTS)[number];\n\n/** Bayer matrix order used to quantise every fill into a dot pattern. */\nexport type DitherChartMatrixOrder = 2 | 4 | 8;\n\nexport interface DitherChartPoint {\n  /** Optional label, used only by the screen-reader text alternative. */\n  label?: string;\n  /** Numeric value of the point. */\n  value: number;\n}\n\nexport interface DitherChartSeries {\n  /** Overrides the palette colour for this series. */\n  color?: string;\n  /** Series name, used only by the screen-reader text alternative. */\n  name?: string;\n  /** Ordered data points of the series. */\n  points: DitherChartPoint[];\n}\n\nexport interface DitherChartProps {\n  /** Draw the chart in on mount / when it scrolls into view. */\n  animate?: boolean;\n  /** Caption rendered under the chart. */\n  caption?: string;\n  className?: string;\n  /**\n   * Chart data. Every variant reads the same shape:\n   * `donut`, `gauge`, `funnel` use the first series, `heatmap` maps one series\n   * per row, the rest render every series.\n   */\n  data: DitherChartSeries[];\n  /** CSS height in pixels. */\n  height?: number;\n  /** Accessible title rendered above the chart. */\n  label?: string;\n  /** Bayer matrix order. Larger matrices produce finer gradients. */\n  matrix?: DitherChartMatrixOrder;\n  /** Colours cycled per series (or per slice for `donut`). */\n  palette?: string[];\n  /** Size in CSS pixels of one dithered pixel. */\n  pixelSize?: number;\n  variant?: DitherChartVariant;\n  /** CSS width in pixels. */\n  width?: number;\n}\n\n/* -------------------------------------------------------------------------- */\n/* Constants                                                                  */\n/* -------------------------------------------------------------------------- */\n\nconst DEFAULT_WIDTH = 320;\nconst DEFAULT_HEIGHT = 180;\nconst DEFAULT_PIXEL_SIZE = 3;\nconst DEFAULT_MATRIX: DitherChartMatrixOrder = 4;\nconst DEFAULT_VARIANT: DitherChartVariant = \"line\";\n\nconst DRAW_IN_DURATION_MS = 900;\nconst BEZIER_ITERATIONS = 16;\nconst EASE_OUT_X1 = 0.23;\nconst EASE_OUT_Y1 = 1;\nconst EASE_OUT_X2 = 0.32;\nconst EASE_OUT_Y2 = 1;\n\nconst ALPHA_MAX = 255;\nconst RGBA_STRIDE = 4;\nconst ALPHA_OFFSET = 3;\nconst QUADRANT_OFFSETS = [\n  [0, 2],\n  [3, 1],\n];\n\n/** Alpha ramp that gives each series a distinct dither density. */\nconst TONE_RAMP = [1, 0.68, 0.46, 0.3, 0.2];\nconst AREA_TONE_FACTOR = 0.45;\nconst TRACK_TONE = 0.16;\nconst HEATMAP_MIN_TONE = 0.08;\n\nconst EDGE_INSET = 1;\nconst LINE_WIDTH = 1;\nconst BAR_FILL_RATIO = 0.72;\nconst FUNNEL_ROW_GAP = 1;\nconst DONUT_INNER_RATIO = 0.55;\nconst GAUGE_INNER_RATIO = 0.62;\nconst GAUGE_START_ANGLE = Math.PI * 0.75;\nconst GAUGE_SWEEP = Math.PI * 1.5;\nconst GAUGE_MAX_VALUE = 100;\nconst BUBBLE_MIN_RADIUS = 1;\nconst BUBBLE_MAX_RATIO = 0.16;\nconst FULL_TURN = Math.PI * 2;\nconst QUARTER_TURN = Math.PI / 2;\nconst HALF = 0.5;\nconst MIN_DIMENSION = 1;\n\n/* -------------------------------------------------------------------------- */\n/* Bayer matrices                                                             */\n/* -------------------------------------------------------------------------- */\n\nconst buildBayer = (order: number): number[][] => {\n  if (order <= 1) {\n    return [[0]];\n  }\n  const half = buildBayer(order / 2);\n  const halfSize = half.length;\n  const matrix: number[][] = [];\n  for (let y = 0; y < order; y++) {\n    const row: number[] = [];\n    for (let x = 0; x < order; x++) {\n      const quadrantY = y < halfSize ? 0 : 1;\n      const quadrantX = x < halfSize ? 0 : 1;\n      row.push(\n        half[y % halfSize][x % halfSize] * QUADRANT_OFFSETS.length ** 2 +\n          QUADRANT_OFFSETS[quadrantY][quadrantX]\n      );\n    }\n    matrix.push(row);\n  }\n  return matrix;\n};\n\nconst BAYER_MATRICES: Record<DitherChartMatrixOrder, number[][]> = {\n  2: buildBayer(2),\n  4: buildBayer(4),\n  8: buildBayer(8),\n};\n\n/* -------------------------------------------------------------------------- */\n/* Easing                                                                     */\n/* -------------------------------------------------------------------------- */\n\nconst bezierAxis = (t: number, a: number, b: number): number => {\n  const inverse = 1 - t;\n  return 3 * inverse * inverse * t * a + 3 * inverse * t * t * b + t * t * t;\n};\n\n/** Solves `cubic-bezier(.23, 1, .32, 1)` for a linear progress value. */\nconst easeOut = (progress: number): number => {\n  let low = 0;\n  let high = 1;\n  let t = progress;\n  for (let i = 0; i < BEZIER_ITERATIONS; i++) {\n    if (bezierAxis(t, EASE_OUT_X1, EASE_OUT_X2) < progress) {\n      low = t;\n    } else {\n      high = t;\n    }\n    t = (low + high) * HALF;\n  }\n  return bezierAxis(t, EASE_OUT_Y1, EASE_OUT_Y2);\n};\n\n/* -------------------------------------------------------------------------- */\n/* Drawing helpers                                                            */\n/* -------------------------------------------------------------------------- */\n\ninterface DrawContext {\n  colorAt: (index: number) => string;\n  ctx: CanvasRenderingContext2D;\n  height: number;\n  progress: number;\n  series: DitherChartSeries[];\n  toneAt: (index: number) => number;\n  width: number;\n}\n\nconst seriesMax = (series: DitherChartSeries[]): number => {\n  let max = 0;\n  for (const item of series) {\n    for (const point of item.points) {\n      if (point.value > max) {\n        max = point.value;\n      }\n    }\n  }\n  return max || 1;\n};\n\nconst maxPointCount = (series: DitherChartSeries[]): number => {\n  let count = 0;\n  for (const item of series) {\n    if (item.points.length > count) {\n      count = item.points.length;\n    }\n  }\n  return count || 1;\n};\n\nconst sumPoints = (points: DitherChartPoint[]): number => {\n  let total = 0;\n  for (const point of points) {\n    total += point.value;\n  }\n  return total || 1;\n};\n\nconst ringSegment = (\n  ctx: CanvasRenderingContext2D,\n  cx: number,\n  cy: number,\n  outer: number,\n  inner: number,\n  start: number,\n  end: number\n) => {\n  ctx.beginPath();\n  ctx.arc(cx, cy, outer, start, end);\n  ctx.arc(cx, cy, inner, end, start, true);\n  ctx.closePath();\n  ctx.fill();\n};\n\nconst drawLine = ({\n  colorAt,\n  ctx,\n  height,\n  progress,\n  series,\n  toneAt,\n  width,\n}: DrawContext) => {\n  const max = seriesMax(series);\n  const baseline = height - EDGE_INSET;\n  const usable = baseline - EDGE_INSET;\n  for (let i = 0; i < series.length; i++) {\n    const { points } = series[i];\n    if (points.length < 2) {\n      continue;\n    }\n    const step = (width - EDGE_INSET * 2) / (points.length - 1);\n    const coords = points.map((point, index) => ({\n      x: EDGE_INSET + step * index,\n      y: baseline - (point.value / max) * usable * progress,\n    }));\n    const color = series[i].color ?? colorAt(i);\n    const lastX = EDGE_INSET + step * (points.length - 1);\n\n    ctx.fillStyle = color;\n    ctx.globalAlpha = toneAt(i) * AREA_TONE_FACTOR;\n    ctx.beginPath();\n    ctx.moveTo(coords[0].x, baseline);\n    for (const coord of coords) {\n      ctx.lineTo(coord.x, coord.y);\n    }\n    ctx.lineTo(lastX, baseline);\n    ctx.closePath();\n    ctx.fill();\n\n    ctx.strokeStyle = color;\n    ctx.globalAlpha = toneAt(i);\n    ctx.lineWidth = LINE_WIDTH;\n    ctx.beginPath();\n    ctx.moveTo(coords[0].x, coords[0].y);\n    for (const coord of coords) {\n      ctx.lineTo(coord.x, coord.y);\n    }\n    ctx.stroke();\n  }\n};\n\nconst drawBar = ({\n  colorAt,\n  ctx,\n  height,\n  progress,\n  series,\n  toneAt,\n  width,\n}: DrawContext) => {\n  const max = seriesMax(series);\n  const groups = maxPointCount(series);\n  const baseline = height - EDGE_INSET;\n  const usable = baseline - EDGE_INSET;\n  const slot = (width - EDGE_INSET * 2) / groups;\n  const barWidth = Math.max(\n    MIN_DIMENSION,\n    (slot * BAR_FILL_RATIO) / series.length\n  );\n  const groupWidth = barWidth * series.length;\n\n  for (let group = 0; group < groups; group++) {\n    const slotStart = EDGE_INSET + slot * group + (slot - groupWidth) * HALF;\n    for (let i = 0; i < series.length; i++) {\n      const value = series[i].points[group]?.value ?? 0;\n      const barHeight = (value / max) * usable * progress;\n      ctx.fillStyle = series[i].color ?? colorAt(i);\n      ctx.globalAlpha = toneAt(i);\n      ctx.fillRect(\n        slotStart + barWidth * i,\n        baseline - barHeight,\n        barWidth,\n        barHeight\n      );\n    }\n  }\n};\n\nconst drawStacked = ({\n  colorAt,\n  ctx,\n  height,\n  progress,\n  series,\n  toneAt,\n  width,\n}: DrawContext) => {\n  const groups = maxPointCount(series);\n  let maxTotal = 1;\n  for (let group = 0; group < groups; group++) {\n    let total = 0;\n    for (const item of series) {\n      total += item.points[group]?.value ?? 0;\n    }\n    if (total > maxTotal) {\n      maxTotal = total;\n    }\n  }\n\n  const baseline = height - EDGE_INSET;\n  const usable = baseline - EDGE_INSET;\n  const slot = (width - EDGE_INSET * 2) / groups;\n  const barWidth = Math.max(MIN_DIMENSION, slot * BAR_FILL_RATIO);\n\n  for (let group = 0; group < groups; group++) {\n    const x = EDGE_INSET + slot * group + (slot - barWidth) * HALF;\n    let stacked = 0;\n    for (let i = 0; i < series.length; i++) {\n      const value = series[i].points[group]?.value ?? 0;\n      const segment = (value / maxTotal) * usable * progress;\n      ctx.fillStyle = series[i].color ?? colorAt(i);\n      ctx.globalAlpha = toneAt(i);\n      ctx.fillRect(x, baseline - stacked - segment, barWidth, segment);\n      stacked += segment;\n    }\n  }\n};\n\nconst drawDonut = ({\n  colorAt,\n  ctx,\n  height,\n  progress,\n  series,\n  toneAt,\n  width,\n}: DrawContext) => {\n  const points = series[0]?.points ?? [];\n  if (points.length === 0) {\n    return;\n  }\n  const total = sumPoints(points);\n  const cx = width * HALF;\n  const cy = height * HALF;\n  const outer = Math.min(width, height) * HALF - EDGE_INSET;\n  const inner = outer * DONUT_INNER_RATIO;\n  let angle = -QUARTER_TURN;\n\n  for (let i = 0; i < points.length; i++) {\n    const sweep = (points[i].value / total) * FULL_TURN * progress;\n    ctx.fillStyle = colorAt(i);\n    ctx.globalAlpha = toneAt(i);\n    ringSegment(ctx, cx, cy, outer, inner, angle, angle + sweep);\n    angle += sweep;\n  }\n};\n\nconst drawGauge = ({\n  colorAt,\n  ctx,\n  height,\n  progress,\n  series,\n  toneAt,\n  width,\n}: DrawContext) => {\n  const value = series[0]?.points[0]?.value ?? 0;\n  const ratio = Math.min(1, Math.max(0, value / GAUGE_MAX_VALUE));\n  const cx = width * HALF;\n  const cy = height * HALF;\n  const outer = Math.min(width, height) * HALF - EDGE_INSET;\n  const inner = outer * GAUGE_INNER_RATIO;\n\n  ctx.fillStyle = colorAt(0);\n  ctx.globalAlpha = TRACK_TONE;\n  ringSegment(\n    ctx,\n    cx,\n    cy,\n    outer,\n    inner,\n    GAUGE_START_ANGLE,\n    GAUGE_START_ANGLE + GAUGE_SWEEP\n  );\n\n  ctx.globalAlpha = toneAt(0);\n  ringSegment(\n    ctx,\n    cx,\n    cy,\n    outer,\n    inner,\n    GAUGE_START_ANGLE,\n    GAUGE_START_ANGLE + GAUGE_SWEEP * ratio * progress\n  );\n};\n\nconst drawFunnel = ({\n  colorAt,\n  ctx,\n  height,\n  progress,\n  series,\n  toneAt,\n  width,\n}: DrawContext) => {\n  const points = series[0]?.points ?? [];\n  if (points.length === 0) {\n    return;\n  }\n  const max = seriesMax(series);\n  const rowHeight = (height - EDGE_INSET * 2) / points.length;\n  const usable = width - EDGE_INSET * 2;\n\n  for (let i = 0; i < points.length; i++) {\n    const rowWidth = (points[i].value / max) * usable * progress;\n    ctx.fillStyle = colorAt(i);\n    ctx.globalAlpha = toneAt(i);\n    ctx.fillRect(\n      (width - rowWidth) * HALF,\n      EDGE_INSET + rowHeight * i,\n      rowWidth,\n      Math.max(MIN_DIMENSION, rowHeight - FUNNEL_ROW_GAP)\n    );\n  }\n};\n\nconst drawHeatmap = ({\n  colorAt,\n  ctx,\n  height,\n  progress,\n  series,\n  width,\n}: DrawContext) => {\n  const max = seriesMax(series);\n  const columns = maxPointCount(series);\n  const cellWidth = (width - EDGE_INSET * 2) / columns;\n  const cellHeight = (height - EDGE_INSET * 2) / series.length;\n  ctx.fillStyle = colorAt(0);\n\n  for (let row = 0; row < series.length; row++) {\n    for (let column = 0; column < columns; column++) {\n      const value = series[row].points[column]?.value ?? 0;\n      const intensity =\n        HEATMAP_MIN_TONE + (value / max) * (1 - HEATMAP_MIN_TONE);\n      ctx.globalAlpha = intensity * progress;\n      ctx.fillRect(\n        EDGE_INSET + cellWidth * column,\n        EDGE_INSET + cellHeight * row,\n        cellWidth,\n        cellHeight\n      );\n    }\n  }\n};\n\nconst drawBubbles = ({\n  colorAt,\n  ctx,\n  height,\n  progress,\n  series,\n  toneAt,\n  width,\n}: DrawContext) => {\n  const max = seriesMax(series);\n  const columns = maxPointCount(series);\n  const baseline = height - EDGE_INSET;\n  const usable = baseline - EDGE_INSET;\n  const step = (width - EDGE_INSET * 2) / columns;\n  const maxRadius = Math.min(width, height) * BUBBLE_MAX_RATIO;\n\n  for (let i = 0; i < series.length; i++) {\n    ctx.fillStyle = series[i].color ?? colorAt(i);\n    ctx.globalAlpha = toneAt(i);\n    const { points } = series[i];\n    for (let j = 0; j < points.length; j++) {\n      const ratio = points[j].value / max;\n      const radius =\n        (BUBBLE_MIN_RADIUS + ratio * (maxRadius - BUBBLE_MIN_RADIUS)) *\n        progress;\n      if (radius <= 0) {\n        continue;\n      }\n      ctx.beginPath();\n      ctx.arc(\n        EDGE_INSET + step * (j + HALF),\n        baseline - ratio * usable,\n        radius,\n        0,\n        FULL_TURN\n      );\n      ctx.fill();\n    }\n  }\n};\n\nconst VARIANT_PAINTERS: Record<\n  DitherChartVariant,\n  (context: DrawContext) => void\n> = {\n  bar: drawBar,\n  bubbles: drawBubbles,\n  donut: drawDonut,\n  funnel: drawFunnel,\n  gauge: drawGauge,\n  heatmap: drawHeatmap,\n  line: drawLine,\n  stacked: drawStacked,\n};\n\n/** Quantises every pixel's alpha through the Bayer matrix. */\nconst applyDither = (\n  ctx: CanvasRenderingContext2D,\n  width: number,\n  height: number,\n  order: DitherChartMatrixOrder\n) => {\n  const image = ctx.getImageData(0, 0, width, height);\n  const pixels = image.data;\n  const matrix = BAYER_MATRICES[order];\n  const levels = order * order;\n\n  for (let y = 0; y < height; y++) {\n    const row = matrix[y % order];\n    for (let x = 0; x < width; x++) {\n      const index = (y * width + x) * RGBA_STRIDE + ALPHA_OFFSET;\n      const coverage = pixels[index] / ALPHA_MAX;\n      const threshold = (row[x % order] + HALF) / levels;\n      pixels[index] = coverage > threshold ? ALPHA_MAX : 0;\n    }\n  }\n\n  ctx.putImageData(image, 0, 0);\n};\n\nconst resolvePalette = (element: HTMLElement, palette?: string[]): string[] => {\n  if (palette && palette.length > 0) {\n    return palette;\n  }\n  const styles = getComputedStyle(element);\n  const current = styles.color || \"#000000\";\n  const brand = styles.getPropertyValue(\"--color-brand\").trim();\n  return brand ? [brand, current] : [current];\n};\n\n/* -------------------------------------------------------------------------- */\n/* Component                                                                  */\n/* -------------------------------------------------------------------------- */\n\nconst DitherChart = ({\n  animate = true,\n  caption,\n  className,\n  data,\n  height = DEFAULT_HEIGHT,\n  label,\n  matrix = DEFAULT_MATRIX,\n  palette,\n  pixelSize = DEFAULT_PIXEL_SIZE,\n  variant = DEFAULT_VARIANT,\n  width = DEFAULT_WIDTH,\n}: DitherChartProps) => {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const bufferRef = useRef<HTMLCanvasElement | null>(null);\n  const shouldReduceMotion = useReducedMotion();\n\n  const draw = useCallback(\n    (progress: number) => {\n      const canvas = canvasRef.current;\n      if (!canvas || data.length === 0) {\n        return;\n      }\n\n      const buffer = bufferRef.current ?? document.createElement(\"canvas\");\n      bufferRef.current = buffer;\n\n      const lowWidth = Math.max(MIN_DIMENSION, Math.round(width / pixelSize));\n      const lowHeight = Math.max(MIN_DIMENSION, Math.round(height / pixelSize));\n      buffer.width = lowWidth;\n      buffer.height = lowHeight;\n\n      const bufferCtx = buffer.getContext(\"2d\", { willReadFrequently: true });\n      if (!bufferCtx) {\n        return;\n      }\n      bufferCtx.clearRect(0, 0, lowWidth, lowHeight);\n      bufferCtx.globalAlpha = 1;\n\n      const colors = resolvePalette(canvas, palette);\n      const painter = VARIANT_PAINTERS[variant];\n      painter({\n        colorAt: (index: number) => colors[index % colors.length],\n        ctx: bufferCtx,\n        height: lowHeight,\n        progress,\n        series: data,\n        toneAt: (index: number) => TONE_RAMP[index % TONE_RAMP.length],\n        width: lowWidth,\n      });\n      bufferCtx.globalAlpha = 1;\n      applyDither(bufferCtx, lowWidth, lowHeight, matrix);\n\n      const ratio = window.devicePixelRatio || 1;\n      canvas.width = Math.round(width * ratio);\n      canvas.height = Math.round(height * ratio);\n      const ctx = canvas.getContext(\"2d\");\n      if (!ctx) {\n        return;\n      }\n      ctx.imageSmoothingEnabled = false;\n      ctx.clearRect(0, 0, canvas.width, canvas.height);\n      ctx.drawImage(\n        buffer,\n        0,\n        0,\n        lowWidth,\n        lowHeight,\n        0,\n        0,\n        canvas.width,\n        canvas.height\n      );\n    },\n    [data, height, matrix, palette, pixelSize, variant, width]\n  );\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) {\n      return;\n    }\n\n    if (!animate || shouldReduceMotion) {\n      draw(1);\n      return;\n    }\n\n    let frame = 0;\n    let elapsed = 0;\n    let last = 0;\n    let running = false;\n    draw(0);\n\n    const tick = (time: number) => {\n      if (last === 0) {\n        last = time;\n      }\n      elapsed += time - last;\n      last = time;\n      const progress = Math.min(1, elapsed / DRAW_IN_DURATION_MS);\n      draw(easeOut(progress));\n      if (progress < 1) {\n        frame = requestAnimationFrame(tick);\n      } else {\n        running = false;\n      }\n    };\n\n    const start = () => {\n      if (running || elapsed >= DRAW_IN_DURATION_MS) {\n        return;\n      }\n      running = true;\n      last = 0;\n      frame = requestAnimationFrame(tick);\n    };\n\n    const stop = () => {\n      if (!running) {\n        return;\n      }\n      cancelAnimationFrame(frame);\n      running = false;\n    };\n\n    const observer = new IntersectionObserver((entries) => {\n      for (const entry of entries) {\n        if (entry.isIntersecting) {\n          start();\n        } else {\n          stop();\n        }\n      }\n    });\n    observer.observe(canvas);\n\n    return () => {\n      cancelAnimationFrame(frame);\n      observer.disconnect();\n    };\n  }, [animate, draw, shouldReduceMotion]);\n\n  const description = useMemo(() => {\n    const summaries = data.map((item, index) => {\n      const name = item.name ?? `Series ${index + 1}`;\n      const values = item.points\n        .map(\n          (point, pointIndex) =>\n            `${point.label ?? `Point ${pointIndex + 1}`}: ${point.value}`\n        )\n        .join(\", \");\n      return `${name} — ${values}`;\n    });\n    return `${label ?? \"Dithered chart\"}, ${variant} variant. ${summaries.join(\". \")}.`;\n  }, [data, label, variant]);\n\n  return (\n    <figure\n      className={cn(\"inline-flex flex-col gap-2 text-foreground\", className)}\n    >\n      {label ? (\n        <span className=\"font-medium text-foreground text-sm\">{label}</span>\n      ) : null}\n      <canvas\n        aria-hidden=\"true\"\n        className=\"block\"\n        height={height}\n        ref={canvasRef}\n        style={{ height: `${height}px`, width: `${width}px` }}\n        tabIndex={-1}\n        width={width}\n      />\n      <span className=\"sr-only\">{description}</span>\n      {caption ? (\n        <figcaption className=\"text-muted-foreground text-xs\">\n          {caption}\n        </figcaption>\n      ) : null}\n    </figure>\n  );\n};\n\nexport default DitherChart;\n","path":"index.tsx","target":"components/smoothui/dither-chart/index.tsx","type":"registry:ui"}],"name":"dither-chart","registryDependencies":[],"title":"Dither Chart","type":"registry:ui"}