Good stuff
This commit is contained in:
parent
9da2db707f
commit
8032011708
32 changed files with 1052 additions and 374 deletions
|
|
@ -28,8 +28,6 @@ MERGE_STAMP := $(DATA_DIR)/.merge_done
|
|||
PRICE_INDEX := $(DATA_DIR)/price_index.parquet
|
||||
PRICES_STAMP := $(DATA_DIR)/.prices_done
|
||||
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
|
||||
CRIME_DIR := $(MANUAL_DATA)/crime
|
||||
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 \
|
||||
transform-pois transform-epc-pp transform-crime transform-poi-proximity \
|
||||
transform-school-proximity transform-geosure transform-postcode-boundaries \
|
||||
generate-postcode-boundaries \
|
||||
journey-times
|
||||
generate-postcode-boundaries
|
||||
|
||||
prepare: $(PRICES_STAMP)
|
||||
merge: $(MERGE_STAMP)
|
||||
|
|
@ -185,32 +182,6 @@ $(GREENSPACE): $(PBF)
|
|||
$(PLACES): $(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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
$(POIS_FILTERED): $(POIS_RAW) $(NAPTAN)
|
||||
|
|
@ -256,15 +227,13 @@ $(PC_BOUNDARIES):
|
|||
|
||||
# ── 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)
|
||||
uv run python -m pipeline.transform.merge \
|
||||
--epc-pp $(EPC_PP) \
|
||||
--arcgis $(ARCGIS) \
|
||||
--iod $(IOD) \
|
||||
--poi-proximity $(POI_PROXIMITY) \
|
||||
--journey-times-bank $(JT_BANK) \
|
||||
--journey-times-fitzrovia $(JT_FITZROVIA) \
|
||||
--ethnicity $(ETHNICITY) \
|
||||
--crime $(CRIME) \
|
||||
--noise $(NOISE) \
|
||||
|
|
|
|||
|
|
@ -77,7 +77,10 @@ export default function App() {
|
|||
const [poiCategoryGroups, setPOICategoryGroups] = useState<POICategoryGroup[]>([]);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
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>(() => {
|
||||
if (isScreenshotMode) return 'dashboard';
|
||||
|
||||
|
|
@ -91,13 +94,6 @@ export default function App() {
|
|||
return 'home';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fromPath = pathToPage(window.location.pathname);
|
||||
if (fromPath?.inviteCode) {
|
||||
setInviteCode(fromPath.inviteCode);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const isMobile = useIsMobile();
|
||||
const {
|
||||
|
|
@ -366,6 +362,7 @@ export default function App() {
|
|||
<SaveSearchModal
|
||||
onClose={() => setShowSaveModal(false)}
|
||||
onSave={savedSearches.saveSearch}
|
||||
onViewSearches={() => { setShowSaveModal(false); navigateTo('account'); }}
|
||||
saving={savedSearches.saving}
|
||||
error={savedSearches.error}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -368,7 +368,7 @@ function SettingsContent({
|
|||
{isLicensed && (
|
||||
<div className="px-5 py-4">
|
||||
<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>
|
||||
{inviteUrl ? (
|
||||
<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"
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
{inviteError && (
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ export default function AreaPane({
|
|||
<LoadingSkeleton />
|
||||
) : stats ? (
|
||||
<div>
|
||||
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
|
||||
<HistogramLegend />
|
||||
{featureGroups.map((group) => {
|
||||
const hasData = group.features.some(
|
||||
|
|
@ -375,7 +376,6 @@ export default function AreaPane({
|
|||
</div>
|
||||
);
|
||||
})}
|
||||
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@ import { groupFeaturesByCategory } from '../../lib/features';
|
|||
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
||||
import { FeatureActions } from '../ui/FeatureIcons';
|
||||
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 { 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 }>> = {
|
||||
car: CarIcon,
|
||||
|
|
@ -96,9 +96,6 @@ export default function FeatureBrowser({
|
|||
</span>
|
||||
</CollapsibleGroupHeader>
|
||||
{(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];
|
||||
return (
|
||||
<div
|
||||
|
|
@ -117,16 +114,6 @@ export default function FeatureBrowser({
|
|||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<PlusIcon className="w-7 h-7 md:w-3.5 md:h-3.5" />
|
||||
</IconButton>
|
||||
|
|
|
|||
|
|
@ -25,15 +25,6 @@ import {
|
|||
|
||||
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({
|
||||
min,
|
||||
max,
|
||||
|
|
@ -89,7 +80,6 @@ interface FiltersProps {
|
|||
openInfoFeature?: string | null;
|
||||
onClearOpenInfoFeature?: () => void;
|
||||
travelTimeEntries: TravelTimeEntry[];
|
||||
travelTimeDataRanges: Map<number, [number, number]>;
|
||||
onTravelTimeAddEntry: (mode: TransportMode) => void;
|
||||
onTravelTimeRemoveEntry: (index: number) => void;
|
||||
onTravelTimeSetDestination: (index: number, slug: string, label: string) => void;
|
||||
|
|
@ -122,7 +112,6 @@ export default memo(function Filters({
|
|||
openInfoFeature,
|
||||
onClearOpenInfoFeature,
|
||||
travelTimeEntries,
|
||||
travelTimeDataRanges,
|
||||
onTravelTimeAddEntry,
|
||||
onTravelTimeRemoveEntry,
|
||||
onTravelTimeSetDestination,
|
||||
|
|
@ -136,6 +125,36 @@ export default memo(function Filters({
|
|||
onUpgradeClick,
|
||||
onResetTutorial,
|
||||
}: 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 val = filters['Listing status'] as string[] | undefined;
|
||||
if (!val || val.length === 0) return 'historical';
|
||||
|
|
@ -145,8 +164,8 @@ export default memo(function Filters({
|
|||
}, [filters]);
|
||||
|
||||
const availableFeatures = useMemo(
|
||||
() => features.filter((f) => !enabledFeatures.has(f.name) && isFeatureAllowedInMode(f.name, activeListingType)),
|
||||
[features, enabledFeatures, activeListingType]
|
||||
() => features.filter((f) => !enabledFeatures.has(f.name) && isAllowed(f.name, activeListingType)),
|
||||
[features, enabledFeatures, activeListingType, isAllowed]
|
||||
);
|
||||
const enabledFeatureList = useMemo(
|
||||
() => features.filter((f) => enabledFeatures.has(f.name) && f.name !== 'Listing status'),
|
||||
|
|
@ -156,7 +175,22 @@ export default memo(function Filters({
|
|||
const handleListingSelect = useCallback(
|
||||
(type: ListingType) => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -167,7 +201,7 @@ export default memo(function Filters({
|
|||
};
|
||||
onFilterChange('Listing status', [valueMap[type]]);
|
||||
},
|
||||
[filters, onFilterChange, onRemoveFilter]
|
||||
[filters, onFilterChange, onRemoveFilter, isAllowed, linkedFeatures]
|
||||
);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -275,7 +309,6 @@ export default memo(function Filters({
|
|||
label={entry.label}
|
||||
timeRange={entry.timeRange}
|
||||
useBest={entry.useBest}
|
||||
dataRange={travelTimeDataRanges.get(index) ?? null}
|
||||
isPinned={pinnedFeature === travelFieldKey(entry)}
|
||||
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
|
||||
onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)}
|
||||
|
|
|
|||
|
|
@ -170,6 +170,7 @@ export default memo(function Map({
|
|||
data,
|
||||
postcodeData,
|
||||
usePostcodeView,
|
||||
zoom: viewState.zoom,
|
||||
pois,
|
||||
viewFeature,
|
||||
colorRange,
|
||||
|
|
|
|||
|
|
@ -188,24 +188,6 @@ export default function MapPage({
|
|||
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -401,7 +383,6 @@ export default function MapPage({
|
|||
openInfoFeature={pendingInfoFeature}
|
||||
onClearOpenInfoFeature={onClearPendingInfoFeature}
|
||||
travelTimeEntries={travelTime.entries}
|
||||
travelTimeDataRanges={travelTimeDataRanges}
|
||||
onTravelTimeAddEntry={travelTime.handleAddEntry}
|
||||
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
|
||||
onTravelTimeSetDestination={handleTravelTimeSetDestination}
|
||||
|
|
@ -625,9 +606,10 @@ export default function MapPage({
|
|||
<button
|
||||
data-tutorial="poi-button"
|
||||
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" />
|
||||
<span className="text-sm font-medium">Points of interest</span>
|
||||
</button>
|
||||
{/* Floating POI panel */}
|
||||
{poiPaneOpen && (
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ interface TravelTimeCardProps {
|
|||
label: string;
|
||||
timeRange: [number, number] | null;
|
||||
useBest: boolean;
|
||||
dataRange: [number, number] | null;
|
||||
isPinned: boolean;
|
||||
onTogglePin: () => void;
|
||||
onSetDestination: (slug: string, label: string) => void;
|
||||
|
|
@ -42,7 +41,6 @@ export function TravelTimeCard({
|
|||
label,
|
||||
timeRange,
|
||||
useBest,
|
||||
dataRange,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
onSetDestination,
|
||||
|
|
@ -74,8 +72,8 @@ export function TravelTimeCard({
|
|||
[onSetDestination, search.clear],
|
||||
);
|
||||
|
||||
const sliderMin = dataRange ? Math.floor(dataRange[0]) : 0;
|
||||
const sliderMax = dataRange ? Math.ceil(dataRange[1]) : 120;
|
||||
const sliderMin = 0;
|
||||
const sliderMax = 120;
|
||||
const displayRange = timeRange ?? [sliderMin, sliderMax];
|
||||
|
||||
const ModeIcon = MODE_ICONS[mode];
|
||||
|
|
@ -142,7 +140,7 @@ export function TravelTimeCard({
|
|||
)}
|
||||
|
||||
{/* Time range slider — only show when we have data */}
|
||||
{slug && dataRange && (
|
||||
{slug && (
|
||||
<div>
|
||||
<span className="text-[10px] font-medium text-warm-500 dark:text-warm-400 uppercase tracking-wide">
|
||||
Max time
|
||||
|
|
|
|||
|
|
@ -58,16 +58,6 @@ export default function PricingPage({
|
|||
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(() => {
|
||||
fetch(apiUrl('pricing'))
|
||||
.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 ? (
|
||||
<button
|
||||
onClick={onOpenDashboard}
|
||||
|
|
@ -183,13 +183,23 @@ export default function PricingPage({
|
|||
/>
|
||||
</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">
|
||||
Early access pricing
|
||||
</h1>
|
||||
<p className="text-lg text-warm-300 max-w-lg mx-auto">
|
||||
No subscriptions, no recurring fees. Pay once and get lifetime
|
||||
access to every feature. The earlier you join, the less you pay.
|
||||
Pay once, access forever. 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 £10k+ in stamp duty, £1,500 in solicitor fees,
|
||||
£500 for a survey. Get the wrong area and you're stuck with a long
|
||||
commute, bad schools, or a road you didn't know about.
|
||||
</p>
|
||||
<p className="text-warm-200 font-semibold">
|
||||
Less than your survey costs. Vastly more useful.
|
||||
</p>
|
||||
</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' }}>
|
||||
{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 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) => {
|
||||
const isCurrent = i === currentTierIndex;
|
||||
const isFilled =
|
||||
|
|
@ -348,17 +358,6 @@ export default function PricingPage({
|
|||
)}
|
||||
</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 £400k house: £10,000. Solicitor fees: £1,500.
|
||||
Survey: £500. Moving costs: £1,000. And that's just the money. Get the
|
||||
wrong area and you're stuck — 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { FeatureMeta } from '../../types';
|
||||
import { InfoIcon } from './icons';
|
||||
import { getGroupIcon } from '../../lib/group-icons';
|
||||
|
||||
interface FeatureLabelProps {
|
||||
feature: FeatureMeta;
|
||||
|
|
@ -15,11 +16,15 @@ export function FeatureLabel({
|
|||
size = 'xs',
|
||||
}: FeatureLabelProps) {
|
||||
const textClass = size === 'sm' ? 'text-sm' : 'text-xs';
|
||||
const GroupIcon = feature.group ? getGroupIcon(feature.group) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
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
|
||||
className={`${textClass} text-warm-700 dark:text-warm-300 ${size === 'xs' ? 'truncate' : ''}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,23 @@
|
|||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { CheckIcon } from './icons/CheckIcon';
|
||||
import { CloseIcon } from './icons/CloseIcon';
|
||||
import { SpinnerIcon } from './icons/SpinnerIcon';
|
||||
|
||||
export default function SaveSearchModal({
|
||||
onClose,
|
||||
onSave,
|
||||
onViewSearches,
|
||||
saving,
|
||||
error,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onSave: (name: string) => Promise<void>;
|
||||
onViewSearches: () => void;
|
||||
saving: boolean;
|
||||
error: string | null;
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
|
|
@ -21,12 +25,12 @@ export default function SaveSearchModal({
|
|||
if (!name.trim() || saving) return;
|
||||
try {
|
||||
await onSave(name.trim());
|
||||
onClose();
|
||||
setSaved(true);
|
||||
} catch {
|
||||
// Error displayed in modal
|
||||
}
|
||||
},
|
||||
[name, saving, onSave, onClose]
|
||||
[name, saving, onSave]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -45,7 +49,9 @@ export default function SaveSearchModal({
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<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
|
||||
onClick={onClose}
|
||||
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>
|
||||
</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
|
||||
/>
|
||||
{saved ? (
|
||||
<div className="p-5 pt-2 space-y-4">
|
||||
<div className="flex items-center gap-2 text-teal-600 dark:text-teal-400">
|
||||
<CheckIcon className="w-5 h-5" />
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300">
|
||||
Your search has been saved successfully.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
) : (
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
{saving && <SpinnerIcon className="w-4 h-4 animate-spin" />}
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
{saving && <SpinnerIcon className="w-4 h-4 animate-spin" />}
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -38,11 +38,29 @@ export default function UserMenu({
|
|||
{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="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">
|
||||
{user.email}
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium text-navy-950 dark:text-warm-100 truncate">
|
||||
{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 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
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
|
|
|
|||
21
frontend/src/components/ui/icons/ChartBarIcon.tsx
Normal file
21
frontend/src/components/ui/icons/ChartBarIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/ui/icons/GraduationCapIcon.tsx
Normal file
20
frontend/src/components/ui/icons/GraduationCapIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/ui/icons/HouseIcon.tsx
Normal file
20
frontend/src/components/ui/icons/HouseIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
frontend/src/components/ui/icons/ShieldIcon.tsx
Normal file
19
frontend/src/components/ui/icons/ShieldIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/ui/icons/ShoppingBagIcon.tsx
Normal file
21
frontend/src/components/ui/icons/ShoppingBagIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/ui/icons/TagIcon.tsx
Normal file
20
frontend/src/components/ui/icons/TagIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/ui/icons/TreeIcon.tsx
Normal file
20
frontend/src/components/ui/icons/TreeIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/ui/icons/UsersIcon.tsx
Normal file
22
frontend/src/components/ui/icons/UsersIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,3 +11,11 @@ export { CarIcon } from './CarIcon';
|
|||
export { BicycleIcon } from './BicycleIcon';
|
||||
export { WalkingIcon } from './WalkingIcon';
|
||||
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';
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ interface UseDeckLayersProps {
|
|||
data: HexagonData[];
|
||||
postcodeData: PostcodeFeature[];
|
||||
usePostcodeView: boolean;
|
||||
zoom: number;
|
||||
pois: POI[];
|
||||
viewFeature: string | null;
|
||||
colorRange: [number, number] | null;
|
||||
|
|
@ -61,6 +62,7 @@ export function useDeckLayers({
|
|||
data,
|
||||
postcodeData,
|
||||
usePostcodeView,
|
||||
zoom,
|
||||
pois,
|
||||
viewFeature,
|
||||
colorRange,
|
||||
|
|
@ -512,12 +514,15 @@ export function useDeckLayers({
|
|||
const layers = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const baseLayers: any[] = usePostcodeView
|
||||
? [postcodeLayer, postcodeLabelsLayer, poiLayer]
|
||||
? zoom >= 16
|
||||
? [postcodeLayer, postcodeLabelsLayer, poiLayer]
|
||||
: [postcodeLayer, poiLayer]
|
||||
: [hexLayer, poiLayer];
|
||||
if (marchingAntsLayer) baseLayers.push(marchingAntsLayer);
|
||||
return baseLayers;
|
||||
}, [
|
||||
usePostcodeView,
|
||||
zoom,
|
||||
hexLayer,
|
||||
postcodeLayer,
|
||||
postcodeLabelsLayer,
|
||||
|
|
|
|||
31
frontend/src/lib/group-icons.ts
Normal file
31
frontend/src/lib/group-icons.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -18,6 +18,9 @@ export interface FeatureMeta {
|
|||
suffix?: string;
|
||||
raw?: boolean;
|
||||
absolute?: boolean;
|
||||
// Mode restriction fields
|
||||
modes?: string[];
|
||||
linked?: string;
|
||||
}
|
||||
|
||||
export interface FeatureGroup {
|
||||
|
|
|
|||
|
|
@ -16,16 +16,16 @@ from .pois import UK_BBOX_EAST, UK_BBOX_NORTH, UK_BBOX_SOUTH, UK_BBOX_WEST
|
|||
|
||||
PLACE_TYPES = {
|
||||
"city",
|
||||
"borough",
|
||||
"town",
|
||||
"suburb",
|
||||
"quarter",
|
||||
"neighbourhood",
|
||||
"village",
|
||||
"hamlet",
|
||||
"locality",
|
||||
"island",
|
||||
"isolated_dwelling",
|
||||
# "borough",
|
||||
# "town",
|
||||
# "suburb",
|
||||
# "quarter",
|
||||
# "neighbourhood",
|
||||
# "village",
|
||||
# "hamlet",
|
||||
# "locality",
|
||||
# "island",
|
||||
# "isolated_dwelling",
|
||||
}
|
||||
|
||||
# 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)
|
||||
return
|
||||
|
||||
# railway=station nodes (tube, national rail, DLR, tram, etc.)
|
||||
# Tube stations only (London Underground)
|
||||
if n.tags.get("railway") == "station":
|
||||
display_name = _station_display_name(name, dict(n.tags))
|
||||
self._add(display_name, "station", lat, lon, population)
|
||||
return
|
||||
tags = dict(n.tags)
|
||||
station_tag = tags.get("station", "")
|
||||
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:
|
||||
|
|
@ -133,7 +137,7 @@ def main() -> None:
|
|||
args = parser.parse_args()
|
||||
|
||||
pbf_file = args.pbf
|
||||
print(f"Extracting place nodes: {sorted(PLACE_TYPES)} + railway=station")
|
||||
print("Extracting place nodes: cities + tube stations")
|
||||
with tqdm(
|
||||
unit=" elements",
|
||||
unit_scale=True,
|
||||
|
|
|
|||
|
|
@ -3,23 +3,27 @@
|
|||
Downloads:
|
||||
- England OSM PBF from Geofabrik (~1.5GB)
|
||||
- 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:
|
||||
- Cleans GTFS (fixes stop_times >72h, feed_info year >2100)
|
||||
- Crops OSM PBF to London bounding box via osmium
|
||||
- Crops GTFS to London bounding box (keeps only London-touching trips)
|
||||
- Cleans BODS GTFS (fixes stop_times >72h, feed_info year >2100)
|
||||
- Converts TfL TransXChange to GTFS via transxchange2gtfs
|
||||
- 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/
|
||||
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 csv
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
|
@ -33,18 +37,30 @@ ENGLAND_PBF_URL = (
|
|||
# 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/"
|
||||
|
||||
# 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)"
|
||||
|
||||
# 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) -> None:
|
||||
def _download_http(url: str, dest: Path, *, desc: str, headers: dict | None = None) -> None:
|
||||
"""Stream-download a URL to a file with progress bar."""
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
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 (
|
||||
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
|
||||
)
|
||||
|
||||
import tempfile
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
mode="wb", delete=False, suffix=".txt"
|
||||
)
|
||||
|
|
@ -170,143 +184,449 @@ def clean_gtfs(src: Path, dst: Path) -> None:
|
|||
print(f" Saved to {dst}")
|
||||
|
||||
|
||||
def crop_osm_to_london(src: Path, dst: Path) -> None:
|
||||
"""Extract London bounding box from England OSM PBF using osmium."""
|
||||
if dst.exists():
|
||||
print(f"London OSM PBF already exists: {dst}")
|
||||
def download_tfl_transxchange(raw_dir: Path) -> Path:
|
||||
"""Download TfL TransXChange timetable bundle."""
|
||||
dest = raw_dir / "tfl_transxchange.zip"
|
||||
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
|
||||
|
||||
bbox = LONDON_BBOX
|
||||
bbox_str = f"{bbox['min_lon']},{bbox['min_lat']},{bbox['max_lon']},{bbox['max_lat']}"
|
||||
print("Downloading NaPTAN stops data...")
|
||||
_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(
|
||||
["osmium", "extract", f"--bbox={bbox_str}", str(src), "-o", str(dst), "--overwrite"],
|
||||
["npx", "--yes", "transxchange2gtfs", str(txc_path), str(dest)],
|
||||
check=True,
|
||||
)
|
||||
size_mb = dst.stat().st_size / (1024 * 1024)
|
||||
print(f" Saved to {dst} ({size_mb:.0f} MB)")
|
||||
size_mb = dest.stat().st_size / (1024 * 1024)
|
||||
print(f" Saved to {dest} ({size_mb:.1f} MB)")
|
||||
return dest
|
||||
|
||||
|
||||
def crop_gtfs_to_london(src: Path, dst: Path) -> None:
|
||||
"""Crop GTFS to trips touching the London bounding box."""
|
||||
def download_national_rail_cif(raw_dir: Path) -> Path | None:
|
||||
"""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():
|
||||
print(f"London GTFS already exists: {dst}")
|
||||
print(f"Cleaned National Rail GTFS already exists: {dst}")
|
||||
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:
|
||||
# Step 1: Find stops in bbox
|
||||
print(" Finding stops in bbox...")
|
||||
# Load valid stop IDs
|
||||
with zin.open("stops.txt") as f:
|
||||
reader = csv.DictReader(io.TextIOWrapper(f))
|
||||
stops_in_bbox = set()
|
||||
all_stops = list(reader)
|
||||
for row in all_stops:
|
||||
lat = float(row["stop_lat"])
|
||||
lon = float(row["stop_lon"])
|
||||
if bbox["min_lat"] <= lat <= bbox["max_lat"] and bbox["min_lon"] <= lon <= bbox["max_lon"]:
|
||||
stops_in_bbox.add(row["stop_id"])
|
||||
print(f" {len(stops_in_bbox):,} / {len(all_stops):,} stops in bbox")
|
||||
header = f.readline().decode("utf-8").strip()
|
||||
stop_id_idx = header.split(",").index("stop_id")
|
||||
lat_idx = header.split(",").index("stop_lat")
|
||||
for line in f:
|
||||
parts = line.decode("utf-8", errors="replace").strip().split(",")
|
||||
if parts:
|
||||
stop_ids.add(parts[stop_id_idx])
|
||||
|
||||
# Step 2: Find trips touching these stops
|
||||
print(" Finding trips touching London stops...")
|
||||
# Find trips with backwards travel times
|
||||
with zin.open("stop_times.txt") as f:
|
||||
reader = csv.DictReader(io.TextIOWrapper(f))
|
||||
st_fieldnames = reader.fieldnames
|
||||
trips_in_bbox = set()
|
||||
for row in reader:
|
||||
if row["stop_id"] in stops_in_bbox:
|
||||
trips_in_bbox.add(row["trip_id"])
|
||||
print(f" {len(trips_in_bbox):,} trips touch London")
|
||||
st_header = f.readline().decode("utf-8").strip()
|
||||
st_cols = st_header.split(",")
|
||||
trip_id_idx = st_cols.index("trip_id")
|
||||
dep_idx = st_cols.index("departure_time")
|
||||
|
||||
# Step 3: Collect all stop_times for those trips
|
||||
print(" Collecting stop_times for London trips...")
|
||||
stop_times_kept = []
|
||||
with zin.open("stop_times.txt") as f:
|
||||
reader = csv.DictReader(io.TextIOWrapper(f))
|
||||
for row in reader:
|
||||
if row["trip_id"] in trips_in_bbox:
|
||||
stop_times_kept.append(row)
|
||||
stops_needed = {row["stop_id"] for row in stop_times_kept}
|
||||
print(f" {len(stop_times_kept):,} stop_times kept")
|
||||
prev_trip = ""
|
||||
prev_dep_secs = -1
|
||||
for line in f:
|
||||
parts = line.decode("utf-8", errors="replace").strip().split(",")
|
||||
if not parts:
|
||||
continue
|
||||
trip_id = parts[trip_id_idx].strip('"')
|
||||
if trip_id != prev_trip:
|
||||
prev_trip = trip_id
|
||||
prev_dep_secs = -1
|
||||
|
||||
# Step 4: Read trips and find needed routes/services/shapes
|
||||
print(" Reading trips...")
|
||||
with zin.open("trips.txt") as f:
|
||||
reader = csv.DictReader(io.TextIOWrapper(f))
|
||||
trips_fieldnames = reader.fieldnames
|
||||
all_trips = list(reader)
|
||||
trips_kept = [t for t in all_trips if t["trip_id"] in trips_in_bbox]
|
||||
routes_needed = {t["route_id"] for t in trips_kept}
|
||||
services_needed = {t["service_id"] for t in trips_kept}
|
||||
shapes_needed = {t.get("shape_id", "") for t in trips_kept} - {""}
|
||||
dep_str = parts[dep_idx].strip('"')
|
||||
if ":" in dep_str:
|
||||
try:
|
||||
h, m, s = dep_str.split(":")
|
||||
dep_secs = int(h) * 3600 + int(m) * 60 + int(s)
|
||||
if dep_secs < prev_dep_secs:
|
||||
bad_trip_ids.add(trip_id)
|
||||
prev_dep_secs = dep_secs
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Step 5: Write cropped GTFS
|
||||
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)
|
||||
print(f" Found {len(bad_trip_ids)} trips with backwards travel times")
|
||||
|
||||
# stop_times
|
||||
_write_csv(zout, "stop_times.txt", st_fieldnames, stop_times_kept)
|
||||
# Second pass: write cleaned zip
|
||||
passthrough_fixed = 0
|
||||
orphan_stops_removed = 0
|
||||
bad_trips_removed = 0
|
||||
seqs_renumbered = 0
|
||||
coords_fixed = 0
|
||||
route_types_fixed = 0
|
||||
|
||||
# trips
|
||||
_write_csv(zout, "trips.txt", trips_fieldnames, trips_kept)
|
||||
with zipfile.ZipFile(src, "r") as zin, zipfile.ZipFile(
|
||||
dst, "w", zipfile.ZIP_DEFLATED
|
||||
) as zout:
|
||||
for info in zin.infolist():
|
||||
# Skip non-standard links.txt
|
||||
if info.filename == "links.txt":
|
||||
continue
|
||||
|
||||
# routes
|
||||
with zin.open("routes.txt") as f:
|
||||
reader = csv.DictReader(io.TextIOWrapper(f))
|
||||
routes_fn = reader.fieldnames
|
||||
routes_kept = [r for r in reader if r["route_id"] in routes_needed]
|
||||
_write_csv(zout, "routes.txt", routes_fn, routes_kept)
|
||||
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(",")
|
||||
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)
|
||||
zout.writestr("agency.txt", zin.read("agency.txt"))
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
mode="wb", delete=False, suffix=".txt"
|
||||
)
|
||||
tmp.write(header)
|
||||
|
||||
# calendar
|
||||
with zin.open("calendar.txt") as f:
|
||||
reader = csv.DictReader(io.TextIOWrapper(f))
|
||||
cal_fn = reader.fieldnames
|
||||
cal_kept = [r for r in reader if r["service_id"] in services_needed]
|
||||
_write_csv(zout, "calendar.txt", cal_fn, cal_kept)
|
||||
prev_trip = ""
|
||||
seq_counter = 0
|
||||
for line in f:
|
||||
line_str = line.decode("utf-8", errors="replace").strip()
|
||||
if not line_str:
|
||||
continue
|
||||
parts = line_str.split(",")
|
||||
trip_id = parts[trip_id_idx].strip('"')
|
||||
stop_id = parts[stop_id_idx].strip('"')
|
||||
|
||||
# calendar_dates
|
||||
with zin.open("calendar_dates.txt") as f:
|
||||
reader = csv.DictReader(io.TextIOWrapper(f))
|
||||
cd_fn = reader.fieldnames
|
||||
cd_kept = [r for r in reader if r["service_id"] in services_needed]
|
||||
_write_csv(zout, "calendar_dates.txt", cd_fn, cd_kept)
|
||||
# Skip trips with backwards times
|
||||
if trip_id in bad_trip_ids:
|
||||
bad_trips_removed += 1
|
||||
continue
|
||||
|
||||
# shapes (stream — can be very large)
|
||||
print(" Streaming shapes.txt...")
|
||||
with zin.open("shapes.txt") as f:
|
||||
reader = csv.DictReader(io.TextIOWrapper(f))
|
||||
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)
|
||||
# Skip stop_times referencing missing stops
|
||||
if stop_id not in stop_ids:
|
||||
orphan_stops_removed += 1
|
||||
continue
|
||||
|
||||
# feed_info + frequencies (copy)
|
||||
zout.writestr("feed_info.txt", zin.read("feed_info.txt"))
|
||||
zout.writestr("frequencies.txt", zin.read("frequencies.txt"))
|
||||
# Fix pass-through stops: set pickup/dropoff to 0 (normal)
|
||||
if pickup_idx >= 0 and dropoff_idx >= 0:
|
||||
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)
|
||||
print(f" Saved to {dst} ({size_mb:.0f} MB)")
|
||||
# Renumber stop_sequence to 0-based
|
||||
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(
|
||||
zout: zipfile.ZipFile, name: str, fieldnames: list[str], rows: list[dict]
|
||||
def _docker_run_dtd2mysql(
|
||||
network: str, db_container: str, volumes: list[str], args: list[str]
|
||||
) -> None:
|
||||
buf = io.StringIO()
|
||||
w = csv.DictWriter(buf, fieldnames=fieldnames)
|
||||
w.writeheader()
|
||||
w.writerows(rows)
|
||||
zout.writestr(name, buf.getvalue())
|
||||
print(f" {name}: {len(rows):,} rows")
|
||||
"""Run dtd2mysql in a Node.js container on the same Docker network as MariaDB."""
|
||||
cmd = [
|
||||
"docker", "run", "--rm", "--network", network,
|
||||
"-e", f"DATABASE_HOSTNAME={db_container}",
|
||||
"-e", "DATABASE_USERNAME=root",
|
||||
"-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:
|
||||
|
|
@ -319,26 +639,43 @@ def main() -> None:
|
|||
required=True,
|
||||
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()
|
||||
|
||||
output_dir: Path = args.output
|
||||
raw_dir = output_dir / "raw"
|
||||
raw_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Download raw data
|
||||
england_pbf = download_osm_pbf(raw_dir)
|
||||
# 1. Download and clean BODS GTFS
|
||||
download_osm_pbf(raw_dir)
|
||||
bods_raw = download_bods_gtfs(raw_dir)
|
||||
|
||||
# Clean GTFS (fix R5 incompatibilities)
|
||||
bods_clean = raw_dir / "bods_gtfs_clean.zip"
|
||||
bods_clean = output_dir / "bods_gtfs.zip"
|
||||
clean_gtfs(bods_raw, bods_clean)
|
||||
|
||||
# Crop to London area for R5 (full England requires >30GB RAM)
|
||||
london_pbf = output_dir / "london.osm.pbf"
|
||||
crop_osm_to_london(england_pbf, london_pbf)
|
||||
# 2. TfL TransXChange → GTFS
|
||||
if args.skip_tfl:
|
||||
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"
|
||||
crop_gtfs_to_london(bods_clean, london_gtfs)
|
||||
# 3. National Rail CIF → 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
|
||||
print()
|
||||
|
|
@ -349,6 +686,11 @@ def main() -> None:
|
|||
size_mb = f.stat().st_size / (1024 * 1024)
|
||||
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__":
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -8,37 +8,10 @@ from pipeline.utils.postcode_mapping import build_postcode_mapping
|
|||
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 = [
|
||||
"Postcode",
|
||||
"lat",
|
||||
"lon",
|
||||
# Transport
|
||||
"Public transport to Bank (mins)",
|
||||
"Cycling to Bank (mins)",
|
||||
"Public transport to Fitzrovia (mins)",
|
||||
"Cycling to Fitzrovia (mins)",
|
||||
# Deprivation
|
||||
"Income Score (rate)",
|
||||
"Employment Score (rate)",
|
||||
|
|
@ -97,8 +70,6 @@ def _build(
|
|||
arcgis_path: Path,
|
||||
iod_path: Path,
|
||||
poi_proximity_path: Path,
|
||||
journey_times_bank_path: Path,
|
||||
journey_times_fitzrovia_path: Path,
|
||||
ethnicity_path: Path,
|
||||
crime_path: Path,
|
||||
noise_path: Path,
|
||||
|
|
@ -138,9 +109,6 @@ def _build(
|
|||
)
|
||||
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)
|
||||
wide = wide.join(iod, left_on="lsoa21", right_on="LSOA code (2021)", how="left")
|
||||
|
||||
|
|
@ -382,18 +350,6 @@ def main():
|
|||
type=Path,
|
||||
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(
|
||||
"--ethnicity",
|
||||
type=Path,
|
||||
|
|
@ -446,8 +402,6 @@ def main():
|
|||
arcgis_path=args.arcgis,
|
||||
iod_path=args.iod,
|
||||
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,
|
||||
crime_path=args.crime,
|
||||
noise_path=args.noise,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
///
|
||||
/// 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).
|
||||
pub struct TravelTimeStore {
|
||||
base_dir: PathBuf,
|
||||
/// Available transport modes (subdirectory names, e.g., "bicycle")
|
||||
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>>,
|
||||
/// (mode, stripped_slug) → full filename stem (with numeric prefix)
|
||||
slug_to_file: FxHashMap<(String, String), String>,
|
||||
cache: Mutex<LruCache>,
|
||||
}
|
||||
|
||||
impl TravelTimeStore {
|
||||
/// 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> {
|
||||
let mut available_modes = Vec::new();
|
||||
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)
|
||||
.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_name.to_string_lossy();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -125,6 +147,7 @@ impl TravelTimeStore {
|
|||
base_dir: base_dir.to_path_buf(),
|
||||
available_modes,
|
||||
destinations,
|
||||
slug_to_file,
|
||||
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
|
||||
.base_dir
|
||||
.join(mode)
|
||||
.join(format!("{}.parquet", slug));
|
||||
.join(format!("{}.parquet", file_stem));
|
||||
if !path.exists() {
|
||||
bail!("Travel time file not found: {}", path.display());
|
||||
}
|
||||
|
|
@ -233,18 +261,15 @@ mod tests {
|
|||
#[test]
|
||||
fn slugify_basic() {
|
||||
assert_eq!(slugify("Abbey Hey"), "abbey-hey");
|
||||
assert_eq!(slugify("Abbots Bickington"), "abbots-bickington");
|
||||
assert_eq!(slugify("London"), "london");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_special_chars() {
|
||||
assert_eq!(slugify("A'Bhuaile Ghlas"), "a-bhuaile-ghlas");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_edges() {
|
||||
assert_eq!(slugify(" Hello "), "hello");
|
||||
assert_eq!(slugify("Abbey"), "abbey");
|
||||
fn strip_numeric_prefix_basic() {
|
||||
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");
|
||||
assert_eq!(strip_numeric_prefix("london"), "london");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ pub struct FeatureConfig {
|
|||
pub raw: bool,
|
||||
/// If true, the slider uses absolute min/max/step instead of percentile scaling
|
||||
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).
|
||||
|
|
@ -61,7 +65,7 @@ pub struct EnumFeatureGroup {
|
|||
|
||||
pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
||||
FeatureGroup {
|
||||
name: "Property",
|
||||
name: "Properties in the area",
|
||||
features: &[
|
||||
FeatureConfig {
|
||||
name: "Last known price",
|
||||
|
|
@ -77,6 +81,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: true,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Estimated current price",
|
||||
|
|
@ -92,6 +98,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: true,
|
||||
modes: &["historical"],
|
||||
linked: "Asking price",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Price per sqm",
|
||||
|
|
@ -107,6 +115,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Est. price per sqm",
|
||||
|
|
@ -122,6 +132,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Total floor area (sqm)",
|
||||
|
|
@ -137,6 +149,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: " sqm",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Interior height (m)",
|
||||
|
|
@ -152,6 +166,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: " m",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Number of bedrooms & living rooms",
|
||||
|
|
@ -167,6 +183,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: " rooms",
|
||||
raw: false,
|
||||
absolute: true,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Estimated monthly rent",
|
||||
|
|
@ -179,6 +197,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/mo",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &["historical"],
|
||||
linked: "Asking rent (monthly)",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Date of last transaction",
|
||||
|
|
@ -194,6 +214,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: true,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Construction age",
|
||||
|
|
@ -209,6 +231,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: true,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Asking price",
|
||||
|
|
@ -224,6 +248,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: true,
|
||||
modes: &["buy"],
|
||||
linked: "Estimated current price",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Asking rent (monthly)",
|
||||
|
|
@ -239,6 +265,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/mo",
|
||||
raw: false,
|
||||
absolute: true,
|
||||
modes: &["rent"],
|
||||
linked: "Estimated monthly rent",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Bedrooms",
|
||||
|
|
@ -254,6 +282,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: true,
|
||||
modes: &["buy", "rent"],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Bathrooms",
|
||||
|
|
@ -269,6 +299,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: true,
|
||||
modes: &["buy", "rent"],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Listing date",
|
||||
|
|
@ -284,6 +316,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: true,
|
||||
absolute: false,
|
||||
modes: &["buy", "rent"],
|
||||
linked: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -304,6 +338,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: " mins",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Public transport to Fitzrovia (mins)",
|
||||
|
|
@ -319,6 +355,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: " mins",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Cycling to Bank (mins)",
|
||||
|
|
@ -334,6 +372,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: " mins",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Cycling to Fitzrovia (mins)",
|
||||
|
|
@ -349,6 +389,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: " mins",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Number of public transport stations within 2km",
|
||||
|
|
@ -364,6 +406,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -384,6 +428,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Good+ primary schools within 5km",
|
||||
|
|
@ -399,6 +445,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Good+ secondary schools within 5km",
|
||||
|
|
@ -414,6 +462,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -431,6 +481,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Employment Score (rate)",
|
||||
|
|
@ -443,6 +495,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Health Deprivation and Disability Score",
|
||||
|
|
@ -458,6 +512,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Living Environment Score",
|
||||
|
|
@ -473,6 +529,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Indoors Sub-domain Score",
|
||||
|
|
@ -488,6 +546,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Outdoors Sub-domain Score",
|
||||
|
|
@ -503,6 +563,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -524,6 +586,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Minor crime (avg/yr)",
|
||||
|
|
@ -539,6 +603,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -559,6 +625,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Violence and sexual offences (avg/yr)",
|
||||
|
|
@ -574,6 +642,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Criminal damage and arson (avg/yr)",
|
||||
|
|
@ -589,6 +659,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Burglary (avg/yr)",
|
||||
|
|
@ -604,6 +676,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Vehicle crime (avg/yr)",
|
||||
|
|
@ -619,6 +693,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Robbery (avg/yr)",
|
||||
|
|
@ -634,6 +710,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Other theft (avg/yr)",
|
||||
|
|
@ -649,6 +727,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Shoplifting (avg/yr)",
|
||||
|
|
@ -664,6 +744,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Drugs (avg/yr)",
|
||||
|
|
@ -679,6 +761,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Possession of weapons (avg/yr)",
|
||||
|
|
@ -694,6 +778,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Public order (avg/yr)",
|
||||
|
|
@ -709,6 +795,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Bicycle theft (avg/yr)",
|
||||
|
|
@ -724,6 +812,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Theft from the person (avg/yr)",
|
||||
|
|
@ -739,6 +829,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Other crime (avg/yr)",
|
||||
|
|
@ -754,6 +846,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -774,6 +868,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "%",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "% Asian",
|
||||
|
|
@ -789,6 +885,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "%",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "% Black",
|
||||
|
|
@ -804,6 +902,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "%",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "% Mixed",
|
||||
|
|
@ -819,6 +919,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "%",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "% Other",
|
||||
|
|
@ -834,6 +936,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "%",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -854,6 +958,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Number of grocery shops and supermarkets within 2km",
|
||||
|
|
@ -869,6 +975,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Number of parks within 2km",
|
||||
|
|
@ -884,6 +992,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -904,6 +1014,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: " dB",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Max available download speed (Mbps)",
|
||||
|
|
@ -919,6 +1031,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
suffix: " Mbps",
|
||||
raw: true,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ fn is_false(val: &bool) -> bool {
|
|||
!val
|
||||
}
|
||||
|
||||
fn is_empty_slice(val: &&[&str]) -> bool {
|
||||
val.is_empty()
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum FeatureInfo {
|
||||
|
|
@ -37,6 +41,10 @@ pub enum FeatureInfo {
|
|||
raw: bool,
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
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")]
|
||||
Enum {
|
||||
|
|
@ -102,6 +110,8 @@ pub fn build_features_response(data: &PropertyData) -> FeaturesResponse {
|
|||
suffix: feature_config.suffix,
|
||||
raw: feature_config.raw,
|
||||
absolute: feature_config.absolute,
|
||||
modes: feature_config.modes,
|
||||
linked: feature_config.linked,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
state: Arc<AppState>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Extension(_user): Extension<OptionalUser>,
|
||||
Path(code): Path<String>,
|
||||
) -> Response {
|
||||
if user.0.is_none() {
|
||||
return StatusCode::UNAUTHORIZED.into_response();
|
||||
}
|
||||
|
||||
if let Err(msg) = validate_invite_code(&code) {
|
||||
return (StatusCode::BAD_REQUEST, msg).into_response();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue