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 ( -
-
-
-
- -
- -
-
- {rangeLabel} -
-
- - +{withoutCount.toLocaleString()} - {' without this filter'} - -
-
- onValueChange([nextValue[0], nextValue[1]])} - /> -
-
- ); -} - -function formatCompactCurrency(value: number): string { - if (value >= 1000000) return `£${(value / 1000000).toFixed(1)}m`; - return `£${Math.round(value / 1000)}k`; -} - -function formatDemoRange(feature: FeatureMeta, value: [number, number]): string { - if (feature.name === 'Estimated price') { - return `${formatCompactCurrency(value[0])} - ${formatCompactCurrency(value[1])}`; - } - if (feature.name === 'Noise (dB)') { - return `${Math.round(value[0])} - ${Math.round(value[1])} dB`; - } - if (feature.name === 'Good+ primary schools within 2km') { - return `${Math.round(value[0])}+ good primaries nearby`; - } - if (feature.name === 'Travel time to nearest train or tube station (min)') { - return `Within ${Math.round(value[1])} min of rail`; - } - return `${value[0]} - ${value[1]}`; -} - -function randomWithoutCount(feature: FeatureMeta, value: [number, number], index: number): number { - const min = feature.min ?? 0; - const max = feature.max ?? 100; - const range = Math.max(max - min, 1); - const selectedWidth = Math.max(value[1] - value[0], 0); - const restrictiveness = Math.max(0.08, Math.min(0.92, 1 - selectedWidth / range)); - const bases = [52000, 23000, 17000, 15000]; - const jitter = 0.86 + Math.random() * 0.28; - const raw = bases[index] * (0.28 + restrictiveness * 0.9) * jitter; - return Math.max(900, Math.round(raw / 113) * 113); -} - -function interpolateRangePath(ranges: [number, number][], progress: number): [number, number] { - if (progress >= 1) return ranges[ranges.length - 1]; - - const segmentCount = Math.max(ranges.length - 1, 1); - const scaled = Math.max(0, progress) * segmentCount; - const index = Math.min(Math.floor(scaled), segmentCount - 1); - const nextIndex = index + 1; - const localProgress = scaled - index; - const eased = easeInOutCubic(localProgress); - const [fromMin, fromMax] = ranges[index]; - const [toMin, toMax] = ranges[nextIndex]; - - return [fromMin + (toMin - fromMin) * eased, fromMax + (toMax - fromMax) * eased]; -} - -function FilterOnlyScreen({ isActive }: { isActive: boolean }) { - const [hasUserAdjusted, setHasUserAdjusted] = useState(false); - - const rows = useMemo( - () => [ - { - feature: DEMO_FEATURES[0], - values: [ - [0, 650000], - [95000, 725000], - [240000, 535000], - [135000, 485000], - [285000, 610000], - [0, 650000], - ] as [number, number][], - without: [41820, 50622, 24860, 18645, 29796, 41820], - }, - { - feature: DEMO_FEATURES[3], - values: [ - [40, 58], - [43, 52], - [40, 58], - ] as [number, number][], - without: [19412, 8706, 19412], - }, - { - feature: DEMO_FEATURES[4], - values: [ - [0, 60], - [5, 25], - [0, 60], - ] as [number, number][], - without: [11209, 4118, 11209], - }, - { - feature: DEMO_FEATURES[2], - values: [ - [1, 8], - [2, 6], - [1, 8], - ] as [number, number][], - without: [13608, 6944, 13608], - }, - ], - [] - ); - - const [filterState, setFilterState] = useState(() => - rows.map((row) => ({ value: row.values[0], without: row.without[0] })) - ); - - useEffect(() => { - if (!isActive || hasUserAdjusted) return; - let frame = 0; - const start = window.performance.now(); - setFilterState(rows.map((row) => ({ value: row.values[0], without: row.without[0] }))); - - const animate = (timestamp: number) => { - const progress = ((timestamp - start) % FILTER_ANIMATION_MS) / FILTER_ANIMATION_MS; - setFilterState( - rows.map((row) => { - const value = interpolateRangePath(row.values, progress); - const without = interpolateRangePath( - row.without.map((count) => [count, count]), - progress - )[0]; - return { - value, - without: Math.round(without), - }; - }) - ); - frame = window.requestAnimationFrame(animate); - }; - - frame = window.requestAnimationFrame(animate); - return () => window.cancelAnimationFrame(frame); - }, [hasUserAdjusted, isActive, rows]); - - const updateFilter = (index: number, value: [number, number]) => { - setHasUserAdjusted(true); - setFilterState((current) => - current.map((item, itemIndex) => - itemIndex === index - ? { - value, - without: randomWithoutCount(rows[index].feature, value, index), - } - : item - ) - ); - }; - - return ( -
-
- {rows.map((row, index) => ( -
= 3 ? 'hidden sm:block' : undefined}> - updateFilter(index, value)} - /> -
- ))} -
-
- ); -} - -function EnglandHexMapScreen({ isActive }: { isActive: boolean }) { - const [viewState, setViewState] = useState(SHOWCASE_MAP_START_VIEW); - const elapsedRef = useRef(0); - const lastFrameRef = useRef(null); - - useEffect(() => { - elapsedRef.current = 0; - lastFrameRef.current = null; - setViewState(SHOWCASE_MAP_START_VIEW); - }, [isActive]); - - useEffect(() => { - if (!isActive) return; - - let frame = 0; - const animate = (timestamp: number) => { - const lastFrame = lastFrameRef.current ?? timestamp; - elapsedRef.current = Math.min( - SHOWCASE_INTERVAL_MS, - elapsedRef.current + timestamp - lastFrame - ); - setViewState(interpolateViewState(elapsedRef.current / SHOWCASE_INTERVAL_MS)); - lastFrameRef.current = timestamp; - - if (elapsedRef.current < SHOWCASE_INTERVAL_MS) { - frame = window.requestAnimationFrame(animate); - } - }; - - frame = window.requestAnimationFrame(animate); - return () => window.cancelAnimationFrame(frame); - }, [isActive]); - - return ( -
- {}} - features={DEMO_FEATURES} - selectedHexagonId={null} - hoveredHexagonId={null} - onHexagonClick={noopHexagonClick} - onHexagonHover={noopHexagonHover} - initialViewState={viewState} - theme="dark" - screenshotMode - hideLegend - densityLabel="Matching homes" - totalCount={SHOWCASE_MAP_TOTAL_COUNT} - /> -
-
Birmingham
-
- {SHOWCASE_MAP_TOTAL_COUNT.toLocaleString()} matching homes -
-
-
- ); -} - -function RightPaneOnlyScreen({ - isActive, - userScrolledRef, -}: { - isActive: boolean; - userScrolledRef: MutableRefObject; -}) { - const { t } = useTranslation(); - const scrollerRef = useRef(null); - const wasActiveRef = useRef(false); - - useEffect(() => { - if (isActive && !wasActiveRef.current) { - userScrolledRef.current = false; - } - wasActiveRef.current = isActive; - }, [isActive, userScrolledRef]); - - useEffect(() => { - if (!isActive || userScrolledRef.current) return; - - const scroller = scrollerRef.current; - if (!scroller) return; - - scroller.scrollTop = 0; - let frame = 0; - let elapsed = 0; - let lastFrame: number | null = null; - - const animate = (timestamp: number) => { - if (userScrolledRef.current) return; - - const previous = lastFrame ?? timestamp; - elapsed = Math.min(INSPECT_SCROLL_ANIMATION_MS, elapsed + timestamp - previous); - const maxScroll = Math.max(0, scroller.scrollHeight - scroller.clientHeight); - scroller.scrollTop = maxScroll * easeInOutCubic(elapsed / INSPECT_SCROLL_ANIMATION_MS); - - lastFrame = timestamp; - if (elapsed < INSPECT_SCROLL_ANIMATION_MS) { - frame = window.requestAnimationFrame(animate); - } - }; - - frame = window.requestAnimationFrame(animate); - return () => window.cancelAnimationFrame(frame); - }, [isActive, userScrolledRef]); - - const markUserScrolled = () => { - if (!isActive) return; - userScrolledRef.current = true; - }; - - return ( -
-
-
-
-
- - - £492k median - -
- -
-
-
- - Journey routes -
- -
-
-
- - - {formatValue(SCHOOL_NEARBY_COUNT, DEMO_FEATURES[2])} nearby - -
- value.toFixed(0)} - /> -
-
-
- - {t('home.showcaseStep3Stat2Label')} -
- -
-
-
-
- - Political vote share -
- - 2024 GE - -
- -
-
- ...and lots more -
-
-
-
- ); -} - -function ScoutScreen({ isActive }: { isActive: boolean }) { - const { t } = useTranslation(); - const [isTableRevealed, setIsTableRevealed] = useState(false); - const scoutRows = [ - { postcode: 'SW5 9AA', score: '94%', commute: '23 min', price: '£492k' }, - { postcode: 'SE22 8EF', score: '91%', commute: '28 min', price: '£518k' }, - { postcode: 'N4 2AB', score: '88%', commute: '31 min', price: '£476k' }, - ]; - - useEffect(() => { - if (!isActive) { - setIsTableRevealed(false); - return; - } - - const prefersReducedMotion = - typeof window.matchMedia === 'function' && - window.matchMedia('(prefers-reduced-motion: reduce)').matches; - if (prefersReducedMotion) { - setIsTableRevealed(true); - return; - } - - setIsTableRevealed(false); - const timer = window.setTimeout(() => setIsTableRevealed(true), SCOUT_TABLE_REVEAL_MS); - return () => window.clearTimeout(timer); - }, [isActive]); - - return ( -
-
-
-
-
-
- - - -
-
- Share -
-
- Send the shortlist -
-
-
-
-
-
-
-
- -
-
-
-
- - - - - {t('home.showcaseStep4FileName')} - -
- - Top 3 - -
-
- {[ - t('home.showcaseStep4ColPostcode'), - t('home.showcaseStep4ColScore'), - t('home.showcaseStep4ColCommute'), - t('home.showcaseStep4ColPrice'), - ].map((heading) => ( -
- {heading} -
- ))} -
- {scoutRows.map((row, index) => ( -
-
- - {index + 1} - - {row.postcode} -
-
-
- - {row.score} - - - - -
-
-
- {row.commute} -
-
{row.price}
-
- ))} -
-
- -
-
- {t('home.showcaseStep4Conclusion')} -
-
- {[ - 'Walk the streets before the listing search narrows your options.', - 'Test the commute from a real front door, not a borough name.', - 'Compare viewings with evidence already in hand.', - ].map((item) => ( -
- - {item} -
- ))} -
-
-
-
- ); -} - -function DashboardShowcase({ - activeStep, - active, - hasStarted, - inspectUserScrolledRef, -}: { - activeStep: number; - active: ShowcaseStep; - hasStarted: boolean; - inspectUserScrolledRef: MutableRefObject; -}) { - const screens = [ - , - , - , - , - ]; - const ActiveIcon = active.Icon; - const showStageHeader = activeStep !== 3; - - return ( -
- {showStageHeader && ( -
-
-
- -
-
-
{active.tab}
-

- {active.title} -

-

- {active.body} -

-
-
-
- )} -
- {screens.map((screen, index) => ( -
- {screen} -
- ))} -
-
- ); -} - -function HeroProductShowcase() { - const { t } = useTranslation(); - const [activeStep, setActiveStep] = useState(0); - const [isStagePaused, setIsStagePaused] = useState(false); - const [hasStarted, setHasStarted] = useState(false); - const [canPauseOnHover, setCanPauseOnHover] = useState(false); - const showcaseRef = useRef(null); - const inspectUserScrolledRef = useRef(false); - - const steps: ShowcaseStep[] = [ - { - tab: t('home.showcaseStep1Tab'), - title: t('home.showcaseStep1Title'), - body: t('home.showcaseStep1Body'), - Icon: FilterIcon, - }, - { - tab: t('home.showcaseStep2Tab'), - title: t('home.showcaseStep2Title'), - body: t('home.showcaseStep2Body'), - Icon: MapPinIcon, - }, - { - tab: t('home.showcaseStep3Tab'), - title: t('home.showcaseStep3Title'), - body: t('home.showcaseStep3Body'), - Icon: ChartBarIcon, - }, - { - tab: t('home.showcaseStep4Tab'), - title: t('home.showcaseStep4Title'), - body: t('home.showcaseStep4Body'), - Icon: RouteIcon, - }, - ]; - - const active = steps[activeStep]; - const activeStepIntervalMs = SHOWCASE_STEP_INTERVALS_MS[activeStep] ?? SHOWCASE_INTERVAL_MS; - const isProgressRunning = hasStarted && !isStagePaused; - - useEffect(() => { - const showcase = showcaseRef.current; - if (!showcase || hasStarted) return; - - if (!('IntersectionObserver' in window)) { - setHasStarted(true); - return; - } - - const observer = new IntersectionObserver( - ([entry]) => { - if (!entry.isIntersecting) return; - setHasStarted(true); - observer.disconnect(); - }, - { threshold: 0.2 } - ); - - observer.observe(showcase); - return () => observer.disconnect(); - }, [hasStarted]); - - useEffect(() => { - if (typeof window.matchMedia !== 'function') return; - - const mediaQuery = window.matchMedia('(hover: hover) and (pointer: fine)'); - const updateCanPause = () => { - setCanPauseOnHover(mediaQuery.matches); - if (!mediaQuery.matches) setIsStagePaused(false); - }; - - updateCanPause(); - mediaQuery.addEventListener('change', updateCanPause); - return () => mediaQuery.removeEventListener('change', updateCanPause); - }, []); - - const pauseForHover = () => { - if (canPauseOnHover) setIsStagePaused(true); - }; - - const resumeAfterHover = () => { - if (canPauseOnHover) setIsStagePaused(false); - }; - - return ( -
-
-
-
- {steps.map((step, index) => { - const Icon = step.Icon; - return ( - - ); - })} -
-
-
- -
-
-
- ); -} - export default function HomePage({ onOpenDashboard, onOpenPricing: _onOpenPricing, @@ -1459,7 +321,7 @@ export default function HomePage({
- + + ); + })} + + +
+ +
+ + + ); +} diff --git a/frontend/src/components/map/EnumBarChart.tsx b/frontend/src/components/map/EnumBarChart.tsx index 1c31b89..b1ec12a 100644 --- a/frontend/src/components/map/EnumBarChart.tsx +++ b/frontend/src/components/map/EnumBarChart.tsx @@ -7,7 +7,7 @@ export default function EnumBarChart({ }: { counts: Record; globalCounts?: Record; - featureName?: string; + featureName: string; }) { const entries = Object.entries(counts).sort(([, countA], [, countB]) => countB - countA); const localTotal = entries.reduce((sum, [, c]) => sum + c, 0); @@ -40,10 +40,8 @@ export default function EnumBarChart({ : (count / maxCount) * 100; const globalWidth = hasGlobal && maxPct > 0 ? (globalPct / maxPct) * 100 : 0; - const overrideColor = featureName ? getEnumValueColor(featureName, label) : null; - const barStyle = overrideColor - ? `rgb(${overrideColor[0]},${overrideColor[1]},${overrideColor[2]})` - : undefined; + const color = getEnumValueColor(featureName, label); + const barStyle = `rgb(${color[0]},${color[1]},${color[2]})`; return (
@@ -58,14 +56,10 @@ export default function EnumBarChart({ /> )}
diff --git a/frontend/src/components/map/FeatureBrowser.tsx b/frontend/src/components/map/FeatureBrowser.tsx index 2759c47..e2d148e 100644 --- a/frontend/src/components/map/FeatureBrowser.tsx +++ b/frontend/src/components/map/FeatureBrowser.tsx @@ -6,7 +6,7 @@ import { SearchInput } from '../ui/SearchInput'; import { FilterIcon } from '../ui/icons'; import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader'; import { EmptyState } from '../ui/EmptyState'; -import type { FeatureMeta } from '../../types'; +import type { FeatureGroup, FeatureMeta } from '../../types'; import { groupFeaturesByCategory } from '../../lib/features'; import { FeatureInfoPopup } from '../ui/FeatureInfoPopup'; import { FeatureActions } from '../ui/FeatureIcons'; @@ -35,6 +35,16 @@ interface FeatureBrowserProps { onAddTravelTimeEntry: (mode: TransportMode) => void; } +function moveTransportFirst(groups: FeatureGroup[]): FeatureGroup[] { + const transportIdx = groups.findIndex((group) => group.name === 'Transport'); + if (transportIdx <= 0) return groups; + return [ + groups[transportIdx], + ...groups.slice(0, transportIdx), + ...groups.slice(transportIdx + 1), + ]; +} + export default function FeatureBrowser({ availableFeatures, allFeatures, @@ -73,7 +83,7 @@ export default function FeatureBrowser({ ); }, [availableFeatures, search]); - const grouped = useMemo(() => groupFeaturesByCategory(filtered), [filtered]); + const grouped = useMemo(() => moveTransportFirst(groupFeaturesByCategory(filtered)), [filtered]); // When searching, expand all groups so results are visible const isSearching = search.length > 0; @@ -91,14 +101,11 @@ export default function FeatureBrowser({ search.toLowerCase() )); - // Ensure "Transport" group exists when travel modes should be shown + // Ensure "Transport" group exists first when travel modes should be shown. const mergedGrouped = useMemo(() => { if (!showTravelModes) return grouped; if (grouped.some((g) => g.name === 'Transport')) return grouped; - const groups = [...grouped]; - const propsIdx = groups.findIndex((g) => g.name === 'Properties in the area'); - groups.splice(propsIdx === -1 ? 0 : propsIdx + 1, 0, { name: 'Transport', features: [] }); - return groups; + return [{ name: 'Transport', features: [] }, ...grouped]; }, [grouped, showTravelModes]); return ( @@ -133,26 +140,6 @@ export default function FeatureBrowser({ {isExpanded && ( <> - {group.features.map((f) => { - const isPinned = pinnedFeature === f.name; - return ( -
-
- -
- -
- ); - })} {group.name === 'Transport' && showTravelModes && visibleModes.map((mode) => { @@ -179,22 +166,46 @@ export default function FeatureBrowser({
setTravelInfoMode(mode)} - title={t('filters.featureInfo')} + title={t('filters.aboutData')} size="md" >
); })} + {group.features.map((f) => { + const isPinned = pinnedFeature === f.name; + return ( +
+
+ +
+ +
+ ); + })} )} diff --git a/frontend/src/components/map/Filters.tsx b/frontend/src/components/map/Filters.tsx index e618778..583a255 100644 --- a/frontend/src/components/map/Filters.tsx +++ b/frontend/src/components/map/Filters.tsx @@ -40,6 +40,17 @@ import { isSpecificCrimeFilterName, replaceSpecificCrimeFilterKeySelection, } from '../../lib/crime-filter'; +import { + ETHNICITIES_FILTER_NAME, + ETHNICITY_FEATURE_NAMES, + clampEthnicityRange, + getDefaultEthnicityFeatureName, + getEthnicityFeatureName, + getEthnicityFilterMeta, + isEthnicityFeatureName, + isEthnicityFilterName, + replaceEthnicityFilterKeySelection, +} from '../../lib/ethnicity-filter'; import { SCHOOL_FILTER_NAME, clampSchoolRange, @@ -53,6 +64,25 @@ import { type SchoolPhase, type SchoolRating, } from '../../lib/school-filter'; +import { + POI_FILTER_NAMES, + POI_DISTANCE_FILTER_NAME, + POI_COUNT_2KM_FILTER_NAME, + POI_COUNT_5KM_FILTER_NAME, + clampPoiFilterRange, + getDefaultPoiDistanceFeatureName, + getDefaultPoiFilterFeatureName, + getPoiFeatureCategory, + getPoiDistanceFeatureName, + getPoiFilterFeatureOptions, + getPoiFilterMeta, + getPoiDistanceFilterMeta, + getPoiFilterName, + isPoiDistanceFilterName, + isPoiFilterFeatureName, + replacePoiFilterKeySelection, + type PoiFilterName, +} from '../../lib/poi-distance-filter'; function EditableLabel({ value, @@ -146,9 +176,10 @@ function SliderLabels({ const leftPct = Math.max(0, Math.min(100, ((value[0] - min) / range) * 100)); const rightPct = Math.max(0, Math.min(100, ((value[1] - min) / range) * 100)); const labels = displayValues || value; + const labelFormat = feature?.suffix === '%' ? { raw, suffix: feature.suffix } : raw; - const minLabel = isAtMin ? 'min' : formatFilterValue(labels[0], raw); - const maxLabel = isAtMax ? 'max' : formatFilterValue(labels[1], raw); + const minLabel = isAtMin ? 'min' : formatFilterValue(labels[0], labelFormat); + const maxLabel = isAtMax ? 'max' : formatFilterValue(labels[1], labelFormat); // Smoothly spread labels apart as thumbs get close to prevent overlap. // t=1 (centered) when far apart, t=0 (split) when touching. @@ -614,6 +645,409 @@ function SpecificCrimeFilterCard({ ); } +function EthnicityFilterCard({ + features, + ethnicityFeature, + filters, + activeFeature, + dragValue, + pinnedFeature, + filterImpact, + percentileScale, + onFilterChange, + onDragStart, + onDragChange, + onDragEnd, + onTogglePin, + onShowInfo, + onRemove, +}: { + features: FeatureMeta[]; + ethnicityFeature: FeatureMeta; + filters: FeatureFilters; + activeFeature: string | null; + dragValue: [number, number] | null; + pinnedFeature: string | null; + filterImpact?: number; + percentileScale?: PercentileScale; + onFilterChange: (name: string, value: [number, number] | string[]) => void; + onDragStart: (name: string) => void; + onDragChange: (value: [number, number]) => void; + onDragEnd: () => void; + onTogglePin: (name: string) => void; + onShowInfo: (feature: FeatureMeta) => void; + onRemove: () => void; +}) { + const ethnicityMeta = getEthnicityFilterMeta(features); + const ethnicityOptions = ETHNICITY_FEATURE_NAMES.map((name) => + features.find((feature) => feature.name === name) + ).filter((feature): feature is FeatureMeta => Boolean(feature)); + const selectedFeatureName = + getEthnicityFeatureName(ethnicityFeature.name) ?? getDefaultEthnicityFeatureName(features); + const selectedFeature = selectedFeatureName + ? features.find((feature) => feature.name === selectedFeatureName) + : undefined; + + if (!selectedFeature || ethnicityOptions.length === 0 || !selectedFeatureName) return null; + + const isActive = activeFeature === ethnicityFeature.name; + const isPinned = pinnedFeature === ethnicityFeature.name; + const hist = selectedFeature.histogram; + const dataMin = hist?.min ?? selectedFeature.min ?? 0; + const dataMax = hist?.max ?? selectedFeature.max ?? 100; + const displayValue = + isActive && dragValue + ? dragValue + : (filters[ethnicityFeature.name] as [number, number]) || [dataMin, dataMax]; + const scale = percentileScale; + const clampMin = displayValue[0] <= dataMin; + const clampMax = displayValue[1] >= dataMax; + const isAtMin = displayValue[0] === dataMin; + const isAtMax = displayValue[1] === dataMax; + const sliderValue: [number, number] = scale + ? [ + clampMin ? 0 : Math.round(scale.toPercentile(displayValue[0])), + clampMax ? 100 : Math.round(scale.toPercentile(displayValue[1])), + ] + : [ + clampMin ? (selectedFeature.min ?? dataMin) : displayValue[0], + clampMax ? (selectedFeature.max ?? dataMax) : displayValue[1], + ]; + + const replaceEthnicityFeature = (nextFeatureName: string) => { + const nextName = replaceEthnicityFilterKeySelection(ethnicityFeature.name, nextFeatureName); + if (nextName === ethnicityFeature.name) return; + + const nextFeature = features.find((feature) => feature.name === nextFeatureName); + const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0; + const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax); + const nextRange = clampEthnicityRange( + [ + displayValue[0] <= dataMin ? nextDataMin : displayValue[0], + displayValue[1] >= dataMax ? nextDataMax : displayValue[1], + ], + nextFeature + ); + + onFilterChange(nextName, nextRange); + if (isPinned) onTogglePin(nextName); + }; + + const mobileIconClass = 'w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0'; + const mobileIcon = + getFeatureIcon(selectedFeature.name, mobileIconClass) || + (() => { + const G = selectedFeature.group ? getGroupIcon(selectedFeature.group) : null; + return G ? : null; + })(); + + return ( +
+
+ + +
+ +
+ +
+ + +
+
+ +
+ {mobileIcon &&
{mobileIcon}
} +
+ { + const step = selectedFeature.step ?? 1; + const snap = (v: number) => Math.round(v / step) * step; + onDragChange([ + pMin <= 0 ? dataMin : snap(scale.toValue(pMin)), + pMax >= 100 ? dataMax : snap(scale.toValue(pMax)), + ]); + } + : ([min, max]) => + onDragChange([ + min <= (selectedFeature.min ?? dataMin) ? dataMin : min, + max >= (selectedFeature.max ?? dataMax) ? dataMax : max, + ]) + } + onPointerDown={() => onDragStart(ethnicityFeature.name)} + onPointerUp={() => onDragEnd()} + /> + + onFilterChange(ethnicityFeature.name, clampEthnicityRange(v, selectedFeature)) + } + /> + {filterImpact != null && filterImpact > 0 && ( +

+ +{formatNumber(filterImpact)} without this filter +

+ )} +
+
+
+ ); +} + +function PoiDistanceFilterCard({ + features, + poiFeature, + filters, + activeFeature, + dragValue, + pinnedFeature, + filterImpact, + percentileScale, + onFilterChange, + onDragStart, + onDragChange, + onDragEnd, + onTogglePin, + onShowInfo, + onRemove, +}: { + features: FeatureMeta[]; + poiFeature: FeatureMeta; + filters: FeatureFilters; + activeFeature: string | null; + dragValue: [number, number] | null; + pinnedFeature: string | null; + filterImpact?: number; + percentileScale?: PercentileScale; + onFilterChange: (name: string, value: [number, number] | string[]) => void; + onDragStart: (name: string) => void; + onDragChange: (value: [number, number]) => void; + onDragEnd: () => void; + onTogglePin: (name: string) => void; + onShowInfo: (feature: FeatureMeta) => void; + onRemove: () => void; +}) { + const filterName = getPoiFilterName(poiFeature.name) ?? POI_DISTANCE_FILTER_NAME; + const poiMeta = getPoiFilterMeta(features, filterName); + const poiOptions = getPoiFilterFeatureOptions(features, filterName); + const selectedFeatureName = + getPoiDistanceFeatureName(poiFeature.name) ?? + getDefaultPoiFilterFeatureName(features, filterName); + const selectedFeature = selectedFeatureName + ? features.find((feature) => feature.name === selectedFeatureName) + : undefined; + + if (!selectedFeature || poiOptions.length === 0 || !selectedFeatureName) return null; + + const isActive = activeFeature === poiFeature.name; + const isPinned = pinnedFeature === poiFeature.name; + const hist = selectedFeature.histogram; + const dataMin = hist?.min ?? selectedFeature.min ?? 0; + const dataMax = hist?.max ?? selectedFeature.max ?? 5; + const displayValue = + isActive && dragValue + ? dragValue + : (filters[poiFeature.name] as [number, number]) || [dataMin, dataMax]; + const scale = percentileScale; + const clampMin = displayValue[0] <= dataMin; + const clampMax = displayValue[1] >= dataMax; + const isAtMin = displayValue[0] === dataMin; + const isAtMax = displayValue[1] === dataMax; + const sliderValue: [number, number] = scale + ? [ + clampMin ? 0 : Math.round(scale.toPercentile(displayValue[0])), + clampMax ? 100 : Math.round(scale.toPercentile(displayValue[1])), + ] + : [ + clampMin ? (selectedFeature.min ?? dataMin) : displayValue[0], + clampMax ? (selectedFeature.max ?? dataMax) : displayValue[1], + ]; + + const replacePoiFeature = (nextFeatureName: string) => { + const nextName = replacePoiFilterKeySelection(poiFeature.name, nextFeatureName); + if (nextName === poiFeature.name) return; + + const nextFeature = features.find((feature) => feature.name === nextFeatureName); + const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0; + const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax); + const nextRange = clampPoiFilterRange( + [ + displayValue[0] <= dataMin ? nextDataMin : displayValue[0], + displayValue[1] >= dataMax ? nextDataMax : displayValue[1], + ], + nextFeature + ); + + onFilterChange(nextName, nextRange); + if (isPinned) onTogglePin(nextName); + }; + + const mobileIconClass = 'w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0'; + const mobileIcon = + getFeatureIcon(selectedFeature.name, mobileIconClass) || + (() => { + const G = selectedFeature.group ? getGroupIcon(selectedFeature.group) : null; + return G ? : null; + })(); + + return ( +
+
+ + +
+ +
+ +
+ + +
+
+ +
+ {mobileIcon &&
{mobileIcon}
} +
+ { + const step = selectedFeature.step ?? 0.1; + const snap = (v: number) => Math.round(v / step) * step; + onDragChange([ + pMin <= 0 ? dataMin : snap(scale.toValue(pMin)), + pMax >= 100 ? dataMax : snap(scale.toValue(pMax)), + ]); + } + : ([min, max]) => + onDragChange([ + min <= (selectedFeature.min ?? dataMin) ? dataMin : min, + max >= (selectedFeature.max ?? dataMax) ? dataMax : max, + ]) + } + onPointerDown={() => onDragStart(poiFeature.name)} + onPointerUp={() => onDragEnd()} + /> + + onFilterChange(poiFeature.name, clampPoiFilterRange(v, selectedFeature)) + } + /> + {filterImpact != null && filterImpact > 0 && ( +

+ +{formatNumber(filterImpact)} without this filter +

+ )} +
+
+
+ ); +} + interface FiltersProps { features: FeatureMeta[]; filters: FeatureFilters; @@ -712,6 +1146,48 @@ export default memo(function Filters({ [features] ); const specificCrimeMeta = useMemo(() => getSpecificCrimeFilterMeta(features), [features]); + const defaultEthnicityFeatureName = useMemo( + () => getDefaultEthnicityFeatureName(features), + [features] + ); + const ethnicityMeta = useMemo(() => getEthnicityFilterMeta(features), [features]); + const defaultPoiDistanceFeatureName = useMemo( + () => getDefaultPoiDistanceFeatureName(features), + [features] + ); + const defaultPoiCount2KmFeatureName = useMemo( + () => getDefaultPoiFilterFeatureName(features, POI_COUNT_2KM_FILTER_NAME), + [features] + ); + const defaultPoiCount5KmFeatureName = useMemo( + () => getDefaultPoiFilterFeatureName(features, POI_COUNT_5KM_FILTER_NAME), + [features] + ); + const poiDistanceMeta = useMemo(() => getPoiDistanceFilterMeta(features), [features]); + const poiCount2KmMeta = useMemo( + () => getPoiFilterMeta(features, POI_COUNT_2KM_FILTER_NAME), + [features] + ); + const poiCount5KmMeta = useMemo( + () => getPoiFilterMeta(features, POI_COUNT_5KM_FILTER_NAME), + [features] + ); + const poiFilterMetas = useMemo( + () => ({ + [POI_DISTANCE_FILTER_NAME]: poiDistanceMeta, + [POI_COUNT_2KM_FILTER_NAME]: poiCount2KmMeta, + [POI_COUNT_5KM_FILTER_NAME]: poiCount5KmMeta, + }), + [poiDistanceMeta, poiCount2KmMeta, poiCount5KmMeta] + ); + const defaultPoiFilterFeatureNames = useMemo( + () => ({ + [POI_DISTANCE_FILTER_NAME]: defaultPoiDistanceFeatureName, + [POI_COUNT_2KM_FILTER_NAME]: defaultPoiCount2KmFeatureName, + [POI_COUNT_5KM_FILTER_NAME]: defaultPoiCount5KmFeatureName, + }), + [defaultPoiDistanceFeatureName, defaultPoiCount2KmFeatureName, defaultPoiCount5KmFeatureName] + ); const schoolFilterItems = useMemo(() => { return Object.keys(filters) .filter(isSchoolFilterName) @@ -734,10 +1210,35 @@ export default memo(function Filters({ return { ...(backendFeature ?? specificCrimeMeta), name, group: 'Crime' }; }); }, [filters, features, specificCrimeMeta]); + const ethnicityFilterItems = useMemo(() => { + return Object.keys(filters) + .filter(isEthnicityFilterName) + .map((name) => { + const backendName = getEthnicityFeatureName(name); + const backendFeature = backendName + ? features.find((feature) => feature.name === backendName) + : undefined; + return { ...(backendFeature ?? ethnicityMeta), name, group: 'Demographics' }; + }); + }, [filters, features, ethnicityMeta]); + const poiDistanceFilterItems = useMemo(() => { + return Object.keys(filters) + .filter(isPoiDistanceFilterName) + .map((name) => { + const backendName = getPoiDistanceFeatureName(name); + const filterName = getPoiFilterName(name) ?? POI_DISTANCE_FILTER_NAME; + const backendFeature = backendName + ? features.find((feature) => feature.name === backendName) + : undefined; + return { ...(backendFeature ?? poiFilterMetas[filterName]), name, group: 'Nearby POIs' }; + }); + }, [filters, features, poiFilterMetas]); const availableFeatures = useMemo(() => { const result: FeatureMeta[] = []; let insertedSchoolFilter = false; let insertedSpecificCrimeFilter = false; + let insertedEthnicityFilter = false; + const insertedPoiFilters = new Set(); for (const feature of features) { if (isSchoolFilterName(feature.name)) { @@ -754,6 +1255,25 @@ export default memo(function Filters({ } continue; } + if (isEthnicityFeatureName(feature.name)) { + if (defaultEthnicityFeatureName && !insertedEthnicityFilter) { + result.push(ethnicityMeta); + insertedEthnicityFilter = true; + } + continue; + } + if (isPoiFilterFeatureName(feature.name)) { + const filterName = getPoiFilterName(feature.name); + if ( + filterName && + defaultPoiFilterFeatureNames[filterName] && + !insertedPoiFilters.has(filterName) + ) { + result.push(poiFilterMetas[filterName]); + insertedPoiFilters.add(filterName); + } + continue; + } if (!enabledFeatures.has(feature.name)) result.push(feature); } @@ -765,11 +1285,17 @@ export default memo(function Filters({ schoolMeta, defaultSpecificCrimeFeatureName, specificCrimeMeta, + defaultEthnicityFeatureName, + ethnicityMeta, + defaultPoiFilterFeatureNames, + poiFilterMetas, ]); const enabledFeatureList = useMemo(() => { const result: FeatureMeta[] = []; let insertedSchoolFilter = false; let insertedSpecificCrimeFilters = false; + let insertedEthnicityFilters = false; + let insertedPoiDistanceFilters = false; for (const feature of features) { if (isSchoolFilterName(feature.name)) { @@ -786,11 +1312,32 @@ export default memo(function Filters({ } continue; } + if (isEthnicityFeatureName(feature.name)) { + if (!insertedEthnicityFilters) { + result.push(...ethnicityFilterItems); + insertedEthnicityFilters = true; + } + continue; + } + if (isPoiFilterFeatureName(feature.name)) { + if (!insertedPoiDistanceFilters) { + result.push(...poiDistanceFilterItems); + insertedPoiDistanceFilters = true; + } + continue; + } if (enabledFeatures.has(feature.name)) result.push(feature); } return result; - }, [features, enabledFeatures, schoolFilterItems, specificCrimeFilterItems]); + }, [ + features, + enabledFeatures, + schoolFilterItems, + specificCrimeFilterItems, + ethnicityFilterItems, + poiDistanceFilterItems, + ]); const containerRef = useRef(null); const scrollRef = useRef(null); @@ -816,11 +1363,30 @@ export default memo(function Filters({ onAddFilter(SPECIFIC_CRIMES_FILTER_NAME); return; } + if (name === ETHNICITIES_FILTER_NAME) { + if (!defaultEthnicityFeatureName) return; + pendingScrollRef.current = ETHNICITIES_FILTER_NAME; + onAddFilter(ETHNICITIES_FILTER_NAME); + return; + } + if (POI_FILTER_NAMES.includes(name as PoiFilterName)) { + const filterName = name as PoiFilterName; + if (!defaultPoiFilterFeatureNames[filterName]) return; + pendingScrollRef.current = filterName; + onAddFilter(filterName); + return; + } pendingScrollRef.current = name; onAddFilter(name); }, - [defaultSchoolFeatureName, defaultSpecificCrimeFeatureName, onAddFilter] + [ + defaultSchoolFeatureName, + defaultSpecificCrimeFeatureName, + defaultEthnicityFeatureName, + defaultPoiFilterFeatureNames, + onAddFilter, + ] ); const handleRemoveSchoolFilter = useCallback( @@ -857,15 +1423,8 @@ export default memo(function Filters({ return scales; }, [features]); - // Insert travel time cards right before the first Transport feature, - // so they visually group with their category. - const travelInsertIdx = useMemo(() => { - const idx = enabledFeatureList.findIndex((f) => f.group === 'Transport'); - if (idx >= 0) return idx; - // No transport features enabled — place after Properties, before next group - const afterProps = enabledFeatureList.findIndex((f) => f.group !== 'Properties'); - return afterProps >= 0 ? afterProps : enabledFeatureList.length; - }, [enabledFeatureList]); + // Keep commute controls at the top of active filters, before other Transport filters. + const travelInsertIdx = 0; const badgeCount = enabledFeatureList.length + activeEntryCount; @@ -920,11 +1479,7 @@ export default memo(function Filters({ >
- {!addFilterCollapsed && ( -
- { - if (name === SCHOOL_FILTER_NAME) { - if (defaultSchoolFeatureName) onTogglePin(defaultSchoolFeatureName); - return; - } - if (name === SPECIFIC_CRIMES_FILTER_NAME) { - if (defaultSpecificCrimeFeatureName) onTogglePin(defaultSpecificCrimeFeatureName); - return; - } - onTogglePin(name); - }} - onNavigateToSource={onNavigateToSource} - openInfoFeature={openInfoFeature} - onClearOpenInfoFeature={onClearOpenInfoFeature} - travelTimeEntries={travelTimeEntries} - onAddTravelTimeEntry={handleAddTravelTimeAndScroll} - /> - {!isLicensed && ( -
-

- {t('filters.upgradePrompt')} -

-

- {t('filters.oneTimeLifetime')} -

- - - - - - - -
- )} + {(!addFilterCollapsed || !isLicensed) && ( +
+
+ {!addFilterCollapsed && ( + { + if (name === SCHOOL_FILTER_NAME) { + if (defaultSchoolFeatureName) onTogglePin(defaultSchoolFeatureName); + return; + } + if (name === SPECIFIC_CRIMES_FILTER_NAME) { + if (defaultSpecificCrimeFeatureName) + onTogglePin(defaultSpecificCrimeFeatureName); + return; + } + if (name === ETHNICITIES_FILTER_NAME) { + if (defaultEthnicityFeatureName) onTogglePin(defaultEthnicityFeatureName); + return; + } + if (POI_FILTER_NAMES.includes(name as PoiFilterName)) { + const defaultPoiFeatureName = + defaultPoiFilterFeatureNames[name as PoiFilterName]; + if (defaultPoiFeatureName) onTogglePin(defaultPoiFeatureName); + return; + } + onTogglePin(name); + }} + onNavigateToSource={onNavigateToSource} + openInfoFeature={openInfoFeature} + onClearOpenInfoFeature={onClearOpenInfoFeature} + travelTimeEntries={travelTimeEntries} + onAddTravelTimeEntry={handleAddTravelTimeAndScroll} + /> + )} + {!isLicensed && ( +
+

+ {t('filters.upgradePrompt')} +

+

+ {t('filters.oneTimeLifetime')} +

+ + + + + + + +
+ )} +
)}
diff --git a/frontend/src/components/map/HoverCard.tsx b/frontend/src/components/map/HoverCard.tsx index 241b940..8fc042a 100644 --- a/frontend/src/components/map/HoverCard.tsx +++ b/frontend/src/components/map/HoverCard.tsx @@ -5,6 +5,8 @@ import { formatValue } from '../../lib/format'; import { ts } from '../../i18n/server'; import { SCHOOL_FILTER_NAME, getSchoolBackendFeatureName } from '../../lib/school-filter'; import { getSpecificCrimeFeatureName } from '../../lib/crime-filter'; +import { getEthnicityFeatureName } from '../../lib/ethnicity-filter'; +import { POI_DISTANCE_FILTER_NAME, getPoiDistanceFeatureName } from '../../lib/poi-distance-filter'; interface HoverCardData { count: number; @@ -45,7 +47,14 @@ export default memo(function HoverCard({ for (const name of activeFilterNames.slice(0, 4)) { const schoolBackendName = getSchoolBackendFeatureName(name); const specificCrimeFeatureName = getSpecificCrimeFeatureName(name); - const backendName = schoolBackendName ?? specificCrimeFeatureName ?? name; + const ethnicityFeatureName = getEthnicityFeatureName(name); + const poiDistanceFeatureName = getPoiDistanceFeatureName(name); + const backendName = + schoolBackendName ?? + specificCrimeFeatureName ?? + ethnicityFeatureName ?? + poiDistanceFeatureName ?? + name; const val = data[`avg_${backendName}`] ?? data[`min_${backendName}`]; if (val == null || typeof val !== 'number') continue; const meta = featureMap.get(backendName); @@ -54,7 +63,11 @@ export default memo(function HoverCard({ if (label) results.push({ name: backendName, value: ts(label) }); } else { results.push({ - name: schoolBackendName ? SCHOOL_FILTER_NAME : backendName, + name: schoolBackendName + ? SCHOOL_FILTER_NAME + : poiDistanceFeatureName + ? POI_DISTANCE_FILTER_NAME + : backendName, value: formatValue(val, meta), }); } diff --git a/frontend/src/components/map/Map.tsx b/frontend/src/components/map/Map.tsx index 87964e6..a0eea7f 100644 --- a/frontend/src/components/map/Map.tsx +++ b/frontend/src/components/map/Map.tsx @@ -1,6 +1,8 @@ import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react'; +import type { CSSProperties } from 'react'; import { useTranslation } from 'react-i18next'; import { Map as MapGL, useControl, ScaleControl } from 'react-map-gl/maplibre'; +import type { MapRef } from 'react-map-gl/maplibre'; import { MapboxOverlay } from '@deck.gl/mapbox'; import 'maplibre-gl/dist/maplibre-gl.css'; import type { @@ -18,17 +20,12 @@ import type { import { zoomToResolution, getBoundsFromViewState, + getBoundsWithBottomScreenInset, getMapStyle, getPoiIconUrl, getMapCenterForTargetScreenPoint, } from '../../lib/map-utils'; -import { - INITIAL_VIEW_STATE, - MAP_MIN_ZOOM, - MAP_BOUNDS, - POI_GROUP_COLORS, - POI_DEFAULT_COLOR, -} from '../../lib/consts'; +import { MAP_MIN_ZOOM, MAP_BOUNDS, POI_GROUP_COLORS } from '../../lib/consts'; import LocationSearch, { type SearchedLocation } from './LocationSearch'; import MapLegend from './MapLegend'; import HoverCard from './HoverCard'; @@ -57,7 +54,7 @@ interface MapProps { hoveredHexagonId: string | null; onHexagonClick: (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => void; onHexagonHover: (h3: string | null, x?: number, y?: number) => void; - initialViewState?: ViewState; + initialViewState: ViewState; flyToRef?: React.MutableRefObject< ((lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void) | null >; @@ -75,6 +72,7 @@ interface MapProps { travelTimeEntries?: TravelTimeEntry[]; densityLabel?: string; totalCount?: number; + bottomScreenInset?: number; } const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = []; @@ -84,6 +82,10 @@ interface Dimensions { height: number; } +type MapContainerStyle = CSSProperties & { + '--map-mobile-bottom-inset'?: string; +}; + function resolveInset( pixelValue: number | undefined, ratioValue: number | undefined, @@ -185,6 +187,27 @@ class SafeMapboxOverlay extends MapboxOverlay { } } +function getPoiGroupColor(group: string): [number, number, number] { + const color = POI_GROUP_COLORS[group]; + if (!color) { + throw new Error(`Missing POI group color for '${group}'`); + } + return color; +} + +function getRenderedViewState(map: MapRef | null): ViewState | null { + if (!map) return null; + + const center = map.getCenter(); + return { + longitude: center.lng, + latitude: center.lat, + zoom: map.getZoom(), + pitch: map.getPitch(), + bearing: map.getBearing(), + }; +} + function DeckOverlay({ layers, getTooltip, @@ -240,18 +263,18 @@ export default memo(function Map({ travelTimeEntries = EMPTY_TRAVEL_ENTRIES, densityLabel: densityLabelProp, totalCount: totalCountProp, + bottomScreenInset = 0, }: MapProps) { const containerRef = useRef(null); + const mapRef = useRef(null); const { t } = useTranslation(); const modes = useTranslatedModes(); const densityLabel = densityLabelProp ?? t('mapLegend.numberOfProperties'); - const [internalViewState, setInternalViewState] = useState( - initialViewState || INITIAL_VIEW_STATE - ); + const [internalViewState, setInternalViewState] = useState(initialViewState); const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); // In screenshot mode, use the prop directly for instant updates (no async lag) - const viewState = screenshotMode && initialViewState ? initialViewState : internalViewState; + const viewState = screenshotMode ? initialViewState : internalViewState; useEffect(() => { const container = containerRef.current; @@ -282,17 +305,33 @@ export default memo(function Map({ useEffect(() => { if (dimensions.width === 0 || dimensions.height === 0) return; - const bounds = getBoundsFromViewState(viewState, dimensions.width, dimensions.height); - const resolution = zoomToResolution(viewState.zoom); + let frame = 0; + const emit = () => { + const renderedViewState = getRenderedViewState(mapRef.current); + // mapRef can be null on the very first effect run if MapLibre hasn't + // finished mounting; retry next frame so the initial bounds always reach + // the data hook. + if (!renderedViewState) { + frame = window.requestAnimationFrame(emit); + return; + } + // The bottom sheet can reveal covered map area without a pan/zoom event. + const dataBoundsHeight = dimensions.height + Math.max(0, bottomScreenInset); + const bounds = getBoundsFromViewState(renderedViewState, dimensions.width, dataBoundsHeight); + const resolution = zoomToResolution(renderedViewState.zoom); - onViewChange({ - resolution, - bounds, - zoom: viewState.zoom, - latitude: viewState.latitude, - longitude: viewState.longitude, - }); - }, [viewState, dimensions, onViewChange]); + onViewChange({ + resolution, + bounds, + zoom: renderedViewState.zoom, + latitude: renderedViewState.latitude, + longitude: renderedViewState.longitude, + }); + }; + frame = window.requestAnimationFrame(emit); + + return () => window.cancelAnimationFrame(frame); + }, [viewState, dimensions, bottomScreenInset, onViewChange]); const handleMove = useCallback((evt: { viewState: ViewState }) => { setInternalViewState((prev) => { @@ -342,6 +381,14 @@ export default memo(function Map({ if (flyToRef) flyToRef.current = handleFlyTo; const mapStyle = useMemo(() => getMapStyle(theme), [theme]); + const maxBounds = useMemo( + () => getBoundsWithBottomScreenInset(MAP_BOUNDS, MAP_MIN_ZOOM, bottomScreenInset), + [bottomScreenInset] + ); + const mapContainerStyle = useMemo( + () => (bottomScreenInset > 0 ? { '--map-mobile-bottom-inset': `${bottomScreenInset}px` } : {}), + [bottomScreenInset] + ); const { layers, @@ -374,8 +421,14 @@ export default memo(function Map({ }); return ( -
+
0 ? 'map-has-mobile-bottom-sheet' : ''}`} + ref={containerRef} + style={mapContainerStyle} + onMouseLeave={handleMouseLeave} + > {!screenshotMode && } @@ -486,6 +539,7 @@ export default memo(function Map({ } featureName={colorFeatureMeta.name} theme={theme} + suffix={colorFeatureMeta.suffix} raw={colorFeatureMeta.raw} /> ) : null @@ -553,7 +607,7 @@ export default memo(function Map({ {popupInfo.category} diff --git a/frontend/src/components/map/MapLegend.tsx b/frontend/src/components/map/MapLegend.tsx index 186c9d9..a4a9859 100644 --- a/frontend/src/components/map/MapLegend.tsx +++ b/frontend/src/components/map/MapLegend.tsx @@ -28,6 +28,22 @@ function ResetScaleIcon({ className = 'w-4 h-4' }: { className?: string }) { ); } +function requireFeatureName(featureName: string | undefined): string { + if (!featureName) { + throw new Error('Enum legend requested without a feature name'); + } + return featureName; +} + +function requireEnumPalette( + palette: [number, number, number][] | null +): [number, number, number][] { + if (!palette) { + throw new Error('Enum legend requested without a palette'); + } + return palette; +} + function EnumSwatches({ values, palette, @@ -114,7 +130,9 @@ export default function MapLegend({ const { t } = useTranslation(); const isEnum = enumValues && enumValues.length > 0; const showResetScale = Boolean(onResetScale) && !isEnum; - const enumPalette = getEnumPaletteForFeature(featureName ?? null, enumValues); + const enumPalette = isEnum + ? getEnumPaletteForFeature(requireFeatureName(featureName), enumValues) + : null; const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT; const gradientStyle = mode === 'density' @@ -165,7 +183,7 @@ export default function MapLegend({ )} {isEnum ? ( - + ) : (
{rangeMin} @@ -213,7 +231,7 @@ export default function MapLegend({ )}
{isEnum ? ( - + ) : ( <>
diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index 500a025..497e1f2 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -38,10 +38,15 @@ import { canWheelScrollInsideTarget } from '../../lib/dom-scroll'; import { INITIAL_VIEW_STATE } from '../../lib/consts'; import { getSchoolBackendFeatureName } from '../../lib/school-filter'; import { getSpecificCrimeFeatureName } from '../../lib/crime-filter'; +import { getEthnicityFeatureName } from '../../lib/ethnicity-filter'; +import { getPoiDistanceFeatureName } from '../../lib/poi-distance-filter'; import { useLicense } from '../../hooks/useLicense'; import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; import { MapPinIcon } from '../ui/icons/MapPinIcon'; import { BookmarkIcon } from '../ui/icons/BookmarkIcon'; +import { CheckIcon } from '../ui/icons/CheckIcon'; +import { CloseIcon } from '../ui/icons/CloseIcon'; +import { InfoIcon } from '../ui/icons/InfoIcon'; const Map = lazy(() => import('./Map')); const Filters = lazy(() => import('./Filters')); @@ -55,7 +60,71 @@ const MapPageSelectionPane = lazy(() => import('./MapPageSelectionPane').then((module) => ({ default: module.MapPageSelectionPane })) ); const UpgradeModal = lazy(() => import('../ui/UpgradeModal')); -const Joyride = lazy(() => import('react-joyride')); +const Joyride = lazy(() => import('react-joyride').then((module) => ({ default: module.Joyride }))); + +const EXPORT_FILE_NAME = 'perfect-postcode-export.xlsx'; +const EXPORT_TIMEOUT_MS = 150_000; +const EXPORT_NOTICE_MS = 6000; +const EXPORT_ERROR_NOTICE_MS = 9000; + +type ExportNotice = { + kind: 'success' | 'error'; + message: string; +}; + +function getExportFileName(res: Response): string { + const disposition = res.headers.get('content-disposition'); + if (!disposition) return EXPORT_FILE_NAME; + + const encodedMatch = disposition.match(/filename\*=UTF-8''([^;]+)/i); + if (encodedMatch?.[1]) { + try { + return decodeURIComponent(encodedMatch[1].trim()); + } catch { + return encodedMatch[1].trim(); + } + } + + const match = disposition.match(/filename="?([^";]+)"?/i); + return match?.[1]?.trim() || EXPORT_FILE_NAME; +} + +async function getExportErrorMessage(res: Response): Promise { + const fallback = `HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ''}`; + const contentType = res.headers.get('content-type') ?? ''; + + try { + if (contentType.includes('application/json')) { + const data: unknown = await res.json(); + if (data && typeof data === 'object') { + const record = data as Record; + const message = record.message ?? record.error; + if (typeof message === 'string' && message.trim()) return message.trim(); + } + return fallback; + } + + const text = await res.text(); + return text.trim() || fallback; + } catch { + return fallback; + } +} + +function triggerExportDownload(blob: Blob, fileName: string): void { + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = fileName; + link.rel = 'noopener'; + link.style.display = 'none'; + + document.body.appendChild(link); + link.click(); + link.remove(); + + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000); +} function MapFallback() { return ( @@ -98,8 +167,8 @@ interface MapPageProps { initialPostcode?: string; shareCode?: string; user?: { id: string; subscription: string; isAdmin?: boolean } | null; - onLoginClick?: () => void; - onRegisterClick?: () => void; + onLoginClick: () => void; + onRegisterClick: () => void; onSaveProperty?: (property: Property) => void; onUnsaveProperty?: (id: string) => void; isPropertySaved?: (address?: string, postcode?: string) => boolean; @@ -146,11 +215,14 @@ export default function MapPage({ const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right'); const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); + const [mobileBottomSheetHeight, setMobileBottomSheetHeight] = useState(0); const [poiPaneOpen, setPoiPaneOpen] = useState(false); const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null); const [showBookmarkToast, setShowBookmarkToast] = useState(false); const bookmarkToastDismissed = useRef(localStorage.getItem('bookmark_toast_dismissed') === '1'); + const [exportNotice, setExportNotice] = useState(null); + const exportNoticeTimeoutRef = useRef(null); const handleSavePropertyWithToast = useCallback( (property: Property) => { @@ -166,6 +238,35 @@ export default function MapPage({ const { t } = useTranslation(); const modes = useTranslatedModes(); + const clearExportNoticeTimer = useCallback(() => { + if (exportNoticeTimeoutRef.current !== null) { + window.clearTimeout(exportNoticeTimeoutRef.current); + exportNoticeTimeoutRef.current = null; + } + }, []); + + const clearExportNotice = useCallback(() => { + clearExportNoticeTimer(); + setExportNotice(null); + }, [clearExportNoticeTimer]); + + const showExportNotice = useCallback( + (notice: ExportNotice) => { + clearExportNoticeTimer(); + setExportNotice(notice); + exportNoticeTimeoutRef.current = window.setTimeout( + () => { + setExportNotice(null); + exportNoticeTimeoutRef.current = null; + }, + notice.kind === 'error' ? EXPORT_ERROR_NOTICE_MS : EXPORT_NOTICE_MS + ); + }, + [clearExportNoticeTimer] + ); + + useEffect(() => clearExportNoticeTimer, [clearExportNoticeTimer]); + const { filters, activeFeature, @@ -555,10 +656,16 @@ export default function MapPage({ ]); const tutorial = useTutorial(initialLoading, isMobile, deferTutorial); + const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]); const [exporting, setExporting] = useState(false); const handleExport = useCallback(() => { - if (!mapData.bounds || exporting) return; + if (exporting) return; + if (!mapData.bounds) { + showExportNotice({ kind: 'error', message: t('header.exportUnavailable') }); + return; + } + const { south, west, north, east } = mapData.bounds; const params = new URLSearchParams({ bounds: `${south},${west},${north},${east}`, @@ -567,23 +674,48 @@ export default function MapPage({ if (filterStr) params.set('filters', filterStr); const url = apiUrl('export', params); + const controller = new AbortController(); + let timedOut = false; + const timeoutId = window.setTimeout(() => { + timedOut = true; + controller.abort(); + }, EXPORT_TIMEOUT_MS); + setExporting(true); - fetch(url, authHeaders()) - .then((res) => { - if (!res.ok) throw new Error(`HTTP ${res.status}`); - return res.blob(); - }) - .then((blob) => { - const link = document.createElement('a'); - link.href = URL.createObjectURL(blob); - link.download = 'perfect-postcode-export.xlsx'; - link.click(); - URL.revokeObjectURL(link.href); + clearExportNotice(); + + void (async () => { + try { + const res = await fetch(url, authHeaders({ signal: controller.signal })); + if (!res.ok) throw new Error(await getExportErrorMessage(res)); + + const blob = await res.blob(); + if (blob.size === 0) throw new Error(t('header.exportEmpty')); + + triggerExportDownload(blob, getExportFileName(res)); trackEvent('Export'); - }) - .catch((err) => logNonAbortError('Export failed', err)) - .finally(() => setExporting(false)); - }, [mapData.bounds, filters, features, exporting]); + showExportNotice({ kind: 'success', message: t('header.exportReady') }); + } catch (err) { + if (!timedOut) logNonAbortError('Export failed', err); + const detail = err instanceof Error && err.message.trim() ? ` ${err.message}` : ''; + showExportNotice({ + kind: 'error', + message: timedOut ? t('header.exportTimedOut') : `${t('header.exportFailed')}${detail}`, + }); + } finally { + window.clearTimeout(timeoutId); + setExporting(false); + } + })(); + }, [ + clearExportNotice, + exporting, + features, + filters, + mapData.bounds, + showExportNotice, + t, + ]); useEffect(() => { onExportStateChange?.({ onExport: handleExport, exporting }); @@ -600,6 +732,8 @@ export default function MapPage({ const featureName = viewFeature ? (getSchoolBackendFeatureName(viewFeature) ?? getSpecificCrimeFeatureName(viewFeature) ?? + getEthnicityFeatureName(viewFeature) ?? + getPoiDistanceFeatureName(viewFeature) ?? viewFeature) : null; return featureName ? features.find((f) => f.name === featureName) || null : null; @@ -609,6 +743,8 @@ export default function MapPage({ viewFeature ? (getSchoolBackendFeatureName(viewFeature) ?? getSpecificCrimeFeatureName(viewFeature) ?? + getEthnicityFeatureName(viewFeature) ?? + getPoiDistanceFeatureName(viewFeature) ?? viewFeature) : null, [viewFeature] @@ -685,6 +821,28 @@ export default function MapPage({
); + const exportToast = exportNotice && ( +
+ {exportNotice.kind === 'success' ? ( + + ) : ( + + )} + {exportNotice.message} + +
+ ); + if (screenshotMode) { return (
@@ -805,7 +963,7 @@ export default function MapPage({ aiFilterSummary={aiFilterSummary} onAiFilterSubmit={handleAiFilterSubmit} isLoggedIn={!!user} - onLoginRequired={onRegisterClick ?? (() => {})} + onLoginRequired={onRegisterClick} isLicensed={user?.subscription === 'licensed'} onUpgradeClick={() => onNavigateTo('pricing')} onResetTutorial={!isMobile ? tutorial.resetTutorial : undefined} @@ -859,6 +1017,7 @@ export default function MapPage({ featureName={mobileLegendMeta.name} theme={theme} inline + suffix={mobileLegendMeta.suffix} raw={mobileLegendMeta.raw} /> ); @@ -926,21 +1085,11 @@ export default function MapPage({ hideLegend hideLocationSearch={mobileDrawerOpen && !!selectedHexagon} travelTimeEntries={entries} + bottomScreenInset={mobileBottomSheetHeight} />
- {mapData.loading && ( -
-
- - - Loading... - -
-
- )} -
)} - + {renderFilters({ destinationDropdownPortal: false })} @@ -979,13 +1131,14 @@ export default function MapPage({ )} {bookmarkToast} + {exportToast} {mapData.licenseRequired && ( {})} - onRegisterClick={onRegisterClick ?? (() => {})} + onLoginClick={onLoginClick} + onRegisterClick={onRegisterClick} onStartCheckout={() => license.startCheckout()} onZoomToFreeZone={handleZoomToFreeZone} isShareReturn={!!shareReturnViewRef.current} @@ -1015,11 +1168,14 @@ export default function MapPage({ steps={tutorial.steps} run={tutorial.run} continuous - showProgress - showSkipButton - callback={tutorial.handleCallback} - styles={getTutorialStyles(theme)} - disableScrolling + onEvent={tutorial.handleCallback} + styles={tutorialTheme.styles} + options={{ + ...tutorialTheme.options, + buttons: ['back', 'close', 'primary', 'skip'], + showProgress: true, + skipScroll: true, + }} locale={{ last: 'Finish' }} /> @@ -1044,6 +1200,15 @@ export default function MapPage({
+ {tutorial.run && ( + <> +