Refactor map page

This commit is contained in:
Andras Schmelczer 2026-02-07 14:34:17 +00:00
parent 29d048ffd4
commit d4d79f0d99
17 changed files with 1014 additions and 878 deletions

View file

@ -0,0 +1,151 @@
import { useState, useCallback, useRef, useMemo } from 'react';
import type { FeatureMeta, FeatureFilters, Bounds, HexagonData, ApiResponse } from '../types';
import { apiUrl, logNonAbortError } from '../lib/api';
interface UseFiltersOptions {
initialFilters: FeatureFilters;
features: FeatureMeta[];
}
export function useFilters({ initialFilters, features }: UseFiltersOptions) {
// Use refs for bounds/resolution so handleDragStart always has latest values
const boundsRef = useRef<Bounds | null>(null);
const resolutionRef = useRef<number>(8);
const [filters, setFilters] = useState<FeatureFilters>(initialFilters);
const [activeFeature, setActiveFeature] = useState<string | null>(null);
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
const [pinnedFeature, setPinnedFeature] = useState<string | null>(null);
const [dragData, setDragData] = useState<HexagonData[] | null>(null);
const dragAbortRef = useRef<AbortController | null>(null);
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);
const viewFeature = activeFeature || pinnedFeature;
const viewSource: 'drag' | 'eye' | null = activeFeature ? 'drag' : pinnedFeature ? 'eye' : null;
const filterRange = useMemo((): [number, number] | null => {
if (!viewFeature) return null;
if (activeFeature && dragValue) return dragValue;
const filterVal = filters[viewFeature];
if (filterVal && typeof filterVal[0] === 'number') return filterVal as [number, number];
return null;
}, [viewFeature, activeFeature, dragValue, filters]);
const handleAddFilter = useCallback(
(name: string) => {
const meta = features.find((f) => f.name === name);
if (!meta) return;
if (meta.type === 'enum' && meta.values) {
setFilters((prev) => ({ ...prev, [name]: [...meta.values!] }));
} else if (meta.min != null && meta.max != null) {
setFilters((prev) => ({ ...prev, [name]: [meta.min!, meta.max!] }));
}
},
[features]
);
const handleFilterChange = useCallback((name: string, value: [number, number] | string[]) => {
setFilters((prev) => ({ ...prev, [name]: value }));
}, []);
const handleRemoveFilter = useCallback((name: string) => {
setFilters((prev) => {
const next = { ...prev };
delete next[name];
return next;
});
setPinnedFeature((prev) => (prev === name ? null : prev));
}, []);
const handleDragStart = useCallback(
(name: string) => {
const meta = features.find((f) => f.name === name);
if (meta?.type === 'enum') return;
setActiveFeature(name);
const fval = filters[name];
setDragValue(fval && !Array.isArray(fval[0]) ? (fval as [number, number]) : null);
const currentBounds = boundsRef.current;
if (!currentBounds) return;
if (dragAbortRef.current) dragAbortRef.current.abort();
dragAbortRef.current = new AbortController();
const otherFilters = Object.entries(filters).filter(([k]) => k !== name);
let filtersStr = '';
if (otherFilters.length > 0) {
filtersStr = otherFilters
.map(([n, value]) => {
const m = features.find((f) => f.name === n);
if (m?.type === 'enum') return `${n}:${(value as string[]).join('|')}`;
const [min, max] = value as [number, number];
return `${n}:${min}:${max}`;
})
.join(',');
}
const boundsStr = `${currentBounds.south},${currentBounds.west},${currentBounds.north},${currentBounds.east}`;
const params = new URLSearchParams({ resolution: resolutionRef.current.toString(), bounds: boundsStr });
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', name);
fetch(apiUrl('hexagons', params), {
signal: dragAbortRef.current.signal,
})
.then((res) => res.json())
.then((json: ApiResponse) => setDragData(json.features || []))
.catch((err) => logNonAbortError('Failed to fetch drag data', err));
},
[filters, features]
);
const handleDragChange = useCallback((value: [number, number]) => {
setDragValue(value);
}, []);
const handleDragEnd = useCallback(() => {
if (activeFeature && dragValue) {
setFilters((prev) => ({ ...prev, [activeFeature]: dragValue }));
}
setActiveFeature(null);
setDragValue(null);
setDragData(null);
if (dragAbortRef.current) {
dragAbortRef.current.abort();
dragAbortRef.current = null;
}
}, [activeFeature, dragValue]);
const handleTogglePin = useCallback((name: string) => {
setPinnedFeature((prev) => (prev === name ? null : name));
}, []);
const handleCancelPin = useCallback(() => {
setPinnedFeature(null);
}, []);
const updateBoundsInfo = useCallback((newBounds: Bounds | null, newResolution: number) => {
boundsRef.current = newBounds;
resolutionRef.current = newResolution;
}, []);
return {
filters,
activeFeature,
dragValue,
dragData,
pinnedFeature,
enabledFeatures,
viewFeature,
viewSource,
filterRange,
handleAddFilter,
handleFilterChange,
handleRemoveFilter,
handleDragStart,
handleDragChange,
handleDragEnd,
handleTogglePin,
handleCancelPin,
updateBoundsInfo,
};
}

