Extract components
Some checks failed
CI / Check (push) Failing after 3m35s
Build and publish Docker image / build-and-push (push) Failing after 3m49s

This commit is contained in:
Andras Schmelczer 2026-05-09 10:21:32 +01:00
parent a48eb945e0
commit fe46cb3379
30 changed files with 4075 additions and 2610 deletions

View file

@ -8,6 +8,35 @@ a React/deck.gl map.
The public product is branded as Perfect Postcodes, while this repository is
still named `property-map`.
## Public SEO Pages
The indexable public pages are listed in `frontend/public/sitemap.xml` and
prerendered by `frontend/scripts/prerender.mjs`:
- [Home](https://perfect-postcode.co.uk/) - `/`
- [Learn](https://perfect-postcode.co.uk/learn) - `/learn`
- [Pricing](https://perfect-postcode.co.uk/pricing) - `/pricing`
- [Property price map](https://perfect-postcode.co.uk/property-price-map) -
`/property-price-map`
- [Postcode property search](https://perfect-postcode.co.uk/postcode-property-search) -
`/postcode-property-search`
- [Commute property search](https://perfect-postcode.co.uk/commute-property-search) -
`/commute-property-search`
- [School property search](https://perfect-postcode.co.uk/school-property-search) -
`/school-property-search`
- [Postcode checker](https://perfect-postcode.co.uk/postcode-checker) -
`/postcode-checker`
- [Birmingham property search](https://perfect-postcode.co.uk/property-search/birmingham) -
`/property-search/birmingham`
- [Manchester property search](https://perfect-postcode.co.uk/property-search/manchester) -
`/property-search/manchester`
- [Bristol property search](https://perfect-postcode.co.uk/property-search/bristol) -
`/property-search/bristol`
- [Data sources](https://perfect-postcode.co.uk/data-sources) - `/data-sources`
- [Methodology](https://perfect-postcode.co.uk/methodology) - `/methodology`
- [Privacy and security](https://perfect-postcode.co.uk/privacy-security) -
`/privacy-security`
## What Is In Here
- `frontend/` - React 18, TypeScript, Tailwind, MapLibre, and deck.gl. The app

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -9,9 +9,17 @@ interface VisualViewportState {
interface MobileBottomSheetProps {
children: ReactNode;
legend?: ReactNode;
onCoveredHeightChange?: (height: number) => void;
}
function getVisualViewportState(): VisualViewportState {
function getVisualViewportState(avoidKeyboard: boolean): VisualViewportState {
if (!avoidKeyboard) {
return {
height: window.innerHeight,
bottomInset: 0,
};
}
const vv = window.visualViewport;
if (!vv) {
return {
@ -27,25 +35,36 @@ function getVisualViewportState(): VisualViewportState {
};
}
function useVisualViewportState(): VisualViewportState {
const [state, setState] = useState(getVisualViewportState);
function useVisualViewportState(avoidKeyboard: boolean): VisualViewportState {
const [state, setState] = useState(() => getVisualViewportState(avoidKeyboard));
useEffect(() => {
const vv = window.visualViewport;
const update = () => setState(getVisualViewportState());
const update = () => {
const next = getVisualViewportState(avoidKeyboard);
setState((prev) =>
prev.height === next.height && prev.bottomInset === next.bottomInset ? prev : next
);
};
update();
window.addEventListener('resize', update);
window.addEventListener('orientationchange', update);
vv?.addEventListener('resize', update);
vv?.addEventListener('scroll', update);
if (avoidKeyboard) {
vv?.addEventListener('resize', update);
vv?.addEventListener('scroll', update);
}
return () => {
window.removeEventListener('resize', update);
window.removeEventListener('orientationchange', update);
vv?.removeEventListener('resize', update);
vv?.removeEventListener('scroll', update);
if (avoidKeyboard) {
vv?.removeEventListener('resize', update);
vv?.removeEventListener('scroll', update);
}
};
}, []);
}, [avoidKeyboard]);
return state;
}
@ -54,12 +73,46 @@ function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
export default function MobileBottomSheet({ children, legend }: MobileBottomSheetProps) {
const viewport = useVisualViewportState();
function isKeyboardEditableElement(element: HTMLElement): boolean {
if (element instanceof HTMLTextAreaElement) return true;
if (element instanceof HTMLInputElement) {
return ![
'button',
'checkbox',
'color',
'file',
'hidden',
'image',
'radio',
'range',
'reset',
'submit',
].includes(element.type);
}
return element.isContentEditable;
}
function getKeyboardEditableElement(target: EventTarget | null): HTMLElement | null {
if (!(target instanceof Element)) return null;
const element = target.closest('input, textarea, [contenteditable]');
if (!(element instanceof HTMLElement)) return null;
return isKeyboardEditableElement(element) ? element : null;
}
export default function MobileBottomSheet({
children,
legend,
onCoveredHeightChange,
}: MobileBottomSheetProps) {
const [keyboardAvoidanceActive, setKeyboardAvoidanceActive] = useState(false);
const viewport = useVisualViewportState(keyboardAvoidanceActive);
const sheetRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const dragStartYRef = useRef(0);
const dragStartHeightRef = useRef(0);
const scrollIntoViewTimerRef = useRef<number | null>(null);
const [height, setHeight] = useState<number | null>(null);
const [isDragging, setIsDragging] = useState(false);
@ -80,6 +133,10 @@ export default function MobileBottomSheet({ children, legend }: MobileBottomShee
);
}, [heightBounds]);
useEffect(() => {
onCoveredHeightChange?.(Math.round(currentHeight + viewport.bottomInset));
}, [currentHeight, onCoveredHeightChange, viewport.bottomInset]);
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
e.preventDefault();
@ -106,30 +163,61 @@ export default function MobileBottomSheet({ children, legend }: MobileBottomShee
setIsDragging(false);
}, []);
const handleSheetPointerDown = useCallback((event: React.PointerEvent) => {
if (getKeyboardEditableElement(event.target)) return;
const activeElement = document.activeElement;
if (
activeElement instanceof HTMLElement &&
sheetRef.current?.contains(activeElement) &&
isKeyboardEditableElement(activeElement)
) {
activeElement.blur();
}
}, []);
useEffect(() => {
const sheet = sheetRef.current;
if (!sheet) return;
const handleFocusIn = (event: FocusEvent) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
if (!target.matches('input, textarea, select, [contenteditable="true"]')) return;
const target = getKeyboardEditableElement(event.target);
if (!target || !sheet.contains(target)) return;
setKeyboardAvoidanceActive(true);
const keyboardMinHeight = Math.min(heightBounds.max, Math.max(300, viewport.height * 0.55));
setHeight((value) => Math.max(value ?? heightBounds.initial, keyboardMinHeight));
window.setTimeout(() => {
if (scrollIntoViewTimerRef.current != null) {
window.clearTimeout(scrollIntoViewTimerRef.current);
}
scrollIntoViewTimerRef.current = window.setTimeout(() => {
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
}, 120);
};
const handleFocusOut = (event: FocusEvent) => {
const nextTarget = getKeyboardEditableElement(event.relatedTarget);
if (nextTarget && sheet.contains(nextTarget)) return;
setKeyboardAvoidanceActive(false);
};
sheet.addEventListener('focusin', handleFocusIn);
return () => sheet.removeEventListener('focusin', handleFocusIn);
sheet.addEventListener('focusout', handleFocusOut);
return () => {
sheet.removeEventListener('focusin', handleFocusIn);
sheet.removeEventListener('focusout', handleFocusOut);
if (scrollIntoViewTimerRef.current != null) {
window.clearTimeout(scrollIntoViewTimerRef.current);
}
};
}, [heightBounds.initial, heightBounds.max, viewport.height]);
return (
<section
ref={sheetRef}
className="fixed inset-x-0 z-30 flex flex-col rounded-t-2xl bg-white dark:bg-navy-950 shadow-2xl border-t border-warm-200 dark:border-navy-700 overflow-hidden"
onPointerDownCapture={handleSheetPointerDown}
style={{
bottom: viewport.bottomInset,
height: currentHeight,

View file

@ -0,0 +1,253 @@
import { Fragment } from 'react';
import type { FeatureFilters, FeatureMeta } from '../../../types';
import type { PercentileScale } from '../../../lib/format';
import { getSpecificCrimeFeatureName, isSpecificCrimeFilterName } from '../../../lib/crime-filter';
import { getEthnicityFeatureName, isEthnicityFilterName } from '../../../lib/ethnicity-filter';
import { getSchoolBackendFeatureName, isSchoolFilterName } from '../../../lib/school-filter';
import {
getPoiDistanceFeatureName,
isPoiDistanceFilterName,
} from '../../../lib/poi-distance-filter';
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
import { EthnicityFilterCard } from './EthnicityFilterCard';
import { PoiDistanceFilterCard } from './PoiDistanceFilterCard';
import { SchoolFilterCard } from './SchoolFilterCard';
import { SpecificCrimeFilterCard } from './SpecificCrimeFilterCard';
import { EnumFeatureFilterCard } from './EnumFeatureFilterCard';
import { NumericFeatureFilterCard } from './NumericFeatureFilterCard';
import { TravelTimeFilterCards } from './TravelTimeFilterCards';
interface ActiveFilterListProps {
features: FeatureMeta[];
filters: FeatureFilters;
enabledFeatureList: FeatureMeta[];
activeFeature: string | null;
dragValue: [number, number] | null;
pinnedFeature: string | null;
travelTimeEntries: TravelTimeEntry[];
travelInsertIdx: number;
filterImpacts?: Record<string, number>;
percentileScales: Map<string, PercentileScale>;
destinationDropdownPortal: boolean;
onFilterChange: (name: string, value: [number, number] | string[]) => void;
onRemoveFilter: (name: string) => void;
onDragStart: (name: string) => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
onTogglePin: (name: string) => void;
onShowInfo: (feature: FeatureMeta) => void;
onTravelTimeRemoveEntry: (index: number) => void;
onTravelTimeSetDestination: (
index: number,
slug: string,
label: string,
lat: number,
lon: number
) => void;
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
onTravelTimeDragEnd: (index: number) => void;
onTravelTimeToggleBest: (index: number) => void;
}
export function ActiveFilterList({
features,
filters,
enabledFeatureList,
activeFeature,
dragValue,
pinnedFeature,
travelTimeEntries,
travelInsertIdx,
filterImpacts,
percentileScales,
destinationDropdownPortal,
onFilterChange,
onRemoveFilter,
onDragStart,
onDragChange,
onDragEnd,
onTogglePin,
onShowInfo,
onTravelTimeRemoveEntry,
onTravelTimeSetDestination,
onTravelTimeRangeChange,
onTravelTimeDragEnd,
onTravelTimeToggleBest,
}: ActiveFilterListProps) {
const travelCards = (
<TravelTimeFilterCards
entries={travelTimeEntries}
activeFeature={activeFeature}
dragValue={dragValue}
pinnedFeature={pinnedFeature}
filterImpacts={filterImpacts}
destinationDropdownPortal={destinationDropdownPortal}
onTogglePin={onTogglePin}
onTravelTimeRemoveEntry={onTravelTimeRemoveEntry}
onTravelTimeSetDestination={onTravelTimeSetDestination}
onTravelTimeRangeChange={onTravelTimeRangeChange}
onTravelTimeDragEnd={onTravelTimeDragEnd}
onTravelTimeToggleBest={onTravelTimeToggleBest}
onDragStart={onDragStart}
onDragChange={onDragChange}
/>
);
return (
<div className="px-2 py-1 space-y-1">
{enabledFeatureList.map((feature, featureIdx) => {
const insertTravelCards = featureIdx === travelInsertIdx;
if (isSchoolFilterName(feature.name)) {
const schoolBackendName = getSchoolBackendFeatureName(feature.name);
return (
<Fragment key={feature.name}>
{insertTravelCards && travelCards}
<SchoolFilterCard
features={features}
schoolFeature={feature}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
pinnedFeature={pinnedFeature}
filterImpact={schoolBackendName ? filterImpacts?.[schoolBackendName] : undefined}
onFilterChange={onFilterChange}
onDragStart={onDragStart}
onDragChange={onDragChange}
onDragEnd={onDragEnd}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onRemove={() => onRemoveFilter(feature.name)}
/>
</Fragment>
);
}
if (isSpecificCrimeFilterName(feature.name)) {
const specificCrimeBackendName = getSpecificCrimeFeatureName(feature.name);
return (
<Fragment key={feature.name}>
{insertTravelCards && travelCards}
<SpecificCrimeFilterCard
features={features}
crimeFeature={feature}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
pinnedFeature={pinnedFeature}
filterImpact={
specificCrimeBackendName ? filterImpacts?.[specificCrimeBackendName] : undefined
}
percentileScale={
specificCrimeBackendName
? percentileScales.get(specificCrimeBackendName)
: 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 (
<Fragment key={feature.name}>
{insertTravelCards && travelCards}
<EthnicityFilterCard
features={features}
ethnicityFeature={feature}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
pinnedFeature={pinnedFeature}
filterImpact={
ethnicityBackendName ? filterImpacts?.[ethnicityBackendName] : undefined
}
percentileScale={
ethnicityBackendName ? percentileScales.get(ethnicityBackendName) : undefined
}
onFilterChange={onFilterChange}
onDragStart={onDragStart}
onDragChange={onDragChange}
onDragEnd={onDragEnd}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onRemove={() => onRemoveFilter(feature.name)}
/>
</Fragment>
);
}
if (isPoiDistanceFilterName(feature.name)) {
const poiBackendName = getPoiDistanceFeatureName(feature.name);
return (
<Fragment key={feature.name}>
{insertTravelCards && travelCards}
<PoiDistanceFilterCard
features={features}
poiFeature={feature}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
pinnedFeature={pinnedFeature}
filterImpact={poiBackendName ? filterImpacts?.[poiBackendName] : undefined}
percentileScale={poiBackendName ? percentileScales.get(poiBackendName) : undefined}
onFilterChange={onFilterChange}
onDragStart={onDragStart}
onDragChange={onDragChange}
onDragEnd={onDragEnd}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onRemove={() => onRemoveFilter(feature.name)}
/>
</Fragment>
);
}
return (
<Fragment key={feature.name}>
{insertTravelCards && travelCards}
{feature.type === 'enum' ? (
<EnumFeatureFilterCard
feature={feature}
filters={filters}
pinnedFeature={pinnedFeature}
filterImpact={filterImpacts?.[feature.name]}
onFilterChange={onFilterChange}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onRemoveFilter={onRemoveFilter}
/>
) : (
<NumericFeatureFilterCard
feature={feature}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
pinnedFeature={pinnedFeature}
filterImpact={filterImpacts?.[feature.name]}
percentileScale={percentileScales.get(feature.name)}
onFilterChange={onFilterChange}
onDragStart={onDragStart}
onDragChange={onDragChange}
onDragEnd={onDragEnd}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onRemoveFilter={onRemoveFilter}
/>
)}
</Fragment>
);
})}
{travelInsertIdx >= enabledFeatureList.length && travelCards}
</div>
);
}

View file

@ -0,0 +1,204 @@
import type { RefObject } from 'react';
import { useTranslation } from 'react-i18next';
import type { AiFilterErrorType } from '../../../hooks/useAiFilters';
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
import type { PercentileScale } from '../../../lib/format';
import type { FeatureFilters, FeatureMeta } from '../../../types';
import { ChevronIcon, LightbulbIcon } from '../../ui/icons';
import AiFilterInput from '../AiFilterInput';
import { ActiveFilterList } from './ActiveFilterList';
interface ActiveFiltersPanelProps {
scrollRef: RefObject<HTMLDivElement | null>;
collapsed: boolean;
badgeCount: number;
activeEntryCount: number;
features: FeatureMeta[];
filters: FeatureFilters;
enabledFeatureList: FeatureMeta[];
activeFeature: string | null;
dragValue: [number, number] | null;
pinnedFeature: string | null;
travelTimeEntries: TravelTimeEntry[];
travelInsertIdx: number;
filterImpacts?: Record<string, number>;
percentileScales: Map<string, PercentileScale>;
destinationDropdownPortal: boolean;
aiFilterLoading: boolean;
aiFilterError: string | null;
aiFilterErrorType: AiFilterErrorType | null;
aiFilterNotes: string | null;
aiFilterSummary: string | null;
isLoggedIn: boolean;
onToggleCollapsed: () => void;
onClearAllClick: () => void;
onShowPhilosophy: () => void;
onAiFilterSubmit: (query: string) => void;
onLoginRequired: () => void;
onFilterChange: (name: string, value: [number, number] | string[]) => void;
onRemoveFilter: (name: string) => void;
onDragStart: (name: string) => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
onTogglePin: (name: string) => void;
onShowInfo: (feature: FeatureMeta) => void;
onTravelTimeRemoveEntry: (index: number) => void;
onTravelTimeSetDestination: (
index: number,
slug: string,
label: string,
lat: number,
lon: number
) => void;
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
onTravelTimeDragEnd: (index: number) => void;
onTravelTimeToggleBest: (index: number) => void;
}
export function ActiveFiltersPanel({
scrollRef,
collapsed,
badgeCount,
activeEntryCount,
features,
filters,
enabledFeatureList,
activeFeature,
dragValue,
pinnedFeature,
travelTimeEntries,
travelInsertIdx,
filterImpacts,
percentileScales,
destinationDropdownPortal,
aiFilterLoading,
aiFilterError,
aiFilterErrorType,
aiFilterNotes,
aiFilterSummary,
isLoggedIn,
onToggleCollapsed,
onClearAllClick,
onShowPhilosophy,
onAiFilterSubmit,
onLoginRequired,
onFilterChange,
onRemoveFilter,
onDragStart,
onDragChange,
onDragEnd,
onTogglePin,
onShowInfo,
onTravelTimeRemoveEntry,
onTravelTimeSetDestination,
onTravelTimeRangeChange,
onTravelTimeDragEnd,
onTravelTimeToggleBest,
}: ActiveFiltersPanelProps) {
const { t } = useTranslation();
return (
<div
className={`flex flex-col md:min-h-0 ${
collapsed ? 'md:[flex:0_0_auto]' : 'md:[flex:0_1_auto]'
}`}
>
<button
onClick={onToggleCollapsed}
className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700 bg-teal-50 dark:bg-teal-900/30 cursor-pointer hover:bg-teal-100 dark:hover:bg-teal-900/50"
>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-teal-700 dark:text-teal-400">
{t('filters.activeFilters')}
</span>
{badgeCount > 0 && (
<span className="text-xs font-medium px-1.5 py-0.5 rounded-full bg-teal-50 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
{badgeCount}
</span>
)}
</div>
<div className="flex items-center gap-2">
{badgeCount > 0 && (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
onClearAllClick();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
onClearAllClick();
}
}}
className="text-xs text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-200 underline"
>
{t('filters.clearAll')}
</span>
)}
<ChevronIcon
direction={collapsed ? 'down' : 'up'}
className="w-4 h-4 text-warm-400 dark:text-warm-500"
/>
</div>
</button>
{!collapsed && (
<div ref={scrollRef} className="md:min-h-0 md:overflow-y-auto overflow-x-hidden">
<AiFilterInput
loading={aiFilterLoading}
error={aiFilterError}
errorType={aiFilterErrorType}
notes={aiFilterNotes}
summary={aiFilterSummary}
onSubmit={onAiFilterSubmit}
isLoggedIn={isLoggedIn}
onLoginRequired={onLoginRequired}
/>
<div className="px-3 pb-2 space-y-2">
<button
onClick={onShowPhilosophy}
className="w-full px-3 py-1.5 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:bg-warm-50 dark:hover:bg-warm-700 text-teal-600 dark:text-teal-400 font-medium text-sm flex items-center justify-center gap-2"
>
<LightbulbIcon />
{t('filters.findingPerfectPostcode')}
</button>
</div>
{enabledFeatureList.length === 0 && activeEntryCount === 0 && (
<p className="px-3 py-1.5 text-xs text-warm-400 dark:text-warm-500">
{t('filters.addFiltersHint')}
</p>
)}
<ActiveFilterList
features={features}
filters={filters}
enabledFeatureList={enabledFeatureList}
activeFeature={activeFeature}
dragValue={dragValue}
pinnedFeature={pinnedFeature}
travelTimeEntries={travelTimeEntries}
travelInsertIdx={travelInsertIdx}
filterImpacts={filterImpacts}
percentileScales={percentileScales}
destinationDropdownPortal={destinationDropdownPortal}
onFilterChange={onFilterChange}
onRemoveFilter={onRemoveFilter}
onDragStart={onDragStart}
onDragChange={onDragChange}
onDragEnd={onDragEnd}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onTravelTimeRemoveEntry={onTravelTimeRemoveEntry}
onTravelTimeSetDestination={onTravelTimeSetDestination}
onTravelTimeRangeChange={onTravelTimeRangeChange}
onTravelTimeDragEnd={onTravelTimeDragEnd}
onTravelTimeToggleBest={onTravelTimeToggleBest}
/>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,162 @@
import { useTranslation } from 'react-i18next';
import type { TravelTimeEntry, TransportMode } from '../../../hooks/useTravelTime';
import type { FeatureMeta } from '../../../types';
import { ChevronIcon } from '../../ui/icons';
import FeatureBrowser from '../FeatureBrowser';
import { SPECIFIC_CRIMES_FILTER_NAME, isSpecificCrimeFilterName } from '../../../lib/crime-filter';
import { ETHNICITIES_FILTER_NAME, isEthnicityFilterName } from '../../../lib/ethnicity-filter';
import { SCHOOL_FILTER_NAME, isSchoolFilterName } from '../../../lib/school-filter';
import {
POI_DISTANCE_FILTER_NAME,
POI_FILTER_NAMES,
getPoiFilterName,
isPoiDistanceFilterName,
type PoiFilterName,
} from '../../../lib/poi-distance-filter';
interface AddFilterPanelProps {
collapsed: boolean;
isLicensed: boolean;
availableFeatures: FeatureMeta[];
allFeatures: FeatureMeta[];
pinnedFeature: string | null;
defaultSchoolFeatureName: string | null;
defaultSpecificCrimeFeatureName: string | null;
defaultEthnicityFeatureName: string | null;
defaultPoiFilterFeatureNames: Record<PoiFilterName, string | null>;
openInfoFeature?: string | null;
travelTimeEntries: TravelTimeEntry[];
onToggleCollapsed: () => void;
onAddFilter: (name: string) => void;
onTogglePin: (name: string) => void;
onNavigateToSource?: (slug: string, featureName: string) => void;
onClearOpenInfoFeature?: () => void;
onAddTravelTimeEntry: (mode: TransportMode) => void;
onUpgradeClick?: () => void;
}
export function AddFilterPanel({
collapsed,
isLicensed,
availableFeatures,
allFeatures,
pinnedFeature,
defaultSchoolFeatureName,
defaultSpecificCrimeFeatureName,
defaultEthnicityFeatureName,
defaultPoiFilterFeatureNames,
openInfoFeature,
travelTimeEntries,
onToggleCollapsed,
onAddFilter,
onTogglePin,
onNavigateToSource,
onClearOpenInfoFeature,
onAddTravelTimeEntry,
onUpgradeClick,
}: AddFilterPanelProps) {
const { t } = useTranslation();
const browserPinnedFeature =
pinnedFeature && isSchoolFilterName(pinnedFeature)
? 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;
const handleTogglePin = (name: string) => {
if (name === SCHOOL_FILTER_NAME) {
if (defaultSchoolFeatureName) onTogglePin(defaultSchoolFeatureName);
return;
}
if (name === SPECIFIC_CRIMES_FILTER_NAME) {
if (defaultSpecificCrimeFeatureName) onTogglePin(defaultSpecificCrimeFeatureName);
return;
}
if (name === ETHNICITIES_FILTER_NAME) {
if (defaultEthnicityFeatureName) onTogglePin(defaultEthnicityFeatureName);
return;
}
if (POI_FILTER_NAMES.includes(name as PoiFilterName)) {
const defaultPoiFeatureName = defaultPoiFilterFeatureNames[name as PoiFilterName];
if (defaultPoiFeatureName) onTogglePin(defaultPoiFeatureName);
return;
}
onTogglePin(name);
};
return (
<div
className={`flex flex-col md:min-h-0 border-t border-warm-200 dark:border-warm-700 ${
collapsed && isLicensed ? 'md:[flex:0_0_auto]' : 'md:[flex:1_1_0]'
}`}
>
<button
onClick={onToggleCollapsed}
className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700 bg-teal-50 dark:bg-teal-900/30 cursor-pointer hover:bg-teal-100 dark:hover:bg-teal-900/50"
>
<span className="text-sm font-semibold text-teal-700 dark:text-teal-400">
{t('filters.addFilter')}
</span>
<ChevronIcon
direction={collapsed ? 'down' : 'up'}
className="w-4 h-4 text-warm-400 dark:text-warm-500"
/>
</button>
{(!collapsed || !isLicensed) && (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
{!collapsed && (
<FeatureBrowser
availableFeatures={availableFeatures}
allFeatures={allFeatures}
pinnedFeature={browserPinnedFeature}
onAddFilter={onAddFilter}
onTogglePin={handleTogglePin}
onNavigateToSource={onNavigateToSource}
openInfoFeature={openInfoFeature}
onClearOpenInfoFeature={onClearOpenInfoFeature}
travelTimeEntries={travelTimeEntries}
onAddTravelTimeEntry={onAddTravelTimeEntry}
/>
)}
{!isLicensed && (
<div className="mt-auto shrink-0 flex flex-col items-center px-5 pt-4 pb-0 border-t border-warm-200 dark:border-warm-700">
<p className="text-sm text-warm-600 dark:text-warm-400 text-center leading-relaxed mb-1">
{t('filters.upgradePrompt')}
</p>
<p className="text-xs text-warm-400 dark:text-warm-500 text-center mb-4">
{t('filters.oneTimeLifetime')}
</p>
<button
onClick={onUpgradeClick}
className="px-5 py-2.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md"
>
{t('filters.upgradeToFullMap')}
</button>
<svg
viewBox="0 120 1600 230"
className="w-full mt-4 block shrink-0"
preserveAspectRatio="xMidYMax meet"
>
<path
d="M0,350 C400,150 1200,150 1600,350 Z"
className="fill-green-500 dark:fill-green-600"
/>
<path d="M100,350 C450,180 1150,180 1500,350 Z" fill="#000" opacity="0.08" />
<path d="M250,350 C550,220 1050,220 1350,350 Z" fill="#000" opacity="0.06" />
<image href="/house.png" x="735" y="110" width="130" height="120" />
</svg>
</div>
)}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,94 @@
import { useEffect, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { CloseIcon, SpinnerIcon } from '../../ui/icons';
interface ClearFiltersDialogProps {
open: boolean;
saveName: string;
saveError: string | null;
savingSearch?: boolean;
onClose: () => void;
onSaveNameChange: (value: string) => void;
onSaveAndClear: (e: FormEvent) => void;
onClearWithoutSaving: () => void;
}
export function ClearFiltersDialog({
open,
saveName,
saveError,
savingSearch,
onClose,
onSaveNameChange,
onSaveAndClear,
onClearWithoutSaving,
}: ClearFiltersDialogProps) {
const { t } = useTranslation();
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open, onClose]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
<div
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">
{t('filters.clearAllTitle')}
</h2>
<button
onClick={onClose}
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
>
<CloseIcon className="w-5 h-5" />
</button>
</div>
<form onSubmit={onSaveAndClear} className="p-5 pt-2 space-y-4">
<p className="text-sm text-warm-600 dark:text-warm-400">
{t('filters.clearAllSavePrompt')}
</p>
<div>
<input
type="text"
value={saveName}
onChange={(e) => onSaveNameChange(e.target.value)}
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder={t('saveSearch.namePlaceholder')}
autoFocus
/>
</div>
{saveError && <p className="text-sm text-red-600 dark:text-red-300">{saveError}</p>}
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-center">
<button
type="button"
onClick={onClearWithoutSaving}
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
>
{t('filters.clearWithoutSaving')}
</button>
<button
type="submit"
disabled={!saveName.trim() || savingSearch}
className="flex items-center justify-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
>
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{savingSearch ? t('saveSearch.saving') : t('filters.saveAndClear')}
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,71 @@
import { ts } from '../../../i18n/server';
import type { FeatureFilters, FeatureMeta } from '../../../types';
import { formatNumber } from '../../../lib/format';
import { PillGroup } from '../../ui/PillGroup';
import { PillToggle } from '../../ui/PillToggle';
import { FeatureActions } from '../../ui/FeatureIcons';
import { FeatureLabel } from '../../ui/FeatureLabel';
interface EnumFeatureFilterCardProps {
feature: FeatureMeta;
filters: FeatureFilters;
pinnedFeature: string | null;
filterImpact?: number;
onFilterChange: (name: string, value: [number, number] | string[]) => void;
onTogglePin: (name: string) => void;
onShowInfo: (feature: FeatureMeta) => void;
onRemoveFilter: (name: string) => void;
}
export function EnumFeatureFilterCard({
feature,
filters,
pinnedFeature,
filterImpact,
onFilterChange,
onTogglePin,
onShowInfo,
onRemoveFilter,
}: EnumFeatureFilterCardProps) {
const selectedValues = (filters[feature.name] as string[]) || [];
const allValues = feature.values || [];
return (
<div
data-filter-name={feature.name}
className={`space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="flex items-center justify-between">
<FeatureLabel feature={feature} size="sm" />
<FeatureActions
feature={feature}
isPinned={pinnedFeature === feature.name}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onRemove={onRemoveFilter}
/>
</div>
<PillGroup>
{allValues.map((val) => (
<PillToggle
key={val}
label={ts(val)}
active={selectedValues.includes(val)}
onClick={() => {
const next = selectedValues.includes(val)
? selectedValues.filter((v) => v !== val)
: [...selectedValues, val];
onFilterChange(feature.name, next);
}}
size="xs"
/>
))}
</PillGroup>
{filterImpact != null && filterImpact > 0 && (
<p className="text-[10px] text-warm-400 dark:text-warm-500 mt-0.5">
+{formatNumber(filterImpact)} without this filter
</p>
)}
</div>
);
}

View file

@ -0,0 +1,223 @@
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 {
ETHNICITIES_FILTER_NAME,
ETHNICITY_FEATURE_NAMES,
clampEthnicityRange,
getDefaultEthnicityFeatureName,
getEthnicityFeatureName,
getEthnicityFilterMeta,
replaceEthnicityFilterKeySelection,
} from '../../../lib/ethnicity-filter';
import { SliderLabels } from './SliderLabels';
export function EthnicityFilterCard({
features,
ethnicityFeature,
filters,
activeFeature,
dragValue,
pinnedFeature,
filterImpact,
percentileScale,
onFilterChange,
onDragStart,
onDragChange,
onDragEnd,
onTogglePin,
onShowInfo,
onRemove,
}: {
features: FeatureMeta[];
ethnicityFeature: 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 ethnicityMeta = getEthnicityFilterMeta(features);
const ethnicityOptions = ETHNICITY_FEATURE_NAMES.map((name) =>
features.find((feature) => feature.name === name)
).filter((feature): feature is FeatureMeta => Boolean(feature));
const selectedFeatureName =
getEthnicityFeatureName(ethnicityFeature.name) ?? getDefaultEthnicityFeatureName(features);
const selectedFeature = selectedFeatureName
? features.find((feature) => feature.name === selectedFeatureName)
: undefined;
if (!selectedFeature || ethnicityOptions.length === 0 || !selectedFeatureName) return null;
const isActive = activeFeature === ethnicityFeature.name;
const isPinned = pinnedFeature === ethnicityFeature.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[ethnicityFeature.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 replaceEthnicityFeature = (nextFeatureName: string) => {
const nextName = replaceEthnicityFilterKeySelection(ethnicityFeature.name, nextFeatureName);
if (nextName === ethnicityFeature.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 = clampEthnicityRange(
[
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={ETHNICITIES_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={ethnicityMeta}
size="sm"
className="min-w-0 shrink"
hideIconOnMobile
/>
<FeatureActions
feature={selectedFeature}
actionName={ethnicityFeature.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">
Ethnicity
</label>
<div className="relative">
<select
value={selectedFeatureName}
onChange={(e) => replaceEthnicityFeature(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"
>
{ethnicityOptions.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(ethnicityFeature.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(ethnicityFeature.name, clampEthnicityRange(v, selectedFeature))
}
/>
{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
</p>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,138 @@
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 { Slider } from '../../ui/Slider';
import { FeatureActions } from '../../ui/FeatureIcons';
import { FeatureLabel } from '../../ui/FeatureLabel';
import { SliderLabels } from './SliderLabels';
interface NumericFeatureFilterCardProps {
feature: 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;
onRemoveFilter: (name: string) => void;
}
export function NumericFeatureFilterCard({
feature,
filters,
activeFeature,
dragValue,
pinnedFeature,
filterImpact,
percentileScale,
onFilterChange,
onDragStart,
onDragChange,
onDragEnd,
onTogglePin,
onShowInfo,
onRemoveFilter,
}: NumericFeatureFilterCardProps) {
const isActive = activeFeature === feature.name;
const isPinned = pinnedFeature === feature.name;
const hist = feature.histogram;
const displayValue =
isActive && dragValue
? dragValue
: (filters[feature.name] as [number, number]) || [
hist?.min ?? feature.min!,
hist?.max ?? feature.max!,
];
const scale = percentileScale;
const dataMin = hist?.min ?? feature.min!;
const dataMax = hist?.max ?? feature.max!;
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 ? feature.min! : displayValue[0], clampMax ? feature.max! : displayValue[1]];
const mobileIconClass = 'w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0';
const mobileIcon =
getFeatureIcon(feature.name, mobileIconClass) ||
(() => {
const G = feature.group ? getGroupIcon(feature.group) : null;
return G ? <G className={mobileIconClass} /> : null;
})();
return (
<div
data-filter-name={feature.name}
className={`space-y-0.5 px-2 py-1.5 rounded ${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={feature} size="sm" className="min-w-0 shrink" hideIconOnMobile />
<FeatureActions
feature={feature}
isPinned={isPinned}
isPreviewing={isActive}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onRemove={onRemoveFilter}
/>
</div>
<div className="flex md:block items-start gap-1.5">
{mobileIcon && <div className="md:hidden shrink-0 pt-0.5">{mobileIcon}</div>}
<div className="min-w-0 flex-1">
<Slider
min={scale ? 0 : feature.min!}
max={scale ? 100 : feature.max!}
step={scale ? 1 : (feature.step ?? (feature.max! - feature.min!) / 100)}
value={sliderValue}
onValueChange={
scale
? ([pMin, pMax]) => {
const step = feature.step ?? 1;
const snap = (v: number) => Math.round(v / step) * step;
onDragChange([
pMin <= 0 ? (hist?.min ?? feature.min!) : snap(scale.toValue(pMin)),
pMax >= 100 ? (hist?.max ?? feature.max!) : snap(scale.toValue(pMax)),
]);
}
: ([min, max]) =>
onDragChange([
min <= feature.min! ? (hist?.min ?? feature.min!) : min,
max >= feature.max! ? (hist?.max ?? feature.max!) : max,
])
}
onPointerDown={() => onDragStart(feature.name)}
onPointerUp={() => onDragEnd()}
/>
<SliderLabels
min={scale ? 0 : feature.min!}
max={scale ? 100 : feature.max!}
value={sliderValue}
displayValues={displayValue}
isAtMin={isAtMin}
isAtMax={isAtMax}
raw={feature.raw}
feature={feature}
onValueChange={(v) => onFilterChange(feature.name, v)}
/>
{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
</p>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,220 @@
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 {
POI_DISTANCE_FILTER_NAME,
clampPoiFilterRange,
getDefaultPoiFilterFeatureName,
getPoiFeatureCategory,
getPoiDistanceFeatureName,
getPoiFilterFeatureOptions,
getPoiFilterMeta,
getPoiFilterName,
replacePoiFilterKeySelection,
} from '../../../lib/poi-distance-filter';
import { SliderLabels } from './SliderLabels';
export function PoiDistanceFilterCard({
features,
poiFeature,
filters,
activeFeature,
dragValue,
pinnedFeature,
filterImpact,
percentileScale,
onFilterChange,
onDragStart,
onDragChange,
onDragEnd,
onTogglePin,
onShowInfo,
onRemove,
}: {
features: FeatureMeta[];
poiFeature: 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 filterName = getPoiFilterName(poiFeature.name) ?? POI_DISTANCE_FILTER_NAME;
const poiMeta = getPoiFilterMeta(features, filterName);
const poiOptions = getPoiFilterFeatureOptions(features, filterName);
const selectedFeatureName =
getPoiDistanceFeatureName(poiFeature.name) ??
getDefaultPoiFilterFeatureName(features, filterName);
const selectedFeature = selectedFeatureName
? features.find((feature) => feature.name === selectedFeatureName)
: undefined;
if (!selectedFeature || poiOptions.length === 0 || !selectedFeatureName) return null;
const isActive = activeFeature === poiFeature.name;
const isPinned = pinnedFeature === poiFeature.name;
const hist = selectedFeature.histogram;
const dataMin = hist?.min ?? selectedFeature.min ?? 0;
const dataMax = hist?.max ?? selectedFeature.max ?? 5;
const displayValue =
isActive && dragValue
? dragValue
: (filters[poiFeature.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 replacePoiFeature = (nextFeatureName: string) => {
const nextName = replacePoiFilterKeySelection(poiFeature.name, nextFeatureName);
if (nextName === poiFeature.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 = clampPoiFilterRange(
[
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={filterName}
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={poiMeta} size="sm" className="min-w-0 shrink" hideIconOnMobile />
<FeatureActions
feature={selectedFeature}
actionName={poiFeature.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">
POI type
</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>
</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 ?? 0.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(poiFeature.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(poiFeature.name, clampPoiFilterRange(v, selectedFeature))
}
/>
{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
</p>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,234 @@
import { Slider } from '../../ui/Slider';
import { FeatureActions } from '../../ui/FeatureIcons';
import { FeatureLabel } from '../../ui/FeatureLabel';
import type { FeatureFilters, FeatureMeta } from '../../../types';
import { formatNumber } from '../../../lib/format';
import {
SCHOOL_FILTER_NAME,
clampSchoolRange,
getSchoolBackendFeatureName,
getSchoolFilterConfig,
getSchoolFilterMeta,
replaceSchoolFilterKeySelection,
type SchoolDistance,
type SchoolPhase,
type SchoolRating,
} from '../../../lib/school-filter';
import { SliderLabels } from './SliderLabels';
export function SchoolFilterCard({
features,
schoolFeature,
filters,
activeFeature,
dragValue,
pinnedFeature,
filterImpact,
onFilterChange,
onDragStart,
onDragChange,
onDragEnd,
onTogglePin,
onShowInfo,
onRemove,
}: {
features: FeatureMeta[];
schoolFeature: FeatureMeta;
filters: FeatureFilters;
activeFeature: string | null;
dragValue: [number, number] | null;
pinnedFeature: string | null;
filterImpact?: number;
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 config = getSchoolFilterConfig(schoolFeature.name);
const schoolMeta = getSchoolFilterMeta(features);
const backendFeature = config
? features.find((feature) => feature.name === config.featureName)
: undefined;
const isActive = activeFeature === schoolFeature.name;
const isPinned = pinnedFeature === schoolFeature.name;
const hist = backendFeature?.histogram;
const dataMin = hist?.min ?? backendFeature?.min ?? 0;
const dataMax = hist?.max ?? backendFeature?.max ?? 10;
const displayValue =
isActive && dragValue
? dragValue
: (filters[schoolFeature.name] as [number, number]) || [dataMin, dataMax];
const sliderValue: [number, number] = [
displayValue[0] <= dataMin ? (backendFeature?.min ?? dataMin) : displayValue[0],
displayValue[1] >= dataMax ? (backendFeature?.max ?? dataMax) : displayValue[1],
];
if (!config) return null;
const replaceSchoolFeature = (
next: Partial<{
phase: SchoolPhase;
rating: SchoolRating;
distance: SchoolDistance;
}>
) => {
const nextName = replaceSchoolFilterKeySelection(schoolFeature.name, next);
if (nextName === schoolFeature.name) return;
const nextBackendName = getSchoolBackendFeatureName(nextName);
const nextFeature = nextBackendName
? features.find((feature) => feature.name === nextBackendName)
: undefined;
const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0;
const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax);
const nextRange = clampSchoolRange(
[
displayValue[0] <= dataMin ? nextDataMin : displayValue[0],
displayValue[1] >= dataMax ? nextDataMax : displayValue[1],
],
nextFeature
);
onFilterChange(nextName, nextRange);
if (isPinned) onTogglePin(nextName);
};
const segmentedClass =
'grid grid-cols-2 overflow-hidden rounded-md border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800';
const optionClass = (active: boolean) =>
`px-2 py-1 text-xs font-medium border-r last:border-r-0 border-warm-200 dark:border-warm-700 transition-colors ${
active
? 'bg-teal-600 text-white dark:bg-teal-500'
: 'text-warm-600 hover:bg-warm-100 dark:text-warm-300 dark:hover:bg-warm-700'
}`;
return (
<div
data-filter-name={SCHOOL_FILTER_NAME}
className={`space-y-1.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : 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={schoolMeta} size="sm" className="min-w-0 shrink" hideIconOnMobile />
<FeatureActions
feature={schoolMeta}
isPinned={isPinned}
isPreviewing={isActive}
onTogglePin={() => onTogglePin(schoolFeature.name)}
onShowInfo={() => onShowInfo(schoolMeta)}
onRemove={onRemove}
/>
</div>
<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
</div>
<div className={segmentedClass} role="radiogroup" aria-label="School type">
<button
type="button"
role="radio"
aria-checked={config.phase === 'primary'}
onClick={() => replaceSchoolFeature({ phase: 'primary' })}
className={optionClass(config.phase === 'primary')}
>
Primary
</button>
<button
type="button"
role="radio"
aria-checked={config.phase === 'secondary'}
onClick={() => replaceSchoolFeature({ phase: 'secondary' })}
className={optionClass(config.phase === 'secondary')}
>
Secondary
</button>
</div>
</div>
<div>
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
Rating
</div>
<div className={segmentedClass} role="radiogroup" aria-label="School rating">
<button
type="button"
role="radio"
aria-checked={config.rating === 'good'}
onClick={() => replaceSchoolFeature({ rating: 'good' })}
className={optionClass(config.rating === 'good')}
>
Good+
</button>
<button
type="button"
role="radio"
aria-checked={config.rating === 'outstanding'}
onClick={() => replaceSchoolFeature({ rating: 'outstanding' })}
className={optionClass(config.rating === 'outstanding')}
>
Outstanding
</button>
</div>
</div>
<div>
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
Distance
</div>
<div className={segmentedClass} role="radiogroup" aria-label="School distance">
<button
type="button"
role="radio"
aria-checked={config.distance === 2}
onClick={() => replaceSchoolFeature({ distance: 2 })}
className={optionClass(config.distance === 2)}
>
2 km
</button>
<button
type="button"
role="radio"
aria-checked={config.distance === 5}
onClick={() => replaceSchoolFeature({ distance: 5 })}
className={optionClass(config.distance === 5)}
>
5 km
</button>
</div>
</div>
</div>
<Slider
min={backendFeature?.min ?? dataMin}
max={backendFeature?.max ?? dataMax}
step={backendFeature?.step ?? 1}
value={sliderValue}
onValueChange={([min, max]) =>
onDragChange([
min <= (backendFeature?.min ?? dataMin) ? dataMin : min,
max >= (backendFeature?.max ?? dataMax) ? dataMax : max,
])
}
onPointerDown={() => onDragStart(schoolFeature.name)}
onPointerUp={() => onDragEnd()}
/>
<SliderLabels
min={backendFeature?.min ?? dataMin}
max={backendFeature?.max ?? dataMax}
value={sliderValue}
displayValues={displayValue}
isAtMin={displayValue[0] === dataMin}
isAtMax={displayValue[1] === dataMax}
raw={backendFeature?.raw}
feature={backendFeature}
onValueChange={(v) => onFilterChange(schoolFeature.name, v)}
/>
{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
</p>
)}
</div>
);
}

View file

@ -0,0 +1,145 @@
import { useEffect, useRef, useState } from 'react';
import type React from 'react';
import type { FeatureMeta } from '../../../types';
import { formatFilterValue, parseInputValue } from '../../../lib/format';
function EditableLabel({
value,
formatted,
onCommit,
prefix,
suffix,
className,
style,
}: {
value: number;
formatted: string;
onCommit: (v: number) => void;
prefix?: string;
suffix?: string;
className?: string;
style?: React.CSSProperties;
}) {
const [editing, setEditing] = useState(false);
const [text, setText] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const startEdit = () => {
setEditing(true);
setText(String(Math.round(value)));
};
const commit = () => {
const parsed = parseInputValue(text, { prefix, suffix });
if (parsed != null) onCommit(parsed);
setEditing(false);
};
useEffect(() => {
if (editing) {
inputRef.current?.focus();
inputRef.current?.select();
}
}, [editing]);
if (editing) {
return (
<input
ref={inputRef}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') commit();
if (e.key === 'Escape') setEditing(false);
}}
onBlur={commit}
className="absolute w-16 text-[10px] text-center rounded border border-warm-300 dark:border-warm-600 bg-white dark:bg-warm-800 text-warm-700 dark:text-warm-200 px-0.5 focus:outline-none focus:ring-1 focus:ring-teal-400"
style={style}
/>
);
}
return (
<span
className={`absolute cursor-pointer hover:text-teal-600 dark:hover:text-teal-400 border-b border-dotted border-warm-400 dark:border-warm-500 ${className ?? ''}`}
style={style}
onClick={startEdit}
>
{formatted}
</span>
);
}
export function SliderLabels({
min,
max,
value,
displayValues,
isAtMin,
isAtMax,
raw,
feature,
onValueChange,
}: {
min: number;
max: number;
value: [number, number];
displayValues?: [number, number];
isAtMin?: boolean;
isAtMax?: boolean;
raw?: boolean;
feature?: FeatureMeta;
onValueChange?: (v: [number, number]) => void;
}) {
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);
// Smoothly spread labels apart as thumbs get close to prevent overlap.
// t=1 (centered) when far apart, t=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}%)`;
if (feature && onValueChange) {
return (
<div className="relative h-4 mt-2 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
<EditableLabel
value={labels[0]}
formatted={minLabel}
onCommit={(v) => onValueChange([v, Math.max(v, labels[1])])}
prefix={feature.prefix}
suffix={feature.suffix}
style={{ left: `${leftPct}%`, transform: leftTranslate }}
/>
<EditableLabel
value={labels[1]}
formatted={maxLabel}
onCommit={(v) => onValueChange([Math.min(labels[0], v), v])}
prefix={feature.prefix}
suffix={feature.suffix}
style={{ left: `${rightPct}%`, transform: rightTranslate }}
/>
</div>
);
}
return (
<div className="relative h-4 mt-2 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
<span className="absolute" style={{ left: `${leftPct}%`, transform: leftTranslate }}>
{minLabel}
</span>
<span className="absolute" style={{ left: `${rightPct}%`, transform: rightTranslate }}>
{maxLabel}
</span>
</div>
);
}

View file

@ -0,0 +1,223 @@
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 {
SPECIFIC_CRIMES_FILTER_NAME,
SPECIFIC_CRIME_FEATURE_NAMES,
clampSpecificCrimeRange,
getDefaultSpecificCrimeFeatureName,
getSpecificCrimeFeatureName,
getSpecificCrimeFilterMeta,
replaceSpecificCrimeFilterKeySelection,
} from '../../../lib/crime-filter';
import { SliderLabels } from './SliderLabels';
export function SpecificCrimeFilterCard({
features,
crimeFeature,
filters,
activeFeature,
dragValue,
pinnedFeature,
filterImpact,
percentileScale,
onFilterChange,
onDragStart,
onDragChange,
onDragEnd,
onTogglePin,
onShowInfo,
onRemove,
}: {
features: FeatureMeta[];
crimeFeature: 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 specificCrimeMeta = getSpecificCrimeFilterMeta(features);
const crimeOptions = SPECIFIC_CRIME_FEATURE_NAMES.map((name) =>
features.find((feature) => feature.name === name)
).filter((feature): feature is FeatureMeta => Boolean(feature));
const selectedFeatureName =
getSpecificCrimeFeatureName(crimeFeature.name) ?? getDefaultSpecificCrimeFeatureName(features);
const selectedFeature = selectedFeatureName
? features.find((feature) => feature.name === selectedFeatureName)
: undefined;
if (!selectedFeature || crimeOptions.length === 0 || !selectedFeatureName) return null;
const isActive = activeFeature === crimeFeature.name;
const isPinned = pinnedFeature === crimeFeature.name;
const hist = selectedFeature.histogram;
const dataMin = hist?.min ?? selectedFeature.min ?? 0;
const dataMax = hist?.max ?? selectedFeature.max ?? 100;
const displayValue =
isActive && dragValue
? dragValue
: (filters[crimeFeature.name] as [number, number]) || [dataMin, dataMax];
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 replaceCrimeFeature = (nextFeatureName: string) => {
const nextName = replaceSpecificCrimeFilterKeySelection(crimeFeature.name, nextFeatureName);
if (nextName === crimeFeature.name) return;
const nextFeature = features.find((feature) => feature.name === nextFeatureName);
const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0;
const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax);
const nextRange = clampSpecificCrimeRange(
[
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={SPECIFIC_CRIMES_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={specificCrimeMeta}
size="sm"
className="min-w-0 shrink"
hideIconOnMobile
/>
<FeatureActions
feature={selectedFeature}
actionName={crimeFeature.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">
Crime type
</label>
<div className="relative">
<select
value={selectedFeatureName}
onChange={(e) => replaceCrimeFeature(e.target.value)}
className="w-full appearance-none rounded-md border border-warm-200 bg-warm-50 px-2 py-1.5 pr-8 text-sm font-medium text-navy-950 shadow-inner outline-none transition-colors hover:bg-white focus:border-teal-400 focus:ring-2 focus:ring-teal-200 dark:border-warm-700 dark:bg-navy-900 dark:text-warm-100 dark:hover:bg-navy-800 dark:focus:ring-teal-900/50"
>
{crimeOptions.map((option) => (
<option key={option.name} value={option.name}>
{ts(option.name)}
</option>
))}
</select>
<ChevronIcon
direction="down"
className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-warm-400 dark:text-warm-500"
/>
</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(crimeFeature.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(crimeFeature.name, clampSpecificCrimeRange(v, selectedFeature))
}
/>
{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
</p>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,76 @@
import { type TravelTimeEntry, travelFieldKey } from '../../../hooks/useTravelTime';
import { TravelTimeCard } from '../TravelTimeCard';
interface TravelTimeFilterCardsProps {
entries: TravelTimeEntry[];
activeFeature: string | null;
dragValue: [number, number] | null;
pinnedFeature: string | null;
filterImpacts?: Record<string, number>;
destinationDropdownPortal: boolean;
onTogglePin: (name: string) => void;
onTravelTimeRemoveEntry: (index: number) => void;
onTravelTimeSetDestination: (
index: number,
slug: string,
label: string,
lat: number,
lon: number
) => void;
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
onTravelTimeDragEnd: (index: number) => void;
onTravelTimeToggleBest: (index: number) => void;
onDragStart: (name: string) => void;
onDragChange: (value: [number, number]) => void;
}
export function TravelTimeFilterCards({
entries,
activeFeature,
dragValue,
pinnedFeature,
filterImpacts,
destinationDropdownPortal,
onTogglePin,
onTravelTimeRemoveEntry,
onTravelTimeSetDestination,
onTravelTimeRangeChange,
onTravelTimeDragEnd,
onTravelTimeToggleBest,
onDragStart,
onDragChange,
}: TravelTimeFilterCardsProps) {
return (
<>
{entries.map((entry, index) => {
const fieldKey = travelFieldKey(entry);
return (
<div key={`tt_${index}`} data-filter-name={`tt_${index}`}>
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
useBest={entry.useBest}
isPinned={pinnedFeature === fieldKey}
isActive={activeFeature === fieldKey}
dragValue={activeFeature === fieldKey ? dragValue : null}
onTogglePin={() => onTogglePin(fieldKey)}
onSetDestination={(slug, label, lat, lon) =>
onTravelTimeSetDestination(index, slug, label, lat, lon)
}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onDragStart={() => onDragStart(fieldKey)}
onDragChange={onDragChange}
onDragEnd={() => onTravelTimeDragEnd(index)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
filterImpact={filterImpacts?.[fieldKey]}
destinationDropdownPortal={destinationDropdownPortal}
/>
</div>
);
})}
</>
);
}

View file

@ -0,0 +1,225 @@
import { Suspense, type MutableRefObject, type ReactNode } from 'react';
import type { FeatureFilters, FeatureMeta, POI, PostcodeGeometry, ViewState } from '../../../types';
import type { useMapData } from '../../../hooks/useMapData';
import type { useTutorial } from '../../../hooks/useTutorial';
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
import type { getTutorialStyles } from '../../../lib/tutorial-styles';
import type { SearchedLocation } from '../LocationSearch';
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
import type { MapFlyTo, PaneResizeHandlers } from './types';
import { MapFallback, PaneFallback } from './Fallbacks';
import { LoadingOverlay } from './LoadingOverlay';
import { Joyride, Map, MapPageSelectionPane } from './lazyComponents';
type MapData = ReturnType<typeof useMapData>;
type Tutorial = ReturnType<typeof useTutorial>;
type TutorialTheme = ReturnType<typeof getTutorialStyles>;
type RightPaneTab = 'properties' | 'area';
interface DesktopMapPageProps {
initialLoading: boolean;
tutorial: Tutorial;
tutorialTheme: TutorialTheme;
leftPaneWidth: number;
leftPaneHandlers: PaneResizeHandlers;
filtersPane: ReactNode;
mapData: MapData;
pois: POI[];
mapViewFeature: string | null;
filterRange: [number, number] | null;
viewSource: 'drag' | 'eye' | null;
onCancelPin: () => void;
features: FeatureMeta[];
selectedHexagonId: string | null;
hoveredHexagonId: string | null;
onHexagonClick: (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => void;
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
initialViewState: ViewState;
flyToRef: MutableRefObject<MapFlyTo | null>;
theme: 'light' | 'dark';
filters: FeatureFilters;
selectedPostcodeGeometry: PostcodeGeometry | null;
onLocationSearched: (location: SearchedLocation | null) => void;
onCurrentLocationFound: (lat: number, lng: number) => void;
currentLocation: { lat: number; lng: number } | null;
travelTimeEntries: TravelTimeEntry[];
densityLabel: string;
totalCount?: number;
poiPaneOpen: boolean;
onTogglePoiPane: () => void;
poiPane: ReactNode;
showSelectionPane: boolean;
rightPaneWidth: number;
rightPaneHandlers: PaneResizeHandlers;
rightPaneTab: RightPaneTab;
onAreaTabClick: () => void;
onPropertiesTabClick: () => void;
onCloseSelection: () => void;
renderAreaPane: () => ReactNode;
renderPropertiesPane: () => ReactNode;
toasts: ReactNode;
upgradeModal: ReactNode;
}
export function DesktopMapPage({
initialLoading,
tutorial,
tutorialTheme,
leftPaneWidth,
leftPaneHandlers,
filtersPane,
mapData,
pois,
mapViewFeature,
filterRange,
viewSource,
onCancelPin,
features,
selectedHexagonId,
hoveredHexagonId,
onHexagonClick,
onHexagonHover,
initialViewState,
flyToRef,
theme,
filters,
selectedPostcodeGeometry,
onLocationSearched,
onCurrentLocationFound,
currentLocation,
travelTimeEntries,
densityLabel,
totalCount,
poiPaneOpen,
onTogglePoiPane,
poiPane,
showSelectionPane,
rightPaneWidth,
rightPaneHandlers,
rightPaneTab,
onAreaTabClick,
onPropertiesTabClick,
onCloseSelection,
renderAreaPane,
renderPropertiesPane,
toasts,
upgradeModal,
}: DesktopMapPageProps) {
return (
<div className="flex-1 flex overflow-hidden relative">
<LoadingOverlay show={initialLoading} />
{tutorial.run && (
<Suspense fallback={null}>
<Joyride
steps={tutorial.steps}
run={tutorial.run}
continuous
onEvent={tutorial.handleCallback}
styles={tutorialTheme.styles}
options={{
...tutorialTheme.options,
buttons: ['back', 'close', 'primary', 'skip'],
showProgress: true,
skipScroll: true,
}}
locale={{ last: 'Finish' }}
/>
</Suspense>
)}
<div
data-tutorial="filters"
className="flex bg-white dark:bg-navy-950 shadow-lg overflow-hidden"
style={{ width: leftPaneWidth }}
>
<div className="flex-1 flex flex-col overflow-hidden">{filtersPane}</div>
<div
className="w-3 cursor-col-resize flex items-center justify-center group bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
{...leftPaneHandlers}
>
<div className="flex flex-col gap-1.5">
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
</div>
</div>
</div>
<div data-tutorial="map" className="flex-1 relative">
{tutorial.run && (
<div
data-tutorial="map-anchor"
aria-hidden="true"
className="pointer-events-none absolute left-1/2 top-1/2 z-20 h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white bg-teal-500 shadow-lg ring-4 ring-teal-500/30 dark:border-navy-950"
/>
)}
<Suspense fallback={<MapFallback />}>
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={pois}
onViewChange={mapData.handleViewChange}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={onCancelPin}
onResetPreviewScale={mapData.handleResetPreviewScale}
canResetPreviewScale={mapData.canResetPreviewScale}
features={features}
selectedHexagonId={selectedHexagonId}
hoveredHexagonId={hoveredHexagonId}
onHexagonClick={onHexagonClick}
onHexagonHover={onHexagonHover}
initialViewState={initialViewState}
flyToRef={flyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={onLocationSearched}
onCurrentLocationFound={onCurrentLocationFound}
currentLocation={currentLocation}
bounds={mapData.bounds}
travelTimeEntries={travelTimeEntries}
densityLabel={densityLabel}
totalCount={totalCount}
/>
</Suspense>
<button
data-tutorial="poi-button"
onClick={onTogglePoiPane}
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>
</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">
{poiPane}
</div>
)}
</div>
{showSelectionPane && (
<Suspense fallback={<PaneFallback />}>
<MapPageSelectionPane
width={rightPaneWidth}
resizeHandlers={rightPaneHandlers}
tab={rightPaneTab}
onAreaTabClick={onAreaTabClick}
onPropertiesTabClick={onPropertiesTabClick}
onClose={onCloseSelection}
renderAreaPane={renderAreaPane}
renderPropertiesPane={renderPropertiesPane}
/>
</Suspense>
)}
{toasts}
{upgradeModal}
</div>
);
}

View file

@ -0,0 +1,17 @@
import { SpinnerIcon } from '../../ui/icons/SpinnerIcon';
export function MapFallback() {
return (
<div className="flex h-full w-full items-center justify-center bg-warm-100 dark:bg-navy-950">
<SpinnerIcon className="h-8 w-8 animate-spin text-teal-600 dark:text-teal-400" />
</div>
);
}
export function PaneFallback() {
return (
<div className="flex h-full w-full items-center justify-center bg-white dark:bg-navy-950">
<SpinnerIcon className="h-6 w-6 animate-spin text-teal-600 dark:text-teal-400" />
</div>
);
}

View file

@ -0,0 +1,20 @@
import { SpinnerIcon } from '../../ui/icons/SpinnerIcon';
interface LoadingOverlayProps {
show: boolean;
}
export function LoadingOverlay({ show }: LoadingOverlayProps) {
if (!show) return null;
return (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
<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...
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,94 @@
import { useTranslation } from 'react-i18next';
import type { FeatureMeta } from '../../../types';
import { useTranslatedModes, type TransportMode } from '../../../hooks/useTravelTime';
import { ts } from '../../../i18n/server';
import MapLegend from '../MapLegend';
interface MobileMapLegendProps {
mapViewFeature: string | null;
colorRange: [number, number] | null;
viewSource: 'drag' | 'eye' | null;
mobileLegendMeta: FeatureMeta | null;
densityLabel: string;
densityRange: [number, number];
theme: 'light' | 'dark';
canResetPreviewScale: boolean;
onCancelPin: () => void;
onResetPreviewScale: () => void;
}
export function MobileMapLegend({
mapViewFeature,
colorRange,
viewSource,
mobileLegendMeta,
densityLabel,
densityRange,
theme,
canResetPreviewScale,
onCancelPin,
onResetPreviewScale,
}: MobileMapLegendProps) {
const { t } = useTranslation();
const modes = useTranslatedModes();
if (mapViewFeature && colorRange) {
if (mapViewFeature.startsWith('tt_')) {
return (
<MapLegend
featureLabel={t('travel.travelTime', {
mode: modes.label(mapViewFeature.split('_')[1] as TransportMode),
})}
range={colorRange}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
onResetScale={viewSource === 'eye' ? onResetPreviewScale : undefined}
resetScaleDisabled={!canResetPreviewScale}
mode="feature"
theme={theme}
inline
suffix=" min"
/>
);
}
if (mobileLegendMeta) {
return (
<MapLegend
featureLabel={
viewSource === 'eye'
? t('mapLegend.previewing', { name: ts(mobileLegendMeta.name) })
: ts(mobileLegendMeta.name)
}
range={colorRange}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
onResetScale={viewSource === 'eye' ? onResetPreviewScale : undefined}
resetScaleDisabled={!canResetPreviewScale}
mode="feature"
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
featureName={mobileLegendMeta.name}
theme={theme}
inline
suffix={mobileLegendMeta.suffix}
raw={mobileLegendMeta.raw}
/>
);
}
return null;
}
return (
<MapLegend
featureLabel={densityLabel}
range={densityRange}
showCancel={false}
onCancel={onCancelPin}
mode="density"
theme={theme}
inline
/>
);
}

View file

@ -0,0 +1,177 @@
import { Suspense, type MutableRefObject, type ReactNode } from 'react';
import type { FeatureFilters, FeatureMeta, POI, PostcodeGeometry, ViewState } from '../../../types';
import type { useMapData } from '../../../hooks/useMapData';
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
import type { SearchedLocation } from '../LocationSearch';
import MobileBottomSheet from '../MobileBottomSheet';
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
import type { MapFlyTo } from './types';
import { MapFallback, PaneFallback } from './Fallbacks';
import { LoadingOverlay } from './LoadingOverlay';
import { Map, MobileDrawer } from './lazyComponents';
type MapData = ReturnType<typeof useMapData>;
type RightPaneTab = 'properties' | 'area';
interface MobileMapPageProps {
initialLoading: boolean;
mapData: MapData;
pois: POI[];
mapViewFeature: string | null;
filterRange: [number, number] | null;
viewSource: 'drag' | 'eye' | null;
onCancelPin: () => void;
features: FeatureMeta[];
selectedHexagonId: string | null;
hoveredHexagonId: string | null;
onHexagonClick: (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => void;
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
initialViewState: ViewState;
flyToRef: MutableRefObject<MapFlyTo | null>;
theme: 'light' | 'dark';
filters: FeatureFilters;
selectedPostcodeGeometry: PostcodeGeometry | null;
onLocationSearched: (location: SearchedLocation | null) => void;
onCurrentLocationFound: (lat: number, lng: number) => void;
currentLocation: { lat: number; lng: number } | null;
travelTimeEntries: TravelTimeEntry[];
bottomScreenInset: number;
onBottomSheetCoveredHeightChange: (height: number) => void;
mobileDrawerOpen: boolean;
onMobileDrawerClose: () => void;
onMobileDrawerPanelRectChange: (rect: DOMRectReadOnly) => void;
rightPaneTab: RightPaneTab;
onMobileDrawerTabChange: (tab: RightPaneTab) => void;
poiPaneOpen: boolean;
onTogglePoiPane: () => void;
poiButtonLabel: string;
poiPane: ReactNode;
filtersPane: ReactNode;
mobileLegend: ReactNode;
renderAreaPane: () => ReactNode;
renderPropertiesPane: () => ReactNode;
toasts: ReactNode;
upgradeModal: ReactNode;
}
export function MobileMapPage({
initialLoading,
mapData,
pois,
mapViewFeature,
filterRange,
viewSource,
onCancelPin,
features,
selectedHexagonId,
hoveredHexagonId,
onHexagonClick,
onHexagonHover,
initialViewState,
flyToRef,
theme,
filters,
selectedPostcodeGeometry,
onLocationSearched,
onCurrentLocationFound,
currentLocation,
travelTimeEntries,
bottomScreenInset,
onBottomSheetCoveredHeightChange,
mobileDrawerOpen,
onMobileDrawerClose,
onMobileDrawerPanelRectChange,
rightPaneTab,
onMobileDrawerTabChange,
poiPaneOpen,
onTogglePoiPane,
poiButtonLabel,
poiPane,
filtersPane,
mobileLegend,
renderAreaPane,
renderPropertiesPane,
toasts,
upgradeModal,
}: MobileMapPageProps) {
return (
<div className="flex-1 overflow-hidden relative">
<LoadingOverlay show={initialLoading} />
<div className="absolute inset-0">
<Suspense fallback={<MapFallback />}>
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={pois}
onViewChange={mapData.handleViewChange}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={onCancelPin}
onResetPreviewScale={mapData.handleResetPreviewScale}
canResetPreviewScale={mapData.canResetPreviewScale}
features={features}
selectedHexagonId={selectedHexagonId}
hoveredHexagonId={hoveredHexagonId}
onHexagonClick={onHexagonClick}
onHexagonHover={onHexagonHover}
initialViewState={initialViewState}
flyToRef={flyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={onLocationSearched}
onCurrentLocationFound={onCurrentLocationFound}
currentLocation={currentLocation}
bounds={mapData.bounds}
hideLegend
hideLocationSearch={mobileDrawerOpen && !!selectedHexagonId}
travelTimeEntries={travelTimeEntries}
bottomScreenInset={bottomScreenInset}
/>
</Suspense>
</div>
<button
onClick={onTogglePoiPane}
className={`absolute top-3 right-3 z-20 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
aria-label={poiButtonLabel}
>
<MapPinIcon className="w-5 h-5" />
</button>
{poiPaneOpen && (
<div className="absolute top-14 right-3 left-3 z-20 flex h-[45dvh] min-h-0 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
{poiPane}
</div>
)}
<MobileBottomSheet
legend={mobileLegend}
onCoveredHeightChange={onBottomSheetCoveredHeightChange}
>
{filtersPane}
</MobileBottomSheet>
{mobileDrawerOpen && selectedHexagonId && (
<Suspense fallback={<PaneFallback />}>
<MobileDrawer
onClose={onMobileDrawerClose}
renderArea={renderAreaPane}
renderProperties={renderPropertiesPane}
tab={rightPaneTab}
onPanelRectChange={onMobileDrawerPanelRectChange}
onTabChange={onMobileDrawerTabChange}
/>
</Suspense>
)}
{toasts}
{upgradeModal}
</div>
);
}

View file

@ -0,0 +1,65 @@
import { Suspense } from 'react';
import type { FeatureMeta, ViewState } from '../../../types';
import type { useMapData } from '../../../hooks/useMapData';
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
import { MapFallback } from './Fallbacks';
import { Map } from './lazyComponents';
type MapData = ReturnType<typeof useMapData>;
interface ScreenshotMapPageProps {
mapData: MapData;
mapViewFeature: string | null;
filterRange: [number, number] | null;
viewSource: 'drag' | 'eye' | null;
features: FeatureMeta[];
initialViewState: ViewState;
theme: 'light' | 'dark';
ogMode?: boolean;
travelTimeEntries: TravelTimeEntry[];
}
export function ScreenshotMapPage({
mapData,
mapViewFeature,
filterRange,
viewSource,
features,
initialViewState,
theme,
ogMode,
travelTimeEntries,
}: ScreenshotMapPageProps) {
return (
<div className="h-full w-full">
<Suspense fallback={<MapFallback />}>
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={[]}
onViewChange={mapData.handleViewChange}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={() => {}}
onResetPreviewScale={mapData.handleResetPreviewScale}
canResetPreviewScale={mapData.canResetPreviewScale}
features={features}
selectedHexagonId={null}
hoveredHexagonId={null}
onHexagonClick={() => {}}
onHexagonHover={() => {}}
initialViewState={initialViewState}
theme={theme}
screenshotMode
ogMode={ogMode}
bounds={mapData.bounds}
travelTimeEntries={travelTimeEntries}
/>
</Suspense>
</div>
);
}

View file

@ -0,0 +1,67 @@
import type { ExportNotice } from './types';
import { BookmarkIcon } from '../../ui/icons/BookmarkIcon';
import { CheckIcon } from '../../ui/icons/CheckIcon';
import { CloseIcon } from '../../ui/icons/CloseIcon';
import { InfoIcon } from '../../ui/icons/InfoIcon';
interface BookmarkToastProps {
show: boolean;
onViewSaved: () => void;
onDismissForever: () => void;
}
export function BookmarkToast({ show, onViewSaved, onDismissForever }: BookmarkToastProps) {
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>
<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
</button>
<button
onClick={onDismissForever}
className="text-warm-400 hover:text-warm-200 text-xs whitespace-nowrap"
>
Don&apos;t show again
</button>
</div>
);
}
interface ExportToastProps {
notice: ExportNotice | null;
offsetForBookmark: boolean;
closeLabel: string;
onClose: () => void;
}
export function ExportToast({ notice, offsetForBookmark, closeLabel, onClose }: ExportToastProps) {
if (!notice) return null;
return (
<div
role={notice.kind === 'error' ? 'alert' : 'status'}
aria-live={notice.kind === 'error' ? 'assertive' : 'polite'}
className={`fixed ${offsetForBookmark ? 'bottom-24' : 'bottom-6'} left-1/2 z-[60] flex max-w-[calc(100vw-2rem)] -translate-x-1/2 items-center gap-3 rounded-lg bg-navy-900 px-4 py-3 text-sm text-white shadow-lg animate-fade-in`}
>
{notice.kind === 'success' ? (
<CheckIcon className="h-4 w-4 shrink-0 text-teal-400" />
) : (
<InfoIcon className="h-4 w-4 shrink-0 text-red-300" />
)}
<span className="min-w-0">{notice.message}</span>
<button
onClick={onClose}
aria-label={closeLabel}
className="-mr-1 flex h-7 w-7 shrink-0 items-center justify-center rounded text-warm-300 hover:bg-navy-800 hover:text-white"
>
<CloseIcon className="h-4 w-4" />
</button>
</div>
);
}

View file

@ -0,0 +1,95 @@
import { useMemo } from 'react';
import { cellToLatLng } from 'h3-js';
import type { FeatureMeta, HexagonStatsResponse, PostcodeFeature } from '../../../types';
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 { getEthnicityFeatureName } from '../../../lib/ethnicity-filter';
import { getPoiDistanceFeatureName } from '../../../lib/poi-distance-filter';
import { getSchoolBackendFeatureName } from '../../../lib/school-filter';
type MapData = ReturnType<typeof useMapData>;
interface SelectedHexagon {
id: string;
type: 'hexagon' | 'postcode';
resolution: number;
}
export function getMapPageBackendFeatureName(featureName: string): string {
return (
getSchoolBackendFeatureName(featureName) ??
getSpecificCrimeFeatureName(featureName) ??
getEthnicityFeatureName(featureName) ??
getPoiDistanceFeatureName(featureName) ??
featureName
);
}
export function useJourneyDestination(entries: TravelTimeEntry[]) {
return useMemo(() => {
const entry = entries.find((item) => item.mode === 'transit' && item.slug);
return entry ? { mode: entry.mode, slug: entry.slug } : null;
}, [entries]);
}
export function useMapViewFeature(viewFeature: string | null) {
return useMemo(
() => (viewFeature ? getMapPageBackendFeatureName(viewFeature) : null),
[viewFeature]
);
}
export function useMobileLegendMeta(viewFeature: string | null, features: FeatureMeta[]) {
return useMemo(() => {
const featureName = viewFeature ? getMapPageBackendFeatureName(viewFeature) : null;
return featureName ? features.find((feature) => feature.name === featureName) || null : null;
}, [viewFeature, features]);
}
export function useMobileDensityRange(mapData: MapData): [number, number] {
return useMemo(() => {
const items = mapData.usePostcodeView ? mapData.postcodeData : mapData.data;
if (items.length === 0) return [0, 1];
let min = Infinity;
let max = -Infinity;
for (const item of items) {
const count = 'count' in item ? item.count : item.properties.count;
if (count < min) min = count;
if (count > max) max = count;
}
if (min === Infinity) return [0, 1];
if (min === max) return [min, min + 1];
return [min, max];
}, [mapData.data, mapData.postcodeData, mapData.usePostcodeView]);
}
export function useHexagonLocation(
selectedHexagon: SelectedHexagon | null,
postcodeData: PostcodeFeature[],
resolution: number,
areaStats: HexagonStatsResponse | null
): HexagonLocation | null {
return useMemo(() => {
const hexId = selectedHexagon?.id;
const isPostcode = selectedHexagon?.type === 'postcode';
if (isPostcode) {
const postcodeFeature = postcodeData.find((feature) => feature.properties.postcode === hexId);
if (!postcodeFeature?.properties.centroid) return null;
const [lon, lat] = postcodeFeature.properties.centroid;
return { lat, lon, resolution, postcode: hexId, isPostcode: true };
}
if (!hexId) return null;
const [lat, lon] = cellToLatLng(hexId);
return {
lat,
lon,
resolution: selectedHexagon?.resolution ?? resolution,
postcode: areaStats?.central_postcode,
};
}, [selectedHexagon, postcodeData, resolution, areaStats?.central_postcode]);
}

View file

@ -0,0 +1,138 @@
import { useEffect } from 'react';
import type { MutableRefObject } from 'react';
import type { PostcodeGeometry, ViewState } from '../../../types';
import type { useMapData } from '../../../hooks/useMapData';
import { authHeaders } from '../../../lib/api';
import { canWheelScrollInsideTarget } from '../../../lib/dom-scroll';
import type { MapFlyTo } from './types';
type MapData = ReturnType<typeof useMapData>;
type RightPaneTab = 'properties' | 'area';
export function useInitialMapPageView(
mapData: MapData,
initialViewState: ViewState,
initialTab: RightPaneTab,
setRightPaneTab: (tab: RightPaneTab) => void
) {
useEffect(() => {
mapData.setInitialView(initialViewState);
setRightPaneTab(initialTab);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}
interface UseInitialPostcodeSelectionOptions {
initialPostcode?: string;
isMobile: boolean;
flyTo: MutableRefObject<MapFlyTo | null>;
onLocationSearch: (
postcode: string,
geometry: PostcodeGeometry,
lat?: number,
lng?: number
) => void;
onOpenMobileDrawer: () => void;
}
export function useInitialPostcodeSelection({
initialPostcode,
isMobile,
flyTo,
onLocationSearch,
onOpenMobileDrawer,
}: UseInitialPostcodeSelectionOptions) {
useEffect(() => {
if (!initialPostcode) return;
const params = new URLSearchParams(window.location.search);
params.delete('pc');
const newUrl = params.toString() ? `/dashboard?${params}` : '/dashboard';
window.history.replaceState(window.history.state, '', newUrl);
fetch(`/api/postcode/${encodeURIComponent(initialPostcode)}`, authHeaders())
.then((res) => {
if (!res.ok) throw new Error('Postcode not found');
return res.json();
})
.then(
(data: {
postcode: string;
latitude: number;
longitude: number;
geometry: PostcodeGeometry;
}) => {
flyTo.current?.(data.latitude, data.longitude, 16);
onLocationSearch(data.postcode, data.geometry, data.latitude, data.longitude);
if (isMobile) onOpenMobileDrawer();
}
)
.catch(() => {
// Silently fail because the postcode might no longer exist.
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}
export function useHorizontalSwipeNavigationGuard() {
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
if (
Math.abs(e.deltaX) > Math.abs(e.deltaY) &&
!canWheelScrollInsideTarget(e.target, e.deltaX, e.deltaY)
) {
e.preventDefault();
}
};
document.addEventListener('wheel', handleWheel, { passive: false });
return () => document.removeEventListener('wheel', handleWheel);
}, []);
}
export function useMobileBackNavigationGuard(isMobile: boolean) {
useEffect(() => {
if (!isMobile) return;
window.history.pushState({ dashboardGuard: true }, '');
const handlePopState = () => {
window.history.pushState({ dashboardGuard: true }, '');
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [isMobile]);
}
interface UseScreenshotReadySignalOptions {
screenshotMode?: boolean;
loading: boolean;
dataLength: number;
postcodeDataLength: number;
usePostcodeView: boolean;
}
export function useScreenshotReadySignal({
screenshotMode,
loading,
dataLength,
postcodeDataLength,
usePostcodeView,
}: UseScreenshotReadySignalOptions) {
useEffect(() => {
if (screenshotMode && !loading) {
const hasData = usePostcodeView ? postcodeDataLength > 0 : dataLength > 0;
if (hasData) {
// Wait for both deck.gl data and MapLibre base map tile rendering.
const waitAndSignal = () => {
if (window.__map_idle) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
window.__screenshot_ready = true;
});
});
} else {
requestAnimationFrame(waitAndSignal);
}
};
waitAndSignal();
}
}
}, [screenshotMode, loading, dataLength, postcodeDataLength, usePostcodeView]);
}

View file

@ -0,0 +1,17 @@
import { lazy } from 'react';
export const Map = lazy(() => import('../Map'));
export const Filters = lazy(() => import('../Filters'));
export const POIPane = lazy(() => import('../POIPane'));
export const AreaPane = lazy(() => import('../AreaPane'));
export const PropertiesPane = lazy(() =>
import('../PropertiesPane').then((module) => ({ default: module.PropertiesPane }))
);
export const MobileDrawer = lazy(() => import('../MobileDrawer'));
export const MapPageSelectionPane = lazy(() =>
import('../MapPageSelectionPane').then((module) => ({ default: module.MapPageSelectionPane }))
);
export const UpgradeModal = lazy(() => import('../../ui/UpgradeModal'));
export const Joyride = lazy(() =>
import('react-joyride').then((module) => ({ default: module.Joyride }))
);

View file

@ -0,0 +1,60 @@
import type {
FeatureFilters,
FeatureMeta,
MapFlyToOptions,
POICategoryGroup,
Property,
ViewState,
} from '../../../types';
import type { TravelTimeInitial } from '../../../hooks/useTravelTime';
import type { Page } from '../../ui/Header';
import type { PointerEvent } from 'react';
export interface ExportState {
onExport: () => void;
exporting: boolean;
}
export type ExportNotice = {
kind: 'success' | 'error';
message: string;
};
export interface MapPageProps {
features: FeatureMeta[];
poiCategoryGroups: POICategoryGroup[];
initialFilters: FeatureFilters;
initialViewState: ViewState;
initialPOICategories: Set<string>;
initialTab: 'properties' | 'area';
initialLoading: boolean;
theme: 'light' | 'dark';
pendingInfoFeature: string | null;
onClearPendingInfoFeature: () => void;
onNavigateTo: (page: Page, hash?: string, infoFeature?: string) => void;
onExportStateChange?: (state: ExportState) => void;
screenshotMode?: boolean;
ogMode?: boolean;
isMobile?: boolean;
initialTravelTime?: TravelTimeInitial;
initialPostcode?: string;
shareCode?: string;
user?: { id: string; subscription: string; isAdmin?: boolean } | null;
onLoginClick: () => void;
onRegisterClick: () => void;
onSaveProperty?: (property: Property) => void;
onUnsaveProperty?: (id: string) => void;
isPropertySaved?: (address?: string, postcode?: string) => boolean;
getSavedPropertyId?: (address?: string, postcode?: string) => string | undefined;
deferTutorial?: boolean;
onSaveSearch?: (name: string) => Promise<void>;
savingSearch?: boolean;
}
export type MapFlyTo = (lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void;
export interface PaneResizeHandlers {
onPointerDown: (event: PointerEvent) => void;
onPointerMove: (event: PointerEvent) => void;
onPointerUp: () => void;
}

View file

@ -0,0 +1,176 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { TFunction } from 'i18next';
import type { Bounds, FeatureFilters, FeatureMeta } from '../../../types';
import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../../lib/api';
import { trackEvent } from '../../../lib/analytics';
import type { ExportNotice, ExportState } from './types';
const EXPORT_FILE_NAME = 'perfect-postcode-export.xlsx';
const EXPORT_TIMEOUT_MS = 150_000;
const EXPORT_NOTICE_MS = 6000;
const EXPORT_ERROR_NOTICE_MS = 9000;
function getExportFileName(res: Response): string {
const disposition = res.headers.get('content-disposition');
if (!disposition) return EXPORT_FILE_NAME;
const encodedMatch = disposition.match(/filename\*=UTF-8''([^;]+)/i);
if (encodedMatch?.[1]) {
try {
return decodeURIComponent(encodedMatch[1].trim());
} catch {
return encodedMatch[1].trim();
}
}
const match = disposition.match(/filename="?([^";]+)"?/i);
return match?.[1]?.trim() || EXPORT_FILE_NAME;
}
async function getExportErrorMessage(res: Response): Promise<string> {
const fallback = `HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ''}`;
const contentType = res.headers.get('content-type') ?? '';
try {
if (contentType.includes('application/json')) {
const data: unknown = await res.json();
if (data && typeof data === 'object') {
const record = data as Record<string, unknown>;
const message = record.message ?? record.error;
if (typeof message === 'string' && message.trim()) return message.trim();
}
return fallback;
}
const text = await res.text();
return text.trim() || fallback;
} catch {
return fallback;
}
}
function triggerExportDownload(blob: Blob, fileName: string): void {
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = fileName;
link.rel = 'noopener';
link.style.display = 'none';
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
}
interface UseExportControllerOptions {
bounds: Bounds | null;
filters: FeatureFilters;
features: FeatureMeta[];
t: TFunction;
onExportStateChange?: (state: ExportState) => void;
}
export function useExportController({
bounds,
filters,
features,
t,
onExportStateChange,
}: UseExportControllerOptions) {
const [exporting, setExporting] = useState(false);
const [exportNotice, setExportNotice] = useState<ExportNotice | null>(null);
const exportNoticeTimeoutRef = useRef<number | null>(null);
const clearExportNoticeTimer = useCallback(() => {
if (exportNoticeTimeoutRef.current !== null) {
window.clearTimeout(exportNoticeTimeoutRef.current);
exportNoticeTimeoutRef.current = null;
}
}, []);
const clearExportNotice = useCallback(() => {
clearExportNoticeTimer();
setExportNotice(null);
}, [clearExportNoticeTimer]);
const showExportNotice = useCallback(
(notice: ExportNotice) => {
clearExportNoticeTimer();
setExportNotice(notice);
exportNoticeTimeoutRef.current = window.setTimeout(
() => {
setExportNotice(null);
exportNoticeTimeoutRef.current = null;
},
notice.kind === 'error' ? EXPORT_ERROR_NOTICE_MS : EXPORT_NOTICE_MS
);
},
[clearExportNoticeTimer]
);
useEffect(() => clearExportNoticeTimer, [clearExportNoticeTimer]);
const handleExport = useCallback(() => {
if (exporting) return;
if (!bounds) {
showExportNotice({ kind: 'error', message: t('header.exportUnavailable') });
return;
}
const { south, west, north, east } = bounds;
const params = new URLSearchParams({
bounds: `${south},${west},${north},${east}`,
});
const filterStr = buildFilterString(filters, features);
if (filterStr) params.set('filters', filterStr);
const url = apiUrl('export', params);
const controller = new AbortController();
let timedOut = false;
const timeoutId = window.setTimeout(() => {
timedOut = true;
controller.abort();
}, EXPORT_TIMEOUT_MS);
setExporting(true);
clearExportNotice();
void (async () => {
try {
const res = await fetch(url, authHeaders({ signal: controller.signal }));
if (!res.ok) throw new Error(await getExportErrorMessage(res));
const blob = await res.blob();
if (blob.size === 0) throw new Error(t('header.exportEmpty'));
triggerExportDownload(blob, getExportFileName(res));
trackEvent('Export');
showExportNotice({ kind: 'success', message: t('header.exportReady') });
} catch (err) {
if (!timedOut) logNonAbortError('Export failed', err);
const detail = err instanceof Error && err.message.trim() ? ` ${err.message}` : '';
showExportNotice({
kind: 'error',
message: timedOut ? t('header.exportTimedOut') : `${t('header.exportFailed')}${detail}`,
});
} finally {
window.clearTimeout(timeoutId);
setExporting(false);
}
})();
}, [bounds, clearExportNotice, exporting, features, filters, showExportNotice, t]);
useEffect(() => {
onExportStateChange?.({ onExport: handleExport, exporting });
}, [handleExport, exporting, onExportStateChange]);
return {
exporting,
exportNotice,
clearExportNotice,
handleExport,
};
}

View file

@ -0,0 +1,106 @@
import type { FeatureFilters, FeatureMeta } from '../types';
export const ETHNICITIES_FILTER_NAME = 'Ethnicities';
export const ETHNICITIES_FILTER_KEY_PREFIX = `${ETHNICITIES_FILTER_NAME}:`;
export const ETHNICITY_FEATURE_NAMES = [
'% White',
'% South Asian',
'% East Asian',
'% Black',
'% Mixed',
'% Other',
] as const;
const ETHNICITY_FEATURE_NAME_SET = new Set<string>(ETHNICITY_FEATURE_NAMES);
export function isEthnicityFeatureName(name: string): boolean {
return ETHNICITY_FEATURE_NAME_SET.has(name);
}
export function isEthnicityFilterName(name: string): boolean {
return isEthnicityFeatureName(name) || name.startsWith(ETHNICITIES_FILTER_KEY_PREFIX);
}
export function createEthnicityFilterKey(featureName: string, id: number | string): string {
return `${ETHNICITIES_FILTER_KEY_PREFIX}${encodeURIComponent(featureName)}:${id}`;
}
export function getEthnicityFilterKeyId(name: string): string | null {
if (!name.startsWith(ETHNICITIES_FILTER_KEY_PREFIX)) return null;
const rest = name.substring(ETHNICITIES_FILTER_KEY_PREFIX.length);
const lastColon = rest.lastIndexOf(':');
return lastColon === -1 ? null : rest.substring(lastColon + 1);
}
export function parseEthnicityFilterKey(name: string): string | null {
if (!name.startsWith(ETHNICITIES_FILTER_KEY_PREFIX)) return null;
const rest = name.substring(ETHNICITIES_FILTER_KEY_PREFIX.length);
const lastColon = rest.lastIndexOf(':');
if (lastColon === -1) return null;
const decoded = decodeURIComponent(rest.substring(0, lastColon));
return isEthnicityFeatureName(decoded) ? decoded : null;
}
export function getEthnicityFeatureName(name: string): string | null {
if (isEthnicityFeatureName(name)) return name;
return parseEthnicityFilterKey(name);
}
export function replaceEthnicityFilterKeySelection(key: string, featureName: string): string {
const id = getEthnicityFilterKeyId(key) ?? '0';
return createEthnicityFilterKey(featureName, id);
}
export function getDefaultEthnicityFeatureName(features: FeatureMeta[]): string | null {
return (
ETHNICITY_FEATURE_NAMES.find((name) => features.some((feature) => feature.name === name)) ??
null
);
}
export function normalizeEthnicityFilters(filters: FeatureFilters): FeatureFilters {
let changed = false;
const next: FeatureFilters = {};
for (const [name, value] of Object.entries(filters)) {
if (isEthnicityFeatureName(name)) {
next[createEthnicityFilterKey(name, Object.keys(next).length)] = value;
changed = true;
continue;
}
next[name] = value;
}
return changed ? next : filters;
}
export function getEthnicityFilterMeta(features: FeatureMeta[]): FeatureMeta {
const sourceFeatureName = getDefaultEthnicityFeatureName(features);
const sourceFeature = sourceFeatureName
? features.find((feature) => feature.name === sourceFeatureName)
: undefined;
return {
name: ETHNICITIES_FILTER_NAME,
type: 'numeric',
group: 'Demographics',
min: sourceFeature?.min ?? 0,
max: sourceFeature?.max ?? 100,
step: 0.1,
description: 'Population percentage by ethnic group',
detail: 'Filter by one Census 2021 ethnicity percentage at a time.',
source: 'ethnicity',
suffix: '%',
};
}
export function clampEthnicityRange(
value: [number, number],
feature?: FeatureMeta
): [number, number] {
const min = feature?.histogram?.min ?? feature?.min ?? 0;
const max = feature?.histogram?.max ?? feature?.max ?? Math.max(1, value[1]);
return [Math.max(min, Math.min(value[0], max)), Math.max(min, Math.min(value[1], max))];
}

View file

@ -0,0 +1,291 @@
import type { FeatureFilters, FeatureMeta } from '../types';
export const POI_DISTANCE_FILTER_NAME = 'POI distance';
export const POI_COUNT_2KM_FILTER_NAME = 'POIs within 2km';
export const POI_COUNT_5KM_FILTER_NAME = 'POIs within 5km';
export const POI_FILTER_NAMES = [
POI_DISTANCE_FILTER_NAME,
POI_COUNT_2KM_FILTER_NAME,
POI_COUNT_5KM_FILTER_NAME,
] as const;
export type PoiFilterName = (typeof POI_FILTER_NAMES)[number];
type PoiMetric = 'distance' | 'count_2km' | 'count_5km';
export const POI_DISTANCE_FILTER_KEY_PREFIX = `${POI_DISTANCE_FILTER_NAME}:`;
export const POI_DISTANCE_FEATURE_NAMES = [
'Distance to nearest park (km)',
'Distance to nearest grocery store (km)',
'Distance to nearest tube station (km)',
'Distance to nearest rail station (km)',
'Distance to nearest Waitrose (km)',
'Distance to nearest Tesco (km)',
'Distance to nearest cafe (km)',
'Distance to nearest pub (km)',
'Distance to nearest restaurant (km)',
] as const;
const LEGACY_POI_DISTANCE_FEATURE_NAME_SET = new Set<string>(POI_DISTANCE_FEATURE_NAMES);
const LEGACY_POI_DISTANCE_AGGREGATE_OPTIONS = [
'Distance to nearest park (km)',
'Distance to nearest grocery store (km)',
] as const;
const DYNAMIC_DISTANCE_RE = /^Distance to nearest (.+) POI \(km\)$/;
const DYNAMIC_COUNT_RE = /^Number of (.+) POIs within (2|5)km$/;
const POI_FILTER_CONFIGS: Record<
PoiFilterName,
{
metric: PoiMetric;
keyPrefix: string;
description: string;
detail: string;
defaultMax: number;
step: number;
suffix: string;
}
> = {
[POI_DISTANCE_FILTER_NAME]: {
metric: 'distance',
keyPrefix: POI_DISTANCE_FILTER_KEY_PREFIX,
description: 'Distance to nearby points of interest',
detail: 'Filter by distance to one nearby point-of-interest type at a time.',
defaultMax: 5,
step: 0.1,
suffix: ' km',
},
[POI_COUNT_2KM_FILTER_NAME]: {
metric: 'count_2km',
keyPrefix: `${POI_COUNT_2KM_FILTER_NAME}:`,
description: 'Number of nearby points of interest within 2km',
detail: 'Filter by the count of one point-of-interest type within 2km.',
defaultMax: 20,
step: 1,
suffix: '',
},
[POI_COUNT_5KM_FILTER_NAME]: {
metric: 'count_5km',
keyPrefix: `${POI_COUNT_5KM_FILTER_NAME}:`,
description: 'Number of nearby points of interest within 5km',
detail: 'Filter by the count of one point-of-interest type within 5km.',
defaultMax: 50,
step: 1,
suffix: '',
},
};
function isPoiFilterNameValue(name: string): name is PoiFilterName {
return POI_FILTER_NAMES.includes(name as PoiFilterName);
}
function getConfig(filterName: PoiFilterName) {
return POI_FILTER_CONFIGS[filterName];
}
function isDynamicPoiDistanceFeatureName(name: string): boolean {
return DYNAMIC_DISTANCE_RE.test(name);
}
function getPoiMetric(name: string): PoiMetric | null {
if (isDynamicPoiDistanceFeatureName(name) || LEGACY_POI_DISTANCE_FEATURE_NAME_SET.has(name)) {
return 'distance';
}
const countMatch = name.match(DYNAMIC_COUNT_RE);
if (!countMatch) return null;
return countMatch[2] === '2' ? 'count_2km' : 'count_5km';
}
function getFilterNameForMetric(metric: PoiMetric): PoiFilterName {
if (metric === 'count_2km') return POI_COUNT_2KM_FILTER_NAME;
if (metric === 'count_5km') return POI_COUNT_5KM_FILTER_NAME;
return POI_DISTANCE_FILTER_NAME;
}
export function getPoiFeatureCategory(name: string): string | null {
const distanceMatch = name.match(DYNAMIC_DISTANCE_RE);
if (distanceMatch) return distanceMatch[1];
const countMatch = name.match(DYNAMIC_COUNT_RE);
if (countMatch) return countMatch[1];
return null;
}
export function isPoiDistanceFeatureName(name: string): boolean {
return isDynamicPoiDistanceFeatureName(name) || LEGACY_POI_DISTANCE_FEATURE_NAME_SET.has(name);
}
export function isPoiFilterFeatureName(name: string): boolean {
return getPoiMetric(name) != null;
}
export function getPoiFilterName(name: string): PoiFilterName | null {
for (const filterName of POI_FILTER_NAMES) {
if (name.startsWith(getConfig(filterName).keyPrefix)) return filterName;
}
const metric = getPoiMetric(name);
return metric ? getFilterNameForMetric(metric) : null;
}
export function isPoiDistanceFilterName(name: string): boolean {
return getPoiFilterName(name) != null;
}
export function createPoiFilterKey(
filterName: PoiFilterName,
featureName: string,
id: number | string
): string {
return `${getConfig(filterName).keyPrefix}${encodeURIComponent(featureName)}:${id}`;
}
export function createPoiDistanceFilterKey(featureName: string, id: number | string): string {
return createPoiFilterKey(POI_DISTANCE_FILTER_NAME, featureName, id);
}
export function getPoiFilterKeyId(name: string): string | null {
const filterName = getPoiFilterName(name);
if (!filterName) return null;
const prefix = getConfig(filterName).keyPrefix;
if (!name.startsWith(prefix)) return null;
const rest = name.substring(prefix.length);
const lastColon = rest.lastIndexOf(':');
return lastColon === -1 ? null : rest.substring(lastColon + 1);
}
export function getPoiDistanceFilterKeyId(name: string): string | null {
return getPoiFilterKeyId(name);
}
export function parsePoiFilterKey(name: string): string | null {
const filterName = getPoiFilterName(name);
if (!filterName) return null;
const prefix = getConfig(filterName).keyPrefix;
if (!name.startsWith(prefix)) return null;
const rest = name.substring(prefix.length);
const lastColon = rest.lastIndexOf(':');
if (lastColon === -1) return null;
const decoded = decodeURIComponent(rest.substring(0, lastColon));
const metric = getPoiMetric(decoded);
return metric === getConfig(filterName).metric ? decoded : null;
}
export function parsePoiDistanceFilterKey(name: string): string | null {
return parsePoiFilterKey(name);
}
export function getPoiDistanceFeatureName(name: string): string | null {
if (isPoiFilterFeatureName(name)) return name;
return parsePoiFilterKey(name);
}
export function replacePoiFilterKeySelection(key: string, featureName: string): string {
const filterName = getPoiFilterName(key) ?? getFilterNameForMetric(getPoiMetric(featureName)!);
const id = getPoiFilterKeyId(key) ?? '0';
return createPoiFilterKey(filterName, featureName, id);
}
export function replacePoiDistanceFilterKeySelection(key: string, featureName: string): string {
return replacePoiFilterKeySelection(key, featureName);
}
export function getPoiFilterFeatureOptions(
features: FeatureMeta[],
filterName: PoiFilterName
): FeatureMeta[] {
const metric = getConfig(filterName).metric;
const dynamicOptions = features.filter((feature) => {
const featureMetric = getPoiMetric(feature.name);
if (featureMetric !== metric) return false;
return metric !== 'distance' || isDynamicPoiDistanceFeatureName(feature.name);
});
if (dynamicOptions.length > 0 && metric === 'distance') {
const aggregateOptions = LEGACY_POI_DISTANCE_AGGREGATE_OPTIONS.map((name) =>
features.find((feature) => feature.name === name)
).filter((feature): feature is FeatureMeta => Boolean(feature));
return [...dynamicOptions, ...aggregateOptions];
}
if (dynamicOptions.length > 0 || metric !== 'distance') {
return dynamicOptions;
}
return POI_DISTANCE_FEATURE_NAMES.map((name) =>
features.find((feature) => feature.name === name)
).filter((feature): feature is FeatureMeta => Boolean(feature));
}
export function getDefaultPoiFilterFeatureName(
features: FeatureMeta[],
filterName: PoiFilterName
): string | null {
return getPoiFilterFeatureOptions(features, filterName)[0]?.name ?? null;
}
export function getDefaultPoiDistanceFeatureName(features: FeatureMeta[]): string | null {
return getDefaultPoiFilterFeatureName(features, POI_DISTANCE_FILTER_NAME);
}
export function getPoiFilterMeta(features: FeatureMeta[], filterName: PoiFilterName): FeatureMeta {
const sourceFeatureName = getDefaultPoiFilterFeatureName(features, filterName);
const sourceFeature = sourceFeatureName
? features.find((feature) => feature.name === sourceFeatureName)
: undefined;
const config = getConfig(filterName);
return {
name: filterName,
type: 'numeric',
group: 'Nearby POIs',
min: sourceFeature?.min ?? 0,
max: sourceFeature?.max ?? config.defaultMax,
step: config.step,
description: config.description,
detail: config.detail,
source: sourceFeature?.source ?? 'osm-pois',
suffix: config.suffix,
};
}
export function getPoiDistanceFilterMeta(features: FeatureMeta[]): FeatureMeta {
return getPoiFilterMeta(features, POI_DISTANCE_FILTER_NAME);
}
export function normalizePoiDistanceFilters(filters: FeatureFilters): FeatureFilters {
let changed = false;
const next: FeatureFilters = {};
for (const [name, value] of Object.entries(filters)) {
if (isPoiFilterFeatureName(name)) {
const filterName = getPoiFilterName(name) ?? POI_DISTANCE_FILTER_NAME;
next[createPoiFilterKey(filterName, name, Object.keys(next).length)] = value;
changed = true;
continue;
}
next[name] = value;
}
return changed ? next : filters;
}
export function clampPoiFilterRange(
value: [number, number],
feature?: FeatureMeta
): [number, number] {
const min = feature?.histogram?.min ?? feature?.min ?? 0;
const max = feature?.histogram?.max ?? feature?.max ?? Math.max(1, value[1]);
return [Math.max(min, Math.min(value[0], max)), Math.max(min, Math.min(value[1], max))];
}
export function clampPoiDistanceRange(
value: [number, number],
feature?: FeatureMeta
): [number, number] {
return clampPoiFilterRange(value, feature);
}