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:
@@ -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]
|
||||
}
|
||||
Reference in New Issue
Block a user