"use client"; import { useEffect } from "react"; import { useMap } from "react-leaflet"; import L from "leaflet"; import "leaflet.heat"; /** * A `react-leaflet` wrapper for the `leaflet.heat` plugin. * * The plugin ships as plain JS and mutates the global `L` namespace * with a `heatLayer(points, options)` factory — there's no first-class * React adapter, so the standard pattern is: * * 1. `useMap()` to get the live Leaflet map instance. * 2. Build the heat layer in `useEffect`, add it to the map. * 3. Return `null` so this component contributes no DOM. * 4. Clean up on unmount. * * The `import "leaflet.heat"` side-effect at the top of the file * augments `L` with the `heatLayer` factory. Without it `L.heatLayer` * is `undefined` at runtime and the page crashes on first render. * * Options: * - `radius` in pixels, default 30. Smaller radius = more localised * blob, larger = broader area. 30 reads well on a single * shaft; crank it down for multiple shafts in view. * - `blur` in pixels, default 15. Trades focus against density — * higher blur makes a busy area look smoother. * - `max` intensity ceiling. Default 1.0 (auto). Drop to taste * if a single very-frequent spot saturates the map. * - `maxZoom` CRITICAL. The plugin scales every point's intensity by * `1/2^k` where `k = max(0, min(maxZoom - mapZoom, 12))`. * If `maxZoom` is left undefined the plugin falls back * to the map's `getMaxZoom()` (18 for OSM) — at the page * default zoom of 10 that's `1/256`, so every check-in * renders at ~5% opacity and the layer looks empty. We * pin it to `10` so the default zoom renders at FULL * intensity; zooming in past it stays at full intensity * (the inner `min(...)` clamps to 0 when `mapZoom > * maxZoom`); zooming out causes decay (which is the * desired UX — heatmaps lose meaning at country scale). * - `minOpacity` floor per-point alpha. Default 0.05 (plugin) is * barely visible; we default to 0.15 so a single check-in * still leaves a readable mark. * - `gradient` colour stops for low → high intensity. * * Points are `[lat, lon]` tuples by default. Each one contributes * intensity 1; pass `[lat, lon, intensity]` tuples to weight them * (e.g. per-visit duration). The `/rep/visits` page uses plain * `[lat, lon]` — every check-in counts once. */ export function HeatmapLayer({ points, visible, options, }: { points: Array<[number, number]>; visible: boolean; options?: { radius?: number; blur?: number; max?: number; maxZoom?: number; minOpacity?: number; gradient?: Record; }; }) { const map = useMap(); useEffect(() => { if (!visible || points.length === 0) return; // `leaflet.heat` types its input as `[lat, lng, intensity]` tuples // only — a 2-tuple is silently dropped by the plugin, so we have // to upgrade every point. Intensity is fixed at 1 because each // rep visit counts the same; the page can pass weighted points // later if duration matters. const tuples = points.map(([lat, lon]): [number, number, number] => [lat, lon, 1]); const layer = L.heatLayer(tuples, { radius: options?.radius ?? 30, blur: options?.blur ?? 15, max: options?.max ?? 1.0, // See the doc-block above for why this matters. Without // `maxZoom` set, the plugin uses the map's max zoom (18 for // OSM tiles) and divides every point's intensity by 256 at // our default zoom of 10 — the layer renders at ~5% opacity // and looks like nothing's there. maxZoom: options?.maxZoom ?? 10, minOpacity: options?.minOpacity ?? 0.15, // Brand-driven gradient: brand yellow (PixelForge signature // accent) on the high end, deep crimson on the low. Reads on // both light OSM and dark CARTO tiles without screaming. gradient: options?.gradient ?? { 0.2: "#1B76BB", 0.4: "#22C55E", 0.6: "#F4B842", 0.8: "#FFE600", 1.0: "#E64B4B", }, }); layer.addTo(map); return () => { map.removeLayer(layer); }; // The `points` and `options` deps drive a full teardown + rebuild // on any data change — fine for a rep-scale dataset (≤5,000 // points) where rebuilding the canvas is microseconds. }, [map, points, visible, options]); return null; }