+
)}
diff --git a/frontend/src/components/map/filters/SpecificCrimeFilterCard.tsx b/frontend/src/components/map/filters/VariantFilterCard.tsx
similarity index 51%
rename from frontend/src/components/map/filters/SpecificCrimeFilterCard.tsx
rename to frontend/src/components/map/filters/VariantFilterCard.tsx
index 07273ba..325e9fd 100644
--- a/frontend/src/components/map/filters/SpecificCrimeFilterCard.tsx
+++ b/frontend/src/components/map/filters/VariantFilterCard.tsx
@@ -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 (
-
-
-
-
-
+ {/* 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 && (
+
+
+
+
+
+
-
+ )}
+
+ {windowConfig && currentWindow && windowOptions.length > 1 && (
+
+ {windowConfig.labelKey && (
+
+ )}
+
+ {windowOptions.map((option) => {
+ const active = option.id === currentWindow;
+ return (
+
+ );
+ })}
+
+
+ )}
{mobileIcon &&
{mobileIcon}
}
@@ -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()}
/>
- onFilterChange(crimeFeature.name, clampSpecificCrimeRange(v, selectedFeature))
+ onFilterChange(variantFeature.name, config.clampRange(v, selectedFeature))
}
/>
{filterImpact != null && filterImpact > 0 && (
diff --git a/frontend/src/components/map/map-page/DesktopMapPage.tsx b/frontend/src/components/map/map-page/DesktopMapPage.tsx
index f66cd5d..9f65db4 100644
--- a/frontend/src/components/map/map-page/DesktopMapPage.tsx
+++ b/frontend/src/components/map/map-page/DesktopMapPage.tsx
@@ -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({
)}
- Listings{actualListingsEnabled ? ` (${actualListings.length})` : ''}
+ {t('map.actualListings.label')}{actualListingsEnabled ? ` (${actualListings.length})` : ''}
)}