Heat Map Overlay
Standalone SVG geographic heat map — radial-gradient blobs with configurable radius, blur, gradient, and opacity.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/heat-map-overlay.jsonbash
Storybook
Explore all variants, controls, and accessibility checks in the interactive Storybook playground.
View in Storybook5 stories available:
Code
"use client";
import {
type ComponentPropsWithoutRef,
forwardRef,
type ReactNode,
useId,
useMemo,
} from "react";
import { cn } from "../../lib/utils";
const VIEWBOX_WIDTH = 1000;
const VIEWBOX_HEIGHT = 500;
/**
* A heat point — geographic position plus optional weight.
*
* @public
*/
export type HeatMapPoint = {
/** Stable identifier (used as React key). */
id: string;
/** Latitude. Positive = north. */
lat: number;
/** Longitude. Positive = east. */
lng: number;
/** Optional weight `0..1`. Drives both radius scale and color stop. Defaults to `1`. */
weight?: number;
};
/**
* Gradient stops `{ stop: color }`. Each stop is a `0..1` ratio.
*
* @public
*/
export type HeatGradient = Record<number, string>;
const DEFAULT_GRADIENT: HeatGradient = {
0.2: "rgba(0, 0, 255, 0.6)",
0.5: "rgba(0, 255, 0, 0.7)",
0.8: "rgba(255, 165, 0, 0.85)",
1: "rgba(255, 0, 0, 0.95)",
};
/**
* Localizable strings.
*
* @public
*/
export type HeatMapOverlayLabels = {
/** Aria-label for the heat map region. Defaults to `"Heat map"`. */
region?: string;
};
const DEFAULT_LABELS = {
region: "Heat map",
} as const satisfies Required<HeatMapOverlayLabels>;
function projectEquirectangular(
lng: number,
lat: number,
): { x: number; y: number } {
const x = ((lng + 180) / 360) * VIEWBOX_WIDTH;
const y = ((90 - lat) / 180) * VIEWBOX_HEIGHT;
return { x, y };
}
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function gradientStops(
gradient: HeatGradient,
): { color: string; offset: number }[] {
return Object.entries(gradient)
.map(([key, value]) => ({ color: value, offset: Number.parseFloat(key) }))
.sort((a, b) => a.offset - b.offset);
}
/**
* Props for {@link HeatMapOverlay}.
*
* @public
*/
export type HeatMapOverlayProps = {
/** Optional backdrop image URL (world map, terrain). */
backdrop?: string;
/** Aria-label for the backdrop image. */
backdropAlt?: string;
/** Gaussian blur radius in viewBox units. Defaults to `12`. */
blur?: number;
/** Heat point data. */
data: HeatMapPoint[];
/** Color gradient stops `0..1` → CSS color. Defaults to a blue→green→orange→red ramp. */
gradient?: HeatGradient;
/** Localizable strings. */
labels?: HeatMapOverlayLabels;
/** Layer opacity `0..1`. Defaults to `0.7`. */
opacity?: number;
/** Top heat-blob radius in viewBox units. Defaults to `30`. Per-point radius scales with `weight`. */
radius?: number;
} & ComponentPropsWithoutRef<"section">;
type ProjectedPoint = {
raw: HeatMapPoint;
weight: number;
x: number;
y: number;
};
function projectPoints(points: HeatMapPoint[]): ProjectedPoint[] {
return points.map((point) => {
const { x, y } = projectEquirectangular(point.lng, point.lat);
return {
raw: point,
weight: clamp(point.weight ?? 1, 0, 1),
x,
y,
};
});
}
type HeatBlobProps = {
gradientId: string;
point: ProjectedPoint;
radius: number;
};
function HeatBlob({ gradientId, point, radius }: HeatBlobProps): ReactNode {
const blobRadius = Math.max(2, radius * (0.4 + 0.6 * point.weight));
return (
<circle
cx={point.x}
cy={point.y}
data-point-id={point.raw.id}
data-weight={point.weight}
fill={`url(#${gradientId})`}
opacity={point.weight}
r={blobRadius}
/>
);
}
type GradientDefsProps = {
blurId: string;
gradient: HeatGradient;
gradientId: string;
};
function GradientDefs({
blurId,
gradient,
gradientId,
}: GradientDefsProps): ReactNode {
const stops = gradientStops(gradient);
return (
<defs>
<radialGradient cx="50%" cy="50%" id={gradientId} r="50%">
{stops.map((stop) => (
<stop
key={stop.offset}
offset={`${(stop.offset * 100).toString()}%`}
stopColor={stop.color}
/>
))}
</radialGradient>
<filter id={blurId}>
<feGaussianBlur stdDeviation={12} />
</filter>
</defs>
);
}
type DataSummaryProps = {
data: HeatMapPoint[];
titleId: string;
};
function DataSummary({ data, titleId }: DataSummaryProps): ReactNode {
return (
<div aria-labelledby={titleId} className="sr-only" role="region">
<h3 id={titleId}>Heat map data summary</h3>
<p>
{data.length.toString()} point{data.length === 1 ? "" : "s"} plotted on
the heat map.
</p>
<ul>
{data.map((point) => (
<li key={point.id}>
{`${point.lat.toString()}, ${point.lng.toString()}: weight ${(point.weight ?? 1).toString()}`}
</li>
))}
</ul>
</div>
);
}
type StageProps = {
backdrop?: string;
backdropAlt?: string;
blur: number;
blurId: string;
data: ProjectedPoint[];
gradient: HeatGradient;
gradientId: string;
opacity: number;
radius: number;
};
function Stage({
backdrop,
backdropAlt,
blur,
blurId,
data,
gradient,
gradientId,
opacity,
radius,
}: StageProps): ReactNode {
return (
<svg
aria-hidden="true"
className="block h-full w-full"
preserveAspectRatio="xMidYMid meet"
viewBox={`0 0 ${VIEWBOX_WIDTH.toString()} ${VIEWBOX_HEIGHT.toString()}`}
>
<GradientDefs
blurId={blurId}
gradient={gradient}
gradientId={gradientId}
/>
<rect
className="fill-muted"
height={VIEWBOX_HEIGHT}
width={VIEWBOX_WIDTH}
x="0"
y="0"
/>
{backdrop ? (
<image
aria-label={backdropAlt}
height={VIEWBOX_HEIGHT}
href={backdrop}
preserveAspectRatio="xMidYMid slice"
width={VIEWBOX_WIDTH}
x="0"
y="0"
/>
) : null}
<g
data-heat-layer
filter={blur > 0 ? `url(#${blurId})` : undefined}
opacity={opacity}
>
{data.map((point) => (
<HeatBlob
gradientId={gradientId}
key={point.raw.id}
point={point}
radius={radius}
/>
))}
</g>
</svg>
);
}
/**
* Standalone SVG heat-map overlay for geographic density / intensity
* data. Plot heat points by `[lng, lat]` with optional `weight`. Each
* point renders as a radial-gradient blob; the layer is Gaussian-blurred
* to merge neighbours into smooth heat clouds.
*
* Use for: troop concentrations, population density, event / incident
* hotspots, archaeological site density, environmental pollution maps.
*
* @example
* ```tsx
* <HeatMapOverlay
* data={[
* { id: "a", lat: 40.7, lng: -74, weight: 0.9 },
* { id: "b", lat: 51.5, lng: -0.13, weight: 0.6 },
* ]}
* radius={40}
* blur={18}
* />
* ```
*
* @public
*/
export const HeatMapOverlay = forwardRef<HTMLElement, HeatMapOverlayProps>(
(props, ref) => {
const {
backdrop,
backdropAlt,
blur = 12,
children,
className,
data,
gradient = DEFAULT_GRADIENT,
labels,
opacity = 0.7,
radius = 30,
...rest
} = props;
const titleId = useId();
const gradientId = useId();
const blurId = useId();
const resolvedLabels = useMemo(
() => ({ ...DEFAULT_LABELS, ...labels }),
[labels],
);
const projected = useMemo(() => projectPoints(data), [data]);
return (
<section
aria-label={resolvedLabels.region}
className={cn(
"relative aspect-[2/1] w-full overflow-hidden rounded-2xl border bg-background text-foreground",
className,
)}
ref={ref}
{...rest}
>
<Stage
backdrop={backdrop}
backdropAlt={backdropAlt}
blur={blur}
blurId={blurId}
data={projected}
gradient={gradient}
gradientId={gradientId}
opacity={opacity}
radius={radius}
/>
{children ? (
<div className="absolute bottom-3 left-3 z-10 max-w-xs rounded-md border bg-background/95 px-3 py-2 text-xs text-foreground shadow-sm backdrop-blur">
{children}
</div>
) : null}
<DataSummary data={data} titleId={titleId} />
</section>
);
},
);
HeatMapOverlay.displayName = "HeatMapOverlay";
typescript