Checkpoint all changes

This commit is contained in:
Andras Schmelczer 2026-02-01 19:30:33 +00:00
parent 65877acf95
commit 66c2a25457
28 changed files with 3035 additions and 621 deletions

View file

@ -3,8 +3,10 @@ import Map from './components/Map';
import Filters from './components/Filters'; import Filters from './components/Filters';
import POIPane from './components/POIPane'; import POIPane from './components/POIPane';
import { PropertiesPane } from './components/PropertiesPane'; import { PropertiesPane } from './components/PropertiesPane';
import AreaPane from './components/AreaPane';
import DataSources from './components/DataSources'; import DataSources from './components/DataSources';
import DataSourcesPage from './components/DataSourcesPage'; import DataSourcesPage from './components/DataSourcesPage';
import FAQPage from './components/FAQPage';
import HomePage from './components/HomePage'; import HomePage from './components/HomePage';
import type { import type {
FeatureMeta, FeatureMeta,
@ -21,18 +23,43 @@ import type {
ViewState, ViewState,
Property, Property,
HexagonPropertiesResponse, HexagonPropertiesResponse,
HexagonStatsResponse,
} from './types'; } from './types';
type Theme = 'light' | 'dark'; type Theme = 'light' | 'dark';
const DEBOUNCE_MS = 150; const DEBOUNCE_MS = 150;
const URL_DEBOUNCE_MS = 300; const URL_DEBOUNCE_MS = 300;
const INITIAL_RETRY_MS = 1000;
const MAX_RETRY_MS = 10000;
async function fetchWithRetry<T>(
url: string,
onSuccess: (data: T) => void,
signal: AbortSignal
): Promise<void> {
let delay = INITIAL_RETRY_MS;
while (!signal.aborted) {
try {
const res = await fetch(url, { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
onSuccess(json);
return;
} catch (err) {
if (signal.aborted) return;
console.error(`Failed to fetch ${url}, retrying in ${delay}ms:`, err);
await new Promise((resolve) => setTimeout(resolve, delay));
delay = Math.min(delay * 2, MAX_RETRY_MS);
}
}
}
// Detect if running through VS Code web proxy and construct API base URL // Detect if running through VS Code web proxy and construct API base URL
function getApiBaseUrl(): string { function getApiBaseUrl(): string {
const { hostname, pathname, href } = window.location; const { pathname, href } = window.location;
// Check pathname for /proxy/PORT pattern // Check pathname for /proxy/PORT pattern (VS Code web proxy)
const pathMatch = pathname.match(/^(\/proxy\/)(\d+)/); const pathMatch = pathname.match(/^(\/proxy\/)(\d+)/);
if (pathMatch) { if (pathMatch) {
return `${pathMatch[1]}8001`; return `${pathMatch[1]}8001`;
@ -44,12 +71,7 @@ function getApiBaseUrl(): string {
return `${hrefMatch[1]}8001`; return `${hrefMatch[1]}8001`;
} }
// If not localhost, assume we're behind a proxy and need explicit backend port // Default: same origin (works for both local dev with webpack proxy and production)
if (hostname !== 'localhost' && hostname !== '127.0.0.1') {
return '/proxy/8001';
}
// Local development - webpack proxies /api to :8001
return ''; return '';
} }
@ -66,7 +88,7 @@ function parseUrlState(): {
viewState?: ViewState; viewState?: ViewState;
filters?: FeatureFilters; filters?: FeatureFilters;
poiCategories?: Set<string>; poiCategories?: Set<string>;
tab?: 'pois' | 'properties'; tab?: 'pois' | 'properties' | 'area';
} { } {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const result: ReturnType<typeof parseUrlState> = {}; const result: ReturnType<typeof parseUrlState> = {};
@ -121,10 +143,11 @@ function parseUrlState(): {
result.poiCategories = new Set(poi.split(',').filter(Boolean)); result.poiCategories = new Set(poi.split(',').filter(Boolean));
} }
// Parse tab: tab=p or tab=o // Parse tab: tab=p or tab=o or tab=a
const tab = params.get('tab'); const tab = params.get('tab');
if (tab === 'p') result.tab = 'properties'; if (tab === 'p') result.tab = 'properties';
else if (tab === 'o') result.tab = 'pois'; else if (tab === 'o') result.tab = 'pois';
else if (tab === 'a') result.tab = 'area';
return result; return result;
} }
@ -134,7 +157,7 @@ function stateToParams(
filters: FeatureFilters, filters: FeatureFilters,
features: FeatureMeta[], features: FeatureMeta[],
selectedPOICategories: Set<string>, selectedPOICategories: Set<string>,
rightPaneTab: 'pois' | 'properties' rightPaneTab: 'pois' | 'properties' | 'area'
): URLSearchParams { ): URLSearchParams {
const params = new URLSearchParams(); const params = new URLSearchParams();
@ -170,6 +193,8 @@ function stateToParams(
// Tab (only if non-default) // Tab (only if non-default)
if (rightPaneTab === 'properties') { if (rightPaneTab === 'properties') {
params.set('tab', 'p'); params.set('tab', 'p');
} else if (rightPaneTab === 'area') {
params.set('tab', 'a');
} }
return params; return params;
@ -177,7 +202,7 @@ function stateToParams(
// --- Header --- // --- Header ---
type Page = 'home' | 'dashboard' | 'data-sources'; type Page = 'home' | 'dashboard' | 'data-sources' | 'faq';
function Header({ function Header({
activePage, activePage,
@ -200,9 +225,9 @@ function Header({
}, []); }, []);
const tabClass = (page: Page) => const tabClass = (page: Page) =>
`px-3 py-1.5 rounded text-sm transition-colors ${ `px-3 py-1.5 rounded text-sm font-medium transition-colors ${
activePage === page activePage === page
? 'bg-navy-700 font-semibold' ? 'bg-navy-700 text-white'
: 'text-warm-300 hover:bg-navy-800 hover:text-white' : 'text-warm-300 hover:bg-navy-800 hover:text-white'
}`; }`;
@ -234,16 +259,16 @@ function Header({
</svg> </svg>
<span className="font-semibold text-lg">Narrowit</span> <span className="font-semibold text-lg">Narrowit</span>
</button> </button>
<nav className="flex items-center gap-1"> <nav className="flex items-center gap-2">
<button className={tabClass('home')} onClick={() => onPageChange('home')}>
Home
</button>
<button className={tabClass('dashboard')} onClick={() => onPageChange('dashboard')}> <button className={tabClass('dashboard')} onClick={() => onPageChange('dashboard')}>
Dashboard Dashboard
</button> </button>
<button className={tabClass('data-sources')} onClick={() => onPageChange('data-sources')}> <button className={tabClass('data-sources')} onClick={() => onPageChange('data-sources')}>
Data Sources Data Sources
</button> </button>
<button className={tabClass('faq')} onClick={() => onPageChange('faq')}>
FAQ
</button>
</nav> </nav>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -355,8 +380,62 @@ export default function App() {
const [propertiesTotal, setPropertiesTotal] = useState(0); const [propertiesTotal, setPropertiesTotal] = useState(0);
const [propertiesOffset, setPropertiesOffset] = useState(0); const [propertiesOffset, setPropertiesOffset] = useState(0);
const [loadingProperties, setLoadingProperties] = useState(false); const [loadingProperties, setLoadingProperties] = useState(false);
const [rightPaneTab, setRightPaneTab] = useState<'pois' | 'properties'>(urlState.tab || 'pois'); const [rightPaneTab, setRightPaneTab] = useState<'pois' | 'properties' | 'area'>(urlState.tab || 'pois');
const [activePage, setActivePage] = useState<Page>('home');
// Area stats state
const [areaStats, setAreaStats] = useState<HexagonStatsResponse | null>(null);
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
// Hover state
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
const [hoveredAreaStats, setHoveredAreaStats] = useState<HexagonStatsResponse | null>(null);
const [hoveredProperties, setHoveredProperties] = useState<Property[] | null>(null);
const [hoveredPropertiesTotal, setHoveredPropertiesTotal] = useState(0);
const [loadingHoveredAreaStats, setLoadingHoveredAreaStats] = useState(false);
const [hoverMode, setHoverMode] = useState(true);
const hoverAbortRef = useRef<AbortController | null>(null);
const hoverDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [initialLoading, setInitialLoading] = useState(true);
const [activePage, setActivePage] = useState<Page>(() => {
// Restore from history state if available (e.g. back/forward navigation)
if (window.history.state?.page) return window.history.state.page;
const params = new URLSearchParams(window.location.search);
return params.has('v') || params.has('f') || params.has('poi') || params.has('tab')
? 'dashboard'
: 'home';
});
// Feature name to auto-open in the info popup after back navigation
const [pendingInfoFeature, setPendingInfoFeature] = useState<string | null>(null);
// Navigate between pages with history support
const navigateTo = useCallback((page: Page, hash?: string, infoFeature?: string) => {
// Before pushing, tag the current state with the info feature so back restores it
if (infoFeature) {
window.history.replaceState({ ...window.history.state, infoFeature }, '');
}
const url = hash ? `${window.location.pathname}${window.location.search}#${hash}` : `${window.location.pathname}${window.location.search}`;
window.history.pushState({ page }, '', url);
setActivePage(page);
}, []);
// Handle browser back/forward
useEffect(() => {
// Tag the initial state so popstate can restore it
if (!window.history.state?.page) {
window.history.replaceState({ page: activePage }, '');
}
const handlePopState = (e: PopStateEvent) => {
if (e.state?.page) {
setActivePage(e.state.page);
if (e.state.infoFeature) {
setPendingInfoFeature(e.state.infoFeature);
}
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Theme state — defaults to system preference on first visit // Theme state — defaults to system preference on first visit
const [theme, setTheme] = useState<Theme>(() => { const [theme, setTheme] = useState<Theme>(() => {
@ -387,10 +466,15 @@ export default function App() {
const viewFeature = activeFeature || pinnedFeature; const viewFeature = activeFeature || pinnedFeature;
const viewSource: 'drag' | 'eye' | null = activeFeature ? 'drag' : pinnedFeature ? 'eye' : null; const viewSource: 'drag' | 'eye' | null = activeFeature ? 'drag' : pinnedFeature ? 'eye' : null;
// Color range: always the feature's full slider range from metadata // Color range: always the feature's full slider range from metadata
// For enum features, use ordinal index range [0, values.length - 1]
const colorRange = useMemo((): [number, number] | null => { const colorRange = useMemo((): [number, number] | null => {
if (!viewFeature) return null; if (!viewFeature) return null;
const meta = features.find((f) => f.name === viewFeature); const meta = features.find((f) => f.name === viewFeature);
if (meta?.min != null && meta?.max != null) return [meta.min, meta.max]; if (!meta) return null;
if (meta.type === 'enum' && meta.values && meta.values.length > 0) {
return [0, meta.values.length - 1];
}
if (meta.min != null && meta.max != null) return [meta.min, meta.max];
return null; return null;
}, [viewFeature, features]); }, [viewFeature, features]);
@ -420,7 +504,7 @@ export default function App() {
); );
const search = params.toString(); const search = params.toString();
const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname; const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname;
window.history.replaceState(null, '', newUrl); window.history.replaceState({ ...window.history.state }, '', newUrl);
}, URL_DEBOUNCE_MS); }, URL_DEBOUNCE_MS);
return () => { return () => {
@ -428,25 +512,40 @@ export default function App() {
}; };
}, [currentView, filters, features, selectedPOICategories, rightPaneTab]); }, [currentView, filters, features, selectedPOICategories, rightPaneTab]);
// Fetch feature metadata + POI categories on mount // Fetch feature metadata + POI categories on mount with exponential backoff
useEffect(() => { useEffect(() => {
fetch(`${getApiBaseUrl()}/api/features`) const controller = new AbortController();
.then((res) => res.json()) let featuresLoaded = false;
.then((json: { groups: FeatureGroup[] }) => { let poisLoaded = false;
// Flatten grouped response into a flat feature list with group annotation
const checkDone = () => {
if (featuresLoaded && poisLoaded) setInitialLoading(false);
};
fetchWithRetry<{ groups: FeatureGroup[] }>(
`${getApiBaseUrl()}/api/features`,
(json) => {
const flat: FeatureMeta[] = json.groups.flatMap((g) => const flat: FeatureMeta[] = json.groups.flatMap((g) =>
g.features.map((f) => ({ ...f, group: g.name })) g.features.map((f) => ({ ...f, group: g.name }))
); );
setFeatures(flat); setFeatures(flat);
}) featuresLoaded = true;
.catch((err) => console.error('Failed to fetch features:', err)); checkDone();
},
controller.signal
);
fetch(`${getApiBaseUrl()}/api/poi-categories`) fetchWithRetry<POICategoriesResponse>(
.then((res) => res.json()) `${getApiBaseUrl()}/api/poi-categories`,
.then((json: POICategoriesResponse) => { (json) => {
setPOICategoryGroups(json.groups); setPOICategoryGroups(json.groups);
}) poisLoaded = true;
.catch((err) => console.error('Failed to fetch POI categories:', err)); checkDone();
},
controller.signal
);
return () => controller.abort();
}, []); }, []);
// Build filter query string helper // Build filter query string helper
@ -674,6 +773,32 @@ export default function App() {
setPinnedFeature(null); setPinnedFeature(null);
}, []); }, []);
const fetchHexagonStats = useCallback(
async (h3: string, res: number, signal?: AbortSignal) => {
const params = new URLSearchParams({
h3,
resolution: res.toString(),
});
const filterEntries = Object.entries(filters);
if (filterEntries.length > 0) {
const filterStr = filterEntries
.map(([name, value]) => {
const meta = features.find((feature) => feature.name === name);
if (meta?.type === 'enum') {
return `${name}:${(value as string[]).join('|')}`;
}
const [min, max] = value as [number, number];
return `${name}:${min}:${max}`;
})
.join(',');
params.append('filters', filterStr);
}
const response = await fetch(`${getApiBaseUrl()}/api/hexagon-stats?${params}`, { signal });
return (await response.json()) as HexagonStatsResponse;
},
[filters, features]
);
const fetchHexagonProperties = useCallback( const fetchHexagonProperties = useCallback(
async (h3: string, res: number, offset = 0) => { async (h3: string, res: number, offset = 0) => {
setLoadingProperties(true); setLoadingProperties(true);
@ -726,16 +851,96 @@ export default function App() {
// Deselect if clicking same hexagon // Deselect if clicking same hexagon
setSelectedHexagon(null); setSelectedHexagon(null);
setProperties([]); setProperties([]);
setAreaStats(null);
} else { } else {
setSelectedHexagon({ h3, resolution }); setSelectedHexagon({ h3, resolution });
setPropertiesOffset(0); setPropertiesOffset(0);
setRightPaneTab('properties'); // Auto-switch to properties tab setRightPaneTab('area'); // Auto-switch to area tab
fetchHexagonProperties(h3, resolution, 0); setLoadingAreaStats(true);
fetchHexagonStats(h3, resolution)
.then((stats) => setAreaStats(stats))
.catch((error) => {
if (error instanceof Error && error.name !== 'AbortError') {
console.error('Failed to fetch area stats:', error);
}
})
.finally(() => setLoadingAreaStats(false));
} }
}, },
[selectedHexagon, resolution, fetchHexagonProperties] [selectedHexagon, resolution, fetchHexagonStats]
); );
const handleHexagonHover = useCallback(
(h3: string | null) => {
setHoveredHexagon(h3);
if (!hoverMode || !h3 || h3 === selectedHexagon?.h3) {
if (hoverDebounceRef.current) clearTimeout(hoverDebounceRef.current);
if (hoverAbortRef.current) hoverAbortRef.current.abort();
setHoveredAreaStats(null);
setHoveredProperties(null);
setHoveredPropertiesTotal(0);
return;
}
if (hoverDebounceRef.current) clearTimeout(hoverDebounceRef.current);
hoverDebounceRef.current = setTimeout(async () => {
if (hoverAbortRef.current) hoverAbortRef.current.abort();
hoverAbortRef.current = new AbortController();
const signal = hoverAbortRef.current.signal;
try {
if (rightPaneTab === 'area') {
setLoadingHoveredAreaStats(true);
const stats = await fetchHexagonStats(h3, resolution, signal);
if (!signal.aborted) setHoveredAreaStats(stats);
} else if (rightPaneTab === 'properties') {
const params = new URLSearchParams({
h3,
resolution: resolution.toString(),
limit: '3',
offset: '0',
});
const filterEntries = Object.entries(filters);
if (filterEntries.length > 0) {
const filterStr = filterEntries
.map(([name, value]) => {
const meta = features.find((feature) => feature.name === name);
if (meta?.type === 'enum') return `${name}:${(value as string[]).join('|')}`;
const [min, max] = value as [number, number];
return `${name}:${min}:${max}`;
})
.join(',');
params.append('filters', filterStr);
}
const response = await fetch(`${getApiBaseUrl()}/api/hexagon-properties?${params}`, {
signal,
});
const data: HexagonPropertiesResponse = await response.json();
if (!signal.aborted) {
setHoveredProperties(data.properties);
setHoveredPropertiesTotal(data.total);
}
}
} catch (error) {
if (error instanceof Error && error.name !== 'AbortError') {
console.error('Failed to fetch hover data:', error);
}
} finally {
if (!signal.aborted) setLoadingHoveredAreaStats(false);
}
}, DEBOUNCE_MS);
},
[hoverMode, selectedHexagon, rightPaneTab, resolution, filters, features, fetchHexagonStats]
);
const handleViewPropertiesFromArea = useCallback(() => {
if (selectedHexagon) {
setRightPaneTab('properties');
setPropertiesOffset(0);
fetchHexagonProperties(selectedHexagon.h3, selectedHexagon.resolution, 0);
}
}, [selectedHexagon, fetchHexagonProperties]);
const handleLoadMoreProperties = useCallback(() => { const handleLoadMoreProperties = useCallback(() => {
if (selectedHexagon) { if (selectedHexagon) {
fetchHexagonProperties(selectedHexagon.h3, selectedHexagon.resolution, propertiesOffset); fetchHexagonProperties(selectedHexagon.h3, selectedHexagon.resolution, propertiesOffset);
@ -745,17 +950,48 @@ export default function App() {
const handleCloseProperties = useCallback(() => { const handleCloseProperties = useCallback(() => {
setSelectedHexagon(null); setSelectedHexagon(null);
setProperties([]); setProperties([]);
setAreaStats(null);
}, []); }, []);
return ( return (
<div className="h-screen flex flex-col"> <div className="h-screen flex flex-col">
<Header activePage={activePage} onPageChange={setActivePage} theme={theme} onToggleTheme={toggleTheme} /> <Header activePage={activePage} onPageChange={navigateTo} theme={theme} onToggleTheme={toggleTheme} />
{activePage === 'home' ? ( {activePage === 'home' ? (
<HomePage onOpenDashboard={() => setActivePage('dashboard')} theme={theme} /> <HomePage onOpenDashboard={() => navigateTo('dashboard')} theme={theme} />
) : activePage === 'data-sources' ? ( ) : activePage === 'data-sources' ? (
<DataSourcesPage /> <DataSourcesPage />
) : activePage === 'faq' ? (
<FAQPage />
) : ( ) : (
<div className="flex-1 flex overflow-hidden"> <div className="flex-1 flex overflow-hidden relative">
{initialLoading && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
<div className="flex flex-col items-center gap-4">
<svg
className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">
Connecting to server...
</p>
</div>
</div>
)}
<Filters <Filters
features={features} features={features}
filters={filters} filters={filters}
@ -772,6 +1008,11 @@ export default function App() {
pinnedFeature={pinnedFeature} pinnedFeature={pinnedFeature}
onTogglePin={handleTogglePin} onTogglePin={handleTogglePin}
onCancelPin={handleCancelPin} onCancelPin={handleCancelPin}
onNavigateToSource={(slug, featureName) => {
navigateTo('data-sources', slug, featureName);
}}
openInfoFeature={pendingInfoFeature}
onClearOpenInfoFeature={() => setPendingInfoFeature(null)}
/> />
<div className="flex-1 relative"> <div className="flex-1 relative">
<Map <Map
@ -785,29 +1026,31 @@ export default function App() {
onCancelPin={handleCancelPin} onCancelPin={handleCancelPin}
features={features} features={features}
selectedHexagonId={selectedHexagon?.h3 || null} selectedHexagonId={selectedHexagon?.h3 || null}
hoveredHexagonId={hoveredHexagon}
onHexagonClick={handleHexagonClick} onHexagonClick={handleHexagonClick}
onHexagonHover={handleHexagonHover}
initialViewState={initialViewState} initialViewState={initialViewState}
theme={theme} theme={theme}
/> />
{loading && ( {loading && (
<div className="absolute top-4 right-4 bg-white dark:bg-warm-800 dark:text-warm-200 px-3 py-1 rounded shadow"> <div className="absolute bottom-4 left-4 bg-white dark:bg-navy-800 dark:text-warm-200 px-3 py-1 rounded shadow">
Loading... Loading...
</div> </div>
)} )}
<DataSources onNavigate={() => setActivePage('data-sources')} /> <DataSources onNavigate={() => navigateTo('data-sources')} />
</div> </div>
<div className="w-72 bg-white dark:bg-warm-900 shadow-lg z-10 flex flex-col"> <div className="w-72 bg-white dark:bg-navy-950 shadow-lg z-10 flex flex-col">
{/* Tab headers */} {/* Tab headers */}
<div className="flex border-b border-warm-200 dark:border-warm-700"> <div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
<button <button
className={`flex-1 p-3 ${ className={`flex-1 p-3 ${
rightPaneTab === 'pois' rightPaneTab === 'area'
? 'border-b-2 border-teal-500 font-semibold dark:text-warm-100' ? 'border-b-2 border-teal-500 font-semibold dark:text-warm-100'
: 'text-warm-600 dark:text-warm-400' : 'text-warm-600 dark:text-warm-400'
}`} }`}
onClick={() => setRightPaneTab('pois')} onClick={() => setRightPaneTab('area')}
> >
POIs {pois.length > 0 && `(${pois.length})`} Area {areaStats ? `(${areaStats.count})` : ''}
</button> </button>
<button <button
className={`flex-1 p-3 ${ className={`flex-1 p-3 ${
@ -819,25 +1062,52 @@ export default function App() {
> >
Properties {propertiesTotal > 0 && `(${propertiesTotal})`} Properties {propertiesTotal > 0 && `(${propertiesTotal})`}
</button> </button>
<button
className={`flex-1 p-3 ${
rightPaneTab === 'pois'
? 'border-b-2 border-teal-500 font-semibold dark:text-warm-100'
: 'text-warm-600 dark:text-warm-400'
}`}
onClick={() => setRightPaneTab('pois')}
>
POIs {pois.length > 0 && `(${pois.length})`}
</button>
</div> </div>
{/* Tab content */} {/* Tab content */}
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
{rightPaneTab === 'pois' ? ( {rightPaneTab === 'area' ? (
<AreaPane
stats={hoverMode && hoveredAreaStats ? hoveredAreaStats : areaStats}
globalFeatures={features}
loading={hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3 ? loadingHoveredAreaStats : loadingAreaStats}
hexagonId={hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3 ? hoveredHexagon : selectedHexagon?.h3 || null}
isHoveredPreview={!!(hoverMode && hoveredAreaStats && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3)}
hoverMode={hoverMode}
onHoverModeChange={setHoverMode}
onViewProperties={handleViewPropertiesFromArea}
onClose={handleCloseProperties}
/>
) : rightPaneTab === 'properties' ? (
<PropertiesPane
properties={hoverMode && hoveredProperties ? hoveredProperties : properties}
total={hoverMode && hoveredProperties ? hoveredPropertiesTotal : propertiesTotal}
loading={loadingProperties}
hexagonId={hoverMode && hoveredProperties ? hoveredHexagon : selectedHexagon?.h3 || null}
onLoadMore={handleLoadMoreProperties}
onClose={handleCloseProperties}
onNavigateToSource={(slug) => navigateTo('data-sources', slug)}
isHoveredPreview={!!(hoverMode && hoveredProperties && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3)}
hoverMode={hoverMode}
onHoverModeChange={setHoverMode}
/>
) : (
<POIPane <POIPane
groups={poiCategoryGroups} groups={poiCategoryGroups}
selectedCategories={selectedPOICategories} selectedCategories={selectedPOICategories}
onCategoriesChange={setSelectedPOICategories} onCategoriesChange={setSelectedPOICategories}
poiCount={pois.length} poiCount={pois.length}
/> onNavigateToSource={(slug) => navigateTo('data-sources', slug)}
) : (
<PropertiesPane
properties={properties}
total={propertiesTotal}
loading={loadingProperties}
hexagonId={selectedHexagon?.h3 || null}
onLoadMore={handleLoadMoreProperties}
onClose={handleCloseProperties}
/> />
)} )}
</div> </div>

View file

@ -0,0 +1,243 @@
import { useMemo } from 'react';
import type { FeatureMeta, HexagonStatsResponse } from '../types';
interface AreaPaneProps {
stats: HexagonStatsResponse | null;
globalFeatures: FeatureMeta[];
loading: boolean;
hexagonId: string | null;
isHoveredPreview: boolean;
hoverMode: boolean;
onHoverModeChange: (enabled: boolean) => void;
onViewProperties: () => void;
onClose: () => void;
}
function formatValue(value: number): string {
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(0)}k`;
if (Number.isInteger(value)) return value.toLocaleString();
return value.toFixed(1);
}
// Group features by their group field from globalFeatures
function groupFeatures(
globalFeatures: FeatureMeta[]
): { name: string; features: FeatureMeta[] }[] {
const groups: { name: string; features: FeatureMeta[] }[] = [];
const seen = new Set<string>();
for (const feature of globalFeatures) {
const groupName = feature.group || 'Other';
if (!seen.has(groupName)) {
seen.add(groupName);
groups.push({ name: groupName, features: [] });
}
groups.find((group) => group.name === groupName)!.features.push(feature);
}
return groups;
}
function MiniHistogram({ counts, maxCount }: { counts: number[]; maxCount: number }) {
if (maxCount === 0) return null;
// Downsample to ~20 bars for display
const targetBars = 20;
const step = Math.max(1, Math.floor(counts.length / targetBars));
const bars: number[] = [];
for (let index = 0; index < counts.length; index += step) {
let sum = 0;
for (let offset = 0; offset < step && index + offset < counts.length; offset++) {
sum += counts[index + offset];
}
bars.push(sum);
}
const barMax = Math.max(...bars, 1);
return (
<div className="flex items-end gap-px h-8 mt-1">
{bars.map((count, index) => (
<div
key={index}
className="flex-1 bg-teal-500 dark:bg-teal-400 rounded-t-sm min-w-[2px]"
style={{ height: `${(count / barMax) * 100}%`, opacity: count > 0 ? 1 : 0.1 }}
/>
))}
</div>
);
}
function EnumBarChart({ counts }: { counts: Record<string, number> }) {
const entries = Object.entries(counts).sort(([, countA], [, countB]) => countB - countA);
const maxCount = Math.max(...entries.map(([, count]) => count), 1);
return (
<div className="space-y-1 mt-1">
{entries.map(([label, count]) => (
<div key={label} className="flex items-center gap-2 text-xs">
<span className="w-16 truncate text-warm-500 dark:text-warm-400 text-right shrink-0">
{label}
</span>
<div className="flex-1 h-3 bg-warm-100 dark:bg-navy-700 rounded overflow-hidden">
<div
className="h-full bg-teal-500 dark:bg-teal-400 rounded"
style={{ width: `${(count / maxCount) * 100}%` }}
/>
</div>
<span className="w-8 text-warm-500 dark:text-warm-400 text-right shrink-0">{count}</span>
</div>
))}
</div>
);
}
export default function AreaPane({
stats,
globalFeatures,
loading,
hexagonId,
isHoveredPreview,
hoverMode,
onHoverModeChange,
onViewProperties,
onClose,
}: AreaPaneProps) {
const featureGroups = useMemo(() => groupFeatures(globalFeatures), [globalFeatures]);
// Build lookup maps from stats
const numericByName = useMemo(() => {
if (!stats) return new Map();
return new Map(stats.numeric_features.map((feature) => [feature.name, feature]));
}, [stats]);
const enumByName = useMemo(() => {
if (!stats) return new Map();
return new Map(stats.enum_features.map((feature) => [feature.name, feature]));
}, [stats]);
if (!hexagonId) {
return (
<div className="flex items-center justify-center h-full text-warm-500 dark:text-warm-400 px-4 text-center text-sm">
Click a hexagon to view area statistics
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="p-3 border-b border-warm-200 dark:border-navy-700">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold dark:text-warm-100">Area Statistics</h2>
{isHoveredPreview && (
<span className="text-xs px-1.5 py-0.5 rounded bg-teal-50 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
Preview
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => onHoverModeChange(!hoverMode)}
className={`p-1 rounded ${
hoverMode
? 'text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30'
: 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
}`}
title={hoverMode ? 'Live preview on (click to lock)' : 'Live preview off (click to enable)'}
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
<button
onClick={onClose}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-1"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{stats && (
<p className="text-sm text-warm-600 dark:text-warm-400 mt-1">
{stats.count.toLocaleString()} properties
</p>
)}
{stats && (
<button
onClick={onViewProperties}
className="mt-2 w-full text-sm py-1.5 rounded bg-teal-600 hover:bg-teal-700 text-white font-medium"
>
View {stats.count.toLocaleString()} Properties
</button>
)}
</div>
{/* Stats content */}
<div className="flex-1 overflow-y-auto">
{loading && !stats ? (
<div className="p-4 text-warm-500 dark:text-warm-400 text-sm">Loading...</div>
) : stats ? (
<div className="p-3 space-y-4">
{featureGroups.map((group) => {
// Check if any feature in this group has data
const hasData = group.features.some(
(feature) => numericByName.has(feature.name) || enumByName.has(feature.name)
);
if (!hasData) return null;
return (
<div key={group.name}>
<h3 className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wider mb-2">
{group.name}
</h3>
<div className="space-y-3">
{group.features.map((feature) => {
const numericStats = numericByName.get(feature.name);
const enumStats = enumByName.get(feature.name);
if (numericStats) {
const maxCount = Math.max(...numericStats.histogram.counts);
return (
<div key={feature.name} className="bg-warm-50 dark:bg-navy-800 rounded p-2">
<div className="flex justify-between items-baseline">
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
{feature.name}
</span>
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(numericStats.mean)}
</span>
</div>
<div className="flex justify-between text-[10px] text-warm-400 dark:text-warm-500 mt-0.5">
<span>{formatValue(numericStats.min)}</span>
<span>{formatValue(numericStats.max)}</span>
</div>
<MiniHistogram counts={numericStats.histogram.counts} maxCount={maxCount} />
</div>
);
}
if (enumStats) {
return (
<div key={feature.name} className="bg-warm-50 dark:bg-navy-800 rounded p-2">
<span className="text-xs text-warm-700 dark:text-warm-300">
{feature.name}
</span>
<EnumBarChart counts={enumStats.counts} />
</div>
);
}
return null;
})}
</div>
</div>
);
})}
</div>
) : null}
</div>
</div>
);
}

View file

@ -2,7 +2,7 @@ export default function DataSources({ onNavigate }: { onNavigate: () => void })
return ( return (
<button <button
onClick={onNavigate} onClick={onNavigate}
className="absolute bottom-2 right-2 bg-white/90 dark:bg-warm-800/90 backdrop-blur-sm px-3 py-2 rounded shadow-lg text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline font-semibold transition-colors" className="absolute bottom-2 right-2 bg-white/90 dark:bg-navy-800/90 backdrop-blur-sm px-3 py-2 rounded shadow-lg text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline font-semibold transition-colors"
> >
Data Sources Data Sources
</button> </button>

View file

@ -1,5 +1,8 @@
import { useEffect, useState, useRef } from 'react';
const DATA_SOURCES = [ const DATA_SOURCES = [
{ {
id: 'price-paid',
name: 'Price Paid Data', name: 'Price Paid Data',
origin: 'HM Land Registry', origin: 'HM Land Registry',
use: 'Complete historical property sale prices for England and Wales. Used for the last known sale price of each property.', use: 'Complete historical property sale prices for England and Wales. Used for the last known sale price of each property.',
@ -7,6 +10,7 @@ const DATA_SOURCES = [
license: 'Open Government Licence v3.0', license: 'Open Government Licence v3.0',
}, },
{ {
id: 'epc',
name: 'Energy Performance Certificates (EPC)', name: 'Energy Performance Certificates (EPC)',
origin: 'Ministry of Housing, Communities & Local Government', origin: 'Ministry of Housing, Communities & Local Government',
use: 'Domestic Energy Performance Certificates providing floor area, number of rooms, construction age, energy ratings, property type, and built form. Fuzzy-joined with Price Paid records by address within postcode buckets.', use: 'Domestic Energy Performance Certificates providing floor area, number of rooms, construction age, energy ratings, property type, and built form. Fuzzy-joined with Price Paid records by address within postcode buckets.',
@ -14,6 +18,7 @@ const DATA_SOURCES = [
license: 'Open Government Licence v3.0', license: 'Open Government Licence v3.0',
}, },
{ {
id: 'nspl',
name: 'National Statistics Postcode Lookup (NSPL)', name: 'National Statistics Postcode Lookup (NSPL)',
origin: 'ONS / ArcGIS', origin: 'ONS / ArcGIS',
use: 'Maps postcodes to latitude/longitude, LSOA, and Output Area codes for geolocation and joining area-level datasets.', use: 'Maps postcodes to latitude/longitude, LSOA, and Output Area codes for geolocation and joining area-level datasets.',
@ -21,6 +26,7 @@ const DATA_SOURCES = [
license: 'Open Government Licence v3.0', license: 'Open Government Licence v3.0',
}, },
{ {
id: 'iod',
name: 'English Indices of Deprivation 2025', name: 'English Indices of Deprivation 2025',
origin: 'Ministry of Housing, Communities & Local Government', origin: 'Ministry of Housing, Communities & Local Government',
use: 'Relative deprivation scores for 33,755 LSOAs across domains: Income, Employment, Education, Health, Crime, Living Environment, and sub-domains. Joined to properties via LSOA code.', use: 'Relative deprivation scores for 33,755 LSOAs across domains: Income, Employment, Education, Health, Crime, Living Environment, and sub-domains. Joined to properties via LSOA code.',
@ -28,6 +34,7 @@ const DATA_SOURCES = [
license: 'Open Government Licence v3.0', license: 'Open Government Licence v3.0',
}, },
{ {
id: 'ethnicity',
name: 'Population by Ethnicity (2021 Census)', name: 'Population by Ethnicity (2021 Census)',
origin: 'ONS', origin: 'ONS',
use: 'Population percentages by ethnic group (Asian, Black, Mixed, White, Other) per Local Authority. Joined via Local Authority District code.', use: 'Population percentages by ethnic group (Asian, Black, Mixed, White, Other) per Local Authority. Joined via Local Authority District code.',
@ -35,6 +42,7 @@ const DATA_SOURCES = [
license: 'Open Government Licence v3.0', license: 'Open Government Licence v3.0',
}, },
{ {
id: 'crime',
name: 'Street-level Crime Data', name: 'Street-level Crime Data',
origin: 'data.police.uk', origin: 'data.police.uk',
use: 'Street-level crime data from 2023 to 2025, aggregated into yearly averages by LSOA and crime type (violence, burglary, anti-social behaviour, drugs, vehicle crime, etc.).', use: 'Street-level crime data from 2023 to 2025, aggregated into yearly averages by LSOA and crime type (violence, burglary, anti-social behaviour, drugs, vehicle crime, etc.).',
@ -42,6 +50,7 @@ const DATA_SOURCES = [
license: 'Open Government Licence v3.0', license: 'Open Government Licence v3.0',
}, },
{ {
id: 'tfl-journey-times',
name: 'TfL Journey Times', name: 'TfL Journey Times',
origin: 'Transport for London', origin: 'Transport for London',
use: "Journey time calculations from postcodes to central London destinations (Bank, Waterloo, King's Cross, etc.) via public transport and cycling.", use: "Journey time calculations from postcodes to central London destinations (Bank, Waterloo, King's Cross, etc.) via public transport and cycling.",
@ -49,6 +58,7 @@ const DATA_SOURCES = [
license: 'Powered by TfL Open Data', license: 'Powered by TfL Open Data',
}, },
{ {
id: 'osm-pois',
name: 'OpenStreetMap POIs', name: 'OpenStreetMap POIs',
origin: 'OpenStreetMap contributors / Geofabrik', origin: 'OpenStreetMap contributors / Geofabrik',
use: 'Points of interest extracted from the Great Britain PBF extract. Covers amenities, shops, healthcare, leisure, tourism, and more. Filtered and remapped to friendly category names.', use: 'Points of interest extracted from the Great Britain PBF extract. Covers amenities, shops, healthcare, leisure, tourism, and more. Filtered and remapped to friendly category names.',
@ -56,6 +66,7 @@ const DATA_SOURCES = [
license: 'Open Data Commons Open Database License (ODbL)', license: 'Open Data Commons Open Database License (ODbL)',
}, },
{ {
id: 'naptan',
name: 'NaPTAN (Public Transport Stops)', name: 'NaPTAN (Public Transport Stops)',
origin: 'Department for Transport', origin: 'Department for Transport',
use: 'National Public Transport Access Nodes providing station and stop locations (rail, bus, metro/tram, ferry, airport), merged into the POI dataset.', use: 'National Public Transport Access Nodes providing station and stop locations (rail, bus, metro/tram, ferry, airport), merged into the POI dataset.',
@ -63,6 +74,7 @@ const DATA_SOURCES = [
license: 'Open Government Licence v3.0', license: 'Open Government Licence v3.0',
}, },
{ {
id: 'noise',
name: 'Defra Noise Mapping', name: 'Defra Noise Mapping',
origin: 'Defra / Environment Agency', origin: 'Defra / Environment Agency',
use: 'Strategic noise mapping Round 4 (2022) for road, rail, and airport sources. Lden (day-evening-night 24h weighted average) at 10m grid resolution, modelled at 4m above ground. Sampled at postcode centroids via WCS GeoTIFF tiles.', use: 'Strategic noise mapping Round 4 (2022) for road, rail, and airport sources. Lden (day-evening-night 24h weighted average) at 10m grid resolution, modelled at 4m above ground. Sampled at postcode centroids via WCS GeoTIFF tiles.',
@ -70,6 +82,7 @@ const DATA_SOURCES = [
license: 'Open Government Licence v3.0', license: 'Open Government Licence v3.0',
}, },
{ {
id: 'ofsted',
name: 'Ofsted School Inspections', name: 'Ofsted School Inspections',
origin: 'Ofsted', origin: 'Ofsted',
use: 'Latest inspection outcomes for state-funded schools (as at April 2025). Averaged per postcode to give a local school quality score (1=Outstanding to 4=Inadequate).', use: 'Latest inspection outcomes for state-funded schools (as at April 2025). Averaged per postcode to give a local school quality score (1=Outstanding to 4=Inadequate).',
@ -77,6 +90,7 @@ const DATA_SOURCES = [
license: 'Open Government Licence v3.0', license: 'Open Government Licence v3.0',
}, },
{ {
id: 'broadband',
name: 'Ofcom Broadband Performance', name: 'Ofcom Broadband Performance',
origin: 'Ofcom', origin: 'Ofcom',
use: 'Fixed broadband coverage and speeds by Output Area from Connected Nations 2025. Includes max download/upload speeds across different speed tiers.', use: 'Fixed broadband coverage and speeds by Output Area from Connected Nations 2025. Includes max download/upload speeds across different speed tiers.',
@ -86,8 +100,29 @@ const DATA_SOURCES = [
]; ];
export default function DataSourcesPage() { export default function DataSourcesPage() {
const [highlightedId, setHighlightedId] = useState<string | null>(null);
const cardRefs = useRef<Record<string, HTMLDivElement | null>>({});
useEffect(() => {
function handleHash() {
const hash = window.location.hash.replace('#', '');
if (hash && DATA_SOURCES.some((s) => s.id === hash)) {
setHighlightedId(hash);
// Scroll after a brief delay to allow render
setTimeout(() => {
cardRefs.current[hash]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);
} else {
setHighlightedId(null);
}
}
handleHash();
window.addEventListener('hashchange', handleHash);
return () => window.removeEventListener('hashchange', handleHash);
}, []);
return ( return (
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-warm-900 flex flex-col"> <div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950 flex flex-col">
<div className="flex-1"> <div className="flex-1">
<div className="max-w-5xl mx-auto px-6 py-8"> <div className="max-w-5xl mx-auto px-6 py-8">
<h1 className="text-2xl font-bold text-warm-900 dark:text-warm-100 mb-2">Data Sources</h1> <h1 className="text-2xl font-bold text-warm-900 dark:text-warm-100 mb-2">Data Sources</h1>
@ -97,10 +132,19 @@ export default function DataSourcesPage() {
</p> </p>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{DATA_SOURCES.map((source) => ( {DATA_SOURCES.map((source) => (
<div key={source.name} className="bg-white dark:bg-warm-800 rounded-lg border border-warm-200 dark:border-warm-700 p-5"> <div
key={source.id}
id={source.id}
ref={(el) => { cardRefs.current[source.id] = el; }}
className={`bg-white dark:bg-navy-800 rounded-lg border p-5 ${
highlightedId === source.id
? 'border-teal-400 ring-2 ring-teal-400'
: 'border-warm-200 dark:border-navy-700'
}`}
>
<div className="flex items-start justify-between gap-4 mb-2"> <div className="flex items-start justify-between gap-4 mb-2">
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">{source.name}</h2> <h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">{source.name}</h2>
<span className="shrink-0 text-xs bg-warm-100 dark:bg-warm-700 text-warm-600 dark:text-warm-300 px-2 py-1 rounded"> <span className="shrink-0 text-xs bg-warm-100 dark:bg-navy-700 text-warm-600 dark:text-warm-300 px-2 py-1 rounded">
{source.license} {source.license}
</span> </span>
</div> </div>

View file

@ -0,0 +1,119 @@
import { useState } from 'react';
interface FAQItem {
question: string;
answer: string;
}
const FAQ_ITEMS: FAQItem[] = [
{
question: 'What is this application?',
answer:
'Narrowit is an interactive map that visualises property-level data across England and Wales. It combines Land Registry sale prices, EPC energy certificates, TfL journey times, deprivation indices, crime statistics, broadband speeds, school ratings, road noise levels, ethnicity demographics, and OpenStreetMap points of interest into a single explorable view.',
},
{
question: 'Where does the data come from?',
answer:
'All data comes from open government and community sources. Property prices are from HM Land Registry, energy certificates from MHCLG, transport times from TfL, deprivation scores from the English Indices of Deprivation 2025, crime data from data.police.uk, school ratings from Ofsted, broadband from Ofcom, noise from Defra, ethnicity from the 2021 Census, and points of interest from OpenStreetMap. See the Data Sources page for full details and links.',
},
{
question: 'What are the coloured hexagons on the map?',
answer:
'The map uses H3 hexagons to aggregate property data at different zoom levels. Each hexagon summarises the properties within it. The colour represents the value of whichever feature you have pinned or are actively filtering — for example, average price or energy rating. Zoom in to see smaller, more detailed hexagons; zoom out for a broader overview.',
},
{
question: 'How do filters work?',
answer:
'Use the Filters panel on the left to narrow down properties. Add a filter by clicking a feature name, then drag the range slider to set minimum and maximum values. For categorical features like property type, select or deselect individual values. Only hexagons containing properties that match all active filters are shown. Filters are combined with AND logic — every property must satisfy every filter.',
},
{
question: 'What does the eye icon do on a filter?',
answer:
'The eye icon pins a feature as the colour source for the hexagon layer. When pinned, hexagons are coloured by that feature\'s value range even when you are not actively dragging its slider. This lets you visualise one feature while filtering on others. Click the eye icon again to unpin.',
},
{
question: 'How fresh is the data?',
answer:
'Property prices cover all Land Registry transactions up to the most recent quarterly release. EPC data includes certificates issued up to the latest available download. Crime data spans 20232025 as yearly averages. TfL journey times are computed from current timetables. Deprivation indices are from the 2025 release. School ratings reflect the latest Ofsted inspections as at April 2025. Broadband data is from Ofcom Connected Nations 2025.',
},
{
question: 'How are EPC records matched to Land Registry sales?',
answer:
'EPC and Land Registry records don\'t share a common identifier, so they are fuzzy-joined by address within each postcode bucket. The pipeline uses token-sorted string similarity with special handling for numeric tokens (house numbers, flat numbers). Matches are assigned greedily from highest similarity score downward so each record is used at most once.',
},
{
question: 'What are Points of Interest (POIs)?',
answer:
'POIs are places like cafes, schools, supermarkets, GP surgeries, parks, and train stations extracted from OpenStreetMap and the NaPTAN public transport dataset. Use the POI panel on the right to toggle categories on and off. POIs appear as markers on the map when you are zoomed in far enough.',
},
{
question: 'Can I share a specific view with someone?',
answer:
'Yes. The URL updates automatically as you pan, zoom, and change filters. Click the Share button in the header to copy the current URL to your clipboard. Anyone who opens that link will see the same view, filters, and active POI categories.',
},
{
question: 'How do I see individual properties?',
answer:
'Click on a hexagon to open the Properties panel on the right. It lists all matching properties within that hexagon, showing address, price, and key features. Use "Load more" at the bottom to paginate through large hexagons.',
},
{
question: 'Why are some hexagons grey?',
answer:
'Grey hexagons contain properties that have data but fall outside the range of your currently pinned or active feature. This gives you a sense of where properties exist even when their values are outside your selected range.',
},
{
question: 'Does this work on mobile?',
answer:
'The app is designed for desktop browsers where you have enough screen space for the map, filter panel, and POI/properties panel side by side. It will load on mobile but the experience is best on a larger screen.',
},
];
function FAQItemCard({ item }: { item: FAQItem }) {
const [open, setOpen] = useState(false);
return (
<div className="bg-white dark:bg-navy-800 rounded-lg border border-warm-200 dark:border-navy-700">
<button
className="w-full text-left px-5 py-4 flex items-center justify-between gap-4"
onClick={() => setOpen(!open)}
>
<span className="font-medium text-warm-900 dark:text-warm-100">{item.question}</span>
<svg
className={`w-5 h-5 shrink-0 text-warm-400 dark:text-warm-500 transform ${open ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="px-5 pb-4">
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">{item.answer}</p>
</div>
)}
</div>
);
}
export default function FAQPage() {
return (
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950">
<div className="max-w-3xl mx-auto px-6 py-8">
<h1 className="text-2xl font-bold text-warm-900 dark:text-warm-100 mb-2">
Frequently Asked Questions
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">
Common questions about how Narrowit works, where the data comes from, and how to use the
map.
</p>
<div className="space-y-3">
{FAQ_ITEMS.map((item, index) => (
<FAQItemCard key={index} item={item} />
))}
</div>
</div>
</div>
);
}

View file

@ -1,4 +1,4 @@
import { memo, useState, useRef, useEffect, useMemo } from 'react'; import { memo, useState, useRef, useCallback, useMemo, useEffect } from 'react';
import { Slider } from './ui/slider'; import { Slider } from './ui/slider';
import { Label } from './ui/label'; import { Label } from './ui/label';
import type { FeatureMeta, FeatureFilters } from '../types'; import type { FeatureMeta, FeatureFilters } from '../types';
@ -19,38 +19,129 @@ interface FiltersProps {
pinnedFeature: string | null; pinnedFeature: string | null;
onTogglePin: (name: string) => void; onTogglePin: (name: string) => void;
onCancelPin: () => void; onCancelPin: () => void;
onNavigateToSource?: (slug: string, featureName: string) => void;
openInfoFeature?: string | null;
onClearOpenInfoFeature?: () => void;
} }
function FilterDropdown({ function EyeIcon({ filled, className }: { filled: boolean; className?: string }) {
availableFeatures, return (
onAddFilter, <svg
className={className || 'w-3.5 h-3.5'}
viewBox="0 0 24 24"
fill={filled ? 'currentColor' : 'none'}
stroke="currentColor"
strokeWidth={2}
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
function InfoPopup({
feature,
onClose,
onNavigateToSource,
}: { }: {
availableFeatures: FeatureMeta[]; feature: FeatureMeta;
onAddFilter: (name: string) => void; onClose: () => void;
onNavigateToSource?: (slug: string, featureName: string) => void;
}) { }) {
const [open, setOpen] = useState(false); const popupRef = useRef<HTMLDivElement>(null);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (!open) return; function handleClickOutside(e: MouseEvent) {
const handler = (e: MouseEvent) => { if (popupRef.current && !popupRef.current.contains(e.target as Node)) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); onClose();
}; }
const keyHandler = (e: KeyboardEvent) => { }
if (e.key === 'Escape') setOpen(false); document.addEventListener('mousedown', handleClickOutside);
}; return () => document.removeEventListener('mousedown', handleClickOutside);
document.addEventListener('mousedown', handler); }, [onClose]);
document.addEventListener('keydown', keyHandler);
return () => { return (
document.removeEventListener('mousedown', handler); <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
document.removeEventListener('keydown', keyHandler); <div
}; ref={popupRef}
}, [open]); className="bg-white dark:bg-navy-800 border border-warm-200 dark:border-navy-700 rounded-lg shadow-xl max-w-md w-full mx-4 p-5"
>
<div className="flex items-start justify-between mb-3">
<h3 className="text-sm font-semibold text-warm-900 dark:text-warm-100 pr-4">
{feature.name}
</h3>
<button
onClick={onClose}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{feature.description && (
<p className="text-xs text-warm-500 dark:text-warm-400 mb-2">{feature.description}</p>
)}
{feature.detail && (
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">{feature.detail}</p>
)}
{feature.source && onNavigateToSource && (
<button
onClick={() => {
onNavigateToSource(feature.source!, feature.name);
onClose();
}}
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
>
View data source
</button>
)}
</div>
</div>
);
}
function FeatureBrowser({
availableFeatures,
allFeatures,
pinnedFeature,
onAddFilter,
onTogglePin,
onNavigateToSource,
openInfoFeature,
onClearOpenInfoFeature,
}: {
availableFeatures: FeatureMeta[];
allFeatures: FeatureMeta[];
pinnedFeature: string | null;
onAddFilter: (name: string) => void;
onTogglePin: (name: string) => void;
onNavigateToSource?: (slug: string, featureName: string) => void;
openInfoFeature?: string | null;
onClearOpenInfoFeature?: () => void;
}) {
const [search, setSearch] = useState('');
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
// Auto-open info popup when navigating back
useEffect(() => {
if (openInfoFeature) {
const feat = allFeatures.find((f) => f.name === openInfoFeature);
if (feat) setInfoFeature(feat);
onClearOpenInfoFeature?.();
}
}, [openInfoFeature, allFeatures, onClearOpenInfoFeature]);
const filtered = useMemo(() => {
if (!search) return availableFeatures;
const lower = search.toLowerCase();
return availableFeatures.filter((f) => f.name.toLowerCase().includes(lower));
}, [availableFeatures, search]);
const grouped = useMemo(() => { const grouped = useMemo(() => {
const groups: { name: string; features: FeatureMeta[] }[] = []; const groups: { name: string; features: FeatureMeta[] }[] = [];
const seen = new Map<string, FeatureMeta[]>(); const seen = new Map<string, FeatureMeta[]>();
for (const f of availableFeatures) { for (const f of filtered) {
const g = f.group || 'Other'; const g = f.group || 'Other';
let arr = seen.get(g); let arr = seen.get(g);
if (!arr) { if (!arr) {
@ -61,40 +152,87 @@ function FilterDropdown({
arr.push(f); arr.push(f);
} }
return groups; return groups;
}, [availableFeatures]); }, [filtered]);
return ( return (
<div ref={ref} className="relative"> <>
<button <div className="p-2 border-b border-warm-200 dark:border-navy-700">
className="w-full p-2 border rounded text-sm bg-white dark:bg-warm-800 dark:border-warm-700 text-left text-warm-500 dark:text-warm-400 hover:border-warm-400" <input
onClick={() => setOpen(!open)} type="text"
> placeholder="Search features..."
+ Add filter... value={search}
</button> onChange={(e) => setSearch(e.target.value)}
{open && ( className="w-full px-2 py-1 text-sm border rounded bg-white dark:bg-navy-800 dark:text-warm-200 border-warm-200 dark:border-navy-700 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-1 focus:ring-teal-400"
<div className="absolute z-50 mt-1 w-full bg-white dark:bg-warm-800 border dark:border-warm-700 rounded shadow-lg max-h-80 overflow-y-auto"> />
{grouped.map((group) => ( </div>
<div key={group.name}> <div className="flex-1 overflow-y-auto">
<div className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0"> {grouped.map((group) => (
{group.name} <div key={group.name}>
</div> <div className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0">
{group.features.map((f) => ( {group.name}
<button
key={f.name}
className="w-full text-left px-3 py-1.5 text-sm hover:bg-teal-50 dark:hover:bg-teal-900/30 hover:text-teal-700 dark:hover:text-teal-400 dark:text-warm-300"
onClick={() => {
onAddFilter(f.name);
setOpen(false);
}}
>
{f.name}
</button>
))}
</div> </div>
))} {group.features.map((f) => {
</div> const isPinned = pinnedFeature === f.name;
return (
<div
key={f.name}
className="flex items-start justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
>
<div className="min-w-0 mr-2">
<span className="text-sm truncate block">{f.name}</span>
{f.description && (
<span className="text-xs text-warm-400 dark:text-warm-500 truncate block">{f.description}</span>
)}
</div>
<div className="flex items-center gap-1 shrink-0 mt-0.5">
{f.detail && (
<button
onClick={() => setInfoFeature(f)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-0.5 rounded"
title="Feature info"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<circle cx="12" cy="12" r="10" />
<path strokeLinecap="round" d="M12 16v-4m0-4h.01" />
</svg>
</button>
)}
<button
onClick={() => onTogglePin(f.name)}
className={`p-0.5 rounded ${isPinned ? 'text-teal-600 dark:text-teal-400' : 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'}`}
title={isPinned ? 'Unpin color view' : 'Color map by this feature'}
>
<EyeIcon filled={isPinned} />
</button>
<button
onClick={() => onAddFilter(f.name)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-0.5 rounded"
title="Add filter"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m-7-7h14" />
</svg>
</button>
</div>
</div>
);
})}
</div>
))}
{grouped.length === 0 && (
<div className="px-3 py-4 text-sm text-warm-400 dark:text-warm-500 text-center">
{search ? 'No matching features' : 'All features are active'}
</div>
)}
</div>
{infoFeature && (
<InfoPopup
feature={infoFeature}
onClose={() => setInfoFeature(null)}
onNavigateToSource={onNavigateToSource}
/>
)} )}
</div> </>
); );
} }
@ -121,128 +259,208 @@ export default memo(function Filters({
pinnedFeature, pinnedFeature,
onTogglePin, onTogglePin,
onCancelPin, onCancelPin,
onNavigateToSource,
openInfoFeature,
onClearOpenInfoFeature,
}: FiltersProps) { }: FiltersProps) {
const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name)); const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name));
const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name)); const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name));
const containerRef = useRef<HTMLDivElement>(null);
const [splitFraction, setSplitFraction] = useState(0.65);
const draggingRef = useRef(false);
const handleSeparatorPointerDown = useCallback(
(e: React.PointerEvent) => {
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
draggingRef.current = true;
},
[]
);
const handleSeparatorPointerMove = useCallback(
(e: React.PointerEvent) => {
if (!draggingRef.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const y = e.clientY - rect.top;
const fraction = Math.min(0.8, Math.max(0.15, y / rect.height));
setSplitFraction(fraction);
},
[]
);
const handleSeparatorPointerUp = useCallback(() => {
draggingRef.current = false;
}, []);
return ( return (
<div className="w-72 p-4 bg-white dark:bg-warm-900 shadow-lg space-y-4 overflow-y-auto"> <div ref={containerRef} className="w-80 flex flex-col bg-white dark:bg-navy-950 shadow-lg overflow-hidden">
<div className="text-sm text-warm-500 dark:text-warm-400">Zoom: {zoom.toFixed(1)}</div> {/* Top: Active filters — user-resizable, scrollable */}
<div className="min-h-0 flex flex-col" style={{ height: `${splitFraction * 100}%` }}>
{/* Add filter dropdown */} {/* Active Filters header */}
{availableFeatures.length > 0 && ( <div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<FilterDropdown availableFeatures={availableFeatures} onAddFilter={onAddFilter} /> <div className="flex items-center gap-2">
)} <span className="text-sm font-semibold text-navy-950 dark:text-warm-100">Active Filters</span>
{enabledFeatureList.length > 0 && (
{/* Active filters */} <span className="text-xs font-medium px-1.5 py-0.5 rounded-full bg-teal-50 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
{enabledFeatureList.map((feature) => { {enabledFeatureList.length}
if (feature.type === 'enum') { </span>
const selectedValues = (filters[feature.name] as string[]) || []; )}
const allValues = feature.values || [];
return (
<div key={feature.name} className="space-y-1 p-2 rounded">
<div className="flex items-center justify-between">
<Label className="text-xs">{feature.name}</Label>
<button
onClick={() => onRemoveFilter(feature.name)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 text-sm px-1"
title="Remove filter"
>
x
</button>
</div>
<div className="flex gap-2 text-xs mb-1">
<button
className="text-teal-600 dark:text-teal-400 hover:underline"
onClick={() => onFilterChange(feature.name, [...allValues])}
>
All
</button>
<button
className="text-teal-600 dark:text-teal-400 hover:underline"
onClick={() => onFilterChange(feature.name, [])}
>
None
</button>
</div>
<div className="space-y-0.5 max-h-40 overflow-y-auto">
{allValues.map((val) => (
<label key={val} className="flex items-center gap-1.5 text-xs cursor-pointer dark:text-warm-300">
<input
type="checkbox"
checked={selectedValues.includes(val)}
onChange={() => {
const next = selectedValues.includes(val)
? selectedValues.filter((v) => v !== val)
: [...selectedValues, val];
onFilterChange(feature.name, next);
}}
className="rounded accent-teal-600"
/>
{val}
</label>
))}
</div>
</div>
);
}
// Numeric feature
const isActive = activeFeature === feature.name;
const isPinned = pinnedFeature === feature.name;
const displayValue =
isActive && dragValue
? dragValue
: (filters[feature.name] as [number, number]) || [feature.min!, feature.max!];
const step = (feature.max! - feature.min!) / 100;
return (
<div
key={feature.name}
className={`space-y-1 p-2 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">
<Label className="text-xs">
{feature.name}: {formatValue(displayValue[0])} - {formatValue(displayValue[1])}
</Label>
<div className="flex items-center gap-0.5">
<button
onClick={() => onTogglePin(feature.name)}
className={`p-0.5 rounded ${isPinned ? 'text-teal-600 dark:text-teal-400' : 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'}`}
title={isPinned ? 'Unpin color view' : 'Color map by this feature'}
>
<svg
className="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill={isPinned ? 'currentColor' : 'none'}
stroke="currentColor"
strokeWidth={2}
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</button>
<button
onClick={() => onRemoveFilter(feature.name)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 text-sm px-1"
title="Remove filter"
>
x
</button>
</div>
</div>
<Slider
min={feature.min!}
max={feature.max!}
step={step}
value={[displayValue[0], displayValue[1]]}
onValueChange={([min, max]) => onDragChange([min, max])}
onPointerDown={() => onDragStart(feature.name)}
onPointerUp={() => onDragEnd()}
/>
</div> </div>
); <span className="text-xs text-warm-500 dark:text-warm-400">Zoom {zoom.toFixed(1)}</span>
})} </div>
<div className="flex-1 overflow-y-auto p-3 space-y-3">
{enabledFeatureList.length === 0 && (
<div className="flex flex-col items-center justify-center py-8 text-center">
<svg className="w-8 h-8 text-warm-300 dark:text-warm-600 mb-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z" />
</svg>
<span className="text-sm font-medium text-warm-400 dark:text-warm-500">No active filters</span>
<span className="text-xs text-warm-400 dark:text-warm-500 mt-1">Browse features below and click + to add a filter</span>
</div>
)}
{enabledFeatureList.map((feature) => {
if (feature.type === 'enum') {
const selectedValues = (filters[feature.name] as string[]) || [];
const allValues = feature.values || [];
return (
<div key={feature.name} className={`space-y-1 p-3 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">
<Label>{feature.name}</Label>
<div className="flex items-center gap-0.5">
<button
onClick={() => onTogglePin(feature.name)}
className={`p-0.5 rounded ${pinnedFeature === feature.name ? 'text-teal-600 dark:text-teal-400' : 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'}`}
title={pinnedFeature === feature.name ? 'Unpin color view' : 'Color map by this feature'}
>
<EyeIcon filled={pinnedFeature === feature.name} />
</button>
<button
onClick={() => onRemoveFilter(feature.name)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 text-sm px-1"
title="Remove filter"
>
x
</button>
</div>
</div>
<div className="flex gap-2 text-sm mb-1">
<button
className="text-teal-600 dark:text-teal-400 hover:underline"
onClick={() => onFilterChange(feature.name, [...allValues])}
>
All
</button>
<button
className="text-teal-600 dark:text-teal-400 hover:underline"
onClick={() => onFilterChange(feature.name, [])}
>
None
</button>
</div>
<div className="space-y-0.5 max-h-40 overflow-y-auto">
{allValues.map((val) => (
<label key={val} className="flex items-center gap-1.5 text-sm cursor-pointer dark:text-warm-300">
<input
type="checkbox"
checked={selectedValues.includes(val)}
onChange={() => {
const next = selectedValues.includes(val)
? selectedValues.filter((v) => v !== val)
: [...selectedValues, val];
onFilterChange(feature.name, next);
}}
className="rounded accent-teal-600"
/>
{val}
</label>
))}
</div>
</div>
);
}
// Numeric feature
const isActive = activeFeature === feature.name;
const isPinned = pinnedFeature === feature.name;
const displayValue =
isActive && dragValue
? dragValue
: (filters[feature.name] as [number, number]) || [feature.min!, feature.max!];
const step = feature.step ?? (feature.max! - feature.min!) / 100;
return (
<div
key={feature.name}
className={`space-y-1 p-3 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">
<Label>
{feature.name}: {formatValue(displayValue[0])} - {formatValue(displayValue[1])}
</Label>
<div className="flex items-center gap-0.5">
<button
onClick={() => onTogglePin(feature.name)}
className={`p-0.5 rounded ${isPinned ? 'text-teal-600 dark:text-teal-400' : 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'}`}
title={isPinned ? 'Unpin color view' : 'Color map by this feature'}
>
<EyeIcon filled={isPinned} />
</button>
<button
onClick={() => onRemoveFilter(feature.name)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 text-sm px-1"
title="Remove filter"
>
x
</button>
</div>
</div>
<Slider
min={feature.min!}
max={feature.max!}
step={step}
value={[displayValue[0], displayValue[1]]}
onValueChange={([min, max]) => onDragChange([min, max])}
onPointerDown={() => onDragStart(feature.name)}
onPointerUp={() => onDragEnd()}
/>
</div>
);
})}
</div>
</div>
{/* Draggable separator */}
<div
className="shrink-0 h-1.5 cursor-row-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-y border-warm-200 dark:border-navy-700"
onPointerDown={handleSeparatorPointerDown}
onPointerMove={handleSeparatorPointerMove}
onPointerUp={handleSeparatorPointerUp}
>
<div className="w-8 h-0.5 rounded bg-warm-300 dark:bg-navy-600" />
</div>
{/* Bottom: Feature browser — fills remaining space */}
<div className="min-h-0 flex-1 flex flex-col">
<div className="shrink-0 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">Add Filter</span>
</div>
<div className="min-h-0 flex-1 flex flex-col">
<FeatureBrowser
availableFeatures={availableFeatures}
allFeatures={features}
pinnedFeature={pinnedFeature}
onAddFilter={onAddFilter}
onTogglePin={onTogglePin}
onNavigateToSource={onNavigateToSource}
openInfoFeature={openInfoFeature}
onClearOpenInfoFeature={onClearOpenInfoFeature}
/>
</div>
</div>
</div> </div>
); );
}); });

View file

@ -185,7 +185,7 @@ export default function HomePage({ onOpenDashboard, theme = 'light' }: { onOpenD
const ctaRef = useFadeInRef(); const ctaRef = useFadeInRef();
return ( return (
<div ref={scrollRef} className="flex-1 overflow-y-auto bg-warm-50 dark:bg-warm-900 relative"> <div ref={scrollRef} className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950 relative">
<HexCanvas scrollProgress={scrollProgress} isDark={theme === 'dark'} /> <HexCanvas scrollProgress={scrollProgress} isDark={theme === 'dark'} />
<div className="relative" style={{ zIndex: 1 }}> <div className="relative" style={{ zIndex: 1 }}>
@ -193,7 +193,7 @@ export default function HomePage({ onOpenDashboard, theme = 'light' }: { onOpenD
<div className="max-w-3xl mx-auto px-6 pt-20 pb-24"> <div className="max-w-3xl mx-auto px-6 pt-20 pb-24">
<div <div
ref={heroRef} ref={heroRef}
className="fade-in-section backdrop-blur-sm bg-warm-50/60 dark:bg-warm-900/60 rounded-2xl p-8 -mx-2" className="fade-in-section backdrop-blur-sm bg-warm-50/60 dark:bg-navy-950/60 rounded-2xl p-8 -mx-2"
> >
<p className="text-teal-600 font-semibold tracking-wide uppercase text-sm mb-4"> <p className="text-teal-600 font-semibold tracking-wide uppercase text-sm mb-4">
Find where to live, not just what&apos;s for sale Find where to live, not just what&apos;s for sale
@ -226,7 +226,7 @@ export default function HomePage({ onOpenDashboard, theme = 'light' }: { onOpenD
{/* The flip */} {/* The flip */}
<div className="max-w-3xl mx-auto px-6 pb-20"> <div className="max-w-3xl mx-auto px-6 pb-20">
<div ref={problemRef} className="fade-in-section"> <div ref={problemRef} className="fade-in-section">
<div className="rounded-2xl backdrop-blur-sm bg-warm-50/40 dark:bg-warm-800/40 border border-warm-200/50 dark:border-warm-700/50 p-8"> <div className="rounded-2xl backdrop-blur-sm bg-warm-50/40 dark:bg-navy-800/40 border border-warm-200/50 dark:border-navy-700/50 p-8">
<div className="grid md:grid-cols-2 gap-8"> <div className="grid md:grid-cols-2 gap-8">
<div> <div>
<h3 className="text-sm font-semibold text-warm-400 uppercase tracking-wide mb-2"> <h3 className="text-sm font-semibold text-warm-400 uppercase tracking-wide mb-2">
@ -265,7 +265,7 @@ export default function HomePage({ onOpenDashboard, theme = 'light' }: { onOpenD
{FILTERS.map((f) => ( {FILTERS.map((f) => (
<div <div
key={f.label} key={f.label}
className="rounded-xl bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 p-4 shadow-sm hover:shadow-md hover:border-teal-300 dark:hover:border-teal-600 transition-all" className="rounded-xl bg-white dark:bg-navy-800 border border-warm-200 dark:border-navy-700 p-4 shadow-sm hover:shadow-md hover:border-teal-300 dark:hover:border-teal-600 transition-all"
> >
<div className="text-2xl mb-2">{f.icon}</div> <div className="text-2xl mb-2">{f.icon}</div>
<div className="font-semibold text-navy-950 dark:text-warm-100 text-sm">{f.label}</div> <div className="font-semibold text-navy-950 dark:text-warm-100 text-sm">{f.label}</div>

View file

@ -19,7 +19,9 @@ interface MapProps {
onCancelPin: () => void; onCancelPin: () => void;
features: FeatureMeta[]; features: FeatureMeta[];
selectedHexagonId: string | null; selectedHexagonId: string | null;
hoveredHexagonId: string | null;
onHexagonClick: (h3: string) => void; onHexagonClick: (h3: string) => void;
onHexagonHover: (h3: string | null) => void;
initialViewState?: ViewState; initialViewState?: ViewState;
theme?: 'light' | 'dark'; theme?: 'light' | 'dark';
} }
@ -74,7 +76,8 @@ function normalizedToColor(t: number): [number, number, number] {
} }
function zoomToResolution(zoom: number): number { function zoomToResolution(zoom: number): number {
if (zoom < 7) return 7; if (zoom < 6) return 5;
if (zoom < 7) return 6;
if (zoom < 9.5) return 8; if (zoom < 9.5) return 8;
if (zoom < 11) return 9; if (zoom < 11) return 9;
if (zoom < 13) return 10; if (zoom < 13) return 10;
@ -145,13 +148,30 @@ function DeckOverlay({
return null; return null;
} }
// Sequential blue scale for count-based coloring // Vibrant density scale: light cyan → teal → deep indigo
const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [130, 234, 220] }, // Light cyan (few)
{ t: 0.5, color: [20, 140, 180] }, // Ocean blue (moderate)
{ t: 1, color: [88, 28, 140] }, // Deep indigo (many)
];
function countToColor(t: number): [number, number, number] { function countToColor(t: number): [number, number, number] {
// light blue (209, 226, 243) -> dark blue (33, 102, 172) if (t <= 0) return DENSITY_GRADIENT[0].color;
const r = Math.round(209 + (33 - 209) * t); if (t >= 1) return DENSITY_GRADIENT[DENSITY_GRADIENT.length - 1].color;
const g = Math.round(226 + (102 - 226) * t);
const b = Math.round(243 + (172 - 243) * t); for (let i = 0; i < DENSITY_GRADIENT.length - 1; i++) {
return [r, g, b]; const lo = DENSITY_GRADIENT[i];
const hi = DENSITY_GRADIENT[i + 1];
if (t >= lo.t && t <= hi.t) {
const frac = (t - lo.t) / (hi.t - lo.t);
return [
Math.round(lo.color[0] + (hi.color[0] - lo.color[0]) * frac),
Math.round(lo.color[1] + (hi.color[1] - lo.color[1]) * frac),
Math.round(lo.color[2] + (hi.color[2] - lo.color[2]) * frac),
];
}
}
return DENSITY_GRADIENT[DENSITY_GRADIENT.length - 1].color;
} }
function PostcodeSearch({ function PostcodeSearch({
@ -206,7 +226,7 @@ function PostcodeSearch({
setError(null); setError(null);
}} }}
placeholder="Search postcode..." placeholder="Search postcode..."
className="px-3 py-2 text-sm w-40 border-none outline-none bg-white dark:bg-warm-800 dark:text-warm-100 dark:placeholder-warm-500" className="px-3 py-2 text-sm w-40 border-none outline-none bg-white dark:bg-navy-800 dark:text-warm-100 dark:placeholder-warm-500"
/> />
<button <button
type="submit" type="submit"
@ -217,7 +237,7 @@ function PostcodeSearch({
</button> </button>
</div> </div>
{error && ( {error && (
<span className="text-xs text-red-600 dark:text-red-400 bg-white/90 dark:bg-warm-800/90 rounded px-2 py-0.5 shadow">{error}</span> <span className="text-xs text-red-600 dark:text-red-400 bg-white/90 dark:bg-navy-800/90 rounded px-2 py-0.5 shadow">{error}</span>
)} )}
</form> </form>
); );
@ -228,11 +248,15 @@ function MapLegend({
range, range,
showCancel, showCancel,
onCancel, onCancel,
mode,
enumValues,
}: { }: {
featureLabel: string; featureLabel: string;
range: [number, number]; range: [number, number];
showCancel: boolean; showCancel: boolean;
onCancel: () => void; onCancel: () => void;
mode: 'feature' | 'density';
enumValues?: string[];
}) { }) {
const formatVal = (v: number) => { const formatVal = (v: number) => {
if (Math.abs(v) >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`; if (Math.abs(v) >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
@ -241,14 +265,19 @@ function MapLegend({
return v.toFixed(1); return v.toFixed(1);
}; };
const gradientStyle =
mode === 'density'
? 'linear-gradient(to right, rgb(130, 234, 220), rgb(20, 140, 180), rgb(88, 28, 140))'
: 'linear-gradient(to right, rgb(46, 204, 113), rgb(241, 196, 15), rgb(231, 76, 60), rgb(142, 68, 173))';
return ( return (
<div className="absolute top-3 right-3 z-10 bg-white dark:bg-warm-800 dark:text-warm-200 rounded shadow-lg p-3 text-xs min-w-[160px]"> <div className="absolute top-3 right-3 z-10 bg-white dark:bg-navy-800 dark:text-warm-200 rounded shadow-lg p-3 text-xs min-w-[160px]">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="font-semibold text-sm">{featureLabel}</span> <span className="font-semibold text-sm">{featureLabel}</span>
{showCancel && ( {showCancel && (
<button <button
onClick={onCancel} onClick={onCancel}
className="text-warm-400 hover:text-warm-700 ml-2" className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 ml-2"
title="Clear color view" title="Clear color view"
> >
<svg <svg
@ -265,14 +294,25 @@ function MapLegend({
</div> </div>
<div <div
className="h-3 rounded" className="h-3 rounded"
style={{ style={{ background: gradientStyle }}
background:
'linear-gradient(to right, rgb(46, 204, 113), rgb(241, 196, 15), rgb(231, 76, 60), rgb(142, 68, 173))',
}}
/> />
<div className="flex justify-between mt-1 text-warm-600 dark:text-warm-400"> <div className="flex justify-between mt-1 text-warm-600 dark:text-warm-400">
<span>{formatVal(range[0])}</span> {mode === 'density' ? (
<span>{formatVal(range[1])}</span> <>
<span>Few</span>
<span>Many</span>
</>
) : enumValues && enumValues.length > 0 ? (
<>
<span>{enumValues[0]}</span>
<span>{enumValues[enumValues.length - 1]}</span>
</>
) : (
<>
<span>{formatVal(range[0])}</span>
<span>{formatVal(range[1])}</span>
</>
)}
</div> </div>
</div> </div>
); );
@ -289,7 +329,9 @@ export default memo(function Map({
onCancelPin, onCancelPin,
features, features,
selectedHexagonId, selectedHexagonId,
hoveredHexagonId,
onHexagonClick, onHexagonClick,
onHexagonHover,
initialViewState, initialViewState,
theme = 'light', theme = 'light',
}: MapProps) { }: MapProps) {
@ -433,6 +475,8 @@ export default memo(function Map({
countRangeRef.current = countRange; countRangeRef.current = countRange;
const selectedHexagonIdRef = useRef(selectedHexagonId); const selectedHexagonIdRef = useRef(selectedHexagonId);
selectedHexagonIdRef.current = selectedHexagonId; selectedHexagonIdRef.current = selectedHexagonId;
const hoveredHexagonIdRef = useRef(hoveredHexagonId);
hoveredHexagonIdRef.current = hoveredHexagonId;
// Stable click handler using ref // Stable click handler using ref
const onHexagonClickRef = useRef(onHexagonClick); const onHexagonClickRef = useRef(onHexagonClick);
@ -443,6 +487,17 @@ export default memo(function Map({
} }
}, []); }, []);
// Stable hover handler using ref
const onHexagonHoverRef = useRef(onHexagonHover);
onHexagonHoverRef.current = onHexagonHover;
const handleHexagonHover = useCallback((info: PickingInfo<HexagonData>) => {
if (info.object && 'h3' in info.object) {
onHexagonHoverRef.current(info.object.h3);
} else {
onHexagonHoverRef.current(null);
}
}, []);
// Stable hover handler using ref // Stable hover handler using ref
const handlePoiHoverRef = useRef(handlePoiHover); const handlePoiHoverRef = useRef(handlePoiHover);
handlePoiHoverRef.current = handlePoiHover; handlePoiHoverRef.current = handlePoiHover;
@ -451,7 +506,7 @@ export default memo(function Map({
}, []); }, []);
// Derive a trigger value from color-affecting state — avoids useEffect+setState double-render // Derive a trigger value from color-affecting state — avoids useEffect+setState double-render
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}`; const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}`;
// Hexagon layer — only recreated when data or color trigger changes // Hexagon layer — only recreated when data or color trigger changes
const hexLayer = useMemo( const hexLayer = useMemo(
@ -493,14 +548,16 @@ export default memo(function Map({
number, number,
]; ];
}, },
getLineColor: (d) => getLineColor: (d) => {
(d.h3 === selectedHexagonIdRef.current ? [255, 255, 255, 255] : [0, 0, 0, 0]) as [ if (d.h3 === selectedHexagonIdRef.current) return [255, 255, 255, 255] as [number, number, number, number];
number, if (d.h3 === hoveredHexagonIdRef.current) return [29, 228, 195, 200] as [number, number, number, number];
number, return [0, 0, 0, 0] as [number, number, number, number];
number, },
number, getLineWidth: (d) => {
], if (d.h3 === selectedHexagonIdRef.current) return 3;
getLineWidth: (d) => (d.h3 === selectedHexagonIdRef.current ? 2 : 0), if (d.h3 === hoveredHexagonIdRef.current) return 2;
return 0;
},
lineWidthUnits: 'pixels', lineWidthUnits: 'pixels',
updateTriggers: { updateTriggers: {
getFillColor: [colorTrigger], getFillColor: [colorTrigger],
@ -512,10 +569,11 @@ export default memo(function Map({
opacity: 1, opacity: 1,
highPrecision: true, highPrecision: true,
onClick: handleHexagonClick, onClick: handleHexagonClick,
onHover: handleHexagonHover,
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps // @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
beforeId: 'waterway_label', beforeId: 'waterway_label',
}), }),
[data, colorTrigger, handleHexagonClick] [data, colorTrigger, handleHexagonClick, handleHexagonHover]
); );
// POI layer — independent, only recreated when POI data changes // POI layer — independent, only recreated when POI data changes
@ -576,51 +634,6 @@ export default memo(function Map({
[hexLayer, poiLayer, postcodeLayer] [hexLayer, poiLayer, postcodeLayer]
); );
// Tooltip uses refs to avoid being a layer dependency
const featuresRef = useRef(features);
featuresRef.current = features;
const getTooltip = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
({ object }: { object?: any }) => {
if (!object) return null;
if (!('h3' in object)) return null;
const lines: string[] = [];
lines.push(`<div>${(object.count as number).toLocaleString()} properties</div>`);
for (const f of featuresRef.current) {
const minVal = object[`min_${f.name}`];
const maxVal = object[`max_${f.name}`];
if (minVal != null && maxVal != null) {
const minStr =
typeof minVal === 'number'
? minVal.toLocaleString(undefined, { maximumFractionDigits: 1 })
: String(minVal);
const maxStr =
typeof maxVal === 'number'
? maxVal.toLocaleString(undefined, { maximumFractionDigits: 1 })
: String(maxVal);
const highlight = f.name === viewFeatureRef.current ? 'font-weight: bold;' : '';
lines.push(`<div style="${highlight}">${f.name}: ${minStr} - ${maxStr}</div>`);
}
}
const isDark = themeRef.current === 'dark';
return {
html: `<div style="padding: 8px; font-size: 12px;">${lines.join('')}</div>`,
style: {
backgroundColor: isDark ? '#292524' : 'white',
color: isDark ? '#e7e5e4' : 'inherit',
borderRadius: '4px',
boxShadow: isDark ? '0 2px 4px rgba(0,0,0,0.5)' : '0 2px 4px rgba(0,0,0,0.2)',
},
};
},
[]
);
return ( return (
<div className="flex-1 h-full relative" ref={containerRef}> <div className="flex-1 h-full relative" ref={containerRef}>
<MapGL <MapGL
@ -635,21 +648,33 @@ export default memo(function Map({
touchPitch={false} touchPitch={false}
keyboard={true} keyboard={true}
pitchWithRotate={false} pitchWithRotate={false}
minZoom={5}
maxBounds={[-12, 49, 4, 62]}
> >
<DeckOverlay layers={layers} getTooltip={getTooltip as never} /> <DeckOverlay layers={layers} getTooltip={null} />
</MapGL> </MapGL>
<PostcodeSearch onFlyTo={handleFlyTo} /> <PostcodeSearch onFlyTo={handleFlyTo} />
{viewFeature && colorRange && colorFeatureMeta && ( {viewFeature && colorRange && colorFeatureMeta ? (
<MapLegend <MapLegend
featureLabel={colorFeatureMeta.name} featureLabel={colorFeatureMeta.name}
range={colorRange} range={colorRange}
showCancel={viewSource === 'eye'} showCancel={viewSource === 'eye'}
onCancel={onCancelPin} onCancel={onCancelPin}
mode="feature"
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
/>
) : (
<MapLegend
featureLabel="Property density"
range={[0, 0]}
showCancel={false}
onCancel={onCancelPin}
mode="density"
/> />
)} )}
{popupInfo && ( {popupInfo && (
<div <div
className="absolute pointer-events-none bg-white dark:bg-warm-800 rounded shadow-lg p-2 text-sm dark:text-warm-200" className="absolute pointer-events-none bg-white dark:bg-navy-800 rounded shadow-lg p-2 text-sm dark:text-warm-200"
style={{ style={{
left: popupInfo.x, left: popupInfo.x,
top: popupInfo.y - 40, top: popupInfo.y - 40,

View file

@ -6,6 +6,7 @@ interface POIPaneProps {
selectedCategories: Set<string>; selectedCategories: Set<string>;
onCategoriesChange: (categories: Set<string>) => void; onCategoriesChange: (categories: Set<string>) => void;
poiCount: number; poiCount: number;
onNavigateToSource?: (slug: string) => void;
} }
export default function POIPane({ export default function POIPane({
@ -13,11 +14,14 @@ export default function POIPane({
selectedCategories, selectedCategories,
onCategoriesChange, onCategoriesChange,
poiCount, poiCount,
onNavigateToSource,
}: POIPaneProps) { }: POIPaneProps) {
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set()); const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const [showInfo, setShowInfo] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const infoPopupRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside // Close dropdown when clicking outside
useEffect(() => { useEffect(() => {
@ -30,6 +34,18 @@ export default function POIPane({
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, []); }, []);
// Close info popup when clicking outside
useEffect(() => {
if (!showInfo) return;
function handleClickOutside(e: MouseEvent) {
if (infoPopupRef.current && !infoPopupRef.current.contains(e.target as Node)) {
setShowInfo(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showInfo]);
const allCategories = groups.flatMap((g) => g.categories); const allCategories = groups.flatMap((g) => g.categories);
const toggleCategory = (category: string) => { const toggleCategory = (category: string) => {
@ -95,13 +111,65 @@ export default function POIPane({
const selectedCount = selectedCategories.size; const selectedCount = selectedCategories.size;
return ( return (
<div className="w-72 p-4 bg-white dark:bg-warm-900 shadow-lg space-y-4 overflow-y-auto max-h-screen"> <div className="w-72 p-4 bg-white dark:bg-navy-950 shadow-lg space-y-4 overflow-y-auto max-h-screen">
<h2 className="text-xl font-bold dark:text-warm-100">Points of Interest</h2> <div className="flex items-center gap-2">
<h2 className="text-xl font-bold dark:text-warm-100">Points of Interest</h2>
<button
onClick={() => setShowInfo(true)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-0.5 rounded"
title="Data source info"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<circle cx="12" cy="12" r="10" />
<path strokeLinecap="round" d="M12 16v-4m0-4h.01" />
</svg>
</button>
</div>
{showInfo && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div
ref={infoPopupRef}
className="bg-white dark:bg-navy-800 border border-warm-200 dark:border-navy-700 rounded-lg shadow-xl max-w-md w-full mx-4 p-5"
>
<div className="flex items-start justify-between mb-3">
<h3 className="text-sm font-semibold text-warm-900 dark:text-warm-100 pr-4">
Points of Interest
</h3>
<button
onClick={() => setShowInfo(false)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
Points of interest are sourced from OpenStreetMap via Geofabrik extracts.
Categories include public transport stops, shops, restaurants, healthcare
facilities, leisure venues, and more. Data is filtered and mapped to
friendly names with exhaustive category coverage.
</p>
{onNavigateToSource && (
<button
onClick={() => {
onNavigateToSource('osm-pois');
setShowInfo(false);
}}
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
>
View data source
</button>
)}
</div>
</div>
)}
<div className="space-y-2" ref={dropdownRef}> <div className="space-y-2" ref={dropdownRef}>
<button <button
onClick={() => setDropdownOpen(!dropdownOpen)} onClick={() => setDropdownOpen(!dropdownOpen)}
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-warm-300 dark:border-warm-700 rounded hover:border-warm-400 bg-white dark:bg-warm-800 dark:text-warm-200" className="w-full flex items-center justify-between px-3 py-2 text-sm border border-warm-300 dark:border-navy-700 rounded hover:border-warm-400 bg-white dark:bg-navy-800 dark:text-warm-200"
> >
<span className="truncate text-left"> <span className="truncate text-left">
{selectedCount === 0 {selectedCount === 0
@ -121,8 +189,8 @@ export default function POIPane({
</button> </button>
{dropdownOpen && ( {dropdownOpen && (
<div className="border border-warm-300 dark:border-warm-700 rounded shadow-lg bg-white dark:bg-warm-800"> <div className="border border-warm-300 dark:border-navy-700 rounded shadow-lg bg-white dark:bg-navy-800">
<div className="flex gap-2 px-3 py-2 border-b border-warm-200 dark:border-warm-700"> <div className="flex gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<button onClick={selectAll} className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"> <button onClick={selectAll} className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300">
All All
</button> </button>
@ -131,13 +199,13 @@ export default function POIPane({
None None
</button> </button>
</div> </div>
<div className="px-3 py-2 border-b border-warm-200 dark:border-warm-700"> <div className="px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<input <input
type="text" type="text"
placeholder="Search categories..." placeholder="Search categories..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-2 py-1 text-sm border border-warm-300 dark:border-warm-700 rounded bg-white dark:bg-warm-900 dark:text-warm-200 dark:placeholder-warm-500" className="w-full px-2 py-1 text-sm border border-warm-300 dark:border-navy-700 rounded bg-white dark:bg-navy-950 dark:text-warm-200 dark:placeholder-warm-500"
/> />
</div> </div>
<div className="max-h-96 overflow-y-auto py-1"> <div className="max-h-96 overflow-y-auto py-1">
@ -151,7 +219,7 @@ export default function POIPane({
return ( return (
<div key={group.name}> <div key={group.name}>
<div className="flex items-center gap-1 px-3 py-1.5 bg-warm-50 dark:bg-warm-900 border-y border-warm-100 dark:border-warm-700"> <div className="flex items-center gap-1 px-3 py-1.5 bg-warm-50 dark:bg-navy-950 border-y border-warm-100 dark:border-navy-700">
<button <button
onClick={() => toggleCollapse(group.name)} onClick={() => toggleCollapse(group.name)}
className="p-0.5 text-warm-400 hover:text-warm-600" className="p-0.5 text-warm-400 hover:text-warm-600"
@ -190,7 +258,7 @@ export default function POIPane({
group.categories.map((category) => ( group.categories.map((category) => (
<label <label
key={category} key={category}
className="flex items-center gap-2 px-3 pl-8 py-1.5 hover:bg-warm-50 dark:hover:bg-warm-700 cursor-pointer dark:text-warm-300" className="flex items-center gap-2 px-3 pl-8 py-1.5 hover:bg-warm-50 dark:hover:bg-navy-700 cursor-pointer dark:text-warm-300"
> >
<input <input
type="checkbox" type="checkbox"
@ -220,7 +288,7 @@ export default function POIPane({
</div> </div>
)} )}
<div className="p-3 bg-warm-100 dark:bg-warm-800 rounded text-xs text-warm-600 dark:text-warm-400"> <div className="p-3 bg-warm-100 dark:bg-navy-800 rounded text-xs text-warm-600 dark:text-warm-400">
<p>Select categories to display POIs on the map.</p> <p>Select categories to display POIs on the map.</p>
<p className="mt-2">Zoom in for better visibility of individual locations.</p> <p className="mt-2">Zoom in for better visibility of individual locations.</p>
</div> </div>

View file

@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState, useRef, useEffect } from 'react';
import { Property } from '../types'; import { Property } from '../types';
interface PropertiesPaneProps { interface PropertiesPaneProps {
@ -8,6 +8,10 @@ interface PropertiesPaneProps {
hexagonId: string | null; hexagonId: string | null;
onLoadMore: () => void; onLoadMore: () => void;
onClose: () => void; onClose: () => void;
onNavigateToSource?: (slug: string) => void;
isHoveredPreview?: boolean;
hoverMode?: boolean;
onHoverModeChange?: (enabled: boolean) => void;
} }
type SortBy = 'price' | 'size' | 'energy'; type SortBy = 'price' | 'size' | 'energy';
@ -19,9 +23,26 @@ export function PropertiesPane({
hexagonId, hexagonId,
onLoadMore, onLoadMore,
onClose, onClose,
onNavigateToSource,
isHoveredPreview,
hoverMode,
onHoverModeChange,
}: PropertiesPaneProps) { }: PropertiesPaneProps) {
const [sortBy, setSortBy] = useState<SortBy>('price'); const [sortBy, setSortBy] = useState<SortBy>('price');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [showInfo, setShowInfo] = useState(false);
const infoPopupRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!showInfo) return;
function handleClickOutside(e: MouseEvent) {
if (infoPopupRef.current && !infoPopupRef.current.contains(e.target as Node)) {
setShowInfo(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showInfo]);
// Filter and sort properties // Filter and sort properties
const filteredAndSorted = useMemo(() => { const filteredAndSorted = useMemo(() => {
@ -56,36 +77,112 @@ export function PropertiesPane({
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Header */} {/* Header */}
<div className="p-4 border-b border-warm-200 dark:border-warm-700"> <div className="p-4 border-b border-warm-200 dark:border-navy-700">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<h2 className="text-lg font-semibold dark:text-warm-100">Properties in Hexagon</h2> <div className="flex items-center gap-2">
<button <h2 className="text-lg font-semibold dark:text-warm-100">Properties</h2>
onClick={onClose} {isHoveredPreview && (
className="text-warm-500 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200 text-2xl leading-none" <span className="text-xs px-1.5 py-0.5 rounded bg-teal-50 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
> Preview
× </span>
</button> )}
<button
onClick={() => setShowInfo(true)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-0.5 rounded"
title="Data source info"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<circle cx="12" cy="12" r="10" />
<path strokeLinecap="round" d="M12 16v-4m0-4h.01" />
</svg>
</button>
</div>
<div className="flex items-center gap-1">
{onHoverModeChange && (
<button
onClick={() => onHoverModeChange(!hoverMode)}
className={`p-1 rounded ${
hoverMode
? 'text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30'
: 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
}`}
title={hoverMode ? 'Live preview on (click to lock)' : 'Live preview off (click to enable)'}
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
)}
<button
onClick={onClose}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-1"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div> </div>
<p className="text-sm text-warm-600 dark:text-warm-400"> <p className="text-sm text-warm-600 dark:text-warm-400">
{search.trim() {search.trim()
? `${filteredAndSorted.length} match${filteredAndSorted.length !== 1 ? 'es' : ''} in ${properties.length} loaded` ? `${filteredAndSorted.length} match${filteredAndSorted.length !== 1 ? 'es' : ''} in ${properties.length} loaded`
: `Showing ${properties.length} of ${total} properties`} : `Showing ${properties.length} of ${total} properties`}
</p> </p>
{showInfo && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div
ref={infoPopupRef}
className="bg-white dark:bg-navy-800 border border-warm-200 dark:border-navy-700 rounded-lg shadow-xl max-w-md w-full mx-4 p-5"
>
<div className="flex items-start justify-between mb-3">
<h3 className="text-sm font-semibold text-warm-900 dark:text-warm-100 pr-4">
Property Data
</h3>
<button
onClick={() => setShowInfo(false)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
Property data combines Energy Performance Certificates (EPC) with HM Land
Registry Price Paid records, fuzzy-matched by address within each postcode.
Includes floor area, energy ratings, construction age, and tenure from EPC
surveys, plus the most recent sale price from the Land Registry.
</p>
{onNavigateToSource && (
<button
onClick={() => {
onNavigateToSource('epc');
setShowInfo(false);
}}
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
>
View data source
</button>
)}
</div>
</div>
)}
</div> </div>
{/* Search and sort controls */} {/* Search and sort controls */}
<div className="p-2 border-b border-warm-200 dark:border-warm-700 space-y-2"> <div className="p-2 border-b border-warm-200 dark:border-navy-700 space-y-2">
<input <input
type="text" type="text"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="Search by address or postcode..." placeholder="Search by address or postcode..."
className="w-full p-2 border border-warm-300 dark:border-warm-700 rounded text-sm bg-white dark:bg-warm-800 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500" className="w-full p-2 border border-warm-300 dark:border-navy-700 rounded text-sm bg-white dark:bg-navy-800 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500"
/> />
<select <select
value={sortBy} value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortBy)} onChange={(e) => setSortBy(e.target.value as SortBy)}
className="w-full p-2 border border-warm-300 dark:border-warm-700 rounded text-sm bg-white dark:bg-warm-800 dark:text-warm-200" className="w-full p-2 border border-warm-300 dark:border-navy-700 rounded text-sm bg-white dark:bg-navy-800 dark:text-warm-200"
> >
<option value="price">Price (High to Low)</option> <option value="price">Price (High to Low)</option>
<option value="size">Size (Large to Small)</option> <option value="size">Size (Large to Small)</option>
@ -156,7 +253,7 @@ function PropertyCard({ property }: { property: Property }) {
const age = getNum(property, 'Approximate construction age', 'construction_age_band'); const age = getNum(property, 'Approximate construction age', 'construction_age_band');
return ( return (
<div className="p-4 border-b border-warm-100 dark:border-warm-800 hover:bg-warm-50 dark:hover:bg-warm-800"> <div className="p-4 border-b border-warm-100 dark:border-navy-800 hover:bg-warm-50 dark:hover:bg-navy-800">
{/* Address & postcode */} {/* Address & postcode */}
<div className="font-semibold dark:text-warm-100">{property.address || 'Unknown Address'}</div> <div className="font-semibold dark:text-warm-100">{property.address || 'Unknown Address'}</div>
<div className="text-sm text-warm-600 dark:text-warm-400">{property.postcode}</div> <div className="text-sm text-warm-600 dark:text-warm-400">{property.postcode}</div>

View file

@ -11,13 +11,13 @@ export function Slider({ className, ...props }: SliderProps) {
className={cn('relative flex w-full touch-none select-none items-center', className)} className={cn('relative flex w-full touch-none select-none items-center', className)}
{...props} {...props}
> >
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-warm-200 dark:bg-warm-700"> <SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-warm-200 dark:bg-navy-700">
<SliderPrimitive.Range className="absolute h-full bg-teal-600" /> <SliderPrimitive.Range className="absolute h-full bg-teal-600" />
</SliderPrimitive.Track> </SliderPrimitive.Track>
{props.value?.map((_, i) => ( {props.value?.map((_, i) => (
<SliderPrimitive.Thumb <SliderPrimitive.Thumb
key={i} key={i}
className="block h-5 w-5 rounded-full border-2 border-teal-600 dark:border-teal-500 bg-white dark:bg-warm-800 ring-offset-white dark:ring-offset-warm-900 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" className="block h-5 w-5 rounded-full border-2 border-teal-600 dark:border-teal-500 bg-white dark:bg-navy-800 ring-offset-white dark:ring-offset-navy-950 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
/> />
))} ))}
</SliderPrimitive.Root> </SliderPrimitive.Root>

View file

@ -11,7 +11,7 @@ body,
} }
html.dark { html.dark {
background-color: #1c1917; background-color: #0a0e1a;
color-scheme: dark; color-scheme: dark;
} }

View file

@ -5,8 +5,13 @@ export interface FeatureMeta {
// Numeric-only fields // Numeric-only fields
min?: number; min?: number;
max?: number; max?: number;
step?: number;
// Enum-only fields // Enum-only fields
values?: string[]; values?: string[];
// Description fields
description?: string;
detail?: string;
source?: string;
} }
export interface FeatureGroup { export interface FeatureGroup {
@ -100,3 +105,23 @@ export interface HexagonPropertiesResponse {
offset: number; offset: number;
truncated: boolean; truncated: boolean;
} }
export interface NumericFeatureStats {
name: string;
count: number;
min: number;
max: number;
mean: number;
histogram: { min: number; max: number; bin_width: number; counts: number[] };
}
export interface EnumFeatureStats {
name: string;
counts: Record<string, number>;
}
export interface HexagonStatsResponse {
count: number;
numeric_features: NumericFeatureStats[];
enum_features: EnumFeatureStats[];
}

View file

@ -132,7 +132,9 @@ def main():
.str.extract(r"(\d{4})", 1) .str.extract(r"(\d{4})", 1)
.cast(pl.UInt16, strict=False) .cast(pl.UInt16, strict=False)
) )
transfer_year = pl.col("first_transfer_date").dt.year().cast(pl.UInt16, strict=False) transfer_year = (
pl.col("first_transfer_date").dt.year().cast(pl.UInt16, strict=False)
)
is_new_build = pl.col("old_new") == "Y" is_new_build = pl.col("old_new") == "Y"
matched = matched.with_columns( matched = matched.with_columns(

View file

@ -8,7 +8,7 @@ pub const SERVER_ADDRESS: &str = "0.0.0.0:8001";
pub const BOUNDS_QUANTIZATION: f64 = 0.01; pub const BOUNDS_QUANTIZATION: f64 = 0.01;
pub const BOUNDS_BUFFER_PERCENT: f64 = 0.1; pub const BOUNDS_BUFFER_PERCENT: f64 = 0.1;
pub const POSTCODE_MIN_RESOLUTION: u8 = 11; pub const POSTCODE_MIN_RESOLUTION: u8 = 11;
pub const MAX_POIS_PER_REQUEST: usize = 5000; pub const MAX_POIS_PER_REQUEST: usize = 2500;
pub const DEFAULT_PROPERTIES_LIMIT: usize = 100; pub const DEFAULT_PROPERTIES_LIMIT: usize = 100;
pub const MAX_PROPERTIES_LIMIT: usize = 500; pub const MAX_PROPERTIES_LIMIT: usize = 500;
pub const ENUM_NULL: u8 = 255; pub const ENUM_NULL: u8 = 255;

676
server-rs/src/features.rs Normal file
View file

@ -0,0 +1,676 @@
//! Static feature configuration. Every numeric and enum column in wide.parquet
//! must be declared here. Unknown columns cause a startup panic.
pub enum Bounds {
/// Fixed min/max values for the slider
Fixed { min: f64, max: f64 },
/// Compute percentile from data at startup
Percentile { low: f64, high: f64 },
}
pub struct FeatureConfig {
/// Must match parquet column name exactly (also used as display label)
pub name: &'static str,
pub bounds: Bounds,
/// Slider step size. Controls the granularity of the range slider in the UI.
pub step: f64,
/// Short one-line description shown in the filter sidebar
pub description: &'static str,
/// Longer description explaining methodology, data source, and caveats
pub detail: &'static str,
/// Data source slug for linking to /data-sources#<slug>
pub source: &'static str,
}
pub struct FeatureGroup {
pub name: &'static str,
pub features: &'static [FeatureConfig],
}
pub struct EnumFeatureConfig {
pub name: &'static str,
/// If set, values are presented in this order instead of alphabetical.
/// Values not listed are appended alphabetically after the ordered ones.
pub order: Option<&'static [&'static str]>,
/// Short one-line description shown in the filter sidebar
pub description: &'static str,
/// Longer description explaining methodology, data source, and caveats
pub detail: &'static str,
/// Data source slug for linking to /data-sources#<slug>
pub source: &'static str,
}
pub struct EnumFeatureGroup {
pub name: &'static str,
pub features: &'static [EnumFeatureConfig],
}
/// Columns in parquet that are neither numeric features nor enum features.
/// These are silently skipped during schema validation.
pub const IGNORED_COLUMNS: &[&str] = &[
"lat",
"lon",
"Address per Property Register",
"Address per EPC",
"Postcode",
"historical_prices",
"Is construction date approximate",
];
pub static FEATURE_GROUPS: &[FeatureGroup] = &[
FeatureGroup {
name: "Property",
features: &[
FeatureConfig {
name: "Last known price",
bounds: Bounds::Fixed {
min: 0.0,
max: 2_000_000.0,
},
step: 10000.0,
description: "Most recent sale price from the Land Registry",
detail: "The last recorded sale price for this property from HM Land Registry Price Paid data. Covers residential sales in England and Wales. May be years old if the property hasn't sold recently.",
source: "price-paid",
},
FeatureConfig {
name: "Price per sqm",
bounds: Bounds::Percentile {
low: 0.0,
high: 98.0,
},
step: 100.0,
description: "Sale price divided by total floor area",
detail: "Calculated by dividing the last known sale price by the total floor area from the EPC certificate. Useful for comparing value across different-sized properties. Only available where both price and floor area data exist.",
source: "price-paid",
},
FeatureConfig {
name: "Total floor area (sqm)",
bounds: Bounds::Percentile {
low: 0.0,
high: 98.0,
},
step: 1.0,
description: "Internal floor area from the EPC survey",
detail: "Total useful floor area in square metres as measured during the Energy Performance Certificate assessment. Includes all habitable rooms but excludes garages, outbuildings, and external areas.",
source: "epc",
},
FeatureConfig {
name: "Number of bedrooms & living rooms",
bounds: Bounds::Fixed {
min: 1.0,
max: 10.0,
},
step: 1.0,
description: "Count of habitable rooms from the EPC survey",
detail: "Total number of habitable rooms (bedrooms plus living rooms) as recorded in the Energy Performance Certificate. Kitchens and bathrooms are typically excluded unless they are large enough to count as habitable rooms.",
source: "epc",
},
FeatureConfig {
name: "Approximate construction age",
bounds: Bounds::Fixed {
min: 0.0,
max: 2026.0,
},
step: 1.0,
description: "Estimated year of construction from the EPC",
detail: "The approximate year of construction as recorded in the Energy Performance Certificate. Derived from the construction age band (e.g. '1930-1949') by taking the midpoint. May be approximate, especially for older buildings.",
source: "epc",
},
],
},
FeatureGroup {
name: "Transport",
features: &[
FeatureConfig {
name: "public_transport_easy_minutes",
bounds: Bounds::Fixed {
min: 0.0,
max: 180.0,
},
step: 2.0,
description: "Quickest public transport journey to central London (easy route)",
detail: "Journey time in minutes by public transport to central London destinations, using TfL's Journey Planner API. The 'easy' route minimises changes and walking. Calculated for weekday morning commute times.",
source: "tfl-journey-times",
},
FeatureConfig {
name: "public_transport_quick_minutes",
bounds: Bounds::Fixed {
min: 0.0,
max: 180.0,
},
step: 2.0,
description: "Fastest public transport journey to central London",
detail: "Journey time in minutes by public transport to central London destinations, using TfL's Journey Planner API. The 'quick' route optimises for shortest total time regardless of changes. Calculated for weekday morning commute times.",
source: "tfl-journey-times",
},
FeatureConfig {
name: "cycling_minutes",
bounds: Bounds::Fixed {
min: 0.0,
max: 180.0,
},
step: 1.0,
description: "Cycling time to central London via TfL routing",
detail: "Cycling journey time in minutes to central London destinations, as calculated by the TfL Journey Planner API. Uses TfL's default cycling speed and route preferences.",
source: "tfl-journey-times",
},
FeatureConfig {
name: "Public transport within 2km",
bounds: Bounds::Percentile {
low: 5.0,
high: 95.0,
},
step: 1.0,
description: "Number of public transport stops within 2km",
detail: "Count of bus stops, rail stations, tube stations, tram stops, and other public transport access points within a 2km radius of the property's postcode. Derived from the NaPTAN (National Public Transport Access Nodes) dataset.",
source: "naptan",
},
],
},
FeatureGroup {
name: "Education",
features: &[
FeatureConfig {
name: "Education, Skills and Training Score",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 0.1,
description: "IoD education deprivation score for the local area",
detail: "From the English Indices of Deprivation. Measures deprivation in education, skills and training in the local area (LSOA). Higher scores indicate greater deprivation. Combines children/young people sub-domain (school attainment, entry to higher education) and adult skills sub-domain (adult qualifications, English language proficiency).",
source: "iod",
},
FeatureConfig {
name: "Good+ primary schools within 5km",
bounds: Bounds::Fixed {
min: 0.0,
max: 30.0,
},
step: 1.0,
description: "Primary schools rated Good or Outstanding by Ofsted nearby",
detail: "Number of state-funded primary schools within 5km that have a current Ofsted rating of Good or Outstanding. Based on the latest inspection outcomes dataset. Schools that have not yet been inspected are excluded.",
source: "ofsted",
},
FeatureConfig {
name: "Good+ secondary schools within 5km",
bounds: Bounds::Fixed {
min: 0.0,
max: 15.0,
},
step: 1.0,
description: "Secondary schools rated Good or Outstanding by Ofsted nearby",
detail: "Number of state-funded secondary schools within 5km that have a current Ofsted rating of Good or Outstanding. Based on the latest inspection outcomes dataset. Schools that have not yet been inspected are excluded.",
source: "ofsted",
},
],
},
FeatureGroup {
name: "Deprivation",
features: &[
FeatureConfig {
name: "Index of Multiple Deprivation (IMD) Score",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 0.1,
description: "Overall deprivation score combining all domains",
detail: "The Index of Multiple Deprivation is the official measure of relative deprivation in England. It combines seven weighted domains: Income (22.5%), Employment (22.5%), Education (13.5%), Health (13.5%), Crime (9.3%), Barriers to Housing & Services (9.3%), and Living Environment (9.3%). Higher scores indicate greater deprivation. Measured at LSOA level (~1,500 people).",
source: "iod",
},
FeatureConfig {
name: "Income Score (rate)",
bounds: Bounds::Fixed { min: 0.0, max: 0.6 },
step: 0.01,
description: "Proportion of the population experiencing income deprivation",
detail: "From the English Indices of Deprivation. The proportion of the local population experiencing deprivation relating to low income. Includes people on Income Support, income-based Jobseeker's Allowance, income-based Employment and Support Allowance, Pension Credit, Working Tax Credit and Child Tax Credit, Universal Credit, and asylum seekers.",
source: "iod",
},
FeatureConfig {
name: "Employment Score (rate)",
bounds: Bounds::Fixed { min: 0.0, max: 0.4 },
step: 0.01,
description: "Proportion of the working-age population involuntarily excluded from work",
detail: "From the English Indices of Deprivation. The proportion of the working-age population involuntarily excluded from the labour market. Includes claimants of Jobseeker's Allowance, Employment and Support Allowance, Incapacity Benefit, Severe Disablement Allowance, Carer's Allowance, and relevant Universal Credit claimants.",
source: "iod",
},
FeatureConfig {
name: "Health Deprivation and Disability Score",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 0.1,
description: "Risk of premature death and quality of life impairment",
detail: "From the English Indices of Deprivation. Measures the risk of premature death and impairment of quality of life through poor physical or mental health. Derived from years of potential life lost, comparative illness and disability ratio, acute morbidity, and mood and anxiety disorders.",
source: "iod",
},
FeatureConfig {
name: "Crime Score",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 0.1,
description: "IoD crime deprivation score measuring personal risk",
detail: "From the English Indices of Deprivation. Measures the risk of personal and material victimisation at local level. Derived from recorded rates of violence, burglary, theft, and criminal damage. Higher scores indicate higher crime-related deprivation.",
source: "iod",
},
FeatureConfig {
name: "Living Environment Score",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 0.1,
description: "Quality of the local indoor and outdoor environment",
detail: "From the English Indices of Deprivation. Measures deprivation in the quality of the local environment. Combines the Indoors sub-domain (housing quality, central heating, housing conditions) and Outdoors sub-domain (air quality, road traffic accidents). Higher scores indicate poorer living environments.",
source: "iod",
},
FeatureConfig {
name: "Indoors Sub-domain Score",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 0.1,
description: "Housing quality and conditions in the local area",
detail: "From the English Indices of Deprivation, Living Environment domain. Measures the quality of housing stock: houses without central heating, housing in poor condition, and houses failing Decent Homes standards. Higher scores indicate worse housing conditions.",
source: "iod",
},
FeatureConfig {
name: "Outdoors Sub-domain Score",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 0.1,
description: "Air quality and road safety in the local area",
detail: "From the English Indices of Deprivation, Living Environment domain. Measures the outdoor living environment quality through air quality indicators and road traffic accident casualties involving pedestrians and cyclists. Higher scores indicate poorer outdoor environments.",
source: "iod",
},
],
},
FeatureGroup {
name: "Crime",
features: &[
FeatureConfig {
name: "Anti-social behaviour (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 1.0,
description: "Average yearly anti-social behaviour incidents in the area",
detail: "Average number of anti-social behaviour incidents per year in the LSOA, from police.uk street-level crime data (2023-2025). Includes nuisance, environmental, and personal anti-social behaviour.",
source: "crime",
},
FeatureConfig {
name: "Violence and sexual offences (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 1.0,
description: "Average yearly violent and sexual offences in the area",
detail: "Average number of violence and sexual offences per year in the LSOA, from police.uk street-level crime data (2023-2025). Includes assault, harassment, and sexual offences.",
source: "crime",
},
FeatureConfig {
name: "Criminal damage and arson (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 1.0,
description: "Average yearly criminal damage and arson in the area",
detail: "Average number of criminal damage and arson incidents per year in the LSOA, from police.uk street-level crime data (2023-2025).",
source: "crime",
},
FeatureConfig {
name: "Burglary (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 1.0,
description: "Average yearly burglary offences in the area",
detail: "Average number of burglary offences per year in the LSOA, from police.uk street-level crime data (2023-2025). Includes residential and commercial burglary.",
source: "crime",
},
FeatureConfig {
name: "Vehicle crime (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 1.0,
description: "Average yearly vehicle crime in the area",
detail: "Average number of vehicle crime incidents per year in the LSOA, from police.uk street-level crime data (2023-2025). Includes theft of and from vehicles.",
source: "crime",
},
FeatureConfig {
name: "Robbery (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 1.0,
description: "Average yearly robbery offences in the area",
detail: "Average number of robbery offences per year in the LSOA, from police.uk street-level crime data (2023-2025). Robbery involves theft with force or threat of force.",
source: "crime",
},
FeatureConfig {
name: "Other theft (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 1.0,
description: "Average yearly other theft offences in the area",
detail: "Average number of 'other theft' offences per year in the LSOA, from police.uk street-level crime data (2023-2025). Includes theft not classified under burglary, vehicle crime, shoplifting, or bicycle theft.",
source: "crime",
},
FeatureConfig {
name: "Shoplifting (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 1.0,
description: "Average yearly shoplifting offences in the area",
detail: "Average number of shoplifting offences per year in the LSOA, from police.uk street-level crime data (2023-2025).",
source: "crime",
},
FeatureConfig {
name: "Drugs (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 1.0,
description: "Average yearly drug offences in the area",
detail: "Average number of drug offences per year in the LSOA, from police.uk street-level crime data (2023-2025). Includes possession and trafficking offences.",
source: "crime",
},
FeatureConfig {
name: "Possession of weapons (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 1.0,
description: "Average yearly weapons possession offences in the area",
detail: "Average number of possession of weapons offences per year in the LSOA, from police.uk street-level crime data (2023-2025).",
source: "crime",
},
FeatureConfig {
name: "Public order (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 1.0,
description: "Average yearly public order offences in the area",
detail: "Average number of public order offences per year in the LSOA, from police.uk street-level crime data (2023-2025). Includes causing fear, alarm, or distress.",
source: "crime",
},
FeatureConfig {
name: "Bicycle theft (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 1.0,
description: "Average yearly bicycle theft in the area",
detail: "Average number of bicycle theft offences per year in the LSOA, from police.uk street-level crime data (2023-2025).",
source: "crime",
},
FeatureConfig {
name: "Theft from the person (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 1.0,
description: "Average yearly theft from the person in the area",
detail: "Average number of theft from the person offences per year in the LSOA, from police.uk street-level crime data (2023-2025). Includes pickpocketing and bag snatching without force.",
source: "crime",
},
FeatureConfig {
name: "Other crime (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 1.0,
description: "Average yearly other crime in the area",
detail: "Average number of other crime offences per year in the LSOA, from police.uk street-level crime data (2023-2025). A catch-all category for offences not classified elsewhere.",
source: "crime",
},
FeatureConfig {
name: "Serious crime (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 1.0,
description: "Aggregate of serious crime categories per year",
detail: "Sum of violence, robbery, burglary, and weapons possession per year in the LSOA, from police.uk street-level crime data (2023-2025). Provides a single serious crime metric.",
source: "crime",
},
FeatureConfig {
name: "Minor crime (avg/yr)",
bounds: Bounds::Percentile {
low: 2.0,
high: 98.0,
},
step: 1.0,
description: "Aggregate of minor crime categories per year",
detail: "Sum of anti-social behaviour, shoplifting, bicycle theft, and other lower-severity crime per year in the LSOA, from police.uk street-level crime data (2023-2025). Provides a single minor crime metric.",
source: "crime",
},
],
},
FeatureGroup {
name: "Demographics",
features: &[
FeatureConfig {
name: "% White",
bounds: Bounds::Fixed {
min: 0.0,
max: 100.0,
},
step: 1.0,
description: "Percentage of population identifying as White",
detail: "From the 2021 Census. Percentage of the local authority population identifying as White (English, Welsh, Scottish, Northern Irish, British, Irish, Gypsy or Irish Traveller, Roma, or any other White background).",
source: "ethnicity",
},
FeatureConfig {
name: "% Asian",
bounds: Bounds::Fixed {
min: 0.0,
max: 100.0,
},
step: 1.0,
description: "Percentage of population identifying as Asian",
detail: "From the 2021 Census. Percentage of the local authority population identifying as Asian or Asian British (Indian, Pakistani, Bangladeshi, Chinese, or any other Asian background).",
source: "ethnicity",
},
FeatureConfig {
name: "% Black",
bounds: Bounds::Fixed {
min: 0.0,
max: 100.0,
},
step: 1.0,
description: "Percentage of population identifying as Black",
detail: "From the 2021 Census. Percentage of the local authority population identifying as Black, Black British, Caribbean, or African.",
source: "ethnicity",
},
FeatureConfig {
name: "% Mixed",
bounds: Bounds::Fixed {
min: 0.0,
max: 100.0,
},
step: 1.0,
description: "Percentage of population identifying as Mixed or Multiple ethnic groups",
detail: "From the 2021 Census. Percentage of the local authority population identifying as Mixed or Multiple ethnic groups (White and Black Caribbean, White and Black African, White and Asian, or any other Mixed or Multiple background).",
source: "ethnicity",
},
FeatureConfig {
name: "% Other",
bounds: Bounds::Fixed {
min: 0.0,
max: 100.0,
},
step: 1.0,
description: "Percentage of population identifying as Other ethnic group",
detail: "From the 2021 Census. Percentage of the local authority population identifying as Other ethnic group (Arab or any other ethnic group not covered by the main categories).",
source: "ethnicity",
},
],
},
FeatureGroup {
name: "Amenities",
features: &[
FeatureConfig {
name: "Restaurants within 2km",
bounds: Bounds::Percentile {
low: 5.0,
high: 95.0,
},
step: 1.0,
description: "Number of restaurants and cafes within 2km",
detail: "Count of restaurants, cafes, and food establishments within a 2km radius of the property's postcode centroid. Derived from OpenStreetMap POI data using haversine distance calculation with a 0.05° spatial grid for candidate reduction.",
source: "osm-pois",
},
FeatureConfig {
name: "Groceries within 2km",
bounds: Bounds::Percentile {
low: 5.0,
high: 95.0,
},
step: 1.0,
description: "Number of grocery shops and supermarkets within 2km",
detail: "Count of supermarkets, convenience stores, and other grocery shops within a 2km radius of the property's postcode centroid. Derived from OpenStreetMap POI data.",
source: "osm-pois",
},
FeatureConfig {
name: "Parks within 2km",
bounds: Bounds::Percentile {
low: 5.0,
high: 95.0,
},
step: 1.0,
description: "Number of parks and green spaces within 2km",
detail: "Count of parks, gardens, nature reserves, and other green spaces within a 2km radius of the property's postcode centroid. Derived from OpenStreetMap POI data.",
source: "osm-pois",
},
],
},
FeatureGroup {
name: "Environment",
features: &[
FeatureConfig {
name: "Noise (dB)",
bounds: Bounds::Fixed {
min: 50.0,
max: 80.0,
},
step: 1.0,
description: "Road noise level at the postcode in decibels (Lden)",
detail: "Road noise level in decibels (Lden — day-evening-night 24-hour weighted average) from Defra's Strategic Noise Mapping Round 4 (2022). Modelled at 4m above ground on a 10m grid. Sampled at postcode centroids via WCS GeoTIFF tiles. Values above ~55 dB are generally considered noticeable; above ~70 dB can affect health.",
source: "noise",
},
FeatureConfig {
name: "Max available download speed (Mbps)",
bounds: Bounds::Percentile {
low: 5.0,
high: 95.0,
},
step: 10.0,
description: "Maximum broadband download speed available at the postcode",
detail: "Maximum available fixed broadband download speed in Megabits per second, from Ofcom's Connected Nations 2025 report. Measured at Output Area level and represents the maximum speed available from any provider, not actual achieved speeds.",
source: "broadband",
},
],
},
];
pub static ENUM_FEATURE_GROUPS: &[EnumFeatureGroup] = &[EnumFeatureGroup {
name: "Property",
features: &[
EnumFeatureConfig {
name: "Leashold/Freehold",
order: Some(&["Freehold", "Leasehold"]),
description: "Whether the property is leasehold or freehold",
detail: "From HM Land Registry Price Paid data. Freehold means you own the building and the land it stands on. Leasehold means you own the building but not the land — you have a lease from the freeholder for a set number of years.",
source: "price-paid",
},
EnumFeatureConfig {
name: "Current energy rating",
order: Some(&["A", "B", "C", "D", "E", "F", "G"]),
description: "Current EPC energy efficiency rating (A-G)",
detail: "The current energy efficiency rating from the Energy Performance Certificate, graded A (most efficient) to G (least efficient). Based on the energy costs per square metre of floor area for heating, hot water, lighting, and ventilation.",
source: "epc",
},
EnumFeatureConfig {
name: "Potential energy rating",
order: Some(&["A", "B", "C", "D", "E", "F", "G"]),
description: "Achievable EPC rating after recommended improvements",
detail: "The potential energy efficiency rating that could be achieved if all cost-effective improvements recommended in the EPC were carried out. Graded A (most efficient) to G (least efficient).",
source: "epc",
},
EnumFeatureConfig {
name: "Property type",
order: Some(&["Detached", "Semi-Detached", "Terraced", "Flat"]),
description: "Type of property: detached, semi-detached, terraced, or flat",
detail: "From HM Land Registry Price Paid data. The broad property type classification: Detached, Semi-Detached, Terraced, or Flat/Maisonette.",
source: "price-paid",
},
EnumFeatureConfig {
name: "Property type/built form",
order: None,
description: "Detailed property type and built form from the EPC",
detail: "A more detailed classification from the Energy Performance Certificate combining property type and built form. Examples include 'Semi-Detached House', 'Mid-Terrace House', 'Ground-Floor Flat', 'Detached Bungalow', etc.",
source: "epc",
},
],
}];
/// Flat ordered list of all numeric feature names (follows group order).
pub fn all_numeric_feature_names() -> Vec<&'static str> {
FEATURE_GROUPS
.iter()
.flat_map(|group| group.features.iter().map(|feature| feature.name))
.collect()
}
/// Flat ordered list of all enum feature names (follows group order).
pub fn all_enum_feature_names() -> Vec<&'static str> {
ENUM_FEATURE_GROUPS
.iter()
.flat_map(|group| group.features.iter().map(|feature| feature.name))
.collect()
}
/// Look up the configured value order for an enum feature by name.
pub fn order_for(name: &str) -> Option<&'static [&'static str]> {
ENUM_FEATURE_GROUPS
.iter()
.flat_map(|group| group.features.iter())
.find(|feature| feature.name == name)
.and_then(|feature| feature.order)
}
/// Look up the Bounds config for a numeric feature by name.
pub fn bounds_for(name: &str) -> Option<&'static Bounds> {
FEATURE_GROUPS
.iter()
.flat_map(|group| group.features.iter())
.find(|feature| feature.name == name)
.map(|feature| &feature.bounds)
}

View file

@ -1,3 +1,4 @@
use crate::consts::ENUM_NULL;
use crate::data::EnumFeatureData; use crate::data::EnumFeatureData;
pub struct ParsedFilter { pub struct ParsedFilter {
@ -22,12 +23,12 @@ pub fn parse_filters(
let mut numeric = Vec::new(); let mut numeric = Vec::new();
let mut enums = Vec::new(); let mut enums = Vec::new();
let s = match filter_str.filter(|s| !s.is_empty()) { let input = match filter_str.filter(|text| !text.is_empty()) {
Some(s) => s, Some(text) => text,
None => return (numeric, enums), None => return (numeric, enums),
}; };
for entry in s.split(',') { for entry in input.split(',') {
let parts: Vec<&str> = entry.splitn(2, ':').collect(); let parts: Vec<&str> = entry.splitn(2, ':').collect();
if parts.len() != 2 { if parts.len() != 2 {
continue; continue;
@ -35,13 +36,13 @@ pub fn parse_filters(
let name = parts[0].trim(); let name = parts[0].trim();
let rest = parts[1].trim(); let rest = parts[1].trim();
if let Some(enum_idx) = enum_features.iter().position(|ef| ef.name == name) { if let Some(enum_idx) = enum_features.iter().position(|enum_feat| enum_feat.name == name) {
let ef = &enum_features[enum_idx]; let enum_feat = &enum_features[enum_idx];
let allowed: Vec<u8> = rest let allowed: Vec<u8> = rest
.split('|') .split('|')
.filter_map(|v| { .filter_map(|value| {
let v = v.trim(); let value = value.trim();
ef.values.iter().position(|ev| ev == v).map(|i| i as u8) enum_feat.values.iter().position(|existing| existing == value).map(|position| position as u8)
}) })
.collect(); .collect();
enums.push(ParsedEnumFilter { enum_idx, allowed }); enums.push(ParsedEnumFilter { enum_idx, allowed });
@ -51,14 +52,14 @@ pub fn parse_filters(
continue; continue;
} }
let min = match num_parts[0].trim().parse::<f64>() { let min = match num_parts[0].trim().parse::<f64>() {
Ok(v) => v, Ok(value) => value,
Err(_) => continue, Err(_) => continue,
}; };
let max = match num_parts[1].trim().parse::<f64>() { let max = match num_parts[1].trim().parse::<f64>() {
Ok(v) => v, Ok(value) => value,
Err(_) => continue, Err(_) => continue,
}; };
if let Some(feat_idx) = feature_names.iter().position(|n| n == name) { if let Some(feat_idx) = feature_names.iter().position(|feat_name| feat_name == name) {
numeric.push(ParsedFilter { feat_idx, min, max }); numeric.push(ParsedFilter { feat_idx, min, max });
} }
} }
@ -75,11 +76,11 @@ pub fn row_passes_filters(
num_features: usize, num_features: usize,
enum_features: &[EnumFeatureData], enum_features: &[EnumFeatureData],
) -> bool { ) -> bool {
filters.iter().all(|f| { filters.iter().all(|filter| {
let v = feature_data[row * num_features + f.feat_idx]; let value = feature_data[row * num_features + filter.feat_idx];
v.is_finite() && v >= f.min && v <= f.max value.is_finite() && value >= filter.min && value <= filter.max
}) && enum_filters.iter().all(|ef| { }) && enum_filters.iter().all(|enum_filter| {
let v = enum_features[ef.enum_idx].data[row]; let value = enum_features[enum_filter.enum_idx].data[row];
v != 255 && ef.allowed.contains(&v) value != ENUM_NULL && enum_filter.allowed.contains(&value)
}) })
} }

View file

@ -19,18 +19,18 @@ impl GridIndex {
let mut min_lon = f64::INFINITY; let mut min_lon = f64::INFINITY;
let mut max_lon = f64::NEG_INFINITY; let mut max_lon = f64::NEG_INFINITY;
for i in 0..lat.len() { for index in 0..lat.len() {
if lat[i] < min_lat { if lat[index] < min_lat {
min_lat = lat[i]; min_lat = lat[index];
} }
if lat[i] > max_lat { if lat[index] > max_lat {
max_lat = lat[i]; max_lat = lat[index];
} }
if lon[i] < min_lon { if lon[index] < min_lon {
min_lon = lon[i]; min_lon = lon[index];
} }
if lon[i] > max_lon { if lon[index] > max_lon {
max_lon = lon[i]; max_lon = lon[index];
} }
} }
@ -52,11 +52,11 @@ impl GridIndex {
let mut cells: Vec<Vec<u32>> = vec![Vec::new(); rows * cols]; let mut cells: Vec<Vec<u32>> = vec![Vec::new(); rows * cols];
for i in 0..lat.len() { for index in 0..lat.len() {
let grid_row = ((lat[i] - min_lat) / cell_size) as usize; let grid_row = ((lat[index] - min_lat) / cell_size) as usize;
let grid_col = ((lon[i] - min_lon) / cell_size) as usize; let grid_col = ((lon[index] - min_lon) / cell_size) as usize;
let idx = grid_row * cols + grid_col; let cell_index = grid_row * cols + grid_col;
cells[idx].push(i as u32); cells[cell_index].push(index as u32);
} }
tracing::debug!("Grid index built"); tracing::debug!("Grid index built");
@ -96,7 +96,7 @@ impl GridIndex {
west: f64, west: f64,
north: f64, north: f64,
east: f64, east: f64,
mut f: impl FnMut(u32), mut callback: impl FnMut(u32),
) { ) {
let Some((row_min, row_max, col_min, col_max)) = let Some((row_min, row_max, col_min, col_max)) =
self.clamp_bounds(south, west, north, east) self.clamp_bounds(south, west, north, east)
@ -108,7 +108,7 @@ impl GridIndex {
let row_start = row * self.cols; let row_start = row * self.cols;
for col in col_min..=col_max { for col in col_min..=col_max {
for &row_idx in &self.cells[row_start + col] { for &row_idx in &self.cells[row_start + col] {
f(row_idx); callback(row_idx);
} }
} }
} }

View file

@ -1,15 +1,20 @@
mod consts; mod consts;
mod data; mod data;
mod features;
mod filter; mod filter;
mod index; mod grid_index;
mod routes; mod routes;
mod state; mod state;
#[cfg(test)]
mod tests;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use anyhow::{bail, Context};
use axum::routing::get; use axum::routing::get;
use axum::Router; use axum::Router;
use clap::Parser;
use tower_http::compression::CompressionLayer; use tower_http::compression::CompressionLayer;
use tower_http::cors::{Any, CorsLayer}; use tower_http::cors::{Any, CorsLayer};
use tower_http::services::ServeDir; use tower_http::services::ServeDir;
@ -19,8 +24,24 @@ use tracing_subscriber::EnvFilter;
use state::AppState; use state::AppState;
#[derive(Parser)]
#[command(name = "narrowit", about = "Narrowit property map server")]
struct Cli {
/// Path to the wide property parquet file
#[arg(long)]
data: PathBuf,
/// Path to the POI parquet file
#[arg(long)]
pois: PathBuf,
/// Path to the frontend dist directory
#[arg(long)]
dist: Option<PathBuf>,
}
#[tokio::main] #[tokio::main]
async fn main() { async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter( .with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
@ -28,18 +49,18 @@ async fn main() {
.with_ansi(true) .with_ansi(true)
.init(); .init();
let parquet_path = PathBuf::from( let cli = Cli::parse();
std::env::args()
.nth(1) let parquet_path = &cli.data;
.unwrap_or_else(|| "data_sources/processed/wide.parquet".to_string()),
);
if !parquet_path.exists() { if !parquet_path.exists() {
tracing::error!("Parquet file not found: {}", parquet_path.display()); bail!(
std::process::exit(1); "Property parquet file not found: {}",
parquet_path.display()
);
} }
info!("Loading property data from {}", parquet_path.display()); info!("Loading property data from {}", parquet_path.display());
let property_data = data::PropertyData::load(&parquet_path); let property_data = data::PropertyData::load(parquet_path)?;
info!( info!(
rows = property_data.lat.len(), rows = property_data.lat.len(),
features = property_data.num_features, features = property_data.num_features,
@ -48,32 +69,90 @@ async fn main() {
); );
info!("Building spatial grid index (0.01° cells)"); info!("Building spatial grid index (0.01° cells)");
let grid = index::GridIndex::build(&property_data.lat, &property_data.lon, 0.01); let grid = grid_index::GridIndex::build(&property_data.lat, &property_data.lon, 0.01);
info!("Precomputing H3 cells for resolutions {}-{}", consts::H3_PRECOMPUTE_MIN, consts::H3_PRECOMPUTE_MAX); info!(
let h3_cells = data::precompute_h3(&property_data.lat, &property_data.lon); "Precomputing H3 cells for resolutions {}-{}",
consts::H3_PRECOMPUTE_MIN,
consts::H3_PRECOMPUTE_MAX
);
let h3_cells = data::precompute_h3(&property_data.lat, &property_data.lon)?;
let poi_path = PathBuf::from("/volumes/syncthing/Projects/property-map/data/filtered_uk_pois.parquet"); let poi_path = cli.pois;
let poi_data = if poi_path.exists() { if !poi_path.exists() {
info!("Loading POI data from {}", poi_path.display()); bail!("POI parquet file not found: {}", poi_path.display());
let pd = data::POIData::load(&poi_path); }
info!(pois = pd.lat.len(), "POI data loaded");
pd info!("Loading POI data from {}", poi_path.display());
} else { let poi_data = data::POIData::load(&poi_path)?;
tracing::warn!("POI file not found: {}. POI endpoints will be unavailable.", poi_path.display()); info!(pois = poi_data.lat.len(), "POI data loaded");
data::POIData {
id: Vec::new(),
name: Vec::new(),
category: Vec::new(),
lat: Vec::new(),
lng: Vec::new(),
emoji: Vec::new(),
}
};
info!("Building POI spatial grid index"); info!("Building POI spatial grid index");
let poi_grid = index::GridIndex::build(&poi_data.lat, &poi_data.lng, 0.01); let poi_grid = grid_index::GridIndex::build(&poi_data.lat, &poi_data.lng, 0.01);
let min_keys: Vec<String> = property_data
.feature_names
.iter()
.map(|name| format!("min_{}", name))
.collect();
let max_keys: Vec<String> = property_data
.feature_names
.iter()
.map(|name| format!("max_{}", name))
.collect();
let enum_min_keys: Vec<String> = property_data
.enum_features
.iter()
.map(|enum_feature| format!("min_{}", enum_feature.name))
.collect();
let enum_max_keys: Vec<String> = property_data
.enum_features
.iter()
.map(|enum_feature| format!("max_{}", enum_feature.name))
.collect();
// Precompute POI category groups
let poi_category_groups = {
let mut group_cats: std::collections::HashMap<String, std::collections::HashSet<String>> =
std::collections::HashMap::new();
for (category, group) in poi_data.category.iter().zip(poi_data.group.iter()) {
group_cats
.entry(group.clone())
.or_default()
.insert(category.clone());
}
// Validate that data groups match the hardcoded order exactly
let expected: std::collections::HashSet<&str> =
consts::POI_GROUP_ORDER.iter().copied().collect();
let actual: std::collections::HashSet<&str> =
group_cats.keys().map(|key| key.as_str()).collect();
let missing_from_data: Vec<&&str> = expected.difference(&actual).collect();
let missing_from_order: Vec<&&str> = actual.difference(&expected).collect();
if !missing_from_data.is_empty() || !missing_from_order.is_empty() {
bail!(
"POI group mismatch!\n In POI_GROUP_ORDER but not in data: {:?}\n In data but not in POI_GROUP_ORDER: {:?}",
missing_from_data, missing_from_order
);
}
consts::POI_GROUP_ORDER.iter().map(|group_name| group_name.to_string()).collect::<Vec<_>>()
.into_iter()
.map(|name| {
let mut categories: Vec<String> =
group_cats.remove(&name).context("POI group validated but missing from map")?.into_iter().collect();
categories.sort();
Ok(state::POICategoryGroup { name, categories })
})
.collect::<anyhow::Result<Vec<_>>>()?
};
// Precompute enum name → index map
let enum_name_to_idx: rustc_hash::FxHashMap<String, usize> = property_data
.enum_features
.iter()
.enumerate()
.map(|(index, enum_feature)| (enum_feature.name.clone(), index))
.collect();
let state = Arc::new(AppState { let state = Arc::new(AppState {
data: property_data, data: property_data,
@ -81,6 +160,12 @@ async fn main() {
h3_cells, h3_cells,
poi_data, poi_data,
poi_grid, poi_grid,
min_keys,
max_keys,
enum_min_keys,
enum_max_keys,
poi_category_groups,
enum_name_to_idx,
}); });
let cors = CorsLayer::new() let cors = CorsLayer::new()
@ -93,6 +178,7 @@ async fn main() {
let state_pois = state.clone(); let state_pois = state.clone();
let state_poi_categories = state.clone(); let state_poi_categories = state.clone();
let state_hexagon_properties = state.clone(); let state_hexagon_properties = state.clone();
let state_hexagon_stats = state.clone();
let api = Router::new() let api = Router::new()
.route( .route(
@ -116,9 +202,23 @@ async fn main() {
get(move |query| { get(move |query| {
routes::get_hexagon_properties(state_hexagon_properties.clone(), query) routes::get_hexagon_properties(state_hexagon_properties.clone(), query)
}), }),
)
.route(
"/api/hexagon-stats",
get(move |query| routes::get_hexagon_stats(state_hexagon_stats.clone(), query)),
); );
let frontend_dist = PathBuf::from("frontend/dist"); let frontend_dist = cli.dist.unwrap_or_else(|| {
// Check next to the binary first, then fall back to working directory
if let Ok(executable) = std::env::current_exe() {
let executable_dir = executable.parent().unwrap_or_else(|| std::path::Path::new("."));
let dist_next_to_binary = executable_dir.join("dist");
if dist_next_to_binary.exists() {
return dist_next_to_binary;
}
}
PathBuf::from("frontend/dist")
});
let app = if frontend_dist.exists() { let app = if frontend_dist.exists() {
api.fallback_service(ServeDir::new(frontend_dist)) api.fallback_service(ServeDir::new(frontend_dist))
} else { } else {
@ -127,12 +227,16 @@ async fn main() {
let app = app let app = app
.layer(cors) .layer(cors)
.layer(CompressionLayer::new().gzip(true)) .layer(CompressionLayer::new().zstd(true).gzip(true))
.layer(TraceLayer::new_for_http()); .layer(TraceLayer::new_for_http());
let addr = "0.0.0.0:8001"; let addr = consts::SERVER_ADDRESS;
let listener = tokio::net::TcpListener::bind(addr)
.await
.with_context(|| format!("Failed to bind to {addr}"))?;
info!("Server listening on {}", addr); info!("Server listening on {}", addr);
axum::serve(listener, app)
let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); .await
axum::serve(listener, app).await.unwrap(); .context("Server error")?;
Ok(())
} }

View file

@ -5,6 +5,7 @@ use serde::Serialize;
use tracing::info; use tracing::info;
use crate::data::Histogram; use crate::data::Histogram;
use crate::features::{ENUM_FEATURE_GROUPS, FEATURE_GROUPS};
use crate::state::AppState; use crate::state::AppState;
#[derive(Serialize)] #[derive(Serialize)]
@ -13,75 +14,123 @@ pub enum FeatureInfo {
#[serde(rename = "numeric")] #[serde(rename = "numeric")]
Numeric { Numeric {
name: String, name: String,
label: String,
min: f64, min: f64,
max: f64, max: f64,
step: f64,
histogram: Histogram, histogram: Histogram,
description: &'static str,
detail: &'static str,
source: &'static str,
}, },
#[serde(rename = "enum")] #[serde(rename = "enum")]
Enum { Enum {
name: String, name: String,
label: String,
values: Vec<String>, values: Vec<String>,
description: &'static str,
detail: &'static str,
source: &'static str,
}, },
} }
#[derive(Serialize)] #[derive(Serialize)]
pub struct FeaturesResponse { pub struct FeatureGroupResponse {
name: String,
features: Vec<FeatureInfo>, features: Vec<FeatureInfo>,
} }
fn snake_to_label(name: &str) -> String { #[derive(Serialize)]
// If name contains '/' or uppercase, assume it's already human-readable pub struct FeaturesResponse {
if name.contains('/') || name.chars().any(|c| c.is_uppercase()) { groups: Vec<FeatureGroupResponse>,
return name.to_string();
}
name.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(c) => {
let mut s = c.to_uppercase().to_string();
s.extend(chars);
s
}
}
})
.collect::<Vec<_>>()
.join(" ")
} }
pub async fn get_features(state: Arc<AppState>) -> Json<FeaturesResponse> { pub async fn get_features(state: Arc<AppState>) -> Json<FeaturesResponse> {
let mut features: Vec<FeatureInfo> = state // Collect all group names in order, merging numeric and enum groups with the same name
.data let mut group_names: Vec<&str> = Vec::new();
.feature_names for feature_group in FEATURE_GROUPS {
.iter() if !group_names.contains(&feature_group.name) {
.enumerate() group_names.push(feature_group.name);
.map(|(i, name): (usize, &String)| { }
let stats = &state.data.feature_stats[i]; }
FeatureInfo::Numeric { for enum_group in ENUM_FEATURE_GROUPS {
name: name.clone(), if !group_names.contains(&enum_group.name) {
label: snake_to_label(name), group_names.push(enum_group.name);
min: stats.p_low, }
max: stats.p_high,
histogram: stats.histogram.clone(),
}
})
.collect();
for ef in &state.data.enum_features {
features.push(FeatureInfo::Enum {
name: ef.name.clone(),
label: snake_to_label(&ef.name),
values: ef.values.clone(),
});
} }
let mut groups: Vec<FeatureGroupResponse> = Vec::new();
for &group_name in &group_names {
let mut features: Vec<FeatureInfo> = Vec::new();
// Add numeric features for this group
for feature_group in FEATURE_GROUPS {
if feature_group.name == group_name {
for feature_config in feature_group.features {
if let Some(feat_idx) =
state.data.feature_names.iter().position(|feat_name| feat_name == feature_config.name)
{
let stats = &state.data.feature_stats[feat_idx];
features.push(FeatureInfo::Numeric {
name: feature_config.name.to_string(),
min: stats.slider_min,
max: stats.slider_max,
step: feature_config.step,
histogram: stats.histogram.clone(),
description: feature_config.description,
detail: feature_config.detail,
source: feature_config.source,
});
}
}
}
}
// Add enum features for this group
for enum_group in ENUM_FEATURE_GROUPS {
if enum_group.name == group_name {
for enum_config in enum_group.features {
if let Some(enum_feature) = state
.data
.enum_features
.iter()
.find(|enum_feat| enum_feat.name == enum_config.name)
{
features.push(FeatureInfo::Enum {
name: enum_config.name.to_string(),
values: enum_feature.values.clone(),
description: enum_config.description,
detail: enum_config.detail,
source: enum_config.source,
});
}
}
}
}
if !features.is_empty() {
groups.push(FeatureGroupResponse {
name: group_name.to_string(),
features,
});
}
}
let num_numeric: usize = groups
.iter()
.flat_map(|group| &group.features)
.filter(|feature| matches!(feature, FeatureInfo::Numeric { .. }))
.count();
let num_enum: usize = groups
.iter()
.flat_map(|group| &group.features)
.filter(|feature| matches!(feature, FeatureInfo::Enum { .. }))
.count();
info!( info!(
numeric = features.iter().filter(|f| matches!(f, FeatureInfo::Numeric { .. })).count(), numeric = num_numeric,
enums = features.iter().filter(|f| matches!(f, FeatureInfo::Enum { .. })).count(), enums = num_enum,
groups = groups.len(),
"GET /api/features" "GET /api/features"
); );
Json(FeaturesResponse { features }) Json(FeaturesResponse { groups })
} }

View file

@ -0,0 +1,251 @@
use std::fmt::Write;
use std::str::FromStr;
use std::sync::Arc;
use axum::extract::Query;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde::Deserialize;
use tracing::{info, warn};
use crate::consts::{ENUM_NULL, HISTOGRAM_BINS};
use crate::filter::{parse_filters, row_passes_filters};
use crate::state::AppState;
use super::parse::h3_cell_bounds;
#[derive(Deserialize)]
pub struct HexagonStatsParams {
pub h3: String,
pub resolution: u8,
pub filters: Option<String>,
}
pub async fn get_hexagon_stats(
state: Arc<AppState>,
Query(params): Query<HexagonStatsParams>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let cell = h3o::CellIndex::from_str(&params.h3).map_err(|error| {
warn!(h3 = %params.h3, error = %error, "Invalid H3 cell index");
(StatusCode::BAD_REQUEST, format!("Invalid H3 cell: {}", error))
})?;
let cell_u64: u64 = cell.into();
let resolution = params.resolution as usize;
if resolution >= state.h3_cells.len() || state.h3_cells[resolution].is_empty() {
warn!(
resolution,
"Invalid or non-precomputed resolution for hexagon-stats"
);
return Err((
StatusCode::BAD_REQUEST,
"Invalid or non-precomputed resolution".to_string(),
));
}
let h3_str = params.h3.clone();
let filters_str = params.filters.clone();
let (parsed_filters, parsed_enum_filters) = parse_filters(
params.filters.as_deref(),
&state.data.feature_names,
&state.data.enum_features,
);
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
let result = tokio::task::spawn_blocking(move || {
let start_time = std::time::Instant::now();
let h3_data = &state.h3_cells[resolution];
let num_features = state.data.num_features;
let feature_data = &state.data.feature_data;
let enum_features = &state.data.enum_features;
let (min_lat, min_lon, max_lat, max_lon) = h3_cell_bounds(cell, 0.001);
// Collect matching rows
let mut matching_rows: Vec<usize> = Vec::new();
state
.grid
.for_each_in_bounds(min_lat, min_lon, max_lat, max_lon, |row_idx| {
let row = row_idx as usize;
if h3_data[row] == cell_u64
&& row_passes_filters(
row,
&parsed_filters,
&parsed_enum_filters,
feature_data,
num_features,
enum_features,
)
{
matching_rows.push(row);
}
});
let total_count = matching_rows.len();
// Build JSON directly via string buffer
let mut output = String::with_capacity(4096);
output.push_str("{\"count\":");
write!(output, "{}", total_count).unwrap();
// Numeric features: compute count, min, max, sum, histogram using global bin edges
output.push_str(",\"numeric_features\":[");
let mut first_numeric = true;
for (feature_index, feature_name) in state.data.feature_names.iter().enumerate() {
let global_stats = &state.data.feature_stats[feature_index];
let histogram_min = global_stats.histogram.min;
let histogram_max = global_stats.histogram.max;
let bin_width = global_stats.histogram.bin_width;
let mut count = 0usize;
let mut min_value = f64::INFINITY;
let mut max_value = f64::NEG_INFINITY;
let mut sum = 0.0f64;
let mut bins = vec![0u64; HISTOGRAM_BINS];
for &row in &matching_rows {
let value = feature_data[row * num_features + feature_index];
if value.is_finite() {
count += 1;
if value < min_value {
min_value = value;
}
if value > max_value {
max_value = value;
}
sum += value;
// Bin into histogram using global edges
if bin_width > 0.0 {
let bin_index =
((value - histogram_min) / bin_width).floor() as isize;
let clamped_index = bin_index.max(0).min((HISTOGRAM_BINS - 1) as isize) as usize;
bins[clamped_index] += 1;
}
}
}
if count == 0 {
continue;
}
if !first_numeric {
output.push(',');
}
first_numeric = false;
let mean = sum / count as f64;
output.push_str("{\"name\":");
write_json_string(&mut output, feature_name);
write!(output, ",\"count\":{}", count).unwrap();
write!(output, ",\"min\":{}", format_f64(min_value)).unwrap();
write!(output, ",\"max\":{}", format_f64(max_value)).unwrap();
write!(output, ",\"mean\":{}", format_f64(mean)).unwrap();
output.push_str(",\"histogram\":{\"min\":");
write!(output, "{}", format_f64(histogram_min)).unwrap();
output.push_str(",\"max\":");
write!(output, "{}", format_f64(histogram_max)).unwrap();
output.push_str(",\"bin_width\":");
write!(output, "{}", format_f64(bin_width)).unwrap();
output.push_str(",\"counts\":[");
for (bin_index, &bin_count) in bins.iter().enumerate() {
if bin_index > 0 {
output.push(',');
}
write!(output, "{}", bin_count).unwrap();
}
output.push_str("]}}")
}
// Enum features: count per value
output.push_str("],\"enum_features\":[");
let mut first_enum = true;
for enum_feature in enum_features {
let enum_index = match state.enum_name_to_idx.get(&enum_feature.name) {
Some(&index) => index,
None => continue,
};
let enum_data = &state.data.enum_features[enum_index];
let mut value_counts = vec![0u64; enum_data.values.len()];
for &row in &matching_rows {
let value = enum_data.data[row];
if value != ENUM_NULL && (value as usize) < value_counts.len() {
value_counts[value as usize] += 1;
}
}
// Only include if there are any non-zero counts
let has_values = value_counts.iter().any(|&count| count > 0);
if !has_values {
continue;
}
if !first_enum {
output.push(',');
}
first_enum = false;
output.push_str("{\"name\":");
write_json_string(&mut output, &enum_feature.name);
output.push_str(",\"counts\":{");
let mut first_value = true;
for (value_index, &count) in value_counts.iter().enumerate() {
if count == 0 {
continue;
}
if !first_value {
output.push(',');
}
first_value = false;
write_json_string(&mut output, &enum_data.values[value_index]);
write!(output, ":{}", count).unwrap();
}
output.push_str("}}");
}
output.push_str("]}");
let elapsed = start_time.elapsed();
info!(
h3 = %h3_str,
resolution,
total_count,
filters = num_filters,
filters_raw = filters_str.as_deref().unwrap_or("-"),
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
"GET /api/hexagon-stats"
);
output
})
.await
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
Ok((
[(axum::http::header::CONTENT_TYPE, "application/json")],
result,
))
}
fn write_json_string(output: &mut String, value: &str) {
output.push('"');
for character in value.chars() {
match character {
'"' => output.push_str("\\\""),
'\\' => output.push_str("\\\\"),
'\n' => output.push_str("\\n"),
'\r' => output.push_str("\\r"),
'\t' => output.push_str("\\t"),
other => output.push(other),
}
}
output.push('"');
}
fn format_f64(value: f64) -> String {
if value.fract() == 0.0 && value.abs() < 1e15 {
format!("{:.1}", value)
} else {
format!("{}", value)
}
}

View file

@ -1,4 +1,4 @@
use std::fmt::Write; use std::fmt::{self, Write};
use std::sync::Arc; use std::sync::Arc;
use axum::extract::Query; use axum::extract::Query;
@ -8,11 +8,29 @@ use rustc_hash::FxHashMap;
use serde::Deserialize; use serde::Deserialize;
use tracing::{info, warn}; use tracing::{info, warn};
use crate::consts::{H3_PRECOMPUTE_MAX, H3_PRECOMPUTE_MIN}; use crate::consts::{
BOUNDS_BUFFER_PERCENT, BOUNDS_QUANTIZATION, ENUM_NULL, H3_PRECOMPUTE_MAX, H3_PRECOMPUTE_MIN,
POSTCODE_MIN_RESOLUTION,
};
use crate::filter::parse_filters; use crate::filter::parse_filters;
use crate::state::AppState; use crate::state::AppState;
const BOUNDS_BUFFER_PERCENT: f64 = 0.2; use super::parse::parse_bounds;
struct HumanBytes(usize);
impl fmt::Display for HumanBytes {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let bytes = self.0;
if bytes >= 1_000_000 {
write!(formatter, "{:.1} MB", bytes as f64 / 1_000_000.0)
} else if bytes >= 1_000 {
write!(formatter, "{:.1} KB", bytes as f64 / 1_000.0)
} else {
write!(formatter, "{} B", bytes)
}
}
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct HexagonParams { pub struct HexagonParams {
@ -28,14 +46,28 @@ struct CellAgg {
count: u32, count: u32,
mins: Vec<f64>, mins: Vec<f64>,
maxs: Vec<f64>, maxs: Vec<f64>,
/// Min/max ordinal indices for enum features (255 = no data yet)
enum_mins: Vec<u8>,
enum_maxs: Vec<u8>,
/// Most common postcode in this cell (only tracked at high resolutions)
postcode: Option<String>,
postcode_count: u32,
lat_sum: f64,
lon_sum: f64,
} }
impl CellAgg { impl CellAgg {
fn new(num_features: usize) -> Self { fn new(num_features: usize, num_enums: usize) -> Self {
CellAgg { CellAgg {
count: 0, count: 0,
mins: vec![f64::INFINITY; num_features], mins: vec![f64::INFINITY; num_features],
maxs: vec![f64::NEG_INFINITY; num_features], maxs: vec![f64::NEG_INFINITY; num_features],
enum_mins: vec![ENUM_NULL; num_enums],
enum_maxs: vec![0; num_enums],
postcode: None,
postcode_count: 0,
lat_sum: 0.0,
lon_sum: 0.0,
} }
} }
@ -47,49 +79,129 @@ impl CellAgg {
self.count += 1; self.count += 1;
let base = row * num_features; let base = row * num_features;
let row_slice = &feature_data[base..base + num_features]; let row_slice = &feature_data[base..base + num_features];
for (i, &v) in row_slice.iter().enumerate() { for (feat_index, &value) in row_slice.iter().enumerate() {
if v.is_finite() { if value.is_finite() {
if v < self.mins[i] { if value < self.mins[feat_index] {
self.mins[i] = v; self.mins[feat_index] = value;
} }
if v > self.maxs[i] { if value > self.maxs[feat_index] {
self.maxs[i] = v; self.maxs[feat_index] = value;
} }
} }
} }
} }
/// Track min/max ordinal index for each enum feature in this cell.
#[inline]
fn add_enums(&mut self, enum_features: &[crate::data::EnumFeatureData], row: usize) {
for (enum_index, enum_feature) in enum_features.iter().enumerate() {
let value = enum_feature.data[row];
if value != ENUM_NULL {
if self.enum_mins[enum_index] == ENUM_NULL || value < self.enum_mins[enum_index] {
self.enum_mins[enum_index] = value;
}
if value > self.enum_maxs[enum_index] {
self.enum_maxs[enum_index] = value;
}
}
}
}
/// Track postcode and centroid for high-resolution cells.
/// Uses simple "first seen" approach — at res 11/12, most rows in a cell share a postcode.
#[inline]
fn add_postcode(&mut self, postcode: &str, lat: f64, lon: f64) {
self.lat_sum += lat;
self.lon_sum += lon;
if postcode.is_empty() {
return;
}
if self.postcode.is_none() {
self.postcode = Some(postcode.to_string());
self.postcode_count = 1;
} else if self.postcode.as_deref() == Some(postcode) {
self.postcode_count += 1;
}
}
}
/// Escape a string for inclusion in a JSON string literal.
pub(crate) fn write_json_escaped(buf: &mut String, text: &str) {
for character in text.chars() {
match character {
'"' => buf.push_str("\\\""),
'\\' => buf.push_str("\\\\"),
'\n' => buf.push_str("\\n"),
'\r' => buf.push_str("\\r"),
'\t' => buf.push_str("\\t"),
ctrl if ctrl < '\x20' => { let _ = write!(buf, "\\u{:04x}", ctrl as u32); }
other => buf.push(other),
}
}
} }
/// Write the hexagons JSON response directly to a String buffer, /// Write the hexagons JSON response directly to a String buffer,
/// avoiding serde_json::Value allocations entirely. /// avoiding serde_json::Value allocations entirely.
#[allow(clippy::too_many_arguments)]
fn write_hexagons_json( fn write_hexagons_json(
buf: &mut String, buf: &mut String,
groups: &FxHashMap<u64, CellAgg>, groups: &FxHashMap<u64, CellAgg>,
min_keys: &[String], min_keys: &[String],
max_keys: &[String], max_keys: &[String],
num_features: usize, num_features: usize,
enum_min_keys: &[String],
enum_max_keys: &[String],
num_enums: usize,
include_postcode: bool,
) { ) {
buf.push_str("{\"features\":["); buf.push_str("{\"features\":[");
let mut first = true; let mut first = true;
for (&cell_id, agg) in groups { for (&cell_id, aggregation) in groups {
let Some(cell) = h3o::CellIndex::try_from(cell_id).ok() else {
continue;
};
if !first { if !first {
buf.push(','); buf.push(',');
} }
first = false; first = false;
let cell = h3o::CellIndex::try_from(cell_id).unwrap(); let _ = write!(buf, "{{\"h3\":\"{}\",\"count\":{}", cell, aggregation.count);
write!(buf, "{{\"h3\":\"{}\",\"count\":{}", cell, agg.count).unwrap();
for i in 0..num_features { for feat_index in 0..num_features {
if agg.mins[i] != f64::INFINITY { if aggregation.mins[feat_index].is_finite() && aggregation.maxs[feat_index].is_finite() {
write!( let _ = write!(
buf, buf,
",\"{}\":{},\"{}\":{}", ",\"{}\":{},\"{}\":{}",
min_keys[i], agg.mins[i], max_keys[i], agg.maxs[i] min_keys[feat_index], aggregation.mins[feat_index], max_keys[feat_index], aggregation.maxs[feat_index]
) );
.unwrap();
} }
} }
for enum_index in 0..num_enums {
if aggregation.enum_mins[enum_index] != ENUM_NULL {
let _ = write!(
buf,
",\"{}\":{},\"{}\":{}",
enum_min_keys[enum_index], aggregation.enum_mins[enum_index],
enum_max_keys[enum_index], aggregation.enum_maxs[enum_index]
);
}
}
if include_postcode {
if let Some(ref postcode) = aggregation.postcode {
let total = aggregation.count as f64;
let centroid_lat = aggregation.lat_sum / total;
let centroid_lon = aggregation.lon_sum / total;
if centroid_lat.is_finite() && centroid_lon.is_finite() {
buf.push_str(",\"postcode\":\"");
write_json_escaped(buf, postcode);
let _ = write!(buf, "\",\"lat\":{},\"lon\":{}", centroid_lat, centroid_lon);
}
}
}
buf.push('}'); buf.push('}');
} }
buf.push_str("]}"); buf.push_str("]}");
@ -101,7 +213,10 @@ pub async fn get_hexagons(
) -> Result<impl IntoResponse, (StatusCode, String)> { ) -> Result<impl IntoResponse, (StatusCode, String)> {
let resolution = params.resolution; let resolution = params.resolution;
if resolution < H3_PRECOMPUTE_MIN || resolution > H3_PRECOMPUTE_MAX { if resolution < H3_PRECOMPUTE_MIN || resolution > H3_PRECOMPUTE_MAX {
warn!(resolution, "Resolution out of range [{}, {}]", H3_PRECOMPUTE_MIN, H3_PRECOMPUTE_MAX); warn!(
resolution,
"Resolution out of range [{}, {}]", H3_PRECOMPUTE_MIN, H3_PRECOMPUTE_MAX
);
return Err(( return Err((
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
format!( format!(
@ -116,25 +231,7 @@ pub async fn get_hexagons(
"bounds parameter is required".into(), "bounds parameter is required".into(),
))?; ))?;
let parts: Vec<f64> = bounds_str let (mut south, mut west, mut north, mut east) = parse_bounds(&bounds_str)?;
.split(',')
.map(|s| s.trim().parse::<f64>())
.collect::<Result<Vec<_>, _>>()
.map_err(|_| {
(
StatusCode::BAD_REQUEST,
"Invalid bounds format. Use: south,west,north,east".into(),
)
})?;
if parts.len() != 4 {
return Err((
StatusCode::BAD_REQUEST,
"Invalid bounds format. Use: south,west,north,east".into(),
));
}
let (mut south, mut west, mut north, mut east) = (parts[0], parts[1], parts[2], parts[3]);
let lat_range = north - south; let lat_range = north - south;
let lng_range = east - west; let lng_range = east - west;
@ -143,11 +240,10 @@ pub async fn get_hexagons(
west -= lng_range * BOUNDS_BUFFER_PERCENT; west -= lng_range * BOUNDS_BUFFER_PERCENT;
east += lng_range * BOUNDS_BUFFER_PERCENT; east += lng_range * BOUNDS_BUFFER_PERCENT;
let precision = 0.01; south = (south / BOUNDS_QUANTIZATION).floor() * BOUNDS_QUANTIZATION;
south = (south / precision).floor() * precision; west = (west / BOUNDS_QUANTIZATION).floor() * BOUNDS_QUANTIZATION;
west = (west / precision).floor() * precision; north = (north / BOUNDS_QUANTIZATION).ceil() * BOUNDS_QUANTIZATION;
north = (north / precision).ceil() * precision; east = (east / BOUNDS_QUANTIZATION).ceil() * BOUNDS_QUANTIZATION;
east = (east / precision).ceil() * precision;
let filters_str = params.filters.clone(); let filters_str = params.filters.clone();
let (parsed_filters, parsed_enum_filters) = parse_filters( let (parsed_filters, parsed_enum_filters) = parse_filters(
@ -157,44 +253,38 @@ pub async fn get_hexagons(
); );
let num_filters = parsed_filters.len() + parsed_enum_filters.len(); let num_filters = parsed_filters.len() + parsed_enum_filters.len();
let json_body = tokio::task::spawn_blocking(move || { let json_body = tokio::task::spawn_blocking(move || -> Result<String, String> {
let t0 = std::time::Instant::now(); let t0 = std::time::Instant::now();
let num_features = state.data.num_features; let num_features = state.data.num_features;
let num_enums = state.data.enum_features.len();
let feature_data = &state.data.feature_data; let feature_data = &state.data.feature_data;
let min_keys: Vec<String> = state let min_keys = &state.min_keys;
.data let max_keys = &state.max_keys;
.feature_names let enum_min_keys = &state.enum_min_keys;
.iter() let enum_max_keys = &state.enum_max_keys;
.map(|n| format!("min_{}", n))
.collect();
let max_keys: Vec<String> = state
.data
.feature_names
.iter()
.map(|n| format!("max_{}", n))
.collect();
let h3_cells_for_res: Option<&[u64]> = state let h3_cells_for_res: Option<&[u64]> = state
.h3_cells .h3_cells
.get(resolution as usize) .get(resolution as usize)
.filter(|v| !v.is_empty()) .filter(|cells| !cells.is_empty())
.map(|v| v.as_slice()); .map(|cells| cells.as_slice());
let mut groups: FxHashMap<u64, CellAgg> = FxHashMap::default(); let mut groups: FxHashMap<u64, CellAgg> = FxHashMap::default();
let enum_features = &state.data.enum_features; let enum_features = &state.data.enum_features;
let include_postcode = resolution >= POSTCODE_MIN_RESOLUTION;
// Row-level filter check: numeric must be non-NaN and within [min, max], // Row-level filter check: numeric must be non-NaN and within [min, max],
// enum must have value index in the allowed set // enum must have value index in the allowed set
let row_passes = |row: usize| -> bool { let row_passes = |row: usize| -> bool {
parsed_filters.iter().all(|f| { parsed_filters.iter().all(|filter| {
let v = feature_data[row * num_features + f.feat_idx]; let value = feature_data[row * num_features + filter.feat_idx];
v.is_finite() && v >= f.min && v <= f.max value.is_finite() && value >= filter.min && value <= filter.max
}) && parsed_enum_filters.iter().all(|ef| { }) && parsed_enum_filters.iter().all(|enum_filter| {
let v = enum_features[ef.enum_idx].data[row]; let value = enum_features[enum_filter.enum_idx].data[row];
v != 255 && ef.allowed.contains(&v) value != ENUM_NULL && enum_filter.allowed.contains(&value)
}) })
}; };
@ -207,13 +297,22 @@ pub async fn get_hexagons(
return; return;
} }
let cell_id = precomputed[row]; let cell_id = precomputed[row];
groups let aggregation = groups
.entry(cell_id) .entry(cell_id)
.or_insert_with(|| CellAgg::new(num_features)) .or_insert_with(|| CellAgg::new(num_features, num_enums));
.add_row(feature_data, row, num_features); aggregation.add_row(feature_data, row, num_features);
aggregation.add_enums(enum_features, row);
if include_postcode {
aggregation.add_postcode(
&state.data.postcode[row],
state.data.lat[row],
state.data.lon[row],
);
}
}); });
} else { } else {
let h3_res = h3o::Resolution::try_from(resolution).unwrap(); let h3_res = h3o::Resolution::try_from(resolution)
.map_err(|error| format!("Invalid H3 resolution {}: {}", resolution, error))?;
state state
.grid .grid
.for_each_in_bounds(south, west, north, east, |row_idx| { .for_each_in_bounds(south, west, north, east, |row_idx| {
@ -222,19 +321,37 @@ pub async fn get_hexagons(
return; return;
} }
let cell_id = h3o::LatLng::new(state.data.lat[row], state.data.lon[row]) let cell_id = h3o::LatLng::new(state.data.lat[row], state.data.lon[row])
.map(|c| u64::from(c.to_cell(h3_res))) .map(|coord| u64::from(coord.to_cell(h3_res)))
.unwrap_or(0); .unwrap_or(0);
groups let aggregation = groups
.entry(cell_id) .entry(cell_id)
.or_insert_with(|| CellAgg::new(num_features)) .or_insert_with(|| CellAgg::new(num_features, num_enums));
.add_row(feature_data, row, num_features); aggregation.add_row(feature_data, row, num_features);
aggregation.add_enums(enum_features, row);
if include_postcode {
aggregation.add_postcode(
&state.data.postcode[row],
state.data.lat[row],
state.data.lon[row],
);
}
}); });
} }
let t_agg = t0.elapsed(); let t_agg = t0.elapsed();
let mut json_buf = String::with_capacity(groups.len() * 128); let mut json_buf = String::with_capacity(groups.len() * 128);
write_hexagons_json(&mut json_buf, &groups, &min_keys, &max_keys, num_features); write_hexagons_json(
&mut json_buf,
&groups,
min_keys,
max_keys,
num_features,
enum_min_keys,
enum_max_keys,
num_enums,
include_postcode,
);
let t_total = t0.elapsed(); let t_total = t0.elapsed();
info!( info!(
@ -244,14 +361,15 @@ pub async fn get_hexagons(
filters_raw = filters_str.as_deref().unwrap_or("-"), filters_raw = filters_str.as_deref().unwrap_or("-"),
agg_ms = format_args!("{:.1}", t_agg.as_secs_f64() * 1000.0), agg_ms = format_args!("{:.1}", t_agg.as_secs_f64() * 1000.0),
total_ms = format_args!("{:.1}", t_total.as_secs_f64() * 1000.0), total_ms = format_args!("{:.1}", t_total.as_secs_f64() * 1000.0),
bytes = json_buf.len(), size = format_args!("{}", HumanBytes(json_buf.len())),
"GET /api/hexagons" "GET /api/hexagons"
); );
json_buf Ok(json_buf)
}) })
.await .await
.unwrap(); .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error))?;
Ok(([("content-type", "application/json")], json_body)) Ok(([("content-type", "application/json")], json_body))
} }

View file

@ -1,9 +1,12 @@
mod features; mod features;
mod hexagons; pub(crate) mod hexagons;
mod hexagon_stats;
pub(crate) mod parse;
mod pois; mod pois;
mod properties; pub(crate) mod properties;
pub use features::get_features; pub use features::get_features;
pub use hexagon_stats::get_hexagon_stats;
pub use hexagons::get_hexagons; pub use hexagons::get_hexagons;
pub use pois::{get_poi_categories, get_pois}; pub use pois::{get_poi_categories, get_pois};
pub use properties::get_hexagon_properties; pub use properties::get_hexagon_properties;

View file

@ -1,9 +1,38 @@
use axum::http::StatusCode; use axum::http::StatusCode;
/// Compute the lat/lon bounding box of an H3 cell, with a configurable buffer in degrees.
pub fn h3_cell_bounds(cell: h3o::CellIndex, buffer: f64) -> (f64, f64, f64, f64) {
let boundary = cell.boundary();
let (mut min_lat, mut max_lat) = (f64::INFINITY, f64::NEG_INFINITY);
let (mut min_lon, mut max_lon) = (f64::INFINITY, f64::NEG_INFINITY);
for vertex in boundary.iter() {
let lat = vertex.lat();
let lon = vertex.lng();
if lat < min_lat {
min_lat = lat;
}
if lat > max_lat {
max_lat = lat;
}
if lon < min_lon {
min_lon = lon;
}
if lon > max_lon {
max_lon = lon;
}
}
(
min_lat - buffer,
min_lon - buffer,
max_lat + buffer,
max_lon + buffer,
)
}
pub fn parse_bounds(bounds_str: &str) -> Result<(f64, f64, f64, f64), (StatusCode, String)> { pub fn parse_bounds(bounds_str: &str) -> Result<(f64, f64, f64, f64), (StatusCode, String)> {
let parts: Vec<f64> = bounds_str let parts: Vec<f64> = bounds_str
.split(',') .split(',')
.map(|s| s.trim().parse::<f64>()) .map(|part| part.trim().parse::<f64>())
.collect::<Result<Vec<_>, _>>() .collect::<Result<Vec<_>, _>>()
.map_err(|_| { .map_err(|_| {
( (

View file

@ -39,37 +39,56 @@ pub async fn get_pois(
let category_filter: Option<rustc_hash::FxHashSet<String>> = params let category_filter: Option<rustc_hash::FxHashSet<String>> = params
.categories .categories
.as_deref() .as_deref()
.filter(|s| !s.is_empty()) .filter(|text| !text.is_empty())
.map(|s| s.split(',').map(|c| c.trim().to_string()).collect()); .map(|text| text.split(',').map(|part| part.trim().to_string()).collect());
let num_categories = category_filter.as_ref().map(|c| c.len()).unwrap_or(0); let num_categories = category_filter.as_ref().map(|cats| cats.len()).unwrap_or(0);
let result = tokio::task::spawn_blocking(move || { let result = tokio::task::spawn_blocking(move || {
let t0 = std::time::Instant::now(); let t0 = std::time::Instant::now();
let row_indices = state.poi_grid.query(south, west, north, east); let row_indices = state.poi_grid.query(south, west, north, east);
let pois: Vec<POI> = row_indices // Collect matching row indices first, then sample randomly so the
// subset covers the viewport uniformly instead of clustering in one area.
let mut matching_rows: Vec<usize> = row_indices
.iter() .iter()
.filter_map(|&row_idx| { .filter_map(|&row_idx| {
let row = row_idx as usize; let row = row_idx as usize;
if let Some(ref categories) = category_filter { if let Some(ref categories) = category_filter {
if !categories.contains(&state.poi_data.category[row]) { if !categories.contains(&state.poi_data.category[row]) {
return None; return None;
} }
} }
Some(row)
Some(POI { })
id: state.poi_data.id[row].clone(), .collect();
name: state.poi_data.name[row].clone(),
category: state.poi_data.category[row].clone(), if matching_rows.len() > MAX_POIS_PER_REQUEST {
group: state.poi_data.group[row].clone(), // Use a power-of-2 sampling step so each POI's inclusion depends
lat: state.poi_data.lat[row], // only on its own priority hash, not on what other POIs are in
lng: state.poi_data.lng[row], // the viewport. This prevents visible reshuffling when panning.
emoji: state.poi_data.emoji[row].clone(), let ratio = (matching_rows.len() / MAX_POIS_PER_REQUEST) as u32;
}) let step = ratio.next_power_of_two();
let mask = step - 1;
matching_rows.retain(|&row| state.poi_data.priority[row] & mask == 0);
// Statistical noise may leave us slightly over the limit
if matching_rows.len() > MAX_POIS_PER_REQUEST {
matching_rows.sort_unstable_by_key(|&row| state.poi_data.priority[row]);
matching_rows.truncate(MAX_POIS_PER_REQUEST);
}
}
let pois: Vec<POI> = matching_rows
.iter()
.map(|&row| POI {
id: state.poi_data.id[row].clone(),
name: state.poi_data.name[row].clone(),
category: state.poi_data.category[row].clone(),
group: state.poi_data.group[row].clone(),
lat: state.poi_data.lat[row],
lng: state.poi_data.lng[row],
emoji: state.poi_data.emoji[row].clone(),
}) })
.take(MAX_POIS_PER_REQUEST)
.collect(); .collect();
let elapsed = t0.elapsed(); let elapsed = t0.elapsed();
@ -85,7 +104,7 @@ pub async fn get_pois(
POIsResponse { pois } POIsResponse { pois }
}) })
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
Ok(Json(result)) Ok(Json(result))
} }
@ -98,7 +117,7 @@ pub struct POICategoriesResponse {
pub async fn get_poi_categories(state: Arc<AppState>) -> Json<POICategoriesResponse> { pub async fn get_poi_categories(state: Arc<AppState>) -> Json<POICategoriesResponse> {
let groups: Vec<POICategoryGroup> = state.poi_category_groups.clone(); let groups: Vec<POICategoryGroup> = state.poi_category_groups.clone();
let total: usize = groups.iter().map(|g| g.categories.len()).sum(); let total: usize = groups.iter().map(|group| group.categories.len()).sum();
info!( info!(
count = total, count = total,
groups = groups.len(), groups = groups.len(),

View file

@ -8,9 +8,13 @@ use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::{info, warn}; use tracing::{info, warn};
use crate::consts::{DEFAULT_PROPERTIES_LIMIT, ENUM_NULL, MAX_PROPERTIES_LIMIT};
use crate::data::EnumFeatureData;
use crate::filter::{parse_filters, row_passes_filters}; use crate::filter::{parse_filters, row_passes_filters};
use crate::state::AppState; use crate::state::AppState;
use super::parse::h3_cell_bounds;
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct HexagonPropertiesParams { pub struct HexagonPropertiesParams {
pub h3: String, pub h3: String,
@ -35,6 +39,8 @@ pub struct Property {
pub lat: f64, pub lat: f64,
pub lon: f64, pub lon: f64,
pub is_construction_date_approximate: Option<bool>,
#[serde(flatten)] #[serde(flatten)]
pub features: FxHashMap<String, f64>, pub features: FxHashMap<String, f64>,
} }
@ -48,20 +54,51 @@ pub struct HexagonPropertiesResponse {
pub truncated: bool, pub truncated: bool,
} }
fn non_empty_string(text: &str) -> Option<String> {
let trimmed = text.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn lookup_enum_value(
enum_features: &[EnumFeatureData],
enum_idx: &FxHashMap<String, usize>,
row: usize,
names: &[&str],
) -> Option<String> {
for name in names {
if let Some(&feature_index) = enum_idx.get(*name) {
let enum_feature = &enum_features[feature_index];
let data_index = enum_feature.data[row];
if data_index != ENUM_NULL {
if let Some(value) = enum_feature.values.get(data_index as usize) {
return Some(value.clone());
}
}
}
}
None
}
pub async fn get_hexagon_properties( pub async fn get_hexagon_properties(
state: Arc<AppState>, state: Arc<AppState>,
Query(params): Query<HexagonPropertiesParams>, Query(params): Query<HexagonPropertiesParams>,
) -> Result<Json<HexagonPropertiesResponse>, (StatusCode, String)> { ) -> Result<Json<HexagonPropertiesResponse>, (StatusCode, String)> {
let cell = h3o::CellIndex::from_str(&params.h3) let cell = h3o::CellIndex::from_str(&params.h3).map_err(|error| {
.map_err(|e| { warn!(h3 = %params.h3, error = %error, "Invalid H3 cell index");
warn!(h3 = %params.h3, error = %e, "Invalid H3 cell index"); (StatusCode::BAD_REQUEST, format!("Invalid H3 cell: {}", error))
(StatusCode::BAD_REQUEST, format!("Invalid H3 cell: {}", e)) })?;
})?;
let cell_u64: u64 = cell.into(); let cell_u64: u64 = cell.into();
let resolution = params.resolution as usize; let resolution = params.resolution as usize;
if resolution >= state.h3_cells.len() || state.h3_cells[resolution].is_empty() { if resolution >= state.h3_cells.len() || state.h3_cells[resolution].is_empty() {
warn!(resolution, "Invalid or non-precomputed resolution for hexagon-properties"); warn!(
resolution,
"Invalid or non-precomputed resolution for hexagon-properties"
);
return Err(( return Err((
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
"Invalid or non-precomputed resolution".to_string(), "Invalid or non-precomputed resolution".to_string(),
@ -84,31 +121,29 @@ pub async fn get_hexagon_properties(
let feature_data = &state.data.feature_data; let feature_data = &state.data.feature_data;
let enum_features = &state.data.enum_features; let enum_features = &state.data.enum_features;
let matching_rows: Vec<usize> = h3_data let (min_lat, min_lon, max_lat, max_lon) = h3_cell_bounds(cell, 0.001);
.iter()
.enumerate() let mut matching_rows: Vec<usize> = Vec::new();
.filter_map(|(idx, &h3_cell)| { state
if h3_cell == cell_u64 { .grid
if row_passes_filters( .for_each_in_bounds(min_lat, min_lon, max_lat, max_lon, |row_idx| {
idx, let row = row_idx as usize;
if h3_data[row] == cell_u64
&& row_passes_filters(
row,
&parsed_filters, &parsed_filters,
&parsed_enum_filters, &parsed_enum_filters,
feature_data, feature_data,
num_features, num_features,
enum_features, enum_features,
) { )
Some(idx) {
} else { matching_rows.push(row);
None
}
} else {
None
} }
}) });
.collect();
let total = matching_rows.len(); let total = matching_rows.len();
let limit = params.limit.unwrap_or(100).min(500); let limit = params.limit.unwrap_or(DEFAULT_PROPERTIES_LIMIT).min(MAX_PROPERTIES_LIMIT);
let offset = params.offset.unwrap_or(0); let offset = params.offset.unwrap_or(0);
let truncated = total > offset + limit; let truncated = total > offset + limit;
@ -120,49 +155,46 @@ pub async fn get_hexagon_properties(
let mut features = FxHashMap::default(); let mut features = FxHashMap::default();
let base = row * num_features; let base = row * num_features;
for (feat_idx, feat_name) in state.data.feature_names.iter().enumerate() { for (feat_idx, feat_name) in state.data.feature_names.iter().enumerate() {
let v = feature_data[base + feat_idx]; let value = feature_data[base + feat_idx];
if v.is_finite() { if value.is_finite() {
features.insert(feat_name.clone(), v); features.insert(feat_name.clone(), value);
} }
} }
let get_string = |s: &str| -> Option<String> {
let trimmed = s.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
};
let get_enum_value = |names: &[&str]| -> Option<String> {
for name in names {
if let Some(val) = enum_features.iter().find_map(|ef| {
if ef.name == *name {
let idx = ef.data[row];
if idx == 255 {
None
} else {
ef.values.get(idx as usize).cloned()
}
} else {
None
}
}) {
return Some(val);
}
}
None
};
Property { Property {
address: get_string(&state.data.address[row]), address: non_empty_string(&state.data.address[row]),
postcode: get_string(&state.data.postcode[row]), postcode: non_empty_string(&state.data.postcode[row]),
property_type: get_enum_value(&["Property type", "epc_property_type", "pp_property_type"]), is_construction_date_approximate: Some(state.data.is_approx_build_date[row]),
built_form: get_enum_value(&["Property type/built form", "built_form"]), property_type: lookup_enum_value(
duration: get_enum_value(&["Leashold/Freehold", "duration"]), enum_features,
current_energy_rating: get_enum_value(&["Current energy rating", "current_energy_rating"]), &state.enum_name_to_idx,
potential_energy_rating: get_enum_value(&["Potential energy rating", "potential_energy_rating"]), row,
&["Property type", "epc_property_type", "pp_property_type"],
),
built_form: lookup_enum_value(
enum_features,
&state.enum_name_to_idx,
row,
&["Property type/built form", "built_form"],
),
duration: lookup_enum_value(
enum_features,
&state.enum_name_to_idx,
row,
&["Leashold/Freehold", "duration"],
),
current_energy_rating: lookup_enum_value(
enum_features,
&state.enum_name_to_idx,
row,
&["Current energy rating", "current_energy_rating"],
),
potential_energy_rating: lookup_enum_value(
enum_features,
&state.enum_name_to_idx,
row,
&["Potential energy rating", "potential_energy_rating"],
),
lat: state.data.lat[row], lat: state.data.lat[row],
lon: state.data.lon[row], lon: state.data.lon[row],
features, features,
@ -192,7 +224,7 @@ pub async fn get_hexagon_properties(
} }
}) })
.await .await
.unwrap(); .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
Ok(Json(result)) Ok(Json(result))
} }

View file

@ -1,5 +1,14 @@
use rustc_hash::FxHashMap;
use serde::Serialize;
use crate::data::{POIData, PropertyData}; use crate::data::{POIData, PropertyData};
use crate::index::GridIndex; use crate::grid_index::GridIndex;
#[derive(Serialize, Clone)]
pub struct POICategoryGroup {
pub name: String,
pub categories: Vec<String>,
}
pub struct AppState { pub struct AppState {
pub data: PropertyData, pub data: PropertyData,
@ -9,4 +18,16 @@ pub struct AppState {
pub h3_cells: Vec<Vec<u64>>, pub h3_cells: Vec<Vec<u64>>,
pub poi_data: POIData, pub poi_data: POIData,
pub poi_grid: GridIndex, pub poi_grid: GridIndex,
/// Precomputed JSON key names: "min_{feature_name}" for each numeric feature
pub min_keys: Vec<String>,
/// Precomputed JSON key names: "max_{feature_name}" for each numeric feature
pub max_keys: Vec<String>,
/// Precomputed JSON key names: "min_{enum_name}" for each enum feature
pub enum_min_keys: Vec<String>,
/// Precomputed JSON key names: "max_{enum_name}" for each enum feature
pub enum_max_keys: Vec<String>,
/// Precomputed POI category groups (sorted)
pub poi_category_groups: Vec<POICategoryGroup>,
/// Precomputed map from enum feature name to index in data.enum_features
pub enum_name_to_idx: FxHashMap<String, usize>,
} }

View file

@ -159,8 +159,6 @@ mod filter_tests {
#[cfg(test)] #[cfg(test)]
mod json_tests { mod json_tests {
use std::fmt::Write;
#[test] #[test]
fn json_escaped_postcode_with_quotes_is_valid() { fn json_escaped_postcode_with_quotes_is_valid() {
use crate::routes::hexagons::write_json_escaped; use crate::routes::hexagons::write_json_escaped;
@ -199,6 +197,7 @@ mod json_tests {
#[test] #[test]
fn nan_is_not_valid_json() { fn nan_is_not_valid_json() {
use std::fmt::Write;
// Verify that raw NaN in write! is still invalid JSON (documenting the risk // Verify that raw NaN in write! is still invalid JSON (documenting the risk
// that the is_finite() guard in write_hexagons_json protects against). // that the is_finite() guard in write_hexagons_json protects against).
let mut buf = String::new(); let mut buf = String::new();
@ -210,6 +209,7 @@ mod json_tests {
#[test] #[test]
fn infinity_is_not_valid_json() { fn infinity_is_not_valid_json() {
use std::fmt::Write;
let mut buf = String::new(); let mut buf = String::new();
write!(buf, "{{\"min_price\":{}}}", f64::INFINITY).unwrap(); write!(buf, "{{\"min_price\":{}}}", f64::INFINITY).unwrap();
@ -225,7 +225,7 @@ mod enum_encoding_tests {
// Documents the underlying u8 wrapping behavior that the truncation // Documents the underlying u8 wrapping behavior that the truncation
// guard in property.rs now prevents. // guard in property.rs now prevents.
let num_values = 300usize; let num_values = 300usize;
let indices: Vec<u8> = (0..num_values).map(|i| i as u8).collect(); let indices: Vec<u8> = (0..num_values).map(|index| index as u8).collect();
assert_eq!(indices[0], indices[256], "u8 wraps: 0 == 256"); assert_eq!(indices[0], indices[256], "u8 wraps: 0 == 256");
assert_eq!(indices[1], indices[257], "u8 wraps: 1 == 257"); assert_eq!(indices[1], indices[257], "u8 wraps: 1 == 257");
@ -235,7 +235,7 @@ mod enum_encoding_tests {
let value_to_idx: HashMap<&str, u8> = values let value_to_idx: HashMap<&str, u8> = values
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, v)| (v.as_str(), i as u8)) .map(|(index, value)| (value.as_str(), index as u8))
.collect(); .collect();
let unique_indices: std::collections::HashSet<u8> = let unique_indices: std::collections::HashSet<u8> =