These work
This commit is contained in:
parent
3599803589
commit
1588c01b19
19 changed files with 260 additions and 201 deletions
|
|
@ -1,11 +1,12 @@
|
|||
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 { apiUrl, 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 };
|
||||
|
|
@ -23,6 +24,8 @@ interface HomeDemoProps {
|
|||
|
||||
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);
|
||||
|
|
@ -83,10 +86,18 @@ export default function HomeDemo({ features, theme }: HomeDemoProps) {
|
|||
}
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = new AbortController();
|
||||
setFetching(true);
|
||||
fetch(apiUrl('hexagons', params), authHeaders({ signal: abortRef.current.signal }))
|
||||
.then((res) => res.json())
|
||||
.then((data: { features: HexagonData[] }) => setHexData(data.features))
|
||||
.catch(() => {});
|
||||
.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(() => {
|
||||
|
|
@ -133,7 +144,7 @@ export default function HomeDemo({ features, theme }: HomeDemoProps) {
|
|||
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
|
||||
.then((res) => res.json())
|
||||
.then((data: { features: HexagonData[] }) => setDragHexData(data.features))
|
||||
.catch(() => {});
|
||||
.catch((err) => logNonAbortError('Failed to fetch demo drag data', err));
|
||||
},
|
||||
[features, sliderValues]
|
||||
);
|
||||
|
|
@ -182,6 +193,21 @@ export default function HomeDemo({ features, theme }: HomeDemoProps) {
|
|||
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">
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
|||
import { PillToggle } from '../ui/PillToggle';
|
||||
import { PillGroup } from '../ui/PillGroup';
|
||||
import type { FeatureMeta, FeatureFilters } from '../../types';
|
||||
import { formatFilterValue } from '../../lib/format';
|
||||
import { formatFilterValue, buildPercentileScale } from '../../lib/format';
|
||||
import type { PercentileScale } from '../../lib/format';
|
||||
import { groupFeaturesByCategory } from '../../lib/features';
|
||||
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
|
||||
import InfoPopup from '../ui/InfoPopup';
|
||||
|
|
@ -21,27 +22,30 @@ function SliderLabels({
|
|||
min,
|
||||
max,
|
||||
value,
|
||||
displayValues,
|
||||
}: {
|
||||
min: number;
|
||||
max: number;
|
||||
value: [number, number];
|
||||
displayValues?: [number, number];
|
||||
}) {
|
||||
const range = max - min || 1;
|
||||
const leftPct = ((value[0] - min) / range) * 100;
|
||||
const rightPct = ((value[1] - min) / range) * 100;
|
||||
const labels = displayValues || value;
|
||||
return (
|
||||
<div className="relative h-4 mt-2 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
||||
<span
|
||||
className="absolute -translate-x-1/2"
|
||||
style={{ left: `${leftPct}%` }}
|
||||
>
|
||||
{formatFilterValue(value[0])}
|
||||
{formatFilterValue(labels[0])}
|
||||
</span>
|
||||
<span
|
||||
className="absolute -translate-x-1/2"
|
||||
style={{ left: `${rightPct}%` }}
|
||||
>
|
||||
{formatFilterValue(value[1])}
|
||||
{formatFilterValue(labels[1])}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -119,6 +123,16 @@ export default memo(function Filters({
|
|||
[enabledFeatureList]
|
||||
);
|
||||
|
||||
const percentileScales = useMemo(() => {
|
||||
const scales = new Map<string, PercentileScale>();
|
||||
for (const f of features) {
|
||||
if (f.type === 'numeric' && f.histogram) {
|
||||
scales.set(f.name, buildPercentileScale(f.histogram));
|
||||
}
|
||||
}
|
||||
return scales;
|
||||
}, [features]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full">
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
|
|
@ -230,7 +244,10 @@ export default memo(function Filters({
|
|||
isActive && dragValue
|
||||
? dragValue
|
||||
: (filters[feature.name] as [number, number]) || [feature.min!, feature.max!];
|
||||
const step = feature.step ?? (feature.max! - feature.min!) / 100;
|
||||
const scale = percentileScales.get(feature.name);
|
||||
const sliderValue: [number, number] = scale
|
||||
? [Math.round(scale.toPercentile(displayValue[0])), Math.round(scale.toPercentile(displayValue[1]))]
|
||||
: displayValue;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -248,15 +265,24 @@ export default memo(function Filters({
|
|||
</div>
|
||||
<div>
|
||||
<Slider
|
||||
min={feature.min!}
|
||||
max={feature.max!}
|
||||
step={step}
|
||||
value={[displayValue[0], displayValue[1]]}
|
||||
onValueChange={([min, max]) => onDragChange([min, max])}
|
||||
min={scale ? 0 : feature.min!}
|
||||
max={scale ? 100 : feature.max!}
|
||||
step={scale ? 1 : (feature.step ?? (feature.max! - feature.min!) / 100)}
|
||||
value={sliderValue}
|
||||
onValueChange={
|
||||
scale
|
||||
? ([pMin, pMax]) => onDragChange([scale.toValue(pMin), scale.toValue(pMax)])
|
||||
: ([min, max]) => onDragChange([min, max])
|
||||
}
|
||||
onPointerDown={() => onDragStart(feature.name)}
|
||||
onPointerUp={() => onDragEnd()}
|
||||
/>
|
||||
<SliderLabels min={feature.min!} max={feature.max!} value={displayValue} />
|
||||
<SliderLabels
|
||||
min={scale ? 0 : feature.min!}
|
||||
max={scale ? 100 : feature.max!}
|
||||
value={sliderValue}
|
||||
displayValues={scale ? displayValue : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -142,18 +142,14 @@ function PropertyLoadingSkeleton() {
|
|||
}
|
||||
|
||||
function PropertyCard({ property }: { property: Property }) {
|
||||
const price = getNum(property, 'Last known price', 'latest_price');
|
||||
const price = getNum(property, 'Last known price');
|
||||
const estimatedPrice = getNum(property, 'Estimated current price');
|
||||
const pricePerSqm = getNum(property, 'Price per sqm', 'price_per_sqm');
|
||||
const pricePerSqm = getNum(property, 'Price per sqm');
|
||||
const estPricePerSqm = getNum(property, 'Est. price per sqm');
|
||||
const floorArea = getNum(property, 'Total floor area (sqm)', 'total_floor_area');
|
||||
const rooms = getNum(
|
||||
property,
|
||||
'Rooms (including bedrooms & bathrooms)',
|
||||
'number_habitable_rooms'
|
||||
);
|
||||
const age = getNum(property, 'Approximate construction age', 'construction_age_band');
|
||||
const transactionDate = getNum(property, 'Date of last transaction', 'date_of_transfer');
|
||||
const floorArea = getNum(property, 'Total floor area (sqm)');
|
||||
const rooms = getNum(property, 'Rooms (including bedrooms & bathrooms)');
|
||||
const age = getNum(property, 'Approximate construction age');
|
||||
const transactionDate = getNum(property, 'Date of last transaction');
|
||||
const councilTax = getNum(property, 'Council tax (£/yr)');
|
||||
const councilTaxD = getNum(property, 'Council tax Band D (£/yr)');
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { SpinnerIcon } from './icons/SpinnerIcon';
|
|||
import UserMenu from './UserMenu';
|
||||
import MobileMenu from './MobileMenu';
|
||||
|
||||
export type Page = 'home' | 'dashboard' | 'saved-searches' | 'pricing';
|
||||
export type Page = 'home' | 'dashboard' | 'saved-searches' | 'learn' | 'pricing';
|
||||
|
||||
export default function Header({
|
||||
activePage,
|
||||
|
|
@ -133,6 +133,9 @@ export default function Header({
|
|||
Saved
|
||||
</button>
|
||||
)}
|
||||
<button className={tabClass('learn')} onClick={() => onPageChange('learn')}>
|
||||
Learn
|
||||
</button>
|
||||
<button className={tabClass('pricing')} onClick={() => onPageChange('pricing')}>
|
||||
Pricing
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ export default function MobileMenu({
|
|||
{mobileNavItem('home', 'Home')}
|
||||
{mobileNavItem('dashboard', 'Dashboard')}
|
||||
{user && mobileNavItem('saved-searches', 'Saved')}
|
||||
{mobileNavItem('learn', 'Learn')}
|
||||
{mobileNavItem('pricing', 'Pricing')}
|
||||
|
||||
{/* Dashboard actions */}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue