lgtm
This commit is contained in:
parent
2efa4d9f47
commit
5e73287eaf
99 changed files with 6392 additions and 1462 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
204
frontend/src/components/map/CrimeGroupBody.tsx
Normal file
204
frontend/src/components/map/CrimeGroupBody.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
147
frontend/src/components/map/CrimeRecordsSection.tsx
Normal file
147
frontend/src/components/map/CrimeRecordsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}`];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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's graphics context is interrupted.
|
||||
{i18n.t('map.error.body', {
|
||||
defaultValue: 'This can happen when your browser’s 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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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', '')
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
98
frontend/src/hooks/useClickedListings.test.ts
Normal file
98
frontend/src/hooks/useClickedListings.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
82
frontend/src/hooks/useClickedListings.ts
Normal file
82
frontend/src/hooks/useClickedListings.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
[]
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
80
frontend/src/hooks/useRevealOnExpand.test.tsx
Normal file
80
frontend/src/hooks/useRevealOnExpand.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
97
frontend/src/hooks/useRevealOnExpand.ts
Normal file
97
frontend/src/hooks/useRevealOnExpand.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
79
frontend/src/lib/crime-filter.test.ts
Normal file
79
frontend/src/lib/crime-filter.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
|
|
|
|||
85
frontend/src/lib/crime-severity-filter.test.ts
Normal file
85
frontend/src/lib/crime-severity-filter.test.ts
Normal 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)`);
|
||||
}
|
||||
});
|
||||
});
|
||||
193
frontend/src/lib/crime-severity-filter.ts
Normal file
193
frontend/src/lib/crime-severity-filter.ts
Normal 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
121
frontend/src/lib/tenure-filter.ts
Normal file
121
frontend/src/lib/tenure-filter.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
80
frontend/src/lib/variant-filter.ts
Normal file
80
frontend/src/lib/variant-filter.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue