Good stuff

This commit is contained in:
Andras Schmelczer 2026-02-22 22:36:40 +00:00
parent 9da2db707f
commit 8032011708
32 changed files with 1052 additions and 374 deletions

View file

@ -28,8 +28,6 @@ MERGE_STAMP := $(DATA_DIR)/.merge_done
PRICE_INDEX := $(DATA_DIR)/price_index.parquet PRICE_INDEX := $(DATA_DIR)/price_index.parquet
PRICES_STAMP := $(DATA_DIR)/.prices_done PRICES_STAMP := $(DATA_DIR)/.prices_done
EPC := $(MANUAL_DATA)/certificates.csv EPC := $(MANUAL_DATA)/certificates.csv
JT_BANK := $(MANUAL_DATA)/journey_times_bank.parquet
JT_FITZROVIA := $(MANUAL_DATA)/journey_times_fitzrovia.parquet
ETHNICITY := $(DATA_DIR)/ethnicity_by_la.parquet ETHNICITY := $(DATA_DIR)/ethnicity_by_la.parquet
CRIME_DIR := $(MANUAL_DATA)/crime CRIME_DIR := $(MANUAL_DATA)/crime
CRIME := $(DATA_DIR)/crime_by_lsoa.parquet CRIME := $(DATA_DIR)/crime_by_lsoa.parquet
@ -68,8 +66,7 @@ PMTILES_VERSION := 1.22.3
download-oa-boundaries download-uprn-lookup download-transit-network download-greenspace download-pbf download-places \ download-oa-boundaries download-uprn-lookup download-transit-network download-greenspace download-pbf download-places \
transform-pois transform-epc-pp transform-crime transform-poi-proximity \ transform-pois transform-epc-pp transform-crime transform-poi-proximity \
transform-school-proximity transform-geosure transform-postcode-boundaries \ transform-school-proximity transform-geosure transform-postcode-boundaries \
generate-postcode-boundaries \ generate-postcode-boundaries
journey-times
prepare: $(PRICES_STAMP) prepare: $(PRICES_STAMP)
merge: $(MERGE_STAMP) merge: $(MERGE_STAMP)
@ -185,32 +182,6 @@ $(GREENSPACE): $(PBF)
$(PLACES): $(PBF) $(PLACES): $(PBF)
uv run python -m pipeline.download.places --output $@ --pbf $(PBF) uv run python -m pipeline.download.places --output $@ --pbf $(PBF)
# ── Journey times (requires TFL_API_KEY) ──────────────────────────────────────
$(JT_BANK):
@echo ""
@echo "=== TFL journey times (bank) not found ==="
@echo "Place journey_times_bank.parquet in $(MANUAL_DATA)/"
@echo "or register for a TFL API key at https://api-portal.tfl.gov.uk/signin"
@echo "and run: TFL_API_KEY=... make -f Makefile.data journey-times DEST=bank"
@echo ""
@exit 1
$(JT_FITZROVIA):
@echo ""
@echo "=== TFL journey times (fitzrovia) not found ==="
@echo "Place journey_times_fitzrovia.parquet in $(MANUAL_DATA)/"
@echo "or register for a TFL API key at https://api-portal.tfl.gov.uk/signin"
@echo "and run: TFL_API_KEY=... make -f Makefile.data journey-times DEST=fitzrovia"
@echo ""
@exit 1
journey-times: $(ARCGIS)
ifndef DEST
$(error DEST required — e.g. make journey-times DEST=bank)
endif
uv run python -m pipeline.journey_times --destination $(DEST) --output-dir $(DATA_DIR) --postcodes $(ARCGIS)
# ── Transforms ──────────────────────────────────────────────────────────────── # ── Transforms ────────────────────────────────────────────────────────────────
$(POIS_FILTERED): $(POIS_RAW) $(NAPTAN) $(POIS_FILTERED): $(POIS_RAW) $(NAPTAN)
@ -256,15 +227,13 @@ $(PC_BOUNDARIES):
# ── Final merge → postcode.parquet + properties.parquet ────────────────────── # ── Final merge → postcode.parquet + properties.parquet ──────────────────────
$(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) $(JT_BANK) $(JT_FITZROVIA) \ $(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \
$(ETHNICITY) $(CRIME) $(NOISE) $(SCHOOL_PROX) $(BROADBAND) $(GEOSURE) $(RENTAL) $(ETHNICITY) $(CRIME) $(NOISE) $(SCHOOL_PROX) $(BROADBAND) $(GEOSURE) $(RENTAL)
uv run python -m pipeline.transform.merge \ uv run python -m pipeline.transform.merge \
--epc-pp $(EPC_PP) \ --epc-pp $(EPC_PP) \
--arcgis $(ARCGIS) \ --arcgis $(ARCGIS) \
--iod $(IOD) \ --iod $(IOD) \
--poi-proximity $(POI_PROXIMITY) \ --poi-proximity $(POI_PROXIMITY) \
--journey-times-bank $(JT_BANK) \
--journey-times-fitzrovia $(JT_FITZROVIA) \
--ethnicity $(ETHNICITY) \ --ethnicity $(ETHNICITY) \
--crime $(CRIME) \ --crime $(CRIME) \
--noise $(NOISE) \ --noise $(NOISE) \

View file

@ -77,7 +77,10 @@ export default function App() {
const [poiCategoryGroups, setPOICategoryGroups] = useState<POICategoryGroup[]>([]); const [poiCategoryGroups, setPOICategoryGroups] = useState<POICategoryGroup[]>([]);
const [initialLoading, setInitialLoading] = useState(true); const [initialLoading, setInitialLoading] = useState(true);
const [pendingInfoFeature, setPendingInfoFeature] = useState<string | null>(null); const [pendingInfoFeature, setPendingInfoFeature] = useState<string | null>(null);
const [inviteCode, setInviteCode] = useState<string | null>(null); const [inviteCode, setInviteCode] = useState<string | null>(() => {
const fromPath = pathToPage(window.location.pathname);
return fromPath?.inviteCode ?? null;
});
const [activePage, setActivePage] = useState<Page>(() => { const [activePage, setActivePage] = useState<Page>(() => {
if (isScreenshotMode) return 'dashboard'; if (isScreenshotMode) return 'dashboard';
@ -91,13 +94,6 @@ export default function App() {
return 'home'; return 'home';
}); });
useEffect(() => {
const fromPath = pathToPage(window.location.pathname);
if (fromPath?.inviteCode) {
setInviteCode(fromPath.inviteCode);
}
}, []);
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { const {
@ -366,6 +362,7 @@ export default function App() {
<SaveSearchModal <SaveSearchModal
onClose={() => setShowSaveModal(false)} onClose={() => setShowSaveModal(false)}
onSave={savedSearches.saveSearch} onSave={savedSearches.saveSearch}
onViewSearches={() => { setShowSaveModal(false); navigateTo('account'); }}
saving={savedSearches.saving} saving={savedSearches.saving}
error={savedSearches.error} error={savedSearches.error}
/> />

View file

@ -368,7 +368,7 @@ function SettingsContent({
{isLicensed && ( {isLicensed && (
<div className="px-5 py-4"> <div className="px-5 py-4">
<p className="text-sm text-warm-500 dark:text-warm-400 mb-3"> <p className="text-sm text-warm-500 dark:text-warm-400 mb-3">
{user.isAdmin ? 'Generate invite link (free access)' : 'Invite friends (30% off)'} {user.isAdmin ? 'Invite friends (100% off)' : 'Invite friends (30% off)'}
</p> </p>
{inviteUrl ? ( {inviteUrl ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -397,7 +397,7 @@ function SettingsContent({
className="px-4 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium disabled:opacity-50 disabled:cursor-wait flex items-center gap-2" className="px-4 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium disabled:opacity-50 disabled:cursor-wait flex items-center gap-2"
> >
{creatingInvite && <SpinnerIcon className="w-4 h-4 animate-spin" />} {creatingInvite && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{user.isAdmin ? 'Generate invite link' : 'Generate referral link'} {user.isAdmin ? 'Generate free invite link' : 'Generate referral link'}
</button> </button>
)} )}
{inviteError && ( {inviteError && (

View file

@ -133,6 +133,7 @@ export default function AreaPane({
<LoadingSkeleton /> <LoadingSkeleton />
) : stats ? ( ) : stats ? (
<div> <div>
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
<HistogramLegend /> <HistogramLegend />
{featureGroups.map((group) => { {featureGroups.map((group) => {
const hasData = group.features.some( const hasData = group.features.some(
@ -375,7 +376,6 @@ export default function AreaPane({
</div> </div>
); );
})} })}
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
</div> </div>
) : null} ) : null}
</div> </div>

View file

@ -9,10 +9,10 @@ import { groupFeaturesByCategory } from '../../lib/features';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup'; import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { FeatureActions } from '../ui/FeatureIcons'; import { FeatureActions } from '../ui/FeatureIcons';
import { FeatureLabel } from '../ui/FeatureLabel'; import { FeatureLabel } from '../ui/FeatureLabel';
import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon, PlusIcon, EyeIcon } from '../ui/icons'; import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon, PlusIcon } from '../ui/icons';
import type { ComponentType } from 'react'; import type { ComponentType } from 'react';
import { IconButton } from '../ui/IconButton'; import { IconButton } from '../ui/IconButton';
import { TRANSPORT_MODES, MODE_LABELS, travelFieldKey, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime'; import { TRANSPORT_MODES, MODE_LABELS, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
const MODE_ICONS: Record<TransportMode, ComponentType<{ className?: string }>> = { const MODE_ICONS: Record<TransportMode, ComponentType<{ className?: string }>> = {
car: CarIcon, car: CarIcon,
@ -96,9 +96,6 @@ export default function FeatureBrowser({
</span> </span>
</CollapsibleGroupHeader> </CollapsibleGroupHeader>
{(isSearching || expandedGroups.has('Travel Time')) && TRANSPORT_MODES.map((mode) => { {(isSearching || expandedGroups.has('Travel Time')) && TRANSPORT_MODES.map((mode) => {
const activeEntry = travelTimeEntries.find((e) => e.mode === mode && e.slug);
const fieldKey = activeEntry ? travelFieldKey(activeEntry) : null;
const isPinned = fieldKey !== null && pinnedFeature === fieldKey;
const ModeIcon = MODE_ICONS[mode]; const ModeIcon = MODE_ICONS[mode];
return ( return (
<div <div
@ -117,16 +114,6 @@ export default function FeatureBrowser({
</div> </div>
</div> </div>
<div className="flex items-center gap-0.5 shrink-0"> <div className="flex items-center gap-0.5 shrink-0">
{fieldKey && (
<IconButton
onClick={() => onTogglePin(fieldKey)}
active={isPinned}
title={isPinned ? 'Unpin color view' : 'Color map by this feature'}
size="md"
>
<EyeIcon filled={isPinned} className="w-7 h-7 md:w-3.5 md:h-3.5" />
</IconButton>
)}
<IconButton onClick={() => onAddTravelTimeEntry(mode)} title={`Add ${MODE_LABELS[mode]} travel time`} size="md"> <IconButton onClick={() => onAddTravelTimeEntry(mode)} title={`Add ${MODE_LABELS[mode]} travel time`} size="md">
<PlusIcon className="w-7 h-7 md:w-3.5 md:h-3.5" /> <PlusIcon className="w-7 h-7 md:w-3.5 md:h-3.5" />
</IconButton> </IconButton>

View file

@ -25,15 +25,6 @@ import {
type ListingType = 'historical' | 'buy' | 'rent'; type ListingType = 'historical' | 'buy' | 'rent';
const MODE_RESTRICTED_FEATURES: Record<string, Set<ListingType>> = {
'Bathrooms': new Set(['buy', 'rent']),
};
function isFeatureAllowedInMode(featureName: string, mode: ListingType): boolean {
const allowed = MODE_RESTRICTED_FEATURES[featureName];
return !allowed || allowed.has(mode);
}
function SliderLabels({ function SliderLabels({
min, min,
max, max,
@ -89,7 +80,6 @@ interface FiltersProps {
openInfoFeature?: string | null; openInfoFeature?: string | null;
onClearOpenInfoFeature?: () => void; onClearOpenInfoFeature?: () => void;
travelTimeEntries: TravelTimeEntry[]; travelTimeEntries: TravelTimeEntry[];
travelTimeDataRanges: Map<number, [number, number]>;
onTravelTimeAddEntry: (mode: TransportMode) => void; onTravelTimeAddEntry: (mode: TransportMode) => void;
onTravelTimeRemoveEntry: (index: number) => void; onTravelTimeRemoveEntry: (index: number) => void;
onTravelTimeSetDestination: (index: number, slug: string, label: string) => void; onTravelTimeSetDestination: (index: number, slug: string, label: string) => void;
@ -122,7 +112,6 @@ export default memo(function Filters({
openInfoFeature, openInfoFeature,
onClearOpenInfoFeature, onClearOpenInfoFeature,
travelTimeEntries, travelTimeEntries,
travelTimeDataRanges,
onTravelTimeAddEntry, onTravelTimeAddEntry,
onTravelTimeRemoveEntry, onTravelTimeRemoveEntry,
onTravelTimeSetDestination, onTravelTimeSetDestination,
@ -136,6 +125,36 @@ export default memo(function Filters({
onUpgradeClick, onUpgradeClick,
onResetTutorial, onResetTutorial,
}: FiltersProps) { }: FiltersProps) {
const modeRestrictions = useMemo(() => {
const map: Record<string, Set<ListingType>> = {};
for (const f of features) {
if (f.modes && f.modes.length > 0) {
map[f.name] = new Set(f.modes as ListingType[]);
}
}
return map;
}, [features]);
const linkedFeatures = useMemo(() => {
const pairs: [string, string][] = [];
const seen = new Set<string>();
for (const f of features) {
if (f.linked && !seen.has(f.name)) {
pairs.push([f.name, f.linked]);
seen.add(f.linked);
}
}
return pairs;
}, [features]);
const isAllowed = useCallback(
(name: string, mode: ListingType) => {
const allowed = modeRestrictions[name];
return !allowed || allowed.has(mode);
},
[modeRestrictions]
);
const activeListingType = useMemo((): ListingType => { const activeListingType = useMemo((): ListingType => {
const val = filters['Listing status'] as string[] | undefined; const val = filters['Listing status'] as string[] | undefined;
if (!val || val.length === 0) return 'historical'; if (!val || val.length === 0) return 'historical';
@ -145,8 +164,8 @@ export default memo(function Filters({
}, [filters]); }, [filters]);
const availableFeatures = useMemo( const availableFeatures = useMemo(
() => features.filter((f) => !enabledFeatures.has(f.name) && isFeatureAllowedInMode(f.name, activeListingType)), () => features.filter((f) => !enabledFeatures.has(f.name) && isAllowed(f.name, activeListingType)),
[features, enabledFeatures, activeListingType] [features, enabledFeatures, activeListingType, isAllowed]
); );
const enabledFeatureList = useMemo( const enabledFeatureList = useMemo(
() => features.filter((f) => enabledFeatures.has(f.name) && f.name !== 'Listing status'), () => features.filter((f) => enabledFeatures.has(f.name) && f.name !== 'Listing status'),
@ -156,7 +175,22 @@ export default memo(function Filters({
const handleListingSelect = useCallback( const handleListingSelect = useCallback(
(type: ListingType) => { (type: ListingType) => {
for (const name of Object.keys(filters)) { for (const name of Object.keys(filters)) {
if (name !== 'Listing status' && !isFeatureAllowedInMode(name, type)) { if (name === 'Listing status') continue;
if (isAllowed(name, type)) continue;
// Check if this feature has a linked counterpart in the new mode
let swapped = false;
for (const [a, b] of linkedFeatures) {
const counterpart = name === a ? b : name === b ? a : null;
if (counterpart && isAllowed(counterpart, type)) {
onFilterChange(counterpart, filters[name] as [number, number]);
onRemoveFilter(name);
swapped = true;
break;
}
}
if (!swapped) {
onRemoveFilter(name); onRemoveFilter(name);
} }
} }
@ -167,7 +201,7 @@ export default memo(function Filters({
}; };
onFilterChange('Listing status', [valueMap[type]]); onFilterChange('Listing status', [valueMap[type]]);
}, },
[filters, onFilterChange, onRemoveFilter] [filters, onFilterChange, onRemoveFilter, isAllowed, linkedFeatures]
); );
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@ -275,7 +309,6 @@ export default memo(function Filters({
label={entry.label} label={entry.label}
timeRange={entry.timeRange} timeRange={entry.timeRange}
useBest={entry.useBest} useBest={entry.useBest}
dataRange={travelTimeDataRanges.get(index) ?? null}
isPinned={pinnedFeature === travelFieldKey(entry)} isPinned={pinnedFeature === travelFieldKey(entry)}
onTogglePin={() => onTogglePin(travelFieldKey(entry))} onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)} onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)}

View file

@ -170,6 +170,7 @@ export default memo(function Map({
data, data,
postcodeData, postcodeData,
usePostcodeView, usePostcodeView,
zoom: viewState.zoom,
pois, pois,
viewFeature, viewFeature,
colorRange, colorRange,

View file

@ -188,24 +188,6 @@ export default function MapPage({
const pois = usePOIData(mapData.bounds, selectedPOICategories); const pois = usePOIData(mapData.bounds, selectedPOICategories);
const travelTimeDataRanges = useMemo((): globalThis.Map<number, [number, number]> => {
const ranges = new globalThis.Map<number, [number, number]>();
for (let i = 0; i < travelTime.entries.length; i++) {
const entry = travelTime.entries[i];
if (!entry.slug) continue;
const fieldName = `avg_${travelFieldKey(entry)}`;
const vals: number[] = [];
for (const item of mapData.data) {
const val = item[fieldName];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
}
if (vals.length === 0) continue;
vals.sort((a, b) => a - b);
ranges.set(i, [vals[0], vals[vals.length - 1]]);
}
return ranges;
}, [travelTime.entries, mapData.data]);
useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab, travelTime.entries); useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab, travelTime.entries);
useEffect(() => { useEffect(() => {
@ -401,7 +383,6 @@ export default function MapPage({
openInfoFeature={pendingInfoFeature} openInfoFeature={pendingInfoFeature}
onClearOpenInfoFeature={onClearPendingInfoFeature} onClearOpenInfoFeature={onClearPendingInfoFeature}
travelTimeEntries={travelTime.entries} travelTimeEntries={travelTime.entries}
travelTimeDataRanges={travelTimeDataRanges}
onTravelTimeAddEntry={travelTime.handleAddEntry} onTravelTimeAddEntry={travelTime.handleAddEntry}
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry} onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
onTravelTimeSetDestination={handleTravelTimeSetDestination} onTravelTimeSetDestination={handleTravelTimeSetDestination}
@ -625,9 +606,10 @@ export default function MapPage({
<button <button
data-tutorial="poi-button" data-tutorial="poi-button"
onClick={() => setPoiPaneOpen((p) => !p)} onClick={() => setPoiPaneOpen((p) => !p)}
className={`absolute bottom-4 right-4 z-10 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`} className={`absolute bottom-4 right-4 z-10 px-3 py-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 flex items-center gap-2 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
> >
<MapPinIcon className="w-5 h-5" /> <MapPinIcon className="w-5 h-5" />
<span className="text-sm font-medium">Points of interest</span>
</button> </button>
{/* Floating POI panel */} {/* Floating POI panel */}
{poiPaneOpen && ( {poiPaneOpen && (

View file

@ -27,7 +27,6 @@ interface TravelTimeCardProps {
label: string; label: string;
timeRange: [number, number] | null; timeRange: [number, number] | null;
useBest: boolean; useBest: boolean;
dataRange: [number, number] | null;
isPinned: boolean; isPinned: boolean;
onTogglePin: () => void; onTogglePin: () => void;
onSetDestination: (slug: string, label: string) => void; onSetDestination: (slug: string, label: string) => void;
@ -42,7 +41,6 @@ export function TravelTimeCard({
label, label,
timeRange, timeRange,
useBest, useBest,
dataRange,
isPinned, isPinned,
onTogglePin, onTogglePin,
onSetDestination, onSetDestination,
@ -74,8 +72,8 @@ export function TravelTimeCard({
[onSetDestination, search.clear], [onSetDestination, search.clear],
); );
const sliderMin = dataRange ? Math.floor(dataRange[0]) : 0; const sliderMin = 0;
const sliderMax = dataRange ? Math.ceil(dataRange[1]) : 120; const sliderMax = 120;
const displayRange = timeRange ?? [sliderMin, sliderMax]; const displayRange = timeRange ?? [sliderMin, sliderMax];
const ModeIcon = MODE_ICONS[mode]; const ModeIcon = MODE_ICONS[mode];
@ -142,7 +140,7 @@ export function TravelTimeCard({
)} )}
{/* Time range slider — only show when we have data */} {/* Time range slider — only show when we have data */}
{slug && dataRange && ( {slug && (
<div> <div>
<span className="text-[10px] font-medium text-warm-500 dark:text-warm-400 uppercase tracking-wide"> <span className="text-[10px] font-medium text-warm-500 dark:text-warm-400 uppercase tracking-wide">
Max time Max time

View file

@ -58,16 +58,6 @@ export default function PricingPage({
if (scrollRef.current) setScrolledLeft(scrollRef.current.scrollLeft > 0); if (scrollRef.current) setScrolledLeft(scrollRef.current.scrollLeft > 0);
}, []); }, []);
useEffect(() => {
if (!pricing || !scrollRef.current || !activeCardRef.current) return;
if (currentTierIndex === 0) return;
const container = scrollRef.current;
const card = activeCardRef.current;
const scrollLeft = card.offsetLeft - container.offsetLeft - (container.clientWidth - card.offsetWidth) / 2;
container.scrollLeft = Math.max(0, scrollLeft);
setScrolledLeft(container.scrollLeft > 0);
}, [pricing, currentTierIndex]);
useEffect(() => { useEffect(() => {
fetch(apiUrl('pricing')) fetch(apiUrl('pricing'))
.then((res) => { .then((res) => {
@ -98,6 +88,16 @@ export default function PricingPage({
} }
} }
useEffect(() => {
if (!pricing || !scrollRef.current || !activeCardRef.current) return;
if (currentTierIndex === 0) return;
const container = scrollRef.current;
const card = activeCardRef.current;
const scrollLeft = card.offsetLeft - container.offsetLeft - (container.clientWidth - card.offsetWidth) / 2;
container.scrollLeft = Math.max(0, scrollLeft);
setScrolledLeft(container.scrollLeft > 0);
}, [pricing, currentTierIndex]);
const ctaButton = isLicensed ? ( const ctaButton = isLicensed ? (
<button <button
onClick={onOpenDashboard} onClick={onOpenDashboard}
@ -183,13 +183,23 @@ export default function PricingPage({
/> />
</div> </div>
<div className="relative z-10 max-w-5xl mx-auto px-6 pt-16 text-center mb-12"> <div className="relative z-10 max-w-5xl mx-auto px-6 pt-16 text-center mb-6">
<h1 className="text-3xl md:text-4xl font-bold text-white mb-3"> <h1 className="text-3xl md:text-4xl font-bold text-white mb-3">
Early access pricing Early access pricing
</h1> </h1>
<p className="text-lg text-warm-300 max-w-lg mx-auto"> <p className="text-lg text-warm-300 max-w-lg mx-auto">
No subscriptions, no recurring fees. Pay once and get lifetime Pay once, access forever. The earlier you join, the less you pay.
access to every feature. The earlier you join, the less you pay. </p>
</div>
<div className="relative z-10 max-w-2xl mx-auto px-6 mb-12 text-center">
<p className="text-warm-400 text-sm leading-relaxed mb-2">
Buying a home costs &pound;10k+ in stamp duty, &pound;1,500 in solicitor fees,
&pound;500 for a survey. Get the wrong area and you&apos;re stuck with a long
commute, bad schools, or a road you didn&apos;t know about.
</p>
<p className="text-warm-200 font-semibold">
Less than your survey costs. Vastly more useful.
</p> </p>
</div> </div>
@ -203,7 +213,7 @@ export default function PricingPage({
<div className="relative mb-12" style={{ marginLeft: 'calc(-50vw + 50%)', marginRight: 'calc(-50vw + 50%)', width: '100vw' }}> <div className="relative mb-12" style={{ marginLeft: 'calc(-50vw + 50%)', marginRight: 'calc(-50vw + 50%)', width: '100vw' }}>
{scrolledLeft && <div className="pointer-events-none absolute inset-y-0 left-0 w-12 z-10 backdrop-blur-sm" style={{ maskImage: 'linear-gradient(to right, black, transparent)' }} />} {scrolledLeft && <div className="pointer-events-none absolute inset-y-0 left-0 w-12 z-10 backdrop-blur-sm" style={{ maskImage: 'linear-gradient(to right, black, transparent)' }} />}
<div className="pointer-events-none absolute inset-y-0 right-0 w-12 z-10 backdrop-blur-sm" style={{ maskImage: 'linear-gradient(to left, black, transparent)' }} /> <div className="pointer-events-none absolute inset-y-0 right-0 w-12 z-10 backdrop-blur-sm" style={{ maskImage: 'linear-gradient(to left, black, transparent)' }} />
<div ref={scrollRef} onScroll={onScroll} className="flex gap-6 overflow-x-auto px-6 pb-4 scrollbar-hide" style={{ scrollbarWidth: 'none' }}> <div ref={scrollRef} onScroll={onScroll} className="flex justify-center gap-6 overflow-x-auto px-6 pb-4 scrollbar-hide" style={{ scrollbarWidth: 'none' }}>
{pricing.tiers.map((tier, i) => { {pricing.tiers.map((tier, i) => {
const isCurrent = i === currentTierIndex; const isCurrent = i === currentTierIndex;
const isFilled = const isFilled =
@ -348,17 +358,6 @@ export default function PricingPage({
)} )}
</div> </div>
<div className="relative z-10 max-w-2xl mx-auto px-6 pb-16 text-center">
<p className="text-warm-400 leading-relaxed mb-3">
Stamp duty on a &pound;400k house: &pound;10,000. Solicitor fees: &pound;1,500.
Survey: &pound;500. Moving costs: &pound;1,000. And that&apos;s just the money. Get the
wrong area and you&apos;re stuck &mdash; with a long commute, bad schools, or a street
that looked fine on the listing photos but turns out to be on a motorway.
</p>
<p className="text-warm-200 font-semibold">
One payment. Lifetime access. Less than your survey costs and vastly more useful.
</p>
</div>
</div> </div>
); );
} }

View file

@ -1,5 +1,6 @@
import type { FeatureMeta } from '../../types'; import type { FeatureMeta } from '../../types';
import { InfoIcon } from './icons'; import { InfoIcon } from './icons';
import { getGroupIcon } from '../../lib/group-icons';
interface FeatureLabelProps { interface FeatureLabelProps {
feature: FeatureMeta; feature: FeatureMeta;
@ -15,11 +16,15 @@ export function FeatureLabel({
size = 'xs', size = 'xs',
}: FeatureLabelProps) { }: FeatureLabelProps) {
const textClass = size === 'sm' ? 'text-sm' : 'text-xs'; const textClass = size === 'sm' ? 'text-sm' : 'text-xs';
const GroupIcon = feature.group ? getGroupIcon(feature.group) : null;
return ( return (
<div <div
className={`flex ${size === 'xs' ? 'items-center' : 'items-start'} gap-1 min-w-0 ${className}`} className={`flex ${size === 'xs' ? 'items-center' : 'items-start'} gap-1 min-w-0 ${className}`}
> >
{GroupIcon && (
<GroupIcon className="w-3.5 h-3.5 text-teal-600 dark:text-teal-400 shrink-0" />
)}
<span <span
className={`${textClass} text-warm-700 dark:text-warm-300 ${size === 'xs' ? 'truncate' : ''}`} className={`${textClass} text-warm-700 dark:text-warm-300 ${size === 'xs' ? 'truncate' : ''}`}
> >

View file

@ -1,19 +1,23 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { CheckIcon } from './icons/CheckIcon';
import { CloseIcon } from './icons/CloseIcon'; import { CloseIcon } from './icons/CloseIcon';
import { SpinnerIcon } from './icons/SpinnerIcon'; import { SpinnerIcon } from './icons/SpinnerIcon';
export default function SaveSearchModal({ export default function SaveSearchModal({
onClose, onClose,
onSave, onSave,
onViewSearches,
saving, saving,
error, error,
}: { }: {
onClose: () => void; onClose: () => void;
onSave: (name: string) => Promise<void>; onSave: (name: string) => Promise<void>;
onViewSearches: () => void;
saving: boolean; saving: boolean;
error: string | null; error: string | null;
}) { }) {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [saved, setSaved] = useState(false);
const handleSubmit = useCallback( const handleSubmit = useCallback(
async (e: React.FormEvent) => { async (e: React.FormEvent) => {
@ -21,12 +25,12 @@ export default function SaveSearchModal({
if (!name.trim() || saving) return; if (!name.trim() || saving) return;
try { try {
await onSave(name.trim()); await onSave(name.trim());
onClose(); setSaved(true);
} catch { } catch {
// Error displayed in modal // Error displayed in modal
} }
}, },
[name, saving, onSave, onClose] [name, saving, onSave]
); );
useEffect(() => { useEffect(() => {
@ -45,7 +49,9 @@ export default function SaveSearchModal({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex items-center justify-between px-5 pt-5 pb-3"> <div className="flex items-center justify-between px-5 pt-5 pb-3">
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">Save Search</h2> <h2 className="text-lg font-semibold text-navy-950 dark:text-white">
{saved ? 'Search saved' : 'Save Search'}
</h2>
<button <button
onClick={onClose} onClick={onClose}
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200" className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
@ -54,41 +60,68 @@ export default function SaveSearchModal({
</button> </button>
</div> </div>
<form onSubmit={handleSubmit} className="p-5 pt-2 space-y-4"> {saved ? (
<div> <div className="p-5 pt-2 space-y-4">
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1"> <div className="flex items-center gap-2 text-teal-600 dark:text-teal-400">
Name <CheckIcon className="w-5 h-5" />
</label> <p className="text-sm text-warm-700 dark:text-warm-300">
<input Your search has been saved successfully.
type="text" </p>
value={name} </div>
onChange={(e) => setName(e.target.value)} <div className="flex gap-3 justify-end">
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500" <button
placeholder="My search" type="button"
autoFocus onClick={onClose}
/> className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
>
Close
</button>
<button
type="button"
onClick={onViewSearches}
className="px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700"
>
View saved searches
</button>
</div>
</div> </div>
) : (
<form onSubmit={handleSubmit} className="p-5 pt-2 space-y-4">
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder="My search"
autoFocus
/>
</div>
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>} {error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}
<div className="flex gap-3 justify-end"> <div className="flex gap-3 justify-end">
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700" className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
> >
Cancel Cancel
</button> </button>
<button <button
type="submit" type="submit"
disabled={!name.trim() || saving} disabled={!name.trim() || saving}
className="flex items-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait" className="flex items-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
> >
{saving && <SpinnerIcon className="w-4 h-4 animate-spin" />} {saving && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{saving ? 'Saving...' : 'Save'} {saving ? 'Saving...' : 'Save'}
</button> </button>
</div> </div>
</form> </form>
)}
</div> </div>
</div> </div>
); );

View file

@ -38,11 +38,29 @@ export default function UserMenu({
{open && ( {open && (
<div className="absolute right-0 top-10 w-56 bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg shadow-lg z-50"> <div className="absolute right-0 top-10 w-56 bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg shadow-lg z-50">
<div className="px-4 py-3 border-b border-warm-200 dark:border-warm-700"> <div className="px-4 py-3 border-b border-warm-200 dark:border-warm-700">
<p className="text-sm font-medium text-navy-950 dark:text-warm-100 truncate"> <div className="flex items-center justify-between gap-2">
{user.email} <p className="text-sm font-medium text-navy-950 dark:text-warm-100 truncate">
</p> {user.email}
</p>
<span
className={`shrink-0 text-xs font-medium px-1.5 py-0.5 rounded ${
user.subscription === 'licensed' || user.isAdmin
? 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400'
: 'bg-warm-100 text-warm-500 dark:bg-warm-700 dark:text-warm-400'
}`}
>
{user.subscription === 'licensed' || user.isAdmin ? 'Pro' : 'Free'}
</span>
</div>
</div> </div>
<div className="p-1"> <div className="p-1">
<a
href="/account"
onClick={() => setOpen(false)}
className="block w-full text-left px-3 py-2 text-sm text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700 rounded"
>
Account
</a>
<button <button
onClick={() => { onClick={() => {
setOpen(false); setOpen(false);

View file

@ -0,0 +1,21 @@
interface IconProps {
className?: string;
}
export function ChartBarIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="20" x2="18" y2="10" />
<line x1="12" y1="20" x2="12" y2="4" />
<line x1="6" y1="20" x2="6" y2="14" />
</svg>
);
}

View file

@ -0,0 +1,20 @@
interface IconProps {
className?: string;
}
export function GraduationCapIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
<path d="M6 12v5c0 2 3 3 6 3s6-1 6-3v-5" />
</svg>
);
}

View file

@ -0,0 +1,20 @@
interface IconProps {
className?: string;
}
export function HouseIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
);
}

View file

@ -0,0 +1,19 @@
interface IconProps {
className?: string;
}
export function ShieldIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
);
}

View file

@ -0,0 +1,21 @@
interface IconProps {
className?: string;
}
export function ShoppingBagIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z" />
<line x1="3" y1="6" x2="21" y2="6" />
<path d="M16 10a4 4 0 01-8 0" />
</svg>
);
}

View file

@ -0,0 +1,20 @@
interface IconProps {
className?: string;
}
export function TagIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82z" />
<line x1="7" y1="7" x2="7.01" y2="7" />
</svg>
);
}

View file

@ -0,0 +1,20 @@
interface IconProps {
className?: string;
}
export function TreeIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 22v-7" />
<path d="M17 15H7l2-4H5l7-9 7 9h-4l2 4z" />
</svg>
);
}

View file

@ -0,0 +1,22 @@
interface IconProps {
className?: string;
}
export function UsersIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 00-3-3.87" />
<path d="M16 3.13a4 4 0 010 7.75" />
</svg>
);
}

View file

@ -11,3 +11,11 @@ export { CarIcon } from './CarIcon';
export { BicycleIcon } from './BicycleIcon'; export { BicycleIcon } from './BicycleIcon';
export { WalkingIcon } from './WalkingIcon'; export { WalkingIcon } from './WalkingIcon';
export { TransitIcon } from './TransitIcon'; export { TransitIcon } from './TransitIcon';
export { HouseIcon } from './HouseIcon';
export { GraduationCapIcon } from './GraduationCapIcon';
export { ChartBarIcon } from './ChartBarIcon';
export { ShieldIcon } from './ShieldIcon';
export { UsersIcon } from './UsersIcon';
export { ShoppingBagIcon } from './ShoppingBagIcon';
export { TreeIcon } from './TreeIcon';
export { TagIcon } from './TagIcon';

View file

@ -34,6 +34,7 @@ interface UseDeckLayersProps {
data: HexagonData[]; data: HexagonData[];
postcodeData: PostcodeFeature[]; postcodeData: PostcodeFeature[];
usePostcodeView: boolean; usePostcodeView: boolean;
zoom: number;
pois: POI[]; pois: POI[];
viewFeature: string | null; viewFeature: string | null;
colorRange: [number, number] | null; colorRange: [number, number] | null;
@ -61,6 +62,7 @@ export function useDeckLayers({
data, data,
postcodeData, postcodeData,
usePostcodeView, usePostcodeView,
zoom,
pois, pois,
viewFeature, viewFeature,
colorRange, colorRange,
@ -512,12 +514,15 @@ export function useDeckLayers({
const layers = useMemo(() => { const layers = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const baseLayers: any[] = usePostcodeView const baseLayers: any[] = usePostcodeView
? [postcodeLayer, postcodeLabelsLayer, poiLayer] ? zoom >= 16
? [postcodeLayer, postcodeLabelsLayer, poiLayer]
: [postcodeLayer, poiLayer]
: [hexLayer, poiLayer]; : [hexLayer, poiLayer];
if (marchingAntsLayer) baseLayers.push(marchingAntsLayer); if (marchingAntsLayer) baseLayers.push(marchingAntsLayer);
return baseLayers; return baseLayers;
}, [ }, [
usePostcodeView, usePostcodeView,
zoom,
hexLayer, hexLayer,
postcodeLayer, postcodeLayer,
postcodeLabelsLayer, postcodeLabelsLayer,

View file

@ -0,0 +1,31 @@
import type { ComponentType } from 'react';
import {
HouseIcon,
RouteIcon,
GraduationCapIcon,
ChartBarIcon,
ShieldIcon,
UsersIcon,
ShoppingBagIcon,
TreeIcon,
TagIcon,
} from '../components/ui/icons';
const GROUP_ICONS: Record<string, ComponentType<{ className?: string }>> = {
'Properties in the area': HouseIcon,
Transport: RouteIcon,
Education: GraduationCapIcon,
Deprivation: ChartBarIcon,
'Crime summary': ShieldIcon,
Crime: ShieldIcon,
Demographics: UsersIcon,
Amenities: ShoppingBagIcon,
Environment: TreeIcon,
Property: TagIcon,
};
export function getGroupIcon(
group: string,
): ComponentType<{ className?: string }> | null {
return GROUP_ICONS[group] ?? null;
}

View file

@ -18,6 +18,9 @@ export interface FeatureMeta {
suffix?: string; suffix?: string;
raw?: boolean; raw?: boolean;
absolute?: boolean; absolute?: boolean;
// Mode restriction fields
modes?: string[];
linked?: string;
} }
export interface FeatureGroup { export interface FeatureGroup {

View file

@ -16,16 +16,16 @@ from .pois import UK_BBOX_EAST, UK_BBOX_NORTH, UK_BBOX_SOUTH, UK_BBOX_WEST
PLACE_TYPES = { PLACE_TYPES = {
"city", "city",
"borough", # "borough",
"town", # "town",
"suburb", # "suburb",
"quarter", # "quarter",
"neighbourhood", # "neighbourhood",
"village", # "village",
"hamlet", # "hamlet",
"locality", # "locality",
"island", # "island",
"isolated_dwelling", # "isolated_dwelling",
} }
# Suffixes to strip from raw station names before appending the typed suffix. # Suffixes to strip from raw station names before appending the typed suffix.
@ -115,11 +115,15 @@ class PlaceHandler(osmium.SimpleHandler):
self._add(name, place_type, lat, lon, population) self._add(name, place_type, lat, lon, population)
return return
# railway=station nodes (tube, national rail, DLR, tram, etc.) # Tube stations only (London Underground)
if n.tags.get("railway") == "station": if n.tags.get("railway") == "station":
display_name = _station_display_name(name, dict(n.tags)) tags = dict(n.tags)
self._add(display_name, "station", lat, lon, population) station_tag = tags.get("station", "")
return network = tags.get("network", "").lower()
if station_tag == "subway" or "underground" in network:
display_name = _station_display_name(name, tags)
self._add(display_name, "station", lat, lon, population)
return
def main() -> None: def main() -> None:
@ -133,7 +137,7 @@ def main() -> None:
args = parser.parse_args() args = parser.parse_args()
pbf_file = args.pbf pbf_file = args.pbf
print(f"Extracting place nodes: {sorted(PLACE_TYPES)} + railway=station") print("Extracting place nodes: cities + tube stations")
with tqdm( with tqdm(
unit=" elements", unit=" elements",
unit_scale=True, unit_scale=True,

View file

@ -3,23 +3,27 @@
Downloads: Downloads:
- England OSM PBF from Geofabrik (~1.5GB) - England OSM PBF from Geofabrik (~1.5GB)
- BODS GTFS from Bus Open Data Service (~1.5GB, all England bus/tram/ferry) - BODS GTFS from Bus Open Data Service (~1.5GB, all England bus/tram/ferry)
- TfL TransXChange timetables converted to GTFS
- National Rail CIF timetable converted to GTFS (requires credentials)
Then processes for R5 compatibility: Then processes for R5 compatibility:
- Cleans GTFS (fixes stop_times >72h, feed_info year >2100) - Cleans BODS GTFS (fixes stop_times >72h, feed_info year >2100)
- Crops OSM PBF to London bounding box via osmium - Converts TfL TransXChange to GTFS via transxchange2gtfs
- Crops GTFS to London bounding box (keeps only London-touching trips) - Converts National Rail CIF to GTFS via dtd2mysql (requires MariaDB Docker)
Requires: osmium-tool (apt install osmium-tool) Requires: osmium-tool, Node.js (npx), Docker (for national rail)
Output directory: property-data/transit/ Output directory: property-data/transit/
Final files: london.osm.pbf + bods_gtfs.zip (London-only, R5-ready) raw/england.osm.pbf + bods_gtfs.zip + tfl_gtfs.zip + national_rail_gtfs.zip
""" """
import argparse import argparse
import csv import json
import io
import os import os
import subprocess import subprocess
import tempfile
import time
import urllib.parse
import urllib.request import urllib.request
import zipfile import zipfile
from pathlib import Path from pathlib import Path
@ -33,18 +37,30 @@ ENGLAND_PBF_URL = (
# Bus Open Data Service — pre-converted GTFS covering all England bus/tram/ferry # Bus Open Data Service — pre-converted GTFS covering all England bus/tram/ferry
BODS_GTFS_URL = "https://data.bus-data.dft.gov.uk/timetable/download/gtfs-file/all/" BODS_GTFS_URL = "https://data.bus-data.dft.gov.uk/timetable/download/gtfs-file/all/"
# TfL TransXChange timetables (tube, DLR, tram, buses, river bus, cable car)
TFL_TRANSXCHANGE_URL = (
"https://tfl.gov.uk/cdn/static/cms/documents/journey-planner-timetables.zip"
)
# NaPTAN stops data — needed by transxchange2gtfs (its built-in URL is broken)
NAPTAN_URL = "https://naptan.api.dft.gov.uk/v1/access-nodes?dataFormat=csv"
# National Rail Open Data API
NR_AUTH_URL = "https://opendata.nationalrail.co.uk/authenticate"
NR_TIMETABLE_URL = "https://opendata.nationalrail.co.uk/api/staticfeeds/3.0/timetable"
USER_AGENT = "property-map-pipeline/1.0 (https://github.com)" USER_AGENT = "property-map-pipeline/1.0 (https://github.com)"
# London + Home Counties bounding box (~50km buffer around Greater London)
LONDON_BBOX = {"min_lat": 51.2, "max_lat": 51.85, "min_lon": -0.65, "max_lon": 0.35}
def _download_http(url: str, dest: Path, *, desc: str, headers: dict | None = None) -> None:
def _download_http(url: str, dest: Path, *, desc: str) -> None:
"""Stream-download a URL to a file with progress bar.""" """Stream-download a URL to a file with progress bar."""
dest.parent.mkdir(parents=True, exist_ok=True) dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_suffix(dest.suffix + ".tmp") tmp = dest.with_suffix(dest.suffix + ".tmp")
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) req_headers = {"User-Agent": USER_AGENT}
if headers:
req_headers.update(headers)
req = urllib.request.Request(url, headers=req_headers)
with ( with (
tqdm(unit="B", unit_scale=True, desc=desc) as bar, tqdm(unit="B", unit_scale=True, desc=desc) as bar,
@ -112,8 +128,6 @@ def clean_gtfs(src: Path, dst: Path) -> None:
cols.index("departure_time") if "departure_time" in cols else -1 cols.index("departure_time") if "departure_time" in cols else -1
) )
import tempfile
tmp = tempfile.NamedTemporaryFile( tmp = tempfile.NamedTemporaryFile(
mode="wb", delete=False, suffix=".txt" mode="wb", delete=False, suffix=".txt"
) )
@ -170,143 +184,449 @@ def clean_gtfs(src: Path, dst: Path) -> None:
print(f" Saved to {dst}") print(f" Saved to {dst}")
def crop_osm_to_london(src: Path, dst: Path) -> None: def download_tfl_transxchange(raw_dir: Path) -> Path:
"""Extract London bounding box from England OSM PBF using osmium.""" """Download TfL TransXChange timetable bundle."""
if dst.exists(): dest = raw_dir / "tfl_transxchange.zip"
print(f"London OSM PBF already exists: {dst}") if dest.exists():
print(f"TfL TransXChange already exists: {dest}")
return dest
print("Downloading TfL TransXChange timetables...")
_download_http(TFL_TRANSXCHANGE_URL, dest, desc="tfl_transxchange.zip")
return dest
def download_naptan() -> None:
"""Download NaPTAN stops to /tmp/Stops.csv (needed by transxchange2gtfs)."""
dest = Path("/tmp/Stops.csv")
if dest.exists():
print(f"NaPTAN Stops.csv already exists: {dest}")
return return
bbox = LONDON_BBOX print("Downloading NaPTAN stops data...")
bbox_str = f"{bbox['min_lon']},{bbox['min_lat']},{bbox['max_lon']},{bbox['max_lat']}" _download_http(NAPTAN_URL, dest, desc="Stops.csv")
print(f"Cropping OSM PBF to London bbox ({bbox_str})...")
def convert_tfl_to_gtfs(raw_dir: Path, output_dir: Path) -> Path:
"""Convert TfL TransXChange to GTFS using transxchange2gtfs."""
dest = output_dir / "tfl_gtfs.zip"
if dest.exists():
print(f"TfL GTFS already exists: {dest}")
return dest
txc_path = raw_dir / "tfl_transxchange.zip"
# Ensure NaPTAN is available (transxchange2gtfs has a broken download URL)
download_naptan()
print("Converting TfL TransXChange → GTFS...")
subprocess.run( subprocess.run(
["osmium", "extract", f"--bbox={bbox_str}", str(src), "-o", str(dst), "--overwrite"], ["npx", "--yes", "transxchange2gtfs", str(txc_path), str(dest)],
check=True, check=True,
) )
size_mb = dst.stat().st_size / (1024 * 1024) size_mb = dest.stat().st_size / (1024 * 1024)
print(f" Saved to {dst} ({size_mb:.0f} MB)") print(f" Saved to {dest} ({size_mb:.1f} MB)")
return dest
def crop_gtfs_to_london(src: Path, dst: Path) -> None: def download_national_rail_cif(raw_dir: Path) -> Path | None:
"""Crop GTFS to trips touching the London bounding box.""" """Download National Rail CIF timetable (requires credentials)."""
dest = raw_dir / "national_rail_cif.zip"
if dest.exists():
print(f"National Rail CIF already exists: {dest}")
return dest
email = os.environ.get("NATIONAL_RAIL_EMAIL")
password = os.environ.get("NATIONAL_RAIL_PASSWORD")
if not email or not password:
print("Warning: NATIONAL_RAIL_EMAIL/NATIONAL_RAIL_PASSWORD not set, skipping national rail")
return None
print("Authenticating with National Rail Open Data...")
auth_data = urllib.parse.urlencode({"username": email, "password": password}).encode()
auth_req = urllib.request.Request(
NR_AUTH_URL,
data=auth_data,
headers={"User-Agent": USER_AGENT, "Content-Type": "application/x-www-form-urlencoded"},
)
with urllib.request.urlopen(auth_req) as resp:
token_data = json.loads(resp.read())
token = token_data["token"]
print(" Authenticated successfully")
print("Downloading National Rail CIF timetable...")
_download_http(
NR_TIMETABLE_URL,
dest,
desc="national_rail_cif.zip",
headers={"X-Auth-Token": token},
)
return dest
def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
"""Fix R5-incompatible entries in dtd2mysql-generated National Rail GTFS.
Fixes:
- Interior pass-through stops (pickup_type=1, drop_off_type=1) normal stops.
R5 builds TripPatterns from the full stop sequence but may build shorter
TripSchedules when stops are non-boarding, causing ArrayIndexOutOfBoundsException.
- Removes stop_times referencing stops not in stops.txt.
- Removes trips with backwards travel times.
- Converts route_type=714 (rail replacement bus) to 3 (bus) for R5 compatibility.
- Removes non-standard links.txt file.
- Renumbers stop_sequence to 0-based (R5/BODS convention).
- Fixes bogus coordinates (lat < 0) on Irish CIE stations.
"""
if dst.exists(): if dst.exists():
print(f"London GTFS already exists: {dst}") print(f"Cleaned National Rail GTFS already exists: {dst}")
return return
bbox = LONDON_BBOX print("Cleaning National Rail GTFS for R5 compatibility...")
print("Cropping GTFS to London area...") # First pass: collect valid stop IDs and find bad trips
stop_ids: set[str] = set()
bad_trip_ids: set[str] = set()
with zipfile.ZipFile(src, "r") as zin: with zipfile.ZipFile(src, "r") as zin:
# Step 1: Find stops in bbox # Load valid stop IDs
print(" Finding stops in bbox...")
with zin.open("stops.txt") as f: with zin.open("stops.txt") as f:
reader = csv.DictReader(io.TextIOWrapper(f)) header = f.readline().decode("utf-8").strip()
stops_in_bbox = set() stop_id_idx = header.split(",").index("stop_id")
all_stops = list(reader) lat_idx = header.split(",").index("stop_lat")
for row in all_stops: for line in f:
lat = float(row["stop_lat"]) parts = line.decode("utf-8", errors="replace").strip().split(",")
lon = float(row["stop_lon"]) if parts:
if bbox["min_lat"] <= lat <= bbox["max_lat"] and bbox["min_lon"] <= lon <= bbox["max_lon"]: stop_ids.add(parts[stop_id_idx])
stops_in_bbox.add(row["stop_id"])
print(f" {len(stops_in_bbox):,} / {len(all_stops):,} stops in bbox")
# Step 2: Find trips touching these stops # Find trips with backwards travel times
print(" Finding trips touching London stops...")
with zin.open("stop_times.txt") as f: with zin.open("stop_times.txt") as f:
reader = csv.DictReader(io.TextIOWrapper(f)) st_header = f.readline().decode("utf-8").strip()
st_fieldnames = reader.fieldnames st_cols = st_header.split(",")
trips_in_bbox = set() trip_id_idx = st_cols.index("trip_id")
for row in reader: dep_idx = st_cols.index("departure_time")
if row["stop_id"] in stops_in_bbox:
trips_in_bbox.add(row["trip_id"])
print(f" {len(trips_in_bbox):,} trips touch London")
# Step 3: Collect all stop_times for those trips prev_trip = ""
print(" Collecting stop_times for London trips...") prev_dep_secs = -1
stop_times_kept = [] for line in f:
with zin.open("stop_times.txt") as f: parts = line.decode("utf-8", errors="replace").strip().split(",")
reader = csv.DictReader(io.TextIOWrapper(f)) if not parts:
for row in reader: continue
if row["trip_id"] in trips_in_bbox: trip_id = parts[trip_id_idx].strip('"')
stop_times_kept.append(row) if trip_id != prev_trip:
stops_needed = {row["stop_id"] for row in stop_times_kept} prev_trip = trip_id
print(f" {len(stop_times_kept):,} stop_times kept") prev_dep_secs = -1
# Step 4: Read trips and find needed routes/services/shapes dep_str = parts[dep_idx].strip('"')
print(" Reading trips...") if ":" in dep_str:
with zin.open("trips.txt") as f: try:
reader = csv.DictReader(io.TextIOWrapper(f)) h, m, s = dep_str.split(":")
trips_fieldnames = reader.fieldnames dep_secs = int(h) * 3600 + int(m) * 60 + int(s)
all_trips = list(reader) if dep_secs < prev_dep_secs:
trips_kept = [t for t in all_trips if t["trip_id"] in trips_in_bbox] bad_trip_ids.add(trip_id)
routes_needed = {t["route_id"] for t in trips_kept} prev_dep_secs = dep_secs
services_needed = {t["service_id"] for t in trips_kept} except ValueError:
shapes_needed = {t.get("shape_id", "") for t in trips_kept} - {""} pass
# Step 5: Write cropped GTFS print(f" Found {len(bad_trip_ids)} trips with backwards travel times")
print(" Writing cropped GTFS...")
with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
# stops
stops_kept = [s for s in all_stops if s["stop_id"] in stops_needed]
_write_csv(zout, "stops.txt", list(all_stops[0].keys()), stops_kept)
# stop_times # Second pass: write cleaned zip
_write_csv(zout, "stop_times.txt", st_fieldnames, stop_times_kept) passthrough_fixed = 0
orphan_stops_removed = 0
bad_trips_removed = 0
seqs_renumbered = 0
coords_fixed = 0
route_types_fixed = 0
# trips with zipfile.ZipFile(src, "r") as zin, zipfile.ZipFile(
_write_csv(zout, "trips.txt", trips_fieldnames, trips_kept) dst, "w", zipfile.ZIP_DEFLATED
) as zout:
for info in zin.infolist():
# Skip non-standard links.txt
if info.filename == "links.txt":
continue
# routes if info.filename == "stop_times.txt":
with zin.open("routes.txt") as f: with zin.open(info) as f:
reader = csv.DictReader(io.TextIOWrapper(f)) header = f.readline()
routes_fn = reader.fieldnames header_str = header.decode("utf-8").strip()
routes_kept = [r for r in reader if r["route_id"] in routes_needed] cols = header_str.split(",")
_write_csv(zout, "routes.txt", routes_fn, routes_kept) trip_id_idx = cols.index("trip_id")
stop_id_idx = cols.index("stop_id")
seq_idx = cols.index("stop_sequence")
pickup_idx = cols.index("pickup_type") if "pickup_type" in cols else -1
dropoff_idx = cols.index("drop_off_type") if "drop_off_type" in cols else -1
# agency (copy all) tmp = tempfile.NamedTemporaryFile(
zout.writestr("agency.txt", zin.read("agency.txt")) mode="wb", delete=False, suffix=".txt"
)
tmp.write(header)
# calendar prev_trip = ""
with zin.open("calendar.txt") as f: seq_counter = 0
reader = csv.DictReader(io.TextIOWrapper(f)) for line in f:
cal_fn = reader.fieldnames line_str = line.decode("utf-8", errors="replace").strip()
cal_kept = [r for r in reader if r["service_id"] in services_needed] if not line_str:
_write_csv(zout, "calendar.txt", cal_fn, cal_kept) continue
parts = line_str.split(",")
trip_id = parts[trip_id_idx].strip('"')
stop_id = parts[stop_id_idx].strip('"')
# calendar_dates # Skip trips with backwards times
with zin.open("calendar_dates.txt") as f: if trip_id in bad_trip_ids:
reader = csv.DictReader(io.TextIOWrapper(f)) bad_trips_removed += 1
cd_fn = reader.fieldnames continue
cd_kept = [r for r in reader if r["service_id"] in services_needed]
_write_csv(zout, "calendar_dates.txt", cd_fn, cd_kept)
# shapes (stream — can be very large) # Skip stop_times referencing missing stops
print(" Streaming shapes.txt...") if stop_id not in stop_ids:
with zin.open("shapes.txt") as f: orphan_stops_removed += 1
reader = csv.DictReader(io.TextIOWrapper(f)) continue
shapes_fn = reader.fieldnames
shapes_rows = [r for r in reader if r["shape_id"] in shapes_needed]
_write_csv(zout, "shapes.txt", shapes_fn, shapes_rows)
# feed_info + frequencies (copy) # Fix pass-through stops: set pickup/dropoff to 0 (normal)
zout.writestr("feed_info.txt", zin.read("feed_info.txt")) if pickup_idx >= 0 and dropoff_idx >= 0:
zout.writestr("frequencies.txt", zin.read("frequencies.txt")) pickup = parts[pickup_idx].strip('"')
dropoff = parts[dropoff_idx].strip('"')
if pickup == "1" and dropoff == "1":
parts[pickup_idx] = "0"
parts[dropoff_idx] = "0"
passthrough_fixed += 1
size_mb = dst.stat().st_size / (1024 * 1024) # Renumber stop_sequence to 0-based
print(f" Saved to {dst} ({size_mb:.0f} MB)") if trip_id != prev_trip:
prev_trip = trip_id
seq_counter = 0
else:
seq_counter += 1
old_seq = parts[seq_idx].strip('"')
parts[seq_idx] = str(seq_counter)
if old_seq != str(seq_counter):
seqs_renumbered += 1
tmp.write((",".join(parts) + "\n").encode("utf-8"))
tmp.close()
zout.write(tmp.name, "stop_times.txt")
os.unlink(tmp.name)
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(",")
lat_idx = cols.index("stop_lat")
lon_idx = cols.index("stop_lon")
tmp = tempfile.NamedTemporaryFile(
mode="wb", delete=False, suffix=".txt"
)
tmp.write(header)
for line in f:
line_str = line.decode("utf-8", errors="replace").strip()
if not line_str:
continue
parts = line_str.split(",")
try:
lat = float(parts[lat_idx])
# Fix bogus Irish CIE coordinates (South Atlantic)
if lat < 0:
# Set to a neutral UK coordinate that won't be routed to
parts[lat_idx] = "54.0"
parts[lon_idx] = "-2.0"
coords_fixed += 1
except ValueError:
pass
tmp.write((",".join(parts) + "\n").encode("utf-8"))
tmp.close()
zout.write(tmp.name, "stops.txt")
os.unlink(tmp.name)
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(",")
rt_idx = cols.index("route_type")
tmp = tempfile.NamedTemporaryFile(
mode="wb", delete=False, suffix=".txt"
)
tmp.write(header)
for line in f:
line_str = line.decode("utf-8", errors="replace").strip()
if not line_str:
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.close()
zout.write(tmp.name, "routes.txt")
os.unlink(tmp.name)
elif info.filename == "trips.txt":
# 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(",")
trip_id_idx = cols.index("trip_id")
tmp = tempfile.NamedTemporaryFile(
mode="wb", delete=False, suffix=".txt"
)
tmp.write(header)
for line in f:
line_str = line.decode("utf-8", errors="replace").strip()
if not line_str:
continue
parts = line_str.split(",")
if parts[trip_id_idx].strip('"') not in bad_trip_ids:
tmp.write(line)
tmp.close()
zout.write(tmp.name, "trips.txt")
os.unlink(tmp.name)
elif info.filename == "calendar.txt":
# 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(",")
end_idx = cols.index("end_date")
tmp = tempfile.NamedTemporaryFile(
mode="wb", delete=False, suffix=".txt"
)
tmp.write(header)
for line in f:
line_str = line.decode("utf-8", errors="replace").strip()
if not line_str:
continue
parts = line_str.split(",")
date_val = parts[end_idx].strip('"')
if len(date_val) == 8:
try:
year = int(date_val[:4])
if year > 2099:
parts[end_idx] = "20991231"
except ValueError:
pass
tmp.write((",".join(parts) + "\n").encode("utf-8"))
tmp.close()
zout.write(tmp.name, "calendar.txt")
os.unlink(tmp.name)
else:
zout.writestr(info, zin.read(info))
print(f" Pass-through stops fixed: {passthrough_fixed}")
print(f" Orphan stop references removed: {orphan_stops_removed}")
print(f" Bad trip stop_times removed: {bad_trips_removed}")
print(f" Stop sequences renumbered: {seqs_renumbered}")
print(f" Bogus coordinates fixed: {coords_fixed}")
print(f" Route types 714→3 fixed: {route_types_fixed}")
print(f" Saved to {dst}")
def _write_csv( def _docker_run_dtd2mysql(
zout: zipfile.ZipFile, name: str, fieldnames: list[str], rows: list[dict] network: str, db_container: str, volumes: list[str], args: list[str]
) -> None: ) -> None:
buf = io.StringIO() """Run dtd2mysql in a Node.js container on the same Docker network as MariaDB."""
w = csv.DictWriter(buf, fieldnames=fieldnames) cmd = [
w.writeheader() "docker", "run", "--rm", "--network", network,
w.writerows(rows) "-e", f"DATABASE_HOSTNAME={db_container}",
zout.writestr(name, buf.getvalue()) "-e", "DATABASE_USERNAME=root",
print(f" {name}: {len(rows):,} rows") "-e", "DATABASE_PASSWORD=root",
"-e", "DATABASE_NAME=dtd",
]
for v in volumes:
cmd.extend(["-v", v])
# Install zip (needed for --gtfs-zip) then run dtd2mysql
inner = "apt-get update -qq && apt-get install -y -qq zip > /dev/null 2>&1 && npx --yes dtd2mysql " + " ".join(args)
cmd.extend(["node:20", "bash", "-c", inner])
subprocess.run(cmd, check=True)
def convert_national_rail_to_gtfs(raw_dir: Path, output_dir: Path) -> Path:
"""Convert National Rail CIF to GTFS using dtd2mysql + MariaDB Docker.
Runs both MariaDB and dtd2mysql as Docker containers on a shared network,
since Docker port forwarding is not available in all environments.
Then cleans the output for R5 compatibility.
"""
dest = output_dir / "national_rail_gtfs.zip"
if dest.exists():
print(f"National Rail GTFS already exists: {dest}")
return dest
raw_dest = raw_dir / "national_rail_gtfs_raw.zip"
if not raw_dest.exists():
db_container = "propertymap-mariadb-temp"
network = "propertymap-dtd-net"
print("Creating Docker network and starting MariaDB...")
subprocess.run(["docker", "network", "create", network], capture_output=True)
subprocess.run(
[
"docker", "run", "-d",
"--name", db_container,
"--network", network,
"-e", "MARIADB_ROOT_PASSWORD=root",
"-e", "MARIADB_DATABASE=dtd",
"mariadb:latest",
],
check=True,
)
try:
# Wait for MariaDB to be ready
print(" Waiting for MariaDB to be ready...")
for attempt in range(30):
result = subprocess.run(
["docker", "exec", db_container, "mariadb", "-uroot", "-proot", "-e", "SELECT 1"],
capture_output=True,
)
if result.returncode == 0:
break
time.sleep(2)
else:
raise RuntimeError("MariaDB did not become ready in time")
raw_abs = str(raw_dir.resolve())
print("Importing CIF timetable into MariaDB...")
_docker_run_dtd2mysql(
network, db_container,
volumes=[f"{raw_abs}:/data:ro"],
args=["--timetable", "/data/national_rail_cif.zip"],
)
print("Exporting GTFS from MariaDB...")
_docker_run_dtd2mysql(
network, db_container,
volumes=[f"{raw_abs}:/output"],
args=["--gtfs-zip", "/output/national_rail_gtfs_raw.zip"],
)
finally:
print("Cleaning up Docker resources...")
subprocess.run(["docker", "stop", db_container], capture_output=True)
subprocess.run(["docker", "rm", db_container], capture_output=True)
subprocess.run(["docker", "network", "rm", network], capture_output=True)
# Clean the raw GTFS for R5 compatibility
clean_national_rail_gtfs(raw_dest, dest)
return dest
def main() -> None: def main() -> None:
@ -319,26 +639,43 @@ def main() -> None:
required=True, required=True,
help="Output directory for transit data", help="Output directory for transit data",
) )
parser.add_argument(
"--skip-tfl",
action="store_true",
help="Skip TfL TransXChange download and conversion",
)
parser.add_argument(
"--skip-national-rail",
action="store_true",
help="Skip National Rail CIF download and conversion",
)
args = parser.parse_args() args = parser.parse_args()
output_dir: Path = args.output output_dir: Path = args.output
raw_dir = output_dir / "raw" raw_dir = output_dir / "raw"
raw_dir.mkdir(parents=True, exist_ok=True) raw_dir.mkdir(parents=True, exist_ok=True)
# Download raw data # 1. Download and clean BODS GTFS
england_pbf = download_osm_pbf(raw_dir) download_osm_pbf(raw_dir)
bods_raw = download_bods_gtfs(raw_dir) bods_raw = download_bods_gtfs(raw_dir)
# Clean GTFS (fix R5 incompatibilities) bods_clean = output_dir / "bods_gtfs.zip"
bods_clean = raw_dir / "bods_gtfs_clean.zip"
clean_gtfs(bods_raw, bods_clean) clean_gtfs(bods_raw, bods_clean)
# Crop to London area for R5 (full England requires >30GB RAM) # 2. TfL TransXChange → GTFS
london_pbf = output_dir / "london.osm.pbf" if args.skip_tfl:
crop_osm_to_london(england_pbf, london_pbf) print("Skipping TfL (--skip-tfl)")
else:
download_tfl_transxchange(raw_dir)
convert_tfl_to_gtfs(raw_dir, output_dir)
london_gtfs = output_dir / "bods_gtfs.zip" # 3. National Rail CIF → GTFS
crop_gtfs_to_london(bods_clean, london_gtfs) if args.skip_national_rail:
print("Skipping National Rail (--skip-national-rail)")
else:
cif = download_national_rail_cif(raw_dir)
if cif is not None:
convert_national_rail_to_gtfs(raw_dir, output_dir)
# Summary # Summary
print() print()
@ -349,6 +686,11 @@ def main() -> None:
size_mb = f.stat().st_size / (1024 * 1024) size_mb = f.stat().st_size / (1024 * 1024)
print(f" {f.name}: {size_mb:.1f} MB") print(f" {f.name}: {size_mb:.1f} MB")
print()
print("IMPORTANT: If you previously built a network from London-only data,")
print("delete the stale cache before running R5:")
print(" rm -f property-data/r5-network/network.dat")
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View file

@ -8,37 +8,10 @@ from pipeline.utils.postcode_mapping import build_postcode_mapping
MIN_FLOOR_AREA_M2 = 10 MIN_FLOOR_AREA_M2 = 10
def _join_journey_times(
wide: pl.LazyFrame,
journey_times_path: Path,
destination_name: str,
) -> pl.LazyFrame:
"""Join journey times for a single destination, renaming columns appropriately."""
journey_times = (
pl.scan_parquet(journey_times_path)
.select(
"postcode",
pl.col("public_transport_quick_minutes").alias(
f"Public transport to {destination_name} (mins)"
),
pl.col("cycling_minutes").alias(f"Cycling to {destination_name} (mins)"),
)
.sort(f"Public transport to {destination_name} (mins)", nulls_last=True)
.group_by("postcode")
.first()
)
return wide.join(journey_times, on="postcode", how="left")
_AREA_COLUMNS = [ _AREA_COLUMNS = [
"Postcode", "Postcode",
"lat", "lat",
"lon", "lon",
# Transport
"Public transport to Bank (mins)",
"Cycling to Bank (mins)",
"Public transport to Fitzrovia (mins)",
"Cycling to Fitzrovia (mins)",
# Deprivation # Deprivation
"Income Score (rate)", "Income Score (rate)",
"Employment Score (rate)", "Employment Score (rate)",
@ -97,8 +70,6 @@ def _build(
arcgis_path: Path, arcgis_path: Path,
iod_path: Path, iod_path: Path,
poi_proximity_path: Path, poi_proximity_path: Path,
journey_times_bank_path: Path,
journey_times_fitzrovia_path: Path,
ethnicity_path: Path, ethnicity_path: Path,
crime_path: Path, crime_path: Path,
noise_path: Path, noise_path: Path,
@ -138,9 +109,6 @@ def _build(
) )
wide = wide.join(arcgis, on="postcode", how="left") wide = wide.join(arcgis, on="postcode", how="left")
wide = _join_journey_times(wide, journey_times_bank_path, "Bank")
wide = _join_journey_times(wide, journey_times_fitzrovia_path, "Fitzrovia")
iod = pl.scan_parquet(iod_path) iod = pl.scan_parquet(iod_path)
wide = wide.join(iod, left_on="lsoa21", right_on="LSOA code (2021)", how="left") wide = wide.join(iod, left_on="lsoa21", right_on="LSOA code (2021)", how="left")
@ -382,18 +350,6 @@ def main():
type=Path, type=Path,
help="POI proximity counts parquet file (optional)", help="POI proximity counts parquet file (optional)",
) )
parser.add_argument(
"--journey-times-bank",
type=Path,
default=None,
help="Journey times to Bank parquet file",
)
parser.add_argument(
"--journey-times-fitzrovia",
type=Path,
default=None,
help="Journey times to Fitzrovia parquet file",
)
parser.add_argument( parser.add_argument(
"--ethnicity", "--ethnicity",
type=Path, type=Path,
@ -446,8 +402,6 @@ def main():
arcgis_path=args.arcgis, arcgis_path=args.arcgis,
iod_path=args.iod, iod_path=args.iod,
poi_proximity_path=args.poi_proximity, poi_proximity_path=args.poi_proximity,
journey_times_bank_path=args.journey_times_bank,
journey_times_fitzrovia_path=args.journey_times_fitzrovia,
ethnicity_path=args.ethnicity, ethnicity_path=args.ethnicity,
crime_path=args.crime, crime_path=args.crime,
noise_path=args.noise, noise_path=args.noise,

View file

@ -66,24 +66,41 @@ impl LruCache {
} }
} }
/// Strip a numeric prefix like "000000-" from a filename stem.
/// "000000-bank-tube-station" → "bank-tube-station"
fn strip_numeric_prefix(stem: &str) -> &str {
if let Some(pos) = stem.find('-') {
if stem[..pos].chars().all(|ch| ch.is_ascii_digit()) {
return &stem[pos + 1..];
}
}
stem
}
/// Manages on-demand loading and caching of precomputed travel time parquet files. /// Manages on-demand loading and caching of precomputed travel time parquet files.
/// ///
/// Directory structure: `{base_dir}/{mode}/{slug}.parquet` /// Directory structure: `{base_dir}/{mode}/{NNNNNN-slug}.parquet`
/// Files have a numeric prefix for uniqueness; lookups use the stripped slug.
/// Each parquet file has columns: `pcds` (String), `travel_minutes` (Int16). /// Each parquet file has columns: `pcds` (String), `travel_minutes` (Int16).
pub struct TravelTimeStore { pub struct TravelTimeStore {
base_dir: PathBuf, base_dir: PathBuf,
/// Available transport modes (subdirectory names, e.g., "bicycle") /// Available transport modes (subdirectory names, e.g., "bicycle")
pub available_modes: Vec<String>, pub available_modes: Vec<String>,
/// mode → set of destination slugs (filenames without .parquet) /// mode → set of destination slugs (numeric prefix stripped)
pub destinations: FxHashMap<String, FxHashSet<String>>, pub destinations: FxHashMap<String, FxHashSet<String>>,
/// (mode, stripped_slug) → full filename stem (with numeric prefix)
slug_to_file: FxHashMap<(String, String), String>,
cache: Mutex<LruCache>, cache: Mutex<LruCache>,
} }
impl TravelTimeStore { impl TravelTimeStore {
/// Scan the travel-times directory to discover available modes and destinations. /// Scan the travel-times directory to discover available modes and destinations.
/// Filename stems have a numeric prefix (e.g., "000000-bank-tube-station") which
/// is stripped for slug lookups but preserved for file loading.
pub fn load(base_dir: &Path, cache_capacity: usize) -> anyhow::Result<Self> { pub fn load(base_dir: &Path, cache_capacity: usize) -> anyhow::Result<Self> {
let mut available_modes = Vec::new(); let mut available_modes = Vec::new();
let mut destinations: FxHashMap<String, FxHashSet<String>> = FxHashMap::default(); let mut destinations: FxHashMap<String, FxHashSet<String>> = FxHashMap::default();
let mut slug_to_file: FxHashMap<(String, String), String> = FxHashMap::default();
for entry in std::fs::read_dir(base_dir) for entry in std::fs::read_dir(base_dir)
.with_context(|| format!("Failed to read travel-times dir: {}", base_dir.display()))? .with_context(|| format!("Failed to read travel-times dir: {}", base_dir.display()))?
@ -103,7 +120,12 @@ impl TravelTimeStore {
let file_name = file_entry.file_name(); let file_name = file_entry.file_name();
let file_name = file_name.to_string_lossy(); let file_name = file_name.to_string_lossy();
if file_name.ends_with(".parquet") { if file_name.ends_with(".parquet") {
let slug = file_name.trim_end_matches(".parquet").to_string(); let file_stem = file_name.trim_end_matches(".parquet");
let slug = strip_numeric_prefix(file_stem).to_string();
slug_to_file.insert(
(mode.clone(), slug.clone()),
file_stem.to_string(),
);
slugs.insert(slug); slugs.insert(slug);
} }
} }
@ -125,6 +147,7 @@ impl TravelTimeStore {
base_dir: base_dir.to_path_buf(), base_dir: base_dir.to_path_buf(),
available_modes, available_modes,
destinations, destinations,
slug_to_file,
cache: Mutex::new(LruCache::new(cache_capacity)), cache: Mutex::new(LruCache::new(cache_capacity)),
}) })
} }
@ -142,11 +165,16 @@ impl TravelTimeStore {
} }
} }
// Load from file (no lock held — harmless if two threads load the same file) // Resolve slug to actual filename (may have numeric prefix)
let file_stem = self
.slug_to_file
.get(&key)
.map(|val| val.as_str())
.unwrap_or(slug);
let path = self let path = self
.base_dir .base_dir
.join(mode) .join(mode)
.join(format!("{}.parquet", slug)); .join(format!("{}.parquet", file_stem));
if !path.exists() { if !path.exists() {
bail!("Travel time file not found: {}", path.display()); bail!("Travel time file not found: {}", path.display());
} }
@ -233,18 +261,15 @@ mod tests {
#[test] #[test]
fn slugify_basic() { fn slugify_basic() {
assert_eq!(slugify("Abbey Hey"), "abbey-hey"); assert_eq!(slugify("Abbey Hey"), "abbey-hey");
assert_eq!(slugify("Abbots Bickington"), "abbots-bickington");
assert_eq!(slugify("London"), "london"); assert_eq!(slugify("London"), "london");
} }
#[test] #[test]
fn slugify_special_chars() { fn strip_numeric_prefix_basic() {
assert_eq!(slugify("A'Bhuaile Ghlas"), "a-bhuaile-ghlas"); assert_eq!(strip_numeric_prefix("000000-bank-tube-station"), "bank-tube-station");
} assert_eq!(strip_numeric_prefix("000123-abbey-hey"), "abbey-hey");
assert_eq!(strip_numeric_prefix("bank-tube-station"), "bank-tube-station");
#[test] assert_eq!(strip_numeric_prefix("london"), "london");
fn slugify_edges() {
assert_eq!(slugify(" Hello "), "hello");
assert_eq!(slugify("Abbey"), "abbey");
} }
} }

View file

@ -28,6 +28,10 @@ pub struct FeatureConfig {
pub raw: bool, pub raw: bool,
/// If true, the slider uses absolute min/max/step instead of percentile scaling /// If true, the slider uses absolute min/max/step instead of percentile scaling
pub absolute: bool, pub absolute: bool,
/// Listing modes this feature is available in (empty = all modes)
pub modes: &'static [&'static str],
/// Name of the linked feature that swaps when switching modes (empty = no link)
pub linked: &'static str,
} }
/// Features whose histogram bins should be exactly 1 unit wide (one per integer). /// Features whose histogram bins should be exactly 1 unit wide (one per integer).
@ -61,7 +65,7 @@ pub struct EnumFeatureGroup {
pub static FEATURE_GROUPS: &[FeatureGroup] = &[ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
FeatureGroup { FeatureGroup {
name: "Property", name: "Properties in the area",
features: &[ features: &[
FeatureConfig { FeatureConfig {
name: "Last known price", name: "Last known price",
@ -77,6 +81,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "", suffix: "",
raw: false, raw: false,
absolute: true, absolute: true,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Estimated current price", name: "Estimated current price",
@ -92,6 +98,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "", suffix: "",
raw: false, raw: false,
absolute: true, absolute: true,
modes: &["historical"],
linked: "Asking price",
}, },
FeatureConfig { FeatureConfig {
name: "Price per sqm", name: "Price per sqm",
@ -107,6 +115,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "", suffix: "",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Est. price per sqm", name: "Est. price per sqm",
@ -122,6 +132,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "", suffix: "",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Total floor area (sqm)", name: "Total floor area (sqm)",
@ -137,6 +149,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: " sqm", suffix: " sqm",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Interior height (m)", name: "Interior height (m)",
@ -152,6 +166,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: " m", suffix: " m",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Number of bedrooms & living rooms", name: "Number of bedrooms & living rooms",
@ -167,6 +183,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: " rooms", suffix: " rooms",
raw: false, raw: false,
absolute: true, absolute: true,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Estimated monthly rent", name: "Estimated monthly rent",
@ -179,6 +197,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/mo", suffix: "/mo",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &["historical"],
linked: "Asking rent (monthly)",
}, },
FeatureConfig { FeatureConfig {
name: "Date of last transaction", name: "Date of last transaction",
@ -194,6 +214,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "", suffix: "",
raw: true, raw: true,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Construction age", name: "Construction age",
@ -209,6 +231,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "", suffix: "",
raw: true, raw: true,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Asking price", name: "Asking price",
@ -224,6 +248,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "", suffix: "",
raw: false, raw: false,
absolute: true, absolute: true,
modes: &["buy"],
linked: "Estimated current price",
}, },
FeatureConfig { FeatureConfig {
name: "Asking rent (monthly)", name: "Asking rent (monthly)",
@ -239,6 +265,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/mo", suffix: "/mo",
raw: false, raw: false,
absolute: true, absolute: true,
modes: &["rent"],
linked: "Estimated monthly rent",
}, },
FeatureConfig { FeatureConfig {
name: "Bedrooms", name: "Bedrooms",
@ -254,6 +282,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "", suffix: "",
raw: false, raw: false,
absolute: true, absolute: true,
modes: &["buy", "rent"],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Bathrooms", name: "Bathrooms",
@ -269,6 +299,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "", suffix: "",
raw: false, raw: false,
absolute: true, absolute: true,
modes: &["buy", "rent"],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Listing date", name: "Listing date",
@ -284,6 +316,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "", suffix: "",
raw: true, raw: true,
absolute: false, absolute: false,
modes: &["buy", "rent"],
linked: "",
}, },
], ],
}, },
@ -304,6 +338,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: " mins", suffix: " mins",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Public transport to Fitzrovia (mins)", name: "Public transport to Fitzrovia (mins)",
@ -319,6 +355,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: " mins", suffix: " mins",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Cycling to Bank (mins)", name: "Cycling to Bank (mins)",
@ -334,6 +372,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: " mins", suffix: " mins",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Cycling to Fitzrovia (mins)", name: "Cycling to Fitzrovia (mins)",
@ -349,6 +389,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: " mins", suffix: " mins",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Number of public transport stations within 2km", name: "Number of public transport stations within 2km",
@ -364,6 +406,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "", suffix: "",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
], ],
}, },
@ -384,6 +428,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "", suffix: "",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Good+ primary schools within 5km", name: "Good+ primary schools within 5km",
@ -399,6 +445,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "", suffix: "",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Good+ secondary schools within 5km", name: "Good+ secondary schools within 5km",
@ -414,6 +462,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "", suffix: "",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
], ],
}, },
@ -431,6 +481,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "", suffix: "",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Employment Score (rate)", name: "Employment Score (rate)",
@ -443,6 +495,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "", suffix: "",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Health Deprivation and Disability Score", name: "Health Deprivation and Disability Score",
@ -458,6 +512,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "", suffix: "",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Living Environment Score", name: "Living Environment Score",
@ -473,6 +529,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "", suffix: "",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Indoors Sub-domain Score", name: "Indoors Sub-domain Score",
@ -488,6 +546,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "", suffix: "",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Outdoors Sub-domain Score", name: "Outdoors Sub-domain Score",
@ -503,6 +563,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "", suffix: "",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
], ],
}, },
@ -524,6 +586,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/yr", suffix: "/yr",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Minor crime (avg/yr)", name: "Minor crime (avg/yr)",
@ -539,6 +603,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/yr", suffix: "/yr",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
], ],
}, },
@ -559,6 +625,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/yr", suffix: "/yr",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Violence and sexual offences (avg/yr)", name: "Violence and sexual offences (avg/yr)",
@ -574,6 +642,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/yr", suffix: "/yr",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Criminal damage and arson (avg/yr)", name: "Criminal damage and arson (avg/yr)",
@ -589,6 +659,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/yr", suffix: "/yr",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Burglary (avg/yr)", name: "Burglary (avg/yr)",
@ -604,6 +676,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/yr", suffix: "/yr",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Vehicle crime (avg/yr)", name: "Vehicle crime (avg/yr)",
@ -619,6 +693,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/yr", suffix: "/yr",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Robbery (avg/yr)", name: "Robbery (avg/yr)",
@ -634,6 +710,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/yr", suffix: "/yr",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Other theft (avg/yr)", name: "Other theft (avg/yr)",
@ -649,6 +727,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/yr", suffix: "/yr",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Shoplifting (avg/yr)", name: "Shoplifting (avg/yr)",
@ -664,6 +744,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/yr", suffix: "/yr",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Drugs (avg/yr)", name: "Drugs (avg/yr)",
@ -679,6 +761,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/yr", suffix: "/yr",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Possession of weapons (avg/yr)", name: "Possession of weapons (avg/yr)",
@ -694,6 +778,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/yr", suffix: "/yr",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Public order (avg/yr)", name: "Public order (avg/yr)",
@ -709,6 +795,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/yr", suffix: "/yr",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Bicycle theft (avg/yr)", name: "Bicycle theft (avg/yr)",
@ -724,6 +812,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/yr", suffix: "/yr",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Theft from the person (avg/yr)", name: "Theft from the person (avg/yr)",
@ -739,6 +829,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/yr", suffix: "/yr",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Other crime (avg/yr)", name: "Other crime (avg/yr)",
@ -754,6 +846,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "/yr", suffix: "/yr",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
], ],
}, },
@ -774,6 +868,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "%", suffix: "%",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "% Asian", name: "% Asian",
@ -789,6 +885,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "%", suffix: "%",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "% Black", name: "% Black",
@ -804,6 +902,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "%", suffix: "%",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "% Mixed", name: "% Mixed",
@ -819,6 +919,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "%", suffix: "%",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "% Other", name: "% Other",
@ -834,6 +936,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "%", suffix: "%",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
], ],
}, },
@ -854,6 +958,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "", suffix: "",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Number of grocery shops and supermarkets within 2km", name: "Number of grocery shops and supermarkets within 2km",
@ -869,6 +975,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "", suffix: "",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Number of parks within 2km", name: "Number of parks within 2km",
@ -884,6 +992,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "", suffix: "",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
], ],
}, },
@ -904,6 +1014,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: " dB", suffix: " dB",
raw: false, raw: false,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
FeatureConfig { FeatureConfig {
name: "Max available download speed (Mbps)", name: "Max available download speed (Mbps)",
@ -919,6 +1031,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: " Mbps", suffix: " Mbps",
raw: true, raw: true,
absolute: false, absolute: false,
modes: &[],
linked: "",
}, },
], ],
}, },

View file

@ -16,6 +16,10 @@ fn is_false(val: &bool) -> bool {
!val !val
} }
fn is_empty_slice(val: &&[&str]) -> bool {
val.is_empty()
}
#[derive(Clone, Serialize)] #[derive(Clone, Serialize)]
#[serde(tag = "type")] #[serde(tag = "type")]
pub enum FeatureInfo { pub enum FeatureInfo {
@ -37,6 +41,10 @@ pub enum FeatureInfo {
raw: bool, raw: bool,
#[serde(skip_serializing_if = "is_false")] #[serde(skip_serializing_if = "is_false")]
absolute: bool, absolute: bool,
#[serde(skip_serializing_if = "is_empty_slice")]
modes: &'static [&'static str],
#[serde(skip_serializing_if = "is_empty")]
linked: &'static str,
}, },
#[serde(rename = "enum")] #[serde(rename = "enum")]
Enum { Enum {
@ -102,6 +110,8 @@ pub fn build_features_response(data: &PropertyData) -> FeaturesResponse {
suffix: feature_config.suffix, suffix: feature_config.suffix,
raw: feature_config.raw, raw: feature_config.raw,
absolute: feature_config.absolute, absolute: feature_config.absolute,
modes: feature_config.modes,
linked: feature_config.linked,
}); });
} }
} }

View file

@ -138,16 +138,13 @@ pub async fn post_invites(
} }
} }
/// Validate an invite code. Requires authentication to prevent enumeration. /// Validate an invite code. Public endpoint — codes are 12-char random alphanumeric
/// so enumeration is impractical, and the response only reveals valid/invalid + type.
pub async fn get_invite( pub async fn get_invite(
state: Arc<AppState>, state: Arc<AppState>,
Extension(user): Extension<OptionalUser>, Extension(_user): Extension<OptionalUser>,
Path(code): Path<String>, Path(code): Path<String>,
) -> Response { ) -> Response {
if user.0.is_none() {
return StatusCode::UNAUTHORIZED.into_response();
}
if let Err(msg) = validate_invite_code(&code) { if let Err(msg) = validate_invite_code(&code) {
return (StatusCode::BAD_REQUEST, msg).into_response(); return (StatusCode::BAD_REQUEST, msg).into_response();
} }