This commit is contained in:
Andras Schmelczer 2026-05-12 22:00:56 +01:00
parent 8708bf000d
commit 11711c57e6
38 changed files with 5361 additions and 265 deletions

View file

@ -301,24 +301,22 @@ export default function HomePage({
{t('home.seeTheDifference')}
</button>
</div>
<div className="flex flex-wrap gap-x-8 sm:gap-x-12 gap-y-4 pt-3 border-t border-white/10">
<div>
<div className="text-2xl md:text-3xl font-bold text-white">
<div className="home-hero-stats flex flex-wrap pt-3 border-t border-white/10">
<div className="home-hero-stat">
<div className="home-hero-stat-value">
<TickerValue text="13M" active={statsActive} />
</div>
<div className="text-sm text-warm-200">{t('home.statProperties')}</div>
<div className="home-hero-stat-label">{t('home.statProperties')}</div>
</div>
<div>
<div className="text-2xl md:text-3xl font-bold text-white">
<div className="home-hero-stat">
<div className="home-hero-stat-value">
<TickerValue text="56" active={statsActive} />
</div>
<div className="text-sm text-warm-200">{t('home.statFilters')}</div>
<div className="home-hero-stat-label">{t('home.statFilters')}</div>
</div>
<div>
<div className="text-2xl md:text-3xl font-bold text-white">
{t('home.statEvery')}
</div>
<div className="text-sm text-warm-200">{t('home.statPostcodeInEngland')}</div>
<div className="home-hero-stat">
<div className="home-hero-stat-value">{t('home.statEvery')}</div>
<div className="home-hero-stat-label">{t('home.statPostcodeInEngland')}</div>
</div>
</div>
</div>

View file

@ -840,7 +840,7 @@ function ScoutScreen({ isActive }: { isActive: boolean }) {
<div className="scout-export-action relative cursor-default select-none overflow-hidden rounded-lg border border-teal-300 bg-teal-600 p-2 text-white shadow-lg shadow-teal-900/20 dark:border-teal-500 dark:bg-teal-500 dark:text-navy-950 sm:p-4">
<span className="scout-export-ripple" aria-hidden="true" />
<div className="relative flex items-center gap-2 sm:gap-3">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-white/15 text-white dark:bg-navy-950/10 dark:text-navy-950 sm:h-10 sm:w-10">
<span className="scout-export-icon flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-white/15 text-white dark:bg-navy-950/10 dark:text-navy-950 sm:h-10 sm:w-10">
<DownloadIcon className="h-4 w-4 sm:h-5 sm:w-5" />
</span>
<div className="min-w-0">
@ -849,8 +849,8 @@ function ScoutScreen({ isActive }: { isActive: boolean }) {
{t('home.showcaseDownloadXlsx')}
</div>
</div>
<span className="scout-export-check ml-auto hidden h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white text-teal-700 shadow-sm dark:bg-navy-950 dark:text-teal-300 sm:flex">
<CheckIcon className="h-3.5 w-3.5" />
<span className="scout-export-check absolute left-4 top-0 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-white text-teal-700 shadow-sm dark:bg-navy-950 dark:text-teal-300 sm:static sm:ml-auto sm:h-6 sm:w-6">
<CheckIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
</span>
</div>
</div>

View file

@ -7,7 +7,7 @@ import { FilterIcon } from '../ui/icons';
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
import { EmptyState } from '../ui/EmptyState';
import type { FeatureMeta } from '../../types';
import { groupFeaturesByCategory, orderFilterGroups } from '../../lib/features';
import { groupFeaturesByCategory } from '../../lib/features';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { FeatureActions } from '../ui/FeatureIcons';
import { FeatureLabel } from '../ui/FeatureLabel';
@ -73,7 +73,7 @@ export default function FeatureBrowser({
);
}, [availableFeatures, search]);
const grouped = useMemo(() => orderFilterGroups(groupFeaturesByCategory(filtered)), [filtered]);
const grouped = useMemo(() => groupFeaturesByCategory(filtered), [filtered]);
// When searching, expand all groups so results are visible
const isSearching = search.length > 0;

View file

@ -45,6 +45,7 @@ import {
import {
POI_FILTER_NAMES,
POI_DISTANCE_FILTER_NAME,
TRANSPORT_DISTANCE_FILTER_NAME,
POI_COUNT_2KM_FILTER_NAME,
POI_COUNT_5KM_FILTER_NAME,
getDefaultPoiDistanceFeatureName,
@ -170,6 +171,10 @@ export default memo(function Filters({
() => getDefaultPoiDistanceFeatureName(features),
[features]
);
const defaultTransportDistanceFeatureName = useMemo(
() => getDefaultPoiFilterFeatureName(features, TRANSPORT_DISTANCE_FILTER_NAME),
[features]
);
const defaultPoiCount2KmFeatureName = useMemo(
() => getDefaultPoiFilterFeatureName(features, POI_COUNT_2KM_FILTER_NAME),
[features]
@ -179,6 +184,10 @@ export default memo(function Filters({
[features]
);
const poiDistanceMeta = useMemo(() => getPoiDistanceFilterMeta(features), [features]);
const transportDistanceMeta = useMemo(
() => getPoiFilterMeta(features, TRANSPORT_DISTANCE_FILTER_NAME),
[features]
);
const poiCount2KmMeta = useMemo(
() => getPoiFilterMeta(features, POI_COUNT_2KM_FILTER_NAME),
[features]
@ -190,18 +199,25 @@ export default memo(function Filters({
const poiFilterMetas = useMemo(
() => ({
[POI_DISTANCE_FILTER_NAME]: poiDistanceMeta,
[TRANSPORT_DISTANCE_FILTER_NAME]: transportDistanceMeta,
[POI_COUNT_2KM_FILTER_NAME]: poiCount2KmMeta,
[POI_COUNT_5KM_FILTER_NAME]: poiCount5KmMeta,
}),
[poiDistanceMeta, poiCount2KmMeta, poiCount5KmMeta]
[poiDistanceMeta, transportDistanceMeta, poiCount2KmMeta, poiCount5KmMeta]
);
const defaultPoiFilterFeatureNames = useMemo(
() => ({
[POI_DISTANCE_FILTER_NAME]: defaultPoiDistanceFeatureName,
[TRANSPORT_DISTANCE_FILTER_NAME]: defaultTransportDistanceFeatureName,
[POI_COUNT_2KM_FILTER_NAME]: defaultPoiCount2KmFeatureName,
[POI_COUNT_5KM_FILTER_NAME]: defaultPoiCount5KmFeatureName,
}),
[defaultPoiDistanceFeatureName, defaultPoiCount2KmFeatureName, defaultPoiCount5KmFeatureName]
[
defaultPoiDistanceFeatureName,
defaultTransportDistanceFeatureName,
defaultPoiCount2KmFeatureName,
defaultPoiCount5KmFeatureName,
]
);
const schoolFilterItems = useMemo(() => {
return Object.keys(filters)
@ -256,7 +272,11 @@ export default memo(function Filters({
const backendFeature = backendName
? features.find((feature) => feature.name === backendName)
: undefined;
return { ...(backendFeature ?? poiFilterMetas[filterName]), name, group: 'Amenities' };
return {
...(backendFeature ?? poiFilterMetas[filterName]),
name,
group: poiFilterMetas[filterName].group,
};
});
}, [filters, features, poiFilterMetas]);
const availableFeatures = useMemo(() => {
@ -266,8 +286,21 @@ export default memo(function Filters({
let insertedElectionVoteShareFilter = false;
let insertedEthnicityFilter = false;
const insertedPoiFilters = new Set<PoiFilterName>();
const maybeInsertPoiFilter = (filterName: PoiFilterName | null) => {
if (
filterName &&
defaultPoiFilterFeatureNames[filterName] &&
!insertedPoiFilters.has(filterName)
) {
result.push(poiFilterMetas[filterName]);
insertedPoiFilters.add(filterName);
}
};
for (const feature of features) {
if (feature.group === 'Transport') {
maybeInsertPoiFilter(TRANSPORT_DISTANCE_FILTER_NAME);
}
if (isSchoolFilterName(feature.name)) {
if (defaultSchoolFeatureName && !insertedSchoolFilter) {
result.push(schoolMeta);
@ -297,15 +330,7 @@ export default memo(function Filters({
continue;
}
if (isPoiFilterFeatureName(feature.name)) {
const filterName = getPoiFilterName(feature.name);
if (
filterName &&
defaultPoiFilterFeatureNames[filterName] &&
!insertedPoiFilters.has(filterName)
) {
result.push(poiFilterMetas[filterName]);
insertedPoiFilters.add(filterName);
}
maybeInsertPoiFilter(getPoiFilterName(feature.name));
continue;
}
if (!enabledFeatures.has(feature.name)) result.push(feature);
@ -332,9 +357,19 @@ export default memo(function Filters({
let insertedSpecificCrimeFilters = false;
let insertedElectionVoteShareFilters = false;
let insertedEthnicityFilters = false;
let insertedPoiDistanceFilters = false;
const insertedPoiFilters = new Set<PoiFilterName>();
const insertPoiFilterItems = (filterName: PoiFilterName | null) => {
if (!filterName || insertedPoiFilters.has(filterName)) return;
result.push(
...poiDistanceFilterItems.filter((item) => getPoiFilterName(item.name) === filterName)
);
insertedPoiFilters.add(filterName);
};
for (const feature of features) {
if (feature.group === 'Transport') {
insertPoiFilterItems(TRANSPORT_DISTANCE_FILTER_NAME);
}
if (isSchoolFilterName(feature.name)) {
if (!insertedSchoolFilter) {
result.push(...schoolFilterItems);
@ -364,10 +399,7 @@ export default memo(function Filters({
continue;
}
if (isPoiFilterFeatureName(feature.name)) {
if (!insertedPoiDistanceFilters) {
result.push(...poiDistanceFilterItems);
insertedPoiDistanceFilters = true;
}
insertPoiFilterItems(getPoiFilterName(feature.name));
continue;
}
if (enabledFeatures.has(feature.name)) result.push(feature);
@ -583,6 +615,7 @@ export default memo(function Filters({
electionVoteShareMeta,
ethnicityMeta,
poiDistanceMeta,
transportDistanceMeta,
poiCount2KmMeta,
poiCount5KmMeta,
]}

View file

@ -7,7 +7,11 @@ import { SCHOOL_FILTER_NAME, getSchoolBackendFeatureName } from '../../lib/schoo
import { getSpecificCrimeFeatureName } from '../../lib/crime-filter';
import { getElectionVoteShareFeatureName } from '../../lib/election-filter';
import { getEthnicityFeatureName } from '../../lib/ethnicity-filter';
import { POI_DISTANCE_FILTER_NAME, getPoiDistanceFeatureName } from '../../lib/poi-distance-filter';
import {
POI_DISTANCE_FILTER_NAME,
getPoiDistanceFeatureName,
getPoiFilterName,
} from '../../lib/poi-distance-filter';
interface HoverCardData {
count: number;
@ -69,7 +73,7 @@ export default memo(function HoverCard({
name: schoolBackendName
? SCHOOL_FILTER_NAME
: poiDistanceFeatureName
? POI_DISTANCE_FILTER_NAME
? (getPoiFilterName(name) ?? POI_DISTANCE_FILTER_NAME)
: backendName,
value: formatValue(val, meta),
});

View file

@ -141,7 +141,7 @@ describe('MobileBottomSheet keyboard avoidance', () => {
it('reports covered height while the drawer is being dragged', async () => {
installViewport({ innerHeight: 800, visualHeight: 800 });
const { coveredHeights, sheet } = renderSheet();
const handle = sheet.firstElementChild;
const handle = sheet.firstElementChild?.firstElementChild;
if (!(handle instanceof HTMLElement)) throw new Error('Expected bottom sheet drag handle');

View file

@ -228,14 +228,18 @@ export default function MobileBottomSheet({
: 'height 140ms ease, bottom 180ms ease',
}}
>
<div
className="shrink-0 touch-none px-4 py-2"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
<div className="w-full flex items-center justify-center" role="presentation">
<div className="relative shrink-0 px-4 py-2">
<div
className="absolute inset-x-0 top-1/2 z-10 h-11 -translate-y-1/2 touch-none"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
/>
<div
className="pointer-events-none flex w-full items-center justify-center"
role="presentation"
>
<span className="h-1.5 w-12 rounded-full bg-warm-300 dark:bg-navy-600" />
</div>
</div>

View file

@ -123,7 +123,7 @@ export function ElectionVoteShareFilterCard({
return (
<div
data-filter-name={ELECTION_VOTE_SHARE_FILTER_NAME}
className={`space-y-2 rounded-lg border border-warm-200 bg-white px-2 py-2 shadow-sm dark:border-warm-700 dark:bg-warm-800 ${
className={`space-y-1.5 px-2 py-1.5 rounded ${
isActive
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
: isPinned

View file

@ -119,7 +119,7 @@ export function EthnicityFilterCard({
return (
<div
data-filter-name={ETHNICITIES_FILTER_NAME}
className={`space-y-2 rounded-lg border border-warm-200 bg-white px-2 py-2 shadow-sm dark:border-warm-700 dark:bg-warm-800 ${
className={`space-y-1.5 px-2 py-1.5 rounded ${
isActive
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
: isPinned

View file

@ -8,6 +8,7 @@ import { getFeatureIcon } from '../../../lib/feature-icons';
import { getGroupIcon } from '../../../lib/group-icons';
import {
POI_DISTANCE_FILTER_NAME,
TRANSPORT_DISTANCE_FILTER_NAME,
clampPoiFilterRange,
getDefaultPoiFilterFeatureName,
getPoiDistanceFeatureName,
@ -119,7 +120,7 @@ export function PoiDistanceFilterCard({
return (
<div
data-filter-name={filterName}
className={`space-y-2 rounded-lg border border-warm-200 bg-white px-2 py-2 shadow-sm dark:border-warm-700 dark:bg-warm-800 ${
className={`space-y-1.5 px-2 py-1.5 rounded ${
isActive
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
: isPinned
@ -192,6 +193,10 @@ export function PoiDistanceFilterCard({
isAtMax={isAtMax}
raw={selectedFeature.raw}
feature={selectedFeature}
showUnit={
filterName === POI_DISTANCE_FILTER_NAME ||
filterName === TRANSPORT_DISTANCE_FILTER_NAME
}
onValueChange={(v) =>
onFilterChange(poiFeature.name, clampPoiFilterRange(v, selectedFeature))
}

View file

@ -119,7 +119,7 @@ export function SpecificCrimeFilterCard({
return (
<div
data-filter-name={SPECIFIC_CRIMES_FILTER_NAME}
className={`space-y-2 rounded-lg border border-warm-200 bg-white px-2 py-2 shadow-sm dark:border-warm-700 dark:bg-warm-800 ${
className={`space-y-1.5 px-2 py-1.5 rounded ${
isActive
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
: isPinned

View file

@ -19,7 +19,7 @@ export function CollapsibleGroupHeader({
return (
<button
onClick={onToggle}
className={`w-full flex items-center justify-between border-b border-warm-300 dark:border-warm-700 ${className}`}
className={`w-full cursor-pointer flex items-center justify-between border-b border-warm-300 dark:border-warm-700 ${className}`}
>
<span>{ts(name)}</span>
<div className="flex items-center gap-1">