LGTM
This commit is contained in:
parent
9248e26af2
commit
f2a2651b8a
95 changed files with 3993 additions and 1471 deletions
|
|
@ -1,12 +1,7 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ts } from '../../i18n/server';
|
||||
import type {
|
||||
FeatureFilters,
|
||||
FeatureMeta,
|
||||
HexagonStatsResponse,
|
||||
PostcodeFeature,
|
||||
} from '../../types';
|
||||
import type { FeatureFilters, FeatureMeta, HexagonStatsResponse } from '../../types';
|
||||
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
import type { HexagonLocation } from '../../lib/external-search';
|
||||
import {
|
||||
|
|
@ -28,7 +23,7 @@ import StackedBarChart from './StackedBarChart';
|
|||
import StackedEnumChart from './StackedEnumChart';
|
||||
import PriceHistoryChart from './PriceHistoryChart';
|
||||
import ExternalSearchLinks from './ExternalSearchLinks';
|
||||
import { FilterIcon, InfoIcon } from '../ui/icons';
|
||||
import { InfoIcon } from '../ui/icons';
|
||||
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
||||
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
||||
import { EmptyState } from '../ui/EmptyState';
|
||||
|
|
@ -43,12 +38,12 @@ interface AreaPaneProps {
|
|||
loading: boolean;
|
||||
hexagonId: string | null;
|
||||
isPostcode?: boolean;
|
||||
postcodeData?: PostcodeFeature | null;
|
||||
onViewProperties: () => void;
|
||||
onClearFilters?: () => void;
|
||||
hexagonLocation: HexagonLocation | null;
|
||||
filters: FeatureFilters;
|
||||
unfilteredCount?: number | null;
|
||||
statsUseFilters: boolean;
|
||||
onStatsUseFiltersChange: (useFilters: boolean) => void;
|
||||
onNavigateToSource?: (slug: string, featureName: string) => void;
|
||||
travelTimeEntries?: TravelTimeEntry[];
|
||||
isGroupExpanded: (name: string) => boolean;
|
||||
|
|
@ -71,21 +66,24 @@ export default function AreaPane({
|
|||
loading,
|
||||
hexagonId,
|
||||
isPostcode = false,
|
||||
postcodeData,
|
||||
onViewProperties,
|
||||
onClearFilters,
|
||||
hexagonLocation,
|
||||
filters,
|
||||
unfilteredCount,
|
||||
statsUseFilters,
|
||||
onStatsUseFiltersChange,
|
||||
onNavigateToSource,
|
||||
travelTimeEntries,
|
||||
isGroupExpanded,
|
||||
onToggleGroup,
|
||||
}: AreaPaneProps) {
|
||||
const { t } = useTranslation();
|
||||
const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count;
|
||||
const propertyCount = stats?.count;
|
||||
const activeFilterCount = Object.keys(filters).length + (travelTimeEntries?.length ?? 0);
|
||||
const hasFilteredOutArea = activeFilterCount > 0 && stats?.count === 0;
|
||||
const filtersActive = activeFilterCount > 0;
|
||||
const filteredStatsEmpty = filtersActive && statsUseFilters && stats?.count === 0;
|
||||
const showFlipToggleCallout = filteredStatsEmpty && unfilteredCount !== 0;
|
||||
const canViewProperties = stats && stats.count > 0 && (statsUseFilters || !filtersActive);
|
||||
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
|
||||
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
|
||||
|
|
@ -148,37 +146,66 @@ export default function AreaPane({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 border-l-2 border-teal-500 bg-warm-50 px-2.5 py-2 text-xs leading-snug text-warm-700 dark:bg-navy-900 dark:text-warm-300">
|
||||
<FilterIcon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-teal-700 dark:text-teal-300" />
|
||||
<p>
|
||||
{activeFilterCount > 0
|
||||
? t('areaPane.filtersAffectStats', { count: activeFilterCount })
|
||||
<div className="rounded border border-warm-200 bg-warm-50 px-2.5 py-2 dark:border-navy-700 dark:bg-navy-900">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-semibold text-warm-700 dark:text-warm-200">
|
||||
{t('areaPane.statsBasis')}
|
||||
</span>
|
||||
<div className="inline-flex shrink-0 rounded-md bg-warm-200 p-0.5 dark:bg-navy-800">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!filtersActive}
|
||||
aria-pressed={statsUseFilters && filtersActive}
|
||||
onClick={() => onStatsUseFiltersChange(true)}
|
||||
className={`rounded px-2 py-1 text-xs font-medium ${
|
||||
statsUseFilters && filtersActive
|
||||
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
|
||||
: 'text-warm-600 hover:text-warm-900 disabled:cursor-not-allowed disabled:opacity-50 dark:text-warm-400 dark:hover:text-warm-100'
|
||||
}`}
|
||||
>
|
||||
{t('areaPane.matchingFiltersOption')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={!statsUseFilters || !filtersActive}
|
||||
onClick={() => onStatsUseFiltersChange(false)}
|
||||
className={`rounded px-2 py-1 text-xs font-medium ${
|
||||
!statsUseFilters || !filtersActive
|
||||
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
|
||||
: 'text-warm-600 hover:text-warm-900 dark:text-warm-400 dark:hover:text-warm-100'
|
||||
}`}
|
||||
>
|
||||
{t('areaPane.allPropertiesOption')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs leading-snug text-warm-500 dark:text-warm-400">
|
||||
{filtersActive
|
||||
? statsUseFilters
|
||||
? t('areaPane.filtersAffectStats', { count: activeFilterCount })
|
||||
: t('areaPane.filtersIgnoredForStats')
|
||||
: t('areaPane.noFiltersAffectStats')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{hasFilteredOutArea && (
|
||||
{showFlipToggleCallout && (
|
||||
<div className="mt-2 rounded border border-amber-200 bg-amber-50 px-2.5 py-2 text-xs leading-snug text-amber-900 dark:border-amber-800/70 dark:bg-amber-950/40 dark:text-amber-100">
|
||||
<p className="font-semibold">{t('areaPane.noFilteredMatches')}</p>
|
||||
<p className="font-semibold">{t('areaPane.filteredStatsEmpty')}</p>
|
||||
<p className="mt-1">
|
||||
{unfilteredCount != null && unfilteredCount > 0
|
||||
? t('areaPane.unfilteredAreaCount', { count: unfilteredCount })
|
||||
: unfilteredCount === 0
|
||||
? t('areaPane.noUnfilteredAreaProperties')
|
||||
: t('areaPane.relaxFiltersHint')}
|
||||
{unfilteredCount != null
|
||||
? t('areaPane.showAllStatsHint', { count: unfilteredCount })
|
||||
: t('areaPane.showAllStatsFallback')}
|
||||
</p>
|
||||
{onClearFilters && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearFilters}
|
||||
className="mt-2 rounded bg-amber-600 px-2 py-1 text-xs font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
|
||||
>
|
||||
{t('filters.clearAll')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStatsUseFiltersChange(false)}
|
||||
className="mt-2 rounded bg-amber-600 px-2 py-1 text-xs font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
|
||||
>
|
||||
{t('areaPane.showAllStats')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{stats && stats.count > 0 && (
|
||||
{canViewProperties && (
|
||||
<button
|
||||
onClick={onViewProperties}
|
||||
className="w-full text-sm py-1.5 rounded bg-teal-600 hover:bg-teal-700 text-white font-medium"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function downsampleBars(counts: number[], targetBars: number): number[] {
|
||||
const step = Math.max(1, Math.floor(counts.length / targetBars));
|
||||
const bars: number[] = [];
|
||||
|
|
@ -34,7 +36,7 @@ export function DualHistogram({
|
|||
p1,
|
||||
p99,
|
||||
globalMean,
|
||||
meanLabel = 'National avg',
|
||||
meanLabel,
|
||||
formatLabel,
|
||||
}: {
|
||||
localCounts: number[];
|
||||
|
|
@ -45,6 +47,7 @@ export function DualHistogram({
|
|||
meanLabel?: string;
|
||||
formatLabel?: (value: number) => string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const targetBars = 25;
|
||||
const localBars = downsampleBars(localCounts, targetBars);
|
||||
const globalBars = downsampleBars(globalCounts, targetBars);
|
||||
|
|
@ -124,7 +127,7 @@ export function DualHistogram({
|
|||
className="absolute top-0 max-w-[7rem] truncate rounded-sm border border-warm-300 bg-white px-1 py-0.5 text-[9px] font-medium leading-none text-warm-600 shadow-sm dark:border-warm-600 dark:bg-navy-900 dark:text-warm-300"
|
||||
style={meanLabelStyle}
|
||||
>
|
||||
{meanLabel}
|
||||
{meanLabel ?? t('areaPane.nationalAvg')}
|
||||
</div>
|
||||
<div className="absolute bottom-0 top-5 w-px border-l border-dashed border-warm-400 dark:border-warm-500" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { ts } from '../../i18n/server';
|
||||
import { getEnumValueColor } from '../../lib/consts';
|
||||
|
||||
export default function EnumBarChart({
|
||||
|
|
@ -46,7 +47,7 @@ export default function EnumBarChart({
|
|||
return (
|
||||
<div key={label} className="flex items-center gap-2 text-xs">
|
||||
<span className="w-16 truncate text-warm-500 dark:text-warm-400 text-right shrink-0">
|
||||
{label}
|
||||
{ts(label)}
|
||||
</span>
|
||||
<div className="flex-1 h-3 bg-warm-100 dark:bg-navy-700 rounded overflow-hidden relative">
|
||||
{hasGlobal && (
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import type { FeatureFilters } from '../../types';
|
||||
import {
|
||||
buildRightmoveExactPostcodeRedirectUrl,
|
||||
buildPropertySearchUrls,
|
||||
H3_RADIUS_MILES,
|
||||
type HexagonLocation,
|
||||
|
|
@ -30,6 +31,11 @@ export default function ExternalSearchLinks({
|
|||
() => buildPropertySearchUrls({ location, filters, rightmoveLocationId }),
|
||||
[location, filters, rightmoveLocationId]
|
||||
);
|
||||
const rightmoveHref = useMemo(() => {
|
||||
if (!urls?.rightmove) return null;
|
||||
if (!location.isPostcode || !location.postcode) return urls.rightmove;
|
||||
return buildRightmoveExactPostcodeRedirectUrl(location.postcode, urls.rightmove);
|
||||
}, [location.isPostcode, location.postcode, urls?.rightmove]);
|
||||
const radiusMiles = location.isPostcode ? 0 : (H3_RADIUS_MILES[location.resolution] ?? 1);
|
||||
const label = radiusMiles === 0 ? t('externalSearch.exact') : `${radiusMiles}mi radius`;
|
||||
|
||||
|
|
@ -46,8 +52,8 @@ export default function ExternalSearchLinks({
|
|||
{t('externalSearch.searchOn', { radius: label })}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{urls.rightmove ? (
|
||||
<a href={urls.rightmove} target="_blank" rel="noopener noreferrer" className={linkClass}>
|
||||
{rightmoveHref ? (
|
||||
<a href={rightmoveHref} target="_blank" rel="noopener noreferrer" className={linkClass}>
|
||||
Rightmove
|
||||
</a>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -19,6 +19,14 @@ import {
|
|||
isSpecificCrimeFeatureName,
|
||||
isSpecificCrimeFilterName,
|
||||
} from '../../lib/crime-filter';
|
||||
import {
|
||||
ELECTION_VOTE_SHARE_FILTER_NAME,
|
||||
getDefaultElectionVoteShareFeatureName,
|
||||
getElectionVoteShareFeatureName,
|
||||
getElectionVoteShareFilterMeta,
|
||||
isElectionVoteShareFeatureName,
|
||||
isElectionVoteShareFilterName,
|
||||
} from '../../lib/election-filter';
|
||||
import {
|
||||
ETHNICITIES_FILTER_NAME,
|
||||
getDefaultEthnicityFeatureName,
|
||||
|
|
@ -148,6 +156,11 @@ export default memo(function Filters({
|
|||
[features]
|
||||
);
|
||||
const specificCrimeMeta = useMemo(() => getSpecificCrimeFilterMeta(features), [features]);
|
||||
const defaultElectionVoteShareFeatureName = useMemo(
|
||||
() => getDefaultElectionVoteShareFeatureName(features),
|
||||
[features]
|
||||
);
|
||||
const electionVoteShareMeta = useMemo(() => getElectionVoteShareFilterMeta(features), [features]);
|
||||
const defaultEthnicityFeatureName = useMemo(
|
||||
() => getDefaultEthnicityFeatureName(features),
|
||||
[features]
|
||||
|
|
@ -212,6 +225,17 @@ export default memo(function Filters({
|
|||
return { ...(backendFeature ?? specificCrimeMeta), name, group: 'Crime' };
|
||||
});
|
||||
}, [filters, features, specificCrimeMeta]);
|
||||
const electionVoteShareFilterItems = useMemo(() => {
|
||||
return Object.keys(filters)
|
||||
.filter(isElectionVoteShareFilterName)
|
||||
.map((name) => {
|
||||
const backendName = getElectionVoteShareFeatureName(name);
|
||||
const backendFeature = backendName
|
||||
? features.find((feature) => feature.name === backendName)
|
||||
: undefined;
|
||||
return { ...(backendFeature ?? electionVoteShareMeta), name, group: 'Neighbours' };
|
||||
});
|
||||
}, [filters, features, electionVoteShareMeta]);
|
||||
const ethnicityFilterItems = useMemo(() => {
|
||||
return Object.keys(filters)
|
||||
.filter(isEthnicityFilterName)
|
||||
|
|
@ -220,7 +244,7 @@ export default memo(function Filters({
|
|||
const backendFeature = backendName
|
||||
? features.find((feature) => feature.name === backendName)
|
||||
: undefined;
|
||||
return { ...(backendFeature ?? ethnicityMeta), name, group: 'Demographics' };
|
||||
return { ...(backendFeature ?? ethnicityMeta), name, group: 'Neighbours' };
|
||||
});
|
||||
}, [filters, features, ethnicityMeta]);
|
||||
const poiDistanceFilterItems = useMemo(() => {
|
||||
|
|
@ -239,6 +263,7 @@ export default memo(function Filters({
|
|||
const result: FeatureMeta[] = [];
|
||||
let insertedSchoolFilter = false;
|
||||
let insertedSpecificCrimeFilter = false;
|
||||
let insertedElectionVoteShareFilter = false;
|
||||
let insertedEthnicityFilter = false;
|
||||
const insertedPoiFilters = new Set<PoiFilterName>();
|
||||
|
||||
|
|
@ -257,6 +282,13 @@ export default memo(function Filters({
|
|||
}
|
||||
continue;
|
||||
}
|
||||
if (isElectionVoteShareFeatureName(feature.name)) {
|
||||
if (defaultElectionVoteShareFeatureName && !insertedElectionVoteShareFilter) {
|
||||
result.push(electionVoteShareMeta);
|
||||
insertedElectionVoteShareFilter = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isEthnicityFeatureName(feature.name)) {
|
||||
if (defaultEthnicityFeatureName && !insertedEthnicityFilter) {
|
||||
result.push(ethnicityMeta);
|
||||
|
|
@ -287,6 +319,8 @@ export default memo(function Filters({
|
|||
schoolMeta,
|
||||
defaultSpecificCrimeFeatureName,
|
||||
specificCrimeMeta,
|
||||
defaultElectionVoteShareFeatureName,
|
||||
electionVoteShareMeta,
|
||||
defaultEthnicityFeatureName,
|
||||
ethnicityMeta,
|
||||
defaultPoiFilterFeatureNames,
|
||||
|
|
@ -296,6 +330,7 @@ export default memo(function Filters({
|
|||
const result: FeatureMeta[] = [];
|
||||
let insertedSchoolFilter = false;
|
||||
let insertedSpecificCrimeFilters = false;
|
||||
let insertedElectionVoteShareFilters = false;
|
||||
let insertedEthnicityFilters = false;
|
||||
let insertedPoiDistanceFilters = false;
|
||||
|
||||
|
|
@ -314,6 +349,13 @@ export default memo(function Filters({
|
|||
}
|
||||
continue;
|
||||
}
|
||||
if (isElectionVoteShareFeatureName(feature.name)) {
|
||||
if (!insertedElectionVoteShareFilters) {
|
||||
result.push(...electionVoteShareFilterItems);
|
||||
insertedElectionVoteShareFilters = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isEthnicityFeatureName(feature.name)) {
|
||||
if (!insertedEthnicityFilters) {
|
||||
result.push(...ethnicityFilterItems);
|
||||
|
|
@ -337,6 +379,7 @@ export default memo(function Filters({
|
|||
enabledFeatures,
|
||||
schoolFilterItems,
|
||||
specificCrimeFilterItems,
|
||||
electionVoteShareFilterItems,
|
||||
ethnicityFilterItems,
|
||||
poiDistanceFilterItems,
|
||||
]);
|
||||
|
|
@ -350,6 +393,7 @@ export default memo(function Filters({
|
|||
const activeEntryCount = travelTimeEntries.length;
|
||||
|
||||
const pendingScrollRef = useRef<string | null>(null);
|
||||
const highlightTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
const handleAddAndScroll = useCallback(
|
||||
(name: string) => {
|
||||
|
|
@ -365,6 +409,12 @@ export default memo(function Filters({
|
|||
onAddFilter(SPECIFIC_CRIMES_FILTER_NAME);
|
||||
return;
|
||||
}
|
||||
if (name === ELECTION_VOTE_SHARE_FILTER_NAME) {
|
||||
if (!defaultElectionVoteShareFeatureName) return;
|
||||
pendingScrollRef.current = ELECTION_VOTE_SHARE_FILTER_NAME;
|
||||
onAddFilter(ELECTION_VOTE_SHARE_FILTER_NAME);
|
||||
return;
|
||||
}
|
||||
if (name === ETHNICITIES_FILTER_NAME) {
|
||||
if (!defaultEthnicityFeatureName) return;
|
||||
pendingScrollRef.current = ETHNICITIES_FILTER_NAME;
|
||||
|
|
@ -385,6 +435,7 @@ export default memo(function Filters({
|
|||
[
|
||||
defaultSchoolFeatureName,
|
||||
defaultSpecificCrimeFeatureName,
|
||||
defaultElectionVoteShareFeatureName,
|
||||
defaultEthnicityFeatureName,
|
||||
defaultPoiFilterFeatureNames,
|
||||
onAddFilter,
|
||||
|
|
@ -403,10 +454,26 @@ export default memo(function Filters({
|
|||
const name = pendingScrollRef.current;
|
||||
if (!name) return;
|
||||
pendingScrollRef.current = null;
|
||||
const el = scrollRef.current?.querySelector(`[data-filter-name="${CSS.escape(name)}"]`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
const el = scrollRef.current?.querySelector<HTMLElement>(
|
||||
`[data-filter-name="${CSS.escape(name)}"]`
|
||||
);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
|
||||
if (highlightTimeoutRef.current !== null) {
|
||||
window.clearTimeout(highlightTimeoutRef.current);
|
||||
document.querySelectorAll('.filter-highlight-flash').forEach((node) => {
|
||||
node.classList.remove('filter-highlight-flash');
|
||||
});
|
||||
}
|
||||
// Restart the animation if the same element is re-highlighted.
|
||||
el.classList.remove('filter-highlight-flash');
|
||||
void el.offsetWidth;
|
||||
el.classList.add('filter-highlight-flash');
|
||||
highlightTimeoutRef.current = window.setTimeout(() => {
|
||||
el.classList.remove('filter-highlight-flash');
|
||||
highlightTimeoutRef.current = null;
|
||||
}, 2000);
|
||||
}, [enabledFeatureList, travelTimeEntries]);
|
||||
const percentileScales = useMemo(() => {
|
||||
const scales = new Map<string, PercentileScale>();
|
||||
|
|
@ -466,6 +533,7 @@ export default memo(function Filters({
|
|||
<ActiveFiltersPanel
|
||||
scrollRef={scrollRef}
|
||||
collapsed={activeFilterCollapsed}
|
||||
addFiltersExpanded={!addFilterCollapsed}
|
||||
badgeCount={badgeCount}
|
||||
activeEntryCount={activeEntryCount}
|
||||
features={features}
|
||||
|
|
@ -512,6 +580,7 @@ export default memo(function Filters({
|
|||
...features,
|
||||
schoolMeta,
|
||||
specificCrimeMeta,
|
||||
electionVoteShareMeta,
|
||||
ethnicityMeta,
|
||||
poiDistanceMeta,
|
||||
poiCount2KmMeta,
|
||||
|
|
@ -520,6 +589,7 @@ export default memo(function Filters({
|
|||
pinnedFeature={pinnedFeature}
|
||||
defaultSchoolFeatureName={defaultSchoolFeatureName}
|
||||
defaultSpecificCrimeFeatureName={defaultSpecificCrimeFeatureName}
|
||||
defaultElectionVoteShareFeatureName={defaultElectionVoteShareFeatureName}
|
||||
defaultEthnicityFeatureName={defaultEthnicityFeatureName}
|
||||
defaultPoiFilterFeatureNames={defaultPoiFilterFeatureNames}
|
||||
openInfoFeature={openInfoFeature}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { formatValue } from '../../lib/format';
|
|||
import { ts } from '../../i18n/server';
|
||||
import { SCHOOL_FILTER_NAME, getSchoolBackendFeatureName } from '../../lib/school-filter';
|
||||
import { getSpecificCrimeFeatureName } from '../../lib/crime-filter';
|
||||
import { getElectionVoteShareFeatureName } from '../../lib/election-filter';
|
||||
import { getEthnicityFeatureName } from '../../lib/ethnicity-filter';
|
||||
import { POI_DISTANCE_FILTER_NAME, getPoiDistanceFeatureName } from '../../lib/poi-distance-filter';
|
||||
|
||||
|
|
@ -47,11 +48,13 @@ export default memo(function HoverCard({
|
|||
for (const name of activeFilterNames.slice(0, 4)) {
|
||||
const schoolBackendName = getSchoolBackendFeatureName(name);
|
||||
const specificCrimeFeatureName = getSpecificCrimeFeatureName(name);
|
||||
const electionVoteShareFeatureName = getElectionVoteShareFeatureName(name);
|
||||
const ethnicityFeatureName = getEthnicityFeatureName(name);
|
||||
const poiDistanceFeatureName = getPoiDistanceFeatureName(name);
|
||||
const backendName =
|
||||
schoolBackendName ??
|
||||
specificCrimeFeatureName ??
|
||||
electionVoteShareFeatureName ??
|
||||
ethnicityFeatureName ??
|
||||
poiDistanceFeatureName ??
|
||||
name;
|
||||
|
|
|
|||
|
|
@ -458,7 +458,7 @@ export default memo(function Map({
|
|||
className="font-bold text-white whitespace-nowrap"
|
||||
style={{ fontSize: '5rem' }}
|
||||
>
|
||||
Your perfect postcode
|
||||
{t('map.ogTitle')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -467,23 +467,23 @@ export default memo(function Map({
|
|||
<div className="absolute bottom-0 left-0 right-0 flex items-center justify-between px-10 py-4 bg-white">
|
||||
<div className="flex items-center gap-6">
|
||||
<span className="text-warm-500 font-medium" style={{ fontSize: '0.95rem' }}>
|
||||
Property prices
|
||||
{t('map.ogPropertyPrices')}
|
||||
</span>
|
||||
<span className="text-warm-300">|</span>
|
||||
<span className="text-warm-500 font-medium" style={{ fontSize: '0.95rem' }}>
|
||||
Energy ratings
|
||||
{t('map.ogEnergyRatings')}
|
||||
</span>
|
||||
<span className="text-warm-300">|</span>
|
||||
<span className="text-warm-500 font-medium" style={{ fontSize: '0.95rem' }}>
|
||||
Schools
|
||||
{t('map.ogSchools')}
|
||||
</span>
|
||||
<span className="text-warm-300">|</span>
|
||||
<span className="text-warm-500 font-medium" style={{ fontSize: '0.95rem' }}>
|
||||
Crime stats
|
||||
{t('map.ogCrimeStats')}
|
||||
</span>
|
||||
<span className="text-warm-300">|</span>
|
||||
<span className="text-warm-500 font-medium" style={{ fontSize: '0.95rem' }}>
|
||||
Transport
|
||||
{t('map.ogTransport')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-teal-600 font-semibold" style={{ fontSize: '1rem' }}>
|
||||
|
|
@ -583,7 +583,9 @@ export default memo(function Map({
|
|||
<div className="text-lg font-bold text-teal-600 dark:text-teal-400">
|
||||
{popupInfo.clusterCount}
|
||||
</div>
|
||||
<div className="text-warm-500 dark:text-warm-400 text-xs">places</div>
|
||||
<div className="text-warm-500 dark:text-warm-400 text-xs">
|
||||
{t('common.places')}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 py-2">
|
||||
|
|
@ -610,7 +612,7 @@ export default memo(function Map({
|
|||
backgroundColor: `rgb(${getPoiGroupColor(popupInfo.group).join(',')})`,
|
||||
}}
|
||||
/>
|
||||
{popupInfo.category}
|
||||
{ts(popupInfo.category)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -271,6 +271,8 @@ export default function MapPage({
|
|||
areaStats,
|
||||
loadingAreaStats,
|
||||
unfilteredAreaCount,
|
||||
areaStatsUseFilters,
|
||||
setAreaStatsUseFilters,
|
||||
hoveredHexagon,
|
||||
rightPaneTab,
|
||||
setRightPaneTab,
|
||||
|
|
@ -456,18 +458,12 @@ export default function MapPage({
|
|||
loading={loadingAreaStats}
|
||||
hexagonId={selectedHexagon?.id || null}
|
||||
isPostcode={selectedHexagon?.type === 'postcode'}
|
||||
postcodeData={
|
||||
selectedHexagon?.type === 'postcode'
|
||||
? mapData.postcodeData.find(
|
||||
(feature) => feature.properties.postcode === selectedHexagon?.id
|
||||
) || null
|
||||
: null
|
||||
}
|
||||
onViewProperties={handleViewPropertiesFromArea}
|
||||
onClearFilters={hasActiveFilters ? handleClearAll : undefined}
|
||||
hexagonLocation={hexagonLocation}
|
||||
filters={filters}
|
||||
unfilteredCount={unfilteredAreaCount}
|
||||
statsUseFilters={areaStatsUseFilters}
|
||||
onStatsUseFiltersChange={setAreaStatsUseFilters}
|
||||
travelTimeEntries={activeEntries}
|
||||
isGroupExpanded={isAreaGroupExpanded}
|
||||
onToggleGroup={toggleAreaGroup}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { PointerEvent, ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { TabButton } from '../ui/TabButton';
|
||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
|
|
@ -30,6 +31,8 @@ export function MapPageSelectionPane({
|
|||
renderAreaPane,
|
||||
renderPropertiesPane,
|
||||
}: MapPageSelectionPaneProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tutorial="right-pane"
|
||||
|
|
@ -48,16 +51,16 @@ export function MapPageSelectionPane({
|
|||
</div>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
|
||||
<TabButton label="Area" isActive={tab === 'area'} onClick={onAreaTabClick} />
|
||||
<TabButton label={t('common.area')} isActive={tab === 'area'} onClick={onAreaTabClick} />
|
||||
<TabButton
|
||||
label="Properties"
|
||||
label={t('common.properties')}
|
||||
isActive={tab === 'properties'}
|
||||
onClick={onPropertiesTabClick}
|
||||
/>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-2 flex items-center text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||
title="Close pane"
|
||||
title={t('common.closePane')}
|
||||
>
|
||||
<CloseIcon className="w-4 h-4" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ts } from '../../i18n/server';
|
||||
import { formatValue, roundedPercentages } from '../../lib/format';
|
||||
|
||||
interface Segment {
|
||||
|
|
@ -27,6 +29,7 @@ function shortenLabel(name: string): string {
|
|||
}
|
||||
|
||||
export default function StackedBarChart({ segments, total, colorMap }: StackedBarChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const sortedSegments = useMemo(() => [...segments].sort((a, b) => b.value - a.value), [segments]);
|
||||
const roundedPcts = useMemo(
|
||||
() =>
|
||||
|
|
@ -39,7 +42,9 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa
|
|||
);
|
||||
|
||||
if (total === 0) {
|
||||
return <div className="text-xs text-warm-400 dark:text-warm-500 italic">No data</div>;
|
||||
return (
|
||||
<div className="text-xs text-warm-400 dark:text-warm-500 italic">{t('common.noData')}</div>
|
||||
);
|
||||
}
|
||||
|
||||
const colorFor = (segmentName: string): string => {
|
||||
|
|
@ -57,6 +62,7 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa
|
|||
{sortedSegments.map((segment, i) => {
|
||||
const pct = (segment.value / total) * 100;
|
||||
if (pct < 0.5) return null;
|
||||
const label = shortenLabel(ts(segment.name));
|
||||
return (
|
||||
<div
|
||||
key={segment.name}
|
||||
|
|
@ -65,7 +71,7 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa
|
|||
width: `${pct}%`,
|
||||
backgroundColor: colorFor(segment.name),
|
||||
}}
|
||||
title={`${shortenLabel(segment.name)}: ${formatValue(segment.value)} (${roundedPcts[i].toFixed(1)}%)`}
|
||||
title={`${label}: ${formatValue(segment.value)} (${roundedPcts[i].toFixed(1)}%)`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
@ -73,22 +79,23 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa
|
|||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5">
|
||||
{sortedSegments.map((segment) => (
|
||||
<div key={segment.name} className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-2 h-2 rounded-sm shrink-0"
|
||||
style={{
|
||||
backgroundColor: colorFor(segment.name),
|
||||
}}
|
||||
/>
|
||||
<span className="text-[10px] text-warm-600 dark:text-warm-400">
|
||||
{shortenLabel(segment.name)}
|
||||
</span>
|
||||
<span className="text-[10px] text-warm-400 dark:text-warm-500">
|
||||
{formatValue(segment.value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{sortedSegments.map((segment) => {
|
||||
const label = shortenLabel(ts(segment.name));
|
||||
return (
|
||||
<div key={segment.name} className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-2 h-2 rounded-sm shrink-0"
|
||||
style={{
|
||||
backgroundColor: colorFor(segment.name),
|
||||
}}
|
||||
/>
|
||||
<span className="text-[10px] text-warm-600 dark:text-warm-400">{label}</span>
|
||||
<span className="text-[10px] text-warm-400 dark:text-warm-500">
|
||||
{formatValue(segment.value)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { ts } from '../../i18n/server';
|
||||
import type { EnumFeatureStats } from '../../types';
|
||||
import { roundedPercentages } from '../../lib/format';
|
||||
|
||||
|
|
@ -17,6 +19,7 @@ export default function StackedEnumChart({
|
|||
valueOrder,
|
||||
valueColors,
|
||||
}: StackedEnumChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const visibleRows = components.filter(({ stats }) => {
|
||||
const total = Object.values(stats.counts).reduce((a, b) => a + b, 0);
|
||||
if (total === 0) return false;
|
||||
|
|
@ -25,7 +28,11 @@ export default function StackedEnumChart({
|
|||
});
|
||||
|
||||
if (visibleRows.length === 0) {
|
||||
return <div className="text-xs text-warm-400 dark:text-warm-500 italic mt-1">All low</div>;
|
||||
return (
|
||||
<div className="text-xs text-warm-400 dark:text-warm-500 italic mt-1">
|
||||
{t('common.allLow')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -38,7 +45,7 @@ export default function StackedEnumChart({
|
|||
return (
|
||||
<div key={label} className="flex items-center gap-2 text-xs">
|
||||
<span className="w-24 truncate text-warm-500 dark:text-warm-400 text-right shrink-0">
|
||||
{shortenLabel(label)}
|
||||
{shortenLabel(ts(label))}
|
||||
</span>
|
||||
<div className="flex-1 flex h-3.5 rounded overflow-hidden bg-warm-200 dark:bg-warm-700">
|
||||
{valueOrder.map((value, i) => {
|
||||
|
|
@ -53,7 +60,7 @@ export default function StackedEnumChart({
|
|||
width: `${pct}%`,
|
||||
backgroundColor: valueColors[i],
|
||||
}}
|
||||
title={`${value}: ${count} (${roundedPcts[i]}%)`}
|
||||
title={`${ts(value)}: ${count} (${roundedPcts[i]}%)`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
@ -70,7 +77,7 @@ export default function StackedEnumChart({
|
|||
className="w-2 h-2 rounded-sm shrink-0"
|
||||
style={{ backgroundColor: valueColors[i] }}
|
||||
/>
|
||||
<span className="text-[10px] text-warm-600 dark:text-warm-400">{value}</span>
|
||||
<span className="text-[10px] text-warm-600 dark:text-warm-400">{ts(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ export function TravelTimeCard({
|
|||
</div>
|
||||
{filterImpact != null && filterImpact > 0 && (
|
||||
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">
|
||||
+{formatNumber(filterImpact)} without this filter
|
||||
{t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@ import { Fragment } from 'react';
|
|||
import type { FeatureFilters, FeatureMeta } from '../../../types';
|
||||
import type { PercentileScale } from '../../../lib/format';
|
||||
import { getSpecificCrimeFeatureName, isSpecificCrimeFilterName } from '../../../lib/crime-filter';
|
||||
import {
|
||||
getElectionVoteShareFeatureName,
|
||||
isElectionVoteShareFilterName,
|
||||
} from '../../../lib/election-filter';
|
||||
import { getEthnicityFeatureName, isEthnicityFilterName } from '../../../lib/ethnicity-filter';
|
||||
import { getSchoolBackendFeatureName, isSchoolFilterName } from '../../../lib/school-filter';
|
||||
import {
|
||||
|
|
@ -14,6 +18,7 @@ import { EthnicityFilterCard } from './EthnicityFilterCard';
|
|||
import { PoiDistanceFilterCard } from './PoiDistanceFilterCard';
|
||||
import { SchoolFilterCard } from './SchoolFilterCard';
|
||||
import { SpecificCrimeFilterCard } from './SpecificCrimeFilterCard';
|
||||
import { ElectionVoteShareFilterCard } from './ElectionVoteShareFilterCard';
|
||||
import { EnumFeatureFilterCard } from './EnumFeatureFilterCard';
|
||||
import { NumericFeatureFilterCard } from './NumericFeatureFilterCard';
|
||||
import { TravelTimeFilterCards } from './TravelTimeFilterCards';
|
||||
|
|
@ -156,6 +161,40 @@ export function ActiveFilterList({
|
|||
);
|
||||
}
|
||||
|
||||
if (isElectionVoteShareFilterName(feature.name)) {
|
||||
const electionVoteShareBackendName = getElectionVoteShareFeatureName(feature.name);
|
||||
return (
|
||||
<Fragment key={feature.name}>
|
||||
{insertTravelCards && travelCards}
|
||||
<ElectionVoteShareFilterCard
|
||||
features={features}
|
||||
voteShareFeature={feature}
|
||||
filters={filters}
|
||||
activeFeature={activeFeature}
|
||||
dragValue={dragValue}
|
||||
pinnedFeature={pinnedFeature}
|
||||
filterImpact={
|
||||
electionVoteShareBackendName
|
||||
? filterImpacts?.[electionVoteShareBackendName]
|
||||
: undefined
|
||||
}
|
||||
percentileScale={
|
||||
electionVoteShareBackendName
|
||||
? percentileScales.get(electionVoteShareBackendName)
|
||||
: undefined
|
||||
}
|
||||
onFilterChange={onFilterChange}
|
||||
onDragStart={onDragStart}
|
||||
onDragChange={onDragChange}
|
||||
onDragEnd={onDragEnd}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={onShowInfo}
|
||||
onRemove={() => onRemoveFilter(feature.name)}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEthnicityFilterName(feature.name)) {
|
||||
const ethnicityBackendName = getEthnicityFeatureName(feature.name);
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { ActiveFilterList } from './ActiveFilterList';
|
|||
interface ActiveFiltersPanelProps {
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
collapsed: boolean;
|
||||
addFiltersExpanded: boolean;
|
||||
badgeCount: number;
|
||||
activeEntryCount: number;
|
||||
features: FeatureMeta[];
|
||||
|
|
@ -59,6 +60,7 @@ interface ActiveFiltersPanelProps {
|
|||
export function ActiveFiltersPanel({
|
||||
scrollRef,
|
||||
collapsed,
|
||||
addFiltersExpanded,
|
||||
badgeCount,
|
||||
activeEntryCount,
|
||||
features,
|
||||
|
|
@ -102,7 +104,7 @@ export function ActiveFiltersPanel({
|
|||
<div
|
||||
className={`flex flex-col md:min-h-0 ${
|
||||
collapsed ? 'md:[flex:0_0_auto]' : 'md:[flex:0_1_auto]'
|
||||
}`}
|
||||
} ${!collapsed && addFiltersExpanded ? 'md:max-h-[60%]' : ''}`}
|
||||
>
|
||||
<button
|
||||
onClick={onToggleCollapsed}
|
||||
|
|
@ -146,7 +148,10 @@ export function ActiveFiltersPanel({
|
|||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<div ref={scrollRef} className="md:min-h-0 md:overflow-y-auto overflow-x-hidden">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="md:min-h-0 md:flex-1 md:overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
<AiFilterInput
|
||||
loading={aiFilterLoading}
|
||||
error={aiFilterError}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ import type { FeatureMeta } from '../../../types';
|
|||
import { ChevronIcon } from '../../ui/icons';
|
||||
import FeatureBrowser from '../FeatureBrowser';
|
||||
import { SPECIFIC_CRIMES_FILTER_NAME, isSpecificCrimeFilterName } from '../../../lib/crime-filter';
|
||||
import {
|
||||
ELECTION_VOTE_SHARE_FILTER_NAME,
|
||||
isElectionVoteShareFilterName,
|
||||
} from '../../../lib/election-filter';
|
||||
import { ETHNICITIES_FILTER_NAME, isEthnicityFilterName } from '../../../lib/ethnicity-filter';
|
||||
import { SCHOOL_FILTER_NAME, isSchoolFilterName } from '../../../lib/school-filter';
|
||||
import {
|
||||
|
|
@ -23,6 +27,7 @@ interface AddFilterPanelProps {
|
|||
pinnedFeature: string | null;
|
||||
defaultSchoolFeatureName: string | null;
|
||||
defaultSpecificCrimeFeatureName: string | null;
|
||||
defaultElectionVoteShareFeatureName: string | null;
|
||||
defaultEthnicityFeatureName: string | null;
|
||||
defaultPoiFilterFeatureNames: Record<PoiFilterName, string | null>;
|
||||
openInfoFeature?: string | null;
|
||||
|
|
@ -44,6 +49,7 @@ export function AddFilterPanel({
|
|||
pinnedFeature,
|
||||
defaultSchoolFeatureName,
|
||||
defaultSpecificCrimeFeatureName,
|
||||
defaultElectionVoteShareFeatureName,
|
||||
defaultEthnicityFeatureName,
|
||||
defaultPoiFilterFeatureNames,
|
||||
openInfoFeature,
|
||||
|
|
@ -63,11 +69,13 @@ export function AddFilterPanel({
|
|||
? SCHOOL_FILTER_NAME
|
||||
: pinnedFeature && isSpecificCrimeFilterName(pinnedFeature)
|
||||
? SPECIFIC_CRIMES_FILTER_NAME
|
||||
: pinnedFeature && isEthnicityFilterName(pinnedFeature)
|
||||
? ETHNICITIES_FILTER_NAME
|
||||
: pinnedFeature && isPoiDistanceFilterName(pinnedFeature)
|
||||
? (getPoiFilterName(pinnedFeature) ?? POI_DISTANCE_FILTER_NAME)
|
||||
: pinnedFeature;
|
||||
: pinnedFeature && isElectionVoteShareFilterName(pinnedFeature)
|
||||
? ELECTION_VOTE_SHARE_FILTER_NAME
|
||||
: pinnedFeature && isEthnicityFilterName(pinnedFeature)
|
||||
? ETHNICITIES_FILTER_NAME
|
||||
: pinnedFeature && isPoiDistanceFilterName(pinnedFeature)
|
||||
? (getPoiFilterName(pinnedFeature) ?? POI_DISTANCE_FILTER_NAME)
|
||||
: pinnedFeature;
|
||||
|
||||
const handleTogglePin = (name: string) => {
|
||||
if (name === SCHOOL_FILTER_NAME) {
|
||||
|
|
@ -78,6 +86,10 @@ export function AddFilterPanel({
|
|||
if (defaultSpecificCrimeFeatureName) onTogglePin(defaultSpecificCrimeFeatureName);
|
||||
return;
|
||||
}
|
||||
if (name === ELECTION_VOTE_SHARE_FILTER_NAME) {
|
||||
if (defaultElectionVoteShareFeatureName) onTogglePin(defaultElectionVoteShareFeatureName);
|
||||
return;
|
||||
}
|
||||
if (name === ETHNICITIES_FILTER_NAME) {
|
||||
if (defaultEthnicityFeatureName) onTogglePin(defaultEthnicityFeatureName);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,229 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { ts } from '../../../i18n/server';
|
||||
import { Slider } from '../../ui/Slider';
|
||||
import { ChevronIcon } from '../../ui/icons';
|
||||
import { FeatureActions } from '../../ui/FeatureIcons';
|
||||
import { FeatureLabel } from '../../ui/FeatureLabel';
|
||||
import type { FeatureFilters, FeatureMeta } from '../../../types';
|
||||
import { formatNumber, type PercentileScale } from '../../../lib/format';
|
||||
import { getFeatureIcon } from '../../../lib/feature-icons';
|
||||
import { getGroupIcon } from '../../../lib/group-icons';
|
||||
import {
|
||||
ELECTION_VOTE_SHARE_FEATURE_NAMES,
|
||||
ELECTION_VOTE_SHARE_FILTER_NAME,
|
||||
clampElectionVoteShareRange,
|
||||
getDefaultElectionVoteShareFeatureName,
|
||||
getElectionVoteShareFeatureName,
|
||||
getElectionVoteShareFilterMeta,
|
||||
replaceElectionVoteShareFilterKeySelection,
|
||||
} from '../../../lib/election-filter';
|
||||
import { SliderLabels } from './SliderLabels';
|
||||
|
||||
export function ElectionVoteShareFilterCard({
|
||||
features,
|
||||
voteShareFeature,
|
||||
filters,
|
||||
activeFeature,
|
||||
dragValue,
|
||||
pinnedFeature,
|
||||
filterImpact,
|
||||
percentileScale,
|
||||
onFilterChange,
|
||||
onDragStart,
|
||||
onDragChange,
|
||||
onDragEnd,
|
||||
onTogglePin,
|
||||
onShowInfo,
|
||||
onRemove,
|
||||
}: {
|
||||
features: FeatureMeta[];
|
||||
voteShareFeature: FeatureMeta;
|
||||
filters: FeatureFilters;
|
||||
activeFeature: string | null;
|
||||
dragValue: [number, number] | null;
|
||||
pinnedFeature: string | null;
|
||||
filterImpact?: number;
|
||||
percentileScale?: PercentileScale;
|
||||
onFilterChange: (name: string, value: [number, number] | string[]) => void;
|
||||
onDragStart: (name: string) => void;
|
||||
onDragChange: (value: [number, number]) => void;
|
||||
onDragEnd: () => void;
|
||||
onTogglePin: (name: string) => void;
|
||||
onShowInfo: (feature: FeatureMeta) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const voteShareMeta = getElectionVoteShareFilterMeta(features);
|
||||
const voteShareOptions = ELECTION_VOTE_SHARE_FEATURE_NAMES.map((name) =>
|
||||
features.find((feature) => feature.name === name)
|
||||
).filter((feature): feature is FeatureMeta => Boolean(feature));
|
||||
const selectedFeatureName =
|
||||
getElectionVoteShareFeatureName(voteShareFeature.name) ??
|
||||
getDefaultElectionVoteShareFeatureName(features);
|
||||
const selectedFeature = selectedFeatureName
|
||||
? features.find((feature) => feature.name === selectedFeatureName)
|
||||
: undefined;
|
||||
|
||||
if (!selectedFeature || voteShareOptions.length === 0 || !selectedFeatureName) return null;
|
||||
|
||||
const isActive = activeFeature === voteShareFeature.name;
|
||||
const isPinned = pinnedFeature === voteShareFeature.name;
|
||||
const hist = selectedFeature.histogram;
|
||||
const dataMin = hist?.min ?? selectedFeature.min ?? 0;
|
||||
const dataMax = hist?.max ?? selectedFeature.max ?? 100;
|
||||
const displayValue =
|
||||
isActive && dragValue
|
||||
? dragValue
|
||||
: (filters[voteShareFeature.name] as [number, number]) || [dataMin, dataMax];
|
||||
const scale = percentileScale;
|
||||
const clampMin = displayValue[0] <= dataMin;
|
||||
const clampMax = displayValue[1] >= dataMax;
|
||||
const isAtMin = displayValue[0] === dataMin;
|
||||
const isAtMax = displayValue[1] === dataMax;
|
||||
const sliderValue: [number, number] = scale
|
||||
? [
|
||||
clampMin ? 0 : Math.round(scale.toPercentile(displayValue[0])),
|
||||
clampMax ? 100 : Math.round(scale.toPercentile(displayValue[1])),
|
||||
]
|
||||
: [
|
||||
clampMin ? (selectedFeature.min ?? dataMin) : displayValue[0],
|
||||
clampMax ? (selectedFeature.max ?? dataMax) : displayValue[1],
|
||||
];
|
||||
|
||||
const replaceVoteShareFeature = (nextFeatureName: string) => {
|
||||
const nextName = replaceElectionVoteShareFilterKeySelection(
|
||||
voteShareFeature.name,
|
||||
nextFeatureName
|
||||
);
|
||||
if (nextName === voteShareFeature.name) return;
|
||||
|
||||
const nextFeature = features.find((feature) => feature.name === nextFeatureName);
|
||||
const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0;
|
||||
const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax);
|
||||
const nextRange = clampElectionVoteShareRange(
|
||||
[
|
||||
displayValue[0] <= dataMin ? nextDataMin : displayValue[0],
|
||||
displayValue[1] >= dataMax ? nextDataMax : displayValue[1],
|
||||
],
|
||||
nextFeature
|
||||
);
|
||||
|
||||
onFilterChange(nextName, nextRange);
|
||||
if (isPinned) onTogglePin(nextName);
|
||||
};
|
||||
|
||||
const mobileIconClass = 'w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0';
|
||||
const mobileIcon =
|
||||
getFeatureIcon(selectedFeature.name, mobileIconClass) ||
|
||||
(() => {
|
||||
const G = selectedFeature.group ? getGroupIcon(selectedFeature.group) : null;
|
||||
return G ? <G className={mobileIconClass} /> : null;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-filter-name={ELECTION_VOTE_SHARE_FILTER_NAME}
|
||||
className={`space-y-2 rounded-lg border border-warm-200 bg-white px-2 py-2 shadow-sm dark:border-warm-700 dark:bg-warm-800 ${
|
||||
isActive
|
||||
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
||||
: isPinned
|
||||
? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<div className="relative z-10 flex items-center justify-between gap-1">
|
||||
<FeatureLabel
|
||||
feature={voteShareMeta}
|
||||
size="sm"
|
||||
className="min-w-0 shrink"
|
||||
hideIconOnMobile
|
||||
/>
|
||||
<FeatureActions
|
||||
feature={selectedFeature}
|
||||
actionName={voteShareFeature.name}
|
||||
isPinned={isPinned}
|
||||
isPreviewing={isActive}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={onShowInfo}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
{t('filters.party')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedFeatureName}
|
||||
onChange={(e) => replaceVoteShareFeature(e.target.value)}
|
||||
className="w-full appearance-none rounded-md border border-warm-200 bg-warm-50 px-2 py-1.5 pr-8 text-sm font-medium text-navy-950 shadow-inner outline-none transition-colors hover:bg-white focus:border-teal-400 focus:ring-2 focus:ring-teal-200 dark:border-warm-700 dark:bg-navy-900 dark:text-warm-100 dark:hover:bg-navy-800 dark:focus:ring-teal-900/50"
|
||||
>
|
||||
{voteShareOptions.map((option) => (
|
||||
<option key={option.name} value={option.name}>
|
||||
{ts(option.name)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronIcon
|
||||
direction="down"
|
||||
className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-warm-400 dark:text-warm-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-1.5 md:block">
|
||||
{mobileIcon && <div className="shrink-0 pt-0.5 md:hidden">{mobileIcon}</div>}
|
||||
<div className="min-w-0 flex-1">
|
||||
<Slider
|
||||
min={scale ? 0 : (selectedFeature.min ?? dataMin)}
|
||||
max={scale ? 100 : (selectedFeature.max ?? dataMax)}
|
||||
step={
|
||||
scale
|
||||
? 1
|
||||
: (selectedFeature.step ??
|
||||
((selectedFeature.max ?? dataMax) - (selectedFeature.min ?? dataMin)) / 100)
|
||||
}
|
||||
value={sliderValue}
|
||||
onValueChange={
|
||||
scale
|
||||
? ([pMin, pMax]) => {
|
||||
const step = selectedFeature.step ?? 1;
|
||||
const snap = (v: number) => Math.round(v / step) * step;
|
||||
onDragChange([
|
||||
pMin <= 0 ? dataMin : snap(scale.toValue(pMin)),
|
||||
pMax >= 100 ? dataMax : snap(scale.toValue(pMax)),
|
||||
]);
|
||||
}
|
||||
: ([min, max]) =>
|
||||
onDragChange([
|
||||
min <= (selectedFeature.min ?? dataMin) ? dataMin : min,
|
||||
max >= (selectedFeature.max ?? dataMax) ? dataMax : max,
|
||||
])
|
||||
}
|
||||
onPointerDown={() => onDragStart(voteShareFeature.name)}
|
||||
onPointerUp={() => onDragEnd()}
|
||||
/>
|
||||
<SliderLabels
|
||||
min={scale ? 0 : (selectedFeature.min ?? dataMin)}
|
||||
max={scale ? 100 : (selectedFeature.max ?? dataMax)}
|
||||
value={sliderValue}
|
||||
displayValues={displayValue}
|
||||
isAtMin={isAtMin}
|
||||
isAtMax={isAtMax}
|
||||
raw={selectedFeature.raw}
|
||||
feature={selectedFeature}
|
||||
onValueChange={(v) =>
|
||||
onFilterChange(voteShareFeature.name, clampElectionVoteShareRange(v, selectedFeature))
|
||||
}
|
||||
/>
|
||||
{filterImpact != null && filterImpact > 0 && (
|
||||
<p className="-mt-1 ml-2.5 text-[10px] text-warm-400 dark:text-warm-500">
|
||||
{t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { ts } from '../../../i18n/server';
|
||||
import type { FeatureFilters, FeatureMeta } from '../../../types';
|
||||
import { formatNumber } from '../../../lib/format';
|
||||
|
|
@ -27,6 +28,7 @@ export function EnumFeatureFilterCard({
|
|||
onShowInfo,
|
||||
onRemoveFilter,
|
||||
}: EnumFeatureFilterCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const selectedValues = (filters[feature.name] as string[]) || [];
|
||||
const allValues = feature.values || [];
|
||||
|
||||
|
|
@ -63,7 +65,7 @@ export function EnumFeatureFilterCard({
|
|||
</PillGroup>
|
||||
{filterImpact != null && filterImpact > 0 && (
|
||||
<p className="text-[10px] text-warm-400 dark:text-warm-500 mt-0.5">
|
||||
+{formatNumber(filterImpact)} without this filter
|
||||
{t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { ts } from '../../../i18n/server';
|
||||
import { Slider } from '../../ui/Slider';
|
||||
import { ChevronIcon } from '../../ui/icons';
|
||||
|
|
@ -51,6 +52,7 @@ export function EthnicityFilterCard({
|
|||
onShowInfo: (feature: FeatureMeta) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const ethnicityMeta = getEthnicityFilterMeta(features);
|
||||
const ethnicityOptions = ETHNICITY_FEATURE_NAMES.map((name) =>
|
||||
features.find((feature) => feature.name === name)
|
||||
|
|
@ -145,7 +147,7 @@ export function EthnicityFilterCard({
|
|||
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
Ethnicity
|
||||
{t('filters.ethnicity')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
|
|
@ -213,7 +215,7 @@ export function EthnicityFilterCard({
|
|||
/>
|
||||
{filterImpact != null && filterImpact > 0 && (
|
||||
<p className="-mt-1 ml-2.5 text-[10px] text-warm-400 dark:text-warm-500">
|
||||
+{formatNumber(filterImpact)} without this filter
|
||||
{t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import type { FeatureFilters, FeatureMeta } from '../../../types';
|
||||
import { formatNumber, type PercentileScale } from '../../../lib/format';
|
||||
import { getFeatureIcon } from '../../../lib/feature-icons';
|
||||
|
|
@ -40,6 +41,7 @@ export function NumericFeatureFilterCard({
|
|||
onShowInfo,
|
||||
onRemoveFilter,
|
||||
}: NumericFeatureFilterCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const isActive = activeFeature === feature.name;
|
||||
const isPinned = pinnedFeature === feature.name;
|
||||
const hist = feature.histogram;
|
||||
|
|
@ -128,7 +130,7 @@ export function NumericFeatureFilterCard({
|
|||
/>
|
||||
{filterImpact != null && filterImpact > 0 && (
|
||||
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">
|
||||
+{formatNumber(filterImpact)} without this filter
|
||||
{t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { ts } from '../../../i18n/server';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Slider } from '../../ui/Slider';
|
||||
import { ChevronIcon } from '../../ui/icons';
|
||||
import { FeatureActions } from '../../ui/FeatureIcons';
|
||||
import { FeatureLabel } from '../../ui/FeatureLabel';
|
||||
import type { FeatureFilters, FeatureMeta } from '../../../types';
|
||||
|
|
@ -11,13 +10,13 @@ import {
|
|||
POI_DISTANCE_FILTER_NAME,
|
||||
clampPoiFilterRange,
|
||||
getDefaultPoiFilterFeatureName,
|
||||
getPoiFeatureCategory,
|
||||
getPoiDistanceFeatureName,
|
||||
getPoiFilterFeatureOptions,
|
||||
getPoiFilterMeta,
|
||||
getPoiFilterName,
|
||||
replacePoiFilterKeySelection,
|
||||
} from '../../../lib/poi-distance-filter';
|
||||
import { PoiTypeDropdown } from './PoiTypeDropdown';
|
||||
import { SliderLabels } from './SliderLabels';
|
||||
|
||||
export function PoiDistanceFilterCard({
|
||||
|
|
@ -53,6 +52,7 @@ export function PoiDistanceFilterCard({
|
|||
onShowInfo: (feature: FeatureMeta) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const filterName = getPoiFilterName(poiFeature.name) ?? POI_DISTANCE_FILTER_NAME;
|
||||
const poiMeta = getPoiFilterMeta(features, filterName);
|
||||
const poiOptions = getPoiFilterFeatureOptions(features, filterName);
|
||||
|
|
@ -142,25 +142,13 @@ export function PoiDistanceFilterCard({
|
|||
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
POI type
|
||||
{t('filters.poiType')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedFeatureName}
|
||||
onChange={(e) => replacePoiFeature(e.target.value)}
|
||||
className="w-full appearance-none rounded-md border border-warm-200 bg-warm-50 px-2 py-1.5 pr-8 text-sm font-medium text-navy-950 shadow-inner outline-none transition-colors hover:bg-white focus:border-teal-400 focus:ring-2 focus:ring-teal-200 dark:border-warm-700 dark:bg-navy-900 dark:text-warm-100 dark:hover:bg-navy-800 dark:focus:ring-teal-900/50"
|
||||
>
|
||||
{poiOptions.map((option) => (
|
||||
<option key={option.name} value={option.name}>
|
||||
{ts(getPoiFeatureCategory(option.name) ?? option.name)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronIcon
|
||||
direction="down"
|
||||
className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-warm-400 dark:text-warm-500"
|
||||
/>
|
||||
</div>
|
||||
<PoiTypeDropdown
|
||||
options={poiOptions}
|
||||
value={selectedFeatureName}
|
||||
onChange={replacePoiFeature}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-1.5 md:block">
|
||||
|
|
@ -210,7 +198,7 @@ export function PoiDistanceFilterCard({
|
|||
/>
|
||||
{filterImpact != null && filterImpact > 0 && (
|
||||
<p className="-mt-1 ml-2.5 text-[10px] text-warm-400 dark:text-warm-500">
|
||||
+{formatNumber(filterImpact)} without this filter
|
||||
{t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
200
frontend/src/components/map/filters/PoiTypeDropdown.tsx
Normal file
200
frontend/src/components/map/filters/PoiTypeDropdown.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ts } from '../../../i18n/server';
|
||||
import { dropdownPositionStyle, useDropdownPosition } from '../../../hooks/useDropdownPosition';
|
||||
import { ChevronIcon } from '../../ui/icons/ChevronIcon';
|
||||
import { SearchIcon } from '../../ui/icons/SearchIcon';
|
||||
import { getFeatureIcon } from '../../../lib/feature-icons';
|
||||
import { getGroupIcon } from '../../../lib/group-icons';
|
||||
import { getPoiFeatureCategory } from '../../../lib/poi-distance-filter';
|
||||
import type { FeatureMeta } from '../../../types';
|
||||
|
||||
interface PoiTypeDropdownProps {
|
||||
options: FeatureMeta[];
|
||||
value: string;
|
||||
onChange: (featureName: string) => void;
|
||||
}
|
||||
|
||||
function optionLabel(option: FeatureMeta): string {
|
||||
return ts(getPoiFeatureCategory(option.name) ?? option.name);
|
||||
}
|
||||
|
||||
function optionIcon(option: FeatureMeta, className: string) {
|
||||
const icon = getFeatureIcon(option.name, className);
|
||||
if (icon) return icon;
|
||||
const G = option.group ? getGroupIcon(option.group) : null;
|
||||
return G ? <G className={className} /> : null;
|
||||
}
|
||||
|
||||
export function PoiTypeDropdown({ options, value, onChange }: PoiTypeDropdownProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const pos = useDropdownPosition(containerRef, open);
|
||||
|
||||
const selected = options.find((option) => option.name === value);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!filter.trim()) return options;
|
||||
const lower = filter.trim().toLowerCase();
|
||||
return options.filter((option) => optionLabel(option).toLowerCase().includes(lower));
|
||||
}, [options, filter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node) &&
|
||||
!dropdownRef.current?.contains(e.target as Node)
|
||||
) {
|
||||
setOpen(false);
|
||||
setFilter('');
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeIndex < 0 || !listRef.current) return;
|
||||
const item = listRef.current.children[activeIndex] as HTMLElement | undefined;
|
||||
item?.scrollIntoView({ block: 'nearest' });
|
||||
}, [activeIndex]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(option: FeatureMeta) => {
|
||||
onChange(option.name);
|
||||
setOpen(false);
|
||||
setFilter('');
|
||||
setActiveIndex(-1);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : prev));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => (prev > 0 ? prev - 1 : -1));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (activeIndex >= 0 && activeIndex < filtered.length) {
|
||||
handleSelect(filtered[activeIndex]);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
setFilter('');
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
},
|
||||
[filtered, activeIndex, handleSelect]
|
||||
);
|
||||
|
||||
const handleOpen = () => {
|
||||
setOpen(true);
|
||||
setFilter('');
|
||||
setActiveIndex(-1);
|
||||
requestAnimationFrame(() => inputRef.current?.focus({ preventScroll: true }));
|
||||
};
|
||||
|
||||
const dropdown = open && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="flex flex-col overflow-hidden rounded-md border border-warm-200 bg-white shadow-lg dark:border-warm-700 dark:bg-warm-800"
|
||||
style={pos ? dropdownPositionStyle(pos) : undefined}
|
||||
>
|
||||
<div className="shrink-0 border-b border-warm-100 p-1.5 dark:border-warm-700">
|
||||
<div className="relative">
|
||||
<SearchIcon className="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-warm-400 dark:text-warm-500" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={filter}
|
||||
onChange={(e) => {
|
||||
setFilter(e.target.value);
|
||||
setActiveIndex(-1);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('poiPane.searchCategories')}
|
||||
className="w-full rounded border border-warm-200 bg-warm-50 py-1 pl-7 pr-2 text-xs text-navy-950 outline-none placeholder:text-warm-400 focus:ring-1 focus:ring-teal-400 dark:border-warm-600 dark:bg-warm-900 dark:text-warm-200 dark:placeholder:text-warm-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={listRef} className="min-h-0 flex-1 overflow-y-auto">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="px-2 py-3 text-center text-xs text-warm-400 dark:text-warm-500">
|
||||
{t('filters.noMatchingFeatures')}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((option, idx) => {
|
||||
const isSelected = option.name === value;
|
||||
const isActive = idx === activeIndex;
|
||||
return (
|
||||
<button
|
||||
key={option.name}
|
||||
type="button"
|
||||
className={`flex w-full cursor-pointer items-center gap-1.5 px-2 py-1.5 text-left text-sm ${
|
||||
isActive
|
||||
? 'bg-teal-50 dark:bg-teal-900/30'
|
||||
: isSelected
|
||||
? 'bg-warm-50 dark:bg-warm-700/50'
|
||||
: 'hover:bg-warm-50 dark:hover:bg-warm-700'
|
||||
}`}
|
||||
onMouseEnter={() => setActiveIndex(idx)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelect(option);
|
||||
}}
|
||||
>
|
||||
{optionIcon(option, 'h-3.5 w-3.5 shrink-0 text-teal-600 dark:text-teal-400')}
|
||||
<span
|
||||
className={`truncate ${
|
||||
isSelected
|
||||
? 'font-medium text-navy-950 dark:text-warm-100'
|
||||
: 'text-warm-700 dark:text-warm-200'
|
||||
}`}
|
||||
>
|
||||
{optionLabel(option)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (open ? setOpen(false) : handleOpen())}
|
||||
className="flex w-full items-center gap-1.5 rounded-md border border-warm-200 bg-warm-50 px-2 py-1.5 pr-8 text-left text-sm font-medium text-navy-950 shadow-inner outline-none transition-colors hover:bg-white focus:border-teal-400 focus:ring-2 focus:ring-teal-200 dark:border-warm-700 dark:bg-navy-900 dark:text-warm-100 dark:hover:bg-navy-800 dark:focus:ring-teal-900/50"
|
||||
>
|
||||
{selected &&
|
||||
optionIcon(selected, 'h-4 w-4 shrink-0 text-teal-600 dark:text-teal-400')}
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{selected ? optionLabel(selected) : ''}
|
||||
</span>
|
||||
<ChevronIcon
|
||||
direction={open ? 'up' : 'down'}
|
||||
className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-warm-400 dark:text-warm-500"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && createPortal(dropdown, document.body)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { Slider } from '../../ui/Slider';
|
||||
import { FeatureActions } from '../../ui/FeatureIcons';
|
||||
import { FeatureLabel } from '../../ui/FeatureLabel';
|
||||
|
|
@ -47,6 +48,7 @@ export function SchoolFilterCard({
|
|||
onShowInfo: (feature: FeatureMeta) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const config = getSchoolFilterConfig(schoolFeature.name);
|
||||
const schoolMeta = getSchoolFilterMeta(features);
|
||||
const backendFeature = config
|
||||
|
|
@ -124,9 +126,9 @@ export function SchoolFilterCard({
|
|||
<div className="space-y-1.5">
|
||||
<div>
|
||||
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
School type
|
||||
{t('filters.schoolType')}
|
||||
</div>
|
||||
<div className={segmentedClass} role="radiogroup" aria-label="School type">
|
||||
<div className={segmentedClass} role="radiogroup" aria-label={t('filters.schoolType')}>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
|
|
@ -134,7 +136,7 @@ export function SchoolFilterCard({
|
|||
onClick={() => replaceSchoolFeature({ phase: 'primary' })}
|
||||
className={optionClass(config.phase === 'primary')}
|
||||
>
|
||||
Primary
|
||||
{t('filters.primary')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -143,15 +145,15 @@ export function SchoolFilterCard({
|
|||
onClick={() => replaceSchoolFeature({ phase: 'secondary' })}
|
||||
className={optionClass(config.phase === 'secondary')}
|
||||
>
|
||||
Secondary
|
||||
{t('filters.secondary')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
Rating
|
||||
{t('filters.rating')}
|
||||
</div>
|
||||
<div className={segmentedClass} role="radiogroup" aria-label="School rating">
|
||||
<div className={segmentedClass} role="radiogroup" aria-label={t('filters.schoolRating')}>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
|
|
@ -159,7 +161,7 @@ export function SchoolFilterCard({
|
|||
onClick={() => replaceSchoolFeature({ rating: 'good' })}
|
||||
className={optionClass(config.rating === 'good')}
|
||||
>
|
||||
Good+
|
||||
{t('filters.goodPlus')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -168,15 +170,19 @@ export function SchoolFilterCard({
|
|||
onClick={() => replaceSchoolFeature({ rating: 'outstanding' })}
|
||||
className={optionClass(config.rating === 'outstanding')}
|
||||
>
|
||||
Outstanding
|
||||
{t('filters.outstanding')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
Distance
|
||||
{t('filters.distance')}
|
||||
</div>
|
||||
<div className={segmentedClass} role="radiogroup" aria-label="School distance">
|
||||
<div
|
||||
className={segmentedClass}
|
||||
role="radiogroup"
|
||||
aria-label={t('filters.schoolDistance')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
|
|
@ -226,7 +232,7 @@ export function SchoolFilterCard({
|
|||
/>
|
||||
{filterImpact != null && filterImpact > 0 && (
|
||||
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">
|
||||
+{formatNumber(filterImpact)} without this filter
|
||||
{t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { ts } from '../../../i18n/server';
|
||||
import { Slider } from '../../ui/Slider';
|
||||
import { ChevronIcon } from '../../ui/icons';
|
||||
|
|
@ -51,6 +52,7 @@ export function SpecificCrimeFilterCard({
|
|||
onShowInfo: (feature: FeatureMeta) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const specificCrimeMeta = getSpecificCrimeFilterMeta(features);
|
||||
const crimeOptions = SPECIFIC_CRIME_FEATURE_NAMES.map((name) =>
|
||||
features.find((feature) => feature.name === name)
|
||||
|
|
@ -145,7 +147,7 @@ export function SpecificCrimeFilterCard({
|
|||
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
Crime type
|
||||
{t('filters.crimeType')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
|
|
@ -213,7 +215,7 @@ export function SpecificCrimeFilterCard({
|
|||
/>
|
||||
{filterImpact != null && filterImpact > 0 && (
|
||||
<p className="-mt-1 ml-2.5 text-[10px] text-warm-400 dark:text-warm-500">
|
||||
+{formatNumber(filterImpact)} without this filter
|
||||
{t('filters.withoutThisFilter', { value: formatNumber(filterImpact) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Suspense, type MutableRefObject, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { FeatureFilters, FeatureMeta, POI, PostcodeGeometry, ViewState } from '../../../types';
|
||||
import type { useMapData } from '../../../hooks/useMapData';
|
||||
|
|
@ -106,6 +107,8 @@ export function DesktopMapPage({
|
|||
toasts,
|
||||
upgradeModal,
|
||||
}: DesktopMapPageProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex overflow-hidden relative">
|
||||
<LoadingOverlay show={initialLoading} />
|
||||
|
|
@ -124,7 +127,7 @@ export function DesktopMapPage({
|
|||
showProgress: true,
|
||||
skipScroll: true,
|
||||
}}
|
||||
locale={{ last: 'Finish' }}
|
||||
locale={{ last: t('common.finish') }}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
|
@ -194,7 +197,7 @@ export function DesktopMapPage({
|
|||
className={`absolute bottom-4 right-4 z-10 px-3 py-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 flex items-center gap-2 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
|
||||
>
|
||||
<MapPinIcon className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">Points of interest</span>
|
||||
<span className="text-sm font-medium">{t('poiPane.pointsOfInterest')}</span>
|
||||
</button>
|
||||
{poiPaneOpen && (
|
||||
<div className="absolute bottom-14 right-4 z-10 flex h-[60vh] min-h-0 w-80 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { SpinnerIcon } from '../../ui/icons/SpinnerIcon';
|
||||
|
||||
interface LoadingOverlayProps {
|
||||
|
|
@ -5,6 +6,8 @@ interface LoadingOverlayProps {
|
|||
}
|
||||
|
||||
export function LoadingOverlay({ show }: LoadingOverlayProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -12,7 +15,7 @@ export function LoadingOverlay({ show }: LoadingOverlayProps) {
|
|||
<div className="flex flex-col items-center gap-4">
|
||||
<SpinnerIcon className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin" />
|
||||
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">
|
||||
Connecting to server...
|
||||
{t('common.connectingToServer')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import type { ExportNotice } from './types';
|
||||
import { BookmarkIcon } from '../../ui/icons/BookmarkIcon';
|
||||
import { CheckIcon } from '../../ui/icons/CheckIcon';
|
||||
|
|
@ -11,23 +12,25 @@ interface BookmarkToastProps {
|
|||
}
|
||||
|
||||
export function BookmarkToast({ show, onViewSaved, onDismissForever }: BookmarkToastProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[60] flex items-center gap-3 px-4 py-3 rounded-lg bg-navy-900 text-white text-sm shadow-lg animate-fade-in">
|
||||
<BookmarkIcon className="w-4 h-4 text-teal-400 shrink-0" filled />
|
||||
<span>Property saved!</span>
|
||||
<span>{t('toasts.propertySaved')}</span>
|
||||
<button
|
||||
onClick={onViewSaved}
|
||||
className="px-3 py-1 rounded bg-teal-600 hover:bg-teal-500 text-white text-xs font-medium whitespace-nowrap"
|
||||
>
|
||||
View saved
|
||||
{t('toasts.viewSaved')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onDismissForever}
|
||||
className="text-warm-400 hover:text-warm-200 text-xs whitespace-nowrap"
|
||||
>
|
||||
Don't show again
|
||||
{t('toasts.dontShowAgain')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue