This commit is contained in:
Andras Schmelczer 2026-06-25 22:29:52 +01:00
parent 2efa4d9f47
commit 5e73287eaf
99 changed files with 6392 additions and 1462 deletions

View file

@ -383,7 +383,7 @@ export function SavedPage({
})
.catch((err) => {
if (!cancelled) {
setShareLinksError(err instanceof Error ? err.message : 'Failed to fetch share links');
setShareLinksError(err instanceof Error ? err.message : t('accountPage.fetchShareLinksError'));
}
})
.finally(() => {
@ -393,7 +393,7 @@ export function SavedPage({
return () => {
cancelled = true;
};
}, []);
}, [t]);
const tabClass = (tab: string) =>
`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
@ -738,7 +738,7 @@ function InviteSection({ user }: { user: AuthUser }) {
setInviteUrl((prev) => ({ ...prev, [type]: data.url }));
fetchInviteHistory();
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to create invite';
const msg = err instanceof Error ? err.message : t('invitesPage.createInviteError');
setInviteError((prev) => ({ ...prev, [type]: msg }));
} finally {
setCreatingInvite((prev) => ({ ...prev, [type]: false }));
@ -931,7 +931,7 @@ export default function AccountPage({
assertOk(res, 'Update newsletter');
await onRefreshAuth();
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to update newsletter';
const msg = err instanceof Error ? err.message : t('accountPage.updateNewsletterError');
setNewsletterError(msg);
} finally {
setNewsletterSaving(false);

View file

@ -150,7 +150,7 @@ function ProductDemoVideo() {
<track
kind="captions"
srcLang={(i18n.language ?? 'en').split('-')[0]}
label="Captions"
label={t('common.captions')}
src={`/video/${productDemoSlug}.vtt`}
/>
</video>

View file

@ -74,7 +74,7 @@ const DEMO_FEATURES: FeatureMeta[] = [
prefix: '£',
},
{
name: 'Serious crime (avg/yr)',
name: 'Serious crime (/yr, 7y)',
type: 'numeric',
group: 'Crime',
min: 0,
@ -763,7 +763,7 @@ function RightPaneOnlyScreen({
<span className="truncate">{t('home.showcasePoliticalVoteShare')}</span>
</div>
<span className="shrink-0 text-xs font-black text-teal-700 dark:text-teal-300">
2024 GE
{t('home.showcaseGe2024')}
</span>
</div>
<StackedBarChart

View file

@ -155,11 +155,11 @@ export default function InvitePage({
window.location.href = data.checkout_url;
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to redeem invite');
setError(err instanceof Error ? err.message : t('invitePage.redeemFailed'));
} finally {
setRedeeming(false);
}
}, [code, user, onLicenseGranted]);
}, [code, user, onLicenseGranted, t]);
if (screenshotMode && loading) {
return (

View file

@ -262,7 +262,7 @@ function SocialVideoCard({
onPause={() => setIsPlaying(false)}
onEnded={() => setIsPlaying(false)}
>
<track kind="captions" srcLang="en" label="Captions" src={`/video/${slug}.vtt`} />
<track kind="captions" srcLang="en" label={t('common.captions')} src={`/video/${slug}.vtt`} />
</video>
{!isPlaying && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-navy-950/15 transition-colors">

View file

@ -1,15 +1,8 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type MutableRefObject,
type ReactNode,
} from 'react';
import { useCallback, useMemo, useState, type MutableRefObject, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { ts } from '../../i18n/server';
import type {
CrimeRecordsResponse,
FeatureFilters,
FeatureGroup,
FeatureMeta,
@ -39,12 +32,14 @@ import {
} from '../../lib/consts';
import { useNearbyStations } from '../../hooks/useNearbyStations';
import { useRetainedScrollTop } from '../../hooks/useRetainedScrollTop';
import { useRevealOnExpand } from '../../hooks/useRevealOnExpand';
import { DualHistogram, LoadingSkeleton } from './DualHistogram';
import EnumBarChart from './EnumBarChart';
import StackedBarChart from './StackedBarChart';
import StackedEnumChart from './StackedEnumChart';
import PriceHistoryChart from './PriceHistoryChart';
import CrimeYearChart from './CrimeYearChart';
import CrimeGroupBody from './CrimeGroupBody';
import NumberLine, { type NumberLinePoint } from './NumberLine';
import ExternalSearchLinks from './ExternalSearchLinks';
import { InfoIcon, TransitIcon } from '../ui/icons';
@ -72,6 +67,7 @@ interface AreaPaneProps {
shareCode?: string;
isGroupExpanded: (name: string) => boolean;
onToggleGroup: (name: string) => void;
onLoadCrimeRecords?: (offset: number) => Promise<CrimeRecordsResponse>;
scrollTopRef?: MutableRefObject<number>;
scrollRestoreKey?: string | null;
scrollSaveDisabled?: boolean;
@ -242,6 +238,7 @@ export default function AreaPane({
shareCode,
isGroupExpanded,
onToggleGroup,
onLoadCrimeRecords,
scrollTopRef,
scrollRestoreKey,
scrollSaveDisabled,
@ -297,61 +294,22 @@ export default function AreaPane({
// When the user expands a group, scroll the bottom of the newly opened content
// into view so the whole group is revealed without manual scrolling. The group
// header stays pinned at the top via its sticky positioning.
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const { setContainer, registerGroup, onToggle: revealGroupOnToggle } = useRevealOnExpand();
const setScrollNode = useCallback(
(node: HTMLDivElement | null) => {
scrollContainerRef.current = node;
setContainer(node);
scrollRef(node);
},
[scrollRef]
[setContainer, scrollRef]
);
const groupRefs = useRef<Map<string, HTMLDivElement>>(new Map());
// A fresh object each toggle so re-expanding the same group still re-triggers.
const [groupToReveal, setGroupToReveal] = useState<{ name: string } | null>(null);
const handleToggleGroup = useCallback(
(name: string) => {
const willExpand = !isGroupExpanded(name);
onToggleGroup(name);
setGroupToReveal(willExpand ? { name } : null);
revealGroupOnToggle(name, willExpand);
},
[isGroupExpanded, onToggleGroup]
[isGroupExpanded, onToggleGroup, revealGroupOnToggle]
);
useEffect(() => {
if (!groupToReveal) return;
const el = groupRefs.current.get(groupToReveal.name);
const container = scrollContainerRef.current;
if (!el || !container) return;
// Scroll down just enough to bring the bottom of the opened group into view.
// For groups taller than the pane this scrolls past the header, but it stays
// visible via its sticky positioning. Never scroll up (group already in view).
const alignBottom = () => {
const delta = el.getBoundingClientRect().bottom - container.getBoundingClientRect().bottom;
if (delta <= 1) return;
container.scrollTo({ top: container.scrollTop + delta });
};
alignBottom();
// The group's charts and async data (e.g. nearby stations) can grow its
// height after this first pass, so keep the bottom pinned as it settles.
// Stop once the user scrolls or after a short grace period so we never fight
// a deliberate scroll.
let timer = 0;
const observer = new ResizeObserver(alignBottom);
const stop = () => {
observer.disconnect();
container.removeEventListener('wheel', stop);
container.removeEventListener('touchmove', stop);
window.clearTimeout(timer);
};
observer.observe(el);
container.addEventListener('wheel', stop, { passive: true });
container.addEventListener('touchmove', stop, { passive: true });
timer = window.setTimeout(stop, 1500);
return stop;
}, [groupToReveal]);
const numericByName = useMemo(() => {
if (!stats) return new Map();
@ -363,26 +321,21 @@ export default function AreaPane({
return new Map(stats.enum_features.map((feature) => [feature.name, feature]));
}, [stats]);
// Crime-by-year series is keyed in the API by the bare crime type (e.g. "Burglary").
// We also index by the configured feature name (with " (avg/yr)" suffix) so the
// metric-row renderer can look it up using the feature name it already has.
const crimeByYearByFeatureName = useMemo(() => {
// Crime-by-year series, keyed by the bare crime type (e.g. "Burglary").
const crimeByYearByType = useMemo(() => {
const map = new Map<string, NonNullable<HexagonStatsResponse['crime_by_year']>[number]>();
for (const entry of stats?.crime_by_year ?? []) {
map.set(entry.name, entry);
map.set(`${entry.name} (avg/yr)`, entry);
}
return map;
}, [stats]);
// Per-crime-type outcode/sector averages, keyed by both the bare crime type
// and the " (avg/yr)" feature name (same convention as crimeByYearByFeatureName)
// so renderers can look them up by the feature name they already hold.
// Per-rate-feature outcode/sector averages, keyed by the FULL rate-feature name
// (e.g. "Burglary (/yr, 7y)") the comparison is computed against.
const crimeAreaAvgByName = useMemo(() => {
const map = new Map<string, NonNullable<HexagonStatsResponse['crime_area_averages']>[number]>();
for (const entry of stats?.crime_area_averages ?? []) {
map.set(entry.name, entry);
map.set(`${entry.name} (avg/yr)`, entry);
}
return map;
}, [stats]);
@ -440,7 +393,11 @@ export default function AreaPane({
<>
<div className="relative flex h-full flex-col">
<IndeterminateProgressBar show={loading && stats != null} />
<div ref={setScrollNode} onScroll={onScroll} className="flex-1 overflow-y-auto">
<div
ref={setScrollNode}
onScroll={onScroll}
className="flex-1 overflow-y-auto pb-[env(safe-area-inset-bottom)]"
>
<div className="border-b border-warm-200 bg-white dark:border-navy-700 dark:bg-navy-950">
<div className="space-y-3 p-3">
<div className="flex items-start justify-between gap-3">
@ -615,13 +572,7 @@ export default function AreaPane({
);
return (
<div
key={group.name}
ref={(el) => {
if (el) groupRefs.current.set(group.name, el);
else groupRefs.current.delete(group.name);
}}
>
<div key={group.name} ref={registerGroup(group.name)}>
<CollapsibleGroupHeader
name={group.name}
expanded={expanded}
@ -631,6 +582,19 @@ export default function AreaPane({
{expanded && (
<div className="divide-y divide-warm-100 px-3 py-1 dark:divide-navy-800">
{showNearbyStations && <NearbyStationsCard location={hexagonLocation} />}
{group.name === 'Crime' ? (
<CrimeGroupBody
stats={stats}
numericByName={numericByName}
crimeAreaAvgByName={crimeAreaAvgByName}
crimeByYearByType={crimeByYearByType}
globalFeatureByName={globalFeatureByName}
onShowInfo={setInfoFeature}
onLoadCrimeRecords={onLoadCrimeRecords}
selectionKey={`${isPostcode ? 'postcode' : 'hexagon'}:${hexagonId}`}
/>
) : (
<>
{stackedCharts?.map((chart) => {
const segments = chart.components
.map((name) => ({
@ -651,7 +615,7 @@ export default function AreaPane({
? aggregateStats.mean
: displaySegments.reduce((sum, s) => sum + s.value, 0);
// Use rateFeature (e.g. per-1k) for display if available
// Use rateFeature (e.g. a percentage) for display if available
const rateStats = chart.rateFeature
? numericByName.get(chart.rateFeature)
: undefined;
@ -715,7 +679,7 @@ export default function AreaPane({
if (total === 0) return null;
const crimeSeries = chart.feature
? crimeByYearByFeatureName.get(chart.feature)
? crimeByYearByType.get(chart.feature)
: undefined;
return (
@ -800,7 +764,7 @@ export default function AreaPane({
const globalMean = globalHistogram
? calculateHistogramMean(globalHistogram)
: undefined;
const crimeSeries = crimeByYearByFeatureName.get(feature.name);
const crimeSeries = crimeByYearByType.get(feature.name);
const crimeAreaAvg = crimeAreaAvgByName.get(feature.name);
// National avg is shown for every metric here as a
// tooltip; for crime metrics the outcode and sector
@ -992,6 +956,8 @@ export default function AreaPane({
</div>
);
})}
</>
)}
</div>
)}
</div>

View file

@ -0,0 +1,204 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ts } from '../../i18n/server';
import type {
CrimeAreaAverage,
CrimeRecordsResponse,
CrimeYearStats,
FeatureMeta,
HexagonStatsResponse,
NumericFeatureStats,
} from '../../types';
import { STACKED_GROUPS, STACKED_SEGMENT_COLORS } from '../../lib/consts';
import { formatValue, calculateHistogramMean } from '../../lib/format';
import { FeatureLabel } from '../ui/FeatureLabel';
import StackedBarChart from './StackedBarChart';
import NumberLine, { type NumberLinePoint } from './NumberLine';
import CrimeYearChart from './CrimeYearChart';
import CrimeRecordsSection from './CrimeRecordsSection';
type CrimeWindow = '7y' | '2y';
/** Strip the " (/yr, 7y|2y)" suffix to the bare crime type. */
function bareType(featureName: string): string {
return featureName.replace(/ \(\/yr, \dy\)$/, '');
}
/** Re-target a crime-feature name at the chosen averaging window. */
function toWindow(featureName: string, window: CrimeWindow): string {
return featureName.replace(/(\/yr, )\dy/, `$1${window}`);
}
/**
* Segment colours keyed by the BARE crime type. The stacked bar then labels each
* segment with the clean crime name (e.g. "Burglary") and keeps one stable colour
* regardless of whether the 7-year or 2-year window is selected.
*/
const SEGMENT_COLOR_BY_BARE: Record<string, string> = Object.fromEntries(
Object.entries(STACKED_SEGMENT_COLORS).map(([name, color]) => [bareType(name), color])
);
interface CrimeGroupBodyProps {
stats: HexagonStatsResponse;
numericByName: Map<string, NumericFeatureStats>;
/** Keyed by the FULL crime-feature name (e.g. "Burglary (/yr, 7y)"). */
crimeAreaAvgByName: Map<string, CrimeAreaAverage>;
/** Keyed by the bare crime type (e.g. "Burglary"). */
crimeByYearByType: Map<string, CrimeYearStats>;
globalFeatureByName: Map<string, FeatureMeta>;
onShowInfo: (feature: FeatureMeta) => void;
onLoadCrimeRecords?: (offset: number) => Promise<CrimeRecordsResponse>;
selectionKey: string | null;
}
export default function CrimeGroupBody({
stats,
numericByName,
crimeAreaAvgByName,
crimeByYearByType,
globalFeatureByName,
onShowInfo,
onLoadCrimeRecords,
selectionKey,
}: CrimeGroupBodyProps) {
const { t } = useTranslation();
// One selector drives every value and comparison in this group.
const [crimeWindow, setCrimeWindow] = useState<CrimeWindow>('7y');
const fmtCount = (value?: number) => (value == null ? '—' : formatValue(value));
const rollupCards = STACKED_GROUPS.Crime ?? [];
return (
<>
<div className="flex items-center justify-between gap-2 py-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-warm-400 dark:text-warm-500">
{t('areaPane.crimeWindowLabel')}
</span>
<div className="grid grid-cols-2 gap-0.5 rounded-md bg-warm-200 p-0.5 dark:bg-navy-800">
{(['7y', '2y'] as const).map((w) => {
const active = crimeWindow === w;
return (
<button
key={w}
type="button"
aria-pressed={active}
onClick={() => setCrimeWindow(w)}
className={`rounded px-3 py-1 text-xs font-medium ${
active
? '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(w === '7y' ? 'areaPane.crimeWindow7y' : 'areaPane.crimeWindow2y')}
</button>
);
})}
</div>
</div>
{rollupCards.map((card) => {
if (!card.feature) return null;
const bare = bareType(card.feature);
const windowFeature = toWindow(card.feature, crimeWindow);
const rate = numericByName.get(windowFeature)?.mean;
const segments = card.components
.map((name) => ({
name: bareType(name),
value: numericByName.get(toWindow(name, crimeWindow))?.mean ?? 0,
}))
.filter((s) => s.value > 0);
const total = segments.reduce((sum, s) => sum + s.value, 0);
if (rate == null && total === 0) return null;
const displayValue = rate ?? total;
// Info popup / icon stay anchored to the 7-year feature so the metric
// definition is stable across windows; only the numbers change.
const featureMeta = globalFeatureByName.get(card.feature);
const globalMean = featureMeta?.histogram
? calculateHistogramMean(featureMeta.histogram)
: undefined;
const crimeAreaAvg = crimeAreaAvgByName.get(windowFeature);
const nationalAvg = crimeAreaAvg?.national ?? globalMean;
const cardTitle = t('areaPane.crimeCardTitle', { name: ts(card.label) });
const numberLinePoints: NumberLinePoint[] = crimeAreaAvg
? (
[
{ kind: 'area', label: t('areaPane.thisArea'), value: displayValue },
nationalAvg != null
? { kind: 'national', label: t('areaPane.national'), value: nationalAvg }
: null,
crimeAreaAvg.outcode != null
? {
kind: 'outcode',
label: stats.crime_outcode ?? t('areaPane.outcodeAvg'),
value: crimeAreaAvg.outcode,
}
: null,
crimeAreaAvg.sector != null
? {
kind: 'sector',
label: stats.crime_sector ?? t('areaPane.sectorAvg'),
value: crimeAreaAvg.sector,
}
: null,
] as (NumberLinePoint | null)[]
).filter((p): p is NumberLinePoint => p !== null)
: [];
const series = crimeByYearByType.get(bare);
return (
<div key={card.label} className="rounded bg-warm-50 p-2 dark:bg-warm-800">
<div className="mb-1.5 flex items-baseline justify-between gap-2">
{featureMeta ? (
<FeatureLabel
feature={{ ...featureMeta, name: ts(card.label) }}
label={cardTitle}
onShowInfo={onShowInfo}
className="mr-2"
wrap
/>
) : (
<span className="mr-2 min-w-0 break-words text-xs leading-snug text-warm-700 dark:text-warm-300">
{cardTitle}
</span>
)}
<div className="shrink-0 text-right">
<span className="whitespace-nowrap text-xs font-semibold text-teal-700 dark:text-teal-400">
{fmtCount(displayValue)}
</span>
</div>
</div>
{total > 0 && (
<StackedBarChart segments={segments} total={total} colorMap={SEGMENT_COLOR_BY_BARE} />
)}
{numberLinePoints.length >= 2 && (
<div className="mt-2">
<NumberLine points={numberLinePoints} format={formatValue} />
</div>
)}
{series && series.points.length > 1 && (
<div className="mt-2">
<CrimeYearChart
points={series.points}
latestAvailableYear={stats.crime_latest_year}
/>
</div>
)}
</div>
);
})}
{onLoadCrimeRecords && (
<CrimeRecordsSection
selectionKey={selectionKey}
total={stats.crime_total_records}
onLoad={onLoadCrimeRecords}
/>
)}
</>
);
}

View file

@ -0,0 +1,147 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ts } from '../../i18n/server';
import type { CrimeIncident, CrimeRecordsResponse } from '../../types';
import { isAbortError, logNonAbortError } from '../../lib/api';
function formatMonth(month: string, locale: string): string {
const [year, m] = month.split('-').map(Number);
if (!year || !m) return month;
return new Date(Date.UTC(year, m - 1, 1)).toLocaleDateString(locale, {
year: 'numeric',
month: 'short',
timeZone: 'UTC',
});
}
interface CrimeRecordsSectionProps {
/** Identity of the current selection; changing it resets the list. */
selectionKey: string | null;
/** Total records for the selection (from stats); the section hides when 0. */
total?: number;
onLoad: (offset: number) => Promise<CrimeRecordsResponse>;
}
export default function CrimeRecordsSection({
selectionKey,
total,
onLoad,
}: CrimeRecordsSectionProps) {
const { t, i18n } = useTranslation();
const [open, setOpen] = useState(false);
const [records, setRecords] = useState<CrimeIncident[]>([]);
const [loadedTotal, setLoadedTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
// Bumped whenever the selection changes; in-flight responses for a previous
// selection are ignored when they resolve.
const selectionIdRef = useRef(0);
useEffect(() => {
selectionIdRef.current += 1;
setOpen(false);
setRecords([]);
setLoadedTotal(0);
setLoading(false);
setError(false);
}, [selectionKey]);
if (!total || total <= 0) return null;
const fetchPage = async (offset: number) => {
const myId = selectionIdRef.current;
setLoading(true);
setError(false);
try {
const data = await onLoad(offset);
if (myId !== selectionIdRef.current) return;
setRecords((prev) => (offset === 0 ? data.records : [...prev, ...data.records]));
setLoadedTotal(data.total);
} catch (err) {
if (isAbortError(err)) return;
logNonAbortError('Failed to fetch crime records', err);
if (myId === selectionIdRef.current) setError(true);
} finally {
if (myId === selectionIdRef.current) setLoading(false);
}
};
const handleToggle = () => {
const next = !open;
setOpen(next);
if (next && records.length === 0 && !loading) fetchPage(0);
};
const shownTotal = loadedTotal || total;
const canLoadMore = records.length < loadedTotal;
return (
<div className="py-1.5">
<button
type="button"
onClick={handleToggle}
className="flex w-full items-center justify-between gap-2 rounded px-1 py-1 text-left hover:bg-warm-100 dark:hover:bg-navy-800"
aria-expanded={open}
>
<span className="text-[13px] font-medium text-warm-900 dark:text-warm-100">
{open ? t('areaPane.crimeRecordsHide') : t('areaPane.crimeRecordsToggle')}
</span>
<span className="shrink-0 text-xs font-semibold tabular-nums text-warm-500 dark:text-warm-400">
{t('areaPane.crimeRecordsCount', { count: shownTotal })}
</span>
</button>
{open && (
<div className="mt-1.5">
{error ? (
<div className="py-2 text-sm text-rose-600 dark:text-rose-400">
{t('areaPane.crimeRecordsError')}
</div>
) : records.length === 0 && loading ? (
<div className="flex items-center gap-2 py-2 text-sm text-warm-500 dark:text-warm-400">
<span className="h-3 w-3 animate-spin rounded-full border-2 border-teal-600 border-t-transparent dark:border-teal-400 dark:border-t-transparent" />
{t('areaPane.crimeRecordsLoading')}
</div>
) : records.length === 0 ? (
<div className="py-2 text-sm text-warm-500 dark:text-warm-400">
{t('areaPane.crimeRecordsEmpty')}
</div>
) : (
<>
<ol className="divide-y divide-warm-100 dark:divide-navy-800">
{records.map((record, index) => (
<li key={`${record.month}-${record.lat}-${record.lon}-${index}`} className="py-1.5">
<div className="flex items-baseline justify-between gap-2">
<span className="min-w-0 break-words text-[13px] font-medium text-warm-900 dark:text-warm-100">
{ts(record.type)}
</span>
<span className="shrink-0 text-xs tabular-nums text-warm-500 dark:text-warm-400">
{formatMonth(record.month, i18n.language)}
</span>
</div>
<div className="mt-0.5 text-xs text-warm-500 dark:text-warm-400">
{record.location ?? t('areaPane.crimeNoLocation')}
</div>
<div className="text-xs text-warm-400 dark:text-warm-500">
{record.outcome ?? t('areaPane.crimeOutcomeUnknown')}
</div>
</li>
))}
</ol>
{canLoadMore && (
<button
type="button"
onClick={() => !loading && fetchPage(records.length)}
disabled={loading}
className="mt-2 w-full rounded border border-warm-200 px-2 py-1.5 text-xs font-medium text-warm-700 hover:bg-warm-100 disabled:opacity-50 dark:border-navy-700 dark:text-warm-200 dark:hover:bg-navy-800"
>
{loading ? t('areaPane.crimeRecordsLoading') : t('areaPane.crimeRecordsLoadMore')}
</button>
)}
</>
)}
</div>
)}
</div>
);
}

View file

@ -82,7 +82,12 @@ export default function CrimeYearChart({ points, latestAvailableYear }: CrimeYea
r={1.6}
className="fill-rose-700 dark:fill-rose-300"
>
<title>{`${p.year}: ${p.count.toFixed(1)}/yr`}</title>
<title>
{t('areaPane.crimeYearPointTooltip', {
year: p.year,
rate: p.count.toFixed(1),
})}
</title>
</circle>
))}
<text

View file

@ -40,7 +40,7 @@ export default function ExternalSearchLinks({
const radiusMiles = location.isPostcode
? POSTCODE_RADIUS_MILES
: (H3_RADIUS_MILES[location.resolution] ?? 1);
const label = `${radiusMiles}mi radius`;
const label = t('externalSearch.radiusMi', { count: radiusMiles });
if (!urls) return null;

View file

@ -33,6 +33,10 @@ interface FeatureBrowserProps {
onClearOpenInfoFeature?: () => void;
travelTimeEntries: TravelTimeEntry[];
onAddTravelTimeEntry: (mode: TransportMode) => void;
/** Registers a group wrapper so an expanded group can be scrolled into view. */
registerGroup?: (name: string) => (node: HTMLDivElement | null) => void;
/** Notifies the reveal-on-expand mechanism when a group is toggled. */
onGroupToggle?: (name: string, willExpand: boolean) => void;
}
export default function FeatureBrowser({
@ -46,6 +50,8 @@ export default function FeatureBrowser({
onClearOpenInfoFeature,
travelTimeEntries: _travelTimeEntries,
onAddTravelTimeEntry,
registerGroup,
onGroupToggle,
}: FeatureBrowserProps) {
const { t } = useTranslation();
const modes = useTranslatedModes();
@ -115,11 +121,15 @@ export default function FeatureBrowser({
{mergedGrouped.map((group) => {
const isExpanded = isSearching || isGroupExpanded(group.name);
return (
<div key={group.name} className="shrink-0">
<div key={group.name} className="shrink-0" ref={registerGroup?.(group.name)}>
<CollapsibleGroupHeader
name={group.name}
expanded={isExpanded}
onToggle={() => toggleGroup(group.name)}
onToggle={() => {
const willExpand = !isExpanded;
toggleGroup(group.name);
onGroupToggle?.(group.name, willExpand);
}}
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-30 hover:bg-warm-200 dark:hover:bg-warm-800"
>
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">

View file

@ -21,6 +21,16 @@ import {
isSpecificCrimeFeatureName,
isSpecificCrimeFilterName,
} from '../../lib/crime-filter';
import {
CRIME_SEVERITY_FILTER_NAMES,
getCrimeSeverityFeatureName,
getCrimeSeverityFilterMeta,
getCrimeSeverityFilterName,
getDefaultCrimeSeverityFeatureName,
isCrimeSeverityFeatureName,
isCrimeSeverityFilterName,
type CrimeSeverityFilterName,
} from '../../lib/crime-severity-filter';
import {
ELECTION_VOTE_SHARE_FILTER_NAME,
getDefaultElectionVoteShareFeatureName,
@ -37,7 +47,22 @@ import {
isEthnicityFeatureName,
isEthnicityFilterName,
} from '../../lib/ethnicity-filter';
import { isQualificationFeatureName } from '../../lib/qualification-filter';
import {
QUALIFICATIONS_FILTER_NAME,
getDefaultQualificationFeatureName,
getQualificationFeatureName,
getQualificationFilterMeta,
isQualificationFeatureName,
isQualificationFilterName,
} from '../../lib/qualification-filter';
import {
TENURE_FILTER_NAME,
getDefaultTenureFeatureName,
getTenureFeatureName,
getTenureFilterMeta,
isTenureFeatureName,
isTenureFilterName,
} from '../../lib/tenure-filter';
import {
SCHOOL_FILTER_NAME,
getDefaultSchoolFeatureName,
@ -182,6 +207,33 @@ export default memo(function Filters({
[features]
);
const ethnicityMeta = useMemo(() => getEthnicityFilterMeta(features), [features]);
const defaultQualificationFeatureName = useMemo(
() => getDefaultQualificationFeatureName(features),
[features]
);
const qualificationMeta = useMemo(() => getQualificationFilterMeta(features), [features]);
const defaultTenureFeatureName = useMemo(() => getDefaultTenureFeatureName(features), [features]);
const tenureMeta = useMemo(() => getTenureFilterMeta(features), [features]);
const crimeSeverityMetas = useMemo(
() =>
Object.fromEntries(
CRIME_SEVERITY_FILTER_NAMES.map((filterName) => [
filterName,
getCrimeSeverityFilterMeta(features, filterName),
])
) as Record<CrimeSeverityFilterName, FeatureMeta>,
[features]
);
const defaultCrimeSeverityFeatureNames = useMemo(
() =>
Object.fromEntries(
CRIME_SEVERITY_FILTER_NAMES.map((filterName) => [
filterName,
getDefaultCrimeSeverityFeatureName(features, filterName),
])
) as Record<CrimeSeverityFilterName, string | null>,
[features]
);
const defaultPoiDistanceFeatureName = useMemo(
() => getDefaultPoiDistanceFeatureName(features),
[features]
@ -256,6 +308,18 @@ export default memo(function Filters({
return { ...(backendFeature ?? specificCrimeMeta), name, group: 'Crime' };
});
}, [filters, features, specificCrimeMeta]);
const crimeSeverityFilterItems = useMemo(() => {
return Object.keys(filters)
.filter(isCrimeSeverityFilterName)
.map((name) => {
const backendName = getCrimeSeverityFeatureName(name);
const filterName = getCrimeSeverityFilterName(name) ?? CRIME_SEVERITY_FILTER_NAMES[0];
const backendFeature = backendName
? features.find((feature) => feature.name === backendName)
: undefined;
return { ...(backendFeature ?? crimeSeverityMetas[filterName]), name, group: 'Crime' };
});
}, [filters, features, crimeSeverityMetas]);
const electionVoteShareFilterItems = useMemo(() => {
return Object.keys(filters)
.filter(isElectionVoteShareFilterName)
@ -278,6 +342,28 @@ export default memo(function Filters({
return { ...(backendFeature ?? ethnicityMeta), name, group: 'Neighbours' };
});
}, [filters, features, ethnicityMeta]);
const qualificationFilterItems = useMemo(() => {
return Object.keys(filters)
.filter(isQualificationFilterName)
.map((name) => {
const backendName = getQualificationFeatureName(name);
const backendFeature = backendName
? features.find((feature) => feature.name === backendName)
: undefined;
return { ...(backendFeature ?? qualificationMeta), name, group: 'Neighbours' };
});
}, [filters, features, qualificationMeta]);
const tenureFilterItems = useMemo(() => {
return Object.keys(filters)
.filter(isTenureFilterName)
.map((name) => {
const backendName = getTenureFeatureName(name);
const backendFeature = backendName
? features.find((feature) => feature.name === backendName)
: undefined;
return { ...(backendFeature ?? tenureMeta), name, group: 'Neighbours' };
});
}, [filters, features, tenureMeta]);
const poiDistanceFilterItems = useMemo(() => {
return Object.keys(filters)
.filter(isPoiDistanceFilterName)
@ -300,6 +386,8 @@ export default memo(function Filters({
let insertedSpecificCrimeFilter = false;
let insertedElectionVoteShareFilter = false;
let insertedEthnicityFilter = false;
let insertedQualificationFilter = false;
let insertedTenureFilter = false;
const insertedPoiFilters = new Set<PoiFilterName>();
const maybeInsertPoiFilter = (filterName: PoiFilterName | null) => {
if (
@ -311,6 +399,17 @@ export default memo(function Filters({
insertedPoiFilters.add(filterName);
}
};
const insertedCrimeSeverityFilters = new Set<CrimeSeverityFilterName>();
const maybeInsertCrimeSeverityFilter = (filterName: CrimeSeverityFilterName | null) => {
if (
filterName &&
defaultCrimeSeverityFeatureNames[filterName] &&
!insertedCrimeSeverityFilters.has(filterName)
) {
result.push(crimeSeverityMetas[filterName]);
insertedCrimeSeverityFilters.add(filterName);
}
};
for (const feature of features) {
if (feature.group === 'Transport') {
@ -330,6 +429,12 @@ export default memo(function Filters({
}
continue;
}
// "Serious crime" and "Minor crime" each fold their 7y/2y windows into one
// card with a period toggle (no variant dropdown — a single feature each).
if (isCrimeSeverityFeatureName(feature.name)) {
maybeInsertCrimeSeverityFilter(getCrimeSeverityFilterName(feature.name));
continue;
}
if (isElectionVoteShareFeatureName(feature.name)) {
if (defaultElectionVoteShareFeatureName && !insertedElectionVoteShareFilter) {
result.push(electionVoteShareMeta);
@ -344,14 +449,28 @@ export default memo(function Filters({
}
continue;
}
// The seven qualification bands fold into one "Qualifications" filter
// whose dropdown picks a band, rather than seven separate sliders.
if (isQualificationFeatureName(feature.name)) {
if (defaultQualificationFeatureName && !insertedQualificationFilter) {
result.push(qualificationMeta);
insertedQualificationFilter = true;
}
continue;
}
// The three tenure categories fold into one "Tenure" filter
// whose dropdown picks a category, rather than three separate sliders.
if (isTenureFeatureName(feature.name)) {
if (defaultTenureFeatureName && !insertedTenureFilter) {
result.push(tenureMeta);
insertedTenureFilter = true;
}
continue;
}
if (isPoiFilterFeatureName(feature.name)) {
maybeInsertPoiFilter(getPoiFilterName(feature.name));
continue;
}
// Qualification breakdown is display-only (shown as the stacked
// "Qualifications" composition in the area pane), so keep its seven
// component features out of the filter browser.
if (isQualificationFeatureName(feature.name)) continue;
if (!enabledFeatures.has(feature.name)) result.push(feature);
}
@ -363,10 +482,16 @@ export default memo(function Filters({
schoolMeta,
defaultSpecificCrimeFeatureName,
specificCrimeMeta,
defaultCrimeSeverityFeatureNames,
crimeSeverityMetas,
defaultElectionVoteShareFeatureName,
electionVoteShareMeta,
defaultEthnicityFeatureName,
ethnicityMeta,
defaultQualificationFeatureName,
qualificationMeta,
defaultTenureFeatureName,
tenureMeta,
defaultPoiFilterFeatureNames,
poiFilterMetas,
]);
@ -376,6 +501,8 @@ export default memo(function Filters({
let insertedSpecificCrimeFilters = false;
let insertedElectionVoteShareFilters = false;
let insertedEthnicityFilters = false;
let insertedQualificationFilters = false;
let insertedTenureFilters = false;
const insertedPoiFilters = new Set<PoiFilterName>();
const insertPoiFilterItems = (filterName: PoiFilterName | null) => {
if (!filterName || insertedPoiFilters.has(filterName)) return;
@ -384,6 +511,16 @@ export default memo(function Filters({
);
insertedPoiFilters.add(filterName);
};
const insertedCrimeSeverityFilters = new Set<CrimeSeverityFilterName>();
const insertCrimeSeverityFilterItems = (filterName: CrimeSeverityFilterName | null) => {
if (!filterName || insertedCrimeSeverityFilters.has(filterName)) return;
result.push(
...crimeSeverityFilterItems.filter(
(item) => getCrimeSeverityFilterName(item.name) === filterName
)
);
insertedCrimeSeverityFilters.add(filterName);
};
for (const feature of features) {
if (feature.group === 'Transport') {
@ -403,6 +540,10 @@ export default memo(function Filters({
}
continue;
}
if (isCrimeSeverityFeatureName(feature.name)) {
insertCrimeSeverityFilterItems(getCrimeSeverityFilterName(feature.name));
continue;
}
if (isElectionVoteShareFeatureName(feature.name)) {
if (!insertedElectionVoteShareFilters) {
result.push(...electionVoteShareFilterItems);
@ -417,6 +558,20 @@ export default memo(function Filters({
}
continue;
}
if (isQualificationFeatureName(feature.name)) {
if (!insertedQualificationFilters) {
result.push(...qualificationFilterItems);
insertedQualificationFilters = true;
}
continue;
}
if (isTenureFeatureName(feature.name)) {
if (!insertedTenureFilters) {
result.push(...tenureFilterItems);
insertedTenureFilters = true;
}
continue;
}
if (isPoiFilterFeatureName(feature.name)) {
insertPoiFilterItems(getPoiFilterName(feature.name));
continue;
@ -430,8 +585,11 @@ export default memo(function Filters({
enabledFeatures,
schoolFilterItems,
specificCrimeFilterItems,
crimeSeverityFilterItems,
electionVoteShareFilterItems,
ethnicityFilterItems,
qualificationFilterItems,
tenureFilterItems,
poiDistanceFilterItems,
]);
@ -460,10 +618,15 @@ export default memo(function Filters({
(name: string): string | null => {
if (name === SCHOOL_FILTER_NAME) return schoolMeta.group ?? 'Schools';
if (name === SPECIFIC_CRIMES_FILTER_NAME) return specificCrimeMeta.group ?? 'Crime';
if (CRIME_SEVERITY_FILTER_NAMES.includes(name as CrimeSeverityFilterName)) {
return crimeSeverityMetas[name as CrimeSeverityFilterName].group ?? 'Crime';
}
if (name === ELECTION_VOTE_SHARE_FILTER_NAME) {
return electionVoteShareMeta.group ?? 'Neighbours';
}
if (name === ETHNICITIES_FILTER_NAME) return ethnicityMeta.group ?? 'Neighbours';
if (name === QUALIFICATIONS_FILTER_NAME) return qualificationMeta.group ?? 'Neighbours';
if (name === TENURE_FILTER_NAME) return tenureMeta.group ?? 'Neighbours';
if (POI_FILTER_NAMES.includes(name as PoiFilterName)) {
return poiFilterMetas[name as PoiFilterName].group ?? null;
}
@ -472,8 +635,11 @@ export default memo(function Filters({
[
electionVoteShareMeta.group,
ethnicityMeta.group,
qualificationMeta.group,
tenureMeta.group,
features,
poiFilterMetas,
crimeSeverityMetas,
schoolMeta.group,
specificCrimeMeta.group,
]
@ -514,6 +680,28 @@ export default memo(function Filters({
onAddFilter(ETHNICITIES_FILTER_NAME);
return;
}
if (name === QUALIFICATIONS_FILTER_NAME) {
if (!defaultQualificationFeatureName) return;
queueActiveFilterScroll(
QUALIFICATIONS_FILTER_NAME,
getAddFilterGroupName(QUALIFICATIONS_FILTER_NAME)
);
onAddFilter(QUALIFICATIONS_FILTER_NAME);
return;
}
if (name === TENURE_FILTER_NAME) {
if (!defaultTenureFeatureName) return;
queueActiveFilterScroll(TENURE_FILTER_NAME, getAddFilterGroupName(TENURE_FILTER_NAME));
onAddFilter(TENURE_FILTER_NAME);
return;
}
if (CRIME_SEVERITY_FILTER_NAMES.includes(name as CrimeSeverityFilterName)) {
const severityFilterName = name as CrimeSeverityFilterName;
if (!defaultCrimeSeverityFeatureNames[severityFilterName]) return;
queueActiveFilterScroll(severityFilterName, getAddFilterGroupName(severityFilterName));
onAddFilter(severityFilterName);
return;
}
if (POI_FILTER_NAMES.includes(name as PoiFilterName)) {
const filterName = name as PoiFilterName;
if (!defaultPoiFilterFeatureNames[filterName]) return;
@ -528,8 +716,11 @@ export default memo(function Filters({
[
defaultSchoolFeatureName,
defaultSpecificCrimeFeatureName,
defaultCrimeSeverityFeatureNames,
defaultElectionVoteShareFeatureName,
defaultEthnicityFeatureName,
defaultQualificationFeatureName,
defaultTenureFeatureName,
defaultPoiFilterFeatureNames,
getAddFilterGroupName,
onAddFilter,
@ -686,8 +877,11 @@ export default memo(function Filters({
...features,
schoolMeta,
specificCrimeMeta,
...Object.values(crimeSeverityMetas),
electionVoteShareMeta,
ethnicityMeta,
qualificationMeta,
tenureMeta,
poiDistanceMeta,
transportDistanceMeta,
poiCount2KmMeta,
@ -696,8 +890,11 @@ export default memo(function Filters({
pinnedFeature={pinnedFeature}
defaultSchoolFeatureName={defaultSchoolFeatureName}
defaultSpecificCrimeFeatureName={defaultSpecificCrimeFeatureName}
defaultCrimeSeverityFeatureNames={defaultCrimeSeverityFeatureNames}
defaultElectionVoteShareFeatureName={defaultElectionVoteShareFeatureName}
defaultEthnicityFeatureName={defaultEthnicityFeatureName}
defaultQualificationFeatureName={defaultQualificationFeatureName}
defaultTenureFeatureName={defaultTenureFeatureName}
defaultPoiFilterFeatureNames={defaultPoiFilterFeatureNames}
openInfoFeature={openInfoFeature}
travelTimeEntries={travelTimeEntries}

View file

@ -5,8 +5,11 @@ 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 { getCrimeSeverityFeatureName } from '../../lib/crime-severity-filter';
import { getElectionVoteShareFeatureName } from '../../lib/election-filter';
import { getEthnicityFeatureName } from '../../lib/ethnicity-filter';
import { getQualificationFeatureName } from '../../lib/qualification-filter';
import { getTenureFeatureName } from '../../lib/tenure-filter';
import {
POI_DISTANCE_FILTER_NAME,
getPoiDistanceFeatureName,
@ -52,14 +55,20 @@ export default memo(function HoverCard({
for (const name of activeFilterNames.slice(0, 4)) {
const schoolBackendName = getSchoolBackendFeatureName(name);
const specificCrimeFeatureName = getSpecificCrimeFeatureName(name);
const crimeSeverityFeatureName = getCrimeSeverityFeatureName(name);
const electionVoteShareFeatureName = getElectionVoteShareFeatureName(name);
const ethnicityFeatureName = getEthnicityFeatureName(name);
const qualificationFeatureName = getQualificationFeatureName(name);
const tenureFeatureName = getTenureFeatureName(name);
const poiDistanceFeatureName = getPoiDistanceFeatureName(name);
const backendName =
schoolBackendName ??
specificCrimeFeatureName ??
crimeSeverityFeatureName ??
electionVoteShareFeatureName ??
ethnicityFeatureName ??
qualificationFeatureName ??
tenureFeatureName ??
poiDistanceFeatureName ??
name;
const val = data[`avg_${backendName}`] ?? data[`min_${backendName}`];

View file

@ -16,6 +16,8 @@ vi.mock('react-i18next', () => ({
if (key === 'travel.noBuses') return 'No buses';
if (key === 'areaPane.walk') return 'Walk';
if (key === 'areaPane.cycle') return 'Cycle';
if (key === 'journey.bus') return 'Bus';
if (key === 'journey.lineSuffix') return 'line';
if (key === 'areaPane.viewOnGoogleMaps') return 'View on Google Maps';
if (key === 'areaPane.noJourneyData') return 'No journey data';
return key;

View file

@ -1,5 +1,6 @@
import { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import type { JourneyLeg } from '../../types';
import { resolveTransitVariant, type TravelTimeEntry } from '../../hooks/useTravelTime';
import { apiUrl, authHeaders, logNonAbortError } from '../../lib/api';
@ -106,15 +107,25 @@ function stripId(label: string): string {
return label.replace(/\s+\([A-Za-z0-9]+\)$/, '');
}
function getRouteDisplay(mode: string): { label: string; color: string; darkText: boolean } {
function getRouteDisplay(
mode: string,
t: TFunction
): { label: string; color: string; darkText: boolean } {
const clean = stripId(mode);
const known = ROUTE_COLORS[clean];
if (known) {
const label = NON_TUBE_NAMES.has(clean) || clean.includes('line') ? clean : `${clean} line`;
const label =
NON_TUBE_NAMES.has(clean) || clean.includes('line')
? clean
: `${clean} ${t('journey.lineSuffix')}`;
return { label, color: known.color, darkText: !!known.darkText };
}
if (/^\d+[A-Za-z]?$/.test(clean.trim())) {
return { label: `Bus ${clean}`, color: '#0d9488', darkText: false };
return {
label: `${t('journey.bus')} ${clean}`,
color: '#0d9488',
darkText: false,
};
}
return { label: clean, color: '#6b7280', darkText: false };
}
@ -203,7 +214,8 @@ function invertLegs(legs: JourneyLeg[]): JourneyLeg[] {
}
function RouteBadge({ mode }: { mode: string }) {
const { label, color, darkText } = getRouteDisplay(mode);
const { t } = useTranslation();
const { label, color, darkText } = getRouteDisplay(mode, t);
return (
<span
className="inline-flex items-center text-[10px] font-bold px-1.5 py-px rounded-sm leading-tight tracking-wide"
@ -242,7 +254,7 @@ function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
);
}
const { color } = getRouteDisplay(leg.mode);
const { color } = getRouteDisplay(leg.mode, t);
return (
<div className="flex">
<div className="flex flex-col items-center w-4 mr-2">

View file

@ -19,15 +19,21 @@ function formatListingHeadline(listing: ActualListing, t: TFunction): string | n
export const ListingPopupSingleContent = memo(function ListingPopupSingleContent({
listing,
clickedUrls,
onOpen,
}: {
listing: ActualListing;
clickedUrls: Set<string>;
onOpen: (url: string) => void;
}) {
const { t } = useTranslation();
const visited = clickedUrls.has(listing.listing_url);
return (
<a
href={listing.listing_url}
target="_blank"
rel="noopener noreferrer"
onClick={() => onOpen(listing.listing_url)}
className="block px-3 py-2"
>
{listing.asking_price != null && (
@ -57,7 +63,7 @@ export const ListingPopupSingleContent = memo(function ListingPopupSingleContent
)}
{listing.floor_area_sqm != null && (
<div className="text-[11px] text-warm-500 dark:text-warm-400 mt-0.5">
{Math.round(listing.floor_area_sqm)} sqm
{Math.round(listing.floor_area_sqm)} {t('common.sqm')}
{listing.asking_price_per_sqm != null
? ` · £${Math.round(listing.asking_price_per_sqm).toLocaleString()}/sqm`
: ''}
@ -72,8 +78,9 @@ export const ListingPopupSingleContent = memo(function ListingPopupSingleContent
))}
</ul>
)}
<div className="mt-1.5 text-[11px] text-teal-600 dark:text-teal-400 font-medium">
Open listing
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] font-medium">
{visited && <span className="text-violet-600 dark:text-violet-400"> {t('listing.viewed')}</span>}
<span className="text-teal-600 dark:text-teal-400">{t('listing.openListing')} </span>
</div>
</a>
);
@ -82,9 +89,13 @@ export const ListingPopupSingleContent = memo(function ListingPopupSingleContent
export const ListingClusterPopupContent = memo(function ListingClusterPopupContent({
count,
listings,
clickedUrls,
onOpen,
}: {
count: number;
listings: ActualListing[];
clickedUrls: Set<string>;
onOpen: (url: string) => void;
}) {
const { t } = useTranslation();
const visibleCount = listings.length;
@ -92,32 +103,41 @@ export const ListingClusterPopupContent = memo(function ListingClusterPopupConte
<div>
<div className="border-b border-warm-200 px-3 py-2 dark:border-warm-700">
<div className="text-base font-bold text-red-600 dark:text-red-400">
{count.toLocaleString()} listings
{count.toLocaleString()} {t('listing.listings')}
</div>
<div className="text-[11px] text-warm-500 dark:text-warm-400">
{visibleCount > 0
? `Showing ${visibleCount.toLocaleString()} of ${count.toLocaleString()}`
: 'Grouped near this map position'}
? t('listing.showingOf', { visible: visibleCount, count })
: t('listing.groupedNear')}
</div>
</div>
{visibleCount > 0 && (
<div className="max-h-80 overflow-y-auto py-1">
{listings.map((listing, idx) => {
const headline = formatListingHeadline(listing, t);
const visited = clickedUrls.has(listing.listing_url);
return (
<a
key={`${listing.listing_url}-${idx}`}
href={listing.listing_url}
target="_blank"
rel="noopener noreferrer"
onClick={() => onOpen(listing.listing_url)}
className="block border-b border-warm-100 px-3 py-2 last:border-b-0 hover:bg-warm-50 dark:border-warm-700 dark:hover:bg-warm-700/60"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-teal-700 dark:text-teal-300">
{listing.asking_price != null
? formatListingPrice(listing.asking_price)
: 'Listing'}
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold text-teal-700 dark:text-teal-300">
{listing.asking_price != null
? formatListingPrice(listing.asking_price)
: t('listing.listing')}
</span>
{visited && (
<span className="shrink-0 text-[10px] font-medium text-violet-600 dark:text-violet-400">
Viewed
</span>
)}
</div>
{headline && (
<div className="mt-0.5 truncate text-xs text-warm-700 dark:text-warm-200">

View file

@ -452,6 +452,8 @@ export default memo(function Map({
visiblePois,
listingPopup,
clearListingPopup,
clickedListingUrls,
markListingClicked,
developmentPopup,
clearDevelopmentPopup,
hoverPosition,
@ -696,11 +698,17 @@ export default memo(function Map({
<CloseIcon className="w-3 h-3" />
</button>
{listingPopup.mode === 'single' ? (
<ListingPopupSingleContent listing={listingPopup.listing} />
<ListingPopupSingleContent
listing={listingPopup.listing}
clickedUrls={clickedListingUrls}
onOpen={markListingClicked}
/>
) : (
<ListingClusterPopupContent
count={listingPopup.count}
listings={listingPopup.listings}
clickedUrls={clickedListingUrls}
onOpen={markListingClicked}
/>
)}
</div>

View file

@ -1,6 +1,7 @@
import { Component, Fragment, type ReactNode } from 'react';
import * as Sentry from '@sentry/react';
import i18n from '../../i18n';
import { MapFallback } from './map-page/Fallbacks';
/**
@ -113,17 +114,19 @@ export class MapErrorBoundary extends Component<MapErrorBoundaryProps, MapErrorB
<div className="flex h-full w-full items-center justify-center bg-warm-100 px-6 text-center dark:bg-navy-950">
<div>
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">
The map ran into a problem
{i18n.t('map.error.heading', { defaultValue: 'The map ran into a problem' })}
</h2>
<p className="mt-2 text-sm text-warm-600 dark:text-warm-300">
This can happen when your browser&apos;s graphics context is interrupted.
{i18n.t('map.error.body', {
defaultValue: 'This can happen when your browsers graphics context is interrupted.',
})}
</p>
<button
type="button"
onClick={this.handleManualRetry}
className="mt-4 rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-400"
>
Reload the map
{i18n.t('map.error.reload', { defaultValue: 'Reload the map' })}
</button>
</div>
</div>

View file

@ -407,6 +407,7 @@ export default function MapPage({
handlePropertiesTabClick,
handleLoadMoreProperties,
handleCloseSelection,
loadCrimeRecords,
selectedPostcodeGeometry,
handleLocationSearch,
handleCurrentLocationSearch,
@ -806,6 +807,7 @@ export default function MapPage({
shareCode={shareCode}
isGroupExpanded={isAreaGroupExpanded}
onToggleGroup={toggleAreaGroup}
onLoadCrimeRecords={loadCrimeRecords}
scrollTopRef={areaPaneScrollTopRef}
scrollRestoreKey={
selectedHexagon ? `${selectedHexagon.type}:${selectedHexagon.id}` : null
@ -823,6 +825,7 @@ export default function MapPage({
hexagonLocation,
isAreaGroupExpanded,
loadingAreaStats,
loadCrimeRecords,
selectedHexagon,
setAreaStatsUseFilters,
shareCode,

View file

@ -105,7 +105,11 @@ export default function MobileDrawer({
return (
<div
data-tutorial="right-pane"
className="pointer-events-none fixed inset-0 z-50 flex flex-col"
// Pin to the dynamic viewport (100dvh) anchored at the top instead of
// `inset-0`. On iOS Safari a fixed element with `bottom: 0` is laid out
// against the large viewport, so when the bottom toolbar shows the panel's
// bottom (and the last collapsible group) hides behind it and can't be tapped.
className="pointer-events-none fixed inset-x-0 top-0 z-50 flex h-[100dvh] flex-col"
>
<div className="h-[10%] shrink-0" aria-hidden="true" />

View file

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { BASEMAPS, type BasemapId } from '../../lib/basemaps';
import { OVERLAYS, type OverlayDefinition, type OverlayId } from '../../lib/overlays';
import { CRIME_TYPES, CRIME_TYPE_VALUES } from '../../lib/crime-types';
import { tDynamic } from '../../i18n';
import { PillToggle } from '../ui/PillToggle';
import { IconButton } from '../ui/IconButton';
import { Slider } from '../ui/Slider';
@ -12,6 +13,18 @@ import { colorOpacityToPercent, normalizeColorOpacity } from '../../lib/color-op
const CRIME_OVERLAY_ID: OverlayId = 'crime-hotspots';
// Maps the kebab-case overlay id to its camelCase i18n key under `overlays.*`.
const OVERLAY_I18N_KEY: Record<OverlayId, string> = {
noise: 'noise',
'crime-hotspots': 'crimeHotspots',
'trees-outside-woodlands': 'treesOutsideWoodlands',
'property-borders': 'propertyBorders',
'new-developments': 'newDevelopments',
};
const overlayLabel = (id: OverlayId) => tDynamic(`overlays.${OVERLAY_I18N_KEY[id]}.label`);
const overlayDetail = (id: OverlayId) => tDynamic(`overlays.${OVERLAY_I18N_KEY[id]}.detail`);
interface OverlayPaneProps {
selectedOverlays: Set<OverlayId>;
onOverlaysChange: (overlays: Set<OverlayId>) => void;
@ -78,7 +91,7 @@ export default function OverlayPane({
<div className="flex-shrink-0 px-3 pt-3 pb-2">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide">
Overlays
{t('overlays.heading')}
</span>
<span className="text-xs text-warm-400 dark:text-warm-500">
{selectedOverlays.size}/{OVERLAYS.length}
@ -88,13 +101,13 @@ export default function OverlayPane({
onClick={selectNone}
className="rounded border border-warm-300 px-2 py-0.5 text-xs text-warm-600 hover:bg-warm-50 dark:border-warm-700 dark:text-warm-400 dark:hover:bg-warm-700"
>
None
{t('common.none')}
</button>
{onClose && (
<button
onClick={onClose}
className="ml-1 p-0.5 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
title="Close"
title={t('common.close')}
>
<CloseIcon className="h-4 w-4" />
</button>
@ -106,8 +119,7 @@ export default function OverlayPane({
role="alert"
className="mt-2 rounded border border-amber-300 bg-amber-50 px-2 py-1.5 text-xs text-amber-800 dark:border-amber-700/60 dark:bg-amber-900/30 dark:text-amber-200"
>
Zoom in further to see the selected{' '}
{selectedOverlays.size === 1 ? 'overlay' : 'overlays'}.
{t('overlays.zoomWarning', { count: selectedOverlays.size })}
</div>
)}
</div>
@ -115,13 +127,13 @@ export default function OverlayPane({
<div className="min-h-0 space-y-4 overflow-y-auto overscroll-contain border-t border-warm-200 px-3 py-3 dark:border-warm-700">
<div>
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wide text-warm-400 dark:text-warm-500">
Base map
{t('overlays.baseMap')}
</div>
<div className="flex flex-wrap gap-1.5">
{BASEMAPS.map((option) => (
<PillToggle
key={option.id}
label={option.label}
label={tDynamic(`map.basemap.${option.id}`)}
active={basemap === option.id}
onClick={() => onBasemapChange(option.id)}
size="sm"
@ -133,7 +145,7 @@ export default function OverlayPane({
<div>
<div className="mb-2 flex items-center justify-between gap-2">
<span className="text-[10px] font-semibold uppercase tracking-wide text-warm-400 dark:text-warm-500">
Colour opacity
{t('overlays.colourOpacity')}
</span>
<span className="text-[10px] font-medium tabular-nums text-warm-500 dark:text-warm-400">
{colorOpacityPercent}%
@ -145,27 +157,27 @@ export default function OverlayPane({
step={5}
value={[colorOpacityPercent]}
onValueChange={handleColorOpacityChange}
aria-label="Colour opacity"
aria-label={t('overlays.colourOpacity')}
/>
</div>
<div>
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wide text-warm-400 dark:text-warm-500">
Data overlays
{t('overlays.dataOverlays')}
</div>
<div className="flex flex-wrap gap-1.5">
{OVERLAYS.map((overlay) => (
<div key={overlay.id} className="inline-flex items-center gap-0.5">
<PillToggle
label={overlay.label}
label={overlayLabel(overlay.id)}
active={selectedOverlays.has(overlay.id)}
onClick={() => toggleOverlay(overlay.id)}
size="sm"
/>
<IconButton
onClick={() => setInfoOverlay(overlay)}
title={`About ${overlay.label}`}
ariaLabel={`About ${overlay.label}`}
title={t('overlays.about', { name: overlayLabel(overlay.id) })}
ariaLabel={t('overlays.about', { name: overlayLabel(overlay.id) })}
>
<InfoIcon className="h-3.5 w-3.5" />
</IconButton>
@ -188,13 +200,13 @@ export default function OverlayPane({
onClick={selectAllCrimeTypes}
className="rounded border border-warm-300 px-1.5 py-0.5 text-[10px] text-warm-600 hover:bg-warm-50 dark:border-warm-700 dark:text-warm-400 dark:hover:bg-warm-700"
>
All
{t('common.all')}
</button>
<button
onClick={selectNoCrimeTypes}
className="rounded border border-warm-300 px-1.5 py-0.5 text-[10px] text-warm-600 hover:bg-warm-50 dark:border-warm-700 dark:text-warm-400 dark:hover:bg-warm-700"
>
None
{t('common.none')}
</button>
</div>
</div>
@ -210,7 +222,7 @@ export default function OverlayPane({
onChange={() => toggleCrimeType(crime.value)}
className="h-3.5 w-3.5 shrink-0 rounded border-warm-300 text-amber-600 focus:ring-1 focus:ring-amber-500 dark:border-warm-600 dark:bg-warm-800"
/>
<span>{crime.label}</span>
<span>{tDynamic(crime.labelKey)}</span>
</label>
))}
</div>
@ -219,9 +231,9 @@ export default function OverlayPane({
</div>
{infoOverlay && (
<InfoPopup title={infoOverlay.label} onClose={() => setInfoOverlay(null)}>
<InfoPopup title={overlayLabel(infoOverlay.id)} onClose={() => setInfoOverlay(null)}>
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
{infoOverlay.detail}
{overlayDetail(infoOverlay.id)}
</p>
</InfoPopup>
)}

View file

@ -1,4 +1,6 @@
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import type { SchoolMetadata } from '../../types';
import { POI_GROUP_COLORS } from '../../lib/consts';
@ -32,7 +34,7 @@ function normalizeSchoolWebsiteUrl(raw: string): string | null {
return null;
}
function renderSchoolMetadata(school: SchoolMetadata) {
function renderSchoolMetadata(school: SchoolMetadata, t: TFunction) {
// First line collects the headline classification (phase, type, religious
// character) so the popup is scannable even when most fields are absent.
const headline: string[] = [];
@ -41,11 +43,11 @@ function renderSchoolMetadata(school: SchoolMetadata) {
const pupilsLine =
school.pupils !== undefined && school.capacity !== undefined
? `${school.pupils.toLocaleString()} / ${school.capacity.toLocaleString()} pupils`
? `${school.pupils.toLocaleString()} / ${school.capacity.toLocaleString()} ${t('poiPopup.school.pupils')}`
: school.pupils !== undefined
? `${school.pupils.toLocaleString()} pupils`
? `${school.pupils.toLocaleString()} ${t('poiPopup.school.pupils')}`
: school.capacity !== undefined
? `Capacity ${school.capacity.toLocaleString()}`
? `${t('poiPopup.school.capacity')} ${school.capacity.toLocaleString()}`
: null;
const websiteUrl = school.website ? normalizeSchoolWebsiteUrl(school.website) : null;
@ -54,69 +56,69 @@ function renderSchoolMetadata(school: SchoolMetadata) {
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 text-xs text-warm-600 dark:text-warm-300">
{headline.length > 0 && (
<>
<dt className="text-warm-500 dark:text-warm-400">Type</dt>
<dt className="text-warm-500 dark:text-warm-400">{t('poiPopup.school.type')}</dt>
<dd className="dark:text-warm-200">{headline.join(' · ')}</dd>
</>
)}
{school.age_range && (
<>
<dt className="text-warm-500 dark:text-warm-400">Ages</dt>
<dt className="text-warm-500 dark:text-warm-400">{t('poiPopup.school.ages')}</dt>
<dd className="dark:text-warm-200">{school.age_range}</dd>
</>
)}
{school.gender && school.gender !== 'Mixed' && (
<>
<dt className="text-warm-500 dark:text-warm-400">Gender</dt>
<dt className="text-warm-500 dark:text-warm-400">{t('poiPopup.school.gender')}</dt>
<dd className="dark:text-warm-200">{school.gender}</dd>
</>
)}
{pupilsLine && (
<>
<dt className="text-warm-500 dark:text-warm-400">Pupils</dt>
<dt className="text-warm-500 dark:text-warm-400">{t('poiPopup.school.pupilsLabel')}</dt>
<dd className="dark:text-warm-200">{pupilsLine}</dd>
</>
)}
{school.fsm_percent !== undefined && (
<>
<dt className="text-warm-500 dark:text-warm-400">Free meal</dt>
<dt className="text-warm-500 dark:text-warm-400">{t('poiPopup.school.freeMeal')}</dt>
<dd className="dark:text-warm-200">{school.fsm_percent.toFixed(1)}%</dd>
</>
)}
{school.ofsted_rating && (
<>
<dt className="text-warm-500 dark:text-warm-400">Ofsted</dt>
<dt className="text-warm-500 dark:text-warm-400">{t('poiPopup.school.ofsted')}</dt>
<dd className="dark:text-warm-200">{school.ofsted_rating}</dd>
</>
)}
{school.sixth_form === 'Has a sixth form' && (
<>
<dt className="text-warm-500 dark:text-warm-400">Sixth form</dt>
<dd className="dark:text-warm-200">Yes</dd>
<dt className="text-warm-500 dark:text-warm-400">{t('poiPopup.school.sixthForm')}</dt>
<dd className="dark:text-warm-200">{t('common.yes')}</dd>
</>
)}
{school.religious_character &&
school.religious_character !== 'Does not apply' &&
school.religious_character !== 'None' && (
<>
<dt className="text-warm-500 dark:text-warm-400">Religion</dt>
<dt className="text-warm-500 dark:text-warm-400">{t('poiPopup.school.religion')}</dt>
<dd className="dark:text-warm-200">{school.religious_character}</dd>
</>
)}
{school.admissions_policy && (
<>
<dt className="text-warm-500 dark:text-warm-400">Admissions</dt>
<dt className="text-warm-500 dark:text-warm-400">{t('poiPopup.school.admissions')}</dt>
<dd className="dark:text-warm-200">{school.admissions_policy}</dd>
</>
)}
{school.trust && (
<>
<dt className="text-warm-500 dark:text-warm-400">Trust</dt>
<dt className="text-warm-500 dark:text-warm-400">{t('poiPopup.school.trust')}</dt>
<dd className="dark:text-warm-200">{school.trust}</dd>
</>
)}
{(school.address || school.postcode) && (
<>
<dt className="text-warm-500 dark:text-warm-400">Address</dt>
<dt className="text-warm-500 dark:text-warm-400">{t('poiPopup.school.address')}</dt>
<dd className="dark:text-warm-200">
{[school.address, school.postcode].filter(Boolean).join(', ')}
</dd>
@ -124,19 +126,19 @@ function renderSchoolMetadata(school: SchoolMetadata) {
)}
{school.local_authority && (
<>
<dt className="text-warm-500 dark:text-warm-400">LA</dt>
<dt className="text-warm-500 dark:text-warm-400">{t('poiPopup.school.localAuthority')}</dt>
<dd className="dark:text-warm-200">{school.local_authority}</dd>
</>
)}
{school.head_name && (
<>
<dt className="text-warm-500 dark:text-warm-400">Head</dt>
<dt className="text-warm-500 dark:text-warm-400">{t('poiPopup.school.head')}</dt>
<dd className="dark:text-warm-200">{school.head_name}</dd>
</>
)}
{websiteUrl && (
<>
<dt className="text-warm-500 dark:text-warm-400">Website</dt>
<dt className="text-warm-500 dark:text-warm-400">{t('poiPopup.school.website')}</dt>
<dd className="truncate">
<a
href={websiteUrl}
@ -158,6 +160,7 @@ export const PoiPopupCardContent = memo(function PoiPopupCardContent({
}: {
poi: PoiPopupCardData;
}) {
const { t } = useTranslation();
return (
<div className="px-3 py-2 max-w-[280px]">
<div className="flex items-center gap-2">
@ -182,7 +185,7 @@ export const PoiPopupCardContent = memo(function PoiPopupCardContent({
</div>
</div>
</div>
{poi.school && renderSchoolMetadata(poi.school)}
{poi.school && renderSchoolMetadata(poi.school, t)}
</div>
);
});

View file

@ -79,7 +79,11 @@ export function PropertiesPane({
return (
<div className="relative flex h-full flex-col">
<IndeterminateProgressBar show={loading && properties.length > 0} />
<div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto">
<div
ref={scrollRef}
onScroll={onScroll}
className="flex-1 overflow-y-auto pb-[env(safe-area-inset-bottom)]"
>
{showInfo && (
<InfoPopup
title={t('propertyCard.propertyData')}

View file

@ -18,6 +18,7 @@ interface StackedBarChartProps {
/** Strip common suffixes/prefixes to produce short legend labels */
function shortenLabel(name: string): string {
return name
.replace(/ \(\/yr, \d+y\)$/, '')
.replace(' (avg/yr)', '')
.replace(/^% /, '')
.replace('and sexual offences', '')

View file

@ -3,12 +3,32 @@ import { useMemo } from 'react';
import type { FeatureFilters, FeatureMeta } from '../../../types';
import type { PercentileScale } from '../../../lib/format';
import { groupFeaturesByCategory } from '../../../lib/features';
import { getSpecificCrimeFeatureName, isSpecificCrimeFilterName } from '../../../lib/crime-filter';
import {
SPECIFIC_CRIME_VARIANT_CONFIG,
getSpecificCrimeFeatureName,
isSpecificCrimeFilterName,
} from '../../../lib/crime-filter';
import {
getCrimeSeverityFeatureName,
getCrimeSeverityFilterName,
getCrimeSeverityVariantConfig,
isCrimeSeverityFilterName,
} from '../../../lib/crime-severity-filter';
import {
getElectionVoteShareFeatureName,
isElectionVoteShareFilterName,
} from '../../../lib/election-filter';
import { getEthnicityFeatureName, isEthnicityFilterName } from '../../../lib/ethnicity-filter';
import {
QUALIFICATION_VARIANT_CONFIG,
getQualificationFeatureName,
isQualificationFilterName,
} from '../../../lib/qualification-filter';
import {
TENURE_VARIANT_CONFIG,
getTenureFeatureName,
isTenureFilterName,
} from '../../../lib/tenure-filter';
import { getSchoolBackendFeatureName, isSchoolFilterName } from '../../../lib/school-filter';
import {
getPoiDistanceFeatureName,
@ -18,7 +38,7 @@ import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
import { EthnicityFilterCard } from './EthnicityFilterCard';
import { PoiDistanceFilterCard } from './PoiDistanceFilterCard';
import { SchoolFilterCard } from './SchoolFilterCard';
import { SpecificCrimeFilterCard } from './SpecificCrimeFilterCard';
import { VariantFilterCard } from './VariantFilterCard';
import { ElectionVoteShareFilterCard } from './ElectionVoteShareFilterCard';
import { EnumFeatureFilterCard } from './EnumFeatureFilterCard';
import { NumericFeatureFilterCard } from './NumericFeatureFilterCard';
@ -40,6 +60,10 @@ interface ActiveFilterListProps {
destinationDropdownPortal: boolean;
isGroupExpanded: (name: string) => boolean;
onToggleGroup: (name: string) => void;
/** Registers a group wrapper so an expanded group can be scrolled into view. */
registerGroup?: (name: string) => (node: HTMLDivElement | null) => void;
/** Notifies the reveal-on-expand mechanism when a group is toggled. */
onGroupToggle?: (name: string, willExpand: boolean) => void;
onFilterChange: (name: string, value: [number, number] | string[]) => void;
onRemoveFilter: (name: string) => void;
onDragStart: (name: string, initialValue?: [number, number]) => void;
@ -76,6 +100,8 @@ export function ActiveFilterList({
destinationDropdownPortal,
isGroupExpanded,
onToggleGroup,
registerGroup,
onGroupToggle,
onFilterChange,
onRemoveFilter,
onDragStart,
@ -152,10 +178,11 @@ export function ActiveFilterList({
if (isSpecificCrimeFilterName(feature.name)) {
const specificCrimeBackendName = getSpecificCrimeFeatureName(feature.name);
return (
<SpecificCrimeFilterCard
<VariantFilterCard
key={feature.name}
config={SPECIFIC_CRIME_VARIANT_CONFIG}
features={features}
crimeFeature={feature}
variantFeature={feature}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
@ -177,6 +204,91 @@ export function ActiveFilterList({
);
}
if (isCrimeSeverityFilterName(feature.name)) {
const crimeSeverityBackendName = getCrimeSeverityFeatureName(feature.name);
const crimeSeverityFilterName = getCrimeSeverityFilterName(feature.name);
if (!crimeSeverityFilterName) return null;
return (
<VariantFilterCard
key={feature.name}
config={getCrimeSeverityVariantConfig(crimeSeverityFilterName)}
features={features}
variantFeature={feature}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
pinnedFeature={pinnedFeature}
filterImpact={
crimeSeverityBackendName ? filterImpacts?.[crimeSeverityBackendName] : undefined
}
percentileScale={
crimeSeverityBackendName ? percentileScales.get(crimeSeverityBackendName) : undefined
}
onFilterChange={onFilterChange}
onDragStart={onDragStart}
onDragChange={onDragChange}
onDragEnd={onDragEnd}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onRemove={() => onRemoveFilter(feature.name)}
/>
);
}
if (isQualificationFilterName(feature.name)) {
const qualificationBackendName = getQualificationFeatureName(feature.name);
return (
<VariantFilterCard
key={feature.name}
config={QUALIFICATION_VARIANT_CONFIG}
features={features}
variantFeature={feature}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
pinnedFeature={pinnedFeature}
filterImpact={
qualificationBackendName ? filterImpacts?.[qualificationBackendName] : undefined
}
percentileScale={
qualificationBackendName ? percentileScales.get(qualificationBackendName) : undefined
}
onFilterChange={onFilterChange}
onDragStart={onDragStart}
onDragChange={onDragChange}
onDragEnd={onDragEnd}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onRemove={() => onRemoveFilter(feature.name)}
/>
);
}
if (isTenureFilterName(feature.name)) {
const tenureBackendName = getTenureFeatureName(feature.name);
return (
<VariantFilterCard
key={feature.name}
config={TENURE_VARIANT_CONFIG}
features={features}
variantFeature={feature}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
pinnedFeature={pinnedFeature}
filterImpact={tenureBackendName ? filterImpacts?.[tenureBackendName] : undefined}
percentileScale={tenureBackendName ? percentileScales.get(tenureBackendName) : undefined}
onFilterChange={onFilterChange}
onDragStart={onDragStart}
onDragChange={onDragChange}
onDragEnd={onDragEnd}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onRemove={() => onRemoveFilter(feature.name)}
/>
);
}
if (isElectionVoteShareFilterName(feature.name)) {
const electionVoteShareBackendName = getElectionVoteShareFeatureName(feature.name);
return (
@ -298,11 +410,14 @@ export function ActiveFilterList({
if (count === 0) return null;
const expanded = isGroupExpanded(group.name);
return (
<div key={group.name} className="shrink-0">
<div key={group.name} className="shrink-0" ref={registerGroup?.(group.name)}>
<CollapsibleGroupHeader
name={group.name}
expanded={expanded}
onToggle={() => onToggleGroup(group.name)}
onToggle={() => {
onToggleGroup(group.name);
onGroupToggle?.(group.name, !expanded);
}}
className="sticky top-0 z-30 px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 hover:bg-warm-200 dark:hover:bg-warm-800"
>
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">{count}</span>

View file

@ -1,6 +1,7 @@
import type { RefObject } from 'react';
import { useCallback, type MutableRefObject, type RefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { useRevealOnExpand } from '../../../hooks/useRevealOnExpand';
import type { AiFilterErrorType } from '../../../hooks/useAiFilters';
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
import type { PercentileScale } from '../../../lib/format';
@ -107,6 +108,16 @@ export function ActiveFiltersPanel({
onTravelTimeToggleNoBuses,
}: ActiveFiltersPanelProps) {
const { t } = useTranslation();
const { setContainer, registerGroup, onToggle: revealGroupOnToggle } = useRevealOnExpand();
// Compose the parent-owned scrollRef (used to locate newly added filters) with
// the reveal-on-expand container ref so both point at the same scroll element.
const setScrollNode = useCallback(
(node: HTMLDivElement | null) => {
(scrollRef as MutableRefObject<HTMLDivElement | null>).current = node;
setContainer(node);
},
[scrollRef, setContainer]
);
return (
<div
@ -156,7 +167,10 @@ export function ActiveFiltersPanel({
</button>
{!collapsed && (
<div ref={scrollRef} className="md:min-h-0 md:flex-1 md:overflow-y-auto overflow-x-hidden">
<div
ref={setScrollNode}
className="md:min-h-0 md:flex-1 md:overflow-y-auto overflow-x-hidden"
>
<AiFilterInput
loading={aiFilterLoading}
error={aiFilterError}
@ -196,6 +210,8 @@ export function ActiveFiltersPanel({
destinationDropdownPortal={destinationDropdownPortal}
isGroupExpanded={isGroupExpanded}
onToggleGroup={onToggleGroup}
registerGroup={registerGroup}
onGroupToggle={revealGroupOnToggle}
onFilterChange={onFilterChange}
onRemoveFilter={onRemoveFilter}
onDragStart={onDragStart}

View file

@ -1,15 +1,27 @@
import { useTranslation } from 'react-i18next';
import { useRevealOnExpand } from '../../../hooks/useRevealOnExpand';
import type { TravelTimeEntry, TransportMode } from '../../../hooks/useTravelTime';
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 {
CRIME_SEVERITY_FILTER_NAMES,
getCrimeSeverityFilterName,
isCrimeSeverityFilterName,
type CrimeSeverityFilterName,
} from '../../../lib/crime-severity-filter';
import {
ELECTION_VOTE_SHARE_FILTER_NAME,
isElectionVoteShareFilterName,
} from '../../../lib/election-filter';
import { ETHNICITIES_FILTER_NAME, isEthnicityFilterName } from '../../../lib/ethnicity-filter';
import {
QUALIFICATIONS_FILTER_NAME,
isQualificationFilterName,
} from '../../../lib/qualification-filter';
import { TENURE_FILTER_NAME, isTenureFilterName } from '../../../lib/tenure-filter';
import { SCHOOL_FILTER_NAME, isSchoolFilterName } from '../../../lib/school-filter';
import {
POI_DISTANCE_FILTER_NAME,
@ -27,8 +39,11 @@ interface AddFilterPanelProps {
pinnedFeature: string | null;
defaultSchoolFeatureName: string | null;
defaultSpecificCrimeFeatureName: string | null;
defaultCrimeSeverityFeatureNames: Record<CrimeSeverityFilterName, string | null>;
defaultElectionVoteShareFeatureName: string | null;
defaultEthnicityFeatureName: string | null;
defaultQualificationFeatureName: string | null;
defaultTenureFeatureName: string | null;
defaultPoiFilterFeatureNames: Record<PoiFilterName, string | null>;
openInfoFeature?: string | null;
travelTimeEntries: TravelTimeEntry[];
@ -49,8 +64,11 @@ export function AddFilterPanel({
pinnedFeature,
defaultSchoolFeatureName,
defaultSpecificCrimeFeatureName,
defaultCrimeSeverityFeatureNames,
defaultElectionVoteShareFeatureName,
defaultEthnicityFeatureName,
defaultQualificationFeatureName,
defaultTenureFeatureName,
defaultPoiFilterFeatureNames,
openInfoFeature,
travelTimeEntries,
@ -63,6 +81,7 @@ export function AddFilterPanel({
onUpgradeClick,
}: AddFilterPanelProps) {
const { t } = useTranslation();
const { setContainer, registerGroup, onToggle: revealGroupOnToggle } = useRevealOnExpand();
const browserPinnedFeature =
pinnedFeature && isSchoolFilterName(pinnedFeature)
@ -73,9 +92,15 @@ export function AddFilterPanel({
? ELECTION_VOTE_SHARE_FILTER_NAME
: pinnedFeature && isEthnicityFilterName(pinnedFeature)
? ETHNICITIES_FILTER_NAME
: pinnedFeature && isPoiDistanceFilterName(pinnedFeature)
? (getPoiFilterName(pinnedFeature) ?? POI_DISTANCE_FILTER_NAME)
: pinnedFeature;
: pinnedFeature && isQualificationFilterName(pinnedFeature)
? QUALIFICATIONS_FILTER_NAME
: pinnedFeature && isTenureFilterName(pinnedFeature)
? TENURE_FILTER_NAME
: pinnedFeature && isPoiDistanceFilterName(pinnedFeature)
? (getPoiFilterName(pinnedFeature) ?? POI_DISTANCE_FILTER_NAME)
: pinnedFeature && isCrimeSeverityFilterName(pinnedFeature)
? (getCrimeSeverityFilterName(pinnedFeature) ?? pinnedFeature)
: pinnedFeature;
const handleTogglePin = (name: string) => {
if (name === SCHOOL_FILTER_NAME) {
@ -94,6 +119,20 @@ export function AddFilterPanel({
if (defaultEthnicityFeatureName) onTogglePin(defaultEthnicityFeatureName);
return;
}
if (name === QUALIFICATIONS_FILTER_NAME) {
if (defaultQualificationFeatureName) onTogglePin(defaultQualificationFeatureName);
return;
}
if (name === TENURE_FILTER_NAME) {
if (defaultTenureFeatureName) onTogglePin(defaultTenureFeatureName);
return;
}
if (CRIME_SEVERITY_FILTER_NAMES.includes(name as CrimeSeverityFilterName)) {
const defaultSeverityFeatureName =
defaultCrimeSeverityFeatureNames[name as CrimeSeverityFilterName];
if (defaultSeverityFeatureName) onTogglePin(defaultSeverityFeatureName);
return;
}
if (POI_FILTER_NAMES.includes(name as PoiFilterName)) {
const defaultPoiFeatureName = defaultPoiFilterFeatureNames[name as PoiFilterName];
if (defaultPoiFeatureName) onTogglePin(defaultPoiFeatureName);
@ -123,7 +162,7 @@ export function AddFilterPanel({
{(!collapsed || !isLicensed) && (
<div className="flex min-h-0 flex-1 flex-col">
{!collapsed && (
<div className="min-h-0 flex-1 overflow-y-auto">
<div ref={setContainer} className="min-h-0 flex-1 overflow-y-auto">
<FeatureBrowser
availableFeatures={availableFeatures}
allFeatures={allFeatures}
@ -135,6 +174,8 @@ export function AddFilterPanel({
onClearOpenInfoFeature={onClearOpenInfoFeature}
travelTimeEntries={travelTimeEntries}
onAddTravelTimeEntry={onAddTravelTimeEntry}
registerGroup={registerGroup}
onGroupToggle={revealGroupOnToggle}
/>
</div>
)}

View file

@ -5,23 +5,22 @@ import { ChevronIcon } from '../../ui/icons';
import { FeatureActions } from '../../ui/FeatureIcons';
import { FeatureLabel } from '../../ui/FeatureLabel';
import type { FeatureFilters, FeatureMeta } from '../../../types';
import type { VariantFilterConfig } from '../../../lib/variant-filter';
import { formatNumber, type PercentileScale } from '../../../lib/format';
import { getFeatureIcon } from '../../../lib/feature-icons';
import { getGroupIcon } from '../../../lib/group-icons';
import {
SPECIFIC_CRIMES_FILTER_NAME,
SPECIFIC_CRIME_FEATURE_NAMES,
clampSpecificCrimeRange,
getDefaultSpecificCrimeFeatureName,
getSpecificCrimeFeatureName,
getSpecificCrimeFilterMeta,
replaceSpecificCrimeFilterKeySelection,
} from '../../../lib/crime-filter';
import { SliderLabels } from './SliderLabels';
export function SpecificCrimeFilterCard({
/**
* One card for a "pick a variant, filter its range" aggregate filter
* (specific crimes, qualifications, ). Behaviour is variant-agnostic: the
* `config` supplies the dropdown options, labels and helper functions so the
* same component backs every such filter (see [`VariantFilterConfig`]).
*/
export function VariantFilterCard({
config,
features,
crimeFeature,
variantFeature,
filters,
activeFeature,
dragValue,
@ -36,8 +35,9 @@ export function SpecificCrimeFilterCard({
onShowInfo,
onRemove,
}: {
config: VariantFilterConfig;
features: FeatureMeta[];
crimeFeature: FeatureMeta;
variantFeature: FeatureMeta;
filters: FeatureFilters;
activeFeature: string | null;
dragValue: [number, number] | null;
@ -53,27 +53,62 @@ export function SpecificCrimeFilterCard({
onRemove: () => void;
}) {
const { t } = useTranslation();
const specificCrimeMeta = getSpecificCrimeFilterMeta(features);
const crimeOptions = SPECIFIC_CRIME_FEATURE_NAMES.map((name) =>
features.find((feature) => feature.name === name)
).filter((feature): feature is FeatureMeta => Boolean(feature));
const variantMeta = config.getFilterMeta(features);
const selectedFeatureName =
getSpecificCrimeFeatureName(crimeFeature.name) ?? getDefaultSpecificCrimeFeatureName(features);
config.getFeatureName(variantFeature.name) ?? config.getDefaultFeatureName(features);
const selectedFeature = selectedFeatureName
? features.find((feature) => feature.name === selectedFeatureName)
: undefined;
if (!selectedFeature || crimeOptions.length === 0 || !selectedFeatureName) return null;
// Optional secondary axis: the same variant measured over a different window
// (e.g. 7- vs 2-year crime rates). The current window comes from the selected
// feature so the dropdown can re-point each option into that window.
const windowConfig = config.window;
const currentWindow =
(windowConfig && selectedFeatureName ? windowConfig.getWindow(selectedFeatureName) : null) ??
windowConfig?.options[0]?.id ??
null;
const isActive = activeFeature === crimeFeature.name;
const isPinned = pinnedFeature === crimeFeature.name;
const variantOptions = config.featureNames
.map((canonicalName) => {
const optionName =
windowConfig && currentWindow
? windowConfig.withWindow(canonicalName, currentWindow)
: canonicalName;
const feature = features.find((f) => f.name === optionName);
if (!feature) return null;
return {
value: optionName,
label: ts(config.getOptionLabelSource ? config.getOptionLabelSource(optionName) : optionName),
feature,
};
})
.filter((option): option is { value: string; label: string; feature: FeatureMeta } =>
Boolean(option)
);
// Only offer windows that actually exist for the selected variant, so a
// missing backend column can't strand the card on an empty selection.
const windowOptions =
windowConfig && selectedFeatureName
? windowConfig.options.filter((option) =>
features.some(
(f) => f.name === windowConfig.withWindow(selectedFeatureName, option.id)
)
)
: [];
if (!selectedFeature || variantOptions.length === 0 || !selectedFeatureName) return null;
const isActive = activeFeature === variantFeature.name;
const isPinned = pinnedFeature === variantFeature.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[crimeFeature.name] as [number, number]) || [dataMin, dataMax];
: (filters[variantFeature.name] as [number, number]) || [dataMin, dataMax];
const scale = percentileScale;
const clampMin = displayValue[0] <= dataMin;
const clampMax = displayValue[1] >= dataMax;
@ -89,14 +124,14 @@ export function SpecificCrimeFilterCard({
clampMax ? (selectedFeature.max ?? dataMax) : displayValue[1],
];
const replaceCrimeFeature = (nextFeatureName: string) => {
const nextName = replaceSpecificCrimeFilterKeySelection(crimeFeature.name, nextFeatureName);
if (nextName === crimeFeature.name) return;
const replaceVariantFeature = (nextFeatureName: string) => {
const nextName = config.replaceFilterKeySelection(variantFeature.name, nextFeatureName);
if (nextName === variantFeature.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 = clampSpecificCrimeRange(
const nextRange = config.clampRange(
[
displayValue[0] <= dataMin ? nextDataMin : displayValue[0],
displayValue[1] >= dataMax ? nextDataMax : displayValue[1],
@ -108,6 +143,11 @@ export function SpecificCrimeFilterCard({
if (isPinned) onTogglePin(nextName);
};
const switchWindow = (windowId: string) => {
if (!windowConfig || windowId === currentWindow) return;
replaceVariantFeature(windowConfig.withWindow(selectedFeatureName, windowId));
};
const mobileIconClass = 'w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0';
const mobileIcon =
getFeatureIcon(selectedFeature.name, mobileIconClass) ||
@ -118,7 +158,7 @@ export function SpecificCrimeFilterCard({
return (
<div
data-filter-name={SPECIFIC_CRIMES_FILTER_NAME}
data-filter-name={config.filterName}
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'
@ -129,7 +169,7 @@ export function SpecificCrimeFilterCard({
>
<div className="relative z-10 flex items-center justify-between gap-1">
<FeatureLabel
feature={specificCrimeMeta}
feature={variantMeta}
size="sm"
className="min-w-0 shrink"
hideIconOnMobile
@ -137,7 +177,7 @@ export function SpecificCrimeFilterCard({
/>
<FeatureActions
feature={selectedFeature}
actionName={crimeFeature.name}
actionName={variantFeature.name}
isPinned={isPinned}
isPreviewing={isActive}
onTogglePin={onTogglePin}
@ -146,28 +186,65 @@ export function SpecificCrimeFilterCard({
/>
</div>
<div>
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
{t('filters.crimeType')}
</label>
<div className="relative">
<select
value={selectedFeatureName}
onChange={(e) => replaceCrimeFeature(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"
>
{crimeOptions.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"
/>
{/* A single-variant filter (e.g. Serious/Minor crime) has nothing to pick,
so the dropdown is hidden and only the window toggle + slider remain. */}
{variantOptions.length > 1 && (
<div>
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
{t(config.dropdownLabelKey)}
</label>
<div className="relative">
<select
value={selectedFeatureName}
onChange={(e) => replaceVariantFeature(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"
>
{variantOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</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>
)}
{windowConfig && currentWindow && windowOptions.length > 1 && (
<div>
{windowConfig.labelKey && (
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
{t(windowConfig.labelKey)}
</label>
)}
<div
role="group"
className="inline-flex rounded-md border border-warm-200 bg-warm-50 p-0.5 dark:border-warm-700 dark:bg-navy-900"
>
{windowOptions.map((option) => {
const active = option.id === currentWindow;
return (
<button
key={option.id}
type="button"
aria-pressed={active}
onClick={() => switchWindow(option.id)}
className={`rounded px-2.5 py-1 text-xs font-medium transition-colors ${
active
? 'bg-teal-600 text-white shadow-sm'
: 'text-warm-500 hover:text-navy-950 dark:text-warm-400 dark:hover:text-warm-100'
}`}
>
{t(option.labelKey)}
</button>
);
})}
</div>
</div>
)}
<div className="flex items-start gap-1.5 md:block">
{mobileIcon && <div className="shrink-0 pt-0.5 md:hidden">{mobileIcon}</div>}
@ -198,7 +275,7 @@ export function SpecificCrimeFilterCard({
max >= (selectedFeature.max ?? dataMax) ? dataMax : max,
])
}
onPointerDown={() => onDragStart(crimeFeature.name, displayValue)}
onPointerDown={() => onDragStart(variantFeature.name, displayValue)}
onPointerUp={() => onDragEnd()}
/>
<SliderLabels
@ -211,7 +288,7 @@ export function SpecificCrimeFilterCard({
raw={selectedFeature.raw}
feature={selectedFeature}
onValueChange={(v) =>
onFilterChange(crimeFeature.name, clampSpecificCrimeRange(v, selectedFeature))
onFilterChange(variantFeature.name, config.clampRange(v, selectedFeature))
}
/>
{filterImpact != null && filterImpact > 0 && (

View file

@ -236,8 +236,8 @@ export function DesktopMapPage({
onClick={onToggleActualListings}
aria-pressed={actualListingsEnabled}
aria-busy={actualListingsLoading}
aria-label={actualListingsEnabled ? 'Hide actual listings' : 'Show actual listings'}
title={actualListingsEnabled ? 'Hide actual listings' : 'Show actual listings'}
aria-label={actualListingsEnabled ? t('map.actualListings.hide') : t('map.actualListings.show')}
title={actualListingsEnabled ? t('map.actualListings.hide') : t('map.actualListings.show')}
className={`flex items-center gap-2 rounded-lg bg-white px-3 py-2 shadow-lg dark:bg-warm-800 ${actualListingsEnabled ? 'text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300' : 'text-warm-500 hover:text-red-600 dark:text-warm-400 dark:hover:text-red-400'}`}
>
{actualListingsLoading ? (
@ -246,16 +246,17 @@ export function DesktopMapPage({
<HouseIcon className="h-5 w-5" />
)}
<span className="text-sm font-medium">
Listings{actualListingsEnabled ? ` (${actualListings.length})` : ''}
{t('map.actualListings.label')}{actualListingsEnabled ? ` (${actualListings.length})` : ''}
</span>
</button>
)}
<button
data-tutorial="overlays-button"
onClick={onToggleOverlayPane}
className={`flex items-center gap-2 rounded-lg bg-white px-3 py-2 shadow-lg dark:bg-warm-800 ${overlayPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 hover:text-teal-600 dark:text-warm-400 dark:hover:text-teal-400'}`}
>
<EyeIcon className="h-5 w-5" filled={overlayPaneOpen} />
<span className="text-sm font-medium">Overlays</span>
<span className="text-sm font-medium">{t('overlays.heading')}</span>
</button>
<button
data-tutorial="poi-button"

View file

@ -1,4 +1,5 @@
import { Suspense, type MutableRefObject, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import type {
ActualListing,
@ -133,6 +134,7 @@ export function MobileMapPage({
upgradeModal,
editingBar,
}: MobileMapPageProps) {
const { t } = useTranslation();
const floatingPaneAvailableHeight = `max(12rem, calc(100dvh - ${Math.ceil(
bottomScreenInset
)}px - 7rem))`;
@ -204,8 +206,16 @@ export function MobileMapPage({
className={`rounded-lg bg-white p-2 shadow-lg dark:bg-warm-800 ${actualListingsEnabled ? 'text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300' : 'text-warm-500 hover:text-red-600 dark:text-warm-400 dark:hover:text-red-400'}`}
aria-pressed={actualListingsEnabled}
aria-busy={actualListingsLoading}
aria-label={actualListingsEnabled ? 'Hide actual listings' : 'Show actual listings'}
title={actualListingsEnabled ? 'Hide actual listings' : 'Show actual listings'}
aria-label={
actualListingsEnabled
? t('map.actualListings.hide')
: t('map.actualListings.show')
}
title={
actualListingsEnabled
? t('map.actualListings.hide')
: t('map.actualListings.show')
}
>
{actualListingsLoading ? (
<SpinnerIcon className="h-5 w-5 animate-spin" />
@ -217,7 +227,7 @@ export function MobileMapPage({
<button
onClick={onToggleOverlayPane}
className={`rounded-lg bg-white p-2 shadow-lg dark:bg-warm-800 ${overlayPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 hover:text-teal-600 dark:text-warm-400 dark:hover:text-teal-400'}`}
aria-label="Overlays"
aria-label={t('overlays.heading')}
>
<EyeIcon className="h-5 w-5" filled={overlayPaneOpen} />
</button>

View file

@ -6,8 +6,11 @@ import type { HexagonLocation } from '../../../lib/external-search';
import type { useMapData } from '../../../hooks/useMapData';
import { resolveTransitVariant, type TravelTimeEntry } from '../../../hooks/useTravelTime';
import { getSpecificCrimeFeatureName } from '../../../lib/crime-filter';
import { getCrimeSeverityFeatureName } from '../../../lib/crime-severity-filter';
import { getElectionVoteShareFeatureName } from '../../../lib/election-filter';
import { getEthnicityFeatureName } from '../../../lib/ethnicity-filter';
import { getQualificationFeatureName } from '../../../lib/qualification-filter';
import { getTenureFeatureName } from '../../../lib/tenure-filter';
import { getPoiDistanceFeatureName } from '../../../lib/poi-distance-filter';
import { getSchoolBackendFeatureName } from '../../../lib/school-filter';
@ -23,8 +26,11 @@ export function getMapPageBackendFeatureName(featureName: string): string {
return (
getSchoolBackendFeatureName(featureName) ??
getSpecificCrimeFeatureName(featureName) ??
getCrimeSeverityFeatureName(featureName) ??
getElectionVoteShareFeatureName(featureName) ??
getEthnicityFeatureName(featureName) ??
getQualificationFeatureName(featureName) ??
getTenureFeatureName(featureName) ??
getPoiDistanceFeatureName(featureName) ??
featureName
);

View file

@ -1,16 +1,20 @@
import { useTranslation } from 'react-i18next';
interface IndeterminateProgressBarProps {
show: boolean;
className?: string;
}
export function IndeterminateProgressBar({ show, className = '' }: IndeterminateProgressBarProps) {
const { t } = useTranslation();
if (!show) return null;
return (
<div
role="progressbar"
aria-busy="true"
aria-valuetext="loading"
aria-valuetext={t('common.loading')}
className={`pointer-events-none absolute top-0 left-0 right-0 z-30 h-0.5 overflow-hidden bg-teal-500/10 dark:bg-teal-400/10 animate-fade-in ${className}`}
>
<div className="h-full w-1/4 bg-teal-500 dark:bg-teal-400 animate-indeterminate-progress" />

View file

@ -1,4 +1,5 @@
import { useState, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import type { FeatureFilters } from '../types';
import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
@ -88,6 +89,7 @@ function buildSummary(
}
export function useAiFilters(): UseAiFiltersResult {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [errorType, setErrorType] = useState<AiFilterErrorType | null>(null);
@ -170,14 +172,14 @@ export function useAiFilters(): UseAiFiltersResult {
} catch (err) {
if (controller.signal.aborted) return null;
logNonAbortError('ai-filters', err);
const message = err instanceof Error ? err.message : 'Failed to generate filters';
const message = err instanceof Error ? err.message : t('aiFilter.generateFailed');
setErrorType('error');
setError(message);
setLoading(false);
return null;
}
},
[]
[t]
);
return { fetchAiFilters, loading, error, errorType, notes, summary };

View file

@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import pb from '../lib/pocketbase';
import { trackEvent } from '../lib/analytics';
@ -31,6 +32,7 @@ function authRefreshInvalidated(error: unknown): boolean {
}
export function useAuth() {
const { t } = useTranslation();
const [user, setUser] = useState<AuthUser | null>(() => {
if (pb.authStore.isValid && pb.authStore.record) {
return recordToUser(pb.authStore.record);
@ -60,13 +62,13 @@ export function useAuth() {
setUser(recordToUser(result.record));
trackEvent('Login', { method: 'email' });
} catch (err) {
const msg = err instanceof Error ? err.message : 'Login failed';
const msg = err instanceof Error ? err.message : t('auth.loginFailed');
setError(msg);
throw err;
} finally {
setLoading(false);
}
}, []);
}, [t]);
const register = useCallback(async (email: string, password: string) => {
setLoading(true);
@ -83,13 +85,13 @@ export function useAuth() {
setUser(recordToUser(result.record));
trackEvent('Register');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Registration failed';
const msg = err instanceof Error ? err.message : t('auth.registrationFailed');
setError(msg);
throw err;
} finally {
setLoading(false);
}
}, []);
}, [t]);
const loginWithOAuth = useCallback(async (provider: string) => {
setLoading(true);
@ -102,13 +104,13 @@ export function useAuth() {
setUser(recordToUser(result.record));
trackEvent('Login', { method: provider });
} catch (err) {
const msg = err instanceof Error ? err.message : 'OAuth login failed';
const msg = err instanceof Error ? err.message : t('auth.oauthFailed');
setError(msg);
throw err;
} finally {
setLoading(false);
}
}, []);
}, [t]);
const logout = useCallback(() => {
trackEvent('Logout');
@ -122,13 +124,13 @@ export function useAuth() {
try {
await pb.collection('users').requestPasswordReset(email);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Password reset request failed';
const msg = err instanceof Error ? err.message : t('auth.passwordResetFailed');
setError(msg);
throw err;
} finally {
setLoading(false);
}
}, []);
}, [t]);
const refreshAuth = useCallback(async () => {
setLoading(true);

View file

@ -0,0 +1,98 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
create: vi.fn(),
getFullList: vi.fn(),
authStore: {
isValid: true,
record: { id: 'user-1' } as { id: string } | null,
onChange: () => () => {},
},
}));
vi.mock('../lib/pocketbase', () => ({
default: {
authStore: mocks.authStore,
collection: () => ({ getFullList: mocks.getFullList, create: mocks.create }),
},
}));
import { useClickedListings } from './useClickedListings';
describe('useClickedListings', () => {
beforeEach(() => {
mocks.create.mockReset().mockResolvedValue({});
mocks.getFullList.mockReset().mockResolvedValue([]);
mocks.authStore.isValid = true;
mocks.authStore.record = { id: 'user-1' };
});
afterEach(() => {
vi.clearAllMocks();
});
it('hydrates the visited set from PocketBase for the signed-in user', async () => {
mocks.getFullList.mockResolvedValue([{ url: 'https://example.com/a' }]);
const { result } = renderHook(() => useClickedListings());
await waitFor(() => expect(result.current.clickedUrls.has('https://example.com/a')).toBe(true));
});
it('records a click optimistically and persists it', async () => {
const { result } = renderHook(() => useClickedListings());
await waitFor(() => expect(mocks.getFullList).toHaveBeenCalled());
act(() => result.current.markClicked('https://example.com/b'));
// Visible immediately — no need to await the network round-trip.
expect(result.current.clickedUrls.has('https://example.com/b')).toBe(true);
expect(mocks.create).toHaveBeenCalledWith({
user: 'user-1',
url: 'https://example.com/b',
});
});
it('produces a new Set instance for a new url so deck.gl re-colours', async () => {
const { result } = renderHook(() => useClickedListings());
await waitFor(() => expect(mocks.getFullList).toHaveBeenCalled());
const before = result.current.clickedUrls;
act(() => result.current.markClicked('https://example.com/c'));
expect(result.current.clickedUrls).not.toBe(before);
});
it('does not re-persist an already visited url', async () => {
mocks.getFullList.mockResolvedValue([{ url: 'https://example.com/a' }]);
const { result } = renderHook(() => useClickedListings());
await waitFor(() => expect(result.current.clickedUrls.has('https://example.com/a')).toBe(true));
const before = result.current.clickedUrls;
act(() => result.current.markClicked('https://example.com/a'));
expect(mocks.create).not.toHaveBeenCalled();
expect(result.current.clickedUrls).toBe(before); // identity stable → no needless recompute
});
it('ignores empty urls', async () => {
const { result } = renderHook(() => useClickedListings());
await waitFor(() => expect(mocks.getFullList).toHaveBeenCalled());
act(() => result.current.markClicked(''));
act(() => result.current.markClicked(undefined));
expect(mocks.create).not.toHaveBeenCalled();
expect(result.current.clickedUrls.size).toBe(0);
});
it('recolours for anonymous users in-memory but does not persist', async () => {
mocks.authStore.isValid = false;
mocks.authStore.record = null;
const { result } = renderHook(() => useClickedListings());
act(() => result.current.markClicked('https://example.com/d'));
expect(result.current.clickedUrls.has('https://example.com/d')).toBe(true);
expect(mocks.create).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,82 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import pb from '../lib/pocketbase';
/** Per-user record of opened listings, stored in PocketBase so visited listings can be drawn
* in a distinct colour on the map and persist across devices. One row per (user, url). */
const CLICKED_LISTINGS_COLLECTION = 'clicked_listings';
function currentUserId(): string | null {
return pb.authStore.isValid && pb.authStore.record ? pb.authStore.record.id : null;
}
/** Tracks which listings the signed-in user has opened.
*
* Reads the user from the shared PocketBase authStore so it stays self-contained no need to
* thread a user id down through the map layer tree. Returns a Set for O(1) membership checks in
* the map's colour accessor; a fresh Set instance is produced on every change so it can double
* as a deck.gl `updateTrigger` (identity-compared).
*
* Writes are optimistic: `markClicked` updates local state synchronously so the pin recolours
* the instant it is clicked, independent of the PocketBase round-trip or the next listings fetch.
*/
export function useClickedListings() {
const [clickedUrls, setClickedUrls] = useState<Set<string>>(() => new Set());
const [userId, setUserId] = useState<string | null>(currentUserId);
const userIdRef = useRef(userId);
userIdRef.current = userId;
// Mirrors the latest committed set so markClicked can decide (synchronously) whether a url is
// new — without depending on the async state updater having run yet.
const clickedUrlsRef = useRef(clickedUrls);
clickedUrlsRef.current = clickedUrls;
// Follow login/logout via the shared authStore (also covers cross-tab auth changes).
useEffect(() => {
const unsubscribe = pb.authStore.onChange(() => setUserId(currentUserId()));
return unsubscribe;
}, []);
// Load the user's previously opened listings whenever the signed-in user changes. Signed-out
// users keep an in-memory set for the current session only (nothing to persist to).
useEffect(() => {
if (!userId) {
setClickedUrls(new Set());
return;
}
let cancelled = false;
pb.collection(CLICKED_LISTINGS_COLLECTION)
.getFullList({ filter: `user = "${userId}"`, fields: 'url' })
.then((records) => {
if (cancelled) return;
const urls = records
.map((record) => (record as { url?: unknown }).url)
.filter((url): url is string => typeof url === 'string');
setClickedUrls(new Set(urls));
})
.catch(() => {
// Visited history is a convenience; a failed load just starts the session empty.
});
return () => {
cancelled = true;
};
}, [userId]);
const markClicked = useCallback((url: string | undefined | null) => {
if (!url || clickedUrlsRef.current.has(url)) return; // unknown or already visited
// Optimistic, synchronous local update → the map pin recolours on click.
setClickedUrls((prev) => (prev.has(url) ? prev : new Set(prev).add(url)));
const uid = userIdRef.current;
if (!uid) return; // anonymous: recolour for the session, nothing to persist
pb.collection(CLICKED_LISTINGS_COLLECTION)
.create({ user: uid, url })
.catch(() => {
// Ignore duplicate-index conflicts and transient failures — local state already
// reflects the click, and a missed write only means it won't persist to the next visit.
});
}, []);
return { clickedUrls, markClicked };
}

View file

@ -17,6 +17,8 @@ import type {
import {
DENSITY_GRADIENT,
DENSITY_GRADIENT_DARK,
FILTERED_OUT_FILL,
FILTERED_OUT_LINE,
getEnumPaletteForFeature,
getFeatureGradient,
} from '../lib/consts';
@ -125,11 +127,12 @@ export function useDeckLayers({
zoom,
isDark,
});
const { listingLayers, listingPopup, clearListingPopup } = useListingLayers({
listings: actualListings,
zoom,
isDark,
});
const { listingLayers, listingPopup, clearListingPopup, clickedListingUrls, markListingClicked } =
useListingLayers({
listings: actualListings,
zoom,
isDark,
});
const { developmentLayers, developmentPopup, clearDevelopmentPopup } = useDevelopmentLayers({
developments,
zoom,
@ -350,7 +353,12 @@ export function useDeckLayers({
getHexagon: (d) => d.h3,
getFillColor: (d) => {
if ((d.count as number) <= 0) {
return [0, 0, 0, 0] as [number, number, number, number];
// Filtered out: keep it on the map as a faint grey ghost (still
// clickable) rather than hiding it entirely.
return withColorOpacity(
FILTERED_OUT_FILL[isDarkRef.current ? 'dark' : 'light'],
colorOpacityRef.current
);
}
const fill = (color: RgbaColor) => withColorOpacity(color, colorOpacityRef.current);
const dark = isDarkRef.current;
@ -504,7 +512,12 @@ export function useDeckLayers({
getFillColor: (f) => {
const d = f.properties;
if (d.count <= 0) {
return [0, 0, 0, 0] as [number, number, number, number];
// Filtered out: keep it on the map as a faint grey ghost (still
// clickable) rather than hiding it entirely.
return withColorOpacity(
FILTERED_OUT_FILL[isDarkRef.current ? 'dark' : 'light'],
colorOpacityRef.current
);
}
const fill = (color: RgbaColor) => withColorOpacity(color, colorOpacityRef.current);
const dark = isDarkRef.current;
@ -587,7 +600,7 @@ export function useDeckLayers({
return [29, 228, 195, 255] as [number, number, number, number];
if (pc === hoveredPostcodeRef.current)
return [29, 228, 195, 200] as [number, number, number, number];
if (f.properties.count <= 0) return [0, 0, 0, 0] as [number, number, number, number];
if (f.properties.count <= 0) return FILTERED_OUT_LINE[dark ? 'dark' : 'light'];
return (dark ? [180, 170, 160, 100] : [100, 100, 100, 150]) as [
number,
number,
@ -599,7 +612,7 @@ export function useDeckLayers({
const pc = f.properties.postcode;
if (pc === selectedPostcodeIdRef.current) return 4;
if (pc === hoveredPostcodeRef.current) return 2;
if (f.properties.count <= 0) return 0;
// Filtered-out polygons (count <= 0) keep the same 1px ghost border.
return 1;
},
lineWidthUnits: 'pixels',
@ -749,6 +762,8 @@ export function useDeckLayers({
clearPopupInfo,
listingPopup,
clearListingPopup,
clickedListingUrls,
markListingClicked,
developmentPopup,
clearDevelopmentPopup,
hoverPosition,

View file

@ -17,6 +17,15 @@ import {
getSpecificCrimeFilterKeyId,
normalizeSpecificCrimeFilters,
} from '../lib/crime-filter';
import {
CRIME_SEVERITY_FILTER_NAMES,
createCrimeSeverityFilterKey,
getCrimeSeverityFeatureName,
getCrimeSeverityFilterKeyId,
getDefaultCrimeSeverityFeatureName,
normalizeCrimeSeverityFilters,
type CrimeSeverityFilterName,
} from '../lib/crime-severity-filter';
import {
ELECTION_VOTE_SHARE_FILTER_NAME,
createElectionVoteShareFilterKey,
@ -33,6 +42,22 @@ import {
getEthnicityFilterKeyId,
normalizeEthnicityFilters,
} from '../lib/ethnicity-filter';
import {
QUALIFICATIONS_FILTER_NAME,
createQualificationFilterKey,
getDefaultQualificationFeatureName,
getQualificationFeatureName,
getQualificationFilterKeyId,
normalizeQualificationFilters,
} from '../lib/qualification-filter';
import {
TENURE_FILTER_NAME,
createTenureFilterKey,
getDefaultTenureFeatureName,
getTenureFeatureName,
getTenureFilterKeyId,
normalizeTenureFilters,
} from '../lib/tenure-filter';
import {
POI_FILTER_NAMES,
createPoiFilterKey,
@ -55,9 +80,15 @@ interface UseFiltersOptions {
function normalizeFilters(filters: FeatureFilters): FeatureFilters {
return normalizePoiDistanceFilters(
normalizeEthnicityFilters(
normalizeElectionVoteShareFilters(
normalizeSpecificCrimeFilters(normalizeSchoolFilters(filters))
normalizeTenureFilters(
normalizeQualificationFilters(
normalizeEthnicityFilters(
normalizeElectionVoteShareFilters(
normalizeCrimeSeverityFilters(
normalizeSpecificCrimeFilters(normalizeSchoolFilters(filters))
)
)
)
)
)
);
@ -67,8 +98,11 @@ function getBackendFeatureName(name: string): string {
return (
getSchoolBackendFeatureName(name) ??
getSpecificCrimeFeatureName(name) ??
getCrimeSeverityFeatureName(name) ??
getElectionVoteShareFeatureName(name) ??
getEthnicityFeatureName(name) ??
getQualificationFeatureName(name) ??
getTenureFeatureName(name) ??
getPoiDistanceFeatureName(name) ??
name
);
@ -140,12 +174,21 @@ export function useFilters({
const specificCrimeFilterIdRef = useRef(
getNextNumericKeyId(initialFiltersRef.current!, getSpecificCrimeFilterKeyId)
);
const crimeSeverityFilterIdRef = useRef(
getNextNumericKeyId(initialFiltersRef.current!, getCrimeSeverityFilterKeyId)
);
const electionVoteShareFilterIdRef = useRef(
getNextNumericKeyId(initialFiltersRef.current!, getElectionVoteShareFilterKeyId)
);
const ethnicityFilterIdRef = useRef(
getNextNumericKeyId(initialFiltersRef.current!, getEthnicityFilterKeyId)
);
const qualificationFilterIdRef = useRef(
getNextNumericKeyId(initialFiltersRef.current!, getQualificationFilterKeyId)
);
const tenureFilterIdRef = useRef(
getNextNumericKeyId(initialFiltersRef.current!, getTenureFilterKeyId)
);
const poiDistanceFilterIdRef = useRef(
getNextNumericKeyId(initialFiltersRef.current!, getPoiDistanceFilterKeyId)
);
@ -184,8 +227,11 @@ export function useFilters({
if (
name !== SCHOOL_FILTER_NAME &&
name !== SPECIFIC_CRIMES_FILTER_NAME &&
!CRIME_SEVERITY_FILTER_NAMES.includes(name as CrimeSeverityFilterName) &&
name !== ELECTION_VOTE_SHARE_FILTER_NAME &&
name !== ETHNICITIES_FILTER_NAME &&
name !== QUALIFICATIONS_FILTER_NAME &&
name !== TENURE_FILTER_NAME &&
!POI_FILTER_NAMES.includes(name as PoiFilterName) &&
!meta
) {
@ -201,8 +247,11 @@ export function useFilters({
const addsNewKey =
name === SCHOOL_FILTER_NAME ||
name === SPECIFIC_CRIMES_FILTER_NAME ||
CRIME_SEVERITY_FILTER_NAMES.includes(name as CrimeSeverityFilterName) ||
name === ELECTION_VOTE_SHARE_FILTER_NAME ||
name === ETHNICITIES_FILTER_NAME ||
name === QUALIFICATIONS_FILTER_NAME ||
name === TENURE_FILTER_NAME ||
POI_FILTER_NAMES.includes(name as PoiFilterName) ||
!(name in current);
if (addsNewKey && Object.keys(current).length >= limit) {
@ -247,6 +296,29 @@ export function useFilters({
],
};
}
if (CRIME_SEVERITY_FILTER_NAMES.includes(name as CrimeSeverityFilterName)) {
const severityFilterName = name as CrimeSeverityFilterName;
const defaultSeverityFeatureName = getDefaultCrimeSeverityFeatureName(
features,
severityFilterName
);
const defaultSeverityFeature = defaultSeverityFeatureName
? features.find((feature) => feature.name === defaultSeverityFeatureName)
: undefined;
if (!defaultSeverityFeatureName) return prev;
return {
...prev,
[createCrimeSeverityFilterKey(
severityFilterName,
defaultSeverityFeatureName,
crimeSeverityFilterIdRef.current++
)]: [
defaultSeverityFeature?.histogram?.min ?? defaultSeverityFeature?.min ?? 0,
defaultSeverityFeature?.histogram?.max ?? defaultSeverityFeature?.max ?? 100,
],
};
}
if (name === ELECTION_VOTE_SHARE_FILTER_NAME) {
const defaultVoteShareFeatureName = getDefaultElectionVoteShareFeatureName(features);
const defaultVoteShareFeature = defaultVoteShareFeatureName
@ -281,6 +353,41 @@ export function useFilters({
],
};
}
if (name === QUALIFICATIONS_FILTER_NAME) {
const defaultQualificationFeatureName = getDefaultQualificationFeatureName(features);
const defaultQualificationFeature = defaultQualificationFeatureName
? features.find((feature) => feature.name === defaultQualificationFeatureName)
: undefined;
if (!defaultQualificationFeatureName) return prev;
return {
...prev,
[createQualificationFilterKey(
defaultQualificationFeatureName,
qualificationFilterIdRef.current++
)]: [
defaultQualificationFeature?.histogram?.min ?? defaultQualificationFeature?.min ?? 0,
defaultQualificationFeature?.histogram?.max ??
defaultQualificationFeature?.max ??
100,
],
};
}
if (name === TENURE_FILTER_NAME) {
const defaultTenureFeatureName = getDefaultTenureFeatureName(features);
const defaultTenureFeature = defaultTenureFeatureName
? features.find((feature) => feature.name === defaultTenureFeatureName)
: undefined;
if (!defaultTenureFeatureName) return prev;
return {
...prev,
[createTenureFilterKey(defaultTenureFeatureName, tenureFilterIdRef.current++)]: [
defaultTenureFeature?.histogram?.min ?? defaultTenureFeature?.min ?? 0,
defaultTenureFeature?.histogram?.max ?? defaultTenureFeature?.max ?? 100,
],
};
}
if (POI_FILTER_NAMES.includes(name as PoiFilterName)) {
const poiFilterName = name as PoiFilterName;
const defaultPoiFeatureName = getDefaultPoiFilterFeatureName(features, poiFilterName);
@ -381,6 +488,23 @@ export function useFilters({
if (replaced) return normalizeFilters(next);
}
const crimeSeverityKeyId = getCrimeSeverityFilterKeyId(name);
if (crimeSeverityKeyId != null) {
let replaced = false;
const next: FeatureFilters = {};
for (const [existingName, existingValue] of Object.entries(prev)) {
if (getCrimeSeverityFilterKeyId(existingName) === crimeSeverityKeyId) {
if (!replaced) {
next[name] = value;
replaced = true;
}
continue;
}
next[existingName] = existingValue;
}
if (replaced) return normalizeFilters(next);
}
const ethnicityKeyId = getEthnicityFilterKeyId(name);
if (ethnicityKeyId != null) {
let replaced = false;
@ -398,6 +522,40 @@ export function useFilters({
if (replaced) return normalizeFilters(next);
}
const qualificationKeyId = getQualificationFilterKeyId(name);
if (qualificationKeyId != null) {
let replaced = false;
const next: FeatureFilters = {};
for (const [existingName, existingValue] of Object.entries(prev)) {
if (getQualificationFilterKeyId(existingName) === qualificationKeyId) {
if (!replaced) {
next[name] = value;
replaced = true;
}
continue;
}
next[existingName] = existingValue;
}
if (replaced) return normalizeFilters(next);
}
const tenureKeyId = getTenureFilterKeyId(name);
if (tenureKeyId != null) {
let replaced = false;
const next: FeatureFilters = {};
for (const [existingName, existingValue] of Object.entries(prev)) {
if (getTenureFilterKeyId(existingName) === tenureKeyId) {
if (!replaced) {
next[name] = value;
replaced = true;
}
continue;
}
next[existingName] = existingValue;
}
if (replaced) return normalizeFilters(next);
}
const electionVoteShareKeyId = getElectionVoteShareFilterKeyId(name);
if (electionVoteShareKeyId != null) {
let replaced = false;

View file

@ -10,7 +10,14 @@ import type {
HexagonPropertiesResponse,
HexagonStatsResponse,
} from '../types';
import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders } from '../lib/api';
import {
buildFilterString,
apiUrl,
assertOk,
logNonAbortError,
authHeaders,
fetchCrimeRecords,
} from '../lib/api';
import { findOverlappingSelectableHexagon } from '../lib/h3-selection';
import { SMALLEST_VISIBLE_HEXAGON_RESOLUTION } from '../lib/consts';
import type { TravelTimeEntry } from './useTravelTime';
@ -327,6 +334,23 @@ export function useHexagonSelection({
]
);
// Lazily fetch a page of individual crimes for the current selection. The
// records are an attribute of the area (filter-independent), so this carries
// no filter string — only the selection identity and share code.
const loadCrimeRecords = useCallback(
(offset: number) => {
if (!selectedHexagon) {
return Promise.resolve({ records: [], total: 0, offset: 0, truncated: false });
}
const selection =
selectedHexagon.type === 'postcode'
? { postcode: selectedHexagon.id }
: { h3: selectedHexagon.id, resolution: selectedHexagon.resolution };
return fetchCrimeRecords(selection, offset, shareCode);
},
[selectedHexagon, shareCode]
);
const handleHexagonClick = useCallback(
(id: string, isPostcode = false, geometry?: PostcodeGeometry) => {
if (selectedHexagon?.id === id) {
@ -806,6 +830,7 @@ export function useHexagonSelection({
handlePropertiesTabClick,
handleLoadMoreProperties,
handleCloseSelection,
loadCrimeRecords,
selectedPostcodeGeometry,
handleLocationSearch,
handleCurrentLocationSearch,

View file

@ -1,8 +1,10 @@
import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { apiUrl, authHeaders, assertOk } from '../lib/api';
import { trackEvent } from '../lib/analytics';
export function useLicense() {
const { t } = useTranslation();
const [checkingOut, setCheckingOut] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -25,13 +27,13 @@ export function useLicense() {
window.location.href = data.url;
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'Checkout failed';
const msg = err instanceof Error ? err.message : t('upgrade.checkoutFailed');
setError(msg);
throw err;
} finally {
setCheckingOut(false);
}
}, []);
}, [t]);
return { startCheckout, checkingOut, error };
}

View file

@ -5,6 +5,13 @@ import Supercluster from 'supercluster';
import type { ActualListing } from '../types';
import { trackEvent } from '../lib/analytics';
import { useClickedListings } from './useClickedListings';
/** Default red pin vs. the violet used once a user has opened that listing. */
const LISTING_PIN_FILL: [number, number, number, number] = [231, 76, 60, 240];
const LISTING_PIN_VISITED_FILL: [number, number, number, number] = [139, 92, 246, 240];
const EXPANDED_PIN_FILL: [number, number, number, number] = [231, 76, 60, 245];
const EXPANDED_PIN_VISITED_FILL: [number, number, number, number] = [139, 92, 246, 245];
const PRICE_LABEL_MIN_ZOOM = 14;
const ADDRESS_LABEL_MIN_ZOOM = 16;
@ -113,6 +120,7 @@ function spiderfyPosition(
export function useListingLayers({ listings, zoom, isDark }: UseListingLayersProps) {
const [popupInfo, setPopupInfo] = useState<ListingPopupInfo | null>(null);
const [selectedCluster, setSelectedCluster] = useState<ListingClusterPoint | null>(null);
const { clickedUrls, markClicked } = useClickedListings();
useEffect(() => {
// Each refetch returns a fresh listings array and rebuilds the cluster index, so a
@ -212,12 +220,16 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
[clearUnlockedPopup]
);
const handleClick = useCallback((info: PickingInfo<ActualListing>) => {
const url = info.object?.listing_url;
if (!url) return;
trackEvent('Actual Listing Click', { url });
window.open(url, '_blank', 'noopener,noreferrer');
}, []);
const handleClick = useCallback(
(info: PickingInfo<ActualListing>) => {
const url = info.object?.listing_url;
if (!url) return;
markClicked(url);
trackEvent('Actual Listing Click', { url });
window.open(url, '_blank', 'noopener,noreferrer');
},
[markClicked]
);
const handleHoverRef = useRef(handleHover);
handleHoverRef.current = handleHover;
@ -244,12 +256,16 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
[clearUnlockedPopup]
);
const handleExpandedClick = useCallback((info: PickingInfo<ExpandedListingMarker>) => {
const url = info.object?.listing.listing_url;
if (!url) return;
trackEvent('Actual Listing Click', { url, source: 'cluster_expanded' });
window.open(url, '_blank', 'noopener,noreferrer');
}, []);
const handleExpandedClick = useCallback(
(info: PickingInfo<ExpandedListingMarker>) => {
const url = info.object?.listing.listing_url;
if (!url) return;
markClicked(url);
trackEvent('Actual Listing Click', { url, source: 'cluster_expanded' });
window.open(url, '_blank', 'noopener,noreferrer');
},
[markClicked]
);
const handleExpandedHoverRef = useRef(handleExpandedHover);
handleExpandedHoverRef.current = handleExpandedHover;
@ -345,7 +361,8 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
getPosition: (d) => [d.lon, d.lat],
getRadius: 7,
radiusUnits: 'pixels',
getFillColor: [231, 76, 60, 240],
getFillColor: (d) =>
clickedUrls.has(d.listing_url) ? LISTING_PIN_VISITED_FILL : LISTING_PIN_FILL,
getLineColor: [255, 255, 255, 255],
getLineWidth: 1.5,
lineWidthUnits: 'pixels',
@ -355,8 +372,9 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
highlightColor: [29, 228, 195, 220],
onHover: stableHover,
onClick: stableClick,
updateTriggers: { getFillColor: clickedUrls },
}),
[visibleListings, stableHover, stableClick]
[visibleListings, clickedUrls, stableHover, stableClick]
);
const clusterShadowLayer = useMemo(
@ -441,7 +459,8 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
getPosition: (d) => [d.lng, d.lat],
getRadius: 6,
radiusUnits: 'pixels',
getFillColor: [231, 76, 60, 245],
getFillColor: (d) =>
clickedUrls.has(d.listing.listing_url) ? EXPANDED_PIN_VISITED_FILL : EXPANDED_PIN_FILL,
getLineColor: [255, 255, 255, 255],
getLineWidth: 1.5,
lineWidthUnits: 'pixels',
@ -451,8 +470,9 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
highlightColor: [29, 228, 195, 220],
onHover: stableExpandedHover,
onClick: stableExpandedClick,
updateTriggers: { getFillColor: clickedUrls },
}),
[expandedListings, stableExpandedHover, stableExpandedClick]
[expandedListings, clickedUrls, stableExpandedHover, stableExpandedClick]
);
const priceLabelLayer = useMemo(() => {
@ -544,5 +564,11 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
setSelectedCluster(null);
}, []);
return { listingLayers, listingPopup: popupInfo, clearListingPopup };
return {
listingLayers,
listingPopup: popupInfo,
clearListingPopup,
clickedListingUrls: clickedUrls,
markListingClicked: markClicked,
};
}

View file

@ -18,8 +18,11 @@ import {
} from '../lib/api';
import { getSchoolBackendFeatureName } from '../lib/school-filter';
import { getSpecificCrimeFeatureName } from '../lib/crime-filter';
import { getCrimeSeverityFeatureName } from '../lib/crime-severity-filter';
import { getElectionVoteShareFeatureName } from '../lib/election-filter';
import { getEthnicityFeatureName } from '../lib/ethnicity-filter';
import { getQualificationFeatureName } from '../lib/qualification-filter';
import { getTenureFeatureName } from '../lib/tenure-filter';
import { getPoiDistanceFeatureName } from '../lib/poi-distance-filter';
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
@ -119,8 +122,11 @@ export function useMapData({
(name: string) =>
getSchoolBackendFeatureName(name) ??
getSpecificCrimeFeatureName(name) ??
getCrimeSeverityFeatureName(name) ??
getElectionVoteShareFeatureName(name) ??
getEthnicityFeatureName(name) ??
getQualificationFeatureName(name) ??
getTenureFeatureName(name) ??
getPoiDistanceFeatureName(name) ??
name,
[]

View file

@ -1,9 +1,19 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import type { POI } from '../types';
import { usePoiLayers } from './usePoiLayers';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
if (key === 'common.places') return 'places';
if (key === 'map.poi.zoomInToSeeDetails') return 'Zoom in to see details';
return key;
},
}),
}));
const supermarket: POI = {
id: 'poi-1',
name: 'Market Hall',

View file

@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { PickingInfo } from '@deck.gl/core';
import { IconLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
import Supercluster from 'supercluster';
@ -65,6 +66,7 @@ function getPoiIconSize(poi: POI): number {
}
export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
const { t } = useTranslation();
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null);
// Dismiss a lingering hover/cluster popup once the POIs behind it are gone — e.g.
@ -108,8 +110,8 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
setPopupInfo({
x: info.x,
y: info.y,
name: `${info.object.count} places`,
category: 'Zoom in to see details',
name: `${info.object.count} ${t('common.places')}`,
category: t('map.poi.zoomInToSeeDetails'),
group: '',
emoji: '',
id: '',
@ -119,7 +121,7 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
} else {
setPopupInfo(null);
}
}, []);
}, [t]);
const handleClusterHoverRef = useRef(handleClusterHover);
handleClusterHoverRef.current = handleClusterHover;

View file

@ -0,0 +1,80 @@
import { cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useState } from 'react';
import { useRevealOnExpand } from './useRevealOnExpand';
function GroupList() {
const { setContainer, registerGroup, onToggle } = useRevealOnExpand();
const [expanded, setExpanded] = useState(false);
return (
<div ref={setContainer} data-testid="container">
<div ref={registerGroup('g1')} data-testid="group">
<button
data-testid="toggle"
onClick={() => {
const willExpand = !expanded;
setExpanded(willExpand);
onToggle('g1', willExpand);
}}
>
toggle
</button>
{expanded && <div style={{ height: 1000 }} />}
</div>
<div style={{ height: 2000 }} />
</div>
);
}
describe('useRevealOnExpand', () => {
const original = {
resizeObserver: globalThis.ResizeObserver,
scrollTo: HTMLElement.prototype.scrollTo,
getBoundingClientRect: HTMLElement.prototype.getBoundingClientRect,
};
beforeEach(() => {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
HTMLElement.prototype.scrollTo = vi.fn();
// The opened group's bottom (800) sits below the container's bottom (500),
// so it needs a 300px downward scroll to come fully into view.
HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) {
const id = this.dataset?.testid;
const bottom = id === 'group' ? 800 : id === 'container' ? 500 : 0;
return { bottom } as DOMRect;
};
});
afterEach(() => {
cleanup();
globalThis.ResizeObserver = original.resizeObserver;
HTMLElement.prototype.scrollTo = original.scrollTo;
HTMLElement.prototype.getBoundingClientRect = original.getBoundingClientRect;
vi.restoreAllMocks();
});
it('scrolls a freshly expanded group into view', () => {
const view = render(<GroupList />);
const scrollTo = view.getByTestId('container').scrollTo as ReturnType<typeof vi.fn>;
fireEvent.click(view.getByTestId('toggle'));
expect(scrollTo).toHaveBeenCalledWith({ top: 300 });
});
it('does not scroll when a group is collapsed', () => {
const view = render(<GroupList />);
const scrollTo = view.getByTestId('container').scrollTo as ReturnType<typeof vi.fn>;
fireEvent.click(view.getByTestId('toggle')); // expand
scrollTo.mockClear();
fireEvent.click(view.getByTestId('toggle')); // collapse
expect(scrollTo).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,97 @@
import { useCallback, useEffect, useRef, useState } from 'react';
/**
* Auto-scrolls a freshly expanded collapsible group into view inside a scroll
* container, so opening a group near the bottom reveals its content without
* manual scrolling. The (sticky) group header stays pinned at the top.
*
* Wiring:
* const { setContainer, registerGroup, onToggle } = useRevealOnExpand();
* <div ref={setContainer} className="overflow-y-auto">
* {groups.map((g) => (
* <div key={g.name} ref={registerGroup(g.name)}>
* <Header
* onToggle={() => {
* const willExpand = !isExpanded(g.name);
* toggle(g.name);
* onToggle(g.name, willExpand);
* }}
* />
* {isExpanded(g.name) && <Body />}
* </div>
* ))}
* </div>
*
* When the container element is also driven by another ref (e.g. the callback
* ref from useRetainedScrollTop), compose them by calling both inside a single
* callback ref.
*/
export function useRevealOnExpand<T extends HTMLElement = HTMLDivElement>() {
const containerRef = useRef<T | null>(null);
const groupRefs = useRef<Map<string, T>>(new Map());
const groupRefCallbacks = useRef<Map<string, (node: T | null) => void>>(new Map());
// A fresh object each toggle so re-expanding the same group still re-triggers.
const [groupToReveal, setGroupToReveal] = useState<{ name: string } | null>(null);
const setContainer = useCallback((node: T | null) => {
containerRef.current = node;
}, []);
// Returns a stable ref callback per group name so React doesn't churn the map
// (cleanup + re-register) on every render.
const registerGroup = useCallback((name: string) => {
const existing = groupRefCallbacks.current.get(name);
if (existing) return existing;
const cb = (node: T | null) => {
if (node) groupRefs.current.set(name, node);
else groupRefs.current.delete(name);
};
groupRefCallbacks.current.set(name, cb);
return cb;
}, []);
// Call from a group's toggle handler: reveals the group when it is being
// expanded, and cancels any pending reveal when it is being collapsed.
const onToggle = useCallback((name: string, willExpand: boolean) => {
setGroupToReveal(willExpand ? { name } : null);
}, []);
useEffect(() => {
if (!groupToReveal) return;
const el = groupRefs.current.get(groupToReveal.name);
const container = containerRef.current;
if (!el || !container) return;
// Scroll down just enough to bring the bottom of the opened group into view.
// For groups taller than the pane this scrolls past the header, but it stays
// visible via its sticky positioning. Never scroll up (group already in view).
const alignBottom = () => {
const delta = el.getBoundingClientRect().bottom - container.getBoundingClientRect().bottom;
if (delta <= 1) return;
container.scrollTo({ top: container.scrollTop + delta });
};
alignBottom();
// The group's charts and async data (e.g. nearby stations) can grow its
// height after this first pass, so keep the bottom pinned as it settles.
// Stop once the user scrolls or after a short grace period so we never fight
// a deliberate scroll.
let timer = 0;
const observer = new ResizeObserver(alignBottom);
const stop = () => {
observer.disconnect();
container.removeEventListener('wheel', stop);
container.removeEventListener('touchmove', stop);
window.clearTimeout(timer);
};
observer.observe(el);
container.addEventListener('wheel', stop, { passive: true });
container.addEventListener('touchmove', stop, { passive: true });
timer = window.setTimeout(stop, 1500);
return stop;
}, [groupToReveal]);
return { setContainer, registerGroup, onToggle };
}

View file

@ -1,4 +1,5 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import pb from '../lib/pocketbase';
import { apiUrl, authHeaders } from '../lib/api';
import { trackEvent } from '../lib/analytics';
@ -24,6 +25,7 @@ function nextPollDelay(attempt: number): number {
}
export function useSavedSearches(userId: string | null) {
const { t } = useTranslation();
const [searches, setSearches] = useState<SavedSearch[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
@ -127,11 +129,11 @@ export function useSavedSearches(userId: string | null) {
stopPolling();
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load searches');
setError(err instanceof Error ? err.message : t('savedPage.loadFailed'));
} finally {
setLoading(false);
}
}, [userId, fetchRecords, startPolling, stopPolling]);
}, [userId, fetchRecords, startPolling, stopPolling, t]);
const saveSearch = useCallback(
async (name: string, paramsOverride?: string) => {
@ -169,14 +171,14 @@ export function useSavedSearches(userId: string | null) {
console.warn('Background screenshot failed:', err);
});
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to save search';
const msg = err instanceof Error ? err.message : t('savedPage.saveFailed');
setError(msg);
throw err;
} finally {
setSaving(false);
}
},
[userId, fetchSearches]
[userId, fetchSearches, t]
);
const deleteSearch = useCallback(async (id: string) => {
@ -186,27 +188,27 @@ export function useSavedSearches(userId: string | null) {
trackEvent('Search Delete');
setSearches((prev) => prev.filter((s) => s.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete search');
setError(err instanceof Error ? err.message : t('savedPage.deleteFailed'));
}
}, []);
}, [t]);
const updateSearchNotes = useCallback(async (id: string, notes: string) => {
try {
await pb.collection('saved_searches').update(id, { notes });
setSearches((prev) => prev.map((s) => (s.id === id ? { ...s, notes } : s)));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update notes');
setError(err instanceof Error ? err.message : t('savedPage.updateNotesFailed'));
}
}, []);
}, [t]);
const updateSearchName = useCallback(async (id: string, name: string) => {
try {
await pb.collection('saved_searches').update(id, { name });
setSearches((prev) => prev.map((s) => (s.id === id ? { ...s, name } : s)));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update name');
setError(err instanceof Error ? err.message : t('savedPage.updateNameFailed'));
}
}, []);
}, [t]);
const updateSearchParams = useCallback(
async (id: string, params: string) => {
@ -238,14 +240,14 @@ export function useSavedSearches(userId: string | null) {
console.warn('Background screenshot failed:', err);
});
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to update search';
const msg = err instanceof Error ? err.message : t('savedPage.updateFailed');
setError(msg);
throw err;
} finally {
setSaving(false);
}
},
[userId, fetchSearches]
[userId, fetchSearches, t]
);
return {

View file

@ -50,6 +50,13 @@ export function useTutorial(initialLoading: boolean, isMobile: boolean, blocked
placement: 'left' as const,
skipBeacon: true,
},
{
target: '[data-tutorial="overlays-button"]',
title: t('tutorial.step7Title'),
content: t('tutorial.step7Content'),
placement: 'left' as const,
skipBeacon: true,
},
],
[t]
);

View file

@ -1,4 +1,5 @@
import { createRoot } from 'react-dom/client';
import { useTranslation } from 'react-i18next';
import App from './App';
import { i18nReady } from './i18n';
import { BugsinkErrorBoundary, initBugsink } from './lib/bugsink';
@ -14,12 +15,17 @@ if (!container) {
const root = container;
function AppErrorFallback() {
const { t } = useTranslation();
return (
<div className="flex min-h-screen items-center justify-center bg-warm-50 px-6 text-center text-warm-900 dark:bg-navy-950 dark:text-warm-100">
<div>
<h1 className="text-xl font-semibold">Something went wrong</h1>
<h1 className="text-xl font-semibold">
{t('errors.appCrashTitle', { defaultValue: 'Something went wrong' })}
</h1>
<p className="mt-2 text-sm text-warm-600 dark:text-warm-300">
Refresh the page to try again.
{t('errors.appCrashBody', {
defaultValue: 'Refresh the page to try again.',
})}
</p>
</div>
</div>

View file

@ -6,6 +6,8 @@ import { createSchoolFilterKey } from './school-filter';
import { createSpecificCrimeFilterKey } from './crime-filter';
import { createElectionVoteShareFilterKey } from './election-filter';
import { createEthnicityFilterKey } from './ethnicity-filter';
import { createQualificationFilterKey } from './qualification-filter';
import { createTenureFilterKey } from './tenure-filter';
import {
POI_COUNT_2KM_FILTER_NAME,
TRANSPORT_DISTANCE_FILTER_NAME,
@ -98,19 +100,19 @@ describe('api utilities', () => {
it('serializes specific crime filters using their selected backend crime feature', () => {
const features: FeatureMeta[] = [
{ name: 'Burglary (avg/yr)', type: 'numeric', min: 0, max: 20 },
{ name: 'Vehicle crime (avg/yr)', type: 'numeric', min: 0, max: 30 },
{ name: 'Burglary (/yr, 7y)', type: 'numeric', min: 0, max: 20 },
{ name: 'Vehicle crime (/yr, 7y)', type: 'numeric', min: 0, max: 30 },
];
expect(
buildFilterString(
{
[createSpecificCrimeFilterKey('Burglary (avg/yr)', 1)]: [0, 5],
[createSpecificCrimeFilterKey('Vehicle crime (avg/yr)', 2)]: [1, 10],
[createSpecificCrimeFilterKey('Burglary (/yr, 7y)', 1)]: [0, 5],
[createSpecificCrimeFilterKey('Vehicle crime (/yr, 7y)', 2)]: [1, 10],
},
features
)
).toBe('Burglary (avg/yr):0:5;;Vehicle crime (avg/yr):1:10');
).toBe('Burglary (/yr, 7y):0:5;;Vehicle crime (/yr, 7y):1:10');
});
it('serializes election vote-share filters using their selected backend party feature', () => {
@ -144,6 +146,40 @@ describe('api utilities', () => {
).toBe('% White:20:80');
});
it('serializes qualification filters using their selected backend band feature', () => {
const features: FeatureMeta[] = [
{ name: '% Degree or higher', type: 'numeric', min: 0, max: 100 },
{ name: '% No qualifications', type: 'numeric', min: 0, max: 100 },
];
expect(
buildFilterString(
{
[createQualificationFilterKey('% Degree or higher', 1)]: [20, 60],
[createQualificationFilterKey('% No qualifications', 2)]: [0, 25],
},
features
)
).toBe('% Degree or higher:20:60;;% No qualifications:0:25');
});
it('serializes tenure filters using their selected backend band feature', () => {
const features: FeatureMeta[] = [
{ name: '% Owner occupied', type: 'numeric', min: 0, max: 100 },
{ name: '% Private rent', type: 'numeric', min: 0, max: 100 },
];
expect(
buildFilterString(
{
[createTenureFilterKey('% Owner occupied', 1)]: [20, 60],
[createTenureFilterKey('% Private rent', 2)]: [0, 25],
},
features
)
).toBe('% Owner occupied:20:60;;% Private rent:0:25');
});
it('serializes amenity distance filters using their selected backend feature', () => {
const features: FeatureMeta[] = [
{ name: 'Distance to nearest amenity (Park) (km)', type: 'numeric', min: 0, max: 2 },

View file

@ -1,10 +1,13 @@
import type { FeatureMeta, FeatureFilters } from '../types';
import type { FeatureMeta, FeatureFilters, CrimeRecordsResponse } from '../types';
import { INITIAL_RETRY_MS, MAX_RETRY_MS } from './consts';
import pb from './pocketbase';
import { getSchoolBackendFeatureName } from './school-filter';
import { getSpecificCrimeFeatureName } from './crime-filter';
import { getCrimeSeverityFeatureName } from './crime-severity-filter';
import { getElectionVoteShareFeatureName } from './election-filter';
import { getEthnicityFeatureName } from './ethnicity-filter';
import { getQualificationFeatureName } from './qualification-filter';
import { getTenureFeatureName } from './tenure-filter';
import { getPoiDistanceFeatureName } from './poi-distance-filter';
const SCREENSHOT_LANGUAGES = new Set(['en', 'fr', 'de', 'zh', 'hi', 'hu']);
@ -61,6 +64,7 @@ const DEMO_GATED_ENDPOINTS = new Set([
'postcode-stats',
'postcode-properties',
'hexagon-properties',
'crime-records',
'journey',
'actual-listings',
'developments',
@ -186,6 +190,26 @@ export async function shortenUrl(params: string, language?: string): Promise<str
return `${window.location.origin}${data.url}`;
}
/** Fetch one page of individual crime records for a hexagon or a postcode. The
* list is independent of property filters, so no filter string is sent. */
export async function fetchCrimeRecords(
selection: { h3: string; resolution: number } | { postcode: string },
offset: number,
shareCode?: string
): Promise<CrimeRecordsResponse> {
const params = new URLSearchParams({ offset: offset.toString() });
if ('postcode' in selection) {
params.set('postcode', selection.postcode);
} else {
params.set('h3', selection.h3);
params.set('resolution', selection.resolution.toString());
}
if (shareCode) params.set('share', shareCode);
const res = await fetch(apiUrl('crime-records', params), authHeaders());
assertOk(res, 'crime-records');
return res.json();
}
export function buildFilterString(
filters: FeatureFilters,
features: FeatureMeta[],
@ -200,8 +224,11 @@ export function buildFilterString(
const backendName =
getSchoolBackendFeatureName(name) ??
getSpecificCrimeFeatureName(name) ??
getCrimeSeverityFeatureName(name) ??
getElectionVoteShareFeatureName(name) ??
getEthnicityFeatureName(name) ??
getQualificationFeatureName(name) ??
getTenureFeatureName(name) ??
getPoiDistanceFeatureName(name) ??
name;
const prev = merged.get(backendName);

View file

@ -56,7 +56,7 @@ export const SMALLEST_VISIBLE_HEXAGON_RESOLUTION = Math.max(
// past the finest hexagon level so detail appears while still relatively
// zoomed out. (Each overlay additionally can't render below its own tile-data
// floor, OVERLAY_MIN_ZOOM, regardless of this limit.)
export const POSTCODE_ZOOM_THRESHOLD = 14;
export const POSTCODE_ZOOM_THRESHOLD = 13.5;
export const POSTCODE_SEARCH_ZOOM = 16;
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [
@ -264,7 +264,7 @@ export const STACKED_GROUPS: Record<
label: string;
/** If set, use this feature's stats for the total and info popup. Otherwise sum components. */
feature?: string;
/** If set, display this feature's mean as the primary value (e.g. per-1k rate) instead of the absolute total. */
/** If set, display this feature's mean as the primary value (e.g. a percentage) instead of the absolute total. */
rateFeature?: string;
/** Suffix shown after the total value (e.g. "avg/yr") */
unit?: string;
@ -275,30 +275,30 @@ export const STACKED_GROUPS: Record<
Crime: [
{
label: 'Serious crime',
feature: 'Serious crime (avg/yr)',
feature: 'Serious crime (/yr, 7y)',
unit: '',
components: [
'Violence and sexual offences (avg/yr)',
'Robbery (avg/yr)',
'Burglary (avg/yr)',
'Possession of weapons (avg/yr)',
'Violence and sexual offences (/yr, 7y)',
'Robbery (/yr, 7y)',
'Burglary (/yr, 7y)',
'Possession of weapons (/yr, 7y)',
],
},
{
label: 'Minor crime',
feature: 'Minor crime (avg/yr)',
feature: 'Minor crime (/yr, 7y)',
unit: '',
components: [
'Anti-social behaviour (avg/yr)',
'Criminal damage and arson (avg/yr)',
'Shoplifting (avg/yr)',
'Bicycle theft (avg/yr)',
'Theft from the person (avg/yr)',
'Other theft (avg/yr)',
'Vehicle crime (avg/yr)',
'Public order (avg/yr)',
'Drugs (avg/yr)',
'Other crime (avg/yr)',
'Anti-social behaviour (/yr, 7y)',
'Criminal damage and arson (/yr, 7y)',
'Shoplifting (/yr, 7y)',
'Bicycle theft (/yr, 7y)',
'Theft from the person (/yr, 7y)',
'Other theft (/yr, 7y)',
'Vehicle crime (/yr, 7y)',
'Public order (/yr, 7y)',
'Drugs (/yr, 7y)',
'Other crime (/yr, 7y)',
],
},
],
@ -493,20 +493,20 @@ export function getEnumValueColor(
/** Explicit colors for stacked bar segments. */
export const STACKED_SEGMENT_COLORS: Record<string, string> = {
'Violence and sexual offences (avg/yr)': '#ef4444',
'Robbery (avg/yr)': '#f97316',
'Burglary (avg/yr)': '#eab308',
'Possession of weapons (avg/yr)': '#8b5cf6',
'Anti-social behaviour (avg/yr)': '#14b8a6',
'Criminal damage and arson (avg/yr)': '#f97316',
'Shoplifting (avg/yr)': '#ec4899',
'Bicycle theft (avg/yr)': '#22c55e',
'Theft from the person (avg/yr)': '#d946ef',
'Other theft (avg/yr)': '#06b6d4',
'Vehicle crime (avg/yr)': '#3b82f6',
'Public order (avg/yr)': '#8b5cf6',
'Drugs (avg/yr)': '#22c55e',
'Other crime (avg/yr)': '#6b7280',
'Violence and sexual offences (/yr, 7y)': '#ef4444',
'Robbery (/yr, 7y)': '#f97316',
'Burglary (/yr, 7y)': '#eab308',
'Possession of weapons (/yr, 7y)': '#8b5cf6',
'Anti-social behaviour (/yr, 7y)': '#14b8a6',
'Criminal damage and arson (/yr, 7y)': '#f97316',
'Shoplifting (/yr, 7y)': '#ec4899',
'Bicycle theft (/yr, 7y)': '#22c55e',
'Theft from the person (/yr, 7y)': '#d946ef',
'Other theft (/yr, 7y)': '#06b6d4',
'Vehicle crime (/yr, 7y)': '#3b82f6',
'Public order (/yr, 7y)': '#8b5cf6',
'Drugs (/yr, 7y)': '#22c55e',
'Other crime (/yr, 7y)': '#6b7280',
'% White': '#3b82f6',
'% South Asian': '#f97316',
'% East Asian': '#eab308',

View file

@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import type { FeatureMeta } from '../types';
import {
SPECIFIC_CRIME_FEATURE_NAMES,
createSpecificCrimeFilterKey,
getDefaultSpecificCrimeFeatureName,
getSpecificCrimeFeatureName,
getSpecificCrimeFilterKeyId,
getSpecificCrimeType,
getSpecificCrimeWindow,
isSpecificCrimeFeatureName,
isSpecificCrimeFilterName,
normalizeSpecificCrimeFilters,
specificCrimeFeatureName,
withSpecificCrimeWindow,
} from './crime-filter';
const numeric = (name: string): FeatureMeta => ({ name, type: 'numeric', min: 0, max: 10 });
describe('specific-crime window helpers', () => {
it('recognizes both the 7-year and 2-year rate features', () => {
expect(isSpecificCrimeFeatureName('Burglary (/yr, 7y)')).toBe(true);
expect(isSpecificCrimeFeatureName('Burglary (/yr, 2y)')).toBe(true);
expect(isSpecificCrimeFeatureName('Burglary (avg/yr)')).toBe(false);
// The "Serious crime" / "Minor crime" aggregates stay separate sliders.
expect(isSpecificCrimeFeatureName('Serious crime (/yr, 7y)')).toBe(false);
});
it('extracts the window and bare type', () => {
expect(getSpecificCrimeWindow('Burglary (/yr, 2y)')).toBe('2y');
expect(getSpecificCrimeWindow('Burglary (/yr, 7y)')).toBe('7y');
expect(getSpecificCrimeWindow('Burglary')).toBeNull();
expect(getSpecificCrimeType('Violence and sexual offences (/yr, 2y)')).toBe(
'Violence and sexual offences'
);
expect(getSpecificCrimeType('not a crime')).toBeNull();
});
it('swaps a feature name between windows and round-trips', () => {
expect(withSpecificCrimeWindow('Burglary (/yr, 7y)', '2y')).toBe(
'Burglary (/yr, 2y)'
);
expect(withSpecificCrimeWindow('Burglary (/yr, 2y)', '7y')).toBe(
'Burglary (/yr, 7y)'
);
// Unrecognized names pass through untouched.
expect(withSpecificCrimeWindow('Burglary', '2y')).toBe('Burglary');
expect(specificCrimeFeatureName('Drugs', '2y')).toBe('Drugs (/yr, 2y)');
});
it('defaults to the 7-year window and enumerates 7-year dropdown names', () => {
const features = [numeric('Burglary (/yr, 7y)'), numeric('Burglary (/yr, 2y)')];
expect(getDefaultSpecificCrimeFeatureName(features)).toBe('Burglary (/yr, 7y)');
expect(SPECIFIC_CRIME_FEATURE_NAMES.every((n) => n.endsWith('(/yr, 7y)'))).toBe(true);
});
});
describe('specific-crime filter keys', () => {
it('resolves a 2-year filter key back to its backend column', () => {
const key = createSpecificCrimeFilterKey('Burglary (/yr, 2y)', 3);
expect(isSpecificCrimeFilterName(key)).toBe(true);
expect(getSpecificCrimeFilterKeyId(key)).toBe('3');
expect(getSpecificCrimeFeatureName(key)).toBe('Burglary (/yr, 2y)');
});
it('folds a bare 2-year crime feature into a multi-instance key', () => {
const normalized = normalizeSpecificCrimeFilters({
'Burglary (/yr, 2y)': [1, 5],
'Median price (£)': [0, 100],
});
const keys = Object.keys(normalized);
expect(keys.some((k) => isSpecificCrimeFilterName(k))).toBe(true);
const crimeKey = keys.find((k) => isSpecificCrimeFilterName(k))!;
expect(getSpecificCrimeFeatureName(crimeKey)).toBe('Burglary (/yr, 2y)');
expect(normalized[crimeKey]).toEqual([1, 5]);
// Non-crime filters are left untouched.
expect(normalized['Median price (£)']).toEqual([0, 100]);
});
});

View file

@ -1,26 +1,74 @@
import type { FeatureFilters, FeatureMeta } from '../types';
import type { VariantFilterConfig } from './variant-filter';
export const SPECIFIC_CRIMES_FILTER_NAME = 'Specific crimes';
export const SPECIFIC_CRIMES_FILTER_KEY_PREFIX = `${SPECIFIC_CRIMES_FILTER_NAME}:`;
export const SPECIFIC_CRIME_FEATURE_NAMES = [
'Violence and sexual offences (avg/yr)',
'Burglary (avg/yr)',
'Robbery (avg/yr)',
'Vehicle crime (avg/yr)',
'Anti-social behaviour (avg/yr)',
'Criminal damage and arson (avg/yr)',
'Other theft (avg/yr)',
'Theft from the person (avg/yr)',
'Shoplifting (avg/yr)',
'Bicycle theft (avg/yr)',
'Drugs (avg/yr)',
'Possession of weapons (avg/yr)',
'Public order (avg/yr)',
'Other crime (avg/yr)',
// The "pick one crime type" filter selects a single street-level category, with
// a toggle to measure it over the 7-year or the recent 2-year window. The 7-year
// window is the default/primary one (also headlined in the area pane).
export const SPECIFIC_CRIME_TYPES = [
'Violence and sexual offences',
'Burglary',
'Robbery',
'Vehicle crime',
'Anti-social behaviour',
'Criminal damage and arson',
'Other theft',
'Theft from the person',
'Shoplifting',
'Bicycle theft',
'Drugs',
'Possession of weapons',
'Public order',
'Other crime',
] as const;
const SPECIFIC_CRIME_FEATURE_NAME_SET = new Set<string>(SPECIFIC_CRIME_FEATURE_NAMES);
export const SPECIFIC_CRIME_WINDOW_DEFAULT = '7y';
export const SPECIFIC_CRIME_WINDOWS = ['7y', '2y'] as const;
export type SpecificCrimeWindow = (typeof SPECIFIC_CRIME_WINDOWS)[number];
/** The backend rate-feature column for a crime type in a given window. */
export function specificCrimeFeatureName(type: string, window: SpecificCrimeWindow): string {
return `${type} (/yr, ${window})`;
}
// Canonical dropdown enumeration: one feature name per crime type, in the
// default (7-year) window. The window toggle re-points these to the chosen window.
export const SPECIFIC_CRIME_FEATURE_NAMES = SPECIFIC_CRIME_TYPES.map((type) =>
specificCrimeFeatureName(type, SPECIFIC_CRIME_WINDOW_DEFAULT)
);
// Every recognized specific-crime feature, across all windows. Used to detect /
// resolve a filter key's backend column for either window.
const SPECIFIC_CRIME_FEATURE_NAME_SET = new Set<string>(
SPECIFIC_CRIME_TYPES.flatMap((type) =>
SPECIFIC_CRIME_WINDOWS.map((window) => specificCrimeFeatureName(type, window))
)
);
const SPECIFIC_CRIME_WINDOW_RE = / \(\/yr, (7y|2y)\)$/;
/** The window suffix of a specific-crime feature name (e.g. "2y"), or null. */
export function getSpecificCrimeWindow(featureName: string): SpecificCrimeWindow | null {
const match = featureName.match(SPECIFIC_CRIME_WINDOW_RE);
return match ? (match[1] as SpecificCrimeWindow) : null;
}
/** The bare crime type of a specific-crime feature name (e.g. "Burglary"), or null. */
export function getSpecificCrimeType(featureName: string): string | null {
const match = featureName.match(SPECIFIC_CRIME_WINDOW_RE);
return match ? featureName.slice(0, match.index) : null;
}
/** The same crime type's feature name in a different window (no-op if unrecognized). */
export function withSpecificCrimeWindow(
featureName: string,
window: SpecificCrimeWindow
): string {
const type = getSpecificCrimeType(featureName);
return type ? specificCrimeFeatureName(type, window) : featureName;
}
export function isSpecificCrimeFeatureName(name: string): boolean {
return SPECIFIC_CRIME_FEATURE_NAME_SET.has(name);
@ -101,7 +149,7 @@ export function getSpecificCrimeFilterMeta(features: FeatureMeta[]): FeatureMeta
description:
'Violence, burglary, robbery, drugs, shoplifting, vehicle crime, anti-social behaviour, public order, theft, and other crime types',
detail:
'Filter by one street-level crime category at a time using an area-normalised crime density near each postcode (not a count of incidents per year).',
'Filter by one street-level crime category at a time, as the average number of recorded incidents per year near the postcode. Toggle between the 7-year and the recent 2-year average.',
source: 'crime',
suffix: '',
};
@ -115,3 +163,26 @@ export function clampSpecificCrimeRange(
const max = feature?.histogram?.max ?? feature?.max ?? Math.max(1, value[1]);
return [Math.max(min, Math.min(value[0], max)), Math.max(min, Math.min(value[1], max))];
}
export const SPECIFIC_CRIME_VARIANT_CONFIG: VariantFilterConfig = {
filterName: SPECIFIC_CRIMES_FILTER_NAME,
featureNames: SPECIFIC_CRIME_FEATURE_NAMES,
dropdownLabelKey: 'filters.crimeType',
getFilterMeta: getSpecificCrimeFilterMeta,
getDefaultFeatureName: getDefaultSpecificCrimeFeatureName,
getFeatureName: getSpecificCrimeFeatureName,
replaceFilterKeySelection: replaceSpecificCrimeFilterKeySelection,
clampRange: clampSpecificCrimeRange,
// Dropdown shows the bare crime type; the window toggle carries the 7y/2y suffix.
getOptionLabelSource: (name) => getSpecificCrimeType(name) ?? name,
window: {
labelKey: 'filters.crimeWindow',
options: [
{ id: '7y', labelKey: 'filters.crimeWindow7y' },
{ id: '2y', labelKey: 'filters.crimeWindow2y' },
],
getWindow: getSpecificCrimeWindow,
withWindow: (name, windowId) =>
withSpecificCrimeWindow(name, windowId as SpecificCrimeWindow),
},
};

View file

@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import type { FeatureMeta } from '../types';
import {
CRIME_SEVERITY_FILTER_NAMES,
createCrimeSeverityFilterKey,
getCrimeSeverityFeatureName,
getCrimeSeverityFilterKeyId,
getCrimeSeverityFilterName,
getCrimeSeverityVariantConfig,
getDefaultCrimeSeverityFeatureName,
isCrimeSeverityFeatureName,
isCrimeSeverityFilterName,
normalizeCrimeSeverityFilters,
} from './crime-severity-filter';
const numeric = (name: string): FeatureMeta => ({ name, type: 'numeric', min: 0, max: 10 });
describe('crime-severity feature recognition', () => {
it('recognizes Serious/Minor crime in both windows only', () => {
expect(isCrimeSeverityFeatureName('Serious crime (/yr, 7y)')).toBe(true);
expect(isCrimeSeverityFeatureName('Serious crime (/yr, 2y)')).toBe(true);
expect(isCrimeSeverityFeatureName('Minor crime (/yr, 2y)')).toBe(true);
// Detailed categories belong to "Specific crimes", not severity.
expect(isCrimeSeverityFeatureName('Burglary (/yr, 7y)')).toBe(false);
// Bare names without a window suffix are not feature columns.
expect(isCrimeSeverityFeatureName('Serious crime')).toBe(false);
});
it('maps a feature or key back to its severity filter name', () => {
expect(getCrimeSeverityFilterName('Serious crime (/yr, 2y)')).toBe('Serious crime');
expect(getCrimeSeverityFilterName('Minor crime (/yr, 7y)')).toBe('Minor crime');
const key = createCrimeSeverityFilterKey('Serious crime', 'Serious crime (/yr, 7y)', 0);
expect(getCrimeSeverityFilterName(key)).toBe('Serious crime');
expect(getCrimeSeverityFilterName('Burglary (/yr, 7y)')).toBeNull();
});
});
describe('crime-severity filter keys', () => {
it('resolves a 2-year key back to its backend column and id', () => {
const key = createCrimeSeverityFilterKey('Minor crime', 'Minor crime (/yr, 2y)', 4);
expect(isCrimeSeverityFilterName(key)).toBe(true);
expect(getCrimeSeverityFilterKeyId(key)).toBe('4');
expect(getCrimeSeverityFeatureName(key)).toBe('Minor crime (/yr, 2y)');
});
it('defaults to the 7-year window when present', () => {
const features = [
numeric('Serious crime (/yr, 7y)'),
numeric('Serious crime (/yr, 2y)'),
];
expect(getDefaultCrimeSeverityFeatureName(features, 'Serious crime')).toBe(
'Serious crime (/yr, 7y)'
);
expect(getDefaultCrimeSeverityFeatureName(features, 'Minor crime')).toBeNull();
});
it('folds bare severity features but leaves specific crimes and others alone', () => {
const normalized = normalizeCrimeSeverityFilters({
'Serious crime (/yr, 2y)': [1, 5],
'Burglary (/yr, 7y)': [0, 3],
'Median price (£)': [0, 100],
});
const keys = Object.keys(normalized);
const severityKey = keys.find((k) => isCrimeSeverityFilterName(k))!;
expect(getCrimeSeverityFeatureName(severityKey)).toBe('Serious crime (/yr, 2y)');
expect(normalized[severityKey]).toEqual([1, 5]);
// Specific-crime + plain features pass through untouched.
expect(normalized['Burglary (/yr, 7y)']).toEqual([0, 3]);
expect(normalized['Median price (£)']).toEqual([0, 100]);
});
});
describe('crime-severity variant config', () => {
it('exposes a single variant per severity with a 7y/2y window toggle', () => {
for (const filterName of CRIME_SEVERITY_FILTER_NAMES) {
const config = getCrimeSeverityVariantConfig(filterName);
expect(config.filterName).toBe(filterName);
expect(config.featureNames).toEqual([`${filterName} (/yr, 7y)`]);
expect(config.window?.options.map((o) => o.id)).toEqual(['7y', '2y']);
// Switching window re-points to the same severity, other window.
const sevenYear = `${filterName} (/yr, 7y)`;
expect(config.window?.withWindow(sevenYear, '2y')).toBe(`${filterName} (/yr, 2y)`);
}
});
});

View file

@ -0,0 +1,193 @@
import type { FeatureFilters, FeatureMeta } from '../types';
import type { VariantFilterConfig } from './variant-filter';
import {
SPECIFIC_CRIME_WINDOW_DEFAULT,
clampSpecificCrimeRange,
getSpecificCrimeType,
getSpecificCrimeWindow,
specificCrimeFeatureName,
withSpecificCrimeWindow,
type SpecificCrimeWindow,
} from './crime-filter';
// "Serious crime" and "Minor crime" are aggregate severity rollups (they overlap
// the 14 detailed categories, so they must stay OUT of the "Specific crimes"
// dropdown). Each is a single feature with a 7-year/2-year window toggle — the
// same folding the specific-crimes card has, but with no variant dropdown (one
// variant). Modeled on the multi-filter-name POI pattern: one lib, two filter
// names, distinguished by their key prefix / bare crime type.
export const SERIOUS_CRIME_FILTER_NAME = 'Serious crime';
export const MINOR_CRIME_FILTER_NAME = 'Minor crime';
export const CRIME_SEVERITY_FILTER_NAMES = [
SERIOUS_CRIME_FILTER_NAME,
MINOR_CRIME_FILTER_NAME,
] as const;
export type CrimeSeverityFilterName = (typeof CRIME_SEVERITY_FILTER_NAMES)[number];
const CRIME_SEVERITY_TYPE_SET = new Set<string>(CRIME_SEVERITY_FILTER_NAMES);
const CRIME_SEVERITY_DETAILS: Record<CrimeSeverityFilterName, { description: string; detail: string }> = {
[SERIOUS_CRIME_FILTER_NAME]: {
description: 'Violence, robbery, burglary and weapons possession near the postcode',
detail:
'Combined count of the more serious street-level categories (violence and sexual offences, robbery, burglary, possession of weapons), as the average number of recorded incidents per year near the postcode. Toggle between the 7-year and the recent 2-year average.',
},
[MINOR_CRIME_FILTER_NAME]: {
description: 'Anti-social behaviour, theft, criminal damage, drugs and public order near the postcode',
detail:
'Combined count of the lower-severity street-level categories (anti-social behaviour, theft, criminal damage and arson, drugs, public order), as the average number of recorded incidents per year near the postcode. Toggle between the 7-year and the recent 2-year average.',
},
};
function keyPrefix(filterName: CrimeSeverityFilterName): string {
return `${filterName}:`;
}
/** The severity filter a name belongs to (from its key prefix or bare feature name), or null. */
export function getCrimeSeverityFilterName(name: string): CrimeSeverityFilterName | null {
for (const filterName of CRIME_SEVERITY_FILTER_NAMES) {
if (name.startsWith(keyPrefix(filterName))) return filterName;
}
const type = getSpecificCrimeType(name);
return type && CRIME_SEVERITY_TYPE_SET.has(type) ? (type as CrimeSeverityFilterName) : null;
}
export function isCrimeSeverityFeatureName(name: string): boolean {
const type = getSpecificCrimeType(name);
return type != null && CRIME_SEVERITY_TYPE_SET.has(type);
}
export function isCrimeSeverityFilterName(name: string): boolean {
return getCrimeSeverityFilterName(name) != null;
}
export function createCrimeSeverityFilterKey(
filterName: CrimeSeverityFilterName,
featureName: string,
id: number | string
): string {
return `${keyPrefix(filterName)}${encodeURIComponent(featureName)}:${id}`;
}
export function getCrimeSeverityFilterKeyId(name: string): string | null {
const filterName = getCrimeSeverityFilterName(name);
if (!filterName) return null;
const prefix = keyPrefix(filterName);
if (!name.startsWith(prefix)) return null; // a bare feature name has no id
const rest = name.substring(prefix.length);
const lastColon = rest.lastIndexOf(':');
return lastColon === -1 ? null : rest.substring(lastColon + 1);
}
export function parseCrimeSeverityFilterKey(name: string): string | null {
const filterName = getCrimeSeverityFilterName(name);
if (!filterName) return null;
const prefix = keyPrefix(filterName);
if (!name.startsWith(prefix)) return null;
const rest = name.substring(prefix.length);
const lastColon = rest.lastIndexOf(':');
if (lastColon === -1) return null;
const decoded = decodeURIComponent(rest.substring(0, lastColon));
return isCrimeSeverityFeatureName(decoded) ? decoded : null;
}
export function getCrimeSeverityFeatureName(name: string): string | null {
if (isCrimeSeverityFeatureName(name)) return name;
return parseCrimeSeverityFilterKey(name);
}
export function replaceCrimeSeverityFilterKeySelection(key: string, featureName: string): string {
const filterName = getCrimeSeverityFilterName(key) ?? getCrimeSeverityFilterName(featureName);
const id = getCrimeSeverityFilterKeyId(key) ?? '0';
// filterName is always resolvable here: the key carries a prefix and a window
// switch keeps the same severity type. Fall back defensively to the key as-is.
if (!filterName) return key;
return createCrimeSeverityFilterKey(filterName, featureName, id);
}
/** The default (7-year) backend feature for a severity, if present in `features`. */
export function getDefaultCrimeSeverityFeatureName(
features: FeatureMeta[],
filterName: CrimeSeverityFilterName
): string | null {
const name = specificCrimeFeatureName(filterName, SPECIFIC_CRIME_WINDOW_DEFAULT);
return features.some((feature) => feature.name === name) ? name : null;
}
export function normalizeCrimeSeverityFilters(filters: FeatureFilters): FeatureFilters {
let changed = false;
const next: FeatureFilters = {};
for (const [name, value] of Object.entries(filters)) {
if (isCrimeSeverityFeatureName(name)) {
const filterName = getCrimeSeverityFilterName(name)!;
next[createCrimeSeverityFilterKey(filterName, name, Object.keys(next).length)] = value;
changed = true;
continue;
}
next[name] = value;
}
return changed ? next : filters;
}
export function getCrimeSeverityFilterMeta(
features: FeatureMeta[],
filterName: CrimeSeverityFilterName
): FeatureMeta {
const sourceFeatureName = getDefaultCrimeSeverityFeatureName(features, filterName);
const sourceFeature = sourceFeatureName
? features.find((feature) => feature.name === sourceFeatureName)
: undefined;
return {
name: filterName,
type: 'numeric',
group: 'Crime',
min: sourceFeature?.min ?? 0,
max: sourceFeature?.max ?? 100,
step: 1,
description: CRIME_SEVERITY_DETAILS[filterName].description,
detail: CRIME_SEVERITY_DETAILS[filterName].detail,
source: 'crime',
suffix: '',
};
}
export function clampCrimeSeverityRange(
value: [number, number],
feature?: FeatureMeta
): [number, number] {
return clampSpecificCrimeRange(value, feature);
}
/** The variant-filter config for one severity (single variant + 7y/2y window toggle). */
export function getCrimeSeverityVariantConfig(
filterName: CrimeSeverityFilterName
): VariantFilterConfig {
return {
filterName,
// One variant: the severity itself, in the default window. The card hides the
// (single-option) dropdown and exposes only the window toggle.
featureNames: [specificCrimeFeatureName(filterName, SPECIFIC_CRIME_WINDOW_DEFAULT)],
dropdownLabelKey: 'filters.crimeType',
getFilterMeta: (features) => getCrimeSeverityFilterMeta(features, filterName),
getDefaultFeatureName: (features) => getDefaultCrimeSeverityFeatureName(features, filterName),
getFeatureName: getCrimeSeverityFeatureName,
replaceFilterKeySelection: replaceCrimeSeverityFilterKeySelection,
clampRange: clampCrimeSeverityRange,
window: {
labelKey: 'filters.crimeWindow',
options: [
{ id: '7y', labelKey: 'filters.crimeWindow7y' },
{ id: '2y', labelKey: 'filters.crimeWindow2y' },
],
getWindow: getSpecificCrimeWindow,
withWindow: (name, windowId) =>
withSpecificCrimeWindow(name, windowId as SpecificCrimeWindow),
},
};
}

View file

@ -6,24 +6,47 @@
export interface CrimeTypeDef {
value: string;
/** English label (fallback / reference). */
label: string;
/** i18n key under `crimeTypes.*` used to render the selector label. */
labelKey: string;
}
export const CRIME_TYPES: readonly CrimeTypeDef[] = [
{ value: 'Violence and sexual offences', label: 'Violence & sexual offences' },
{ value: 'Anti-social behaviour', label: 'Anti-social behaviour' },
{ value: 'Criminal damage and arson', label: 'Criminal damage & arson' },
{ value: 'Public order', label: 'Public order' },
{ value: 'Shoplifting', label: 'Shoplifting' },
{ value: 'Vehicle crime', label: 'Vehicle crime' },
{ value: 'Burglary', label: 'Burglary' },
{ value: 'Other theft', label: 'Other theft' },
{ value: 'Theft from the person', label: 'Theft from the person' },
{ value: 'Bicycle theft', label: 'Bicycle theft' },
{ value: 'Drugs', label: 'Drugs' },
{ value: 'Robbery', label: 'Robbery' },
{ value: 'Possession of weapons', label: 'Possession of weapons' },
{ value: 'Other crime', label: 'Other crime' },
{
value: 'Violence and sexual offences',
label: 'Violence & sexual offences',
labelKey: 'crimeTypes.violenceAndSexualOffences',
},
{
value: 'Anti-social behaviour',
label: 'Anti-social behaviour',
labelKey: 'crimeTypes.antiSocialBehaviour',
},
{
value: 'Criminal damage and arson',
label: 'Criminal damage & arson',
labelKey: 'crimeTypes.criminalDamageAndArson',
},
{ value: 'Public order', label: 'Public order', labelKey: 'crimeTypes.publicOrder' },
{ value: 'Shoplifting', label: 'Shoplifting', labelKey: 'crimeTypes.shoplifting' },
{ value: 'Vehicle crime', label: 'Vehicle crime', labelKey: 'crimeTypes.vehicleCrime' },
{ value: 'Burglary', label: 'Burglary', labelKey: 'crimeTypes.burglary' },
{ value: 'Other theft', label: 'Other theft', labelKey: 'crimeTypes.otherTheft' },
{
value: 'Theft from the person',
label: 'Theft from the person',
labelKey: 'crimeTypes.theftFromThePerson',
},
{ value: 'Bicycle theft', label: 'Bicycle theft', labelKey: 'crimeTypes.bicycleTheft' },
{ value: 'Drugs', label: 'Drugs', labelKey: 'crimeTypes.drugs' },
{ value: 'Robbery', label: 'Robbery', labelKey: 'crimeTypes.robbery' },
{
value: 'Possession of weapons',
label: 'Possession of weapons',
labelKey: 'crimeTypes.possessionOfWeapons',
},
{ value: 'Other crime', label: 'Other crime', labelKey: 'crimeTypes.otherCrime' },
] as const;
export const CRIME_TYPE_VALUES: readonly string[] = CRIME_TYPES.map((c) => c.value);

View file

@ -404,7 +404,12 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
* Returns a complete SVG icon element for a given feature name, or null if unmapped.
*/
export function getFeatureIcon(featureName: string, className: string): ReactElement | null {
const paths = FEATURE_ICON_PATHS[featureName];
// Crime features ("Burglary (/yr, 7y)") share the bare type's legacy
// "(avg/yr)" icon key; both windows use the same icon.
const rateMatch = featureName.match(/^(.*) \(\/yr, \d+y\)$/);
const paths =
FEATURE_ICON_PATHS[featureName] ??
(rateMatch ? FEATURE_ICON_PATHS[`${rateMatch[1]} (avg/yr)`] : undefined);
if (!paths) return null;
return (
<svg

View file

@ -1,9 +1,16 @@
import type { FeatureFilters, FeatureMeta } from '../types';
import type { VariantFilterConfig } from './variant-filter';
export const QUALIFICATIONS_FILTER_NAME = 'Qualifications';
export const QUALIFICATIONS_FILTER_KEY_PREFIX = `${QUALIFICATIONS_FILTER_NAME}:`;
/**
* The Census 2021 qualification-breakdown features (TS067). These render as a
* single stacked "Qualifications" composition in the area pane (see
* STACKED_GROUPS["Neighbours"] in consts.ts) and are display-only: they are
* hidden from the filter browser rather than offered as seven individual
* sliders, so the breakdown reads as a ratio without cluttering the filter list.
* The Census 2021 "highest level of qualification" bands (TS067). They sum to
* 100% per neighbourhood and render as a single stacked "Qualifications"
* composition in the area pane (see STACKED_GROUPS["Neighbours"] in consts.ts).
* In the filter browser they fold into one "Qualifications" filter whose
* dropdown selects a band including "% Degree or higher" rather than seven
* separate sliders.
*/
export const QUALIFICATION_FEATURE_NAMES = [
'% No qualifications',
@ -20,3 +27,103 @@ const QUALIFICATION_FEATURE_NAME_SET = new Set<string>(QUALIFICATION_FEATURE_NAM
export function isQualificationFeatureName(name: string): boolean {
return QUALIFICATION_FEATURE_NAME_SET.has(name);
}
export function isQualificationFilterName(name: string): boolean {
return isQualificationFeatureName(name) || name.startsWith(QUALIFICATIONS_FILTER_KEY_PREFIX);
}
export function createQualificationFilterKey(featureName: string, id: number | string): string {
return `${QUALIFICATIONS_FILTER_KEY_PREFIX}${encodeURIComponent(featureName)}:${id}`;
}
export function getQualificationFilterKeyId(name: string): string | null {
if (!name.startsWith(QUALIFICATIONS_FILTER_KEY_PREFIX)) return null;
const rest = name.substring(QUALIFICATIONS_FILTER_KEY_PREFIX.length);
const lastColon = rest.lastIndexOf(':');
return lastColon === -1 ? null : rest.substring(lastColon + 1);
}
export function parseQualificationFilterKey(name: string): string | null {
if (!name.startsWith(QUALIFICATIONS_FILTER_KEY_PREFIX)) return null;
const rest = name.substring(QUALIFICATIONS_FILTER_KEY_PREFIX.length);
const lastColon = rest.lastIndexOf(':');
if (lastColon === -1) return null;
const decoded = decodeURIComponent(rest.substring(0, lastColon));
return isQualificationFeatureName(decoded) ? decoded : null;
}
export function getQualificationFeatureName(name: string): string | null {
if (isQualificationFeatureName(name)) return name;
return parseQualificationFilterKey(name);
}
export function replaceQualificationFilterKeySelection(key: string, featureName: string): string {
const id = getQualificationFilterKeyId(key) ?? '0';
return createQualificationFilterKey(featureName, id);
}
export function getDefaultQualificationFeatureName(features: FeatureMeta[]): string | null {
return (
QUALIFICATION_FEATURE_NAMES.find((name) => features.some((feature) => feature.name === name)) ??
null
);
}
export function normalizeQualificationFilters(filters: FeatureFilters): FeatureFilters {
let changed = false;
const next: FeatureFilters = {};
for (const [name, value] of Object.entries(filters)) {
if (isQualificationFeatureName(name)) {
next[createQualificationFilterKey(name, Object.keys(next).length)] = value;
changed = true;
continue;
}
next[name] = value;
}
return changed ? next : filters;
}
export function getQualificationFilterMeta(features: FeatureMeta[]): FeatureMeta {
const sourceFeatureName = getDefaultQualificationFeatureName(features);
const sourceFeature = sourceFeatureName
? features.find((feature) => feature.name === sourceFeatureName)
: undefined;
return {
name: QUALIFICATIONS_FILTER_NAME,
type: 'numeric',
group: 'Neighbours',
min: sourceFeature?.min ?? 0,
max: sourceFeature?.max ?? 100,
step: 1,
description:
'Share of residents (16+) by highest qualification, from no qualifications to a degree or higher',
detail:
'Filter by one Census 2021 (TS067) highest-qualification band at a time, e.g. the percentage of residents whose highest qualification is a degree or higher.',
source: 'census-2021',
suffix: '%',
};
}
export function clampQualificationRange(
value: [number, number],
feature?: FeatureMeta
): [number, number] {
const min = feature?.histogram?.min ?? feature?.min ?? 0;
const max = feature?.histogram?.max ?? feature?.max ?? Math.max(1, value[1]);
return [Math.max(min, Math.min(value[0], max)), Math.max(min, Math.min(value[1], max))];
}
export const QUALIFICATION_VARIANT_CONFIG: VariantFilterConfig = {
filterName: QUALIFICATIONS_FILTER_NAME,
featureNames: QUALIFICATION_FEATURE_NAMES,
dropdownLabelKey: 'filters.qualificationLevel',
getFilterMeta: getQualificationFilterMeta,
getDefaultFeatureName: getDefaultQualificationFeatureName,
getFeatureName: getQualificationFeatureName,
replaceFilterKeySelection: replaceQualificationFilterKeySelection,
clampRange: clampQualificationRange,
};

View file

@ -0,0 +1,121 @@
import type { FeatureFilters, FeatureMeta } from '../types';
import type { VariantFilterConfig } from './variant-filter';
export const TENURE_FILTER_NAME = 'Tenure';
export const TENURE_FILTER_KEY_PREFIX = `${TENURE_FILTER_NAME}:`;
/**
* The Census 2021 housing tenure categories (TS054). They sum to 100% per
* neighbourhood and render as a single stacked "Tenure" composition in the area
* pane (see STACKED_GROUPS["Neighbours"] in consts.ts). In the filter browser
* they fold into one "Tenure" filter whose dropdown selects a category
* owner-occupied, social rent or private rent rather than three separate
* sliders.
*/
export const TENURE_FEATURE_NAMES = [
'% Owner occupied',
'% Social rent',
'% Private rent',
] as const;
const TENURE_FEATURE_NAME_SET = new Set<string>(TENURE_FEATURE_NAMES);
export function isTenureFeatureName(name: string): boolean {
return TENURE_FEATURE_NAME_SET.has(name);
}
export function isTenureFilterName(name: string): boolean {
return isTenureFeatureName(name) || name.startsWith(TENURE_FILTER_KEY_PREFIX);
}
export function createTenureFilterKey(featureName: string, id: number | string): string {
return `${TENURE_FILTER_KEY_PREFIX}${encodeURIComponent(featureName)}:${id}`;
}
export function getTenureFilterKeyId(name: string): string | null {
if (!name.startsWith(TENURE_FILTER_KEY_PREFIX)) return null;
const rest = name.substring(TENURE_FILTER_KEY_PREFIX.length);
const lastColon = rest.lastIndexOf(':');
return lastColon === -1 ? null : rest.substring(lastColon + 1);
}
export function parseTenureFilterKey(name: string): string | null {
if (!name.startsWith(TENURE_FILTER_KEY_PREFIX)) return null;
const rest = name.substring(TENURE_FILTER_KEY_PREFIX.length);
const lastColon = rest.lastIndexOf(':');
if (lastColon === -1) return null;
const decoded = decodeURIComponent(rest.substring(0, lastColon));
return isTenureFeatureName(decoded) ? decoded : null;
}
export function getTenureFeatureName(name: string): string | null {
if (isTenureFeatureName(name)) return name;
return parseTenureFilterKey(name);
}
export function replaceTenureFilterKeySelection(key: string, featureName: string): string {
const id = getTenureFilterKeyId(key) ?? '0';
return createTenureFilterKey(featureName, id);
}
export function getDefaultTenureFeatureName(features: FeatureMeta[]): string | null {
return (
TENURE_FEATURE_NAMES.find((name) => features.some((feature) => feature.name === name)) ?? null
);
}
export function normalizeTenureFilters(filters: FeatureFilters): FeatureFilters {
let changed = false;
const next: FeatureFilters = {};
for (const [name, value] of Object.entries(filters)) {
if (isTenureFeatureName(name)) {
next[createTenureFilterKey(name, Object.keys(next).length)] = value;
changed = true;
continue;
}
next[name] = value;
}
return changed ? next : filters;
}
export function getTenureFilterMeta(features: FeatureMeta[]): FeatureMeta {
const sourceFeatureName = getDefaultTenureFeatureName(features);
const sourceFeature = sourceFeatureName
? features.find((feature) => feature.name === sourceFeatureName)
: undefined;
return {
name: TENURE_FILTER_NAME,
type: 'numeric',
group: 'Neighbours',
min: sourceFeature?.min ?? 0,
max: sourceFeature?.max ?? 100,
step: 1,
description:
'Share of households that own their home, rent from a social landlord, or rent privately',
detail:
'Filter by one Census 2021 (TS054) housing tenure category at a time, e.g. the percentage of households that own their home.',
source: 'census-2021',
suffix: '%',
};
}
export function clampTenureRange(value: [number, number], feature?: FeatureMeta): [number, number] {
const min = feature?.histogram?.min ?? feature?.min ?? 0;
const max = feature?.histogram?.max ?? feature?.max ?? Math.max(1, value[1]);
return [Math.max(min, Math.min(value[0], max)), Math.max(min, Math.min(value[1], max))];
}
export const TENURE_VARIANT_CONFIG: VariantFilterConfig = {
filterName: TENURE_FILTER_NAME,
featureNames: TENURE_FEATURE_NAMES,
dropdownLabelKey: 'filters.tenureType',
getFilterMeta: getTenureFilterMeta,
getDefaultFeatureName: getDefaultTenureFeatureName,
getFeatureName: getTenureFeatureName,
replaceFilterKeySelection: replaceTenureFilterKeySelection,
clampRange: clampTenureRange,
};

View file

@ -5,8 +5,11 @@ import { parseUrlState, stateToParams } from './url-state';
import { INITIAL_VIEW_STATE } from './consts';
import { createSchoolFilterKey } from './school-filter';
import { createSpecificCrimeFilterKey } from './crime-filter';
import { createCrimeSeverityFilterKey } from './crime-severity-filter';
import { createElectionVoteShareFilterKey } from './election-filter';
import { createEthnicityFilterKey } from './ethnicity-filter';
import { createQualificationFilterKey } from './qualification-filter';
import { createTenureFilterKey } from './tenure-filter';
import {
POI_COUNT_2KM_FILTER_NAME,
TRANSPORT_DISTANCE_FILTER_NAME,
@ -405,8 +408,8 @@ describe('url-state', () => {
});
it('round-trips repeated specific crime filters with dedicated URL params', () => {
const burglary = createSpecificCrimeFilterKey('Burglary (avg/yr)', 1);
const vehicleCrime = createSpecificCrimeFilterKey('Vehicle crime (avg/yr)', 2);
const burglary = createSpecificCrimeFilterKey('Burglary (/yr, 7y)', 1);
const vehicleCrime = createSpecificCrimeFilterKey('Vehicle crime (/yr, 7y)', 2);
const params = stateToParams(
null,
@ -420,8 +423,8 @@ describe('url-state', () => {
);
expect(params.getAll('crime')).toEqual([
'Burglary (avg/yr):0:5',
'Vehicle crime (avg/yr):1:10',
'Burglary (/yr, 7y):0:5',
'Vehicle crime (/yr, 7y):1:10',
]);
expect(params.getAll('filter')).toEqual([]);
@ -429,8 +432,42 @@ describe('url-state', () => {
const state = parseUrlState();
expect(state.filters).toEqual({
[createSpecificCrimeFilterKey('Burglary (avg/yr)', 0)]: [0, 5],
[createSpecificCrimeFilterKey('Vehicle crime (avg/yr)', 1)]: [1, 10],
[createSpecificCrimeFilterKey('Burglary (/yr, 7y)', 0)]: [0, 5],
[createSpecificCrimeFilterKey('Vehicle crime (/yr, 7y)', 1)]: [1, 10],
});
});
it('round-trips serious/minor crime severity filters with dedicated URL params', () => {
const serious = createCrimeSeverityFilterKey(
'Serious crime',
'Serious crime (/yr, 7y)',
0
);
const minor = createCrimeSeverityFilterKey('Minor crime', 'Minor crime (/yr, 2y)', 1);
const params = stateToParams(
null,
{
[serious]: [0, 12],
[minor]: [3, 40],
},
[],
new Set(),
'area'
);
expect(params.getAll('crimeSeverity')).toEqual([
'Serious crime (/yr, 7y):0:12',
'Minor crime (/yr, 2y):3:40',
]);
expect(params.getAll('filter')).toEqual([]);
window.history.replaceState({}, '', `/?${params.toString()}`);
const state = parseUrlState();
expect(state.filters).toEqual({
[createCrimeSeverityFilterKey('Serious crime', 'Serious crime (/yr, 7y)', 0)]: [0, 12],
[createCrimeSeverityFilterKey('Minor crime', 'Minor crime (/yr, 2y)', 1)]: [3, 40],
});
});
@ -488,6 +525,65 @@ describe('url-state', () => {
});
});
it('round-trips repeated qualification filters with dedicated URL params', () => {
// "% Degree or higher" exercises the leading-`%` + space encoding path.
const degree = createQualificationFilterKey('% Degree or higher', 1);
const noQuals = createQualificationFilterKey('% No qualifications', 2);
const params = stateToParams(
null,
{
[degree]: [20, 60],
[noQuals]: [0, 25],
},
[],
new Set(),
'area'
);
expect(params.getAll('qualification')).toEqual([
'% Degree or higher:20:60',
'% No qualifications:0:25',
]);
expect(params.getAll('filter')).toEqual([]);
window.history.replaceState({}, '', `/?${params.toString()}`);
const state = parseUrlState();
expect(state.filters).toEqual({
[createQualificationFilterKey('% Degree or higher', 0)]: [20, 60],
[createQualificationFilterKey('% No qualifications', 1)]: [0, 25],
});
});
it('round-trips repeated tenure filters with dedicated URL params', () => {
// "% Owner occupied" exercises the leading-`%` + space encoding path.
const owner = createTenureFilterKey('% Owner occupied', 1);
const privateRent = createTenureFilterKey('% Private rent', 2);
const params = stateToParams(
null,
{
[owner]: [20, 60],
[privateRent]: [0, 25],
},
[],
new Set(),
'area'
);
expect(params.getAll('tenure')).toEqual(['% Owner occupied:20:60', '% Private rent:0:25']);
expect(params.getAll('filter')).toEqual([]);
window.history.replaceState({}, '', `/?${params.toString()}`);
const state = parseUrlState();
expect(state.filters).toEqual({
[createTenureFilterKey('% Owner occupied', 0)]: [20, 60],
[createTenureFilterKey('% Private rent', 1)]: [0, 25],
});
});
it('round-trips repeated amenity distance filters with dedicated URL params', () => {
const park = createPoiDistanceFilterKey('Distance to nearest amenity (Park) (km)', 3);
const cafe = createPoiDistanceFilterKey('Distance to nearest amenity (Café) (km)', 4);

View file

@ -22,6 +22,13 @@ import {
isSpecificCrimeFeatureName,
isSpecificCrimeFilterName,
} from './crime-filter';
import {
createCrimeSeverityFilterKey,
getCrimeSeverityFeatureName,
getCrimeSeverityFilterName,
isCrimeSeverityFeatureName,
isCrimeSeverityFilterName,
} from './crime-severity-filter';
import {
ELECTION_VOTE_SHARE_FILTER_NAME,
createElectionVoteShareFilterKey,
@ -36,6 +43,20 @@ import {
isEthnicityFeatureName,
isEthnicityFilterName,
} from './ethnicity-filter';
import {
QUALIFICATIONS_FILTER_NAME,
createQualificationFilterKey,
getQualificationFeatureName,
isQualificationFeatureName,
isQualificationFilterName,
} from './qualification-filter';
import {
TENURE_FILTER_NAME,
createTenureFilterKey,
getTenureFeatureName,
isTenureFeatureName,
isTenureFilterName,
} from './tenure-filter';
import {
POI_DISTANCE_FILTER_NAME,
TRANSPORT_DISTANCE_FILTER_NAME,
@ -83,8 +104,11 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
const filterParams = params.getAll('filter');
const schoolParams = params.getAll('school');
const crimeParams = params.getAll('crime');
const crimeSeverityParams = params.getAll('crimeSeverity');
const voteShareParams = params.getAll('voteShare');
const ethnicityParams = params.getAll('ethnicity');
const qualificationParams = params.getAll('qualification');
const tenureParams = params.getAll('tenure');
const amenityDistanceParams = params.getAll('amenityDistance');
const transportDistanceParams = params.getAll('transportDistance');
const amenityCount2KmParams = params.getAll('amenityCount2km');
@ -93,8 +117,11 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
filterParams.length === 0 &&
schoolParams.length === 0 &&
crimeParams.length === 0 &&
crimeSeverityParams.length === 0 &&
voteShareParams.length === 0 &&
ethnicityParams.length === 0 &&
qualificationParams.length === 0 &&
tenureParams.length === 0 &&
amenityDistanceParams.length === 0 &&
transportDistanceParams.length === 0 &&
amenityCount2KmParams.length === 0 &&
@ -155,6 +182,19 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
filters[createSpecificCrimeFilterKey(featureName, index)] = [min, max];
});
crimeSeverityParams.forEach((entry, index) => {
const parts = entry.split(':');
if (parts.length < 3) return;
const featureName = parts.slice(0, -2).join(':');
const min = Number(parts[parts.length - 2]);
const max = Number(parts[parts.length - 1]);
const filterName = getCrimeSeverityFilterName(featureName);
if (!isCrimeSeverityFeatureName(featureName) || !filterName || isNaN(min) || isNaN(max)) {
return;
}
filters[createCrimeSeverityFilterKey(filterName, featureName, index)] = [min, max];
});
voteShareParams.forEach((entry, index) => {
const parts = entry.split(':');
if (parts.length < 3) return;
@ -179,6 +219,30 @@ function parseFilters(params: URLSearchParams): FeatureFilters {
filters[createEthnicityFilterKey(featureName, index)] = [min, max];
});
qualificationParams.forEach((entry, index) => {
const parts = entry.split(':');
if (parts.length < 3) return;
const featureName = parts.slice(0, -2).join(':');
const min = Number(parts[parts.length - 2]);
const max = Number(parts[parts.length - 1]);
if (!isQualificationFeatureName(featureName) || isNaN(min) || isNaN(max)) {
return;
}
filters[createQualificationFilterKey(featureName, index)] = [min, max];
});
tenureParams.forEach((entry, index) => {
const parts = entry.split(':');
if (parts.length < 3) return;
const featureName = parts.slice(0, -2).join(':');
const min = Number(parts[parts.length - 2]);
const max = Number(parts[parts.length - 1]);
if (!isTenureFeatureName(featureName) || isNaN(min) || isNaN(max)) {
return;
}
filters[createTenureFilterKey(featureName, index)] = [min, max];
});
const parsePoiParams = (entries: string[], filterName: PoiFilterName, startIndex: number) => {
entries.forEach((entry, index) => {
const parts = entry.split(':');
@ -401,6 +465,13 @@ export function stateToParams(
continue;
}
const crimeSeverityFeatureName = getCrimeSeverityFeatureName(name);
if (crimeSeverityFeatureName && isCrimeSeverityFilterName(name)) {
const [min, max] = value as [number, number];
params.append('crimeSeverity', `${crimeSeverityFeatureName}:${min}:${max}`);
continue;
}
const electionVoteShareFeatureName = getElectionVoteShareFeatureName(name);
if (electionVoteShareFeatureName && isElectionVoteShareFilterName(name)) {
const [min, max] = value as [number, number];
@ -415,6 +486,20 @@ export function stateToParams(
continue;
}
const qualificationFeatureName = getQualificationFeatureName(name);
if (qualificationFeatureName && isQualificationFilterName(name)) {
const [min, max] = value as [number, number];
params.append('qualification', `${qualificationFeatureName}:${min}:${max}`);
continue;
}
const tenureFeatureName = getTenureFeatureName(name);
if (tenureFeatureName && isTenureFilterName(name)) {
const [min, max] = value as [number, number];
params.append('tenure', `${tenureFeatureName}:${min}:${max}`);
continue;
}
const amenityDistanceFeatureName = getPoiDistanceFeatureName(name);
if (amenityDistanceFeatureName && isPoiDistanceFilterName(name)) {
const [min, max] = value as [number, number];
@ -522,8 +607,11 @@ export function summarizeParams(queryString: string): string {
const filterParams = params.getAll('filter');
const schoolParams = params.getAll('school');
const crimeParams = params.getAll('crime');
const crimeSeverityParams = params.getAll('crimeSeverity');
const voteShareParams = params.getAll('voteShare');
const ethnicityParams = params.getAll('ethnicity');
const qualificationParams = params.getAll('qualification');
const tenureParams = params.getAll('tenure');
const amenityDistanceParams = params.getAll('amenityDistance');
const transportDistanceParams = params.getAll('transportDistance');
const amenityCount2KmParams = params.getAll('amenityCount2km');
@ -532,8 +620,11 @@ export function summarizeParams(queryString: string): string {
filterParams.length > 0 ||
schoolParams.length > 0 ||
crimeParams.length > 0 ||
crimeSeverityParams.length > 0 ||
voteShareParams.length > 0 ||
ethnicityParams.length > 0 ||
qualificationParams.length > 0 ||
tenureParams.length > 0 ||
amenityDistanceParams.length > 0 ||
transportDistanceParams.length > 0 ||
amenityCount2KmParams.length > 0 ||
@ -544,8 +635,11 @@ export function summarizeParams(queryString: string): string {
const colonIdx = entry.indexOf(':');
const name = colonIdx > 0 ? entry.substring(0, colonIdx) : entry;
if (isSpecificCrimeFeatureName(name)) return SPECIFIC_CRIMES_FILTER_NAME;
if (isCrimeSeverityFeatureName(name)) return getCrimeSeverityFilterName(name) ?? name;
if (isElectionVoteShareFeatureName(name)) return ELECTION_VOTE_SHARE_FILTER_NAME;
if (isEthnicityFeatureName(name)) return ETHNICITIES_FILTER_NAME;
if (isQualificationFeatureName(name)) return QUALIFICATIONS_FILTER_NAME;
if (isTenureFeatureName(name)) return TENURE_FILTER_NAME;
const poiFilterName = getPoiFilterName(name);
if (poiFilterName) return poiFilterName;
return name;
@ -555,12 +649,24 @@ export function summarizeParams(queryString: string): string {
for (let i = 0; i < crimeParams.length; i++) {
filterNames.push(SPECIFIC_CRIMES_FILTER_NAME);
}
for (const entry of crimeSeverityParams) {
const colonIdx = entry.indexOf(':');
const featureName = colonIdx > 0 ? entry.substring(0, colonIdx) : entry;
const severityFilterName = getCrimeSeverityFilterName(featureName);
if (severityFilterName) filterNames.push(severityFilterName);
}
for (let i = 0; i < voteShareParams.length; i++) {
filterNames.push(ELECTION_VOTE_SHARE_FILTER_NAME);
}
for (let i = 0; i < ethnicityParams.length; i++) {
filterNames.push(ETHNICITIES_FILTER_NAME);
}
for (let i = 0; i < qualificationParams.length; i++) {
filterNames.push(QUALIFICATIONS_FILTER_NAME);
}
for (let i = 0; i < tenureParams.length; i++) {
filterNames.push(TENURE_FILTER_NAME);
}
for (let i = 0; i < amenityDistanceParams.length; i++) {
filterNames.push(POI_DISTANCE_FILTER_NAME);
}

View file

@ -0,0 +1,80 @@
import type { FeatureMeta } from '../types';
/** i18n keys (typed for the strict `t()`) usable as a variant dropdown label. */
type VariantDropdownLabelKey =
| 'filters.crimeType'
| 'filters.qualificationLevel'
| 'filters.tenureType';
/** i18n keys (typed for the strict `t()`) usable as a window-toggle label. */
type VariantWindowLabelKey =
| 'filters.crimeWindow'
| 'filters.crimeWindow7y'
| 'filters.crimeWindow2y';
/** One option in a variant filter's secondary window/period toggle. */
export interface VariantWindowOption {
/** Stable id encoded inside the feature name (e.g. "7y"). */
id: string;
/** i18n key for the toggle button label. */
labelKey: VariantWindowLabelKey;
}
/**
* Optional secondary axis for a variant filter: the same variant measured over
* a different time window (e.g. crime rates over 7 vs 2 years). The dropdown
* still picks the variant; this toggle picks the window. Both ultimately select
* a single backend feature name, so switching either re-points the filter key.
*/
export interface VariantWindowConfig {
/** Toggle options in display order. */
options: VariantWindowOption[];
/** Optional i18n key for a small label above the toggle. */
labelKey?: VariantWindowLabelKey;
/** Window id of a feature name (e.g. "7y"), or null if unrecognized. */
getWindow: (featureName: string) => string | null;
/** The same variant's feature name in a different window. */
withWindow: (featureName: string, windowId: string) => string;
}
/**
* Shared shape for the "pick one backend feature variant, filter its range"
* family of client-side aggregate filters. Several Census/police breakdowns
* (specific crimes, qualifications, ) are dozens of individual percentage
* features that would clutter the filter browser as separate sliders. Instead
* each is folded into a single named filter whose card carries a dropdown to
* choose the variant, reusing one component ([`VariantFilterCard`]).
*
* Each filter library (e.g. `crime-filter`, `qualification-filter`) exports a
* config so the card stays variant-agnostic.
*/
export interface VariantFilterConfig {
/** Display name of the aggregate filter, e.g. "Specific crimes". */
filterName: string;
/**
* Backend feature names enumerating the dropdown options, in display order.
* With a [`window`] config these are the canonical (default-window) names;
* the card re-points each to the currently selected window.
*/
featureNames: readonly string[];
/** i18n key for the dropdown label, e.g. "filters.crimeType". */
dropdownLabelKey: VariantDropdownLabelKey;
/** Synthetic [`FeatureMeta`] used for the aggregate filter's own label/info. */
getFilterMeta: (features: FeatureMeta[]) => FeatureMeta;
/** First selectable variant present in `features`, or null if none. */
getDefaultFeatureName: (features: FeatureMeta[]) => string | null;
/** Backend feature name for a filter key (or a bare feature name). */
getFeatureName: (name: string) => string | null;
/** Rewrite a filter key to point at a different variant, keeping its id. */
replaceFilterKeySelection: (key: string, featureName: string) => string;
/** Clamp a [min, max] range into the selected feature's bounds. */
clampRange: (value: [number, number], feature?: FeatureMeta) => [number, number];
/**
* Server-translatable source value for a dropdown option's label; the card
* renders `ts(...)` of it. Defaults to the feature name itself. Useful with a
* [`window`] toggle so the label can be the bare variant (no window suffix).
*/
getOptionLabelSource?: (featureName: string) => string;
/** Optional secondary window/period toggle (e.g. 7- vs 2-year crime rates). */
window?: VariantWindowConfig;
}

View file

@ -316,26 +316,17 @@ export interface CrimeYearStats {
}
export interface CrimeAreaAverage {
/** Full rate-feature name (e.g. "Burglary (per 1k/yr, 7y)"). */
/** Full crime-feature name (e.g. "Burglary (/yr, 7y)"). */
name: string;
/** Exact national mean rate. Preferred over the histogram-bin national
/** Exact national mean count. Preferred over the histogram-bin national
* average for crime so all reference numbers share one estimator. */
national?: number;
/** Mean rate across the selection's outcode. */
/** Mean count across the selection's outcode. */
outcode?: number;
/** Mean rate across the selection's postcode sector. */
/** Mean count across the selection's postcode sector. */
sector?: number;
}
export interface CrimeRawStats {
/** Bare crime type (e.g. "Burglary"). */
name: string;
/** Mean recorded incidents/yr over the last 7 years. */
per_yr_7y?: number;
/** Mean recorded incidents/yr over the last 2 years. */
per_yr_2y?: number;
}
/** One individual police.uk crime, from /api/crime-records. */
export interface CrimeIncident {
/** "YYYY-MM". */
@ -352,6 +343,11 @@ export interface CrimeRecordsResponse {
records: CrimeIncident[];
total: number;
offset: number;
/**
* Server flag meaning "more pages exist beyond this one". The client derives
* pagination from `total` vs `records.length`, so this is currently not
* surfaced in the UI (kept to mirror the server response shape).
*/
truncated: boolean;
}
@ -386,12 +382,9 @@ export interface HexagonStatsResponse {
/** Postcode sector (e.g. "E14 2") of the selection's central postcode, when
* sector crime averages are available for it. */
crime_sector?: string;
/** Per-rate-feature average rates across the central postcode's outcode and
/** Per-crime-feature average counts across the central postcode's outcode and
* sector, shown alongside the national average for each crime metric. */
crime_area_averages?: CrimeAreaAverage[];
/** Raw (un-normalised) recorded incidents/yr per crime type, shown beside the
* normalised rate. Display-only. */
crime_raw?: CrimeRawStats[];
/** Total individual crime records (last 7 years) across the selection's
* postcodes the count behind the "individual crimes" list. */
crime_total_records?: number;