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 POIPane from './components/POIPane';
import { PropertiesPane } from './components/PropertiesPane'; import { PropertiesPane } from './components/PropertiesPane';
import DataSources from './components/DataSources'; import DataSources from './components/DataSources';
import DataSourcesPage from './components/DataSourcesPage';
import HomePage from './components/HomePage';
import type { import type {
FeatureMeta, FeatureMeta,
FeatureGroup,
FeatureFilters, FeatureFilters,
Bounds, Bounds,
HexagonData, HexagonData,
@ -14,6 +17,7 @@ import type {
POI, POI,
POIResponse, POIResponse,
POICategoriesResponse, POICategoriesResponse,
POICategoryGroup,
ViewState, ViewState,
Property, Property,
HexagonPropertiesResponse, HexagonPropertiesResponse,
@ -171,7 +175,15 @@ function stateToParams(
// --- Header --- // --- 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 [copied, setCopied] = useState(false);
const handleShare = useCallback(() => { const handleShare = useCallback(() => {
@ -181,60 +193,85 @@ 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 ( return (
<header className="h-12 bg-slate-800 text-white flex items-center justify-between px-4 shrink-0"> <header className="h-12 bg-navy-900 text-white flex items-center justify-between px-4 shrink-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-4">
<svg <button
className="w-5 h-5 text-blue-400" className="flex items-center gap-2 hover:opacity-80 transition-opacity"
fill="none" onClick={() => onPageChange('home')}
stroke="currentColor"
viewBox="0 0 24 24"
> >
<path <svg
strokeLinecap="round" className="w-5 h-5 text-teal-400"
strokeLinejoin="round" fill="none"
strokeWidth={2} stroke="currentColor"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" viewBox="0 0 24 24"
/> >
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/> />
</svg> <path
<span className="font-semibold text-lg">Property Map</span> strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<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> </div>
<button {activePage === 'dashboard' && (
onClick={handleShare} <button
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-slate-700 hover:bg-slate-600 transition-colors text-sm" onClick={handleShare}
> 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 ? ( >
<> {copied ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <>
<path <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
strokeLinecap="round" <path
strokeLinejoin="round" strokeLinecap="round"
strokeWidth={2} strokeLinejoin="round"
d="M5 13l4 4L19 7" strokeWidth={2}
/> d="M5 13l4 4L19 7"
</svg> />
Copied! </svg>
</> Copied!
) : ( </>
<> ) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <>
<path <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
strokeLinecap="round" <path
strokeLinejoin="round" strokeLinecap="round"
strokeWidth={2} strokeLinejoin="round"
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" strokeWidth={2}
/> d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"
</svg> />
Share </svg>
</> Share
)} </>
</button> )}
</button>
)}
</header> </header>
); );
} }
@ -249,13 +286,16 @@ export default function App() {
const [filters, setFilters] = useState<FeatureFilters>(urlState.filters || {}); const [filters, setFilters] = useState<FeatureFilters>(urlState.filters || {});
const [activeFeature, setActiveFeature] = useState<string | null>(null); const [activeFeature, setActiveFeature] = useState<string | null>(null);
const [dragValue, setDragValue] = useState<[number, number] | null>(null); const [dragValue, setDragValue] = useState<[number, number] | null>(null);
const [pinnedFeature, setPinnedFeature] = useState<string | null>(null);
const [rawData, setRawData] = useState<HexagonData[]>([]); const [rawData, setRawData] = useState<HexagonData[]>([]);
const [dragData, setDragData] = useState<HexagonData[] | null>(null);
const [resolution, setResolution] = useState<number>(8); const [resolution, setResolution] = useState<number>(8);
const [bounds, setBounds] = useState<Bounds | null>(null); const [bounds, setBounds] = useState<Bounds | null>(null);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [zoom, setZoom] = useState<number>(urlState.viewState?.zoom || DEFAULT_VIEW.zoom); const [zoom, setZoom] = useState<number>(urlState.viewState?.zoom || DEFAULT_VIEW.zoom);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
const dragAbortRef = useRef<AbortController | null>(null);
// View state for URL serialization // View state for URL serialization
const [currentView, setCurrentView] = useState<{ const [currentView, setCurrentView] = useState<{
@ -277,7 +317,7 @@ export default function App() {
// POI state // POI state
const [pois, setPois] = useState<POI[]>([]); const [pois, setPois] = useState<POI[]>([]);
const [poiCategories, setPOICategories] = useState<string[]>([]); const [poiCategoryGroups, setPOICategoryGroups] = useState<POICategoryGroup[]>([]);
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<string>>( const [selectedPOICategories, setSelectedPOICategories] = useState<Set<string>>(
urlState.poiCategories || new Set() urlState.poiCategories || new Set()
); );
@ -293,10 +333,31 @@ export default function App() {
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'>(urlState.tab || 'pois');
const [activePage, setActivePage] = useState<Page>('home');
// Derive enabled features from filter keys // Derive enabled features from filter keys
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]); 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 --- // --- URL sync ---
const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -326,20 +387,40 @@ export default function App() {
useEffect(() => { useEffect(() => {
fetch(`${getApiBaseUrl()}/api/features`) fetch(`${getApiBaseUrl()}/api/features`)
.then((res) => res.json()) .then((res) => res.json())
.then((json: { features: FeatureMeta[] }) => { .then((json: { groups: FeatureGroup[] }) => {
setFeatures(json.features); // 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)); .catch((err) => console.error('Failed to fetch features:', err));
fetch(`${getApiBaseUrl()}/api/poi-categories`) fetch(`${getApiBaseUrl()}/api/poi-categories`)
.then((res) => res.json()) .then((res) => res.json())
.then((json: POICategoriesResponse) => { .then((json: POICategoriesResponse) => {
setPOICategories(json.categories); setPOICategoryGroups(json.groups);
}) })
.catch((err) => console.error('Failed to fetch POI categories:', err)); .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(() => { useEffect(() => {
if (!bounds) return; if (!bounds) return;
@ -356,25 +437,13 @@ export default function App() {
setLoading(true); setLoading(true);
try { try {
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`; const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
const filtersStr = buildFilterParam();
const params = new URLSearchParams({ const params = new URLSearchParams({
resolution: resolution.toString(), resolution: resolution.toString(),
bounds: boundsStr, bounds: boundsStr,
}); });
// Build filters param: numeric=name:min:max, enum=name:val1|val2 if (filtersStr) params.set('filters', filtersStr);
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);
}
const res = await fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, { const res = await fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, {
signal: abortControllerRef.current.signal, signal: abortControllerRef.current.signal,
}); });
@ -394,10 +463,11 @@ export default function App() {
clearTimeout(debounceRef.current); 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 // During slider drag, use the expanded dataset (without active feature filter)
const data = rawData; // so both narrowing and expanding are visible. Otherwise use server-filtered data.
const data = dragData ?? rawData;
// Fetch POIs when bounds or selected categories change // Fetch POIs when bounds or selected categories change
useEffect(() => { useEffect(() => {
@ -444,7 +514,13 @@ export default function App() {
const prevBoundsRef = useRef<string>(''); const prevBoundsRef = useRef<string>('');
const handleViewChange = useCallback( 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 // Only update bounds/resolution when quantized values actually change
const boundsKey = `${newBounds.south},${newBounds.west},${newBounds.north},${newBounds.east},${newRes}`; const boundsKey = `${newBounds.south},${newBounds.west},${newBounds.north},${newBounds.east},${newRes}`;
if (boundsKey !== prevBoundsRef.current) { if (boundsKey !== prevBoundsRef.current) {
@ -471,12 +547,9 @@ export default function App() {
[features] [features]
); );
const handleFilterChange = useCallback( const handleFilterChange = useCallback((name: string, value: [number, number] | string[]) => {
(name: string, value: [number, number] | string[]) => { setFilters((prev) => ({ ...prev, [name]: value }));
setFilters((prev) => ({ ...prev, [name]: value })); }, []);
},
[]
);
const handleRemoveFilter = useCallback((name: string) => { const handleRemoveFilter = useCallback((name: string) => {
setFilters((prev) => { setFilters((prev) => {
@ -484,6 +557,7 @@ export default function App() {
delete next[name]; delete next[name];
return next; return next;
}); });
setPinnedFeature((prev) => (prev === name ? null : prev));
}, []); }, []);
const handleDragStart = useCallback( const handleDragStart = useCallback(
@ -493,8 +567,41 @@ export default function App() {
setActiveFeature(name); setActiveFeature(name);
const fval = filters[name]; const fval = filters[name];
setDragValue(fval && !Array.isArray(fval[0]) ? (fval as [number, number]) : null); 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]) => { const handleDragChange = useCallback((value: [number, number]) => {
@ -507,8 +614,21 @@ export default function App() {
} }
setActiveFeature(null); setActiveFeature(null);
setDragValue(null); setDragValue(null);
setDragData(null);
if (dragAbortRef.current) {
dragAbortRef.current.abort();
dragAbortRef.current = null;
}
}, [activeFeature, dragValue]); }, [activeFeature, dragValue]);
const handleTogglePin = useCallback((name: string) => {
setPinnedFeature((prev) => (prev === name ? null : name));
}, []);
const handleCancelPin = useCallback(() => {
setPinnedFeature(null);
}, []);
const fetchHexagonProperties = useCallback( const fetchHexagonProperties = useCallback(
async (h3: string, res: number, offset = 0) => { async (h3: string, res: number, offset = 0) => {
setLoadingProperties(true); setLoadingProperties(true);
@ -584,84 +704,100 @@ export default function App() {
return ( return (
<div className="h-screen flex flex-col"> <div className="h-screen flex flex-col">
<Header /> <Header activePage={activePage} onPageChange={setActivePage} />
<div className="flex-1 flex overflow-hidden"> {activePage === 'home' ? (
<Filters <HomePage onOpenDashboard={() => setActivePage('dashboard')} />
features={features} ) : activePage === 'data-sources' ? (
filters={filters} <DataSourcesPage />
activeFeature={activeFeature} ) : (
dragValue={dragValue} <div className="flex-1 flex overflow-hidden">
enabledFeatures={enabledFeatures} <Filters
onAddFilter={handleAddFilter} features={features}
onRemoveFilter={handleRemoveFilter} filters={filters}
onFilterChange={handleFilterChange}
onDragStart={handleDragStart}
onDragChange={handleDragChange}
onDragEnd={handleDragEnd}
zoom={zoom}
/>
<div className="flex-1 relative">
<Map
data={data}
pois={pois}
onViewChange={handleViewChange}
activeFeature={activeFeature} activeFeature={activeFeature}
dragValue={dragValue} dragValue={dragValue}
features={features} enabledFeatures={enabledFeatures}
selectedHexagonId={selectedHexagon?.h3 || null} onAddFilter={handleAddFilter}
onHexagonClick={handleHexagonClick} onRemoveFilter={handleRemoveFilter}
initialViewState={initialViewState} onFilterChange={handleFilterChange}
onDragStart={handleDragStart}
onDragChange={handleDragChange}
onDragEnd={handleDragEnd}
zoom={zoom}
pinnedFeature={pinnedFeature}
onTogglePin={handleTogglePin}
onCancelPin={handleCancelPin}
/> />
{loading && ( <div className="flex-1 relative">
<div className="absolute top-4 right-4 bg-white px-3 py-1 rounded shadow">Loading...</div> <Map
)} data={data}
<DataSources /> pois={pois}
</div> onViewChange={handleViewChange}
<div className="w-72 bg-white shadow-lg z-10 flex flex-col"> viewFeature={viewFeature}
{/* Tab headers */} colorRange={colorRange}
<div className="flex border-b border-gray-200"> filterRange={filterRange}
<button viewSource={viewSource}
className={`flex-1 p-3 ${ onCancelPin={handleCancelPin}
rightPaneTab === 'pois' ? 'border-b-2 border-blue-500 font-semibold' : 'text-gray-600' features={features}
}`} selectedHexagonId={selectedHexagon?.h3 || null}
onClick={() => setRightPaneTab('pois')} onHexagonClick={handleHexagonClick}
> initialViewState={initialViewState}
POIs {pois.length > 0 && `(${pois.length})`} />
</button> {loading && (
<button <div className="absolute top-4 right-4 bg-white px-3 py-1 rounded shadow">
className={`flex-1 p-3 ${ Loading...
rightPaneTab === 'properties' </div>
? 'border-b-2 border-blue-500 font-semibold'
: 'text-gray-600'
}`}
onClick={() => setRightPaneTab('properties')}
>
Properties {propertiesTotal > 0 && `(${propertiesTotal})`}
</button>
</div>
{/* Tab content */}
<div className="flex-1 overflow-hidden">
{rightPaneTab === 'pois' ? (
<POIPane
categories={poiCategories}
selectedCategories={selectedPOICategories}
onCategoriesChange={setSelectedPOICategories}
poiCount={pois.length}
/>
) : (
<PropertiesPane
properties={properties}
total={propertiesTotal}
loading={loadingProperties}
hexagonId={selectedHexagon?.h3 || null}
onLoadMore={handleLoadMoreProperties}
onClose={handleCloseProperties}
/>
)} )}
<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-warm-200">
<button
className={`flex-1 p-3 ${
rightPaneTab === 'pois'
? 'border-b-2 border-teal-500 font-semibold'
: 'text-warm-600'
}`}
onClick={() => setRightPaneTab('pois')}
>
POIs {pois.length > 0 && `(${pois.length})`}
</button>
<button
className={`flex-1 p-3 ${
rightPaneTab === 'properties'
? 'border-b-2 border-teal-500 font-semibold'
: 'text-warm-600'
}`}
onClick={() => setRightPaneTab('properties')}
>
Properties {propertiesTotal > 0 && `(${propertiesTotal})`}
</button>
</div>
{/* Tab content */}
<div className="flex-1 overflow-hidden">
{rightPaneTab === 'pois' ? (
<POIPane
groups={poiCategoryGroups}
selectedCategories={selectedPOICategories}
onCategoriesChange={setSelectedPOICategories}
poiCount={pois.length}
/>
) : (
<PropertiesPane
properties={properties}
total={propertiesTotal}
loading={loadingProperties}
hexagonId={selectedHexagon?.h3 || null}
onLoadMore={handleLoadMoreProperties}
onClose={handleCloseProperties}
/>
)}
</div>
</div> </div>
</div> </div>
</div> )}
</div> </div>
); );
} }

View file

@ -44,7 +44,7 @@ const DATA_SOURCES = [
{ {
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.",
url: 'https://api-portal.tfl.gov.uk/', url: 'https://api-portal.tfl.gov.uk/',
license: 'Powered by TfL Open Data', 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"> <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> <h1 className="text-2xl font-bold text-warm-900 mb-2">Data Sources</h1>
<p className="text-warm-600 mb-6"> <p className="text-warm-600 mb-6">
This application combines {DATA_SOURCES.length} open datasets covering property prices, energy This application combines {DATA_SOURCES.length} open datasets covering property prices,
performance, transport, demographics, crime, environment, and more. energy performance, transport, demographics, crime, environment, and more.
</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 <div key={source.name} className="bg-white rounded-lg border border-warm-200 p-5">
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"> <div className="flex items-start justify-between gap-4 mb-2">
<h2 className="text-lg font-semibold text-warm-900">{source.name}</h2> <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"> <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"> <footer className="bg-navy-900 text-warm-400 px-6 py-6">
<div className="max-w-5xl mx-auto"> <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"> <ul className="space-y-1.5 text-sm">
<li> <li>Contains HM Land Registry data &copy; Crown copyright and database right 2025.</li>
Contains HM Land Registry data &copy; Crown copyright and database right 2025.
</li>
<li> <li>
Contains public sector information licensed under the{' '} Contains public sector information licensed under the{' '}
<a <a
@ -142,9 +139,7 @@ export default function DataSourcesPage() {
</a> </a>
. .
</li> </li>
<li> <li>Contains OS data &copy; Crown copyright and database rights 2025.</li>
Contains OS data &copy; Crown copyright and database rights 2025.
</li>
<li>Powered by TfL Open Data.</li> <li>Powered by TfL Open Data.</li>
<li> <li>
Contains data from{' '} 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 { 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';
@ -21,6 +21,83 @@ interface FiltersProps {
onCancelPin: () => void; 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 { 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_000) return `${(value / 1_000_000).toFixed(1)}M`;
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`; 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 */} {/* Add filter dropdown */}
{availableFeatures.length > 0 && ( {availableFeatures.length > 0 && (
<select <FilterDropdown availableFeatures={availableFeatures} onAddFilter={onAddFilter} />
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>
)} )}
{/* Active filters */} {/* 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'}`} 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'} 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" /> <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" /> <circle cx="12" cy="12" r="3" />
</svg> </svg>
</button> </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 <button
onClick={() => onRemoveFilter(feature.name)} onClick={() => onRemoveFilter(feature.name)}
className="text-warm-400 hover:text-warm-700 text-sm px-1" className="text-warm-400 hover:text-warm-700 text-sm px-1"
@ -184,7 +243,6 @@ export default memo(function Filters({
</div> </div>
); );
})} })}
</div> </div>
); );
}); });

View file

@ -182,28 +182,29 @@ export default function HomePage({ onOpenDashboard }: { onOpenDashboard: () => v
const ctaRef = useFadeInRef(); const ctaRef = useFadeInRef();
return ( return (
<div <div ref={scrollRef} className="flex-1 overflow-y-auto bg-warm-50 relative">
ref={scrollRef}
className="flex-1 overflow-y-auto bg-warm-50 relative"
>
<HexCanvas scrollProgress={scrollProgress} /> <HexCanvas scrollProgress={scrollProgress} />
<div className="relative" style={{ zIndex: 1 }}> <div className="relative" style={{ zIndex: 1 }}>
{/* Hero */} {/* Hero */}
<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 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"> <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
</p> </p>
<h1 className="text-5xl font-extrabold text-navy-950 mb-6 leading-[1.1] tracking-tight"> <h1 className="text-5xl font-extrabold text-navy-950 mb-6 leading-[1.1] tracking-tight">
Every neighbourhood<br /> Every neighbourhood
in England &amp; Wales.<br /> <br />
in England &amp; Wales.
<br />
<span className="text-teal-600">One map. Your&nbsp;rules.</span> <span className="text-teal-600">One map. Your&nbsp;rules.</span>
</h1> </h1>
<p className="text-xl text-warm-600 mb-8 leading-relaxed max-w-xl"> <p className="text-xl text-warm-600 mb-8 leading-relaxed max-w-xl">
Set the commute, budget, school rating, noise level, and crime Set the commute, budget, school rating, noise level, and crime threshold you&apos;ll
threshold you&apos;ll accept. Narrowit shows you every area that accept. Narrowit shows you every area that qualifies &mdash; instantly.
qualifies &mdash; instantly.
</p> </p>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button <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="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 className="grid md:grid-cols-2 gap-8">
<div> <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"> <p className="text-warm-700 leading-relaxed">
Pick a postcode. Google the schools. Check crime stats on Pick a postcode. Google the schools. Check crime stats on another site. Look up
another site. Look up commute times. Realise it&apos;s too commute times. Realise it&apos;s too expensive. Start over. Repeat 40 times.
expensive. Start over. Repeat 40 times.
</p> </p>
</div> </div>
<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"> <p className="text-warm-700 leading-relaxed">
Tell the map what you need. Every hexagon that lights up is Tell the map what you need. Every hexagon that lights up is a place worth
a place worth looking at. Drill into any one to see looking at. Drill into any one to see individual properties, prices, and energy
individual properties, prices, and energy ratings. ratings.
</p> </p>
</div> </div>
</div> </div>
@ -308,9 +312,7 @@ export default function HomePage({ onOpenDashboard }: { onOpenDashboard: () => v
{/* Final CTA */} {/* Final CTA */}
<div className="max-w-3xl mx-auto px-6 pb-24"> <div className="max-w-3xl mx-auto px-6 pb-24">
<div ref={ctaRef} className="fade-in-section text-center"> <div ref={ctaRef} className="fade-in-section text-center">
<h2 className="text-3xl font-bold text-navy-950 mb-3"> <h2 className="text-3xl font-bold text-navy-950 mb-3">Ready to narrow it down?</h2>
Ready to narrow it down?
</h2>
<p className="text-warm-500 mb-8 max-w-md mx-auto"> <p className="text-warm-500 mb-8 max-w-md mx-auto">
100% open data. No account required. Just set your filters and go. 100% open data. No account required. Just set your filters and go.
</p> </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 type { MapRef } from 'react-map-gl/maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox'; import { MapboxOverlay } from '@deck.gl/mapbox';
import { H3HexagonLayer } from '@deck.gl/geo-layers'; 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 type { PickingInfo } from '@deck.gl/core';
import 'maplibre-gl/dist/maplibre-gl.css'; 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 { interface MapProps {
data: HexagonData[]; data: HexagonData[];
pois: POI[]; pois: POI[];
onViewChange: (params: ViewChangeParams) => void; onViewChange: (params: ViewChangeParams) => void;
viewFeature: string | null; viewFeature: string | null;
viewRange: [number, number] | null; colorRange: [number, number] | null;
filterRange: [number, number] | null;
viewSource: 'drag' | 'eye' | null; viewSource: 'drag' | 'eye' | null;
onCancelPin: () => void; onCancelPin: () => void;
features: FeatureMeta[]; features: FeatureMeta[];
selectedHexagonId: string | null; selectedHexagonId: string | null;
onHexagonClick: (h3: string) => void; onHexagonClick: (h3: string) => void;
initialViewState?: ViewState; initialViewState?: ViewState;
postcodeData: PostcodeData[];
selectedPostcode: string | null;
onPostcodeClick: (postcode: string) => void;
} }
// Twemoji CDN base URL // Twemoji CDN base URL
@ -44,7 +42,7 @@ const INITIAL_VIEW: ViewState = {
pitch: 0, 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 // Gradient stops for normalized [0,1] values
const GRADIENT: { t: number; color: [number, number, number] }[] = [ 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 < 9.5) return 8;
if (zoom < 11) return 9; if (zoom < 11) return 9;
if (zoom < 13) return 10; if (zoom < 13) return 10;
return 11; if (zoom < 15) return 11;
return 12;
} }
function getBoundsFromViewState(viewState: ViewState, width: number, height: number): Bounds { function getBoundsFromViewState(viewState: ViewState, width: number, height: number): Bounds {
@ -195,10 +194,7 @@ function PostcodeSearch({
); );
return ( return (
<form <form onSubmit={handleSubmit} className="absolute top-3 left-3 z-10 flex flex-col gap-1">
onSubmit={handleSubmit}
className="absolute top-3 left-3 z-10 flex flex-col gap-1"
>
<div className="flex shadow-lg rounded overflow-hidden"> <div className="flex shadow-lg rounded overflow-hidden">
<input <input
type="text" type="text"
@ -219,9 +215,7 @@ function PostcodeSearch({
</button> </button>
</div> </div>
{error && ( {error && (
<span className="text-xs text-red-600 bg-white/90 rounded px-2 py-0.5 shadow"> <span className="text-xs text-red-600 bg-white/90 rounded px-2 py-0.5 shadow">{error}</span>
{error}
</span>
)} )}
</form> </form>
); );
@ -255,7 +249,13 @@ function MapLegend({
className="text-warm-400 hover:text-warm-700 ml-2" className="text-warm-400 hover:text-warm-700 ml-2"
title="Clear color view" 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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
@ -281,16 +281,14 @@ export default memo(function Map({
pois, pois,
onViewChange, onViewChange,
viewFeature, viewFeature,
viewRange, colorRange,
filterRange,
viewSource, viewSource,
onCancelPin, onCancelPin,
features, features,
selectedHexagonId, selectedHexagonId,
onHexagonClick, onHexagonClick,
initialViewState, initialViewState,
postcodeData,
selectedPostcode,
onPostcodeClick,
}: MapProps) { }: MapProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW); const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW);
@ -328,7 +326,13 @@ export default memo(function Map({
east: Math.ceil(raw.east / QUANT) * QUANT, 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]); }, [viewState, dimensions, onViewChange]);
const handleMove = useCallback((evt: { viewState: ViewState }) => { 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-halo-width', 2);
map.setPaintProperty(layer.id, 'text-color', '#222'); 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', 'visibility', 'none');
map.setLayoutProperty('building-top', '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 // Use refs for values that change during drag so layers aren't recreated
const viewFeatureRef = useRef(viewFeature); const viewFeatureRef = useRef(viewFeature);
viewFeatureRef.current = viewFeature; viewFeatureRef.current = viewFeature;
const viewRangeRef = useRef(viewRange); const colorRangeRef = useRef(colorRange);
viewRangeRef.current = viewRange; colorRangeRef.current = colorRange;
const filterRangeRef = useRef(filterRange);
filterRangeRef.current = filterRange;
const colorFeatureMetaRef = useRef(colorFeatureMeta); const colorFeatureMetaRef = useRef(colorFeatureMeta);
colorFeatureMetaRef.current = colorFeatureMeta; colorFeatureMetaRef.current = colorFeatureMeta;
const countRangeRef = useRef(countRange); const countRangeRef = useRef(countRange);
@ -408,32 +420,14 @@ export default memo(function Map({
const selectedHexagonIdRef = useRef(selectedHexagonId); const selectedHexagonIdRef = useRef(selectedHexagonId);
selectedHexagonIdRef.current = selectedHexagonId; selectedHexagonIdRef.current = selectedHexagonId;
// Postcode refs
const selectedPostcodeRef = useRef(selectedPostcode);
selectedPostcodeRef.current = selectedPostcode;
// Stable click handler using ref // Stable click handler using ref
const onHexagonClickRef = useRef(onHexagonClick); const onHexagonClickRef = useRef(onHexagonClick);
onHexagonClickRef.current = onHexagonClick; onHexagonClickRef.current = onHexagonClick;
const handleHexagonClick = useCallback( const handleHexagonClick = useCallback((info: PickingInfo<HexagonData>) => {
(info: PickingInfo<HexagonData>) => { if (info.object && 'h3' in info.object) {
if (info.object && 'h3' in info.object) { onHexagonClickRef.current(info.object.h3);
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 // Stable hover handler using ref
const handlePoiHoverRef = useRef(handlePoiHover); 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 // 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 // Hexagon layer — only recreated when data or color trigger changes
const hexLayer = useMemo( const hexLayer = useMemo(
@ -454,29 +448,36 @@ export default memo(function Map({
getHexagon: (d) => d.h3, getHexagon: (d) => d.h3,
getFillColor: (d) => { getFillColor: (d) => {
const vf = viewFeatureRef.current; const vf = viewFeatureRef.current;
const vr = viewRangeRef.current; const clr = colorRangeRef.current;
const fr = filterRangeRef.current;
const cfm = colorFeatureMetaRef.current; const cfm = colorFeatureMetaRef.current;
if (vf && vr && cfm) { if (vf && clr && cfm) {
const val = d[`min_${vf}`]; const val = d[`min_${vf}`];
if (val == null) return [128, 128, 128, 80] as [number, number, number, number]; if (val == null) return [128, 128, 128, 80] as [number, number, number, number];
const min = vr[0]; // Gray out hexagons outside filter range
const max = vr[1]; if (fr) {
const minVal = d[`min_${vf}`] as number; const minVal = d[`min_${vf}`] as number;
const maxVal = d[`max_${vf}`] as number; const maxVal = d[`max_${vf}`] as number;
// Gray out hexagons outside range if (maxVal < fr[0] || minVal > fr[1]) {
if (maxVal < min || minVal > max) { return [180, 180, 180, 60] as [number, number, number, number];
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]; 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))); const rgb = normalizedToColor(Math.max(0, Math.min(1, t)));
return [...rgb, 200] as [number, number, number, number]; return [...rgb, 200] as [number, number, number, number];
} }
const cr = countRangeRef.current; const cr = countRangeRef.current;
const c = d.count as number; const c = d.count as number;
const t = (c - cr.min) / (cr.max - cr.min); 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) => getLineColor: (d) =>
(d.h3 === selectedHexagonIdRef.current ? [255, 255, 255, 255] : [0, 0, 0, 0]) as [ (d.h3 === selectedHexagonIdRef.current ? [255, 255, 255, 255] : [0, 0, 0, 0]) as [
@ -498,84 +499,11 @@ export default memo(function Map({
highPrecision: true, highPrecision: true,
onClick: handleHexagonClick, onClick: handleHexagonClick,
// @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]
); );
// 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 // POI layer — independent, only recreated when POI data changes
const poiLayer = useMemo( const poiLayer = useMemo(
() => () =>
@ -597,9 +525,41 @@ export default memo(function Map({
[pois, stablePoiHover] [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( const layers = useMemo(
() => (postcodeData.length > 0 ? [postcodeLayer, poiLayer] : [hexLayer, poiLayer]), () => [hexLayer, poiLayer, ...(postcodeLayer ? [postcodeLayer] : [])],
[postcodeData.length, postcodeLayer, hexLayer, poiLayer] [hexLayer, poiLayer, postcodeLayer]
); );
// Tooltip uses refs to avoid being a layer dependency // Tooltip uses refs to avoid being a layer dependency
@ -611,15 +571,9 @@ export default memo(function Map({
({ object }: { object?: any }) => { ({ object }: { object?: any }) => {
if (!object) return null; if (!object) return null;
// Handle both hexagon and postcode objects if (!('h3' in object)) return null;
const isPostcode = 'postcode' in object;
const isHexagon = 'h3' in object;
if (!isPostcode && !isHexagon) return null;
const lines: string[] = []; const lines: string[] = [];
if (isPostcode) {
lines.push(`<strong>${object.postcode}</strong>`);
}
lines.push(`<div>${(object.count as number).toLocaleString()} properties</div>`); lines.push(`<div>${(object.count as number).toLocaleString()} properties</div>`);
for (const f of featuresRef.current) { for (const f of featuresRef.current) {
@ -664,10 +618,10 @@ export default memo(function Map({
<DeckOverlay layers={layers} getTooltip={getTooltip as never} /> <DeckOverlay layers={layers} getTooltip={getTooltip as never} />
</MapGL> </MapGL>
<PostcodeSearch onFlyTo={handleFlyTo} /> <PostcodeSearch onFlyTo={handleFlyTo} />
{viewFeature && viewRange && colorFeatureMeta && ( {viewFeature && colorRange && colorFeatureMeta && (
<MapLegend <MapLegend
featureLabel={colorFeatureMeta.name} featureLabel={colorFeatureMeta.name}
range={viewRange} range={colorRange}
showCancel={viewSource === 'eye'} showCancel={viewSource === 'eye'}
onCancel={onCancelPin} onCancel={onCancelPin}
/> />

View file

@ -1,21 +1,22 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect, useCallback } from 'react';
import { Label } from './ui/label'; import type { POICategoryGroup } from '../types';
interface POIPaneProps { interface POIPaneProps {
categories: string[]; groups: POICategoryGroup[];
selectedCategories: Set<string>; selectedCategories: Set<string>;
onCategoriesChange: (categories: Set<string>) => void; onCategoriesChange: (categories: Set<string>) => void;
poiCount: number; poiCount: number;
} }
export default function POIPane({ export default function POIPane({
categories, groups,
selectedCategories, selectedCategories,
onCategoriesChange, onCategoriesChange,
poiCount, poiCount,
}: 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 dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside // Close dropdown when clicking outside
@ -29,6 +30,8 @@ export default function POIPane({
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, []); }, []);
const allCategories = groups.flatMap((g) => g.categories);
const toggleCategory = (category: string) => { const toggleCategory = (category: string) => {
const newSet = new Set(selectedCategories); const newSet = new Set(selectedCategories);
if (newSet.has(category)) { if (newSet.has(category)) {
@ -40,17 +43,55 @@ export default function POIPane({
}; };
const selectAll = () => { const selectAll = () => {
onCategoriesChange(new Set(categories)); onCategoriesChange(new Set(allCategories));
}; };
const selectNone = () => { const selectNone = () => {
onCategoriesChange(new Set()); onCategoriesChange(new Set());
}; };
const filteredCategories = categories.filter((cat) => const toggleGroup = useCallback(
cat.toLowerCase().includes(searchTerm.toLowerCase()) (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; const selectedCount = selectedCategories.size;
return ( return (
@ -58,7 +99,6 @@ export default function POIPane({
<h2 className="text-xl font-bold">Points of Interest</h2> <h2 className="text-xl font-bold">Points of Interest</h2>
<div className="space-y-2" ref={dropdownRef}> <div className="space-y-2" ref={dropdownRef}>
<Label>Categories</Label>
<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 rounded hover:border-warm-400 bg-white" 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"> <span className="truncate text-left">
{selectedCount === 0 {selectedCount === 0
? 'Select categories...' ? 'Select categories...'
: selectedCount === categories.length : selectedCount === allCategories.length
? 'All categories' ? 'All categories'
: `${selectedCount} selected`} : `${selectedCount} selected`}
</span> </span>
@ -101,20 +141,69 @@ export default function POIPane({
/> />
</div> </div>
<div className="max-h-96 overflow-y-auto py-1"> <div className="max-h-96 overflow-y-auto py-1">
{filteredCategories.map((category) => ( {filteredGroups.map((group) => {
<label const groupSelected = group.categories.filter((c) =>
key={category} selectedCategories.has(c)
className="flex items-center gap-2 px-3 py-1.5 hover:bg-warm-50 cursor-pointer" ).length;
> const allInGroupSelected = groupSelected === group.categories.length;
<input const someInGroupSelected = groupSelected > 0 && !allInGroupSelected;
type="checkbox" const isCollapsed = collapsedGroups.has(group.name) && !searchTerm;
checked={selectedCategories.has(category)}
onChange={() => toggleCategory(category)} return (
className="rounded" <div key={group.name}>
/> <div className="flex items-center gap-1 px-3 py-1.5 bg-warm-50 border-y border-warm-100">
<span className="text-sm flex-1">{category}</span> <button
</label> 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 pl-8 py-1.5 hover:bg-warm-50 cursor-pointer"
>
<input
type="checkbox"
checked={selectedCategories.has(category)}
onChange={() => toggleCategory(category)}
className="rounded"
/>
<span className="text-sm flex-1">{category}</span>
</label>
))}
</div>
);
})}
</div> </div>
</div> </div>
)} )}

View file

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

View file

@ -9,3 +9,17 @@ body,
margin: 0; margin: 0;
padding: 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> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>UK Property Prices Map</title> <title>Narrowit</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

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