Codex changes

This commit is contained in:
Andras Schmelczer 2026-05-04 16:19:09 +01:00
parent 0bae902e08
commit d4dde21ad2
46 changed files with 4953 additions and 966 deletions

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,7 @@
"build": "webpack --mode production && node scripts/prerender.mjs",
"build:no-prerender": "webpack --mode production",
"prerender": "node scripts/prerender.mjs",
"test": "vitest run --environment jsdom",
"typecheck": "tsc --noEmit",
"lint": "eslint src --ext .ts,.tsx",
"lint:fix": "eslint src --ext .ts,.tsx --fix",
@ -39,6 +40,7 @@
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@testing-library/react": "^16.3.2",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
@ -53,6 +55,7 @@
"favicons": "^7.2.0",
"favicons-webpack-plugin": "^6.0.1",
"html-webpack-plugin": "^5.6.0",
"jsdom": "^29.1.1",
"mini-css-extract-plugin": "^2.9.0",
"postcss": "^8.4.0",
"postcss-loader": "^8.0.0",
@ -63,6 +66,7 @@
"tailwindcss": "^3.4.0",
"ts-loader": "^9.5.0",
"typescript": "^5.4.0",
"vitest": "^4.1.5",
"webpack": "^5.90.0",
"webpack-cli": "^5.1.0",
"webpack-dev-server": "^5.0.0"

View file

