275 lines
10 KiB
TypeScript
275 lines
10 KiB
TypeScript
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<HexagonData[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [fetching, setFetching] = useState(false);
|
|
const [sliderValues, setSliderValues] = useState<Record<string, [number, number]>>({});
|
|
const [activeFeature, setActiveFeature] = useState<string | null>(null);
|
|
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
|
|
const [dragHexData, setDragHexData] = useState<HexagonData[] | null>(null);
|
|
const fetchTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
|
const abortRef = useRef<AbortController>();
|
|
const dragAbortRef = useRef<AbortController>();
|
|
const activeFeatureRef = useRef<string | null>(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<string, [number, number]> = {};
|
|
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 (
|
|
<div className="flex flex-col md:flex-row gap-6">
|
|
{/* Map */}
|
|
<div className="relative rounded-xl overflow-hidden shadow-sm aspect-[4/3] md:w-3/5">
|
|
<div className="absolute inset-0 z-50 cursor-default" />
|
|
<div className="absolute inset-0">
|
|
<MapComponent
|
|
data={displayData}
|
|
postcodeData={[]}
|
|
usePostcodeView={false}
|
|
pois={[]}
|
|
onViewChange={noop}
|
|
viewFeature={viewFeatureName}
|
|
colorRange={colorRange}
|
|
filterRange={filterRange}
|
|
viewSource={activeFeature ? 'drag' : null}
|
|
onCancelPin={noop}
|
|
features={features}
|
|
selectedHexagonId={null}
|
|
hoveredHexagonId={null}
|
|
onHexagonClick={noop}
|
|
onHexagonHover={noop}
|
|
initialViewState={DEMO_VIEW}
|
|
theme={theme}
|
|
screenshotMode={true}
|
|
hideLegend={true}
|
|
/>
|
|
</div>
|
|
{loading && (
|
|
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
|
|
<div className="flex flex-col items-center gap-4">
|
|
<SpinnerIcon className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin" />
|
|
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">
|
|
Connecting to server...
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{!loading && fetching && (
|
|
<div className="absolute top-3 left-3 z-50 bg-white dark:bg-navy-800 dark:text-warm-200 px-2 py-1 rounded shadow text-xs">
|
|
Loading...
|
|
</div>
|
|
)}
|
|
{/* Colour spectrum legend */}
|
|
<div className="absolute bottom-3 left-3 right-3 z-50 pointer-events-none">
|
|
<div className="bg-white/90 dark:bg-warm-800/90 rounded-lg px-3 py-2 backdrop-blur-sm text-xs">
|
|
<div className="font-semibold text-navy-950 dark:text-warm-100 mb-1 truncate">
|
|
{activeFeature ? viewMeta?.name || activeFeature : 'Property density'}
|
|
</div>
|
|
<div
|
|
className="h-2.5 rounded-full"
|
|
style={{
|
|
background: activeFeature
|
|
? featureGradientStyle
|
|
: gradientToCss(theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT),
|
|
}}
|
|
/>
|
|
{colorRange && (
|
|
<div className="flex justify-between mt-0.5 text-warm-500 dark:text-warm-400">
|
|
<TickerValue text={formatValue(colorRange[0], viewMeta ?? undefined)} />
|
|
<TickerValue text={formatValue(colorRange[1], viewMeta ?? undefined)} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sliders */}
|
|
<div className="md:w-2/5 flex flex-col justify-center space-y-6">
|
|
{demoFeatures.map((feature) => {
|
|
const value = sliderValues[feature.name];
|
|
if (!value || feature.min == null || feature.max == null) return null;
|
|
const isActive = activeFeature === feature.name;
|
|
return (
|
|
<div
|
|
key={feature.name}
|
|
className={`rounded-lg p-3 ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : ''}`}
|
|
>
|
|
<div className="flex justify-between mb-2">
|
|
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">
|
|
{feature.name}
|
|
</span>
|
|
<span className="text-sm text-warm-500 dark:text-warm-400">
|
|
{formatValue(value[0], feature)} – {formatValue(value[1], feature)}
|
|
</span>
|
|
</div>
|
|
<Slider
|
|
min={feature.min}
|
|
max={feature.max}
|
|
step={feature.step || 1}
|
|
value={[value[0], value[1]]}
|
|
onValueChange={([min, max]) => handleSliderChange(feature.name, [min, max])}
|
|
onPointerDown={() => handleDragStart(feature.name)}
|
|
onPointerUp={() => handleDragEnd()}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|