More improvements
This commit is contained in:
parent
e0798b24f7
commit
72653a4ddf
7 changed files with 146 additions and 33 deletions
|
|
@ -89,7 +89,7 @@ Rust + Axum. Loads parquet into memory at startup.
|
||||||
**Structure** (uses Rust 2018 module style — `foo.rs` + `foo/` directory, not `foo/mod.rs`):
|
**Structure** (uses Rust 2018 module style — `foo.rs` + `foo/` directory, not `foo/mod.rs`):
|
||||||
- `data.rs` + `data/` — Property and POI data loading
|
- `data.rs` + `data/` — Property and POI data loading
|
||||||
- `parsing.rs` + `parsing/` — Filter parsing and bounds parsing
|
- `parsing.rs` + `parsing/` — Filter parsing and bounds parsing
|
||||||
- `routes.rs` + `routes/` — One file per endpoint
|
- `routes.rs` + `routes/` — One file per endpoint. `properties.rs` exports shared `build_property()` used by both hexagon and postcode property endpoints
|
||||||
- `utils.rs` + `utils/` — GridIndex, hashing, interned columns
|
- `utils.rs` + `utils/` — GridIndex, hashing, interned columns
|
||||||
- `consts.rs` — Key constants (histogram bins, H3 range, max enum cardinality, excluded columns)
|
- `consts.rs` — Key constants (histogram bins, H3 range, max enum cardinality, excluded columns)
|
||||||
|
|
||||||
|
|
@ -99,6 +99,7 @@ Rust + Axum. Loads parquet into memory at startup.
|
||||||
- `GET /api/postcodes?bounds=&filters=&fields=` — Postcode polygon aggregates, AABB-filtered to bounds
|
- `GET /api/postcodes?bounds=&filters=&fields=` — Postcode polygon aggregates, AABB-filtered to bounds
|
||||||
- `GET /api/postcode/:postcode` — Single postcode lookup (centroid + polygon)
|
- `GET /api/postcode/:postcode` — Single postcode lookup (centroid + polygon)
|
||||||
- `GET /api/hexagon-properties?h3=&resolution=&filters=&limit=&offset=` — Paginated properties within a hexagon
|
- `GET /api/hexagon-properties?h3=&resolution=&filters=&limit=&offset=` — Paginated properties within a hexagon
|
||||||
|
- `GET /api/postcode-properties?postcode=&filters=&limit=&offset=` — Paginated properties within a postcode
|
||||||
- `GET /api/pois?bounds=&categories=` — POIs by bounds (max 5000)
|
- `GET /api/pois?bounds=&categories=` — POIs by bounds (max 5000)
|
||||||
- `GET /api/poi-categories` — Available POI category names
|
- `GET /api/poi-categories` — Available POI category names
|
||||||
|
|
||||||
|
|
@ -120,7 +121,7 @@ React 18 + TypeScript. deck.gl `H3HexagonLayer` over MapLibre GL. TailwindCSS. N
|
||||||
- Custom hooks in `hooks/` encapsulate stateful logic:
|
- Custom hooks in `hooks/` encapsulate stateful logic:
|
||||||
- `useMapData` — Hexagon/postcode fetching, bounds, loading state, color range calculation
|
- `useMapData` — Hexagon/postcode fetching, bounds, loading state, color range calculation
|
||||||
- `useFilters` — Filter state and handlers (add/remove/change/drag/pin)
|
- `useFilters` — Filter state and handlers (add/remove/change/drag/pin)
|
||||||
- `useHexagonSelection` — Selection state, area stats, properties fetching
|
- `useHexagonSelection` — Selection state, area stats, properties fetching (supports both hexagons and postcodes)
|
||||||
- `usePOIData` — POI fetching with debounce
|
- `usePOIData` — POI fetching with debounce
|
||||||
- `usePaneResize` — Reusable pane resize handlers
|
- `usePaneResize` — Reusable pane resize handlers
|
||||||
- `useTheme` — Theme state with localStorage persistence
|
- `useTheme` — Theme state with localStorage persistence
|
||||||
|
|
@ -324,6 +325,7 @@ Follow these conventions in all Rust code:
|
||||||
- **Travel time is strict**: `mode` param is required (400) when `destination` is set — no silent default to "car". R5 failures return 502 Bad Gateway, not silent omission. `r5_url` is `Option<String>` — returns 503 if travel time requested without R5 configured.
|
- **Travel time is strict**: `mode` param is required (400) when `destination` is set — no silent default to "car". R5 failures return 502 Bad Gateway, not silent omission. `r5_url` is `Option<String>` — returns 503 if travel time requested without R5 configured.
|
||||||
- **Filter bounds format**: `south,west,north,east` (not standard bbox order)
|
- **Filter bounds format**: `south,west,north,east` (not standard bbox order)
|
||||||
- **Server-side AABB filtering**: Both `/api/hexagons` and `/api/postcodes` filter results by bounding-box intersection with query bounds. Hexagons use `h3_cell_bounds()` (h3o returns degrees, not radians). Postcodes compute polygon AABB from vertices. See `bounds_intersect()` in `parsing/bounds.rs`.
|
- **Server-side AABB filtering**: Both `/api/hexagons` and `/api/postcodes` filter results by bounding-box intersection with query bounds. Hexagons use `h3_cell_bounds()` (h3o returns degrees, not radians). Postcodes compute polygon AABB from vertices. See `bounds_intersect()` in `parsing/bounds.rs`.
|
||||||
|
- **Postcode row matching**: Both `postcode-stats` and `postcode-properties` use the same pattern: look up centroid from `postcode_data`, search `GridIndex` within `POSTCODE_SEARCH_OFFSET` (0.02°) of centroid, then exact string match on `state.data.postcode(row)`. Simpler than hexagon matching (no H3 cell computation needed).
|
||||||
- **GridIndex returns slightly more than requested**: The 0.01° grid cells mean properties up to ~1km outside the viewport may be returned. The AABB filter in the route handlers catches these extras.
|
- **GridIndex returns slightly more than requested**: The 0.01° grid cells mean properties up to ~1km outside the viewport may be returned. The AABB filter in the route handlers catches these extras.
|
||||||
- **POI proximity**: Uses 0.05° grid (~5km cells) to reduce candidates before haversine distance check
|
- **POI proximity**: Uses 0.05° grid (~5km cells) to reduce candidates before haversine distance check
|
||||||
- **OG tag injection**: Uses `<meta name="x-og-placeholder" content="__PERFECT_POSTCODES_OG_TAGS__"/>` placeholder in HTML, replaced at runtime by middleware
|
- **OG tag injection**: Uses `<meta name="x-og-placeholder" content="__PERFECT_POSTCODES_OG_TAGS__"/>` placeholder in HTML, replaced at runtime by middleware
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { memo, useState, useMemo, useRef, useCallback } from 'react';
|
import { memo, useState, useMemo, useRef, useCallback, useEffect } from 'react';
|
||||||
import { Slider } from '../ui/Slider';
|
import { Slider } from '../ui/Slider';
|
||||||
import { LightbulbIcon } from '../ui/icons';
|
import { LightbulbIcon } from '../ui/icons';
|
||||||
|
|
||||||
|
|
@ -212,23 +212,27 @@ export default memo(function Filters({
|
||||||
|
|
||||||
const activeEntryCount = travelTimeEntries.length;
|
const activeEntryCount = travelTimeEntries.length;
|
||||||
|
|
||||||
|
const pendingScrollRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const handleAddAndScroll = useCallback(
|
const handleAddAndScroll = useCallback(
|
||||||
(name: string) => {
|
(name: string) => {
|
||||||
const feature = features.find((f) => f.name === name);
|
const feature = features.find((f) => f.name === name);
|
||||||
if (feature?.group) expandGroup(feature.group);
|
if (feature?.group) expandGroup(feature.group);
|
||||||
|
pendingScrollRef.current = name;
|
||||||
onAddFilter(name);
|
onAddFilter(name);
|
||||||
// Double rAF: first lets React commit the DOM update, second lets layout settle
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const el = scrollRef.current?.querySelector(`[data-filter-name="${CSS.escape(name)}"]`);
|
|
||||||
if (el) {
|
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[onAddFilter, features, expandGroup]
|
[onAddFilter, features, expandGroup]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const name = pendingScrollRef.current;
|
||||||
|
if (!name) return;
|
||||||
|
pendingScrollRef.current = null;
|
||||||
|
const el = scrollRef.current?.querySelector(`[data-filter-name="${CSS.escape(name)}"]`);
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
}, [enabledFeatureList]);
|
||||||
const enabledGroups = useMemo(
|
const enabledGroups = useMemo(
|
||||||
() => groupFeaturesByCategory(enabledFeatureList),
|
() => groupFeaturesByCategory(enabledFeatureList),
|
||||||
[enabledFeatureList]
|
[enabledFeatureList]
|
||||||
|
|
@ -357,7 +361,7 @@ export default memo(function Filters({
|
||||||
<div
|
<div
|
||||||
key={feature.name}
|
key={feature.name}
|
||||||
data-filter-name={feature.name}
|
data-filter-name={feature.name}
|
||||||
className={`space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
className={`scroll-mt-7 space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" />
|
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" />
|
||||||
|
|
@ -414,7 +418,7 @@ export default memo(function Filters({
|
||||||
<div
|
<div
|
||||||
key={feature.name}
|
key={feature.name}
|
||||||
data-filter-name={feature.name}
|
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={`scroll-mt-7 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' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-1">
|
<div className="flex items-center justify-between gap-1">
|
||||||
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" className="min-w-0 shrink" />
|
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" className="min-w-0 shrink" />
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,22 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||||
import { TabButton } from '../ui/TabButton';
|
import { TabButton } from '../ui/TabButton';
|
||||||
|
|
||||||
type DrawerTab = 'area' | 'properties';
|
|
||||||
|
|
||||||
interface MobileDrawerProps {
|
interface MobileDrawerProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
renderArea: () => React.ReactNode;
|
renderArea: () => React.ReactNode;
|
||||||
renderProperties: () => React.ReactNode;
|
renderProperties: () => React.ReactNode;
|
||||||
|
tab: 'area' | 'properties';
|
||||||
|
onTabChange: (tab: 'area' | 'properties') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MobileDrawer({
|
export default function MobileDrawer({
|
||||||
onClose,
|
onClose,
|
||||||
renderArea,
|
renderArea,
|
||||||
renderProperties,
|
renderProperties,
|
||||||
|
tab,
|
||||||
|
onTabChange,
|
||||||
}: MobileDrawerProps) {
|
}: MobileDrawerProps) {
|
||||||
const [tab, setTab] = useState<DrawerTab>('area');
|
|
||||||
|
|
||||||
// Close on Escape
|
// Close on Escape
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -35,11 +36,11 @@ export default function MobileDrawer({
|
||||||
<div className="h-[90%] bg-white dark:bg-navy-950 rounded-t-2xl flex flex-col shadow-xl overflow-hidden">
|
<div className="h-[90%] bg-white dark:bg-navy-950 rounded-t-2xl flex flex-col shadow-xl overflow-hidden">
|
||||||
{/* Tab bar + close */}
|
{/* Tab bar + close */}
|
||||||
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm shrink-0">
|
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm shrink-0">
|
||||||
<TabButton label="Area" isActive={tab === 'area'} onClick={() => setTab('area')} />
|
<TabButton label="Area" isActive={tab === 'area'} onClick={() => onTabChange('area')} />
|
||||||
<TabButton
|
<TabButton
|
||||||
label="Properties"
|
label="Properties"
|
||||||
isActive={tab === 'properties'}
|
isActive={tab === 'properties'}
|
||||||
onClick={() => setTab('properties')}
|
onClick={() => onTabChange('properties')}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
export function useCollapsibleGroups(): [Set<string>, (name: string) => void] {
|
export function useCollapsibleGroups(): [Set<string>, (name: string) => void, (name: string) => void] {
|
||||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const toggle = useCallback((name: string) => {
|
const toggle = useCallback((name: string) => {
|
||||||
|
|
@ -12,5 +12,14 @@ export function useCollapsibleGroups(): [Set<string>, (name: string) => void] {
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return [collapsed, toggle];
|
const expand = useCallback((name: string) => {
|
||||||
|
setCollapsed((prev) => {
|
||||||
|
if (!prev.has(name)) return prev;
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(name);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return [collapsed, toggle, expand];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import { trackEvent } from '../lib/analytics';
|
import { trackEvent } from '../lib/analytics';
|
||||||
import type {
|
import type {
|
||||||
FeatureMeta,
|
FeatureMeta,
|
||||||
|
|
@ -99,6 +99,39 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
|
||||||
[filters, features]
|
[filters, features]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const fetchPostcodeProperties = useCallback(
|
||||||
|
async (postcode: string, offset = 0) => {
|
||||||
|
setLoadingProperties(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
postcode,
|
||||||
|
limit: '100',
|
||||||
|
offset: offset.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const filterStr = buildFilterString(filters, features);
|
||||||
|
if (filterStr) params.append('filters', filterStr);
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl('postcode-properties', params), authHeaders());
|
||||||
|
assertOk(response, 'postcode-properties');
|
||||||
|
const data: HexagonPropertiesResponse = await response.json();
|
||||||
|
|
||||||
|
if (offset === 0) {
|
||||||
|
setProperties(data.properties);
|
||||||
|
} else {
|
||||||
|
setProperties((prev) => [...prev, ...data.properties]);
|
||||||
|
}
|
||||||
|
setPropertiesTotal(data.total);
|
||||||
|
setPropertiesOffset(offset + data.properties.length);
|
||||||
|
} catch (err) {
|
||||||
|
logNonAbortError('Failed to fetch postcode properties', err);
|
||||||
|
} finally {
|
||||||
|
setLoadingProperties(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[filters, features]
|
||||||
|
);
|
||||||
|
|
||||||
const handleHexagonClick = useCallback(
|
const handleHexagonClick = useCallback(
|
||||||
(id: string, isPostcode = false, geometry?: PostcodeGeometry) => {
|
(id: string, isPostcode = false, geometry?: PostcodeGeometry) => {
|
||||||
if (selectedHexagon?.id === id) {
|
if (selectedHexagon?.id === id) {
|
||||||
|
|
@ -139,27 +172,37 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleViewPropertiesFromArea = useCallback(() => {
|
const handleViewPropertiesFromArea = useCallback(() => {
|
||||||
if (selectedHexagon && selectedHexagon.type === 'hexagon') {
|
if (!selectedHexagon) return;
|
||||||
trackEvent('View Properties');
|
trackEvent('View Properties');
|
||||||
setRightPaneTab('properties');
|
setRightPaneTab('properties');
|
||||||
setPropertiesOffset(0);
|
setPropertiesOffset(0);
|
||||||
|
if (selectedHexagon.type === 'postcode') {
|
||||||
|
fetchPostcodeProperties(selectedHexagon.id, 0);
|
||||||
|
} else {
|
||||||
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
|
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
|
||||||
}
|
}
|
||||||
}, [selectedHexagon, fetchHexagonProperties]);
|
}, [selectedHexagon, fetchHexagonProperties, fetchPostcodeProperties]);
|
||||||
|
|
||||||
const handlePropertiesTabClick = useCallback(() => {
|
const handlePropertiesTabClick = useCallback(() => {
|
||||||
setRightPaneTab('properties');
|
setRightPaneTab('properties');
|
||||||
if (selectedHexagon?.type === 'hexagon' && properties.length === 0 && !loadingProperties) {
|
if (selectedHexagon && properties.length === 0 && !loadingProperties) {
|
||||||
setPropertiesOffset(0);
|
setPropertiesOffset(0);
|
||||||
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
|
if (selectedHexagon.type === 'postcode') {
|
||||||
|
fetchPostcodeProperties(selectedHexagon.id, 0);
|
||||||
|
} else {
|
||||||
|
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [selectedHexagon, properties.length, loadingProperties, fetchHexagonProperties]);
|
}, [selectedHexagon, properties.length, loadingProperties, fetchHexagonProperties, fetchPostcodeProperties]);
|
||||||
|
|
||||||
const handleLoadMoreProperties = useCallback(() => {
|
const handleLoadMoreProperties = useCallback(() => {
|
||||||
if (selectedHexagon && selectedHexagon.type === 'hexagon') {
|
if (!selectedHexagon) return;
|
||||||
|
if (selectedHexagon.type === 'postcode') {
|
||||||
|
fetchPostcodeProperties(selectedHexagon.id, propertiesOffset);
|
||||||
|
} else {
|
||||||
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, propertiesOffset);
|
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, propertiesOffset);
|
||||||
}
|
}
|
||||||
}, [selectedHexagon, propertiesOffset, fetchHexagonProperties]);
|
}, [selectedHexagon, propertiesOffset, fetchHexagonProperties, fetchPostcodeProperties]);
|
||||||
|
|
||||||
const handleCloseSelection = useCallback(() => {
|
const handleCloseSelection = useCallback(() => {
|
||||||
setSelectedHexagon(null);
|
setSelectedHexagon(null);
|
||||||
|
|
@ -168,6 +211,53 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
|
||||||
setSelectedPostcodeGeometry(null);
|
setSelectedPostcodeGeometry(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Re-fetch stats when filters change while a hexagon is selected
|
||||||
|
const filterStr = useMemo(() => buildFilterString(filters, features), [filters, features]);
|
||||||
|
const prevFilterStr = useRef(filterStr);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevFilterStr.current === filterStr) return;
|
||||||
|
prevFilterStr.current = filterStr;
|
||||||
|
|
||||||
|
if (!selectedHexagon) return;
|
||||||
|
|
||||||
|
// Clear stale properties
|
||||||
|
setProperties([]);
|
||||||
|
setPropertiesTotal(0);
|
||||||
|
setPropertiesOffset(0);
|
||||||
|
|
||||||
|
setLoadingAreaStats(true);
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const fetchStats =
|
||||||
|
selectedHexagon.type === 'postcode'
|
||||||
|
? fetchPostcodeStats(selectedHexagon.id)
|
||||||
|
: fetchHexagonStats(selectedHexagon.id, selectedHexagon.resolution);
|
||||||
|
|
||||||
|
fetchStats
|
||||||
|
.then((stats) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (stats.count === 0) {
|
||||||
|
setSelectedHexagon(null);
|
||||||
|
setAreaStats(null);
|
||||||
|
setSelectedPostcodeGeometry(null);
|
||||||
|
} else {
|
||||||
|
setAreaStats(stats);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
logNonAbortError('Failed to refresh stats', error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoadingAreaStats(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [filterStr, selectedHexagon, fetchHexagonStats, fetchPostcodeStats]);
|
||||||
|
|
||||||
const handleLocationSearch = useCallback(
|
const handleLocationSearch = useCallback(
|
||||||
(postcode: string, geometry: PostcodeGeometry) => {
|
(postcode: string, geometry: PostcodeGeometry) => {
|
||||||
trackEvent('Postcode Search');
|
trackEvent('Postcode Search');
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<meta name="theme-color" content="#0f1528" />
|
<meta name="theme-color" content="#0f1528" />
|
||||||
|
<meta name="referrer" content="no-referrer" />
|
||||||
<title>Perfect Postcode — Every neighbourhood in England</title>
|
<title>Perfect Postcode — Every neighbourhood in England</title>
|
||||||
<meta name="description" content="Explore property prices, energy ratings, crime stats, school ratings, and more across England on one interactive map." />
|
<meta name="description" content="Explore property prices, energy ratings, crime stats, school ratings, and more across England on one interactive map." />
|
||||||
<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__" />
|
<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__" />
|
||||||
|
|
|
||||||
|
|
@ -289,6 +289,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
let http_client = reqwest::Client::builder()
|
let http_client = reqwest::Client::builder()
|
||||||
.timeout(Duration::from_secs(SERVICE_CALL_TIMEOUT))
|
.timeout(Duration::from_secs(SERVICE_CALL_TIMEOUT))
|
||||||
.connect_timeout(Duration::from_secs(5))
|
.connect_timeout(Duration::from_secs(5))
|
||||||
|
.referer(false)
|
||||||
.build()
|
.build()
|
||||||
.context("Failed to build HTTP client")?;
|
.context("Failed to build HTTP client")?;
|
||||||
|
|
||||||
|
|
@ -410,6 +411,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
let state_crawler = state.clone();
|
let state_crawler = state.clone();
|
||||||
let state_pb = state.clone();
|
let state_pb = state.clone();
|
||||||
let state_postcode_stats = state.clone();
|
let state_postcode_stats = state.clone();
|
||||||
|
let state_postcode_properties = state.clone();
|
||||||
let state_places = state.clone();
|
let state_places = state.clone();
|
||||||
let state_shorten = state.clone();
|
let state_shorten = state.clone();
|
||||||
let state_short_url = state.clone();
|
let state_short_url = state.clone();
|
||||||
|
|
@ -473,6 +475,10 @@ async fn main() -> anyhow::Result<()> {
|
||||||
"/api/postcode-stats",
|
"/api/postcode-stats",
|
||||||
get(move |ext, query| routes::get_postcode_stats(state_postcode_stats.clone(), ext, query)),
|
get(move |ext, query| routes::get_postcode_stats(state_postcode_stats.clone(), ext, query)),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/postcode-properties",
|
||||||
|
get(move |ext, query| routes::get_postcode_properties(state_postcode_properties.clone(), ext, query)),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/screenshot",
|
"/api/screenshot",
|
||||||
get(move |headers, query| routes::get_screenshot(state_screenshot.clone(), headers, query)),
|
get(move |headers, query| routes::get_screenshot(state_screenshot.clone(), headers, query)),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue