lgtm 2
This commit is contained in:
parent
a8de0a614d
commit
3fa95819e3
30 changed files with 907 additions and 205 deletions
|
|
@ -22,6 +22,13 @@ export interface SearchedLocation {
|
|||
focusAddress?: string;
|
||||
}
|
||||
|
||||
interface PostcodeLookupResponse {
|
||||
postcode: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
geometry: PostcodeGeometry;
|
||||
}
|
||||
|
||||
const ZOOM_FOR_TYPE: Record<string, number> = {
|
||||
city: 10,
|
||||
borough: 12,
|
||||
|
|
@ -48,11 +55,15 @@ export default function LocationSearch({
|
|||
onLocationSearched,
|
||||
onCurrentLocationFound,
|
||||
onMouseEnter,
|
||||
className = '',
|
||||
inputClassName,
|
||||
}: {
|
||||
onFlyTo: (lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void;
|
||||
onLocationSearched?: (postcode: SearchedLocation | null) => void;
|
||||
onCurrentLocationFound?: (lat: number, lng: number) => void;
|
||||
onMouseEnter?: () => void;
|
||||
className?: string;
|
||||
inputClassName?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const search = useLocationSearch();
|
||||
|
|
@ -86,10 +97,37 @@ export default function LocationSearch({
|
|||
async (result: SearchResult) => {
|
||||
if (result.type === 'place') {
|
||||
const zoom = ZOOM_FOR_TYPE[result.place_type] ?? 14;
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
search.close();
|
||||
onFlyTo(result.lat, result.lon, zoom);
|
||||
onLocationSearched?.(null);
|
||||
search.clear();
|
||||
if (isMobile) setExpanded(false);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
lat: String(result.lat),
|
||||
lng: String(result.lon),
|
||||
log: 'false',
|
||||
});
|
||||
const res = await fetch(`/api/nearest-postcode?${params}`, authHeaders());
|
||||
if (!res.ok) {
|
||||
setError(t('locationSearch.lookupFailed'));
|
||||
return;
|
||||
}
|
||||
const json: PostcodeLookupResponse = await res.json();
|
||||
onLocationSearched?.({
|
||||
postcode: json.postcode,
|
||||
geometry: json.geometry,
|
||||
latitude: json.latitude,
|
||||
longitude: json.longitude,
|
||||
markerLatitude: result.lat,
|
||||
markerLongitude: result.lon,
|
||||
});
|
||||
search.clear();
|
||||
if (isMobile) setExpanded(false);
|
||||
} catch {
|
||||
setError(t('locationSearch.lookupFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -106,12 +144,7 @@ export default function LocationSearch({
|
|||
setError(t('locationSearch.postcodeNotFound'));
|
||||
return;
|
||||
}
|
||||
const json: {
|
||||
postcode: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
geometry: PostcodeGeometry;
|
||||
} = await res.json();
|
||||
const json: PostcodeLookupResponse = await res.json();
|
||||
onFlyTo(result.lat, result.lon, 17);
|
||||
onLocationSearched?.({
|
||||
postcode: json.postcode,
|
||||
|
|
@ -143,12 +176,7 @@ export default function LocationSearch({
|
|||
setError(t('locationSearch.postcodeNotFound'));
|
||||
return;
|
||||
}
|
||||
const json: {
|
||||
postcode: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
geometry: PostcodeGeometry;
|
||||
} = await res.json();
|
||||
const json: PostcodeLookupResponse = await res.json();
|
||||
onFlyTo(json.latitude, json.longitude, POSTCODE_SEARCH_ZOOM);
|
||||
onLocationSearched?.({
|
||||
postcode: json.postcode,
|
||||
|
|
@ -237,7 +265,7 @@ export default function LocationSearch({
|
|||
<div
|
||||
ref={containerRef}
|
||||
data-tutorial="search"
|
||||
className="flex flex-col pointer-events-auto"
|
||||
className={`flex flex-col pointer-events-auto ${className}`}
|
||||
onMouseEnter={onMouseEnter}
|
||||
>
|
||||
<div className="flex items-center shadow-lg rounded bg-white dark:bg-warm-800">
|
||||
|
|
@ -248,7 +276,10 @@ export default function LocationSearch({
|
|||
loading={loading}
|
||||
placeholder={t('locationSearch.placeholder')}
|
||||
size="sm"
|
||||
inputClassName="px-2 py-2 text-sm w-56 border-none outline-none bg-transparent text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500"
|
||||
inputClassName={
|
||||
inputClassName ??
|
||||
'px-2 py-2 text-sm w-56 border-none outline-none bg-transparent text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500'
|
||||
}
|
||||
inputRef={inputRef}
|
||||
onInputChange={() => setError(null)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ export default function MapLegend({
|
|||
totalCount,
|
||||
onResetScale,
|
||||
resetScaleDisabled = false,
|
||||
className = '',
|
||||
}: {
|
||||
featureLabel: string;
|
||||
range: [number, number];
|
||||
|
|
@ -126,6 +127,7 @@ export default function MapLegend({
|
|||
totalCount?: number;
|
||||
onResetScale?: () => void;
|
||||
resetScaleDisabled?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isEnum = enumValues && enumValues.length > 0;
|
||||
|
|
@ -199,7 +201,9 @@ export default function MapLegend({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-navy-800 dark:text-white rounded shadow-lg p-3 text-xs min-w-[300px] pointer-events-auto">
|
||||
<div
|
||||
className={`bg-white dark:bg-navy-800 dark:text-white rounded shadow-lg p-3 text-xs min-w-[300px] pointer-events-auto ${className}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-semibold text-sm dark:text-white min-w-0 truncate">
|
||||
{featureLabel}
|
||||
|
|
|
|||
|
|
@ -260,18 +260,10 @@ export default function MapPage({
|
|||
const license = useLicense();
|
||||
|
||||
const handleTravelTimeSetDestination = useCallback(
|
||||
(index: number, slug: string, label: string, lat: number, lon: number) => {
|
||||
(index: number, slug: string, label: string, _lat: number, _lon: number) => {
|
||||
handleSetDestination(index, slug, label);
|
||||
if (slug) {
|
||||
mapFlyToRef.current?.(
|
||||
lat,
|
||||
lon,
|
||||
mapData.currentView?.zoom ?? INITIAL_VIEW_STATE.zoom,
|
||||
getMobileMapFlyToOptions()
|
||||
);
|
||||
}
|
||||
},
|
||||
[getMobileMapFlyToOptions, handleSetDestination, mapData.currentView?.zoom]
|
||||
[handleSetDestination]
|
||||
);
|
||||
|
||||
const journeyDest = useJourneyDestination(entries);
|
||||
|
|
@ -463,7 +455,11 @@ export default function MapPage({
|
|||
mapData.resolution,
|
||||
areaStats
|
||||
);
|
||||
const tutorial = useTutorial(initialLoading, isMobile, deferTutorial);
|
||||
const tutorial = useTutorial(
|
||||
initialLoading,
|
||||
isMobile,
|
||||
deferTutorial || mapData.licenseRequired
|
||||
);
|
||||
const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]);
|
||||
const densityLabel = t('mapLegend.historicalMatches');
|
||||
const hasActiveFilters = Object.keys(filters).length > 0 || activeEntries.length > 0;
|
||||
|
|
@ -480,10 +476,13 @@ export default function MapPage({
|
|||
onExportStateChange,
|
||||
});
|
||||
|
||||
const shareAndSaveView = isMobile
|
||||
? (mapData.currentVisibleView ?? mapData.currentView)
|
||||
: mapData.currentView;
|
||||
const dashboardParams = useMemo(
|
||||
() =>
|
||||
stateToParams(
|
||||
mapData.currentView,
|
||||
shareAndSaveView,
|
||||
filters,
|
||||
features,
|
||||
selectedPOICategories,
|
||||
|
|
@ -495,12 +494,18 @@ export default function MapPage({
|
|||
entries,
|
||||
features,
|
||||
filters,
|
||||
mapData.currentView,
|
||||
rightPaneTab,
|
||||
selectedPOICategories,
|
||||
shareCode,
|
||||
shareAndSaveView,
|
||||
]
|
||||
);
|
||||
const handleSaveSearch = useCallback(
|
||||
async (name: string) => {
|
||||
await onSaveSearch?.(name, dashboardParams);
|
||||
},
|
||||
[dashboardParams, onSaveSearch]
|
||||
);
|
||||
const checkoutReturnPath = useMemo(
|
||||
() => `/dashboard${dashboardParams ? `?${dashboardParams}` : ''}`,
|
||||
[dashboardParams]
|
||||
|
|
@ -614,7 +619,7 @@ export default function MapPage({
|
|||
onResetTutorial={!isMobile ? tutorial.resetTutorial : undefined}
|
||||
filterImpacts={filterCounts.impacts}
|
||||
onClearAll={handleClearAll}
|
||||
onSaveSearch={onSaveSearch}
|
||||
onSaveSearch={onSaveSearch ? handleSaveSearch : undefined}
|
||||
savingSearch={savingSearch}
|
||||
destinationDropdownPortal={options?.destinationDropdownPortal}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ export function TravelTimeCard({
|
|||
{t('travel.travelTime', { mode: modes.label(mode) })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className="flex items-center gap-2 md:gap-0.5">
|
||||
<IconButton onClick={() => setShowInfo(true)} title={t('filters.aboutData')}>
|
||||
<InfoIcon className="w-3.5 h-3.5" />
|
||||
</IconButton>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
getPoiFilterMeta,
|
||||
getPoiFilterName,
|
||||
replacePoiFilterKeySelection,
|
||||
usesFixedPoiDistanceScale,
|
||||
} from '../../../lib/poi-distance-filter';
|
||||
import { PoiTypeDropdown } from './PoiTypeDropdown';
|
||||
import { SliderLabels } from './SliderLabels';
|
||||
|
|
@ -69,38 +70,45 @@ export function PoiDistanceFilterCard({
|
|||
const isActive = activeFeature === poiFeature.name;
|
||||
const isPinned = pinnedFeature === poiFeature.name;
|
||||
const hist = selectedFeature.histogram;
|
||||
const fixedDistanceScale = usesFixedPoiDistanceScale(selectedFeature);
|
||||
const dataMin = hist?.min ?? selectedFeature.min ?? 0;
|
||||
const dataMax = hist?.max ?? selectedFeature.max ?? 5;
|
||||
const displayValue =
|
||||
const sliderMin = selectedFeature.min ?? dataMin;
|
||||
const sliderMax = selectedFeature.max ?? dataMax;
|
||||
const rawDisplayValue =
|
||||
isActive && dragValue
|
||||
? dragValue
|
||||
: (filters[poiFeature.name] as [number, number]) || [dataMin, dataMax];
|
||||
const scale = percentileScale;
|
||||
const clampMin = displayValue[0] <= dataMin;
|
||||
const clampMax = displayValue[1] >= dataMax;
|
||||
const isAtMin = displayValue[0] === dataMin;
|
||||
const isAtMax = displayValue[1] === dataMax;
|
||||
: (filters[poiFeature.name] as [number, number]) ||
|
||||
(fixedDistanceScale ? [sliderMin, sliderMax] : [dataMin, dataMax]);
|
||||
const displayValue = fixedDistanceScale
|
||||
? clampPoiFilterRange(rawDisplayValue, selectedFeature)
|
||||
: rawDisplayValue;
|
||||
const scale = fixedDistanceScale ? undefined : percentileScale;
|
||||
const clampMin = fixedDistanceScale ? displayValue[0] <= sliderMin : displayValue[0] <= dataMin;
|
||||
const clampMax = fixedDistanceScale ? displayValue[1] >= sliderMax : displayValue[1] >= dataMax;
|
||||
const isAtMin = fixedDistanceScale ? false : displayValue[0] === dataMin;
|
||||
const isAtMax = fixedDistanceScale ? false : displayValue[1] === dataMax;
|
||||
const sliderValue: [number, number] = scale
|
||||
? [
|
||||
clampMin ? 0 : Math.round(scale.toPercentile(displayValue[0])),
|
||||
clampMax ? 100 : Math.round(scale.toPercentile(displayValue[1])),
|
||||
]
|
||||
: [
|
||||
clampMin ? (selectedFeature.min ?? dataMin) : displayValue[0],
|
||||
clampMax ? (selectedFeature.max ?? dataMax) : displayValue[1],
|
||||
];
|
||||
: [clampMin ? sliderMin : displayValue[0], clampMax ? sliderMax : displayValue[1]];
|
||||
|
||||
const replacePoiFeature = (nextFeatureName: string) => {
|
||||
const nextName = replacePoiFilterKeySelection(poiFeature.name, nextFeatureName);
|
||||
if (nextName === poiFeature.name) return;
|
||||
|
||||
const nextFeature = features.find((feature) => feature.name === nextFeatureName);
|
||||
const nextFixedDistanceScale = usesFixedPoiDistanceScale(nextFeature);
|
||||
const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0;
|
||||
const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax);
|
||||
const nextSliderMin = nextFeature?.min ?? nextDataMin;
|
||||
const nextSliderMax = nextFeature?.max ?? nextDataMax;
|
||||
const nextRange = clampPoiFilterRange(
|
||||
[
|
||||
displayValue[0] <= dataMin ? nextDataMin : displayValue[0],
|
||||
displayValue[1] >= dataMax ? nextDataMax : displayValue[1],
|
||||
clampMin ? (nextFixedDistanceScale ? nextSliderMin : nextDataMin) : displayValue[0],
|
||||
clampMax ? (nextFixedDistanceScale ? nextSliderMax : nextDataMax) : displayValue[1],
|
||||
],
|
||||
nextFeature
|
||||
);
|
||||
|
|
@ -156,14 +164,9 @@ export function PoiDistanceFilterCard({
|
|||
{mobileIcon && <div className="shrink-0 pt-0.5 md:hidden">{mobileIcon}</div>}
|
||||
<div className="min-w-0 flex-1">
|
||||
<Slider
|
||||
min={scale ? 0 : (selectedFeature.min ?? dataMin)}
|
||||
max={scale ? 100 : (selectedFeature.max ?? dataMax)}
|
||||
step={
|
||||
scale
|
||||
? 1
|
||||
: (selectedFeature.step ??
|
||||
((selectedFeature.max ?? dataMax) - (selectedFeature.min ?? dataMin)) / 100)
|
||||
}
|
||||
min={scale ? 0 : sliderMin}
|
||||
max={scale ? 100 : sliderMax}
|
||||
step={scale ? 1 : (selectedFeature.step ?? (sliderMax - sliderMin) / 100)}
|
||||
value={sliderValue}
|
||||
onValueChange={
|
||||
scale
|
||||
|
|
@ -177,16 +180,16 @@ export function PoiDistanceFilterCard({
|
|||
}
|
||||
: ([min, max]) =>
|
||||
onDragChange([
|
||||
min <= (selectedFeature.min ?? dataMin) ? dataMin : min,
|
||||
max >= (selectedFeature.max ?? dataMax) ? dataMax : max,
|
||||
min <= sliderMin ? sliderMin : min,
|
||||
max >= sliderMax ? sliderMax : max,
|
||||
])
|
||||
}
|
||||
onPointerDown={() => onDragStart(poiFeature.name)}
|
||||
onPointerUp={() => onDragEnd()}
|
||||
/>
|
||||
<SliderLabels
|
||||
min={scale ? 0 : (selectedFeature.min ?? dataMin)}
|
||||
max={scale ? 100 : (selectedFeature.max ?? dataMax)}
|
||||
min={scale ? 0 : sliderMin}
|
||||
max={scale ? 100 : sliderMax}
|
||||
value={sliderValue}
|
||||
displayValues={displayValue}
|
||||
isAtMin={isAtMin}
|
||||
|
|
|
|||
|
|
@ -151,13 +151,6 @@ export function DesktopMapPage({
|
|||
</div>
|
||||
|
||||
<div data-tutorial="map" className="flex-1 relative">
|
||||
{tutorial.run && (
|
||||
<div
|
||||
data-tutorial="map-anchor"
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-1/2 top-1/2 z-20 h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white bg-teal-500 shadow-lg ring-4 ring-teal-500/30 dark:border-navy-950"
|
||||
/>
|
||||
)}
|
||||
<Suspense fallback={<MapFallback />}>
|
||||
<Map
|
||||
data={mapData.data}
|
||||
|
|
@ -186,6 +179,7 @@ export function DesktopMapPage({
|
|||
onCurrentLocationFound={onCurrentLocationFound}
|
||||
currentLocation={currentLocation}
|
||||
bounds={mapData.bounds}
|
||||
hideTopCardsWhenNarrow
|
||||
travelTimeEntries={travelTimeEntries}
|
||||
densityLabel={densityLabel}
|
||||
totalCount={totalCount}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export interface MapPageProps {
|
|||
onCheckoutLoginClick?: (returnPath?: string) => void;
|
||||
onCheckoutRegisterClick?: (returnPath?: string) => void;
|
||||
deferTutorial?: boolean;
|
||||
onSaveSearch?: (name: string) => Promise<void>;
|
||||
onSaveSearch?: (name: string, paramsOverride?: string) => Promise<void>;
|
||||
savingSearch?: boolean;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { AuthUser } from '../../hooks/useAuth';
|
||||
import { shortenUrl, prewarmScreenshot } from '../../lib/api';
|
||||
import { shortenUrl, prewarmScreenshot, paramsWithLanguage } from '../../lib/api';
|
||||
import { copyToClipboard } from '../../lib/clipboard';
|
||||
import { DownloadIcon } from './icons/DownloadIcon';
|
||||
import { BookmarkIcon } from './icons/BookmarkIcon';
|
||||
|
|
@ -95,7 +95,7 @@ export default function Header({
|
|||
onLogout: () => void;
|
||||
isMobile: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
|
@ -140,17 +140,21 @@ export default function Header({
|
|||
doCopy(window.location.href);
|
||||
return;
|
||||
}
|
||||
prewarmScreenshot(params);
|
||||
prewarmScreenshot(params, i18n.language);
|
||||
setSharing(true);
|
||||
try {
|
||||
const shortUrl = await shortenUrl(params);
|
||||
const shortUrl = await shortenUrl(params, i18n.language);
|
||||
doCopy(shortUrl);
|
||||
} catch {
|
||||
doCopy(window.location.href);
|
||||
doCopy(
|
||||
activePage === 'dashboard'
|
||||
? `${window.location.origin}/dashboard?${paramsWithLanguage(params, i18n.language)}`
|
||||
: window.location.href
|
||||
);
|
||||
} finally {
|
||||
setSharing(false);
|
||||
}
|
||||
}, [activePage, dashboardParams, doCopy]);
|
||||
}, [activePage, dashboardParams, doCopy, i18n.language]);
|
||||
|
||||
const navLink = (page: Page, e: React.MouseEvent, hash?: string) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ export default function LicenseSuccessModal({
|
|||
: t('licenseSuccess.description');
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50">
|
||||
{isSuccess && (
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
{particles.map((p) => (
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import {
|
|||
getPoiDistanceFeatureName,
|
||||
getPoiDistanceFilterKeyId,
|
||||
normalizePoiDistanceFilters,
|
||||
usesFixedPoiDistanceScale,
|
||||
type PoiFilterName,
|
||||
} from '../lib/poi-distance-filter';
|
||||
|
||||
|
|
@ -256,6 +257,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
? features.find((feature) => feature.name === defaultPoiFeatureName)
|
||||
: undefined;
|
||||
if (!defaultPoiFeatureName) return prev;
|
||||
const fixedDistanceScale = usesFixedPoiDistanceScale(defaultPoiFeature);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
|
|
@ -264,8 +266,12 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
defaultPoiFeatureName,
|
||||
poiDistanceFilterIdRef.current++
|
||||
)]: [
|
||||
defaultPoiFeature?.histogram?.min ?? defaultPoiFeature?.min ?? 0,
|
||||
defaultPoiFeature?.histogram?.max ?? defaultPoiFeature?.max ?? 5,
|
||||
fixedDistanceScale
|
||||
? (defaultPoiFeature?.min ?? 0)
|
||||
: (defaultPoiFeature?.histogram?.min ?? defaultPoiFeature?.min ?? 0),
|
||||
fixedDistanceScale
|
||||
? (defaultPoiFeature?.max ?? 5)
|
||||
: (defaultPoiFeature?.histogram?.max ?? defaultPoiFeature?.max ?? 5),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
12
frontend/src/lib/active-filter-scroll.ts
Normal file
12
frontend/src/lib/active-filter-scroll.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export function findActiveFilterElement(root: ParentNode | null, filterName: string) {
|
||||
if (!root) return null;
|
||||
|
||||
const cards = root.querySelectorAll<HTMLElement>('[data-filter-name]');
|
||||
for (let i = cards.length - 1; i >= 0; i -= 1) {
|
||||
if (cards[i].dataset.filterName === filterName) {
|
||||
return cards[i];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { FeatureMeta } from '../types';
|
||||
import { apiUrl, assertOk, buildFilterString, isAbortError } from './api';
|
||||
import { apiUrl, assertOk, buildFilterString, isAbortError, paramsWithLanguage } from './api';
|
||||
import { createSchoolFilterKey } from './school-filter';
|
||||
import { createSpecificCrimeFilterKey } from './crime-filter';
|
||||
import { createElectionVoteShareFilterKey } from './election-filter';
|
||||
|
|
@ -38,6 +38,11 @@ describe('api utilities', () => {
|
|||
expect(isAbortError(regular)).toBe(false);
|
||||
});
|
||||
|
||||
it('adds supported language parameters without overriding explicit languages', () => {
|
||||
expect(paramsWithLanguage('lat=51.5&lon=-0.1', 'fr-FR')).toBe('lat=51.5&lon=-0.1&lang=fr');
|
||||
expect(paramsWithLanguage('lat=51.5&lang=de', 'fr')).toBe('lat=51.5&lang=de');
|
||||
});
|
||||
|
||||
it('serializes numeric, absolute, and enum filters for backend routes', () => {
|
||||
const features: FeatureMeta[] = [
|
||||
{ name: 'Last known price', type: 'numeric', min: 0, max: 1_000_000 },
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { getElectionVoteShareFeatureName } from './election-filter';
|
|||
import { getEthnicityFeatureName } from './ethnicity-filter';
|
||||
import { getPoiDistanceFeatureName } from './poi-distance-filter';
|
||||
|
||||
const SCREENSHOT_LANGUAGES = new Set(['en', 'fr', 'de', 'zh', 'hi', 'hu']);
|
||||
|
||||
export function logNonAbortError(label: string, error: unknown): void {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return;
|
||||
|
|
@ -42,6 +44,36 @@ export function apiUrl(endpoint: string, params?: URLSearchParams): string {
|
|||
return query ? `${path}?${query}` : path;
|
||||
}
|
||||
|
||||
function toSupportedLanguage(value: string | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const lower = value.toLowerCase();
|
||||
if (SCREENSHOT_LANGUAGES.has(lower)) return lower;
|
||||
|
||||
const prefix = lower.split('-')[0];
|
||||
if (SCREENSHOT_LANGUAGES.has(prefix)) return prefix;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function browserLanguage(): string | null {
|
||||
if (typeof navigator === 'undefined') return null;
|
||||
const languages = navigator.languages?.length ? navigator.languages : [navigator.language];
|
||||
for (const language of languages) {
|
||||
const supported = toSupportedLanguage(language);
|
||||
if (supported) return supported;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function paramsWithLanguage(params: string, language?: string): string {
|
||||
const qs = new URLSearchParams(params.replace(/^\?/, ''));
|
||||
if (!qs.has('lang')) {
|
||||
const supported = toSupportedLanguage(language) ?? browserLanguage();
|
||||
if (supported) qs.set('lang', supported);
|
||||
}
|
||||
return qs.toString();
|
||||
}
|
||||
|
||||
export async function fetchWithRetry<T>(
|
||||
url: string,
|
||||
onSuccess: (data: T) => void,
|
||||
|
|
@ -65,17 +97,19 @@ export async function fetchWithRetry<T>(
|
|||
}
|
||||
|
||||
/** Fire-and-forget request to pre-warm the screenshot cache for OG images. */
|
||||
export function prewarmScreenshot(params: string): void {
|
||||
fetch(apiUrl('screenshot', new URLSearchParams(`og=1&${params}`)), authHeaders()).catch(() => {}); // best-effort, don't care if it fails
|
||||
export function prewarmScreenshot(params: string, language?: string): void {
|
||||
const qs = new URLSearchParams(paramsWithLanguage(params, language));
|
||||
qs.set('og', '1');
|
||||
fetch(apiUrl('screenshot', qs), authHeaders()).catch(() => {}); // best-effort, don't care if it fails
|
||||
}
|
||||
|
||||
export async function shortenUrl(params: string): Promise<string> {
|
||||
export async function shortenUrl(params: string, language?: string): Promise<string> {
|
||||
const res = await fetch(
|
||||
apiUrl('shorten'),
|
||||
authHeaders({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ params }),
|
||||
body: JSON.stringify({ params: paramsWithLanguage(params, language) }),
|
||||
})
|
||||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ describe('external property search URLs', () => {
|
|||
expect(new URL(urls!.zoopla).searchParams.get('radius')).toBe('0.25');
|
||||
});
|
||||
|
||||
it('uses Rightmove this-area-only radius when an exact postcode identifier is provided', () => {
|
||||
it('uses Rightmove quarter-mile radius when an exact postcode identifier is provided', () => {
|
||||
const urls = buildPropertySearchUrls({
|
||||
location: {
|
||||
lat: 51.501,
|
||||
|
|
@ -93,7 +93,28 @@ describe('external property search URLs', () => {
|
|||
|
||||
expect(rightmove.searchParams.get('searchLocation')).toBe('SW1A 1AA');
|
||||
expect(rightmove.searchParams.get('locationIdentifier')).toBe('POSTCODE^837246');
|
||||
expect(rightmove.searchParams.get('radius')).toBe('0.25');
|
||||
});
|
||||
|
||||
it('uses Rightmove outcode-only radius when an outcode identifier is provided', () => {
|
||||
const urls = buildPropertySearchUrls({
|
||||
location: {
|
||||
lat: 51.501,
|
||||
lon: -0.141,
|
||||
resolution: 8,
|
||||
postcode: 'SW1A 1AA',
|
||||
isPostcode: false,
|
||||
},
|
||||
rightmoveLocationId: 'OUTCODE^2506',
|
||||
filters: {},
|
||||
});
|
||||
|
||||
const rightmove = new URL(urls!.rightmove!);
|
||||
|
||||
expect(rightmove.searchParams.get('locationIdentifier')).toBe('OUTCODE^2506');
|
||||
expect(rightmove.searchParams.get('radius')).toBe('0.0');
|
||||
expect(new URL(urls!.onthemarket).searchParams.get('radius')).toBe('0.5');
|
||||
expect(new URL(urls!.zoopla).searchParams.get('radius')).toBe('0.5');
|
||||
});
|
||||
|
||||
it('builds a same-origin Rightmove redirect for exact postcode clicks', () => {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,10 @@ export const H3_RADIUS_MILES: Record<number, number> = {
|
|||
12: 1,
|
||||
};
|
||||
|
||||
export const POSTCODE_RADIUS_MILES = 0.25;
|
||||
|
||||
const RIGHTMOVE_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 30, 40];
|
||||
const RIGHTMOVE_OUTCODE_RADIUS_MILES = '0.0';
|
||||
const OTM_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 30, 40];
|
||||
const ZOOPLA_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 25, 30];
|
||||
|
||||
|
|
@ -111,7 +114,7 @@ export function buildPropertySearchUrls({
|
|||
const { postcode, resolution, isPostcode } = location;
|
||||
if (!postcode) return null;
|
||||
|
||||
const radiusMiles = isPostcode ? 0 : (H3_RADIUS_MILES[resolution] ?? 1);
|
||||
const radiusMiles = isPostcode ? POSTCODE_RADIUS_MILES : (H3_RADIUS_MILES[resolution] ?? 1);
|
||||
|
||||
const priceFilter = filters['Estimated current price'] ?? filters['Last known price'];
|
||||
const minPrice =
|
||||
|
|
@ -150,8 +153,8 @@ export function buildPropertySearchUrls({
|
|||
rmParams.set('locationIdentifier', rightmoveLocationId);
|
||||
rmParams.set(
|
||||
'radius',
|
||||
isPostcode && rightmoveLocationId.startsWith('POSTCODE^')
|
||||
? '0.0'
|
||||
rightmoveLocationId.startsWith('OUTCODE^')
|
||||
? RIGHTMOVE_OUTCODE_RADIUS_MILES
|
||||
: String(nearestRadius(radiusMiles, RIGHTMOVE_RADII))
|
||||
);
|
||||
if (minPrice !== undefined)
|
||||
|
|
|
|||
|
|
@ -5,15 +5,17 @@ import {
|
|||
POI_COUNT_2KM_FILTER_NAME,
|
||||
POI_DISTANCE_FILTER_NAME,
|
||||
TRANSPORT_DISTANCE_FILTER_NAME,
|
||||
clampPoiFilterRange,
|
||||
getPoiFilterFeatureOptions,
|
||||
getPoiFilterName,
|
||||
} from './poi-distance-filter';
|
||||
|
||||
const numeric = (name: string): FeatureMeta => ({
|
||||
const numeric = (name: string, overrides: Partial<FeatureMeta> = {}): FeatureMeta => ({
|
||||
name,
|
||||
type: 'numeric',
|
||||
min: 0,
|
||||
max: 5,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('poi-distance-filter', () => {
|
||||
|
|
@ -57,4 +59,13 @@ describe('poi-distance-filter', () => {
|
|||
);
|
||||
expect(getPoiFilterName('Number of amenities (Bus stop) within 2km')).toBeNull();
|
||||
});
|
||||
|
||||
it('clamps fixed amenity distance scales to the 0-5km slider bounds', () => {
|
||||
const feature = numeric('Distance to nearest amenity (Cafe) (km)', {
|
||||
absolute: true,
|
||||
histogram: { min: 0.2, max: 12, p1: 0.3, p99: 9, counts: [1, 2, 3] },
|
||||
});
|
||||
|
||||
expect(clampPoiFilterRange([-1, 8], feature)).toEqual([0, 5]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -136,6 +136,10 @@ export function isPoiDistanceFeatureName(name: string): boolean {
|
|||
return isDynamicPoiDistanceFeatureName(name);
|
||||
}
|
||||
|
||||
export function usesFixedPoiDistanceScale(feature?: FeatureMeta): boolean {
|
||||
return Boolean(feature?.absolute && isPoiDistanceFeatureName(feature.name));
|
||||
}
|
||||
|
||||
export function isPoiFilterFeatureName(name: string): boolean {
|
||||
return getPoiMetric(name) != null;
|
||||
}
|
||||
|
|
@ -263,6 +267,7 @@ export function getPoiFilterMeta(features: FeatureMeta[], filterName: PoiFilterN
|
|||
detail: config.detail,
|
||||
source: sourceFeature?.source ?? 'osm-pois',
|
||||
suffix: config.suffix,
|
||||
absolute: sourceFeature?.absolute,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -295,8 +300,12 @@ export function clampPoiFilterRange(
|
|||
value: [number, number],
|
||||
feature?: FeatureMeta
|
||||
): [number, number] {
|
||||
const min = feature?.histogram?.min ?? feature?.min ?? 0;
|
||||
const max = feature?.histogram?.max ?? feature?.max ?? Math.max(1, value[1]);
|
||||
const min = usesFixedPoiDistanceScale(feature)
|
||||
? (feature?.min ?? 0)
|
||||
: (feature?.histogram?.min ?? feature?.min ?? 0);
|
||||
const max = usesFixedPoiDistanceScale(feature)
|
||||
? (feature?.max ?? 5)
|
||||
: (feature?.histogram?.max ?? feature?.max ?? Math.max(1, value[1]));
|
||||
return [Math.max(min, Math.min(value[0], max)), Math.max(min, Math.min(value[1], max))];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import {
|
|||
isPoiDistanceFilterName,
|
||||
type PoiFilterName,
|
||||
} from './poi-distance-filter';
|
||||
import { dedupeTravelTimeEntries } from './travel-params';
|
||||
|
||||
const POI_NONE_PARAM = '__none';
|
||||
|
||||
|
|
@ -280,7 +281,7 @@ export function parseUrlState(): UrlState {
|
|||
entries.push({ mode, slug, label, timeRange, useBest });
|
||||
}
|
||||
if (entries.length > 0) {
|
||||
result.travelTime = { entries };
|
||||
result.travelTime = { entries: dedupeTravelTimeEntries(entries) };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -379,7 +380,7 @@ export function stateToParams(
|
|||
|
||||
// Travel time: repeated `tt` params
|
||||
if (travelTimeEntries) {
|
||||
for (const entry of travelTimeEntries) {
|
||||
for (const entry of dedupeTravelTimeEntries(travelTimeEntries)) {
|
||||
if (!entry.slug) continue;
|
||||
let val = `${entry.mode}:${entry.slug}:${encodeURIComponent(entry.label)}`;
|
||||
if (entry.useBest) val += ':b';
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ export interface PricePoint {
|
|||
export interface FilterExclusion {
|
||||
name: string;
|
||||
kind: 'numeric' | 'enum' | 'poi' | 'travel';
|
||||
direction: 'lower_min' | 'raise_max' | 'allow_value';
|
||||
direction: 'lower_min' | 'raise_max' | 'allow_value' | 'missing_value';
|
||||
value?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue