Extract components
This commit is contained in:
parent
a48eb945e0
commit
fe46cb3379
30 changed files with 4075 additions and 2610 deletions
29
README.md
29
README.md
|
|
@ -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
|
|
@ -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,
|
||||
|
|
|
|||
253
frontend/src/components/map/filters/ActiveFilterList.tsx
Normal file
253
frontend/src/components/map/filters/ActiveFilterList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
204
frontend/src/components/map/filters/ActiveFiltersPanel.tsx
Normal file
204
frontend/src/components/map/filters/ActiveFiltersPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
162
frontend/src/components/map/filters/AddFilterPanel.tsx
Normal file
162
frontend/src/components/map/filters/AddFilterPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
frontend/src/components/map/filters/ClearFiltersDialog.tsx
Normal file
94
frontend/src/components/map/filters/ClearFiltersDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
223
frontend/src/components/map/filters/EthnicityFilterCard.tsx
Normal file
223
frontend/src/components/map/filters/EthnicityFilterCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
frontend/src/components/map/filters/NumericFeatureFilterCard.tsx
Normal file
138
frontend/src/components/map/filters/NumericFeatureFilterCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
220
frontend/src/components/map/filters/PoiDistanceFilterCard.tsx
Normal file
220
frontend/src/components/map/filters/PoiDistanceFilterCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
234
frontend/src/components/map/filters/SchoolFilterCard.tsx
Normal file
234
frontend/src/components/map/filters/SchoolFilterCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
frontend/src/components/map/filters/SliderLabels.tsx
Normal file
145
frontend/src/components/map/filters/SliderLabels.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
223
frontend/src/components/map/filters/SpecificCrimeFilterCard.tsx
Normal file
223
frontend/src/components/map/filters/SpecificCrimeFilterCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
225
frontend/src/components/map/map-page/DesktopMapPage.tsx
Normal file
225
frontend/src/components/map/map-page/DesktopMapPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
frontend/src/components/map/map-page/Fallbacks.tsx
Normal file
17
frontend/src/components/map/map-page/Fallbacks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/map/map-page/LoadingOverlay.tsx
Normal file
20
frontend/src/components/map/map-page/LoadingOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
frontend/src/components/map/map-page/MobileMapLegend.tsx
Normal file
94
frontend/src/components/map/map-page/MobileMapLegend.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
177
frontend/src/components/map/map-page/MobileMapPage.tsx
Normal file
177
frontend/src/components/map/map-page/MobileMapPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
frontend/src/components/map/map-page/ScreenshotMapPage.tsx
Normal file
65
frontend/src/components/map/map-page/ScreenshotMapPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
frontend/src/components/map/map-page/Toasts.tsx
Normal file
67
frontend/src/components/map/map-page/Toasts.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
95
frontend/src/components/map/map-page/derivedState.ts
Normal file
95
frontend/src/components/map/map-page/derivedState.ts
Normal 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]);
|
||||
}
|
||||
138
frontend/src/components/map/map-page/effects.ts
Normal file
138
frontend/src/components/map/map-page/effects.ts
Normal 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]);
|
||||
}
|
||||
17
frontend/src/components/map/map-page/lazyComponents.ts
Normal file
17
frontend/src/components/map/map-page/lazyComponents.ts
Normal 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 }))
|
||||
);
|
||||
60
frontend/src/components/map/map-page/types.ts
Normal file
60
frontend/src/components/map/map-page/types.ts
Normal 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;
|
||||
}
|
||||
176
frontend/src/components/map/map-page/useExportController.ts
Normal file
176
frontend/src/components/map/map-page/useExportController.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
106
frontend/src/lib/ethnicity-filter.ts
Normal file
106
frontend/src/lib/ethnicity-filter.ts
Normal 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))];
|
||||
}
|
||||
291
frontend/src/lib/poi-distance-filter.ts
Normal file
291
frontend/src/lib/poi-distance-filter.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue