Update UI

This commit is contained in:
Andras Schmelczer 2026-02-01 11:07:58 +00:00
parent 2ac37ece97
commit 5f311233e4
10 changed files with 663 additions and 408 deletions

View file

@ -4,8 +4,11 @@ import Filters from './components/Filters';
import POIPane from './components/POIPane';
import { PropertiesPane } from './components/PropertiesPane';
import DataSources from './components/DataSources';
import DataSourcesPage from './components/DataSourcesPage';
import HomePage from './components/HomePage';
import type {
FeatureMeta,
FeatureGroup,
FeatureFilters,
Bounds,
HexagonData,
@ -14,6 +17,7 @@ import type {
POI,
POIResponse,
POICategoriesResponse,
POICategoryGroup,
ViewState,
Property,
HexagonPropertiesResponse,
@ -171,7 +175,15 @@ function stateToParams(
// --- Header ---
function Header() {
type Page = 'home' | 'dashboard' | 'data-sources';
function Header({
activePage,
onPageChange,
}: {
activePage: Page;
onPageChange: (page: Page) => void;
}) {
const [copied, setCopied] = useState(false);
const handleShare = useCallback(() => {
@ -181,11 +193,22 @@ function Header() {
});
}, []);
const tabClass = (page: Page) =>
`px-3 py-1.5 rounded text-sm transition-colors ${
activePage === page
? 'bg-navy-700 font-semibold'
: 'text-warm-300 hover:bg-navy-800 hover:text-white'
}`;
return (
<header className="h-12 bg-slate-800 text-white flex items-center justify-between px-4 shrink-0">
<div className="flex items-center gap-2">
<header className="h-12 bg-navy-900 text-white flex items-center justify-between px-4 shrink-0">
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
onClick={() => onPageChange('home')}
>
<svg
className="w-5 h-5 text-blue-400"
className="w-5 h-5 text-teal-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -203,11 +226,24 @@ function Header() {
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<span className="font-semibold text-lg">Property Map</span>
<span className="font-semibold text-lg">Narrowit</span>
</button>
<nav className="flex items-center gap-1">
<button className={tabClass('home')} onClick={() => onPageChange('home')}>
Home
</button>
<button className={tabClass('dashboard')} onClick={() => onPageChange('dashboard')}>
Dashboard
</button>
<button className={tabClass('data-sources')} onClick={() => onPageChange('data-sources')}>
Data Sources
</button>
</nav>
</div>
{activePage === 'dashboard' && (
<button
onClick={handleShare}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-slate-700 hover:bg-slate-600 transition-colors text-sm"
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
>
{copied ? (
<>
@ -235,6 +271,7 @@ function Header() {
</>
)}
</button>
)}
</header>
);
}
@ -249,13 +286,16 @@ export default function App() {
const [filters, setFilters] = useState<FeatureFilters>(urlState.filters || {});
const [activeFeature, setActiveFeature] = useState<string | null>(null);
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
const [pinnedFeature, setPinnedFeature] = useState<string | null>(null);
const [rawData, setRawData] = useState<HexagonData[]>([]);
const [dragData, setDragData] = useState<HexagonData[] | null>(null);
const [resolution, setResolution] = useState<number>(8);
const [bounds, setBounds] = useState<Bounds | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [zoom, setZoom] = useState<number>(urlState.viewState?.zoom || DEFAULT_VIEW.zoom);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const dragAbortRef = useRef<AbortController | null>(null);
// View state for URL serialization
const [currentView, setCurrentView] = useState<{
@ -277,7 +317,7 @@ export default function App() {
// POI state
const [pois, setPois] = useState<POI[]>([]);
const [poiCategories, setPOICategories] = useState<string[]>([]);
const [poiCategoryGroups, setPOICategoryGroups] = useState<POICategoryGroup[]>([]);
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<string>>(
urlState.poiCategories || new Set()
);
@ -293,10 +333,31 @@ export default function App() {
const [propertiesOffset, setPropertiesOffset] = useState(0);
const [loadingProperties, setLoadingProperties] = useState(false);
const [rightPaneTab, setRightPaneTab] = useState<'pois' | 'properties'>(urlState.tab || 'pois');
const [activePage, setActivePage] = useState<Page>('home');
// Derive enabled features from filter keys
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);
// Derive view feature: active drag takes priority over pinned
const viewFeature = activeFeature || pinnedFeature;
const viewSource: 'drag' | 'eye' | null = activeFeature ? 'drag' : pinnedFeature ? 'eye' : null;
// Color range: always the feature's full slider range from metadata
const colorRange = useMemo((): [number, number] | null => {
if (!viewFeature) return null;
const meta = features.find((f) => f.name === viewFeature);
if (meta?.min != null && meta?.max != null) return [meta.min, meta.max];
return null;
}, [viewFeature, features]);
// Filter range: current drag or committed filter values, used for gray-out
const filterRange = useMemo((): [number, number] | null => {
if (!viewFeature) return null;
if (activeFeature && dragValue) return dragValue;
const filterVal = filters[viewFeature];
if (filterVal && typeof filterVal[0] === 'number') return filterVal as [number, number];
return null;
}, [viewFeature, activeFeature, dragValue, filters]);
// --- URL sync ---
const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -326,20 +387,40 @@ export default function App() {
useEffect(() => {
fetch(`${getApiBaseUrl()}/api/features`)
.then((res) => res.json())
.then((json: { features: FeatureMeta[] }) => {
setFeatures(json.features);
.then((json: { groups: FeatureGroup[] }) => {
// Flatten grouped response into a flat feature list with group annotation
const flat: FeatureMeta[] = json.groups.flatMap((g) =>
g.features.map((f) => ({ ...f, group: g.name }))
);
setFeatures(flat);
})
.catch((err) => console.error('Failed to fetch features:', err));
fetch(`${getApiBaseUrl()}/api/poi-categories`)
.then((res) => res.json())
.then((json: POICategoriesResponse) => {
setPOICategories(json.categories);
setPOICategoryGroups(json.groups);
})
.catch((err) => console.error('Failed to fetch POI categories:', err));
}, []);
// Debounced fetch when resolution/bounds/filters change
// Build filter query string helper
const buildFilterParam = useCallback((): string => {
const filterEntries = Object.entries(filters);
if (filterEntries.length === 0) return '';
return filterEntries
.map(([name, value]) => {
const meta = features.find((f) => f.name === name);
if (meta?.type === 'enum') {
return `${name}:${(value as string[]).join('|')}`;
}
const [min, max] = value as [number, number];
return `${name}:${min}:${max}`;
})
.join(',');
}, [filters, features]);
// Debounced fetch when resolution/bounds/filters change — always fetch hexagons
useEffect(() => {
if (!bounds) return;
@ -356,25 +437,13 @@ export default function App() {
setLoading(true);
try {
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
const filtersStr = buildFilterParam();
const params = new URLSearchParams({
resolution: resolution.toString(),
bounds: boundsStr,
});
// Build filters param: numeric=name:min:max, enum=name:val1|val2
const filterEntries = Object.entries(filters);
if (filterEntries.length > 0) {
const filtersStr = filterEntries
.map(([name, value]) => {
const meta = features.find((f) => f.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.set('filters', filtersStr);
}
if (filtersStr) params.set('filters', filtersStr);
const res = await fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, {
signal: abortControllerRef.current.signal,
});
@ -394,10 +463,11 @@ export default function App() {
clearTimeout(debounceRef.current);
}
};
}, [resolution, bounds, filters]);
}, [resolution, bounds, filters, buildFilterParam]);
// Data passed directly to Map — visual filtering is done via color/opacity in the layer
const data = rawData;
// During slider drag, use the expanded dataset (without active feature filter)
// so both narrowing and expanding are visible. Otherwise use server-filtered data.
const data = dragData ?? rawData;
// Fetch POIs when bounds or selected categories change
useEffect(() => {
@ -444,7 +514,13 @@ export default function App() {
const prevBoundsRef = useRef<string>('');
const handleViewChange = useCallback(
({ resolution: newRes, bounds: newBounds, zoom: newZoom, latitude, longitude }: ViewChangeParams) => {
({
resolution: newRes,
bounds: newBounds,
zoom: newZoom,
latitude,
longitude,
}: ViewChangeParams) => {
// Only update bounds/resolution when quantized values actually change
const boundsKey = `${newBounds.south},${newBounds.west},${newBounds.north},${newBounds.east},${newRes}`;
if (boundsKey !== prevBoundsRef.current) {
@ -471,12 +547,9 @@ export default function App() {
[features]
);
const handleFilterChange = useCallback(
(name: string, value: [number, number] | string[]) => {
const handleFilterChange = useCallback((name: string, value: [number, number] | string[]) => {
setFilters((prev) => ({ ...prev, [name]: value }));
},
[]
);
}, []);
const handleRemoveFilter = useCallback((name: string) => {
setFilters((prev) => {
@ -484,6 +557,7 @@ export default function App() {
delete next[name];
return next;
});
setPinnedFeature((prev) => (prev === name ? null : prev));
}, []);
const handleDragStart = useCallback(
@ -493,8 +567,41 @@ export default function App() {
setActiveFeature(name);
const fval = filters[name];
setDragValue(fval && !Array.isArray(fval[0]) ? (fval as [number, number]) : null);
// Fetch hexagons without this feature's filter so we can expand the range
if (!bounds) return;
if (dragAbortRef.current) dragAbortRef.current.abort();
dragAbortRef.current = new AbortController();
const otherFilters = Object.entries(filters).filter(([k]) => k !== name);
let filtersStr = '';
if (otherFilters.length > 0) {
filtersStr = otherFilters
.map(([n, value]) => {
const m = features.find((f) => f.name === n);
if (m?.type === 'enum') return `${n}:${(value as string[]).join('|')}`;
const [min, max] = value as [number, number];
return `${n}:${min}:${max}`;
})
.join(',');
}
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
const params = new URLSearchParams({ resolution: resolution.toString(), bounds: boundsStr });
if (filtersStr) params.set('filters', filtersStr);
fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, {
signal: dragAbortRef.current.signal,
})
.then((res) => res.json())
.then((json: ApiResponse) => setDragData(json.features || []))
.catch((err) => {
if (err instanceof Error && err.name !== 'AbortError') {
console.error('Failed to fetch drag data:', err);
}
});
},
[filters, features]
[filters, features, bounds, resolution]
);
const handleDragChange = useCallback((value: [number, number]) => {
@ -507,8 +614,21 @@ export default function App() {
}
setActiveFeature(null);
setDragValue(null);
setDragData(null);
if (dragAbortRef.current) {
dragAbortRef.current.abort();
dragAbortRef.current = null;
}
}, [activeFeature, dragValue]);
const handleTogglePin = useCallback((name: string) => {
setPinnedFeature((prev) => (prev === name ? null : name));
}, []);
const handleCancelPin = useCallback(() => {
setPinnedFeature(null);
}, []);
const fetchHexagonProperties = useCallback(
async (h3: string, res: number, offset = 0) => {
setLoadingProperties(true);
@ -584,7 +704,12 @@ export default function App() {
return (
<div className="h-screen flex flex-col">
<Header />
<Header activePage={activePage} onPageChange={setActivePage} />
{activePage === 'home' ? (
<HomePage onOpenDashboard={() => setActivePage('dashboard')} />
) : activePage === 'data-sources' ? (
<DataSourcesPage />
) : (
<div className="flex-1 flex overflow-hidden">
<Filters
features={features}
@ -599,30 +724,40 @@ export default function App() {
onDragChange={handleDragChange}
onDragEnd={handleDragEnd}
zoom={zoom}
pinnedFeature={pinnedFeature}
onTogglePin={handleTogglePin}
onCancelPin={handleCancelPin}
/>
<div className="flex-1 relative">
<Map
data={data}
pois={pois}
onViewChange={handleViewChange}
activeFeature={activeFeature}
dragValue={dragValue}
viewFeature={viewFeature}
colorRange={colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={handleCancelPin}
features={features}
selectedHexagonId={selectedHexagon?.h3 || null}
onHexagonClick={handleHexagonClick}
initialViewState={initialViewState}
/>
{loading && (
<div className="absolute top-4 right-4 bg-white px-3 py-1 rounded shadow">Loading...</div>
<div className="absolute top-4 right-4 bg-white px-3 py-1 rounded shadow">
Loading...
</div>
)}
<DataSources />
<DataSources onNavigate={() => setActivePage('data-sources')} />
</div>
<div className="w-72 bg-white shadow-lg z-10 flex flex-col">
{/* Tab headers */}
<div className="flex border-b border-gray-200">
<div className="flex border-b border-warm-200">
<button
className={`flex-1 p-3 ${
rightPaneTab === 'pois' ? 'border-b-2 border-blue-500 font-semibold' : 'text-gray-600'
rightPaneTab === 'pois'
? 'border-b-2 border-teal-500 font-semibold'
: 'text-warm-600'
}`}
onClick={() => setRightPaneTab('pois')}
>
@ -631,8 +766,8 @@ export default function App() {
<button
className={`flex-1 p-3 ${
rightPaneTab === 'properties'
? 'border-b-2 border-blue-500 font-semibold'
: 'text-gray-600'
? 'border-b-2 border-teal-500 font-semibold'
: 'text-warm-600'
}`}
onClick={() => setRightPaneTab('properties')}
>
@ -644,7 +779,7 @@ export default function App() {
<div className="flex-1 overflow-hidden">
{rightPaneTab === 'pois' ? (
<POIPane
categories={poiCategories}
groups={poiCategoryGroups}
selectedCategories={selectedPOICategories}
onCategoriesChange={setSelectedPOICategories}
poiCount={pois.length}
@ -662,6 +797,7 @@ export default function App() {
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -44,7 +44,7 @@ const DATA_SOURCES = [
{
name: 'TfL Journey Times',
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.",
url: 'https://api-portal.tfl.gov.uk/',
license: 'Powered by TfL Open Data',
},
@ -92,15 +92,12 @@ export default function DataSourcesPage() {
<div className="max-w-5xl mx-auto px-6 py-8">
<h1 className="text-2xl font-bold text-warm-900 mb-2">Data Sources</h1>
<p className="text-warm-600 mb-6">
This application combines {DATA_SOURCES.length} open datasets covering property prices, energy
performance, transport, demographics, crime, environment, and more.
This application combines {DATA_SOURCES.length} open datasets covering property prices,
energy performance, transport, demographics, crime, environment, and more.
</p>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{DATA_SOURCES.map((source) => (
<div
key={source.name}
className="bg-white rounded-lg border border-warm-200 p-5"
>
<div key={source.name} className="bg-white rounded-lg border border-warm-200 p-5">
<div className="flex items-start justify-between gap-4 mb-2">
<h2 className="text-lg font-semibold text-warm-900">{source.name}</h2>
<span className="shrink-0 text-xs bg-warm-100 text-warm-600 px-2 py-1 rounded">
@ -125,11 +122,11 @@ export default function DataSourcesPage() {
<footer className="bg-navy-900 text-warm-400 px-6 py-6">
<div className="max-w-5xl mx-auto">
<h2 className="text-sm font-semibold text-warm-300 uppercase tracking-wide mb-3">Attribution</h2>
<h2 className="text-sm font-semibold text-warm-300 uppercase tracking-wide mb-3">
Attribution
</h2>
<ul className="space-y-1.5 text-sm">
<li>
Contains HM Land Registry data &copy; Crown copyright and database right 2025.
</li>
<li>Contains HM Land Registry data &copy; Crown copyright and database right 2025.</li>
<li>
Contains public sector information licensed under the{' '}
<a
@ -142,9 +139,7 @@ export default function DataSourcesPage() {
</a>
.
</li>
<li>
Contains OS data &copy; Crown copyright and database rights 2025.
</li>
<li>Contains OS data &copy; Crown copyright and database rights 2025.</li>
<li>Powered by TfL Open Data.</li>
<li>
Contains data from{' '}

View file

@ -1,4 +1,4 @@
import { memo } from 'react';
import { memo, useState, useRef, useEffect, useMemo } from 'react';
import { Slider } from './ui/slider';
import { Label } from './ui/label';
import type { FeatureMeta, FeatureFilters } from '../types';
@ -21,6 +21,83 @@ interface FiltersProps {
onCancelPin: () => void;
}
function FilterDropdown({
availableFeatures,
onAddFilter,
}: {
availableFeatures: FeatureMeta[];
onAddFilter: (name: string) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const keyHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', handler);
document.addEventListener('keydown', keyHandler);
return () => {
document.removeEventListener('mousedown', handler);
document.removeEventListener('keydown', keyHandler);
};
}, [open]);
const grouped = useMemo(() => {
const groups: { name: string; features: FeatureMeta[] }[] = [];
const seen = new Map<string, FeatureMeta[]>();
for (const f of availableFeatures) {
const g = f.group || 'Other';
let arr = seen.get(g);
if (!arr) {
arr = [];
seen.set(g, arr);
groups.push({ name: g, features: arr });
}
arr.push(f);
}
return groups;
}, [availableFeatures]);
return (
<div ref={ref} className="relative">
<button
className="w-full p-2 border rounded text-sm bg-white text-left text-warm-500 hover:border-warm-400"
onClick={() => setOpen(!open)}
>
+ Add filter...
</button>
{open && (
<div className="absolute z-50 mt-1 w-full bg-white border rounded shadow-lg max-h-80 overflow-y-auto">
{grouped.map((group) => (
<div key={group.name}>
<div className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 sticky top-0">
{group.name}
</div>
{group.features.map((f) => (
<button
key={f.name}
className="w-full text-left px-3 py-1.5 text-sm hover:bg-teal-50 hover:text-teal-700"
onClick={() => {
onAddFilter(f.name);
setOpen(false);
}}
>
{f.name}
</button>
))}
</div>
))}
</div>
)}
</div>
);
}
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(1)}k`;
@ -54,22 +131,7 @@ export default memo(function Filters({
{/* Add filter dropdown */}
{availableFeatures.length > 0 && (
<select
className="w-full p-2 border rounded text-sm bg-white"
value=""
onChange={(e) => {
if (e.target.value) onAddFilter(e.target.value);
}}
>
<option value="" disabled>
+ Add filter...
</option>
{availableFeatures.map((f) => (
<option key={f.name} value={f.name}>
{f.name}
</option>
))}
</select>
<FilterDropdown availableFeatures={availableFeatures} onAddFilter={onAddFilter} />
)}
{/* Active filters */}
@ -149,20 +211,17 @@ export default memo(function Filters({
className={`p-0.5 rounded ${isPinned ? 'text-teal-600' : 'text-warm-400 hover:text-warm-700'}`}
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}>
<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>
{isPinned && (
<button
onClick={onCancelPin}
className="text-warm-400 hover:text-warm-700 text-xs px-0.5"
title="Clear color view"
>
x
</button>
)}
<button
onClick={() => onRemoveFilter(feature.name)}
className="text-warm-400 hover:text-warm-700 text-sm px-1"
@ -184,7 +243,6 @@ export default memo(function Filters({
</div>
);
})}
</div>
);
});

View file

@ -182,28 +182,29 @@ export default function HomePage({ onOpenDashboard }: { onOpenDashboard: () => v
const ctaRef = useFadeInRef();
return (
<div
ref={scrollRef}
className="flex-1 overflow-y-auto bg-warm-50 relative"
>
<div ref={scrollRef} className="flex-1 overflow-y-auto bg-warm-50 relative">
<HexCanvas scrollProgress={scrollProgress} />
<div className="relative" style={{ zIndex: 1 }}>
{/* Hero */}
<div className="max-w-3xl mx-auto px-6 pt-20 pb-24">
<div ref={heroRef} className="fade-in-section backdrop-blur-sm bg-warm-50/60 rounded-2xl p-8 -mx-2">
<div
ref={heroRef}
className="fade-in-section backdrop-blur-sm bg-warm-50/60 rounded-2xl p-8 -mx-2"
>
<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
</p>
<h1 className="text-5xl font-extrabold text-navy-950 mb-6 leading-[1.1] tracking-tight">
Every neighbourhood<br />
in England &amp; Wales.<br />
Every neighbourhood
<br />
in England &amp; Wales.
<br />
<span className="text-teal-600">One map. Your&nbsp;rules.</span>
</h1>
<p className="text-xl text-warm-600 mb-8 leading-relaxed max-w-xl">
Set the commute, budget, school rating, noise level, and crime
threshold you&apos;ll accept. Narrowit shows you every area that
qualifies &mdash; instantly.
Set the commute, budget, school rating, noise level, and crime threshold you&apos;ll
accept. Narrowit shows you every area that qualifies &mdash; instantly.
</p>
<div className="flex items-center gap-4">
<button
@ -225,19 +226,22 @@ export default function HomePage({ onOpenDashboard }: { onOpenDashboard: () => v
<div className="rounded-2xl backdrop-blur-sm bg-warm-50/40 border border-warm-200/50 p-8">
<div className="grid md:grid-cols-2 gap-8">
<div>
<h3 className="text-sm font-semibold text-warm-400 uppercase tracking-wide mb-2">The old way</h3>
<h3 className="text-sm font-semibold text-warm-400 uppercase tracking-wide mb-2">
The old way
</h3>
<p className="text-warm-700 leading-relaxed">
Pick a postcode. Google the schools. Check crime stats on
another site. Look up commute times. Realise it&apos;s too
expensive. Start over. Repeat 40 times.
Pick a postcode. Google the schools. Check crime stats on another site. Look up
commute times. Realise it&apos;s too expensive. Start over. Repeat 40 times.
</p>
</div>
<div>
<h3 className="text-sm font-semibold text-teal-600 uppercase tracking-wide mb-2">With Narrowit</h3>
<h3 className="text-sm font-semibold text-teal-600 uppercase tracking-wide mb-2">
With Narrowit
</h3>
<p className="text-warm-700 leading-relaxed">
Tell the map what you need. Every hexagon that lights up is
a place worth looking at. Drill into any one to see
individual properties, prices, and energy ratings.
Tell the map what you need. Every hexagon that lights up is a place worth
looking at. Drill into any one to see individual properties, prices, and energy
ratings.
</p>
</div>
</div>
@ -308,9 +312,7 @@ export default function HomePage({ onOpenDashboard }: { onOpenDashboard: () => v
{/* Final CTA */}
<div className="max-w-3xl mx-auto px-6 pb-24">
<div ref={ctaRef} className="fade-in-section text-center">
<h2 className="text-3xl font-bold text-navy-950 mb-3">
Ready to narrow it down?
</h2>
<h2 className="text-3xl font-bold text-navy-950 mb-3">Ready to narrow it down?</h2>
<p className="text-warm-500 mb-8 max-w-md mx-auto">
100% open data. No account required. Just set your filters and go.
</p>

View file

@ -3,26 +3,24 @@ import { Map as MapGL, useControl } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
import { H3HexagonLayer } from '@deck.gl/geo-layers';
import { IconLayer, PolygonLayer } from '@deck.gl/layers';
import { IconLayer, TextLayer } from '@deck.gl/layers';
import type { PickingInfo } from '@deck.gl/core';
import 'maplibre-gl/dist/maplibre-gl.css';
import type { HexagonData, ViewState, ViewChangeParams, Bounds, POI, FeatureMeta, PostcodeData } from '../types';
import type { HexagonData, ViewState, ViewChangeParams, Bounds, POI, FeatureMeta } from '../types';
interface MapProps {
data: HexagonData[];
pois: POI[];
onViewChange: (params: ViewChangeParams) => void;
viewFeature: string | null;
viewRange: [number, number] | null;
colorRange: [number, number] | null;
filterRange: [number, number] | null;
viewSource: 'drag' | 'eye' | null;
onCancelPin: () => void;
features: FeatureMeta[];
selectedHexagonId: string | null;
onHexagonClick: (h3: string) => void;
initialViewState?: ViewState;
postcodeData: PostcodeData[];
selectedPostcode: string | null;
onPostcodeClick: (postcode: string) => void;
}
// Twemoji CDN base URL
@ -44,7 +42,7 @@ const INITIAL_VIEW: ViewState = {
pitch: 0,
};
const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json';
const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json';
// Gradient stops for normalized [0,1] values
const GRADIENT: { t: number; color: [number, number, number] }[] = [
@ -78,7 +76,8 @@ function zoomToResolution(zoom: number): number {
if (zoom < 9.5) return 8;
if (zoom < 11) return 9;
if (zoom < 13) return 10;
return 11;
if (zoom < 15) return 11;
return 12;
}
function getBoundsFromViewState(viewState: ViewState, width: number, height: number): Bounds {
@ -195,10 +194,7 @@ function PostcodeSearch({
);
return (
<form
onSubmit={handleSubmit}
className="absolute top-3 left-3 z-10 flex flex-col gap-1"
>
<form onSubmit={handleSubmit} className="absolute top-3 left-3 z-10 flex flex-col gap-1">
<div className="flex shadow-lg rounded overflow-hidden">
<input
type="text"
@ -219,9 +215,7 @@ function PostcodeSearch({
</button>
</div>
{error && (
<span className="text-xs text-red-600 bg-white/90 rounded px-2 py-0.5 shadow">
{error}
</span>
<span className="text-xs text-red-600 bg-white/90 rounded px-2 py-0.5 shadow">{error}</span>
)}
</form>
);
@ -255,7 +249,13 @@ function MapLegend({
className="text-warm-400 hover:text-warm-700 ml-2"
title="Clear color view"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
@ -281,16 +281,14 @@ export default memo(function Map({
pois,
onViewChange,
viewFeature,
viewRange,
colorRange,
filterRange,
viewSource,
onCancelPin,
features,
selectedHexagonId,
onHexagonClick,
initialViewState,
postcodeData,
selectedPostcode,
onPostcodeClick,
}: MapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW);
@ -328,7 +326,13 @@ export default memo(function Map({
east: Math.ceil(raw.east / QUANT) * QUANT,
};
onViewChange({ resolution, bounds, zoom: viewState.zoom, latitude: viewState.latitude, longitude: viewState.longitude });
onViewChange({
resolution,
bounds,
zoom: viewState.zoom,
latitude: viewState.latitude,
longitude: viewState.longitude,
});
}, [viewState, dimensions, onViewChange]);
const handleMove = useCallback((evt: { viewState: ViewState }) => {
@ -349,6 +353,12 @@ export default memo(function Map({
map.setPaintProperty(layer.id, 'text-halo-width', 2);
map.setPaintProperty(layer.id, 'text-color', '#222');
}
// Make water more prominent
for (const layer of map.getStyle().layers || []) {
if (layer.id === 'water' || layer.id.startsWith('water')) {
map.setPaintProperty(layer.id, 'fill-color', '#6baed6');
}
}
map.setLayoutProperty('building', 'visibility', 'none');
map.setLayoutProperty('building-top', 'visibility', 'none');
},
@ -399,8 +409,10 @@ export default memo(function Map({
// Use refs for values that change during drag so layers aren't recreated
const viewFeatureRef = useRef(viewFeature);
viewFeatureRef.current = viewFeature;
const viewRangeRef = useRef(viewRange);
viewRangeRef.current = viewRange;
const colorRangeRef = useRef(colorRange);
colorRangeRef.current = colorRange;
const filterRangeRef = useRef(filterRange);
filterRangeRef.current = filterRange;
const colorFeatureMetaRef = useRef(colorFeatureMeta);
colorFeatureMetaRef.current = colorFeatureMeta;
const countRangeRef = useRef(countRange);
@ -408,32 +420,14 @@ export default memo(function Map({
const selectedHexagonIdRef = useRef(selectedHexagonId);
selectedHexagonIdRef.current = selectedHexagonId;
// Postcode refs
const selectedPostcodeRef = useRef(selectedPostcode);
selectedPostcodeRef.current = selectedPostcode;
// Stable click handler using ref
const onHexagonClickRef = useRef(onHexagonClick);
onHexagonClickRef.current = onHexagonClick;
const handleHexagonClick = useCallback(
(info: PickingInfo<HexagonData>) => {
const handleHexagonClick = useCallback((info: PickingInfo<HexagonData>) => {
if (info.object && 'h3' in info.object) {
onHexagonClickRef.current(info.object.h3);
}
},
[]
);
const onPostcodeClickRef = useRef(onPostcodeClick);
onPostcodeClickRef.current = onPostcodeClick;
const handlePostcodeClick = useCallback(
(info: PickingInfo<PostcodeData>) => {
if (info.object && 'postcode' in info.object) {
onPostcodeClickRef.current(info.object.postcode);
}
},
[]
);
}, []);
// Stable hover handler using ref
const handlePoiHoverRef = useRef(handlePoiHover);
@ -443,7 +437,7 @@ export default memo(function Map({
}, []);
// Derive a trigger value from color-affecting state — avoids useEffect+setState double-render
const colorTrigger = `${viewFeature}|${viewRange?.[0]}|${viewRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}`;
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}`;
// Hexagon layer — only recreated when data or color trigger changes
const hexLayer = useMemo(
@ -454,29 +448,36 @@ export default memo(function Map({
getHexagon: (d) => d.h3,
getFillColor: (d) => {
const vf = viewFeatureRef.current;
const vr = viewRangeRef.current;
const clr = colorRangeRef.current;
const fr = filterRangeRef.current;
const cfm = colorFeatureMetaRef.current;
if (vf && vr && cfm) {
if (vf && clr && cfm) {
const val = d[`min_${vf}`];
if (val == null) return [128, 128, 128, 80] as [number, number, number, number];
const min = vr[0];
const max = vr[1];
// Gray out hexagons outside filter range
if (fr) {
const minVal = d[`min_${vf}`] as number;
const maxVal = d[`max_${vf}`] as number;
// Gray out hexagons outside range
if (maxVal < min || minVal > max) {
if (maxVal < fr[0] || minVal > fr[1]) {
return [180, 180, 180, 60] as [number, number, number, number];
}
const range = max - min;
}
// Color using full slider range
const range = clr[1] - clr[0];
if (range === 0) return [...GRADIENT[0].color, 200] as [number, number, number, number];
const t = ((val as number) - min) / range;
const t = ((val as number) - clr[0]) / range;
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)));
return [...rgb, 200] as [number, number, number, number];
}
const cr = countRangeRef.current;
const c = d.count as number;
const t = (c - cr.min) / (cr.max - cr.min);
return [...countToColor(Math.max(0, Math.min(1, t))), 200] as [number, number, number, number];
return [...countToColor(Math.max(0, Math.min(1, t))), 200] as [
number,
number,
number,
number,
];
},
getLineColor: (d) =>
(d.h3 === selectedHexagonIdRef.current ? [255, 255, 255, 255] : [0, 0, 0, 0]) as [
@ -498,84 +499,11 @@ export default memo(function Map({
highPrecision: true,
onClick: handleHexagonClick,
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
beforeId: "waterway_label",
beforeId: 'waterway_label',
}),
[data, colorTrigger, handleHexagonClick]
);
// Postcode count range
const postcodeCountRange = useMemo(() => {
if (postcodeData.length === 0) return { min: 0, max: 1 };
let min = Infinity;
let max = -Infinity;
for (const d of postcodeData) {
const c = d.count as number;
if (c < min) min = c;
if (c > max) max = c;
}
if (min === max) return { min, max: min + 1 };
return { min, max };
}, [postcodeData]);
const postcodeCountRangeRef = useRef(postcodeCountRange);
postcodeCountRangeRef.current = postcodeCountRange;
// Postcode color trigger
const postcodeColorTrigger = `${viewFeature}|${viewRange?.[0]}|${viewRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${selectedPostcode}`;
// Postcode polygon layer
const postcodeLayer = useMemo(
() =>
new PolygonLayer<PostcodeData>({
id: 'postcode-polygons',
data: postcodeData,
getPolygon: (d) => d.polygon,
getFillColor: (d) => {
const vf = viewFeatureRef.current;
const vr = viewRangeRef.current;
const cfm = colorFeatureMetaRef.current;
if (vf && vr && cfm) {
const val = d[`min_${vf}`];
if (val == null) return [128, 128, 128, 80] as [number, number, number, number];
const min = vr[0];
const max = vr[1];
const minVal = d[`min_${vf}`] as number;
const maxVal = d[`max_${vf}`] as number;
if (maxVal < min || minVal > max) {
return [180, 180, 180, 60] as [number, number, number, number];
}
const range = max - min;
if (range === 0) return [...GRADIENT[0].color, 200] as [number, number, number, number];
const t = ((val as number) - min) / range;
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)));
return [...rgb, 200] as [number, number, number, number];
}
const cr = postcodeCountRangeRef.current;
const c = d.count as number;
const t = (c - cr.min) / (cr.max - cr.min);
return [...countToColor(Math.max(0, Math.min(1, t))), 200] as [number, number, number, number];
},
getLineColor: (d) =>
(d.postcode === selectedPostcodeRef.current
? [255, 255, 255, 255]
: [160, 160, 160, 200]) as [number, number, number, number],
getLineWidth: (d) => (d.postcode === selectedPostcodeRef.current ? 2 : 1),
lineWidthUnits: 'pixels' as const,
stroked: true,
filled: true,
pickable: true,
updateTriggers: {
getFillColor: [postcodeColorTrigger],
getLineColor: [postcodeColorTrigger],
getLineWidth: [postcodeColorTrigger],
},
onClick: handlePostcodeClick,
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
beforeId: "waterway_label",
}),
[postcodeData, postcodeColorTrigger, handlePostcodeClick]
);
// POI layer — independent, only recreated when POI data changes
const poiLayer = useMemo(
() =>
@ -597,9 +525,41 @@ export default memo(function Map({
[pois, stablePoiHover]
);
// Postcode labels on high-res hexagons (resolution 11+, zoom >= 13)
const postcodeData = useMemo(
() => data.filter((d) => d.postcode && d.lat != null && d.lon != null),
[data]
);
const showPostcodes = viewState.zoom >= 13;
const postcodeLayer = useMemo(
() =>
showPostcodes
? new TextLayer<HexagonData>({
id: 'postcode-labels',
data: postcodeData,
getPosition: (d) => [d.lon as number, d.lat as number],
getText: (d) => d.postcode as string,
getSize: 11,
getColor: [30, 30, 30, 220],
getTextAnchor: 'middle',
getAlignmentBaseline: 'center',
fontFamily: 'Inter, system-ui, sans-serif',
fontWeight: 600,
outlineWidth: 2,
outlineColor: [255, 255, 255, 200],
billboard: false,
sizeUnits: 'pixels',
sizeMinPixels: 10,
sizeMaxPixels: 14,
})
: null,
[postcodeData, showPostcodes]
);
const layers = useMemo(
() => (postcodeData.length > 0 ? [postcodeLayer, poiLayer] : [hexLayer, poiLayer]),
[postcodeData.length, postcodeLayer, hexLayer, poiLayer]
() => [hexLayer, poiLayer, ...(postcodeLayer ? [postcodeLayer] : [])],
[hexLayer, poiLayer, postcodeLayer]
);
// Tooltip uses refs to avoid being a layer dependency
@ -611,15 +571,9 @@ export default memo(function Map({
({ object }: { object?: any }) => {
if (!object) return null;
// Handle both hexagon and postcode objects
const isPostcode = 'postcode' in object;
const isHexagon = 'h3' in object;
if (!isPostcode && !isHexagon) return null;
if (!('h3' in object)) return null;
const lines: string[] = [];
if (isPostcode) {
lines.push(`<strong>${object.postcode}</strong>`);
}
lines.push(`<div>${(object.count as number).toLocaleString()} properties</div>`);
for (const f of featuresRef.current) {
@ -664,10 +618,10 @@ export default memo(function Map({
<DeckOverlay layers={layers} getTooltip={getTooltip as never} />
</MapGL>
<PostcodeSearch onFlyTo={handleFlyTo} />
{viewFeature && viewRange && colorFeatureMeta && (
{viewFeature && colorRange && colorFeatureMeta && (
<MapLegend
featureLabel={colorFeatureMeta.name}
range={viewRange}
range={colorRange}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
/>

View file

@ -1,21 +1,22 @@
import { useState, useRef, useEffect } from 'react';
import { Label } from './ui/label';
import { useState, useRef, useEffect, useCallback } from 'react';
import type { POICategoryGroup } from '../types';
interface POIPaneProps {
categories: string[];
groups: POICategoryGroup[];
selectedCategories: Set<string>;
onCategoriesChange: (categories: Set<string>) => void;
poiCount: number;
}
export default function POIPane({
categories,
groups,
selectedCategories,
onCategoriesChange,
poiCount,
}: POIPaneProps) {
const [dropdownOpen, setDropdownOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
@ -29,6 +30,8 @@ export default function POIPane({
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const allCategories = groups.flatMap((g) => g.categories);
const toggleCategory = (category: string) => {
const newSet = new Set(selectedCategories);
if (newSet.has(category)) {
@ -40,17 +43,55 @@ export default function POIPane({
};
const selectAll = () => {
onCategoriesChange(new Set(categories));
onCategoriesChange(new Set(allCategories));
};
const selectNone = () => {
onCategoriesChange(new Set());
};
const filteredCategories = categories.filter((cat) =>
cat.toLowerCase().includes(searchTerm.toLowerCase())
const toggleGroup = useCallback(
(groupName: string) => {
const group = groups.find((g) => g.name === groupName);
if (!group) return;
const allSelected = group.categories.every((c) => selectedCategories.has(c));
const newSet = new Set(selectedCategories);
if (allSelected) {
group.categories.forEach((c) => newSet.delete(c));
} else {
group.categories.forEach((c) => newSet.add(c));
}
onCategoriesChange(newSet);
},
[groups, selectedCategories, onCategoriesChange]
);
const toggleCollapse = (groupName: string) => {
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(groupName)) {
next.delete(groupName);
} else {
next.add(groupName);
}
return next;
});
};
const lowerSearch = searchTerm.toLowerCase();
// Filter groups and categories by search term
const filteredGroups = groups
.map((group) => {
if (!searchTerm) return group;
const matchingCats = group.categories.filter((c) => c.toLowerCase().includes(lowerSearch));
const groupMatches = group.name.toLowerCase().includes(lowerSearch);
if (groupMatches) return group;
if (matchingCats.length === 0) return null;
return { ...group, categories: matchingCats };
})
.filter(Boolean) as POICategoryGroup[];
const selectedCount = selectedCategories.size;
return (
@ -58,7 +99,6 @@ export default function POIPane({
<h2 className="text-xl font-bold">Points of Interest</h2>
<div className="space-y-2" ref={dropdownRef}>
<Label>Categories</Label>
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-warm-300 rounded hover:border-warm-400 bg-white"
@ -66,7 +106,7 @@ export default function POIPane({
<span className="truncate text-left">
{selectedCount === 0
? 'Select categories...'
: selectedCount === categories.length
: selectedCount === allCategories.length
? 'All categories'
: `${selectedCount} selected`}
</span>
@ -101,10 +141,56 @@ export default function POIPane({
/>
</div>
<div className="max-h-96 overflow-y-auto py-1">
{filteredCategories.map((category) => (
{filteredGroups.map((group) => {
const groupSelected = group.categories.filter((c) =>
selectedCategories.has(c)
).length;
const allInGroupSelected = groupSelected === group.categories.length;
const someInGroupSelected = groupSelected > 0 && !allInGroupSelected;
const isCollapsed = collapsedGroups.has(group.name) && !searchTerm;
return (
<div key={group.name}>
<div className="flex items-center gap-1 px-3 py-1.5 bg-warm-50 border-y border-warm-100">
<button
onClick={() => toggleCollapse(group.name)}
className="p-0.5 text-warm-400 hover:text-warm-600"
>
<svg
className={`w-3 h-3 transition-transform ${isCollapsed ? '' : 'rotate-90'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</button>
<label className="flex items-center gap-2 flex-1 cursor-pointer">
<input
type="checkbox"
checked={allInGroupSelected}
ref={(el) => {
if (el) el.indeterminate = someInGroupSelected;
}}
onChange={() => toggleGroup(group.name)}
className="rounded"
/>
<span className="text-xs font-semibold text-warm-700">{group.name}</span>
</label>
<span className="text-xs text-warm-400">
{groupSelected}/{group.categories.length}
</span>
</div>
{!isCollapsed &&
group.categories.map((category) => (
<label
key={category}
className="flex items-center gap-2 px-3 py-1.5 hover:bg-warm-50 cursor-pointer"
className="flex items-center gap-2 px-3 pl-8 py-1.5 hover:bg-warm-50 cursor-pointer"
>
<input
type="checkbox"
@ -116,6 +202,9 @@ export default function POIPane({
</label>
))}
</div>
);
})}
</div>
</div>
)}
</div>

View file

@ -6,7 +6,6 @@ interface PropertiesPaneProps {
total: number;
loading: boolean;
hexagonId: string | null;
postcodeId?: string | null;
onLoadMore: () => void;
onClose: () => void;
}
@ -18,7 +17,6 @@ export function PropertiesPane({
total,
loading,
hexagonId,
postcodeId,
onLoadMore,
onClose,
}: PropertiesPaneProps) {
@ -38,11 +36,10 @@ export function PropertiesPane({
});
}, [properties, sortBy]);
const selectionId = hexagonId || postcodeId;
if (!selectionId) {
if (!hexagonId) {
return (
<div className="flex items-center justify-center h-full text-warm-500">
Click a hexagon or postcode to view properties
Click a hexagon to view properties
</div>
);
}
@ -52,9 +49,7 @@ export function PropertiesPane({
{/* Header */}
<div className="p-4 border-b border-warm-200">
<div className="flex justify-between items-center">
<h2 className="text-lg font-semibold">
{postcodeId ? `Properties in ${postcodeId}` : 'Properties in Hexagon'}
</h2>
<h2 className="text-lg font-semibold">Properties in Hexagon</h2>
<button
onClick={onClose}
className="text-warm-500 hover:text-warm-700 text-2xl leading-none"
@ -111,9 +106,8 @@ function formatDuration(d: string): string {
return d;
}
function formatAge(value: number): string {
// construction_age_band is a midpoint year, e.g. 1935
if (value >= 1000) return `~${Math.round(value)}`;
function formatAge(value: number, approximate = true): string {
if (value >= 1000) return approximate ? `~${Math.round(value)}` : `${Math.round(value)}`;
return Math.round(value).toString();
}
@ -136,7 +130,11 @@ function PropertyCard({ property }: { property: Property }) {
const price = getNum(property, 'Last known price', 'latest_price');
const pricePerSqm = getNum(property, 'Price per sqm', 'price_per_sqm');
const floorArea = getNum(property, 'Total floor area (sqm)', 'total_floor_area');
const rooms = getNum(property, 'Rooms (including bedrooms & bathrooms)', 'number_habitable_rooms');
const rooms = getNum(
property,
'Rooms (including bedrooms & bathrooms)',
'number_habitable_rooms'
);
const age = getNum(property, 'Approximate construction age', 'construction_age_band');
return (
@ -150,10 +148,7 @@ function PropertyCard({ property }: { property: Property }) {
<div className="mt-2 text-lg font-bold text-teal-700">
£{fmt(price)}
{pricePerSqm !== undefined && (
<span className="text-sm font-normal text-warm-600">
{' '}
(£{fmt(pricePerSqm)}/m²)
</span>
<span className="text-sm font-normal text-warm-600"> (£{fmt(pricePerSqm)}/m²)</span>
)}
</div>
)}
@ -187,7 +182,7 @@ function PropertyCard({ property }: { property: Property }) {
)}
{age !== undefined && (
<div>
<span className="text-warm-500">Built:</span> {formatAge(age)}
<span className="text-warm-500">Built:</span> {formatAge(age, property.is_construction_date_approximate ?? true)}
</div>
)}
{property.current_energy_rating && (
@ -197,8 +192,7 @@ function PropertyCard({ property }: { property: Property }) {
)}
{property.potential_energy_rating && (
<div>
<span className="text-warm-500">EPC potential:</span>{' '}
{property.potential_energy_rating}
<span className="text-warm-500">EPC potential:</span> {property.potential_energy_rating}
</div>
)}
</div>

View file

@ -9,3 +9,17 @@ body,
margin: 0;
padding: 0;
}
/* Fade-in animation for homepage sections */
.fade-in-section {
opacity: 0;
transform: translateY(24px);
transition:
opacity 0.6s ease-out,
transform 0.6s ease-out;
}
.fade-in-visible {
opacity: 1;
transform: translateY(0);
}

View file

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>UK Property Prices Map</title>
<title>Narrowit</title>
</head>
<body>
<div id="root"></div>

View file

@ -1,7 +1,7 @@
export interface FeatureMeta {
name: string;
label: string;
type: 'numeric' | 'enum';
group?: string;
// Numeric-only fields
min?: number;
max?: number;
@ -9,6 +9,11 @@ export interface FeatureMeta {
values?: string[];
}
export interface FeatureGroup {
name: string;
features: FeatureMeta[];
}
// Filters: feature name -> [selectedMin, selectedMax] for numeric, string[] for enum
export type FeatureFilters = Record<string, [number, number] | string[]>;
@ -49,6 +54,7 @@ export interface POI {
id: string;
name: string;
category: string;
group: string;
lat: number;
lng: number;
emoji: string;
@ -58,10 +64,15 @@ export interface POIResponse {
pois: POI[];
}
export interface POICategoriesResponse {
export interface POICategoryGroup {
name: string;
categories: string[];
}
export interface POICategoriesResponse {
groups: POICategoryGroup[];
}
export interface Property {
// String fields
address?: string;
@ -76,8 +87,10 @@ export interface Property {
lat: number;
lon: number;
is_construction_date_approximate?: boolean;
// All other numeric features (dynamic, including construction_age_band)
[key: string]: string | number | undefined;
[key: string]: string | number | boolean | undefined;
}
export interface HexagonPropertiesResponse {