fine 2
This commit is contained in:
parent
9e4e65fa2a
commit
ca771a7edf
32 changed files with 1467 additions and 109 deletions
|
|
@ -8,7 +8,10 @@ import type {
|
|||
FeatureMeta,
|
||||
FilterExclusion,
|
||||
HexagonStatsResponse,
|
||||
PriceMetric,
|
||||
PricePoint,
|
||||
} from '../../types';
|
||||
import { postcodeOutcode, postcodeSector } from '../../lib/postcode';
|
||||
import { travelFieldKey, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
import type { HexagonLocation } from '../../lib/external-search';
|
||||
import {
|
||||
|
|
@ -289,6 +292,7 @@ export default function AreaPane({
|
|||
return [{ name: STATION_GROUP_NAME, features: [] }, ...paneFeatureGroups];
|
||||
}, [paneFeatureGroups, hexagonLocation]);
|
||||
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
const [priceMetric, setPriceMetric] = useState<PriceMetric>('price');
|
||||
const { scrollRef, onScroll } = useRetainedScrollTop<HTMLDivElement>({
|
||||
restoreKey: scrollRestoreKey ?? hexagonId,
|
||||
scrollTopRef,
|
||||
|
|
@ -548,14 +552,105 @@ export default function AreaPane({
|
|||
{stats.price_history &&
|
||||
(() => {
|
||||
const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year)));
|
||||
return uniqueYears.size > 1 ? (
|
||||
if (uniqueYears.size <= 1) return null;
|
||||
|
||||
const charts: {
|
||||
key: string;
|
||||
label: string;
|
||||
dot: string;
|
||||
points: PricePoint[];
|
||||
}[] = [
|
||||
{
|
||||
key: 'area',
|
||||
label: t('areaPane.priceHistoryThisArea'),
|
||||
dot: 'bg-teal-500 dark:bg-teal-400',
|
||||
points: stats.price_history,
|
||||
},
|
||||
];
|
||||
// Sector/outcode context is postcode-only: a hexagon cell
|
||||
// straddles arbitrary postcodes, so its sector/outcode is not a
|
||||
// meaningful aggregation unit. The Total/Per-m² toggle still
|
||||
// applies to the area chart in both cases.
|
||||
if (isPostcode && hexagonId) {
|
||||
const sector = postcodeSector(hexagonId);
|
||||
const outcode = postcodeOutcode(hexagonId);
|
||||
if (sector && stats.sector_price_history?.length) {
|
||||
charts.push({
|
||||
key: 'sector',
|
||||
label: sector,
|
||||
dot: 'bg-indigo-500 dark:bg-indigo-400',
|
||||
points: stats.sector_price_history,
|
||||
});
|
||||
}
|
||||
if (outcode && stats.outcode_price_history?.length) {
|
||||
charts.push({
|
||||
key: 'outcode',
|
||||
label: outcode,
|
||||
dot: 'bg-amber-500 dark:bg-amber-400',
|
||||
points: stats.outcode_price_history,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const hasPerSqm = charts.some((c) =>
|
||||
c.points.some((p) => p.price_per_sqm != null)
|
||||
);
|
||||
const metric: PriceMetric = hasPerSqm ? priceMetric : 'price';
|
||||
const optionClass = (active: boolean) =>
|
||||
`px-2 py-0.5 text-[11px] font-medium border-r last:border-r-0 border-warm-200 dark:border-warm-700 transition-colors ${
|
||||
active
|
||||
? 'bg-teal-600 text-white dark:bg-teal-500'
|
||||
: 'text-warm-600 hover:bg-warm-100 dark:text-warm-300 dark:hover:bg-warm-700'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="mx-3 mt-2 bg-warm-50 dark:bg-warm-800 rounded p-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
{t('areaPane.priceHistory')}
|
||||
</span>
|
||||
<PriceHistoryChart points={stats.price_history} />
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
{t('areaPane.priceHistory')}
|
||||
</span>
|
||||
{hasPerSqm && (
|
||||
<div
|
||||
className="grid grid-cols-2 overflow-hidden rounded-md border border-warm-200 dark:border-warm-700"
|
||||
role="radiogroup"
|
||||
aria-label={t('areaPane.priceMetric')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={metric === 'price'}
|
||||
onClick={() => setPriceMetric('price')}
|
||||
className={optionClass(metric === 'price')}
|
||||
>
|
||||
{t('areaPane.priceMetricTotal')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={metric === 'price_per_sqm'}
|
||||
onClick={() => setPriceMetric('price_per_sqm')}
|
||||
className={optionClass(metric === 'price_per_sqm')}
|
||||
>
|
||||
{t('areaPane.priceMetricPerSqm')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{charts.map((chart) => (
|
||||
<div key={chart.key} className={chart.key === 'area' ? '' : 'mt-1.5'}>
|
||||
{charts.length > 1 && (
|
||||
<span className="flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-warm-500 dark:text-warm-400">
|
||||
<span
|
||||
className={`inline-block h-1.5 w-1.5 rounded-full ${chart.dot}`}
|
||||
/>
|
||||
{chart.label}
|
||||
</span>
|
||||
)}
|
||||
<PriceHistoryChart points={chart.points} metric={metric} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
);
|
||||
})()}
|
||||
{displayFeatureGroups.map((group) => {
|
||||
const showNearbyStations =
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ export default function FeatureBrowser({
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="shrink-0 px-2 py-1.5 border-b border-warm-200 dark:border-navy-700">
|
||||
<div className="shrink-0 px-2 py-1 border-b border-warm-200 dark:border-navy-700">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
|
|
@ -125,7 +125,7 @@ export default function FeatureBrowser({
|
|||
toggleGroup(group.name);
|
||||
onGroupToggle?.(group.name, willExpand);
|
||||
}}
|
||||
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-30 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
className="px-3 py-1.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-30 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
>
|
||||
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
|
||||
{group.features.length +
|
||||
|
|
@ -141,7 +141,7 @@ export default function FeatureBrowser({
|
|||
return (
|
||||
<div
|
||||
key={mode}
|
||||
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer"
|
||||
className="flex items-center justify-between px-3 py-1.5 md:py-1 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer"
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 min-w-0"
|
||||
|
|
@ -184,7 +184,7 @@ export default function FeatureBrowser({
|
|||
return (
|
||||
<div
|
||||
key={f.name}
|
||||
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
|
||||
className="flex items-center justify-between px-3 py-1.5 md:py-1 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
|
||||
>
|
||||
<div className="min-w-0 mr-2">
|
||||
<FeatureLabel feature={f} size="sm" description={f.description} />
|
||||
|
|
|
|||
97
frontend/src/components/map/ListingPopups.test.tsx
Normal file
97
frontend/src/components/map/ListingPopups.test.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render } from '@testing-library/react';
|
||||
|
||||
import { ListingPopupSingleContent } from './ListingPopups';
|
||||
import type { ActualListing } from '../../types';
|
||||
|
||||
// No global RTL setup file registers auto-cleanup, so unmount between cases to
|
||||
// keep each render isolated in the shared jsdom document.
|
||||
afterEach(cleanup);
|
||||
|
||||
// Minimal react-i18next stub: t() echoes a readable label per key; i18n.language
|
||||
// is fixed so date formatting is deterministic.
|
||||
vi.mock('react-i18next', () => {
|
||||
const labels: Record<string, string> = {
|
||||
'listing.priceHistory': 'Price history',
|
||||
'listing.priceListed': 'Listed',
|
||||
'listing.priceReduced': 'Reduced',
|
||||
'listing.priceIncreased': 'Increased',
|
||||
'listing.openListing': 'Open listing',
|
||||
'listing.viewed': 'Viewed',
|
||||
};
|
||||
return {
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
labels[key] ?? (opts ? `${key} ${JSON.stringify(opts)}` : key),
|
||||
i18n: { language: 'en-GB' },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
function baseListing(overrides: Partial<ActualListing> = {}): ActualListing {
|
||||
return {
|
||||
lat: 51.5,
|
||||
lon: -0.1,
|
||||
postcode: 'SW9 0HD',
|
||||
listing_url: 'https://www.rightmove.co.uk/properties/1',
|
||||
asking_price: 490000,
|
||||
features: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ListingPopupSingleContent price history', () => {
|
||||
it('renders points newest-first with signed deltas and reason labels', () => {
|
||||
const listing = baseListing({
|
||||
price_history: [
|
||||
{ date: '2026-07-01', price: 500000, reason: 'listed' },
|
||||
{ date: '2026-07-19', price: 480000, reason: 'reduced' },
|
||||
{ date: '2026-07-26', price: 490000, reason: 'increased' },
|
||||
],
|
||||
});
|
||||
const { getByText, container } = render(
|
||||
<ListingPopupSingleContent listing={listing} clickedUrls={new Set()} onOpen={() => {}} />
|
||||
);
|
||||
|
||||
getByText('Price history');
|
||||
// Newest first: increased row shows +£10,000 vs the £480k point before it.
|
||||
const items = Array.from(container.querySelectorAll('ol li'));
|
||||
expect(items).toHaveLength(3);
|
||||
expect(items[0].textContent).toContain('£490,000');
|
||||
expect(items[0].textContent).toContain('+£10,000');
|
||||
expect(items[0].textContent).toContain('Increased');
|
||||
// Middle: the reduction from £500k -> £480k.
|
||||
expect(items[1].textContent).toContain('£480,000');
|
||||
expect(items[1].textContent).toContain('−£20,000');
|
||||
expect(items[1].textContent).toContain('Reduced');
|
||||
// Oldest: the initial listing, no delta.
|
||||
expect(items[2].textContent).toContain('£500,000');
|
||||
expect(items[2].textContent).toContain('Listed');
|
||||
expect(items[2].textContent).not.toContain('£0');
|
||||
// UTC-stable date: "2026-07-01" must read as 1 Jul regardless of runner TZ.
|
||||
expect(items[2].textContent).toContain('1 Jul 2026');
|
||||
});
|
||||
|
||||
it('renders a single listed point with no delta', () => {
|
||||
const listing = baseListing({
|
||||
price_history: [{ date: '2026-06-15', price: 325000, reason: 'listed' }],
|
||||
});
|
||||
const { getByText, container } = render(
|
||||
<ListingPopupSingleContent listing={listing} clickedUrls={new Set()} onOpen={() => {}} />
|
||||
);
|
||||
getByText('Price history');
|
||||
expect(container.querySelectorAll('ol li')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('omits the section entirely when there is no history', () => {
|
||||
const { queryByText } = render(
|
||||
<ListingPopupSingleContent
|
||||
listing={baseListing({ price_history: [] })}
|
||||
clickedUrls={new Set()}
|
||||
onOpen={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(queryByText('Price history')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -2,12 +2,89 @@ import { memo } from 'react';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import type { ActualListing } from '../../types';
|
||||
import type { ActualListing, PriceHistoryPoint } from '../../types';
|
||||
|
||||
function formatListingPrice(price: number): string {
|
||||
return `£${price.toLocaleString()}`;
|
||||
}
|
||||
|
||||
function formatHistoryDate(iso: string, locale: string): string {
|
||||
const parsed = new Date(iso);
|
||||
if (Number.isNaN(parsed.getTime())) return iso;
|
||||
// `iso` is a UTC calendar date ("YYYY-MM-DD"), parsed as UTC midnight. Format
|
||||
// in UTC too, or a viewer west of UTC would see every date shifted a day back.
|
||||
return parsed.toLocaleDateString(locale, {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
function reasonLabel(reason: string, t: TFunction): string {
|
||||
switch (reason) {
|
||||
case 'reduced':
|
||||
return t('listing.priceReduced');
|
||||
case 'increased':
|
||||
return t('listing.priceIncreased');
|
||||
default:
|
||||
return t('listing.priceListed');
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact asking-price timeline for the hover card. Renders most-recent first,
|
||||
* with the £ change from the prior point on each reduction/increase. Accrues from
|
||||
* the scraper's forward-only store, so a fresh listing shows a single point. */
|
||||
function ListingPriceHistory({ history }: { history: PriceHistoryPoint[] }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
if (history.length === 0) return null;
|
||||
// history is oldest -> newest; show up to the 5 most recent, newest first.
|
||||
const shown = history.slice(-5);
|
||||
const baseIndex = history.length - shown.length;
|
||||
const rows = shown
|
||||
.map((point, idx) => {
|
||||
const globalIdx = baseIndex + idx;
|
||||
const prev = globalIdx > 0 ? history[globalIdx - 1] : null;
|
||||
const delta = prev ? point.price - prev.price : 0;
|
||||
return { point, delta };
|
||||
})
|
||||
.reverse();
|
||||
|
||||
return (
|
||||
<div className="mt-2 border-t border-warm-100 pt-1.5 dark:border-warm-700/60">
|
||||
<div className="text-[11px] font-medium text-warm-500 dark:text-warm-400">
|
||||
{t('listing.priceHistory')}
|
||||
</div>
|
||||
<ol className="mt-1 space-y-0.5">
|
||||
{rows.map(({ point, delta }, idx) => {
|
||||
const isReduction = point.reason === 'reduced';
|
||||
const isIncrease = point.reason === 'increased';
|
||||
const accent = isReduction
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: isIncrease
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-warm-600 dark:text-warm-300';
|
||||
return (
|
||||
<li key={idx} className="flex items-baseline justify-between gap-2 text-[11px]">
|
||||
<span className="flex items-baseline gap-1.5">
|
||||
<span className={`font-semibold ${accent}`}>{formatListingPrice(point.price)}</span>
|
||||
{delta !== 0 && (
|
||||
<span className={accent}>
|
||||
{delta < 0 ? '−' : '+'}£{Math.abs(delta).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="shrink-0 text-warm-400 dark:text-warm-500">
|
||||
{reasonLabel(point.reason, t)} · {formatHistoryDate(point.date, i18n.language)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatListingHeadline(listing: ActualListing, t: TFunction): string | null {
|
||||
const parts: string[] = [];
|
||||
if (listing.bedrooms != null) parts.push(t('common.bedsCount', { count: listing.bedrooms }));
|
||||
|
|
@ -78,6 +155,9 @@ export const ListingPopupSingleContent = memo(function ListingPopupSingleContent
|
|||
))}
|
||||
</ul>
|
||||
)}
|
||||
{listing.price_history && listing.price_history.length > 0 && (
|
||||
<ListingPriceHistory history={listing.price_history} />
|
||||
)}
|
||||
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] font-medium">
|
||||
{visited && (
|
||||
<span className="text-violet-600 dark:text-violet-400">✓ {t('listing.viewed')}</span>
|
||||
|
|
|
|||
|
|
@ -710,6 +710,11 @@ export default function MapPage({
|
|||
const shareAndSaveView = isMobile
|
||||
? (mapData.currentVisibleView ?? mapData.currentView)
|
||||
: mapData.currentView;
|
||||
// Params for share/save/checkout-return/last-session, deliberately WITHOUT the
|
||||
// selected postcode: the lat/lon/zoom already convey the location, so focusing
|
||||
// a postcode adds nothing to a shared link and shouldn't be baked into a saved
|
||||
// search. The live URL (useUrlSync above) still carries `pc` so a reload
|
||||
// re-opens the selection.
|
||||
const dashboardParams = useMemo(
|
||||
() =>
|
||||
stateToParams(
|
||||
|
|
@ -723,7 +728,7 @@ export default function MapPage({
|
|||
activeOverlays,
|
||||
basemap,
|
||||
crimeTypes,
|
||||
selectedPostcodeParam,
|
||||
undefined,
|
||||
colorOpacity,
|
||||
listingsMode
|
||||
).toString(),
|
||||
|
|
@ -738,7 +743,6 @@ export default function MapPage({
|
|||
listingsMode,
|
||||
rightPaneTab,
|
||||
selectedPOICategories,
|
||||
selectedPostcodeParam,
|
||||
shareCode,
|
||||
shareAndSaveView,
|
||||
]
|
||||
|
|
@ -894,6 +898,9 @@ export default function MapPage({
|
|||
total={propertiesTotal}
|
||||
loading={loadingProperties}
|
||||
hexagonId={selectedHexagon?.id || null}
|
||||
statsUseFilters={areaStatsUseFilters}
|
||||
onStatsUseFiltersChange={setAreaStatsUseFilters}
|
||||
filtersActive={Object.keys(filters).length + activeEntries.length > 0}
|
||||
onLoadMore={handleLoadMoreProperties}
|
||||
scrollTopRef={propertiesPaneScrollTopRef}
|
||||
scrollRestoreKey={
|
||||
|
|
@ -903,7 +910,17 @@ export default function MapPage({
|
|||
/>
|
||||
</Suspense>
|
||||
),
|
||||
[handleLoadMoreProperties, loadingProperties, properties, propertiesTotal, selectedHexagon]
|
||||
[
|
||||
activeEntries,
|
||||
areaStatsUseFilters,
|
||||
filters,
|
||||
handleLoadMoreProperties,
|
||||
loadingProperties,
|
||||
properties,
|
||||
propertiesTotal,
|
||||
selectedHexagon,
|
||||
setAreaStatsUseFilters,
|
||||
]
|
||||
);
|
||||
|
||||
const poiPane = useMemo(
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { useMemo, useRef, useState, useEffect } from 'react';
|
||||
import type { PricePoint } from '../../types';
|
||||
import type { PriceMetric, PricePoint } from '../../types';
|
||||
import { formatValue } from '../../lib/format';
|
||||
|
||||
interface PriceHistoryChartProps {
|
||||
points: PricePoint[];
|
||||
/** Which value to plot. Defaults to the absolute sale price. */
|
||||
metric?: PriceMetric;
|
||||
}
|
||||
|
||||
const PADDING = { top: 8, right: 24, bottom: 20, left: 48 };
|
||||
|
|
@ -22,7 +24,7 @@ interface PriceScale {
|
|||
ticks: number[];
|
||||
}
|
||||
|
||||
export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
||||
export default function PriceHistoryChart({ points, metric = 'price' }: PriceHistoryChartProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
|
||||
|
|
@ -37,7 +39,18 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Project each point onto the chosen metric as a plain {year, price} so the
|
||||
// rest of the chart is metric-agnostic. In per-m² mode, points without a
|
||||
// recorded floor area drop out.
|
||||
const plotPoints = useMemo<PricePoint[]>(() => {
|
||||
if (metric === 'price') return points;
|
||||
return points
|
||||
.filter((p) => Number.isFinite(p.price_per_sqm))
|
||||
.map((p) => ({ year: p.year, price: p.price_per_sqm as number }));
|
||||
}, [points, metric]);
|
||||
|
||||
const { yearMin, yearMax, priceScale, medians } = useMemo(() => {
|
||||
const points = plotPoints;
|
||||
let yMin = Infinity,
|
||||
yMax = -Infinity;
|
||||
for (const p of points) {
|
||||
|
|
@ -83,7 +96,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
priceScale: getPriceScale(points),
|
||||
medians: meds,
|
||||
};
|
||||
}, [points]);
|
||||
}, [plotPoints]);
|
||||
|
||||
const plotW = width - PADDING.left - PADDING.right;
|
||||
const plotH = HEIGHT - PADDING.top - PADDING.bottom;
|
||||
|
|
@ -104,7 +117,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
|
||||
return (
|
||||
<div ref={containerRef} style={{ height: HEIGHT }}>
|
||||
{width > 0 && (
|
||||
{width > 0 && plotPoints.length > 0 && (
|
||||
<svg width={width} height={HEIGHT}>
|
||||
{/* Grid lines */}
|
||||
{priceScale.ticks.map((tick) => (
|
||||
|
|
@ -120,7 +133,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
))}
|
||||
|
||||
{/* Dots */}
|
||||
{points.map((p, i) => (
|
||||
{plotPoints.map((p, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={scaleX(p.year)}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ interface PropertiesPaneProps {
|
|||
total: number;
|
||||
loading: boolean;
|
||||
hexagonId: string | null;
|
||||
statsUseFilters: boolean;
|
||||
onStatsUseFiltersChange: (useFilters: boolean) => void;
|
||||
filtersActive: boolean;
|
||||
onLoadMore: () => void;
|
||||
onNavigateToSource?: (slug: string) => void;
|
||||
scrollTopRef?: MutableRefObject<number>;
|
||||
|
|
@ -35,6 +38,9 @@ export function PropertiesPane({
|
|||
total,
|
||||
loading,
|
||||
hexagonId,
|
||||
statsUseFilters,
|
||||
onStatsUseFiltersChange,
|
||||
filtersActive,
|
||||
onLoadMore,
|
||||
onNavigateToSource,
|
||||
scrollTopRef,
|
||||
|
|
@ -106,13 +112,41 @@ export function PropertiesPane({
|
|||
</InfoPopup>
|
||||
)}
|
||||
|
||||
<div className="p-2">
|
||||
<div className="p-2 space-y-2">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder={t('propertyCard.searchPlaceholder')}
|
||||
className="p-2"
|
||||
/>
|
||||
{filtersActive && (
|
||||
<div className="grid grid-cols-2 rounded-md bg-warm-200 p-0.5 dark:bg-navy-800">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={statsUseFilters}
|
||||
onClick={() => onStatsUseFiltersChange(true)}
|
||||
className={`min-w-0 rounded px-2 py-1 text-center text-xs font-medium leading-tight break-words ${
|
||||
statsUseFilters
|
||||
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
|
||||
: 'text-warm-600 hover:text-warm-900 dark:text-warm-400 dark:hover:text-warm-100'
|
||||
}`}
|
||||
>
|
||||
{t('areaPane.matchingFiltersOption')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={!statsUseFilters}
|
||||
onClick={() => onStatsUseFiltersChange(false)}
|
||||
className={`min-w-0 rounded px-2 py-1 text-center text-xs font-medium leading-tight break-words ${
|
||||
!statsUseFilters
|
||||
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
|
||||
: 'text-warm-600 hover:text-warm-900 dark:text-warm-400 dark:hover:text-warm-100'
|
||||
}`}
|
||||
>
|
||||
{t('areaPane.allPropertiesOption')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -448,12 +448,12 @@ export function ActiveFilterList({
|
|||
onToggleGroup(group.name);
|
||||
onGroupToggle?.(group.name, !expanded);
|
||||
}}
|
||||
className="sticky top-0 z-30 px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
className="sticky top-0 z-30 px-3 py-1.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
>
|
||||
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">{count}</span>
|
||||
</CollapsibleGroupHeader>
|
||||
{expanded && (
|
||||
<div className="px-2 py-1.5 space-y-3.5">
|
||||
<div className="px-2 py-1 space-y-2">
|
||||
{group.name === TRANSPORT_GROUP && travelCards}
|
||||
{group.features.map((feature) => renderFeatureCard(feature))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ export function ElectionVoteShareFilterCard({
|
|||
return (
|
||||
<div
|
||||
data-filter-name={ELECTION_VOTE_SHARE_FILTER_NAME}
|
||||
className={`space-y-1.5 px-2 py-1.5 rounded ${
|
||||
className={`space-y-1 px-2 py-1 rounded ${
|
||||
isActive
|
||||
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
||||
: isPinned
|
||||
|
|
@ -151,7 +151,7 @@ export function ElectionVoteShareFilterCard({
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
<label className="mb-0.5 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
{t('filters.party')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ export function NumericFeatureFilterCard({
|
|||
return (
|
||||
<div
|
||||
data-filter-name={feature.name}
|
||||
className={`space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
className={`space-y-0.5 px-2 py-1 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="relative z-10 flex items-center justify-between gap-1">
|
||||
<FeatureLabel
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ export function PoiDistanceFilterCard({
|
|||
return (
|
||||
<div
|
||||
data-filter-name={filterName}
|
||||
className={`space-y-1.5 px-2 py-1.5 rounded ${
|
||||
className={`space-y-1 px-2 py-1 rounded ${
|
||||
isActive
|
||||
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
||||
: isPinned
|
||||
|
|
@ -156,7 +156,7 @@ export function PoiDistanceFilterCard({
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
<label className="mb-0.5 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
{t('filters.poiType')}
|
||||
</label>
|
||||
<PoiTypeDropdown
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ export function SchoolFilterCard({
|
|||
return (
|
||||
<div
|
||||
data-filter-name={SCHOOL_FILTER_NAME}
|
||||
className={`space-y-1.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
className={`space-y-1 px-2 py-1 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="relative z-10 flex items-center justify-between gap-1">
|
||||
<FeatureLabel
|
||||
|
|
@ -127,7 +127,7 @@ export function SchoolFilterCard({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
{t('filters.schoolType')}
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ export function SliderLabels({
|
|||
|
||||
if (feature && onValueChange) {
|
||||
return (
|
||||
<div className="relative h-4 mt-2 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
||||
<div className="relative h-4 mt-1.5 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
||||
<EditableLabel
|
||||
value={labels[0]}
|
||||
formatted={minLabel}
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ export function VariantFilterCard({
|
|||
return (
|
||||
<div
|
||||
data-filter-name={config.filterName}
|
||||
className={`space-y-1.5 px-2 py-1.5 rounded ${
|
||||
className={`space-y-1 px-2 py-1 rounded ${
|
||||
isActive
|
||||
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
||||
: isPinned
|
||||
|
|
@ -190,7 +190,7 @@ export function VariantFilterCard({
|
|||
so the dropdown is hidden and only the window toggle + slider remain. */}
|
||||
{variantOptions.length > 1 && (
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
<label className="mb-0.5 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
{t(config.dropdownLabelKey)}
|
||||
</label>
|
||||
<div className="relative">
|
||||
|
|
@ -216,7 +216,7 @@ export function VariantFilterCard({
|
|||
{windowConfig && currentWindow && windowOptions.length > 1 && (
|
||||
<div>
|
||||
{windowConfig.labelKey && (
|
||||
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
<label className="mb-0.5 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
{t(windowConfig.labelKey)}
|
||||
</label>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ export function DesktopMapPage({
|
|||
>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">{filtersPane}</div>
|
||||
<div
|
||||
className="w-3 cursor-col-resize flex items-center justify-center group bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
|
||||
className="w-2 cursor-col-resize flex items-center justify-center group bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
|
||||
{...leftPaneHandlers}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ export type Page =
|
|||
| 'privacy'
|
||||
| 'account'
|
||||
| 'saved'
|
||||
| 'invite';
|
||||
| 'invite'
|
||||
| 'reset-password';
|
||||
|
||||
export interface HeaderExportState {
|
||||
onExport: (options?: { postcodes?: string[] }) => void;
|
||||
|
|
@ -65,6 +66,7 @@ export const PAGE_PATHS: Record<Page, string> = {
|
|||
saved: '/saved',
|
||||
account: '/account',
|
||||
invite: '/invite',
|
||||
'reset-password': '/reset-password',
|
||||
};
|
||||
|
||||
const DASHBOARD_TABLET_SIDEBAR_QUERY = '(min-width: 768px) and (max-width: 1023px)';
|
||||
|
|
@ -206,33 +208,6 @@ export default function Header({
|
|||
return (
|
||||
<>
|
||||
<header className="relative z-50 h-12 bg-navy-900 text-white flex items-center px-4 shrink-0">
|
||||
{showEditingBar && (
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 bottom-0 flex items-center justify-center px-4">
|
||||
<div className="pointer-events-auto flex items-center gap-3 max-w-[60%]">
|
||||
<span className="text-sm text-warm-300 truncate" title={editingSearch.name}>
|
||||
<Trans
|
||||
i18nKey="savedPage.isBeingUpdated"
|
||||
values={{ name: editingSearch.name }}
|
||||
components={{ strong: <strong className="font-semibold text-white" /> }}
|
||||
/>
|
||||
</span>
|
||||
<button
|
||||
onClick={onCancelEdit}
|
||||
className="cursor-pointer px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onUpdateEdit}
|
||||
disabled={savingSearch || dashboardActionsBlocked}
|
||||
className="cursor-pointer px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
|
||||
>
|
||||
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
|
||||
{savingSearch ? t('savedPage.updating') : t('common.update')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Left: Logo + nav */}
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<a
|
||||
|
|
@ -246,9 +221,9 @@ export default function Header({
|
|||
</span>
|
||||
</a>
|
||||
|
||||
{/* Desktop nav: hidden while the saved-search "is being updated" banner
|
||||
is shown so the centered pointer-events-auto banner can't overlap (and
|
||||
block clicks on) the Invite Friends / Learn / Pricing links at ~1366px. */}
|
||||
{/* Desktop nav: hidden while the saved-search "is being updated" bar is
|
||||
shown so the centered edit bar has room and the header can't get
|
||||
cramped (the bar would otherwise crowd the Learn / Pricing links). */}
|
||||
{!useSidebarNav && !showEditingBar && (
|
||||
<nav className="top-menu flex items-center">
|
||||
<a
|
||||
|
|
@ -287,6 +262,36 @@ export default function Header({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Saved-search "is being updated" bar. In normal flow (not an absolute
|
||||
overlay) between the logo and the right-side actions so flexbox
|
||||
reserves its space: the Cancel / Update buttons can never overlap the
|
||||
Share / Export buttons. Text truncates; the buttons stay put. */}
|
||||
{showEditingBar && (
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center gap-3 px-3">
|
||||
<span className="min-w-0 truncate text-sm text-warm-300" title={editingSearch.name}>
|
||||
<Trans
|
||||
i18nKey="savedPage.isBeingUpdated"
|
||||
values={{ name: editingSearch.name }}
|
||||
components={{ strong: <strong className="font-semibold text-white" /> }}
|
||||
/>
|
||||
</span>
|
||||
<button
|
||||
onClick={onCancelEdit}
|
||||
className="shrink-0 cursor-pointer px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onUpdateEdit}
|
||||
disabled={savingSearch || dashboardActionsBlocked}
|
||||
className="shrink-0 cursor-pointer px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
|
||||
>
|
||||
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
|
||||
{savingSearch ? t('savedPage.updating') : t('common.update')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right side */}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
{/* Desktop-only dashboard actions: shown to everyone; a logged-out click
|
||||
|
|
|
|||
22
frontend/src/components/ui/icons/EyeOffIcon.tsx
Normal file
22
frontend/src/components/ui/icons/EyeOffIcon.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EyeOffIcon({ className = 'w-7 h-7' }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" />
|
||||
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
|
||||
<path d="M6.61 6.61A13.526 13.526 0 0 0 1 12s4 8 11 8a9.74 9.74 0 0 0 5.39-1.61" />
|
||||
<line x1="2" y1="2" x2="22" y2="22" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ export { CloseIcon } from './CloseIcon';
|
|||
export { DownloadIcon } from './DownloadIcon';
|
||||
export { ExpandIcon } from './ExpandIcon';
|
||||
export { EyeIcon } from './EyeIcon';
|
||||
export { EyeOffIcon } from './EyeOffIcon';
|
||||
export { FilterIcon } from './FilterIcon';
|
||||
export { GoogleIcon } from './GoogleIcon';
|
||||
export { GraduationCapIcon } from './GraduationCapIcon';
|
||||
|
|
|
|||
|
|
@ -93,23 +93,23 @@ interface UseFiltersOptions {
|
|||
onFilterLimitReached?: () => void;
|
||||
}
|
||||
|
||||
// Applied in order: each normalizer folds its own raw feature names into a single
|
||||
// folded filter. Council folds AFTER tenure so a bare "% Social rent" is claimed by
|
||||
// tenure first.
|
||||
const FILTER_NORMALIZERS: Array<(filters: FeatureFilters) => FeatureFilters> = [
|
||||
normalizeSchoolFilters,
|
||||
normalizeSpecificCrimeFilters,
|
||||
normalizeCrimeSeverityFilters,
|
||||
normalizeElectionVoteShareFilters,
|
||||
normalizeEthnicityFilters,
|
||||
normalizeQualificationFilters,
|
||||
normalizeTenureFilters,
|
||||
normalizeCouncilFilters,
|
||||
normalizePoiDistanceFilters,
|
||||
];
|
||||
|
||||
function normalizeFilters(filters: FeatureFilters): FeatureFilters {
|
||||
return normalizePoiDistanceFilters(
|
||||
// Council folds AFTER tenure so a bare "% Social rent" is claimed by tenure.
|
||||
normalizeCouncilFilters(
|
||||
normalizeTenureFilters(
|
||||
normalizeQualificationFilters(
|
||||
normalizeEthnicityFilters(
|
||||
normalizeElectionVoteShareFilters(
|
||||
normalizeCrimeSeverityFilters(
|
||||
normalizeSpecificCrimeFilters(normalizeSchoolFilters(filters))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
return FILTER_NORMALIZERS.reduce((acc, normalize) => normalize(acc), filters);
|
||||
}
|
||||
|
||||
function getBackendFeatureName(name: string): string {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||
import pb from '../lib/pocketbase';
|
||||
import { apiUrl, authHeaders } from '../lib/api';
|
||||
import { trackEvent } from '../lib/analytics';
|
||||
import { stripSelectedPostcodeParam } from '../lib/url-state';
|
||||
|
||||
export interface SavedSearch {
|
||||
id: string;
|
||||
|
|
@ -141,7 +142,11 @@ export function useSavedSearches(userId: string | null) {
|
|||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = paramsOverride ?? window.location.search.replace(/^\?/, '');
|
||||
// A saved search stores filter criteria, so drop the transient
|
||||
// selected-postcode param (a search/map-click leaves it in the URL).
|
||||
const params = stripSelectedPostcodeParam(
|
||||
paramsOverride ?? window.location.search.replace(/^\?/, '')
|
||||
);
|
||||
|
||||
// Create record immediately without screenshot
|
||||
const formData = new FormData();
|
||||
|
|
@ -220,11 +225,14 @@ export function useSavedSearches(userId: string | null) {
|
|||
);
|
||||
|
||||
const updateSearchParams = useCallback(
|
||||
async (id: string, params: string) => {
|
||||
async (id: string, rawParams: string) => {
|
||||
if (!userId) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Match saveSearch: a saved search never carries the transient
|
||||
// selected-postcode param.
|
||||
const params = stripSelectedPostcodeParam(rawParams);
|
||||
const record = await pb.collection('saved_searches').update(id, { params });
|
||||
trackEvent('Search Update');
|
||||
setSearches((prev) =>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { ViewState } from '../types';
|
||||
import type { TravelTimeInitial } from '../hooks/useTravelTime';
|
||||
|
||||
export const INITIAL_RETRY_MS = 1000;
|
||||
export const MAX_RETRY_MS = 10000;
|
||||
|
|
@ -45,14 +46,32 @@ export function filterCapFor(isLoggedIn: boolean, filtersUnlimited: boolean): nu
|
|||
}
|
||||
|
||||
/** Funnel fix (growth): a cold map open lands empty, so first-time visitors never feel the value
|
||||
* or the 3-filter cap. These two high-intent filters (value for money + good secondary schools)
|
||||
* are pre-seeded when the map opens with no filters in the URL, so the map is immediately useful
|
||||
* and one more filter hits the cap. Deep links (OG screenshots, the SEO landing-page CTAs) carry
|
||||
* their own filters and are left untouched. Unknown feature names are dropped safely by useFilters.
|
||||
* Tune or empty this object to change/disable the behaviour. */
|
||||
* or the 3-filter cap. On a cold open (no filters AND no travel time in the URL) we pre-seed the
|
||||
* price filter plus a public-transport commute card, so the map is immediately useful and framed
|
||||
* around the two highest-intent decisions: budget and commute. Deep links (OG screenshots, the
|
||||
* SEO landing-page CTAs) carry their own filters/travel and are left untouched. Unknown feature
|
||||
* names are dropped safely by useFilters. Tune or empty these to change/disable the behaviour. */
|
||||
export const DEFAULT_DEMO_FILTERS: Record<string, [number, number]> = {
|
||||
// 'Est. price per sqm': [0, 7000],
|
||||
// 'Good+ secondary school catchments': [1, 11],
|
||||
'Estimated current price': [0, 600000],
|
||||
};
|
||||
|
||||
/** The public-transport commute half of the cold-open defaults. A single transit entry with no
|
||||
* destination selected: it renders the "Public Transport" card (prompting the visitor to pick a
|
||||
* destination) without filtering the map or touching the URL until they choose one. Mirrors what
|
||||
* clicking "add public transport" produces, so it flows through useTravelTime unchanged. */
|
||||
export const DEFAULT_DEMO_TRAVEL: TravelTimeInitial = {
|
||||
entries: [
|
||||
{
|
||||
mode: 'transit',
|
||||
slug: '',
|
||||
label: '',
|
||||
timeRange: null,
|
||||
useBest: false,
|
||||
noChange: false,
|
||||
oneChange: false,
|
||||
noBuses: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export const POI_DISTANCE_FILTER_KEY_PREFIX = `${POI_DISTANCE_FILTER_NAME}:`;
|
|||
|
||||
const TRANSPORT_POI_CATEGORIES = new Set([
|
||||
'Airport',
|
||||
'Any station',
|
||||
'Bus station',
|
||||
'Bus stop',
|
||||
'DLR station',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import type { FeatureMeta } from '../types';
|
||||
import { parseUrlState, stateToParams } from './url-state';
|
||||
import { parseUrlState, stateToParams, stripSelectedPostcodeParam } from './url-state';
|
||||
import { DEFAULT_OVERLAY_IDS } from './overlays';
|
||||
import { INITIAL_VIEW_STATE } from './consts';
|
||||
import { createSchoolFilterKey } from './school-filter';
|
||||
|
|
@ -60,6 +60,24 @@ describe('url-state', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('strips the selected-postcode param for saved searches, keeping filters intact', () => {
|
||||
const params =
|
||||
'lat=51.5074&lon=-0.1278&zoom=12.5&filter=Last%20known%20price:100000:500000&pc=SW1A%201AA&poi=supermarket';
|
||||
|
||||
const stripped = stripSelectedPostcodeParam(params);
|
||||
const result = new URLSearchParams(stripped);
|
||||
|
||||
expect(result.has('pc')).toBe(false);
|
||||
expect(result.get('filter')).toBe('Last known price:100000:500000');
|
||||
expect(result.get('poi')).toBe('supermarket');
|
||||
expect(result.get('lat')).toBe('51.5074');
|
||||
});
|
||||
|
||||
it('returns the query string unchanged when no selected-postcode param is present', () => {
|
||||
const params = 'filter=Last%20known%20price:100000:500000&poi=supermarket';
|
||||
expect(stripSelectedPostcodeParam(params)).toBe(params);
|
||||
});
|
||||
|
||||
it('leaves POIs unselected when URL params are omitted', () => {
|
||||
const state = parseUrlState();
|
||||
|
||||
|
|
|
|||
|
|
@ -92,6 +92,23 @@ const CRIME_TYPES_NONE_PARAM = '__none';
|
|||
const OVERLAY_NONE_PARAM = '__none';
|
||||
const CRIME_OVERLAY_ID: OverlayId = 'crime-hotspots';
|
||||
|
||||
/** URL param holding the currently focused postcode (from a search or a map
|
||||
* click). It reflects a transient selection, not search criteria, so a live or
|
||||
* shared link keeps it but a saved search must not bake it in. */
|
||||
export const SELECTED_POSTCODE_PARAM = 'pc';
|
||||
|
||||
/**
|
||||
* Drop the transient selected-postcode param from a serialized query string so a
|
||||
* saved search captures the filter criteria, not whichever postcode the user
|
||||
* last clicked. Takes and returns a query string without the leading '?'.
|
||||
*/
|
||||
export function stripSelectedPostcodeParam(params: string): string {
|
||||
const parsed = new URLSearchParams(params);
|
||||
if (!parsed.has(SELECTED_POSTCODE_PARAM)) return params;
|
||||
parsed.delete(SELECTED_POSTCODE_PARAM);
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
export interface UrlState {
|
||||
viewState: ViewState;
|
||||
/** True only when the URL carried explicit lat/lon/zoom (shared/dashboard link).
|
||||
|
|
@ -406,7 +423,7 @@ export function parseUrlState(): UrlState {
|
|||
|
||||
// Selected postcode. This is also accepted as the historical one-time
|
||||
// navigate-to-postcode param used by saved-property links.
|
||||
const pc = params.get('pc');
|
||||
const pc = params.get(SELECTED_POSTCODE_PARAM);
|
||||
if (pc) {
|
||||
result.postcode = pc;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -158,6 +158,15 @@ export interface ActualListing {
|
|||
listing_status?: string;
|
||||
listing_date_iso?: string;
|
||||
features: string[];
|
||||
price_history?: PriceHistoryPoint[];
|
||||
}
|
||||
|
||||
/** One observed point on a listing's asking-price timeline, accrued across
|
||||
* scrapes. `reason` is 'listed' | 'reduced' | 'increased'; `date` is YYYY-MM-DD. */
|
||||
export interface PriceHistoryPoint {
|
||||
date: string;
|
||||
price: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ActualListingsResponse {
|
||||
|
|
@ -303,8 +312,14 @@ export interface EnumFeatureStats {
|
|||
export interface PricePoint {
|
||||
year: number;
|
||||
price: number;
|
||||
/** Sale price per square metre (sale price / EPC floor area). Absent where no
|
||||
* floor area is recorded; the per-m² view drops those points. */
|
||||
price_per_sqm?: number;
|
||||
}
|
||||
|
||||
/** Which value a price-history chart plots. */
|
||||
export type PriceMetric = 'price' | 'price_per_sqm';
|
||||
|
||||
export interface CrimeYearPoint {
|
||||
year: number;
|
||||
count: number;
|
||||
|
|
@ -369,6 +384,12 @@ export interface HexagonStatsResponse {
|
|||
numeric_features: NumericFeatureStats[];
|
||||
enum_features: EnumFeatureStats[];
|
||||
price_history?: PricePoint[];
|
||||
/** Price history for every sale in the selection's postcode sector (e.g.
|
||||
* "E14 2"), filter-independent: wider-area context for the selection's chart. */
|
||||
sector_price_history?: PricePoint[];
|
||||
/** Price history for every sale in the selection's outward code (e.g. "E14"),
|
||||
* filter-independent. */
|
||||
outcode_price_history?: PricePoint[];
|
||||
/** Per-crime-type per-year counts averaged across the selection. */
|
||||
crime_by_year?: CrimeYearStats[];
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -7,9 +7,13 @@ from pathlib import Path
|
|||
import pytest
|
||||
|
||||
from pipeline.download.transit_network import (
|
||||
STATION_COORD_OVERRIDES,
|
||||
_repair_stop_coordinate,
|
||||
clean_national_rail_gtfs,
|
||||
convert_high_freq_to_frequency_based,
|
||||
validate_gtfs_feed,
|
||||
validate_london_coverage,
|
||||
validate_stop_geometry,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -231,9 +235,7 @@ def test_validate_gtfs_feed_zero_and_empty_coords(tmp_path: Path) -> None:
|
|||
feed = _make_gtfs(
|
||||
tmp_path / "feed.zip",
|
||||
stops=(
|
||||
"stop_id,stop_name,stop_lat,stop_lon\n"
|
||||
"STOP_A,Nowhere,0,0\n"
|
||||
"STOP_B,Blank,,\n"
|
||||
"stop_id,stop_name,stop_lat,stop_lon\nSTOP_A,Nowhere,0,0\nSTOP_B,Blank,,\n"
|
||||
),
|
||||
)
|
||||
with pytest.raises(RuntimeError, match=r"plausible UK coordinates"):
|
||||
|
|
@ -281,3 +283,254 @@ def test_validate_gtfs_feed_not_a_zip(tmp_path: Path) -> None:
|
|||
bogus.write_text("not a zip")
|
||||
with pytest.raises(RuntimeError, match="not a valid zip"):
|
||||
validate_gtfs_feed(bogus, "bogus feed", today=TODAY)
|
||||
|
||||
|
||||
# ── _repair_stop_coordinate ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"stop_id,lat,lon,expected",
|
||||
[
|
||||
# Known-bad stations get an authoritative override (TCR ships transposed,
|
||||
# BDS ships a wrong-signed longitude; both are in STATION_COORD_OVERRIDES).
|
||||
("TCR", -0.1306, 51.5163, (*STATION_COORD_OVERRIDES["TCR"], "override")),
|
||||
("BDS", 51.514, 0.15, (*STATION_COORD_OVERRIDES["BDS"], "override")),
|
||||
# A plausible UK coordinate is left untouched.
|
||||
("ZFD", 51.5205, -0.1050, (51.5205, -0.1050, "keep")),
|
||||
# An unknown station with lat/lon transposed is swapped back generically.
|
||||
("ZZZ", -0.13, 51.51, (51.51, -0.13, "transpose")),
|
||||
# Genuinely out-of-area garbage (Irish CIE South Atlantic) is neutralised.
|
||||
("IEP", -4.172, -14.5154, (54.0, -2.0, "dump")),
|
||||
# Missing coordinates are neutralised too.
|
||||
("NUL", None, None, (54.0, -2.0, "dump")),
|
||||
],
|
||||
)
|
||||
def test_repair_stop_coordinate(stop_id, lat, lon, expected) -> None:
|
||||
assert _repair_stop_coordinate(stop_id, lat, lon) == expected
|
||||
|
||||
|
||||
def test_clean_national_rail_repairs_broken_station_coords(tmp_path: Path) -> None:
|
||||
"""End-to-end: the cleaner repairs the exact TCR/BDS failure modes.
|
||||
|
||||
TCR (transposed) and BDS (wrong-signed lon) are corrected to their override
|
||||
coordinates; an unknown transposed stop is swapped back; genuine out-of-area
|
||||
garbage is dumped; a good coordinate is preserved.
|
||||
"""
|
||||
src = tmp_path / "in.zip"
|
||||
dst = tmp_path / "out.zip"
|
||||
stops = (
|
||||
"stop_id,stop_name,stop_lat,stop_lon\n"
|
||||
"TCR,Tottenham Court Road (Elizabeth line),-0.1306,51.5163\n"
|
||||
"BDS,Bond Street (Elizabeth line),51.514,0.15\n"
|
||||
"ZZZ,Transposed Halt,-0.20,51.40\n"
|
||||
"IEP,Cork (CIE),-4.172,-14.5154\n"
|
||||
"GUD,Good Station,51.50,-0.10\n"
|
||||
)
|
||||
with zipfile.ZipFile(src, "w") as z:
|
||||
z.writestr("stops.txt", stops)
|
||||
z.writestr("routes.txt", "route_id,route_type\nR1,2\n")
|
||||
z.writestr("trips.txt", "trip_id,route_id,service_id\nT1,R1,S1\n")
|
||||
z.writestr(
|
||||
"stop_times.txt",
|
||||
"trip_id,stop_id,stop_sequence,departure_time\n"
|
||||
"T1,TCR,1,06:00:00\n"
|
||||
"T1,BDS,2,06:03:00\n"
|
||||
"T1,GUD,3,06:06:00\n",
|
||||
)
|
||||
|
||||
clean_national_rail_gtfs(src, dst)
|
||||
|
||||
with zipfile.ZipFile(dst, "r") as z:
|
||||
rows = z.read("stops.txt").decode("utf-8").splitlines()
|
||||
coords = {r.split(",")[0]: r.split(",")[-2:] for r in rows[1:]}
|
||||
assert (
|
||||
float(coords["TCR"][0]),
|
||||
float(coords["TCR"][1]),
|
||||
) == STATION_COORD_OVERRIDES["TCR"]
|
||||
assert (
|
||||
float(coords["BDS"][0]),
|
||||
float(coords["BDS"][1]),
|
||||
) == STATION_COORD_OVERRIDES["BDS"]
|
||||
assert (float(coords["ZZZ"][0]), float(coords["ZZZ"][1])) == (51.40, -0.20)
|
||||
assert (float(coords["IEP"][0]), float(coords["IEP"][1])) == (54.0, -2.0)
|
||||
assert (float(coords["GUD"][0]), float(coords["GUD"][1])) == (51.50, -0.10)
|
||||
|
||||
|
||||
# ── validate_stop_geometry ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _geometry_gtfs(path: Path, *, n_trips: int, b_lat: float, b_lon: float) -> Path:
|
||||
"""A metro line A–B–C (5-minute hops) repeated across n_trips.
|
||||
|
||||
Displacing B far from A and C makes it a displacement outlier; n_trips sets
|
||||
its service level (the hard-fail vs warn tier).
|
||||
"""
|
||||
routes = "route_id,route_type\nR1,1\n"
|
||||
trips = "trip_id,route_id,service_id\n" + "".join(
|
||||
f"T{i},R1,S1\n" for i in range(n_trips)
|
||||
)
|
||||
stops = (
|
||||
"stop_id,stop_name,stop_lat,stop_lon\n"
|
||||
"A,Aaa,51.50,-0.10\n"
|
||||
f"B,Bbb,{b_lat},{b_lon}\n"
|
||||
"C,Ccc,51.52,-0.10\n"
|
||||
)
|
||||
header = "trip_id,stop_id,stop_sequence,arrival_time,departure_time\n"
|
||||
body = "".join(
|
||||
f"T{i},A,0,06:00:00,06:00:00\n"
|
||||
f"T{i},B,1,06:05:00,06:05:00\n"
|
||||
f"T{i},C,2,06:10:00,06:10:00\n"
|
||||
for i in range(n_trips)
|
||||
)
|
||||
with zipfile.ZipFile(path, "w") as z:
|
||||
z.writestr("routes.txt", routes)
|
||||
z.writestr("trips.txt", trips)
|
||||
z.writestr("stops.txt", stops)
|
||||
z.writestr("stop_times.txt", header + body)
|
||||
return path
|
||||
|
||||
|
||||
def test_validate_stop_geometry_fails_on_high_service_displacement(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A busy stop whose trains imply teleportation fails the build (TCR mode)."""
|
||||
feed = _geometry_gtfs(tmp_path / "feed.zip", n_trips=120, b_lat=58.0, b_lon=-2.0)
|
||||
with pytest.raises(RuntimeError, match="stop-geometry validation failed"):
|
||||
validate_stop_geometry(feed, "displaced feed")
|
||||
|
||||
|
||||
def test_validate_stop_geometry_passes_when_stops_are_coherent(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
feed = _geometry_gtfs(tmp_path / "feed.zip", n_trips=120, b_lat=51.51, b_lon=-0.10)
|
||||
validate_stop_geometry(feed, "coherent feed") # must not raise
|
||||
|
||||
|
||||
def test_validate_stop_geometry_only_warns_on_low_service_displacement(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Heritage-line quirks (few trips) warn but do not block a build."""
|
||||
feed = _geometry_gtfs(tmp_path / "feed.zip", n_trips=6, b_lat=58.0, b_lon=-2.0)
|
||||
validate_stop_geometry(feed, "heritage feed") # must not raise
|
||||
|
||||
|
||||
# ── validate_london_coverage ──────────────────────────────────────────────────
|
||||
|
||||
_ALL_LU = (
|
||||
"Bakerloo",
|
||||
"Central",
|
||||
"Circle",
|
||||
"District",
|
||||
"Hammersmith & City",
|
||||
"Jubilee",
|
||||
"Metropolitan",
|
||||
"Northern",
|
||||
"Piccadilly",
|
||||
"Victoria",
|
||||
"Waterloo & City",
|
||||
)
|
||||
|
||||
_COVERAGE_CALENDAR = (
|
||||
"service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,"
|
||||
"start_date,end_date\n"
|
||||
"S1,1,1,1,1,1,1,1,20260101,20271231\n"
|
||||
)
|
||||
|
||||
|
||||
def _bods_coverage(
|
||||
path: Path,
|
||||
*,
|
||||
lu_lines: tuple[str, ...] = _ALL_LU,
|
||||
include_dlr: bool = True,
|
||||
include_tramlink: bool = True,
|
||||
) -> Path:
|
||||
routes = ["route_id,agency_id,route_short_name,route_long_name,route_type"]
|
||||
trips = ["trip_id,route_id,service_id"]
|
||||
n = 0
|
||||
for line in lu_lines:
|
||||
routes.append(f"LU{n},LU,{line},,1")
|
||||
trips.append(f"T{n},LU{n},S1")
|
||||
n += 1
|
||||
if include_dlr:
|
||||
routes.append(f"DLR{n},DLRA,DLR,Docklands Light Railway,2")
|
||||
trips.append(f"T{n},DLR{n},S1")
|
||||
n += 1
|
||||
if include_tramlink:
|
||||
routes.append(f"TR{n},TRAM,Tram,London Tramlink,0")
|
||||
trips.append(f"T{n},TR{n},S1")
|
||||
n += 1
|
||||
with zipfile.ZipFile(path, "w") as z:
|
||||
z.writestr("calendar.txt", _COVERAGE_CALENDAR)
|
||||
z.writestr("routes.txt", "\n".join(routes) + "\n")
|
||||
z.writestr("trips.txt", "\n".join(trips) + "\n")
|
||||
return path
|
||||
|
||||
|
||||
def _nr_coverage(
|
||||
path: Path, *, include_elizabeth: bool = True, include_overground: bool = True
|
||||
) -> Path:
|
||||
routes = ["route_id,agency_id,route_short_name,route_long_name,route_type"]
|
||||
trips = ["trip_id,route_id,service_id"]
|
||||
if include_elizabeth:
|
||||
routes.append("XR1,XR,XR:PAD->ABW,Elizabeth line,2")
|
||||
trips.append("TX,XR1,S1")
|
||||
if include_overground:
|
||||
routes.append("LO1,LO,LO,London Overground,2")
|
||||
trips.append("TL,LO1,S1")
|
||||
with zipfile.ZipFile(path, "w") as z:
|
||||
z.writestr("calendar.txt", _COVERAGE_CALENDAR)
|
||||
z.writestr("routes.txt", "\n".join(routes) + "\n")
|
||||
z.writestr("trips.txt", "\n".join(trips) + "\n")
|
||||
return path
|
||||
|
||||
|
||||
def test_validate_london_coverage_happy_path(tmp_path: Path) -> None:
|
||||
bods = _bods_coverage(tmp_path / "bods.zip")
|
||||
nr = _nr_coverage(tmp_path / "nr.zip")
|
||||
validate_london_coverage(bods, nr, today=TODAY) # must not raise
|
||||
|
||||
|
||||
def test_validate_london_coverage_missing_tube_line_fails(tmp_path: Path) -> None:
|
||||
bods = _bods_coverage(
|
||||
tmp_path / "bods.zip",
|
||||
lu_lines=tuple(name for name in _ALL_LU if name != "Victoria"),
|
||||
)
|
||||
nr = _nr_coverage(tmp_path / "nr.zip")
|
||||
with pytest.raises(RuntimeError, match="Victoria"):
|
||||
validate_london_coverage(bods, nr, today=TODAY)
|
||||
|
||||
|
||||
def test_validate_london_coverage_missing_dlr_fails(tmp_path: Path) -> None:
|
||||
bods = _bods_coverage(tmp_path / "bods.zip", include_dlr=False)
|
||||
nr = _nr_coverage(tmp_path / "nr.zip")
|
||||
with pytest.raises(RuntimeError, match="DLR"):
|
||||
validate_london_coverage(bods, nr, today=TODAY)
|
||||
|
||||
|
||||
def test_validate_london_coverage_missing_elizabeth_fails(tmp_path: Path) -> None:
|
||||
bods = _bods_coverage(tmp_path / "bods.zip")
|
||||
nr = _nr_coverage(tmp_path / "nr.zip", include_elizabeth=False)
|
||||
with pytest.raises(RuntimeError, match="Elizabeth line"):
|
||||
validate_london_coverage(bods, nr, today=TODAY)
|
||||
|
||||
|
||||
def test_validate_london_coverage_expired_service_fails(tmp_path: Path) -> None:
|
||||
"""A line whose only calendar expired years ago counts as missing (present in
|
||||
routes.txt but with no active service — the retired-TfL-feed failure mode)."""
|
||||
bods = _bods_coverage(tmp_path / "bods.zip")
|
||||
nr = tmp_path / "nr.zip"
|
||||
expired = (
|
||||
"service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,"
|
||||
"start_date,end_date\n"
|
||||
"S1,1,1,1,1,1,1,1,20091201,20101224\n"
|
||||
)
|
||||
with zipfile.ZipFile(nr, "w") as z:
|
||||
z.writestr("calendar.txt", expired)
|
||||
z.writestr(
|
||||
"routes.txt",
|
||||
"route_id,agency_id,route_short_name,route_long_name,route_type\n"
|
||||
"XR1,XR,XR:PAD->ABW,Elizabeth line,2\nLO1,LO,LO,London Overground,2\n",
|
||||
)
|
||||
z.writestr("trips.txt", "trip_id,route_id,service_id\nTX,XR1,S1\nTL,LO1,S1\n")
|
||||
with pytest.raises(RuntimeError, match="Elizabeth line|Overground"):
|
||||
validate_london_coverage(bods, nr, today=TODAY)
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import zipfile
|
|||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
import polars as pl
|
||||
from tqdm import tqdm
|
||||
|
||||
from pipeline.local_temp import local_tmp_dir
|
||||
|
|
@ -66,6 +67,56 @@ GTFS_MIN_VALID_STOP_FRACTION = 0.95
|
|||
UK_LAT_RANGE = (49.0, 61.0)
|
||||
UK_LON_RANGE = (-9.0, 2.5)
|
||||
|
||||
# Authoritative (lat, lon) for stops that upstream feeds ship mislocated, keyed
|
||||
# by GTFS stop_id (National Rail CRS code). The National Rail CIF → GTFS export
|
||||
# ships the Elizabeth-line-only core stations broken: Tottenham Court Road (TCR)
|
||||
# has its lat/lon transposed and Bond Street (BDS) has a wrong-signed longitude,
|
||||
# leaving them ~300km and ~20km from reality. Both then fail to link to the
|
||||
# street network, so nobody can board/alight the Elizabeth line there and every
|
||||
# journey through them silently reroutes (e.g. TCR→Heathrow via the Central line
|
||||
# + Heathrow Express instead of one seat on the Elizabeth line). Coordinates are
|
||||
# the TfL/BODS station nodes for the same physical stations. New breakages that
|
||||
# these overrides don't cover are caught by validate_stop_geometry() below, which
|
||||
# fails the build rather than shipping a phantom stop.
|
||||
STATION_COORD_OVERRIDES = {
|
||||
"TCR": (51.51643, -0.13041), # Tottenham Court Road (Elizabeth line)
|
||||
"BDS": (51.51430, -0.14972), # Bond Street (Elizabeth line)
|
||||
}
|
||||
|
||||
# validate_stop_geometry(): a served tram/metro/rail stop is a "displacement
|
||||
# outlier" when, over real timetabled hops (>= GEOMETRY_MIN_HOP_SECONDS, so that
|
||||
# coarse timetables where adjacent stops share a minute don't count), the implied
|
||||
# in-vehicle speed to a MAJORITY of its distinct trip-neighbours exceeds
|
||||
# GEOMETRY_MAX_KMH. Such a stop sits nowhere near where its trains actually run
|
||||
# (the TCR failure mode). Outliers with >= GEOMETRY_HARDFAIL_MIN_TRIPS timetabled
|
||||
# trips FAIL the build; rarer ones (heritage lines) only warn. Scoped to
|
||||
# rail/metro/tram route_types: buses carry demand-responsive "area" stops and
|
||||
# same-minute urban hops that are noisy without being real geometry errors, and
|
||||
# the R5-side unlinked-stop check covers gross bus displacement anyway.
|
||||
GEOMETRY_RAIL_ROUTE_TYPES = frozenset({"0", "1", "2"})
|
||||
GEOMETRY_MIN_HOP_SECONDS = 120
|
||||
GEOMETRY_MAX_KMH = 300.0
|
||||
GEOMETRY_HARDFAIL_MIN_TRIPS = 100
|
||||
|
||||
# London modes/lines that must be present with active service in the combined
|
||||
# network. A feed regression that silently drops one (as the retired TfL
|
||||
# TransXChange feed did — see module docstring) FAILS the build instead of
|
||||
# quietly degrading every affected journey. Underground lines and Tramlink/DLR
|
||||
# come from BODS; the Elizabeth line and Overground come from National Rail.
|
||||
LONDON_UNDERGROUND_LINES = (
|
||||
"Bakerloo",
|
||||
"Central",
|
||||
"Circle",
|
||||
"District",
|
||||
"Hammersmith & City",
|
||||
"Jubilee",
|
||||
"Metropolitan",
|
||||
"Northern",
|
||||
"Piccadilly",
|
||||
"Victoria",
|
||||
"Waterloo & City",
|
||||
)
|
||||
|
||||
|
||||
def _download_http(
|
||||
url: str, dest: Path, *, desc: str, headers: dict | None = None
|
||||
|
|
@ -804,6 +855,8 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
|
|||
bad_trips_removed = 0
|
||||
seqs_renumbered = 0
|
||||
coords_fixed = 0
|
||||
coords_overridden = 0
|
||||
coords_transposed = 0
|
||||
route_types_fixed = 0
|
||||
|
||||
with (
|
||||
|
|
@ -890,6 +943,7 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
|
|||
with zin.open(info) as f:
|
||||
header = f.readline()
|
||||
cols = _parse_csv_line(header)
|
||||
stop_id_idx = cols.index("stop_id")
|
||||
lat_idx = cols.index("stop_lat")
|
||||
lon_idx = cols.index("stop_lon")
|
||||
|
||||
|
|
@ -905,16 +959,24 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
|
|||
parts = _parse_csv_line(line)
|
||||
if not parts:
|
||||
continue
|
||||
stop_id = parts[stop_id_idx].strip('"')
|
||||
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"
|
||||
lon = float(parts[lon_idx])
|
||||
except (ValueError, IndexError):
|
||||
lat = lon = None
|
||||
new_lat, new_lon, action = _repair_stop_coordinate(
|
||||
stop_id, lat, lon
|
||||
)
|
||||
if action != "keep":
|
||||
parts[lat_idx] = repr(new_lat)
|
||||
parts[lon_idx] = repr(new_lon)
|
||||
if action == "override":
|
||||
coords_overridden += 1
|
||||
elif action == "transpose":
|
||||
coords_transposed += 1
|
||||
else: # "dump"
|
||||
coords_fixed += 1
|
||||
except ValueError:
|
||||
pass
|
||||
tmp.write(_format_csv_row(parts))
|
||||
|
||||
tmp.close()
|
||||
|
|
@ -1014,7 +1076,9 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
|
|||
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" Coordinates overridden (known-bad stations): {coords_overridden}")
|
||||
print(f" Coordinates de-transposed (lat/lon swapped): {coords_transposed}")
|
||||
print(f" Bogus coordinates dumped (out-of-area): {coords_fixed}")
|
||||
print(f" Route types 714→3 fixed: {route_types_fixed}")
|
||||
print(f" Saved to {dst}")
|
||||
|
||||
|
|
@ -1139,6 +1203,410 @@ def convert_national_rail_to_gtfs(raw_dir: Path, output_dir: Path) -> Path:
|
|||
return dest
|
||||
|
||||
|
||||
def _in_uk(lat: float, lon: float) -> bool:
|
||||
"""True if (lat, lon) falls inside the coarse UK routing bounding box."""
|
||||
return (
|
||||
UK_LAT_RANGE[0] <= lat <= UK_LAT_RANGE[1]
|
||||
and UK_LON_RANGE[0] <= lon <= UK_LON_RANGE[1]
|
||||
)
|
||||
|
||||
|
||||
def _repair_stop_coordinate(
|
||||
stop_id: str, lat: float | None, lon: float | None
|
||||
) -> tuple[float, float, str]:
|
||||
"""Repair an obviously-broken stop coordinate. Returns (lat, lon, action).
|
||||
|
||||
action is one of:
|
||||
"override" - an authoritative coordinate was substituted for a known-bad
|
||||
station (see STATION_COORD_OVERRIDES).
|
||||
"transpose" - the feed shipped lat/lon swapped (the coordinate is outside
|
||||
the UK but swapping lands inside it), so they are swapped
|
||||
back. This is the Tottenham Court Road failure mode and is
|
||||
handled generically, not just for the hard-coded stations.
|
||||
"dump" - the coordinate is genuinely out of area (Irish CIE stations
|
||||
at 0,0-ish South Atlantic garbage, missing coordinates) and
|
||||
is moved to a neutral inland point that will not be routed
|
||||
to, preserving the historical behaviour for those stops.
|
||||
"keep" - already a plausible UK coordinate; left unchanged.
|
||||
"""
|
||||
if stop_id in STATION_COORD_OVERRIDES:
|
||||
return (*STATION_COORD_OVERRIDES[stop_id], "override")
|
||||
if lat is None or lon is None:
|
||||
return 54.0, -2.0, "dump"
|
||||
if _in_uk(lat, lon):
|
||||
return lat, lon, "keep"
|
||||
if _in_uk(lon, lat):
|
||||
return lon, lat, "transpose"
|
||||
return 54.0, -2.0, "dump"
|
||||
|
||||
|
||||
def _secs_expr(col: str) -> pl.Expr:
|
||||
"""Polars expression parsing an HH:MM:SS GTFS time to seconds since midnight."""
|
||||
parts = pl.col(col).str.split(":")
|
||||
return (
|
||||
parts.list.get(0).cast(pl.Int64, strict=False) * 3600
|
||||
+ parts.list.get(1).cast(pl.Int64, strict=False) * 60
|
||||
+ parts.list.get(2).cast(pl.Int64, strict=False)
|
||||
)
|
||||
|
||||
|
||||
def _extract_member(path: Path, member: str, dest_dir: str) -> Path:
|
||||
"""Stream one file out of a GTFS zip to dest_dir (avoids holding it in RAM)."""
|
||||
out = Path(dest_dir) / member
|
||||
with zipfile.ZipFile(path) as z, z.open(member) as src, open(out, "wb") as dst:
|
||||
shutil.copyfileobj(src, dst)
|
||||
return out
|
||||
|
||||
|
||||
def validate_stop_geometry(path: Path, feed_name: str) -> None:
|
||||
"""Fail if a served rail/metro/tram stop is a coordinate displacement outlier.
|
||||
|
||||
Guards against the Tottenham Court Road failure mode: a stop whose timetabled
|
||||
trains imply teleportation (>{GEOMETRY_MAX_KMH:.0f} km/h over a real hop) to a
|
||||
MAJORITY of its distinct trip-neighbours is not where the feed places it, so
|
||||
it cannot link to the street network and every journey through it silently
|
||||
reroutes. High-service outliers (>= {GEOMETRY_HARDFAIL_MIN_TRIPS} trips) raise;
|
||||
negligible-service ones (heritage lines) only warn. Scoped to tram/metro/rail
|
||||
route types; see GEOMETRY_* constants.
|
||||
"""
|
||||
print(f"Validating stop geometry for feed '{feed_name}'...")
|
||||
with tempfile.TemporaryDirectory(dir=local_tmp_dir()) as td:
|
||||
routes = pl.read_csv(
|
||||
_extract_member(path, "routes.txt", td), infer_schema_length=0
|
||||
).select(
|
||||
pl.col("route_id").cast(pl.Utf8),
|
||||
pl.col("route_type").cast(pl.Utf8),
|
||||
)
|
||||
rail_route_ids = routes.filter(
|
||||
pl.col("route_type").is_in(list(GEOMETRY_RAIL_ROUTE_TYPES))
|
||||
).select("route_id")
|
||||
trips = pl.read_csv(
|
||||
_extract_member(path, "trips.txt", td), infer_schema_length=0
|
||||
).select(
|
||||
pl.col("trip_id").cast(pl.Utf8),
|
||||
pl.col("route_id").cast(pl.Utf8),
|
||||
)
|
||||
rail_trips = trips.join(rail_route_ids, on="route_id", how="inner").select(
|
||||
"trip_id"
|
||||
)
|
||||
if rail_trips.height == 0:
|
||||
print(" no rail/metro/tram trips in feed; nothing to check")
|
||||
return
|
||||
stops = pl.read_csv(
|
||||
_extract_member(path, "stops.txt", td), infer_schema_length=0
|
||||
).select(
|
||||
pl.col("stop_id").cast(pl.Utf8),
|
||||
pl.col("stop_name").cast(pl.Utf8).alias("name"),
|
||||
pl.col("stop_lat").cast(pl.Float64, strict=False).alias("lat"),
|
||||
pl.col("stop_lon").cast(pl.Float64, strict=False).alias("lon"),
|
||||
)
|
||||
|
||||
# Only rail/metro/tram stop_times, ordered within each trip.
|
||||
st = (
|
||||
pl.scan_csv(
|
||||
_extract_member(path, "stop_times.txt", td), infer_schema_length=0
|
||||
)
|
||||
.select(
|
||||
pl.col("trip_id").cast(pl.Utf8),
|
||||
pl.col("stop_id").cast(pl.Utf8),
|
||||
pl.col("stop_sequence").cast(pl.Int64, strict=False).alias("seq"),
|
||||
_secs_expr("departure_time").alias("dep"),
|
||||
_secs_expr("arrival_time").alias("arr"),
|
||||
)
|
||||
.join(rail_trips.lazy(), on="trip_id", how="inner")
|
||||
.join(
|
||||
stops.lazy().select(["stop_id", "lat", "lon"]), on="stop_id", how="left"
|
||||
)
|
||||
.collect()
|
||||
.sort(["trip_id", "seq"])
|
||||
)
|
||||
|
||||
# Service level: distinct trips serving each stop (the hard-fail tier).
|
||||
svc = st.group_by("stop_id").agg(pl.col("trip_id").n_unique().alias("trips"))
|
||||
|
||||
# Consecutive-stop hops within a trip.
|
||||
st = st.with_columns(
|
||||
pl.col("lat").shift(1).over("trip_id").alias("plat"),
|
||||
pl.col("lon").shift(1).over("trip_id").alias("plon"),
|
||||
pl.col("stop_id").shift(1).over("trip_id").alias("pid"),
|
||||
pl.col("dep").shift(1).over("trip_id").alias("pdep"),
|
||||
)
|
||||
earth_km = 6371.0
|
||||
dlat = (pl.col("lat") - pl.col("plat")).radians()
|
||||
dlon = (pl.col("lon") - pl.col("plon")).radians()
|
||||
hav = (dlat / 2).sin() ** 2 + pl.col("plat").radians().cos() * pl.col(
|
||||
"lat"
|
||||
).radians().cos() * (dlon / 2).sin() ** 2
|
||||
hops = (
|
||||
st.with_columns(
|
||||
(2 * earth_km * hav.sqrt().arcsin()).alias("dist_km"),
|
||||
(pl.col("arr") - pl.col("pdep")).alias("dt_s"),
|
||||
)
|
||||
.filter(
|
||||
pl.col("plat").is_not_null()
|
||||
& pl.col("dist_km").is_not_null()
|
||||
& (pl.col("dt_s") >= GEOMETRY_MIN_HOP_SECONDS)
|
||||
)
|
||||
.with_columns((pl.col("dist_km") / (pl.col("dt_s") / 3600.0)).alias("kmh"))
|
||||
)
|
||||
if hops.height == 0:
|
||||
print(" no timetabled hops long enough to assess; skipping")
|
||||
return
|
||||
|
||||
# Undirected stop-neighbour edges, flagged if any hop teleports.
|
||||
fwd = hops.select(
|
||||
pl.col("stop_id").alias("a"),
|
||||
pl.col("pid").alias("b"),
|
||||
(pl.col("kmh") > GEOMETRY_MAX_KMH).alias("tp"),
|
||||
)
|
||||
rev = fwd.select(pl.col("b").alias("a"), pl.col("a").alias("b"), "tp")
|
||||
edges = (
|
||||
pl.concat([fwd, rev])
|
||||
.group_by(["a", "b"])
|
||||
.agg(pl.col("tp").max().alias("tp"))
|
||||
)
|
||||
per_stop = edges.group_by("a").agg(
|
||||
pl.len().alias("nbrs"), pl.col("tp").sum().alias("tp_nbrs")
|
||||
)
|
||||
outliers = (
|
||||
per_stop.filter(
|
||||
(pl.col("nbrs") >= 2) & (pl.col("tp_nbrs") / pl.col("nbrs") >= 0.5)
|
||||
)
|
||||
.join(stops, left_on="a", right_on="stop_id", how="left")
|
||||
.join(svc, left_on="a", right_on="stop_id", how="left")
|
||||
.with_columns(pl.col("trips").fill_null(0))
|
||||
.sort("trips", descending=True)
|
||||
)
|
||||
|
||||
hard = outliers.filter(pl.col("trips") >= GEOMETRY_HARDFAIL_MIN_TRIPS)
|
||||
soft = outliers.filter(pl.col("trips") < GEOMETRY_HARDFAIL_MIN_TRIPS)
|
||||
for r in soft.iter_rows(named=True):
|
||||
print(
|
||||
f" WARN low-service displacement outlier: {r['a']} "
|
||||
f"'{r['name']}' ({r['trips']} trips) at ({r['lat']}, {r['lon']})"
|
||||
)
|
||||
if hard.height > 0:
|
||||
lines = [
|
||||
f" {r['a']} '{r['name']}' ({r['trips']} trips) "
|
||||
f"at ({r['lat']}, {r['lon']})"
|
||||
for r in hard.iter_rows(named=True)
|
||||
]
|
||||
raise RuntimeError(
|
||||
f"stop-geometry validation failed for feed '{feed_name}': "
|
||||
f"{hard.height} high-service rail/metro/tram stop(s) sit nowhere "
|
||||
f"near where their trains run (implied speed > {GEOMETRY_MAX_KMH:.0f} "
|
||||
f"km/h to most neighbours). These cannot link to the street network "
|
||||
f"and every journey through them silently reroutes. Add an entry to "
|
||||
f"STATION_COORD_OVERRIDES or fix the upstream feed:\n"
|
||||
+ "\n".join(lines)
|
||||
)
|
||||
print(
|
||||
f" OK: {outliers.height} displacement outlier(s), none at or above "
|
||||
f"{GEOMETRY_HARDFAIL_MIN_TRIPS} trips"
|
||||
)
|
||||
|
||||
|
||||
def _active_service_ids(path: Path, window_start: int, window_end: int) -> set[str]:
|
||||
"""Service ids with at least one running day in [window_start, window_end]."""
|
||||
active: set[str] = set()
|
||||
weekdays = (
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
"sunday",
|
||||
)
|
||||
with zipfile.ZipFile(path) as z:
|
||||
names = set(z.namelist())
|
||||
if "calendar.txt" in names:
|
||||
with z.open("calendar.txt") as f:
|
||||
cols = _parse_csv_line(f.readline())
|
||||
sid_i = cols.index("service_id")
|
||||
start_i = cols.index("start_date")
|
||||
end_i = cols.index("end_date")
|
||||
day_i = [cols.index(d) for d in weekdays if d in cols]
|
||||
for line in f:
|
||||
parts = _parse_csv_line(line)
|
||||
if not parts:
|
||||
continue
|
||||
try:
|
||||
start = int(parts[start_i].strip('"'))
|
||||
end = int(parts[end_i].strip('"'))
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
if start > window_end or end < window_start:
|
||||
continue
|
||||
if day_i and not any(
|
||||
parts[i].strip('"') == "1" for i in day_i if i < len(parts)
|
||||
):
|
||||
continue
|
||||
active.add(parts[sid_i].strip('"'))
|
||||
if "calendar_dates.txt" in names:
|
||||
with z.open("calendar_dates.txt") as f:
|
||||
cols = _parse_csv_line(f.readline())
|
||||
sid_i = cols.index("service_id")
|
||||
date_i = cols.index("date")
|
||||
exc_i = cols.index("exception_type")
|
||||
for line in f:
|
||||
parts = _parse_csv_line(line)
|
||||
if not parts:
|
||||
continue
|
||||
try:
|
||||
date = int(parts[date_i].strip('"'))
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
if exc_i < len(parts) and parts[exc_i].strip('"') != "1":
|
||||
continue
|
||||
if window_start <= date <= window_end:
|
||||
active.add(parts[sid_i].strip('"'))
|
||||
return active
|
||||
|
||||
|
||||
def _lines_with_active_service(
|
||||
path: Path,
|
||||
active_services: set[str],
|
||||
*,
|
||||
route_predicate,
|
||||
) -> set[str]:
|
||||
"""Return the set of route labels (agency_id::short_name matched by
|
||||
route_predicate) that have at least one trip on an active service.
|
||||
|
||||
route_predicate((agency_id, route_type, short_name, long_name)) -> label|None.
|
||||
A returned label marks the route as one we care about; None ignores it.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory(dir=local_tmp_dir()) as td:
|
||||
routes = pl.read_csv(
|
||||
_extract_member(path, "routes.txt", td), infer_schema_length=0
|
||||
)
|
||||
route_label: dict[str, str] = {}
|
||||
for r in routes.iter_rows(named=True):
|
||||
label = route_predicate(
|
||||
(
|
||||
str(r.get("agency_id", "")),
|
||||
str(r.get("route_type", "")),
|
||||
str(r.get("route_short_name", "")),
|
||||
str(r.get("route_long_name", "")),
|
||||
)
|
||||
)
|
||||
if label is not None:
|
||||
route_label[str(r["route_id"])] = label
|
||||
|
||||
if not route_label:
|
||||
return set()
|
||||
|
||||
trips = pl.read_csv(
|
||||
_extract_member(path, "trips.txt", td), infer_schema_length=0
|
||||
).select(
|
||||
pl.col("route_id").cast(pl.Utf8),
|
||||
pl.col("service_id").cast(pl.Utf8),
|
||||
)
|
||||
present: set[str] = set()
|
||||
for row in trips.iter_rows():
|
||||
route_id, service_id = str(row[0]), str(row[1])
|
||||
label = route_label.get(route_id)
|
||||
if label is not None and service_id in active_services:
|
||||
present.add(label)
|
||||
return present
|
||||
|
||||
|
||||
def validate_london_coverage(
|
||||
bods_path: Path, nr_path: Path, *, today: dt.date | None = None
|
||||
) -> None:
|
||||
"""Fail if any must-have London line/mode lacks active service in the window.
|
||||
|
||||
A silent regression that drops the Underground, DLR, Tramlink, the Elizabeth
|
||||
line or the Overground (as the retired TfL TransXChange feed did) would leave
|
||||
the map quietly under-serving huge swaths of journeys. This turns that into a
|
||||
hard build failure. See LONDON_UNDERGROUND_LINES.
|
||||
"""
|
||||
if today is None:
|
||||
today = dt.date.today()
|
||||
window_start = int(today.strftime("%Y%m%d"))
|
||||
window_end = int(
|
||||
(today + dt.timedelta(days=GTFS_CALENDAR_LOOKAHEAD_DAYS)).strftime("%Y%m%d")
|
||||
)
|
||||
print("Validating London mode/line coverage...")
|
||||
|
||||
bods_active = _active_service_ids(bods_path, window_start, window_end)
|
||||
nr_active = _active_service_ids(nr_path, window_start, window_end)
|
||||
|
||||
# BODS underground lines: agency 'London Underground (TfL)', route_type=1.
|
||||
def lu_pred(row):
|
||||
agency_id, route_type, short, _long = row
|
||||
return (
|
||||
short if route_type == "1" and short in LONDON_UNDERGROUND_LINES else None
|
||||
)
|
||||
|
||||
# DLR: metro/light-rail named DLR (agency 'London Docklands Light Railway').
|
||||
def dlr_pred(row):
|
||||
_agency_id, _route_type, short, long = row
|
||||
text = f"{short} {long}".lower()
|
||||
return "DLR" if ("dlr" in text or "docklands light" in text) else None
|
||||
|
||||
# London Tramlink tram service.
|
||||
def tramlink_pred(row):
|
||||
_agency_id, route_type, short, long = row
|
||||
text = f"{short} {long}".lower()
|
||||
return "Tramlink" if (route_type == "0" and "tram" in text) else None
|
||||
|
||||
lu_present = _lines_with_active_service(
|
||||
bods_path, bods_active, route_predicate=lu_pred
|
||||
)
|
||||
dlr_present = _lines_with_active_service(
|
||||
bods_path, bods_active, route_predicate=dlr_pred
|
||||
)
|
||||
tramlink_present = _lines_with_active_service(
|
||||
bods_path, bods_active, route_predicate=tramlink_pred
|
||||
)
|
||||
|
||||
# National Rail: Elizabeth line (agency_id XR), Overground (agency_id LO).
|
||||
def nr_agency_pred(target_id, label):
|
||||
def pred(row):
|
||||
agency_id, _route_type, _short, _long = row
|
||||
return label if agency_id == target_id else None
|
||||
|
||||
return pred
|
||||
|
||||
elizabeth_present = _lines_with_active_service(
|
||||
nr_path, nr_active, route_predicate=nr_agency_pred("XR", "Elizabeth line")
|
||||
)
|
||||
overground_present = _lines_with_active_service(
|
||||
nr_path, nr_active, route_predicate=nr_agency_pred("LO", "London Overground")
|
||||
)
|
||||
|
||||
problems: list[str] = []
|
||||
missing_lu = [line for line in LONDON_UNDERGROUND_LINES if line not in lu_present]
|
||||
if missing_lu:
|
||||
problems.append(
|
||||
"London Underground lines missing/without active service: "
|
||||
+ ", ".join(missing_lu)
|
||||
)
|
||||
if not dlr_present:
|
||||
problems.append("DLR missing or without active service (BODS)")
|
||||
if not tramlink_present:
|
||||
problems.append("London Tramlink missing or without active service (BODS)")
|
||||
if not elizabeth_present:
|
||||
problems.append(
|
||||
"Elizabeth line missing or without active service (National Rail, XR)"
|
||||
)
|
||||
if not overground_present:
|
||||
problems.append(
|
||||
"London Overground missing or without active service (National Rail, LO)"
|
||||
)
|
||||
|
||||
if problems:
|
||||
raise RuntimeError(
|
||||
"London coverage validation failed (window "
|
||||
f"{window_start}-{window_end}):\n " + "\n ".join(problems)
|
||||
)
|
||||
print(
|
||||
f" OK: {len(lu_present)}/{len(LONDON_UNDERGROUND_LINES)} Underground lines, "
|
||||
"DLR, Tramlink, Elizabeth line and Overground all present with active service"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Download and prepare transit network data for R5 routing engine"
|
||||
|
|
@ -1167,6 +1635,7 @@ def main() -> None:
|
|||
bods_final = output_dir / "bods_gtfs.zip"
|
||||
convert_high_freq_to_frequency_based(bods_cleaned, bods_final)
|
||||
validate_gtfs_feed(bods_final, "BODS GTFS")
|
||||
validate_stop_geometry(bods_final, "BODS GTFS")
|
||||
|
||||
# 2. National Rail CIF → GTFS. Heavy rail is mandatory: trains are how people
|
||||
# reach the ~2,725 railway-station destinations, so a bus/metro-only network
|
||||
|
|
@ -1183,6 +1652,11 @@ def main() -> None:
|
|||
)
|
||||
nr_final = convert_national_rail_to_gtfs(raw_dir, output_dir)
|
||||
validate_gtfs_feed(nr_final, "National Rail GTFS")
|
||||
validate_stop_geometry(nr_final, "National Rail GTFS")
|
||||
|
||||
# 3. Cross-feed check: every must-have London mode/line is present with
|
||||
# active service. Catches a feed regression that silently drops a whole mode.
|
||||
validate_london_coverage(bods_final, nr_final)
|
||||
|
||||
# Summary
|
||||
print()
|
||||
|
|
|
|||
|
|
@ -75,7 +75,9 @@ pub use poi::{resolve_poi_category_filter, POICategoryGroup, POIData, SchoolMeta
|
|||
pub use postcode_population::PostcodePopulation;
|
||||
pub use postcodes::{OutcodeData, PostcodeData};
|
||||
pub use property::{
|
||||
precompute_h3, FeatureStats, Histogram, HistoricalPrice, PostcodePoiMetrics, PropertyData,
|
||||
QuantRef, RenovationEvent, TenureEvent,
|
||||
combine_nearest_distances, combined_station_feature_name,
|
||||
combined_station_source_feature_names, precompute_h3, FeatureStats, Histogram, HistoricalPrice,
|
||||
PostcodePoiMetrics, PropertyData, QuantRef, RenovationEvent, TenureEvent,
|
||||
COMBINED_STATION_CATEGORY,
|
||||
};
|
||||
pub use travel_time::{slugify, TravelTimeStore};
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@ use serde::Serialize;
|
|||
use tracing::info;
|
||||
|
||||
use crate::consts::{NAN_U16, QUANT_SCALE};
|
||||
use crate::data::{PropertyData, QuantRef};
|
||||
use crate::data::{
|
||||
combine_nearest_distances, combined_station_feature_name,
|
||||
combined_station_source_feature_names, PropertyData, QuantRef,
|
||||
};
|
||||
use crate::utils::{normalize_postcode, GridIndex, InternedColumn};
|
||||
|
||||
const GRID_CELL_SIZE: f32 = 0.01;
|
||||
|
|
@ -318,8 +321,17 @@ fn build_poi_filter_feature_data(
|
|||
let mut encoded_columns = 0usize;
|
||||
|
||||
for (metric_idx, name) in poi_metrics.feature_names.iter().enumerate() {
|
||||
let Some(values) = extract_optional_feature_f32(df, name)? else {
|
||||
continue;
|
||||
let values = match extract_optional_feature_f32(df, name)? {
|
||||
Some(values) => values,
|
||||
// Some POI columns (the combined-station distance) are synthesized
|
||||
// in memory by PostcodePoiMetrics and never written to the listings
|
||||
// parquet. Reconstruct them from their source columns so listing POI
|
||||
// filters match the map exactly instead of silently rejecting every
|
||||
// row on an all-NaN column.
|
||||
None => match synthesize_missing_poi_column(df, name, row_count)? {
|
||||
Some(values) => values,
|
||||
None => continue,
|
||||
},
|
||||
};
|
||||
for (row, value) in values.into_iter().enumerate() {
|
||||
let dst = row * num_features + metric_idx;
|
||||
|
|
@ -338,6 +350,45 @@ fn build_poi_filter_feature_data(
|
|||
Ok(feature_data)
|
||||
}
|
||||
|
||||
/// Reconstruct a POI metric column that `PostcodePoiMetrics` synthesizes in
|
||||
/// memory and therefore is absent from the listings parquet. Currently only the
|
||||
/// combined-station distance, derived as the elementwise nearest of its per-mode
|
||||
/// source columns (the same rule the postcode side table uses). Returns `None`
|
||||
/// if `name` is not a synthesized column, or if none of its sources are present.
|
||||
fn synthesize_missing_poi_column(
|
||||
df: &DataFrame,
|
||||
name: &str,
|
||||
row_count: usize,
|
||||
) -> Result<Option<Vec<Option<f32>>>> {
|
||||
if name != combined_station_feature_name() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut sources: Vec<Vec<f32>> = Vec::new();
|
||||
for source_name in combined_station_source_feature_names() {
|
||||
if let Some(values) = extract_optional_feature_f32(df, &source_name)? {
|
||||
sources.push(
|
||||
values
|
||||
.into_iter()
|
||||
.map(|value| value.unwrap_or(f32::NAN))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if sources.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let source_refs: Vec<&[f32]> = sources.iter().map(Vec::as_slice).collect();
|
||||
let combined = combine_nearest_distances(&source_refs, row_count);
|
||||
Ok(Some(
|
||||
combined
|
||||
.into_iter()
|
||||
.map(|value| value.is_finite().then_some(value))
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
fn feature_index(property_data: &PropertyData, name: &str) -> Option<usize> {
|
||||
property_data
|
||||
.feature_names
|
||||
|
|
@ -741,6 +792,51 @@ mod tests {
|
|||
assert!(!any_listing.listing_url.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthesizes_combined_station_column_from_source_columns() {
|
||||
// Mirrors the postcode side table: the combined-station distance is the
|
||||
// finite minimum of the per-mode source columns present in the parquet.
|
||||
let df = df![
|
||||
"Distance to nearest amenity (Rail station) (km)" => [2.0f32, f32::NAN, 5.0],
|
||||
"Distance to nearest amenity (Tube station) (km)" => [1.0f32, f32::NAN, f32::NAN],
|
||||
"Distance to nearest amenity (DLR station) (km)" => [3.0f32, 4.0, f32::NAN],
|
||||
]
|
||||
.unwrap();
|
||||
|
||||
let combined = synthesize_missing_poi_column(&df, &combined_station_feature_name(), 3)
|
||||
.unwrap()
|
||||
.expect("combined-station column should be synthesized");
|
||||
|
||||
assert_eq!(combined[0], Some(1.0)); // min(2, 1, 3)
|
||||
assert_eq!(combined[1], Some(4.0)); // only DLR present
|
||||
assert_eq!(combined[2], Some(5.0)); // only Rail present
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_synthesize_non_combined_or_sourceless_columns() {
|
||||
let df = df![
|
||||
"Distance to nearest amenity (Rail station) (km)" => [1.0f32],
|
||||
]
|
||||
.unwrap();
|
||||
|
||||
// A real per-mode column is read from the parquet, never synthesized.
|
||||
assert!(synthesize_missing_poi_column(
|
||||
&df,
|
||||
"Distance to nearest amenity (Rail station) (km)",
|
||||
1
|
||||
)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
|
||||
// The combined column cannot be built when no source columns exist.
|
||||
let empty = df!["Postcode" => ["AB1 2CD"]].unwrap();
|
||||
assert!(
|
||||
synthesize_missing_poi_column(&empty, &combined_station_feature_name(), 1)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_price_history_from_parquet() {
|
||||
let path = PathBuf::from("src/data/testdata/listings_price_history.parquet");
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ mod quant;
|
|||
mod stats;
|
||||
|
||||
pub use h3::precompute_h3;
|
||||
pub use poi_metrics::{PostcodePoiMetrics, COMBINED_STATION_CATEGORY};
|
||||
pub use poi_metrics::{
|
||||
combine_nearest_distances, combined_station_feature_name, combined_station_source_feature_names,
|
||||
PostcodePoiMetrics, COMBINED_STATION_CATEGORY,
|
||||
};
|
||||
pub use quant::QuantRef;
|
||||
pub use stats::{FeatureStats, Histogram};
|
||||
|
||||
|
|
|
|||
|
|
@ -1445,6 +1445,87 @@ pub async fn ensure_oauth_providers(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Point the password-reset email at the SPA's own `/reset-password` page.
|
||||
///
|
||||
/// PocketBase's default reset template links to `{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}`,
|
||||
/// i.e. its superuser UI under `/pb/_/`. `meta.appURL` is `{public_url}/pb` (set in
|
||||
/// `ensure_oauth_providers` for OAuth redirects), and the `/pb` proxy allowlist deliberately
|
||||
/// rejects `/_/` (see `routes/pb_proxy.rs`), so the emailed link 404s. We therefore bake the raw
|
||||
/// `public_url` (NOT `appURL`, which carries the `/pb` prefix) straight into the template body and
|
||||
/// keep PocketBase's `{TOKEN}` placeholder for it to substitute at send time. The SPA route reads
|
||||
/// `?token=` and calls `confirmPasswordReset`.
|
||||
pub async fn ensure_password_reset_template(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
admin_email: &str,
|
||||
admin_password: &str,
|
||||
public_url: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let base_url = base_url.trim_end_matches('/');
|
||||
let token = auth_superuser(client, base_url, admin_email, admin_password).await?;
|
||||
|
||||
let site = public_url.trim_end_matches('/');
|
||||
// `{{TOKEN}}` renders to the literal `{TOKEN}` placeholder that PocketBase fills in.
|
||||
let action_url = format!("{site}/reset-password?token={{TOKEN}}");
|
||||
let body = format!(
|
||||
"<p>Hello,</p>\n\
|
||||
<p>Click the button below to choose a new password for your Perfect Postcode account.</p>\n\
|
||||
<p><a class=\"btn\" href=\"{action_url}\" target=\"_blank\" rel=\"noopener\">Reset password</a></p>\n\
|
||||
<p>If you did not request this, you can safely ignore this email.</p>\n\
|
||||
<p>Thanks,<br/>The Perfect Postcode team</p>"
|
||||
);
|
||||
|
||||
// PocketBase 0.23+: email templates are per-collection. GET the users collection, set the
|
||||
// reset template, and PATCH it back (mirrors the OAuth config flow above).
|
||||
let collection_url = format!("{base_url}/api/collections/users");
|
||||
let resp = client
|
||||
.get(&collection_url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Failed to fetch users collection for reset template ({status}): {text}");
|
||||
}
|
||||
let mut collection: serde_json::Value = resp.json().await?;
|
||||
|
||||
// The field is an object `{subject, body}`; preserve any sibling keys PocketBase may add. If a
|
||||
// future PocketBase version renames or moves it, warn and skip rather than blocking server
|
||||
// startup over an email template: a broken reset link is far less bad than a server that won't boot.
|
||||
let Some(template) = collection
|
||||
.get_mut("resetPasswordTemplate")
|
||||
.and_then(|v| v.as_object_mut())
|
||||
else {
|
||||
warn!(
|
||||
"users collection has no resetPasswordTemplate object; \
|
||||
leaving the reset email on PocketBase's default (which 404s via /pb/_/)"
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
template.insert(
|
||||
"subject".to_string(),
|
||||
serde_json::json!("Reset your Perfect Postcode password"),
|
||||
);
|
||||
template.insert("body".to_string(), serde_json::json!(body));
|
||||
let reset_template = collection["resetPasswordTemplate"].clone();
|
||||
|
||||
let patch_resp = client
|
||||
.patch(&collection_url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({ "resetPasswordTemplate": reset_template }))
|
||||
.send()
|
||||
.await?;
|
||||
if !patch_resp.status().is_success() {
|
||||
let status = patch_resp.status();
|
||||
let text = patch_resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Failed to set password-reset template ({status}): {text}");
|
||||
}
|
||||
|
||||
info!("PocketBase password-reset email template set to {action_url}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Spawn a background task that polls PocketBase every 60 seconds for collection counts
|
||||
/// and exposes them as Prometheus gauges.
|
||||
pub fn start_metrics_poller(shared: Arc<crate::state::SharedState>) {
|
||||
|
|
|
|||
|
|
@ -166,6 +166,7 @@ impl AppState {
|
|||
listing_status: InternedColumn::build(&[]),
|
||||
listing_date_iso: Vec::new(),
|
||||
features: Vec::new(),
|
||||
price_history: Vec::new(),
|
||||
filter_feature_data: Vec::new(),
|
||||
poi_filter_feature_data: Vec::new(),
|
||||
grid: GridIndex::build(&[], &[], 0.01),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue