Optimisations

This commit is contained in:
Andras Schmelczer 2026-02-01 21:00:59 +00:00
parent 66c2a25457
commit 9179acd4cd
21 changed files with 653 additions and 139 deletions

View file

@ -1,5 +1,12 @@
import { useMemo } from 'react';
import type { FeatureMeta, HexagonStatsResponse } from '../types';
import type { FeatureFilters, FeatureMeta, HexagonStatsResponse } from '../types';
interface HexagonLocation {
lat: number;
lon: number;
postcode: string | null;
resolution: number;
}
interface AreaPaneProps {
stats: HexagonStatsResponse | null;
@ -11,6 +18,8 @@ interface AreaPaneProps {
onHoverModeChange: (enabled: boolean) => void;
onViewProperties: () => void;
onClose: () => void;
hexagonLocation: HexagonLocation | null;
filters: FeatureFilters;
}
function formatValue(value: number): string {
@ -37,10 +46,7 @@ function groupFeatures(
return groups;
}
function MiniHistogram({ counts, maxCount }: { counts: number[]; maxCount: number }) {
if (maxCount === 0) return null;
// Downsample to ~20 bars for display
const targetBars = 20;
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) {
@ -50,21 +56,268 @@ function MiniHistogram({ counts, maxCount }: { counts: number[]; maxCount: numbe
}
bars.push(sum);
}
const barMax = Math.max(...bars, 1);
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="flex items-end gap-px h-8 mt-1">
{bars.map((count, index) => (
<div
key={index}
className="flex-1 bg-teal-500 dark:bg-teal-400 rounded-t-sm min-w-[2px]"
style={{ height: `${(count / barMax) * 100}%`, opacity: count > 0 ? 1 : 0.1 }}
/>
<div 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);
@ -99,6 +352,8 @@ export default function AreaPane({
onHoverModeChange,
onViewProperties,
onClose,
hexagonLocation,
filters,
}: AreaPaneProps) {
const featureGroups = useMemo(() => groupFeatures(globalFeatures), [globalFeatures]);
@ -113,6 +368,12 @@ 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]
);
if (!hexagonId) {
return (
<div className="flex items-center justify-center h-full text-warm-500 dark:text-warm-400 px-4 text-center text-sm">
@ -174,10 +435,13 @@ export default function AreaPane({
)}
</div>
{/* External search links */}
{hexagonLocation && stats && <ExternalSearchLinks location={hexagonLocation} filters={filters} />}
{/* Stats content */}
<div className="flex-1 overflow-y-auto">
{loading && !stats ? (
<div className="p-4 text-warm-500 dark:text-warm-400 text-sm">Loading...</div>
<LoadingSkeleton />
) : stats ? (
<div className="p-3 space-y-4">
{featureGroups.map((group) => {
@ -198,9 +462,24 @@ export default function AreaPane({
const enumStats = enumByName.get(feature.name);
if (numericStats) {
const maxCount = Math.max(...numericStats.histogram.counts);
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;
weightedSum += binCenter * globalHistogram.counts[i];
}
globalMean = weightedSum / totalCount;
}
}
return (
<div key={feature.name} className="bg-warm-50 dark:bg-navy-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}
@ -210,17 +489,32 @@ export default function AreaPane({
</span>
</div>
<div className="flex justify-between text-[10px] text-warm-400 dark:text-warm-500 mt-0.5">
<span>{formatValue(numericStats.min)}</span>
<span>{formatValue(numericStats.max)}</span>
<span>{formatValue(numericStats.histogram.min)}</span>
<span>{formatValue(numericStats.histogram.max)}</span>
</div>
<MiniHistogram counts={numericStats.histogram.counts} maxCount={maxCount} />
{globalHistogram ? (
<DualHistogram
localCounts={numericStats.histogram.counts}
globalCounts={globalHistogram.counts}
min={numericStats.histogram.min}
max={numericStats.histogram.max}
globalMean={globalMean}
/>
) : (
<DualHistogram
localCounts={numericStats.histogram.counts}
globalCounts={numericStats.histogram.counts}
min={numericStats.histogram.min}
max={numericStats.histogram.max}
/>
)}
</div>
);
}
if (enumStats) {
return (
<div key={feature.name} className="bg-warm-50 dark:bg-navy-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>