This commit is contained in:
Andras Schmelczer 2026-02-02 21:56:35 +00:00
parent 2c613dc0d1
commit a677b9331f
28 changed files with 1647 additions and 1498 deletions

View file

@ -1,12 +1,10 @@
import { useMemo } from 'react';
import type { FeatureFilters, FeatureMeta, HexagonStatsResponse } from '../types';
interface HexagonLocation {
lat: number;
lon: number;
postcode: string | null;
resolution: number;
}
import type { HexagonLocation } from '../lib/external-search';
import { formatValue } from '../lib/format';
import { DualHistogram, LoadingSkeleton } from './DualHistogram';
import EnumBarChart from './EnumBarChart';
import ExternalSearchLinks from './ExternalSearchLinks';
interface AreaPaneProps {
stats: HexagonStatsResponse | null;
@ -22,17 +20,7 @@ interface AreaPaneProps {
filters: FeatureFilters;
}
function formatValue(value: number): string {
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(0)}k`;
if (Number.isInteger(value)) return value.toLocaleString();
return value.toFixed(1);
}
// Group features by their group field from globalFeatures
function groupFeatures(
globalFeatures: FeatureMeta[]
): { name: string; features: FeatureMeta[] }[] {
function groupFeatures(globalFeatures: FeatureMeta[]): { name: string; features: FeatureMeta[] }[] {
const groups: { name: string; features: FeatureMeta[] }[] = [];
const seen = new Set<string>();
for (const feature of globalFeatures) {
@ -46,302 +34,6 @@ function groupFeatures(
return groups;
}
function downsampleBars(counts: number[], targetBars: number): number[] {
const step = Math.max(1, Math.floor(counts.length / targetBars));
const bars: number[] = [];
for (let index = 0; index < counts.length; index += step) {
let sum = 0;
for (let offset = 0; offset < step && index + offset < counts.length; offset++) {
sum += counts[index + offset];
}
bars.push(sum);
}
return bars;
}
function DualHistogram({
localCounts,
globalCounts,
min,
max,
globalMean,
}: {
localCounts: number[];
globalCounts: number[];
min: number;
max: number;
globalMean?: number;
}) {
const targetBars = 25;
const localBars = downsampleBars(localCounts, targetBars);
const globalBars = downsampleBars(globalCounts, targetBars);
const barCount = Math.min(localBars.length, globalBars.length);
const localMax = Math.max(...localBars, 1);
const globalMax = Math.max(...globalBars, 1);
const meanFraction =
globalMean != null && max > min ? (globalMean - min) / (max - min) : null;
return (
<div className="mt-1">
<div className="relative flex items-end gap-px h-10">
{Array.from({ length: barCount }).map((_, index) => {
const globalHeight = (globalBars[index] / globalMax) * 100;
const localHeight = (localBars[index] / localMax) * 100;
return (
<div key={index} className="flex-1 relative min-w-[2px] h-full flex items-end">
<div
className="absolute bottom-0 left-0 right-0 bg-warm-300/40 dark:bg-warm-600/40 rounded-t-sm"
style={{ height: `${globalHeight}%` }}
/>
<div
className="absolute bottom-0 left-0 right-0 bg-teal-500 dark:bg-teal-400 rounded-t-sm"
style={{
height: `${localHeight}%`,
opacity: localBars[index] > 0 ? 1 : 0.1,
}}
/>
</div>
);
})}
{meanFraction != null && meanFraction >= 0 && meanFraction <= 1 && (
<div
className="absolute bottom-0 top-0 w-px border-l border-dashed border-warm-400 dark:border-warm-500"
style={{ left: `${meanFraction * 100}%` }}
/>
)}
</div>
</div>
);
}
function SkeletonHistogram() {
return (
<div className="bg-warm-50 dark:bg-warm-800 rounded p-2 animate-pulse">
<div className="flex justify-between items-baseline">
<div className="h-3 w-24 bg-warm-200 dark:bg-warm-700 rounded" />
<div className="h-3 w-10 bg-warm-200 dark:bg-warm-700 rounded" />
</div>
<div className="flex items-end gap-px h-10 mt-2">
{Array.from({ length: 15 }).map((_, i) => (
<div
key={i}
className="flex-1 bg-warm-200 dark:bg-warm-700 rounded-t-sm min-w-[2px]"
style={{ height: `${20 + Math.sin(i * 0.7) * 30 + 30}%` }}
/>
))}
</div>
<div className="flex justify-between mt-1">
<div className="h-2 w-6 bg-warm-200 dark:bg-warm-700 rounded" />
<div className="h-2 w-6 bg-warm-200 dark:bg-warm-700 rounded" />
</div>
</div>
);
}
function LoadingSkeleton() {
return (
<div className="p-3 space-y-4">
{[0, 1, 2].map((groupIdx) => (
<div key={groupIdx}>
<div className="h-3 w-20 bg-warm-200 dark:bg-warm-700 rounded animate-pulse mb-2" />
<div className="space-y-3">
{Array.from({ length: groupIdx === 0 ? 3 : 2 }).map((_, i) => (
<SkeletonHistogram key={i} />
))}
</div>
</div>
))}
</div>
);
}
// Map app property types to each site's expected values
const PROPERTY_TYPE_MAP: Record<string, { rightmove: string; onthemarket: string; zoopla: string }> = {
'House': { rightmove: 'detached,semi-detached,terraced', onthemarket: 'property', zoopla: '' },
'Detached': { rightmove: 'detached', onthemarket: 'detached', zoopla: 'detached' },
'Semi-Detached': { rightmove: 'semi-detached', onthemarket: 'semi-detached', zoopla: 'semi_detached' },
'Mid-Terrace': { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' },
'End-Terrace': { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' },
'Enclosed Mid-Terrace': { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' },
'Enclosed End-Terrace': { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' },
'Flat': { rightmove: 'flat', onthemarket: 'flats', zoopla: 'flat' },
'Maisonette': { rightmove: 'flat', onthemarket: 'flats', zoopla: 'flat' },
'Bungalow': { rightmove: 'bungalow', onthemarket: 'bungalow', zoopla: 'bungalow' },
'Park home': { rightmove: 'park-home', onthemarket: 'property', zoopla: '' },
};
// Approximate H3 hex edge length in miles by resolution
// See https://h3geo.org/docs/core-library/restable
const H3_RADIUS_MILES: Record<number, number> = {
4: 15, // ~24km edge → ~15mi
5: 6, // ~9km → ~6mi
6: 3, // ~3.5km → ~3mi
7: 1, // ~1.3km → ~1mi
8: 0.5, // ~0.5km → ~0.3mi, round up
9: 0.25, // ~0.17km
10: 0.25, // ~0.07km
11: 0.25, // ~0.025km
12: 0.25,
};
// Rightmove only accepts specific radius values
const RIGHTMOVE_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 30, 40];
// OnTheMarket and Zoopla accept similar sets
const OTM_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 30, 40];
const ZOOPLA_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 25, 30];
function nearestRadius(target: number, allowed: number[]): number {
return allowed.reduce((best, r) => (Math.abs(r - target) < Math.abs(best - target) ? r : best));
}
function buildPropertySearchUrls(
location: HexagonLocation,
filters: FeatureFilters
): { rightmove: string; onthemarket: string; zoopla: string } {
const { lat, lon, postcode, resolution } = location;
const radiusMiles = H3_RADIUS_MILES[resolution] ?? 1;
const coordStr = `${lat.toFixed(5)},${lon.toFixed(5)}`;
// Extract price filters
const priceFilter = filters['Last known price'];
const minPrice = Array.isArray(priceFilter) && typeof priceFilter[0] === 'number' ? priceFilter[0] : undefined;
const maxPrice = Array.isArray(priceFilter) && typeof priceFilter[1] === 'number' ? priceFilter[1] : undefined;
// Extract property type filters
const propertyTypes = filters['Property type'];
const selectedTypes = Array.isArray(propertyTypes) && typeof propertyTypes[0] === 'string' ? propertyTypes as string[] : [];
// --- Rightmove ---
// Rightmove accepts both postcodes and lat,lon in searchLocation
const rmParams = new URLSearchParams();
rmParams.set('searchLocation', postcode || coordStr);
rmParams.set('channel', 'BUY');
rmParams.set('radius', String(nearestRadius(radiusMiles, RIGHTMOVE_RADII)));
if (minPrice !== undefined) rmParams.set('minPrice', String(Math.round(minPrice)));
if (maxPrice !== undefined) rmParams.set('maxPrice', String(Math.round(maxPrice)));
if (selectedTypes.length > 0) {
const rmTypes = [...new Set(selectedTypes.flatMap((t) => {
const mapped = PROPERTY_TYPE_MAP[t]?.rightmove;
return mapped ? mapped.split(',') : [];
}))];
if (rmTypes.length > 0) rmParams.set('propertyTypes', rmTypes.join(','));
}
const rightmove = `https://www.rightmove.co.uk/property-for-sale/find.html?${rmParams.toString()}`;
// --- OnTheMarket ---
let otmType = 'property';
if (selectedTypes.length > 0) {
const otmTypes = [...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean))];
if (otmTypes.length === 1 && otmTypes[0] !== 'property') otmType = otmTypes[0]!;
}
const otmParams = new URLSearchParams();
otmParams.set('radius', String(nearestRadius(radiusMiles, OTM_RADII)));
if (minPrice !== undefined) otmParams.set('min-price', String(Math.round(minPrice)));
if (maxPrice !== undefined) otmParams.set('max-price', String(Math.round(maxPrice)));
let onthemarket: string;
if (postcode) {
const slug = postcode.replace(/\s+/g, '-').toLowerCase();
onthemarket = `https://www.onthemarket.com/for-sale/${otmType}/${slug}/?${otmParams.toString()}`;
} else {
// Use lat/lon search with geo params for bigger hexagons without a postcode
otmParams.set('search-site', 'geo');
otmParams.set('geo-lat', String(lat));
otmParams.set('geo-lng', String(lon));
onthemarket = `https://www.onthemarket.com/for-sale/${otmType}/?${otmParams.toString()}`;
}
// --- Zoopla ---
const zParams = new URLSearchParams();
zParams.set('q', postcode || coordStr);
zParams.set('search_source', 'for-sale');
zParams.set('radius', String(nearestRadius(radiusMiles, ZOOPLA_RADII)));
if (minPrice !== undefined) zParams.set('price_min', String(Math.round(minPrice)));
if (maxPrice !== undefined) zParams.set('price_max', String(Math.round(maxPrice)));
if (selectedTypes.length > 0) {
const zTypes = [...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.zoopla).filter(Boolean))];
for (const zt of zTypes) {
zParams.append('property_sub_type', zt!);
}
}
let zoopla: string;
if (postcode) {
const slug = postcode.replace(/\s+/g, '-').toLowerCase();
zoopla = `https://www.zoopla.co.uk/for-sale/property/${slug}/?${zParams.toString()}`;
} else {
// Use coordinate-based path for bigger hexagons
zParams.set('geo_autocomplete_identifier', `geo_${lat}_${lon}`);
zoopla = `https://www.zoopla.co.uk/for-sale/property/?${zParams.toString()}`;
}
return { rightmove, onthemarket, zoopla };
}
function ExternalSearchLinks({ location, filters }: { location: HexagonLocation; filters: FeatureFilters }) {
const urls = useMemo(() => buildPropertySearchUrls(location, filters), [location, filters]);
const radiusMiles = H3_RADIUS_MILES[location.resolution] ?? 1;
const label = location.postcode || `${radiusMiles}mi radius`;
return (
<div className="p-3 border-b border-warm-200 dark:border-navy-700">
<h3 className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wider mb-2">
Search {label} on
</h3>
<div className="flex gap-2">
<a
href={urls.rightmove}
target="_blank"
rel="noopener noreferrer"
className="flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium"
>
Rightmove
</a>
<a
href={urls.onthemarket}
target="_blank"
rel="noopener noreferrer"
className="flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium"
>
OnTheMarket
</a>
<a
href={urls.zoopla}
target="_blank"
rel="noopener noreferrer"
className="flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium"
>
Zoopla
</a>
</div>
</div>
);
}
function EnumBarChart({ counts }: { counts: Record<string, number> }) {
const entries = Object.entries(counts).sort(([, countA], [, countB]) => countB - countA);
const maxCount = Math.max(...entries.map(([, count]) => count), 1);
return (
<div className="space-y-1 mt-1">
{entries.map(([label, count]) => (
<div key={label} className="flex items-center gap-2 text-xs">
<span className="w-16 truncate text-warm-500 dark:text-warm-400 text-right shrink-0">
{label}
</span>
<div className="flex-1 h-3 bg-warm-100 dark:bg-navy-700 rounded overflow-hidden">
<div
className="h-full bg-teal-500 dark:bg-teal-400 rounded"
style={{ width: `${(count / maxCount) * 100}%` }}
/>
</div>
<span className="w-8 text-warm-500 dark:text-warm-400 text-right shrink-0">{count}</span>
</div>
))}
</div>
);
}
export default function AreaPane({
stats,
globalFeatures,
@ -357,7 +49,6 @@ export default function AreaPane({
}: AreaPaneProps) {
const featureGroups = useMemo(() => groupFeatures(globalFeatures), [globalFeatures]);
// Build lookup maps from stats
const numericByName = useMemo(() => {
if (!stats) return new Map();
return new Map(stats.numeric_features.map((feature) => [feature.name, feature]));
@ -368,7 +59,6 @@ export default function AreaPane({
return new Map(stats.enum_features.map((feature) => [feature.name, feature]));
}, [stats]);
// Build lookup for global feature metadata (for histogram overlay)
const globalFeatureByName = useMemo(
() => new Map(globalFeatures.map((f) => [f.name, f])),
[globalFeatures]
@ -384,7 +74,6 @@ export default function AreaPane({
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="p-3 border-b border-warm-200 dark:border-navy-700">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
@ -403,18 +92,40 @@ export default function AreaPane({
? 'text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30'
: 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
}`}
title={hoverMode ? 'Live preview on (click to lock)' : 'Live preview off (click to enable)'}
title={
hoverMode ? 'Live preview on (click to lock)' : 'Live preview off (click to enable)'
}
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
</button>
<button
onClick={onClose}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-1"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
@ -435,17 +146,16 @@ export default function AreaPane({
)}
</div>
{/* External search links */}
{hexagonLocation && stats && <ExternalSearchLinks location={hexagonLocation} filters={filters} />}
{hexagonLocation && stats && (
<ExternalSearchLinks location={hexagonLocation} filters={filters} />
)}
{/* Stats content */}
<div className="flex-1 overflow-y-auto">
{loading && !stats ? (
<LoadingSkeleton />
) : stats ? (
<div className="p-3 space-y-4">
{featureGroups.map((group) => {
// Check if any feature in this group has data
const hasData = group.features.some(
(feature) => numericByName.has(feature.name) || enumByName.has(feature.name)
);
@ -464,14 +174,14 @@ export default function AreaPane({
if (numericStats) {
const globalFeature = globalFeatureByName.get(feature.name);
const globalHistogram = globalFeature?.histogram;
// Compute a global mean from the global histogram for the mean line
let globalMean: number | undefined;
if (globalHistogram && globalHistogram.counts.length > 0) {
const totalCount = globalHistogram.counts.reduce((a, b) => a + b, 0);
if (totalCount > 0) {
let weightedSum = 0;
for (let i = 0; i < globalHistogram.counts.length; i++) {
const binCenter = globalHistogram.min + (i + 0.5) * globalHistogram.bin_width;
const binCenter =
globalHistogram.min + (i + 0.5) * globalHistogram.bin_width;
weightedSum += binCenter * globalHistogram.counts[i];
}
globalMean = weightedSum / totalCount;
@ -479,7 +189,10 @@ export default function AreaPane({
}
return (
<div key={feature.name} className="bg-warm-50 dark:bg-warm-800 rounded p-2">
<div
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="flex justify-between items-baseline">
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
{feature.name}
@ -514,7 +227,10 @@ export default function AreaPane({
if (enumStats) {
return (
<div key={feature.name} className="bg-warm-50 dark:bg-warm-800 rounded p-2">
<div
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<span className="text-xs text-warm-700 dark:text-warm-300">
{feature.name}
</span>