Format and lint
This commit is contained in:
parent
42ee2d4c51
commit
04a78e7bfe
75 changed files with 1290 additions and 719 deletions
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 (0–100) 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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue