Optimisations
This commit is contained in:
parent
66c2a25457
commit
9179acd4cd
21 changed files with 653 additions and 139 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue