This commit is contained in:
Andras Schmelczer 2026-05-15 08:17:05 +01:00
parent 3fa95819e3
commit e9a06417ad
32 changed files with 1531 additions and 407 deletions

View file

@ -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>

View file

@ -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}

View file

@ -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">

View file

@ -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,17 +105,23 @@ export function ActiveFilterList({
/>
);
return (
<div className="px-2 py-1 space-y-1">
{enabledFeatureList.map((feature, featureIdx) => {
const insertTravelCards = featureIdx === travelInsertIdx;
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 (
<Fragment key={feature.name}>
{insertTravelCards && travelCards}
<SchoolFilterCard
key={feature.name}
features={features}
schoolFeature={feature}
filters={filters}
@ -125,16 +137,14 @@ export function ActiveFilterList({
onShowInfo={onShowInfo}
onRemove={() => onRemoveFilter(feature.name)}
/>
</Fragment>
);
}
if (isSpecificCrimeFilterName(feature.name)) {
const specificCrimeBackendName = getSpecificCrimeFeatureName(feature.name);
return (
<Fragment key={feature.name}>
{insertTravelCards && travelCards}
<SpecificCrimeFilterCard
key={feature.name}
features={features}
crimeFeature={feature}
filters={filters}
@ -145,9 +155,7 @@ export function ActiveFilterList({
specificCrimeBackendName ? filterImpacts?.[specificCrimeBackendName] : undefined
}
percentileScale={
specificCrimeBackendName
? percentileScales.get(specificCrimeBackendName)
: undefined
specificCrimeBackendName ? percentileScales.get(specificCrimeBackendName) : undefined
}
onFilterChange={onFilterChange}
onDragStart={onDragStart}
@ -157,16 +165,14 @@ export function ActiveFilterList({
onShowInfo={onShowInfo}
onRemove={() => onRemoveFilter(feature.name)}
/>
</Fragment>
);
}
if (isElectionVoteShareFilterName(feature.name)) {
const electionVoteShareBackendName = getElectionVoteShareFeatureName(feature.name);
return (
<Fragment key={feature.name}>
{insertTravelCards && travelCards}
<ElectionVoteShareFilterCard
key={feature.name}
features={features}
voteShareFeature={feature}
filters={filters}
@ -174,9 +180,7 @@ export function ActiveFilterList({
dragValue={dragValue}
pinnedFeature={pinnedFeature}
filterImpact={
electionVoteShareBackendName
? filterImpacts?.[electionVoteShareBackendName]
: undefined
electionVoteShareBackendName ? filterImpacts?.[electionVoteShareBackendName] : undefined
}
percentileScale={
electionVoteShareBackendName
@ -191,25 +195,21 @@ export function ActiveFilterList({
onShowInfo={onShowInfo}
onRemove={() => onRemoveFilter(feature.name)}
/>
</Fragment>
);
}
if (isEthnicityFilterName(feature.name)) {
const ethnicityBackendName = getEthnicityFeatureName(feature.name);
return (
<Fragment key={feature.name}>
{insertTravelCards && travelCards}
<EthnicityFilterCard
key={feature.name}
features={features}
ethnicityFeature={feature}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
pinnedFeature={pinnedFeature}
filterImpact={
ethnicityBackendName ? filterImpacts?.[ethnicityBackendName] : undefined
}
filterImpact={ethnicityBackendName ? filterImpacts?.[ethnicityBackendName] : undefined}
percentileScale={
ethnicityBackendName ? percentileScales.get(ethnicityBackendName) : undefined
}
@ -221,16 +221,14 @@ export function ActiveFilterList({
onShowInfo={onShowInfo}
onRemove={() => onRemoveFilter(feature.name)}
/>
</Fragment>
);
}
if (isPoiDistanceFilterName(feature.name)) {
const poiBackendName = getPoiDistanceFeatureName(feature.name);
return (
<Fragment key={feature.name}>
{insertTravelCards && travelCards}
<PoiDistanceFilterCard
key={feature.name}
features={features}
poiFeature={feature}
filters={filters}
@ -247,15 +245,12 @@ export function ActiveFilterList({
onShowInfo={onShowInfo}
onRemove={() => onRemoveFilter(feature.name)}
/>
</Fragment>
);
}
return (
<Fragment key={feature.name}>
{insertTravelCards && travelCards}
{feature.type === 'enum' ? (
return feature.type === 'enum' ? (
<EnumFeatureFilterCard
key={feature.name}
feature={feature}
filters={filters}
pinnedFeature={pinnedFeature}
@ -267,6 +262,7 @@ export function ActiveFilterList({
/>
) : (
<NumericFeatureFilterCard
key={feature.name}
feature={feature}
filters={filters}
activeFeature={activeFeature}
@ -282,11 +278,35 @@ export function ActiveFilterList({
onShowInfo={onShowInfo}
onRemoveFilter={onRemoveFilter}
/>
);
};
return (
<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 (
<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>
);
}

View file

@ -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}

View file

@ -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) && (

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)}

View file

@ -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';

View file

@ -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({});
});
});

View file

@ -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;

View file

@ -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 (0100) 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,

View file

@ -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,
},
]);
});
});

View file

@ -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) =>
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) =>
dedupeTravelTimeEntries(
prev.map((entry, i) => (i === index ? { ...entry, timeRange: range } : entry))
)
);
}, []);
const handleToggleBest = useCallback((index: number) => {
setEntries((prev) =>
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) */

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -792,10 +792,12 @@ const hi: Translations = {
showAllStatsFallback:
'सक्रिय फिल्टर के बिना इस क्षेत्र को देखने के लिए सभी संपत्तियों पर जाएं.',
showAllStats: 'सभी संपत्तियां दिखाएं',
closestBlockingFilters: 'इस क्षेत्र को बाहर करने वाले निकटतम फिल्टर',
closestBlockingFilters: 'इस क्षेत्र को शामिल करने के निकटतम बदलाव',
lowerMinTo: 'न्यूनतम को {{value}} तक घटाएं',
raiseMaxTo: 'अधिकतम को {{value}} तक बढ़ाएं',
allowCategory: '{{value}} की अनुमति दें',
missingFilterValue: 'इस फिल्टर के लिए कोई मान नहीं है; इसे हटाएं या गायब मानों की अनुमति दें',
noFilterDataShort: 'कोई डेटा नहीं',
travelTo: '{{destination}} तक यात्रा',
viewProperties: '{{count}} संपत्तियां देखें',
viewPropertiesShort: 'संपत्तियां देखें',

View file

@ -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',

View file

@ -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: '查看房产',

View 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');
});
});

View file

@ -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}`;

View file

@ -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');

View file

@ -3,12 +3,15 @@ use std::sync::Arc;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json};
use axum::Extension;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::auth::OptionalUser;
use crate::consts::NAN_U16;
use crate::data::travel_time::TravelData;
use crate::licensing::{check_license_bounds, resolve_share_code};
use crate::parsing::{parse_filters_with_poi, require_bounds};
use crate::routes::travel_time::parse_optional_travel;
use crate::state::SharedState;
@ -18,6 +21,7 @@ pub struct FilterCountsParams {
bounds: Option<String>,
filters: Option<String>,
travel: Option<String>,
share: Option<String>,
}
#[derive(Serialize)]
@ -28,12 +32,15 @@ pub struct FilterCountsResponse {
pub async fn get_filter_counts(
State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>,
Query(params): Query<FilterCountsParams>,
) -> Result<Json<FilterCountsResponse>, axum::response::Response> {
let state = shared.load_state();
let (south, west, north, east) =
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
check_license_bounds(&user.0, (south, west, north, east), share_bounds)?;
let quant = state.data.quant_ref();
let poi_quant = state.data.poi_metrics.quant_ref();

View file

@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::str::FromStr;
use std::sync::Arc;
@ -11,15 +11,25 @@ use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::consts::NAN_U16;
use crate::data::travel_time::TravelData;
use crate::data::PropertyData;
use crate::features::{Feature, FEATURE_GROUPS};
use crate::licensing::{check_license_bounds, resolve_share_code};
use crate::parsing::{
cell_for_row_cached, h3_cell_bounds, needs_parent, parse_field_set, parse_filters_with_poi,
row_passes_filters, row_passes_poi_filters, validate_h3_resolution,
row_passes_filters, row_passes_poi_filters, validate_h3_resolution, ParsedEnumFilter,
ParsedFilter, ParsedPoiFilter,
};
use crate::state::SharedState;
use super::stats;
use super::travel_time::{load_travel_data, parse_optional_travel, row_passes_travel_filters};
use super::travel_time::{
load_travel_data, parse_optional_travel, row_passes_travel_filters, TravelEntry,
};
const AREA_STATS_EXCLUDED_GROUPS: &[&str] = &["Amenities"];
const MAX_FILTER_EXCLUSIONS: usize = 5;
#[derive(Serialize)]
pub struct HistogramStats {
@ -54,6 +64,47 @@ pub struct PricePoint {
pub price: f32,
}
#[derive(Serialize)]
pub struct FilterExclusion {
pub name: String,
pub kind: String,
pub direction: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
pub relative_difference: f32,
pub rejected_count: usize,
}
fn filter_exclusion_key(exclusion: &FilterExclusion) -> String {
format!(
"{}\u{1f}{}\u{1f}{}\u{1f}{}",
exclusion.kind,
exclusion.name,
exclusion.direction,
exclusion.category.as_deref().unwrap_or("")
)
}
fn missing_filter_exclusion(name: String, kind: &str) -> FilterExclusion {
FilterExclusion {
name,
kind: kind.to_string(),
direction: "missing_value".to_string(),
value: None,
min: None,
max: None,
category: None,
relative_difference: 1.0,
rejected_count: 0,
}
}
#[derive(Serialize)]
pub struct HexagonStatsResponse {
pub count: usize,
@ -63,6 +114,8 @@ pub struct HexagonStatsResponse {
pub price_history: Vec<PricePoint>,
#[serde(skip_serializing_if = "Option::is_none")]
pub central_postcode: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub filter_exclusions: Vec<FilterExclusion>,
}
#[derive(Deserialize)]
@ -70,8 +123,9 @@ pub struct HexagonStatsParams {
pub h3: String,
pub resolution: u8,
pub filters: Option<String>,
/// Comma-separated feature names to include in stats response.
/// Only listed features are computed; if absent or empty, no features are returned.
/// `;;`-separated feature names to include in stats response.
/// Only listed features are computed. If absent, area stats default to
/// displayable groups; if empty, no feature stats are returned.
pub fields: Option<String>,
/// When set (with journey_slug), pick central_postcode as the postcode with the
/// shortest travel time for this mode+slug (so it has journey data).
@ -84,6 +138,271 @@ pub struct HexagonStatsParams {
pub share: Option<String>,
}
fn default_area_stat_field_set() -> HashSet<String> {
FEATURE_GROUPS
.iter()
.filter(|group| !AREA_STATS_EXCLUDED_GROUPS.contains(&group.name))
.flat_map(|group| group.features.iter())
.map(|feature| match feature {
Feature::Numeric(config) => config.name.to_string(),
Feature::Enum(config) => config.name.to_string(),
})
.collect()
}
pub(super) fn parse_area_stats_field_set(fields: Option<&str>) -> (bool, HashSet<String>) {
let (fields_specified, field_set) = parse_field_set(fields);
if fields_specified {
return (fields_specified, field_set);
}
(true, default_area_stat_field_set())
}
#[inline]
fn relative_difference(value: f32, min: f32, max: f32) -> Option<(String, f32)> {
let distance = if value < min {
min - value
} else if value > max {
value - max
} else {
return None;
};
let range = (max - min).abs();
let denominator = if range.is_finite() && range > f32::EPSILON {
range
} else {
min.abs().max(max.abs()).max(1.0)
};
let direction = if value < min {
"lower_min".to_string()
} else {
"raise_max".to_string()
};
Some((direction, distance / denominator))
}
pub(super) fn top_filter_exclusions(
area_rows: &[usize],
numeric_filters: &[ParsedFilter],
enum_filters: &[ParsedEnumFilter],
poi_filters: &[ParsedPoiFilter],
travel_entries: &[TravelEntry],
travel_data: &[TravelData],
data: &PropertyData,
) -> Vec<FilterExclusion> {
if area_rows.is_empty()
|| (numeric_filters.is_empty()
&& enum_filters.is_empty()
&& poi_filters.is_empty()
&& !travel_entries
.iter()
.any(|entry| entry.filter_min.is_some() && entry.filter_max.is_some()))
{
return Vec::new();
}
let feature_data = &data.feature_data;
let num_features = data.num_features;
let quant = data.quant_ref();
let poi_quant = data.poi_metrics.quant_ref();
let mut rejection_counts: HashMap<String, usize> = HashMap::new();
let mut best_path: Option<Vec<FilterExclusion>> = None;
for &row in area_rows {
let mut path = Vec::new();
for filter in numeric_filters {
let min = quant.decode(filter.feat_idx, filter.min_u16);
let max = quant.decode(filter.feat_idx, filter.max_u16);
let raw = feature_data[row * num_features + filter.feat_idx];
if raw == NAN_U16 {
path.push(missing_filter_exclusion(
data.feature_names[filter.feat_idx].clone(),
"numeric",
));
continue;
}
let value = quant.decode(filter.feat_idx, raw);
let Some((direction, rel_diff)) = relative_difference(value, min, max) else {
continue;
};
path.push(FilterExclusion {
name: data.feature_names[filter.feat_idx].clone(),
kind: "numeric".to_string(),
direction,
value: Some(value),
min: Some(min),
max: Some(max),
category: None,
relative_difference: rel_diff,
rejected_count: 0,
});
}
for filter in enum_filters {
let raw = feature_data[row * num_features + filter.feat_idx];
if raw == NAN_U16 {
path.push(missing_filter_exclusion(
data.feature_names[filter.feat_idx].clone(),
"enum",
));
continue;
}
if filter.allowed.contains(&raw) {
continue;
}
let Some(values) = data.enum_values.get(&filter.feat_idx) else {
continue;
};
let Some(category) = values.get(raw as usize) else {
continue;
};
path.push(FilterExclusion {
name: data.feature_names[filter.feat_idx].clone(),
kind: "enum".to_string(),
direction: "allow_value".to_string(),
value: None,
min: None,
max: None,
category: Some(category.clone()),
relative_difference: 1.0,
rejected_count: 0,
});
}
for filter in poi_filters {
let min = poi_quant.decode(filter.metric_idx, filter.min_u16);
let max = poi_quant.decode(filter.metric_idx, filter.max_u16);
let raw = data
.poi_metrics
.raw_for_property_row(row, filter.metric_idx);
if raw == NAN_U16 {
path.push(missing_filter_exclusion(
data.poi_metrics.feature_names[filter.metric_idx].clone(),
"poi",
));
continue;
}
let value = poi_quant.decode(filter.metric_idx, raw);
let Some((direction, rel_diff)) = relative_difference(value, min, max) else {
continue;
};
path.push(FilterExclusion {
name: data.poi_metrics.feature_names[filter.metric_idx].clone(),
kind: "poi".to_string(),
direction,
value: Some(value),
min: Some(min),
max: Some(max),
category: None,
relative_difference: rel_diff,
rejected_count: 0,
});
}
for (filter_index, entry) in travel_entries.iter().enumerate() {
let (Some(min), Some(max)) = (entry.filter_min, entry.filter_max) else {
continue;
};
let postcode = data.postcode(row);
let Some(row_data) = travel_data
.get(filter_index)
.and_then(|travel| travel.get(postcode))
else {
path.push(missing_filter_exclusion(
format!("tt_{}_{}", entry.mode, entry.slug),
"travel",
));
continue;
};
let minutes = if entry.use_best {
row_data.best_minutes.unwrap_or(row_data.minutes)
} else {
row_data.minutes
} as f32;
let Some((direction, rel_diff)) = relative_difference(minutes, min, max) else {
continue;
};
path.push(FilterExclusion {
name: format!("tt_{}_{}", entry.mode, entry.slug),
kind: "travel".to_string(),
direction,
value: Some(minutes),
min: Some(min),
max: Some(max),
category: None,
relative_difference: rel_diff,
rejected_count: 0,
});
}
if path.is_empty() {
continue;
}
for exclusion in &path {
*rejection_counts
.entry(filter_exclusion_key(exclusion))
.or_default() += 1;
}
let path_score = path
.iter()
.map(|exclusion| exclusion.relative_difference)
.sum::<f32>();
let current_score = best_path
.as_ref()
.map(|current| {
current
.iter()
.map(|exclusion| exclusion.relative_difference)
.sum::<f32>()
})
.unwrap_or(f32::INFINITY);
let replace = path_score < current_score
|| (path_score == current_score
&& best_path
.as_ref()
.map_or(true, |current| path.len() < current.len()));
if replace {
best_path = Some(path);
}
}
let Some(mut exclusions) = best_path else {
return Vec::new();
};
for exclusion in &mut exclusions {
exclusion.rejected_count = rejection_counts
.get(&filter_exclusion_key(exclusion))
.copied()
.unwrap_or(0);
}
exclusions.sort_by(|a, b| {
a.relative_difference
.partial_cmp(&b.relative_difference)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| b.rejected_count.cmp(&a.rejected_count))
.then_with(|| a.name.cmp(&b.name))
});
exclusions.truncate(MAX_FILTER_EXCLUSIONS);
exclusions
}
pub async fn get_hexagon_stats(
State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>,
@ -124,7 +443,7 @@ pub async fn get_hexagon_stats(
let filters_str = params.filters;
let has_poi_filters = !parsed_poi_filters.is_empty();
let (fields_specified, field_set) = parse_field_set(params.fields.as_deref());
let (fields_specified, field_set) = parse_area_stats_field_set(params.fields.as_deref());
let travel_entries = parse_optional_travel(params.travel.as_deref())
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
@ -151,38 +470,55 @@ pub async fn get_hexagon_stats(
let (min_lat, min_lon, max_lat, max_lon) = h3_cell_bounds(cell, 0.001);
let mut h3_cache: FxHashMap<u64, u64> = FxHashMap::default();
let mut area_rows: Vec<usize> = Vec::new();
let mut matching_rows: Vec<usize> = Vec::new();
state
.grid
.for_each_in_bounds(min_lat, min_lon, max_lat, max_lon, |row_idx| {
let row = row_idx as usize;
if cell_for_row_cached(row, precomputed, h3_res, need_parent, &mut h3_cache)
== cell_u64
&& row_passes_filters(
!= cell_u64
{
return;
}
area_rows.push(row);
if row_passes_filters(
row,
&parsed_filters,
&parsed_enum_filters,
feature_data,
num_features,
)
&& (!has_poi_filters
|| row_passes_poi_filters(
row,
&parsed_poi_filters,
&state.data.poi_metrics,
))
) && (!has_poi_filters
|| row_passes_poi_filters(row, &parsed_poi_filters, &state.data.poi_metrics))
{
if has_travel
&& !row_passes_travel_filters(
state.data.postcode(row),
&travel_entries,
&travel_data,
)
{
if has_travel {
let postcode = state.data.postcode(row);
if !row_passes_travel_filters(postcode, &travel_entries, &travel_data) {
return;
}
}
matching_rows.push(row);
}
});
let total_count = matching_rows.len();
let filter_exclusions = if total_count == 0 {
top_filter_exclusions(
&area_rows,
&parsed_filters,
&parsed_enum_filters,
&parsed_poi_filters,
&travel_entries,
&travel_data,
&state.data,
)
} else {
Vec::new()
};
// Pick central_postcode: prefer the postcode with the shortest travel time
// for the requested journey destination (so it has journey data). Fall back
@ -277,6 +613,7 @@ pub async fn get_hexagon_stats(
enum_features: enum_features_out,
price_history,
central_postcode,
filter_exclusions,
})
})
.await
@ -285,3 +622,30 @@ pub async fn get_hexagon_stats(
Ok(Json(response))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_area_stat_fields_skip_amenities() {
let (fields_specified, field_set) = parse_area_stats_field_set(None);
assert!(fields_specified);
assert!(field_set.contains("Property type"));
assert!(!field_set.contains("Noise (dB)"));
assert!(!field_set.contains("Max available download speed (Mbps)"));
assert!(!field_set.contains("Distance to nearest amenity (Cafe) (km)"));
}
#[test]
fn explicit_area_stat_fields_are_respected() {
let (fields_specified, field_set) =
parse_area_stats_field_set(Some("Noise (dB);;Property type"));
assert!(fields_specified);
assert!(field_set.contains("Noise (dB)"));
assert!(field_set.contains("Property type"));
assert_eq!(field_set.len(), 2);
}
}

View file

@ -1,7 +1,7 @@
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::http::{header, StatusCode};
use axum::http::{header, HeaderMap, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use axum::Extension;
use axum::Json;
@ -11,7 +11,8 @@ use tracing::warn;
use url::form_urlencoded;
use crate::auth::OptionalUser;
use crate::licensing::{is_valid_share_bounds, share_bounds_from_params, ShareBounds};
use crate::language::{language_from_accept_language, query_string_with_language};
use crate::licensing::{is_valid_share_bounds, share_params_and_bounds_from_params, ShareBounds};
use crate::pocketbase::get_superuser_token;
use crate::state::SharedState;
@ -136,6 +137,7 @@ fn is_allowed_param_key(key: &str) -> bool {
| "tab"
| "pc"
| "tt"
| "lang"
| "share"
)
}
@ -180,15 +182,42 @@ fn record_share_bounds(item: &serde_json::Value) -> Option<ShareBounds> {
}
fn dashboard_redirect_url(params: &str, code: &str, include_share: bool) -> String {
match (params.is_empty(), include_share) {
(true, false) => "/dashboard".to_string(),
(true, true) => format!("/dashboard?share={code}"),
(false, false) => format!("/dashboard?{params}"),
(false, true) => format!("/dashboard?{params}&share={code}"),
let params = match include_share {
true => params_with_share(params, code),
false => params.to_string(),
};
if params.is_empty() {
"/dashboard".to_string()
} else {
format!("/dashboard?{params}")
}
}
fn og_image_url(public_url: &str, params: &str) -> String {
fn params_with_share(params: &str, code: &str) -> String {
let mut out = form_urlencoded::Serializer::new(String::new());
for (key, value) in form_urlencoded::parse(params.as_bytes()) {
if key == "share" {
continue;
}
out.append_pair(&key, &value);
}
out.append_pair("share", code);
out.finish()
}
fn og_image_url(
public_url: &str,
params: &str,
language: &str,
share_code: Option<&str>,
) -> String {
let params = query_string_with_language(params, language);
let params = match share_code {
Some(code) => params_with_share(&params, code),
None => params,
};
if params.is_empty() {
format!("{}/api/screenshot?og=1", public_url.trim_end_matches('/'))
} else {
@ -208,7 +237,7 @@ pub async fn post_shorten(
let pb_url = state.pocketbase_url.trim_end_matches('/');
let can_create_share_grant = user_can_create_share_grant(&user);
let params = match sanitized_query_params(&req.params, !can_create_share_grant) {
let mut params = match sanitized_query_params(&req.params, !can_create_share_grant) {
Ok(params) => params,
Err(reason) => {
warn!("Rejected short URL params: {reason}");
@ -226,7 +255,10 @@ pub async fn post_shorten(
let code = generate_code();
let share_bounds = if can_create_share_grant {
share_bounds_from_params(&params)
share_params_and_bounds_from_params(&params).map(|(share_params, share_bounds)| {
params = share_params;
share_bounds
})
} else {
None
};
@ -275,6 +307,7 @@ pub async fn post_shorten(
pub async fn get_share_links(
State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>,
headers: HeaderMap,
) -> Response {
let state = shared.load_state();
let user = match user.0 {
@ -328,6 +361,11 @@ pub async fn get_share_links(
};
let public_url = state.public_url.trim_end_matches('/');
let language = language_from_accept_language(
headers
.get(header::ACCEPT_LANGUAGE)
.and_then(|value| value.to_str().ok()),
);
let links: Vec<ShareLinkListItem> = body["items"]
.as_array()
.map(|arr| {
@ -335,10 +373,17 @@ pub async fn get_share_links(
.map(|item| {
let code = item["code"].as_str().unwrap_or("").to_string();
let params = item["params"].as_str().unwrap_or("").to_string();
let has_share_grant = record_share_bounds(item).is_some();
let og_image_url = og_image_url(
public_url,
&params,
language,
has_share_grant.then_some(code.as_str()),
);
ShareLinkListItem {
url: format!("{public_url}/s/{code}"),
code,
og_image_url: og_image_url(public_url, &params),
og_image_url,
params,
click_count: json_number_as_u64(&item["click_count"]),
created: item["created"].as_str().unwrap_or("").to_string(),
@ -354,8 +399,14 @@ pub async fn get_share_links(
pub async fn get_short_url(
State(shared): State<Arc<SharedState>>,
Path(code): Path<String>,
headers: HeaderMap,
) -> Response {
let state = shared.load_state();
let language = language_from_accept_language(
headers
.get(header::ACCEPT_LANGUAGE)
.and_then(|value| value.to_str().ok()),
);
if code.is_empty() || code.len() > 20 || !code.bytes().all(|b| b.is_ascii_alphanumeric()) {
return StatusCode::BAD_REQUEST.into_response();
@ -428,9 +479,14 @@ pub async fn get_short_url(
Err(err) => warn!("PocketBase click count update failed: {err}"),
}
}
let redirect_url =
dashboard_redirect_url(&params, &code, record_share_bounds(item).is_some());
let og_image_url = og_image_url(&state.public_url, &params);
let has_share_grant = record_share_bounds(item).is_some();
let redirect_url = dashboard_redirect_url(&params, &code, has_share_grant);
let og_image_url = og_image_url(
&state.public_url,
&params,
language,
has_share_grant.then_some(code.as_str()),
);
let og_url = format!("{}/s/{code}", state.public_url.trim_end_matches('/'));
let og_title = "Perfect Postcode | Every neighbourhood in England";
let og_description = "Explore property prices, energy ratings, crime stats, school ratings, and more across England on one interactive map.";
@ -454,6 +510,7 @@ pub async fn get_short_url(
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{og_title}" />
<meta name="twitter:description" content="{og_description}" />
<meta name="twitter:image" content="{og_image_url}" />
<meta http-equiv="refresh" content="0;url={redirect_url}" />
<title>{og_title}</title>
</head><body></body></html>"#
@ -517,4 +574,34 @@ mod tests {
fn escapes_html_attributes() {
assert_eq!(escape_attr(r#""'><&"#), "&quot;&#39;&gt;&lt;&amp;");
}
#[test]
fn og_image_url_includes_language_and_share_grant() {
assert_eq!(
og_image_url(
"http://localhost:3001/",
"lat=51.5&lon=-0.1&zoom=12",
"de",
Some("abc123")
),
"http://localhost:3001/api/screenshot?og=1&lat=51.5&lon=-0.1&zoom=12&lang=de&share=abc123"
);
}
#[test]
fn share_grant_replaces_existing_share_param() {
assert_eq!(
dashboard_redirect_url("lat=51.5&share=oldcode&zoom=12", "newcode", true),
"/dashboard?lat=51.5&zoom=12&share=newcode"
);
assert_eq!(
og_image_url(
"https://perfect-postcodes.co.uk",
"lat=51.5&share=oldcode&zoom=12",
"en",
Some("newcode")
),
"https://perfect-postcodes.co.uk/api/screenshot?og=1&lat=51.5&zoom=12&lang=en&share=newcode"
);
}
}

View file

@ -9,10 +9,11 @@ use sha2::Sha256;
use tracing::{info, warn};
use crate::checkout_sessions::{
complete_verified_checkout, reverse_license_for_payment_intent, verify_checkout_completion,
CheckoutCompletion,
complete_verified_checkout, reinstate_license_for_payment_intent,
reverse_license_for_payment_intent, verify_checkout_completion, CheckoutCompletion,
PaymentReinstatementOutcome, PaymentReversalOutcome,
};
use crate::state::SharedState;
use crate::state::{AppState, SharedState};
type HmacSha256 = Hmac<Sha256>;
@ -73,12 +74,17 @@ fn verify_signature(payload: &[u8], sig_header: &str, secret: &str) -> bool {
matched
}
fn payment_intent_id_from_object(object: &serde_json::Value) -> Option<&str> {
object["payment_intent"]
fn stripe_id_from_value(value: &serde_json::Value) -> Option<&str> {
value
.as_str()
.or_else(|| value["id"].as_str())
.filter(|id| is_safe_stripe_id(id))
}
fn payment_intent_id_from_object(object: &serde_json::Value) -> Option<&str> {
stripe_id_from_value(&object["payment_intent"])
}
fn is_safe_stripe_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 128
@ -87,21 +93,169 @@ fn is_safe_stripe_id(id: &str) -> bool {
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
}
fn reversal_event_is_actionable(event_type: &str, object: &serde_json::Value) -> bool {
fn integer_field(value: &serde_json::Value, field: &str) -> Option<u64> {
value[field].as_u64().or_else(|| {
value[field]
.as_f64()
.filter(|n| n.is_finite() && *n >= 0.0 && n.fract() == 0.0)
.map(|n| n as u64)
})
}
fn reversal_refund_amount_pence(
event_type: &str,
object: &serde_json::Value,
) -> Option<Option<u64>> {
match event_type {
"charge.refunded" => {
object["refunded"].as_bool().unwrap_or(false)
|| object["amount_refunded"].as_u64().unwrap_or(0) > 0
let amount_refunded = integer_field(object, "amount_refunded").unwrap_or(0);
let amount = integer_field(object, "amount").unwrap_or(0);
if object["refunded"].as_bool().unwrap_or(false)
|| (amount > 0 && amount_refunded >= amount)
{
Some(Some(amount_refunded))
} else {
None
}
}
"charge.refund.updated" | "refund.created" | "refund.updated" => {
matches!(object["status"].as_str(), Some("succeeded"))
if matches!(object["status"].as_str(), Some("succeeded")) {
integer_field(object, "amount").map(Some)
} else {
None
}
"charge.dispute.created" | "charge.dispute.funds_withdrawn" => true,
"charge.dispute.closed" => matches!(object["status"].as_str(), Some("lost")),
}
"charge.dispute.created" | "charge.dispute.funds_withdrawn" => Some(None),
"charge.dispute.closed" => {
if matches!(object["status"].as_str(), Some("lost")) {
Some(None)
} else {
None
}
}
_ => None,
}
}
fn reinstatement_event_is_actionable(event_type: &str, object: &serde_json::Value) -> bool {
match event_type {
"charge.dispute.funds_reinstated" => true,
"charge.dispute.closed" => matches!(object["status"].as_str(), Some("won")),
_ => false,
}
}
async fn process_stripe_reversal_event(
state: &AppState,
payment_intent_id: &str,
event_type: &str,
refunded_amount_pence: Option<u64>,
) -> Result<(), StatusCode> {
match reverse_license_for_payment_intent(
state,
payment_intent_id,
event_type,
refunded_amount_pence,
)
.await
{
Ok(PaymentReversalOutcome::Applied { user_id }) => {
info!(
user_id,
payment_intent_id, event_type, "Processed Stripe payment reversal event"
);
}
Ok(PaymentReversalOutcome::AlreadyHandled { user_id }) => {
info!(
user_id,
payment_intent_id, event_type, "Stripe payment reversal was already handled"
);
}
Ok(PaymentReversalOutcome::IgnoredPartialRefund {
user_id,
refunded_amount_pence,
paid_amount_pence,
}) => {
info!(
user_id,
payment_intent_id,
refunded_amount_pence,
paid_amount_pence,
"Ignoring partial Stripe refund"
);
}
Ok(PaymentReversalOutcome::NotReversible { user_id, status }) => {
warn!(
user_id,
payment_intent_id,
status,
event_type,
"Stripe reversal event matched a non-reversible checkout"
);
}
Ok(PaymentReversalOutcome::NoMatchingCheckout) => {
warn!(
payment_intent_id,
event_type, "Stripe reversal event had no matching checkout reservation"
);
}
Err(err) => {
warn!(
payment_intent_id,
event_type, "Failed to process Stripe payment reversal event: {err:?}"
);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
}
Ok(())
}
async fn process_stripe_reinstatement_event(
state: &AppState,
payment_intent_id: &str,
event_type: &str,
) -> Result<(), StatusCode> {
match reinstate_license_for_payment_intent(state, payment_intent_id, event_type).await {
Ok(PaymentReinstatementOutcome::Applied { user_id }) => {
info!(
user_id,
payment_intent_id, event_type, "Processed Stripe payment reinstatement event"
);
}
Ok(PaymentReinstatementOutcome::AlreadyHandled { user_id }) => {
info!(
user_id,
payment_intent_id, event_type, "Stripe payment reinstatement was already handled"
);
}
Ok(PaymentReinstatementOutcome::Ignored { user_id, reason }) => {
info!(
user_id,
payment_intent_id,
event_type,
reason,
"Ignoring Stripe payment reinstatement event"
);
}
Ok(PaymentReinstatementOutcome::NoMatchingCheckout) => {
warn!(
payment_intent_id,
event_type, "Stripe reinstatement event had no matching checkout reservation"
);
}
Err(err) => {
warn!(
payment_intent_id,
event_type, "Failed to process Stripe payment reinstatement event: {err:?}"
);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
}
Ok(())
}
/// Handle Stripe webhook events.
/// On `checkout.session.completed`, updates the user's subscription to "licensed".
pub async fn post_stripe_webhook(
@ -159,7 +313,15 @@ pub async fn post_stripe_webhook(
"User subscription updated to licensed via verified Stripe checkout"
);
}
Ok(CheckoutCompletion::AlreadyHandled) => {
Ok(CheckoutCompletion::AlreadyHandled(checkout)) => {
if let Err(err) = complete_verified_checkout(&state, &checkout).await {
warn!(
user_id = %checkout.user_id,
reservation_id = %checkout.reservation_id,
"Failed to finish idempotent Stripe checkout side effects: {err:?}"
);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
info!("Stripe checkout session was already handled");
}
Ok(CheckoutCompletion::Rejected(reason)) => {
@ -179,44 +341,173 @@ pub async fn post_stripe_webhook(
| "charge.dispute.created"
| "charge.dispute.closed"
| "charge.dispute.funds_withdrawn"
| "charge.dispute.funds_reinstated"
) {
let object = &event["data"]["object"];
let Some(payment_intent_id) = payment_intent_id_from_object(object) else {
warn!(
event_id,
event_type, "Stripe reversal event missing payment intent id"
event_type, "Stripe payment adjustment event missing payment intent id"
);
return StatusCode::OK.into_response();
};
if !reversal_event_is_actionable(event_type, object) {
info!(
payment_intent_id,
event_type, "Ignoring non-final Stripe reversal event"
);
if reinstatement_event_is_actionable(event_type, object) {
if let Err(status) =
process_stripe_reinstatement_event(&state, payment_intent_id, event_type).await
{
return status.into_response();
}
return StatusCode::OK.into_response();
}
match reverse_license_for_payment_intent(&state, payment_intent_id, event_type).await {
Ok(Some(user_id)) => {
if let Some(refunded_amount_pence) = reversal_refund_amount_pence(event_type, object) {
if let Err(status) = process_stripe_reversal_event(
&state,
payment_intent_id,
event_type,
refunded_amount_pence,
)
.await
{
return status.into_response();
}
return StatusCode::OK.into_response();
}
info!(
user_id,
payment_intent_id, event_type, "Processed Stripe payment reversal event"
);
}
Ok(None) => {
warn!(
payment_intent_id,
event_type, "Stripe reversal event had no matching checkout reservation"
event_type, "Ignoring non-final Stripe payment adjustment event"
);
}
Err(err) => {
warn!(
payment_intent_id,
event_type, "Failed to process Stripe payment reversal event: {err:?}"
);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
}
}
StatusCode::OK.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
fn signed_header(payload: &[u8], secret: &str, timestamp: i64) -> String {
let signed_payload = format!("{timestamp}.{}", String::from_utf8_lossy(payload));
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
mac.update(signed_payload.as_bytes());
let signature = hex::encode(mac.finalize().into_bytes());
format!("t={timestamp},v1={signature}")
}
#[test]
fn verify_signature_accepts_valid_header() {
let payload = br#"{"id":"evt_123","type":"checkout.session.completed"}"#;
let secret = "whsec_test_secret";
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let header = signed_header(payload, secret, now);
assert!(verify_signature(payload, &header, secret));
}
#[test]
fn verify_signature_rejects_tampered_payload() {
let payload = br#"{"id":"evt_123","type":"checkout.session.completed"}"#;
let secret = "whsec_test_secret";
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let header = signed_header(payload, secret, now);
assert!(!verify_signature(
br#"{"id":"evt_123","type":"invoice.paid"}"#,
&header,
secret
));
}
#[test]
fn verify_signature_rejects_stale_header() {
let payload = br#"{"id":"evt_123","type":"checkout.session.completed"}"#;
let secret = "whsec_test_secret";
let header = signed_header(payload, secret, 1);
assert!(!verify_signature(payload, &header, secret));
}
#[test]
fn reversal_refund_amount_only_accepts_full_charge_refunds() {
let partial = serde_json::json!({
"amount": 1000,
"amount_refunded": 500,
"refunded": false,
});
let full = serde_json::json!({
"amount": 1000,
"amount_refunded": 1000,
"refunded": true,
});
assert_eq!(
reversal_refund_amount_pence("charge.refunded", &partial),
None
);
assert_eq!(
reversal_refund_amount_pence("charge.refunded", &full),
Some(Some(1000))
);
}
#[test]
fn reversal_refund_amount_requires_succeeded_refund() {
let pending = serde_json::json!({
"amount": 1000,
"status": "pending",
});
let succeeded = serde_json::json!({
"amount": 1000,
"status": "succeeded",
});
assert_eq!(
reversal_refund_amount_pence("refund.created", &pending),
None
);
assert_eq!(
reversal_refund_amount_pence("refund.created", &succeeded),
Some(Some(1000))
);
}
#[test]
fn payment_intent_id_accepts_expanded_objects() {
let object = serde_json::json!({
"payment_intent": { "id": "pi_123" }
});
assert_eq!(payment_intent_id_from_object(&object), Some("pi_123"));
}
#[test]
fn dispute_closed_routes_lost_and_won_differently() {
let lost = serde_json::json!({ "status": "lost" });
let won = serde_json::json!({ "status": "won" });
assert_eq!(
reversal_refund_amount_pence("charge.dispute.closed", &lost),
Some(None)
);
assert!(!reinstatement_event_is_actionable(
"charge.dispute.closed",
&lost
));
assert_eq!(
reversal_refund_amount_pence("charge.dispute.closed", &won),
None
);
assert!(reinstatement_event_is_actionable(
"charge.dispute.closed",
&won
));
}
}