Good changes

This commit is contained in:
Andras Schmelczer 2026-03-11 20:44:34 +00:00
parent 80a5a2a774
commit 791bc6976b
24 changed files with 890 additions and 312 deletions

View file

@ -17,8 +17,9 @@ All commands use [Task](https://taskfile.dev) runner. Python uses `uv run`. Fron
task dev:server # Rust backend on :8001 (cargo run --release)
task dev:frontend # Webpack dev server on :3001 (proxies /api to :8001)
# Data pipeline
task prepare # Build wide.parquet from all pre-downloaded sources
# Data pipeline (uses Make, not Task — see Makefile.data)
make -f Makefile.data prepare # Build properties.parquet (merge + price estimation)
make -f Makefile.data merge # Just the merge step (no price estimation)
# Assets
task download:map-assets # Download font glyphs + twemoji PNGs into frontend/public/assets/
@ -55,28 +56,30 @@ uv run pytest pipeline/utils/test_haversine.py -k "test_name" # Single test
```
Raw sources → [Download scripts] → data/*.parquet
→ [Fuzzy join EPC ↔ Price-Paid] → epc_pp.parquet
→ [Merge all datasets] → wide.parquet
→ [Merge all datasets] → properties.parquet
→ [Price estimation] → properties.parquet (augmented with estimated prices)
→ [Rust server loads into memory + precomputes H3 + spatial grid]
→ [Frontend renders deck.gl H3HexagonLayer over MapLibre GL]
```
### Data Pipeline (`pipeline/`)
Python + Polars. Two phases:
Python + Polars. Orchestrated by `Makefile.data` (Make DAG with sentinel files like `.merge_done`, `.prices_done`). Two phases:
1. **Download** (`pipeline/download/`) — Each script fetches one raw dataset into `data/`
2. **Transform** (`pipeline/transform/`) — Joins and derives features:
- `join_epc_pp.py` — Fuzzy-joins EPC ↔ price-paid by address within postcode buckets
- `merge.py`**Main pipeline**: joins all datasets → `wide.parquet` with human-readable column names
- `merge.py`**Main pipeline**: joins all datasets → `properties.parquet` with human-readable column names
- `price_estimation/` — Post-merge step: adds "Estimated current price" and "Est. price per sqm" columns to `properties.parquet`. Uses repeat-sales price index + kNN spatial blending. Requires `price_index.parquet` (built by `price_estimation/index.py`). Run via `make -f Makefile.data prepare` (the `merge` target alone skips this).
- `transform_poi.py` — Filters POIs, maps to friendly names + emoji (exhaustive category validation)
- `poi_proximity.py` — Counts POIs within 2km per postcode using 0.05° spatial grid
- `crime.py` — Aggregates crime CSVs into yearly averages by LSOA
**Critical: column renaming in `merge.py`** — The pipeline renames columns from snake_case to human-readable names before writing `wide.parquet`. The Rust server and frontend use **only** these human-readable names — there are no fallbacks to snake_case. Key renames:
**Critical: column renaming in `merge.py`** — The pipeline renames columns from snake_case to human-readable names before writing `properties.parquet`. The Rust server and frontend use **only** these human-readable names — there are no fallbacks to snake_case. Key renames:
- `pp_address``Address per Property Register`
- `postcode``Postcode`
- `latest_price``Last known price`
- `duration``Leashold/Freehold`
- `duration``Leasehold/Freehold`
- `total_floor_area``Total floor area (sqm)`
- `current_energy_rating``Current energy rating`
@ -321,7 +324,7 @@ Follow these conventions in all Rust code:
- **POI transform validation**: Fails if any OSM category is unmapped — guarantees exhaustive coverage
- **Fuzzy join**: Groups by postcode, uses `thefuzz.token_sort_ratio` with numeric token compatibility, greedy assignment from highest score
- **Filter parsing is strict**: `parse_filters()` returns `Result` — malformed entries, unknown feature names, and unparseable numbers all return 400 Bad Request. No silent skipping of invalid filters.
- **Data loading is strict**: `extract_string_col` and `lookup_enum_value` take a single column name (no fallback names). H3 precomputation panics on invalid coordinates. Required parquet columns must exist at startup.
- **Data loading is strict**: `extract_string_col` and `lookup_enum_value` take a single column name (no fallback names). H3 precomputation panics on invalid coordinates. All configured features (defined in `features.rs`) must exist in at least one data source — the server panics at startup if any are missing (no NaN placeholders). This means all pipeline steps must be complete before starting the server. Polars `diagonal: true` concat fills nulls for features that exist in some but not all sources (e.g. "Listing date" from listings only).
- **Travel time is strict**: `mode` param is required (400) when `destination` is set — no silent default to "car". R5 failures return 502 Bad Gateway, not silent omission. `r5_url` is `Option<String>` — returns 503 if travel time requested without R5 configured.
- **Filter bounds format**: `south,west,north,east` (not standard bbox order)
- **Server-side AABB filtering**: Both `/api/hexagons` and `/api/postcodes` filter results by bounding-box intersection with query bounds. Hexagons use `h3_cell_bounds()` (h3o returns degrees, not radians). Postcodes compute polygon AABB from vertices. See `bounds_intersect()` in `parsing/bounds.rs`.

View file

@ -271,7 +271,7 @@ def transform_property(
"lat": lat,
"Postcode": postcode,
"Address per Property Register": address,
"Leashold/Freehold": None, # not available from home.co.uk
"Leasehold/Freehold": None, # not available from home.co.uk
"Property type": map_property_type(listing_type),
"Property sub-type": listing_type or "Unknown",
"price": int(price),

View file

@ -6,6 +6,7 @@ import type {
HexagonStatsResponse,
PostcodeFeature,
} from '../../types';
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
import type { HexagonLocation } from '../../lib/external-search';
import { formatValue, formatFilterValue, calculateHistogramMean } from '../../lib/format';
import { groupFeaturesByCategory } from '../../lib/features';
@ -16,14 +17,14 @@ import StackedBarChart from './StackedBarChart';
import StackedEnumChart from './StackedEnumChart';
import PriceHistoryChart from './PriceHistoryChart';
import ExternalSearchLinks from './ExternalSearchLinks';
import { InfoIcon, CloseIcon } from '../ui/icons';
import { InfoIcon } from '../ui/icons';
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
import { IconButton } from '../ui/IconButton';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { EmptyState } from '../ui/EmptyState';
import { FeatureLabel } from '../ui/FeatureLabel';
import StreetViewEmbed from './StreetViewEmbed';
import HistogramLegend from './HistogramLegend';
import JourneyInstructions from './JourneyInstructions';
interface AreaPaneProps {
stats: HexagonStatsResponse | null;
@ -33,10 +34,10 @@ interface AreaPaneProps {
isPostcode?: boolean;
postcodeData?: PostcodeFeature | null;
onViewProperties: () => void;
onClose: () => void;
hexagonLocation: HexagonLocation | null;
filters: FeatureFilters;
onNavigateToSource?: (slug: string, featureName: string) => void;
travelTimeEntries?: TravelTimeEntry[];
}
export default function AreaPane({
@ -47,10 +48,10 @@ export default function AreaPane({
isPostcode = false,
postcodeData,
onViewProperties,
onClose,
hexagonLocation,
filters,
onNavigateToSource,
travelTimeEntries,
}: AreaPaneProps) {
const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count;
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
@ -84,16 +85,10 @@ export default function AreaPane({
}
return (
<div className="relative h-full">
<div className="absolute top-2 right-2 z-20">
<IconButton onClick={onClose} title="Close">
<CloseIcon />
</IconButton>
</div>
<>
<div className="h-full overflow-y-auto">
<div className="p-3">
<div className="flex items-center gap-2 pr-8">
<div className="flex items-center gap-2">
<div>
<h2 className="text-sm font-semibold dark:text-warm-100">
{isPostcode ? hexagonId : 'Area Statistics'}
@ -129,6 +124,16 @@ export default function AreaPane({
{hexagonLocation && stats && (
<ExternalSearchLinks location={hexagonLocation} filters={filters} />
)}
{(() => {
const journeyPostcode = isPostcode ? hexagonId : stats?.central_postcode;
return journeyPostcode && travelTimeEntries && travelTimeEntries.length > 0 ? (
<JourneyInstructions
postcode={journeyPostcode}
entries={travelTimeEntries}
label={!isPostcode ? journeyPostcode : undefined}
/>
) : null;
})()}
{loading && !stats ? (
<LoadingSkeleton />
) : stats ? (
@ -260,7 +265,7 @@ export default function AreaPane({
p1={numericStats.histogram.p1}
p99={numericStats.histogram.p99}
globalMean={globalMean}
formatLabel={formatFilterValue}
formatLabel={(v) => formatFilterValue(v, feature.raw)}
/>
) : (
<DualHistogram
@ -268,7 +273,7 @@ export default function AreaPane({
globalCounts={numericStats.histogram.counts}
p1={numericStats.histogram.p1}
p99={numericStats.histogram.p99}
formatLabel={formatFilterValue}
formatLabel={(v) => formatFilterValue(v, feature.raw)}
/>
))}
</div>
@ -386,6 +391,6 @@ export default function AreaPane({
onNavigateToSource={onNavigateToSource}
/>
)}
</div>
</>
);
}

View file

@ -1,34 +1,19 @@
import { useMemo, useState, useEffect } from 'react';
import { useMemo } from 'react';
import type { FeatureFilters } from '../../types';
import {
buildPropertySearchUrls,
H3_RADIUS_MILES,
type HexagonLocation,
} from '../../lib/external-search';
import { apiUrl, logNonAbortError } from '../../lib/api';
import outcodeIds from '../../lib/rightmove-outcodes.json';
function useRightmoveLocationId(postcode: string | undefined): string | undefined {
const [locationId, setLocationId] = useState<string | undefined>();
const rightmoveOutcodes = outcodeIds as Record<string, string>;
useEffect(() => {
if (!postcode) {
setLocationId(undefined);
return;
}
setLocationId(undefined);
const controller = new AbortController();
fetch(apiUrl('rightmove-location', new URLSearchParams({ postcode })), {
signal: controller.signal,
})
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data?.location_identifier) setLocationId(data.location_identifier);
})
.catch((err) => logNonAbortError('rightmove-location', err));
return () => controller.abort();
}, [postcode]);
return locationId;
function getRightmoveLocationId(postcode: string | undefined): string | undefined {
if (!postcode) return undefined;
const outcode = postcode.trim().split(/\s+/)[0].toUpperCase();
const id = rightmoveOutcodes[outcode];
return id ? `OUTCODE^${id}` : undefined;
}
export default function ExternalSearchLinks({
@ -38,7 +23,7 @@ export default function ExternalSearchLinks({
location: HexagonLocation;
filters: FeatureFilters;
}) {
const rightmoveLocationId = useRightmoveLocationId(location.postcode);
const rightmoveLocationId = getRightmoveLocationId(location.postcode);
const urls = useMemo(
() => buildPropertySearchUrls({ location, filters, rightmoveLocationId }),
[location, filters, rightmoveLocationId]
@ -69,7 +54,7 @@ export default function ExternalSearchLinks({
Rightmove
</a>
) : (
<span className={disabledClass} title="Loading...">
<span className={disabledClass} title="Outcode not recognised">
Rightmove
</span>
)}

View file

@ -32,6 +32,7 @@ function SliderLabels({
displayValues,
isAtMin,
isAtMax,
raw,
}: {
min: number;
max: number;
@ -39,6 +40,7 @@ function SliderLabels({
displayValues?: [number, number];
isAtMin?: boolean;
isAtMax?: boolean;
raw?: boolean;
}) {
const range = max - min || 1;
const leftPct = ((value[0] - min) / range) * 100;
@ -50,13 +52,13 @@ function SliderLabels({
className="absolute -translate-x-1/2"
style={{ left: `${leftPct}%` }}
>
{isAtMin ? 'min' : formatFilterValue(labels[0])}
{isAtMin ? 'min' : formatFilterValue(labels[0], raw)}
</span>
<span
className="absolute -translate-x-1/2"
style={{ left: `${rightPct}%` }}
>
{isAtMax ? 'max' : formatFilterValue(labels[1])}
{isAtMax ? 'max' : formatFilterValue(labels[1], raw)}
</span>
</div>
);
@ -224,6 +226,15 @@ export default memo(function Filters({
[onAddFilter, features, expandGroup]
);
const handleAddTravelTimeAndScroll = useCallback(
(mode: TransportMode) => {
expandGroup('Travel Time');
pendingScrollRef.current = `tt_${travelTimeEntries.length}`;
onTravelTimeAddEntry(mode);
},
[onTravelTimeAddEntry, travelTimeEntries.length, expandGroup]
);
useEffect(() => {
const name = pendingScrollRef.current;
if (!name) return;
@ -232,7 +243,7 @@ export default memo(function Filters({
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, [enabledFeatureList]);
}, [enabledFeatureList, travelTimeEntries]);
const enabledGroups = useMemo(
() => groupFeaturesByCategory(enabledFeatureList),
[enabledFeatureList]
@ -311,8 +322,8 @@ export default memo(function Filters({
{!collapsedGroups.has('Travel Time') && (
<div className="px-2 py-1 space-y-1">
{travelTimeEntries.map((entry, index) => (
<div key={index} data-filter-name={`tt_${index}`} className="scroll-mt-7">
<TravelTimeCard
key={index}
mode={entry.mode}
slug={entry.slug}
label={entry.label}
@ -325,6 +336,7 @@ export default memo(function Filters({
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
/>
</div>
))}
</div>
)}
@ -460,6 +472,7 @@ export default memo(function Filters({
displayValues={scale ? displayValue : undefined}
isAtMin={isAtMin}
isAtMax={isAtMax}
raw={feature.raw}
/>
</div>
</div>
@ -488,7 +501,7 @@ export default memo(function Filters({
openInfoFeature={openInfoFeature}
onClearOpenInfoFeature={onClearOpenInfoFeature}
travelTimeEntries={travelTimeEntries}
onAddTravelTimeEntry={onTravelTimeAddEntry}
onAddTravelTimeEntry={handleAddTravelTimeAndScroll}
isLicensed={isLicensed}
onUpgradeClick={onUpgradeClick}
/>

View file

@ -12,7 +12,7 @@ export default function HistogramLegend() {
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-warm-300/60 dark:bg-warm-600/60 rounded" />
<span className="text-warm-700 dark:text-warm-300">
<span className="font-medium text-warm-900 dark:text-warm-100">Gray bars</span> show the
<span className="font-medium text-warm-900 dark:text-warm-100">Grey bars</span> show the
overall distribution across all areas
</span>
</div>

View file

@ -0,0 +1,262 @@
import { useState, useEffect } from 'react';
import type { JourneyLeg } from '../../types';
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
import { apiUrl, logNonAbortError } from '../../lib/api';
import { WalkingIcon } from '../ui/icons/WalkingIcon';
import { BicycleIcon } from '../ui/icons/BicycleIcon';
interface JourneyInstructionsProps {
postcode: string;
entries: TravelTimeEntry[];
/** When set, shown as a subtitle (e.g. the central postcode for a hexagon) */
label?: string;
}
interface JourneyData {
slug: string;
label: string;
legs: JourneyLeg[] | null;
/** Median (50th percentile) total travel time from R5, including waiting. */
minutes: number | null;
/** Best-case (5th percentile) total travel time from R5. */
bestMinutes: number | null;
loading: boolean;
}
// Official TfL line colors + other known London transit
const ROUTE_COLORS: Record<string, { color: string; darkText?: boolean }> = {
Bakerloo: { color: '#B36305' },
Central: { color: '#E32017' },
Circle: { color: '#FFD300', darkText: true },
District: { color: '#00782A' },
'Elizabeth line': { color: '#6950A1' },
Elizabeth: { color: '#6950A1' },
'Hammersmith & City': { color: '#F3A9BB', darkText: true },
'Hammersmith and City': { color: '#F3A9BB', darkText: true },
Jubilee: { color: '#A0A5A9', darkText: true },
Metropolitan: { color: '#9B0056' },
Northern: { color: '#333333' },
Piccadilly: { color: '#003688' },
Victoria: { color: '#0098D4' },
'Waterloo & City': { color: '#95CDBA', darkText: true },
'Waterloo and City': { color: '#95CDBA', darkText: true },
DLR: { color: '#00A4A7' },
'London Overground': { color: '#EE7C0E' },
};
const NON_TUBE_NAMES = new Set(['DLR', 'London Overground', 'Elizabeth line']);
function getRouteDisplay(mode: string): { label: string; color: string; darkText: boolean } {
const known = ROUTE_COLORS[mode];
if (known) {
const label = NON_TUBE_NAMES.has(mode) || mode.includes('line') ? mode : `${mode} line`;
return { label, color: known.color, darkText: !!known.darkText };
}
if (/^\d+[A-Za-z]?$/.test(mode.trim())) {
return { label: `Bus ${mode}`, color: '#0d9488', darkText: false };
}
return { label: mode, color: '#6b7280', darkText: false };
}
function invertLegs(legs: JourneyLeg[]): JourneyLeg[] {
return [...legs]
.reverse()
.map((leg) => (leg.from && leg.to ? { ...leg, from: leg.to, to: leg.from } : leg));
}
function RouteBadge({ mode }: { mode: string }) {
const { label, color, darkText } = getRouteDisplay(mode);
return (
<span
className="inline-flex items-center text-[10px] font-bold px-1.5 py-px rounded-sm leading-tight tracking-wide"
style={{ backgroundColor: color, color: darkText ? '#292524' : '#fff' }}
>
{label}
</span>
);
}
function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
const isAccess = leg.mode === 'walk' || leg.mode === 'bicycle';
if (isAccess) {
return (
<div className="flex">
<div className="flex flex-col items-center w-4 mr-2">
<div className="w-2 h-2 rounded-full border-[1.5px] border-warm-400 dark:border-warm-500 shrink-0 mt-1" />
{!isLast && (
<div className="flex-1 min-h-[4px] border-l border-dashed border-warm-300 dark:border-warm-600" />
)}
</div>
<div className="flex items-center gap-1.5 py-0.5 min-w-0">
{leg.mode === 'walk' ? (
<WalkingIcon className="w-3 h-3 text-warm-400 dark:text-warm-500 shrink-0" />
) : (
<BicycleIcon className="w-3 h-3 text-warm-400 dark:text-warm-500 shrink-0" />
)}
<span className="text-[11px] text-warm-500 dark:text-warm-400">
{leg.mode === 'walk' ? 'Walk' : 'Cycle'} · {leg.minutes} min
</span>
</div>
</div>
);
}
const { color } = getRouteDisplay(leg.mode);
return (
<div className="flex">
<div className="flex flex-col items-center w-4 mr-2">
<div
className="w-2.5 h-2.5 rounded-full shrink-0 mt-0.5"
style={{ backgroundColor: color }}
/>
{!isLast && (
<div className="flex-1 min-h-[4px] w-px bg-warm-300 dark:bg-warm-600" />
)}
</div>
<div className="pb-1.5 min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<RouteBadge mode={leg.mode} />
<span className="text-[11px] text-warm-500 dark:text-warm-400">{leg.minutes} min</span>
</div>
{leg.from && leg.to && (
<div className="text-[11px] text-warm-600 dark:text-warm-300 mt-0.5 truncate">
{leg.from} {leg.to}
</div>
)}
</div>
</div>
);
}
export default function JourneyInstructions({ postcode, entries, label }: JourneyInstructionsProps) {
const [journeys, setJourneys] = useState<JourneyData[]>([]);
// Only transit entries with a destination set
const transitEntries = entries.filter((e) => e.mode === 'transit' && e.slug !== '');
useEffect(() => {
if (transitEntries.length === 0) {
setJourneys([]);
return;
}
const controller = new AbortController();
const results: JourneyData[] = transitEntries.map((e) => ({
slug: e.slug,
label: e.label,
legs: null,
minutes: null,
bestMinutes: null,
loading: true,
}));
setJourneys([...results]);
transitEntries.forEach((entry, idx) => {
const params = new URLSearchParams({
postcode,
mode: 'transit',
slug: entry.slug,
});
fetch(apiUrl('journey', params), { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(
(data: {
journey: JourneyLeg[] | null;
minutes: number | null;
best_minutes: number | null;
}) => {
setJourneys((prev) =>
prev.map((j, i) =>
i === idx
? {
...j,
legs: data.journey,
minutes: data.minutes,
bestMinutes: data.best_minutes,
loading: false,
}
: j
)
);
}
)
.catch((err) => {
logNonAbortError('journey', err);
setJourneys((prev) =>
prev.map((j, i) => (i === idx ? { ...j, loading: false } : j))
);
});
});
return () => controller.abort();
}, [postcode, transitEntries.map((e) => e.slug).join(',')]); // eslint-disable-line react-hooks/exhaustive-deps
if (transitEntries.length === 0) return null;
return (
<div className="mx-3 mt-2 space-y-2">
{label && (
<div className="text-xs text-warm-500 dark:text-warm-400">Journeys from {label}</div>
)}
{journeys.map((j) => {
const displayLegs = j.legs ? invertLegs(j.legs) : null;
const legSum = j.legs ? j.legs.reduce((sum, l) => sum + l.minutes, 0) : 0;
const totalMin = j.minutes ?? legSum;
const waitingMin = j.minutes != null ? Math.max(0, j.minutes - legSum) : null;
const bestWaitingMin =
j.bestMinutes != null ? Math.max(0, j.bestMinutes - legSum) : null;
return (
<div key={j.slug} className="bg-warm-50 dark:bg-warm-800 rounded-lg p-2.5">
<div className="flex items-baseline justify-between mb-2">
<span className="text-xs font-medium text-warm-700 dark:text-warm-300">
To {j.label || j.slug}
</span>
{displayLegs && displayLegs.length > 0 && (
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">
{totalMin} min
</span>
)}
</div>
{j.loading ? (
<div className="flex items-center gap-2 py-1">
<div className="w-3 h-3 border-2 border-teal-600 dark:border-teal-400 border-t-transparent rounded-full animate-spin" />
<span className="text-xs text-warm-500 dark:text-warm-400">Loading...</span>
</div>
) : displayLegs && displayLegs.length > 0 ? (
<div>
{displayLegs.map((leg, i) => (
<TimelineLeg key={i} leg={leg} isLast={i === displayLegs.length - 1} />
))}
{waitingMin != null && waitingMin > 0 && (
<div className="mt-1.5 pt-1.5 border-t border-warm-200 dark:border-warm-700 flex items-baseline justify-between">
<span className="text-[11px] text-warm-500 dark:text-warm-400">
Waiting & transfers
</span>
<span className="text-[11px] text-warm-600 dark:text-warm-300">
{waitingMin} min
{bestWaitingMin != null && (
<span className="text-warm-400 dark:text-warm-500">
{' '}
(best: {bestWaitingMin === 0 ? '~0' : bestWaitingMin} min)
</span>
)}
</span>
</div>
)}
</div>
) : (
<span className="text-xs text-warm-500 dark:text-warm-400">
No journey data available
</span>
)}
</div>
);
})}
</div>
);
}

View file

@ -244,6 +244,7 @@ export default memo(function Map({
mode="feature"
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
theme={theme}
raw={colorFeatureMeta.raw}
/>
) : null
) : (

View file

@ -14,6 +14,7 @@ export default function MapLegend({
theme = 'light',
inline = false,
suffix,
raw,
}: {
featureLabel: string;
range: [number, number];
@ -24,26 +25,65 @@ export default function MapLegend({
theme?: 'light' | 'dark';
inline?: boolean;
suffix?: string;
raw?: boolean;
}) {
const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
const gradientStyle =
mode === 'density' ? gradientToCss(densityGradient) : gradientToCss(FEATURE_GRADIENT);
const fmt = raw ? { raw: true } : undefined;
const rangeMin =
mode === 'density' ? (
<TickerValue text={formatValue(range[0])} />
) : enumValues && enumValues.length > 0 ? (
<span>{enumValues[0]}</span>
) : (
<TickerValue text={formatValue(range[0], fmt) + (suffix || '')} />
);
const rangeMax =
mode === 'density' ? (
<TickerValue text={formatValue(range[1])} />
) : enumValues && enumValues.length > 0 ? (
<span>{enumValues[enumValues.length - 1]}</span>
) : (
<TickerValue text={formatValue(range[1], fmt) + (suffix || '')} />
);
if (inline) {
return (
<div
className={
inline
? 'bg-white dark:bg-warm-800 dark:text-white p-3 text-xs border-b border-warm-200 dark:border-warm-700'
: 'absolute top-3 right-3 z-10 bg-white dark:bg-navy-800 dark:text-white rounded shadow-lg p-3 text-xs min-w-[160px]'
}
<div className="bg-warm-100 dark:bg-warm-800 text-warm-700 dark:text-warm-200 px-3 py-1.5 text-xs border-b border-warm-200 dark:border-warm-700 flex items-center gap-2">
<span className="font-semibold text-xs text-warm-800 dark:text-warm-200 truncate">
{featureLabel}
</span>
{showCancel && (
<button
onClick={onCancel}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
title="Clear colour view"
>
<CloseIcon className="w-3.5 h-3.5" />
</button>
)}
<div className="flex items-center gap-1.5 flex-1 min-w-[40%] text-warm-500 dark:text-warm-400">
{rangeMin}
<div className="h-2.5 rounded flex-1 min-w-[40px]" style={{ background: gradientStyle }} />
{rangeMax}
</div>
</div>
);
}
return (
<div className="absolute top-3 right-3 z-10 bg-white dark:bg-navy-800 dark:text-white rounded shadow-lg p-3 text-xs min-w-[160px]">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-sm dark:text-white">{featureLabel}</span>
{showCancel && (
<button
onClick={onCancel}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 ml-2"
title="Clear color view"
title="Clear colour view"
>
<CloseIcon className="w-4 h-4" />
</button>
@ -51,22 +91,8 @@ export default function MapLegend({
</div>
<div className="h-3 rounded" style={{ background: gradientStyle }} />
<div className="flex justify-between mt-1 text-warm-600 dark:text-warm-200">
{mode === 'density' ? (
<>
<TickerValue text={formatValue(range[0])} />
<TickerValue text={formatValue(range[1])} />
</>
) : enumValues && enumValues.length > 0 ? (
<>
<span>{enumValues[0]}</span>
<span>{enumValues[enumValues.length - 1]}</span>
</>
) : (
<>
<TickerValue text={formatValue(range[0]) + (suffix || '')} />
<TickerValue text={formatValue(range[1]) + (suffix || '')} />
</>
)}
{rangeMin}
{rangeMax}
</div>
</div>
);

View file

@ -161,10 +161,17 @@ export default function MapPage({
travelTimeEntries: travelTime.entries,
});
// First transit destination — used to pick the best central_postcode for journey display
const journeyDest = useMemo(() => {
const entry = travelTime.entries.find((e) => e.mode === 'transit' && e.slug);
return entry ? { mode: entry.mode, slug: entry.slug } : null;
}, [travelTime.entries]);
const selection = useHexagonSelection({
filters,
features,
resolution: mapData.resolution,
journeyDest,
});
const handleLocationSearchResult = useCallback(
@ -196,6 +203,17 @@ export default function MapPage({
selection.setRightPaneTab(initialTab);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Prevent browser back/forward navigation from horizontal trackpad swipes
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
e.preventDefault();
}
};
document.addEventListener('wheel', handleWheel, { passive: false });
return () => document.removeEventListener('wheel', handleWheel);
}, []);
const { handleHexagonClick } = selection;
const handleMobileHexagonClick = useCallback(
(id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => {
@ -351,9 +369,9 @@ export default function MapPage({
: null
}
onViewProperties={selection.handleViewPropertiesFromArea}
onClose={selection.handleCloseSelection}
hexagonLocation={hexagonLocation}
filters={filters}
travelTimeEntries={travelTime.activeEntries}
/>
);
@ -501,6 +519,7 @@ export default function MapPage({
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
theme={theme}
inline
raw={mobileLegendMeta.raw}
/>
) : null
) : (

View file

@ -1,8 +1,8 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useState, useCallback } from 'react';
import { Slider } from '../ui/Slider';
import { IconButton } from '../ui/IconButton';
import { PillToggle } from '../ui/PillToggle';
import { PlaceSearchInput } from '../ui/PlaceSearchInput';
import { DestinationDropdown } from '../ui/DestinationDropdown';
import InfoPopup from '../ui/InfoPopup';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { EyeIcon } from '../ui/icons/EyeIcon';
@ -13,7 +13,7 @@ import { BicycleIcon } from '../ui/icons/BicycleIcon';
import { WalkingIcon } from '../ui/icons/WalkingIcon';
import { TransitIcon } from '../ui/icons/TransitIcon';
import { formatFilterValue } from '../../lib/format';
import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch';
import { useTravelDestinations } from '../../hooks/useTravelDestinations';
import { MODE_LABELS, type TransportMode } from '../../hooks/useTravelTime';
import type { ComponentType } from 'react';
@ -51,29 +51,14 @@ export function TravelTimeCard({
onToggleBest,
onRemove,
}: TravelTimeCardProps) {
const search = useLocationSearch(mode);
const containerRef = useRef<HTMLDivElement>(null);
const { destinations, loading: destinationsLoading } = useTravelDestinations(mode);
const [showBestInfo, setShowBestInfo] = useState(false);
// Close dropdown on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
search.close();
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [search.close]);
const selectResult = useCallback(
(result: SearchResult) => {
if (result.type === 'place') {
onSetDestination(result.slug, result.name);
search.clear();
}
const handleDestinationSelect = useCallback(
(selectedSlug: string, selectedLabel: string) => {
onSetDestination(selectedSlug, selectedLabel);
},
[onSetDestination, search.clear],
[onSetDestination],
);
const sliderMin = 0;
@ -120,16 +105,12 @@ export function TravelTimeCard({
</button>
</div>
) : (
<div ref={containerRef} className="relative">
<PlaceSearchInput
search={search}
onSelect={selectResult}
placeholder="Search stations..."
size="xs"
inputClassName="w-full px-2 py-1 text-xs rounded border border-warm-200 dark:border-warm-600 bg-white dark:bg-warm-800 text-navy-950 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-1 focus:ring-teal-400"
portal
<DestinationDropdown
destinations={destinations}
loading={destinationsLoading}
onSelect={handleDestinationSelect}
placeholder="Select destination..."
/>
</div>
)}
{/* Best-case toggle — transit only, shown when destination is set */}

View file

@ -0,0 +1,222 @@
import {
useState,
useRef,
useEffect,
useCallback,
useLayoutEffect,
useMemo,
} from 'react';
import { createPortal } from 'react-dom';
import type { Destination } from '../../hooks/useTravelDestinations';
import { MapPinIcon } from './icons/MapPinIcon';
import { ChevronIcon } from './icons/ChevronIcon';
interface DestinationDropdownProps {
destinations: Destination[];
loading: boolean;
onSelect: (slug: string, label: string) => void;
placeholder?: string;
}
export function DestinationDropdown({
destinations,
loading,
onSelect,
placeholder = 'Select destination...',
}: DestinationDropdownProps) {
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState('');
const [activeIndex, setActiveIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{
top: number;
left: number;
width: number;
} | null>(null);
const filtered = useMemo(() => {
if (!filter) return destinations;
const lower = filter.toLowerCase();
return destinations.filter(
(d) =>
d.name.toLowerCase().includes(lower) ||
d.city?.toLowerCase().includes(lower),
);
}, [destinations, filter]);
// Position the dropdown portal
const updatePos = useCallback(() => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width });
}, []);
useLayoutEffect(() => {
if (!open) return;
updatePos();
window.addEventListener('scroll', updatePos, true);
window.addEventListener('resize', updatePos);
return () => {
window.removeEventListener('scroll', updatePos, true);
window.removeEventListener('resize', updatePos);
};
}, [open, updatePos]);
// Close on outside click
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setOpen(false);
setFilter('');
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
// Scroll active item into view
useEffect(() => {
if (activeIndex < 0 || !listRef.current) return;
const item = listRef.current.children[activeIndex] as HTMLElement;
item?.scrollIntoView({ block: 'nearest' });
}, [activeIndex]);
const handleSelect = useCallback(
(dest: Destination) => {
onSelect(dest.slug, dest.name);
setOpen(false);
setFilter('');
setActiveIndex(-1);
},
[onSelect],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((prev) =>
prev < filtered.length - 1 ? prev + 1 : prev,
);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((prev) => (prev > 0 ? prev - 1 : -1));
} else if (e.key === 'Enter') {
e.preventDefault();
if (activeIndex >= 0 && activeIndex < filtered.length) {
handleSelect(filtered[activeIndex]);
}
} else if (e.key === 'Escape') {
setOpen(false);
setFilter('');
}
},
[filtered, activeIndex, handleSelect],
);
const handleOpen = useCallback(() => {
setOpen(true);
setActiveIndex(-1);
// Focus input after opening
requestAnimationFrame(() => inputRef.current?.focus());
}, []);
const dropdown = open && (
<div
className="bg-white dark:bg-warm-800 rounded shadow-lg border border-warm-200 dark:border-warm-700 overflow-hidden"
style={
pos
? {
position: 'fixed',
top: pos.top,
left: pos.left,
width: pos.width,
zIndex: 50,
}
: undefined
}
>
{/* Filter input */}
<div className="p-1.5 border-b border-warm-100 dark:border-warm-700">
<input
ref={inputRef}
type="text"
value={filter}
onChange={(e) => {
setFilter(e.target.value);
setActiveIndex(-1);
}}
onKeyDown={handleKeyDown}
placeholder="Type to filter..."
className="w-full px-2 py-1 text-xs rounded border border-warm-200 dark:border-warm-600 bg-warm-50 dark:bg-warm-900 text-navy-950 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-1 focus:ring-teal-400"
/>
</div>
{/* Results list */}
<div ref={listRef} className="max-h-48 overflow-y-auto">
{filtered.length === 0 ? (
<div className="px-2 py-2 text-xs text-warm-400 dark:text-warm-500 text-center">
{loading ? 'Loading...' : 'No destinations found'}
</div>
) : (
filtered.map((dest, idx) => (
<button
key={dest.slug}
type="button"
className={`w-full text-left flex items-center cursor-pointer px-2 py-1.5 gap-1.5 text-xs ${
idx === activeIndex
? 'bg-teal-50 dark:bg-teal-900/30'
: 'hover:bg-warm-50 dark:hover:bg-warm-700'
}`}
onMouseEnter={() => setActiveIndex(idx)}
onMouseDown={(e) => {
e.preventDefault();
handleSelect(dest);
}}
>
<MapPinIcon className="w-3 h-3 text-warm-400 dark:text-warm-500 shrink-0" />
<span className="text-warm-700 dark:text-warm-200 truncate">
{dest.name}
{dest.city && (
<span className="text-warm-400 dark:text-warm-500">
{' '}
({dest.city})
</span>
)}
</span>
</button>
))
)}
</div>
</div>
);
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={handleOpen}
className="w-full flex items-center gap-1.5 px-2 py-1 text-xs rounded border border-warm-200 dark:border-warm-600 bg-white dark:bg-warm-800 text-warm-400 dark:text-warm-500 hover:border-warm-300 dark:hover:border-warm-500 outline-none focus:ring-1 focus:ring-teal-400"
>
{loading ? (
<div className="w-3 h-3 border-2 border-warm-300 dark:border-warm-600 border-t-teal-500 rounded-full animate-spin shrink-0" />
) : (
<MapPinIcon className="w-3 h-3 shrink-0" />
)}
<span className="flex-1 text-left truncate">{placeholder}</span>
<ChevronIcon
direction={open ? 'up' : 'down'}
className="w-3 h-3 shrink-0"
/>
</button>
{open && createPortal(dropdown, document.body)}
</div>
);
}

View file

@ -28,7 +28,7 @@ export function FeatureActions({
)}
<IconButton
onClick={() => onTogglePin(feature.name)}
title={isPinned ? 'Unpin color view' : 'Color map by this feature'}
title={isPinned ? 'Unpin colour view' : 'Colour map by this feature'}
active={isPinned}
size="md"
>

View file

@ -3,15 +3,21 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#0f1528" />
<meta name="theme-color" content="#fafaf9" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0a0e1a" media="(prefers-color-scheme: dark)" />
<meta name="referrer" content="no-referrer" />
<title>Perfect Postcode — Every neighbourhood in England</title>
<meta name="description" content="Explore property prices, energy ratings, crime stats, school ratings, and more across England on one interactive map." />
<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__" />
<script>
(function() {
if (localStorage.getItem('theme') === 'dark') {
document.documentElement.classList.add('dark');
var theme = localStorage.getItem('theme');
var isDark = theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (isDark) document.documentElement.classList.add('dark');
// Override theme-color when user has explicit preference
if (theme === 'dark' || theme === 'light') {
var color = theme === 'dark' ? '#0a0e1a' : '#fafaf9';
document.querySelectorAll('meta[name="theme-color"]').forEach(function(m) { m.setAttribute('content', color); });
}
})();
</script>

View file

@ -84,7 +84,7 @@ def main():
& pl.col("_prev_rooms").is_not_null()
& (pl.col("NUMBER_HABITABLE_ROOMS") != pl.col("_prev_rooms"))
)
.then(pl.lit("Remodeling"))
.then(pl.lit("Remodelling"))
.when(
pl.col("TOTAL_FLOOR_AREA").is_not_null()
& pl.col("_prev_area").is_not_null()

View file

@ -21,7 +21,8 @@ _AREA_COLUMNS = [
"Indoors Sub-domain Score",
"Outdoors Sub-domain Score",
# Ethnicity
"% Asian",
"% South Asian",
"% East Asian",
"% Black",
"% Mixed",
"% White",
@ -49,7 +50,8 @@ _AREA_COLUMNS = [
"Number of restaurants within 2km",
"Number of grocery shops and supermarkets within 2km",
"Number of parks within 2km",
"Number of public transport stations within 2km",
"Train or tube stations within 1km",
"Distance to nearest train or tube station (km)",
# Environment
"Noise (dB)",
"Max available download speed (Mbps)",
@ -298,7 +300,7 @@ def _build(
"pp_address": "Address per Property Register",
"epc_address": "Address per EPC",
"postcode": "Postcode",
"duration": "Leashold/Freehold",
"duration": "Leasehold/Freehold",
"current_energy_rating": "Current energy rating",
"potential_energy_rating": "Potential energy rating",
"total_floor_area": "Total floor area (sqm)",
@ -306,7 +308,8 @@ def _build(
"restaurants_2km": "Number of restaurants within 2km",
"groceries_2km": "Number of grocery shops and supermarkets within 2km",
"parks_2km": "Number of parks within 2km",
"public_transport_2km": "Number of public transport stations within 2km",
"train_tube_1km": "Train or tube stations within 1km",
"train_tube_nearest_km": "Distance to nearest train or tube station (km)",
"latest_price": "Last known price",
"number_habitable_rooms": "Number of bedrooms & living rooms",
"noise_lden_db": "Noise (dB)",

View file

@ -22,8 +22,8 @@ set -euo pipefail
# --demo only compute Bank + TCR, transit only (quick test)
# --- Defaults ---
THREADS=4
HEAP=12g
THREADS=8
HEAP=16g
NETWORK_DIR=property-data/r5-network
OUTPUT_BASE=property-data/travel-times
R5_DIR=r5-java

View file

@ -8,11 +8,13 @@ use polars::lazy::frame::LazyFrame;
use rustc_hash::{FxHashMap, FxHashSet};
use tracing::info;
/// Per-postcode travel time data: median and optional best-case (transit only).
#[derive(Clone, Copy)]
/// Per-postcode travel time data: median, optional best-case (transit only),
/// and optional journey instructions (JSON leg array, transit only with --paths).
#[derive(Clone)]
pub struct TravelDataRow {
pub minutes: i16,
pub best_minutes: Option<i16>,
pub journey: Option<Arc<str>>,
}
/// Cached postcode → travel time data for a single destination file.
@ -198,17 +200,26 @@ impl TravelTimeStore {
.column("best_minutes")
.ok()
.map(|col| col.i16().expect("'best_minutes' is not i16"));
let journeys = df
.column("journey")
.ok()
.map(|col| col.str().expect("'journey' is not string"));
let mut map = FxHashMap::default();
map.reserve(df.height());
for (i, (pc, min)) in postcodes.into_iter().zip(minutes.into_iter()).enumerate() {
if let (Some(pc), Some(min)) = (pc, min) {
let best_min = best.as_ref().and_then(|b| b.get(i));
let journey = journeys
.as_ref()
.and_then(|j| j.get(i))
.map(Arc::from);
map.insert(
pc.to_string(),
TravelDataRow {
minutes: min,
best_minutes: best_min,
journey,
},
);
}

View file

@ -71,6 +71,10 @@ pub struct HexagonStatsParams {
/// Comma-separated feature names to include in stats response.
/// Only listed features are computed; if absent or empty, no features are returned.
pub fields: Option<String>,
/// When set (with journey_slug), pick central_postcode as the postcode with the
/// shortest travel time for this mode+slug (so it has journey data).
pub journey_mode: Option<String>,
pub journey_slug: Option<String>,
}
pub async fn get_hexagon_stats(
@ -107,6 +111,17 @@ pub async fn get_hexagon_stats(
let (fields_specified, field_set) = parse_field_set(params.fields.as_deref());
// Load travel time data for central_postcode selection (if requested)
let journey_travel_data = match (&params.journey_mode, &params.journey_slug) {
(Some(mode), Some(slug)) if state.travel_time_store.has_destination(mode, slug) => {
state
.travel_time_store
.get(mode, slug)
.ok()
}
_ => None,
};
let response = tokio::task::spawn_blocking(move || {
let start_time = std::time::Instant::now();
let precomputed = &state.h3_cells;
@ -138,8 +153,42 @@ pub async fn get_hexagon_stats(
let total_count = matching_rows.len();
// Find the postcode of the property closest to the hexagon center
// Pick central_postcode: prefer the postcode with the shortest travel time
// for the requested journey destination (so it has journey data). Fall back
// to geographic proximity to the hexagon center.
let central_postcode = if !matching_rows.is_empty() {
if let Some(ref travel_data) = journey_travel_data {
// Find the row with the shortest travel time in the travel data
let best_row = matching_rows
.iter()
.copied()
.filter_map(|row| {
let pc = state.data.postcode(row);
travel_data.get(pc).map(|td| (row, td.minutes))
})
.min_by_key(|&(_, mins)| mins)
.map(|(row, _)| row);
// Fall back to geographic center if no row has travel data
let row = best_row.unwrap_or_else(|| {
let center: h3o::LatLng = cell.into();
let center_lat = center.lat() as f32;
let center_lon = center.lng() as f32;
matching_rows
.iter()
.copied()
.min_by(|&a, &b| {
let da = (state.data.lat[a] - center_lat).powi(2)
+ (state.data.lon[a] - center_lon).powi(2);
let db = (state.data.lat[b] - center_lat).powi(2)
+ (state.data.lon[b] - center_lon).powi(2);
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
.expect("matching_rows is non-empty")
});
Some(state.data.postcode(row).to_string())
} else {
// No journey destination requested — use geographic center
let center: h3o::LatLng = cell.into();
let center_lat = center.lat() as f32;
let center_lon = center.lng() as f32;
@ -147,18 +196,15 @@ pub async fn get_hexagon_stats(
.iter()
.copied()
.min_by(|&a, &b| {
let da_lat = state.data.lat[a] - center_lat;
let da_lon = state.data.lon[a] - center_lon;
let db_lat = state.data.lat[b] - center_lat;
let db_lon = state.data.lon[b] - center_lon;
let dist_a = da_lat * da_lat + da_lon * da_lon;
let dist_b = db_lat * db_lat + db_lon * db_lon;
dist_a
.partial_cmp(&dist_b)
.unwrap_or(std::cmp::Ordering::Equal)
let da = (state.data.lat[a] - center_lat).powi(2)
+ (state.data.lon[a] - center_lon).powi(2);
let db = (state.data.lat[b] - center_lat).powi(2)
+ (state.data.lon[b] - center_lon).powi(2);
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
.expect("matching_rows is non-empty");
Some(state.data.postcode(closest_row).to_string())
}
} else {
None
};

View file

@ -25,6 +25,12 @@ struct InviteValidation {
used: bool,
}
#[derive(Deserialize)]
pub struct CreateInviteRequest {
/// Admins can explicitly choose "admin" or "referral". Ignored for non-admins.
invite_type: Option<String>,
}
#[derive(Deserialize)]
pub struct RedeemRequest {
code: String,
@ -66,12 +72,12 @@ fn generate_invite_code() -> String {
chars.into_iter().collect()
}
/// Create an invite. Admins create "admin" invites (free license).
/// Licensed non-admin users create "referral" invites (30% off).
/// Create an invite. Admins create "admin" invites (free license) by default,
/// but can explicitly request "referral" type. Licensed non-admin users always create "referral" invites (30% off).
pub async fn post_invites(
state: Arc<AppState>,
Extension(user): Extension<OptionalUser>,
_body: Json<serde_json::Value>,
Json(body): Json<CreateInviteRequest>,
) -> Response {
let user = match user.0 {
Some(u) => u,
@ -79,7 +85,10 @@ pub async fn post_invites(
};
let invite_type = if user.is_admin {
"admin"
match body.invite_type.as_deref() {
Some("referral") => "referral",
_ => "admin",
}
} else if user.subscription == "licensed" {
"referral"
} else {

View file

@ -0,0 +1,58 @@
use std::sync::Arc;
use axum::http::StatusCode;
use axum::response::Json;
use serde::{Deserialize, Serialize};
use crate::state::AppState;
#[derive(Deserialize)]
pub struct JourneyQuery {
postcode: String,
mode: String,
slug: String,
}
#[derive(Serialize)]
pub struct JourneyResponse {
/// Raw JSON array of journey legs, or null if no journey data available.
journey: Option<serde_json::Value>,
/// Median (50th percentile) total travel time in minutes.
minutes: Option<i16>,
/// Best-case (5th percentile) total travel time in minutes (transit only).
best_minutes: Option<i16>,
}
pub async fn get_journey(
state: Arc<AppState>,
query: axum::extract::Query<JourneyQuery>,
) -> Result<Json<JourneyResponse>, (StatusCode, String)> {
let store = &state.travel_time_store;
if !store.has_destination(&query.mode, &query.slug) {
return Err((
StatusCode::NOT_FOUND,
format!("No travel data for mode={} slug={}", query.mode, query.slug),
));
}
let travel_data = store.get(&query.mode, &query.slug).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to load travel data: {e}"),
)
})?;
let row = travel_data.get(&query.postcode);
let journey = row
.and_then(|r| r.journey.as_ref())
.and_then(|j| serde_json::from_str::<serde_json::Value>(j).ok());
let minutes = row.map(|r| r.minutes);
let best_minutes = row.and_then(|r| r.best_minutes);
Ok(Json(JourneyResponse {
journey,
minutes,
best_minutes,
}))
}

View file

@ -1,83 +0,0 @@
use std::sync::Arc;
use axum::extract::Query;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json};
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::state::AppState;
const TYPEAHEAD_URL: &str = "https://los.rightmove.co.uk/typeahead";
#[derive(Deserialize)]
pub struct TypeaheadParams {
pub postcode: String,
}
#[derive(Serialize)]
pub struct TypeaheadResponse {
pub location_identifier: String,
}
#[derive(Deserialize)]
struct RightmoveMatch {
#[serde(rename = "type")]
match_type: String,
#[serde(rename = "displayName")]
display_name: String,
id: serde_json::Value,
}
#[derive(Deserialize)]
struct RightmoveTypeaheadResponse {
matches: Vec<RightmoveMatch>,
}
pub async fn get_rightmove_typeahead(
state: Arc<AppState>,
Query(params): Query<TypeaheadParams>,
) -> Result<Json<TypeaheadResponse>, axum::response::Response> {
let postcode = params.postcode.trim().to_uppercase();
let resp = state
.http_client
.get(TYPEAHEAD_URL)
.query(&[("query", &postcode), ("limit", &"10".to_string())])
.send()
.await
.map_err(|err| {
warn!(error = %err, "Rightmove typeahead request failed");
(StatusCode::BAD_GATEWAY, "Rightmove typeahead unavailable").into_response()
})?;
let data: RightmoveTypeaheadResponse = resp.json().await.map_err(|err| {
warn!(error = %err, "Failed to parse Rightmove typeahead response");
(StatusCode::BAD_GATEWAY, "Invalid typeahead response").into_response()
})?;
// Look for POSTCODE match first, then OUTCODE
for match_type in &["POSTCODE", "OUTCODE"] {
for m in &data.matches {
if m.match_type == *match_type
&& m.display_name.to_uppercase().replace(' ', "")
== postcode.replace(' ', "")
{
let id = match &m.id {
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
return Ok(Json(TypeaheadResponse {
location_identifier: format!("{}^{}", match_type, id),
}));
}
}
}
Err((
StatusCode::NOT_FOUND,
format!("No Rightmove location found for: {}", postcode),
)
.into_response())
}

View file

@ -1,78 +0,0 @@
use std::sync::Arc;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use serde::Deserialize;
use tracing::warn;
use crate::auth::OptionalUser;
use crate::pocketbase::auth_superuser;
use crate::state::AppState;
const VALID_SUBSCRIPTIONS: &[&str] = &["free", "licensed"];
#[derive(Deserialize)]
pub struct UpdateSubscriptionRequest {
subscription: String,
}
pub async fn patch_subscription(
state: Arc<AppState>,
Extension(user): Extension<OptionalUser>,
Json(req): Json<UpdateSubscriptionRequest>,
) -> Response {
let user = match user.0 {
Some(u) => u,
None => return StatusCode::UNAUTHORIZED.into_response(),
};
if !user.is_admin {
return StatusCode::FORBIDDEN.into_response();
}
if !VALID_SUBSCRIPTIONS.contains(&req.subscription.as_str()) {
return (
StatusCode::BAD_REQUEST,
format!("Invalid subscription: {}", req.subscription),
)
.into_response();
}
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await
{
Ok(t) => t,
Err(err) => {
warn!("Failed to authenticate as PocketBase superuser: {err}");
return StatusCode::BAD_GATEWAY.into_response();
}
};
let url = format!("{pb_url}/api/collections/users/records/{}", user.id);
let res = state
.http_client
.patch(&url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({ "subscription": req.subscription }))
.send()
.await;
match res {
Ok(resp) if resp.status().is_success() => {
state.token_cache.invalidate_by_user_id(&user.id);
StatusCode::OK.into_response()
}
Ok(resp) => {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
warn!("PocketBase user update failed ({status}): {text}");
StatusCode::BAD_GATEWAY.into_response()
}
Err(err) => {
warn!("PocketBase request error: {err}");
StatusCode::BAD_GATEWAY.into_response()
}
}
}

View file

@ -0,0 +1,89 @@
use std::sync::Arc;
use axum::extract::Query;
use axum::http::StatusCode;
use axum::response::Json;
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::data::slugify;
use crate::state::AppState;
#[derive(Serialize)]
pub struct DestinationResult {
name: String,
slug: String,
place_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
city: Option<String>,
}
#[derive(Serialize)]
pub struct DestinationsResponse {
destinations: Vec<DestinationResult>,
}
#[derive(Deserialize)]
pub struct DestinationsParams {
mode: String,
}
pub async fn get_travel_destinations(
state: Arc<AppState>,
Query(params): Query<DestinationsParams>,
) -> Result<Json<DestinationsResponse>, (StatusCode, String)> {
let mode = params.mode;
let destinations = tokio::task::spawn_blocking(move || {
let t0 = std::time::Instant::now();
let pd = &state.place_data;
let tt_store = &state.travel_time_store;
let slug_set = match tt_store.destinations.get(&mode) {
Some(slugs) => slugs,
None => return Vec::new(),
};
// Find places that have travel time data for this mode
let mut matches: Vec<(usize, String, u8, u32, usize)> = pd
.name
.iter()
.enumerate()
.filter_map(|(idx, name)| {
let slug = slugify(name);
if slug_set.contains(&slug) {
Some((idx, slug, pd.type_rank[idx], pd.population[idx], name.len()))
} else {
None
}
})
.collect();
// Sort: type rank asc, population desc, name length asc
matches.sort_unstable_by(|a, b| a.2.cmp(&b.2).then(b.3.cmp(&a.3)).then(a.4.cmp(&b.4)));
let results: Vec<DestinationResult> = matches
.into_iter()
.map(|(idx, slug, ..)| DestinationResult {
name: pd.name[idx].clone(),
slug,
place_type: pd.place_type.get(idx).to_string(),
city: pd.city[idx].clone(),
})
.collect();
let elapsed = t0.elapsed();
info!(
mode = mode.as_str(),
results = results.len(),
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
"GET /api/travel-destinations"
);
results
})
.await
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
Ok(Json(DestinationsResponse { destinations }))
}