+
;
openInfoFeature?: string | null;
@@ -44,6 +49,7 @@ export function AddFilterPanel({
pinnedFeature,
defaultSchoolFeatureName,
defaultSpecificCrimeFeatureName,
+ defaultElectionVoteShareFeatureName,
defaultEthnicityFeatureName,
defaultPoiFilterFeatureNames,
openInfoFeature,
@@ -63,11 +69,13 @@ export function AddFilterPanel({
? SCHOOL_FILTER_NAME
: pinnedFeature && isSpecificCrimeFilterName(pinnedFeature)
? SPECIFIC_CRIMES_FILTER_NAME
- : pinnedFeature && isEthnicityFilterName(pinnedFeature)
- ? ETHNICITIES_FILTER_NAME
- : pinnedFeature && isPoiDistanceFilterName(pinnedFeature)
- ? (getPoiFilterName(pinnedFeature) ?? POI_DISTANCE_FILTER_NAME)
- : pinnedFeature;
+ : pinnedFeature && isElectionVoteShareFilterName(pinnedFeature)
+ ? ELECTION_VOTE_SHARE_FILTER_NAME
+ : pinnedFeature && isEthnicityFilterName(pinnedFeature)
+ ? ETHNICITIES_FILTER_NAME
+ : pinnedFeature && isPoiDistanceFilterName(pinnedFeature)
+ ? (getPoiFilterName(pinnedFeature) ?? POI_DISTANCE_FILTER_NAME)
+ : pinnedFeature;
const handleTogglePin = (name: string) => {
if (name === SCHOOL_FILTER_NAME) {
@@ -78,6 +86,10 @@ export function AddFilterPanel({
if (defaultSpecificCrimeFeatureName) onTogglePin(defaultSpecificCrimeFeatureName);
return;
}
+ if (name === ELECTION_VOTE_SHARE_FILTER_NAME) {
+ if (defaultElectionVoteShareFeatureName) onTogglePin(defaultElectionVoteShareFeatureName);
+ return;
+ }
if (name === ETHNICITIES_FILTER_NAME) {
if (defaultEthnicityFeatureName) onTogglePin(defaultEthnicityFeatureName);
return;
diff --git a/frontend/src/components/map/filters/ElectionVoteShareFilterCard.tsx b/frontend/src/components/map/filters/ElectionVoteShareFilterCard.tsx
new file mode 100644
index 0000000..d78b983
--- /dev/null
+++ b/frontend/src/components/map/filters/ElectionVoteShareFilterCard.tsx
@@ -0,0 +1,229 @@
+import { useTranslation } from 'react-i18next';
+import { ts } from '../../../i18n/server';
+import { Slider } from '../../ui/Slider';
+import { ChevronIcon } from '../../ui/icons';
+import { FeatureActions } from '../../ui/FeatureIcons';
+import { FeatureLabel } from '../../ui/FeatureLabel';
+import type { FeatureFilters, FeatureMeta } from '../../../types';
+import { formatNumber, type PercentileScale } from '../../../lib/format';
+import { getFeatureIcon } from '../../../lib/feature-icons';
+import { getGroupIcon } from '../../../lib/group-icons';
+import {
+ ELECTION_VOTE_SHARE_FEATURE_NAMES,
+ ELECTION_VOTE_SHARE_FILTER_NAME,
+ clampElectionVoteShareRange,
+ getDefaultElectionVoteShareFeatureName,
+ getElectionVoteShareFeatureName,
+ getElectionVoteShareFilterMeta,
+ replaceElectionVoteShareFilterKeySelection,
+} from '../../../lib/election-filter';
+import { SliderLabels } from './SliderLabels';
+
+export function ElectionVoteShareFilterCard({
+ features,
+ voteShareFeature,
+ filters,
+ activeFeature,
+ dragValue,
+ pinnedFeature,
+ filterImpact,
+ percentileScale,
+ onFilterChange,
+ onDragStart,
+ onDragChange,
+ onDragEnd,
+ onTogglePin,
+ onShowInfo,
+ onRemove,
+}: {
+ features: FeatureMeta[];
+ voteShareFeature: FeatureMeta;
+ filters: FeatureFilters;
+ activeFeature: string | null;
+ dragValue: [number, number] | null;
+ pinnedFeature: string | null;
+ filterImpact?: number;
+ percentileScale?: PercentileScale;
+ onFilterChange: (name: string, value: [number, number] | string[]) => void;
+ onDragStart: (name: string) => void;
+ onDragChange: (value: [number, number]) => void;
+ onDragEnd: () => void;
+ onTogglePin: (name: string) => void;
+ onShowInfo: (feature: FeatureMeta) => void;
+ onRemove: () => void;
+}) {
+ const { t } = useTranslation();
+ const voteShareMeta = getElectionVoteShareFilterMeta(features);
+ const voteShareOptions = ELECTION_VOTE_SHARE_FEATURE_NAMES.map((name) =>
+ features.find((feature) => feature.name === name)
+ ).filter((feature): feature is FeatureMeta => Boolean(feature));
+ const selectedFeatureName =
+ getElectionVoteShareFeatureName(voteShareFeature.name) ??
+ getDefaultElectionVoteShareFeatureName(features);
+ const selectedFeature = selectedFeatureName
+ ? features.find((feature) => feature.name === selectedFeatureName)
+ : undefined;
+
+ if (!selectedFeature || voteShareOptions.length === 0 || !selectedFeatureName) return null;
+
+ const isActive = activeFeature === voteShareFeature.name;
+ const isPinned = pinnedFeature === voteShareFeature.name;
+ const hist = selectedFeature.histogram;
+ const dataMin = hist?.min ?? selectedFeature.min ?? 0;
+ const dataMax = hist?.max ?? selectedFeature.max ?? 100;
+ const displayValue =
+ isActive && dragValue
+ ? dragValue
+ : (filters[voteShareFeature.name] as [number, number]) || [dataMin, dataMax];
+ const scale = percentileScale;
+ const clampMin = displayValue[0] <= dataMin;
+ const clampMax = displayValue[1] >= dataMax;
+ const isAtMin = displayValue[0] === dataMin;
+ const isAtMax = displayValue[1] === dataMax;
+ const sliderValue: [number, number] = scale
+ ? [
+ clampMin ? 0 : Math.round(scale.toPercentile(displayValue[0])),
+ clampMax ? 100 : Math.round(scale.toPercentile(displayValue[1])),
+ ]
+ : [
+ clampMin ? (selectedFeature.min ?? dataMin) : displayValue[0],
+ clampMax ? (selectedFeature.max ?? dataMax) : displayValue[1],
+ ];
+
+ const replaceVoteShareFeature = (nextFeatureName: string) => {
+ const nextName = replaceElectionVoteShareFilterKeySelection(
+ voteShareFeature.name,
+ nextFeatureName
+ );
+ if (nextName === voteShareFeature.name) return;
+
+ const nextFeature = features.find((feature) => feature.name === nextFeatureName);
+ const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0;
+ const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax);
+ const nextRange = clampElectionVoteShareRange(
+ [
+ displayValue[0] <= dataMin ? nextDataMin : displayValue[0],
+ displayValue[1] >= dataMax ? nextDataMax : displayValue[1],
+ ],
+ nextFeature
+ );
+
+ onFilterChange(nextName, nextRange);
+ if (isPinned) onTogglePin(nextName);
+ };
+
+ const mobileIconClass = 'w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0';
+ const mobileIcon =
+ getFeatureIcon(selectedFeature.name, mobileIconClass) ||
+ (() => {
+ const G = selectedFeature.group ? getGroupIcon(selectedFeature.group) : null;
+ return G ? : null;
+ })();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {mobileIcon &&
{mobileIcon}
}
+
+
{
+ const step = selectedFeature.step ?? 1;
+ const snap = (v: number) => Math.round(v / step) * step;
+ onDragChange([
+ pMin <= 0 ? dataMin : snap(scale.toValue(pMin)),
+ pMax >= 100 ? dataMax : snap(scale.toValue(pMax)),
+ ]);
+ }
+ : ([min, max]) =>
+ onDragChange([
+ min <= (selectedFeature.min ?? dataMin) ? dataMin : min,
+ max >= (selectedFeature.max ?? dataMax) ? dataMax : max,
+ ])
+ }
+ onPointerDown={() => onDragStart(voteShareFeature.name)}
+ onPointerUp={() => onDragEnd()}
+ />
+
+ onFilterChange(voteShareFeature.name, clampElectionVoteShareRange(v, selectedFeature))
+ }
+ />
+ {filterImpact != null && filterImpact > 0 && (
+
+ {t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/components/map/filters/EnumFeatureFilterCard.tsx b/frontend/src/components/map/filters/EnumFeatureFilterCard.tsx
index ada8871..66f21ee 100644
--- a/frontend/src/components/map/filters/EnumFeatureFilterCard.tsx
+++ b/frontend/src/components/map/filters/EnumFeatureFilterCard.tsx
@@ -1,3 +1,4 @@
+import { useTranslation } from 'react-i18next';
import { ts } from '../../../i18n/server';
import type { FeatureFilters, FeatureMeta } from '../../../types';
import { formatNumber } from '../../../lib/format';
@@ -27,6 +28,7 @@ export function EnumFeatureFilterCard({
onShowInfo,
onRemoveFilter,
}: EnumFeatureFilterCardProps) {
+ const { t } = useTranslation();
const selectedValues = (filters[feature.name] as string[]) || [];
const allValues = feature.values || [];
@@ -63,7 +65,7 @@ export function EnumFeatureFilterCard({
{filterImpact != null && filterImpact > 0 && (
- +{formatNumber(filterImpact)} without this filter
+ {t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
)}
diff --git a/frontend/src/components/map/filters/EthnicityFilterCard.tsx b/frontend/src/components/map/filters/EthnicityFilterCard.tsx
index 6b839e2..ca25f6a 100644
--- a/frontend/src/components/map/filters/EthnicityFilterCard.tsx
+++ b/frontend/src/components/map/filters/EthnicityFilterCard.tsx
@@ -1,3 +1,4 @@
+import { useTranslation } from 'react-i18next';
import { ts } from '../../../i18n/server';
import { Slider } from '../../ui/Slider';
import { ChevronIcon } from '../../ui/icons';
@@ -51,6 +52,7 @@ export function EthnicityFilterCard({
onShowInfo: (feature: FeatureMeta) => void;
onRemove: () => void;
}) {
+ const { t } = useTranslation();
const ethnicityMeta = getEthnicityFilterMeta(features);
const ethnicityOptions = ETHNICITY_FEATURE_NAMES.map((name) =>
features.find((feature) => feature.name === name)
@@ -145,7 +147,7 @@ export function EthnicityFilterCard({
{filterImpact != null && filterImpact > 0 && (
- +{formatNumber(filterImpact)} without this filter
+ {t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
)}
diff --git a/frontend/src/components/map/filters/NumericFeatureFilterCard.tsx b/frontend/src/components/map/filters/NumericFeatureFilterCard.tsx
index a73112a..8021685 100644
--- a/frontend/src/components/map/filters/NumericFeatureFilterCard.tsx
+++ b/frontend/src/components/map/filters/NumericFeatureFilterCard.tsx
@@ -1,3 +1,4 @@
+import { useTranslation } from 'react-i18next';
import type { FeatureFilters, FeatureMeta } from '../../../types';
import { formatNumber, type PercentileScale } from '../../../lib/format';
import { getFeatureIcon } from '../../../lib/feature-icons';
@@ -40,6 +41,7 @@ export function NumericFeatureFilterCard({
onShowInfo,
onRemoveFilter,
}: NumericFeatureFilterCardProps) {
+ const { t } = useTranslation();
const isActive = activeFeature === feature.name;
const isPinned = pinnedFeature === feature.name;
const hist = feature.histogram;
@@ -128,7 +130,7 @@ export function NumericFeatureFilterCard({
/>
{filterImpact != null && filterImpact > 0 && (
- +{formatNumber(filterImpact)} without this filter
+ {t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
)}
diff --git a/frontend/src/components/map/filters/PoiDistanceFilterCard.tsx b/frontend/src/components/map/filters/PoiDistanceFilterCard.tsx
index bdbb1e3..64e0861 100644
--- a/frontend/src/components/map/filters/PoiDistanceFilterCard.tsx
+++ b/frontend/src/components/map/filters/PoiDistanceFilterCard.tsx
@@ -1,6 +1,5 @@
-import { ts } from '../../../i18n/server';
+import { useTranslation } from 'react-i18next';
import { Slider } from '../../ui/Slider';
-import { ChevronIcon } from '../../ui/icons';
import { FeatureActions } from '../../ui/FeatureIcons';
import { FeatureLabel } from '../../ui/FeatureLabel';
import type { FeatureFilters, FeatureMeta } from '../../../types';
@@ -11,13 +10,13 @@ import {
POI_DISTANCE_FILTER_NAME,
clampPoiFilterRange,
getDefaultPoiFilterFeatureName,
- getPoiFeatureCategory,
getPoiDistanceFeatureName,
getPoiFilterFeatureOptions,
getPoiFilterMeta,
getPoiFilterName,
replacePoiFilterKeySelection,
} from '../../../lib/poi-distance-filter';
+import { PoiTypeDropdown } from './PoiTypeDropdown';
import { SliderLabels } from './SliderLabels';
export function PoiDistanceFilterCard({
@@ -53,6 +52,7 @@ export function PoiDistanceFilterCard({
onShowInfo: (feature: FeatureMeta) => void;
onRemove: () => void;
}) {
+ const { t } = useTranslation();
const filterName = getPoiFilterName(poiFeature.name) ?? POI_DISTANCE_FILTER_NAME;
const poiMeta = getPoiFilterMeta(features, filterName);
const poiOptions = getPoiFilterFeatureOptions(features, filterName);
@@ -142,25 +142,13 @@ export function PoiDistanceFilterCard({
-
-
-
-
+
@@ -210,7 +198,7 @@ export function PoiDistanceFilterCard({
/>
{filterImpact != null && filterImpact > 0 && (
- +{formatNumber(filterImpact)} without this filter
+ {t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
)}
diff --git a/frontend/src/components/map/filters/PoiTypeDropdown.tsx b/frontend/src/components/map/filters/PoiTypeDropdown.tsx
new file mode 100644
index 0000000..e32b206
--- /dev/null
+++ b/frontend/src/components/map/filters/PoiTypeDropdown.tsx
@@ -0,0 +1,200 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { createPortal } from 'react-dom';
+import { ts } from '../../../i18n/server';
+import { dropdownPositionStyle, useDropdownPosition } from '../../../hooks/useDropdownPosition';
+import { ChevronIcon } from '../../ui/icons/ChevronIcon';
+import { SearchIcon } from '../../ui/icons/SearchIcon';
+import { getFeatureIcon } from '../../../lib/feature-icons';
+import { getGroupIcon } from '../../../lib/group-icons';
+import { getPoiFeatureCategory } from '../../../lib/poi-distance-filter';
+import type { FeatureMeta } from '../../../types';
+
+interface PoiTypeDropdownProps {
+ options: FeatureMeta[];
+ value: string;
+ onChange: (featureName: string) => void;
+}
+
+function optionLabel(option: FeatureMeta): string {
+ return ts(getPoiFeatureCategory(option.name) ?? option.name);
+}
+
+function optionIcon(option: FeatureMeta, className: string) {
+ const icon = getFeatureIcon(option.name, className);
+ if (icon) return icon;
+ const G = option.group ? getGroupIcon(option.group) : null;
+ return G ?
: null;
+}
+
+export function PoiTypeDropdown({ options, value, onChange }: PoiTypeDropdownProps) {
+ const { t } = useTranslation();
+ const [open, setOpen] = useState(false);
+ const [filter, setFilter] = useState('');
+ const [activeIndex, setActiveIndex] = useState(-1);
+ const containerRef = useRef
(null);
+ const dropdownRef = useRef(null);
+ const inputRef = useRef(null);
+ const listRef = useRef(null);
+ const pos = useDropdownPosition(containerRef, open);
+
+ const selected = options.find((option) => option.name === value);
+
+ const filtered = useMemo(() => {
+ if (!filter.trim()) return options;
+ const lower = filter.trim().toLowerCase();
+ return options.filter((option) => optionLabel(option).toLowerCase().includes(lower));
+ }, [options, filter]);
+
+ useEffect(() => {
+ if (!open) return;
+ const handler = (e: MouseEvent) => {
+ if (
+ containerRef.current &&
+ !containerRef.current.contains(e.target as Node) &&
+ !dropdownRef.current?.contains(e.target as Node)
+ ) {
+ setOpen(false);
+ setFilter('');
+ setActiveIndex(-1);
+ }
+ };
+ document.addEventListener('mousedown', handler);
+ return () => document.removeEventListener('mousedown', handler);
+ }, [open]);
+
+ useEffect(() => {
+ if (activeIndex < 0 || !listRef.current) return;
+ const item = listRef.current.children[activeIndex] as HTMLElement | undefined;
+ item?.scrollIntoView({ block: 'nearest' });
+ }, [activeIndex]);
+
+ const handleSelect = useCallback(
+ (option: FeatureMeta) => {
+ onChange(option.name);
+ setOpen(false);
+ setFilter('');
+ setActiveIndex(-1);
+ },
+ [onChange]
+ );
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ setActiveIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : prev));
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ setActiveIndex((prev) => (prev > 0 ? prev - 1 : -1));
+ } else if (e.key === 'Enter') {
+ e.preventDefault();
+ if (activeIndex >= 0 && activeIndex < filtered.length) {
+ handleSelect(filtered[activeIndex]);
+ }
+ } else if (e.key === 'Escape') {
+ setOpen(false);
+ setFilter('');
+ setActiveIndex(-1);
+ }
+ },
+ [filtered, activeIndex, handleSelect]
+ );
+
+ const handleOpen = () => {
+ setOpen(true);
+ setFilter('');
+ setActiveIndex(-1);
+ requestAnimationFrame(() => inputRef.current?.focus({ preventScroll: true }));
+ };
+
+ const dropdown = open && (
+
+
+
+
+ {
+ setFilter(e.target.value);
+ setActiveIndex(-1);
+ }}
+ onKeyDown={handleKeyDown}
+ placeholder={t('poiPane.searchCategories')}
+ className="w-full rounded border border-warm-200 bg-warm-50 py-1 pl-7 pr-2 text-xs text-navy-950 outline-none placeholder:text-warm-400 focus:ring-1 focus:ring-teal-400 dark:border-warm-600 dark:bg-warm-900 dark:text-warm-200 dark:placeholder:text-warm-500"
+ />
+
+
+
+
+ {filtered.length === 0 ? (
+
+ {t('filters.noMatchingFeatures')}
+
+ ) : (
+ filtered.map((option, idx) => {
+ const isSelected = option.name === value;
+ const isActive = idx === activeIndex;
+ return (
+
+ );
+ })
+ )}
+
+
+ );
+
+ return (
+
+
+
+ {open && createPortal(dropdown, document.body)}
+
+ );
+}
diff --git a/frontend/src/components/map/filters/SchoolFilterCard.tsx b/frontend/src/components/map/filters/SchoolFilterCard.tsx
index d06ac4f..41d5180 100644
--- a/frontend/src/components/map/filters/SchoolFilterCard.tsx
+++ b/frontend/src/components/map/filters/SchoolFilterCard.tsx
@@ -1,3 +1,4 @@
+import { useTranslation } from 'react-i18next';
import { Slider } from '../../ui/Slider';
import { FeatureActions } from '../../ui/FeatureIcons';
import { FeatureLabel } from '../../ui/FeatureLabel';
@@ -47,6 +48,7 @@ export function SchoolFilterCard({
onShowInfo: (feature: FeatureMeta) => void;
onRemove: () => void;
}) {
+ const { t } = useTranslation();
const config = getSchoolFilterConfig(schoolFeature.name);
const schoolMeta = getSchoolFilterMeta(features);
const backendFeature = config
@@ -124,9 +126,9 @@ export function SchoolFilterCard({
- School type
+ {t('filters.schoolType')}
-
+
- Rating
+ {t('filters.rating')}
-
+
- Distance
+ {t('filters.distance')}
-
+
{filterImpact != null && filterImpact > 0 && (
- +{formatNumber(filterImpact)} without this filter
+ {t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
)}
diff --git a/frontend/src/components/map/filters/SliderLabels.tsx b/frontend/src/components/map/filters/SliderLabels.tsx
index e66c495..eee4e3c 100644
--- a/frontend/src/components/map/filters/SliderLabels.tsx
+++ b/frontend/src/components/map/filters/SliderLabels.tsx
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import type React from 'react';
+import { useTranslation } from 'react-i18next';
import type { FeatureMeta } from '../../../types';
import { formatFilterValue, parseInputValue } from '../../../lib/format';
@@ -92,22 +93,23 @@ export function SliderLabels({
feature?: FeatureMeta;
onValueChange?: (v: [number, number]) => void;
}) {
+ const { t } = useTranslation();
const range = max - min || 1;
const leftPct = Math.max(0, Math.min(100, ((value[0] - min) / range) * 100));
const rightPct = Math.max(0, Math.min(100, ((value[1] - min) / range) * 100));
const labels = displayValues || value;
const labelFormat = feature?.suffix === '%' ? { raw, suffix: feature.suffix } : raw;
- const minLabel = isAtMin ? 'min' : formatFilterValue(labels[0], labelFormat);
- const maxLabel = isAtMax ? 'max' : formatFilterValue(labels[1], labelFormat);
+ const minLabel = isAtMin ? t('common.min') : formatFilterValue(labels[0], labelFormat);
+ const maxLabel = isAtMax ? t('common.max') : formatFilterValue(labels[1], labelFormat);
// Smoothly spread labels apart as thumbs get close to prevent overlap.
- // t=1 (centered) when far apart, t=0 (split) when touching.
+ // gapRatio=1 (centered) when far apart, gapRatio=0 (split) when touching.
const SPREAD_THRESHOLD = 20; // percentage gap below which labels start separating
const gapPct = rightPct - leftPct;
- const t = Math.min(1, Math.max(0, gapPct / SPREAD_THRESHOLD));
- const leftTranslate = `translateX(${-100 + t * 50}%)`;
- const rightTranslate = `translateX(${-t * 50}%)`;
+ const gapRatio = Math.min(1, Math.max(0, gapPct / SPREAD_THRESHOLD));
+ const leftTranslate = `translateX(${-100 + gapRatio * 50}%)`;
+ const rightTranslate = `translateX(${-gapRatio * 50}%)`;
if (feature && onValueChange) {
return (
diff --git a/frontend/src/components/map/filters/SpecificCrimeFilterCard.tsx b/frontend/src/components/map/filters/SpecificCrimeFilterCard.tsx
index d47bf49..7eaa6ab 100644
--- a/frontend/src/components/map/filters/SpecificCrimeFilterCard.tsx
+++ b/frontend/src/components/map/filters/SpecificCrimeFilterCard.tsx
@@ -1,3 +1,4 @@
+import { useTranslation } from 'react-i18next';
import { ts } from '../../../i18n/server';
import { Slider } from '../../ui/Slider';
import { ChevronIcon } from '../../ui/icons';
@@ -51,6 +52,7 @@ export function SpecificCrimeFilterCard({
onShowInfo: (feature: FeatureMeta) => void;
onRemove: () => void;
}) {
+ const { t } = useTranslation();
const specificCrimeMeta = getSpecificCrimeFilterMeta(features);
const crimeOptions = SPECIFIC_CRIME_FEATURE_NAMES.map((name) =>
features.find((feature) => feature.name === name)
@@ -145,7 +147,7 @@ export function SpecificCrimeFilterCard({
{filterImpact != null && filterImpact > 0 && (
- +{formatNumber(filterImpact)} without this filter
+ {t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
)}
diff --git a/frontend/src/components/map/map-page/DesktopMapPage.tsx b/frontend/src/components/map/map-page/DesktopMapPage.tsx
index a163634..74f7f21 100644
--- a/frontend/src/components/map/map-page/DesktopMapPage.tsx
+++ b/frontend/src/components/map/map-page/DesktopMapPage.tsx
@@ -1,4 +1,5 @@
import { Suspense, type MutableRefObject, type ReactNode } from 'react';
+import { useTranslation } from 'react-i18next';
import type { FeatureFilters, FeatureMeta, POI, PostcodeGeometry, ViewState } from '../../../types';
import type { useMapData } from '../../../hooks/useMapData';
@@ -106,6 +107,8 @@ export function DesktopMapPage({
toasts,
upgradeModal,
}: DesktopMapPageProps) {
+ const { t } = useTranslation();
+
return (
@@ -124,7 +127,7 @@ export function DesktopMapPage({
showProgress: true,
skipScroll: true,
}}
- locale={{ last: 'Finish' }}
+ locale={{ last: t('common.finish') }}
/>
)}
@@ -194,7 +197,7 @@ export function DesktopMapPage({
className={`absolute bottom-4 right-4 z-10 px-3 py-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 flex items-center gap-2 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
>
-
Points of interest
+
{t('poiPane.pointsOfInterest')}
{poiPaneOpen && (
diff --git a/frontend/src/components/map/map-page/LoadingOverlay.tsx b/frontend/src/components/map/map-page/LoadingOverlay.tsx
index 01767b7..93e2fe6 100644
--- a/frontend/src/components/map/map-page/LoadingOverlay.tsx
+++ b/frontend/src/components/map/map-page/LoadingOverlay.tsx
@@ -1,3 +1,4 @@
+import { useTranslation } from 'react-i18next';
import { SpinnerIcon } from '../../ui/icons/SpinnerIcon';
interface LoadingOverlayProps {
@@ -5,6 +6,8 @@ interface LoadingOverlayProps {
}
export function LoadingOverlay({ show }: LoadingOverlayProps) {
+ const { t } = useTranslation();
+
if (!show) return null;
return (
@@ -12,7 +15,7 @@ export function LoadingOverlay({ show }: LoadingOverlayProps) {
- Connecting to server...
+ {t('common.connectingToServer')}
diff --git a/frontend/src/components/map/map-page/Toasts.tsx b/frontend/src/components/map/map-page/Toasts.tsx
index 84765ef..2671893 100644
--- a/frontend/src/components/map/map-page/Toasts.tsx
+++ b/frontend/src/components/map/map-page/Toasts.tsx
@@ -1,3 +1,4 @@
+import { useTranslation } from 'react-i18next';
import type { ExportNotice } from './types';
import { BookmarkIcon } from '../../ui/icons/BookmarkIcon';
import { CheckIcon } from '../../ui/icons/CheckIcon';
@@ -11,23 +12,25 @@ interface BookmarkToastProps {
}
export function BookmarkToast({ show, onViewSaved, onDismissForever }: BookmarkToastProps) {
+ const { t } = useTranslation();
+
if (!show) return null;
return (
- Property saved!
+ {t('toasts.propertySaved')}
);
diff --git a/frontend/src/components/map/map-page/derivedState.ts b/frontend/src/components/map/map-page/derivedState.ts
index 89fe75e..8dd0007 100644
--- a/frontend/src/components/map/map-page/derivedState.ts
+++ b/frontend/src/components/map/map-page/derivedState.ts
@@ -6,6 +6,7 @@ import type { HexagonLocation } from '../../../lib/external-search';
import type { useMapData } from '../../../hooks/useMapData';
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
import { getSpecificCrimeFeatureName } from '../../../lib/crime-filter';
+import { getElectionVoteShareFeatureName } from '../../../lib/election-filter';
import { getEthnicityFeatureName } from '../../../lib/ethnicity-filter';
import { getPoiDistanceFeatureName } from '../../../lib/poi-distance-filter';
import { getSchoolBackendFeatureName } from '../../../lib/school-filter';
@@ -22,6 +23,7 @@ export function getMapPageBackendFeatureName(featureName: string): string {
return (
getSchoolBackendFeatureName(featureName) ??
getSpecificCrimeFeatureName(featureName) ??
+ getElectionVoteShareFeatureName(featureName) ??
getEthnicityFeatureName(featureName) ??
getPoiDistanceFeatureName(featureName) ??
featureName
@@ -57,6 +59,7 @@ export function useMobileDensityRange(mapData: MapData): [number, number] {
let max = -Infinity;
for (const item of items) {
const count = 'count' in item ? item.count : item.properties.count;
+ if (count <= 0) continue;
if (count < min) min = count;
if (count > max) max = count;
}
diff --git a/frontend/src/components/ui/DestinationDropdown.tsx b/frontend/src/components/ui/DestinationDropdown.tsx
index 6afcebc..962f8a4 100644
--- a/frontend/src/components/ui/DestinationDropdown.tsx
+++ b/frontend/src/components/ui/DestinationDropdown.tsx
@@ -2,7 +2,7 @@ import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { createPortal } from 'react-dom';
import type { Destination } from '../../hooks/useTravelDestinations';
-import { useDropdownPosition } from '../../hooks/useDropdownPosition';
+import { dropdownPositionStyle, useDropdownPosition } from '../../hooks/useDropdownPosition';
import { MapPinIcon } from './icons/MapPinIcon';
import { ChevronIcon } from './icons/ChevronIcon';
import { CloseIcon } from './icons/CloseIcon';
@@ -102,28 +102,16 @@ export function DestinationDropdown({
const handleOpen = useCallback(() => {
setOpen(true);
setActiveIndex(-1);
- // Focus input after opening
- requestAnimationFrame(() => inputRef.current?.focus());
+ requestAnimationFrame(() => inputRef.current?.focus({ preventScroll: true }));
}, []);
const dropdown = open && (
- {/* Filter input */}
-
+
- {/* Results list */}
-
+
{filtered.length === 0 ? (
{loading ? t('common.loading') : t('travel.noDestinations')}
diff --git a/frontend/src/components/ui/LanguageDropdown.tsx b/frontend/src/components/ui/LanguageDropdown.tsx
index 09d0c3e..512478b 100644
--- a/frontend/src/components/ui/LanguageDropdown.tsx
+++ b/frontend/src/components/ui/LanguageDropdown.tsx
@@ -7,7 +7,7 @@ import {
} from '../../i18n';
export default function LanguageDropdown() {
- const { i18n } = useTranslation();
+ const { t, i18n } = useTranslation();
const [open, setOpen] = useState(false);
const ref = useRef
(null);
@@ -34,7 +34,7 @@ export default function LanguageDropdown() {