{points: [{x,y,severity,event_type,occurred_at}], days} * * Spatial density of everything the camera detected (incidents AND near-misses) in the * window — the raw material the zone editor overlays on the camera frame. Points are * normalized 0..1 of the image, the same space the zone polygons live in. */ final class HeatmapController { private const DEFAULT_DAYS = 30; private const MAX_DAYS = 90; private const MAX_POINTS = 5000; public function __construct(private EventRepository $events, private SessionStore $sess) {} public function json(Request $req, array $params): Response { if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401); $camera = (string)($params['camera'] ?? ''); if ($camera === '') return Response::json(['error' => 'missing_camera'], 400); $days = self::clampDays($req->query('days')); // Same fail-closed site scoping as the event list: a scoped operator sees only their // site's points; only a genuinely null scope is global. $scope = $this->sess->get('site_scope'); $site = is_string($scope) ? $scope : null; $points = $this->events->heatmapPoints($camera, time() - $days * 86400, $site, self::MAX_POINTS); return Response::json(['points' => $points, 'days' => $days], 200); } private static function clampDays(mixed $raw): int { if ($raw === null || $raw === '' || !is_numeric($raw)) return self::DEFAULT_DAYS; return max(1, min(self::MAX_DAYS, (int)$raw)); } }