Heat Map Overlay
Standalone SVG geographic heat map — radial-gradient blobs with configurable radius, blur, gradient, and opacity. Install Heat Map Overlay from the VLLNT UI registry with the shadcn CLI.
Standalone SVG geographic heat map — radial-gradient blobs with configurable radius, blur, gradient, and opacity. Part of the data-display family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
Add Heat Map Overlay to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:
pnpm dlx shadcn@latest add https://ui.vllnt.com/r/heat-map-overlay.jsonSource
"use client";
import {
type ComponentPropsWithoutRef,
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 = ({
ref,
...props
}: HeatMapOverlayProps & { ref?: React.Ref<HTMLElement> }) => {
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";
Stories
Explore every variant and state in the interactive Storybook:
Preview
Switch between light and dark to inspect the embedded Storybook preview.