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,4 +1,5 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { trackPageview } from './usePlausible';
import Map from './components/Map';
import Filters from './components/Filters';
import POIPane from './components/POIPane';
@ -57,6 +58,11 @@ async function fetchWithRetry<T>(
// Detect if running through VS Code web proxy and construct API base URL
function getApiBaseUrl(): string {
// In production builds, always use same-origin (Rust server serves both API and frontend)
if (process.env.NODE_ENV === 'production') {
return '';
}
const { pathname, href } = window.location;
// Check pathname for /proxy/PORT pattern (VS Code web proxy)
@ -71,7 +77,7 @@ function getApiBaseUrl(): string {
return `${hrefMatch[1]}8001`;
}
// Default: same origin (works for both local dev with webpack proxy and production)
// Default: same origin (works for local dev with webpack proxy)
return '';
}
@ -417,6 +423,7 @@ export default function App() {
const url = hash ? `${window.location.pathname}${window.location.search}#${hash}` : `${window.location.pathname}${window.location.search}`;
window.history.pushState({ page }, '', url);
setActivePage(page);
trackPageview();
}, []);
// Handle browser back/forward
@ -465,8 +472,9 @@ export default function App() {
// Derive view feature: active drag takes priority over pinned
const viewFeature = activeFeature || pinnedFeature;
const viewSource: 'drag' | 'eye' | null = activeFeature ? 'drag' : pinnedFeature ? 'eye' : null;
// Color range: always the feature's full slider range from metadata
// For enum features, use ordinal index range [0, values.length - 1]
// Color range: use the filter slider range when a numeric filter is active,
// otherwise fall back to the feature's full range from metadata.
// For enum features, use ordinal index range [0, values.length - 1].
const colorRange = useMemo((): [number, number] | null => {
if (!viewFeature) return null;
const meta = features.find((f) => f.name === viewFeature);
@ -474,9 +482,13 @@ export default function App() {
if (meta.type === 'enum' && meta.values && meta.values.length > 0) {
return [0, meta.values.length - 1];
}
// Use live drag values or committed filter range if available
if (activeFeature === viewFeature && dragValue) return dragValue;
const filterVal = filters[viewFeature];
if (filterVal && typeof filterVal[0] === 'number') return filterVal as [number, number];
if (meta.min != null && meta.max != null) return [meta.min, meta.max];
return null;
}, [viewFeature, features]);
}, [viewFeature, features, activeFeature, dragValue, filters]);
// Filter range: current drag or committed filter values, used for gray-out
const filterRange = useMemo((): [number, number] | null => {
@ -1087,6 +1099,22 @@ export default function App() {
onHoverModeChange={setHoverMode}
onViewProperties={handleViewPropertiesFromArea}
onClose={handleCloseProperties}
hexagonLocation={
(() => {
const hexId = hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3
? hoveredHexagon
: selectedHexagon?.h3;
const hex = hexId ? data.find((d) => d.h3 === hexId) : null;
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null;
return {
lat: hex.lat as number,
lon: hex.lon as number,
postcode: (hex.postcode as string | undefined) ?? null,
resolution,
};
})()
}
filters={filters}
/>
) : rightPaneTab === 'properties' ? (
<PropertiesPane

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>

View file

@ -654,6 +654,19 @@ export default memo(function Map({
<DeckOverlay layers={layers} getTooltip={null} />
</MapGL>
<PostcodeSearch onFlyTo={handleFlyTo} />
{viewSource === 'eye' && viewFeature && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 bg-white dark:bg-warm-800 rounded-lg shadow-lg px-5 py-3">
<span className="text-lg font-semibold text-navy-950 dark:text-warm-100">
Previewing &ldquo;{viewFeature}&rdquo;
</span>
<button
onClick={onCancelPin}
className="px-4 py-1.5 rounded-md bg-warm-200 dark:bg-warm-700 text-warm-700 dark:text-warm-200 hover:bg-warm-300 dark:hover:bg-warm-600 font-medium text-sm"
>
Cancel
</button>
</div>
)}
{viewFeature && colorRange && colorFeatureMeta ? (
<MapLegend
featureLabel={colorFeatureMeta.name}

View file

@ -1,6 +1,9 @@
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
import { initPlausible } from './usePlausible';
initPlausible();
const container = document.getElementById('root');
if (!container) {

View file

@ -6,6 +6,7 @@ export interface FeatureMeta {
min?: number;
max?: number;
step?: number;
histogram?: { min: number; max: number; bin_width: number; counts: number[] };
// Enum-only fields
values?: string[];
// Description fields

View file

@ -0,0 +1,73 @@
const DOMAIN = 'narrowit.schmelczer.dev';
const ENDPOINT = '/status';
type EventOptions = {
props?: Record<string, string | number | boolean>;
revenue?: { currency: string; amount: number };
};
function sendEvent(name: string, options?: EventOptions) {
const payload: Record<string, unknown> = {
n: name,
u: window.location.href,
d: DOMAIN,
r: document.referrer || null,
};
if (options?.props) {
payload.p = JSON.stringify(options.props);
}
if (options?.revenue) {
payload.$ = JSON.stringify(options.revenue);
}
if (navigator.sendBeacon) {
navigator.sendBeacon(
ENDPOINT,
new Blob([JSON.stringify(payload)], { type: 'application/json' })
);
} else {
fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
keepalive: true,
}).catch(() => {});
}
}
let initialized = false;
/**
* Tracks pageview on first call and returns a trackEvent function.
* Tracks outbound link clicks automatically.
*/
export function initPlausible() {
if (initialized) return;
initialized = true;
// Initial pageview
sendEvent('pageview');
// Track outbound link clicks
document.addEventListener('click', (e) => {
const link = (e.target as HTMLElement).closest?.('a');
if (!link) return;
const href = link.getAttribute('href');
if (!href) return;
try {
const url = new URL(href, window.location.origin);
if (url.hostname !== window.location.hostname) {
sendEvent('Outbound Link: Click', { props: { url: href } });
}
} catch {
// invalid URL, ignore
}
});
}
export function trackPageview(options?: EventOptions) {
sendEvent('pageview', options);
}
export function trackEvent(name: string, options?: EventOptions) {
sendEvent(name, options);
}

View file

@ -51,6 +51,12 @@ module.exports = (env, argv) => {
context: ['/api'],
target: 'http://localhost:8001',
},
{
context: ['/status'],
target: 'https://stats.schmelczer.dev',
changeOrigin: true,
pathRewrite: { '^/status': '/api/event' },
},
],
},
};