Extract components
This commit is contained in:
parent
a48eb945e0
commit
fe46cb3379
30 changed files with 4075 additions and 2610 deletions
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