WFDASH-WEBAPP/src/components/leaflet-heatmap-layer.tsx
Dave b99aacb020 feat(rep): add /rep/visits map with heatmap, per-customer clusters
New page lets a rep see their own rep_visits on a Leaflet map.
Defaults to last 7 days; toggleable to 14 / 30 / 90.

  /rep/visits       ── server-side fetch + re-export to client
  /rep/visits map   ── per-customer cluster, count badge, last-visit
                       label, popup with full per-visit timeline + notes
                       + 'manual' badge, side-list of customers in window
                       + search filter + heatmap toggle

Service + route (no pb-helpers):
  visits-service.getVisitsForRep(repId, {from?, to?, limit?})
    - admin PB (session-scoped at the route layer)
    - expands customer relation, filters position_source != 'tracker'
      server-side, PB date format gotcha (T → space), 5000-row cap
  /api/v1/rep-visits/list   GET, browser-session auth, scopes repId
                            from user.id (never a query param — same
                            hardening as POST /api/v1/visits)

Heatmap:
  leaflet.heat wrapper at src/components/leaflet-heatmap-layer.tsx
  - useMap() + useEffect lifecycle
  - brand gradient (deep blue → green → amber → yellow → crimson)
  - reads on both light OSM and dark CARTO tile sets
  - maxZoom default 10 — leaflet.heat scales intensity by
    1/2^(maxZoom-currentZoom); without this the OSM default maxZoom
    of 18 makes points render at ~5% opacity at our default zoom 10

Other fixes landed mid-feature:
  - KPI cards collapsed from 3 into 1 (clusters count is a
    zoom-dependent implementation detail, not a glanceable stat)
  - added 'View my visits on a map' nav link under /rep header

Dep: +leaflet.heat@^0.2.0, +@types/leaflet.heat@^0.2.5
     (react-leaflet@5, leaflet@1.9.4 already present)

Tests: 50 + 34 = 84 pass in the touched (rep) area + visits-service
       + rep-visits API. Pre-existing failures on main (sidebar,
       pocketbase-admin, quotes-service) confirmed unrelated by
       stashing this branch.
2026-09-20 23:50:03 +02:00

114 lines
No EOL
4.5 KiB
TypeScript

"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<number, string>;
};
}) {
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;
}