Files
home-automation/frontend/src/map/mapUtils.ts
T
tliu93 da236643f2
frontend / frontend (push) Successful in 2m0s
pytest / test (push) Successful in 1m32s
M2: frontend walkthrough fixes + explicit dev compose stack
Post-M2 self-walkthrough polish, batched into one commit.

Map / heat:
- fix heat-layer white-screen crash after login (add layer to map before
  setLatLngs; an off-map leaflet.heat layer has a null _map and throws)
- normalize each heat layer to the densest pixel cell visible in the CURRENT
  viewport (maxZoom:0 so intensity factor f=1) and recompute on moveend/zoomend,
  so sparse poo data reaches red and stays normalized at any zoom level
- dark CARTO basemap tiles when the color scheme is dark

UI:
- dark-mode toggle in the top-right, beside the settings gear
- switch top-right nav (records / theme / settings / logout) to Feather icons
  with hover tooltips
- home: Grafana-style quick time-range presets + back/forward shift buttons,
  placed between the From/To pickers and Apply; fix Select/tooltip z-index
  (Leaflet stacking) and the shift-button height alignment

API client:
- stop flooding GET /api/session with 401s: the session probe and the login
  endpoint own their 401s (no global redirect), which fixes the logout hang and
  the spinning login page

Compose:
- rename docker-compose.override.yml -> docker-compose.dev.yml as an explicit,
  non-auto-layered dev stack (8001, -dev container names, prod-copy ./data DB);
  update tests/test_deployment.py (read dev.yml, tolerate the !override tag) and
  the README "Docker Compose" section

Tests:
- pixel-grid peak counter, time-range presets, heat-layer ordering regression,
  and 401-redirect regression
2026-06-13 15:20:50 +02:00

185 lines
6.2 KiB
TypeScript

/**
* 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<string, number>()
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)),
}
}