This commit is contained in:
Andras Schmelczer 2026-05-11 21:38:26 +01:00
parent 9248e26af2
commit f2a2651b8a
95 changed files with 3993 additions and 1471 deletions

View file

@ -1,12 +1,7 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ts } from '../../i18n/server';
import type {
FeatureFilters,
FeatureMeta,
HexagonStatsResponse,
PostcodeFeature,
} from '../../types';
import type { FeatureFilters, FeatureMeta, HexagonStatsResponse } from '../../types';
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
import type { HexagonLocation } from '../../lib/external-search';
import {
@ -28,7 +23,7 @@ import StackedBarChart from './StackedBarChart';
import StackedEnumChart from './StackedEnumChart';
import PriceHistoryChart from './PriceHistoryChart';
import ExternalSearchLinks from './ExternalSearchLinks';
import { FilterIcon, InfoIcon } from '../ui/icons';
import { InfoIcon } from '../ui/icons';
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { EmptyState } from '../ui/EmptyState';
@ -43,12 +38,12 @@ interface AreaPaneProps {
loading: boolean;
hexagonId: string | null;
isPostcode?: boolean;
postcodeData?: PostcodeFeature | null;
onViewProperties: () => void;
onClearFilters?: () => void;
hexagonLocation: HexagonLocation | null;
filters: FeatureFilters;
unfilteredCount?: number | null;
statsUseFilters: boolean;
onStatsUseFiltersChange: (useFilters: boolean) => void;
onNavigateToSource?: (slug: string, featureName: string) => void;
travelTimeEntries?: TravelTimeEntry[];
isGroupExpanded: (name: string) => boolean;
@ -71,21 +66,24 @@ export default function AreaPane({
loading,
hexagonId,
isPostcode = false,
postcodeData,
onViewProperties,
onClearFilters,
hexagonLocation,
filters,
unfilteredCount,
statsUseFilters,
onStatsUseFiltersChange,
onNavigateToSource,
travelTimeEntries,
isGroupExpanded,
onToggleGroup,
}: AreaPaneProps) {
const { t } = useTranslation();
const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count;
const propertyCount = stats?.count;
const activeFilterCount = Object.keys(filters).length + (travelTimeEntries?.length ?? 0);
const hasFilteredOutArea = activeFilterCount > 0 && stats?.count === 0;
const filtersActive = activeFilterCount > 0;
const filteredStatsEmpty = filtersActive && statsUseFilters && stats?.count === 0;
const showFlipToggleCallout = filteredStatsEmpty && unfilteredCount !== 0;
const canViewProperties = stats && stats.count > 0 && (statsUseFilters || !filtersActive);
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
@ -148,37 +146,66 @@ export default function AreaPane({
</div>
</div>
<div className="flex gap-2 border-l-2 border-teal-500 bg-warm-50 px-2.5 py-2 text-xs leading-snug text-warm-700 dark:bg-navy-900 dark:text-warm-300">
<FilterIcon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-teal-700 dark:text-teal-300" />
<p>
{activeFilterCount > 0
? t('areaPane.filtersAffectStats', { count: activeFilterCount })
<div className="rounded border border-warm-200 bg-warm-50 px-2.5 py-2 dark:border-navy-700 dark:bg-navy-900">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-warm-700 dark:text-warm-200">
{t('areaPane.statsBasis')}
</span>
<div className="inline-flex shrink-0 rounded-md bg-warm-200 p-0.5 dark:bg-navy-800">
<button
type="button"
disabled={!filtersActive}
aria-pressed={statsUseFilters && filtersActive}
onClick={() => onStatsUseFiltersChange(true)}
className={`rounded px-2 py-1 text-xs font-medium ${
statsUseFilters && filtersActive
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
: 'text-warm-600 hover:text-warm-900 disabled:cursor-not-allowed disabled:opacity-50 dark:text-warm-400 dark:hover:text-warm-100'
}`}
>
{t('areaPane.matchingFiltersOption')}
</button>
<button
type="button"
aria-pressed={!statsUseFilters || !filtersActive}
onClick={() => onStatsUseFiltersChange(false)}
className={`rounded px-2 py-1 text-xs font-medium ${
!statsUseFilters || !filtersActive
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
: 'text-warm-600 hover:text-warm-900 dark:text-warm-400 dark:hover:text-warm-100'
}`}
>
{t('areaPane.allPropertiesOption')}
</button>
</div>
</div>
<p className="mt-1.5 text-xs leading-snug text-warm-500 dark:text-warm-400">
{filtersActive
? statsUseFilters
? t('areaPane.filtersAffectStats', { count: activeFilterCount })
: t('areaPane.filtersIgnoredForStats')
: t('areaPane.noFiltersAffectStats')}
</p>
</div>
{hasFilteredOutArea && (
{showFlipToggleCallout && (
<div className="mt-2 rounded border border-amber-200 bg-amber-50 px-2.5 py-2 text-xs leading-snug text-amber-900 dark:border-amber-800/70 dark:bg-amber-950/40 dark:text-amber-100">
<p className="font-semibold">{t('areaPane.noFilteredMatches')}</p>
<p className="font-semibold">{t('areaPane.filteredStatsEmpty')}</p>
<p className="mt-1">
{unfilteredCount != null && unfilteredCount > 0
? t('areaPane.unfilteredAreaCount', { count: unfilteredCount })
: unfilteredCount === 0
? t('areaPane.noUnfilteredAreaProperties')
: t('areaPane.relaxFiltersHint')}
{unfilteredCount != null
? t('areaPane.showAllStatsHint', { count: unfilteredCount })
: t('areaPane.showAllStatsFallback')}
</p>
{onClearFilters && (
<button
type="button"
onClick={onClearFilters}
className="mt-2 rounded bg-amber-600 px-2 py-1 text-xs font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
>
{t('filters.clearAll')}
</button>
)}
<button
type="button"
onClick={() => onStatsUseFiltersChange(false)}
className="mt-2 rounded bg-amber-600 px-2 py-1 text-xs font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
>
{t('areaPane.showAllStats')}
</button>
</div>
)}
{stats && stats.count > 0 && (
{canViewProperties && (
<button
onClick={onViewProperties}
className="w-full text-sm py-1.5 rounded bg-teal-600 hover:bg-teal-700 text-white font-medium"

View file

@ -1,3 +1,5 @@
import { useTranslation } from 'react-i18next';
function downsampleBars(counts: number[], targetBars: number): number[] {
const step = Math.max(1, Math.floor(counts.length / targetBars));
const bars: number[] = [];
@ -34,7 +36,7 @@ export function DualHistogram({
p1,
p99,
globalMean,
meanLabel = 'National avg',
meanLabel,
formatLabel,
}: {
localCounts: number[];
@ -45,6 +47,7 @@ export function DualHistogram({
meanLabel?: string;
formatLabel?: (value: number) => string;
}) {
const { t } = useTranslation();
const targetBars = 25;
const localBars = downsampleBars(localCounts, targetBars);
const globalBars = downsampleBars(globalCounts, targetBars);
@ -124,7 +127,7 @@ export function DualHistogram({
className="absolute top-0 max-w-[7rem] truncate rounded-sm border border-warm-300 bg-white px-1 py-0.5 text-[9px] font-medium leading-none text-warm-600 shadow-sm dark:border-warm-600 dark:bg-navy-900 dark:text-warm-300"
style={meanLabelStyle}
>
{meanLabel}
{meanLabel ?? t('areaPane.nationalAvg')}
</div>
<div className="absolute bottom-0 top-5 w-px border-l border-dashed border-warm-400 dark:border-warm-500" />
</div>

View file

@ -1,3 +1,4 @@
import { ts } from '../../i18n/server';
import { getEnumValueColor } from '../../lib/consts';
export default function EnumBarChart({
@ -46,7 +47,7 @@ export default function EnumBarChart({
return (
<div key={label} className="flex items-center gap-2 text-xs">
<span className="w-16 truncate text-warm-500 dark:text-warm-400 text-right shrink-0">
{label}
{ts(label)}
</span>
<div className="flex-1 h-3 bg-warm-100 dark:bg-navy-700 rounded overflow-hidden relative">
{hasGlobal && (

View file

@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { FeatureFilters } from '../../types';
import {
buildRightmoveExactPostcodeRedirectUrl,
buildPropertySearchUrls,
H3_RADIUS_MILES,
type HexagonLocation,
@ -30,6 +31,11 @@ export default function ExternalSearchLinks({
() => buildPropertySearchUrls({ location, filters, rightmoveLocationId }),
[location, filters, rightmoveLocationId]
);
const rightmoveHref = useMemo(() => {
if (!urls?.rightmove) return null;
if (!location.isPostcode || !location.postcode) return urls.rightmove;
return buildRightmoveExactPostcodeRedirectUrl(location.postcode, urls.rightmove);
}, [location.isPostcode, location.postcode, urls?.rightmove]);
const radiusMiles = location.isPostcode ? 0 : (H3_RADIUS_MILES[location.resolution] ?? 1);
const label = radiusMiles === 0 ? t('externalSearch.exact') : `${radiusMiles}mi radius`;
@ -46,8 +52,8 @@ export default function ExternalSearchLinks({
{t('externalSearch.searchOn', { radius: label })}
</h3>
<div className="flex flex-wrap gap-2">
{urls.rightmove ? (
<a href={urls.rightmove} target="_blank" rel="noopener noreferrer" className={linkClass}>
{rightmoveHref ? (
<a href={rightmoveHref} target="_blank" rel="noopener noreferrer" className={linkClass}>
Rightmove
</a>
) : (

View file

@ -19,6 +19,14 @@ import {
isSpecificCrimeFeatureName,
isSpecificCrimeFilterName,
} from '../../lib/crime-filter';
import {
ELECTION_VOTE_SHARE_FILTER_NAME,
getDefaultElectionVoteShareFeatureName,
getElectionVoteShareFeatureName,
getElectionVoteShareFilterMeta,
isElectionVoteShareFeatureName,
isElectionVoteShareFilterName,
} from '../../lib/election-filter';
import {
ETHNICITIES_FILTER_NAME,
getDefaultEthnicityFeatureName,
@ -148,6 +156,11 @@ export default memo(function Filters({
[features]
);
const specificCrimeMeta = useMemo(() => getSpecificCrimeFilterMeta(features), [features]);
const defaultElectionVoteShareFeatureName = useMemo(
() => getDefaultElectionVoteShareFeatureName(features),
[features]
);
const electionVoteShareMeta = useMemo(() => getElectionVoteShareFilterMeta(features), [features]);
const defaultEthnicityFeatureName = useMemo(
() => getDefaultEthnicityFeatureName(features),
[features]
@ -212,6 +225,17 @@ export default memo(function Filters({
return { ...(backendFeature ?? specificCrimeMeta), name, group: 'Crime' };
});
}, [filters, features, specificCrimeMeta]);
const electionVoteShareFilterItems = useMemo(() => {
return Object.keys(filters)
.filter(isElectionVoteShareFilterName)
.map((name) => {
const backendName = getElectionVoteShareFeatureName(name);
const backendFeature = backendName
? features.find((feature) => feature.name === backendName)
: undefined;
return { ...(backendFeature ?? electionVoteShareMeta), name, group: 'Neighbours' };
});
}, [filters, features, electionVoteShareMeta]);
const ethnicityFilterItems = useMemo(() => {
return Object.keys(filters)
.filter(isEthnicityFilterName)
@ -220,7 +244,7 @@ export default memo(function Filters({
const backendFeature = backendName
? features.find((feature) => feature.name === backendName)
: undefined;
return { ...(backendFeature ?? ethnicityMeta), name, group: 'Demographics' };
return { ...(backendFeature ?? ethnicityMeta), name, group: 'Neighbours' };
});
}, [filters, features, ethnicityMeta]);
const poiDistanceFilterItems = useMemo(() => {
@ -239,6 +263,7 @@ export default memo(function Filters({
const result: FeatureMeta[] = [];
let insertedSchoolFilter = false;
let insertedSpecificCrimeFilter = false;
let insertedElectionVoteShareFilter = false;
let insertedEthnicityFilter = false;
const insertedPoiFilters = new Set<PoiFilterName>();
@ -257,6 +282,13 @@ export default memo(function Filters({
}
continue;
}
if (isElectionVoteShareFeatureName(feature.name)) {
if (defaultElectionVoteShareFeatureName && !insertedElectionVoteShareFilter) {
result.push(electionVoteShareMeta);
insertedElectionVoteShareFilter = true;
}
continue;
}
if (isEthnicityFeatureName(feature.name)) {
if (defaultEthnicityFeatureName && !insertedEthnicityFilter) {
result.push(ethnicityMeta);
@ -287,6 +319,8 @@ export default memo(function Filters({
schoolMeta,
defaultSpecificCrimeFeatureName,
specificCrimeMeta,
defaultElectionVoteShareFeatureName,
electionVoteShareMeta,
defaultEthnicityFeatureName,
ethnicityMeta,
defaultPoiFilterFeatureNames,
@ -296,6 +330,7 @@ export default memo(function Filters({
const result: FeatureMeta[] = [];
let insertedSchoolFilter = false;
let insertedSpecificCrimeFilters = false;
let insertedElectionVoteShareFilters = false;
let insertedEthnicityFilters = false;
let insertedPoiDistanceFilters = false;
@ -314,6 +349,13 @@ export default memo(function Filters({
}
continue;
}
if (isElectionVoteShareFeatureName(feature.name)) {
if (!insertedElectionVoteShareFilters) {
result.push(...electionVoteShareFilterItems);
insertedElectionVoteShareFilters = true;
}
continue;
}
if (isEthnicityFeatureName(feature.name)) {
if (!insertedEthnicityFilters) {
result.push(...ethnicityFilterItems);
@ -337,6 +379,7 @@ export default memo(function Filters({
enabledFeatures,
schoolFilterItems,
specificCrimeFilterItems,
electionVoteShareFilterItems,
ethnicityFilterItems,
poiDistanceFilterItems,
]);
@ -350,6 +393,7 @@ export default memo(function Filters({
const activeEntryCount = travelTimeEntries.length;
const pendingScrollRef = useRef<string | null>(null);
const highlightTimeoutRef = useRef<number | null>(null);
const handleAddAndScroll = useCallback(
(name: string) => {
@ -365,6 +409,12 @@ export default memo(function Filters({
onAddFilter(SPECIFIC_CRIMES_FILTER_NAME);
return;
}
if (name === ELECTION_VOTE_SHARE_FILTER_NAME) {
if (!defaultElectionVoteShareFeatureName) return;
pendingScrollRef.current = ELECTION_VOTE_SHARE_FILTER_NAME;
onAddFilter(ELECTION_VOTE_SHARE_FILTER_NAME);
return;
}
if (name === ETHNICITIES_FILTER_NAME) {
if (!defaultEthnicityFeatureName) return;
pendingScrollRef.current = ETHNICITIES_FILTER_NAME;
@ -385,6 +435,7 @@ export default memo(function Filters({
[
defaultSchoolFeatureName,
defaultSpecificCrimeFeatureName,
defaultElectionVoteShareFeatureName,
defaultEthnicityFeatureName,
defaultPoiFilterFeatureNames,
onAddFilter,
@ -403,10 +454,26 @@ export default memo(function Filters({
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' });
const el = scrollRef.current?.querySelector<HTMLElement>(
`[data-filter-name="${CSS.escape(name)}"]`
);
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
if (highlightTimeoutRef.current !== null) {
window.clearTimeout(highlightTimeoutRef.current);
document.querySelectorAll('.filter-highlight-flash').forEach((node) => {
node.classList.remove('filter-highlight-flash');
});
}
// Restart the animation if the same element is re-highlighted.
el.classList.remove('filter-highlight-flash');
void el.offsetWidth;
el.classList.add('filter-highlight-flash');
highlightTimeoutRef.current = window.setTimeout(() => {
el.classList.remove('filter-highlight-flash');
highlightTimeoutRef.current = null;
}, 2000);
}, [enabledFeatureList, travelTimeEntries]);
const percentileScales = useMemo(() => {
const scales = new Map<string, PercentileScale>();
@ -466,6 +533,7 @@ export default memo(function Filters({
<ActiveFiltersPanel
scrollRef={scrollRef}
collapsed={activeFilterCollapsed}
addFiltersExpanded={!addFilterCollapsed}
badgeCount={badgeCount}
activeEntryCount={activeEntryCount}
features={features}
@ -512,6 +580,7 @@ export default memo(function Filters({
...features,
schoolMeta,
specificCrimeMeta,
electionVoteShareMeta,
ethnicityMeta,
poiDistanceMeta,
poiCount2KmMeta,
@ -520,6 +589,7 @@ export default memo(function Filters({
pinnedFeature={pinnedFeature}
defaultSchoolFeatureName={defaultSchoolFeatureName}
defaultSpecificCrimeFeatureName={defaultSpecificCrimeFeatureName}
defaultElectionVoteShareFeatureName={defaultElectionVoteShareFeatureName}
defaultEthnicityFeatureName={defaultEthnicityFeatureName}
defaultPoiFilterFeatureNames={defaultPoiFilterFeatureNames}
openInfoFeature={openInfoFeature}

View file

@ -5,6 +5,7 @@ import { formatValue } from '../../lib/format';
import { ts } from '../../i18n/server';
import { SCHOOL_FILTER_NAME, getSchoolBackendFeatureName } from '../../lib/school-filter';
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';
@ -47,11 +48,13 @@ export default memo(function HoverCard({
for (const name of activeFilterNames.slice(0, 4)) {
const schoolBackendName = getSchoolBackendFeatureName(name);
const specificCrimeFeatureName = getSpecificCrimeFeatureName(name);
const electionVoteShareFeatureName = getElectionVoteShareFeatureName(name);
const ethnicityFeatureName = getEthnicityFeatureName(name);
const poiDistanceFeatureName = getPoiDistanceFeatureName(name);
const backendName =
schoolBackendName ??
specificCrimeFeatureName ??
electionVoteShareFeatureName ??
ethnicityFeatureName ??
poiDistanceFeatureName ??
name;

View file

@ -458,7 +458,7 @@ export default memo(function Map({
className="font-bold text-white whitespace-nowrap"
style={{ fontSize: '5rem' }}
>
Your perfect postcode
{t('map.ogTitle')}
</span>
</div>
</div>
@ -467,23 +467,23 @@ export default memo(function Map({
<div className="absolute bottom-0 left-0 right-0 flex items-center justify-between px-10 py-4 bg-white">
<div className="flex items-center gap-6">
<span className="text-warm-500 font-medium" style={{ fontSize: '0.95rem' }}>
Property prices
{t('map.ogPropertyPrices')}
</span>
<span className="text-warm-300">|</span>
<span className="text-warm-500 font-medium" style={{ fontSize: '0.95rem' }}>
Energy ratings
{t('map.ogEnergyRatings')}
</span>
<span className="text-warm-300">|</span>
<span className="text-warm-500 font-medium" style={{ fontSize: '0.95rem' }}>
Schools
{t('map.ogSchools')}
</span>
<span className="text-warm-300">|</span>
<span className="text-warm-500 font-medium" style={{ fontSize: '0.95rem' }}>
Crime stats
{t('map.ogCrimeStats')}
</span>
<span className="text-warm-300">|</span>
<span className="text-warm-500 font-medium" style={{ fontSize: '0.95rem' }}>
Transport
{t('map.ogTransport')}
</span>
</div>
<span className="text-teal-600 font-semibold" style={{ fontSize: '1rem' }}>
@ -583,7 +583,9 @@ export default memo(function Map({
<div className="text-lg font-bold text-teal-600 dark:text-teal-400">
{popupInfo.clusterCount}
</div>
<div className="text-warm-500 dark:text-warm-400 text-xs">places</div>
<div className="text-warm-500 dark:text-warm-400 text-xs">
{t('common.places')}
</div>
</div>
) : (
<div className="px-3 py-2">
@ -610,7 +612,7 @@ export default memo(function Map({
backgroundColor: `rgb(${getPoiGroupColor(popupInfo.group).join(',')})`,
}}
/>
{popupInfo.category}
{ts(popupInfo.category)}
</div>
</div>
</div>

View file

@ -271,6 +271,8 @@ export default function MapPage({
areaStats,
loadingAreaStats,
unfilteredAreaCount,
areaStatsUseFilters,
setAreaStatsUseFilters,
hoveredHexagon,
rightPaneTab,
setRightPaneTab,
@ -456,18 +458,12 @@ export default function MapPage({
loading={loadingAreaStats}
hexagonId={selectedHexagon?.id || null}
isPostcode={selectedHexagon?.type === 'postcode'}
postcodeData={
selectedHexagon?.type === 'postcode'
? mapData.postcodeData.find(
(feature) => feature.properties.postcode === selectedHexagon?.id
) || null
: null
}
onViewProperties={handleViewPropertiesFromArea}
onClearFilters={hasActiveFilters ? handleClearAll : undefined}
hexagonLocation={hexagonLocation}
filters={filters}
unfilteredCount={unfilteredAreaCount}
statsUseFilters={areaStatsUseFilters}
onStatsUseFiltersChange={setAreaStatsUseFilters}
travelTimeEntries={activeEntries}
isGroupExpanded={isAreaGroupExpanded}
onToggleGroup={toggleAreaGroup}

View file

@ -1,4 +1,5 @@
import type { PointerEvent, ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { TabButton } from '../ui/TabButton';
import { CloseIcon } from '../ui/icons/CloseIcon';
@ -30,6 +31,8 @@ export function MapPageSelectionPane({
renderAreaPane,
renderPropertiesPane,
}: MapPageSelectionPaneProps) {
const { t } = useTranslation();
return (
<div
data-tutorial="right-pane"
@ -48,16 +51,16 @@ export function MapPageSelectionPane({
</div>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
<TabButton label="Area" isActive={tab === 'area'} onClick={onAreaTabClick} />
<TabButton label={t('common.area')} isActive={tab === 'area'} onClick={onAreaTabClick} />
<TabButton
label="Properties"
label={t('common.properties')}
isActive={tab === 'properties'}
onClick={onPropertiesTabClick}
/>
<button
onClick={onClose}
className="px-2 flex items-center text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
title="Close pane"
title={t('common.closePane')}
>
<CloseIcon className="w-4 h-4" />
</button>

View file

@ -1,4 +1,6 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ts } from '../../i18n/server';
import { formatValue, roundedPercentages } from '../../lib/format';
interface Segment {
@ -27,6 +29,7 @@ function shortenLabel(name: string): string {
}
export default function StackedBarChart({ segments, total, colorMap }: StackedBarChartProps) {
const { t } = useTranslation();
const sortedSegments = useMemo(() => [...segments].sort((a, b) => b.value - a.value), [segments]);
const roundedPcts = useMemo(
() =>
@ -39,7 +42,9 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa
);
if (total === 0) {
return <div className="text-xs text-warm-400 dark:text-warm-500 italic">No data</div>;
return (
<div className="text-xs text-warm-400 dark:text-warm-500 italic">{t('common.noData')}</div>
);
}
const colorFor = (segmentName: string): string => {
@ -57,6 +62,7 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa
{sortedSegments.map((segment, i) => {
const pct = (segment.value / total) * 100;
if (pct < 0.5) return null;
const label = shortenLabel(ts(segment.name));
return (
<div
key={segment.name}
@ -65,7 +71,7 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa
width: `${pct}%`,
backgroundColor: colorFor(segment.name),
}}
title={`${shortenLabel(segment.name)}: ${formatValue(segment.value)} (${roundedPcts[i].toFixed(1)}%)`}
title={`${label}: ${formatValue(segment.value)} (${roundedPcts[i].toFixed(1)}%)`}
/>
);
})}
@ -73,22 +79,23 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa
{/* Legend */}
<div className="flex flex-wrap gap-x-3 gap-y-0.5">
{sortedSegments.map((segment) => (
<div key={segment.name} className="flex items-center gap-1">
<span
className="w-2 h-2 rounded-sm shrink-0"
style={{
backgroundColor: colorFor(segment.name),
}}
/>
<span className="text-[10px] text-warm-600 dark:text-warm-400">
{shortenLabel(segment.name)}
</span>
<span className="text-[10px] text-warm-400 dark:text-warm-500">
{formatValue(segment.value)}
</span>
</div>
))}
{sortedSegments.map((segment) => {
const label = shortenLabel(ts(segment.name));
return (
<div key={segment.name} className="flex items-center gap-1">
<span
className="w-2 h-2 rounded-sm shrink-0"
style={{
backgroundColor: colorFor(segment.name),
}}
/>
<span className="text-[10px] text-warm-600 dark:text-warm-400">{label}</span>
<span className="text-[10px] text-warm-400 dark:text-warm-500">
{formatValue(segment.value)}
</span>
</div>
);
})}
</div>
</div>
);

View file

@ -1,3 +1,5 @@
import { useTranslation } from 'react-i18next';
import { ts } from '../../i18n/server';
import type { EnumFeatureStats } from '../../types';
import { roundedPercentages } from '../../lib/format';
@ -17,6 +19,7 @@ export default function StackedEnumChart({
valueOrder,
valueColors,
}: StackedEnumChartProps) {
const { t } = useTranslation();
const visibleRows = components.filter(({ stats }) => {
const total = Object.values(stats.counts).reduce((a, b) => a + b, 0);
if (total === 0) return false;
@ -25,7 +28,11 @@ export default function StackedEnumChart({
});
if (visibleRows.length === 0) {
return <div className="text-xs text-warm-400 dark:text-warm-500 italic mt-1">All low</div>;
return (
<div className="text-xs text-warm-400 dark:text-warm-500 italic mt-1">
{t('common.allLow')}
</div>
);
}
return (
@ -38,7 +45,7 @@ export default function StackedEnumChart({
return (
<div key={label} className="flex items-center gap-2 text-xs">
<span className="w-24 truncate text-warm-500 dark:text-warm-400 text-right shrink-0">
{shortenLabel(label)}
{shortenLabel(ts(label))}
</span>
<div className="flex-1 flex h-3.5 rounded overflow-hidden bg-warm-200 dark:bg-warm-700">
{valueOrder.map((value, i) => {
@ -53,7 +60,7 @@ export default function StackedEnumChart({
width: `${pct}%`,
backgroundColor: valueColors[i],
}}
title={`${value}: ${count} (${roundedPcts[i]}%)`}
title={`${ts(value)}: ${count} (${roundedPcts[i]}%)`}
/>
);
})}
@ -70,7 +77,7 @@ export default function StackedEnumChart({
className="w-2 h-2 rounded-sm shrink-0"
style={{ backgroundColor: valueColors[i] }}
/>
<span className="text-[10px] text-warm-600 dark:text-warm-400">{value}</span>
<span className="text-[10px] text-warm-600 dark:text-warm-400">{ts(value)}</span>
</div>
))}
</div>

View file

@ -166,7 +166,7 @@ export function TravelTimeCard({
</div>
{filterImpact != null && filterImpact > 0 && (
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">
+{formatNumber(filterImpact)} without this filter
{t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
</p>
)}
</div>

View file

@ -3,6 +3,10 @@ import { Fragment } from 'react';
import type { FeatureFilters, FeatureMeta } from '../../../types';
import type { PercentileScale } from '../../../lib/format';
import { getSpecificCrimeFeatureName, isSpecificCrimeFilterName } from '../../../lib/crime-filter';
import {
getElectionVoteShareFeatureName,
isElectionVoteShareFilterName,
} from '../../../lib/election-filter';
import { getEthnicityFeatureName, isEthnicityFilterName } from '../../../lib/ethnicity-filter';
import { getSchoolBackendFeatureName, isSchoolFilterName } from '../../../lib/school-filter';
import {
@ -14,6 +18,7 @@ import { EthnicityFilterCard } from './EthnicityFilterCard';
import { PoiDistanceFilterCard } from './PoiDistanceFilterCard';
import { SchoolFilterCard } from './SchoolFilterCard';
import { SpecificCrimeFilterCard } from './SpecificCrimeFilterCard';
import { ElectionVoteShareFilterCard } from './ElectionVoteShareFilterCard';
import { EnumFeatureFilterCard } from './EnumFeatureFilterCard';
import { NumericFeatureFilterCard } from './NumericFeatureFilterCard';
import { TravelTimeFilterCards } from './TravelTimeFilterCards';
@ -156,6 +161,40 @@ export function ActiveFilterList({
);
}
if (isElectionVoteShareFilterName(feature.name)) {
const electionVoteShareBackendName = getElectionVoteShareFeatureName(feature.name);
return (
<Fragment key={feature.name}>
{insertTravelCards && travelCards}
<ElectionVoteShareFilterCard
features={features}
voteShareFeature={feature}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
pinnedFeature={pinnedFeature}
filterImpact={
electionVoteShareBackendName
? filterImpacts?.[electionVoteShareBackendName]
: undefined
}
percentileScale={
electionVoteShareBackendName
? percentileScales.get(electionVoteShareBackendName)
: undefined
}
onFilterChange={onFilterChange}
onDragStart={onDragStart}
onDragChange={onDragChange}
onDragEnd={onDragEnd}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onRemove={() => onRemoveFilter(feature.name)}
/>
</Fragment>
);
}
if (isEthnicityFilterName(feature.name)) {
const ethnicityBackendName = getEthnicityFeatureName(feature.name);
return (

View file

@ -12,6 +12,7 @@ import { ActiveFilterList } from './ActiveFilterList';
interface ActiveFiltersPanelProps {
scrollRef: RefObject<HTMLDivElement | null>;
collapsed: boolean;
addFiltersExpanded: boolean;
badgeCount: number;
activeEntryCount: number;
features: FeatureMeta[];
@ -59,6 +60,7 @@ interface ActiveFiltersPanelProps {
export function ActiveFiltersPanel({
scrollRef,
collapsed,
addFiltersExpanded,
badgeCount,
activeEntryCount,
features,
@ -102,7 +104,7 @@ export function ActiveFiltersPanel({
<div
className={`flex flex-col md:min-h-0 ${
collapsed ? 'md:[flex:0_0_auto]' : 'md:[flex:0_1_auto]'
}`}
} ${!collapsed && addFiltersExpanded ? 'md:max-h-[60%]' : ''}`}
>
<button
onClick={onToggleCollapsed}
@ -146,7 +148,10 @@ export function ActiveFiltersPanel({
</button>
{!collapsed && (
<div ref={scrollRef} className="md:min-h-0 md:overflow-y-auto overflow-x-hidden">
<div
ref={scrollRef}
className="md:min-h-0 md:flex-1 md:overflow-y-auto overflow-x-hidden"
>
<AiFilterInput
loading={aiFilterLoading}
error={aiFilterError}

View file

@ -5,6 +5,10 @@ import type { FeatureMeta } from '../../../types';
import { ChevronIcon } from '../../ui/icons';
import FeatureBrowser from '../FeatureBrowser';
import { SPECIFIC_CRIMES_FILTER_NAME, isSpecificCrimeFilterName } from '../../../lib/crime-filter';
import {
ELECTION_VOTE_SHARE_FILTER_NAME,
isElectionVoteShareFilterName,
} from '../../../lib/election-filter';
import { ETHNICITIES_FILTER_NAME, isEthnicityFilterName } from '../../../lib/ethnicity-filter';
import { SCHOOL_FILTER_NAME, isSchoolFilterName } from '../../../lib/school-filter';
import {
@ -23,6 +27,7 @@ interface AddFilterPanelProps {
pinnedFeature: string | null;
defaultSchoolFeatureName: string | null;
defaultSpecificCrimeFeatureName: string | null;
defaultElectionVoteShareFeatureName: string | null;
defaultEthnicityFeatureName: string | null;
defaultPoiFilterFeatureNames: Record<PoiFilterName, string | null>;
openInfoFeature?: string | null;
@ -44,6 +49,7 @@ export function AddFilterPanel({
pinnedFeature,
defaultSchoolFeatureName,
defaultSpecificCrimeFeatureName,
defaultElectionVoteShareFeatureName,
defaultEthnicityFeatureName,
defaultPoiFilterFeatureNames,
openInfoFeature,
@ -63,11 +69,13 @@ export function AddFilterPanel({
? SCHOOL_FILTER_NAME
: pinnedFeature && isSpecificCrimeFilterName(pinnedFeature)
? SPECIFIC_CRIMES_FILTER_NAME
: pinnedFeature && isEthnicityFilterName(pinnedFeature)
? ETHNICITIES_FILTER_NAME
: pinnedFeature && isPoiDistanceFilterName(pinnedFeature)
? (getPoiFilterName(pinnedFeature) ?? POI_DISTANCE_FILTER_NAME)
: pinnedFeature;
: pinnedFeature && isElectionVoteShareFilterName(pinnedFeature)
? ELECTION_VOTE_SHARE_FILTER_NAME
: pinnedFeature && isEthnicityFilterName(pinnedFeature)
? ETHNICITIES_FILTER_NAME
: pinnedFeature && isPoiDistanceFilterName(pinnedFeature)
? (getPoiFilterName(pinnedFeature) ?? POI_DISTANCE_FILTER_NAME)
: pinnedFeature;
const handleTogglePin = (name: string) => {
if (name === SCHOOL_FILTER_NAME) {
@ -78,6 +86,10 @@ export function AddFilterPanel({
if (defaultSpecificCrimeFeatureName) onTogglePin(defaultSpecificCrimeFeatureName);
return;
}
if (name === ELECTION_VOTE_SHARE_FILTER_NAME) {
if (defaultElectionVoteShareFeatureName) onTogglePin(defaultElectionVoteShareFeatureName);
return;
}
if (name === ETHNICITIES_FILTER_NAME) {
if (defaultEthnicityFeatureName) onTogglePin(defaultEthnicityFeatureName);
return;

View file

@ -0,0 +1,229 @@
import { useTranslation } from 'react-i18next';
import { ts } from '../../../i18n/server';
import { Slider } from '../../ui/Slider';
import { ChevronIcon } from '../../ui/icons';
import { FeatureActions } from '../../ui/FeatureIcons';
import { FeatureLabel } from '../../ui/FeatureLabel';
import type { FeatureFilters, FeatureMeta } from '../../../types';
import { formatNumber, type PercentileScale } from '../../../lib/format';
import { getFeatureIcon } from '../../../lib/feature-icons';
import { getGroupIcon } from '../../../lib/group-icons';
import {
ELECTION_VOTE_SHARE_FEATURE_NAMES,
ELECTION_VOTE_SHARE_FILTER_NAME,
clampElectionVoteShareRange,
getDefaultElectionVoteShareFeatureName,
getElectionVoteShareFeatureName,
getElectionVoteShareFilterMeta,
replaceElectionVoteShareFilterKeySelection,
} from '../../../lib/election-filter';
import { SliderLabels } from './SliderLabels';
export function ElectionVoteShareFilterCard({
features,
voteShareFeature,
filters,
activeFeature,
dragValue,
pinnedFeature,
filterImpact,
percentileScale,
onFilterChange,
onDragStart,
onDragChange,
onDragEnd,
onTogglePin,
onShowInfo,
onRemove,
}: {
features: FeatureMeta[];
voteShareFeature: FeatureMeta;
filters: FeatureFilters;
activeFeature: string | null;
dragValue: [number, number] | null;
pinnedFeature: string | null;
filterImpact?: number;
percentileScale?: PercentileScale;
onFilterChange: (name: string, value: [number, number] | string[]) => void;
onDragStart: (name: string) => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
onTogglePin: (name: string) => void;
onShowInfo: (feature: FeatureMeta) => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
const voteShareMeta = getElectionVoteShareFilterMeta(features);
const voteShareOptions = ELECTION_VOTE_SHARE_FEATURE_NAMES.map((name) =>
features.find((feature) => feature.name === name)
).filter((feature): feature is FeatureMeta => Boolean(feature));
const selectedFeatureName =
getElectionVoteShareFeatureName(voteShareFeature.name) ??
getDefaultElectionVoteShareFeatureName(features);
const selectedFeature = selectedFeatureName
? features.find((feature) => feature.name === selectedFeatureName)
: undefined;
if (!selectedFeature || voteShareOptions.length === 0 || !selectedFeatureName) return null;
const isActive = activeFeature === voteShareFeature.name;
const isPinned = pinnedFeature === voteShareFeature.name;
const hist = selectedFeature.histogram;
const dataMin = hist?.min ?? selectedFeature.min ?? 0;
const dataMax = hist?.max ?? selectedFeature.max ?? 100;
const displayValue =
isActive && dragValue
? dragValue
: (filters[voteShareFeature.name] as [number, number]) || [dataMin, dataMax];
const scale = percentileScale;
const clampMin = displayValue[0] <= dataMin;
const clampMax = displayValue[1] >= dataMax;
const isAtMin = displayValue[0] === dataMin;
const isAtMax = displayValue[1] === dataMax;
const sliderValue: [number, number] = scale
? [
clampMin ? 0 : Math.round(scale.toPercentile(displayValue[0])),
clampMax ? 100 : Math.round(scale.toPercentile(displayValue[1])),
]
: [
clampMin ? (selectedFeature.min ?? dataMin) : displayValue[0],
clampMax ? (selectedFeature.max ?? dataMax) : displayValue[1],
];
const replaceVoteShareFeature = (nextFeatureName: string) => {
const nextName = replaceElectionVoteShareFilterKeySelection(
voteShareFeature.name,
nextFeatureName
);
if (nextName === voteShareFeature.name) return;
const nextFeature = features.find((feature) => feature.name === nextFeatureName);
const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0;
const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax);
const nextRange = clampElectionVoteShareRange(
[
displayValue[0] <= dataMin ? nextDataMin : displayValue[0],
displayValue[1] >= dataMax ? nextDataMax : displayValue[1],
],
nextFeature
);
onFilterChange(nextName, nextRange);
if (isPinned) onTogglePin(nextName);
};
const mobileIconClass = 'w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0';
const mobileIcon =
getFeatureIcon(selectedFeature.name, mobileIconClass) ||
(() => {
const G = selectedFeature.group ? getGroupIcon(selectedFeature.group) : null;
return G ? <G className={mobileIconClass} /> : null;
})();
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 ${
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="relative z-10 flex items-center justify-between gap-1">
<FeatureLabel
feature={voteShareMeta}
size="sm"
className="min-w-0 shrink"
hideIconOnMobile
/>
<FeatureActions
feature={selectedFeature}
actionName={voteShareFeature.name}
isPinned={isPinned}
isPreviewing={isActive}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onRemove={onRemove}
/>
</div>
<div>
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
{t('filters.party')}
</label>
<div className="relative">
<select
value={selectedFeatureName}
onChange={(e) => replaceVoteShareFeature(e.target.value)}
className="w-full appearance-none rounded-md border border-warm-200 bg-warm-50 px-2 py-1.5 pr-8 text-sm font-medium text-navy-950 shadow-inner outline-none transition-colors hover:bg-white focus:border-teal-400 focus:ring-2 focus:ring-teal-200 dark:border-warm-700 dark:bg-navy-900 dark:text-warm-100 dark:hover:bg-navy-800 dark:focus:ring-teal-900/50"
>
{voteShareOptions.map((option) => (
<option key={option.name} value={option.name}>
{ts(option.name)}
</option>
))}
</select>
<ChevronIcon
direction="down"
className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-warm-400 dark:text-warm-500"
/>
</div>
</div>
<div className="flex items-start gap-1.5 md:block">
{mobileIcon && <div className="shrink-0 pt-0.5 md:hidden">{mobileIcon}</div>}
<div className="min-w-0 flex-1">
<Slider
min={scale ? 0 : (selectedFeature.min ?? dataMin)}
max={scale ? 100 : (selectedFeature.max ?? dataMax)}
step={
scale
? 1
: (selectedFeature.step ??
((selectedFeature.max ?? dataMax) - (selectedFeature.min ?? dataMin)) / 100)
}
value={sliderValue}
onValueChange={
scale
? ([pMin, pMax]) => {
const step = selectedFeature.step ?? 1;
const snap = (v: number) => Math.round(v / step) * step;
onDragChange([
pMin <= 0 ? dataMin : snap(scale.toValue(pMin)),
pMax >= 100 ? dataMax : snap(scale.toValue(pMax)),
]);
}
: ([min, max]) =>
onDragChange([
min <= (selectedFeature.min ?? dataMin) ? dataMin : min,
max >= (selectedFeature.max ?? dataMax) ? dataMax : max,
])
}
onPointerDown={() => onDragStart(voteShareFeature.name)}
onPointerUp={() => onDragEnd()}
/>
<SliderLabels
min={scale ? 0 : (selectedFeature.min ?? dataMin)}
max={scale ? 100 : (selectedFeature.max ?? dataMax)}
value={sliderValue}
displayValues={displayValue}
isAtMin={isAtMin}
isAtMax={isAtMax}
raw={selectedFeature.raw}
feature={selectedFeature}
onValueChange={(v) =>
onFilterChange(voteShareFeature.name, clampElectionVoteShareRange(v, selectedFeature))
}
/>
{filterImpact != null && filterImpact > 0 && (
<p className="-mt-1 ml-2.5 text-[10px] text-warm-400 dark:text-warm-500">
{t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
</p>
)}
</div>
</div>
</div>
);
}

View file

@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { ts } from '../../../i18n/server';
import type { FeatureFilters, FeatureMeta } from '../../../types';
import { formatNumber } from '../../../lib/format';
@ -27,6 +28,7 @@ export function EnumFeatureFilterCard({
onShowInfo,
onRemoveFilter,
}: EnumFeatureFilterCardProps) {
const { t } = useTranslation();
const selectedValues = (filters[feature.name] as string[]) || [];
const allValues = feature.values || [];
@ -63,7 +65,7 @@ export function EnumFeatureFilterCard({
</PillGroup>
{filterImpact != null && filterImpact > 0 && (
<p className="text-[10px] text-warm-400 dark:text-warm-500 mt-0.5">
+{formatNumber(filterImpact)} without this filter
{t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
</p>
)}
</div>

View file

@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { ts } from '../../../i18n/server';
import { Slider } from '../../ui/Slider';
import { ChevronIcon } from '../../ui/icons';
@ -51,6 +52,7 @@ export function EthnicityFilterCard({
onShowInfo: (feature: FeatureMeta) => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
const ethnicityMeta = getEthnicityFilterMeta(features);
const ethnicityOptions = ETHNICITY_FEATURE_NAMES.map((name) =>
features.find((feature) => feature.name === name)
@ -145,7 +147,7 @@ export function EthnicityFilterCard({
<div>
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
Ethnicity
{t('filters.ethnicity')}
</label>
<div className="relative">
<select
@ -213,7 +215,7 @@ export function EthnicityFilterCard({
/>
{filterImpact != null && filterImpact > 0 && (
<p className="-mt-1 ml-2.5 text-[10px] text-warm-400 dark:text-warm-500">
+{formatNumber(filterImpact)} without this filter
{t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
</p>
)}
</div>

View file

@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import type { FeatureFilters, FeatureMeta } from '../../../types';
import { formatNumber, type PercentileScale } from '../../../lib/format';
import { getFeatureIcon } from '../../../lib/feature-icons';
@ -40,6 +41,7 @@ export function NumericFeatureFilterCard({
onShowInfo,
onRemoveFilter,
}: NumericFeatureFilterCardProps) {
const { t } = useTranslation();
const isActive = activeFeature === feature.name;
const isPinned = pinnedFeature === feature.name;
const hist = feature.histogram;
@ -128,7 +130,7 @@ export function NumericFeatureFilterCard({
/>
{filterImpact != null && filterImpact > 0 && (
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">
+{formatNumber(filterImpact)} without this filter
{t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
</p>
)}
</div>

View file

@ -1,6 +1,5 @@
import { ts } from '../../../i18n/server';
import { useTranslation } from 'react-i18next';
import { Slider } from '../../ui/Slider';
import { ChevronIcon } from '../../ui/icons';
import { FeatureActions } from '../../ui/FeatureIcons';
import { FeatureLabel } from '../../ui/FeatureLabel';
import type { FeatureFilters, FeatureMeta } from '../../../types';
@ -11,13 +10,13 @@ import {
POI_DISTANCE_FILTER_NAME,
clampPoiFilterRange,
getDefaultPoiFilterFeatureName,
getPoiFeatureCategory,
getPoiDistanceFeatureName,
getPoiFilterFeatureOptions,
getPoiFilterMeta,
getPoiFilterName,
replacePoiFilterKeySelection,
} from '../../../lib/poi-distance-filter';
import { PoiTypeDropdown } from './PoiTypeDropdown';
import { SliderLabels } from './SliderLabels';
export function PoiDistanceFilterCard({
@ -53,6 +52,7 @@ export function PoiDistanceFilterCard({
onShowInfo: (feature: FeatureMeta) => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
const filterName = getPoiFilterName(poiFeature.name) ?? POI_DISTANCE_FILTER_NAME;
const poiMeta = getPoiFilterMeta(features, filterName);
const poiOptions = getPoiFilterFeatureOptions(features, filterName);
@ -142,25 +142,13 @@ export function PoiDistanceFilterCard({
<div>
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
POI type
{t('filters.poiType')}
</label>
<div className="relative">
<select
value={selectedFeatureName}
onChange={(e) => replacePoiFeature(e.target.value)}
className="w-full appearance-none rounded-md border border-warm-200 bg-warm-50 px-2 py-1.5 pr-8 text-sm font-medium text-navy-950 shadow-inner outline-none transition-colors hover:bg-white focus:border-teal-400 focus:ring-2 focus:ring-teal-200 dark:border-warm-700 dark:bg-navy-900 dark:text-warm-100 dark:hover:bg-navy-800 dark:focus:ring-teal-900/50"
>
{poiOptions.map((option) => (
<option key={option.name} value={option.name}>
{ts(getPoiFeatureCategory(option.name) ?? option.name)}
</option>
))}
</select>
<ChevronIcon
direction="down"
className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-warm-400 dark:text-warm-500"
/>
</div>
<PoiTypeDropdown
options={poiOptions}
value={selectedFeatureName}
onChange={replacePoiFeature}
/>
</div>
<div className="flex items-start gap-1.5 md:block">
@ -210,7 +198,7 @@ export function PoiDistanceFilterCard({
/>
{filterImpact != null && filterImpact > 0 && (
<p className="-mt-1 ml-2.5 text-[10px] text-warm-400 dark:text-warm-500">
+{formatNumber(filterImpact)} without this filter
{t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
</p>
)}
</div>

View file

@ -0,0 +1,200 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { createPortal } from 'react-dom';
import { ts } from '../../../i18n/server';
import { dropdownPositionStyle, useDropdownPosition } from '../../../hooks/useDropdownPosition';
import { ChevronIcon } from '../../ui/icons/ChevronIcon';
import { SearchIcon } from '../../ui/icons/SearchIcon';
import { getFeatureIcon } from '../../../lib/feature-icons';
import { getGroupIcon } from '../../../lib/group-icons';
import { getPoiFeatureCategory } from '../../../lib/poi-distance-filter';
import type { FeatureMeta } from '../../../types';
interface PoiTypeDropdownProps {
options: FeatureMeta[];
value: string;
onChange: (featureName: string) => void;
}
function optionLabel(option: FeatureMeta): string {
return ts(getPoiFeatureCategory(option.name) ?? option.name);
}
function optionIcon(option: FeatureMeta, className: string) {
const icon = getFeatureIcon(option.name, className);
if (icon) return icon;
const G = option.group ? getGroupIcon(option.group) : null;
return G ? <G className={className} /> : null;
}
export function PoiTypeDropdown({ options, value, onChange }: PoiTypeDropdownProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState('');
const [activeIndex, setActiveIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const pos = useDropdownPosition(containerRef, open);
const selected = options.find((option) => option.name === value);
const filtered = useMemo(() => {
if (!filter.trim()) return options;
const lower = filter.trim().toLowerCase();
return options.filter((option) => optionLabel(option).toLowerCase().includes(lower));
}, [options, filter]);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node) &&
!dropdownRef.current?.contains(e.target as Node)
) {
setOpen(false);
setFilter('');
setActiveIndex(-1);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
useEffect(() => {
if (activeIndex < 0 || !listRef.current) return;
const item = listRef.current.children[activeIndex] as HTMLElement | undefined;
item?.scrollIntoView({ block: 'nearest' });
}, [activeIndex]);
const handleSelect = useCallback(
(option: FeatureMeta) => {
onChange(option.name);
setOpen(false);
setFilter('');
setActiveIndex(-1);
},
[onChange]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : prev));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((prev) => (prev > 0 ? prev - 1 : -1));
} else if (e.key === 'Enter') {
e.preventDefault();
if (activeIndex >= 0 && activeIndex < filtered.length) {
handleSelect(filtered[activeIndex]);
}
} else if (e.key === 'Escape') {
setOpen(false);
setFilter('');
setActiveIndex(-1);
}
},
[filtered, activeIndex, handleSelect]
);
const handleOpen = () => {
setOpen(true);
setFilter('');
setActiveIndex(-1);
requestAnimationFrame(() => inputRef.current?.focus({ preventScroll: true }));
};
const dropdown = open && (
<div
ref={dropdownRef}
className="flex flex-col overflow-hidden rounded-md border border-warm-200 bg-white shadow-lg dark:border-warm-700 dark:bg-warm-800"
style={pos ? dropdownPositionStyle(pos) : undefined}
>
<div className="shrink-0 border-b border-warm-100 p-1.5 dark:border-warm-700">
<div className="relative">
<SearchIcon className="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-warm-400 dark:text-warm-500" />
<input
ref={inputRef}
type="text"
value={filter}
onChange={(e) => {
setFilter(e.target.value);
setActiveIndex(-1);
}}
onKeyDown={handleKeyDown}
placeholder={t('poiPane.searchCategories')}
className="w-full rounded border border-warm-200 bg-warm-50 py-1 pl-7 pr-2 text-xs text-navy-950 outline-none placeholder:text-warm-400 focus:ring-1 focus:ring-teal-400 dark:border-warm-600 dark:bg-warm-900 dark:text-warm-200 dark:placeholder:text-warm-500"
/>
</div>
</div>
<div ref={listRef} className="min-h-0 flex-1 overflow-y-auto">
{filtered.length === 0 ? (
<div className="px-2 py-3 text-center text-xs text-warm-400 dark:text-warm-500">
{t('filters.noMatchingFeatures')}
</div>
) : (
filtered.map((option, idx) => {
const isSelected = option.name === value;
const isActive = idx === activeIndex;
return (
<button
key={option.name}
type="button"
className={`flex w-full cursor-pointer items-center gap-1.5 px-2 py-1.5 text-left text-sm ${
isActive
? 'bg-teal-50 dark:bg-teal-900/30'
: isSelected
? 'bg-warm-50 dark:bg-warm-700/50'
: 'hover:bg-warm-50 dark:hover:bg-warm-700'
}`}
onMouseEnter={() => setActiveIndex(idx)}
onMouseDown={(e) => {
e.preventDefault();
handleSelect(option);
}}
>
{optionIcon(option, 'h-3.5 w-3.5 shrink-0 text-teal-600 dark:text-teal-400')}
<span
className={`truncate ${
isSelected
? 'font-medium text-navy-950 dark:text-warm-100'
: 'text-warm-700 dark:text-warm-200'
}`}
>
{optionLabel(option)}
</span>
</button>
);
})
)}
</div>
</div>
);
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => (open ? setOpen(false) : handleOpen())}
className="flex w-full items-center gap-1.5 rounded-md border border-warm-200 bg-warm-50 px-2 py-1.5 pr-8 text-left text-sm font-medium text-navy-950 shadow-inner outline-none transition-colors hover:bg-white focus:border-teal-400 focus:ring-2 focus:ring-teal-200 dark:border-warm-700 dark:bg-navy-900 dark:text-warm-100 dark:hover:bg-navy-800 dark:focus:ring-teal-900/50"
>
{selected &&
optionIcon(selected, 'h-4 w-4 shrink-0 text-teal-600 dark:text-teal-400')}
<span className="min-w-0 flex-1 truncate">
{selected ? optionLabel(selected) : ''}
</span>
<ChevronIcon
direction={open ? 'up' : 'down'}
className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-warm-400 dark:text-warm-500"
/>
</button>
{open && createPortal(dropdown, document.body)}
</div>
);
}

View file

@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { Slider } from '../../ui/Slider';
import { FeatureActions } from '../../ui/FeatureIcons';
import { FeatureLabel } from '../../ui/FeatureLabel';
@ -47,6 +48,7 @@ export function SchoolFilterCard({
onShowInfo: (feature: FeatureMeta) => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
const config = getSchoolFilterConfig(schoolFeature.name);
const schoolMeta = getSchoolFilterMeta(features);
const backendFeature = config
@ -124,9 +126,9 @@ export function SchoolFilterCard({
<div className="space-y-1.5">
<div>
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
School type
{t('filters.schoolType')}
</div>
<div className={segmentedClass} role="radiogroup" aria-label="School type">
<div className={segmentedClass} role="radiogroup" aria-label={t('filters.schoolType')}>
<button
type="button"
role="radio"
@ -134,7 +136,7 @@ export function SchoolFilterCard({
onClick={() => replaceSchoolFeature({ phase: 'primary' })}
className={optionClass(config.phase === 'primary')}
>
Primary
{t('filters.primary')}
</button>
<button
type="button"
@ -143,15 +145,15 @@ export function SchoolFilterCard({
onClick={() => replaceSchoolFeature({ phase: 'secondary' })}
className={optionClass(config.phase === 'secondary')}
>
Secondary
{t('filters.secondary')}
</button>
</div>
</div>
<div>
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
Rating
{t('filters.rating')}
</div>
<div className={segmentedClass} role="radiogroup" aria-label="School rating">
<div className={segmentedClass} role="radiogroup" aria-label={t('filters.schoolRating')}>
<button
type="button"
role="radio"
@ -159,7 +161,7 @@ export function SchoolFilterCard({
onClick={() => replaceSchoolFeature({ rating: 'good' })}
className={optionClass(config.rating === 'good')}
>
Good+
{t('filters.goodPlus')}
</button>
<button
type="button"
@ -168,15 +170,19 @@ export function SchoolFilterCard({
onClick={() => replaceSchoolFeature({ rating: 'outstanding' })}
className={optionClass(config.rating === 'outstanding')}
>
Outstanding
{t('filters.outstanding')}
</button>
</div>
</div>
<div>
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
Distance
{t('filters.distance')}
</div>
<div className={segmentedClass} role="radiogroup" aria-label="School distance">
<div
className={segmentedClass}
role="radiogroup"
aria-label={t('filters.schoolDistance')}
>
<button
type="button"
role="radio"
@ -226,7 +232,7 @@ export function SchoolFilterCard({
/>
{filterImpact != null && filterImpact > 0 && (
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">
+{formatNumber(filterImpact)} without this filter
{t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
</p>
)}
</div>

View file

@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import type React from 'react';
import { useTranslation } from 'react-i18next';
import type { FeatureMeta } from '../../../types';
import { formatFilterValue, parseInputValue } from '../../../lib/format';
@ -92,22 +93,23 @@ export function SliderLabels({
feature?: FeatureMeta;
onValueChange?: (v: [number, number]) => void;
}) {
const { t } = useTranslation();
const range = max - min || 1;
const leftPct = Math.max(0, Math.min(100, ((value[0] - min) / range) * 100));
const rightPct = Math.max(0, Math.min(100, ((value[1] - min) / range) * 100));
const labels = displayValues || value;
const labelFormat = feature?.suffix === '%' ? { raw, suffix: feature.suffix } : raw;
const minLabel = isAtMin ? 'min' : formatFilterValue(labels[0], labelFormat);
const maxLabel = isAtMax ? 'max' : formatFilterValue(labels[1], labelFormat);
const minLabel = isAtMin ? t('common.min') : formatFilterValue(labels[0], labelFormat);
const maxLabel = isAtMax ? t('common.max') : formatFilterValue(labels[1], labelFormat);
// Smoothly spread labels apart as thumbs get close to prevent overlap.
// t=1 (centered) when far apart, t=0 (split) when touching.
// gapRatio=1 (centered) when far apart, gapRatio=0 (split) when touching.
const SPREAD_THRESHOLD = 20; // percentage gap below which labels start separating
const gapPct = rightPct - leftPct;
const t = Math.min(1, Math.max(0, gapPct / SPREAD_THRESHOLD));
const leftTranslate = `translateX(${-100 + t * 50}%)`;
const rightTranslate = `translateX(${-t * 50}%)`;
const gapRatio = Math.min(1, Math.max(0, gapPct / SPREAD_THRESHOLD));
const leftTranslate = `translateX(${-100 + gapRatio * 50}%)`;
const rightTranslate = `translateX(${-gapRatio * 50}%)`;
if (feature && onValueChange) {
return (

View file

@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { ts } from '../../../i18n/server';
import { Slider } from '../../ui/Slider';
import { ChevronIcon } from '../../ui/icons';
@ -51,6 +52,7 @@ export function SpecificCrimeFilterCard({
onShowInfo: (feature: FeatureMeta) => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
const specificCrimeMeta = getSpecificCrimeFilterMeta(features);
const crimeOptions = SPECIFIC_CRIME_FEATURE_NAMES.map((name) =>
features.find((feature) => feature.name === name)
@ -145,7 +147,7 @@ export function SpecificCrimeFilterCard({
<div>
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
Crime type
{t('filters.crimeType')}
</label>
<div className="relative">
<select
@ -213,7 +215,7 @@ export function SpecificCrimeFilterCard({
/>
{filterImpact != null && filterImpact > 0 && (
<p className="-mt-1 ml-2.5 text-[10px] text-warm-400 dark:text-warm-500">
+{formatNumber(filterImpact)} without this filter
{t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
</p>
)}
</div>

View file

@ -1,4 +1,5 @@
import { Suspense, type MutableRefObject, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import type { FeatureFilters, FeatureMeta, POI, PostcodeGeometry, ViewState } from '../../../types';
import type { useMapData } from '../../../hooks/useMapData';
@ -106,6 +107,8 @@ export function DesktopMapPage({
toasts,
upgradeModal,
}: DesktopMapPageProps) {
const { t } = useTranslation();
return (
<div className="flex-1 flex overflow-hidden relative">
<LoadingOverlay show={initialLoading} />
@ -124,7 +127,7 @@ export function DesktopMapPage({
showProgress: true,
skipScroll: true,
}}
locale={{ last: 'Finish' }}
locale={{ last: t('common.finish') }}
/>
</Suspense>
)}
@ -194,7 +197,7 @@ export function DesktopMapPage({
className={`absolute bottom-4 right-4 z-10 px-3 py-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 flex items-center gap-2 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
>
<MapPinIcon className="w-5 h-5" />
<span className="text-sm font-medium">Points of interest</span>
<span className="text-sm font-medium">{t('poiPane.pointsOfInterest')}</span>
</button>
{poiPaneOpen && (
<div className="absolute bottom-14 right-4 z-10 flex h-[60vh] min-h-0 w-80 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">

View file

@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { SpinnerIcon } from '../../ui/icons/SpinnerIcon';
interface LoadingOverlayProps {
@ -5,6 +6,8 @@ interface LoadingOverlayProps {
}
export function LoadingOverlay({ show }: LoadingOverlayProps) {
const { t } = useTranslation();
if (!show) return null;
return (
@ -12,7 +15,7 @@ export function LoadingOverlay({ show }: LoadingOverlayProps) {
<div className="flex flex-col items-center gap-4">
<SpinnerIcon className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin" />
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">
Connecting to server...
{t('common.connectingToServer')}
</p>
</div>
</div>

View file

@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import type { ExportNotice } from './types';
import { BookmarkIcon } from '../../ui/icons/BookmarkIcon';
import { CheckIcon } from '../../ui/icons/CheckIcon';
@ -11,23 +12,25 @@ interface BookmarkToastProps {
}
export function BookmarkToast({ show, onViewSaved, onDismissForever }: BookmarkToastProps) {
const { t } = useTranslation();
if (!show) return null;
return (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[60] flex items-center gap-3 px-4 py-3 rounded-lg bg-navy-900 text-white text-sm shadow-lg animate-fade-in">
<BookmarkIcon className="w-4 h-4 text-teal-400 shrink-0" filled />
<span>Property saved!</span>
<span>{t('toasts.propertySaved')}</span>
<button
onClick={onViewSaved}
className="px-3 py-1 rounded bg-teal-600 hover:bg-teal-500 text-white text-xs font-medium whitespace-nowrap"
>
View saved
{t('toasts.viewSaved')}
</button>
<button
onClick={onDismissForever}
className="text-warm-400 hover:text-warm-200 text-xs whitespace-nowrap"
>
Don&apos;t show again
{t('toasts.dontShowAgain')}
</button>
</div>
);

View file

@ -6,6 +6,7 @@ import type { HexagonLocation } from '../../../lib/external-search';
import type { useMapData } from '../../../hooks/useMapData';
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
import { getSpecificCrimeFeatureName } from '../../../lib/crime-filter';
import { getElectionVoteShareFeatureName } from '../../../lib/election-filter';
import { getEthnicityFeatureName } from '../../../lib/ethnicity-filter';
import { getPoiDistanceFeatureName } from '../../../lib/poi-distance-filter';
import { getSchoolBackendFeatureName } from '../../../lib/school-filter';
@ -22,6 +23,7 @@ export function getMapPageBackendFeatureName(featureName: string): string {
return (
getSchoolBackendFeatureName(featureName) ??
getSpecificCrimeFeatureName(featureName) ??
getElectionVoteShareFeatureName(featureName) ??
getEthnicityFeatureName(featureName) ??
getPoiDistanceFeatureName(featureName) ??
featureName
@ -57,6 +59,7 @@ export function useMobileDensityRange(mapData: MapData): [number, number] {
let max = -Infinity;
for (const item of items) {
const count = 'count' in item ? item.count : item.properties.count;
if (count <= 0) continue;
if (count < min) min = count;
if (count > max) max = count;
}