This commit is contained in:
Andras Schmelczer 2026-07-03 18:28:56 +01:00
parent 909e241907
commit 1ee796b282
29 changed files with 250 additions and 126 deletions

View file

@ -12,6 +12,7 @@ import type { TFunction } from 'i18next';
import { useTranslation } from 'react-i18next';
import { cellToLatLng, polygonToCells } from 'h3-js';
import PriceHistoryChart from '../map/PriceHistoryChart';
import { MapErrorBoundary } from '../map/MapErrorBoundary';
import StackedBarChart from '../map/StackedBarChart';
import JourneyInstructions, { type JourneyInstructionPreset } from '../map/JourneyInstructions';
import { DualHistogram } from '../map/DualHistogram';
@ -214,10 +215,13 @@ const SHOWCASE_MAP_START_VIEW: ViewState = {
bearing: 0,
};
// End the Match fly-in over Greater London so the "match" step lands on the same
// place the Inspect (SW5 9AA) and Scout (SW5/SE22/N4) steps then drill into. The
// single-search narrative used to break by zooming to Birmingham then showing London.
const SHOWCASE_MAP_END_VIEW: ViewState = {
longitude: -1.89,
latitude: 52.49,
zoom: 8.72,
longitude: -0.12,
latitude: 51.51,
zoom: 9.05,
pitch: 0,
bearing: 0,
};
@ -260,7 +264,12 @@ function buildShowcaseMapData(): HexagonData[] {
}
const SHOWCASE_MAP_DATA = buildShowcaseMapData();
const SHOWCASE_MAP_TOTAL_COUNT = SHOWCASE_MAP_DATA.reduce((sum, item) => sum + item.count, 0);
// Count only the hexes around Greater London so the Match card's figure matches the
// area it has zoomed to, instead of mislabelling the England-wide total as one city.
const SHOWCASE_LONDON_COUNT = SHOWCASE_MAP_DATA.reduce((sum, item) => {
const nearLondon = Math.abs(item.lat - 51.5072) < 0.55 && Math.abs(item.lon + 0.1276) < 0.85;
return nearLondon ? sum + item.count : sum;
}, 0);
const EMPTY_SHOWCASE_POSTCODES: PostcodeFeature[] = [];
const EMPTY_SHOWCASE_POIS: POI[] = [];
@ -601,37 +610,42 @@ function EnglandHexMapScreen({ isActive }: { isActive: boolean }) {
return (
<div className="pointer-events-none relative h-full overflow-hidden bg-warm-100 dark:bg-navy-950/50">
{shouldRenderMap && (
<Suspense fallback={<div className="h-full bg-navy-950/40" aria-hidden="true" />}>
<ProductMap
data={SHOWCASE_MAP_DATA}
postcodeData={EMPTY_SHOWCASE_POSTCODES}
usePostcodeView={false}
pois={EMPTY_SHOWCASE_POIS}
onViewChange={noopViewChange}
viewFeature={null}
colorRange={null}
filterRange={null}
viewSource={null}
onCancelPin={() => {}}
features={DEMO_FEATURES}
selectedHexagonId={null}
hoveredHexagonId={null}
onHexagonClick={noopHexagonClick}
onHexagonHover={noopHexagonHover}
initialViewState={viewState}
theme="dark"
screenshotMode
hideLegend
densityLabel={t('home.showcaseMatchingHomesLabel')}
totalCount={SHOWCASE_MAP_TOTAL_COUNT}
/>
</Suspense>
// The full deck.gl/maplibre map sits above the fold; contain any WebGL
// context-loss crash to this pane (and auto-recover) instead of letting it
// bubble to the top-level boundary and blank the hero.
<MapErrorBoundary>
<Suspense fallback={<div className="h-full bg-navy-950/40" aria-hidden="true" />}>
<ProductMap
data={SHOWCASE_MAP_DATA}
postcodeData={EMPTY_SHOWCASE_POSTCODES}
usePostcodeView={false}
pois={EMPTY_SHOWCASE_POIS}
onViewChange={noopViewChange}
viewFeature={null}
colorRange={null}
filterRange={null}
viewSource={null}
onCancelPin={() => {}}
features={DEMO_FEATURES}
selectedHexagonId={null}
hoveredHexagonId={null}
onHexagonClick={noopHexagonClick}
onHexagonHover={noopHexagonHover}
initialViewState={viewState}
theme="dark"
screenshotMode
hideLegend
densityLabel={t('home.showcaseMatchingHomesLabel')}
totalCount={SHOWCASE_LONDON_COUNT}
/>
</Suspense>
</MapErrorBoundary>
)}
<div className="pointer-events-none absolute bottom-4 left-4 max-w-[16rem] rounded-md border border-white/15 bg-navy-950/95 px-4 py-3 text-white shadow-2xl shadow-navy-950/35">
<div className="text-sm font-black leading-none">Birmingham</div>
<div className="text-sm font-black leading-none">{t('home.showcaseStep2Region')}</div>
<div className="mt-1 text-xs font-bold leading-tight text-warm-200">
{t('home.showcaseMatchingHomes', {
value: SHOWCASE_MAP_TOTAL_COUNT.toLocaleString(),
value: SHOWCASE_LONDON_COUNT.toLocaleString(),
})}
</div>
<div className="mt-2 text-[10px] font-bold uppercase tracking-wide text-warm-400">
@ -789,19 +803,22 @@ function ScoutScreen({ isActive }: { isActive: boolean }) {
postcode: 'SW5 9AA',
score: '94%',
commute: t('home.showcaseMinutes', { count: 23 }),
price: '£492k',
perSqm: '£8,200',
delta: '16%',
},
{
postcode: 'SE22 8EF',
score: '91%',
commute: t('home.showcaseMinutes', { count: 28 }),
price: '£518k',
perSqm: '£5,900',
delta: '24%',
},
{
postcode: 'N4 2AB',
score: '88%',
commute: t('home.showcaseMinutes', { count: 31 }),
price: '£476k',
perSqm: '£6,400',
delta: '21%',
},
];
@ -934,7 +951,12 @@ function ScoutScreen({ isActive }: { isActive: boolean }) {
<div className="truncate border-r border-warm-200 px-2 py-1.5 dark:border-navy-700 sm:px-3 sm:py-2.5">
{row.commute}
</div>
<div className="truncate px-2 py-1.5 font-black sm:px-3 sm:py-2.5">{row.price}</div>
<div className="px-2 py-1.5 sm:px-3 sm:py-2.5">
<div className="truncate font-black tabular-nums">{row.perSqm}</div>
<div className="text-[10px] font-bold tabular-nums text-emerald-600 dark:text-emerald-300">
{row.delta}
</div>
</div>
</div>
))}
</div>
@ -1031,6 +1053,7 @@ export default function ProductShowcase({ className = '' }: ProductShowcaseProps
const [isStagePaused, setIsStagePaused] = useState(false);
const [hasStarted, setHasStarted] = useState(false);
const [canPauseOnHover, setCanPauseOnHover] = useState(false);
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
const showcaseRef = useRef<HTMLDivElement | null>(null);
const inspectUserScrolledRef = useRef(false);
@ -1101,6 +1124,27 @@ export default function ProductShowcase({ className = '' }: ProductShowcaseProps
return () => mediaQuery.removeEventListener('change', updateCanPause);
}, []);
useEffect(() => {
if (typeof window.matchMedia !== 'function') return;
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const update = () => setPrefersReducedMotion(mediaQuery.matches);
update();
mediaQuery.addEventListener('change', update);
return () => mediaQuery.removeEventListener('change', update);
}, []);
// Under prefers-reduced-motion the progress-bar animation is disabled in CSS, so
// its onAnimationEnd (which normally advances the carousel) never fires and the
// demo would freeze on step 1. Drive the advance from a timer in that case.
useEffect(() => {
if (!prefersReducedMotion || !isProgressRunning) return;
const timer = window.setTimeout(
() => setActiveStep((step) => (step + 1) % SHOWCASE_STEP_COUNT),
activeStepIntervalMs
);
return () => window.clearTimeout(timer);
}, [prefersReducedMotion, isProgressRunning, activeStep, activeStepIntervalMs]);
const pauseForHover = () => {
if (canPauseOnHover) setIsStagePaused(true);
};

View file

@ -235,6 +235,7 @@ export interface HistoricalPrice {
year: number;
month: number;
price: number;
is_new: boolean;
}
export interface Property {
@ -386,7 +387,7 @@ export interface HexagonStatsResponse {
* sector, shown alongside the national average for each crime metric. */
crime_area_averages?: CrimeAreaAverage[];
/** Total individual crime records (last 7 years) across the selection's
* postcodes the count behind the "individual crimes" list. */
* postcodes, the count behind the "individual crimes" list. */
crime_total_records?: number;
central_postcode?: string;
/** Total usual residents (ONS Census 2021) across the postcodes in this