View file

@ -0,0 +1,196 @@
import { useState, useCallback } from 'react';
import type {
FeatureMeta,
FeatureFilters,
PostcodeFeature,
Property,
HexagonPropertiesResponse,
HexagonStatsResponse,
NumericFeatureStats,
} from '../types';
import { buildFilterString, apiUrl, logNonAbortError } from '../lib/api';
interface SelectedHexagon {
id: string;
type: 'hexagon' | 'postcode';
resolution: number;
}
interface UseHexagonSelectionOptions {
filters: FeatureFilters;
features: FeatureMeta[];
postcodeData: PostcodeFeature[];
resolution: number;
}
export function useHexagonSelection({
filters,
features,
postcodeData,
resolution,
}: UseHexagonSelectionOptions) {
const [selectedHexagon, setSelectedHexagon] = useState<SelectedHexagon | null>(null);
const [properties, setProperties] = useState<Property[]>([]);
const [propertiesTotal, setPropertiesTotal] = useState(0);
const [propertiesOffset, setPropertiesOffset] = useState(0);
const [loadingProperties, setLoadingProperties] = useState(false);
const [areaStats, setAreaStats] = useState<HexagonStatsResponse | null>(null);
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
const [rightPaneTab, setRightPaneTab] = useState<'pois' | 'properties' | 'area'>('pois');
const fetchHexagonStats = useCallback(
async (h3: string, res: number, signal?: AbortSignal, fields?: string[]) => {
const params = new URLSearchParams({
h3,
resolution: res.toString(),
});
const filterStr = buildFilterString(filters, features);
if (filterStr) params.append('filters', filterStr);
if (fields) {
params.set('fields', fields.join(','));
}
const response = await fetch(apiUrl('hexagon-stats', params), { signal });
return (await response.json()) as HexagonStatsResponse;
},
[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;
numeric_features.push({
name: f.name,
count: props.count,
min: minVal,
max: maxVal,
mean: (minVal + maxVal) / 2,
});
}
return { count: props.count, numeric_features, enum_features: [] };
},
[postcodeData, features]
);
const fetchHexagonProperties = useCallback(
async (h3: string, res: number, offset = 0) => {
setLoadingProperties(true);
try {
const params = new URLSearchParams({
h3,
resolution: res.toString(),
limit: '100',
offset: offset.toString(),
});
const filterStr = buildFilterString(filters, features);
if (filterStr) params.append('filters', filterStr);
const response = await fetch(apiUrl('hexagon-properties', params));
const data: HexagonPropertiesResponse = await response.json();
if (offset === 0) {
setProperties(data.properties);
} else {
setProperties((prev) => [...prev, ...data.properties]);
}
setPropertiesTotal(data.total);
setPropertiesOffset(offset + data.properties.length);
} catch (err) {
console.error('Failed to fetch properties:', err);
} finally {
setLoadingProperties(false);
}
},
[filters, features]
);
const handleHexagonClick = useCallback(
(id: string, isPostcode = false) => {
if (selectedHexagon?.id === id) {
setSelectedHexagon(null);
setProperties([]);
setAreaStats(null);
} else {
const type = isPostcode ? 'postcode' : 'hexagon';
setSelectedHexagon({ id, type, resolution });
setProperties([]);
setPropertiesTotal(0);
setPropertiesOffset(0);
setRightPaneTab('area');
if (isPostcode) {
setAreaStats(buildPostcodeStats(id));
setLoadingAreaStats(false);
} else {
setLoadingAreaStats(true);
fetchHexagonStats(id, resolution)
.then((stats) => setAreaStats(stats))
.catch((error) => logNonAbortError('Failed to fetch area stats', error))
.finally(() => setLoadingAreaStats(false));
}
}
},
[selectedHexagon, resolution, fetchHexagonStats, buildPostcodeStats]
);
const handleHexagonHover = useCallback((h3: string | null) => {
setHoveredHexagon(h3);
}, []);
const handleViewPropertiesFromArea = useCallback(() => {
if (selectedHexagon && selectedHexagon.type === 'hexagon') {
setRightPaneTab('properties');
setPropertiesOffset(0);
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
}
}, [selectedHexagon, fetchHexagonProperties]);
const handlePropertiesTabClick = useCallback(() => {
setRightPaneTab('properties');
if (selectedHexagon?.type === 'hexagon' && properties.length === 0 && !loadingProperties) {
setPropertiesOffset(0);
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
}
}, [selectedHexagon, properties.length, loadingProperties, fetchHexagonProperties]);
const handleLoadMoreProperties = useCallback(() => {
if (selectedHexagon && selectedHexagon.type === 'hexagon') {
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, propertiesOffset);
}
}, [selectedHexagon, propertiesOffset, fetchHexagonProperties]);
const handleCloseSelection = useCallback(() => {
setSelectedHexagon(null);
setProperties([]);
setAreaStats(null);
}, []);
return {
selectedHexagon,
properties,
propertiesTotal,
loadingProperties,
areaStats,
loadingAreaStats,
hoveredHexagon,
rightPaneTab,
setRightPaneTab,
handleHexagonClick,
handleHexagonHover,
handleViewPropertiesFromArea,
handlePropertiesTabClick,
handleLoadMoreProperties,
handleCloseSelection,
};
}

