Last night

This commit is contained in:
Andras Schmelczer 2026-02-08 10:21:37 +00:00
parent 2906b01734
commit 42ee2d4c51
47 changed files with 848 additions and 478 deletions

View file

@ -65,7 +65,7 @@ export function useAreaSummary({
name: f.name,
mean: f.mean,
})),
enum_stats: stats.enum_features.map((f) => ({
enum_stats: stats.enum_features.filter(f => !FORBIDDEN_FEATURES.includes(f.name)).map((f) => ({
name: f.name,
counts: f.counts,
})),

View file

@ -4,8 +4,6 @@ import pb from '../lib/pocketbase';
export interface AuthUser {
id: string;
email: string;
name: string;
avatar: string;
verified: boolean;
}
@ -15,8 +13,6 @@ function recordToUser(record: any): AuthUser {
return {
id: record.id || '',
email: record.email || '',
name: record.name || '',
avatar: record.avatar || '',
verified: record.verified || false,
};
}
@ -58,7 +54,7 @@ export function useAuth() {
}
}, []);
const register = useCallback(async (email: string, password: string, name?: string) => {
const register = useCallback(async (email: string, password: string) => {
setLoading(true);
setError(null);
try {
@ -66,7 +62,6 @@ export function useAuth() {
email,
password,
passwordConfirm: password,
name: name || '',
});
// Auto-login after registration
const result = await pb.collection('users').authWithPassword(email, password);

View file

@ -68,12 +68,13 @@ export function useHexagonSelection({
const minVal = props[`min_${f.name}`];
const maxVal = props[`max_${f.name}`];
if (typeof minVal !== 'number' || typeof maxVal !== 'number') continue;
const avgVal = props[`avg_${f.name}`];
numeric_features.push({
name: f.name,
count: props.count,
min: minVal,
max: maxVal,
mean: (minVal + maxVal) / 2,
mean: typeof avgVal === 'number' ? avgVal : (minVal + maxVal) / 2,
});
}

View file

@ -0,0 +1,16 @@
import { useState, useEffect } from 'react';
const MOBILE_QUERY = '(max-width: 767px)';
export function useIsMobile(): boolean {
const [isMobile, setIsMobile] = useState(() => window.matchMedia(MOBILE_QUERY).matches);
useEffect(() => {
const mql = window.matchMedia(MOBILE_QUERY);
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, []);
return isMobile;
}

View file

@ -10,6 +10,19 @@ import type {
} from '../types';
import { buildFilterString, apiUrl, logNonAbortError, authHeaders } from '../lib/api';
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/map-utils';
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
import { cellToLatLng } from 'h3-js';
/** Return the p-th percentile (0100) from a sorted array via linear interpolation. */
function percentile(sorted: number[], p: number): number {
if (sorted.length === 0) return 0;
if (sorted.length === 1) return sorted[0];
const idx = (p / 100) * (sorted.length - 1);
const lo = Math.floor(idx);
const hi = Math.ceil(idx);
if (lo === hi) return sorted[lo];
return sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo);
}
const DEBOUNCE_MS = 150;
@ -118,7 +131,8 @@ export function useMapData({
const data = dragData ?? rawData;
// Compute actual min/max from visible data for the viewed feature
// Compute p5/p95 from visible data for the viewed feature
// Only considers hexagons/postcodes whose center falls within the viewport bounds
const dataRange = useMemo((): [number, number] | null => {
if (!viewFeature) return null;
const meta = features.find((f) => f.name === viewFeature);
@ -126,32 +140,34 @@ export function useMapData({
if (activeFeature && !dragData) return null;
let min = Infinity;
let max = -Infinity;
const vals: number[] = [];
if (usePostcodeView) {
if (postcodeData.length === 0) return null;
for (const feat of postcodeData) {
const val = feat.properties[`min_${viewFeature}`];
if (typeof val === 'number' && !isNaN(val)) {
min = Math.min(min, val);
max = Math.max(max, val);
if (bounds) {
const [lng, lat] = feat.properties.centroid as [number, number];
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east) continue;
}
const val = feat.properties[`avg_${viewFeature}`] ?? feat.properties[`min_${viewFeature}`];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
}
} else {
if (data.length === 0) return null;
for (const item of data) {
const val = item[`min_${viewFeature}`];
if (typeof val === 'number' && !isNaN(val)) {
min = Math.min(min, val);
max = Math.max(max, val);
if (bounds) {
const [lat, lng] = cellToLatLng(item.h3);
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east) continue;
}
const val = item[`avg_${viewFeature}`] ?? item[`min_${viewFeature}`];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
}
}
if (min === Infinity || max === -Infinity) return null;
return [min, max];
}, [viewFeature, data, dragData, postcodeData, usePostcodeView, features, activeFeature]);
if (vals.length === 0) return null;
vals.sort((a, b) => a - b);
return [percentile(vals, COLOR_RANGE_LOW_PERCENTILE), percentile(vals, COLOR_RANGE_HIGH_PERCENTILE)];
}, [viewFeature, data, dragData, postcodeData, usePostcodeView, features, activeFeature, bounds]);
// Color range for the legend and hex coloring
const colorRange = useMemo((): [number, number] | null => {

View file

@ -1,5 +1,5 @@
const DOMAIN = 'narrowit.schmelczer.dev';
const ENDPOINT = '/status';
const ENDPOINT = 'https://stats.schmelczer.dev/status';
const IS_DEV = process.env.NODE_ENV !== 'production';
type EventOptions = {
@ -32,8 +32,9 @@ function sendEvent(name: string, options?: EventOptions) {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
credentials: 'omit',
keepalive: true,
}).catch(() => {});
}).catch(() => { });
}
}