diff --git a/README.md b/README.md index 4bdcb70..0c90747 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,6 @@ rm data/d29f0314840ef7dcbb5cde66e383fe08059dab5a.zip https://xploria.co.uk/data-sources/ - - - --- - stripe diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b8d58db..0b45089 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -230,7 +230,7 @@ export default function App() { )} {showLicenseSuccess && ( - setShowLicenseSuccess(false)} /> + { setShowLicenseSuccess(false); navigateTo('dashboard'); }} /> )} ); diff --git a/frontend/src/components/account/AccountPage.tsx b/frontend/src/components/account/AccountPage.tsx index 2ec003f..d8f6c6b 100644 --- a/frontend/src/components/account/AccountPage.tsx +++ b/frontend/src/components/account/AccountPage.tsx @@ -2,6 +2,7 @@ import { useState, useCallback, useEffect } from 'react'; import type { AuthUser } from '../../hooks/useAuth'; import type { SavedSearch } from '../../hooks/useSavedSearches'; import { apiUrl, authHeaders, assertOk, shortenUrl } from '../../lib/api'; +import { copyToClipboard } from '../../lib/clipboard'; import { formatRelativeTime } from '../../lib/format'; import { summarizeParams } from '../../lib/url-state'; import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; @@ -42,37 +43,24 @@ function SavedSearchesContent({ setDeleteConfirmId(null); }, [deleteConfirmId, onDelete]); - const copyToClipboard = useCallback((text: string, id: string) => { - const onSuccess = () => { + const doCopy = useCallback((text: string, id: string) => { + copyToClipboard(text, () => { setCopiedId(id); setTimeout(() => setCopiedId(null), 2000); - }; - if (navigator.clipboard?.writeText) { - navigator.clipboard.writeText(text).then(onSuccess); - } else { - const ta = document.createElement('textarea'); - ta.value = text; - ta.style.position = 'fixed'; - ta.style.opacity = '0'; - document.body.appendChild(ta); - ta.select(); - document.execCommand('copy'); - document.body.removeChild(ta); - onSuccess(); - } + }); }, []); const handleShare = useCallback(async (params: string, id: string) => { setSharingId(id); try { const shortUrl = await shortenUrl(params); - copyToClipboard(shortUrl, id); + doCopy(shortUrl, id); } catch { - copyToClipboard(`${window.location.origin}/?${params}`, id); + doCopy(`${window.location.origin}/?${params}`, id); } finally { setSharingId(null); } - }, [copyToClipboard]); + }, [doCopy]); return ( <> @@ -270,7 +258,7 @@ function SettingsContent({ const handleCopyInvite = () => { if (!inviteUrl) return; - navigator.clipboard.writeText(inviteUrl).then(() => { + copyToClipboard(inviteUrl, () => { setInviteCopied(true); setTimeout(() => setInviteCopied(false), 2000); }); @@ -284,7 +272,7 @@ function SettingsContent({ const isLicensed = user.subscription === 'licensed' || user.isAdmin; return ( -
+
{/* Email */}
@@ -455,6 +443,20 @@ function SettingsContent({
)}
+ + {/* Support */} +
+

Need help? Email us at

+ + support@propertymap.co.uk + +

+ We typically respond within 24 hours. +