View file

@ -0,0 +1,197 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import type {
FeatureMeta,
FeatureFilters,
Bounds,
HexagonData,
PostcodeFeature,
ViewChangeParams,
ApiResponse,
} from '../types';
import { buildFilterString, apiUrl, logNonAbortError } from '../lib/api';
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/map-utils';
const DEBOUNCE_MS = 150;
interface UseMapDataOptions {
filters: FeatureFilters;
features: FeatureMeta[];
viewFeature: string | null;
activeFeature: string | null;
dragValue: [number, number] | null;
dragData: HexagonData[] | null;
}
export function useMapData({
filters,
features,
viewFeature,
activeFeature,
dragValue,
dragData,
}: UseMapDataOptions) {
const [rawData, setRawData] = useState<HexagonData[]>([]);
const [postcodeData, setPostcodeData] = useState<PostcodeFeature[]>([]);
const [resolution, setResolution] = useState<number>(8);
const [bounds, setBounds] = useState<Bounds | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [zoom, setZoom] = useState<number>(10);
const [currentView, setCurrentView] = useState<{
latitude: number;
longitude: number;
zoom: number;
} | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const prevBoundsRef = useRef<string>('');
const usePostcodeView = zoom >= POSTCODE_ZOOM_THRESHOLD;
const buildFilterParam = useCallback(
(): string => buildFilterString(filters, features),
[filters, features]
);
// Fetch hexagons or postcodes when bounds/filters change
useEffect(() => {
if (!bounds) return;
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(async () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
setLoading(true);
try {
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
const filtersStr = buildFilterParam();
if (usePostcodeView) {
const params = new URLSearchParams({ bounds: boundsStr });
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', viewFeature || '');
const res = await fetch(apiUrl('postcodes', params), {
signal: abortControllerRef.current.signal,
});
const json: { features: PostcodeFeature[] } = await res.json();
setPostcodeData(json.features || []);
setRawData([]);
} else {
const params = new URLSearchParams({
resolution: resolution.toString(),
bounds: boundsStr,
});
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', viewFeature || '');
const res = await fetch(apiUrl('hexagons', params), {
signal: abortControllerRef.current.signal,
});
const json: ApiResponse = await res.json();
setRawData(json.features || []);
setPostcodeData([]);
}
} catch (err) {
logNonAbortError('Failed to fetch data', err);
} finally {
setLoading(false);
}
}, DEBOUNCE_MS);
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView]);
const data = dragData ?? rawData;
// Compute actual min/max from visible data for the viewed feature
const dataRange = useMemo((): [number, number] | null => {
if (!viewFeature) return null;
const meta = features.find((f) => f.name === viewFeature);
if (!meta || meta.type === 'enum') return null;
if (activeFeature && !dragData) return null;
let min = Infinity;
let max = -Infinity;
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);
}
}
} 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 (min === Infinity || max === -Infinity) return null;
return [min, max];
}, [viewFeature, data, dragData, postcodeData, usePostcodeView, features, activeFeature]);
// Color range for the legend and hex coloring
const colorRange = useMemo((): [number, number] | null => {
if (!viewFeature) return null;
const meta = features.find((f) => f.name === viewFeature);
if (!meta) return null;
if (meta.type === 'enum' && meta.values && meta.values.length > 0) {
return [0, meta.values.length - 1];
}
if (dataRange) return dataRange;
if (activeFeature && dragValue) return dragValue;
if (meta.min != null && meta.max != null) return [meta.min, meta.max];
return null;
}, [viewFeature, features, dataRange, activeFeature, dragValue]);
const handleViewChange = useCallback(
({ resolution: newRes, bounds: newBounds, zoom: newZoom, latitude, longitude }: ViewChangeParams) => {
const boundsKey = `${newBounds.south},${newBounds.west},${newBounds.north},${newBounds.east},${newRes}`;
if (boundsKey !== prevBoundsRef.current) {
prevBoundsRef.current = boundsKey;
setResolution(newRes);
setBounds(newBounds);
}
setZoom(newZoom);
setCurrentView({ latitude, longitude, zoom: newZoom });
},
[]
);
const setInitialView = useCallback((view: { latitude: number; longitude: number; zoom: number }) => {
setCurrentView(view);
setZoom(view.zoom);
}, []);
return {
data,
rawData,
postcodeData,
resolution,
bounds,
loading,
zoom,
currentView,
usePostcodeView,
colorRange,
handleViewChange,
setInitialView,
};
}

