Heat Overlay

Heatmap-style overlay drawing soft radial blobs for canvas activity samples.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/heat-overlay.json
bash

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

4 stories available:

Code

"use client";

import { type ComponentPropsWithoutRef, forwardRef, useId } from "react";

import { cn } from "../../lib/utils";

/**
 * Tone of the heat gradient — drives the inner circle color.
 *
 * @public
 */
export type HeatOverlayTone = "cool" | "danger" | "neutral" | "warn";

const TONE_FILL: Record<HeatOverlayTone, string> = {
  cool: "fill-blue-500",
  danger: "fill-red-500",
  neutral: "fill-foreground",
  warn: "fill-amber-500",
};

/**
 * One heat sample.
 *
 * @public
 */
export type HeatPoint = {
  /** Stable identifier — used as the React key. */
  id: string;
  /** Optional per-point tone override. */
  tone?: HeatOverlayTone;
  /** Sample weight `0..1` — drives the circle opacity + radius. */
  weight: number;
  /** X coordinate in canvas pixels. */
  x: number;
  /** Y coordinate in canvas pixels. */
  y: number;
};

/**
 * Localizable strings.
 *
 * @public
 */
export type HeatOverlayLabels = {
  /** Aria-label override. Defaults to `"Heat overlay"`. */
  region?: string;
};

const DEFAULT_LABELS = {
  region: "Heat overlay",
} as const satisfies Required<HeatOverlayLabels>;

/**
 * Props for {@link HeatOverlay}.
 *
 * @public
 */
export type HeatOverlayProps = {
  /** Optional global tone applied to every point that omits its own. Defaults to `"warn"`. */
  defaultTone?: HeatOverlayTone;
  /** Sample radius in pixels at full weight. Defaults to `48`. */
  intensity?: number;
  /** Localizable strings. */
  labels?: HeatOverlayLabels;
  /** Sample points in render order. */
  points: HeatPoint[];
} & ComponentPropsWithoutRef<"svg">;

const clamp01 = (v: number): number => {
  if (v < 0) {
    return 0;
  }
  if (v > 1) {
    return 1;
  }
  return v;
};

const HeatBlob = (props: {
  defaultTone: HeatOverlayTone;
  gradientId: string;
  intensity: number;
  point: HeatPoint;
}): React.ReactElement => {
  const { defaultTone, gradientId, intensity, point } = props;
  const weight = clamp01(point.weight);
  const tone = point.tone ?? defaultTone;
  return (
    <circle
      className={cn("mix-blend-multiply", TONE_FILL[tone])}
      cx={point.x}
      cy={point.y}
      data-heat-point={point.id}
      data-heat-tone={tone}
      fill={`url(#${gradientId})`}
      fillOpacity={weight * 0.6}
      r={Math.max(8, intensity * weight)}
    />
  );
};

/**
 * Heatmap-style overlay drawn on top of a canvas. Each sample renders
 * as a soft radial blob whose radius + opacity scale with its weight.
 * Pure presentation; the host computes the point list from the
 * activity stream.
 *
 * Render inside a `position: relative` parent that shares the canvas
 * pixel coordinate space; the SVG is `pointer-events: none` so host
 * gestures pass through.
 *
 * @example
 * ```tsx
 * <div className="relative h-screen w-screen">
 *   <Canvas />
 *   <HeatOverlay
 *     points={[
 *       { id: "a", x: 120, y: 80,  weight: 1.0, tone: "danger" },
 *       { id: "b", x: 320, y: 220, weight: 0.4 },
 *     ]}
 *   />
 * </div>
 * ```
 *
 * @public
 */
export const HeatOverlay = forwardRef<SVGSVGElement, HeatOverlayProps>(
  (props, ref) => {
    const {
      className,
      defaultTone = "warn",
      intensity = 48,
      labels,
      points,
      ...rest
    } = props;
    const resolvedLabels = { ...DEFAULT_LABELS, ...labels };
    const gradientId = useId();
    if (points.length === 0) {
      return null;
    }
    return (
      <svg
        aria-label={resolvedLabels.region}
        className={cn(
          "pointer-events-none absolute inset-0 z-10 h-full w-full",
          className,
        )}
        data-heat-overlay
        ref={ref}
        role="img"
        {...rest}
      >
        <defs>
          <radialGradient cx="50%" cy="50%" id={gradientId} r="50%">
            <stop offset="0%" stopColor="currentColor" stopOpacity="1" />
            <stop offset="70%" stopColor="currentColor" stopOpacity="0.4" />
            <stop offset="100%" stopColor="currentColor" stopOpacity="0" />
          </radialGradient>
        </defs>
        {points.map((point) => (
          <HeatBlob
            defaultTone={defaultTone}
            gradientId={gradientId}
            intensity={intensity}
            key={point.id}
            point={point}
          />
        ))}
      </svg>
    );
  },
);
HeatOverlay.displayName = "HeatOverlay";
typescript

Dependencies

  • @vllnt/ui@^0.2.1