+
); } diff --git a/frontend/src/components/learn/LearnPage.tsx b/frontend/src/components/learn/LearnPage.tsx index 6fc0917..03487b5 100644 --- a/frontend/src/components/learn/LearnPage.tsx +++ b/frontend/src/components/learn/LearnPage.tsx @@ -255,7 +255,7 @@ function FAQItemCard({ item }: { item: FAQItem }) { } export default function LearnPage() { - const [tab, setTab] = useState('data-sources'); + const [tab, setTab] = useState('faq'); const [highlightedId, setHighlightedId] = useState(null); const cardRefs = useRef>({}); const scrollContainerRef = useRef(null); @@ -299,12 +299,12 @@ export default function LearnPage() {
- + diff --git a/frontend/src/components/map/AiFilterInput.tsx b/frontend/src/components/map/AiFilterInput.tsx index 9b6cf5c..3608d3a 100644 --- a/frontend/src/components/map/AiFilterInput.tsx +++ b/frontend/src/components/map/AiFilterInput.tsx @@ -23,24 +23,24 @@ export default memo(function AiFilterInput({ loading, error, notes, onSubmit }: return (
-
+ setQuery(e.target.value)} - placeholder="Describe your ideal property..." - className="flex-1 min-w-0 px-2.5 py-1.5 text-sm rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-2 focus:ring-teal-400" + placeholder="Describe your ideal property and area..." + className="w-full px-2.5 py-1.5 text-sm rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-2 focus:ring-teal-400" disabled={loading} />
diff --git a/frontend/src/components/map/AreaPane.tsx b/frontend/src/components/map/AreaPane.tsx index ba90d25..56bec78 100644 --- a/frontend/src/components/map/AreaPane.tsx +++ b/frontend/src/components/map/AreaPane.tsx @@ -109,6 +109,11 @@ export default function AreaPane({ {propertyCount.toLocaleString()} properties

)} +

+ Stats for {isPostcode ? 'current and historical' : 'all'} properties + in this {isPostcode ? 'postcode' : 'area'} + {Object.keys(filters).length > 0 ? ' matching all active filters' : ''} +

{!isPostcode && stats && ( + + + + + + +
)}
{infoFeature && ( diff --git a/frontend/src/components/map/Filters.tsx b/frontend/src/components/map/Filters.tsx index 730e8dd..04cbe09 100644 --- a/frontend/src/components/map/Filters.tsx +++ b/frontend/src/components/map/Filters.tsx @@ -39,13 +39,15 @@ function SliderLabels({ max, value, displayValues, - absoluteMax, + isAtMin, + isAtMax, }: { min: number; max: number; value: [number, number]; displayValues?: [number, number]; - absoluteMax?: boolean; + isAtMin?: boolean; + isAtMax?: boolean; }) { const range = max - min || 1; const leftPct = ((value[0] - min) / range) * 100; @@ -57,13 +59,13 @@ function SliderLabels({ className="absolute -translate-x-1/2" style={{ left: `${leftPct}%` }} > - {formatFilterValue(labels[0])} + {isAtMin ? 'min' : formatFilterValue(labels[0])} - {formatFilterValue(labels[1])}{absoluteMax && value[1] >= max ? '+' : ''} + {isAtMax ? 'max' : formatFilterValue(labels[1])}
); @@ -92,10 +94,14 @@ interface FiltersProps { onTravelTimeRemoveEntry: (index: number) => void; onTravelTimeSetDestination: (index: number, slug: string, label: string) => void; onTravelTimeRangeChange: (index: number, range: [number, number]) => void; + onTravelTimeToggleBest: (index: number) => void; aiFilterLoading: boolean; aiFilterError: string | null; aiFilterNotes: string | null; onAiFilterSubmit: (query: string) => void; + isLicensed: boolean; + onUpgradeClick?: () => void; + onResetTutorial?: () => void; } export default memo(function Filters({ @@ -121,10 +127,14 @@ export default memo(function Filters({ onTravelTimeRemoveEntry, onTravelTimeSetDestination, onTravelTimeRangeChange, + onTravelTimeToggleBest, aiFilterLoading, aiFilterError, aiFilterNotes, onAiFilterSubmit, + isLicensed, + onUpgradeClick, + onResetTutorial, }: FiltersProps) { const activeListingType = useMemo((): ListingType => { const val = filters['Listing status'] as string[] | undefined; @@ -145,16 +155,11 @@ export default memo(function Filters({ const handleListingSelect = useCallback( (type: ListingType) => { - if (type === activeListingType && !filters['Listing status']) return; for (const name of Object.keys(filters)) { if (name !== 'Listing status' && !isFeatureAllowedInMode(name, type)) { onRemoveFilter(name); } } - if (type === 'historical' && !filters['Listing status']) { - onFilterChange('Listing status', ['Historical sale']); - return; - } const valueMap: Record = { historical: 'Historical sale', buy: 'For sale', @@ -162,7 +167,7 @@ export default memo(function Filters({ }; onFilterChange('Listing status', [valueMap[type]]); }, - [activeListingType, filters, onFilterChange, onRemoveFilter] + [filters, onFilterChange, onRemoveFilter] ); const containerRef = useRef(null); @@ -205,7 +210,7 @@ export default memo(function Filters({
@@ -416,6 +445,8 @@ export default memo(function Filters({ onClearOpenInfoFeature={onClearOpenInfoFeature} travelTimeEntries={travelTimeEntries} onAddTravelTimeEntry={onTravelTimeAddEntry} + isLicensed={isLicensed} + onUpgradeClick={onUpgradeClick} />
@@ -423,59 +454,95 @@ export default memo(function Filters({ {showPhilosophy && ( setShowPhilosophy(false)}>
+

+ Start with your must-haves, then layer on nice-to-haves. + The map narrows down as you add filters — the areas that survive are your best matches. +

+

- Be intentional, not reactive + 1. Budget & property basics

- Your future home isn't a box of cereal you grab because it's on sale. - Don't let a seemingly good deal turn into lifelong regret. Instead of waiting - for listings to appear, define what you actually want and go find it. + Set your price range, minimum floor area, and property type. + If you need a lease over freehold (or vice versa), filter for that too. + This eliminates most of the map immediately.

- See the full picture + 2. Commute & transport

- Current listings show only a fraction of the market. There are too few to give you a - complete picture, yet too many to evaluate one by one. We aggregate millions of - historical sales so you can understand what's truly available in any area. + Add a travel time filter to your workplace — choose public transport or cycling + and set your maximum tolerable commute. You can also filter by + how many stations are within walking distance.

- Your priorities, your filters + 3. Safety & environment

- We all care about different things. Some want peace and quiet; others want to be - near the action. Use our filters to define exactly what matters to you and discover - postcodes that match. + Use the crime filters to cap serious or minor crime rates. + Check road noise levels if you're a light sleeper, and + environmental risk filters for ground stability concerns.

- Find the right place, not just the right listing + 4. Schools & education

- The best areas to live don't always have properties listed right now. We help - you identify where you should be looking, so when something does come up, - you're ready. + Filter by the number of Ofsted-rated Good or Outstanding primary and + secondary schools nearby. The education deprivation score captures + broader area-level attainment.

- Know what's possible + 5. Lifestyle & amenities

- We'd rather tell you upfront if your expectations are unrealistic than have you - spend months searching for something that doesn't exist. + Want restaurants, parks, or grocery shops within walking distance? + Filter by nearby amenity counts. Broadband speed filters help if + you work from home.

+ +
+

+ 6. Energy & running costs +

+

+ EPC ratings from A to G indicate energy efficiency. + Filter for better ratings to find homes with lower bills and + fewer upgrade headaches. +

+
+ +
+

+ Tip: if nothing survives your filters, relax one constraint at a time + to see which compromise unlocks the most options. +

+
+ + {onResetTutorial && ( + + )}
)} diff --git a/frontend/src/components/map/HoverCard.tsx b/frontend/src/components/map/HoverCard.tsx index bb06df3..b9a99d8 100644 --- a/frontend/src/components/map/HoverCard.tsx +++ b/frontend/src/components/map/HoverCard.tsx @@ -1,5 +1,5 @@ -import { memo } from 'react'; -import type { FeatureFilters } from '../../types'; +import { memo, useMemo } from 'react'; +import type { FeatureFilters, FeatureMeta } from '../../types'; import { formatValue } from '../../lib/format'; interface HoverCardData { @@ -14,11 +14,17 @@ interface HoverCardProps { isPostcode: boolean; data: HoverCardData | null; filters: FeatureFilters; + features: FeatureMeta[]; } -export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }: HoverCardProps) { +export default memo(function HoverCard({ x, y, id, isPostcode, data, filters, features }: HoverCardProps) { const activeFilterNames = Object.keys(filters); + const featureMap = useMemo( + () => new Map(features.map((f) => [f.name, f])), + [features] + ); + // Get key stats to show from local data (min_ values) const getDisplayStats = () => { if (!data) return []; @@ -28,8 +34,13 @@ export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }: // Show stats for active filters (up to 4) for (const name of activeFilterNames.slice(0, 4)) { const val = data[`avg_${name}`] ?? data[`min_${name}`]; - if (val != null && typeof val === 'number') { - results.push({ name, value: formatValue(val) }); + if (val == null || typeof val !== 'number') continue; + const meta = featureMap.get(name); + if (meta?.type === 'enum' && meta.values) { + const label = meta.values[Math.round(val)]; + if (label) results.push({ name, value: label }); + } else { + results.push({ name, value: formatValue(val, meta) }); } } diff --git a/frontend/src/components/map/Map.tsx b/frontend/src/components/map/Map.tsx index 2a0679a..f43f87b 100644 --- a/frontend/src/components/map/Map.tsx +++ b/frontend/src/components/map/Map.tsx @@ -296,6 +296,7 @@ export default memo(function Map({ : data.find((d) => d.h3 === hoveredHexagonId) || null } filters={filters} + features={features} /> )} diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index 66aaba7..4c92ec7 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -229,16 +229,21 @@ export default function MapPage({ const isPostcode = selection.selectedHexagon?.type === 'postcode'; if (isPostcode) { - // For postcodes, get centroid from postcodeData + // For postcodes, get centroid from postcodeData; postcode string is the selection id const postcodeFeature = mapData.postcodeData.find((f) => f.properties.postcode === hexId); if (!postcodeFeature?.properties.centroid) return null; const [lon, lat] = postcodeFeature.properties.centroid; - return { lat, lon, resolution: mapData.resolution }; + return { lat, lon, resolution: mapData.resolution, postcode: hexId, isPostcode: true }; } else { - // For hexagons, get lat/lon from hexagon data + // For hexagons, get lat/lon from hexagon data; central postcode comes from stats const hex = hexId ? mapData.data.find((d) => d.h3 === hexId) : null; if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null; - return { lat: hex.lat as number, lon: hex.lon as number, resolution: mapData.resolution }; + return { + lat: hex.lat as number, + lon: hex.lon as number, + resolution: mapData.resolution, + postcode: selection.areaStats?.central_postcode, + }; } }, [ selection.selectedHexagon?.id, @@ -246,6 +251,7 @@ export default function MapPage({ mapData.data, mapData.postcodeData, mapData.resolution, + selection.areaStats?.central_postcode, ]); const tutorial = useTutorial(initialLoading, isMobile); @@ -400,10 +406,14 @@ export default function MapPage({ onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry} onTravelTimeSetDestination={handleTravelTimeSetDestination} onTravelTimeRangeChange={travelTime.handleTimeRangeChange} + onTravelTimeToggleBest={travelTime.handleToggleBest} aiFilterLoading={aiFilters.loading} aiFilterError={aiFilters.error} aiFilterNotes={aiFilters.notes} onAiFilterSubmit={handleAiFilterSubmit} + isLicensed={user?.subscription === 'licensed'} + onUpgradeClick={() => onNavigateTo('pricing')} + onResetTutorial={tutorial.resetTutorial} /> ); @@ -560,6 +570,7 @@ export default function MapPage({ callback={tutorial.handleCallback} styles={getTutorialStyles(theme)} disableScrolling + locale={{ last: 'Finish' }} />
-
+ {selection.selectedHexagon && (
-
-
-
-
- selection.setRightPaneTab('area')} - /> - +
+
+
+
+ selection.setRightPaneTab('area')} + /> + +
-
- {selection.rightPaneTab === 'properties' - ? renderPropertiesPane() - : renderAreaPane()} +
+ {selection.rightPaneTab === 'properties' + ? renderPropertiesPane() + : renderAreaPane()} +
-
+ )} {mapData.licenseRequired && ( void; onSetDestination: (slug: string, label: string) => void; onTimeRangeChange: (range: [number, number]) => void; + onToggleBest: () => void; onRemove: () => void; } @@ -39,11 +41,13 @@ export function TravelTimeCard({ slug, label, timeRange, + useBest, dataRange, isPinned, onTogglePin, onSetDestination, onTimeRangeChange, + onToggleBest, onRemove, }: TravelTimeCardProps) { const search = useLocationSearch(mode); @@ -119,6 +123,24 @@ export function TravelTimeCard({ )}
+ {/* Best-case toggle — transit only, shown when destination is set */} + {slug && mode === 'transit' && ( + + )} + {/* Time range slider — only show when we have data */} {slug && dataRange && (
diff --git a/frontend/src/hooks/useDeckLayers.ts b/frontend/src/hooks/useDeckLayers.ts index e1a057a..87a8d70 100644 --- a/frontend/src/hooks/useDeckLayers.ts +++ b/frontend/src/hooks/useDeckLayers.ts @@ -1,6 +1,7 @@ import { useCallback, useRef, useState, useMemo, useEffect } from 'react'; import { H3HexagonLayer } from '@deck.gl/geo-layers'; import { GeoJsonLayer, IconLayer, TextLayer } from '@deck.gl/layers'; +import { cellToBoundary } from 'h3-js'; import type { PickingInfo } from '@deck.gl/core'; import type { HexagonData, @@ -80,13 +81,13 @@ export function useDeckLayers({ // Marching ants animation const [marchTime, setMarchTime] = useState(0); - const hasPostcodeGeometry = selectedPostcodeGeometry != null; + const hasSelection = selectedPostcodeGeometry != null || selectedHexagonId != null; useEffect(() => { - if (!hasPostcodeGeometry) return; + if (!hasSelection) return; setMarchTime(0); const id = setInterval(() => setMarchTime((t) => t + 0.3), 50); return () => clearInterval(id); - }, [hasPostcodeGeometry]); + }, [hasSelection]); const isDark = theme === 'dark'; const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT; @@ -332,14 +333,11 @@ export function useDeckLayers({ ); }, getLineColor: (d) => { - if (d.h3 === selectedHexagonIdRef.current) - return [255, 255, 255, 255] as [number, number, number, number]; if (d.h3 === hoveredHexagonIdRef.current) return [29, 228, 195, 200] as [number, number, number, number]; return [0, 0, 0, 0] as [number, number, number, number]; }, getLineWidth: (d) => { - if (d.h3 === selectedHexagonIdRef.current) return 3; if (d.h3 === hoveredHexagonIdRef.current) return 2; return 0; }, @@ -481,15 +479,22 @@ export function useDeckLayers({ [pois, stablePoiHover] ); - // Marching ants highlight layer for selected postcode + // Marching ants highlight layer for selected hexagon or postcode const marchingAntsLayer = useMemo(() => { - if (!selectedPostcodeGeometry) return null; + let geometry: PostcodeGeometry | null = null; + if (selectedPostcodeGeometry) { + geometry = selectedPostcodeGeometry; + } else if (selectedHexagonId) { + const boundary = cellToBoundary(selectedHexagonId, true); + geometry = { type: 'Polygon', coordinates: [boundary] }; + } + if (!geometry) return null; return new GeoJsonLayer({ id: 'marching-ants', data: [ { type: 'Feature' as const, - geometry: selectedPostcodeGeometry, + geometry, properties: {}, }, ], @@ -502,7 +507,7 @@ export function useDeckLayers({ marchTime, extensions: [new MarchingAntsExtension()], }); - }, [selectedPostcodeGeometry, marchTime]); + }, [selectedPostcodeGeometry, selectedHexagonId, marchTime]); const layers = useMemo(() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/frontend/src/hooks/useFilters.ts b/frontend/src/hooks/useFilters.ts index c23ede1..f12d975 100644 --- a/frontend/src/hooks/useFilters.ts +++ b/frontend/src/hooks/useFilters.ts @@ -31,6 +31,8 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) { if (!meta) return; if (meta.type === 'enum' && meta.values) { setFilters((prev) => ({ ...prev, [name]: [...meta.values!] })); + } else if (meta.type === 'numeric' && meta.histogram) { + setFilters((prev) => ({ ...prev, [name]: [meta.histogram!.min, meta.histogram!.max] })); } else if (meta.min != null && meta.max != null) { setFilters((prev) => ({ ...prev, [name]: [meta.min!, meta.max!] })); } diff --git a/frontend/src/hooks/useTravelTime.ts b/frontend/src/hooks/useTravelTime.ts index d916435..09f4797 100644 --- a/frontend/src/hooks/useTravelTime.ts +++ b/frontend/src/hooks/useTravelTime.ts @@ -16,6 +16,8 @@ export interface TravelTimeEntry { slug: string; label: string; timeRange: [number, number] | null; + /** Use best-case (5th percentile) travel time instead of median. Transit only. */ + useBest: boolean; } /** Field key matching the backend response: tt_{mode}_{slug} */ @@ -33,7 +35,7 @@ export function useTravelTime(initial?: TravelTimeInitial) { const handleAddEntry = useCallback((mode: TransportMode) => { setEntries((prev) => [ ...prev, - { mode, slug: '', label: '', timeRange: null }, + { mode, slug: '', label: '', timeRange: null, useBest: false }, ]); }, []); @@ -63,6 +65,17 @@ export function useTravelTime(initial?: TravelTimeInitial) { [] ); + const handleToggleBest = useCallback( + (index: number) => { + setEntries((prev) => + prev.map((entry, i) => + i === index ? { ...entry, useBest: !entry.useBest, timeRange: null } : entry + ) + ); + }, + [] + ); + /** Entries that have a destination selected (slug is set) */ const activeEntries = useMemo( () => entries.filter((e) => e.slug !== ''), @@ -76,5 +89,6 @@ export function useTravelTime(initial?: TravelTimeInitial) { handleRemoveEntry, handleSetDestination, handleTimeRangeChange, + handleToggleBest, }; } diff --git a/frontend/src/hooks/useTutorial.ts b/frontend/src/hooks/useTutorial.ts index 32180d4..b6ac7fc 100644 --- a/frontend/src/hooks/useTutorial.ts +++ b/frontend/src/hooks/useTutorial.ts @@ -9,7 +9,7 @@ const STEPS: Step[] = [ target: '[data-tutorial="filters"]', title: 'Filter Properties', content: - 'Use filters to narrow down properties by price, energy rating, floor area, and more. Pin a filter to colour the map by that feature.', + 'Use filters to narrow down to areas which contain matching properties. Filter by crime rate, number of schools around, or filter to an area with detached houses. Pin a filter with the eye icon to colour the map by that feature.', placement: 'right', disableBeacon: true, }, @@ -17,7 +17,7 @@ const STEPS: Step[] = [ target: '[data-tutorial="map"]', title: 'Explore the Map', content: - 'Pan and zoom to explore property data across the UK. Click any hexagon to see detailed stats and individual properties.', + 'Pan and zoom to explore property data across England. Click any area (hexagon or postcode boundary) to see detailed stats of historical or currently sold properties matching your filters.', placement: 'bottom', disableBeacon: true, }, @@ -44,6 +44,11 @@ const STEPS: Step[] = [ 'Toggle points of interest like schools, shops, and transport stops to see what amenities are nearby.', placement: 'left', disableBeacon: true, + styles: { + tooltip: { + transform: 'translateY(-50px)', + }, + }, }, ]; diff --git a/frontend/src/lib/MarchingAntsExtension.ts b/frontend/src/lib/MarchingAntsExtension.ts index b462f48..232f008 100644 --- a/frontend/src/lib/MarchingAntsExtension.ts +++ b/frontend/src/lib/MarchingAntsExtension.ts @@ -26,7 +26,7 @@ uniform marchingAntsUniforms { } marchingAnts;`, 'fs:DECKGL_FILTER_COLOR': `\ float marchSegLen = 4.0; -float marchPos = mod(vPathPosition.y - marchingAnts.marchTime, marchSegLen * 2.0); +float marchPos = mod(geometry.uv.y - marchingAnts.marchTime, marchSegLen * 2.0); if (marchPos < marchSegLen) { color = vec4(1.0, 1.0, 1.0, color.a); } else { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ca53331..9edbb09 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -81,7 +81,8 @@ export function buildFilterString(filters: FeatureFilters, features: FeatureMeta return `${name}:${(value as string[]).join('|')}`; } const [min, max] = value as [number, number]; - const maxStr = meta?.absolute && max === meta.max ? 'inf' : String(max); + const isAtMax = meta?.histogram ? max >= meta.histogram.max : max === meta?.max; + const maxStr = meta?.absolute && isAtMax ? 'inf' : String(max); return `${name}:${min}:${maxStr}`; }) .join(';;'); diff --git a/frontend/src/lib/clipboard.ts b/frontend/src/lib/clipboard.ts new file mode 100644 index 0000000..c0a8b5d --- /dev/null +++ b/frontend/src/lib/clipboard.ts @@ -0,0 +1,16 @@ +/** Copy text to clipboard with execCommand fallback for older browsers. */ +export function copyToClipboard(text: string, onSuccess: () => void): void { + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(text).then(onSuccess); + } else { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + onSuccess(); + } +} diff --git a/frontend/src/lib/consts.ts b/frontend/src/lib/consts.ts index d5f25ee..df17cd7 100644 --- a/frontend/src/lib/consts.ts +++ b/frontend/src/lib/consts.ts @@ -19,7 +19,7 @@ export const FREE_ZONE_BOUNDS = { south: 51.42, west: -0.34, north: 51.60, east: export const INITIAL_VIEW_STATE: ViewState = { longitude: (FREE_ZONE_BOUNDS.west + FREE_ZONE_BOUNDS.east) / 2, latitude: (FREE_ZONE_BOUNDS.south + FREE_ZONE_BOUNDS.north) / 2, - zoom: 14, + zoom: 15, pitch: 0, }; @@ -33,10 +33,9 @@ export const ZOOM_TO_RESOLUTION_THRESHOLDS = [ { maxZoom: 10.5, resolution: 7 }, { maxZoom: 11.5, resolution: 8 }, { maxZoom: 13, resolution: 9 }, - { maxZoom: Infinity, resolution: 10 }, ] as const; -export const POSTCODE_ZOOM_THRESHOLD = 16; +export const POSTCODE_ZOOM_THRESHOLD = 14.5; export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [ { t: 0, color: [46, 204, 113] }, diff --git a/frontend/src/lib/external-search.ts b/frontend/src/lib/external-search.ts index 6c08787..4a4dc79 100644 --- a/frontend/src/lib/external-search.ts +++ b/frontend/src/lib/external-search.ts @@ -4,6 +4,8 @@ export interface HexagonLocation { lat: number; lon: number; resolution: number; + postcode?: string; + isPostcode?: boolean; } const PROPERTY_TYPE_MAP: Record< @@ -32,10 +34,10 @@ export const H3_RADIUS_MILES: Record = { 6: 3, 7: 1, 8: 0.5, - 9: 0.25, - 10: 0.25, - 11: 0.25, - 12: 0.25, + 9: 1, + 10: 1, + 11: 1, + 12: 1, }; const RIGHTMOVE_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 30, 40]; @@ -46,13 +48,21 @@ function nearestRadius(target: number, allowed: number[]): number { return allowed.reduce((best, r) => (Math.abs(r - target) < Math.abs(best - target) ? r : best)); } -export function buildPropertySearchUrls( - location: HexagonLocation, - filters: FeatureFilters -): { rightmove: string; onthemarket: string; zoopla: string } { - const { lat, lon, resolution } = location; - const radiusMiles = H3_RADIUS_MILES[resolution] ?? 1; - const coordStr = `${lat.toFixed(5)},${lon.toFixed(5)}`; +interface SearchUrlOptions { + location: HexagonLocation; + filters: FeatureFilters; + rightmoveLocationId?: string; +} + +export function buildPropertySearchUrls({ + location, + filters, + rightmoveLocationId, +}: SearchUrlOptions): { rightmove: string | null; onthemarket: string; zoopla: string } | null { + const { postcode, resolution, isPostcode } = location; + if (!postcode) return null; + + const radiusMiles = isPostcode ? 0.25 : (H3_RADIUS_MILES[resolution] ?? 1); const priceFilter = filters['Last known price']; const minPrice = @@ -66,43 +76,51 @@ export function buildPropertySearchUrls( ? (propertyTypes as string[]) : []; - const rmParams = new URLSearchParams(); - rmParams.set('searchLocation', coordStr); - rmParams.set('channel', 'BUY'); - rmParams.set('radius', String(nearestRadius(radiusMiles, RIGHTMOVE_RADII))); - if (minPrice !== undefined) rmParams.set('minPrice', String(Math.round(minPrice))); - if (maxPrice !== undefined) rmParams.set('maxPrice', String(Math.round(maxPrice))); - if (selectedTypes.length > 0) { - const rmTypes = [ - ...new Set( - selectedTypes.flatMap((t) => { - const mapped = PROPERTY_TYPE_MAP[t]?.rightmove; - return mapped ? mapped.split(',') : []; - }) - ), - ]; - if (rmTypes.length > 0) rmParams.set('propertyTypes', rmTypes.join(',')); + // Rightmove — requires locationIdentifier from typeahead API + let rightmove: string | null = null; + if (rightmoveLocationId) { + const rmParams = new URLSearchParams(); + rmParams.set('searchLocation', postcode); + rmParams.set('useLocationIdentifier', 'true'); + rmParams.set('locationIdentifier', rightmoveLocationId); + rmParams.set('radius', String(nearestRadius(radiusMiles, RIGHTMOVE_RADII))); + if (minPrice !== undefined) rmParams.set('minPrice', String(Math.round(minPrice))); + if (maxPrice !== undefined) rmParams.set('maxPrice', String(Math.round(maxPrice))); + if (selectedTypes.length > 0) { + const rmTypes = [ + ...new Set( + selectedTypes.flatMap((t) => { + const mapped = PROPERTY_TYPE_MAP[t]?.rightmove; + return mapped ? mapped.split(',') : []; + }) + ), + ]; + if (rmTypes.length > 0) rmParams.set('propertyTypes', rmTypes.join(',')); + } + rmParams.set('_includeSSTC', 'on'); + rightmove = `https://www.rightmove.co.uk/property-for-sale/find.html?${rmParams.toString()}`; } - const rightmove = `https://www.rightmove.co.uk/property-for-sale/find.html?${rmParams.toString()}`; - let otmType = 'property'; - if (selectedTypes.length > 0) { - const otmTypes = [ - ...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean)), - ]; - if (otmTypes.length === 1 && otmTypes[0] !== 'property') otmType = otmTypes[0]!; - } + // OnTheMarket — postcode slug in URL path (e.g. "SW1A 1AA" → "sw1a-1aa") + const otmSlug = postcode.toLowerCase().replace(/\s+/g, '-'); const otmParams = new URLSearchParams(); otmParams.set('radius', String(nearestRadius(radiusMiles, OTM_RADII))); if (minPrice !== undefined) otmParams.set('min-price', String(Math.round(minPrice))); if (maxPrice !== undefined) otmParams.set('max-price', String(Math.round(maxPrice))); - otmParams.set('search-site', 'geo'); - otmParams.set('geo-lat', String(lat)); - otmParams.set('geo-lng', String(lon)); - const onthemarket = `https://www.onthemarket.com/for-sale/${otmType}/?${otmParams.toString()}`; + if (selectedTypes.length > 0) { + const otmTypes = [ + ...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean)), + ]; + for (const ot of otmTypes) { + otmParams.append('prop-types', ot!); + } + } + otmParams.set('view', 'map-list'); + const onthemarket = `https://www.onthemarket.com/for-sale/property/${otmSlug}/?${otmParams.toString()}`; + // Zoopla const zParams = new URLSearchParams(); - zParams.set('q', coordStr); + zParams.set('q', postcode); zParams.set('search_source', 'for-sale'); zParams.set('radius', String(nearestRadius(radiusMiles, ZOOPLA_RADII))); if (minPrice !== undefined) zParams.set('price_min', String(Math.round(minPrice))); @@ -115,7 +133,6 @@ export function buildPropertySearchUrls( zParams.append('property_sub_type', zt!); } } - zParams.set('geo_autocomplete_identifier', `geo_${lat}_${lon}`); const zoopla = `https://www.zoopla.co.uk/for-sale/property/?${zParams.toString()}`; return { rightmove, onthemarket, zoopla }; diff --git a/frontend/src/lib/url-state.ts b/frontend/src/lib/url-state.ts index 3f97a18..2bf811b 100644 --- a/frontend/src/lib/url-state.ts +++ b/frontend/src/lib/url-state.ts @@ -71,7 +71,7 @@ export function parseUrlState(): { } // Travel time: repeated `tt` params - // Format: mode:slug:label or mode:slug:label:min:max + // Format: mode:slug:label or mode:slug:label:b or mode:slug:label:min:max or mode:slug:label:b:min:max const ttParams = params.getAll('tt'); if (ttParams.length > 0) { const entries: TravelTimeEntry[] = []; @@ -82,15 +82,17 @@ export function parseUrlState(): { if (!TRANSPORT_MODES.includes(mode)) continue; const slug = parts[1]; const label = decodeURIComponent(parts[2]); + const useBest = parts.length >= 4 && parts[3] === 'b'; + const rangeOffset = useBest ? 1 : 0; let timeRange: [number, number] | null = null; - if (parts.length >= 5) { - const min = Number(parts[3]); - const max = Number(parts[4]); + if (parts.length >= 5 + rangeOffset) { + const min = Number(parts[3 + rangeOffset]); + const max = Number(parts[4 + rangeOffset]); if (!isNaN(min) && !isNaN(max)) { timeRange = [min, max]; } } - entries.push({ mode, slug, label, timeRange }); + entries.push({ mode, slug, label, timeRange, useBest }); } if (entries.length > 0) { result.travelTime = { entries }; @@ -139,6 +141,7 @@ export function stateToParams( for (const entry of travelTimeEntries) { if (!entry.slug) continue; let val = `${entry.mode}:${entry.slug}:${encodeURIComponent(entry.label)}`; + if (entry.useBest) val += ':b'; if (entry.timeRange) { val += `:${entry.timeRange[0]}:${entry.timeRange[1]}`; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index ff61a57..7a39379 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -172,4 +172,5 @@ export interface HexagonStatsResponse { numeric_features: NumericFeatureStats[]; enum_features: EnumFeatureStats[]; price_history?: PricePoint[]; + central_postcode?: string; } diff --git a/r5-java/run.sh b/r5-java/run.sh index 968977a..ab19ac1 100755 --- a/r5-java/run.sh +++ b/r5-java/run.sh @@ -4,6 +4,9 @@ set -euo pipefail # Batch-compute travel times from all places to all England postcodes # for all transport modes (car, bicycle, walking, transit). # +# Uses full England OSM + 2 GTFS feeds (BODS buses, National Rail). +# R5's TransportNetwork.fromDirectory() picks up all .osm.pbf and .zip files. +# # Uses each place as origin with all postcodes as destinations — R5 does one # routing computation per place, then reads off travel times to all postcodes. # For car/bicycle/walking this is symmetric (place->postcode = postcode->place). @@ -15,11 +18,10 @@ set -euo pipefail # # Usage: # ./r5-java/run.sh -# ./r5-java/run.sh --threads 8 --heap 24g --output-dir property-data/travel-times # --- Defaults --- -THREADS=16 -HEAP=16g +THREADS=4 +HEAP=12g NETWORK_DIR=property-data/r5-network OUTPUT_BASE=property-data/travel-times R5_DIR=r5-java @@ -102,25 +104,26 @@ fi # R5 writes .mapdb temp files next to OSM/GTFS files during network construction. # Copy source data to a writable build dir to avoid polluting the originals. mkdir -p "$NETWORK_DIR" -DATA_DIR="property-data/transit" +TRANSIT_SRC="property-data/transit" +NETWORK_DATA_DIR="$TRANSIT_SRC" if [ ! -f "$NETWORK_DIR/network.dat" ]; then BUILD_DIR="$NETWORK_DIR/build" echo "--- No cached network — copying transit data to build dir ---" mkdir -p "$BUILD_DIR" - if ! cp property-data/transit/raw/*.osm.pbf "$BUILD_DIR/" 2>/dev/null; then - echo "Warning: no .osm.pbf files found in property-data/transit/raw/" + if ! cp "$TRANSIT_SRC"/raw/*.osm.pbf "$BUILD_DIR/" 2>/dev/null; then + echo "Warning: no .osm.pbf files found in $TRANSIT_SRC/raw/" fi - if ! cp property-data/transit/*.zip "$BUILD_DIR/" 2>/dev/null; then - echo "Warning: no .zip files found in property-data/transit/" + if ! cp "$TRANSIT_SRC"/*.zip "$BUILD_DIR/" 2>/dev/null; then + echo "Warning: no .zip files found in $TRANSIT_SRC/" fi - DATA_DIR="$BUILD_DIR" + NETWORK_DATA_DIR="$BUILD_DIR" fi # --- Step 5: Run batch --- echo "" echo "--- Starting batch computation ---" -DATA_DIR="$DATA_DIR" NETWORK_CACHE_DIR="$NETWORK_DIR" \ +DATA_DIR="$NETWORK_DATA_DIR" NETWORK_CACHE_DIR="$NETWORK_DIR" \ java -Xmx"$HEAP" -cp "$OUT_DIR:$LIB_DIR/*" propertymap.App \ --postcodes property-data/arcgis_data.parquet \ --places property-data/places.parquet \ diff --git a/r5-java/src/main/java/propertymap/App.java b/r5-java/src/main/java/propertymap/App.java index f00935a..afdc6d8 100644 --- a/r5-java/src/main/java/propertymap/App.java +++ b/r5-java/src/main/java/propertymap/App.java @@ -192,6 +192,9 @@ public class App { if (attempt < MAX_RETRIES) { System.err.printf("%n [RETRY %d/%d] %s: %s%n", attempt + 1, MAX_RETRIES, name, e.getMessage()); + } else { + System.err.printf("%n [FAIL TRACE] %s:%n", name); + e.printStackTrace(System.err); } } } @@ -215,7 +218,7 @@ public class App { String safe = name.toLowerCase() .replaceAll("[^a-z0-9 -]", "") .replaceAll("\\s+", "-"); - return String.format("%04d-%s.parquet", index, safe); + return String.format("%06d-%s.parquet", index, safe); } private static String requiredArg(String[] args, String name) { diff --git a/r5-java/src/main/java/propertymap/Router.java b/r5-java/src/main/java/propertymap/Router.java index 108f4ae..bff77a8 100644 --- a/r5-java/src/main/java/propertymap/Router.java +++ b/r5-java/src/main/java/propertymap/Router.java @@ -29,6 +29,10 @@ public class Router { private static final int DEPARTURE_TO_TIME = 9 * 3600; // 09:00 private static final int MAX_TRIP_DURATION_MINUTES = 120; + // Percentile indices in R5 result arrays (order must match task.percentiles in buildTask) + private static final int PERCENTILE_BEST = 0; // 5th percentile (transit only) + private static final int PERCENTILE_MEDIAN = 1; // 50th percentile (transit: index 1, others: index 0) + /** Result of computing travel times for a single origin with spatial pre-filtering. */ record FilteredResult(int[] originalIndices, short[] times, short[] bestTimes) {} @@ -102,10 +106,9 @@ public class Router { boolean isTransit = mode.equals("transit"); short[][] allTimes = computeTravelTimes(network, chunks, originLat, originLon, mode, fLats.length, date); - // For transit: allTimes[0]=best (5th percentile), allTimes[1]=median (50th) - // For others: allTimes[0]=median (50th), no best - short[] medianTimes = isTransit ? allTimes[1] : allTimes[0]; - short[] bestTimes = isTransit ? allTimes[0] : null; + // Transit requests [5th, 50th] percentiles; others request [50th] only + short[] medianTimes = isTransit ? allTimes[PERCENTILE_MEDIAN] : allTimes[0]; + short[] bestTimes = isTransit ? allTimes[PERCENTILE_BEST] : null; return new FilteredResult(filtered, medianTimes, bestTimes); } @@ -205,13 +208,24 @@ public class Router { OneOriginResult result = computer.computeTravelTimes(); TravelTimeResult tt = result.travelTimes; - if (tt != null) { - int[][] values = tt.getValues(); - for (int p = 0; p < nPercentiles && p < values.length; p++) { - for (int i = 0; i < chunk.originalIndices.length && i < values[p].length; i++) { - if (values[p][i] != Integer.MAX_VALUE) { - allTimes[p][chunk.originalIndices[i]] = (short) values[p][i]; - } + if (tt == null) { + throw new RuntimeException("R5 returned null travelTimes for chunk with " + + chunk.originalIndices.length + " destinations"); + } + int[][] values = tt.getValues(); + if (values.length < nPercentiles) { + throw new RuntimeException("R5 returned " + values.length + " percentiles, expected " + + nPercentiles); + } + for (int p = 0; p < nPercentiles; p++) { + if (values[p].length < chunk.originalIndices.length) { + throw new RuntimeException("R5 returned " + values[p].length + + " travel times for percentile " + p + ", expected " + + chunk.originalIndices.length); + } + for (int i = 0; i < chunk.originalIndices.length; i++) { + if (values[p][i] != Integer.MAX_VALUE) { + allTimes[p][chunk.originalIndices[i]] = (short) values[p][i]; } } } diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index d0c8390..6f8a242 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -2743,6 +2743,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", + "futures-util", "h2", "http", "http-body", @@ -2767,12 +2768,14 @@ dependencies = [ "tokio", "tokio-native-tls", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots", ] @@ -3803,6 +3806,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" version = "0.3.85" diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index fa897bd..72ad1e9 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -22,7 +22,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } tracing-appender = "0.2" metrics = "0.24" metrics-exporter-prometheus = "0.16" -reqwest = { version = "0.12", features = ["rustls-tls", "json"] } +reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream"] } urlencoding = "2" rust_xlsxwriter = "0.79" pmtiles = { version = "0.12", features = ["mmap-async-tokio"] } diff --git a/server-rs/src/data/places.rs b/server-rs/src/data/places.rs index d3abb0a..25f4f6c 100644 --- a/server-rs/src/data/places.rs +++ b/server-rs/src/data/places.rs @@ -22,18 +22,8 @@ pub struct PlaceData { fn type_rank(place_type: &str) -> u8 { match place_type { "city" => 0, - "borough" => 1, - "town" => 2, - "suburb" => 3, - "quarter" => 4, - "neighbourhood" => 5, - "village" => 6, - "station" => 7, - "island" => 8, - "hamlet" => 9, - "locality" => 10, - "isolated_dwelling" => 11, - _ => 12, + "station" => 1, + _ => 2, } } @@ -159,10 +149,7 @@ mod tests { #[test] fn type_rank_ordering() { - assert!(type_rank("city") < type_rank("town")); - assert!(type_rank("town") < type_rank("suburb")); - assert!(type_rank("suburb") < type_rank("village")); - assert!(type_rank("village") < type_rank("hamlet")); - assert!(type_rank("hamlet") < type_rank("isolated_dwelling")); + assert!(type_rank("city") < type_rank("station")); + assert!(type_rank("station") < type_rank("unknown")); } } diff --git a/server-rs/src/data/travel_time.rs b/server-rs/src/data/travel_time.rs index fe5814b..d8c6709 100644 --- a/server-rs/src/data/travel_time.rs +++ b/server-rs/src/data/travel_time.rs @@ -8,8 +8,15 @@ use polars::lazy::frame::LazyFrame; use rustc_hash::{FxHashMap, FxHashSet}; use tracing::info; -/// Cached postcode → travel_minutes mapping for a single destination file. -pub type TravelData = Arc>; +/// Per-postcode travel time data: median and optional best-case (transit only). +#[derive(Clone, Copy)] +pub struct TravelDataRow { + pub minutes: i16, + pub best_minutes: Option, +} + +/// Cached postcode → travel time data for a single destination file. +pub type TravelData = Arc>; /// Simple LRU cache for travel time data, limited to `capacity` entries. struct LruCache { @@ -159,12 +166,23 @@ impl TravelTimeStore { .context("Missing 'travel_minutes' column")? .i16() .context("'travel_minutes' is not i16")?; + let best = df + .column("best_minutes") + .ok() + .map(|col| col.i16().expect("'best_minutes' is not i16")); let mut map = FxHashMap::default(); map.reserve(df.height()); - for (pc, min) in postcodes.into_iter().zip(minutes.into_iter()) { + for (i, (pc, min)) in postcodes.into_iter().zip(minutes.into_iter()).enumerate() { if let (Some(pc), Some(min)) = (pc, min) { - map.insert(pc.to_string(), min); + let best_min = best.as_ref().and_then(|b| b.get(i)); + map.insert( + pc.to_string(), + TravelDataRow { + minutes: min, + best_minutes: best_min, + }, + ); } } diff --git a/server-rs/src/main.rs b/server-rs/src/main.rs index 997c81f..0104e8d 100644 --- a/server-rs/src/main.rs +++ b/server-rs/src/main.rs @@ -424,6 +424,7 @@ async fn main() -> anyhow::Result<()> { let state_invites_create = state.clone(); let state_invite_get = state.clone(); let state_redeem_invite = state.clone(); + let state_rightmove = state.clone(); let api = Router::new() .route( @@ -495,6 +496,10 @@ async fn main() -> anyhow::Result<()> { "/api/streetview", get(move |query| routes::get_streetview(state_streetview.clone(), query)), ) + .route( + "/api/rightmove-location", + get(move |query| routes::get_rightmove_typeahead(state_rightmove.clone(), query)), + ) .route( "/api/subscription", patch(move |ext, body| { @@ -569,7 +574,7 @@ async fn main() -> anyhow::Result<()> { let app = if let Some(ref dist) = cli.dist { api.fallback_service( - ServeDir::new(dist).not_found_service(ServeFile::new(dist.join("index.html"))), + ServeDir::new(dist).fallback(ServeFile::new(dist.join("index.html"))), ) } else { api diff --git a/server-rs/src/pocketbase.rs b/server-rs/src/pocketbase.rs index 7afb7d1..7a81246 100644 --- a/server-rs/src/pocketbase.rs +++ b/server-rs/src/pocketbase.rs @@ -405,35 +405,49 @@ pub async fn ensure_oauth_providers( let base_url = base_url.trim_end_matches('/'); let token = auth_superuser(client, base_url, admin_email, admin_password).await?; - // GET current settings + // Set meta.appURL in global settings for OAuth redirects + let app_url = format!("{}/pb", public_url.trim_end_matches('/')); let settings_url = format!("{base_url}/api/settings"); + let patch_resp = client + .patch(&settings_url) + .header("Authorization", format!("Bearer {token}")) + .json(&serde_json::json!({ "meta": { "appURL": app_url } })) + .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 update PocketBase meta.appURL ({status}): {text}"); + } + info!("PocketBase meta.appURL set to {app_url}"); + + // PocketBase 0.23+: OAuth providers are configured per-collection, not in global settings. + // GET the users collection to update its oauth2 config. + let collection_url = format!("{base_url}/api/collections/users"); let resp = client - .get(&settings_url) + .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 PocketBase settings ({status}): {text}"); + anyhow::bail!("Failed to fetch users collection ({status}): {text}"); } - let mut settings: serde_json::Value = resp.json().await?; + let mut collection: serde_json::Value = resp.json().await?; - // Set meta.appUrl for OAuth redirect - let app_url = format!("{}/pb", public_url.trim_end_matches('/')); - if let Some(meta) = settings.get_mut("meta") { - meta["appUrl"] = serde_json::json!(app_url); - } else { - settings["meta"] = serde_json::json!({ "appUrl": app_url }); - } + let oauth2 = collection + .get_mut("oauth2") + .ok_or_else(|| anyhow::anyhow!("users collection missing oauth2 field"))?; - // Update OAuth2 providers - let providers = settings - .pointer_mut("/oauth2/providers") + // Ensure enabled + oauth2["enabled"] = serde_json::json!(true); + + let providers = oauth2 + .get_mut("providers") .and_then(|v| v.as_array_mut()) - .ok_or_else(|| anyhow::anyhow!("PocketBase settings missing oauth2.providers array — cannot configure OAuth"))?; + .ok_or_else(|| anyhow::anyhow!("users collection missing oauth2.providers array"))?; let google = match providers .iter() @@ -441,7 +455,7 @@ pub async fn ensure_oauth_providers( { Some(idx) => &mut providers[idx], None => { - info!("Google provider not found in PocketBase settings — adding it"); + info!("Google provider not found — adding it"); providers.push(serde_json::json!({"name": "google"})); providers.last_mut().expect("just pushed") } @@ -449,23 +463,20 @@ pub async fn ensure_oauth_providers( google["clientId"] = serde_json::json!(google_client_id); google["clientSecret"] = serde_json::json!(google_client_secret); - google["enabled"] = serde_json::json!(true); - info!("Configured Google OAuth provider"); - // PATCH settings back + // PATCH the collection let patch_resp = client - .patch(&settings_url) + .patch(&collection_url) .header("Authorization", format!("Bearer {token}")) - .json(&settings) + .json(&serde_json::json!({ "oauth2": oauth2 })) .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 update PocketBase settings ({status}): {text}"); + anyhow::bail!("Failed to update users collection OAuth ({status}): {text}"); } - info!("PocketBase OAuth settings updated (appUrl: {app_url})"); + info!("PocketBase OAuth configured on users collection"); Ok(()) } diff --git a/server-rs/src/routes.rs b/server-rs/src/routes.rs index 6089973..f04e226 100644 --- a/server-rs/src/routes.rs +++ b/server-rs/src/routes.rs @@ -19,6 +19,7 @@ mod streetview; mod stripe_webhook; mod newsletter; pub(crate) mod pricing; +mod rightmove_typeahead; mod subscription; mod tiles; pub(crate) mod travel_time; @@ -46,4 +47,5 @@ pub use pricing::get_pricing; pub use stripe_webhook::post_stripe_webhook; pub use subscription::patch_subscription; pub use tiles::{get_style, get_tile, init_tile_reader}; +pub use rightmove_typeahead::get_rightmove_typeahead; pub use travel_modes::get_travel_modes; diff --git a/server-rs/src/routes/hexagon_stats.rs b/server-rs/src/routes/hexagon_stats.rs index 26a9428..c36785b 100644 --- a/server-rs/src/routes/hexagon_stats.rs +++ b/server-rs/src/routes/hexagon_stats.rs @@ -59,6 +59,8 @@ pub struct HexagonStatsResponse { pub enum_features: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] pub price_history: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub central_postcode: Option, } #[derive(Deserialize)] @@ -136,6 +138,31 @@ pub async fn get_hexagon_stats( let total_count = matching_rows.len(); + // Find the postcode of the property closest to the hexagon center + let central_postcode = if !matching_rows.is_empty() { + let center: h3o::LatLng = cell.into(); + let center_lat = center.lat() as f32; + let center_lon = center.lng() as f32; + let closest_row = matching_rows + .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) + }) + .expect("matching_rows is non-empty"); + Some(state.data.postcode(closest_row).to_string()) + } else { + None + }; + let price_history = stats::extract_price_history( &matching_rows, feature_data, @@ -170,6 +197,7 @@ pub async fn get_hexagon_stats( numeric_features, enum_features: enum_features_out, price_history, + central_postcode, }) }) .await diff --git a/server-rs/src/routes/hexagons.rs b/server-rs/src/routes/hexagons.rs index 2ee26b0..3eef756 100644 --- a/server-rs/src/routes/hexagons.rs +++ b/server-rs/src/routes/hexagons.rs @@ -43,12 +43,13 @@ pub struct HexagonParams { struct TravelEntry { mode: String, slug: String, + use_best: bool, filter_min: Option, filter_max: Option, } /// Parse `travel` param into a list of travel entries. -/// Format: `mode:slug` or `mode:slug:min:max` +/// Format: `mode:slug` or `mode:slug:best` or `mode:slug:min:max` or `mode:slug:best:min:max` fn parse_travel_entries(travel_str: &str) -> Result, String> { let mut entries = Vec::new(); let mut seen_keys = Vec::new(); @@ -63,12 +64,15 @@ fn parse_travel_entries(travel_str: &str) -> Result, String> { let mode = parts[0].trim().to_string(); let slug = parts[1].trim().to_string(); - let (filter_min, filter_max) = if parts.len() >= 4 { - let min: f32 = parts[2] + let use_best = parts.len() >= 3 && parts[2].trim() == "best"; + let filter_offset = if use_best { 1 } else { 0 }; + + let (filter_min, filter_max) = if parts.len() >= 4 + filter_offset { + let min: f32 = parts[2 + filter_offset] .trim() .parse() .map_err(|_| format!("invalid travel filter min in '{}'", segment))?; - let max: f32 = parts[3] + let max: f32 = parts[3 + filter_offset] .trim() .parse() .map_err(|_| format!("invalid travel filter max in '{}'", segment))?; @@ -85,6 +89,7 @@ fn parse_travel_entries(travel_str: &str) -> Result, String> { entries.push(TravelEntry { mode, slug, + use_best, filter_min, filter_max, }); @@ -286,7 +291,14 @@ pub async fn get_hexagons( let postcode = pc_interner.resolve(&pc_keys[row]); travel_minutes.reserve(travel_entries.len()); for (ti, entry) in travel_entries.iter().enumerate() { - let minutes = travel_data[ti].get(postcode).copied(); + let row_data = travel_data[ti].get(postcode); + let minutes = row_data.map(|r| { + if entry.use_best { + r.best_minutes.unwrap_or(r.minutes) + } else { + r.minutes + } + }); travel_minutes.push(minutes); if let (Some(fmin), Some(fmax)) = (entry.filter_min, entry.filter_max) { match minutes { diff --git a/server-rs/src/routes/pb_proxy.rs b/server-rs/src/routes/pb_proxy.rs index 122507d..06fc2db 100644 --- a/server-rs/src/routes/pb_proxy.rs +++ b/server-rs/src/routes/pb_proxy.rs @@ -11,10 +11,11 @@ use crate::state::AppState; /// Dedicated HTTP client for proxying — does not follow redirects so 3xx /// responses are passed through to the browser (needed for OAuth flows). +/// No overall timeout because SSE (Server-Sent Events) connections used by +/// PocketBase realtime/OAuth2 are long-lived streams. static PROXY_CLIENT: LazyLock = LazyLock::new(|| { reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) - .timeout(Duration::from_secs(30)) .connect_timeout(Duration::from_secs(5)) .build() .expect("Failed to build proxy HTTP client") @@ -97,16 +98,12 @@ pub async fn proxy_to_pocketbase(state: Arc, req: Request) -> impl Int } } - match upstream.bytes().await { - Ok(bytes) => response.body(Body::from(bytes)).unwrap(), - Err(err) => { - warn!("Failed to read upstream response: {err}"); - Response::builder() - .status(StatusCode::BAD_GATEWAY) - .body(Body::from("Failed to read upstream response")) - .unwrap() - } - } + // Stream the response body instead of buffering it entirely. + // This is critical for SSE (Server-Sent Events) used by PocketBase's + // realtime system and OAuth2 flow — buffering would hang forever + // since SSE responses never complete. + let body = Body::from_stream(upstream.bytes_stream()); + response.body(body).unwrap() } Err(err) => { warn!("PocketBase proxy error: {err}"); diff --git a/server-rs/src/routes/postcode_stats.rs b/server-rs/src/routes/postcode_stats.rs index 8e6aed1..6b96c4a 100644 --- a/server-rs/src/routes/postcode_stats.rs +++ b/server-rs/src/routes/postcode_stats.rs @@ -135,6 +135,7 @@ pub async fn get_postcode_stats( numeric_features, enum_features: enum_features_out, price_history, + central_postcode: None, }) }) .await diff --git a/server-rs/src/routes/rightmove_typeahead.rs b/server-rs/src/routes/rightmove_typeahead.rs new file mode 100644 index 0000000..a241290 --- /dev/null +++ b/server-rs/src/routes/rightmove_typeahead.rs @@ -0,0 +1,83 @@ +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, +} + +pub async fn get_rightmove_typeahead( + state: Arc, + Query(params): Query, +) -> Result, 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()) +}