import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import MapComponent from '../map/Map'; import { Slider } from '../ui/Slider'; import { apiUrl, assertOk, authHeaders, logNonAbortError } from '../../lib/api'; import { formatValue } from '../../lib/format'; import { FEATURE_GRADIENT, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../../lib/consts'; import { gradientToCss } from '../../lib/utils'; import { TickerValue } from '../ui/TickerValue'; import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; import type { FeatureMeta, HexagonData } from '../../types'; const DEMO_VIEW = { longitude: -1.9, latitude: 52.2, zoom: 5.5, pitch: 0 }; const DEMO_FEATURE_NAMES = ['Estimated current price', 'Good+ primary schools within 5km', 'Number of restaurants within 2km']; const DEMO_BOUNDS = '49,-9.5,57,5'; const DEMO_RESOLUTION = 5; const noop = () => {}; const featureGradientStyle = gradientToCss(FEATURE_GRADIENT); interface HomeDemoProps { features: FeatureMeta[]; theme: 'light' | 'dark'; } export default function HomeDemo({ features, theme }: HomeDemoProps) { const [hexData, setHexData] = useState([]); const [loading, setLoading] = useState(true); const [fetching, setFetching] = useState(false); const [sliderValues, setSliderValues] = useState>({}); const [activeFeature, setActiveFeature] = useState(null); const [dragValue, setDragValue] = useState<[number, number] | null>(null); const [dragHexData, setDragHexData] = useState(null); const fetchTimeoutRef = useRef>(); const abortRef = useRef(); const dragAbortRef = useRef(); const activeFeatureRef = useRef(null); activeFeatureRef.current = activeFeature; const demoFeatures = useMemo( () => DEMO_FEATURE_NAMES.map((name) => features.find((f) => f.name === name)).filter( Boolean ) as FeatureMeta[], [features] ); // Initialize slider values when features arrive useEffect(() => { if (demoFeatures.length === 0) return; const initial: Record = {}; for (const f of demoFeatures) { if (f.min != null && f.max != null) { initial[f.name] = [f.min, f.max]; } } setSliderValues(initial); }, [demoFeatures]); // Feature coloring only during drag; density (property count) otherwise const viewFeatureName = activeFeature; const viewMeta = viewFeatureName ? features.find((f) => f.name === viewFeatureName) : null; const colorRange: [number, number] | null = viewMeta?.min != null && viewMeta?.max != null ? [viewMeta.min, viewMeta.max] : null; const filterRange: [number, number] | null = activeFeature && dragValue ? dragValue : null; const displayData = dragHexData ?? hexData; // Fetch hexagons (debounced) — skipped while dragging const fetchHexagons = useCallback(() => { if (activeFeatureRef.current) return; if (features.length === 0 || Object.keys(sliderValues).length === 0) return; const params = new URLSearchParams({ resolution: String(DEMO_RESOLUTION), bounds: DEMO_BOUNDS, }); const filterParts: string[] = []; for (const [name, [min, max]] of Object.entries(sliderValues)) { const meta = features.find((f) => f.name === name); if (meta?.min != null && meta?.max != null) { if (min !== meta.min || max !== meta.max) { filterParts.push(`${name}:${min}:${max}`); } } } if (filterParts.length > 0) { params.set('filters', filterParts.join(',')); } abortRef.current?.abort(); abortRef.current = new AbortController(); setFetching(true); fetch(apiUrl('hexagons', params), authHeaders({ signal: abortRef.current.signal })) .then((res) => { assertOk(res, 'hexagons'); return res.json(); }) .then((data: { features: HexagonData[] }) => { setHexData(data.features); setLoading(false); setFetching(false); }) .catch((err) => { logNonAbortError('Failed to fetch demo hexagons', err); setFetching(false); }); }, [features, sliderValues]); useEffect(() => { clearTimeout(fetchTimeoutRef.current); fetchTimeoutRef.current = setTimeout(fetchHexagons, 200); return () => clearTimeout(fetchTimeoutRef.current); }, [fetchHexagons]); useEffect(() => { return () => { abortRef.current?.abort(); dragAbortRef.current?.abort(); clearTimeout(fetchTimeoutRef.current); }; }, []); // Drag start: fetch preview data with other filters only, fields=dragged feature const handleDragStart = useCallback( (name: string) => { setActiveFeature(name); const currentVal = sliderValues[name]; const meta = features.find((f) => f.name === name); setDragValue(currentVal || (meta?.min != null ? [meta.min, meta.max!] : null)); const params = new URLSearchParams({ resolution: String(DEMO_RESOLUTION), bounds: DEMO_BOUNDS, }); const otherFilterParts: string[] = []; for (const [n, [min, max]] of Object.entries(sliderValues)) { if (n === name) continue; const m = features.find((f) => f.name === n); if (m?.min != null && m?.max != null && (min !== m.min || max !== m.max)) { otherFilterParts.push(`${n}:${min}:${max}`); } } if (otherFilterParts.length > 0) { params.set('filters', otherFilterParts.join(',')); } params.set('fields', name); dragAbortRef.current?.abort(); dragAbortRef.current = new AbortController(); fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal })) .then((res) => { assertOk(res, 'hexagons'); return res.json(); }) .then((data: { features: HexagonData[] }) => setDragHexData(data.features)) .catch((err) => logNonAbortError('Failed to fetch demo drag data', err)); }, [features, sliderValues] ); const handleSliderChange = useCallback( (name: string, value: [number, number]) => { setSliderValues((prev) => ({ ...prev, [name]: value })); if (activeFeatureRef.current === name) { setDragValue(value); } }, [] ); const handleDragEnd = useCallback(() => { setActiveFeature(null); setDragValue(null); setDragHexData(null); }, []); return (
{/* Map */}
{loading && (

Connecting to server...

)} {!loading && fetching && (
Loading...
)} {/* Colour spectrum legend */}
{activeFeature ? viewMeta?.name || activeFeature : 'Property density'}
{colorRange && (
)}
{/* Sliders */}
{demoFeatures.map((feature) => { const value = sliderValues[feature.name]; if (!value || feature.min == null || feature.max == null) return null; const isActive = activeFeature === feature.name; return (
{feature.name} {formatValue(value[0], feature)} – {formatValue(value[1], feature)}
handleSliderChange(feature.name, [min, max])} onPointerDown={() => handleDragStart(feature.name)} onPointerUp={() => handleDragEnd()} />
); })}
); }