/** * Pure data-transform utilities for the map view (M2-T09). * No leaflet imports — these functions are unit-testable in jsdom. */ import type { LocationRecord, PooRecord } from '../records' /** A heat point for L.heatLayer: [lat, lng, intensity]. */ export type HeatPoint = [number, number, number] /** Map point with attached source record for click-to-edit. */ export interface LocationMapPoint { lat: number lng: number record: LocationRecord } export interface PooMapPoint { lat: number lng: number record: PooRecord } // --------------------------------------------------------------------------- // Transforms // --------------------------------------------------------------------------- /** * Convert location records to heat points. * All points get intensity=1; callers can adjust if needed. */ export function locationsToHeatPoints(records: LocationRecord[]): HeatPoint[] { return records.map((r) => [r.latitude, r.longitude, 1]) } /** * Convert poo records to heat points. */ export function pooToHeatPoints(records: PooRecord[]): HeatPoint[] { return records.map((r) => [r.latitude, r.longitude, 1]) } /** * Peak number of 2D coordinates that fall into the same `cellSize`-sized grid * cell. Pure + leaflet-free so it is unit-testable. * * Used by the map heat normalization: project the VISIBLE points to screen * pixels (in the map component), then this returns the densest pixel cell's * count, which becomes leaflet.heat's `max`. With maxZoom:0 (intensity factor * f=1) the accumulated per-cell value equals this count, so the densest visible * cluster maps to the hot color — recomputed on every zoom/pan so it always * normalizes within the current viewport. Returns at least 1. */ export function peakGridCount(coords: Array<[number, number]>, cellSize: number): number { if (coords.length === 0) return 1 const g = Math.max(1, cellSize) const counts = new Map() let peak = 1 for (const [x, y] of coords) { const key = `${Math.floor(x / g)}:${Math.floor(y / g)}` const next = (counts.get(key) ?? 0) + 1 counts.set(key, next) if (next > peak) peak = next } return peak } /** * Convert location records to map points (for scatter layer). */ export function locationsToMapPoints(records: LocationRecord[]): LocationMapPoint[] { return records.map((r) => ({ lat: r.latitude, lng: r.longitude, record: r })) } /** * Convert poo records to map points (for scatter layer). */ export function pooToMapPoints(records: PooRecord[]): PooMapPoint[] { return records.map((r) => ({ lat: r.latitude, lng: r.longitude, record: r })) } // --------------------------------------------------------------------------- // Client-side time-window filter (for poo records — the endpoint has no server filter) // --------------------------------------------------------------------------- /** * Filter poo records to those whose timestamp falls within [start, end] (inclusive). * start and end are ISO8601 strings (e.g. "2026-01-01T00:00:00Z"). * If start or end is null, that bound is open (no filtering on that side). */ export function filterPooByTimeWindow( records: PooRecord[], start: string | null, end: string | null, ): PooRecord[] { if (!start && !end) return records return records.filter((r) => { const ts = r.timestamp if (start && ts < start) return false if (end && ts > end) return false return true }) } // --------------------------------------------------------------------------- // Default time window helpers // --------------------------------------------------------------------------- /** Returns ISO8601 string for N days ago from now (UTC). */ export function daysAgoISO(days: number): string { const d = new Date() d.setUTCDate(d.getUTCDate() - days) return d.toISOString() } /** Returns ISO8601 string for now (UTC). */ export function nowISO(): string { return new Date().toISOString() } /** Compute a bounding center from an array of lat/lng points. Returns null if empty. */ export function computeCenter( points: Array<{ lat: number; lng: number }>, ): [number, number] | null { if (points.length === 0) return null const sumLat = points.reduce((s, p) => s + p.lat, 0) const sumLng = points.reduce((s, p) => s + p.lng, 0) return [sumLat / points.length, sumLng / points.length] } // --------------------------------------------------------------------------- // Quick time-range presets + window shifting (Grafana-style) // --------------------------------------------------------------------------- const HOUR_MS = 3_600_000 const DAY_MS = 24 * HOUR_MS /** A quick-range preset: a label + a span in milliseconds (month/year approximated). */ export interface TimePreset { value: string label: string spanMs: number } export const TIME_PRESETS: TimePreset[] = [ { value: '24h', label: 'Past 24 hours', spanMs: 24 * HOUR_MS }, { value: '1w', label: 'Past 1 week', spanMs: 7 * DAY_MS }, { value: '2w', label: 'Past 2 weeks', spanMs: 14 * DAY_MS }, { value: '1mo', label: 'Past 1 month', spanMs: 30 * DAY_MS }, { value: '6mo', label: 'Past 6 months', spanMs: 182 * DAY_MS }, { value: '1y', label: 'Past 1 year', spanMs: 365 * DAY_MS }, { value: '5y', label: 'Past 5 years', spanMs: 5 * 365 * DAY_MS }, ] /** ISO8601 with second precision, no milliseconds: "YYYY-MM-DDTHH:MM:SSZ". */ function isoSeconds(d: Date): string { return d.toISOString().slice(0, 19) + 'Z' } /** * Compute a [start, end] window of width `spanMs` ending at `now`. * Used when the user picks a quick-range preset. */ export function presetRange(spanMs: number, now: Date = new Date()): { start: string; end: string } { return { start: isoSeconds(new Date(now.getTime() - spanMs)), end: isoSeconds(now) } } /** * Shift a [start, end] window by its OWN span. direction = -1 moves earlier * (back in time), +1 moves later. The window width is preserved. */ export function shiftRange( startISO: string, endISO: string, direction: -1 | 1, ): { start: string; end: string } { const startMs = Date.parse(startISO) const endMs = Date.parse(endISO) const span = endMs - startMs return { start: isoSeconds(new Date(startMs + direction * span)), end: isoSeconds(new Date(endMs + direction * span)), } }