303 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|