This commit is contained in:
Andras Schmelczer 2026-05-06 22:40:46 +01:00
parent 28323f145e
commit 94f9c0d594
76 changed files with 3238 additions and 1230 deletions

View file

@ -66,7 +66,11 @@ export default function FeatureBrowser({
const filtered = useMemo(() => {
if (!search) return availableFeatures;
const lower = search.toLowerCase();
return availableFeatures.filter((f) => f.name.toLowerCase().includes(lower));
return availableFeatures.filter((f) =>
[f.name, f.description, f.detail, f.group]
.filter((value): value is string => Boolean(value))
.some((value) => value.toLowerCase().includes(lower))
);
}, [availableFeatures, search]);
const grouped = useMemo(() => groupFeaturesByCategory(filtered), [filtered]);

View file

@ -29,6 +29,17 @@ import {
type TravelTimeEntry,
travelFieldKey,
} from '../../hooks/useTravelTime';
import {
SPECIFIC_CRIMES_FILTER_NAME,
SPECIFIC_CRIME_FEATURE_NAMES,
clampSpecificCrimeRange,
getDefaultSpecificCrimeFeatureName,
getSpecificCrimeFeatureName,
getSpecificCrimeFilterMeta,
isSpecificCrimeFeatureName,
isSpecificCrimeFilterName,
replaceSpecificCrimeFilterKeySelection,
} from '../../lib/crime-filter';
import {
SCHOOL_FILTER_NAME,
clampSchoolRange,
@ -399,6 +410,210 @@ function SchoolFilterCard({
);
}
function SpecificCrimeFilterCard({
features,
crimeFeature,
filters,
activeFeature,
dragValue,
pinnedFeature,
filterImpact,
percentileScale,
onFilterChange,
onDragStart,
onDragChange,
onDragEnd,
onTogglePin,
onShowInfo,
onRemove,
}: {
features: FeatureMeta[];
crimeFeature: FeatureMeta;
filters: FeatureFilters;
activeFeature: string | null;
dragValue: [number, number] | null;
pinnedFeature: string | null;
filterImpact?: number;
percentileScale?: PercentileScale;
onFilterChange: (name: string, value: [number, number] | string[]) => void;
onDragStart: (name: string) => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
onTogglePin: (name: string) => void;
onShowInfo: (feature: FeatureMeta) => void;
onRemove: () => void;
}) {
const specificCrimeMeta = getSpecificCrimeFilterMeta(features);
const crimeOptions = SPECIFIC_CRIME_FEATURE_NAMES.map((name) =>
features.find((feature) => feature.name === name)
).filter((feature): feature is FeatureMeta => Boolean(feature));
const selectedFeatureName =
getSpecificCrimeFeatureName(crimeFeature.name) ?? getDefaultSpecificCrimeFeatureName(features);
const selectedFeature = selectedFeatureName
? features.find((feature) => feature.name === selectedFeatureName)
: undefined;
if (!selectedFeature || crimeOptions.length === 0 || !selectedFeatureName) return null;
const isActive = activeFeature === crimeFeature.name;
const isPinned = pinnedFeature === crimeFeature.name;
const hist = selectedFeature.histogram;
const dataMin = hist?.min ?? selectedFeature.min ?? 0;
const dataMax = hist?.max ?? selectedFeature.max ?? 100;
const displayValue =
isActive && dragValue
? dragValue
: (filters[crimeFeature.name] as [number, number]) || [dataMin, dataMax];
const scale = percentileScale;
const clampMin = displayValue[0] <= dataMin;
const clampMax = displayValue[1] >= dataMax;
const isAtMin = displayValue[0] === dataMin;
const isAtMax = displayValue[1] === dataMax;
const sliderValue: [number, number] = scale
? [
clampMin ? 0 : Math.round(scale.toPercentile(displayValue[0])),
clampMax ? 100 : Math.round(scale.toPercentile(displayValue[1])),
]
: [
clampMin ? (selectedFeature.min ?? dataMin) : displayValue[0],
clampMax ? (selectedFeature.max ?? dataMax) : displayValue[1],
];
const replaceCrimeFeature = (nextFeatureName: string) => {
const nextName = replaceSpecificCrimeFilterKeySelection(crimeFeature.name, nextFeatureName);
if (nextName === crimeFeature.name) return;
const nextFeature = features.find((feature) => feature.name === nextFeatureName);
const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0;
const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax);
const nextRange = clampSpecificCrimeRange(
[
displayValue[0] <= dataMin ? nextDataMin : displayValue[0],
displayValue[1] >= dataMax ? nextDataMax : displayValue[1],
],
nextFeature
);
onFilterChange(nextName, nextRange);
if (isPinned) onTogglePin(nextName);
};
const mobileIconClass = 'w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0';
const mobileIcon =
getFeatureIcon(selectedFeature.name, mobileIconClass) ||
(() => {
const G = selectedFeature.group ? getGroupIcon(selectedFeature.group) : null;
return G ? <G className={mobileIconClass} /> : null;
})();
return (
<div
data-filter-name={SPECIFIC_CRIMES_FILTER_NAME}
className={`space-y-2 rounded-lg border border-warm-200 bg-white px-2 py-2 shadow-sm dark:border-warm-700 dark:bg-warm-800 ${
isActive
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
: isPinned
? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20'
: ''
}`}
>
<div className="relative z-10 flex items-center justify-between gap-1">
<FeatureLabel
feature={specificCrimeMeta}
size="sm"
className="min-w-0 shrink"
hideIconOnMobile
/>
<FeatureActions
feature={selectedFeature}
actionName={crimeFeature.name}
isPinned={isPinned}
isPreviewing={isActive}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onRemove={onRemove}
/>
</div>
<div>
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
Crime type
</label>
<div className="relative">
<select
value={selectedFeatureName}
onChange={(e) => replaceCrimeFeature(e.target.value)}
className="w-full appearance-none rounded-md border border-warm-200 bg-warm-50 px-2 py-1.5 pr-8 text-sm font-medium text-navy-950 shadow-inner outline-none transition-colors hover:bg-white focus:border-teal-400 focus:ring-2 focus:ring-teal-200 dark:border-warm-700 dark:bg-navy-900 dark:text-warm-100 dark:hover:bg-navy-800 dark:focus:ring-teal-900/50"
>
{crimeOptions.map((option) => (
<option key={option.name} value={option.name}>
{ts(option.name)}
</option>
))}
</select>
<ChevronIcon
direction="down"
className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-warm-400 dark:text-warm-500"
/>
</div>
</div>
<div className="flex items-start gap-1.5 md:block">
{mobileIcon && <div className="shrink-0 pt-0.5 md:hidden">{mobileIcon}</div>}
<div className="min-w-0 flex-1">
<Slider
min={scale ? 0 : (selectedFeature.min ?? dataMin)}
max={scale ? 100 : (selectedFeature.max ?? dataMax)}
step={
scale
? 1
: (selectedFeature.step ??
((selectedFeature.max ?? dataMax) - (selectedFeature.min ?? dataMin)) / 100)
}
value={sliderValue}
onValueChange={
scale
? ([pMin, pMax]) => {
const step = selectedFeature.step ?? 1;
const snap = (v: number) => Math.round(v / step) * step;
onDragChange([
pMin <= 0 ? dataMin : snap(scale.toValue(pMin)),
pMax >= 100 ? dataMax : snap(scale.toValue(pMax)),
]);
}
: ([min, max]) =>
onDragChange([
min <= (selectedFeature.min ?? dataMin) ? dataMin : min,
max >= (selectedFeature.max ?? dataMax) ? dataMax : max,
])
}
onPointerDown={() => onDragStart(crimeFeature.name)}
onPointerUp={() => onDragEnd()}
/>
<SliderLabels
min={scale ? 0 : (selectedFeature.min ?? dataMin)}
max={scale ? 100 : (selectedFeature.max ?? dataMax)}
value={sliderValue}
displayValues={displayValue}
isAtMin={isAtMin}
isAtMax={isAtMax}
raw={selectedFeature.raw}
feature={selectedFeature}
onValueChange={(v) =>
onFilterChange(crimeFeature.name, clampSpecificCrimeRange(v, selectedFeature))
}
/>
{filterImpact != null && filterImpact > 0 && (
<p className="-mt-1 ml-2.5 text-[10px] text-warm-400 dark:text-warm-500">
+{formatNumber(filterImpact)} without this filter
</p>
)}
</div>
</div>
</div>
);
}
interface FiltersProps {
features: FeatureMeta[];
filters: FeatureFilters;
@ -492,6 +707,11 @@ export default memo(function Filters({
const defaultSchoolFeatureName = useMemo(() => getDefaultSchoolFeatureName(features), [features]);
const schoolMeta = useMemo(() => getSchoolFilterMeta(features), [features]);
const defaultSpecificCrimeFeatureName = useMemo(
() => getDefaultSpecificCrimeFeatureName(features),
[features]
);
const specificCrimeMeta = useMemo(() => getSpecificCrimeFilterMeta(features), [features]);
const schoolFilterItems = useMemo(() => {
return Object.keys(filters)
.filter(isSchoolFilterName)
@ -503,9 +723,21 @@ export default memo(function Filters({
return { ...(backendFeature ?? schoolMeta), name, group: 'Education' };
});
}, [filters, features, schoolMeta]);
const specificCrimeFilterItems = useMemo(() => {
return Object.keys(filters)
.filter(isSpecificCrimeFilterName)
.map((name) => {
const backendName = getSpecificCrimeFeatureName(name);
const backendFeature = backendName
? features.find((feature) => feature.name === backendName)
: undefined;
return { ...(backendFeature ?? specificCrimeMeta), name, group: 'Crime' };
});
}, [filters, features, specificCrimeMeta]);
const availableFeatures = useMemo(() => {
const result: FeatureMeta[] = [];
let insertedSchoolFilter = false;
let insertedSpecificCrimeFilter = false;
for (const feature of features) {
if (isSchoolFilterName(feature.name)) {
@ -515,14 +747,29 @@ export default memo(function Filters({
}
continue;
}
if (isSpecificCrimeFeatureName(feature.name)) {
if (defaultSpecificCrimeFeatureName && !insertedSpecificCrimeFilter) {
result.push(specificCrimeMeta);
insertedSpecificCrimeFilter = true;
}
continue;
}
if (!enabledFeatures.has(feature.name)) result.push(feature);
}
return result;
}, [features, enabledFeatures, defaultSchoolFeatureName, schoolMeta]);
}, [
features,
enabledFeatures,
defaultSchoolFeatureName,
schoolMeta,
defaultSpecificCrimeFeatureName,
specificCrimeMeta,
]);
const enabledFeatureList = useMemo(() => {
const result: FeatureMeta[] = [];
let insertedSchoolFilter = false;
let insertedSpecificCrimeFilters = false;
for (const feature of features) {
if (isSchoolFilterName(feature.name)) {
@ -532,11 +779,18 @@ export default memo(function Filters({
}
continue;
}
if (isSpecificCrimeFeatureName(feature.name)) {
if (!insertedSpecificCrimeFilters) {
result.push(...specificCrimeFilterItems);
insertedSpecificCrimeFilters = true;
}
continue;
}
if (enabledFeatures.has(feature.name)) result.push(feature);
}
return result;
}, [features, enabledFeatures, schoolFilterItems]);
}, [features, enabledFeatures, schoolFilterItems, specificCrimeFilterItems]);
const containerRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
@ -556,11 +810,17 @@ export default memo(function Filters({
onAddFilter(SCHOOL_FILTER_NAME);
return;
}
if (name === SPECIFIC_CRIMES_FILTER_NAME) {
if (!defaultSpecificCrimeFeatureName) return;
pendingScrollRef.current = SPECIFIC_CRIMES_FILTER_NAME;
onAddFilter(SPECIFIC_CRIMES_FILTER_NAME);
return;
}
pendingScrollRef.current = name;
onAddFilter(name);
},
[defaultSchoolFeatureName, onAddFilter]
[defaultSchoolFeatureName, defaultSpecificCrimeFeatureName, onAddFilter]
);
const handleRemoveSchoolFilter = useCallback(
@ -793,6 +1053,66 @@ export default memo(function Filters({
);
}
if (isSpecificCrimeFilterName(feature.name)) {
const specificCrimeBackendName = getSpecificCrimeFeatureName(feature.name);
return (
<Fragment key={feature.name}>
{featureIdx === travelInsertIdx &&
travelTimeEntries.map((entry, index) => (
<div key={`tt_${index}`} data-filter-name={`tt_${index}`}>
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
useBest={entry.useBest}
isPinned={pinnedFeature === travelFieldKey(entry)}
isActive={activeFeature === travelFieldKey(entry)}
dragValue={activeFeature === travelFieldKey(entry) ? dragValue : null}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label, lat, lon) =>
onTravelTimeSetDestination(index, slug, label, lat, lon)
}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onDragStart={() => onDragStart(travelFieldKey(entry))}
onDragChange={onDragChange}
onDragEnd={() => onTravelTimeDragEnd(index)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
destinationDropdownPortal={destinationDropdownPortal}
/>
</div>
))}
<SpecificCrimeFilterCard
features={features}
crimeFeature={feature}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
pinnedFeature={pinnedFeature}
filterImpact={
specificCrimeBackendName
? filterImpacts?.[specificCrimeBackendName]
: undefined
}
percentileScale={
specificCrimeBackendName
? percentileScales.get(specificCrimeBackendName)
: undefined
}
onFilterChange={onFilterChange}
onDragStart={onDragStart}
onDragChange={onDragChange}
onDragEnd={onDragEnd}
onTogglePin={onTogglePin}
onShowInfo={setActiveInfoFeature}
onRemove={() => onRemoveFilter(feature.name)}
/>
</Fragment>
);
}
if (feature.type === 'enum') {
const selectedValues = (filters[feature.name] as string[]) || [];
const allValues = feature.values || [];
@ -1063,11 +1383,13 @@ export default memo(function Filters({
<div className="md:flex-1 md:min-h-0 md:overflow-y-auto">
<FeatureBrowser
availableFeatures={availableFeatures}
allFeatures={[...features, schoolMeta]}
allFeatures={[...features, schoolMeta, specificCrimeMeta]}
pinnedFeature={
pinnedFeature && isSchoolFilterName(pinnedFeature)
? SCHOOL_FILTER_NAME
: pinnedFeature
: pinnedFeature && isSpecificCrimeFilterName(pinnedFeature)
? SPECIFIC_CRIMES_FILTER_NAME
: pinnedFeature
}
onAddFilter={handleAddAndScroll}
onTogglePin={(name) => {
@ -1075,6 +1397,10 @@ export default memo(function Filters({
if (defaultSchoolFeatureName) onTogglePin(defaultSchoolFeatureName);
return;
}
if (name === SPECIFIC_CRIMES_FILTER_NAME) {
if (defaultSpecificCrimeFeatureName) onTogglePin(defaultSpecificCrimeFeatureName);
return;
}
onTogglePin(name);
}}
onNavigateToSource={onNavigateToSource}
@ -1203,7 +1529,7 @@ export default memo(function Filters({
{clearSaveError && (
<p className="text-sm text-red-600 dark:text-red-300">{clearSaveError}</p>
)}
<div className="flex gap-3 justify-end">
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-center">
<button
type="button"
onClick={handleClearWithoutSaving}
@ -1214,7 +1540,7 @@ export default memo(function Filters({
<button
type="submit"
disabled={!clearSaveName.trim() || savingSearch}
className="flex items-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
className="flex items-center justify-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
>
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{savingSearch ? t('saveSearch.saving') : t('filters.saveAndClear')}

View file

@ -4,6 +4,7 @@ import type { FeatureFilters, FeatureMeta } from '../../types';
import { formatValue } from '../../lib/format';
import { ts } from '../../i18n/server';
import { SCHOOL_FILTER_NAME, getSchoolBackendFeatureName } from '../../lib/school-filter';
import { getSpecificCrimeFeatureName } from '../../lib/crime-filter';
interface HoverCardData {
count: number;
@ -42,7 +43,9 @@ export default memo(function HoverCard({
// Show stats for active filters (up to 4)
for (const name of activeFilterNames.slice(0, 4)) {
const backendName = getSchoolBackendFeatureName(name) ?? name;
const schoolBackendName = getSchoolBackendFeatureName(name);
const specificCrimeFeatureName = getSpecificCrimeFeatureName(name);
const backendName = schoolBackendName ?? specificCrimeFeatureName ?? name;
const val = data[`avg_${backendName}`] ?? data[`min_${backendName}`];
if (val == null || typeof val !== 'number') continue;
const meta = featureMap.get(backendName);
@ -51,7 +54,7 @@ export default memo(function HoverCard({
if (label) results.push({ name: backendName, value: ts(label) });
} else {
results.push({
name: backendName === name ? name : SCHOOL_FILTER_NAME,
name: schoolBackendName ? SCHOOL_FILTER_NAME : backendName,
value: formatValue(val, meta),
});
}

View file

@ -1,6 +1,6 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import type { PostcodeGeometry } from '../../types';
import type { MapFlyToOptions, PostcodeGeometry } from '../../types';
import { authHeaders } from '../../lib/api';
import { useIsMobile } from '../../hooks/useIsMobile';
import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch';
@ -8,6 +8,8 @@ import { PlaceSearchInput } from '../ui/PlaceSearchInput';
import { LocateIcon } from '../ui/icons/LocateIcon';
import { SearchIcon } from '../ui/icons/SearchIcon';
declare const __DEV__: boolean;
export interface SearchedLocation {
postcode: string;
geometry: PostcodeGeometry;
@ -35,13 +37,18 @@ const ZOOM_FOR_TYPE: Record<string, number> = {
isolated_dwelling: 16,
};
const DEV_CURRENT_LOCATION = {
latitude: 51.5033635,
longitude: -0.1276248,
};
export default function LocationSearch({
onFlyTo,
onLocationSearched,
onCurrentLocationFound,
onMouseEnter,
}: {
onFlyTo: (lat: number, lng: number, zoom: number) => void;
onFlyTo: (lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void;
onLocationSearched?: (postcode: SearchedLocation | null) => void;
onCurrentLocationFound?: (lat: number, lng: number) => void;
onMouseEnter?: () => void;
@ -162,7 +169,7 @@ export default function LocationSearch({
const [locating, setLocating] = useState(false);
const locateUser = useCallback(async () => {
if (!navigator.geolocation) {
if (!__DEV__ && !navigator.geolocation) {
setError(t('locationSearch.geolocationUnsupported'));
return;
}
@ -170,15 +177,27 @@ export default function LocationSearch({
setLocating(true);
search.close();
try {
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: true,
timeout: 10000,
});
});
const { latitude, longitude } = position.coords;
onFlyTo(latitude, longitude, 17);
onCurrentLocationFound?.(latitude, longitude);
const { latitude, longitude } = __DEV__
? DEV_CURRENT_LOCATION
: await new Promise<GeolocationCoordinates>((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error('Geolocation unsupported'));
return;
}
navigator.geolocation.getCurrentPosition(
(position) => resolve(position.coords),
reject,
{
enableHighAccuracy: true,
timeout: 10000,
}
);
});
if (onCurrentLocationFound) {
onCurrentLocationFound(latitude, longitude);
} else {
onFlyTo(latitude, longitude, 17);
}
search.clear();
if (isMobile) setExpanded(false);
} catch {

View file

@ -12,6 +12,7 @@ import type {
POI,
FeatureMeta,
Bounds,
MapFlyToOptions,
} from '../../types';
import {
@ -19,6 +20,7 @@ import {
getBoundsFromViewState,
getMapStyle,
getPoiIconUrl,
getMapCenterForTargetScreenPoint,
} from '../../lib/map-utils';
import {
INITIAL_VIEW_STATE,
@ -56,7 +58,9 @@ interface MapProps {
onHexagonClick: (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => void;
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
initialViewState?: ViewState;
flyToRef?: React.MutableRefObject<((lat: number, lng: number, zoom: number) => void) | null>;
flyToRef?: React.MutableRefObject<
((lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void) | null
>;
theme?: 'light' | 'dark';
screenshotMode?: boolean;
ogMode?: boolean;
@ -80,6 +84,61 @@ interface Dimensions {
height: number;
}
function resolveInset(pixelValue: number | undefined, ratioValue: number | undefined, size: number) {
return Math.max(0, (pixelValue ?? 0) + (ratioValue ?? 0) * size);
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function getMapRelativeVisibleAreaCenter(dimensions: Dimensions, options?: MapFlyToOptions) {
const area = options?.visibleArea;
const leftInset = resolveInset(area?.left, area?.leftRatio, dimensions.width);
const rightInset = resolveInset(area?.right, area?.rightRatio, dimensions.width);
const topInset = resolveInset(area?.top, area?.topRatio, dimensions.height);
const bottomInset = resolveInset(area?.bottom, area?.bottomRatio, dimensions.height);
const left = Math.min(dimensions.width, leftInset);
const right = Math.max(left, dimensions.width - Math.min(dimensions.width, rightInset));
const top = Math.min(dimensions.height, topInset);
const bottom = Math.max(top, dimensions.height - Math.min(dimensions.height, bottomInset));
return {
x: (left + right) / 2,
y: (top + bottom) / 2,
};
}
function getViewportRelativeVisibleAreaCenter(
dimensions: Dimensions,
container: HTMLDivElement | null,
options?: MapFlyToOptions
) {
const area = options?.visibleViewportArea;
if (!area || !container) return null;
const rect = container.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const viewportLeft = resolveInset(area.left, area.leftRatio, viewportWidth);
const viewportRight =
viewportWidth - resolveInset(area.right, area.rightRatio, viewportWidth);
const viewportTop = resolveInset(area.top, area.topRatio, viewportHeight);
const viewportBottom =
viewportHeight - resolveInset(area.bottom, area.bottomRatio, viewportHeight);
const left = clamp(viewportLeft - rect.left, 0, dimensions.width);
const right = clamp(viewportRight - rect.left, left, dimensions.width);
const top = clamp(viewportTop - rect.top, 0, dimensions.height);
const bottom = clamp(viewportBottom - rect.top, top, dimensions.height);
return {
x: (left + right) / 2,
y: (top + bottom) / 2,
};
}
interface DeckWithPrivateDraw {
_drawLayers?: (
redrawReason: string,
@ -255,9 +314,27 @@ export default memo(function Map({
if (screenshotMode) window.__map_idle = true;
}, [screenshotMode]);
const handleFlyTo = useCallback((lat: number, lng: number, zoom: number) => {
setInternalViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom }));
}, []);
const handleFlyTo = useCallback(
(lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => {
setInternalViewState((prev) => {
const targetPoint =
getViewportRelativeVisibleAreaCenter(dimensions, containerRef.current, options) ??
getMapRelativeVisibleAreaCenter(dimensions, options);
const center = getMapCenterForTargetScreenPoint(
lat,
lng,
zoom,
dimensions.width,
dimensions.height,
targetPoint.x,
targetPoint.y
);
return { ...prev, ...center, zoom };
});
},
[dimensions]
);
if (flyToRef) flyToRef.current = handleFlyTo;
@ -361,7 +438,7 @@ export default memo(function Map({
) : null
) : (
<>
<div className="absolute top-3 left-3 right-3 z-[60] flex flex-wrap items-start justify-between gap-2 pointer-events-none">
<div className="absolute top-3 left-3 right-3 z-20 flex flex-wrap items-start justify-between gap-2 pointer-events-none">
{!hideLocationSearch && (
<LocationSearch
onFlyTo={handleFlyTo}

View file

@ -8,6 +8,7 @@ import type {
ViewState,
PostcodeGeometry,
Property,
MapFlyToOptions,
} from '../../types';
import type { SearchedLocation } from './LocationSearch';
import type { Page } from '../ui/Header';
@ -36,6 +37,7 @@ import { trackEvent } from '../../lib/analytics';
import { canWheelScrollInsideTarget } from '../../lib/dom-scroll';
import { INITIAL_VIEW_STATE } from '../../lib/consts';
import { getSchoolBackendFeatureName } from '../../lib/school-filter';
import { getSpecificCrimeFeatureName } from '../../lib/crime-filter';
import { useLicense } from '../../hooks/useLicense';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { MapPinIcon } from '../ui/icons/MapPinIcon';
@ -208,7 +210,11 @@ export default function MapPage({
handleToggleBest,
} = useTravelTime(initialTravelTime);
const mapFlyToRef = useRef<((lat: number, lng: number, zoom: number) => void) | null>(null);
const mapFlyToRef = useRef<
((lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void) | null
>(null);
const pendingCurrentLocationFlyToRef = useRef<{ lat: number; lng: number } | null>(null);
const mobileDrawerPanelRectRef = useRef<DOMRectReadOnly | null>(null);
const mapData = useMapData({
filters,
@ -349,8 +355,11 @@ export default function MapPage({
} = useHexagonSelection({
filters,
features,
hexagonData: mapData.committedHexagonData,
resolution: mapData.resolution,
usePostcodeView: mapData.usePostcodeView,
travelTimeEntries: entries,
shareCode,
journeyDest,
});
@ -379,15 +388,44 @@ export default function MapPage({
[handleLocationSearch, handleCloseSelection, isMobile]
);
const consumePendingCurrentLocationFlyTo = useCallback((rect?: DOMRectReadOnly | null) => {
const pending = pendingCurrentLocationFlyToRef.current;
const panelRect = rect ?? mobileDrawerPanelRectRef.current;
if (!pending || !panelRect) return;
const bottomInset = Math.max(0, window.innerHeight - panelRect.top);
mapFlyToRef.current?.(pending.lat, pending.lng, 17, {
visibleViewportArea: { bottom: bottomInset },
});
pendingCurrentLocationFlyToRef.current = null;
}, []);
const handleCurrentLocationFound = useCallback(
(lat: number, lng: number) => {
if (isMobile) {
pendingCurrentLocationFlyToRef.current = { lat, lng };
consumePendingCurrentLocationFlyTo();
} else {
mapFlyToRef.current?.(lat, lng, 17);
}
setCurrentLocation({ lat, lng });
handleCurrentLocationSearch(lat, lng);
if (isMobile) setMobileDrawerOpen(true);
},
[handleCurrentLocationSearch, isMobile]
[consumePendingCurrentLocationFlyTo, handleCurrentLocationSearch, isMobile]
);
const handleMobileDrawerPanelRectChange = useCallback((rect: DOMRectReadOnly) => {
mobileDrawerPanelRectRef.current = rect;
consumePendingCurrentLocationFlyTo(rect);
}, [consumePendingCurrentLocationFlyTo]);
const handleMobileDrawerClose = useCallback(() => {
pendingCurrentLocationFlyToRef.current = null;
mobileDrawerPanelRectRef.current = null;
setMobileDrawerOpen(false);
}, []);
// For share-link recipients, "Continue with Demo" snaps back to the shared
// coords (the area their link was meant to show), not the central-London
// free-zone demo. Captured once on mount so a later URL rewrite by
@ -557,12 +595,19 @@ export default function MapPage({
const mobileLegendMeta = useMemo(() => {
const featureName = viewFeature
? (getSchoolBackendFeatureName(viewFeature) ?? viewFeature)
? (getSchoolBackendFeatureName(viewFeature) ??
getSpecificCrimeFeatureName(viewFeature) ??
viewFeature)
: null;
return featureName ? features.find((f) => f.name === featureName) || null : null;
}, [viewFeature, features]);
const mapViewFeature = useMemo(
() => (viewFeature ? (getSchoolBackendFeatureName(viewFeature) ?? viewFeature) : null),
() =>
viewFeature
? (getSchoolBackendFeatureName(viewFeature) ??
getSpecificCrimeFeatureName(viewFeature) ??
viewFeature)
: null,
[viewFeature]
);
const mobileDensityRange = useMemo((): [number, number] => {
@ -760,7 +805,7 @@ export default function MapPage({
onLoginRequired={onRegisterClick ?? (() => {})}
isLicensed={user?.subscription === 'licensed'}
onUpgradeClick={() => onNavigateTo('pricing')}
onResetTutorial={tutorial.resetTutorial}
onResetTutorial={!isMobile ? tutorial.resetTutorial : undefined}
filterImpacts={filterCounts.impacts}
onClearAll={handleClearAll}
onSaveSearch={onSaveSearch}
@ -914,10 +959,11 @@ export default function MapPage({
{mobileDrawerOpen && selectedHexagon && (
<Suspense fallback={<PaneFallback />}>
<MobileDrawer
onClose={() => setMobileDrawerOpen(false)}
onClose={handleMobileDrawerClose}
renderArea={renderAreaPane}
renderProperties={renderPropertiesPane}
tab={rightPaneTab}
onPanelRectChange={handleMobileDrawerPanelRectChange}
onTabChange={(t) => {
if (t === 'properties') {
handlePropertiesTabClick();

View file

@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useLayoutEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { TabButton } from '../ui/TabButton';
@ -9,6 +9,7 @@ interface MobileDrawerProps {
renderProperties: () => React.ReactNode;
tab: 'area' | 'properties';
onTabChange: (tab: 'area' | 'properties') => void;
onPanelRectChange?: (rect: DOMRectReadOnly) => void;
}
export default function MobileDrawer({
@ -17,8 +18,30 @@ export default function MobileDrawer({
renderProperties,
tab,
onTabChange,
onPanelRectChange,
}: MobileDrawerProps) {
const { t } = useTranslation();
const panelRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const panel = panelRef.current;
if (!panel || !onPanelRectChange) return;
const reportRect = () => onPanelRectChange(panel.getBoundingClientRect());
reportRect();
const observer = new ResizeObserver(reportRect);
observer.observe(panel);
window.addEventListener('resize', reportRect);
window.visualViewport?.addEventListener('resize', reportRect);
return () => {
observer.disconnect();
window.removeEventListener('resize', reportRect);
window.visualViewport?.removeEventListener('resize', reportRect);
};
}, [onPanelRectChange]);
// Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => {
@ -34,7 +57,10 @@ export default function MobileDrawer({
<div className="h-[10%] bg-black/50" onClick={onClose} />
{/* Panel — bottom 90% */}
<div className="h-[90%] bg-white dark:bg-navy-950 rounded-t-2xl flex flex-col shadow-xl overflow-hidden">
<div
ref={panelRef}
className="h-[90%] bg-white dark:bg-navy-950 rounded-t-2xl flex flex-col shadow-xl overflow-hidden"
>
{/* Tab bar + close */}
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm shrink-0">
<TabButton

View file

@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { getPriceScale } from './PriceHistoryChart';
describe('PriceHistoryChart scale', () => {
it('uses a high percentile ceiling instead of the absolute max', () => {
const scale = getPriceScale([
...Array.from({ length: 19 }, (_, i) => ({
year: 2000 + i,
price: 3_000_000 + i * 10_000,
})),
{ year: 2025, price: 10_000_000 },
]);
expect(scale.max).toBeGreaterThan(3_000_000);
expect(scale.max).toBeLessThan(10_000_000);
expect(Math.max(...scale.ticks)).toBe(scale.max);
});
it('keeps single-value data visible with a padded domain', () => {
const scale = getPriceScale([
{ year: 2020, price: 2_500_000 },
{ year: 2021, price: 2_500_000 },
]);
expect(scale.min).toBeLessThan(2_500_000);
expect(scale.max).toBeGreaterThan(2_500_000);
});
});

View file

@ -8,8 +8,15 @@ interface PriceHistoryChartProps {
const PADDING = { top: 8, right: 24, bottom: 20, left: 42 };
const HEIGHT = 120;
const PRICE_SCALE_TOP_PERCENTILE = 95;
const priceFmt = { prefix: '£' };
interface PriceScale {
min: number;
max: number;
ticks: number[];
}
export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
@ -25,7 +32,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
return () => observer.disconnect();
}, []);
const { yearMin, yearMax, priceMin, priceMax, medians, priceTicks } = useMemo(() => {
const { yearMin, yearMax, priceScale, medians } = useMemo(() => {
let yMin = Infinity,
yMax = -Infinity;
for (const p of points) {
@ -33,14 +40,6 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
if (p.year > yMax) yMax = p.year;
}
// Use p5/p95 to clip outliers
const sorted = points.map((p) => p.price).sort((a, b) => a - b);
const p5 = sorted[Math.floor(sorted.length * 0.05)];
const p95 = sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95))];
const pRange = p95 - p5 || 1;
const pMin = Math.max(0, p5 - pRange * 0.1);
const pMax = p95 + pRange * 0.1;
// Yearly medians (robust to outliers)
const byYear = new Map<number, number[]>();
for (const p of points) {
@ -73,15 +72,11 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
return { year: pt.year + 0.5, price: sum / count };
});
const ticks = niceTicksForRange(pMin, pMax, 4);
return {
yearMin: yMin,
yearMax: yMax,
priceMin: pMin,
priceMax: pMax,
priceScale: getPriceScale(points),
medians: meds,
priceTicks: ticks,
};
}, [points]);
@ -91,8 +86,8 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
const scaleX = (year: number) => PADDING.left + ((year - yearMin) / yearRange) * plotW;
const scaleY = (price: number) => {
const t = (price - priceMin) / (priceMax - priceMin || 1);
return PADDING.top + (1 - Math.max(0, Math.min(1, t))) * plotH;
const t = (price - priceScale.min) / (priceScale.max - priceScale.min || 1);
return PADDING.top + (1 - t) * plotH;
};
// Year labels: every 5 years
@ -107,7 +102,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
{width > 0 && (
<svg width={width} height={HEIGHT}>
{/* Grid lines */}
{priceTicks.map((tick) => (
{priceScale.ticks.map((tick) => (
<line
key={tick}
x1={PADDING.left}
@ -119,7 +114,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
/>
))}
{/* Dots (clamp outliers to visible range) */}
{/* Dots */}
{points.map((p, i) => (
<circle
key={i}
@ -143,7 +138,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
)}
{/* Y-axis labels */}
{priceTicks.map((tick) => (
{priceScale.ticks.map((tick) => (
<text
key={`label-${tick}`}
x={PADDING.left - 4}
@ -176,6 +171,40 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
);
}
export function getPriceScale(points: PricePoint[]): PriceScale {
const prices = points
.map((p) => p.price)
.filter(Number.isFinite)
.sort((a, b) => a - b);
if (prices.length === 0) {
return { min: 0, max: 1, ticks: [0, 1] };
}
const min = prices[0];
const scaleTop = percentile(prices, PRICE_SCALE_TOP_PERCENTILE);
const range = scaleTop - min;
const padding = range > 0 ? range * 0.1 : Math.max(Math.abs(scaleTop) * 0.1, 1);
const paddedMin = Math.max(0, min - padding);
const paddedMax = scaleTop + padding;
const ticks = niceTicksForRange(paddedMin, paddedMax, 4);
return {
min: ticks[0] ?? paddedMin,
max: ticks[ticks.length - 1] ?? paddedMax,
ticks,
};
}
function percentile(sorted: number[], p: number): number {
const clamped = Math.max(0, Math.min(100, p));
const rank = ((sorted.length - 1) * clamped) / 100;
const lower = Math.floor(rank);
const upper = Math.ceil(rank);
if (lower === upper) return sorted[lower];
const weight = rank - lower;
return sorted[lower] * (1 - weight) + sorted[upper] * weight;
}
/** Generate ~count nice round tick values spanning [min, max]. */
function niceTicksForRange(min: number, max: number, count: number): number[] {
const range = max - min;
@ -189,10 +218,17 @@ function niceTicksForRange(min: number, max: number, count: number): number[] {
else if (normalized <= 7.5) step = 5 * magnitude;
else step = 10 * magnitude;
const start =
min >= 0 ? Math.max(0, Math.floor(min / step) * step) : Math.floor(min / step) * step;
const end = Math.ceil(max / step) * step;
const ticks: number[] = [];
const start = Math.ceil(min / step) * step;
for (let t = start; t <= max; t += step) {
ticks.push(t);
for (let t = start; t <= end + step / 2; t += step) {
ticks.push(cleanTick(t));
}
return ticks;
}
function cleanTick(value: number): number {
return Number(value.toPrecision(12));
}