Progress
This commit is contained in:
parent
5b68c8da04
commit
536fd14378
28 changed files with 1683 additions and 313 deletions
243
frontend/src/components/home/HomeDemo.tsx
Normal file
243
frontend/src/components/home/HomeDemo.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import MapComponent from '../map/Map';
|
||||
import { Slider } from '../ui/Slider';
|
||||
import { apiUrl, authHeaders } 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 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 [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();
|
||||
fetch(apiUrl('hexagons', params), authHeaders({ signal: abortRef.current.signal }))
|
||||
.then((res) => res.json())
|
||||
.then((data: { features: HexagonData[] }) => setHexData(data.features))
|
||||
.catch(() => {});
|
||||
}, [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) => res.json())
|
||||
.then((data: { features: HexagonData[] }) => setDragHexData(data.features))
|
||||
.catch(() => {});
|
||||
},
|
||||
[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>
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue