Format and lint

This commit is contained in:
Andras Schmelczer 2026-02-08 12:37:07 +00:00
parent 42ee2d4c51
commit 04a78e7bfe
75 changed files with 1290 additions and 719 deletions

View file

@ -14,11 +14,21 @@ interface UseAreaSummaryResult {
summary: string;
loading: boolean;
error: string | null;
retry: () => void;
}
const FORBIDDEN_FEATURES = ['% White', '% Black', '% Asian', '% Mixed', '% Other',
'Environmental risk', 'Collapsible deposits risk', 'Compressible ground risk', 'Landslide risk', 'Running sand risk', 'Shrink-swell risk', 'Soluble rocks risk'
const FORBIDDEN_FEATURES = [
'% White',
'% Black',
'% Asian',
'% Mixed',
'% Other',
'Environmental risk',
'Collapsible deposits risk',
'Compressible ground risk',
'Landslide risk',
'Running sand risk',
'Shrink-swell risk',
'Soluble rocks risk',
];
export function useAreaSummary({
@ -61,23 +71,30 @@ export function useAreaSummary({
location: hexagonId,
is_postcode: isPostcode,
filters: filterDescriptions,
numeric_stats: stats.numeric_features.filter(f => !FORBIDDEN_FEATURES.includes(f.name)).map((f) => ({
name: f.name,
mean: f.mean,
})),
enum_stats: stats.enum_features.filter(f => !FORBIDDEN_FEATURES.includes(f.name)).map((f) => ({
name: f.name,
counts: f.counts,
})),
numeric_stats: stats.numeric_features
.filter((f) => !FORBIDDEN_FEATURES.includes(f.name))
.map((f) => ({
name: f.name,
mean: f.mean,
})),
enum_stats: stats.enum_features
.filter((f) => !FORBIDDEN_FEATURES.includes(f.name))
.map((f) => ({
name: f.name,
counts: f.counts,
})),
};
const url = apiUrl('area-summary');
const response = await fetch(url, authHeaders({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal,
}));
const response = await fetch(
url,
authHeaders({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal,
})
);
if (!response.ok) {
const text = await response.text();
@ -102,9 +119,5 @@ export function useAreaSummary({
};
}, [stats, hexagonId]); // eslint-disable-line react-hooks/exhaustive-deps
const retry = useCallback(() => {
fetchSummary();
}, [fetchSummary]);
return { summary, loading, error, retry };
return { summary, loading, error };
}

View file

@ -113,5 +113,15 @@ export function useAuth() {
setError(null);
}, []);
return { user, loading, error, login, register, loginWithOAuth, logout, requestPasswordReset, clearError };
return {
user,
loading,
error,
login,
register,
loginWithOAuth,
logout,
requestPasswordReset,
clearError,
};
}

View file

@ -84,7 +84,10 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
}
const boundsStr = `${currentBounds.south},${currentBounds.west},${currentBounds.north},${currentBounds.east}`;
const params = new URLSearchParams({ resolution: resolutionRef.current.toString(), bounds: boundsStr });
const params = new URLSearchParams({
resolution: resolutionRef.current.toString(),
bounds: boundsStr,
});
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', name);

View file

