M2-T09: build data visualization UI (heatmap map as home page)

- self-contained RecordsMap (only module importing leaflet/react-leaflet/
  leaflet.heat/leaflet.markercluster); OSM tiles, swappable behind clean props
- heatmap layers for location + poo (primary); time-range selector fetches
  only the window (locations server-filtered; poo client-filtered)
- toggleable scatter layer with marker clustering; point-select reuses T10's
  edit/delete modals + hooks; query-key prefixes refresh map on mutation
- pure map logic isolated + unit-tested; leaflet mocked in component tests
- responsive layout; typed client only
This commit is contained in:
2026-06-13 11:02:11 +02:00
parent 0d988a9b28
commit 32d93bba2a
10 changed files with 1536 additions and 10 deletions
+104
View File
@@ -0,0 +1,104 @@
/**
* 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])
}
/**
* 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]
}