Spice up website
This commit is contained in:
parent
f7d586a1e9
commit
7627818e98
9 changed files with 831 additions and 164 deletions
|
|
@ -1,49 +1,10 @@
|
||||||
export default function DataSources() {
|
export default function DataSources({ onNavigate }: { onNavigate: () => void }) {
|
||||||
const sources = [
|
|
||||||
{
|
|
||||||
name: 'Land Registry',
|
|
||||||
url: 'https://www.gov.uk/government/statistical-data-sets/price-paid-data-downloads',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'EPC',
|
|
||||||
url: 'https://epc.opendatacommunities.org/downloads/domestic',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'ArcGIS Postcodes',
|
|
||||||
url: 'https://www.arcgis.com/sharing/rest/content/items/077631e063eb4e1ab43575d01381ec33/data',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'OpenStreetMap',
|
|
||||||
url: 'https://www.openstreetmap.org/copyright',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'IoD 2025',
|
|
||||||
url: 'https://www.gov.uk/government/statistics/english-indices-of-deprivation-2025',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'TfL API',
|
|
||||||
url: 'https://api-portal.tfl.gov.uk/',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute bottom-2 right-2 bg-white/90 backdrop-blur-sm px-3 py-2 rounded shadow-lg text-xs max-w-xs">
|
<button
|
||||||
<div className="font-semibold mb-1 text-gray-700">Data Sources</div>
|
onClick={onNavigate}
|
||||||
<div className="flex flex-wrap gap-x-2 gap-y-0.5">
|
className="absolute bottom-2 right-2 bg-white/90 backdrop-blur-sm px-3 py-2 rounded shadow-lg text-xs text-teal-600 hover:text-teal-800 hover:underline font-semibold transition-colors"
|
||||||
{sources.map((source, idx) => (
|
>
|
||||||
<span key={source.name}>
|
Data Sources
|
||||||
<a
|
</button>
|
||||||
href={source.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 hover:text-blue-800 hover:underline"
|
|
||||||
>
|
|
||||||
{source.name}
|
|
||||||
</a>
|
|
||||||
{idx < sources.length - 1 && <span className="text-gray-400">•</span>}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
175
frontend/src/components/DataSourcesPage.tsx
Normal file
175
frontend/src/components/DataSourcesPage.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
const DATA_SOURCES = [
|
||||||
|
{
|
||||||
|
name: 'Price Paid Data',
|
||||||
|
origin: 'HM Land Registry',
|
||||||
|
use: 'Complete historical property sale prices for England and Wales. Used for the last known sale price of each property.',
|
||||||
|
url: 'https://www.gov.uk/government/statistical-data-sets/price-paid-data-downloads',
|
||||||
|
license: 'Open Government Licence v3.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Energy Performance Certificates (EPC)',
|
||||||
|
origin: 'Ministry of Housing, Communities & Local Government',
|
||||||
|
use: 'Domestic Energy Performance Certificates providing floor area, number of rooms, construction age, energy ratings, property type, and built form. Fuzzy-joined with Price Paid records by address within postcode buckets.',
|
||||||
|
url: 'https://epc.opendatacommunities.org/downloads/domestic',
|
||||||
|
license: 'Open Government Licence v3.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'National Statistics Postcode Lookup (NSPL)',
|
||||||
|
origin: 'ONS / ArcGIS',
|
||||||
|
use: 'Maps postcodes to latitude/longitude, LSOA, and Output Area codes for geolocation and joining area-level datasets.',
|
||||||
|
url: 'https://www.arcgis.com/sharing/rest/content/items/077631e063eb4e1ab43575d01381ec33/data',
|
||||||
|
license: 'Open Government Licence v3.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'English Indices of Deprivation 2025',
|
||||||
|
origin: 'Ministry of Housing, Communities & Local Government',
|
||||||
|
use: 'Relative deprivation scores for 33,755 LSOAs across domains: Income, Employment, Education, Health, Crime, Living Environment, and sub-domains. Joined to properties via LSOA code.',
|
||||||
|
url: 'https://www.gov.uk/government/statistics/english-indices-of-deprivation-2025',
|
||||||
|
license: 'Open Government Licence v3.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Population by Ethnicity (2021 Census)',
|
||||||
|
origin: 'ONS',
|
||||||
|
use: 'Population percentages by ethnic group (Asian, Black, Mixed, White, Other) per Local Authority. Joined via Local Authority District code.',
|
||||||
|
url: 'https://www.ethnicity-facts-figures.service.gov.uk/uk-population-by-ethnicity/national-and-regional-populations/regional-ethnic-diversity/latest/#download-the-data',
|
||||||
|
license: 'Open Government Licence v3.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Street-level Crime Data',
|
||||||
|
origin: 'data.police.uk',
|
||||||
|
use: 'Street-level crime data from 2023 to 2025, aggregated into yearly averages by LSOA and crime type (violence, burglary, anti-social behaviour, drugs, vehicle crime, etc.).',
|
||||||
|
url: 'https://data.police.uk/data/',
|
||||||
|
license: 'Open Government Licence v3.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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.',
|
||||||
|
url: 'https://api-portal.tfl.gov.uk/',
|
||||||
|
license: 'Powered by TfL Open Data',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'OpenStreetMap POIs',
|
||||||
|
origin: 'OpenStreetMap contributors / Geofabrik',
|
||||||
|
use: 'Points of interest extracted from the Great Britain PBF extract. Covers amenities, shops, healthcare, leisure, tourism, and more. Filtered and remapped to friendly category names.',
|
||||||
|
url: 'https://download.geofabrik.de/europe/great-britain-latest.osm.pbf',
|
||||||
|
license: 'Open Data Commons Open Database License (ODbL)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'NaPTAN (Public Transport Stops)',
|
||||||
|
origin: 'Department for Transport',
|
||||||
|
use: 'National Public Transport Access Nodes providing station and stop locations (rail, bus, metro/tram, ferry, airport), merged into the POI dataset.',
|
||||||
|
url: 'https://naptan.dft.gov.uk/naptan/schema/2.4/doc/NaPTANSchemaGuide-2.4-v0.57.pdf',
|
||||||
|
license: 'Open Government Licence v3.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Defra Noise Mapping',
|
||||||
|
origin: 'Defra / Environment Agency',
|
||||||
|
use: 'Strategic noise mapping Round 4 (2022) for road, rail, and airport sources. Lden (day-evening-night 24h weighted average) at 10m grid resolution, modelled at 4m above ground. Sampled at postcode centroids via WCS GeoTIFF tiles.',
|
||||||
|
url: 'https://environment.data.gov.uk/spatialdata/road-noise-all-metrics-england-round-4/wcs',
|
||||||
|
license: 'Open Government Licence v3.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Ofsted School Inspections',
|
||||||
|
origin: 'Ofsted',
|
||||||
|
use: 'Latest inspection outcomes for state-funded schools (as at April 2025). Averaged per postcode to give a local school quality score (1=Outstanding to 4=Inadequate).',
|
||||||
|
url: 'https://www.gov.uk/government/statistical-data-sets/monthly-management-information-ofsteds-school-inspections-outcomes',
|
||||||
|
license: 'Open Government Licence v3.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Ofcom Broadband Performance',
|
||||||
|
origin: 'Ofcom',
|
||||||
|
use: 'Fixed broadband coverage and speeds by Output Area from Connected Nations 2025. Includes max download/upload speeds across different speed tiers.',
|
||||||
|
url: 'https://www.ofcom.org.uk/phones-and-broadband/coverage-and-speeds/connected-nations-20252/data-downloads-2025',
|
||||||
|
license: 'Open Government Licence v3.0',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function DataSourcesPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto bg-warm-50 flex flex-col">
|
||||||
|
<div className="flex-1">
|
||||||
|
<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.
|
||||||
|
</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 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">
|
||||||
|
{source.license}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-warm-500 mb-2">Source: {source.origin}</p>
|
||||||
|
<p className="text-sm text-warm-700 mb-3">{source.use}</p>
|
||||||
|
<a
|
||||||
|
href={source.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-teal-600 hover:text-teal-800 hover:underline break-all"
|
||||||
|
>
|
||||||
|
{source.url}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<ul className="space-y-1.5 text-sm">
|
||||||
|
<li>
|
||||||
|
Contains HM Land Registry data © Crown copyright and database right 2025.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Contains public sector information licensed under the{' '}
|
||||||
|
<a
|
||||||
|
href="https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-teal-400 hover:text-teal-300 hover:underline"
|
||||||
|
>
|
||||||
|
Open Government Licence v3.0
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Contains OS data © Crown copyright and database rights 2025.
|
||||||
|
</li>
|
||||||
|
<li>Powered by TfL Open Data.</li>
|
||||||
|
<li>
|
||||||
|
Contains data from{' '}
|
||||||
|
<a
|
||||||
|
href="https://www.openstreetmap.org/copyright"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-teal-400 hover:text-teal-300 hover:underline"
|
||||||
|
>
|
||||||
|
© OpenStreetMap contributors
|
||||||
|
</a>
|
||||||
|
, available under the{' '}
|
||||||
|
<a
|
||||||
|
href="https://opendatacommons.org/licenses/odbl/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-teal-400 hover:text-teal-300 hover:underline"
|
||||||
|
>
|
||||||
|
Open Data Commons Open Database License (ODbL)
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,9 @@ interface FiltersProps {
|
||||||
onDragChange: (value: [number, number]) => void;
|
onDragChange: (value: [number, number]) => void;
|
||||||
onDragEnd: () => void;
|
onDragEnd: () => void;
|
||||||
zoom: number;
|
zoom: number;
|
||||||
|
pinnedFeature: string | null;
|
||||||
|
onTogglePin: (name: string) => void;
|
||||||
|
onCancelPin: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatValue(value: number): string {
|
function formatValue(value: number): string {
|
||||||
|
|
@ -38,13 +41,16 @@ export default memo(function Filters({
|
||||||
onDragChange,
|
onDragChange,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
zoom,
|
zoom,
|
||||||
|
pinnedFeature,
|
||||||
|
onTogglePin,
|
||||||
|
onCancelPin,
|
||||||
}: FiltersProps) {
|
}: FiltersProps) {
|
||||||
const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name));
|
const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name));
|
||||||
const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name));
|
const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-72 p-4 bg-white shadow-lg space-y-4 overflow-y-auto">
|
<div className="w-72 p-4 bg-white shadow-lg space-y-4 overflow-y-auto">
|
||||||
<div className="text-sm text-slate-500">Zoom: {zoom.toFixed(1)}</div>
|
<div className="text-sm text-warm-500">Zoom: {zoom.toFixed(1)}</div>
|
||||||
|
|
||||||
{/* Add filter dropdown */}
|
{/* Add filter dropdown */}
|
||||||
{availableFeatures.length > 0 && (
|
{availableFeatures.length > 0 && (
|
||||||
|
|
@ -60,7 +66,7 @@ export default memo(function Filters({
|
||||||
</option>
|
</option>
|
||||||
{availableFeatures.map((f) => (
|
{availableFeatures.map((f) => (
|
||||||
<option key={f.name} value={f.name}>
|
<option key={f.name} value={f.name}>
|
||||||
{f.label}
|
{f.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -74,10 +80,10 @@ export default memo(function Filters({
|
||||||
return (
|
return (
|
||||||
<div key={feature.name} className="space-y-1 p-2 rounded">
|
<div key={feature.name} className="space-y-1 p-2 rounded">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs">{feature.label}</Label>
|
<Label className="text-xs">{feature.name}</Label>
|
||||||
<button
|
<button
|
||||||
onClick={() => onRemoveFilter(feature.name)}
|
onClick={() => onRemoveFilter(feature.name)}
|
||||||
className="text-slate-400 hover:text-slate-700 text-sm px-1"
|
className="text-warm-400 hover:text-warm-700 text-sm px-1"
|
||||||
title="Remove filter"
|
title="Remove filter"
|
||||||
>
|
>
|
||||||
x
|
x
|
||||||
|
|
@ -85,13 +91,13 @@ export default memo(function Filters({
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 text-xs mb-1">
|
<div className="flex gap-2 text-xs mb-1">
|
||||||
<button
|
<button
|
||||||
className="text-blue-600 hover:underline"
|
className="text-teal-600 hover:underline"
|
||||||
onClick={() => onFilterChange(feature.name, [...allValues])}
|
onClick={() => onFilterChange(feature.name, [...allValues])}
|
||||||
>
|
>
|
||||||
All
|
All
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="text-blue-600 hover:underline"
|
className="text-teal-600 hover:underline"
|
||||||
onClick={() => onFilterChange(feature.name, [])}
|
onClick={() => onFilterChange(feature.name, [])}
|
||||||
>
|
>
|
||||||
None
|
None
|
||||||
|
|
@ -121,6 +127,7 @@ export default memo(function Filters({
|
||||||
|
|
||||||
// Numeric feature
|
// Numeric feature
|
||||||
const isActive = activeFeature === feature.name;
|
const isActive = activeFeature === feature.name;
|
||||||
|
const isPinned = pinnedFeature === feature.name;
|
||||||
const displayValue =
|
const displayValue =
|
||||||
isActive && dragValue
|
isActive && dragValue
|
||||||
? dragValue
|
? dragValue
|
||||||
|
|
@ -130,19 +137,40 @@ export default memo(function Filters({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={feature.name}
|
key={feature.name}
|
||||||
className={`space-y-1 p-2 rounded ${isActive ? 'ring-2 ring-blue-400 bg-blue-50' : ''}`}
|
className={`space-y-1 p-2 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs">
|
<Label className="text-xs">
|
||||||
{feature.label}: {formatValue(displayValue[0])} - {formatValue(displayValue[1])}
|
{feature.name}: {formatValue(displayValue[0])} - {formatValue(displayValue[1])}
|
||||||
</Label>
|
</Label>
|
||||||
<button
|
<div className="flex items-center gap-0.5">
|
||||||
onClick={() => onRemoveFilter(feature.name)}
|
<button
|
||||||
className="text-slate-400 hover:text-slate-700 text-sm px-1"
|
onClick={() => onTogglePin(feature.name)}
|
||||||
title="Remove filter"
|
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'}
|
||||||
x
|
>
|
||||||
</button>
|
<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"
|
||||||
|
title="Remove filter"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Slider
|
<Slider
|
||||||
min={feature.min!}
|
min={feature.min!}
|
||||||
|
|
@ -157,37 +185,6 @@ export default memo(function Filters({
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<div className="p-3 bg-slate-100 rounded text-xs">
|
|
||||||
<div className="mb-2 font-medium">Color Scale</div>
|
|
||||||
{activeFeature ? (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="h-4 rounded"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
'linear-gradient(to right, rgb(46, 204, 113), rgb(241, 196, 15), rgb(231, 76, 60), rgb(142, 68, 173))',
|
|
||||||
}}
|
|
||||||
></div>
|
|
||||||
<div className="flex justify-between mt-1">
|
|
||||||
<span>Low</span>
|
|
||||||
<span>High</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="h-4 rounded"
|
|
||||||
style={{
|
|
||||||
background: 'linear-gradient(to right, rgb(209, 226, 243), rgb(33, 102, 172))',
|
|
||||||
}}
|
|
||||||
></div>
|
|
||||||
<div className="flex justify-between mt-1">
|
|
||||||
<span>Few</span>
|
|
||||||
<span>Many</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
362
frontend/src/components/HomePage.tsx
Normal file
362
frontend/src/components/HomePage.tsx
Normal file
|
|
@ -0,0 +1,362 @@
|
||||||
|
import { useRef, useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
// --- Floating hex particle canvas that reacts to scroll ---
|
||||||
|
|
||||||
|
const HEX_COUNT = 60;
|
||||||
|
const TAU = Math.PI * 2;
|
||||||
|
|
||||||
|
interface Hex {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
baseY: number;
|
||||||
|
size: number;
|
||||||
|
opacity: number;
|
||||||
|
speed: number; // horizontal drift px/s
|
||||||
|
phase: number; // for gentle bob
|
||||||
|
}
|
||||||
|
|
||||||
|
function initHexes(w: number, h: number): Hex[] {
|
||||||
|
const hexes: Hex[] = [];
|
||||||
|
for (let i = 0; i < HEX_COUNT; i++) {
|
||||||
|
const y = Math.random() * h;
|
||||||
|
hexes.push({
|
||||||
|
x: Math.random() * w,
|
||||||
|
y,
|
||||||
|
baseY: y,
|
||||||
|
size: 8 + Math.random() * 20,
|
||||||
|
opacity: 0.06 + Math.random() * 0.12,
|
||||||
|
speed: 6 + Math.random() * 14,
|
||||||
|
phase: Math.random() * TAU,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return hexes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawHex(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number) {
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const angle = (TAU / 6) * i - Math.PI / 6;
|
||||||
|
const px = cx + r * Math.cos(angle);
|
||||||
|
const py = cy + r * Math.sin(angle);
|
||||||
|
if (i === 0) ctx.moveTo(px, py);
|
||||||
|
else ctx.lineTo(px, py);
|
||||||
|
}
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
function HexCanvas({ scrollProgress }: { scrollProgress: number }) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const hexesRef = useRef<Hex[]>([]);
|
||||||
|
const animRef = useRef(0);
|
||||||
|
const scrollRef = useRef(scrollProgress);
|
||||||
|
scrollRef.current = scrollProgress;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
let w = 0;
|
||||||
|
let h = 0;
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const rect = canvas!.parentElement!.getBoundingClientRect();
|
||||||
|
w = rect.width;
|
||||||
|
h = rect.height;
|
||||||
|
canvas!.width = w * dpr;
|
||||||
|
canvas!.height = h * dpr;
|
||||||
|
canvas!.style.width = `${w}px`;
|
||||||
|
canvas!.style.height = `${h}px`;
|
||||||
|
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
hexesRef.current = initHexes(w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
resize();
|
||||||
|
const ro = new ResizeObserver(resize);
|
||||||
|
ro.observe(canvas.parentElement!);
|
||||||
|
|
||||||
|
let prev = performance.now();
|
||||||
|
|
||||||
|
function frame(now: number) {
|
||||||
|
const dt = (now - prev) / 1000;
|
||||||
|
prev = now;
|
||||||
|
const scroll = scrollRef.current;
|
||||||
|
ctx!.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
// Teal accent color, fade to 0 as user scrolls down
|
||||||
|
const globalAlpha = Math.max(0, 1 - scroll * 2);
|
||||||
|
|
||||||
|
for (const hex of hexesRef.current) {
|
||||||
|
// drift right, wrap
|
||||||
|
hex.x = (hex.x + hex.speed * dt) % (w + hex.size * 2);
|
||||||
|
// gentle vertical bob + parallax push from scroll
|
||||||
|
const bob = Math.sin(now / 1000 + hex.phase) * 8;
|
||||||
|
const parallax = scroll * h * 0.3 * (hex.speed / 20);
|
||||||
|
hex.y = hex.baseY + bob - parallax;
|
||||||
|
|
||||||
|
// wrap vertically
|
||||||
|
if (hex.y < -hex.size * 2) hex.y += h + hex.size * 4;
|
||||||
|
if (hex.y > h + hex.size * 2) hex.y -= h + hex.size * 4;
|
||||||
|
|
||||||
|
ctx!.globalAlpha = hex.opacity * globalAlpha;
|
||||||
|
ctx!.fillStyle = '#00a28c';
|
||||||
|
drawHex(ctx!, hex.x, hex.y, hex.size);
|
||||||
|
ctx!.fill();
|
||||||
|
|
||||||
|
ctx!.globalAlpha = hex.opacity * 0.5 * globalAlpha;
|
||||||
|
ctx!.strokeStyle = '#05c9aa';
|
||||||
|
ctx!.lineWidth = 1;
|
||||||
|
drawHex(ctx!, hex.x, hex.y, hex.size);
|
||||||
|
ctx!.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
animRef.current = requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
animRef.current = requestAnimationFrame(frame);
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(animRef.current);
|
||||||
|
ro.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="absolute inset-0 pointer-events-none"
|
||||||
|
style={{ zIndex: 0 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Fade-in hook ---
|
||||||
|
|
||||||
|
function useFadeInRef() {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
el.classList.add('fade-in-visible');
|
||||||
|
observer.unobserve(el);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.15 }
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Page ---
|
||||||
|
|
||||||
|
export default function HomePage({ onOpenDashboard }: { onOpenDashboard: () => void }) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [scrollProgress, setScrollProgress] = useState(0);
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const max = el.scrollHeight - el.clientHeight;
|
||||||
|
if (max <= 0) return;
|
||||||
|
setScrollProgress(el.scrollTop / max);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
return () => el.removeEventListener('scroll', handleScroll);
|
||||||
|
}, [handleScroll]);
|
||||||
|
|
||||||
|
const heroRef = useFadeInRef();
|
||||||
|
const problemRef = useFadeInRef();
|
||||||
|
const filtersRef = useFadeInRef();
|
||||||
|
const howRef = useFadeInRef();
|
||||||
|
const numbersRef = useFadeInRef();
|
||||||
|
const ctaRef = useFadeInRef();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<p className="text-teal-600 font-semibold tracking-wide uppercase text-sm mb-4">
|
||||||
|
Find where to live, not just what'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 & Wales.<br />
|
||||||
|
<span className="text-teal-600">One map. Your 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'll accept. Narrowit shows you every area that
|
||||||
|
qualifies — instantly.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={onOpenDashboard}
|
||||||
|
className="px-7 py-3.5 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25"
|
||||||
|
>
|
||||||
|
Explore the map
|
||||||
|
</button>
|
||||||
|
<span className="text-warm-400 text-sm">
|
||||||
|
No signup · Free · Open data
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The flip */}
|
||||||
|
<div className="max-w-3xl mx-auto px-6 pb-20">
|
||||||
|
<div ref={problemRef} className="fade-in-section">
|
||||||
|
<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>
|
||||||
|
<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'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>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter showcase */}
|
||||||
|
<div className="max-w-4xl mx-auto px-6 pb-20">
|
||||||
|
<div ref={filtersRef} className="fade-in-section">
|
||||||
|
<h2 className="text-3xl font-bold text-navy-950 mb-2 text-center">
|
||||||
|
12 datasets. One slider each.
|
||||||
|
</h2>
|
||||||
|
<p className="text-warm-500 text-center mb-10 max-w-lg mx-auto">
|
||||||
|
Every filter narrows the map in real time. Combine as many as you like.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
{FILTERS.map((f) => (
|
||||||
|
<div
|
||||||
|
key={f.label}
|
||||||
|
className="rounded-xl bg-white border border-warm-200 p-4 shadow-sm hover:shadow-md hover:border-teal-300 transition-all"
|
||||||
|
>
|
||||||
|
<div className="text-2xl mb-2">{f.icon}</div>
|
||||||
|
<div className="font-semibold text-navy-950 text-sm">{f.label}</div>
|
||||||
|
<div className="text-xs text-warm-500 mt-0.5">{f.example}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* How it works */}
|
||||||
|
<div className="max-w-3xl mx-auto px-6 pb-20">
|
||||||
|
<div ref={howRef} className="fade-in-section">
|
||||||
|
<h2 className="text-3xl font-bold text-navy-950 mb-10 text-center">
|
||||||
|
Three clicks to clarity
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{STEPS.map((step, i) => (
|
||||||
|
<div key={i} className="flex gap-5 items-start">
|
||||||
|
<span className="shrink-0 w-10 h-10 rounded-full bg-teal-600 text-white flex items-center justify-center text-lg font-bold">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-navy-950 text-lg">{step.title}</h3>
|
||||||
|
<p className="text-warm-600 mt-0.5">{step.body}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Numbers */}
|
||||||
|
<div className="max-w-3xl mx-auto px-6 pb-20">
|
||||||
|
<div ref={numbersRef} className="fade-in-section">
|
||||||
|
<div className="grid grid-cols-3 gap-6 text-center">
|
||||||
|
{STATS.map((s) => (
|
||||||
|
<div key={s.label}>
|
||||||
|
<div className="text-3xl font-extrabold text-teal-600">{s.value}</div>
|
||||||
|
<div className="text-sm text-warm-500 mt-1">{s.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
<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>
|
||||||
|
<button
|
||||||
|
onClick={onOpenDashboard}
|
||||||
|
className="px-8 py-4 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
|
||||||
|
>
|
||||||
|
Open the map
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Data ---
|
||||||
|
|
||||||
|
const FILTERS = [
|
||||||
|
{ icon: '\u00A3', label: 'Sale price', example: 'e.g. under \u00A3400k' },
|
||||||
|
{ icon: '\uD83D\uDE86', label: 'Commute time', example: 'e.g. < 45 min to Bank' },
|
||||||
|
{ icon: '\uD83C\uDFEB', label: 'School quality', example: 'Ofsted Outstanding' },
|
||||||
|
{ icon: '\uD83D\uDEA8', label: 'Crime rate', example: 'Low burglary areas' },
|
||||||
|
{ icon: '\u26A1', label: 'Energy rating', example: 'EPC band A\u2013C' },
|
||||||
|
{ icon: '\uD83D\uDCCF', label: 'Floor area', example: 'e.g. 80+ sqm' },
|
||||||
|
{ icon: '\uD83D\uDD07', label: 'Road noise', example: 'Below 55 dB Lden' },
|
||||||
|
{ icon: '\uD83C\uDF10', label: 'Broadband speed', example: '100+ Mbps available' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{
|
||||||
|
title: 'Add your deal-breakers',
|
||||||
|
body: 'Slide the filters for everything you care about \u2014 price cap, max commute, school quality, noise. The map updates as you drag.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Spot the clusters',
|
||||||
|
body: 'Hexagons light up where properties match. Zoom in and they split into finer cells. At street level you see individual postcode boundaries.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Dive into a neighbourhood',
|
||||||
|
body: 'Click any hexagon to see every property inside it \u2014 sale prices, floor plans, energy ratings, tenure. Layer on cafes, GP surgeries, and parks from OpenStreetMap.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATS = [
|
||||||
|
{ value: '26M+', label: 'property records' },
|
||||||
|
{ value: '12', label: 'open datasets' },
|
||||||
|
{ value: '1.7M', label: 'postcodes mapped' },
|
||||||
|
];
|
||||||
|
|
@ -3,21 +3,26 @@ 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 } from '@deck.gl/layers';
|
import { IconLayer, PolygonLayer } 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 } from '../types';
|
import type { HexagonData, ViewState, ViewChangeParams, Bounds, POI, FeatureMeta, PostcodeData } from '../types';
|
||||||
|
|
||||||
interface MapProps {
|
interface MapProps {
|
||||||
data: HexagonData[];
|
data: HexagonData[];
|
||||||
pois: POI[];
|
pois: POI[];
|
||||||
onViewChange: (params: ViewChangeParams) => void;
|
onViewChange: (params: ViewChangeParams) => void;
|
||||||
activeFeature: string | null;
|
viewFeature: string | null;
|
||||||
dragValue: [number, number] | null;
|
viewRange: [number, number] | null;
|
||||||
|
viewSource: 'drag' | 'eye' | null;
|
||||||
|
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
|
||||||
|
|
@ -123,7 +128,8 @@ function DeckOverlay({
|
||||||
layers,
|
layers,
|
||||||
getTooltip,
|
getTooltip,
|
||||||
}: {
|
}: {
|
||||||
layers: (H3HexagonLayer<HexagonData> | IconLayer<POI>)[];
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
layers: any[];
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
getTooltip: any;
|
getTooltip: any;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -207,7 +213,7 @@ function PostcodeSearch({
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="px-3 py-2 bg-blue-500 text-white text-sm hover:bg-blue-600 disabled:opacity-50"
|
className="px-3 py-2 bg-teal-600 text-white text-sm hover:bg-teal-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{loading ? '...' : 'Go'}
|
{loading ? '...' : 'Go'}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -221,16 +227,70 @@ function PostcodeSearch({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MapLegend({
|
||||||
|
featureLabel,
|
||||||
|
range,
|
||||||
|
showCancel,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
featureLabel: string;
|
||||||
|
range: [number, number];
|
||||||
|
showCancel: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const formatVal = (v: number) => {
|
||||||
|
if (Math.abs(v) >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (Math.abs(v) >= 1_000) return `${(v / 1_000).toFixed(1)}k`;
|
||||||
|
if (Number.isInteger(v)) return v.toString();
|
||||||
|
return v.toFixed(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute top-3 right-3 z-10 bg-white rounded shadow-lg p-3 text-xs min-w-[160px]">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="font-semibold text-sm">{featureLabel}</span>
|
||||||
|
{showCancel && (
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
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}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="h-3 rounded"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(to right, rgb(46, 204, 113), rgb(241, 196, 15), rgb(231, 76, 60), rgb(142, 68, 173))',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between mt-1 text-warm-600">
|
||||||
|
<span>{formatVal(range[0])}</span>
|
||||||
|
<span>{formatVal(range[1])}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default memo(function Map({
|
export default memo(function Map({
|
||||||
data,
|
data,
|
||||||
pois,
|
pois,
|
||||||
onViewChange,
|
onViewChange,
|
||||||
activeFeature,
|
viewFeature,
|
||||||
dragValue,
|
viewRange,
|
||||||
|
viewSource,
|
||||||
|
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);
|
||||||
|
|
@ -332,15 +392,15 @@ export default memo(function Map({
|
||||||
|
|
||||||
// Memoize feature lookup to avoid new reference each render
|
// Memoize feature lookup to avoid new reference each render
|
||||||
const colorFeatureMeta = useMemo(
|
const colorFeatureMeta = useMemo(
|
||||||
() => (activeFeature ? features.find((f) => f.name === activeFeature) || null : null),
|
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
|
||||||
[activeFeature, features]
|
[viewFeature, features]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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 activeFeatureRef = useRef(activeFeature);
|
const viewFeatureRef = useRef(viewFeature);
|
||||||
activeFeatureRef.current = activeFeature;
|
viewFeatureRef.current = viewFeature;
|
||||||
const dragValueRef = useRef(dragValue);
|
const viewRangeRef = useRef(viewRange);
|
||||||
dragValueRef.current = dragValue;
|
viewRangeRef.current = viewRange;
|
||||||
const colorFeatureMetaRef = useRef(colorFeatureMeta);
|
const colorFeatureMetaRef = useRef(colorFeatureMeta);
|
||||||
colorFeatureMetaRef.current = colorFeatureMeta;
|
colorFeatureMetaRef.current = colorFeatureMeta;
|
||||||
const countRangeRef = useRef(countRange);
|
const countRangeRef = useRef(countRange);
|
||||||
|
|
@ -348,6 +408,10 @@ 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;
|
||||||
|
|
@ -360,6 +424,17 @@ export default memo(function Map({
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
handlePoiHoverRef.current = handlePoiHover;
|
handlePoiHoverRef.current = handlePoiHover;
|
||||||
|
|
@ -368,7 +443,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 = `${activeFeature}|${dragValue?.[0]}|${dragValue?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}`;
|
const colorTrigger = `${viewFeature}|${viewRange?.[0]}|${viewRange?.[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(
|
||||||
|
|
@ -378,17 +453,17 @@ export default memo(function Map({
|
||||||
data,
|
data,
|
||||||
getHexagon: (d) => d.h3,
|
getHexagon: (d) => d.h3,
|
||||||
getFillColor: (d) => {
|
getFillColor: (d) => {
|
||||||
const af = activeFeatureRef.current;
|
const vf = viewFeatureRef.current;
|
||||||
const dv = dragValueRef.current;
|
const vr = viewRangeRef.current;
|
||||||
const cfm = colorFeatureMetaRef.current;
|
const cfm = colorFeatureMetaRef.current;
|
||||||
if (af && dv && cfm) {
|
if (vf && vr && cfm) {
|
||||||
const val = d[`min_${af}`];
|
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 = dv[0];
|
const min = vr[0];
|
||||||
const max = dv[1];
|
const max = vr[1];
|
||||||
const minVal = d[`min_${af}`] as number;
|
const minVal = d[`min_${vf}`] as number;
|
||||||
const maxVal = d[`max_${af}`] as number;
|
const maxVal = d[`max_${vf}`] as number;
|
||||||
// Gray out hexagons outside drag range
|
// Gray out hexagons outside range
|
||||||
if (maxVal < min || minVal > max) {
|
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];
|
||||||
}
|
}
|
||||||
|
|
@ -428,6 +503,79 @@ export default memo(function Map({
|
||||||
[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(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -449,23 +597,34 @@ export default memo(function Map({
|
||||||
[pois, stablePoiHover]
|
[pois, stablePoiHover]
|
||||||
);
|
);
|
||||||
|
|
||||||
const layers = useMemo(() => [hexLayer, poiLayer], [hexLayer, poiLayer]);
|
const layers = useMemo(
|
||||||
|
() => (postcodeData.length > 0 ? [postcodeLayer, poiLayer] : [hexLayer, poiLayer]),
|
||||||
|
[postcodeData.length, postcodeLayer, hexLayer, poiLayer]
|
||||||
|
);
|
||||||
|
|
||||||
// Tooltip uses refs to avoid being a layer dependency
|
// Tooltip uses refs to avoid being a layer dependency
|
||||||
const featuresRef = useRef(features);
|
const featuresRef = useRef(features);
|
||||||
featuresRef.current = features;
|
featuresRef.current = features;
|
||||||
|
|
||||||
const getTooltip = useCallback(
|
const getTooltip = useCallback(
|
||||||
({ object }: { object?: HexagonData }) => {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
if (!object || !('h3' in object)) return null;
|
({ 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;
|
||||||
|
|
||||||
const hex = object;
|
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
lines.push(`<strong>${(hex.count as number).toLocaleString()} properties</strong>`);
|
if (isPostcode) {
|
||||||
|
lines.push(`<strong>${object.postcode}</strong>`);
|
||||||
|
}
|
||||||
|
lines.push(`<div>${(object.count as number).toLocaleString()} properties</div>`);
|
||||||
|
|
||||||
for (const f of featuresRef.current) {
|
for (const f of featuresRef.current) {
|
||||||
const minVal = hex[`min_${f.name}`];
|
const minVal = object[`min_${f.name}`];
|
||||||
const maxVal = hex[`max_${f.name}`];
|
const maxVal = object[`max_${f.name}`];
|
||||||
if (minVal != null && maxVal != null) {
|
if (minVal != null && maxVal != null) {
|
||||||
const minStr =
|
const minStr =
|
||||||
typeof minVal === 'number'
|
typeof minVal === 'number'
|
||||||
|
|
@ -475,8 +634,8 @@ export default memo(function Map({
|
||||||
typeof maxVal === 'number'
|
typeof maxVal === 'number'
|
||||||
? maxVal.toLocaleString(undefined, { maximumFractionDigits: 1 })
|
? maxVal.toLocaleString(undefined, { maximumFractionDigits: 1 })
|
||||||
: String(maxVal);
|
: String(maxVal);
|
||||||
const highlight = f.name === activeFeatureRef.current ? 'font-weight: bold;' : '';
|
const highlight = f.name === viewFeatureRef.current ? 'font-weight: bold;' : '';
|
||||||
lines.push(`<div style="${highlight}">${f.label}: ${minStr} - ${maxStr}</div>`);
|
lines.push(`<div style="${highlight}">${f.name}: ${minStr} - ${maxStr}</div>`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -505,6 +664,14 @@ 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 && (
|
||||||
|
<MapLegend
|
||||||
|
featureLabel={colorFeatureMeta.name}
|
||||||
|
range={viewRange}
|
||||||
|
showCancel={viewSource === 'eye'}
|
||||||
|
onCancel={onCancelPin}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{popupInfo && (
|
{popupInfo && (
|
||||||
<div
|
<div
|
||||||
className="absolute pointer-events-none bg-white rounded shadow-lg p-2 text-sm"
|
className="absolute pointer-events-none bg-white rounded shadow-lg p-2 text-sm"
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ export default function POIPane({
|
||||||
<Label>Categories</Label>
|
<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-slate-300 rounded hover:border-slate-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"
|
||||||
>
|
>
|
||||||
<span className="truncate text-left">
|
<span className="truncate text-left">
|
||||||
{selectedCount === 0
|
{selectedCount === 0
|
||||||
|
|
@ -81,30 +81,30 @@ export default function POIPane({
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{dropdownOpen && (
|
{dropdownOpen && (
|
||||||
<div className="border border-slate-300 rounded shadow-lg bg-white">
|
<div className="border border-warm-300 rounded shadow-lg bg-white">
|
||||||
<div className="flex gap-2 px-3 py-2 border-b border-slate-200">
|
<div className="flex gap-2 px-3 py-2 border-b border-warm-200">
|
||||||
<button onClick={selectAll} className="text-xs text-blue-600 hover:text-blue-800">
|
<button onClick={selectAll} className="text-xs text-teal-600 hover:text-teal-800">
|
||||||
All
|
All
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xs text-slate-300">|</span>
|
<span className="text-xs text-warm-300">|</span>
|
||||||
<button onClick={selectNone} className="text-xs text-blue-600 hover:text-blue-800">
|
<button onClick={selectNone} className="text-xs text-teal-600 hover:text-teal-800">
|
||||||
None
|
None
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-2 border-b border-slate-200">
|
<div className="px-3 py-2 border-b border-warm-200">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search categories..."
|
placeholder="Search categories..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="w-full px-2 py-1 text-sm border border-slate-300 rounded"
|
className="w-full px-2 py-1 text-sm border border-warm-300 rounded"
|
||||||
/>
|
/>
|
||||||
</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) => (
|
{filteredCategories.map((category) => (
|
||||||
<label
|
<label
|
||||||
key={category}
|
key={category}
|
||||||
className="flex items-center gap-2 px-3 py-1.5 hover:bg-slate-50 cursor-pointer"
|
className="flex items-center gap-2 px-3 py-1.5 hover:bg-warm-50 cursor-pointer"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
@ -121,17 +121,17 @@ export default function POIPane({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedCount > 0 && (
|
{selectedCount > 0 && (
|
||||||
<div className="p-3 bg-blue-50 rounded text-sm">
|
<div className="p-3 bg-teal-50 rounded text-sm">
|
||||||
<div className="font-medium text-blue-900">
|
<div className="font-medium text-teal-900">
|
||||||
{poiCount.toLocaleString()} POI{poiCount !== 1 ? 's' : ''} visible
|
{poiCount.toLocaleString()} POI{poiCount !== 1 ? 's' : ''} visible
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-blue-700 mt-1">
|
<div className="text-xs text-teal-700 mt-1">
|
||||||
{selectedCount} categor{selectedCount !== 1 ? 'ies' : 'y'} selected
|
{selectedCount} categor{selectedCount !== 1 ? 'ies' : 'y'} selected
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="p-3 bg-slate-100 rounded text-xs text-slate-600">
|
<div className="p-3 bg-warm-100 rounded text-xs text-warm-600">
|
||||||
<p>Select categories to display POIs on the map.</p>
|
<p>Select categories to display POIs on the map.</p>
|
||||||
<p className="mt-2">Zoom in for better visibility of individual locations.</p>
|
<p className="mt-2">Zoom in for better visibility of individual locations.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
@ -17,6 +18,7 @@ export function PropertiesPane({
|
||||||
total,
|
total,
|
||||||
loading,
|
loading,
|
||||||
hexagonId,
|
hexagonId,
|
||||||
|
postcodeId,
|
||||||
onLoadMore,
|
onLoadMore,
|
||||||
onClose,
|
onClose,
|
||||||
}: PropertiesPaneProps) {
|
}: PropertiesPaneProps) {
|
||||||
|
|
@ -36,10 +38,11 @@ export function PropertiesPane({
|
||||||
});
|
});
|
||||||
}, [properties, sortBy]);
|
}, [properties, sortBy]);
|
||||||
|
|
||||||
if (!hexagonId) {
|
const selectionId = hexagonId || postcodeId;
|
||||||
|
if (!selectionId) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full text-gray-500">
|
<div className="flex items-center justify-center h-full text-warm-500">
|
||||||
Click a hexagon to view properties
|
Click a hexagon or postcode to view properties
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -47,27 +50,29 @@ export function PropertiesPane({
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-4 border-b border-gray-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">Properties in Hexagon</h2>
|
<h2 className="text-lg font-semibold">
|
||||||
|
{postcodeId ? `Properties in ${postcodeId}` : 'Properties in Hexagon'}
|
||||||
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-gray-500 hover:text-gray-700 text-2xl leading-none"
|
className="text-warm-500 hover:text-warm-700 text-2xl leading-none"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-warm-600">
|
||||||
Showing {properties.length} of {total} properties
|
Showing {properties.length} of {total} properties
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sort controls */}
|
{/* Sort controls */}
|
||||||
<div className="p-2 border-b border-gray-200">
|
<div className="p-2 border-b border-warm-200">
|
||||||
<select
|
<select
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
onChange={(e) => setSortBy(e.target.value as SortBy)}
|
onChange={(e) => setSortBy(e.target.value as SortBy)}
|
||||||
className="w-full p-2 border border-gray-300 rounded text-sm"
|
className="w-full p-2 border border-warm-300 rounded text-sm"
|
||||||
>
|
>
|
||||||
<option value="price">Price (High to Low)</option>
|
<option value="price">Price (High to Low)</option>
|
||||||
<option value="size">Size (Large to Small)</option>
|
<option value="size">Size (Large to Small)</option>
|
||||||
|
|
@ -88,7 +93,7 @@ export function PropertiesPane({
|
||||||
<button
|
<button
|
||||||
onClick={onLoadMore}
|
onClick={onLoadMore}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full p-4 text-blue-600 hover:bg-blue-50 disabled:opacity-50"
|
className="w-full p-4 text-teal-600 hover:bg-teal-50 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{loading ? 'Loading...' : `Load More (${total - properties.length} remaining)`}
|
{loading ? 'Loading...' : `Load More (${total - properties.length} remaining)`}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -135,17 +140,17 @@ function PropertyCard({ property }: { property: Property }) {
|
||||||
const age = getNum(property, 'Approximate construction age', 'construction_age_band');
|
const age = getNum(property, 'Approximate construction age', 'construction_age_band');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 border-b border-gray-100 hover:bg-gray-50">
|
<div className="p-4 border-b border-warm-100 hover:bg-warm-50">
|
||||||
{/* Address & postcode */}
|
{/* Address & postcode */}
|
||||||
<div className="font-semibold">{property.address || 'Unknown Address'}</div>
|
<div className="font-semibold">{property.address || 'Unknown Address'}</div>
|
||||||
<div className="text-sm text-gray-600">{property.postcode}</div>
|
<div className="text-sm text-warm-600">{property.postcode}</div>
|
||||||
|
|
||||||
{/* Price */}
|
{/* Price */}
|
||||||
{price !== undefined && (
|
{price !== undefined && (
|
||||||
<div className="mt-2 text-lg font-bold text-green-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-gray-600">
|
<span className="text-sm font-normal text-warm-600">
|
||||||
{' '}
|
{' '}
|
||||||
(£{fmt(pricePerSqm)}/m²)
|
(£{fmt(pricePerSqm)}/m²)
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -157,42 +162,42 @@ function PropertyCard({ property }: { property: Property }) {
|
||||||
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
||||||
{property.property_type && (
|
{property.property_type && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500">Type:</span> {property.property_type}
|
<span className="text-warm-500">Type:</span> {property.property_type}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{property.built_form && (
|
{property.built_form && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500">Built form:</span> {property.built_form}
|
<span className="text-warm-500">Built form:</span> {property.built_form}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{property.duration && (
|
{property.duration && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500">Tenure:</span> {formatDuration(property.duration)}
|
<span className="text-warm-500">Tenure:</span> {formatDuration(property.duration)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{floorArea !== undefined && (
|
{floorArea !== undefined && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500">Floor area:</span> {fmt(floorArea)}m²
|
<span className="text-warm-500">Floor area:</span> {fmt(floorArea)}m²
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{rooms !== undefined && (
|
{rooms !== undefined && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500">Rooms:</span> {fmt(rooms)}
|
<span className="text-warm-500">Rooms:</span> {fmt(rooms)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{age !== undefined && (
|
{age !== undefined && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500">Built:</span> {formatAge(age)}
|
<span className="text-warm-500">Built:</span> {formatAge(age)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{property.current_energy_rating && (
|
{property.current_energy_rating && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500">EPC rating:</span> {property.current_energy_rating}
|
<span className="text-warm-500">EPC rating:</span> {property.current_energy_rating}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{property.potential_energy_rating && (
|
{property.potential_energy_rating && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500">EPC potential:</span>{' '}
|
<span className="text-warm-500">EPC potential:</span>{' '}
|
||||||
{property.potential_energy_rating}
|
{property.potential_energy_rating}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,6 @@ interface LabelProps {
|
||||||
|
|
||||||
export function Label({ children, className }: LabelProps) {
|
export function Label({ children, className }: LabelProps) {
|
||||||
return (
|
return (
|
||||||
<label className={`text-sm font-medium text-slate-700 ${className || ''}`}>{children}</label>
|
<label className={`text-sm font-medium text-warm-700 ${className || ''}`}>{children}</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,13 @@ export function Slider({ className, ...props }: SliderProps) {
|
||||||
className={cn('relative flex w-full touch-none select-none items-center', className)}
|
className={cn('relative flex w-full touch-none select-none items-center', className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-slate-200">
|
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-warm-200">
|
||||||
<SliderPrimitive.Range className="absolute h-full bg-slate-900" />
|
<SliderPrimitive.Range className="absolute h-full bg-teal-600" />
|
||||||
</SliderPrimitive.Track>
|
</SliderPrimitive.Track>
|
||||||
{props.value?.map((_, i) => (
|
{props.value?.map((_, i) => (
|
||||||
<SliderPrimitive.Thumb
|
<SliderPrimitive.Thumb
|
||||||
key={i}
|
key={i}
|
||||||
className="block h-5 w-5 rounded-full border-2 border-slate-900 bg-white ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
className="block h-5 w-5 rounded-full border-2 border-teal-600 bg-white ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SliderPrimitive.Root>
|
</SliderPrimitive.Root>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue