More improvements

This commit is contained in:
Andras Schmelczer 2026-03-08 21:09:30 +00:00
parent e0798b24f7
commit 72653a4ddf
7 changed files with 146 additions and 33 deletions

View file

@ -1,4 +1,4 @@
import { memo, useState, useMemo, useRef, useCallback } from 'react';
import { memo, useState, useMemo, useRef, useCallback, useEffect } from 'react';
import { Slider } from '../ui/Slider';
import { LightbulbIcon } from '../ui/icons';
@ -212,23 +212,27 @@ export default memo(function Filters({
const activeEntryCount = travelTimeEntries.length;
const pendingScrollRef = useRef<string | null>(null);
const handleAddAndScroll = useCallback(
(name: string) => {
const feature = features.find((f) => f.name === name);
if (feature?.group) expandGroup(feature.group);
pendingScrollRef.current = name;
onAddFilter(name);
// Double rAF: first lets React commit the DOM update, second lets layout settle
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const el = scrollRef.current?.querySelector(`[data-filter-name="${CSS.escape(name)}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});
});
},
[onAddFilter, features, expandGroup]
);
useEffect(() => {
const name = pendingScrollRef.current;
if (!name) return;
pendingScrollRef.current = null;
const el = scrollRef.current?.querySelector(`[data-filter-name="${CSS.escape(name)}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, [enabledFeatureList]);
const enabledGroups = useMemo(
() => groupFeaturesByCategory(enabledFeatureList),
[enabledFeatureList]
@ -357,7 +361,7 @@ export default memo(function Filters({
<div
key={feature.name}
data-filter-name={feature.name}
className={`space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
className={`scroll-mt-7 space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="flex items-center justify-between">
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" />
@ -414,7 +418,7 @@ export default memo(function Filters({
<div
key={feature.name}
data-filter-name={feature.name}
className={`space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
className={`scroll-mt-7 space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="flex items-center justify-between gap-1">
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" className="min-w-0 shrink" />

View file

@ -1,21 +1,22 @@
import { useState, useEffect } from 'react';
import { useEffect } from 'react';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { TabButton } from '../ui/TabButton';
type DrawerTab = 'area' | 'properties';
interface MobileDrawerProps {
onClose: () => void;
renderArea: () => React.ReactNode;
renderProperties: () => React.ReactNode;
tab: 'area' | 'properties';
onTabChange: (tab: 'area' | 'properties') => void;
}
export default function MobileDrawer({
onClose,
renderArea,
renderProperties,
tab,
onTabChange,
}: MobileDrawerProps) {
const [tab, setTab] = useState<DrawerTab>('area');
// Close on Escape
useEffect(() => {
@ -35,11 +36,11 @@ export default function MobileDrawer({
<div className="h-[90%] bg-white dark:bg-navy-950 rounded-t-2xl flex flex-col shadow-xl overflow-hidden">
{/* Tab bar + close */}
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm shrink-0">
<TabButton label="Area" isActive={tab === 'area'} onClick={() => setTab('area')} />
<TabButton label="Area" isActive={tab === 'area'} onClick={() => onTabChange('area')} />
<TabButton
label="Properties"
isActive={tab === 'properties'}
onClick={() => setTab('properties')}
onClick={() => onTabChange('properties')}
/>
<button
onClick={onClose}

View file

@ -1,6 +1,6 @@
import { useState, useCallback } from 'react';
export function useCollapsibleGroups(): [Set<string>, (name: string) => void] {
export function useCollapsibleGroups(): [Set<string>, (name: string) => void, (name: string) => void] {
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const toggle = useCallback((name: string) => {
@ -12,5 +12,14 @@ export function useCollapsibleGroups(): [Set<string>, (name: string) => void] {
});
}, []);
return [collapsed, toggle];
const expand = useCallback((name: string) => {
setCollapsed((prev) => {
if (!prev.has(name)) return prev;
const next = new Set(prev);
next.delete(name);
return next;
});
}, []);
return [collapsed, toggle, expand];
}

View file

@ -1,4 +1,4 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { trackEvent } from '../lib/analytics';
import type {
FeatureMeta,
@ -99,6 +99,39 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
[filters, features]
);
const fetchPostcodeProperties = useCallback(
async (postcode: string, offset = 0) => {
setLoadingProperties(true);
try {
const params = new URLSearchParams({
postcode,
limit: '100',
offset: offset.toString(),
});
const filterStr = buildFilterString(filters, features);
if (filterStr) params.append('filters', filterStr);
const response = await fetch(apiUrl('postcode-properties', params), authHeaders());
assertOk(response, 'postcode-properties');
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) {
logNonAbortError('Failed to fetch postcode properties', err);
} finally {
setLoadingProperties(false);
}
},
[filters, features]
);
const handleHexagonClick = useCallback(
(id: string, isPostcode = false, geometry?: PostcodeGeometry) => {
if (selectedHexagon?.id === id) {
@ -139,27 +172,37 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
}, []);
const handleViewPropertiesFromArea = useCallback(() => {
if (selectedHexagon && selectedHexagon.type === 'hexagon') {
trackEvent('View Properties');
setRightPaneTab('properties');
setPropertiesOffset(0);
if (!selectedHexagon) return;
trackEvent('View Properties');
setRightPaneTab('properties');
setPropertiesOffset(0);
if (selectedHexagon.type === 'postcode') {
fetchPostcodeProperties(selectedHexagon.id, 0);
} else {
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
}
}, [selectedHexagon, fetchHexagonProperties]);
}, [selectedHexagon, fetchHexagonProperties, fetchPostcodeProperties]);
const handlePropertiesTabClick = useCallback(() => {
setRightPaneTab('properties');
if (selectedHexagon?.type === 'hexagon' && properties.length === 0 && !loadingProperties) {
if (selectedHexagon && properties.length === 0 && !loadingProperties) {
setPropertiesOffset(0);
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
if (selectedHexagon.type === 'postcode') {
fetchPostcodeProperties(selectedHexagon.id, 0);
} else {
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
}
}
}, [selectedHexagon, properties.length, loadingProperties, fetchHexagonProperties]);
}, [selectedHexagon, properties.length, loadingProperties, fetchHexagonProperties, fetchPostcodeProperties]);
const handleLoadMoreProperties = useCallback(() => {
if (selectedHexagon && selectedHexagon.type === 'hexagon') {
if (!selectedHexagon) return;
if (selectedHexagon.type === 'postcode') {
fetchPostcodeProperties(selectedHexagon.id, propertiesOffset);
} else {
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, propertiesOffset);
}
}, [selectedHexagon, propertiesOffset, fetchHexagonProperties]);
}, [selectedHexagon, propertiesOffset, fetchHexagonProperties, fetchPostcodeProperties]);
const handleCloseSelection = useCallback(() => {
setSelectedHexagon(null);
@ -168,6 +211,53 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
setSelectedPostcodeGeometry(null);
}, []);
// Re-fetch stats when filters change while a hexagon is selected
const filterStr = useMemo(() => buildFilterString(filters, features), [filters, features]);
const prevFilterStr = useRef(filterStr);
useEffect(() => {
if (prevFilterStr.current === filterStr) return;
prevFilterStr.current = filterStr;
if (!selectedHexagon) return;
// Clear stale properties
setProperties([]);
setPropertiesTotal(0);
setPropertiesOffset(0);
setLoadingAreaStats(true);
let cancelled = false;
const fetchStats =
selectedHexagon.type === 'postcode'
? fetchPostcodeStats(selectedHexagon.id)
: fetchHexagonStats(selectedHexagon.id, selectedHexagon.resolution);
fetchStats
.then((stats) => {
if (cancelled) return;
if (stats.count === 0) {
setSelectedHexagon(null);
setAreaStats(null);
setSelectedPostcodeGeometry(null);
} else {
setAreaStats(stats);
}
})
.catch((error) => {
if (cancelled) return;
logNonAbortError('Failed to refresh stats', error);
})
.finally(() => {
if (!cancelled) setLoadingAreaStats(false);
});
return () => {
cancelled = true;
};
}, [filterStr, selectedHexagon, fetchHexagonStats, fetchPostcodeStats]);
const handleLocationSearch = useCallback(
(postcode: string, geometry: PostcodeGeometry) => {
trackEvent('Postcode Search');

View file

@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#0f1528" />
<meta name="referrer" content="no-referrer" />
<title>Perfect Postcode — Every neighbourhood in England</title>
<meta name="description" content="Explore property prices, energy ratings, crime stats, school ratings, and more across England on one interactive map." />
<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__" />