@ -2,11 +2,9 @@ import { useState, useCallback } from 'react';
import type {
FeatureMeta,
FeatureFilters,
PostcodeFeature,
Property,
HexagonPropertiesResponse,
HexagonStatsResponse,
NumericFeatureStats,
} from '../types';
import { buildFilterString, apiUrl, logNonAbortError, authHeaders } from '../lib/api';
@ -19,16 +17,10 @@ interface SelectedHexagon {
interface UseHexagonSelectionOptions {
filters: FeatureFilters;
features: FeatureMeta[];
postcodeData: PostcodeFeature[];
resolution: number;
}
export function useHexagonSelection({
filters,
features,
postcodeData,
resolution,
}: UseHexagonSelectionOptions) {
export function useHexagonSelection({ filters, features, resolution }: UseHexagonSelectionOptions) {
const [selectedHexagon, setSelectedHexagon] = useState<SelectedHexagon | null>(null);
const [properties, setProperties] = useState<Property[]>([]);
const [propertiesTotal, setPropertiesTotal] = useState(0);
@ -56,31 +48,15 @@ export function useHexagonSelection({
[filters, features]
);
const buildPostcodeStats = useCallback(
(postcode: string): HexagonStatsResponse | null => {
const feat = postcodeData.find((f) => f.properties.postcode === postcode);
if (!feat) return null;
const props = feat.properties;
const numeric_features: NumericFeatureStats[] = [];
for (const f of features) {
if (f.type !== 'numeric') continue;
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: typeof avgVal === 'number' ? avgVal : (minVal + maxVal) / 2,
});
}
return { count: props.count, numeric_features, enum_features: [] };
const fetchPostcodeStats = useCallback(
async (postcode: string, signal?: AbortSignal) => {
const params = new URLSearchParams({ postcode });
const filterStr = buildFilterString(filters, features);
if (filterStr) params.append('filters', filterStr);
const response = await fetch(apiUrl('postcode-stats', params), authHeaders({ signal }));
return (await response.json()) as HexagonStatsResponse;
},
[postcodeData, features]
[filters, features]
);
const fetchHexagonProperties = useCallback(
@ -131,8 +107,11 @@ export function useHexagonSelection({
setRightPaneTab('area');
if (isPostcode) {
setAreaStats(buildPostcodeStats(id));
setLoadingAreaStats(false);
setLoadingAreaStats(true);
fetchPostcodeStats(id)
.then((stats) => setAreaStats(stats))
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
.finally(() => setLoadingAreaStats(false));
} else {
setLoadingAreaStats(true);
fetchHexagonStats(id, resolution)
@ -142,7 +121,7 @@ export function useHexagonSelection({
}
}
},
[selectedHexagon, resolution, fetchHexagonStats, buildPostcodeStats]
[selectedHexagon, resolution, fetchHexagonStats, fetchPostcodeStats]
);
const handleHexagonHover = useCallback((h3: string | null) => {

View file

@ -11,7 +11,6 @@ import type {
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 {
@ -147,7 +146,8 @@ export function useMapData({
for (const feat of postcodeData) {
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;
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);
@ -156,8 +156,9 @@ export function useMapData({
if (data.length === 0) return null;
for (const item of data) {
if (bounds) {
const [lat, lng] = cellToLatLng(item.h3);
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east) continue;
const { lat, lon } = item;
if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
continue;
}
const val = item[`avg_${viewFeature}`] ?? item[`min_${viewFeature}`];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
@ -166,7 +167,10 @@ export function useMapData({
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)];
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

View file

@ -1,77 +1,11 @@
const DOMAIN = 'narrowit.schmelczer.dev';
const ENDPOINT = 'https://stats.schmelczer.dev/status';
const IS_DEV = process.env.NODE_ENV !== 'production';
import { init as plausibleInit } from '@plausible-analytics/tracker';
type EventOptions = {
props?: Record<string, string | number | boolean>;
revenue?: { currency: string; amount: number };
};
function sendEvent(name: string, options?: EventOptions) {
if (IS_DEV) return;
const payload: Record<string, unknown> = {
n: name,
u: window.location.href,
d: DOMAIN,
r: document.referrer || null,
};
if (options?.props) {
payload.p = JSON.stringify(options.props);
}
if (options?.revenue) {
payload.$ = JSON.stringify(options.revenue);
}
if (navigator.sendBeacon) {
navigator.sendBeacon(
ENDPOINT,
new Blob([JSON.stringify(payload)], { type: 'application/json' })
);
} else {
fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
credentials: 'omit',
keepalive: true,
}).catch(() => { });
}
}
let initialized = false;
/**
* Tracks pageview on first call and returns a trackEvent function.
* Tracks outbound link clicks automatically.
*/
export function initPlausible() {
if (initialized) return;
initialized = true;
// Initial pageview
sendEvent('pageview');
// Track outbound link clicks
document.addEventListener('click', (e) => {
const link = (e.target as HTMLElement).closest?.('a');
if (!link) return;
const href = link.getAttribute('href');
if (!href) return;
try {
const url = new URL(href, window.location.origin);
if (url.hostname !== window.location.hostname) {
sendEvent('Outbound Link: Click', { props: { url: href } });
}
} catch {
// invalid URL, ignore
}
});
}
export function trackPageview(options?: EventOptions) {
sendEvent('pageview', options);
}
export function trackEvent(name: string, options?: EventOptions) {
sendEvent(name, options);
}
plausibleInit({
domain: 'narrowit.schmelczer.dev',
endpoint: 'https://stats.schmelczer.dev/status',
autoCapturePageviews: true,
captureOnLocalhost: true,
logging: true,
fileDownloads: true,
hashBasedRouting: true,
});

View file

@ -51,11 +51,11 @@ export function useSavedSearches(userId: string | null) {
try {
const params = window.location.search.replace(/^\?/, '');
// Try to capture a screenshot via the OG image endpoint
// Try to capture a screenshot via the screenshot endpoint
let screenshotBlob: Blob | null = null;
try {
const ogUrl = apiUrl('og-image', new URLSearchParams(params));
const res = await fetch(ogUrl);
const screenshotUrl = apiUrl('screenshot', new URLSearchParams(params));
const res = await fetch(screenshotUrl);
if (res.ok) {
screenshotBlob = await res.blob();
}
@ -84,18 +84,15 @@ export function useSavedSearches(userId: string | null) {
[userId, fetchSearches]
);
const deleteSearch = useCallback(
async (id: string) => {
setError(null);
try {
await pb.collection('saved_searches').delete(id);
setSearches((prev) => prev.filter((s) => s.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete search');
}
},
[]
);
const deleteSearch = useCallback(async (id: string) => {
setError(null);
try {
await pb.collection('saved_searches').delete(id);
setSearches((prev) => prev.filter((s) => s.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete search');
}
}, []);
return { searches, loading, saving, error, fetchSearches, saveSearch, deleteSearch };
}