diff --git a/frontend/public/home-hex-pattern-dark.svg b/frontend/public/home-hex-pattern-dark.svg new file mode 100644 index 0000000..51f941a --- /dev/null +++ b/frontend/public/home-hex-pattern-dark.svg @@ -0,0 +1,9 @@ + diff --git a/frontend/public/video/poster.jpg b/frontend/public/video/poster.jpg index e8f2873..88dc2b5 100644 Binary files a/frontend/public/video/poster.jpg and b/frontend/public/video/poster.jpg differ diff --git a/frontend/public/video/recording.mp4 b/frontend/public/video/recording.mp4 index 4698acb..8902753 100644 Binary files a/frontend/public/video/recording.mp4 and b/frontend/public/video/recording.mp4 differ diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx index b27cb80..fff1df4 100644 --- a/frontend/src/components/home/HomePage.tsx +++ b/frontend/src/components/home/HomePage.tsx @@ -1,62 +1,13 @@ -import { - useState, - useEffect, - useMemo, - useRef, - type ComponentType, - type MutableRefObject, -} from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { cellToLatLng, polygonToCells } from 'h3-js'; import { useFadeInRef } from '../../hooks/useFadeIn'; import HexCanvas from './HexCanvas'; +import ProductShowcase from './ProductShowcase'; import BottomIllustration from './BottomIllustration'; import { TickerValue } from '../ui/TickerValue'; -import { - ChartBarIcon, - CheckIcon, - ChevronIcon, - ClipboardIcon, - DownloadIcon, - FilterIcon, - LogoIcon, - MapPinIcon, - PlayIcon, - RouteIcon, -} from '../ui/icons'; +import { ChevronIcon, LogoIcon, PlayIcon } from '../ui/icons'; import { trackEvent } from '../../lib/analytics'; -import ProductMap from '../map/Map'; -import PriceHistoryChart from '../map/PriceHistoryChart'; -import StackedBarChart from '../map/StackedBarChart'; -import JourneyInstructions, { type JourneyInstructionPreset } from '../map/JourneyInstructions'; -import { DualHistogram } from '../map/DualHistogram'; -import { FeatureLabel } from '../ui/FeatureLabel'; -import { Slider } from '../ui/Slider'; -import type { TravelTimeEntry } from '../../hooks/useTravelTime'; -import { PARTY_FEATURE_COLORS, STACKED_SEGMENT_COLORS } from '../../lib/consts'; -import { formatValue } from '../../lib/format'; -import type { - FeatureMeta, - HexagonData, - POI, - PostcodeFeature, - PricePoint, - ViewChangeParams, - ViewState, -} from '../../types'; -const SHOWCASE_STEP_COUNT = 4; -const SHOWCASE_INTERVAL_MS = 5200; -const SHOWCASE_SCOUT_INTERVAL_MS = 9000; -const SHOWCASE_STEP_INTERVALS_MS = [ - SHOWCASE_INTERVAL_MS, - SHOWCASE_INTERVAL_MS, - SHOWCASE_INTERVAL_MS, - SHOWCASE_SCOUT_INTERVAL_MS, -]; -const FILTER_ANIMATION_MS = 5000; -const INSPECT_SCROLL_ANIMATION_MS = 4600; -const SCOUT_TABLE_REVEAL_MS = 2400; const BRAND_NAME = 'Perfect Postcode'; const BRAND_TEXT_CLASS = 'text-teal-600 dark:text-teal-400'; const HOME_SECTION_CONTAINER_CLASS = 'max-w-7xl mx-auto px-6 md:px-10'; @@ -69,13 +20,6 @@ const PRODUCT_DEMO_VIDEO_SRC = '/video/recording.mp4'; const PRODUCT_DEMO_POSTER_SRC = '/video/poster.jpg'; const PRODUCT_DEMO_SECTION_ID = 'product-demo-video'; -type ShowcaseStep = { - tab: string; - title: string; - body: string; - Icon: ComponentType<{ className?: string }>; -}; - function highlightBrandText(text: string) { const parts = text.split(BRAND_NAME); if (parts.length === 1) return text; @@ -172,1088 +116,6 @@ function ProductDemoVideo() { ); } -const DEMO_FEATURES: FeatureMeta[] = [ - { - name: 'Estimated price', - type: 'numeric', - group: 'Properties', - min: 0, - max: 900000, - step: 25000, - prefix: '£', - }, - { - name: 'Serious crime per 1k residents (avg/yr)', - type: 'numeric', - group: 'Crime', - min: 0, - max: 120, - step: 1, - }, - { - name: 'Good+ primary schools within 2km', - type: 'numeric', - group: 'Education', - min: 0, - max: 8, - step: 1, - }, - { - name: 'Noise (dB)', - type: 'numeric', - group: 'Environment', - min: 40, - max: 80, - step: 1, - suffix: ' dB', - }, - { - name: 'Travel time to nearest train or tube station (min)', - type: 'numeric', - group: 'Transport', - min: 0, - max: 60, - step: 1, - suffix: ' min', - }, -]; - -const PRICE_POINTS: PricePoint[] = [ - { year: 2017.15, price: 358000 }, - { year: 2017, price: 365000 }, - { year: 2017.42, price: 382000 }, - { year: 2017.78, price: 354000 }, - { year: 2018.2, price: 378000 }, - { year: 2018, price: 372000 }, - { year: 2018.67, price: 392000 }, - { year: 2019.12, price: 383000 }, - { year: 2019, price: 389000 }, - { year: 2019.55, price: 401000 }, - { year: 2019.88, price: 415000 }, - { year: 2020.25, price: 395000 }, - { year: 2020, price: 402000 }, - { year: 2020.62, price: 423000 }, - { year: 2021.08, price: 421000 }, - { year: 2021, price: 431000 }, - { year: 2021.34, price: 446000 }, - { year: 2021.79, price: 437000 }, - { year: 2022.17, price: 449000 }, - { year: 2022, price: 456000 }, - { year: 2022.53, price: 467000 }, - { year: 2022.91, price: 463000 }, - { year: 2023.22, price: 472000 }, - { year: 2023, price: 470000 }, - { year: 2023.63, price: 483000 }, - { year: 2024.18, price: 474000 }, - { year: 2024, price: 482000 }, - { year: 2024.71, price: 493000 }, - { year: 2025, price: 492000 }, -]; - -const SCHOOL_HISTOGRAM_GLOBAL = [12, 20, 27, 24, 18, 12, 7, 4, 2]; -const SCHOOL_HISTOGRAM_LOCAL = [0, 0, 0, 0, 18, 0, 0, 0, 0]; -const SCHOOL_NEARBY_COUNT = 4; -const SCHOOL_GLOBAL_MEAN = 2.6; -const CRIME_SEGMENTS = [ - { name: 'Anti-social', value: 34 }, - { name: 'Vehicle', value: 18 }, - { name: 'Burglary', value: 11 }, - { name: 'Other', value: 19 }, -]; - -const VOTE_SHARE_SEGMENTS = [ - { name: '% Labour', value: 42 }, - { name: '% Conservative', value: 17 }, - { name: '% Liberal Democrat', value: 12 }, - { name: '% Reform UK', value: 7 }, - { name: '% Green', value: 18 }, - { name: '% Other parties', value: 4 }, -]; - -const INSPECT_TRAVEL_ENTRIES: TravelTimeEntry[] = [ - { - mode: 'transit', - slug: 'oxford-circus', - label: 'Oxford Circus', - timeRange: [0, 45], - useBest: false, - }, -]; - -const INSPECT_JOURNEYS: JourneyInstructionPreset[] = [ - { - slug: 'oxford-circus', - label: 'Oxford Circus', - minutes: 34, - bestMinutes: 29, - legs: [ - { mode: 'Victoria', from: 'Oxford Circus Underground Station', to: 'Victoria', minutes: 4 }, - { mode: 'District', from: 'Victoria', to: "Earl's Court", minutes: 10 }, - { mode: 'walk', from: "Earl's Court", to: 'SW5 9AA', minutes: 7 }, - ], - }, -]; - -const ENGLAND_SHOWCASE_POLYGON: number[][] = [ - [49.95, -5.72], - [50.16, -3.84], - [50.5, -2.22], - [50.74, -0.18], - [51.12, 1.4], - [52.04, 1.72], - [53.02, 1.2], - [54.12, 0.04], - [55.58, -1.58], - [55.82, -2.05], - [54.96, -3.08], - [54.34, -3.42], - [53.45, -3.06], - [52.76, -2.2], - [52.02, -2.78], - [51.38, -3.06], - [50.78, -3.52], - [50.22, -4.76], -]; - -const SHOWCASE_MAP_START_VIEW: ViewState = { - longitude: -1.7, - latitude: 52.7, - zoom: 6.6, - pitch: 0, - bearing: 0, -}; - -const SHOWCASE_MAP_END_VIEW: ViewState = { - longitude: -1.89, - latitude: 52.49, - zoom: 8.72, - pitch: 0, - bearing: 0, -}; - -const SHOWCASE_MAP_CENTRES = [ - { lat: 52.4862, lon: -1.8904, weight: 190, spread: 0.48 }, - { lat: 51.5072, lon: -0.1276, weight: 112, spread: 0.5 }, - { lat: 53.4808, lon: -2.2426, weight: 92, spread: 0.42 }, - { lat: 53.8008, lon: -1.5491, weight: 74, spread: 0.38 }, - { lat: 52.9548, lon: -1.1581, weight: 68, spread: 0.34 }, - { lat: 51.4545, lon: -2.5879, weight: 58, spread: 0.34 }, -]; - -function stableMapJitter(value: string): number { - let hash = 0; - for (let index = 0; index < value.length; index += 1) { - hash = (hash * 31 + value.charCodeAt(index)) % 9973; - } - return hash / 9973; -} - -function buildShowcaseMapData(): HexagonData[] { - return polygonToCells(ENGLAND_SHOWCASE_POLYGON, 6).flatMap((h3) => { - const [lat, lon] = cellToLatLng(h3); - const centreWeight = SHOWCASE_MAP_CENTRES.reduce((sum, centre) => { - const latDelta = (lat - centre.lat) * 1.28; - const lonDelta = (lon - centre.lon) * Math.cos((centre.lat * Math.PI) / 180); - const distance = Math.hypot(latDelta, lonDelta); - return sum + Math.max(0, 1 - distance / centre.spread) * centre.weight; - }, 0); - const corridorWeight = Math.max(0, 1 - Math.abs(lon + 1.7 + (lat - 52.45) * 0.34) / 0.62); - const jitter = stableMapJitter(h3); - const inclusionChance = Math.min(0.42, 0.04 + centreWeight / 340); - if (centreWeight < 18 || jitter > inclusionChance) return []; - - const count = Math.round(18 + centreWeight + corridorWeight * 22 + jitter * 42); - - return { h3, lat, lon, count }; - }); -} - -const SHOWCASE_MAP_DATA = buildShowcaseMapData(); -const SHOWCASE_MAP_TOTAL_COUNT = SHOWCASE_MAP_DATA.reduce((sum, item) => sum + item.count, 0); -const EMPTY_SHOWCASE_POSTCODES: PostcodeFeature[] = []; -const EMPTY_SHOWCASE_POIS: POI[] = []; - -function noopViewChange(_params: ViewChangeParams) { - void _params; -} - -function noopHexagonClick(_id: string) { - void _id; -} - -function noopHexagonHover(_h3: string | null) { - void _h3; -} - -function easeInOutCubic(value: number): number { - return value < 0.5 ? 4 * value * value * value : 1 - Math.pow(-2 * value + 2, 3) / 2; -} - -function interpolateViewState(progress: number): ViewState { - const eased = easeInOutCubic(Math.max(0, Math.min(1, progress))); - return { - longitude: - SHOWCASE_MAP_START_VIEW.longitude + - (SHOWCASE_MAP_END_VIEW.longitude - SHOWCASE_MAP_START_VIEW.longitude) * eased, - latitude: - SHOWCASE_MAP_START_VIEW.latitude + - (SHOWCASE_MAP_END_VIEW.latitude - SHOWCASE_MAP_START_VIEW.latitude) * eased, - zoom: - SHOWCASE_MAP_START_VIEW.zoom + - (SHOWCASE_MAP_END_VIEW.zoom - SHOWCASE_MAP_START_VIEW.zoom) * eased, - pitch: 0, - bearing: 0, - }; -} - -function demoSliderStep(feature: FeatureMeta): number { - if (feature.name === 'Estimated price') return 1000; - if (feature.name === 'Noise (dB)') return 0.05; - if (feature.name === 'Good+ primary schools within 2km') return 0.01; - if (feature.name === 'Travel time to nearest train or tube station (min)') return 0.1; - return feature.step ?? 1; -} - -const FILTER_ROW_STYLES = [ - { - rail: 'bg-teal-500', - activeSurface: 'bg-teal-50/40', - chip: 'bg-coral-50 text-coral-700', - }, - { - rail: 'bg-coral-500', - activeSurface: 'bg-coral-50/40', - chip: 'bg-teal-50 text-teal-700', - }, - { - rail: 'bg-navy-500', - activeSurface: 'bg-navy-50/50', - chip: 'bg-navy-50 text-navy-700', - }, - { - rail: 'bg-amber-500', - activeSurface: 'bg-amber-50/50', - chip: 'bg-amber-50 text-amber-800', - }, -]; - -function FilterPreviewRow({ - feature, - value, - rangeLabel, - withoutCount, - index, - isTightened, - onValueChange, -}: { - feature: FeatureMeta; - value: [number, number]; - rangeLabel: string; - withoutCount: number; - index: number; - isTightened: boolean; - onValueChange: (value: [number, number]) => void; -}) { - const { t } = useTranslation(); - const style = FILTER_ROW_STYLES[index % FILTER_ROW_STYLES.length]; - const shortLabelKeys = { - 'Estimated price': 'home.showcaseFeaturePriceShort', - 'Noise (dB)': 'home.showcaseFeatureNoiseShort', - 'Good+ primary schools within 2km': 'home.showcaseFeatureSchoolsShort', - 'Travel time to nearest train or tube station (min)': 'home.showcaseFeatureTravelShort', - } as const; - const shortLabelKey = shortLabelKeys[feature.name as keyof typeof shortLabelKeys]; - const shortLabel = shortLabelKey ? t(shortLabelKey) : undefined; - - return ( -
- {active.body} -
-