deploy
This commit is contained in:
parent
3fa95819e3
commit
e9a06417ad
32 changed files with 1531 additions and 407 deletions
|
|
@ -75,12 +75,6 @@ function filterValueFormat(feature?: FeatureMeta) {
|
|||
};
|
||||
}
|
||||
|
||||
function formatExclusionPercent(value: number): string {
|
||||
const percent = value * 100;
|
||||
if (percent < 10) return `${percent.toFixed(1)}%`;
|
||||
return `${Math.round(percent)}%`;
|
||||
}
|
||||
|
||||
export default function AreaPane({
|
||||
stats,
|
||||
globalFeatures,
|
||||
|
|
@ -144,6 +138,9 @@ export default function AreaPane({
|
|||
};
|
||||
|
||||
const getExclusionAdjustment = (exclusion: FilterExclusion) => {
|
||||
if (exclusion.direction === 'missing_value') {
|
||||
return t('areaPane.missingFilterValue');
|
||||
}
|
||||
if (exclusion.direction === 'allow_value') {
|
||||
return t('areaPane.allowCategory', { value: ts(exclusion.category ?? '') });
|
||||
}
|
||||
|
|
@ -264,14 +261,7 @@ export default function AreaPane({
|
|||
key={`${exclusion.kind}:${exclusion.name}:${exclusion.direction}:${exclusion.category ?? ''}`}
|
||||
className="rounded bg-white/70 px-2 py-1.5 dark:bg-navy-950/40"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="min-w-0 truncate font-medium">
|
||||
{getExclusionLabel(exclusion)}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums text-amber-700 dark:text-amber-200">
|
||||
{formatExclusionPercent(exclusion.relative_difference)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="truncate font-medium">{getExclusionLabel(exclusion)}</div>
|
||||
<p className="mt-0.5 text-amber-800/80 dark:text-amber-100/80">
|
||||
{getExclusionAdjustment(exclusion)}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { FeatureMeta, FeatureFilters } from '../../types';
|
|||
import { findActiveFilterElement } from '../../lib/active-filter-scroll';
|
||||
import { buildPercentileScale } from '../../lib/format';
|
||||
import type { PercentileScale } from '../../lib/format';
|
||||
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
|
||||
import InfoPopup from '../ui/InfoPopup';
|
||||
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
||||
import type { AiFilterErrorType } from '../../hooks/useAiFilters';
|
||||
|
|
@ -69,7 +70,7 @@ interface FiltersProps {
|
|||
onAddFilter: (name: string) => void;
|
||||
onRemoveFilter: (name: string) => void;
|
||||
onFilterChange: (name: string, value: [number, number] | string[]) => void;
|
||||
onDragStart: (name: string) => void;
|
||||
onDragStart: (name: string, initialValue?: [number, number]) => void;
|
||||
onDragChange: (value: [number, number]) => void;
|
||||
onDragEnd: () => void;
|
||||
pinnedFeature: string | null;
|
||||
|
|
@ -423,46 +424,88 @@ export default memo(function Filters({
|
|||
const [activeInfoFeature, setActiveInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
const [activeFilterCollapsed, setActiveFilterCollapsed] = useState(false);
|
||||
const [addFilterCollapsed, setAddFilterCollapsed] = useState(false);
|
||||
const [isActiveFilterGroupExpanded, toggleActiveFilterGroup, expandActiveFilterGroup] =
|
||||
useCollapsibleGroups();
|
||||
const activeEntryCount = travelTimeEntries.length;
|
||||
|
||||
const pendingScrollRef = useRef<string | null>(null);
|
||||
const highlightTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
const queueActiveFilterScroll = useCallback(
|
||||
(filterName: string, groupName: string | null | undefined) => {
|
||||
if (groupName) expandActiveFilterGroup(groupName);
|
||||
pendingScrollRef.current = filterName;
|
||||
},
|
||||
[expandActiveFilterGroup]
|
||||
);
|
||||
|
||||
const getAddFilterGroupName = useCallback(
|
||||
(name: string): string | null => {
|
||||
if (name === SCHOOL_FILTER_NAME) return schoolMeta.group ?? 'Education';
|
||||
if (name === SPECIFIC_CRIMES_FILTER_NAME) return specificCrimeMeta.group ?? 'Crime';
|
||||
if (name === ELECTION_VOTE_SHARE_FILTER_NAME) {
|
||||
return electionVoteShareMeta.group ?? 'Neighbours';
|
||||
}
|
||||
if (name === ETHNICITIES_FILTER_NAME) return ethnicityMeta.group ?? 'Neighbours';
|
||||
if (POI_FILTER_NAMES.includes(name as PoiFilterName)) {
|
||||
return poiFilterMetas[name as PoiFilterName].group ?? null;
|
||||
}
|
||||
return features.find((feature) => feature.name === name)?.group ?? null;
|
||||
},
|
||||
[
|
||||
electionVoteShareMeta.group,
|
||||
ethnicityMeta.group,
|
||||
features,
|
||||
poiFilterMetas,
|
||||
schoolMeta.group,
|
||||
specificCrimeMeta.group,
|
||||
]
|
||||
);
|
||||
|
||||
const handleAddAndScroll = useCallback(
|
||||
(name: string) => {
|
||||
if (name === SCHOOL_FILTER_NAME) {
|
||||
if (!defaultSchoolFeatureName) return;
|
||||
pendingScrollRef.current = SCHOOL_FILTER_NAME;
|
||||
queueActiveFilterScroll(SCHOOL_FILTER_NAME, getAddFilterGroupName(SCHOOL_FILTER_NAME));
|
||||
onAddFilter(SCHOOL_FILTER_NAME);
|
||||
return;
|
||||
}
|
||||
if (name === SPECIFIC_CRIMES_FILTER_NAME) {
|
||||
if (!defaultSpecificCrimeFeatureName) return;
|
||||
pendingScrollRef.current = SPECIFIC_CRIMES_FILTER_NAME;
|
||||
queueActiveFilterScroll(
|
||||
SPECIFIC_CRIMES_FILTER_NAME,
|
||||
getAddFilterGroupName(SPECIFIC_CRIMES_FILTER_NAME)
|
||||
);
|
||||
onAddFilter(SPECIFIC_CRIMES_FILTER_NAME);
|
||||
return;
|
||||
}
|
||||
if (name === ELECTION_VOTE_SHARE_FILTER_NAME) {
|
||||
if (!defaultElectionVoteShareFeatureName) return;
|
||||
pendingScrollRef.current = ELECTION_VOTE_SHARE_FILTER_NAME;
|
||||
queueActiveFilterScroll(
|
||||
ELECTION_VOTE_SHARE_FILTER_NAME,
|
||||
getAddFilterGroupName(ELECTION_VOTE_SHARE_FILTER_NAME)
|
||||
);
|
||||
onAddFilter(ELECTION_VOTE_SHARE_FILTER_NAME);
|
||||
return;
|
||||
}
|
||||
if (name === ETHNICITIES_FILTER_NAME) {
|
||||
if (!defaultEthnicityFeatureName) return;
|
||||
pendingScrollRef.current = ETHNICITIES_FILTER_NAME;
|
||||
queueActiveFilterScroll(
|
||||
ETHNICITIES_FILTER_NAME,
|
||||
getAddFilterGroupName(ETHNICITIES_FILTER_NAME)
|
||||
);
|
||||
onAddFilter(ETHNICITIES_FILTER_NAME);
|
||||
return;
|
||||
}
|
||||
if (POI_FILTER_NAMES.includes(name as PoiFilterName)) {
|
||||
const filterName = name as PoiFilterName;
|
||||
if (!defaultPoiFilterFeatureNames[filterName]) return;
|
||||
pendingScrollRef.current = filterName;
|
||||
queueActiveFilterScroll(filterName, getAddFilterGroupName(filterName));
|
||||
onAddFilter(filterName);
|
||||
return;
|
||||
}
|
||||
|
||||
pendingScrollRef.current = name;
|
||||
queueActiveFilterScroll(name, getAddFilterGroupName(name));
|
||||
onAddFilter(name);
|
||||
},
|
||||
[
|
||||
|
|
@ -471,16 +514,18 @@ export default memo(function Filters({
|
|||
defaultElectionVoteShareFeatureName,
|
||||
defaultEthnicityFeatureName,
|
||||
defaultPoiFilterFeatureNames,
|
||||
getAddFilterGroupName,
|
||||
onAddFilter,
|
||||
queueActiveFilterScroll,
|
||||
]
|
||||
);
|
||||
|
||||
const handleAddTravelTimeAndScroll = useCallback(
|
||||
(mode: TransportMode) => {
|
||||
pendingScrollRef.current = `tt_${travelTimeEntries.length}`;
|
||||
queueActiveFilterScroll(`tt_${travelTimeEntries.length}`, 'Transport');
|
||||
onTravelTimeAddEntry(mode);
|
||||
},
|
||||
[onTravelTimeAddEntry, travelTimeEntries.length]
|
||||
[onTravelTimeAddEntry, queueActiveFilterScroll, travelTimeEntries.length]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -516,9 +561,6 @@ export default memo(function Filters({
|
|||
return scales;
|
||||
}, [features]);
|
||||
|
||||
// Keep commute controls at the top of active filters, before other Transport filters.
|
||||
const travelInsertIdx = 0;
|
||||
|
||||
const badgeCount = enabledFeatureList.length + activeEntryCount;
|
||||
|
||||
const [showClearPopup, setShowClearPopup] = useState(false);
|
||||
|
|
@ -574,10 +616,11 @@ export default memo(function Filters({
|
|||
dragValue={dragValue}
|
||||
pinnedFeature={pinnedFeature}
|
||||
travelTimeEntries={travelTimeEntries}
|
||||
travelInsertIdx={travelInsertIdx}
|
||||
filterImpacts={filterImpacts}
|
||||
percentileScales={percentileScales}
|
||||
destinationDropdownPortal={destinationDropdownPortal}
|
||||
isGroupExpanded={isActiveFilterGroupExpanded}
|
||||
onToggleGroup={toggleActiveFilterGroup}
|
||||
aiFilterLoading={aiFilterLoading}
|
||||
aiFilterError={aiFilterError}
|
||||
aiFilterErrorType={aiFilterErrorType}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ interface TravelTimeCardProps {
|
|||
onTogglePin: () => void;
|
||||
onSetDestination: (slug: string, label: string, lat: number, lon: number) => void;
|
||||
onTimeRangeChange: (range: [number, number]) => void;
|
||||
onDragStart: () => void;
|
||||
onDragStart: (range: [number, number]) => void;
|
||||
onDragChange: (value: [number, number]) => void;
|
||||
onDragEnd: () => void;
|
||||
onToggleBest: () => void;
|
||||
|
|
@ -152,7 +152,7 @@ export function TravelTimeCard({
|
|||
step={1}
|
||||
value={[displayRange[0], displayRange[1]]}
|
||||
onValueChange={([min, max]) => onDragChange([min, max])}
|
||||
onPointerDown={() => onDragStart()}
|
||||
onPointerDown={() => onDragStart(displayRange)}
|
||||
onPointerUp={() => onDragEnd()}
|
||||
/>
|
||||
<div className="relative h-4 mt-1 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { Fragment } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import type { FeatureFilters, FeatureMeta } from '../../../types';
|
||||
import type { PercentileScale } from '../../../lib/format';
|
||||
import { groupFeaturesByCategory } from '../../../lib/features';
|
||||
import { getSpecificCrimeFeatureName, isSpecificCrimeFilterName } from '../../../lib/crime-filter';
|
||||
import {
|
||||
getElectionVoteShareFeatureName,
|
||||
|
|
@ -22,6 +23,9 @@ import { ElectionVoteShareFilterCard } from './ElectionVoteShareFilterCard';
|
|||
import { EnumFeatureFilterCard } from './EnumFeatureFilterCard';
|
||||
import { NumericFeatureFilterCard } from './NumericFeatureFilterCard';
|
||||
import { TravelTimeFilterCards } from './TravelTimeFilterCards';
|
||||
import { CollapsibleGroupHeader } from '../../ui/CollapsibleGroupHeader';
|
||||
|
||||
const TRANSPORT_GROUP = 'Transport';
|
||||
|
||||
interface ActiveFilterListProps {
|
||||
features: FeatureMeta[];
|
||||
|
|
@ -31,13 +35,14 @@ interface ActiveFilterListProps {
|
|||
dragValue: [number, number] | null;
|
||||
pinnedFeature: string | null;
|
||||
travelTimeEntries: TravelTimeEntry[];
|
||||
travelInsertIdx: number;
|
||||
filterImpacts?: Record<string, number>;
|
||||
percentileScales: Map<string, PercentileScale>;
|
||||
destinationDropdownPortal: boolean;
|
||||
isGroupExpanded: (name: string) => boolean;
|
||||
onToggleGroup: (name: string) => void;
|
||||
onFilterChange: (name: string, value: [number, number] | string[]) => void;
|
||||
onRemoveFilter: (name: string) => void;
|
||||
onDragStart: (name: string) => void;
|
||||
onDragStart: (name: string, initialValue?: [number, number]) => void;
|
||||
onDragChange: (value: [number, number]) => void;
|
||||
onDragEnd: () => void;
|
||||
onTogglePin: (name: string) => void;
|
||||
|
|
@ -63,10 +68,11 @@ export function ActiveFilterList({
|
|||
dragValue,
|
||||
pinnedFeature,
|
||||
travelTimeEntries,
|
||||
travelInsertIdx,
|
||||
filterImpacts,
|
||||
percentileScales,
|
||||
destinationDropdownPortal,
|
||||
isGroupExpanded,
|
||||
onToggleGroup,
|
||||
onFilterChange,
|
||||
onRemoveFilter,
|
||||
onDragStart,
|
||||
|
|
@ -99,194 +105,208 @@ export function ActiveFilterList({
|
|||
/>
|
||||
);
|
||||
|
||||
const groupedFeatures = useMemo(() => {
|
||||
const groups = groupFeaturesByCategory(enabledFeatureList);
|
||||
const transportGroup = groups.find((group) => group.name === TRANSPORT_GROUP);
|
||||
const otherGroups = groups.filter((group) => group.name !== TRANSPORT_GROUP);
|
||||
|
||||
if (transportGroup) return [transportGroup, ...otherGroups];
|
||||
if (travelTimeEntries.length > 0)
|
||||
return [{ name: TRANSPORT_GROUP, features: [] }, ...otherGroups];
|
||||
return otherGroups;
|
||||
}, [enabledFeatureList, travelTimeEntries.length]);
|
||||
|
||||
const renderFeatureCard = (feature: FeatureMeta) => {
|
||||
if (isSchoolFilterName(feature.name)) {
|
||||
const schoolBackendName = getSchoolBackendFeatureName(feature.name);
|
||||
return (
|
||||
<SchoolFilterCard
|
||||
key={feature.name}
|
||||
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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSpecificCrimeFilterName(feature.name)) {
|
||||
const specificCrimeBackendName = getSpecificCrimeFeatureName(feature.name);
|
||||
return (
|
||||
<SpecificCrimeFilterCard
|
||||
key={feature.name}
|
||||
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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isElectionVoteShareFilterName(feature.name)) {
|
||||
const electionVoteShareBackendName = getElectionVoteShareFeatureName(feature.name);
|
||||
return (
|
||||
<ElectionVoteShareFilterCard
|
||||
key={feature.name}
|
||||
features={features}
|
||||
voteShareFeature={feature}
|
||||
filters={filters}
|
||||
activeFeature={activeFeature}
|
||||
dragValue={dragValue}
|
||||
pinnedFeature={pinnedFeature}
|
||||
filterImpact={
|
||||
electionVoteShareBackendName ? filterImpacts?.[electionVoteShareBackendName] : undefined
|
||||
}
|
||||
percentileScale={
|
||||
electionVoteShareBackendName
|
||||
? percentileScales.get(electionVoteShareBackendName)
|
||||
: undefined
|
||||
}
|
||||
onFilterChange={onFilterChange}
|
||||
onDragStart={onDragStart}
|
||||
onDragChange={onDragChange}
|
||||
onDragEnd={onDragEnd}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={onShowInfo}
|
||||
onRemove={() => onRemoveFilter(feature.name)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEthnicityFilterName(feature.name)) {
|
||||
const ethnicityBackendName = getEthnicityFeatureName(feature.name);
|
||||
return (
|
||||
<EthnicityFilterCard
|
||||
key={feature.name}
|
||||
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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPoiDistanceFilterName(feature.name)) {
|
||||
const poiBackendName = getPoiDistanceFeatureName(feature.name);
|
||||
return (
|
||||
<PoiDistanceFilterCard
|
||||
key={feature.name}
|
||||
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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return feature.type === 'enum' ? (
|
||||
<EnumFeatureFilterCard
|
||||
key={feature.name}
|
||||
feature={feature}
|
||||
filters={filters}
|
||||
pinnedFeature={pinnedFeature}
|
||||
filterImpact={filterImpacts?.[feature.name]}
|
||||
onFilterChange={onFilterChange}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={onShowInfo}
|
||||
onRemoveFilter={onRemoveFilter}
|
||||
/>
|
||||
) : (
|
||||
<NumericFeatureFilterCard
|
||||
key={feature.name}
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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 (isElectionVoteShareFilterName(feature.name)) {
|
||||
const electionVoteShareBackendName = getElectionVoteShareFeatureName(feature.name);
|
||||
return (
|
||||
<Fragment key={feature.name}>
|
||||
{insertTravelCards && travelCards}
|
||||
<ElectionVoteShareFilterCard
|
||||
features={features}
|
||||
voteShareFeature={feature}
|
||||
filters={filters}
|
||||
activeFeature={activeFeature}
|
||||
dragValue={dragValue}
|
||||
pinnedFeature={pinnedFeature}
|
||||
filterImpact={
|
||||
electionVoteShareBackendName
|
||||
? filterImpacts?.[electionVoteShareBackendName]
|
||||
: undefined
|
||||
}
|
||||
percentileScale={
|
||||
electionVoteShareBackendName
|
||||
? percentileScales.get(electionVoteShareBackendName)
|
||||
: undefined
|
||||
}
|
||||
onFilterChange={onFilterChange}
|
||||
onDragStart={onDragStart}
|
||||
onDragChange={onDragChange}
|
||||
onDragEnd={onDragEnd}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={onShowInfo}
|
||||
onRemove={() => onRemoveFilter(feature.name)}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEthnicityFilterName(feature.name)) {
|
||||
const ethnicityBackendName = getEthnicityFeatureName(feature.name);
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
<div>
|
||||
{groupedFeatures.map((group) => {
|
||||
const travelCount = group.name === TRANSPORT_GROUP ? travelTimeEntries.length : 0;
|
||||
const count = group.features.length + travelCount;
|
||||
if (count === 0) return null;
|
||||
const expanded = isGroupExpanded(group.name);
|
||||
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}
|
||||
/>
|
||||
<div key={group.name} className="shrink-0">
|
||||
<CollapsibleGroupHeader
|
||||
name={group.name}
|
||||
expanded={expanded}
|
||||
onToggle={() => onToggleGroup(group.name)}
|
||||
className="sticky top-0 z-10 px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
>
|
||||
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">{count}</span>
|
||||
</CollapsibleGroupHeader>
|
||||
{expanded && (
|
||||
<div className="px-2 py-1.5 space-y-3.5">
|
||||
{group.name === TRANSPORT_GROUP && travelCards}
|
||||
{group.features.map((feature) => renderFeatureCard(feature))}
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{travelInsertIdx >= enabledFeatureList.length && travelCards}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,10 +22,11 @@ interface ActiveFiltersPanelProps {
|
|||
dragValue: [number, number] | null;
|
||||
pinnedFeature: string | null;
|
||||
travelTimeEntries: TravelTimeEntry[];
|
||||
travelInsertIdx: number;
|
||||
filterImpacts?: Record<string, number>;
|
||||
percentileScales: Map<string, PercentileScale>;
|
||||
destinationDropdownPortal: boolean;
|
||||
isGroupExpanded: (name: string) => boolean;
|
||||
onToggleGroup: (name: string) => void;
|
||||
aiFilterLoading: boolean;
|
||||
aiFilterError: string | null;
|
||||
aiFilterErrorType: AiFilterErrorType | null;
|
||||
|
|
@ -39,7 +40,7 @@ interface ActiveFiltersPanelProps {
|
|||
onLoginRequired: () => void;
|
||||
onFilterChange: (name: string, value: [number, number] | string[]) => void;
|
||||
onRemoveFilter: (name: string) => void;
|
||||
onDragStart: (name: string) => void;
|
||||
onDragStart: (name: string, initialValue?: [number, number]) => void;
|
||||
onDragChange: (value: [number, number]) => void;
|
||||
onDragEnd: () => void;
|
||||
onTogglePin: (name: string) => void;
|
||||
|
|
@ -70,10 +71,11 @@ export function ActiveFiltersPanel({
|
|||
dragValue,
|
||||
pinnedFeature,
|
||||
travelTimeEntries,
|
||||
travelInsertIdx,
|
||||
filterImpacts,
|
||||
percentileScales,
|
||||
destinationDropdownPortal,
|
||||
isGroupExpanded,
|
||||
onToggleGroup,
|
||||
aiFilterLoading,
|
||||
aiFilterError,
|
||||
aiFilterErrorType,
|
||||
|
|
@ -108,14 +110,14 @@ export function ActiveFiltersPanel({
|
|||
>
|
||||
<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"
|
||||
className="shrink-0 flex items-center justify-between border-b border-l-4 border-warm-200 border-l-teal-500 bg-white px-3 py-2 cursor-pointer shadow-sm hover:bg-warm-50 dark:border-navy-700 dark:border-l-teal-400 dark:bg-navy-900 dark:hover:bg-navy-800"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-teal-700 dark:text-teal-400">
|
||||
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">
|
||||
{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">
|
||||
<span className="rounded-full bg-teal-50 px-1.5 py-0.5 text-xs font-medium text-teal-700 ring-1 ring-teal-100 dark:bg-teal-900/30 dark:text-teal-300 dark:ring-teal-800">
|
||||
{badgeCount}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -135,14 +137,14 @@ export function ActiveFiltersPanel({
|
|||
onClearAllClick();
|
||||
}
|
||||
}}
|
||||
className="text-xs text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-200 underline"
|
||||
className="text-xs text-teal-700 underline hover:text-teal-900 dark:text-teal-300 dark:hover:text-teal-200"
|
||||
>
|
||||
{t('filters.clearAll')}
|
||||
</span>
|
||||
)}
|
||||
<ChevronIcon
|
||||
direction={collapsed ? 'down' : 'up'}
|
||||
className="w-4 h-4 text-warm-400 dark:text-warm-500"
|
||||
className="w-4 h-4 text-warm-500 dark:text-warm-300"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
|
@ -182,10 +184,11 @@ export function ActiveFiltersPanel({
|
|||
dragValue={dragValue}
|
||||
pinnedFeature={pinnedFeature}
|
||||
travelTimeEntries={travelTimeEntries}
|
||||
travelInsertIdx={travelInsertIdx}
|
||||
filterImpacts={filterImpacts}
|
||||
percentileScales={percentileScales}
|
||||
destinationDropdownPortal={destinationDropdownPortal}
|
||||
isGroupExpanded={isGroupExpanded}
|
||||
onToggleGroup={onToggleGroup}
|
||||
onFilterChange={onFilterChange}
|
||||
onRemoveFilter={onRemoveFilter}
|
||||
onDragStart={onDragStart}
|
||||
|
|
|
|||
|
|
@ -110,14 +110,14 @@ export function AddFilterPanel({
|
|||
>
|
||||
<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"
|
||||
className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-navy-800 dark:border-navy-700 bg-navy-900 dark:bg-navy-900 cursor-pointer hover:bg-navy-800 dark:hover:bg-navy-800"
|
||||
>
|
||||
<span className="text-sm font-semibold text-teal-700 dark:text-teal-400">
|
||||
<span className="text-sm font-semibold text-warm-100 dark:text-warm-100">
|
||||
{t('filters.addFilter')}
|
||||
</span>
|
||||
<ChevronIcon
|
||||
direction={collapsed ? 'down' : 'up'}
|
||||
className="w-4 h-4 text-warm-400 dark:text-warm-500"
|
||||
className="w-4 h-4 text-warm-300 dark:text-warm-300"
|
||||
/>
|
||||
</button>
|
||||
{(!collapsed || !isLicensed) && (
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export function ElectionVoteShareFilterCard({
|
|||
filterImpact?: number;
|
||||
percentileScale?: PercentileScale;
|
||||
onFilterChange: (name: string, value: [number, number] | string[]) => void;
|
||||
onDragStart: (name: string) => void;
|
||||
onDragStart: (name: string, initialValue?: [number, number]) => void;
|
||||
onDragChange: (value: [number, number]) => void;
|
||||
onDragEnd: () => void;
|
||||
onTogglePin: (name: string) => void;
|
||||
|
|
@ -201,7 +201,7 @@ export function ElectionVoteShareFilterCard({
|
|||
max >= (selectedFeature.max ?? dataMax) ? dataMax : max,
|
||||
])
|
||||
}
|
||||
onPointerDown={() => onDragStart(voteShareFeature.name)}
|
||||
onPointerDown={() => onDragStart(voteShareFeature.name, displayValue)}
|
||||
onPointerUp={() => onDragEnd()}
|
||||
/>
|
||||
<SliderLabels
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export function EthnicityFilterCard({
|
|||
filterImpact?: number;
|
||||
percentileScale?: PercentileScale;
|
||||
onFilterChange: (name: string, value: [number, number] | string[]) => void;
|
||||
onDragStart: (name: string) => void;
|
||||
onDragStart: (name: string, initialValue?: [number, number]) => void;
|
||||
onDragChange: (value: [number, number]) => void;
|
||||
onDragEnd: () => void;
|
||||
onTogglePin: (name: string) => void;
|
||||
|
|
@ -197,7 +197,7 @@ export function EthnicityFilterCard({
|
|||
max >= (selectedFeature.max ?? dataMax) ? dataMax : max,
|
||||
])
|
||||
}
|
||||
onPointerDown={() => onDragStart(ethnicityFeature.name)}
|
||||
onPointerDown={() => onDragStart(ethnicityFeature.name, displayValue)}
|
||||
onPointerUp={() => onDragEnd()}
|
||||
/>
|
||||
<SliderLabels
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ interface NumericFeatureFilterCardProps {
|
|||
filterImpact?: number;
|
||||
percentileScale?: PercentileScale;
|
||||
onFilterChange: (name: string, value: [number, number] | string[]) => void;
|
||||
onDragStart: (name: string) => void;
|
||||
onDragStart: (name: string, initialValue?: [number, number]) => void;
|
||||
onDragChange: (value: [number, number]) => void;
|
||||
onDragEnd: () => void;
|
||||
onTogglePin: (name: string) => void;
|
||||
|
|
@ -114,7 +114,7 @@ export function NumericFeatureFilterCard({
|
|||
max >= feature.max! ? (hist?.max ?? feature.max!) : max,
|
||||
])
|
||||
}
|
||||
onPointerDown={() => onDragStart(feature.name)}
|
||||
onPointerDown={() => onDragStart(feature.name, displayValue)}
|
||||
onPointerUp={() => onDragEnd()}
|
||||
/>
|
||||
<SliderLabels
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export function PoiDistanceFilterCard({
|
|||
filterImpact?: number;
|
||||
percentileScale?: PercentileScale;
|
||||
onFilterChange: (name: string, value: [number, number] | string[]) => void;
|
||||
onDragStart: (name: string) => void;
|
||||
onDragStart: (name: string, initialValue?: [number, number]) => void;
|
||||
onDragChange: (value: [number, number]) => void;
|
||||
onDragEnd: () => void;
|
||||
onTogglePin: (name: string) => void;
|
||||
|
|
@ -184,7 +184,7 @@ export function PoiDistanceFilterCard({
|
|||
max >= sliderMax ? sliderMax : max,
|
||||
])
|
||||
}
|
||||
onPointerDown={() => onDragStart(poiFeature.name)}
|
||||
onPointerDown={() => onDragStart(poiFeature.name, displayValue)}
|
||||
onPointerUp={() => onDragEnd()}
|
||||
/>
|
||||
<SliderLabels
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export function SchoolFilterCard({
|
|||
pinnedFeature: string | null;
|
||||
filterImpact?: number;
|
||||
onFilterChange: (name: string, value: [number, number] | string[]) => void;
|
||||
onDragStart: (name: string) => void;
|
||||
onDragStart: (name: string, initialValue?: [number, number]) => void;
|
||||
onDragChange: (value: [number, number]) => void;
|
||||
onDragEnd: () => void;
|
||||
onTogglePin: (name: string) => void;
|
||||
|
|
@ -216,7 +216,7 @@ export function SchoolFilterCard({
|
|||
max >= (backendFeature?.max ?? dataMax) ? dataMax : max,
|
||||
])
|
||||
}
|
||||
onPointerDown={() => onDragStart(schoolFeature.name)}
|
||||
onPointerDown={() => onDragStart(schoolFeature.name, displayValue)}
|
||||
onPointerUp={() => onDragEnd()}
|
||||
/>
|
||||
<SliderLabels
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export function SpecificCrimeFilterCard({
|
|||
filterImpact?: number;
|
||||
percentileScale?: PercentileScale;
|
||||
onFilterChange: (name: string, value: [number, number] | string[]) => void;
|
||||
onDragStart: (name: string) => void;
|
||||
onDragStart: (name: string, initialValue?: [number, number]) => void;
|
||||
onDragChange: (value: [number, number]) => void;
|
||||
onDragEnd: () => void;
|
||||
onTogglePin: (name: string) => void;
|
||||
|
|
@ -197,7 +197,7 @@ export function SpecificCrimeFilterCard({
|
|||
max >= (selectedFeature.max ?? dataMax) ? dataMax : max,
|
||||
])
|
||||
}
|
||||
onPointerDown={() => onDragStart(crimeFeature.name)}
|
||||
onPointerDown={() => onDragStart(crimeFeature.name, displayValue)}
|
||||
onPointerUp={() => onDragEnd()}
|
||||
/>
|
||||
<SliderLabels
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ interface TravelTimeFilterCardsProps {
|
|||
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
|
||||
onTravelTimeDragEnd: (index: number) => void;
|
||||
onTravelTimeToggleBest: (index: number) => void;
|
||||
onDragStart: (name: string) => void;
|
||||
onDragStart: (name: string, initialValue?: [number, number]) => void;
|
||||
onDragChange: (value: [number, number]) => void;
|
||||
}
|
||||
|
||||
|
|
@ -60,7 +60,7 @@ export function TravelTimeFilterCards({
|
|||
onTravelTimeSetDestination(index, slug, label, lat, lon)
|
||||
}
|
||||
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
|
||||
onDragStart={() => onDragStart(fieldKey)}
|
||||
onDragStart={(range) => onDragStart(fieldKey, range)}
|
||||
onDragChange={onDragChange}
|
||||
onDragEnd={() => onTravelTimeDragEnd(index)}
|
||||
onToggleBest={() => onTravelTimeToggleBest(index)}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../
|
|||
import { trackEvent } from '../../../lib/analytics';
|
||||
import type { ExportNotice, ExportState } from './types';
|
||||
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
|
||||
import { buildTravelParam } from '../../../lib/travel-params';
|
||||
import { buildTravelParam, dedupeTravelTimeEntries } from '../../../lib/travel-params';
|
||||
|
||||
const EXPORT_FILE_NAME = 'perfect-postcode-export.xlsx';
|
||||
const EXPORT_TIMEOUT_MS = 150_000;
|
||||
|
|
@ -68,7 +68,7 @@ function triggerExportDownload(blob: Blob, fileName: string): void {
|
|||
}
|
||||
|
||||
function appendTravelStateParams(params: URLSearchParams, entries: TravelTimeEntry[]): void {
|
||||
for (const entry of entries) {
|
||||
for (const entry of dedupeTravelTimeEntries(entries)) {
|
||||
if (!entry.slug) continue;
|
||||
let value = `${entry.mode}:${entry.slug}:${encodeURIComponent(entry.label)}`;
|
||||
if (entry.useBest) value += ':b';
|
||||
|
|
|
|||
|
|
@ -27,21 +27,24 @@ describe('useFilters', () => {
|
|||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragStart('price');
|
||||
result.current.handleDragStart('price', [0, 100]);
|
||||
});
|
||||
|
||||
expect(result.current.activeFeature).toBe('price');
|
||||
expect(result.current.viewSource).toBe('drag');
|
||||
expect(result.current.dragValue).toEqual([0, 100]);
|
||||
expect(result.current.filterRange).toEqual([0, 100]);
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragEnd();
|
||||
});
|
||||
|
||||
expect(result.current.activeFeature).toBeNull();
|
||||
expect(result.current.dragValue).toBeNull();
|
||||
expect(result.current.filters.price).toEqual([0, 100]);
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragStart('price');
|
||||
result.current.handleDragStart('price', [0, 100]);
|
||||
result.current.handleDragChange([10, 90]);
|
||||
});
|
||||
|
||||
|
|
@ -55,4 +58,29 @@ describe('useFilters', () => {
|
|||
expect(result.current.activeFeature).toBeNull();
|
||||
expect(result.current.filters.price).toEqual([10, 90]);
|
||||
});
|
||||
|
||||
it('uses the provided initial range for drag-only feature keys', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useFilters({
|
||||
initialFilters: {},
|
||||
features,
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragStart('tt_car_station', [15, 45]);
|
||||
});
|
||||
|
||||
expect(result.current.activeFeature).toBe('tt_car_station');
|
||||
expect(result.current.dragValue).toEqual([15, 45]);
|
||||
expect(result.current.filterRange).toEqual([15, 45]);
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragEnd();
|
||||
});
|
||||
|
||||
expect(result.current.activeFeature).toBeNull();
|
||||
expect(result.current.dragValue).toBeNull();
|
||||
expect(result.current.filters).toEqual({});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -416,10 +416,12 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
}, []);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(name: string) => {
|
||||
(name: string, initialValue?: [number, number]) => {
|
||||
const meta = features.find((f) => f.name === name);
|
||||
if (meta?.type === 'enum') return;
|
||||
pendingDragRef.current = name;
|
||||
setDragValue(initialValue ?? null);
|
||||
dragValueRef.current = initialValue ?? null;
|
||||
setActiveFeature(name);
|
||||
},
|
||||
[features]
|
||||
|
|
@ -440,6 +442,8 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
// Click without drag — no filter value was changed, just clear preview state.
|
||||
pendingDragRef.current = null;
|
||||
setActiveFeature(null);
|
||||
setDragValue(null);
|
||||
dragValueRef.current = null;
|
||||
return;
|
||||
}
|
||||
const af = dragActiveRef.current;
|
||||
|
|
@ -458,6 +462,8 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
if (pendingDragRef.current) {
|
||||
pendingDragRef.current = null;
|
||||
setActiveFeature(null);
|
||||
setDragValue(null);
|
||||
dragValueRef.current = null;
|
||||
return null;
|
||||
}
|
||||
const dv = dragValueRef.current;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { getPoiDistanceFeatureName } from '../lib/poi-distance-filter';
|
|||
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
|
||||
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
|
||||
import { type TravelTimeEntry } from './useTravelTime';
|
||||
import { buildTravelParam as serializeTravelParam } from '../lib/travel-params';
|
||||
|
||||
/** Return the p-th percentile (0–100) from a sorted array via linear interpolation. */
|
||||
function percentile(sorted: number[], p: number): number {
|
||||
|
|
@ -70,12 +71,18 @@ export function useMapData({
|
|||
longitude: number;
|
||||
zoom: number;
|
||||
} | null>(null);
|
||||
const [currentVisibleView, setCurrentVisibleView] = useState<{
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
zoom: number;
|
||||
} | null>(null);
|
||||
const [licenseRequired, setLicenseRequired] = useState(false);
|
||||
const [freeZone, setFreeZone] = useState<Bounds | null>(null);
|
||||
|
||||
// Drag preview state
|
||||
const [dragHexData, setDragHexData] = useState<HexagonData[] | null>(null);
|
||||
const [dragPostcodeData, setDragPostcodeData] = useState<PostcodeFeature[] | null>(null);
|
||||
const [dragDataKey, setDragDataKey] = useState<string>('');
|
||||
const dragFeatureRef = useRef<string | null>(null);
|
||||
const dragAbortRef = useRef<AbortController | null>(null);
|
||||
const activeFeatureRef = useRef<string | null>(null);
|
||||
|
|
@ -119,32 +126,19 @@ export function useMapData({
|
|||
);
|
||||
const filtersParam = useMemo(() => buildFilterParam(), [buildFilterParam]);
|
||||
|
||||
// Build the travel param string from entries with destinations.
|
||||
// Format: mode:slug[:best][:min:max] — server filters rows outside [min,max].
|
||||
// When excludeFieldKey is set, that entry uses a wide range (0:1440) instead of
|
||||
// the committed range. This still filters out rows with no travel data (the server
|
||||
// skips rows where minutes=None when any range is set) while including all actual values.
|
||||
// Format: mode:slug[:best][:min:max]. For drag preview, the active travel
|
||||
// filter uses an unbounded range so rows with travel data stay visible.
|
||||
const buildTravelParam = useCallback(
|
||||
(excludeFieldKey?: string): string => {
|
||||
const segments: string[] = [];
|
||||
for (const entry of travelTimeEntries) {
|
||||
if (!entry.slug) continue;
|
||||
let seg = `${entry.mode}:${entry.slug}`;
|
||||
if (entry.useBest) seg += ':best';
|
||||
const isExcluded = excludeFieldKey === `tt_${entry.mode}_${entry.slug}`;
|
||||
if (isExcluded) {
|
||||
seg += ':0:1440';
|
||||
} else if (entry.timeRange) {
|
||||
seg += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
|
||||
}
|
||||
segments.push(seg);
|
||||
}
|
||||
return segments.join('|');
|
||||
},
|
||||
(excludeFieldKey?: string): string =>
|
||||
serializeTravelParam(travelTimeEntries, excludeFieldKey, true),
|
||||
[travelTimeEntries]
|
||||
);
|
||||
|
||||
const travelParam = useMemo(() => buildTravelParam(), [buildTravelParam]);
|
||||
const filterStateKey = useMemo(
|
||||
() => `${filtersParam}|${travelParam}`,
|
||||
[filtersParam, travelParam]
|
||||
);
|
||||
const boundsParam = useMemo(
|
||||
() => (bounds ? `${bounds.south},${bounds.west},${bounds.north},${bounds.east}` : ''),
|
||||
[bounds]
|
||||
|
|
@ -176,28 +170,13 @@ export function useMapData({
|
|||
]
|
||||
);
|
||||
const [loadedDataKey, setLoadedDataKey] = useState<string>('');
|
||||
|
||||
// Keep activeFeatureRef in sync
|
||||
useEffect(() => {
|
||||
activeFeatureRef.current = activeFeature;
|
||||
}, [activeFeature]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeFeature) return;
|
||||
latestDragRequestKeyRef.current = '';
|
||||
dragFeatureRef.current = null;
|
||||
setDragHexData(null);
|
||||
setDragPostcodeData(null);
|
||||
}, [activeFeature]);
|
||||
|
||||
// Drag prefetch: when activeFeature starts, fetch data excluding that filter.
|
||||
// For regular filters: excludes the filter from the filter string.
|
||||
// For travel time: excludes the time range from that entry's travel param segment.
|
||||
useEffect(() => {
|
||||
if (!activeFeature || !bounds) return;
|
||||
|
||||
if (dragAbortRef.current) dragAbortRef.current.abort();
|
||||
dragAbortRef.current = new AbortController();
|
||||
const previousDragStateRef = useRef<{ activeFeature: string | null; filterStateKey: string }>({
|
||||
activeFeature: null,
|
||||
filterStateKey,
|
||||
});
|
||||
const resetPreviewScaleAfterSliderRef = useRef(false);
|
||||
const activeDragRequest = useMemo(() => {
|
||||
if (!activeFeature || !bounds) return null;
|
||||
|
||||
const filtersStr = buildFilterString(filters, features, activeFeature);
|
||||
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
||||
|
|
@ -218,7 +197,58 @@ export function useMapData({
|
|||
viewFeatureIsEnum && dataViewFeature ? dataViewFeature : '',
|
||||
shareCode ?? '',
|
||||
].join('|');
|
||||
|
||||
return { boundsStr, dragTravelParam, fieldsParam, filtersStr, requestKey };
|
||||
}, [
|
||||
activeFeature,
|
||||
bounds,
|
||||
buildTravelParam,
|
||||
dataViewFeature,
|
||||
features,
|
||||
filters,
|
||||
getBackendFeatureName,
|
||||
resolution,
|
||||
shareCode,
|
||||
travelParam,
|
||||
usePostcodeView,
|
||||
viewFeatureIsEnum,
|
||||
]);
|
||||
|
||||
// Keep activeFeatureRef in sync
|
||||
useEffect(() => {
|
||||
activeFeatureRef.current = activeFeature;
|
||||
}, [activeFeature]);
|
||||
|
||||
useEffect(() => {
|
||||
const previous = previousDragStateRef.current;
|
||||
if (!activeFeature && previous.activeFeature && previous.filterStateKey !== filterStateKey) {
|
||||
resetPreviewScaleAfterSliderRef.current = true;
|
||||
}
|
||||
previousDragStateRef.current = { activeFeature, filterStateKey };
|
||||
}, [activeFeature, filterStateKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeFeature) return;
|
||||
latestDragRequestKeyRef.current = '';
|
||||
dragFeatureRef.current = null;
|
||||
setDragDataKey('');
|
||||
setDragHexData(null);
|
||||
setDragPostcodeData(null);
|
||||
}, [activeFeature]);
|
||||
|
||||
// Drag prefetch: when activeFeature starts, fetch data excluding that filter.
|
||||
// For regular filters: excludes the filter from the filter string.
|
||||
// For travel time: excludes the time range from that entry's travel param segment.
|
||||
useEffect(() => {
|
||||
if (!activeFeature || !activeDragRequest) return;
|
||||
|
||||
if (dragAbortRef.current) dragAbortRef.current.abort();
|
||||
dragAbortRef.current = new AbortController();
|
||||
|
||||
const { boundsStr, dragTravelParam, fieldsParam, filtersStr, requestKey } = activeDragRequest;
|
||||
latestDragRequestKeyRef.current = requestKey;
|
||||
setDragDataKey('');
|
||||
dragFeatureRef.current = null;
|
||||
|
||||
if (usePostcodeView) {
|
||||
const params = new URLSearchParams({ bounds: boundsStr });
|
||||
|
|
@ -234,6 +264,7 @@ export function useMapData({
|
|||
if (latestDragRequestKeyRef.current !== requestKey) return;
|
||||
setDragPostcodeData(json.features);
|
||||
setDragHexData(null);
|
||||
setDragDataKey(requestKey);
|
||||
dragFeatureRef.current = activeFeature;
|
||||
})
|
||||
.catch((err) => logNonAbortError('Failed to fetch drag postcode data', err));
|
||||
|
|
@ -254,6 +285,7 @@ export function useMapData({
|
|||
if (latestDragRequestKeyRef.current !== requestKey) return;
|
||||
setDragHexData(json.features);
|
||||
setDragPostcodeData(null);
|
||||
setDragDataKey(requestKey);
|
||||
dragFeatureRef.current = activeFeature;
|
||||
})
|
||||
.catch((err) => logNonAbortError('Failed to fetch drag hex data', err));
|
||||
|
|
@ -270,15 +302,9 @@ export function useMapData({
|
|||
};
|
||||
}, [
|
||||
activeFeature,
|
||||
bounds,
|
||||
resolution,
|
||||
filters,
|
||||
features,
|
||||
usePostcodeView,
|
||||
travelParam,
|
||||
buildTravelParam,
|
||||
activeDragRequest,
|
||||
dataViewFeature,
|
||||
getBackendFeatureName,
|
||||
usePostcodeView,
|
||||
viewFeatureIsEnum,
|
||||
shareCode,
|
||||
]);
|
||||
|
|
@ -386,6 +412,7 @@ export function useMapData({
|
|||
if (!activeFeatureRef.current) {
|
||||
setDragHexData(null);
|
||||
setDragPostcodeData(null);
|
||||
setDragDataKey('');
|
||||
dragFeatureRef.current = null;
|
||||
}
|
||||
setLoading(false);
|
||||
|
|
@ -420,18 +447,16 @@ export function useMapData({
|
|||
shareCode,
|
||||
]);
|
||||
|
||||
// Use drag data when it matches the current view feature, otherwise fall back to rawData
|
||||
const data =
|
||||
(activeFeature && viewFeature && dragFeatureRef.current === viewFeature ? dragHexData : null) ??
|
||||
rawData;
|
||||
const effectivePostcodeData =
|
||||
(activeFeature && viewFeature && dragFeatureRef.current === viewFeature
|
||||
? dragPostcodeData
|
||||
: null) ?? postcodeData;
|
||||
// Use drag data only when it matches the current view feature and request key.
|
||||
const hasMatchingDragData =
|
||||
Boolean(activeFeature && viewFeature && activeDragRequest) &&
|
||||
dragFeatureRef.current === viewFeature &&
|
||||
dragDataKey === activeDragRequest?.requestKey;
|
||||
const data = (hasMatchingDragData ? dragHexData : null) ?? rawData;
|
||||
const effectivePostcodeData = (hasMatchingDragData ? dragPostcodeData : null) ?? postcodeData;
|
||||
|
||||
// Compute p5/p95 from committed data for the viewed feature.
|
||||
// Always uses rawData/postcodeData (not drag preview data) so the color
|
||||
// scale stays stable while dragging a filter slider.
|
||||
// Compute p5/p95 from the data currently being drawn. During slider drags
|
||||
// this uses the drag-preview data so the colour scale resets to that preview.
|
||||
const dataRange = useMemo((): [number, number] | null => {
|
||||
if (!dataViewFeature) return null;
|
||||
|
||||
|
|
@ -445,8 +470,8 @@ export function useMapData({
|
|||
const vals: number[] = [];
|
||||
|
||||
if (usePostcodeView) {
|
||||
if (postcodeData.length === 0) return null;
|
||||
for (const feat of postcodeData) {
|
||||
if (effectivePostcodeData.length === 0) return null;
|
||||
for (const feat of effectivePostcodeData) {
|
||||
if (bounds) {
|
||||
const [lng, lat] = feat.properties.centroid as [number, number];
|
||||
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east)
|
||||
|
|
@ -456,8 +481,8 @@ export function useMapData({
|
|||
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
|
||||
}
|
||||
} else {
|
||||
if (rawData.length === 0) return null;
|
||||
for (const item of rawData) {
|
||||
if (data.length === 0) return null;
|
||||
for (const item of data) {
|
||||
if (bounds) {
|
||||
const { lat, lon } = item;
|
||||
if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
|
||||
|
|
@ -474,7 +499,7 @@ export function useMapData({
|
|||
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
|
||||
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
|
||||
];
|
||||
}, [dataViewFeature, rawData, postcodeData, usePostcodeView, features, bounds]);
|
||||
}, [dataViewFeature, data, effectivePostcodeData, usePostcodeView, features, bounds]);
|
||||
|
||||
// Live color range for the legend and hex coloring.
|
||||
const liveColorRange = useMemo((): [number, number] | null => {
|
||||
|
|
@ -505,7 +530,10 @@ export function useMapData({
|
|||
|
||||
useEffect(() => {
|
||||
setFrozenPreviewRange((prev) => {
|
||||
if (!pinnedDataViewFeature) return prev ? null : prev;
|
||||
if (!pinnedDataViewFeature) {
|
||||
resetPreviewScaleAfterSliderRef.current = false;
|
||||
return prev ? null : prev;
|
||||
}
|
||||
return prev?.feature === pinnedDataViewFeature ? prev : null;
|
||||
});
|
||||
}, [pinnedDataViewFeature]);
|
||||
|
|
@ -524,11 +552,13 @@ export function useMapData({
|
|||
: null;
|
||||
if (!rangeToFreeze) return;
|
||||
|
||||
const resetAfterSlider = resetPreviewScaleAfterSliderRef.current;
|
||||
setFrozenPreviewRange((prev) =>
|
||||
prev?.feature === pinnedDataViewFeature
|
||||
? prev
|
||||
: { feature: pinnedDataViewFeature, range: rangeToFreeze }
|
||||
resetAfterSlider || prev?.feature !== pinnedDataViewFeature
|
||||
? { feature: pinnedDataViewFeature, range: rangeToFreeze }
|
||||
: prev
|
||||
);
|
||||
if (resetAfterSlider) resetPreviewScaleAfterSliderRef.current = false;
|
||||
}, [
|
||||
dataRange,
|
||||
dataRequestKey,
|
||||
|
|
@ -583,6 +613,8 @@ export function useMapData({
|
|||
zoom: newZoom,
|
||||
latitude,
|
||||
longitude,
|
||||
visibleLatitude,
|
||||
visibleLongitude,
|
||||
}: ViewChangeParams) => {
|
||||
const boundsKey = `${newBounds.south},${newBounds.west},${newBounds.north},${newBounds.east},${newRes}`;
|
||||
if (boundsKey !== prevBoundsRef.current) {
|
||||
|
|
@ -592,6 +624,11 @@ export function useMapData({
|
|||
}
|
||||
setZoom(newZoom);
|
||||
setCurrentView({ latitude, longitude, zoom: newZoom });
|
||||
setCurrentVisibleView({
|
||||
latitude: visibleLatitude ?? latitude,
|
||||
longitude: visibleLongitude ?? longitude,
|
||||
zoom: newZoom,
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
|
@ -599,6 +636,7 @@ export function useMapData({
|
|||
const setInitialView = useCallback(
|
||||
(view: { latitude: number; longitude: number; zoom: number }) => {
|
||||
setCurrentView(view);
|
||||
setCurrentVisibleView(view);
|
||||
setZoom(view.zoom);
|
||||
},
|
||||
[]
|
||||
|
|
@ -613,6 +651,7 @@ export function useMapData({
|
|||
loading,
|
||||
zoom,
|
||||
currentView,
|
||||
currentVisibleView,
|
||||
usePostcodeView,
|
||||
colorRange,
|
||||
canResetPreviewScale,
|
||||
|
|
|
|||
|
|
@ -65,4 +65,51 @@ describe('useTravelTime', () => {
|
|||
expect(result.current.entries).toEqual([replacement]);
|
||||
expect(result.current.activeEntries).toEqual([replacement]);
|
||||
});
|
||||
|
||||
it('deduplicates initial and replacement entries using the tightest range', () => {
|
||||
const wide: TravelTimeEntry = {
|
||||
mode: 'transit',
|
||||
slug: 'bank-tube-station',
|
||||
label: 'Bank',
|
||||
timeRange: [0, 60],
|
||||
useBest: false,
|
||||
};
|
||||
const tight: TravelTimeEntry = {
|
||||
mode: 'transit',
|
||||
slug: 'bank-tube-station',
|
||||
label: 'Bank',
|
||||
timeRange: [10, 45],
|
||||
useBest: false,
|
||||
};
|
||||
const replacement: TravelTimeEntry = {
|
||||
mode: 'transit',
|
||||
slug: 'bank-tube-station',
|
||||
label: 'Bank',
|
||||
timeRange: [20, 40],
|
||||
useBest: true,
|
||||
};
|
||||
const { result } = renderHook(() => useTravelTime({ entries: [wide, tight] }));
|
||||
|
||||
expect(result.current.entries).toEqual([
|
||||
{
|
||||
mode: 'transit',
|
||||
slug: 'bank-tube-station',
|
||||
label: 'Bank',
|
||||
timeRange: [10, 45],
|
||||
useBest: false,
|
||||
},
|
||||
]);
|
||||
|
||||
act(() => result.current.handleSetEntries([wide, replacement]));
|
||||
|
||||
expect(result.current.entries).toEqual([
|
||||
{
|
||||
mode: 'transit',
|
||||
slug: 'bank-tube-station',
|
||||
label: 'Bank',
|
||||
timeRange: [20, 40],
|
||||
useBest: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useState, useCallback, useMemo } from 'react';
|
|||
import type { ComponentType } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon } from '../components/ui/icons';
|
||||
import { dedupeTravelTimeEntries } from '../lib/travel-params';
|
||||
|
||||
export type TransportMode = 'car' | 'bicycle' | 'walking' | 'transit';
|
||||
|
||||
|
|
@ -75,7 +76,9 @@ export interface TravelTimeInitial {
|
|||
}
|
||||
|
||||
export function useTravelTime(initial?: TravelTimeInitial) {
|
||||
const [entries, setEntries] = useState<TravelTimeEntry[]>(initial?.entries ?? []);
|
||||
const [entries, setEntries] = useState<TravelTimeEntry[]>(() =>
|
||||
dedupeTravelTimeEntries(initial?.entries ?? [])
|
||||
);
|
||||
|
||||
const handleAddEntry = useCallback((mode: TransportMode) => {
|
||||
setEntries((prev) => [...prev, { mode, slug: '', label: '', timeRange: null, useBest: false }]);
|
||||
|
|
@ -87,26 +90,32 @@ export function useTravelTime(initial?: TravelTimeInitial) {
|
|||
|
||||
const handleSetDestination = useCallback((index: number, slug: string, label: string) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((entry, i) =>
|
||||
i === index ? { ...entry, slug, label, timeRange: slug ? [0, 120] : null } : entry
|
||||
dedupeTravelTimeEntries(
|
||||
prev.map((entry, i) =>
|
||||
i === index ? { ...entry, slug, label, timeRange: slug ? [0, 120] : null } : entry
|
||||
)
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleTimeRangeChange = useCallback((index: number, range: [number, number]) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((entry, i) => (i === index ? { ...entry, timeRange: range } : entry))
|
||||
dedupeTravelTimeEntries(
|
||||
prev.map((entry, i) => (i === index ? { ...entry, timeRange: range } : entry))
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleToggleBest = useCallback((index: number) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((entry, i) => (i === index ? { ...entry, useBest: !entry.useBest } : entry))
|
||||
dedupeTravelTimeEntries(
|
||||
prev.map((entry, i) => (i === index ? { ...entry, useBest: !entry.useBest } : entry))
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleSetEntries = useCallback((newEntries: TravelTimeEntry[]) => {
|
||||
setEntries(newEntries);
|
||||
setEntries(dedupeTravelTimeEntries(newEntries));
|
||||
}, []);
|
||||
|
||||
/** Entries that have a destination selected (slug is set) */
|
||||
|
|
|
|||
|
|
@ -829,10 +829,13 @@ const de: Translations = {
|
|||
showAllStatsFallback:
|
||||
'Wechseln Sie zu allen Immobilien, um dieses Gebiet ohne aktive Filter zu prüfen.',
|
||||
showAllStats: 'Alle Immobilien anzeigen',
|
||||
closestBlockingFilters: 'Nächste Filter, die dieses Gebiet ausschließen',
|
||||
closestBlockingFilters: 'Nächste Änderungen, um dieses Gebiet einzuschließen',
|
||||
lowerMinTo: 'Minimum auf {{value}} senken',
|
||||
raiseMaxTo: 'Maximum auf {{value}} erhöhen',
|
||||
allowCategory: '{{value}} zulassen',
|
||||
missingFilterValue:
|
||||
'Kein Wert für diesen Filter; entfernen Sie ihn oder lassen Sie fehlende Werte zu',
|
||||
noFilterDataShort: 'Keine Daten',
|
||||
travelTo: 'Fahrt zu {{destination}}',
|
||||
viewProperties: '{{count}} Immobilien ansehen',
|
||||
viewPropertiesShort: 'Immobilien ansehen',
|
||||
|
|
|
|||
|
|
@ -802,10 +802,12 @@ const en = {
|
|||
showAllStatsFallback:
|
||||
'Switch to all properties to inspect this area without the active filters.',
|
||||
showAllStats: 'Show all properties',
|
||||
closestBlockingFilters: 'Closest filters excluding this area',
|
||||
closestBlockingFilters: 'Closest changes to include this area',
|
||||
lowerMinTo: 'Lower minimum to {{value}}',
|
||||
raiseMaxTo: 'Raise maximum to {{value}}',
|
||||
allowCategory: 'Allow {{value}}',
|
||||
missingFilterValue: 'No value for this filter; remove it or allow missing values',
|
||||
noFilterDataShort: 'No data',
|
||||
travelTo: 'Travel to {{destination}}',
|
||||
viewProperties: 'View {{count}} Properties',
|
||||
viewPropertiesShort: 'View properties',
|
||||
|
|
|
|||
|
|
@ -834,10 +834,13 @@ const fr: Translations = {
|
|||
showAllStatsFallback:
|
||||
'Passez à toutes les propriétés pour inspecter cette zone sans les filtres actifs.',
|
||||
showAllStats: 'Afficher toutes les propriétés',
|
||||
closestBlockingFilters: 'Filtres les plus proches qui excluent cette zone',
|
||||
closestBlockingFilters: 'Modifications les plus proches pour inclure cette zone',
|
||||
lowerMinTo: 'Abaisser le minimum à {{value}}',
|
||||
raiseMaxTo: 'Augmenter le maximum à {{value}}',
|
||||
allowCategory: 'Autoriser {{value}}',
|
||||
missingFilterValue:
|
||||
'Aucune valeur pour ce filtre ; supprimez-le ou autorisez les valeurs manquantes',
|
||||
noFilterDataShort: 'Aucune donnée',
|
||||
travelTo: 'Trajet vers {{destination}}',
|
||||
viewProperties: 'Voir {{count}} propriétés',
|
||||
viewPropertiesShort: 'Voir les propriétés',
|
||||
|
|
|
|||
|
|
@ -792,10 +792,12 @@ const hi: Translations = {
|
|||
showAllStatsFallback:
|
||||
'सक्रिय फिल्टर के बिना इस क्षेत्र को देखने के लिए सभी संपत्तियों पर जाएं.',
|
||||
showAllStats: 'सभी संपत्तियां दिखाएं',
|
||||
closestBlockingFilters: 'इस क्षेत्र को बाहर करने वाले निकटतम फिल्टर',
|
||||
closestBlockingFilters: 'इस क्षेत्र को शामिल करने के निकटतम बदलाव',
|
||||
lowerMinTo: 'न्यूनतम को {{value}} तक घटाएं',
|
||||
raiseMaxTo: 'अधिकतम को {{value}} तक बढ़ाएं',
|
||||
allowCategory: '{{value}} की अनुमति दें',
|
||||
missingFilterValue: 'इस फिल्टर के लिए कोई मान नहीं है; इसे हटाएं या गायब मानों की अनुमति दें',
|
||||
noFilterDataShort: 'कोई डेटा नहीं',
|
||||
travelTo: '{{destination}} तक यात्रा',
|
||||
viewProperties: '{{count}} संपत्तियां देखें',
|
||||
viewPropertiesShort: 'संपत्तियां देखें',
|
||||
|
|
|
|||
|
|
@ -815,10 +815,13 @@ const hu: Translations = {
|
|||
showAllStatsFallback:
|
||||
'Váltson az összes ingatlanra, hogy aktív szűrők nélkül tekintse át ezt a területet.',
|
||||
showAllStats: 'Összes ingatlan mutatása',
|
||||
closestBlockingFilters: 'A területet kizáró legközelebbi szűrők',
|
||||
closestBlockingFilters: 'A terület bevonásához legközelebbi módosítások',
|
||||
lowerMinTo: 'Minimum csökkentése erre: {{value}}',
|
||||
raiseMaxTo: 'Maximum növelése erre: {{value}}',
|
||||
allowCategory: '{{value}} engedélyezése',
|
||||
missingFilterValue:
|
||||
'Ehhez a szűrőhöz nincs érték; távolítsa el, vagy engedélyezze a hiányzó értékeket',
|
||||
noFilterDataShort: 'Nincs adat',
|
||||
travelTo: 'Utazás ide: {{destination}}',
|
||||
viewProperties: '{{count}} ingatlan megtekintése',
|
||||
viewPropertiesShort: 'Ingatlanok megtekintése',
|
||||
|
|
|
|||
|
|
@ -762,10 +762,12 @@ const zh: Translations = {
|
|||
showAllStatsHint: '筛选前这里有 {{count}} 处房产。切换到全部房产即可查看该区域。',
|
||||
showAllStatsFallback: '切换到全部房产即可在不应用当前筛选条件的情况下查看该区域。',
|
||||
showAllStats: '显示全部房产',
|
||||
closestBlockingFilters: '最接近的排除此区域的筛选条件',
|
||||
closestBlockingFilters: '纳入该区域所需的最小调整',
|
||||
lowerMinTo: '将最小值降至 {{value}}',
|
||||
raiseMaxTo: '将最大值提高至 {{value}}',
|
||||
allowCategory: '允许 {{value}}',
|
||||
missingFilterValue: '此筛选条件没有值;请移除它或允许缺失值',
|
||||
noFilterDataShort: '无数据',
|
||||
travelTo: '前往 {{destination}} 的出行',
|
||||
viewProperties: '查看 {{count}} 处房产',
|
||||
viewPropertiesShort: '查看房产',
|
||||
|
|
|
|||
97
frontend/src/lib/travel-params.test.ts
Normal file
97
frontend/src/lib/travel-params.test.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { TravelTimeEntry } from '../hooks/useTravelTime';
|
||||
import { buildTravelParam, dedupeTravelTimeEntries } from './travel-params';
|
||||
|
||||
const bankMedian: TravelTimeEntry = {
|
||||
mode: 'transit',
|
||||
slug: 'bank-tube-station',
|
||||
label: 'Bank',
|
||||
timeRange: [0, 60],
|
||||
useBest: false,
|
||||
};
|
||||
|
||||
describe('travel-params', () => {
|
||||
it('deduplicates travel entries by backend key and keeps the tightest range', () => {
|
||||
const entries = dedupeTravelTimeEntries([
|
||||
bankMedian,
|
||||
{
|
||||
mode: 'transit',
|
||||
slug: 'bank-tube-station',
|
||||
label: 'Bank tube station',
|
||||
timeRange: [15, 45],
|
||||
useBest: false,
|
||||
},
|
||||
{
|
||||
mode: 'walking',
|
||||
slug: 'bank-tube-station',
|
||||
label: 'Bank',
|
||||
timeRange: [0, 20],
|
||||
useBest: false,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
mode: 'transit',
|
||||
slug: 'bank-tube-station',
|
||||
label: 'Bank',
|
||||
timeRange: [15, 45],
|
||||
useBest: false,
|
||||
},
|
||||
{
|
||||
mode: 'walking',
|
||||
slug: 'bank-tube-station',
|
||||
label: 'Bank',
|
||||
timeRange: [0, 20],
|
||||
useBest: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('serializes deduplicated entries before backend requests', () => {
|
||||
expect(
|
||||
buildTravelParam([
|
||||
bankMedian,
|
||||
{
|
||||
mode: 'transit',
|
||||
slug: 'bank-tube-station',
|
||||
label: 'Bank',
|
||||
timeRange: [10, 50],
|
||||
useBest: false,
|
||||
},
|
||||
])
|
||||
).toBe('transit:bank-tube-station:10:50');
|
||||
});
|
||||
|
||||
it('keeps duplicate blank entries because they are editable placeholders', () => {
|
||||
const blank: TravelTimeEntry = {
|
||||
mode: 'transit',
|
||||
slug: '',
|
||||
label: '',
|
||||
timeRange: null,
|
||||
useBest: false,
|
||||
};
|
||||
|
||||
expect(dedupeTravelTimeEntries([blank, blank])).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('uses an unbounded range when excluding a deduplicated travel filter', () => {
|
||||
expect(
|
||||
buildTravelParam(
|
||||
[
|
||||
bankMedian,
|
||||
{
|
||||
mode: 'transit',
|
||||
slug: 'bank-tube-station',
|
||||
label: 'Bank',
|
||||
timeRange: [10, 50],
|
||||
useBest: false,
|
||||
},
|
||||
],
|
||||
'tt_transit_bank-tube-station',
|
||||
true
|
||||
)
|
||||
).toBe('transit:bank-tube-station:0:1440');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,44 @@
|
|||
import type { TravelTimeEntry } from '../hooks/useTravelTime';
|
||||
|
||||
function mergeTimeRanges(
|
||||
current: [number, number] | null,
|
||||
next: [number, number] | null
|
||||
): [number, number] | null {
|
||||
if (!current) return next;
|
||||
if (!next) return current;
|
||||
return [Math.max(current[0], next[0]), Math.min(current[1], next[1])];
|
||||
}
|
||||
|
||||
export function dedupeTravelTimeEntries(entries: TravelTimeEntry[]): TravelTimeEntry[] {
|
||||
const result: TravelTimeEntry[] = [];
|
||||
const indexByKey = new Map<string, number>();
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.slug) {
|
||||
result.push(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `${entry.mode}:${entry.slug}`;
|
||||
const existingIndex = indexByKey.get(key);
|
||||
if (existingIndex == null) {
|
||||
indexByKey.set(key, result.length);
|
||||
result.push({ ...entry });
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = result[existingIndex];
|
||||
result[existingIndex] = {
|
||||
...existing,
|
||||
label: existing.label || entry.label,
|
||||
timeRange: mergeTimeRanges(existing.timeRange, entry.timeRange),
|
||||
useBest: existing.useBest || entry.useBest,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function buildTravelParam(
|
||||
entries: TravelTimeEntry[],
|
||||
excludeFieldKey?: string,
|
||||
|
|
@ -7,7 +46,7 @@ export function buildTravelParam(
|
|||
): string {
|
||||
const segments: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
for (const entry of dedupeTravelTimeEntries(entries)) {
|
||||
if (!entry.slug) continue;
|
||||
|
||||
let segment = `${entry.mode}:${entry.slug}`;
|
||||
|
|
|
|||
|
|
@ -99,6 +99,45 @@ describe('url-state', () => {
|
|||
expect(params.getAll('tt')).toEqual(['bicycle:bank:Bank:5:25']);
|
||||
});
|
||||
|
||||
it('deduplicates travel-time URL params with the tightest range', () => {
|
||||
window.history.replaceState(
|
||||
{},
|
||||
'',
|
||||
'/?tt=transit:bank-tube-station:Bank:0:60&tt=transit:bank-tube-station:Bank:10:45'
|
||||
);
|
||||
|
||||
const state = parseUrlState();
|
||||
|
||||
expect(state.travelTime?.entries).toEqual([
|
||||
{
|
||||
mode: 'transit',
|
||||
slug: 'bank-tube-station',
|
||||
label: 'Bank',
|
||||
timeRange: [10, 45],
|
||||
useBest: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const params = stateToParams(null, {}, [], new Set(), 'area', [
|
||||
{
|
||||
mode: 'transit',
|
||||
slug: 'bank-tube-station',
|
||||
label: 'Bank',
|
||||
useBest: false,
|
||||
timeRange: [0, 60],
|
||||
},
|
||||
{
|
||||
mode: 'transit',
|
||||
slug: 'bank-tube-station',
|
||||
label: 'Bank',
|
||||
useBest: false,
|
||||
timeRange: [10, 45],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(params.getAll('tt')).toEqual(['transit:bank-tube-station:Bank:10:45']);
|
||||
});
|
||||
|
||||
it('round-trips an explicitly empty POI selection', () => {
|
||||
const params = stateToParams(null, {}, [], new Set(), 'area');
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue