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
This commit is contained in:
@@ -40,6 +40,31 @@ 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).
|
||||
*/
|
||||
@@ -102,3 +127,58 @@ export function computeCenter(
|
||||
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)),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user