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

View file

@ -1,94 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
python:
name: Python (lint + test)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v4
with:
enable-cache: true
- name: Install dependencies
run: uv sync
- name: Ruff check
run: uv run ruff check .
- name: Deptry (unused dependencies)
run: uv run deptry .
- name: Tests
run: |
uv run pytest pipeline/utils/test_haversine.py
uv run pytest pipeline/utils/test_poi_counts.py
uv run pytest pipeline/transform/postcode_boundaries/test_postcode_boundaries.py
frontend:
name: Frontend (lint + typecheck)
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: npm ci
- name: ESLint
run: npm run lint
- name: Prettier check
run: npm run format:check
- name: TypeScript typecheck
run: npm run typecheck
rust:
name: Rust (lint + test)
runs-on: ubuntu-latest
defaults:
run:
working-directory: server-rs
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: server-rs
- name: Clippy
run: cargo clippy -- -D warnings
- name: Format check
run: cargo fmt --check
- name: Install cargo-machete
run: cargo install cargo-machete
- name: Unused dependencies check
run: cargo machete
- name: Tests
run: cargo test

View file

@ -1,72 +0,0 @@
name: Build and publish Docker image
on:
push:
branches: [main]
tags: ["v*"]
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up uv
uses: astral-sh/setup-uv@v4
- name: Download map assets (fonts, sprites, twemoji)
run: uv run python -m pipeline.download.map_assets --output frontend/public/assets
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push screenshot service
uses: docker/build-push-action@v6
with:
context: ./screenshot
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-screenshot:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-screenshot:sha-${{ github.sha }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=screenshot
cache-to: type=gha,mode=max,scope=screenshot

View file

@ -22,6 +22,7 @@ POIS_RAW := $(DATA_DIR)/uk_pois.parquet
POIS_FILTERED := $(DATA_DIR)/filtered_uk_pois.parquet
POI_PROXIMITY := $(DATA_DIR)/poi_proximity.parquet
EPC_PP := $(DATA_DIR)/epc_pp.parquet
POSTCODES_RAW := $(DATA_DIR)/gb-postcodes-v5
POSTCODES_PQ := $(DATA_DIR)/postcode.parquet
PROPERTIES_PQ := $(DATA_DIR)/properties.parquet
MERGE_STAMP := $(DATA_DIR)/.merge_done
@ -80,7 +81,7 @@ download-naptan: $(NAPTAN)
download-pois: $(POIS_RAW)
download-ofsted: $(OFSTED)
download-broadband: $(BROADBAND)
download-postcodes: $(POSTCODES)
download-postcodes: $(POSTCODES_RAW)
download-rental-prices: $(RENTAL)
download-noise: $(NOISE)
download-inspire: $(INSPIRE_STAMP)
@ -154,7 +155,7 @@ $(OFSTED):
$(BROADBAND):
uv run python -m pipeline.download.broadband --output $@
$(POSTCODES):
$(POSTCODES_RAW):
uv run python -m pipeline.download.postcodes --output $@
$(NOISE): $(ARCGIS)

View file

@ -26,6 +26,7 @@ tasks:
cmds:
- uv run pytest pipeline/utils/test_haversine.py
- uv run pytest pipeline/utils/test_poi_counts.py
- uv run pytest pipeline/download/test_naptan.py
- uv run pytest pipeline/transform/postcode_boundaries/test_postcode_boundaries.py
test:python:fuzzy-join:

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 [

View file

@ -22,6 +22,92 @@ STOP_TYPES = {
}
OUTPUT_COLUMNS = ["id", "name", "category", "lat", "lng"]
def canonical_station_name_expr(name_col: str = "name") -> pl.Expr:
"""Normalize station names so entrances/transport-mode variants collapse."""
expr = pl.col(name_col).str.to_lowercase()
expr = expr.str.replace_all(r"\([^)]*\)", " ")
expr = expr.str.replace_all(r"['`]", "")
expr = expr.str.replace_all(r"&", " and ")
expr = expr.str.replace_all(r"[^a-z0-9]+", " ")
expr = expr.str.replace_all(r"\s+", " ").str.strip_chars()
expr = expr.str.replace_all(
r"\s+(underground|tube|dlr|metro|rail|railway)\s+station$", ""
)
expr = expr.str.replace_all(r"\s+tram\s+stop$", "")
expr = expr.str.replace_all(r"\s+(station|stop)$", "")
return expr.str.strip_chars()
def _has_locality() -> pl.Expr:
return pl.col("locality").is_not_null() & (pl.col("locality") != "")
def _deduplicate_tube_partition(
df: pl.DataFrame, group_cols: list[str]
) -> pl.DataFrame:
if len(df) == 0:
return pl.DataFrame(
{
"id": pl.Series([], dtype=pl.String),
"name": pl.Series([], dtype=pl.String),
"category": pl.Series([], dtype=pl.String),
"lat": pl.Series([], dtype=pl.Float64),
"lng": pl.Series([], dtype=pl.Float64),
}
)
name_len = pl.col("name").str.len_chars()
return (
df.group_by(group_cols)
.agg(
pl.col("id").sort_by(name_len).first(),
pl.col("name").sort_by(name_len).first(),
pl.col("category").first(),
pl.col("lat").mean(),
pl.col("lng").mean(),
)
.select(OUTPUT_COLUMNS)
)
def deduplicate_naptan(df: pl.DataFrame) -> pl.DataFrame:
"""Deduplicate NaPTAN stops, with stricter station-level merging for Tube POIs."""
has_loc = df.filter(_has_locality())
no_loc = df.filter(~_has_locality())
cols_with_locality = [*OUTPUT_COLUMNS, "locality"]
# First pass: one record per exact stop name/category/locality.
deduped_has_loc = (
has_loc.group_by("name", "category", "locality")
.agg(
pl.col("id").first(),
pl.col("lat").mean(),
pl.col("lng").mean(),
)
.select(cols_with_locality)
)
df = pl.concat([deduped_has_loc, no_loc.select(cols_with_locality)])
tube = df.filter(pl.col("category") == "Tube station").with_columns(
canonical_station_name_expr().alias("_station_key")
)
other = df.filter(pl.col("category") != "Tube station")
tube_with_loc = tube.filter(_has_locality())
tube_no_loc = tube.filter(~_has_locality())
deduped_tube = pl.concat(
[
_deduplicate_tube_partition(tube_with_loc, ["_station_key", "locality"]),
_deduplicate_tube_partition(tube_no_loc, ["_station_key"]),
]
)
return pl.concat([other.select(OUTPUT_COLUMNS), deduped_tube])
def download_naptan(output: Path) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
@ -50,24 +136,12 @@ def download_naptan(output: Path) -> None:
)
before = len(df)
df = deduplicate_naptan(df)
# Deduplicate: one record per name+category+locality
# (merges entrances, bus stop pairs on opposite sides of the road, etc.)
has_loc = df.filter(
pl.col("locality").is_not_null() & (pl.col("locality") != "")
print(
f"Deduplicated {before:,}{len(df):,} stops "
"(by name+category+locality; tube stations by normalized station name)"
)
no_loc = df.filter(
pl.col("locality").is_null() | (pl.col("locality") == "")
)
cols = ["id", "name", "category", "lat", "lng"]
deduped = has_loc.group_by("name", "category", "locality").agg(
pl.col("id").first(),
pl.col("lat").mean(),
pl.col("lng").mean(),
)
df = pl.concat([deduped.select(cols), no_loc.select(cols)])
print(f"Deduplicated {before:,}{len(df):,} stops (by name+category+locality)")
df.write_parquet(output)
size_mb = output.stat().st_size / (1024 * 1024)

View file

@ -0,0 +1,71 @@
import polars as pl
import pytest
from pipeline.download.naptan import canonical_station_name_expr, deduplicate_naptan
def test_canonical_station_name_expr_normalizes_transport_suffixes():
df = pl.DataFrame(
{
"name": [
"Bank",
"Bank Underground Station",
"Bank DLR Station",
"Pleasure Beach (Blackpool Tramway)",
"Earl's Court Tube Station",
]
}
)
result = df.select(canonical_station_name_expr().alias("key"))["key"].to_list()
assert result == [
"bank",
"bank",
"bank",
"pleasure beach",
"earls court",
]
def test_deduplicate_naptan_merges_tube_station_variants_by_locality():
df = pl.DataFrame(
{
"id": ["bank", "bank-lu", "bank-dlr", "other-bank"],
"name": [
"Bank",
"Bank Underground Station",
"Bank DLR Station",
"Bank Underground Station",
],
"category": ["Tube station"] * 4,
"lat": [51.5129, 51.5134, 51.5132, 55.0140],
"lng": [-0.0889, -0.0890, -0.0885, -1.6781],
"locality": ["LOC1", "LOC1", "LOC1", "LOC2"],
}
)
result = deduplicate_naptan(df).sort("lat")
assert len(result) == 2
assert result["name"].to_list() == ["Bank", "Bank Underground Station"]
assert result["lat"].to_list()[0] == pytest.approx(
(51.5129 + 51.5134 + 51.5132) / 3
)
def test_deduplicate_naptan_does_not_merge_missing_locality_bus_stops():
df = pl.DataFrame(
{
"id": ["a", "b"],
"name": ["High Street", "High Street"],
"category": ["Bus stop", "Bus stop"],
"lat": [51.5, 52.5],
"lng": [-0.1, -1.1],
"locality": [None, None],
}
)
result = deduplicate_naptan(df)
assert len(result) == 2

View file

@ -19,6 +19,8 @@ Output directory: property-data/transit/
"""
import argparse
import csv
import io
import json
import os
import shutil
@ -108,6 +110,30 @@ def download_bods_gtfs(output_dir: Path) -> Path:
return dest
def _parse_csv_line(line: bytes | str) -> list[str]:
"""Parse a single GTFS CSV record."""
if isinstance(line, bytes):
line = line.decode("utf-8", errors="replace")
line = line.rstrip("\r\n")
if not line:
return []
return next(csv.reader([line]))
def _format_csv_row(parts: list[str]) -> bytes:
"""Serialize one GTFS CSV row with stable LF line endings."""
output = io.StringIO()
csv.writer(output, lineterminator="\n").writerow(parts)
return output.getvalue().encode("utf-8")
def _format_csv_rows(rows: list[list[str]]) -> str:
output = io.StringIO()
writer = csv.writer(output, lineterminator="\n")
writer.writerows(rows)
return output.getvalue()
def clean_gtfs(src: Path, dst: Path) -> None:
"""Fix R5-incompatible entries in GTFS.
@ -128,8 +154,7 @@ def clean_gtfs(src: Path, dst: Path) -> None:
dropped = 0
with zin.open(info) as f:
header = f.readline()
header_str = header.decode("utf-8").strip()
cols = header_str.split(",")
cols = _parse_csv_line(header)
arr_idx = (
cols.index("arrival_time") if "arrival_time" in cols else -1
)
@ -143,10 +168,9 @@ def clean_gtfs(src: Path, dst: Path) -> None:
tmp.write(header)
for line in f:
line_str = line.decode("utf-8", errors="replace").strip()
if not line_str:
parts = _parse_csv_line(line)
if not parts:
continue
parts = line_str.split(",")
skip = False
for idx in [arr_idx, dep_idx]:
if 0 <= idx < len(parts):
@ -171,12 +195,13 @@ def clean_gtfs(src: Path, dst: Path) -> None:
elif info.filename == "feed_info.txt":
data = zin.read(info).decode("utf-8")
lines = data.strip().split("\n")
header_line = lines[0]
feed_cols = header_line.split(",")
fixed_lines = [header_line]
for line in lines[1:]:
parts = line.split(",")
rows = list(csv.reader(io.StringIO(data)))
if not rows:
zout.writestr("feed_info.txt", data)
continue
feed_cols = rows[0]
fixed_rows = [feed_cols]
for parts in rows[1:]:
for i, col_name in enumerate(feed_cols):
if "end_date" in col_name.lower() and i < len(parts):
date_val = parts[i].strip('"')
@ -187,8 +212,8 @@ def clean_gtfs(src: Path, dst: Path) -> None:
print(
f" feed_info: capped end_date {date_val} → 20991231"
)
fixed_lines.append(",".join(parts))
zout.writestr("feed_info.txt", "\n".join(fixed_lines) + "\n")
fixed_rows.append(parts)
zout.writestr("feed_info.txt", _format_csv_rows(fixed_rows))
else:
zout.writestr(info, zin.read(info))
@ -237,12 +262,11 @@ def convert_high_freq_to_frequency_based(
# Step 1: Find metro/tram route IDs
target_route_ids: set[str] = set()
with zin.open("routes.txt") as f:
header = f.readline().decode("utf-8").strip()
cols = header.split(",")
cols = _parse_csv_line(f.readline())
route_id_idx = cols.index("route_id")
rt_idx = cols.index("route_type")
for line in f:
parts = line.decode("utf-8", errors="replace").strip().split(",")
parts = _parse_csv_line(line)
if not parts:
continue
route_type = parts[rt_idx].strip('"')
@ -259,14 +283,13 @@ def convert_high_freq_to_frequency_based(
# Step 2: Map target trips to grouping keys
trip_group_key: dict[str, tuple[str, str, str]] = {}
with zin.open("trips.txt") as f:
header = f.readline().decode("utf-8").strip()
cols = header.split(",")
cols = _parse_csv_line(f.readline())
trip_id_idx = cols.index("trip_id")
route_id_idx = cols.index("route_id")
dir_idx = cols.index("direction_id") if "direction_id" in cols else -1
service_idx = cols.index("service_id")
for line in f:
parts = line.decode("utf-8", errors="replace").strip().split(",")
parts = _parse_csv_line(line)
if not parts:
continue
route_id = parts[route_id_idx].strip('"')
@ -282,14 +305,13 @@ def convert_high_freq_to_frequency_based(
trip_first_dep: dict[str, int] = {}
trip_first_stop: dict[str, str] = {}
with zin.open("stop_times.txt") as f:
header = f.readline().decode("utf-8").strip()
cols = header.split(",")
cols = _parse_csv_line(f.readline())
trip_id_idx = cols.index("trip_id")
dep_idx = cols.index("departure_time")
seq_idx = cols.index("stop_sequence")
stop_id_idx = cols.index("stop_id")
for line in f:
parts = line.decode("utf-8", errors="replace").strip().split(",")
parts = _parse_csv_line(line)
if not parts:
continue
trip_id = parts[trip_id_idx].strip('"')
@ -361,8 +383,7 @@ def convert_high_freq_to_frequency_based(
if info.filename == "trips.txt":
with zin.open(info) as f:
header = f.readline()
header_str = header.decode("utf-8").strip()
cols = header_str.split(",")
cols = _parse_csv_line(header)
trip_id_idx = cols.index("trip_id")
tmp = tempfile.NamedTemporaryFile(
@ -370,9 +391,7 @@ def convert_high_freq_to_frequency_based(
)
tmp.write(header)
for line in f:
parts = (
line.decode("utf-8", errors="replace").strip().split(",")
)
parts = _parse_csv_line(line)
if not parts:
continue
if parts[trip_id_idx].strip('"') not in trips_to_remove:
@ -384,8 +403,7 @@ def convert_high_freq_to_frequency_based(
elif info.filename == "stop_times.txt":
with zin.open(info) as f:
header = f.readline()
header_str = header.decode("utf-8").strip()
cols = header_str.split(",")
cols = _parse_csv_line(header)
trip_id_idx = cols.index("trip_id")
tmp = tempfile.NamedTemporaryFile(
@ -393,9 +411,7 @@ def convert_high_freq_to_frequency_based(
)
tmp.write(header)
for line in f:
parts = (
line.decode("utf-8", errors="replace").strip().split(",")
)
parts = _parse_csv_line(line)
if not parts:
continue
if parts[trip_id_idx].strip('"') not in trips_to_remove:
@ -535,25 +551,23 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
with zipfile.ZipFile(src, "r") as zin:
# Load valid stop IDs
with zin.open("stops.txt") as f:
header = f.readline().decode("utf-8").strip()
stop_id_idx = header.split(",").index("stop_id")
lat_idx = header.split(",").index("stop_lat")
cols = _parse_csv_line(f.readline())
stop_id_idx = cols.index("stop_id")
for line in f:
parts = line.decode("utf-8", errors="replace").strip().split(",")
parts = _parse_csv_line(line)
if parts:
stop_ids.add(parts[stop_id_idx])
# Find trips with backwards travel times
with zin.open("stop_times.txt") as f:
st_header = f.readline().decode("utf-8").strip()
st_cols = st_header.split(",")
st_cols = _parse_csv_line(f.readline())
trip_id_idx = st_cols.index("trip_id")
dep_idx = st_cols.index("departure_time")
prev_trip = ""
prev_dep_secs = -1
for line in f:
parts = line.decode("utf-8", errors="replace").strip().split(",")
parts = _parse_csv_line(line)
if not parts:
continue
trip_id = parts[trip_id_idx].strip('"')
@ -594,8 +608,7 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
if info.filename == "stop_times.txt":
with zin.open(info) as f:
header = f.readline()
header_str = header.decode("utf-8").strip()
cols = header_str.split(",")
cols = _parse_csv_line(header)
trip_id_idx = cols.index("trip_id")
stop_id_idx = cols.index("stop_id")
seq_idx = cols.index("stop_sequence")
@ -614,10 +627,9 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
prev_trip = ""
seq_counter = 0
for line in f:
line_str = line.decode("utf-8", errors="replace").strip()
if not line_str:
parts = _parse_csv_line(line)
if not parts:
continue
parts = line_str.split(",")
trip_id = parts[trip_id_idx].strip('"')
stop_id = parts[stop_id_idx].strip('"')
@ -651,7 +663,7 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
if old_seq != str(seq_counter):
seqs_renumbered += 1
tmp.write((",".join(parts) + "\n").encode("utf-8"))
tmp.write(_format_csv_row(parts))
tmp.close()
zout.write(tmp.name, "stop_times.txt")
@ -660,8 +672,7 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
elif info.filename == "stops.txt":
with zin.open(info) as f:
header = f.readline()
header_str = header.decode("utf-8").strip()
cols = header_str.split(",")
cols = _parse_csv_line(header)
lat_idx = cols.index("stop_lat")
lon_idx = cols.index("stop_lon")
@ -671,10 +682,9 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
tmp.write(header)
for line in f:
line_str = line.decode("utf-8", errors="replace").strip()
if not line_str:
parts = _parse_csv_line(line)
if not parts:
continue
parts = line_str.split(",")
try:
lat = float(parts[lat_idx])
# Fix bogus Irish CIE coordinates (South Atlantic)
@ -685,7 +695,7 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
coords_fixed += 1
except ValueError:
pass
tmp.write((",".join(parts) + "\n").encode("utf-8"))
tmp.write(_format_csv_row(parts))
tmp.close()
zout.write(tmp.name, "stops.txt")
@ -694,8 +704,7 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
elif info.filename == "routes.txt":
with zin.open(info) as f:
header = f.readline()
header_str = header.decode("utf-8").strip()
cols = header_str.split(",")
cols = _parse_csv_line(header)
rt_idx = cols.index("route_type")
tmp = tempfile.NamedTemporaryFile(
@ -704,14 +713,13 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
tmp.write(header)
for line in f:
line_str = line.decode("utf-8", errors="replace").strip()
if not line_str:
parts = _parse_csv_line(line)
if not parts:
continue
parts = line_str.split(",")
if parts[rt_idx].strip('"') == "714":
parts[rt_idx] = "3"
route_types_fixed += 1
tmp.write((",".join(parts) + "\n").encode("utf-8"))
tmp.write(_format_csv_row(parts))
tmp.close()
zout.write(tmp.name, "routes.txt")
@ -721,8 +729,7 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
# Remove trips that have backwards travel times
with zin.open(info) as f:
header = f.readline()
header_str = header.decode("utf-8").strip()
cols = header_str.split(",")
cols = _parse_csv_line(header)
trip_id_idx = cols.index("trip_id")
tmp = tempfile.NamedTemporaryFile(
@ -731,10 +738,9 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
tmp.write(header)
for line in f:
line_str = line.decode("utf-8", errors="replace").strip()
if not line_str:
parts = _parse_csv_line(line)
if not parts:
continue
parts = line_str.split(",")
if parts[trip_id_idx].strip('"') not in bad_trip_ids:
tmp.write(line)
@ -746,8 +752,7 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
# Cap end_date year to 2099
with zin.open(info) as f:
header = f.readline()
header_str = header.decode("utf-8").strip()
cols = header_str.split(",")
cols = _parse_csv_line(header)
end_idx = cols.index("end_date")
tmp = tempfile.NamedTemporaryFile(
@ -756,10 +761,9 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
tmp.write(header)
for line in f:
line_str = line.decode("utf-8", errors="replace").strip()
if not line_str:
parts = _parse_csv_line(line)
if not parts:
continue
parts = line_str.split(",")
date_val = parts[end_idx].strip('"')
if len(date_val) == 8:
try:
@ -768,7 +772,7 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
parts[end_idx] = "20991231"
except ValueError:
pass
tmp.write((",".join(parts) + "\n").encode("utf-8"))
tmp.write(_format_csv_row(parts))
tmp.close()
zout.write(tmp.name, "calendar.txt")

View file

@ -60,6 +60,10 @@ _AREA_COLUMNS = [
"Good+ secondary schools within 5km",
"Good+ primary schools within 2km",
"Good+ secondary schools within 2km",
"Outstanding primary schools within 5km",
"Outstanding secondary schools within 5km",
"Outstanding primary schools within 2km",
"Outstanding secondary schools within 2km",
# Demographics
"Median age",
# Politics
@ -351,6 +355,10 @@ def _build(
"good_secondary_5km": "Good+ secondary schools within 5km",
"good_primary_2km": "Good+ primary schools within 2km",
"good_secondary_2km": "Good+ secondary schools within 2km",
"outstanding_primary_5km": "Outstanding primary schools within 5km",
"outstanding_secondary_5km": "Outstanding secondary schools within 5km",
"outstanding_primary_2km": "Outstanding primary schools within 2km",
"outstanding_secondary_2km": "Outstanding secondary schools within 2km",
"max_download_speed": "Max available download speed (Mbps)",
"serious_crime_avg_yr": "Serious crime (avg/yr)",
"minor_crime_avg_yr": "Minor crime (avg/yr)",

View file

@ -1,4 +1,4 @@
"""Compute good-rated school proximity counts per postcode."""
"""Compute Ofsted-rated school proximity counts per postcode."""
import argparse
from pathlib import Path
@ -8,14 +8,16 @@ import polars as pl
from pipeline.utils.poi_counts import count_pois_per_postcode
SCHOOL_GROUPS = {
"good_primary": ["good_primary"],
"good_secondary": ["good_secondary"],
"good_primary": ["good_primary", "outstanding_primary"],
"good_secondary": ["good_secondary", "outstanding_secondary"],
"outstanding_primary": ["outstanding_primary"],
"outstanding_secondary": ["outstanding_secondary"],
}
def main():
parser = argparse.ArgumentParser(
description="Count good+ primary/secondary schools within 2km per postcode"
description="Count good+ and outstanding primary/secondary schools near each postcode"
)
parser.add_argument(
"--ofsted", type=Path, required=True, help="Ofsted inspection parquet"
@ -39,12 +41,25 @@ def main():
)
print(f"Good+ schools: {len(ofsted):,}")
print(
"Outstanding schools: "
f"{ofsted.filter(pl.col('Latest OEIF overall effectiveness') == '1').height:,}"
)
# Assign category based on phase
# Assign category based on phase and rating. Good+ groups include both
# category variants; outstanding groups count grade 1 only.
ofsted = ofsted.with_columns(
pl.when(pl.col("Ofsted phase") == "Primary")
.then(pl.lit("good_primary"))
.otherwise(pl.lit("good_secondary"))
.then(
pl.when(pl.col("Latest OEIF overall effectiveness") == "1")
.then(pl.lit("outstanding_primary"))
.otherwise(pl.lit("good_primary"))
)
.otherwise(
pl.when(pl.col("Latest OEIF overall effectiveness") == "1")
.then(pl.lit("outstanding_secondary"))
.otherwise(pl.lit("good_secondary"))
)
.alias("category")
).select(
pl.col("Postcode").alias("postcode"),

View file

@ -4,6 +4,7 @@
"private": true,
"scripts": {
"build": "tsc",
"test": "npm run build && node --test dist/**/*.test.js",
"start": "node dist/server.js",
"dev": "tsc --watch & node --watch dist/server.js"
},

View file

@ -1,10 +1,14 @@
import express from 'express';
import express, { type Request } from 'express';
import { ScreenshotCache } from './cache.js';
import { takeScreenshot, checkWebGL, closeBrowser, initialize } from './screenshot.js';
import { buildScreenshotRequest, ValidationError } from './validation.js';
const PORT = parseInt(process.env.PORT || '8002', 10);
const APP_URL = process.env.APP_URL;
const CACHE_DIR = process.env.CACHE_DIR;
const SCREENSHOT_CONCURRENCY = parsePositiveIntEnv('SCREENSHOT_CONCURRENCY', 3);
const RATE_LIMIT_WINDOW_MS = parsePositiveIntEnv('SCREENSHOT_RATE_WINDOW_MS', 60_000);
const RATE_LIMIT_MAX = parsePositiveIntEnv('SCREENSHOT_RATE_LIMIT', 30);
if (!APP_URL) {
console.error('Error: APP_URL environment variable is required');
@ -18,6 +22,58 @@ if (!CACHE_DIR) {
const cache = new ScreenshotCache(CACHE_DIR);
const app = express();
app.set('trust proxy', true);
let activeScreenshots = 0;
let lastRateLimitPrune = 0;
const rateLimitBuckets = new Map<string, { count: number; resetAt: number }>();
function parsePositiveIntEnv(name: string, fallback: number): number {
const value = Number.parseInt(process.env[name] || '', 10);
return Number.isFinite(value) && value > 0 ? value : fallback;
}
function acquireScreenshotSlot(): (() => void) | null {
if (activeScreenshots >= SCREENSHOT_CONCURRENCY) {
return null;
}
activeScreenshots += 1;
let released = false;
return () => {
if (released) return;
released = true;
activeScreenshots = Math.max(0, activeScreenshots - 1);
};
}
function rateLimitKey(req: Request): string {
const forwardedFor = req.get('x-forwarded-for')?.split(',')[0]?.trim();
return forwardedFor || req.ip || req.socket.remoteAddress || 'unknown';
}
function allowScreenshotRequest(req: Request): boolean {
const now = Date.now();
if (now - lastRateLimitPrune > RATE_LIMIT_WINDOW_MS) {
lastRateLimitPrune = now;
for (const [key, bucket] of rateLimitBuckets) {
if (bucket.resetAt <= now) {
rateLimitBuckets.delete(key);
}
}
}
const key = rateLimitKey(req);
let bucket = rateLimitBuckets.get(key);
if (!bucket || bucket.resetAt <= now) {
bucket = { count: 0, resetAt: now + RATE_LIMIT_WINDOW_MS };
rateLimitBuckets.set(key, bucket);
}
if (bucket.count >= RATE_LIMIT_MAX) {
return false;
}
bucket.count += 1;
return true;
}
app.get('/health', (_req, res) => {
res.status(200).send('ok');
@ -33,28 +89,9 @@ app.get('/debug', async (_req, res) => {
});
app.get('/screenshot', async (req, res) => {
let releaseSlot: (() => void) | null = null;
try {
const qs = new URLSearchParams();
for (const key of ['lat', 'lon', 'zoom', 'tab', 'og']) {
const val = req.query[key];
if (typeof val === 'string' && val) {
qs.set(key, val);
}
}
// Repeated params: filter, poi, tt (travel time)
for (const key of ['filter', 'poi', 'tt']) {
const val = req.query[key];
if (typeof val === 'string' && val) {
qs.append(key, val);
} else if (Array.isArray(val)) {
for (const v of val) {
if (typeof v === 'string' && v) qs.append(key, v);
}
}
}
// Extract path param (used for non-root pages like /invite/CODE)
const pagePath = typeof req.query.path === 'string' && req.query.path ? req.query.path : '/';
const { pagePath, qs } = buildScreenshotRequest(req.query as Record<string, unknown>);
if (pagePath !== '/') qs.set('path', pagePath);
// Include auth status in cache key so authenticated screenshots
@ -75,6 +112,17 @@ app.get('/screenshot', async (req, res) => {
return;
}
if (!allowScreenshotRequest(req)) {
res.status(429).json({ error: 'Screenshot rate limit exceeded' });
return;
}
releaseSlot = acquireScreenshotSlot();
if (!releaseSlot) {
res.status(503).json({ error: 'Screenshot service busy' });
return;
}
// Build the URL for the frontend in screenshot mode
qs.set('screenshot', '1');
const url = `${APP_URL}${pagePath}?${qs}`;
@ -90,8 +138,14 @@ app.get('/screenshot', async (req, res) => {
res.setHeader('X-Cache', 'MISS');
res.send(jpeg);
} catch (err) {
if (err instanceof ValidationError) {
res.status(err.status).json({ error: err.message });
return;
}
console.error('Screenshot failed:', err);
res.status(500).json({ error: 'Screenshot failed' });
} finally {
releaseSlot?.();
}
});
@ -99,6 +153,8 @@ const server = app.listen(PORT, () => {
console.log(`Screenshot service listening on port ${PORT}`);
console.log(` APP_URL: ${APP_URL}`);
console.log(` CACHE_DIR: ${CACHE_DIR}`);
console.log(` SCREENSHOT_CONCURRENCY: ${SCREENSHOT_CONCURRENCY}`);
console.log(` SCREENSHOT_RATE_LIMIT: ${RATE_LIMIT_MAX}/${RATE_LIMIT_WINDOW_MS}ms`);
// Pre-warm browser and populate network cache in background.
// The health endpoint is available immediately; screenshot requests

View file

@ -1953,3 +1953,6 @@
2026-04-04T21:54:19.870055Z INFO property_map_server::data::property: Loading properties from "/app/data/properties.parquet"
2026-04-04T21:54:22.182709Z INFO property_map_server::data::property: Properties joined with postcodes rows=15203393
2026-04-04T22:00:48.160218Z INFO property_map_server: Prometheus metrics initialized
2026-04-04T22:12:50.835405Z INFO property_map_server: Prometheus metrics initialized
2026-04-04T22:13:40.021088Z INFO property_map_server: Prometheus metrics initialized
2026-04-04T22:14:09.136995Z INFO property_map_server: Prometheus metrics initialized

File diff suppressed because it is too large Load diff

View file

@ -514,10 +514,7 @@ pub fn precompute_h3(lat: &[f32], lon: &[f32]) -> anyhow::Result<Vec<u64>> {
}
impl PropertyData {
pub fn load(
properties_path: &Path,
postcode_features_path: &Path,
) -> anyhow::Result<Self> {
pub fn load(properties_path: &Path, postcode_features_path: &Path) -> anyhow::Result<Self> {
// Load postcode.parquet
tracing::info!(
"Loading postcode features from {:?}",
@ -643,11 +640,22 @@ impl PropertyData {
}
let df = combined
.lazy()
.filter(col("lat").is_not_null().and(col("lon").is_not_null()))
.select(select_exprs)
.collect()
.context("Failed to select columns from combined data")?;
let row_count = df.height();
if row_count == 0 {
bail!("No property rows have usable coordinates after joining postcode data");
}
let dropped_coordinate_rows = total_rows.saturating_sub(row_count);
if dropped_coordinate_rows > 0 {
tracing::warn!(
rows = dropped_coordinate_rows,
"Dropped properties with missing postcode coordinates"
);
}
tracing::info!(rows = row_count, "Combined data selected");
let lat_series = df
@ -659,8 +667,8 @@ impl PropertyData {
.f32()
.context("Failed to read 'lat' as f32")?
.into_iter()
.map(|value| value.unwrap_or(0.0))
.collect();
.map(|value| value.context("Missing 'lat' value after coordinate filter"))
.collect::<anyhow::Result<Vec<_>>>()?;
let lon_series = df
.column("lon")
@ -671,8 +679,14 @@ impl PropertyData {
.f32()
.context("Failed to read 'lon' as f32")?
.into_iter()
.map(|value| value.unwrap_or(0.0))
.collect();
.map(|value| value.context("Missing 'lon' value after coordinate filter"))
.collect::<anyhow::Result<Vec<_>>>()?;
for (row, (&latitude, &longitude)) in lat.iter().zip(&lon).enumerate() {
if !(-90.0..=90.0).contains(&latitude) || !(-180.0..=180.0).contains(&longitude) {
bail!("Invalid coordinates at row {row}: lat={latitude}, lon={longitude}");
}
}
tracing::info!("Extracting numeric feature columns");
let numeric_col_major: Vec<Vec<f32>> = numeric_names

View file

@ -32,8 +32,7 @@ pub struct FeatureConfig {
/// Features whose histogram bins should be exactly 1 unit wide (one per integer).
/// p1/p99 are snapped to integer boundaries before binning.
pub const INTEGER_BIN_FEATURES: &[&str] =
&["Number of bedrooms & living rooms"];
pub const INTEGER_BIN_FEATURES: &[&str] = &["Number of bedrooms & living rooms"];
pub struct EnumFeatureConfig {
pub name: &'static str,
@ -302,6 +301,36 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "Outstanding primary schools within 2km",
bounds: Bounds::Fixed {
min: 0.0,
max: 10.0,
},
step: 1.0,
description: "Primary schools rated Outstanding by Ofsted within 2km",
detail: "State-funded primary schools within 2km with a current Ofsted rating of Outstanding. Schools not yet inspected are excluded.",
source: "ofsted",
prefix: "",
suffix: "",
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "Outstanding secondary schools within 2km",
bounds: Bounds::Fixed {
min: 0.0,
max: 5.0,
},
step: 1.0,
description: "Secondary schools rated Outstanding by Ofsted within 2km",
detail: "State-funded secondary schools within 2km with a current Ofsted rating of Outstanding. Schools not yet inspected are excluded.",
source: "ofsted",
prefix: "",
suffix: "",
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "Good+ primary schools within 5km",
bounds: Bounds::Fixed {
@ -332,6 +361,36 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "Outstanding primary schools within 5km",
bounds: Bounds::Fixed {
min: 0.0,
max: 30.0,
},
step: 1.0,
description: "Primary schools rated Outstanding by Ofsted within 5km",
detail: "State-funded primary schools within 5km with a current Ofsted rating of Outstanding. Schools not yet inspected are excluded.",
source: "ofsted",
prefix: "",
suffix: "",
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "Outstanding secondary schools within 5km",
bounds: Bounds::Fixed {
min: 0.0,
max: 15.0,
},
step: 1.0,
description: "Secondary schools rated Outstanding by Ofsted within 5km",
detail: "State-funded secondary schools within 5km with a current Ofsted rating of Outstanding. Schools not yet inspected are excluded.",
source: "ofsted",
prefix: "",
suffix: "",
raw: false,
absolute: false,
}),
Feature::Numeric(FeatureConfig {
name: "Education, Skills and Training Score",
bounds: Bounds::Percentile {

View file

@ -165,10 +165,7 @@ async fn main() -> anyhow::Result<()> {
cli.properties.display(),
cli.postcode_features.display(),
);
let property_data = data::PropertyData::load(
&cli.properties,
&cli.postcode_features,
)?;
let property_data = data::PropertyData::load(&cli.properties, &cli.postcode_features)?;
info!(
rows = property_data.lat.len(),
features = property_data.num_features,
@ -450,7 +447,10 @@ async fn main() -> anyhow::Result<()> {
"/api/postcode-properties",
get(routes::get_postcode_properties),
)
.route("/api/screenshot", get(routes::get_screenshot))
.route(
"/api/screenshot",
get(routes::get_screenshot).layer(ConcurrencyLimitLayer::new(3)),
)
.route(
"/api/export",
get(routes::get_export).layer(ConcurrencyLimitLayer::new(3)),

View file

@ -281,6 +281,7 @@ pub fn build_system_prompt(
- \"cheap\" / \"affordable\" = lower price range. \"expensive\" = higher price range.\n\
- \"low crime\" / \"safe\" = low values on Serious crime and Minor crime summary features. \
\"quiet\" = low Noise (dB). \"green\" / \"near parks\" = high Number of parks within 1km.\n\
- \"good schools\" = Good+ school features. \"outstanding schools\" = Outstanding school features.\n\
- When the user says a number like \"under 400k\", interpret it as 400000.\n\
- When the user says \"3 bed\" or \"3 bedroom\", use Number of bedrooms & living rooms \
(note: this counts bedrooms + living rooms combined, so 3 bed ~ min 4).\n\
@ -424,6 +425,16 @@ pub fn build_system_prompt(
.to_string(),
);
parts.push(
"\nUser: \"quiet area with outstanding schools\"\n\
Output: {\"numeric_filters\": [\
{\"name\": \"Noise (dB)\", \"bound\": \"max\", \"value\": 55}, \
{\"name\": \"Outstanding primary schools within 2km\", \"bound\": \"min\", \"value\": 1}, \
{\"name\": \"Outstanding secondary schools within 2km\", \"bound\": \"min\", \"value\": 1}], \
\"enum_filters\": [], \"travel_time_filters\": [], \"notes\": \"\"}"
.to_string(),
);
parts.push(
"\nUser: \"3 bed flat under 300k with fast broadband near the beach\"\n\
Output: {\"numeric_filters\": [\

View file

@ -14,6 +14,7 @@ use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::consts::NAN_U16;
use crate::data::QuantRef;
use crate::features::INTEGER_BIN_FEATURES;
use crate::licensing::check_license_bounds;
use crate::parsing::{parse_field_indices, parse_filters, require_bounds, row_passes_filters};
use crate::routes::{fetch_screenshot_bytes, FeatureInfo};
@ -315,6 +316,11 @@ pub async fn get_export(
})
.collect();
let integer_feature_indices: FxHashSet<usize> = INTEGER_BIN_FEATURES
.iter()
.filter_map(|name| state.feature_name_to_index.get(*name).copied())
.collect();
// Build Excel number formats per feature index for unit display
let mut feat_num_fmts: FxHashMap<usize, Format> = FxHashMap::default();
for &feat_idx in &all_feature_indices {
@ -324,6 +330,8 @@ pub async fn get_export(
}
let num_fmt_str = if !prefix.is_empty() {
format!("\"{}\"#,##0", prefix)
} else if integer_feature_indices.contains(&feat_idx) {
format!("#,##0\"{}\"", suffix)
} else {
format!("#,##0.0\"{}\"", suffix)
};
@ -488,7 +496,11 @@ pub async fn get_export(
} else {
let fc = agg.finite_counts[feat_idx];
if fc > 0 {
let mean = (agg.sums[feat_idx] / fc as f64 * 100.0).round() / 100.0;
let mean = if integer_feature_indices.contains(&feat_idx) {
(agg.sums[feat_idx] / fc as f64).round()
} else {
(agg.sums[feat_idx] / fc as f64 * 100.0).round() / 100.0
};
if let Some(fmt) = feat_num_fmts.get(&feat_idx) {
sheet
.write_number_with_format(row, col, mean, fmt)

View file

@ -1,4 +1,5 @@
use std::sync::Arc;
use std::collections::HashSet;
use std::sync::{Arc, LazyLock, Mutex};
use axum::extract::{Path, State};
use axum::http::StatusCode;
@ -7,9 +8,39 @@ use axum::{Extension, Json};
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::auth::{OptionalUser, PocketBaseUser};
use crate::pocketbase::get_superuser_token;
use crate::state::SharedState;
use crate::state::{AppState, SharedState};
static INVITE_REDEMPTIONS_IN_PROGRESS: LazyLock<Mutex<HashSet<String>>> =
LazyLock::new(|| Mutex::new(HashSet::new()));
struct InviteRedemptionGuard {
code: String,
}
impl InviteRedemptionGuard {
fn acquire(code: &str) -> Option<Self> {
let mut in_progress = INVITE_REDEMPTIONS_IN_PROGRESS
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if !in_progress.insert(code.to_string()) {
return None;
}
Some(Self {
code: code.to_string(),
})
}
}
impl Drop for InviteRedemptionGuard {
fn drop(&mut self) {
let mut in_progress = INVITE_REDEMPTIONS_IN_PROGRESS
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
in_progress.remove(&self.code);
}
}
#[derive(Serialize)]
struct InviteResponse {
@ -87,6 +118,207 @@ fn generate_invite_code() -> String {
chars.into_iter().collect()
}
fn current_unix_secs_string() -> String {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
.to_string()
}
async fn lookup_unused_invite(
state: &AppState,
pb_url: &str,
token: &str,
code: &str,
) -> Result<Option<serde_json::Value>, Response> {
let filter = format!("code=\"{}\" && used_by_id=\"\"", code);
let lookup_url = format!(
"{pb_url}/api/collections/invites/records?filter={}&perPage=1",
urlencoding::encode(&filter)
);
let res = match state
.http_client
.get(&lookup_url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await
{
Ok(resp) => resp,
Err(err) => {
warn!("Failed to look up invite: {err}");
return Err(StatusCode::BAD_GATEWAY.into_response());
}
};
if !res.status().is_success() {
let status = res.status();
let text = res.text().await.unwrap_or_default();
warn!("PocketBase invite lookup failed ({status}): {text}");
return Err(StatusCode::BAD_GATEWAY.into_response());
}
let body: serde_json::Value = match res.json().await {
Ok(value) => value,
Err(err) => {
warn!("Failed to parse invite lookup response: {err}");
return Err(StatusCode::BAD_GATEWAY.into_response());
}
};
Ok(body["items"]
.as_array()
.and_then(|arr| arr.first())
.cloned())
}
async fn mark_invite_used(
state: &AppState,
pb_url: &str,
token: &str,
invite_id: &str,
user_id: &str,
) -> Result<(), Response> {
let resp = match state
.http_client
.patch(format!(
"{pb_url}/api/collections/invites/records/{invite_id}"
))
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({
"used_by_id": user_id,
"used_at": current_unix_secs_string(),
}))
.send()
.await
{
Ok(resp) => resp,
Err(err) => {
warn!("Failed to mark invite as used: {err}");
return Err(StatusCode::BAD_GATEWAY.into_response());
}
};
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
warn!("PocketBase invite usage update failed ({status}): {text}");
return Err(StatusCode::BAD_GATEWAY.into_response());
}
Ok(())
}
async fn grant_license_for_invite(
state: &AppState,
pb_url: &str,
token: &str,
user_id: &str,
) -> Result<(), Response> {
let update_url = format!("{pb_url}/api/collections/users/records/{user_id}");
let resp = match state
.http_client
.patch(&update_url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({ "subscription": "licensed" }))
.send()
.await
{
Ok(resp) => resp,
Err(err) => {
warn!("Failed to update user subscription for admin invite: {err}");
return Err(StatusCode::BAD_GATEWAY.into_response());
}
};
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
warn!("PocketBase user subscription update failed ({status}): {text}");
return Err(StatusCode::BAD_GATEWAY.into_response());
}
state.token_cache.invalidate_by_user_id(user_id);
Ok(())
}
async fn create_referral_checkout(
state: &AppState,
user: &PocketBaseUser,
) -> Result<String, Response> {
let count = match super::pricing::count_licensed_users(state).await {
Ok(count) => count,
Err(err) => {
warn!("Failed to count licensed users for invite checkout: {err}");
return Err(StatusCode::SERVICE_UNAVAILABLE.into_response());
}
};
let price_pence = super::pricing::price_for_count(count);
let public_url = &state.public_url;
let success_url = format!("{public_url}/pricing?license_success=1");
let cancel_url = format!("{public_url}/pricing");
let form_params = vec![
("mode", "payment".to_string()),
(
"line_items[0][price_data][unit_amount]",
price_pence.to_string(),
),
("line_items[0][price_data][currency]", "gbp".to_string()),
(
"line_items[0][price_data][product_data][name]",
"Perfect Postcodes Lifetime License".to_string(),
),
("line_items[0][quantity]", "1".to_string()),
("success_url", success_url),
("cancel_url", cancel_url),
("client_reference_id", user.id.clone()),
("customer_email", user.email.clone()),
(
"discounts[0][coupon]",
state.stripe_referral_coupon_id.clone(),
),
];
let stripe_res = state
.http_client
.post("https://api.stripe.com/v1/checkout/sessions")
.basic_auth(&state.stripe_secret_key, None::<&str>)
.form(&form_params)
.send()
.await;
match stripe_res {
Ok(resp) if resp.status().is_success() => {
let stripe_body: serde_json::Value = match resp.json().await {
Ok(value) => value,
Err(err) => {
warn!("Failed to parse Stripe checkout response: {err}");
return Err(StatusCode::BAD_GATEWAY.into_response());
}
};
let checkout_url = stripe_body["url"].as_str().unwrap_or_default().to_string();
if checkout_url.is_empty() {
warn!("Stripe checkout response did not include a URL");
return Err(StatusCode::BAD_GATEWAY.into_response());
}
Ok(checkout_url)
}
Ok(resp) => {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
warn!("Failed to create Stripe checkout for referral invite ({status}): {text}");
Err(StatusCode::BAD_GATEWAY.into_response())
}
Err(err) => {
warn!("Stripe request error for referral invite: {err}");
Err(StatusCode::BAD_GATEWAY.into_response())
}
}
}
/// Create an invite. Admins create "admin" invites (free license) by default,
/// but can explicitly request "referral" type. Licensed non-admin users always create "referral" invites (30% off).
pub async fn post_invites(
@ -319,154 +551,80 @@ pub async fn post_redeem_invite(
}
};
// Look up invite
let filter = format!("code=\"{}\" && used_by_id=\"\"", req.code);
let lookup_url = format!(
"{pb_url}/api/collections/invites/records?filter={}&perPage=1",
urlencoding::encode(&filter)
);
let _redemption_guard = match InviteRedemptionGuard::acquire(&req.code) {
Some(guard) => guard,
None => {
return (
StatusCode::CONFLICT,
"Invite redemption is already in progress",
)
.into_response()
}
};
let res = match state
.http_client
.get(&lookup_url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await
{
Ok(r) => r,
Err(err) => {
warn!("Failed to look up invite: {err}");
let invite = match lookup_unused_invite(&state, pb_url, &token, &req.code).await {
Ok(Some(invite)) => invite,
Ok(None) => {
return (StatusCode::NOT_FOUND, "Invalid or already used invite code").into_response()
}
Err(response) => return response,
};
let invite_id = match invite["id"].as_str().filter(|id| !id.is_empty()) {
Some(id) => id,
None => {
warn!(code = %req.code, "Invite lookup returned record without id");
return StatusCode::BAD_GATEWAY.into_response();
}
};
let invite_type = match invite["invite_type"].as_str() {
Some("admin") => "admin",
Some("referral") => "referral",
Some(other) => {
warn!(code = %req.code, invite_type = other, "Invite has unsupported type");
return StatusCode::BAD_GATEWAY.into_response();
}
None => {
warn!(code = %req.code, "Invite lookup returned record without invite_type");
return StatusCode::BAD_GATEWAY.into_response();
}
};
let body: serde_json::Value = match res.json().await {
Ok(v) => v,
Err(_) => return StatusCode::BAD_GATEWAY.into_response(),
};
let invite = match body["items"].as_array().and_then(|arr| arr.first()) {
Some(inv) => inv.clone(),
None => {
return (StatusCode::NOT_FOUND, "Invalid or already used invite code").into_response()
}
};
let invite_id = invite["id"].as_str().unwrap_or("");
let invite_type = invite["invite_type"].as_str().unwrap_or("");
// Mark invite as used
let now = {
let dur = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
dur.as_secs().to_string()
};
let _ = state
.http_client
.patch(format!(
"{pb_url}/api/collections/invites/records/{invite_id}"
))
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({
"used_by_id": user.id,
"used_at": now,
}))
.send()
.await;
if invite_type == "admin" {
// Grant license directly
let update_url = format!("{pb_url}/api/collections/users/records/{}", user.id);
let res = state
.http_client
.patch(&update_url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({ "subscription": "licensed" }))
.send()
.await;
match res {
Ok(resp) if resp.status().is_success() => {
state.token_cache.invalidate_by_user_id(&user.id);
info!(user_id = %user.id, code = %req.code, "Admin invite redeemed — user licensed");
Json(RedeemResponse {
result: "licensed".to_string(),
checkout_url: None,
})
.into_response()
}
_ => {
warn!("Failed to update user subscription for admin invite");
StatusCode::BAD_GATEWAY.into_response()
}
if let Err(response) = grant_license_for_invite(&state, pb_url, &token, &user.id).await {
return response;
}
} else {
// Referral invite — create discounted checkout with dynamic pricing
let count = match super::pricing::count_licensed_users(&state).await {
Ok(c) => c,
Err(err) => {
warn!("Failed to count licensed users for invite checkout: {err}");
return StatusCode::SERVICE_UNAVAILABLE.into_response();
}
};
let price_pence = super::pricing::price_for_count(count);
let secret_key = &state.stripe_secret_key;
let public_url = &state.public_url;
let success_url = format!("{public_url}/pricing?license_success=1");
let cancel_url = format!("{public_url}/pricing");
let form_params = vec![
("mode", "payment".to_string()),
(
"line_items[0][price_data][unit_amount]",
price_pence.to_string(),
),
("line_items[0][price_data][currency]", "gbp".to_string()),
(
"line_items[0][price_data][product_data][name]",
"Perfect Postcodes Lifetime License".to_string(),
),
("line_items[0][quantity]", "1".to_string()),
("success_url", success_url),
("cancel_url", cancel_url),
("client_reference_id", user.id.clone()),
("customer_email", user.email.clone()),
(
"discounts[0][coupon]",
state.stripe_referral_coupon_id.clone(),
),
];
let stripe_res = state
.http_client
.post("https://api.stripe.com/v1/checkout/sessions")
.basic_auth(secret_key, None::<&str>)
.form(&form_params)
.send()
.await;
match stripe_res {
Ok(resp) if resp.status().is_success() => {
let stripe_body: serde_json::Value = resp.json().await.unwrap_or_default();
let checkout_url = stripe_body["url"].as_str().unwrap_or_default().to_string();
info!(user_id = %user.id, code = %req.code, "Referral invite redeemed — checkout created");
Json(RedeemResponse {
result: "checkout".to_string(),
checkout_url: Some(checkout_url),
})
.into_response()
}
_ => {
warn!("Failed to create Stripe checkout for referral invite");
StatusCode::BAD_GATEWAY.into_response()
}
if let Err(response) = mark_invite_used(&state, pb_url, &token, invite_id, &user.id).await {
return response;
}
info!(user_id = %user.id, code = %req.code, "Admin invite redeemed; user licensed");
return Json(RedeemResponse {
result: "licensed".to_string(),
checkout_url: None,
})
.into_response();
}
let checkout_url = match create_referral_checkout(&state, &user).await {
Ok(url) => url,
Err(response) => return response,
};
if let Err(response) = mark_invite_used(&state, pb_url, &token, invite_id, &user.id).await {
return response;
}
info!(user_id = %user.id, code = %req.code, "Referral invite redeemed; checkout created");
Json(RedeemResponse {
result: "checkout".to_string(),
checkout_url: Some(checkout_url),
})
.into_response()
}
/// List invites. Admins see all invites; licensed users see only their own.
/// List invites. Users only see invites they created, including admins.
pub async fn get_invites(
State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>,
@ -487,16 +645,9 @@ pub async fn get_invites(
}
};
let filter = if user.is_admin {
String::new()
} else {
format!("created_by=\"{}\"", user.id)
};
let filter = format!("created_by=\"{}\"", user.id);
let mut url = format!("{pb_url}/api/collections/invites/records?sort=-created&perPage=200");
if !filter.is_empty() {
url.push_str(&format!("&filter={}", urlencoding::encode(&filter)));
}
url.push_str(&format!("&filter={}", urlencoding::encode(&filter)));
let res = match state
.http_client

View file

@ -3,14 +3,18 @@ use std::sync::Arc;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::Json;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::consts::MAX_POIS_PER_REQUEST;
use crate::data::POICategoryGroup;
use crate::data::{POICategoryGroup, POIData};
use crate::parsing::require_bounds;
use crate::state::SharedState;
const TUBE_STATION_CATEGORY: &str = "Tube station";
const TUBE_STATION_MERGE_RADIUS_DEGREES: f32 = 0.01;
#[derive(Serialize)]
#[allow(clippy::upper_case_acronyms)]
pub struct POI {
@ -35,6 +39,167 @@ pub struct POIParams {
categories: Option<String>,
}
struct SelectedPOIRow {
row: usize,
id_override: Option<String>,
name_override: Option<String>,
lat: f32,
lng: f32,
lat_sum: f32,
lng_sum: f32,
count: u32,
priority: u32,
}
impl SelectedPOIRow {
fn new(data: &POIData, row: usize, override_identity: bool) -> Self {
Self {
row,
id_override: override_identity.then(|| data.id(row).to_string()),
name_override: override_identity.then(|| data.name[row].clone()),
lat: data.lat[row],
lng: data.lng[row],
lat_sum: data.lat[row],
lng_sum: data.lng[row],
count: 1,
priority: data.priority[row],
}
}
fn merge_tube_station(&mut self, data: &POIData, row: usize) {
self.lat_sum += data.lat[row];
self.lng_sum += data.lng[row];
self.count += 1;
self.lat = self.lat_sum / self.count as f32;
self.lng = self.lng_sum / self.count as f32;
self.priority = self.priority.min(data.priority[row]);
let current_name = self
.name_override
.as_deref()
.unwrap_or(&data.name[self.row]);
let candidate_name = &data.name[row];
if tube_station_name_score(candidate_name) < tube_station_name_score(current_name) {
self.id_override = Some(data.id(row).to_string());
self.name_override = Some(candidate_name.clone());
}
}
fn id(&self, data: &POIData) -> String {
self.id_override
.clone()
.unwrap_or_else(|| data.id(self.row).to_string())
}
fn name(&self, data: &POIData) -> String {
self.name_override
.clone()
.unwrap_or_else(|| data.name[self.row].clone())
}
}
fn dedupe_tube_stations(data: &POIData, rows: Vec<usize>) -> Vec<SelectedPOIRow> {
let mut selected = Vec::with_capacity(rows.len());
let mut tube_groups: FxHashMap<String, Vec<usize>> = FxHashMap::default();
for row in rows {
if data.category.get(row) != TUBE_STATION_CATEGORY {
selected.push(SelectedPOIRow::new(data, row, false));
continue;
}
let station_key = canonical_tube_station_name(&data.name[row]);
if station_key.is_empty() {
selected.push(SelectedPOIRow::new(data, row, false));
continue;
}
let existing = tube_groups.get(&station_key).and_then(|indices| {
indices.iter().copied().find(|&index| {
same_tube_station_area(&selected[index], data.lat[row], data.lng[row])
})
});
if let Some(index) = existing {
selected[index].merge_tube_station(data, row);
} else {
let index = selected.len();
selected.push(SelectedPOIRow::new(data, row, true));
tube_groups.entry(station_key).or_default().push(index);
}
}
selected
}
fn canonical_tube_station_name(name: &str) -> String {
let mut normalized = String::with_capacity(name.len());
let mut paren_depth = 0u32;
for ch in name.chars() {
match ch {
'(' => {
paren_depth += 1;
normalized.push(' ');
}
')' => {
paren_depth = paren_depth.saturating_sub(1);
normalized.push(' ');
}
_ if paren_depth > 0 => {}
'\'' | '' | '`' => {}
'&' => normalized.push_str(" and "),
_ if ch.is_ascii_alphanumeric() => normalized.push(ch.to_ascii_lowercase()),
_ => normalized.push(' '),
}
}
let mut words: Vec<&str> = normalized.split_whitespace().collect();
const SUFFIXES: &[&[&str]] = &[
&["underground", "station"],
&["tube", "station"],
&["dlr", "station"],
&["metro", "station"],
&["tram", "stop"],
&["rail", "station"],
&["railway", "station"],
&["station"],
&["stop"],
];
loop {
let Some(suffix) = SUFFIXES.iter().find(|suffix| words.ends_with(suffix)) else {
break;
};
words.truncate(words.len() - suffix.len());
}
words.join(" ")
}
fn same_tube_station_area(station: &SelectedPOIRow, lat: f32, lng: f32) -> bool {
let dlat = station.lat - lat;
let dlng = (station.lng - lng) * station.lat.to_radians().cos();
(dlat * dlat + dlng * dlng) <= TUBE_STATION_MERGE_RADIUS_DEGREES.powi(2)
}
fn tube_station_name_score(name: &str) -> (u8, usize) {
let lower = name.to_ascii_lowercase();
let suffix_penalty = if lower.ends_with(" underground station")
|| lower.ends_with(" tube station")
|| lower.ends_with(" dlr station")
|| lower.ends_with(" metro station")
|| lower.ends_with(" tram stop")
|| lower.ends_with(" station")
|| lower.ends_with(" stop")
{
1
} else {
0
};
(suffix_penalty, name.len())
}
pub async fn get_pois(
State(shared): State<Arc<SharedState>>,
Query(params): Query<POIParams>,
@ -68,7 +233,7 @@ pub async fn get_pois(
let t0 = std::time::Instant::now();
let row_indices = state.poi_grid.query(south, west, north, east);
let mut matching_rows: Vec<usize> = row_indices
let matching_rows: Vec<usize> = row_indices
.iter()
.filter_map(|&row_idx| {
let row = row_idx as usize;
@ -81,27 +246,32 @@ pub async fn get_pois(
})
.collect();
if matching_rows.len() > MAX_POIS_PER_REQUEST {
let ratio = (matching_rows.len() / MAX_POIS_PER_REQUEST) as u32;
let mut matching_pois = dedupe_tube_stations(&state.poi_data, matching_rows);
if matching_pois.len() > MAX_POIS_PER_REQUEST {
let ratio = (matching_pois.len() / MAX_POIS_PER_REQUEST) as u32;
let step = ratio.next_power_of_two();
let mask = step - 1;
matching_rows.retain(|&row| state.poi_data.priority[row] & mask == 0);
if matching_rows.len() > MAX_POIS_PER_REQUEST {
matching_rows.sort_unstable_by_key(|&row| state.poi_data.priority[row]);
matching_rows.truncate(MAX_POIS_PER_REQUEST);
matching_pois.retain(|poi| poi.priority & mask == 0);
if matching_pois.len() > MAX_POIS_PER_REQUEST {
matching_pois.sort_unstable_by_key(|poi| poi.priority);
matching_pois.truncate(MAX_POIS_PER_REQUEST);
}
}
let pois: Vec<POI> = matching_rows
let pois: Vec<POI> = matching_pois
.iter()
.map(|&row| POI {
id: state.poi_data.id(row).to_string(),
name: state.poi_data.name[row].clone(),
category: state.poi_data.category.get(row).to_string(),
group: state.poi_data.group.get(row).to_string(),
lat: state.poi_data.lat[row],
lng: state.poi_data.lng[row],
emoji: state.poi_data.emoji.get(row).to_string(),
.map(|poi| {
let row = poi.row;
POI {
id: poi.id(&state.poi_data),
name: poi.name(&state.poi_data),
category: state.poi_data.category.get(row).to_string(),
group: state.poi_data.group.get(row).to_string(),
lat: poi.lat,
lng: poi.lng,
emoji: state.poi_data.emoji.get(row).to_string(),
}
})
.collect();
@ -143,3 +313,53 @@ pub async fn get_poi_categories(
Json(POICategoriesResponse { groups })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_tube_station_name_strips_transport_suffixes() {
assert_eq!(canonical_tube_station_name("Bank"), "bank");
assert_eq!(
canonical_tube_station_name("Bank Underground Station"),
"bank"
);
assert_eq!(canonical_tube_station_name("Bank DLR Station"), "bank");
assert_eq!(
canonical_tube_station_name("Pleasure Beach (Blackpool Tramway)"),
"pleasure beach"
);
assert_eq!(
canonical_tube_station_name("Earl's Court Tube Station"),
"earls court"
);
}
#[test]
fn same_tube_station_area_keeps_distant_names_separate() {
let station = SelectedPOIRow {
row: 0,
id_override: None,
name_override: None,
lat: 51.5130,
lng: -0.0889,
lat_sum: 51.5130,
lng_sum: -0.0889,
count: 1,
priority: 0,
};
assert!(same_tube_station_area(&station, 51.5132, -0.0885));
assert!(!same_tube_station_area(&station, 55.0140, -1.6781));
}
#[test]
fn tube_station_name_score_prefers_plain_station_names() {
assert!(tube_station_name_score("Bank") < tube_station_name_score("Bank DLR Station"));
assert!(
tube_station_name_score("Acton Town")
< tube_station_name_score("Acton Town Underground Station")
);
}
}

View file

@ -13,13 +13,13 @@ use tracing::info;
use crate::aggregation::{Aggregator, EnumDistConfig};
use crate::auth::OptionalUser;
use crate::consts::MAX_CELLS_PER_REQUEST;
use crate::pocketbase::log_user_location;
use crate::data::travel_time::TravelData;
use crate::licensing::check_license_bounds;
use crate::parsing::{
bounds_intersect, parse_enum_dist, parse_field_indices, parse_filters, require_bounds,
row_passes_filters,
};
use crate::pocketbase::log_user_location;
use crate::routes::travel_time::{parse_optional_travel, TravelTimeAgg};
use crate::state::SharedState;
use crate::utils::normalize_postcode;