View file

@ -0,0 +1,53 @@
import { useState, useEffect, useRef } from 'react';
import type { Bounds, POI, POIResponse } from '../types';
import { apiUrl, logNonAbortError } from '../lib/api';
const DEBOUNCE_MS = 150;
export function usePOIData(bounds: Bounds | null, selectedCategories: Set<string>) {
const [pois, setPois] = useState<POI[]>([]);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
if (!bounds || selectedCategories.size === 0) {
setPois([]);
return;
}
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(async () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
try {
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
const categoriesStr = Array.from(selectedCategories).join(',');
const params = new URLSearchParams({
categories: categoriesStr,
bounds: boundsStr,
});
const res = await fetch(apiUrl('pois', params), {
signal: abortControllerRef.current.signal,
});
const json: POIResponse = await res.json();
setPois(json.pois || []);
} catch (err) {
logNonAbortError('Failed to fetch POIs', err);
}
}, DEBOUNCE_MS);
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, [bounds, selectedCategories]);
return pois;
}

View file

@ -0,0 +1,48 @@
import { useState, useCallback, useRef } from 'react';
interface PaneResizeHandlers {
onPointerDown: (e: React.PointerEvent) => void;
onPointerMove: (e: React.PointerEvent) => void;
onPointerUp: () => void;
}
export function usePaneResize(
initialWidth: number,
minWidth: number,
maxWidth: number,
side: 'left' | 'right'
): [number, PaneResizeHandlers] {
const [width, setWidth] = useState(initialWidth);
const draggingRef = useRef(false);
const handlePointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
draggingRef.current = true;
}, []);
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!draggingRef.current) return;
const newWidth =
side === 'left'
? Math.min(maxWidth, Math.max(minWidth, e.clientX))
: Math.min(maxWidth, Math.max(minWidth, window.innerWidth - e.clientX));
setWidth(newWidth);
},
[side, minWidth, maxWidth]
);
const handlePointerUp = useCallback(() => {
draggingRef.current = false;
}, []);
return [
width,
{
onPointerDown: handlePointerDown,
onPointerMove: handlePointerMove,
onPointerUp: handlePointerUp,
},
];
}

View file

@ -0,0 +1,76 @@
const DOMAIN = 'narrowit.schmelczer.dev';
const ENDPOINT = '/status';
const IS_DEV = process.env.NODE_ENV !== 'production';
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),
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);
}