perfect-postcode/frontend/src/components/map/map-page/DesktopMapPage.tsx
Andras Schmelczer 4a0f00f2a4
Some checks failed
Build and publish Docker image / build-and-push (push) Successful in 8m43s
CI / Check (push) Failing after 8m49s
new demo mode & tenure
2026-06-17 07:54:30 +01:00

303 lines
11 KiB
TypeScript

import { Suspense, type MutableRefObject, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import type {
ActualListing,
FeatureFilters,
FeatureMeta,
POI,
PostcodeGeometry,
ViewState,
} from '../../../types';
import type { useMapData } from '../../../hooks/useMapData';
import type { useTutorial } from '../../../hooks/useTutorial';
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
import type { getTutorialStyles } from '../../../lib/tutorial-styles';
import type { OverlayId } from '../../../lib/overlays';
import type { BasemapId } from '../../../lib/basemaps';
import type { SearchedLocation } from '../LocationSearch';
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
import { EyeIcon } from '../../ui/icons/EyeIcon';
import { HouseIcon } from '../../ui/icons/HouseIcon';
import { SpinnerIcon } from '../../ui/icons/SpinnerIcon';
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
import type { MapFlyTo, PaneResizeHandlers } from './types';
import { MapFallback, PaneFallback } from './Fallbacks';
import { MapErrorBoundary } from '../MapErrorBoundary';
import { LoadingOverlay } from './LoadingOverlay';
import { Joyride, Map, MapPageSelectionPane } from './lazyComponents';
type MapData = ReturnType<typeof useMapData>;
type Tutorial = ReturnType<typeof useTutorial>;
type TutorialTheme = ReturnType<typeof getTutorialStyles>;
type RightPaneTab = 'properties' | 'area';
interface DesktopMapPageProps {
initialLoading: boolean;
tutorial: Tutorial;
tutorialTheme: TutorialTheme;
leftPaneWidth: number;
leftPaneHandlers: PaneResizeHandlers;
filtersPane: ReactNode;
mapData: MapData;
pois: POI[];
activeOverlays: Set<OverlayId>;
activeCrimeTypes: Set<string>;
basemap: BasemapId;
colorOpacity: number;
mapViewFeature: string | null;
filterRange: [number, number] | null;
viewSource: 'drag' | 'eye' | null;
onCancelPin: () => void;
features: FeatureMeta[];
selectedHexagonId: string | null;
hoveredHexagonId: string | null;
onHexagonClick: (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => void;
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
initialViewState: ViewState;
flyToRef: MutableRefObject<MapFlyTo | null>;
theme: 'light' | 'dark';
filters: FeatureFilters;
selectedPostcodeGeometry: PostcodeGeometry | null;
onLocationSearched: (location: SearchedLocation | null) => void;
onCurrentLocationFound: (lat: number, lng: number) => void;
currentLocation: { lat: number; lng: number } | null;
actualListings: ActualListing[];
actualListingsEnabled: boolean;
actualListingsLoading: boolean;
onToggleActualListings?: () => void;
travelTimeEntries: TravelTimeEntry[];
densityLabel: string;
totalCount?: number;
poiPaneOpen: boolean;
onTogglePoiPane: () => void;
poiPane: ReactNode;
overlayPaneOpen: boolean;
onToggleOverlayPane: () => void;
overlayPane: ReactNode;
showSelectionPane: boolean;
rightPaneWidth: number;
rightPaneHandlers: PaneResizeHandlers;
rightPaneTab: RightPaneTab;
onAreaTabClick: () => void;
onPropertiesTabClick: () => void;
onCloseSelection: () => void;
renderAreaPane: () => ReactNode;
renderPropertiesPane: () => ReactNode;
toasts: ReactNode;
upgradeModal: ReactNode;
}
export function DesktopMapPage({
initialLoading,
tutorial,
tutorialTheme,
leftPaneWidth,
leftPaneHandlers,
filtersPane,
mapData,
pois,
activeOverlays,
activeCrimeTypes,
basemap,
colorOpacity,
mapViewFeature,
filterRange,
viewSource,
onCancelPin,
features,
selectedHexagonId,
hoveredHexagonId,
onHexagonClick,
onHexagonHover,
initialViewState,
flyToRef,
theme,
filters,
selectedPostcodeGeometry,
onLocationSearched,
onCurrentLocationFound,
currentLocation,
actualListings,
actualListingsEnabled,
actualListingsLoading,
onToggleActualListings,
travelTimeEntries,
densityLabel,
totalCount,
poiPaneOpen,
onTogglePoiPane,
poiPane,
overlayPaneOpen,
onToggleOverlayPane,
overlayPane,
showSelectionPane,
rightPaneWidth,
rightPaneHandlers,
rightPaneTab,
onAreaTabClick,
onPropertiesTabClick,
onCloseSelection,
renderAreaPane,
renderPropertiesPane,
toasts,
upgradeModal,
}: DesktopMapPageProps) {
const { t } = useTranslation();
return (
<div className="flex-1 flex overflow-hidden relative">
<LoadingOverlay show={initialLoading} />
{tutorial.run && (
<Suspense fallback={null}>
<Joyride
steps={tutorial.steps}
run={tutorial.run}
continuous
onEvent={tutorial.handleCallback}
styles={tutorialTheme.styles}
options={{
...tutorialTheme.options,
buttons: ['back', 'close', 'primary', 'skip'],
showProgress: true,
skipScroll: true,
}}
locale={{ last: t('common.finish') }}
/>
</Suspense>
)}
<div
data-tutorial="filters"
className="relative z-40 flex bg-white dark:bg-navy-950 shadow-lg overflow-hidden"
style={{ width: leftPaneWidth }}
>
<div className="flex-1 flex flex-col overflow-hidden">{filtersPane}</div>
<div
className="w-3 cursor-col-resize flex items-center justify-center group bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
{...leftPaneHandlers}
>
<div className="flex flex-col gap-1.5">
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
</div>
</div>
</div>
<div data-tutorial="map" className="flex-1 relative">
<IndeterminateProgressBar show={mapData.loading && !initialLoading} />
<MapErrorBoundary>
<Suspense fallback={<MapFallback />}>
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={pois}
activeOverlays={activeOverlays}
activeCrimeTypes={activeCrimeTypes}
basemap={basemap}
colorOpacity={colorOpacity}
onViewChange={mapData.handleViewChange}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={onCancelPin}
onResetPreviewScale={mapData.handleResetPreviewScale}
canResetPreviewScale={mapData.canResetPreviewScale}
features={features}
selectedHexagonId={selectedHexagonId}
hoveredHexagonId={hoveredHexagonId}
onHexagonClick={onHexagonClick}
onHexagonHover={onHexagonHover}
initialViewState={initialViewState}
flyToRef={flyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={onLocationSearched}
onCurrentLocationFound={onCurrentLocationFound}
currentLocation={currentLocation}
actualListings={actualListings}
bounds={mapData.bounds}
hideTopCardsWhenNarrow
travelTimeEntries={travelTimeEntries}
densityLabel={densityLabel}
totalCount={totalCount}
/>
</Suspense>
</MapErrorBoundary>
<div className="absolute bottom-4 right-4 z-10 flex max-w-[calc(100%_-_2rem)] flex-row flex-wrap justify-end gap-2">
{onToggleActualListings && (
<button
type="button"
onClick={onToggleActualListings}
aria-pressed={actualListingsEnabled}
aria-busy={actualListingsLoading}
aria-label={actualListingsEnabled ? 'Hide actual listings' : 'Show actual listings'}
title={actualListingsEnabled ? 'Hide actual listings' : 'Show actual listings'}
className={`flex items-center gap-2 rounded-lg bg-white px-3 py-2 shadow-lg dark:bg-warm-800 ${actualListingsEnabled ? 'text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300' : 'text-warm-500 hover:text-red-600 dark:text-warm-400 dark:hover:text-red-400'}`}
>
{actualListingsLoading ? (
<SpinnerIcon className="h-5 w-5 animate-spin" />
) : (
<HouseIcon className="h-5 w-5" />
)}
<span className="text-sm font-medium">
Listings{actualListingsEnabled ? ` (${actualListings.length})` : ''}
</span>
</button>
)}
<button
onClick={onToggleOverlayPane}
className={`flex items-center gap-2 rounded-lg bg-white px-3 py-2 shadow-lg dark:bg-warm-800 ${overlayPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 hover:text-teal-600 dark:text-warm-400 dark:hover:text-teal-400'}`}
>
<EyeIcon className="h-5 w-5" filled={overlayPaneOpen} />
<span className="text-sm font-medium">Overlays</span>
</button>
<button
data-tutorial="poi-button"
onClick={onTogglePoiPane}
className={`flex items-center gap-2 rounded-lg bg-white px-3 py-2 shadow-lg dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 hover:text-teal-600 dark:text-warm-400 dark:hover:text-teal-400'}`}
>
<MapPinIcon className="h-5 w-5" />
<span className="text-sm font-medium">{t('poiPane.pointsOfInterest')}</span>
</button>
</div>
{overlayPaneOpen && (
<div className="absolute bottom-16 right-4 z-10 flex max-h-[60vh] min-h-0 w-80 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
{overlayPane}
</div>
)}
{poiPaneOpen && (
<div
className="absolute bottom-28 right-4 z-10 flex min-h-0 w-80 max-w-[calc(100%_-_2rem)] flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900"
style={{ height: 'min(30rem, calc(100vh - 10rem))' }}
>
{poiPane}
</div>
)}
</div>
{showSelectionPane && (
<Suspense fallback={<PaneFallback />}>
<MapPageSelectionPane
width={rightPaneWidth}
resizeHandlers={rightPaneHandlers}
tab={rightPaneTab}
onAreaTabClick={onAreaTabClick}
onPropertiesTabClick={onPropertiesTabClick}
onClose={onCloseSelection}
renderAreaPane={renderAreaPane}
renderPropertiesPane={renderPropertiesPane}
/>
</Suspense>
)}
{toasts}
{upgradeModal}
</div>
);
}