@ -9,16 +9,21 @@ import type {
} from '../../types';
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
import type { HexagonLocation } from '../../lib/external-search';
import { formatValue, formatFilterValue, calculateHistogramMean } from '../../lib/format';
import {
formatValue,
formatFilterValue,
calculateHistogramMean,
roundedPercentages,
} from '../../lib/format';
import { groupFeaturesByCategory } from '../../lib/features';
import { STACKED_GROUPS, STACKED_ENUM_GROUPS } from '../../lib/consts';
import { PARTY_FEATURE_COLORS, STACKED_GROUPS, STACKED_ENUM_GROUPS } from '../../lib/consts';
import { DualHistogram, LoadingSkeleton } from './DualHistogram';
import EnumBarChart from './EnumBarChart';
import StackedBarChart from './StackedBarChart';
import StackedEnumChart from './StackedEnumChart';
import PriceHistoryChart from './PriceHistoryChart';
import ExternalSearchLinks from './ExternalSearchLinks';
import { InfoIcon } from '../ui/icons';
import { FilterIcon, InfoIcon } from '../ui/icons';
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { EmptyState } from '../ui/EmptyState';
@ -35,14 +40,26 @@ interface AreaPaneProps {
isPostcode?: boolean;
postcodeData?: PostcodeFeature | null;
onViewProperties: () => void;
onClearFilters?: () => void;
hexagonLocation: HexagonLocation | null;
filters: FeatureFilters;
unfilteredCount?: number | null;
onNavigateToSource?: (slug: string, featureName: string) => void;
travelTimeEntries?: TravelTimeEntry[];
isGroupExpanded: (name: string) => boolean;
onToggleGroup: (name: string) => void;
}
function normalizePercentageSegments<T extends { value: number }>(segments: T[]): T[] {
const total = segments.reduce((sum, segment) => sum + segment.value, 0);
const normalizedValues = roundedPercentages(
segments.map((segment) => segment.value),
total,
1
);
return segments.map((segment, index) => ({ ...segment, value: normalizedValues[index] }));
}
export default function AreaPane({
stats,
globalFeatures,
@ -51,8 +68,10 @@ export default function AreaPane({
isPostcode = false,
postcodeData,
onViewProperties,
onClearFilters,
hexagonLocation,
filters,
unfilteredCount,
onNavigateToSource,
travelTimeEntries,
isGroupExpanded,
@ -60,6 +79,8 @@ export default function AreaPane({
}: AreaPaneProps) {
const { t } = useTranslation();
const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count;
const activeFilterCount = Object.keys(filters).length + (travelTimeEntries?.length ?? 0);
const hasFilteredOutArea = activeFilterCount > 0 && stats?.count === 0;
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
@ -119,8 +140,36 @@ export default function AreaPane({
? t('common.postcode').toLowerCase()
: t('common.area').toLowerCase(),
})}
{Object.keys(filters).length > 0 ? t('areaPane.matchingFilters') : ''}
</p>
<div className="mt-2 flex gap-2 rounded border border-teal-200 bg-teal-50 px-2.5 py-2 text-xs leading-snug text-teal-800 dark:border-teal-800/70 dark:bg-teal-950/40 dark:text-teal-200">
<FilterIcon className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<p>
{activeFilterCount > 0
? t('areaPane.filtersAffectStats', { count: activeFilterCount })
: t('areaPane.noFiltersAffectStats')}
</p>
</div>
{hasFilteredOutArea && (
<div className="mt-2 rounded border border-amber-200 bg-amber-50 px-2.5 py-2 text-xs leading-snug text-amber-900 dark:border-amber-800/70 dark:bg-amber-950/40 dark:text-amber-100">
<p className="font-semibold">{t('areaPane.noFilteredMatches')}</p>
<p className="mt-1">
{unfilteredCount != null && unfilteredCount > 0
? t('areaPane.unfilteredAreaCount', { count: unfilteredCount })
: unfilteredCount === 0
? t('areaPane.noUnfilteredAreaProperties')
: t('areaPane.relaxFiltersHint')}
</p>
{onClearFilters && (
<button
type="button"
onClick={onClearFilters}
className="mt-2 rounded bg-amber-600 px-2 py-1 text-xs font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
>
{t('filters.clearAll')}
</button>
)}
</div>
)}
{stats && stats.count > 0 && (
<button
onClick={onViewProperties}
@ -149,7 +198,7 @@ export default function AreaPane({
) : stats ? (
<div>
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
<HistogramLegend />
{stats.count > 0 && <HistogramLegend />}
{stats.price_history &&
(() => {
const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year)));
@ -190,148 +239,170 @@ export default function AreaPane({
{expanded && (
<div className="px-3 py-2 space-y-3">
{stackedCharts?.map((chart) => {
const segments = chart.components
.map((name) => ({
name,
value: numericByName.get(name)?.mean ?? 0,
}))
.filter((s) => s.value > 0);
const segments = chart.components
.map((name) => ({
name,
value: numericByName.get(name)?.mean ?? 0,
}))
.filter((s) => s.value > 0);
const aggregateStats = chart.feature
? numericByName.get(chart.feature)
: undefined;
const total = aggregateStats
? aggregateStats.mean
: segments.reduce((sum, s) => sum + s.value, 0);
const isPercentageComposition = chart.unit === '%' && !chart.feature;
const displaySegments = isPercentageComposition
? normalizePercentageSegments(segments)
: segments;
// Use rateFeature (e.g. per-1k) for display if available
const rateStats = chart.rateFeature
? numericByName.get(chart.rateFeature)
: undefined;
const displayValue = rateStats ? rateStats.mean : total;
const aggregateStats = chart.feature
? numericByName.get(chart.feature)
: undefined;
const total = aggregateStats
? aggregateStats.mean
: displaySegments.reduce((sum, s) => sum + s.value, 0);
// Use rateFeature for info popup and national average when available
const infoFeatureName = chart.rateFeature ?? chart.feature;
const featureMeta = infoFeatureName
? globalFeatureByName.get(infoFeatureName)
: undefined;
// Use rateFeature (e.g. per-1k) for display if available
const rateStats = chart.rateFeature
? numericByName.get(chart.rateFeature)
: undefined;
const displayValue = isPercentageComposition
? 100
: rateStats
? rateStats.mean
: total;
const globalMean =
featureMeta?.histogram
? calculateHistogramMean(featureMeta.histogram)
: undefined;
// Use rateFeature for info popup and national average when available
const infoFeatureName = chart.rateFeature ?? chart.feature;
const featureMeta = infoFeatureName
? globalFeatureByName.get(infoFeatureName)
: undefined;
if (total === 0) return null;
const globalMean = featureMeta?.histogram
? calculateHistogramMean(featureMeta.histogram)
: undefined;
return (
<div
key={ts(chart.label)}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="flex justify-between items-baseline mb-1.5">
{featureMeta ? (
<FeatureLabel
feature={{ ...featureMeta, name: ts(chart.label) }}
onShowInfo={setInfoFeature}
className="mr-2"
/>
) : (
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
{ts(chart.label)}
</span>
)}
<div className="text-right shrink-0">
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(displayValue)}
{chart.unit ? ` ${chart.unit}` : ''}
</span>
{globalMean != null && (
<div className="text-[10px] text-warm-400 dark:text-warm-500 whitespace-nowrap">
{t('areaPane.nationalAvg')}: {formatValue(globalMean)}
</div>
)}
if (total === 0) return null;
return (
<div
key={ts(chart.label)}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="flex justify-between items-baseline mb-1.5">
{featureMeta ? (
<FeatureLabel
feature={{ ...featureMeta, name: ts(chart.label) }}
onShowInfo={setInfoFeature}
className="mr-2"
/>
) : (
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
{ts(chart.label)}
</span>
)}
<div className="text-right shrink-0">
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(displayValue)}
{chart.unit ? ` ${chart.unit}` : ''}
</span>
{globalMean != null && (
<div className="text-[10px] text-warm-400 dark:text-warm-500 whitespace-nowrap">
{t('areaPane.nationalAvg')}: {formatValue(globalMean)}
</div>
</div>
<StackedBarChart segments={segments} total={total} />
)}
</div>
);
})}
</div>
<StackedBarChart
segments={displaySegments}
total={total}
colorMap={
chart.label === 'Political vote share'
? PARTY_FEATURE_COLORS
: undefined
}
/>
</div>
);
})}
{(() => {
const stackedFeatureNames = new Set<string>(
stackedCharts?.flatMap((c) =>
[c.feature, c.rateFeature, ...c.components].filter((s): s is string => Boolean(s))
[c.feature, c.rateFeature, ...c.components].filter((s): s is string =>
Boolean(s)
)
) ?? []
);
return group.features
.filter((f) => !stackedFeatureNames.has(f.name) && !stackedEnumFeatureNames.has(f.name))
.map((feature) => {
const numericStats = numericByName.get(feature.name);
const enumStats = enumByName.get(feature.name);
.filter(
(f) =>
!stackedFeatureNames.has(f.name) &&
!stackedEnumFeatureNames.has(f.name)
)
.map((feature) => {
const numericStats = numericByName.get(feature.name);
const enumStats = enumByName.get(feature.name);
if (numericStats) {
const globalFeature = globalFeatureByName.get(feature.name);
const globalHistogram = globalFeature?.histogram;
const globalMean = globalHistogram
? calculateHistogramMean(globalHistogram)
: undefined;
if (numericStats) {
const globalFeature = globalFeatureByName.get(feature.name);
const globalHistogram = globalFeature?.histogram;
const globalMean = globalHistogram
? calculateHistogramMean(globalHistogram)
: undefined;
return (
<div
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="flex justify-between items-baseline">
<FeatureLabel
feature={feature}
onShowInfo={setInfoFeature}
className="mr-2"
/>
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(numericStats.mean, feature)}
</span>
</div>
{numericStats.histogram &&
(globalHistogram ? (
<DualHistogram
localCounts={numericStats.histogram.counts}
globalCounts={globalHistogram.counts}
p1={numericStats.histogram.p1}
p99={numericStats.histogram.p99}
globalMean={globalMean}
formatLabel={(v) => formatFilterValue(v, feature.raw)}
/>
) : (
<DualHistogram
localCounts={numericStats.histogram.counts}
globalCounts={numericStats.histogram.counts}
p1={numericStats.histogram.p1}
p99={numericStats.histogram.p99}
formatLabel={(v) => formatFilterValue(v, feature.raw)}
/>
))}
</div>
);
}
if (enumStats) {
const globalFeature = globalFeatureByName.get(feature.name);
return (
<div
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
<EnumBarChart
counts={enumStats.counts}
globalCounts={globalFeature?.counts}
featureName={feature.name}
return (
<div
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="flex justify-between items-baseline">
<FeatureLabel
feature={feature}
onShowInfo={setInfoFeature}
className="mr-2"
/>
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(numericStats.mean, feature)}
</span>
</div>
);
}
{numericStats.histogram &&
(globalHistogram ? (
<DualHistogram
localCounts={numericStats.histogram.counts}
globalCounts={globalHistogram.counts}
p1={numericStats.histogram.p1}
p99={numericStats.histogram.p99}
globalMean={globalMean}
formatLabel={(v) => formatFilterValue(v, feature.raw)}
/>
) : (
<DualHistogram
localCounts={numericStats.histogram.counts}
globalCounts={numericStats.histogram.counts}
p1={numericStats.histogram.p1}
p99={numericStats.histogram.p99}
formatLabel={(v) => formatFilterValue(v, feature.raw)}
/>
))}
</div>
);
}
return null;
});
if (enumStats) {
const globalFeature = globalFeatureByName.get(feature.name);
return (
<div
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
<EnumBarChart
counts={enumStats.counts}
globalCounts={globalFeature?.counts}
featureName={feature.name}
/>
</div>
);
}
return null;
});
})()}
{stackedEnumCharts?.map((chart) => {
const featureMeta = chart.feature

View file

@ -604,6 +604,7 @@ export default memo(function Filters({
<FeatureActions
feature={feature}
isPinned={isPinned}
isPreviewing={isActive}
onTogglePin={onTogglePin}
onShowInfo={setActiveInfoFeature}
onRemove={onRemoveFilter}

View file

@ -34,10 +34,12 @@ const ZOOM_FOR_TYPE: Record<string, number> = {
export default function LocationSearch({
onFlyTo,
onLocationSearched,
onCurrentLocationFound,
onMouseEnter,
}: {
onFlyTo: (lat: number, lng: number, zoom: number) => void;
onLocationSearched?: (postcode: SearchedLocation | null) => void;
onCurrentLocationFound?: (lat: number, lng: number) => void;
onMouseEnter?: () => void;
}) {
const { t } = useTranslation();
@ -131,27 +133,8 @@ export default function LocationSearch({
});
});
const { latitude, longitude } = position.coords;
const res = await fetch(
`/api/nearest-postcode?lat=${latitude}&lng=${longitude}`,
authHeaders()
);
if (!res.ok) {
setError(t('locationSearch.lookupFailed'));
return;
}
const json: {
postcode: string;
latitude: number;
longitude: number;
geometry: PostcodeGeometry;
} = await res.json();
onFlyTo(json.latitude, json.longitude, 16);
onLocationSearched?.({
postcode: json.postcode,
geometry: json.geometry,
latitude: json.latitude,
longitude: json.longitude,
});
onFlyTo(latitude, longitude, 17);
onCurrentLocationFound?.(latitude, longitude);
search.clear();
if (isMobile) setExpanded(false);
} catch {
@ -159,7 +142,7 @@ export default function LocationSearch({
} finally {
setLocating(false);
}
}, [onFlyTo, onLocationSearched, isMobile, search, t]);
}, [onFlyTo, onCurrentLocationFound, isMobile, search, t]);
// Mobile collapsed state: search icon + locate button
if (isMobile && !expanded) {

View file

@ -56,6 +56,8 @@ interface MapProps {
filters?: FeatureFilters;
selectedPostcodeGeometry?: PostcodeGeometry | null;
onLocationSearched?: (location: SearchedLocation | null) => void;
onCurrentLocationFound?: (lat: number, lng: number) => void;
currentLocation?: { lat: number; lng: number } | null;
bounds?: Bounds | null;
hideLegend?: boolean;
travelTimeEntries?: TravelTimeEntry[];
@ -114,6 +116,8 @@ export default memo(function Map({
filters = {},
selectedPostcodeGeometry,
onLocationSearched,
onCurrentLocationFound,
currentLocation,
bounds: viewportBounds,
hideLegend = false,
travelTimeEntries = EMPTY_TRAVEL_ENTRIES,
@ -225,6 +229,7 @@ export default memo(function Map({
onHexagonHover,
theme,
selectedPostcodeGeometry,
currentLocation,
bounds: viewportBounds,
travelTimeEntries,
});
@ -307,6 +312,7 @@ export default memo(function Map({
<LocationSearch
onFlyTo={handleFlyTo}
onLocationSearched={onLocationSearched}
onCurrentLocationFound={onCurrentLocationFound}
onMouseEnter={handleMouseLeave}
/>
{!hideLegend &&

View file

@ -2,10 +2,10 @@ import { useTranslation } from 'react-i18next';
import { formatValue } from '../../lib/format';
import { ts } from '../../i18n/server';
import {
FEATURE_GRADIENT,
DENSITY_GRADIENT,
DENSITY_GRADIENT_DARK,
getEnumPaletteForFeature,
getFeatureGradient,
} from '../../lib/consts';
import { gradientToCss } from '../../lib/utils';
import { CloseIcon } from '../ui/icons/CloseIcon';
@ -95,7 +95,9 @@ export default function MapLegend({
const enumPalette = getEnumPaletteForFeature(featureName ?? null, enumValues);
const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
const gradientStyle =
mode === 'density' ? gradientToCss(densityGradient) : gradientToCss(FEATURE_GRADIENT);
mode === 'density'
? gradientToCss(densityGradient)
: gradientToCss(getFeatureGradient(featureName));
const fmt = raw ? { raw: true } : undefined;

View file

@ -1,5 +1,6 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { cellToLatLng } from 'h3-js';
import type {
FeatureMeta,
FeatureFilters,
@ -17,7 +18,7 @@ import { PropertiesPane } from './PropertiesPane';
import AreaPane from './AreaPane';
import MobileDrawer from './MobileDrawer';
import MapLegend from './MapLegend';
import { TabButton } from '../ui/TabButton';
import { MapPageSelectionPane } from './MapPageSelectionPane';
import { useMapData } from '../../hooks/useMapData';
import { usePOIData } from '../../hooks/usePOIData';
import { useFilters } from '../../hooks/useFilters';
@ -42,7 +43,6 @@ import { trackEvent } from '../../lib/analytics';
import { INITIAL_VIEW_STATE } from '../../lib/consts';
import { useLicense } from '../../hooks/useLicense';
import UpgradeModal from '../ui/UpgradeModal';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { MapPinIcon } from '../ui/icons/MapPinIcon';
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
@ -125,6 +125,7 @@ export default function MapPage({
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null);
const [showBookmarkToast, setShowBookmarkToast] = useState(false);
const bookmarkToastDismissed = useRef(localStorage.getItem('bookmark_toast_dismissed') === '1');
@ -249,7 +250,14 @@ export default function MapPage({
}
}
},
[fetchAiFilters, handleSetFilters, handleSetEntries, activeEntries, filters, mapData.currentView?.zoom]
[
fetchAiFilters,
handleSetFilters,
handleSetEntries,
activeEntries,
filters,
mapData.currentView?.zoom,
]
);
const handleClearAll = useCallback(() => {
@ -304,6 +312,7 @@ export default function MapPage({
loadingProperties,
areaStats,
loadingAreaStats,
unfilteredAreaCount,
hoveredHexagon,
rightPaneTab,
setRightPaneTab,
@ -315,25 +324,38 @@ export default function MapPage({
handleCloseSelection,
selectedPostcodeGeometry,
handleLocationSearch,
handleCurrentLocationSearch,
} = useHexagonSelection({
filters,
features,
resolution: mapData.resolution,
usePostcodeView: mapData.usePostcodeView,
journeyDest,
});
const handleLocationSearchResult = useCallback(
(result: SearchedLocation | null) => {
if (result) {
setCurrentLocation(null);
handleLocationSearch(result.postcode, result.geometry, result.latitude, result.longitude);
if (isMobile) setMobileDrawerOpen(true);
} else {
setCurrentLocation(null);
handleCloseSelection();
}
},
[handleLocationSearch, handleCloseSelection, isMobile]
);
const handleCurrentLocationFound = useCallback(
(lat: number, lng: number) => {
setCurrentLocation({ lat, lng });
handleCurrentLocationSearch(lat, lng);
if (isMobile) setMobileDrawerOpen(true);
},
[handleCurrentLocationSearch, isMobile]
);
const handleZoomToFreeZone = useCallback(() => {
mapFlyToRef.current?.(
INITIAL_VIEW_STATE.latitude,
@ -428,20 +450,19 @@ export default function MapPage({
const [lon, lat] = postcodeFeature.properties.centroid;
return { lat, lon, resolution: mapData.resolution, postcode: hexId, isPostcode: true };
} else {
// For hexagons, get lat/lon from hexagon data; central postcode comes from stats
const hex = hexId ? mapData.data.find((d) => d.h3 === hexId) : null;
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null;
if (!hexId) return null;
const [lat, lon] = cellToLatLng(hexId);
return {
lat: hex.lat as number,
lon: hex.lon as number,
resolution: mapData.resolution,
lat,
lon,
resolution: selectedHexagon?.resolution ?? mapData.resolution,
postcode: areaStats?.central_postcode,
};
}
}, [
selectedHexagon?.id,
selectedHexagon?.resolution,
selectedHexagon?.type,
mapData.data,
mapData.postcodeData,
mapData.resolution,
areaStats?.central_postcode,
@ -487,6 +508,7 @@ export default function MapPage({
}, [mapData.licenseRequired]);
const densityLabel = t('mapLegend.historicalMatches');
const hasActiveFilters = Object.keys(filters).length > 0 || activeEntries.length > 0;
const mobileLegendMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
@ -607,8 +629,10 @@ export default function MapPage({
: null
}
onViewProperties={handleViewPropertiesFromArea}
onClearFilters={hasActiveFilters ? handleClearAll : undefined}
hexagonLocation={hexagonLocation}
filters={filters}
unfilteredCount={unfilteredAreaCount}
travelTimeEntries={activeEntries}
isGroupExpanded={isAreaGroupExpanded}
onToggleGroup={toggleAreaGroup}
@ -695,10 +719,7 @@ export default function MapPage({
</div>
)}
<div
ref={mobileMapRef}
className="relative overflow-hidden"
>
<div ref={mobileMapRef} className="relative overflow-hidden">
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
@ -721,6 +742,8 @@ export default function MapPage({
filters={filters}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={handleLocationSearchResult}
onCurrentLocationFound={handleCurrentLocationFound}
currentLocation={currentLocation}
bounds={mapData.bounds}
hideLegend
travelTimeEntries={entries}
@ -907,10 +930,12 @@ export default function MapPage({
filters={filters}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={handleLocationSearchResult}
onCurrentLocationFound={handleCurrentLocationFound}
currentLocation={currentLocation}
bounds={mapData.bounds}
travelTimeEntries={entries}
densityLabel={densityLabel}
totalCount={filterCounts.total || undefined}
totalCount={hasActiveFilters ? filterCounts.total : undefined}
/>
{mapData.loading && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
@ -940,47 +965,16 @@ export default function MapPage({
</div>
{selectedHexagon && (
<div
data-tutorial="right-pane"
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
style={{ width: rightPaneWidth }}
>
<div
className="w-3 cursor-col-resize flex items-center justify-center group bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
{...rightPaneHandlers}
>
<div className="flex flex-col gap-1.5">
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
</div>
</div>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
<TabButton
label="Area"
isActive={rightPaneTab === 'area'}
onClick={() => setRightPaneTab('area')}
/>
<TabButton
label="Properties"
isActive={rightPaneTab === 'properties'}
onClick={handlePropertiesTabClick}
/>
<button
onClick={handleCloseSelection}
className="px-2 flex items-center text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
title="Close pane"
>
<CloseIcon className="w-4 h-4" />
</button>
</div>
<div className="flex-1 overflow-hidden">
{rightPaneTab === 'properties' ? renderPropertiesPane() : renderAreaPane()}
</div>
</div>
</div>
<MapPageSelectionPane
width={rightPaneWidth}
resizeHandlers={rightPaneHandlers}
tab={rightPaneTab}
onAreaTabClick={() => setRightPaneTab('area')}
onPropertiesTabClick={handlePropertiesTabClick}
onClose={handleCloseSelection}
renderAreaPane={renderAreaPane}
renderPropertiesPane={renderPropertiesPane}
/>
)}
{bookmarkToast}

View file

@ -90,10 +90,10 @@ export function TravelTimeCard({
{slug && (
<IconButton
onClick={onTogglePin}
active={isPinned}
active={isPinned || isActive}
title={isPinned ? t('travel.stopPreviewing') : t('travel.previewOnMap')}
>
<EyeIcon className="w-3.5 h-3.5" filled={isPinned} />
<EyeIcon className="w-3.5 h-3.5" filled={isPinned || isActive} />
</IconButton>
)}
<IconButton onClick={() => onRemove()} title={t('travel.removeTravelTime')}>

View file

@ -5,6 +5,7 @@ import { IconButton } from './IconButton';
interface FeatureActionsProps {
feature: FeatureMeta;
isPinned: boolean;
isPreviewing?: boolean;
onTogglePin: (name: string) => void;
onShowInfo?: (feature: FeatureMeta) => void;
onRemove?: (name: string) => void;
@ -14,11 +15,14 @@ interface FeatureActionsProps {
export function FeatureActions({
feature,
isPinned,
isPreviewing = false,
onTogglePin,
onShowInfo,
onRemove,
onAdd,
}: FeatureActionsProps) {
const isEyeActive = isPinned || isPreviewing;
return (
<div className="flex items-center gap-0.5 shrink-0">
{feature.detail && onShowInfo && (
@ -29,10 +33,10 @@ export function FeatureActions({
<IconButton
onClick={() => onTogglePin(feature.name)}
title={isPinned ? 'Unpin colour view' : 'Colour map by this feature'}
active={isPinned}
active={isEyeActive}
size="md"
>
<EyeIcon filled={isPinned} className="w-5 h-5 md:w-3.5 md:h-3.5" />
<EyeIcon filled={isEyeActive} className="w-5 h-5 md:w-3.5 md:h-3.5" />
</IconButton>
{onAdd && (
<button

View file

@ -1,8 +1,7 @@
import { useCallback, useRef, useState, useMemo, useEffect } from 'react';
import { H3HexagonLayer } from '@deck.gl/geo-layers';
import { GeoJsonLayer, IconLayer, TextLayer, ScatterplotLayer } from '@deck.gl/layers';
import { GeoJsonLayer, TextLayer, ScatterplotLayer } from '@deck.gl/layers';
import { cellToBoundary } from 'h3-js';
import Supercluster from 'supercluster';
import type { PickingInfo } from '@deck.gl/core';
import type {
HexagonData,
@ -16,16 +15,12 @@ import type {
import {
DENSITY_GRADIENT,
DENSITY_GRADIENT_DARK,
POI_GROUP_COLORS,
POI_DEFAULT_COLOR,
MINOR_POI_CATEGORIES,
MINOR_POI_ZOOM_THRESHOLD,
POI_CLUSTER_RADIUS,
POI_CLUSTER_MAX_ZOOM,
getEnumPaletteForFeature,
getFeatureGradient,
} from '../lib/consts';
import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils';
import { getFeatureFillColor } from '../lib/map-utils';
import type { TravelTimeEntry } from './useTravelTime';
import { usePoiLayers } from './usePoiLayers';
import { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
import { PieHexExtension } from '../lib/PieHexExtension';
@ -45,29 +40,11 @@ interface UseDeckLayersProps {
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
theme: 'light' | 'dark';
selectedPostcodeGeometry?: PostcodeGeometry | null;
currentLocation?: { lat: number; lng: number } | null;
bounds?: Bounds | null;
travelTimeEntries?: TravelTimeEntry[];
}
interface PopupInfo {
x: number;
y: number;
name: string;
category: string;
group: string;
emoji: string;
id: string;
isCluster?: boolean;
clusterCount?: number;
}
interface ClusterPoint {
lng: number;
lat: number;
count: number;
clusterId: number;
}
/** Normalize a distribution count array to [0..1] ratios, padded to 10 values. */
function distToRatios(dist: unknown): number[] {
if (!Array.isArray(dist) || dist.length === 0) return [1, 0, 0, 0, 0, 0, 0, 0, 0, 0];
@ -95,10 +72,10 @@ export function useDeckLayers({
onHexagonHover,
theme,
selectedPostcodeGeometry,
currentLocation,
bounds: viewportBounds,
travelTimeEntries = [],
}: UseDeckLayersProps) {
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null);
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
const [hoveredPostcode, setHoveredPostcode] = useState<string | null>(null);
@ -114,6 +91,7 @@ export function useDeckLayers({
const isDark = theme === 'dark';
const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
const { poiLayers, popupInfo, clearPopupInfo } = usePoiLayers({ pois, zoom, isDark });
// --- Refs for deck.gl accessors ---
const viewFeatureRef = useRef(viewFeature);
@ -126,6 +104,8 @@ export function useDeckLayers({
isDarkRef.current = isDark;
const densityGradientRef = useRef(densityGradient);
densityGradientRef.current = densityGradient;
const featureGradientRef = useRef(getFeatureGradient(viewFeature));
featureGradientRef.current = getFeatureGradient(viewFeature);
const selectedHexagonIdRef = useRef(selectedHexagonId);
selectedHexagonIdRef.current = selectedHexagonId;
const hoveredHexagonIdRef = useRef(hoveredHexagonId);
@ -148,9 +128,7 @@ export function useDeckLayers({
: 0;
// Per-feature color palette (uses overrides when defined)
const enumPaletteRef = useRef(
getEnumPaletteForFeature(viewFeature, colorFeatureMeta?.values)
);
const enumPaletteRef = useRef(getEnumPaletteForFeature(viewFeature, colorFeatureMeta?.values));
enumPaletteRef.current = getEnumPaletteForFeature(viewFeature, colorFeatureMeta?.values);
const countRange = useMemo(() => {
@ -231,52 +209,6 @@ export function useDeckLayers({
}
}, []);
const handlePoiHover = useCallback((info: PickingInfo<POI>) => {
if (info.object && info.x !== undefined && info.y !== undefined) {
setPopupInfo({
x: info.x,
y: info.y,
name: info.object.name,
category: info.object.category,
group: info.object.group,
emoji: info.object.emoji,
id: info.object.id,
});
} else {
setPopupInfo(null);
}
}, []);
const handlePoiHoverRef = useRef(handlePoiHover);
handlePoiHoverRef.current = handlePoiHover;
const stablePoiHover = useCallback((info: PickingInfo<POI>) => {
handlePoiHoverRef.current(info);
}, []);
const handleClusterHover = useCallback((info: PickingInfo<ClusterPoint>) => {
if (info.object && info.x !== undefined && info.y !== undefined) {
setPopupInfo({
x: info.x,
y: info.y,
name: `${info.object.count} places`,
category: 'Zoom in to see details',
group: '',
emoji: '',
id: '',
isCluster: true,
clusterCount: info.object.count,
});
} else {
setPopupInfo(null);
}
}, []);
const handleClusterHoverRef = useRef(handleClusterHover);
handleClusterHoverRef.current = handleClusterHover;
const stableClusterHover = useCallback((info: PickingInfo<ClusterPoint>) => {
handleClusterHoverRef.current(info);
}, []);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handlePostcodeClick = useCallback((info: PickingInfo<any>) => {
const pc = info.object?.properties?.postcode;
@ -380,7 +312,10 @@ export function useDeckLayers({
0,
densityGradientRef.current,
dark,
255
255,
0,
undefined,
featureGradientRef.current
);
}
@ -399,7 +334,8 @@ export function useDeckLayers({
dark,
255,
enumCountRef.current,
enumPaletteRef.current
enumPaletteRef.current,
featureGradientRef.current
);
}
}
@ -481,7 +417,10 @@ export function useDeckLayers({
0,
densityGradientRef.current,
dark,
180
180,
0,
undefined,
featureGradientRef.current
);
}
@ -501,7 +440,8 @@ export function useDeckLayers({
dark,
180,
enumCountRef.current,
enumPaletteRef.current
enumPaletteRef.current,
featureGradientRef.current
);
}
}
@ -576,148 +516,6 @@ export function useDeckLayers({
[postcodeData, theme]
);
// --- POI clustering ---
const clusterIndex = useMemo(() => {
if (pois.length === 0) return null;
const index = new Supercluster<POI>({
radius: POI_CLUSTER_RADIUS,
maxZoom: POI_CLUSTER_MAX_ZOOM,
});
const features: Supercluster.PointFeature<POI>[] = pois.map((poi) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [poi.lng, poi.lat] },
properties: poi,
}));
index.load(features);
return index;
}, [pois]);
const clusterZoom = Math.floor(zoom);
const showMinorPois = zoom >= MINOR_POI_ZOOM_THRESHOLD;
const { visiblePois, clusters } = useMemo(() => {
if (!clusterIndex || pois.length === 0) {
return { visiblePois: [] as POI[], clusters: [] as ClusterPoint[] };
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const allFeatures = clusterIndex.getClusters([-180, -85, 180, 85], clusterZoom) as any[];
const individual: POI[] = [];
const clusterPoints: ClusterPoint[] = [];
for (const feature of allFeatures) {
if (feature.properties.cluster) {
clusterPoints.push({
lng: feature.geometry.coordinates[0],
lat: feature.geometry.coordinates[1],
count: feature.properties.point_count,
clusterId: feature.properties.cluster_id,
});
} else {
const poi = feature.properties as POI;
if (!showMinorPois && MINOR_POI_CATEGORIES.has(poi.category)) continue;
individual.push(poi);
}
}
return { visiblePois: individual, clusters: clusterPoints };
}, [clusterIndex, clusterZoom, showMinorPois, pois]);
// --- Individual POI layers (shadow → background → emoji) ---
const poiShadowLayer = useMemo(
() =>
new ScatterplotLayer<POI>({
id: 'poi-shadow',
data: visiblePois,
getPosition: (d) => [d.lng, d.lat],
getRadius: 16,
radiusUnits: 'pixels',
getFillColor: isDark ? [0, 0, 0, 50] : [0, 0, 0, 25],
pickable: false,
transitions: { getRadius: { duration: 300, enter: () => [0] } },
}),
[visiblePois, isDark]
);
const poiBackgroundLayer = useMemo(
() =>
new ScatterplotLayer<POI>({
id: 'poi-background',
data: visiblePois,
getPosition: (d) => [d.lng, d.lat],
getRadius: 14,
radiusUnits: 'pixels',
getFillColor: isDark ? [41, 37, 36, 255] : [255, 255, 255, 255],
getLineColor: (d) => {
const c = POI_GROUP_COLORS[d.group] || POI_DEFAULT_COLOR;
return [c[0], c[1], c[2], 255] as [number, number, number, number];
},
getLineWidth: 2.5,
lineWidthUnits: 'pixels',
stroked: true,
pickable: true,
onHover: stablePoiHover,
transitions: { getRadius: { duration: 300, enter: () => [0] } },
}),
[visiblePois, isDark, stablePoiHover]
);
const poiIconLayer = useMemo(
() =>
new IconLayer<POI>({
id: 'poi-icons',
data: visiblePois,
getPosition: (d) => [d.lng, d.lat],
getIcon: (d) => ({
url: emojiToTwemojiUrl(d.emoji),
width: 72,
height: 72,
}),
getSize: 18,
sizeUnits: 'pixels',
pickable: false,
transitions: { getSize: { duration: 300, enter: () => [0] } },
}),
[visiblePois]
);
// --- Cluster layers ---
const clusterCircleLayer = useMemo(
() =>
new ScatterplotLayer<ClusterPoint>({
id: 'poi-clusters',
data: clusters,
getPosition: (d) => [d.lng, d.lat],
getRadius: (d) => Math.min(30, 14 + Math.sqrt(d.count) * 2),
radiusUnits: 'pixels',
getFillColor: isDark ? [5, 129, 114, 220] : [20, 184, 166, 220],
getLineColor: [255, 255, 255, isDark ? 60 : 120],
getLineWidth: 2,
lineWidthUnits: 'pixels',
stroked: true,
pickable: true,
onHover: stableClusterHover,
transitions: { getRadius: { duration: 300, enter: () => [0] } },
}),
[clusters, isDark, stableClusterHover]
);
const clusterTextLayer = useMemo(
() =>
new TextLayer<ClusterPoint>({
id: 'poi-cluster-text',
data: clusters,
getPosition: (d) => [d.lng, d.lat],
getText: (d) => (d.count >= 1000 ? `${(d.count / 1000).toFixed(1)}k` : String(d.count)),
getSize: 12,
getColor: [255, 255, 255, 255],
fontWeight: 700,
fontFamily: 'Inter, system-ui, sans-serif',
getTextAnchor: 'middle',
getAlignmentBaseline: 'center',
sizeUnits: 'pixels',
pickable: false,
}),
[clusters]
);
// Marching ants highlight layer for selected hexagon or postcode
const marchingAntsLayer = useMemo(() => {
let geometry: PostcodeGeometry | null = null;
@ -748,10 +546,25 @@ export function useDeckLayers({
});
}, [selectedPostcodeGeometry, selectedHexagonId, marchTime]);
const poiLayers = useMemo(
() => [poiShadowLayer, poiBackgroundLayer, poiIconLayer, clusterCircleLayer, clusterTextLayer],
[poiShadowLayer, poiBackgroundLayer, poiIconLayer, clusterCircleLayer, clusterTextLayer]
);
const currentLocationLayer = useMemo(() => {
if (!currentLocation) return null;
return new ScatterplotLayer<{ lat: number; lng: number; kind: 'ring' | 'dot' }>({
id: 'current-location-dot',
data: [
{ ...currentLocation, kind: 'ring' },
{ ...currentLocation, kind: 'dot' },
],
getPosition: (d) => [d.lng, d.lat],
getRadius: (d) => (d.kind === 'ring' ? 16 : 5),
radiusUnits: 'pixels',
getFillColor: (d) => (d.kind === 'ring' ? [20, 184, 166, 45] : [220, 38, 38, 255]),
getLineColor: (d) => (d.kind === 'ring' ? [20, 184, 166, 240] : [255, 255, 255, 240]),
getLineWidth: 2,
lineWidthUnits: 'pixels',
stroked: true,
pickable: false,
});
}, [currentLocation]);
const layers = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -761,6 +574,7 @@ export function useDeckLayers({
: [postcodeLayer, ...poiLayers]
: [hexLayer, ...poiLayers];
if (marchingAntsLayer) baseLayers.push(marchingAntsLayer);
if (currentLocationLayer) baseLayers.push(currentLocationLayer);
return baseLayers;
}, [
usePostcodeView,
@ -770,16 +584,15 @@ export function useDeckLayers({
postcodeLabelsLayer,
poiLayers,
marchingAntsLayer,
currentLocationLayer,
]);
const handleMouseLeave = useCallback(() => {
setHoverPosition(null);
setHoveredPostcode(null);
setPopupInfo(null);
clearPopupInfo();
onHexagonHoverRef.current(null);
}, []);
const clearPopupInfo = useCallback(() => setPopupInfo(null), []);
}, [clearPopupInfo]);
return {
layers,

View file

@ -96,6 +96,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
const meta = features.find((f) => f.name === name);
if (meta?.type === 'enum') return;
pendingDragRef.current = name;
setActiveFeature(name);
},
[features]
);
@ -112,8 +113,9 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
const handleDragEnd = useCallback(() => {
if (pendingDragRef.current) {
// Click without drag — no state was changed, just clear the ref
// Click without drag — no filter value was changed, just clear preview state.
pendingDragRef.current = null;
setActiveFeature(null);
return;
}
const af = dragActiveRef.current;
@ -131,6 +133,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
const handleDragEndNoCommit = useCallback((): [number, number] | null => {
if (pendingDragRef.current) {
pendingDragRef.current = null;
setActiveFeature(null);
return null;
}
const dv = dragValueRef.current;

View file

@ -1,5 +1,5 @@
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { latLngToCell } from 'h3-js';
import { cellToLatLng, cellToParent, latLngToCell } from 'h3-js';
import { trackEvent } from '../lib/analytics';
import type {
FeatureMeta,
@ -11,10 +11,13 @@ import type {
} from '../types';
import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders } from '../lib/api';
const CURRENT_LOCATION_HEX_RESOLUTION = 12;
interface SelectedHexagon {
id: string;
type: 'hexagon' | 'postcode';
resolution: number;
lockedResolution?: boolean;
}
interface JourneyDest {
@ -22,10 +25,18 @@ interface JourneyDest {
slug: string;
}
interface PostcodeLookupResponse {
postcode: string;
latitude: number;
longitude: number;
geometry: PostcodeGeometry;
}
interface UseHexagonSelectionOptions {
filters: FeatureFilters;
features: FeatureMeta[];
resolution: number;
usePostcodeView: boolean;
/** First transit destination — used to pick the best central_postcode for journey display. */
journeyDest?: JourneyDest | null;
}
@ -34,6 +45,7 @@ export function useHexagonSelection({
filters,
features,
resolution,
usePostcodeView,
journeyDest,
}: UseHexagonSelectionOptions) {
const [selectedHexagon, setSelectedHexagon] = useState<SelectedHexagon | null>(null);
@ -42,6 +54,7 @@ export function useHexagonSelection({
const [propertiesOffset, setPropertiesOffset] = useState(0);
const [loadingProperties, setLoadingProperties] = useState(false);
const [areaStats, setAreaStats] = useState<HexagonStatsResponse | null>(null);
const [unfilteredAreaCount, setUnfilteredAreaCount] = useState<number | null>(null);
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
const [rightPaneTab, setRightPaneTab] = useState<'properties' | 'area'>('area');
@ -50,12 +63,18 @@ export function useHexagonSelection({
);
const fetchHexagonStats = useCallback(
async (h3: string, res: number, signal?: AbortSignal, fields?: string[]) => {
async (
h3: string,
res: number,
signal?: AbortSignal,
fields?: string[],
includeFilters = true
) => {
const params = new URLSearchParams({
h3,
resolution: res.toString(),
});
const filterStr = buildFilterString(filters, features);
const filterStr = includeFilters ? buildFilterString(filters, features) : '';
if (filterStr) params.append('filters', filterStr);
if (fields) {
params.set('fields', fields.join(';;'));
@ -72,9 +91,9 @@ export function useHexagonSelection({
);
const fetchPostcodeStats = useCallback(
async (postcode: string, signal?: AbortSignal) => {
async (postcode: string, signal?: AbortSignal, includeFilters = true) => {
const params = new URLSearchParams({ postcode });
const filterStr = buildFilterString(filters, features);
const filterStr = includeFilters ? buildFilterString(filters, features) : '';
if (filterStr) params.append('filters', filterStr);
const response = await fetch(apiUrl('postcode-stats', params), authHeaders({ signal }));
assertOk(response, 'postcode-stats');
@ -83,6 +102,47 @@ export function useHexagonSelection({
[filters, features]
);
const filterStr = useMemo(() => buildFilterString(filters, features), [filters, features]);
const fetchUnfilteredAreaCount = useCallback(
async (selection: SelectedHexagon, signal?: AbortSignal) => {
if (!filterStr) {
setUnfilteredAreaCount(null);
return;
}
const stats =
selection.type === 'postcode'
? await fetchPostcodeStats(selection.id, signal, false)
: await fetchHexagonStats(selection.id, selection.resolution, signal, undefined, false);
setUnfilteredAreaCount(stats.count);
},
[filterStr, fetchHexagonStats, fetchPostcodeStats]
);
const refreshUnfilteredAreaCount = useCallback(
(selection: SelectedHexagon, filteredCount: number, signal?: AbortSignal) => {
if (!filterStr || filteredCount > 0) {
setUnfilteredAreaCount(null);
return;
}
fetchUnfilteredAreaCount(selection, signal).catch((error) =>
logNonAbortError('Failed to fetch unfiltered area count', error)
);
},
[filterStr, fetchUnfilteredAreaCount]
);
const fetchPostcodeLookup = useCallback(async (postcode: string, signal?: AbortSignal) => {
const response = await fetch(
`/api/postcode/${encodeURIComponent(postcode)}`,
authHeaders({ signal })
);
assertOk(response, 'postcode lookup');
return (await response.json()) as PostcodeLookupResponse;
}, []);
const fetchHexagonProperties = useCallback(
async (h3: string, res: number, offset = 0) => {
setLoadingProperties(true);
@ -156,33 +216,42 @@ export function useHexagonSelection({
setSelectedHexagon(null);
setProperties([]);
setAreaStats(null);
setUnfilteredAreaCount(null);
setSelectedPostcodeGeometry(null);
} else {
const type = isPostcode ? 'postcode' : 'hexagon';
const type: SelectedHexagon['type'] = isPostcode ? 'postcode' : 'hexagon';
const selection = { id, type, resolution };
trackEvent('Hexagon Click', { type });
setSelectedHexagon({ id, type, resolution });
setSelectedHexagon(selection);
setSelectedPostcodeGeometry(isPostcode && geometry ? geometry : null);
setProperties([]);
setPropertiesTotal(0);
setPropertiesOffset(0);
setUnfilteredAreaCount(null);
setRightPaneTab('area');
if (isPostcode) {
setLoadingAreaStats(true);
fetchPostcodeStats(id)
.then((stats) => setAreaStats(stats))
.then((stats) => {
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count);
})
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
.finally(() => setLoadingAreaStats(false));
} else {
setLoadingAreaStats(true);
fetchHexagonStats(id, resolution)
.then((stats) => setAreaStats(stats))
.then((stats) => {
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count);
})
.catch((error) => logNonAbortError('Failed to fetch area stats', error))
.finally(() => setLoadingAreaStats(false));
}
}
},
[selectedHexagon, resolution, fetchHexagonStats, fetchPostcodeStats]
[selectedHexagon, resolution, fetchHexagonStats, fetchPostcodeStats, refreshUnfilteredAreaCount]
);
const handleHexagonHover = useCallback((h3: string | null) => {
@ -232,11 +301,111 @@ export function useHexagonSelection({
setSelectedHexagon(null);
setProperties([]);
setAreaStats(null);
setUnfilteredAreaCount(null);
setSelectedPostcodeGeometry(null);
}, []);
// Keep the selected area aligned with the active map view as zoom changes.
useEffect(() => {
if (!selectedHexagon) return;
const selection = selectedHexagon;
const shouldSync =
(usePostcodeView &&
selection.type === 'hexagon' &&
!selection.lockedResolution &&
areaStats?.central_postcode != null) ||
(!usePostcodeView && selection.type === 'postcode') ||
(!usePostcodeView &&
selection.type === 'hexagon' &&
!selection.lockedResolution &&
selection.resolution !== resolution);
if (!shouldSync) return;
let cancelled = false;
const controller = new AbortController();
const refreshProperties = (selection: SelectedHexagon) => {
if (rightPaneTab !== 'properties') return;
if (selection.type === 'postcode') {
fetchPostcodeProperties(selection.id, 0);
} else {
fetchHexagonProperties(selection.id, selection.resolution, 0);
}
};
async function syncSelection() {
let nextSelection: SelectedHexagon | null = null;
let nextGeometry: PostcodeGeometry | null = null;
let nextStats: HexagonStatsResponse | null = null;
if (usePostcodeView && selection.type === 'hexagon' && !selection.lockedResolution) {
if (!areaStats?.central_postcode) return;
const lookup = await fetchPostcodeLookup(areaStats.central_postcode, controller.signal);
nextSelection = { id: lookup.postcode, type: 'postcode', resolution };
nextGeometry = lookup.geometry;
nextStats = await fetchPostcodeStats(lookup.postcode, controller.signal);
} else if (!usePostcodeView && selection.type === 'postcode') {
const lookup = await fetchPostcodeLookup(selection.id, controller.signal);
const nextId = latLngToCell(lookup.latitude, lookup.longitude, resolution);
nextSelection = { id: nextId, type: 'hexagon', resolution };
nextStats = await fetchHexagonStats(nextId, resolution, controller.signal);
} else if (
!usePostcodeView &&
selection.type === 'hexagon' &&
!selection.lockedResolution &&
selection.resolution !== resolution
) {
const nextId =
resolution < selection.resolution
? cellToParent(selection.id, resolution)
: latLngToCell(...cellToLatLng(selection.id), resolution);
nextSelection = { id: nextId, type: 'hexagon', resolution };
nextStats = await fetchHexagonStats(nextId, resolution, controller.signal);
} else {
return;
}
if (cancelled || !nextSelection || !nextStats) return;
setSelectedHexagon(nextSelection);
setSelectedPostcodeGeometry(nextGeometry);
setAreaStats(nextStats);
refreshUnfilteredAreaCount(nextSelection, nextStats.count, controller.signal);
refreshProperties(nextSelection);
}
setProperties([]);
setPropertiesTotal(0);
setPropertiesOffset(0);
setUnfilteredAreaCount(null);
setLoadingAreaStats(true);
syncSelection()
.catch((error) => {
if (!cancelled) logNonAbortError('Failed to sync selected area with map view', error);
})
.finally(() => {
if (!cancelled) setLoadingAreaStats(false);
});
return () => {
cancelled = true;
controller.abort();
};
}, [
selectedHexagon,
resolution,
usePostcodeView,
areaStats?.central_postcode,
fetchHexagonStats,
fetchPostcodeStats,
fetchPostcodeLookup,
fetchHexagonProperties,
fetchPostcodeProperties,
refreshUnfilteredAreaCount,
rightPaneTab,
]);
// Re-fetch stats when filters change while a hexagon is selected
const filterStr = useMemo(() => buildFilterString(filters, features), [filters, features]);
const prevFilterStr = useRef(filterStr);
useEffect(() => {
@ -261,19 +430,14 @@ export function useHexagonSelection({
fetchStats
.then((stats) => {
if (cancelled) return;
if (stats.count === 0) {
setSelectedHexagon(null);
setAreaStats(null);
setSelectedPostcodeGeometry(null);
} else {
setAreaStats(stats);
// Re-fetch properties if the properties tab is active
if (rightPaneTab === 'properties') {
if (selectedHexagon.type === 'postcode') {
fetchPostcodeProperties(selectedHexagon.id, 0);
} else {
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
}
setAreaStats(stats);
refreshUnfilteredAreaCount(selectedHexagon, stats.count);
// Re-fetch properties if the properties tab is active and the filtered area still has matches.
if (rightPaneTab === 'properties' && stats.count > 0) {
if (selectedHexagon.type === 'postcode') {
fetchPostcodeProperties(selectedHexagon.id, 0);
} else {
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
}
}
})
@ -296,6 +460,7 @@ export function useHexagonSelection({
rightPaneTab,
fetchHexagonProperties,
fetchPostcodeProperties,
refreshUnfilteredAreaCount,
]);
const handleLocationSearch = useCallback(
@ -304,6 +469,7 @@ export function useHexagonSelection({
setProperties([]);
setPropertiesTotal(0);
setPropertiesOffset(0);
setUnfilteredAreaCount(null);
setRightPaneTab('area');
setLoadingAreaStats(true);
@ -311,18 +477,22 @@ export function useHexagonSelection({
fetchPostcodeStats(postcode)
.then(async (stats) => {
if (stats.count > 0) {
setSelectedHexagon({ id: postcode, type: 'postcode', resolution });
const selection = { id: postcode, type: 'postcode' as const, resolution };
setSelectedHexagon(selection);
setSelectedPostcodeGeometry(geometry);
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count);
return;
}
// No properties in this postcode — fall back to hexagons
if (lat == null || lng == null) {
// No coordinates available, show empty postcode anyway
setSelectedHexagon({ id: postcode, type: 'postcode', resolution });
const selection = { id: postcode, type: 'postcode' as const, resolution };
setSelectedHexagon(selection);
setSelectedPostcodeGeometry(geometry);
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count);
return;
}
@ -332,9 +502,11 @@ export function useHexagonSelection({
const h3 = latLngToCell(lat, lng, res);
const hexStats = await fetchHexagonStats(h3, res);
if (hexStats.count > 1) {
setSelectedHexagon({ id: h3, type: 'hexagon', resolution: res });
const selection = { id: h3, type: 'hexagon' as const, resolution: res };
setSelectedHexagon(selection);
setSelectedPostcodeGeometry(null);
setAreaStats(hexStats);
refreshUnfilteredAreaCount(selection, hexStats.count);
return;
}
}
@ -342,14 +514,47 @@ export function useHexagonSelection({
// Even the coarsest hexagon has ≤1 property — show whatever the finest has
const h3 = latLngToCell(lat, lng, 9);
const fallbackStats = await fetchHexagonStats(h3, 9);
setSelectedHexagon({ id: h3, type: 'hexagon', resolution: 9 });
const selection = { id: h3, type: 'hexagon' as const, resolution: 9 };
setSelectedHexagon(selection);
setSelectedPostcodeGeometry(null);
setAreaStats(fallbackStats);
refreshUnfilteredAreaCount(selection, fallbackStats.count);
})
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
.finally(() => setLoadingAreaStats(false));
},
[resolution, fetchPostcodeStats, fetchHexagonStats]
[resolution, fetchPostcodeStats, fetchHexagonStats, refreshUnfilteredAreaCount]
);
const handleCurrentLocationSearch = useCallback(
(lat: number, lng: number) => {
const h3 = latLngToCell(lat, lng, CURRENT_LOCATION_HEX_RESOLUTION);
const selection = {
id: h3,
type: 'hexagon' as const,
resolution: CURRENT_LOCATION_HEX_RESOLUTION,
lockedResolution: true,
};
trackEvent('Current Location Search');
setSelectedHexagon(selection);
setSelectedPostcodeGeometry(null);
setProperties([]);
setPropertiesTotal(0);
setPropertiesOffset(0);
setUnfilteredAreaCount(null);
setRightPaneTab('area');
setLoadingAreaStats(true);
fetchHexagonStats(h3, CURRENT_LOCATION_HEX_RESOLUTION)
.then((stats) => {
setAreaStats(stats);
refreshUnfilteredAreaCount(selection, stats.count);
})
.catch((error) => logNonAbortError('Failed to fetch current location hex stats', error))
.finally(() => setLoadingAreaStats(false));
},
[fetchHexagonStats, refreshUnfilteredAreaCount]
);
return {
@ -359,6 +564,7 @@ export function useHexagonSelection({
loadingProperties,
areaStats,
loadingAreaStats,
unfilteredAreaCount,
hoveredHexagon,
rightPaneTab,
setRightPaneTab,
@ -370,5 +576,6 @@ export function useHexagonSelection({
handleCloseSelection,
selectedPostcodeGeometry,
handleLocationSearch,
handleCurrentLocationSearch,
};
}

View file

@ -38,6 +38,14 @@ const descriptions: Record<string, Record<string, string>> = {
'Écoles primaires notées Bien ou Excellent par Ofsted dans un rayon de 5 km',
'Good+ secondary schools within 5km':
'Collèges/lycées notés Bien ou Excellent par Ofsted dans un rayon de 5 km',
'Outstanding primary schools within 2km':
'Écoles primaires notées Excellent par Ofsted dans un rayon de 2 km',
'Outstanding secondary schools within 2km':
'Collèges/lycées notés Excellent par Ofsted dans un rayon de 2 km',
'Outstanding primary schools within 5km':
'Écoles primaires notées Excellent par Ofsted dans un rayon de 5 km',
'Outstanding secondary schools within 5km':
'Collèges/lycées notés Excellent par Ofsted dans un rayon de 5 km',
'Education, Skills and Training Score':
'Score de qualité éducative du secteur (plus élevé = meilleur)',
'Income Score (rate)': 'Taux de précarité de revenu, inversé (plus élevé = moins précaire)',
@ -121,6 +129,14 @@ const descriptions: Record<string, Record<string, string>> = {
'Von Ofsted mit Gut oder Hervorragend bewertete Grundschulen im Umkreis von 5 km',
'Good+ secondary schools within 5km':
'Von Ofsted mit Gut oder Hervorragend bewertete weiterführende Schulen im Umkreis von 5 km',
'Outstanding primary schools within 2km':
'Von Ofsted mit Hervorragend bewertete Grundschulen im Umkreis von 2 km',
'Outstanding secondary schools within 2km':
'Von Ofsted mit Hervorragend bewertete weiterführende Schulen im Umkreis von 2 km',
'Outstanding primary schools within 5km':
'Von Ofsted mit Hervorragend bewertete Grundschulen im Umkreis von 5 km',
'Outstanding secondary schools within 5km':
'Von Ofsted mit Hervorragend bewertete weiterführende Schulen im Umkreis von 5 km',
'Education, Skills and Training Score': 'Bildungsqualitätsscore der Gegend (höher = besser)',
'Income Score (rate)':
'Einkommensbenachteiligungsrate, invertiert (höher = weniger benachteiligt)',
@ -202,6 +218,10 @@ const descriptions: Record<string, Record<string, string>> = {
'Good+ secondary schools within 2km': 'Ofsted评为良好或优秀的2公里内中学',
'Good+ primary schools within 5km': 'Ofsted评为良好或优秀的5公里内小学',
'Good+ secondary schools within 5km': 'Ofsted评为良好或优秀的5公里内中学',
'Outstanding primary schools within 2km': 'Ofsted评为优秀的2公里内小学',
'Outstanding secondary schools within 2km': 'Ofsted评为优秀的2公里内中学',
'Outstanding primary schools within 5km': 'Ofsted评为优秀的5公里内小学',
'Outstanding secondary schools within 5km': 'Ofsted评为优秀的5公里内中学',
'Education, Skills and Training Score': '当地教育质量得分(越高越好)',
'Income Score (rate)': '收入贫困率,反向指标(越高越不贫困)',
'Employment Score (rate)': '就业贫困率,反向指标(越高越不贫困)',
@ -275,6 +295,14 @@ const descriptions: Record<string, Record<string, string>> = {
'Ofsted által Jó vagy Kiváló minősítésű általános iskolák 5 km-en belül',
'Good+ secondary schools within 5km':
'Ofsted által Jó vagy Kiváló minősítésű középiskolák 5 km-en belül',
'Outstanding primary schools within 2km':
'Ofsted által Kiváló minősítésű általános iskolák 2 km-en belül',
'Outstanding secondary schools within 2km':
'Ofsted által Kiváló minősítésű középiskolák 2 km-en belül',
'Outstanding primary schools within 5km':
'Ofsted által Kiváló minősítésű általános iskolák 5 km-en belül',
'Outstanding secondary schools within 5km':
'Ofsted által Kiváló minősítésű középiskolák 5 km-en belül',
'Education, Skills and Training Score':
'A környék oktatási minőségi pontszáma (magasabb = jobb)',
'Income Score (rate)': 'Jövedelmi deprivációs ráta, invertálva (magasabb = kevésbé hátrányos)',

View file

@ -45,6 +45,14 @@ export const details: Record<string, Record<string, string>> = {
"Écoles primaires financées par l'État dans un rayon de 5km ayant une note Ofsted actuelle de Bon ou Exceptionnel. Les écoles n'ayant pas encore été inspectées sont exclues.",
'Good+ secondary schools within 5km':
"Lycées et collèges financés par l'État dans un rayon de 5km ayant une note Ofsted actuelle de Bon ou Exceptionnel. Les établissements n'ayant pas encore été inspectés sont exclus.",
'Outstanding primary schools within 2km':
"Écoles primaires financées par l'État dans un rayon de 2km ayant une note Ofsted actuelle d'Exceptionnel. Les écoles n'ayant pas encore été inspectées sont exclues.",
'Outstanding secondary schools within 2km':
"Lycées et collèges financés par l'État dans un rayon de 2km ayant une note Ofsted actuelle d'Exceptionnel. Les établissements n'ayant pas encore été inspectés sont exclus.",
'Outstanding primary schools within 5km':
"Écoles primaires financées par l'État dans un rayon de 5km ayant une note Ofsted actuelle d'Exceptionnel. Les écoles n'ayant pas encore été inspectées sont exclues.",
'Outstanding secondary schools within 5km':
"Lycées et collèges financés par l'État dans un rayon de 5km ayant une note Ofsted actuelle d'Exceptionnel. Les établissements n'ayant pas encore été inspectés sont exclus.",
'Education, Skills and Training Score':
"Provient des Indices de Déprivation anglais (inversé afin que plus le score est élevé, meilleur est le résultat). Couvre les résultats scolaires, l'accès à l'enseignement supérieur, les qualifications des adultes et la maîtrise de la langue anglaise. Des scores plus élevés indiquent moins de déprivation.",
'Income Score (rate)':
@ -177,6 +185,14 @@ export const details: Record<string, Record<string, string>> = {
'Staatlich geförderte Grundschulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von „Good" oder „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Good+ secondary schools within 5km':
'Staatlich geförderte weiterführende Schulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von „Good" oder „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Outstanding primary schools within 2km':
'Staatlich geförderte Grundschulen innerhalb von 2 km mit einer aktuellen Ofsted-Bewertung von „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Outstanding secondary schools within 2km':
'Staatlich geförderte weiterführende Schulen innerhalb von 2 km mit einer aktuellen Ofsted-Bewertung von „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Outstanding primary schools within 5km':
'Staatlich geförderte Grundschulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Outstanding secondary schools within 5km':
'Staatlich geförderte weiterführende Schulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Education, Skills and Training Score':
'Aus den englischen Deprivationsindizes (invertiert, sodass höher = besser bedeutet). Umfasst Schulleistungen, Hochschulzugang, Qualifikationen Erwachsener und Englischsprachkenntnisse. Höhere Werte weisen auf geringere Benachteiligung hin.',
'Income Score (rate)':
@ -309,6 +325,14 @@ export const details: Record<string, Record<string, string>> = {
'5km范围内Ofsted评级为"Good"或"Outstanding"的公立小学数量。尚未接受评估的学校不计入。',
'Good+ secondary schools within 5km':
'5km范围内Ofsted评级为"Good"或"Outstanding"的公立中学数量。尚未接受评估的学校不计入。',
'Outstanding primary schools within 2km':
'2km范围内Ofsted评级为"Outstanding"的公立小学数量。尚未接受评估的学校不计入。',
'Outstanding secondary schools within 2km':
'2km范围内Ofsted评级为"Outstanding"的公立中学数量。尚未接受评估的学校不计入。',
'Outstanding primary schools within 5km':
'5km范围内Ofsted评级为"Outstanding"的公立小学数量。尚未接受评估的学校不计入。',
'Outstanding secondary schools within 5km':
'5km范围内Ofsted评级为"Outstanding"的公立中学数量。尚未接受评估的学校不计入。',
'Education, Skills and Training Score':
'来自英格兰剥夺指数(取反后越高越好)。涵盖学校成绩、高等教育入学率、成人学历和英语水平。分数越高表示剥夺程度越低。',
'Income Score (rate)':
@ -439,6 +463,14 @@ export const details: Record<string, Record<string, string>> = {
'5 km-en belüli állami fenntartású általános iskolák, amelyek aktuális Ofsted besorolása Jó vagy Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
'Good+ secondary schools within 5km':
'5 km-en belüli állami fenntartású középiskolák, amelyek aktuális Ofsted besorolása Jó vagy Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
'Outstanding primary schools within 2km':
'2 km-en belüli állami fenntartású általános iskolák, amelyek aktuális Ofsted besorolása Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
'Outstanding secondary schools within 2km':
'2 km-en belüli állami fenntartású középiskolák, amelyek aktuális Ofsted besorolása Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
'Outstanding primary schools within 5km':
'5 km-en belüli állami fenntartású általános iskolák, amelyek aktuális Ofsted besorolása Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
'Outstanding secondary schools within 5km':
'5 km-en belüli állami fenntartású középiskolák, amelyek aktuális Ofsted besorolása Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
'Education, Skills and Training Score':
'Az Angol Nélkülözési Indexekből (megfordítva, így magasabb = jobb). Az iskolai teljesítményt, a felsőoktatásba való bejutást, a felnőttkori képesítéseket és az angol nyelvi jártasságot foglalja magában. A magasabb pontszámok kisebb mértékű nélkülözést jeleznek.',
'Income Score (rate)':

View file

@ -259,6 +259,16 @@ const de: Translations = {
areaStatistics: 'Gebietsstatistiken',
statsFor: 'Statistiken für alle Immobilien in diesem {{type}}',
matchingFilters: ', die allen aktiven Filtern entsprechen',
filtersAffectStats:
'Filter im linken Bereich werden hier angewendet: Werte, Diagramme und Immobilienzahlen nutzen die {{count}} aktiven Filter.',
noFiltersAffectStats:
'Filter im linken Bereich aktualisieren diesen Bereich: Fügen Sie Filter hinzu, um diese Werte für passende Immobilien neu zu berechnen.',
noFilteredMatches: 'Keine Immobilien in diesem Gebiet entsprechen Ihren Filtern.',
unfilteredAreaCount:
'{{count}} Immobilien gibt es hier vor den Filtern; der Ort ist gültig, wird aber herausgefiltert.',
noUnfilteredAreaProperties:
'In diesem ausgewählten Gebiet wurden auch vor den Filtern keine Immobilien gefunden.',
relaxFiltersHint: 'Lockern oder löschen Sie Filter, um Immobilien in diesem Gebiet zu sehen.',
viewProperties: '{{count}} Immobilien ansehen',
priceHistory: 'Preisentwicklung',
journeysFrom: 'Verbindungen ab {{label}}',
@ -742,6 +752,12 @@ const de: Translations = {
'Good+ secondary schools within 2km': 'Gute+ weiterführende Schulen im Umkreis von 2 km',
'Good+ primary schools within 5km': 'Gute+ Grundschulen im Umkreis von 5 km',
'Good+ secondary schools within 5km': 'Gute+ weiterführende Schulen im Umkreis von 5 km',
'Outstanding primary schools within 2km': 'Hervorragende Grundschulen im Umkreis von 2 km',
'Outstanding secondary schools within 2km':
'Hervorragende weiterführende Schulen im Umkreis von 2 km',
'Outstanding primary schools within 5km': 'Hervorragende Grundschulen im Umkreis von 5 km',
'Outstanding secondary schools within 5km':
'Hervorragende weiterführende Schulen im Umkreis von 5 km',
'Education, Skills and Training Score': 'Score für Bildung, Kompetenzen und Ausbildung',
// ─ Feature names (Deprivation) ─

View file

@ -256,6 +256,15 @@ const en = {
areaStatistics: 'Area Statistics',
statsFor: 'Stats for all properties in this {{type}}',
matchingFilters: ' matching all active filters',
filtersAffectStats:
'Left-pane filters are applied here: values, charts, and property counts use the {{count}} active filters.',
noFiltersAffectStats:
'Left-pane filters update this pane: add filters to recalculate these values for matching properties.',
noFilteredMatches: 'No properties match your filters in this area.',
unfilteredAreaCount:
'{{count}} properties exist here before filters, so the location is valid but filtered out.',
noUnfilteredAreaProperties: 'No properties were found in this selected area before filters.',
relaxFiltersHint: 'Relax or clear filters to see properties in this area.',
viewProperties: 'View {{count}} Properties',
priceHistory: 'Price History',
journeysFrom: 'Journeys from {{label}}',
@ -729,6 +738,10 @@ const en = {
'Good+ secondary schools within 2km': 'Good+ secondary schools within 2km',
'Good+ primary schools within 5km': 'Good+ primary schools within 5km',
'Good+ secondary schools within 5km': 'Good+ secondary schools within 5km',
'Outstanding primary schools within 2km': 'Outstanding primary schools within 2km',
'Outstanding secondary schools within 2km': 'Outstanding secondary schools within 2km',
'Outstanding primary schools within 5km': 'Outstanding primary schools within 5km',
'Outstanding secondary schools within 5km': 'Outstanding secondary schools within 5km',
'Education, Skills and Training Score': 'Education, Skills and Training Score',
// ─ Feature names (Deprivation) ─

View file

@ -263,6 +263,16 @@ const fr: Translations = {
areaStatistics: 'Statistiques de la zone',
statsFor: 'Statistiques pour toutes les propriétés de ce/cette {{type}}',
matchingFilters: ' correspondant à tous les filtres actifs',
filtersAffectStats:
'Les filtres du panneau de gauche sont appliqués ici : valeurs, graphiques et nombres de propriétés utilisent les {{count}} filtres actifs.',
noFiltersAffectStats:
'Les filtres du panneau de gauche mettent ce panneau à jour : ajoutez des filtres pour recalculer ces valeurs pour les propriétés correspondantes.',
noFilteredMatches: 'Aucune propriété de cette zone ne correspond à vos filtres.',
unfilteredAreaCount:
'{{count}} propriétés existent ici avant les filtres ; le lieu est valide, mais filtré.',
noUnfilteredAreaProperties:
'Aucune propriété na été trouvée dans cette zone sélectionnée avant les filtres.',
relaxFiltersHint: 'Assouplissez ou effacez les filtres pour voir les propriétés de cette zone.',
viewProperties: 'Voir {{count}} propriétés',
priceHistory: 'Historique des prix',
journeysFrom: 'Trajets depuis {{label}}',
@ -745,6 +755,10 @@ const fr: Translations = {
'Good+ secondary schools within 2km': 'Collèges/lycées Bien+ dans un rayon de 2 km',
'Good+ primary schools within 5km': 'Écoles primaires Bien+ dans un rayon de 5 km',
'Good+ secondary schools within 5km': 'Collèges/lycées Bien+ dans un rayon de 5 km',
'Outstanding primary schools within 2km': 'Écoles primaires Excellent dans un rayon de 2 km',
'Outstanding secondary schools within 2km': 'Collèges/lycées Excellent dans un rayon de 2 km',
'Outstanding primary schools within 5km': 'Écoles primaires Excellent dans un rayon de 5 km',
'Outstanding secondary schools within 5km': 'Collèges/lycées Excellent dans un rayon de 5 km',
'Education, Skills and Training Score': 'Score éducation, compétences et formation',
// ─ Feature names (Deprivation) ─

View file

@ -257,6 +257,16 @@ const hu: Translations = {
areaStatistics: 'Területi statisztikák',
statsFor: 'Statisztikák a(z) {{type}} összes ingatlanáról',
matchingFilters: ' az összes aktív szűrőnek megfelelően',
filtersAffectStats:
'A bal oldali panel szűrői itt is érvényesek: az értékek, diagramok és ingatlanszámok a(z) {{count}} aktív szűrőt használják.',
noFiltersAffectStats:
'A bal oldali panel szűrői frissítik ezt a panelt: adjon hozzá szűrőket, hogy ezek az értékek az illeszkedő ingatlanokra számolódjanak újra.',
noFilteredMatches: 'Ezen a területen egyetlen ingatlan sem felel meg a szűrőknek.',
unfilteredAreaCount:
'{{count}} ingatlan található itt szűrők nélkül, tehát a hely érvényes, csak a szűrők kizárják.',
noUnfilteredAreaProperties:
'A kiválasztott területen szűrők nélkül sem található ingatlan.',
relaxFiltersHint: 'Lazítson vagy törölje a szűrőket, hogy lássa a terület ingatlanjait.',
viewProperties: '{{count}} ingatlan megtekintése',
priceHistory: 'Ártörténet',
journeysFrom: 'Utazások innen: {{label}}',
@ -737,6 +747,10 @@ const hu: Translations = {
'Good+ secondary schools within 2km': 'Jó+ középiskolák 2 km-en belül',
'Good+ primary schools within 5km': 'Jó+ általános iskolák 5 km-en belül',
'Good+ secondary schools within 5km': 'Jó+ középiskolák 5 km-en belül',
'Outstanding primary schools within 2km': 'Kiemelkedő általános iskolák 2 km-en belül',
'Outstanding secondary schools within 2km': 'Kiemelkedő középiskolák 2 km-en belül',
'Outstanding primary schools within 5km': 'Kiemelkedő általános iskolák 5 km-en belül',
'Outstanding secondary schools within 5km': 'Kiemelkedő középiskolák 5 km-en belül',
'Education, Skills and Training Score': 'Oktatás, készségek és képzés pontszám',
// ─ Feature names (Deprivation) ─

View file

@ -254,6 +254,14 @@ const zh: Translations = {
areaStatistics: '区域统计',
statsFor: '该{{type}}内所有房产的统计数据',
matchingFilters: ',满足所有当前筛选条件',
filtersAffectStats:
'左侧面板的筛选条件会应用到这里:数值、图表和房产数量都会使用 {{count}} 个当前筛选条件。',
noFiltersAffectStats:
'左侧面板的筛选条件会更新此面板:添加筛选条件后,这些值会按匹配的房产重新计算。',
noFilteredMatches: '该区域没有房产符合当前筛选条件。',
unfilteredAreaCount: '筛选前这里有 {{count}} 处房产;位置有效,但被筛选条件排除了。',
noUnfilteredAreaProperties: '筛选前该选定区域内也没有找到房产。',
relaxFiltersHint: '放宽或清除筛选条件即可查看该区域的房产。',
viewProperties: '查看 {{count}} 处房产',
priceHistory: '价格历史',
journeysFrom: '从 {{label}} 出发的路线',
@ -661,7 +669,7 @@ const zh: Translations = {
'设置预算、通勤上限、学校质量、犯罪门槛。您关心的一切。只有符合条件的区域会保持高亮。使用眼睛图标按任意特征着色。',
step2Title: '或者直接描述',
step2Content:
'用中文输入您的需求例如“安静的地区靠近好学校£400k 以下”,我们会为您设置筛选。',
'用中文输入您的需求例如“安静的地区靠近好学校£40 以下”,我们会为您设置筛选。',
step3Title: '探索现有住宅',
step3Content:
'在英格兰各地平移和缩放。点击任何彩色区域查看犯罪、学校、价格、宽带、噪音等信息。',
@ -712,6 +720,10 @@ const zh: Translations = {
'Good+ secondary schools within 2km': '2公里内良好+中学数量',
'Good+ primary schools within 5km': '5公里内良好+小学数量',
'Good+ secondary schools within 5km': '5公里内良好+中学数量',
'Outstanding primary schools within 2km': '2公里内优秀小学数量',
'Outstanding secondary schools within 2km': '2公里内优秀中学数量',
'Outstanding primary schools within 5km': '5公里内优秀小学数量',
'Outstanding secondary schools within 5km': '5公里内优秀中学数量',
'Education, Skills and Training Score': '教育、技能和培训得分',
// ─ Feature names (Deprivation) ─

View file

@ -44,6 +44,46 @@ export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[]
{ t: 1, color: [142, 68, 173] },
];
export type GradientStop = { t: number; color: [number, number, number] };
function partyGradient(color: [number, number, number]): GradientStop[] {
return [
{ t: 0, color: [255, 255, 255] },
{
t: 0.5,
color: [
Math.round(255 + (color[0] - 255) * 0.45),
Math.round(255 + (color[1] - 255) * 0.45),
Math.round(255 + (color[2] - 255) * 0.45),
],
},
{ t: 1, color },
];
}
/** UK party colours for the 2024 General Election vote-share map layers. */
export const PARTY_FEATURE_GRADIENTS: Record<string, GradientStop[]> = {
'% Labour': partyGradient([228, 0, 59]), // Labour red
'% Conservative': partyGradient([0, 135, 220]), // Conservative blue
'% Liberal Democrat': partyGradient([255, 100, 0]), // Liberal Democrat orange
'% Reform UK': partyGradient([18, 182, 207]), // Reform UK cyan
'% Green': partyGradient([106, 176, 35]), // Green Party green
'% Other parties': partyGradient([107, 114, 128]), // neutral fallback for grouped parties
};
export const PARTY_FEATURE_COLORS: Record<string, string> = Object.fromEntries(
Object.entries(PARTY_FEATURE_GRADIENTS).map(([featureName, gradient]) => {
const color = gradient[gradient.length - 1].color;
return [featureName, `rgb(${color[0]}, ${color[1]}, ${color[2]})`];
})
);
export function getFeatureGradient(featureName: string | null | undefined): GradientStop[] {
return featureName
? (PARTY_FEATURE_GRADIENTS[featureName] ?? FEATURE_GRADIENT)
: FEATURE_GRADIENT;
}
/** Number of properties gradient — light mode (cream → orange) */
export const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [255, 255, 255] },

View file

@ -122,6 +122,16 @@ export function buildPropertySearchUrls({
? (tenureFilter as string[])
: [];
const habitableRoomsFilter = filters['Number of bedrooms & living rooms'];
const minBedrooms =
Array.isArray(habitableRoomsFilter) && typeof habitableRoomsFilter[0] === 'number'
? Math.max(0, habitableRoomsFilter[0] - 1)
: undefined;
const maxBedrooms =
Array.isArray(habitableRoomsFilter) && typeof habitableRoomsFilter[1] === 'number'
? Math.max(0, habitableRoomsFilter[1] - 1)
: undefined;
// Rightmove — requires locationIdentifier from typeahead API
let rightmove: string | null = null;
if (rightmoveLocationId) {
@ -134,6 +144,8 @@ export function buildPropertySearchUrls({
rmParams.set('minPrice', String(snapToAllowed(minPrice, RIGHTMOVE_PRICES, 'floor')));
if (maxPrice !== undefined)
rmParams.set('maxPrice', String(snapToAllowed(maxPrice, RIGHTMOVE_PRICES, 'ceil')));
if (minBedrooms !== undefined) rmParams.set('minBedrooms', String(minBedrooms));
if (maxBedrooms !== undefined) rmParams.set('maxBedrooms', String(maxBedrooms));
if (selectedTypes.length > 0) {
const rmTypes = [
...new Set(
@ -161,6 +173,8 @@ export function buildPropertySearchUrls({
otmParams.set('min-price', String(snapToAllowed(minPrice, OTM_PRICES, 'floor')));
if (maxPrice !== undefined)
otmParams.set('max-price', String(snapToAllowed(maxPrice, OTM_PRICES, 'ceil')));
if (minBedrooms !== undefined) otmParams.set('min-bedrooms', String(minBedrooms));
if (maxBedrooms !== undefined) otmParams.set('max-bedrooms', String(maxBedrooms));
if (selectedTypes.length > 0) {
const otmTypes = [
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean)),
@ -181,6 +195,8 @@ export function buildPropertySearchUrls({
zParams.set('price_min', String(snapToAllowed(minPrice, ZOOPLA_PRICES, 'floor')));
if (maxPrice !== undefined)
zParams.set('price_max', String(snapToAllowed(maxPrice, ZOOPLA_PRICES, 'ceil')));
if (minBedrooms !== undefined) zParams.set('beds_min', String(minBedrooms));
if (maxBedrooms !== undefined) zParams.set('beds_max', String(maxBedrooms));
if (selectedTypes.length > 0) {
const zTypes = [
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.zoopla).filter(Boolean)),

View file

@ -129,6 +129,19 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
</>
),
'Outstanding primary schools within 5km': (
<>
<path d="M4 19V9l8-6 8 6v10" />
<path d="M9 19v-6h6v6" />
<line x1="4" y1="19" x2="20" y2="19" />
</>
),
'Outstanding secondary schools within 5km': (
<>
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
</>
),
'Good+ primary schools within 2km': (
<>
<path d="M4 19V9l8-6 8 6v10" />
@ -142,6 +155,19 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
</>
),
'Outstanding primary schools within 2km': (
<>
<path d="M4 19V9l8-6 8 6v10" />
<path d="M9 19v-6h6v6" />
<line x1="4" y1="19" x2="20" y2="19" />
</>
),
'Outstanding secondary schools within 2km': (
<>
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
</>
),
// ── Deprivation ──────────────────────────────
'Income Score (rate)': (

View file

@ -1,3 +1,5 @@
import i18n from 'i18next';
interface ValueFormat {
prefix?: string;
suffix?: string;
@ -5,10 +7,31 @@ interface ValueFormat {
raw?: boolean;
}
function usesChineseNumberUnits(): boolean {
return i18n.language?.toLowerCase().startsWith('zh') ?? false;
}
function formatChineseCompactNumber(value: number): string | null {
const abs = Math.abs(value);
if (abs >= 100_000_000) return `${trimFixed(value / 100_000_000)}亿`;
if (abs >= 10_000) return `${trimFixed(value / 10_000)}`;
return null;
}
function trimFixed(value: number): string {
return value.toFixed(1).replace(/\.0$/, '');
}
export function formatValue(value: number, fmt?: ValueFormat): string {
const p = fmt?.prefix ?? '';
const s = fmt?.suffix ?? '';
if (fmt?.raw) return `${p}${Math.round(value)}${s}`;
if (usesChineseNumberUnits()) {
const chineseCompactValue = formatChineseCompactNumber(value);
if (chineseCompactValue) return `${p}${chineseCompactValue}${s}`;
if (Number.isInteger(value)) return `${p}${value.toLocaleString()}${s}`;
return `${p}${value.toFixed(1)}${s}`;
}
if (Math.abs(value) >= 1_000_000) return `${p}${(value / 1_000_000).toFixed(1)}M${s}`;
if (Math.abs(value) >= 1_000) return `${p}${(value / 1_000).toFixed(1)}k${s}`;
if (Number.isInteger(value)) return `${p}${value.toLocaleString()}${s}`;
@ -17,6 +40,12 @@ export function formatValue(value: number, fmt?: ValueFormat): string {
export function formatFilterValue(value: number, raw?: boolean): string {
if (raw) return Math.round(value).toString();
if (usesChineseNumberUnits()) {
const chineseCompactValue = formatChineseCompactNumber(value);
if (chineseCompactValue) return chineseCompactValue;
if (Number.isInteger(value)) return value.toString();
return value.toFixed(2);
}
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
if (Number.isInteger(value)) return value.toString();
@ -31,14 +60,17 @@ export function parseInputValue(
let s = text.trim();
if (opts?.prefix) s = s.replace(new RegExp(`^\\${opts.prefix}`), '');
if (opts?.suffix) s = s.replace(new RegExp(`${opts.suffix.trim()}$`), '');
s = s.trim().replace(/,/g, '');
const m = s.match(/^(-?\d+\.?\d*)\s*([kKmM]?)$/);
s = s.trim().replace(/[,]/g, '');
const m = s.match(/^(-?\d+\.?\d*)\s*([kKmM万亿億]?)$/);
if (!m) return null;
let val = parseFloat(m[1]);
if (isNaN(val)) return null;
const unit = m[2].toLowerCase();
const unit = m[2];
if (unit === 'k') val *= 1_000;
else if (unit === 'm') val *= 1_000_000;
else if (unit === 'K') val *= 1_000;
else if (unit === 'm' || unit === 'M') val *= 1_000_000;
else if (unit === '万') val *= 10_000;
else if (unit === '亿' || unit === '億') val *= 100_000_000;
if (opts?.step) val = Math.round(val / opts.step) * opts.step;
return val;
}
@ -102,9 +134,7 @@ export function roundedPercentages(values: number[], total: number, decimals = 0
const floors = raw.map((r) => Math.floor(r));
const result = floors.slice();
let diff = targetSum - floors.reduce((a, b) => a + b, 0);
const order = raw
.map((r, i) => ({ i, frac: r - floors[i] }))
.sort((a, b) => b.frac - a.frac);
const order = raw.map((r, i) => ({ i, frac: r - floors[i] })).sort((a, b) => b.frac - a.frac);
for (let k = 0; k < order.length && diff > 0; k++) {
result[order[k].i] += 1;
diff -= 1;

View file

@ -9,6 +9,7 @@ import {
TWEMOJI_BASE,
BUFFER_MULTIPLIER,
ENUM_PALETTE,
type GradientStop,
} from './consts';
const ROAD_OPACITY = 0.4;
@ -64,8 +65,6 @@ export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
} as StyleSpecification;
}
type GradientStop = { t: number; color: [number, number, number] };
// Oklab color space for perceptually uniform interpolation
function srgbToLinear(c: number): number {
const v = c / 255;
@ -131,8 +130,11 @@ function interpolateGradient(t: number, gradient: GradientStop[]): [number, numb
return gradient[gradient.length - 1].color;
}
function normalizedToColor(t: number): [number, number, number] {
return interpolateGradient(t, FEATURE_GRADIENT);
function normalizedToColor(
t: number,
gradient: GradientStop[] = FEATURE_GRADIENT
): [number, number, number] {
return interpolateGradient(t, gradient);
}
function countToColor(
@ -220,7 +222,8 @@ export function getFeatureFillColor(
isDark: boolean,
alpha: number,
enumCount: number = 0,
enumPalette?: [number, number, number][]
enumPalette?: [number, number, number][],
featureGradient: GradientStop[] = FEATURE_GRADIENT
): [number, number, number, number] {
if (colorRange) {
if (value == null)
@ -244,9 +247,9 @@ export function getFeatureFillColor(
const range = colorRange[1] - colorRange[0];
if (range === 0)
return [...FEATURE_GRADIENT[0].color, alpha] as [number, number, number, number];
return [...featureGradient[0].color, alpha] as [number, number, number, number];
const t = ((value as number) - colorRange[0]) / range;
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)));
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)), featureGradient);
return [...rgb, alpha] as [number, number, number, number];
}
return [...countToColor(Math.max(0, Math.min(1, countNormalized)), densityGradient), alpha] as [