SPlit up
This commit is contained in:
parent
cf39ad754e
commit
f59d01227b
91 changed files with 10370 additions and 7562 deletions
|
|
@ -619,7 +619,10 @@ export default function AreaPane({
|
|||
/>
|
||||
{crimeSeries && crimeSeries.points.length > 1 && (
|
||||
<div className="mt-2">
|
||||
<CrimeYearChart points={crimeSeries.points} />
|
||||
<CrimeYearChart
|
||||
points={crimeSeries.points}
|
||||
latestAvailableYear={stats?.crime_latest_year}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -663,7 +666,10 @@ export default function AreaPane({
|
|||
}
|
||||
chart={
|
||||
crimeSeries && crimeSeries.points.length > 1 ? (
|
||||
<CrimeYearChart points={crimeSeries.points} />
|
||||
<CrimeYearChart
|
||||
points={crimeSeries.points}
|
||||
latestAvailableYear={stats?.crime_latest_year}
|
||||
/>
|
||||
) : (
|
||||
numericStats.histogram &&
|
||||
(globalHistogram ? (
|
||||
|
|
|
|||
|
|
@ -1,14 +1,22 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { CrimeYearPoint } from '../../types';
|
||||
|
||||
interface CrimeYearChartProps {
|
||||
points: CrimeYearPoint[];
|
||||
/**
|
||||
* Latest year available in the crime dataset as a whole. When the series
|
||||
* ends earlier, the area's police force stopped publishing (e.g. Greater
|
||||
* Manchester since mid-2019) and the chart is captioned as stale.
|
||||
*/
|
||||
latestAvailableYear?: number;
|
||||
}
|
||||
|
||||
const PADDING = { top: 6, right: 4, bottom: 14, left: 4 };
|
||||
const HEIGHT = 48;
|
||||
|
||||
export default function CrimeYearChart({ points }: CrimeYearChartProps) {
|
||||
export default function CrimeYearChart({ points, latestAvailableYear }: CrimeYearChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
|
||||
|
|
@ -97,6 +105,11 @@ export default function CrimeYearChart({ points }: CrimeYearChartProps) {
|
|||
</text>
|
||||
</svg>
|
||||
)}
|
||||
{latestAvailableYear != null && yearMax < latestAvailableYear && (
|
||||
<p className="mt-0.5 text-[10px] leading-snug text-amber-700 dark:text-amber-400">
|
||||
{t('areaPane.crimeDataEnds', { year: yearMax })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
67
frontend/src/components/map/DeckOverlay.tsx
Normal file
67
frontend/src/components/map/DeckOverlay.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useControl } from 'react-map-gl/maplibre';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
|
||||
interface DeckWithPrivateDraw {
|
||||
_drawLayers?: (
|
||||
redrawReason: string,
|
||||
renderOptions?: { viewports?: unknown[]; [key: string]: unknown }
|
||||
) => unknown;
|
||||
__propertyMapNullViewportPatch?: boolean;
|
||||
}
|
||||
|
||||
function patchNullViewportDraw(overlay: MapboxOverlay) {
|
||||
const deck = (overlay as unknown as { _deck?: DeckWithPrivateDraw })._deck;
|
||||
if (!deck || deck.__propertyMapNullViewportPatch || typeof deck._drawLayers !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const drawLayers = deck._drawLayers.bind(deck);
|
||||
deck._drawLayers = (redrawReason, renderOptions) => {
|
||||
const viewports = renderOptions?.viewports;
|
||||
if (viewports) {
|
||||
// Split-route startup can hand deck.gl a transient null viewport before MapLibre has sized the map.
|
||||
const nonNullViewports = viewports.filter(Boolean);
|
||||
if (nonNullViewports.length === 0) return;
|
||||
if (nonNullViewports.length !== viewports.length) {
|
||||
return drawLayers(redrawReason, { ...renderOptions, viewports: nonNullViewports });
|
||||
}
|
||||
}
|
||||
return drawLayers(redrawReason, renderOptions);
|
||||
};
|
||||
deck.__propertyMapNullViewportPatch = true;
|
||||
}
|
||||
|
||||
class SafeMapboxOverlay extends MapboxOverlay {
|
||||
onAdd(map: unknown) {
|
||||
const element = super.onAdd(map);
|
||||
patchNullViewportDraw(this);
|
||||
return element;
|
||||
}
|
||||
|
||||
setProps(props: Parameters<MapboxOverlay['setProps']>[0]) {
|
||||
super.setProps(props);
|
||||
patchNullViewportDraw(this);
|
||||
}
|
||||
}
|
||||
|
||||
export function DeckOverlay({
|
||||
layers,
|
||||
getTooltip,
|
||||
}: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
layers: any[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getTooltip: any;
|
||||
}) {
|
||||
const overlay = useControl(() => new SafeMapboxOverlay({ interleaved: true }));
|
||||
|
||||
useEffect(() => {
|
||||
overlay.setProps({
|
||||
layers: layers.filter(Boolean),
|
||||
getTooltip,
|
||||
});
|
||||
}, [overlay, layers, getTooltip]);
|
||||
|
||||
return null;
|
||||
}
|
||||
45
frontend/src/components/map/HoverCardOverlay.tsx
Normal file
45
frontend/src/components/map/HoverCardOverlay.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { memo } from 'react';
|
||||
|
||||
import type { FeatureFilters, FeatureMeta, HexagonData, PostcodeFeature } from '../../types';
|
||||
import HoverCard from './HoverCard';
|
||||
|
||||
interface HoverCardOverlayProps {
|
||||
x: number;
|
||||
y: number;
|
||||
id: string;
|
||||
usePostcodeView: boolean;
|
||||
data: HexagonData[];
|
||||
postcodeData: PostcodeFeature[];
|
||||
filters: FeatureFilters;
|
||||
features: FeatureMeta[];
|
||||
}
|
||||
|
||||
/** Resolves the hovered hexagon/postcode row from the loaded map data and
|
||||
* renders the hover card for it. Memoized so the row lookup only reruns when
|
||||
* the hover target or the underlying data actually changes. */
|
||||
export const HoverCardOverlay = memo(function HoverCardOverlay({
|
||||
x,
|
||||
y,
|
||||
id,
|
||||
usePostcodeView,
|
||||
data,
|
||||
postcodeData,
|
||||
filters,
|
||||
features,
|
||||
}: HoverCardOverlayProps) {
|
||||
return (
|
||||
<HoverCard
|
||||
x={x}
|
||||
y={y}
|
||||
id={id}
|
||||
isPostcode={usePostcodeView}
|
||||
data={
|
||||
usePostcodeView
|
||||
? postcodeData.find((f) => f.properties.postcode === id)?.properties || null
|
||||
: data.find((d) => d.h3 === id) || null
|
||||
}
|
||||
filters={filters}
|
||||
features={features}
|
||||
/>
|
||||
);
|
||||
});
|
||||
146
frontend/src/components/map/ListingPopups.tsx
Normal file
146
frontend/src/components/map/ListingPopups.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import type { ActualListing } from '../../types';
|
||||
|
||||
function formatListingPrice(price: number): string {
|
||||
return `£${price.toLocaleString()}`;
|
||||
}
|
||||
|
||||
function formatListingHeadline(listing: ActualListing, t: TFunction): string | null {
|
||||
const parts: string[] = [];
|
||||
if (listing.bedrooms != null) parts.push(t('common.bedsCount', { count: listing.bedrooms }));
|
||||
if (listing.bathrooms != null) parts.push(t('common.bathsCount', { count: listing.bathrooms }));
|
||||
if (listing.property_sub_type) parts.push(listing.property_sub_type);
|
||||
else if (listing.property_type) parts.push(listing.property_type);
|
||||
return parts.length > 0 ? parts.join(' · ') : null;
|
||||
}
|
||||
|
||||
export const ListingPopupSingleContent = memo(function ListingPopupSingleContent({
|
||||
listing,
|
||||
}: {
|
||||
listing: ActualListing;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<a
|
||||
href={listing.listing_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block px-3 py-2"
|
||||
>
|
||||
{listing.asking_price != null && (
|
||||
<div className="text-base font-bold text-teal-600 dark:text-teal-400">
|
||||
{formatListingPrice(listing.asking_price)}
|
||||
{listing.price_qualifier ? (
|
||||
<span className="ml-1 text-xs font-medium text-warm-500 dark:text-warm-400">
|
||||
{listing.price_qualifier}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{formatListingHeadline(listing, t) && (
|
||||
<div className="text-xs text-warm-700 dark:text-warm-200 mt-0.5">
|
||||
{formatListingHeadline(listing, t)}
|
||||
</div>
|
||||
)}
|
||||
{listing.address && (
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400 mt-0.5 line-clamp-2">
|
||||
{listing.address}
|
||||
</div>
|
||||
)}
|
||||
{listing.postcode && (
|
||||
<div className="text-[11px] text-warm-400 dark:text-warm-500 mt-0.5">
|
||||
{listing.postcode}
|
||||
</div>
|
||||
)}
|
||||
{listing.floor_area_sqm != null && (
|
||||
<div className="text-[11px] text-warm-500 dark:text-warm-400 mt-0.5">
|
||||
{Math.round(listing.floor_area_sqm)} sqm
|
||||
{listing.asking_price_per_sqm != null
|
||||
? ` · £${Math.round(listing.asking_price_per_sqm).toLocaleString()}/sqm`
|
||||
: ''}
|
||||
</div>
|
||||
)}
|
||||
{listing.features.length > 0 && (
|
||||
<ul className="mt-1.5 text-[11px] text-warm-600 dark:text-warm-300 list-disc pl-4 space-y-0.5">
|
||||
{listing.features.slice(0, 3).map((feature, idx) => (
|
||||
<li key={idx} className="line-clamp-1">
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="mt-1.5 text-[11px] text-teal-600 dark:text-teal-400 font-medium">
|
||||
Open listing ↗
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
});
|
||||
|
||||
export const ListingClusterPopupContent = memo(function ListingClusterPopupContent({
|
||||
count,
|
||||
listings,
|
||||
}: {
|
||||
count: number;
|
||||
listings: ActualListing[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const visibleCount = listings.length;
|
||||
return (
|
||||
<div>
|
||||
<div className="border-b border-warm-200 px-3 py-2 dark:border-warm-700">
|
||||
<div className="text-base font-bold text-red-600 dark:text-red-400">
|
||||
{count.toLocaleString()} listings
|
||||
</div>
|
||||
<div className="text-[11px] text-warm-500 dark:text-warm-400">
|
||||
{visibleCount > 0
|
||||
? `Showing ${visibleCount.toLocaleString()} of ${count.toLocaleString()}`
|
||||
: 'Grouped near this map position'}
|
||||
</div>
|
||||
</div>
|
||||
{visibleCount > 0 && (
|
||||
<div className="max-h-80 overflow-y-auto py-1">
|
||||
{listings.map((listing, idx) => {
|
||||
const headline = formatListingHeadline(listing, t);
|
||||
return (
|
||||
<a
|
||||
key={`${listing.listing_url}-${idx}`}
|
||||
href={listing.listing_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block border-b border-warm-100 px-3 py-2 last:border-b-0 hover:bg-warm-50 dark:border-warm-700 dark:hover:bg-warm-700/60"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-teal-700 dark:text-teal-300">
|
||||
{listing.asking_price != null
|
||||
? formatListingPrice(listing.asking_price)
|
||||
: 'Listing'}
|
||||
</div>
|
||||
{headline && (
|
||||
<div className="mt-0.5 truncate text-xs text-warm-700 dark:text-warm-200">
|
||||
{headline}
|
||||
</div>
|
||||
)}
|
||||
{listing.address && (
|
||||
<div className="mt-0.5 line-clamp-1 text-[11px] text-warm-500 dark:text-warm-400">
|
||||
{listing.address}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{listing.postcode && (
|
||||
<div className="shrink-0 text-[11px] font-medium text-warm-400 dark:text-warm-500">
|
||||
{listing.postcode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,10 +1,8 @@
|
|||
import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { Layer, Map as MapGL, Source, useControl, ScaleControl } from 'react-map-gl/maplibre';
|
||||
import { Map as MapGL, 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 {
|
||||
HexagonData,
|
||||
|
|
@ -17,7 +15,6 @@ import type {
|
|||
Bounds,
|
||||
MapFlyToOptions,
|
||||
ActualListing,
|
||||
SchoolMetadata,
|
||||
} from '../../types';
|
||||
|
||||
import {
|
||||
|
|
@ -26,28 +23,25 @@ import {
|
|||
getBoundsWithBottomScreenInset,
|
||||
getMapStyle,
|
||||
getMapDataBeforeId,
|
||||
getPoiIconUrl,
|
||||
getMapCenterForTargetScreenPoint,
|
||||
} from '../../lib/map-utils';
|
||||
import {
|
||||
MAP_MIN_ZOOM,
|
||||
MAP_BOUNDS,
|
||||
POI_GROUP_COLORS,
|
||||
POSTCODE_ZOOM_THRESHOLD,
|
||||
POI_AUTO_CARD_ZOOM_THRESHOLD,
|
||||
} from '../../lib/consts';
|
||||
import LocationSearch, { type SearchedLocation } from './LocationSearch';
|
||||
import MapLegend from './MapLegend';
|
||||
import HoverCard from './HoverCard';
|
||||
import { MAP_MIN_ZOOM, MAP_BOUNDS, POI_AUTO_CARD_ZOOM_THRESHOLD } from '../../lib/consts';
|
||||
import type { SearchedLocation } from './LocationSearch';
|
||||
import { LogoIcon } from '../ui/icons/LogoIcon';
|
||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import type { FeatureFilters } from '../../types';
|
||||
import { useDeckLayers } from '../../hooks/useDeckLayers';
|
||||
import { useTranslatedModes, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
import { ts } from '../../i18n/server';
|
||||
import { type OverlayId, OVERLAY_MIN_ZOOM } from '../../lib/overlays';
|
||||
import { useMapCardLayout } from '../../hooks/useMapCardLayout';
|
||||
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
import { type OverlayId } from '../../lib/overlays';
|
||||
import { CRIME_TYPE_VALUES } from '../../lib/crime-types';
|
||||
import type { BasemapId } from '../../lib/basemaps';
|
||||
import { DeckOverlay } from './DeckOverlay';
|
||||
import { OverlayTileLayers } from './OverlayTileLayers';
|
||||
import { MapTopCards } from './MapTopCards';
|
||||
import { PoiPopupCardContent } from './PoiPopupCard';
|
||||
import { ListingClusterPopupContent, ListingPopupSingleContent } from './ListingPopups';
|
||||
import { HoverCardOverlay } from './HoverCardOverlay';
|
||||
|
||||
interface MapProps {
|
||||
data: HexagonData[];
|
||||
|
|
@ -99,168 +93,11 @@ const EMPTY_ACTUAL_LISTINGS: ActualListing[] = [];
|
|||
const EMPTY_OVERLAYS = new Set<OverlayId>();
|
||||
const ALL_CRIME_TYPES = new Set<string>(CRIME_TYPE_VALUES);
|
||||
|
||||
function formatListingPrice(price: number): string {
|
||||
return `£${price.toLocaleString()}`;
|
||||
}
|
||||
|
||||
function formatListingHeadline(listing: ActualListing, t: TFunction): string | null {
|
||||
const parts: string[] = [];
|
||||
if (listing.bedrooms != null) parts.push(t('common.bedsCount', { count: listing.bedrooms }));
|
||||
if (listing.bathrooms != null) parts.push(t('common.bathsCount', { count: listing.bathrooms }));
|
||||
if (listing.property_sub_type) parts.push(listing.property_sub_type);
|
||||
else if (listing.property_type) parts.push(listing.property_type);
|
||||
return parts.length > 0 ? parts.join(' · ') : null;
|
||||
}
|
||||
|
||||
function ListingPopupSingleContent({ listing, t }: { listing: ActualListing; t: TFunction }) {
|
||||
return (
|
||||
<a
|
||||
href={listing.listing_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block px-3 py-2"
|
||||
>
|
||||
{listing.asking_price != null && (
|
||||
<div className="text-base font-bold text-teal-600 dark:text-teal-400">
|
||||
{formatListingPrice(listing.asking_price)}
|
||||
{listing.price_qualifier ? (
|
||||
<span className="ml-1 text-xs font-medium text-warm-500 dark:text-warm-400">
|
||||
{listing.price_qualifier}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{formatListingHeadline(listing, t) && (
|
||||
<div className="text-xs text-warm-700 dark:text-warm-200 mt-0.5">
|
||||
{formatListingHeadline(listing, t)}
|
||||
</div>
|
||||
)}
|
||||
{listing.address && (
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400 mt-0.5 line-clamp-2">
|
||||
{listing.address}
|
||||
</div>
|
||||
)}
|
||||
{listing.postcode && (
|
||||
<div className="text-[11px] text-warm-400 dark:text-warm-500 mt-0.5">
|
||||
{listing.postcode}
|
||||
</div>
|
||||
)}
|
||||
{listing.floor_area_sqm != null && (
|
||||
<div className="text-[11px] text-warm-500 dark:text-warm-400 mt-0.5">
|
||||
{Math.round(listing.floor_area_sqm)} sqm
|
||||
{listing.asking_price_per_sqm != null
|
||||
? ` · £${Math.round(listing.asking_price_per_sqm).toLocaleString()}/sqm`
|
||||
: ''}
|
||||
</div>
|
||||
)}
|
||||
{listing.features.length > 0 && (
|
||||
<ul className="mt-1.5 text-[11px] text-warm-600 dark:text-warm-300 list-disc pl-4 space-y-0.5">
|
||||
{listing.features.slice(0, 3).map((feature, idx) => (
|
||||
<li key={idx} className="line-clamp-1">
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="mt-1.5 text-[11px] text-teal-600 dark:text-teal-400 font-medium">
|
||||
Open listing ↗
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function ListingClusterPopupContent({
|
||||
count,
|
||||
listings,
|
||||
t,
|
||||
}: {
|
||||
count: number;
|
||||
listings: ActualListing[];
|
||||
t: TFunction;
|
||||
}) {
|
||||
const visibleCount = listings.length;
|
||||
return (
|
||||
<div>
|
||||
<div className="border-b border-warm-200 px-3 py-2 dark:border-warm-700">
|
||||
<div className="text-base font-bold text-red-600 dark:text-red-400">
|
||||
{count.toLocaleString()} listings
|
||||
</div>
|
||||
<div className="text-[11px] text-warm-500 dark:text-warm-400">
|
||||
{visibleCount > 0
|
||||
? `Showing ${visibleCount.toLocaleString()} of ${count.toLocaleString()}`
|
||||
: 'Grouped near this map position'}
|
||||
</div>
|
||||
</div>
|
||||
{visibleCount > 0 && (
|
||||
<div className="max-h-80 overflow-y-auto py-1">
|
||||
{listings.map((listing, idx) => {
|
||||
const headline = formatListingHeadline(listing, t);
|
||||
return (
|
||||
<a
|
||||
key={`${listing.listing_url}-${idx}`}
|
||||
href={listing.listing_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block border-b border-warm-100 px-3 py-2 last:border-b-0 hover:bg-warm-50 dark:border-warm-700 dark:hover:bg-warm-700/60"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-teal-700 dark:text-teal-300">
|
||||
{listing.asking_price != null
|
||||
? formatListingPrice(listing.asking_price)
|
||||
: 'Listing'}
|
||||
</div>
|
||||
{headline && (
|
||||
<div className="mt-0.5 truncate text-xs text-warm-700 dark:text-warm-200">
|
||||
{headline}
|
||||
</div>
|
||||
)}
|
||||
{listing.address && (
|
||||
<div className="mt-0.5 line-clamp-1 text-[11px] text-warm-500 dark:text-warm-400">
|
||||
{listing.address}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{listing.postcode && (
|
||||
<div className="shrink-0 text-[11px] font-medium text-warm-400 dark:text-warm-500">
|
||||
{listing.postcode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PoiPopupCardData {
|
||||
name: string;
|
||||
category: string;
|
||||
icon_category?: string;
|
||||
group: string;
|
||||
emoji: string;
|
||||
school?: SchoolMetadata;
|
||||
}
|
||||
|
||||
interface Dimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const DESKTOP_TOP_CARD_WIDTH = 300;
|
||||
const DESKTOP_TOP_CARD_GAP = 8;
|
||||
const DESKTOP_TOP_CARD_HORIZONTAL_INSET = 24;
|
||||
const DESKTOP_TOP_CARDS_STACKED_MIN_MAP_WIDTH =
|
||||
DESKTOP_TOP_CARD_WIDTH + DESKTOP_TOP_CARD_HORIZONTAL_INSET;
|
||||
const DESKTOP_TOP_CARDS_ROW_MIN_MAP_WIDTH =
|
||||
DESKTOP_TOP_CARD_WIDTH * 2 + DESKTOP_TOP_CARD_GAP + DESKTOP_TOP_CARD_HORIZONTAL_INSET;
|
||||
const DESKTOP_TOP_CARD_CLASS = 'w-[300px]';
|
||||
const DESKTOP_LOCATION_SEARCH_INPUT_CLASS =
|
||||
'px-2 py-2 text-sm w-full border-none outline-none bg-transparent text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500';
|
||||
|
||||
type MapContainerStyle = CSSProperties & {
|
||||
'--map-mobile-bottom-inset'?: string;
|
||||
};
|
||||
|
|
@ -323,218 +160,6 @@ function getViewportRelativeVisibleAreaCenter(
|
|||
};
|
||||
}
|
||||
|
||||
interface DeckWithPrivateDraw {
|
||||
_drawLayers?: (
|
||||
redrawReason: string,
|
||||
renderOptions?: { viewports?: unknown[]; [key: string]: unknown }
|
||||
) => unknown;
|
||||
__propertyMapNullViewportPatch?: boolean;
|
||||
}
|
||||
|
||||
function patchNullViewportDraw(overlay: MapboxOverlay) {
|
||||
const deck = (overlay as unknown as { _deck?: DeckWithPrivateDraw })._deck;
|
||||
if (!deck || deck.__propertyMapNullViewportPatch || typeof deck._drawLayers !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const drawLayers = deck._drawLayers.bind(deck);
|
||||
deck._drawLayers = (redrawReason, renderOptions) => {
|
||||
const viewports = renderOptions?.viewports;
|
||||
if (viewports) {
|
||||
// Split-route startup can hand deck.gl a transient null viewport before MapLibre has sized the map.
|
||||
const nonNullViewports = viewports.filter(Boolean);
|
||||
if (nonNullViewports.length === 0) return;
|
||||
if (nonNullViewports.length !== viewports.length) {
|
||||
return drawLayers(redrawReason, { ...renderOptions, viewports: nonNullViewports });
|
||||
}
|
||||
}
|
||||
return drawLayers(redrawReason, renderOptions);
|
||||
};
|
||||
deck.__propertyMapNullViewportPatch = true;
|
||||
}
|
||||
|
||||
class SafeMapboxOverlay extends MapboxOverlay {
|
||||
onAdd(map: unknown) {
|
||||
const element = super.onAdd(map);
|
||||
patchNullViewportDraw(this);
|
||||
return element;
|
||||
}
|
||||
|
||||
setProps(props: Parameters<MapboxOverlay['setProps']>[0]) {
|
||||
super.setProps(props);
|
||||
patchNullViewportDraw(this);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Best-effort web URL from a free-text website field — GIAS stores some with
|
||||
* "http://", some without, and some as bare hostnames. */
|
||||
function normalizeSchoolWebsiteUrl(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||
if (/^[\w.-]+\.[a-z]{2,}/i.test(trimmed)) return `http://${trimmed}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderSchoolMetadata(school: SchoolMetadata) {
|
||||
// First line collects the headline classification (phase, type, religious
|
||||
// character) so the popup is scannable even when most fields are absent.
|
||||
const headline: string[] = [];
|
||||
if (school.phase) headline.push(school.phase);
|
||||
if (school.type) headline.push(school.type);
|
||||
|
||||
const pupilsLine =
|
||||
school.pupils !== undefined && school.capacity !== undefined
|
||||
? `${school.pupils.toLocaleString()} / ${school.capacity.toLocaleString()} pupils`
|
||||
: school.pupils !== undefined
|
||||
? `${school.pupils.toLocaleString()} pupils`
|
||||
: school.capacity !== undefined
|
||||
? `Capacity ${school.capacity.toLocaleString()}`
|
||||
: null;
|
||||
|
||||
const websiteUrl = school.website ? normalizeSchoolWebsiteUrl(school.website) : null;
|
||||
|
||||
return (
|
||||
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 text-xs text-warm-600 dark:text-warm-300">
|
||||
{headline.length > 0 && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Type</dt>
|
||||
<dd className="dark:text-warm-200">{headline.join(' · ')}</dd>
|
||||
</>
|
||||
)}
|
||||
{school.age_range && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Ages</dt>
|
||||
<dd className="dark:text-warm-200">{school.age_range}</dd>
|
||||
</>
|
||||
)}
|
||||
{school.gender && school.gender !== 'Mixed' && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Gender</dt>
|
||||
<dd className="dark:text-warm-200">{school.gender}</dd>
|
||||
</>
|
||||
)}
|
||||
{pupilsLine && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Pupils</dt>
|
||||
<dd className="dark:text-warm-200">{pupilsLine}</dd>
|
||||
</>
|
||||
)}
|
||||
{school.fsm_percent !== undefined && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Free meal</dt>
|
||||
<dd className="dark:text-warm-200">{school.fsm_percent.toFixed(1)}%</dd>
|
||||
</>
|
||||
)}
|
||||
{school.ofsted_rating && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Ofsted</dt>
|
||||
<dd className="dark:text-warm-200">{school.ofsted_rating}</dd>
|
||||
</>
|
||||
)}
|
||||
{school.sixth_form === 'Has a sixth form' && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Sixth form</dt>
|
||||
<dd className="dark:text-warm-200">Yes</dd>
|
||||
</>
|
||||
)}
|
||||
{school.religious_character &&
|
||||
school.religious_character !== 'Does not apply' &&
|
||||
school.religious_character !== 'None' && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Religion</dt>
|
||||
<dd className="dark:text-warm-200">{school.religious_character}</dd>
|
||||
</>
|
||||
)}
|
||||
{school.admissions_policy && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Admissions</dt>
|
||||
<dd className="dark:text-warm-200">{school.admissions_policy}</dd>
|
||||
</>
|
||||
)}
|
||||
{school.trust && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Trust</dt>
|
||||
<dd className="dark:text-warm-200">{school.trust}</dd>
|
||||
</>
|
||||
)}
|
||||
{(school.address || school.postcode) && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Address</dt>
|
||||
<dd className="dark:text-warm-200">
|
||||
{[school.address, school.postcode].filter(Boolean).join(', ')}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
{school.local_authority && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">LA</dt>
|
||||
<dd className="dark:text-warm-200">{school.local_authority}</dd>
|
||||
</>
|
||||
)}
|
||||
{school.head_name && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Head</dt>
|
||||
<dd className="dark:text-warm-200">{school.head_name}</dd>
|
||||
</>
|
||||
)}
|
||||
{websiteUrl && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Website</dt>
|
||||
<dd className="truncate">
|
||||
<a
|
||||
href={websiteUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="pointer-events-auto text-teal-600 hover:underline dark:text-teal-400"
|
||||
>
|
||||
{websiteUrl.replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function PoiPopupCardContent({ poi }: { poi: PoiPopupCardData }) {
|
||||
return (
|
||||
<div className="px-3 py-2 max-w-[280px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={getPoiIconUrl(poi.category, poi.emoji, poi.icon_category, poi.name)}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-5 w-5 shrink-0 rounded-[4px] bg-white object-contain p-0.5"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold dark:text-warm-100">{poi.name}</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-warm-500 dark:text-warm-400">
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: `rgb(${getPoiGroupColor(poi.group).join(',')})`,
|
||||
}}
|
||||
/>
|
||||
{ts(poi.category)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{poi.school && renderSchoolMetadata(poi.school)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getRenderedViewState(map: MapRef | null): ViewState | null {
|
||||
if (!map) return null;
|
||||
|
||||
|
|
@ -565,186 +190,6 @@ function getRenderedVisibleCenter(
|
|||
};
|
||||
}
|
||||
|
||||
function DeckOverlay({
|
||||
layers,
|
||||
getTooltip,
|
||||
}: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
layers: any[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getTooltip: any;
|
||||
}) {
|
||||
const overlay = useControl(() => new SafeMapboxOverlay({ interleaved: true }));
|
||||
|
||||
useEffect(() => {
|
||||
overlay.setProps({
|
||||
layers: layers.filter(Boolean),
|
||||
getTooltip,
|
||||
});
|
||||
}, [overlay, layers, getTooltip]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function overlayTileUrl(path: string): string {
|
||||
return `${window.location.origin}/api/overlays/${path}/{z}/{x}/{y}`;
|
||||
}
|
||||
|
||||
function OverlayTileLayers({
|
||||
activeOverlays,
|
||||
activeCrimeTypes,
|
||||
zoom,
|
||||
}: {
|
||||
activeOverlays: Set<OverlayId>;
|
||||
activeCrimeTypes: Set<string>;
|
||||
zoom: number;
|
||||
}) {
|
||||
if (zoom < POSTCODE_ZOOM_THRESHOLD || activeOverlays.size === 0) return null;
|
||||
|
||||
const showNoise = activeOverlays.has('noise');
|
||||
const showCrime = activeOverlays.has('crime-hotspots');
|
||||
const showTrees = activeOverlays.has('trees-outside-woodlands');
|
||||
const showPropertyBorders = activeOverlays.has('property-borders');
|
||||
|
||||
// Restrict the heatmap to the selected crime types. This must always be a
|
||||
// concrete expression: passing `filter={undefined}` makes react-map-gl call
|
||||
// map.addLayer({filter: undefined}), which MapLibre rejects at validation
|
||||
// ("filter: array expected, undefined found"), so the layer is never created
|
||||
// and the heatmap stays blank until a later setFilter call. An `in` over the
|
||||
// selected types matches everything when all 14 are selected.
|
||||
const crimeFilter = ['in', ['get', 'crime_type'], ['literal', Array.from(activeCrimeTypes)]];
|
||||
|
||||
return (
|
||||
<>
|
||||
{showNoise && (
|
||||
<Source
|
||||
id="overlay-noise-source"
|
||||
type="raster"
|
||||
tiles={[overlayTileUrl('noise')]}
|
||||
tileSize={256}
|
||||
minzoom={OVERLAY_MIN_ZOOM.noise}
|
||||
maxzoom={14}
|
||||
>
|
||||
<Layer
|
||||
id="overlay-noise"
|
||||
type="raster"
|
||||
minzoom={POSTCODE_ZOOM_THRESHOLD}
|
||||
paint={{
|
||||
'raster-opacity': 0.68,
|
||||
'raster-fade-duration': 120,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{showCrime && (
|
||||
<Source
|
||||
id="overlay-crime-source"
|
||||
type="vector"
|
||||
tiles={[overlayTileUrl('crime-hotspots')]}
|
||||
minzoom={OVERLAY_MIN_ZOOM['crime-hotspots']}
|
||||
maxzoom={15}
|
||||
>
|
||||
<Layer
|
||||
id="overlay-crime-heatmap"
|
||||
type="heatmap"
|
||||
source-layer="crime_hotspots"
|
||||
minzoom={POSTCODE_ZOOM_THRESHOLD}
|
||||
filter={crimeFilter as never}
|
||||
paint={
|
||||
{
|
||||
'heatmap-weight': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['coalesce', ['get', 'count'], ['get', 'weight'], 1],
|
||||
0,
|
||||
0,
|
||||
10,
|
||||
1,
|
||||
],
|
||||
'heatmap-intensity': ['interpolate', ['linear'], ['zoom'], 15, 0.8, 18, 2.2],
|
||||
'heatmap-radius': ['interpolate', ['linear'], ['zoom'], 15, 18, 18, 30],
|
||||
'heatmap-opacity': 0.72,
|
||||
'heatmap-color': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['heatmap-density'],
|
||||
0,
|
||||
'rgba(0, 0, 0, 0)',
|
||||
0.2,
|
||||
'rgb(253, 224, 71)',
|
||||
0.45,
|
||||
'rgb(249, 115, 22)',
|
||||
0.75,
|
||||
'rgb(220, 38, 38)',
|
||||
1,
|
||||
'rgb(127, 29, 29)',
|
||||
],
|
||||
} as never
|
||||
}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{showTrees && (
|
||||
<Source
|
||||
id="overlay-trees-source"
|
||||
type="vector"
|
||||
tiles={[overlayTileUrl('trees-outside-woodlands')]}
|
||||
minzoom={OVERLAY_MIN_ZOOM['trees-outside-woodlands']}
|
||||
maxzoom={16}
|
||||
>
|
||||
<Layer
|
||||
id="overlay-tree-polygons"
|
||||
type="fill"
|
||||
source-layer="trees_outside_woodlands"
|
||||
minzoom={POSTCODE_ZOOM_THRESHOLD}
|
||||
paint={
|
||||
{
|
||||
'fill-color': '#1f9d55',
|
||||
'fill-opacity': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['coalesce', ['get', 'area_sqm'], 0],
|
||||
0,
|
||||
0.28,
|
||||
250,
|
||||
0.62,
|
||||
],
|
||||
'fill-outline-color': 'rgba(15, 81, 50, 0.65)',
|
||||
} as never
|
||||
}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{showPropertyBorders && (
|
||||
<Source
|
||||
id="overlay-property-borders-source"
|
||||
type="vector"
|
||||
tiles={[overlayTileUrl('property-borders')]}
|
||||
minzoom={OVERLAY_MIN_ZOOM['property-borders']}
|
||||
maxzoom={16}
|
||||
>
|
||||
<Layer
|
||||
id="overlay-property-borders"
|
||||
type="line"
|
||||
source-layer="property_borders"
|
||||
minzoom={POSTCODE_ZOOM_THRESHOLD}
|
||||
paint={
|
||||
{
|
||||
'line-color': '#b45309',
|
||||
'line-opacity': ['interpolate', ['linear'], ['zoom'], 15, 0.35, 18, 0.85],
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 15, 0.4, 18, 1.4],
|
||||
} as never
|
||||
}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(function Map({
|
||||
data,
|
||||
postcodeData,
|
||||
|
|
@ -790,7 +235,6 @@ export default memo(function Map({
|
|||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<MapRef | null>(null);
|
||||
const { t } = useTranslation();
|
||||
const modes = useTranslatedModes();
|
||||
const densityLabel = densityLabelProp ?? t('mapLegend.numberOfProperties');
|
||||
const [internalViewState, setInternalViewState] = useState<ViewState>(initialViewState);
|
||||
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
|
||||
|
|
@ -941,23 +385,16 @@ export default memo(function Map({
|
|||
() => (bottomScreenInset > 0 ? { '--map-mobile-bottom-inset': `${bottomScreenInset}px` } : {}),
|
||||
[bottomScreenInset]
|
||||
);
|
||||
const hideDesktopTopCardsForWidth =
|
||||
hideTopCardsWhenNarrow &&
|
||||
dimensions.width > 0 &&
|
||||
dimensions.width < DESKTOP_TOP_CARDS_STACKED_MIN_MAP_WIDTH;
|
||||
const stackDesktopTopCards =
|
||||
hideTopCardsWhenNarrow &&
|
||||
dimensions.width >= DESKTOP_TOP_CARDS_STACKED_MIN_MAP_WIDTH &&
|
||||
dimensions.width < DESKTOP_TOP_CARDS_ROW_MIN_MAP_WIDTH;
|
||||
const showLocationSearch = !hideLocationSearch && !hideDesktopTopCardsForWidth;
|
||||
const showLegend = !hideLegend && !hideDesktopTopCardsForWidth;
|
||||
const { showLocationSearch, showLegend, topCardsLayoutClass } = useMapCardLayout({
|
||||
mapWidth: dimensions.width,
|
||||
hideTopCardsWhenNarrow,
|
||||
hideLegend,
|
||||
hideLocationSearch,
|
||||
});
|
||||
const getViewportCenter = useCallback(() => {
|
||||
const center = mapRef.current?.getCenter();
|
||||
return center ? { lat: center.lat, lng: center.lng } : null;
|
||||
}, []);
|
||||
const desktopTopCardsLayoutClass = stackDesktopTopCards
|
||||
? 'flex-col items-start'
|
||||
: 'items-start justify-between';
|
||||
|
||||
const {
|
||||
layers,
|
||||
|
|
@ -1108,79 +545,29 @@ export default memo(function Map({
|
|||
) : (
|
||||
<>
|
||||
{(showLocationSearch || showLegend) && (
|
||||
<div
|
||||
className={`absolute top-3 left-3 right-3 z-20 flex gap-2 pointer-events-none ${desktopTopCardsLayoutClass}`}
|
||||
>
|
||||
{showLocationSearch && (
|
||||
<LocationSearch
|
||||
onFlyTo={handleFlyTo}
|
||||
onLocationSearched={onLocationSearched}
|
||||
onCurrentLocationFound={onCurrentLocationFound}
|
||||
onMouseEnter={handleMouseLeave}
|
||||
getViewportCenter={getViewportCenter}
|
||||
className={DESKTOP_TOP_CARD_CLASS}
|
||||
inputClassName={DESKTOP_LOCATION_SEARCH_INPUT_CLASS}
|
||||
/>
|
||||
)}
|
||||
{showLegend &&
|
||||
(viewFeature && colorRange ? (
|
||||
viewFeature.startsWith('tt_') ? (
|
||||
<MapLegend
|
||||
featureLabel={t('travel.travelTime', {
|
||||
mode: modes.label(
|
||||
viewFeature.split('_')[1] as 'car' | 'bicycle' | 'walking' | 'transit'
|
||||
),
|
||||
})}
|
||||
range={colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={onCancelPin}
|
||||
onResetScale={viewSource === 'eye' ? onResetPreviewScale : undefined}
|
||||
resetScaleDisabled={!canResetPreviewScale}
|
||||
mode="feature"
|
||||
theme={theme}
|
||||
suffix=" min"
|
||||
className={DESKTOP_TOP_CARD_CLASS}
|
||||
/>
|
||||
) : colorFeatureMeta ? (
|
||||
<MapLegend
|
||||
featureLabel={
|
||||
viewSource === 'eye'
|
||||
? t('mapLegend.previewing', { name: ts(colorFeatureMeta.name) })
|
||||
: ts(colorFeatureMeta.name)
|
||||
}
|
||||
range={colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={onCancelPin}
|
||||
onResetScale={viewSource === 'eye' ? onResetPreviewScale : undefined}
|
||||
resetScaleDisabled={!canResetPreviewScale}
|
||||
mode="feature"
|
||||
enumValues={
|
||||
colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined
|
||||
}
|
||||
featureName={colorFeatureMeta.name}
|
||||
theme={theme}
|
||||
suffix={colorFeatureMeta.suffix}
|
||||
raw={colorFeatureMeta.raw}
|
||||
className={DESKTOP_TOP_CARD_CLASS}
|
||||
/>
|
||||
) : null
|
||||
) : (
|
||||
<MapLegend
|
||||
featureLabel={densityLabel}
|
||||
range={
|
||||
usePostcodeView
|
||||
? [postcodeCountRange.min, postcodeCountRange.max]
|
||||
: [countRange.min, countRange.max]
|
||||
}
|
||||
totalCount={totalCountProp}
|
||||
showCancel={false}
|
||||
onCancel={onCancelPin}
|
||||
mode="density"
|
||||
theme={theme}
|
||||
className={DESKTOP_TOP_CARD_CLASS}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<MapTopCards
|
||||
layoutClass={topCardsLayoutClass}
|
||||
showLocationSearch={showLocationSearch}
|
||||
showLegend={showLegend}
|
||||
onFlyTo={handleFlyTo}
|
||||
onLocationSearched={onLocationSearched}
|
||||
onCurrentLocationFound={onCurrentLocationFound}
|
||||
onLocationSearchMouseEnter={handleMouseLeave}
|
||||
getViewportCenter={getViewportCenter}
|
||||
viewFeature={viewFeature}
|
||||
colorRange={colorRange}
|
||||
viewSource={viewSource}
|
||||
onCancelPin={onCancelPin}
|
||||
onResetPreviewScale={onResetPreviewScale}
|
||||
canResetPreviewScale={canResetPreviewScale}
|
||||
colorFeatureMeta={colorFeatureMeta}
|
||||
usePostcodeView={usePostcodeView}
|
||||
countRange={countRange}
|
||||
postcodeCountRange={postcodeCountRange}
|
||||
densityLabel={densityLabel}
|
||||
totalCount={totalCountProp}
|
||||
theme={theme}
|
||||
/>
|
||||
)}
|
||||
{autoPoiCards.map(({ poi, x, y }) => (
|
||||
<div
|
||||
|
|
@ -1247,28 +634,23 @@ export default memo(function Map({
|
|||
<CloseIcon className="w-3 h-3" />
|
||||
</button>
|
||||
{listingPopup.mode === 'single' ? (
|
||||
<ListingPopupSingleContent listing={listingPopup.listing} t={t} />
|
||||
<ListingPopupSingleContent listing={listingPopup.listing} />
|
||||
) : (
|
||||
<ListingClusterPopupContent
|
||||
count={listingPopup.count}
|
||||
listings={listingPopup.listings}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hoverPosition && hoveredHexagonId && hoveredHexagonId !== selectedHexagonId && (
|
||||
<HoverCard
|
||||
<HoverCardOverlay
|
||||
x={hoverPosition.x}
|
||||
y={hoverPosition.y}
|
||||
id={hoveredHexagonId}
|
||||
isPostcode={usePostcodeView}
|
||||
data={
|
||||
usePostcodeView
|
||||
? postcodeData.find((f) => f.properties.postcode === hoveredHexagonId)
|
||||
?.properties || null
|
||||
: data.find((d) => d.h3 === hoveredHexagonId) || null
|
||||
}
|
||||
usePostcodeView={usePostcodeView}
|
||||
data={data}
|
||||
postcodeData={postcodeData}
|
||||
filters={filters}
|
||||
features={features}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import type { ActualListing, MapFlyToOptions, PostcodeGeometry } from '../../types';
|
||||
import type { ActualListing, PostcodeGeometry } from '../../types';
|
||||
import type { SearchedLocation } from './LocationSearch';
|
||||
import { useMapData } from '../../hooks/useMapData';
|
||||
import { usePOIData } from '../../hooks/usePOIData';
|
||||
|
|
@ -67,11 +67,11 @@ import {
|
|||
useMobileBackNavigationGuard,
|
||||
useScreenshotReadySignal,
|
||||
} from './map-page/effects';
|
||||
import { useMobileDrawer } from './map-page/useMobileDrawer';
|
||||
import type { MapFlyTo, MapPageProps } from './map-page/types';
|
||||
|
||||
export type { ExportState } from './map-page/types';
|
||||
|
||||
type PendingFlyTo = { lat: number; lng: number; zoom: number };
|
||||
const EMPTY_ACTUAL_LISTINGS: ActualListing[] = [];
|
||||
|
||||
export default function MapPage({
|
||||
|
|
@ -127,10 +127,11 @@ export default function MapPage({
|
|||
);
|
||||
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 0.45, 'left');
|
||||
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 [overlayPaneOpen, setOverlayPaneOpen] = useState(false);
|
||||
// The POI and overlay panes are mutually exclusive, so a single state tracks
|
||||
// which one (if any) is open.
|
||||
const [openMapPane, setOpenMapPane] = useState<'poi' | 'overlay' | null>(null);
|
||||
const poiPaneOpen = openMapPane === 'poi';
|
||||
const overlayPaneOpen = openMapPane === 'overlay';
|
||||
const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null);
|
||||
const [listingsToggleEnabled, setListingsToggleEnabled] = useState(true);
|
||||
const [pendingInitialPostcode, setPendingInitialPostcode] = useState<string | null>(
|
||||
|
|
@ -184,27 +185,21 @@ export default function MapPage({
|
|||
} = useTravelTime(initialTravelTime);
|
||||
|
||||
const mapFlyToRef = useRef<MapFlyTo | null>(null);
|
||||
const pendingCurrentLocationFlyToRef = useRef<{ lat: number; lng: number } | null>(null);
|
||||
const pendingLocationSearchFlyToRef = useRef<PendingFlyTo | null>(null);
|
||||
const mobileDrawerPanelRectRef = useRef<DOMRectReadOnly | null>(null);
|
||||
const areaPaneScrollTopRef = useRef(0);
|
||||
const propertiesPaneScrollTopRef = useRef(0);
|
||||
|
||||
const getMobileMapFlyToOptions = useCallback((): MapFlyToOptions | undefined => {
|
||||
if (!isMobile) return undefined;
|
||||
|
||||
const panelRect = mobileDrawerPanelRectRef.current;
|
||||
if (mobileDrawerOpen && panelRect) {
|
||||
const bottomInset = Math.max(0, window.innerHeight - panelRect.top);
|
||||
if (bottomInset > 0) {
|
||||
return { visibleViewportArea: { bottom: bottomInset } };
|
||||
}
|
||||
}
|
||||
|
||||
return mobileBottomSheetHeight > 0
|
||||
? { visibleArea: { bottom: mobileBottomSheetHeight } }
|
||||
: undefined;
|
||||
}, [isMobile, mobileBottomSheetHeight, mobileDrawerOpen]);
|
||||
const {
|
||||
mobileDrawerOpen,
|
||||
mobileBottomSheetHeight,
|
||||
setMobileBottomSheetHeight,
|
||||
openMobileDrawer,
|
||||
openMobileDrawerForLocationSearch,
|
||||
clearPendingLocationSearchFlyTo,
|
||||
queueCurrentLocationFlyTo,
|
||||
handleMobileDrawerPanelRectChange,
|
||||
handleMobileDrawerClose,
|
||||
getMobileMapFlyToOptions,
|
||||
} = useMobileDrawer(isMobile, mapFlyToRef);
|
||||
|
||||
const mapData = useMapData({
|
||||
filters,
|
||||
|
|
@ -217,6 +212,12 @@ export default function MapPage({
|
|||
shareCode,
|
||||
});
|
||||
|
||||
// Read the zoom through a ref inside handleAiFilterSubmit so panning/zooming
|
||||
// doesn't recreate the callback (it sits in the Filters pane's dependency
|
||||
// chain, which would otherwise re-render on every camera move).
|
||||
const currentViewZoomRef = useRef<number | undefined>(undefined);
|
||||
currentViewZoomRef.current = mapData.currentView?.zoom;
|
||||
|
||||
const handleAiFilterSubmit = useCallback(
|
||||
async (query: string) => {
|
||||
const context = {
|
||||
|
|
@ -283,7 +284,7 @@ export default function MapPage({
|
|||
mapFlyToRef.current?.(
|
||||
destination.lat,
|
||||
destination.lon,
|
||||
mapData.currentView?.zoom ?? INITIAL_VIEW_STATE.zoom,
|
||||
currentViewZoomRef.current ?? INITIAL_VIEW_STATE.zoom,
|
||||
getMobileMapFlyToOptions()
|
||||
);
|
||||
}
|
||||
|
|
@ -298,7 +299,6 @@ export default function MapPage({
|
|||
getMobileMapFlyToOptions,
|
||||
handleSetEntries,
|
||||
handleSetFilters,
|
||||
mapData.currentView?.zoom,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -395,20 +395,6 @@ export default function MapPage({
|
|||
journeyDest,
|
||||
});
|
||||
|
||||
const consumePendingLocationSearchFlyTo = useCallback((rect?: DOMRectReadOnly | null) => {
|
||||
const pending = pendingLocationSearchFlyToRef.current;
|
||||
const panelRect = rect ?? mobileDrawerPanelRectRef.current;
|
||||
if (!pending || !panelRect) return;
|
||||
|
||||
const bottomInset = Math.max(0, window.innerHeight - panelRect.top);
|
||||
const flyTo = mapFlyToRef.current;
|
||||
if (!flyTo) return;
|
||||
flyTo(pending.lat, pending.lng, pending.zoom, {
|
||||
visibleViewportArea: { bottom: bottomInset },
|
||||
});
|
||||
pendingLocationSearchFlyToRef.current = null;
|
||||
}, []);
|
||||
|
||||
const handleLocationSearchResult = useCallback(
|
||||
(result: SearchedLocation | null) => {
|
||||
if (result) {
|
||||
|
|
@ -428,68 +414,41 @@ export default function MapPage({
|
|||
result.focusAddress
|
||||
);
|
||||
if (isMobile) {
|
||||
pendingLocationSearchFlyToRef.current = {
|
||||
openMobileDrawerForLocationSearch({
|
||||
lat: markerLat ?? result.latitude,
|
||||
lng: markerLng ?? result.longitude,
|
||||
zoom: result.zoom,
|
||||
};
|
||||
setMobileDrawerOpen(true);
|
||||
consumePendingLocationSearchFlyTo();
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setCurrentLocation(null);
|
||||
pendingLocationSearchFlyToRef.current = null;
|
||||
clearPendingLocationSearchFlyTo();
|
||||
handleCloseSelection();
|
||||
}
|
||||
},
|
||||
[consumePendingLocationSearchFlyTo, handleCloseSelection, handleLocationSearch, isMobile]
|
||||
[
|
||||
clearPendingLocationSearchFlyTo,
|
||||
handleCloseSelection,
|
||||
handleLocationSearch,
|
||||
isMobile,
|
||||
openMobileDrawerForLocationSearch,
|
||||
]
|
||||
);
|
||||
|
||||
const consumePendingCurrentLocationFlyTo = useCallback((rect?: DOMRectReadOnly | null) => {
|
||||
const pending = pendingCurrentLocationFlyToRef.current;
|
||||
const panelRect = rect ?? mobileDrawerPanelRectRef.current;
|
||||
if (!pending || !panelRect) return;
|
||||
|
||||
const bottomInset = Math.max(0, window.innerHeight - panelRect.top);
|
||||
const flyTo = mapFlyToRef.current;
|
||||
if (!flyTo) return;
|
||||
flyTo(pending.lat, pending.lng, 17, {
|
||||
visibleViewportArea: { bottom: bottomInset },
|
||||
});
|
||||
pendingCurrentLocationFlyToRef.current = null;
|
||||
}, []);
|
||||
|
||||
const handleCurrentLocationFound = useCallback(
|
||||
(lat: number, lng: number) => {
|
||||
if (isMobile) {
|
||||
pendingCurrentLocationFlyToRef.current = { lat, lng };
|
||||
consumePendingCurrentLocationFlyTo();
|
||||
queueCurrentLocationFlyTo(lat, lng);
|
||||
} else {
|
||||
mapFlyToRef.current?.(lat, lng, 17);
|
||||
}
|
||||
setCurrentLocation({ lat, lng });
|
||||
handleCurrentLocationSearch(lat, lng);
|
||||
if (isMobile) setMobileDrawerOpen(true);
|
||||
if (isMobile) openMobileDrawer();
|
||||
},
|
||||
[consumePendingCurrentLocationFlyTo, handleCurrentLocationSearch, isMobile]
|
||||
[handleCurrentLocationSearch, isMobile, openMobileDrawer, queueCurrentLocationFlyTo]
|
||||
);
|
||||
|
||||
const handleMobileDrawerPanelRectChange = useCallback(
|
||||
(rect: DOMRectReadOnly) => {
|
||||
mobileDrawerPanelRectRef.current = rect;
|
||||
consumePendingCurrentLocationFlyTo(rect);
|
||||
consumePendingLocationSearchFlyTo(rect);
|
||||
},
|
||||
[consumePendingCurrentLocationFlyTo, consumePendingLocationSearchFlyTo]
|
||||
);
|
||||
|
||||
const handleMobileDrawerClose = useCallback(() => {
|
||||
pendingCurrentLocationFlyToRef.current = null;
|
||||
pendingLocationSearchFlyToRef.current = null;
|
||||
mobileDrawerPanelRectRef.current = null;
|
||||
setMobileDrawerOpen(false);
|
||||
}, []);
|
||||
|
||||
const shareReturnViewRef = useRef(shareCode ? initialViewState : null);
|
||||
// Hide the upgrade modal as soon as the user dismisses it. We can't rely on
|
||||
// the camera fly alone to close it: flying back to the free/shared zone only
|
||||
|
|
@ -555,11 +514,7 @@ export default function MapPage({
|
|||
isMobile,
|
||||
flyTo: mapFlyToRef,
|
||||
onLocationSearch: handleLocationSearch,
|
||||
onOpenMobileDrawer: (target) => {
|
||||
pendingLocationSearchFlyToRef.current = target;
|
||||
setMobileDrawerOpen(true);
|
||||
consumePendingLocationSearchFlyTo();
|
||||
},
|
||||
onOpenMobileDrawer: openMobileDrawerForLocationSearch,
|
||||
onSettled: () => setPendingInitialPostcode(null),
|
||||
});
|
||||
useHorizontalSwipeNavigationGuard();
|
||||
|
|
@ -578,10 +533,10 @@ export default function MapPage({
|
|||
(id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => {
|
||||
handleHexagonClick(id, isPostcode, geometry);
|
||||
if (id) {
|
||||
setMobileDrawerOpen(true);
|
||||
openMobileDrawer();
|
||||
}
|
||||
},
|
||||
[handleHexagonClick]
|
||||
[handleHexagonClick, openMobileDrawer]
|
||||
);
|
||||
|
||||
const hexagonLocation = useHexagonLocation(
|
||||
|
|
@ -641,15 +596,20 @@ export default function MapPage({
|
|||
shareAndSaveView,
|
||||
]
|
||||
);
|
||||
// dashboardParams changes on every camera move; read it through a ref so the
|
||||
// save/update handlers (and the Filters pane depending on them) stay stable
|
||||
// while panning. The ref always holds the params of the latest render.
|
||||
const dashboardParamsRef = useRef(dashboardParams);
|
||||
dashboardParamsRef.current = dashboardParams;
|
||||
const handleSaveSearch = useCallback(
|
||||
async (name: string) => {
|
||||
await onSaveSearch?.(name, dashboardParams);
|
||||
await onSaveSearch?.(name, dashboardParamsRef.current);
|
||||
},
|
||||
[dashboardParams, onSaveSearch]
|
||||
[onSaveSearch]
|
||||
);
|
||||
const handleUpdateEditInPlaceWithParams = useCallback(async () => {
|
||||
await onUpdateEditInPlace?.(dashboardParams);
|
||||
}, [dashboardParams, onUpdateEditInPlace]);
|
||||
await onUpdateEditInPlace?.(dashboardParamsRef.current);
|
||||
}, [onUpdateEditInPlace]);
|
||||
const checkoutReturnPath = useMemo(
|
||||
() => `/dashboard${dashboardParams ? `?${dashboardParams}` : ''}`,
|
||||
[dashboardParams]
|
||||
|
|
@ -686,6 +646,273 @@ export default function MapPage({
|
|||
}
|
||||
}, [mapData.licenseRequired]);
|
||||
|
||||
const handleUpgradeClick = useCallback(() => {
|
||||
onNavigateTo('pricing');
|
||||
}, [onNavigateTo]);
|
||||
const handleTogglePoiPane = useCallback(() => {
|
||||
setOpenMapPane((pane) => (pane === 'poi' ? null : 'poi'));
|
||||
}, []);
|
||||
const handleToggleOverlayPane = useCallback(() => {
|
||||
setOpenMapPane((pane) => (pane === 'overlay' ? null : 'overlay'));
|
||||
}, []);
|
||||
const handleClosePoiPane = useCallback(() => {
|
||||
setOpenMapPane((pane) => (pane === 'poi' ? null : pane));
|
||||
}, []);
|
||||
const handleCloseOverlayPane = useCallback(() => {
|
||||
setOpenMapPane((pane) => (pane === 'overlay' ? null : pane));
|
||||
}, []);
|
||||
const handleAreaTabClick = useCallback(() => {
|
||||
setRightPaneTab('area');
|
||||
}, [setRightPaneTab]);
|
||||
const handleMobileDrawerTabChange = useCallback(
|
||||
(tab: 'area' | 'properties') => {
|
||||
if (tab === 'properties') {
|
||||
handlePropertiesTabClick();
|
||||
} else {
|
||||
setRightPaneTab(tab);
|
||||
}
|
||||
},
|
||||
[handlePropertiesTabClick, setRightPaneTab]
|
||||
);
|
||||
|
||||
const renderAreaPane = useCallback(
|
||||
() => (
|
||||
<Suspense fallback={<PaneFallback />}>
|
||||
<AreaPane
|
||||
stats={areaStats}
|
||||
globalFeatures={features}
|
||||
loading={loadingAreaStats}
|
||||
hexagonId={selectedHexagon?.id || null}
|
||||
isPostcode={selectedHexagon?.type === 'postcode'}
|
||||
hexagonLocation={hexagonLocation}
|
||||
filters={filters}
|
||||
unfilteredCount={unfilteredAreaCount}
|
||||
statsUseFilters={areaStatsUseFilters}
|
||||
onStatsUseFiltersChange={setAreaStatsUseFilters}
|
||||
travelTimeEntries={activeEntries}
|
||||
shareCode={shareCode}
|
||||
isGroupExpanded={isAreaGroupExpanded}
|
||||
onToggleGroup={toggleAreaGroup}
|
||||
scrollTopRef={areaPaneScrollTopRef}
|
||||
scrollRestoreKey={
|
||||
selectedHexagon ? `${selectedHexagon.type}:${selectedHexagon.id}` : null
|
||||
}
|
||||
scrollSaveDisabled={loadingAreaStats && areaStats == null}
|
||||
/>
|
||||
</Suspense>
|
||||
),
|
||||
[
|
||||
activeEntries,
|
||||
areaStats,
|
||||
areaStatsUseFilters,
|
||||
features,
|
||||
filters,
|
||||
hexagonLocation,
|
||||
isAreaGroupExpanded,
|
||||
loadingAreaStats,
|
||||
selectedHexagon,
|
||||
setAreaStatsUseFilters,
|
||||
shareCode,
|
||||
toggleAreaGroup,
|
||||
unfilteredAreaCount,
|
||||
]
|
||||
);
|
||||
|
||||
const renderPropertiesPane = useCallback(
|
||||
() => (
|
||||
<Suspense fallback={<PaneFallback />}>
|
||||
<PropertiesPane
|
||||
properties={properties}
|
||||
total={propertiesTotal}
|
||||
loading={loadingProperties}
|
||||
hexagonId={selectedHexagon?.id || null}
|
||||
onLoadMore={handleLoadMoreProperties}
|
||||
scrollTopRef={propertiesPaneScrollTopRef}
|
||||
scrollRestoreKey={
|
||||
selectedHexagon ? `${selectedHexagon.type}:${selectedHexagon.id}` : null
|
||||
}
|
||||
scrollSaveDisabled={loadingProperties && properties.length === 0}
|
||||
/>
|
||||
</Suspense>
|
||||
),
|
||||
[handleLoadMoreProperties, loadingProperties, properties, propertiesTotal, selectedHexagon]
|
||||
);
|
||||
|
||||
const poiPane = useMemo(
|
||||
() => (
|
||||
<Suspense fallback={<PaneFallback />}>
|
||||
<POIPane
|
||||
groups={poiCategoryGroups}
|
||||
selectedCategories={selectedPOICategories}
|
||||
onCategoriesChange={setSelectedPOICategories}
|
||||
poiCount={pois.length}
|
||||
onClose={handleClosePoiPane}
|
||||
/>
|
||||
</Suspense>
|
||||
),
|
||||
[handleClosePoiPane, poiCategoryGroups, pois.length, selectedPOICategories]
|
||||
);
|
||||
|
||||
const overlayPane = useMemo(
|
||||
() => (
|
||||
<Suspense fallback={<PaneFallback />}>
|
||||
<OverlayPane
|
||||
selectedOverlays={activeOverlays}
|
||||
onOverlaysChange={setActiveOverlays}
|
||||
selectedCrimeTypes={crimeTypes}
|
||||
onCrimeTypesChange={setCrimeTypes}
|
||||
basemap={basemap}
|
||||
onBasemapChange={setBasemap}
|
||||
colorOpacity={colorOpacity}
|
||||
onColorOpacityChange={setColorOpacity}
|
||||
zoomedIn={overlaysZoomedIn}
|
||||
onClose={handleCloseOverlayPane}
|
||||
/>
|
||||
</Suspense>
|
||||
),
|
||||
[activeOverlays, basemap, colorOpacity, crimeTypes, handleCloseOverlayPane, overlaysZoomedIn]
|
||||
);
|
||||
|
||||
const filtersPane = useMemo(
|
||||
() => (
|
||||
<Suspense fallback={<PaneFallback />}>
|
||||
<Filters
|
||||
features={features}
|
||||
filters={filters}
|
||||
activeFeature={activeFeature}
|
||||
dragValue={dragValue}
|
||||
enabledFeatures={enabledFeatures}
|
||||
onAddFilter={handleAddFilter}
|
||||
onRemoveFilter={handleRemoveFilter}
|
||||
onFilterChange={handleFilterChange}
|
||||
onDragStart={handleDragStart}
|
||||
onDragChange={handleDragChange}
|
||||
onDragEnd={handleDragEnd}
|
||||
pinnedFeature={pinnedFeature}
|
||||
onTogglePin={handleTogglePin}
|
||||
openInfoFeature={pendingInfoFeature}
|
||||
onClearOpenInfoFeature={onClearPendingInfoFeature}
|
||||
travelTimeEntries={entries}
|
||||
onTravelTimeAddEntry={handleAddEntry}
|
||||
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
|
||||
onTravelTimeSetDestination={handleTravelTimeSetDestination}
|
||||
onTravelTimeRangeChange={handleTimeRangeChange}
|
||||
onTravelTimeDragEnd={handleTravelTimeDragEnd}
|
||||
onTravelTimeToggleBest={handleToggleBest}
|
||||
onTravelTimeToggleNoChange={handleToggleNoChange}
|
||||
onTravelTimeToggleNoBuses={handleToggleNoBuses}
|
||||
aiFilterLoading={aiFilterLoading}
|
||||
aiFilterError={aiFilterError}
|
||||
aiFilterErrorType={aiFilterErrorType}
|
||||
aiFilterNotes={aiFilterNotes}
|
||||
aiFilterSummary={aiFilterSummary}
|
||||
onAiFilterSubmit={handleAiFilterSubmit}
|
||||
isLoggedIn={!!user}
|
||||
onLoginRequired={onRegisterClick}
|
||||
isLicensed={user?.subscription === 'licensed'}
|
||||
onUpgradeClick={handleUpgradeClick}
|
||||
onResetTutorial={!isMobile ? tutorial.resetTutorial : undefined}
|
||||
filterImpacts={filterCounts.impacts}
|
||||
onClearAll={handleClearAll}
|
||||
onSaveSearch={onSaveSearch ? handleSaveSearch : undefined}
|
||||
savingSearch={savingSearch}
|
||||
editingSearchName={editingSearch?.name ?? null}
|
||||
onUpdateSearch={
|
||||
editingSearch && onUpdateEditInPlace ? handleUpdateEditInPlaceWithParams : undefined
|
||||
}
|
||||
onExitEditing={onCancelEdit}
|
||||
destinationDropdownPortal={isMobile ? false : undefined}
|
||||
/>
|
||||
</Suspense>
|
||||
),
|
||||
[
|
||||
activeFeature,
|
||||
aiFilterError,
|
||||
aiFilterErrorType,
|
||||
aiFilterLoading,
|
||||
aiFilterNotes,
|
||||
aiFilterSummary,
|
||||
dragValue,
|
||||
editingSearch,
|
||||
enabledFeatures,
|
||||
entries,
|
||||
features,
|
||||
filterCounts.impacts,
|
||||
filters,
|
||||
handleAddEntry,
|
||||
handleAddFilter,
|
||||
handleAiFilterSubmit,
|
||||
handleClearAll,
|
||||
handleDragChange,
|
||||
handleDragEnd,
|
||||
handleDragStart,
|
||||
handleFilterChange,
|
||||
handleRemoveFilter,
|
||||
handleSaveSearch,
|
||||
handleTimeRangeChange,
|
||||
handleToggleBest,
|
||||
handleToggleNoBuses,
|
||||
handleToggleNoChange,
|
||||
handleTogglePin,
|
||||
handleTravelTimeDragEnd,
|
||||
handleTravelTimeRemoveEntry,
|
||||
handleTravelTimeSetDestination,
|
||||
handleUpdateEditInPlaceWithParams,
|
||||
handleUpgradeClick,
|
||||
isMobile,
|
||||
onCancelEdit,
|
||||
onClearPendingInfoFeature,
|
||||
onRegisterClick,
|
||||
onSaveSearch,
|
||||
onUpdateEditInPlace,
|
||||
pendingInfoFeature,
|
||||
pinnedFeature,
|
||||
savingSearch,
|
||||
tutorial.resetTutorial,
|
||||
user,
|
||||
]
|
||||
);
|
||||
|
||||
const mobileLegend = useMemo(
|
||||
() => (
|
||||
<MobileMapLegend
|
||||
mapViewFeature={mapViewFeature}
|
||||
colorRange={mapData.colorRange}
|
||||
viewSource={viewSource}
|
||||
mobileLegendMeta={mobileLegendMeta}
|
||||
densityLabel={densityLabel}
|
||||
densityRange={mobileDensityRange}
|
||||
theme={theme}
|
||||
canResetPreviewScale={mapData.canResetPreviewScale}
|
||||
onCancelPin={handleCancelPin}
|
||||
onResetPreviewScale={mapData.handleResetPreviewScale}
|
||||
/>
|
||||
),
|
||||
[
|
||||
densityLabel,
|
||||
handleCancelPin,
|
||||
mapData.canResetPreviewScale,
|
||||
mapData.colorRange,
|
||||
mapData.handleResetPreviewScale,
|
||||
mapViewFeature,
|
||||
mobileDensityRange,
|
||||
mobileLegendMeta,
|
||||
theme,
|
||||
viewSource,
|
||||
]
|
||||
);
|
||||
|
||||
const toasts = useMemo(
|
||||
() => (
|
||||
<ExportToast
|
||||
notice={exportNotice}
|
||||
closeLabel={t('common.close')}
|
||||
onClose={clearExportNotice}
|
||||
/>
|
||||
),
|
||||
[clearExportNotice, exportNotice, t]
|
||||
);
|
||||
|
||||
if (screenshotMode) {
|
||||
return (
|
||||
<ScreenshotMapPage
|
||||
|
|
@ -706,147 +933,6 @@ export default function MapPage({
|
|||
);
|
||||
}
|
||||
|
||||
const renderAreaPane = () => (
|
||||
<Suspense fallback={<PaneFallback />}>
|
||||
<AreaPane
|
||||
stats={areaStats}
|
||||
globalFeatures={features}
|
||||
loading={loadingAreaStats}
|
||||
hexagonId={selectedHexagon?.id || null}
|
||||
isPostcode={selectedHexagon?.type === 'postcode'}
|
||||
hexagonLocation={hexagonLocation}
|
||||
filters={filters}
|
||||
unfilteredCount={unfilteredAreaCount}
|
||||
statsUseFilters={areaStatsUseFilters}
|
||||
onStatsUseFiltersChange={setAreaStatsUseFilters}
|
||||
travelTimeEntries={activeEntries}
|
||||
shareCode={shareCode}
|
||||
isGroupExpanded={isAreaGroupExpanded}
|
||||
onToggleGroup={toggleAreaGroup}
|
||||
scrollTopRef={areaPaneScrollTopRef}
|
||||
scrollRestoreKey={selectedHexagon ? `${selectedHexagon.type}:${selectedHexagon.id}` : null}
|
||||
scrollSaveDisabled={loadingAreaStats && areaStats == null}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
const renderPropertiesPane = () => (
|
||||
<Suspense fallback={<PaneFallback />}>
|
||||
<PropertiesPane
|
||||
properties={properties}
|
||||
total={propertiesTotal}
|
||||
loading={loadingProperties}
|
||||
hexagonId={selectedHexagon?.id || null}
|
||||
onLoadMore={handleLoadMoreProperties}
|
||||
scrollTopRef={propertiesPaneScrollTopRef}
|
||||
scrollRestoreKey={selectedHexagon ? `${selectedHexagon.type}:${selectedHexagon.id}` : null}
|
||||
scrollSaveDisabled={loadingProperties && properties.length === 0}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
const renderPOIPane = () => (
|
||||
<Suspense fallback={<PaneFallback />}>
|
||||
<POIPane
|
||||
groups={poiCategoryGroups}
|
||||
selectedCategories={selectedPOICategories}
|
||||
onCategoriesChange={setSelectedPOICategories}
|
||||
poiCount={pois.length}
|
||||
onClose={() => setPoiPaneOpen(false)}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
const renderOverlayPane = () => (
|
||||
<Suspense fallback={<PaneFallback />}>
|
||||
<OverlayPane
|
||||
selectedOverlays={activeOverlays}
|
||||
onOverlaysChange={setActiveOverlays}
|
||||
selectedCrimeTypes={crimeTypes}
|
||||
onCrimeTypesChange={setCrimeTypes}
|
||||
basemap={basemap}
|
||||
onBasemapChange={setBasemap}
|
||||
colorOpacity={colorOpacity}
|
||||
onColorOpacityChange={setColorOpacity}
|
||||
zoomedIn={overlaysZoomedIn}
|
||||
onClose={() => setOverlayPaneOpen(false)}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
const renderFilters = (options?: { destinationDropdownPortal?: boolean }) => (
|
||||
<Suspense fallback={<PaneFallback />}>
|
||||
<Filters
|
||||
features={features}
|
||||
filters={filters}
|
||||
activeFeature={activeFeature}
|
||||
dragValue={dragValue}
|
||||
enabledFeatures={enabledFeatures}
|
||||
onAddFilter={handleAddFilter}
|
||||
onRemoveFilter={handleRemoveFilter}
|
||||
onFilterChange={handleFilterChange}
|
||||
onDragStart={handleDragStart}
|
||||
onDragChange={handleDragChange}
|
||||
onDragEnd={handleDragEnd}
|
||||
pinnedFeature={pinnedFeature}
|
||||
onTogglePin={handleTogglePin}
|
||||
openInfoFeature={pendingInfoFeature}
|
||||
onClearOpenInfoFeature={onClearPendingInfoFeature}
|
||||
travelTimeEntries={entries}
|
||||
onTravelTimeAddEntry={handleAddEntry}
|
||||
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
|
||||
onTravelTimeSetDestination={handleTravelTimeSetDestination}
|
||||
onTravelTimeRangeChange={handleTimeRangeChange}
|
||||
onTravelTimeDragEnd={handleTravelTimeDragEnd}
|
||||
onTravelTimeToggleBest={handleToggleBest}
|
||||
onTravelTimeToggleNoChange={handleToggleNoChange}
|
||||
onTravelTimeToggleNoBuses={handleToggleNoBuses}
|
||||
aiFilterLoading={aiFilterLoading}
|
||||
aiFilterError={aiFilterError}
|
||||
aiFilterErrorType={aiFilterErrorType}
|
||||
aiFilterNotes={aiFilterNotes}
|
||||
aiFilterSummary={aiFilterSummary}
|
||||
onAiFilterSubmit={handleAiFilterSubmit}
|
||||
isLoggedIn={!!user}
|
||||
onLoginRequired={onRegisterClick}
|
||||
isLicensed={user?.subscription === 'licensed'}
|
||||
onUpgradeClick={() => onNavigateTo('pricing')}
|
||||
onResetTutorial={!isMobile ? tutorial.resetTutorial : undefined}
|
||||
filterImpacts={filterCounts.impacts}
|
||||
onClearAll={handleClearAll}
|
||||
onSaveSearch={onSaveSearch ? handleSaveSearch : undefined}
|
||||
savingSearch={savingSearch}
|
||||
editingSearchName={editingSearch?.name ?? null}
|
||||
onUpdateSearch={
|
||||
editingSearch && onUpdateEditInPlace ? handleUpdateEditInPlaceWithParams : undefined
|
||||
}
|
||||
onExitEditing={onCancelEdit}
|
||||
destinationDropdownPortal={options?.destinationDropdownPortal}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
const handleTogglePoiPane = () => {
|
||||
setOverlayPaneOpen(false);
|
||||
setPoiPaneOpen((open) => !open);
|
||||
};
|
||||
const handleToggleOverlayPane = () => {
|
||||
setPoiPaneOpen(false);
|
||||
setOverlayPaneOpen((open) => !open);
|
||||
};
|
||||
const handleMobileDrawerTabChange = (tab: 'area' | 'properties') => {
|
||||
if (tab === 'properties') {
|
||||
handlePropertiesTabClick();
|
||||
} else {
|
||||
setRightPaneTab(tab);
|
||||
}
|
||||
};
|
||||
|
||||
const exportToast = (
|
||||
<ExportToast notice={exportNotice} closeLabel={t('common.close')} onClose={clearExportNotice} />
|
||||
);
|
||||
const toasts = exportToast;
|
||||
|
||||
const editingBar =
|
||||
editingSearch && isMobile ? (
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700 bg-warm-50 dark:bg-navy-900">
|
||||
|
|
@ -940,25 +1026,12 @@ export default function MapPage({
|
|||
poiPaneOpen={poiPaneOpen}
|
||||
onTogglePoiPane={handleTogglePoiPane}
|
||||
poiButtonLabel={t('poiPane.pointsOfInterest')}
|
||||
poiPane={renderPOIPane()}
|
||||
poiPane={poiPane}
|
||||
overlayPaneOpen={overlayPaneOpen}
|
||||
onToggleOverlayPane={handleToggleOverlayPane}
|
||||
overlayPane={renderOverlayPane()}
|
||||
filtersPane={renderFilters({ destinationDropdownPortal: false })}
|
||||
mobileLegend={
|
||||
<MobileMapLegend
|
||||
mapViewFeature={mapViewFeature}
|
||||
colorRange={mapData.colorRange}
|
||||
viewSource={viewSource}
|
||||
mobileLegendMeta={mobileLegendMeta}
|
||||
densityLabel={densityLabel}
|
||||
densityRange={mobileDensityRange}
|
||||
theme={theme}
|
||||
canResetPreviewScale={mapData.canResetPreviewScale}
|
||||
onCancelPin={handleCancelPin}
|
||||
onResetPreviewScale={mapData.handleResetPreviewScale}
|
||||
/>
|
||||
}
|
||||
overlayPane={overlayPane}
|
||||
filtersPane={filtersPane}
|
||||
mobileLegend={mobileLegend}
|
||||
renderAreaPane={renderAreaPane}
|
||||
renderPropertiesPane={renderPropertiesPane}
|
||||
toasts={toasts}
|
||||
|
|
@ -975,7 +1048,7 @@ export default function MapPage({
|
|||
tutorialTheme={tutorialTheme}
|
||||
leftPaneWidth={leftPaneWidth}
|
||||
leftPaneHandlers={leftPaneHandlers}
|
||||
filtersPane={renderFilters()}
|
||||
filtersPane={filtersPane}
|
||||
mapData={mapData}
|
||||
pois={pois}
|
||||
activeOverlays={activeOverlays}
|
||||
|
|
@ -1008,15 +1081,15 @@ export default function MapPage({
|
|||
totalCount={filterCounts.total ?? undefined}
|
||||
poiPaneOpen={poiPaneOpen}
|
||||
onTogglePoiPane={handleTogglePoiPane}
|
||||
poiPane={renderPOIPane()}
|
||||
poiPane={poiPane}
|
||||
overlayPaneOpen={overlayPaneOpen}
|
||||
onToggleOverlayPane={handleToggleOverlayPane}
|
||||
overlayPane={renderOverlayPane()}
|
||||
overlayPane={overlayPane}
|
||||
showSelectionPane={!!selectedHexagon}
|
||||
rightPaneWidth={rightPaneWidth}
|
||||
rightPaneHandlers={rightPaneHandlers}
|
||||
rightPaneTab={rightPaneTab}
|
||||
onAreaTabClick={() => setRightPaneTab('area')}
|
||||
onAreaTabClick={handleAreaTabClick}
|
||||
onPropertiesTabClick={handlePropertiesTabClick}
|
||||
onCloseSelection={handleCloseSelection}
|
||||
renderAreaPane={renderAreaPane}
|
||||
|
|
|
|||
138
frontend/src/components/map/MapTopCards.tsx
Normal file
138
frontend/src/components/map/MapTopCards.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { FeatureMeta, MapFlyToOptions } from '../../types';
|
||||
import { useTranslatedModes } from '../../hooks/useTravelTime';
|
||||
import { ts } from '../../i18n/server';
|
||||
import LocationSearch, { type SearchedLocation } from './LocationSearch';
|
||||
import MapLegend from './MapLegend';
|
||||
|
||||
const DESKTOP_TOP_CARD_CLASS = 'w-[300px]';
|
||||
const DESKTOP_LOCATION_SEARCH_INPUT_CLASS =
|
||||
'px-2 py-2 text-sm w-full border-none outline-none bg-transparent text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500';
|
||||
|
||||
interface MapTopCardsProps {
|
||||
layoutClass: string;
|
||||
showLocationSearch: boolean;
|
||||
showLegend: boolean;
|
||||
onFlyTo: (lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void;
|
||||
onLocationSearched?: (location: SearchedLocation | null) => void;
|
||||
onCurrentLocationFound?: (lat: number, lng: number) => void;
|
||||
onLocationSearchMouseEnter: () => void;
|
||||
getViewportCenter: () => { lat: number; lng: number } | null;
|
||||
viewFeature: string | null;
|
||||
colorRange: [number, number] | null;
|
||||
viewSource: 'drag' | 'eye' | null;
|
||||
onCancelPin: () => void;
|
||||
onResetPreviewScale?: () => void;
|
||||
canResetPreviewScale: boolean;
|
||||
colorFeatureMeta: FeatureMeta | null;
|
||||
usePostcodeView: boolean;
|
||||
countRange: { min: number; max: number };
|
||||
postcodeCountRange: { min: number; max: number };
|
||||
densityLabel: string;
|
||||
totalCount?: number;
|
||||
theme: 'light' | 'dark';
|
||||
}
|
||||
|
||||
/** Desktop top-card overlay area: the location search box and the map legend. */
|
||||
export const MapTopCards = memo(function MapTopCards({
|
||||
layoutClass,
|
||||
showLocationSearch,
|
||||
showLegend,
|
||||
onFlyTo,
|
||||
onLocationSearched,
|
||||
onCurrentLocationFound,
|
||||
onLocationSearchMouseEnter,
|
||||
getViewportCenter,
|
||||
viewFeature,
|
||||
colorRange,
|
||||
viewSource,
|
||||
onCancelPin,
|
||||
onResetPreviewScale,
|
||||
canResetPreviewScale,
|
||||
colorFeatureMeta,
|
||||
usePostcodeView,
|
||||
countRange,
|
||||
postcodeCountRange,
|
||||
densityLabel,
|
||||
totalCount,
|
||||
theme,
|
||||
}: MapTopCardsProps) {
|
||||
const { t } = useTranslation();
|
||||
const modes = useTranslatedModes();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute top-3 left-3 right-3 z-20 flex gap-2 pointer-events-none ${layoutClass}`}
|
||||
>
|
||||
{showLocationSearch && (
|
||||
<LocationSearch
|
||||
onFlyTo={onFlyTo}
|
||||
onLocationSearched={onLocationSearched}
|
||||
onCurrentLocationFound={onCurrentLocationFound}
|
||||
onMouseEnter={onLocationSearchMouseEnter}
|
||||
getViewportCenter={getViewportCenter}
|
||||
className={DESKTOP_TOP_CARD_CLASS}
|
||||
inputClassName={DESKTOP_LOCATION_SEARCH_INPUT_CLASS}
|
||||
/>
|
||||
)}
|
||||
{showLegend &&
|
||||
(viewFeature && colorRange ? (
|
||||
viewFeature.startsWith('tt_') ? (
|
||||
<MapLegend
|
||||
featureLabel={t('travel.travelTime', {
|
||||
mode: modes.label(
|
||||
viewFeature.split('_')[1] as 'car' | 'bicycle' | 'walking' | 'transit'
|
||||
),
|
||||
})}
|
||||
range={colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={onCancelPin}
|
||||
onResetScale={viewSource === 'eye' ? onResetPreviewScale : undefined}
|
||||
resetScaleDisabled={!canResetPreviewScale}
|
||||
mode="feature"
|
||||
theme={theme}
|
||||
suffix=" min"
|
||||
className={DESKTOP_TOP_CARD_CLASS}
|
||||
/>
|
||||
) : colorFeatureMeta ? (
|
||||
<MapLegend
|
||||
featureLabel={
|
||||
viewSource === 'eye'
|
||||
? t('mapLegend.previewing', { name: ts(colorFeatureMeta.name) })
|
||||
: ts(colorFeatureMeta.name)
|
||||
}
|
||||
range={colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={onCancelPin}
|
||||
onResetScale={viewSource === 'eye' ? onResetPreviewScale : undefined}
|
||||
resetScaleDisabled={!canResetPreviewScale}
|
||||
mode="feature"
|
||||
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
|
||||
featureName={colorFeatureMeta.name}
|
||||
theme={theme}
|
||||
suffix={colorFeatureMeta.suffix}
|
||||
raw={colorFeatureMeta.raw}
|
||||
className={DESKTOP_TOP_CARD_CLASS}
|
||||
/>
|
||||
) : null
|
||||
) : (
|
||||
<MapLegend
|
||||
featureLabel={densityLabel}
|
||||
range={
|
||||
usePostcodeView
|
||||
? [postcodeCountRange.min, postcodeCountRange.max]
|
||||
: [countRange.min, countRange.max]
|
||||
}
|
||||
totalCount={totalCount}
|
||||
showCancel={false}
|
||||
onCancel={onCancelPin}
|
||||
mode="density"
|
||||
theme={theme}
|
||||
className={DESKTOP_TOP_CARD_CLASS}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
163
frontend/src/components/map/OverlayTileLayers.tsx
Normal file
163
frontend/src/components/map/OverlayTileLayers.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { Layer, Source } from 'react-map-gl/maplibre';
|
||||
|
||||
import { POSTCODE_ZOOM_THRESHOLD } from '../../lib/consts';
|
||||
import { type OverlayId, OVERLAY_MIN_ZOOM } from '../../lib/overlays';
|
||||
|
||||
function overlayTileUrl(path: string): string {
|
||||
return `${window.location.origin}/api/overlays/${path}/{z}/{x}/{y}`;
|
||||
}
|
||||
|
||||
export function OverlayTileLayers({
|
||||
activeOverlays,
|
||||
activeCrimeTypes,
|
||||
zoom,
|
||||
}: {
|
||||
activeOverlays: Set<OverlayId>;
|
||||
activeCrimeTypes: Set<string>;
|
||||
zoom: number;
|
||||
}) {
|
||||
if (zoom < POSTCODE_ZOOM_THRESHOLD || activeOverlays.size === 0) return null;
|
||||
|
||||
const showNoise = activeOverlays.has('noise');
|
||||
const showCrime = activeOverlays.has('crime-hotspots');
|
||||
const showTrees = activeOverlays.has('trees-outside-woodlands');
|
||||
const showPropertyBorders = activeOverlays.has('property-borders');
|
||||
|
||||
// Restrict the heatmap to the selected crime types. This must always be a
|
||||
// concrete expression: passing `filter={undefined}` makes react-map-gl call
|
||||
// map.addLayer({filter: undefined}), which MapLibre rejects at validation
|
||||
// ("filter: array expected, undefined found"), so the layer is never created
|
||||
// and the heatmap stays blank until a later setFilter call. An `in` over the
|
||||
// selected types matches everything when all 14 are selected.
|
||||
const crimeFilter = ['in', ['get', 'crime_type'], ['literal', Array.from(activeCrimeTypes)]];
|
||||
|
||||
return (
|
||||
<>
|
||||
{showNoise && (
|
||||
<Source
|
||||
id="overlay-noise-source"
|
||||
type="raster"
|
||||
tiles={[overlayTileUrl('noise')]}
|
||||
tileSize={256}
|
||||
minzoom={OVERLAY_MIN_ZOOM.noise}
|
||||
maxzoom={14}
|
||||
>
|
||||
<Layer
|
||||
id="overlay-noise"
|
||||
type="raster"
|
||||
minzoom={POSTCODE_ZOOM_THRESHOLD}
|
||||
paint={{
|
||||
'raster-opacity': 0.68,
|
||||
'raster-fade-duration': 120,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{showCrime && (
|
||||
<Source
|
||||
id="overlay-crime-source"
|
||||
type="vector"
|
||||
tiles={[overlayTileUrl('crime-hotspots')]}
|
||||
minzoom={OVERLAY_MIN_ZOOM['crime-hotspots']}
|
||||
maxzoom={15}
|
||||
>
|
||||
<Layer
|
||||
id="overlay-crime-heatmap"
|
||||
type="heatmap"
|
||||
source-layer="crime_hotspots"
|
||||
minzoom={POSTCODE_ZOOM_THRESHOLD}
|
||||
filter={crimeFilter as never}
|
||||
paint={
|
||||
{
|
||||
'heatmap-weight': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['coalesce', ['get', 'count'], ['get', 'weight'], 1],
|
||||
0,
|
||||
0,
|
||||
10,
|
||||
1,
|
||||
],
|
||||
'heatmap-intensity': ['interpolate', ['linear'], ['zoom'], 15, 0.8, 18, 2.2],
|
||||
'heatmap-radius': ['interpolate', ['linear'], ['zoom'], 15, 18, 18, 30],
|
||||
'heatmap-opacity': 0.72,
|
||||
'heatmap-color': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['heatmap-density'],
|
||||
0,
|
||||
'rgba(0, 0, 0, 0)',
|
||||
0.2,
|
||||
'rgb(253, 224, 71)',
|
||||
0.45,
|
||||
'rgb(249, 115, 22)',
|
||||
0.75,
|
||||
'rgb(220, 38, 38)',
|
||||
1,
|
||||
'rgb(127, 29, 29)',
|
||||
],
|
||||
} as never
|
||||
}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{showTrees && (
|
||||
<Source
|
||||
id="overlay-trees-source"
|
||||
type="vector"
|
||||
tiles={[overlayTileUrl('trees-outside-woodlands')]}
|
||||
minzoom={OVERLAY_MIN_ZOOM['trees-outside-woodlands']}
|
||||
maxzoom={16}
|
||||
>
|
||||
<Layer
|
||||
id="overlay-tree-polygons"
|
||||
type="fill"
|
||||
source-layer="trees_outside_woodlands"
|
||||
minzoom={POSTCODE_ZOOM_THRESHOLD}
|
||||
paint={
|
||||
{
|
||||
'fill-color': '#1f9d55',
|
||||
'fill-opacity': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['coalesce', ['get', 'area_sqm'], 0],
|
||||
0,
|
||||
0.28,
|
||||
250,
|
||||
0.62,
|
||||
],
|
||||
'fill-outline-color': 'rgba(15, 81, 50, 0.65)',
|
||||
} as never
|
||||
}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{showPropertyBorders && (
|
||||
<Source
|
||||
id="overlay-property-borders-source"
|
||||
type="vector"
|
||||
tiles={[overlayTileUrl('property-borders')]}
|
||||
minzoom={OVERLAY_MIN_ZOOM['property-borders']}
|
||||
maxzoom={16}
|
||||
>
|
||||
<Layer
|
||||
id="overlay-property-borders"
|
||||
type="line"
|
||||
source-layer="property_borders"
|
||||
minzoom={POSTCODE_ZOOM_THRESHOLD}
|
||||
paint={
|
||||
{
|
||||
'line-color': '#b45309',
|
||||
'line-opacity': ['interpolate', ['linear'], ['zoom'], 15, 0.35, 18, 0.85],
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 15, 0.4, 18, 1.4],
|
||||
} as never
|
||||
}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
188
frontend/src/components/map/PoiPopupCard.tsx
Normal file
188
frontend/src/components/map/PoiPopupCard.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import { memo } from 'react';
|
||||
|
||||
import type { SchoolMetadata } from '../../types';
|
||||
import { POI_GROUP_COLORS } from '../../lib/consts';
|
||||
import { getPoiIconUrl } from '../../lib/map-utils';
|
||||
import { ts } from '../../i18n/server';
|
||||
|
||||
export interface PoiPopupCardData {
|
||||
name: string;
|
||||
category: string;
|
||||
icon_category?: string;
|
||||
group: string;
|
||||
emoji: string;
|
||||
school?: SchoolMetadata;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Best-effort web URL from a free-text website field — GIAS stores some with
|
||||
* "http://", some without, and some as bare hostnames. */
|
||||
function normalizeSchoolWebsiteUrl(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||
if (/^[\w.-]+\.[a-z]{2,}/i.test(trimmed)) return `http://${trimmed}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderSchoolMetadata(school: SchoolMetadata) {
|
||||
// First line collects the headline classification (phase, type, religious
|
||||
// character) so the popup is scannable even when most fields are absent.
|
||||
const headline: string[] = [];
|
||||
if (school.phase) headline.push(school.phase);
|
||||
if (school.type) headline.push(school.type);
|
||||
|
||||
const pupilsLine =
|
||||
school.pupils !== undefined && school.capacity !== undefined
|
||||
? `${school.pupils.toLocaleString()} / ${school.capacity.toLocaleString()} pupils`
|
||||
: school.pupils !== undefined
|
||||
? `${school.pupils.toLocaleString()} pupils`
|
||||
: school.capacity !== undefined
|
||||
? `Capacity ${school.capacity.toLocaleString()}`
|
||||
: null;
|
||||
|
||||
const websiteUrl = school.website ? normalizeSchoolWebsiteUrl(school.website) : null;
|
||||
|
||||
return (
|
||||
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 text-xs text-warm-600 dark:text-warm-300">
|
||||
{headline.length > 0 && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Type</dt>
|
||||
<dd className="dark:text-warm-200">{headline.join(' · ')}</dd>
|
||||
</>
|
||||
)}
|
||||
{school.age_range && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Ages</dt>
|
||||
<dd className="dark:text-warm-200">{school.age_range}</dd>
|
||||
</>
|
||||
)}
|
||||
{school.gender && school.gender !== 'Mixed' && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Gender</dt>
|
||||
<dd className="dark:text-warm-200">{school.gender}</dd>
|
||||
</>
|
||||
)}
|
||||
{pupilsLine && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Pupils</dt>
|
||||
<dd className="dark:text-warm-200">{pupilsLine}</dd>
|
||||
</>
|
||||
)}
|
||||
{school.fsm_percent !== undefined && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Free meal</dt>
|
||||
<dd className="dark:text-warm-200">{school.fsm_percent.toFixed(1)}%</dd>
|
||||
</>
|
||||
)}
|
||||
{school.ofsted_rating && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Ofsted</dt>
|
||||
<dd className="dark:text-warm-200">{school.ofsted_rating}</dd>
|
||||
</>
|
||||
)}
|
||||
{school.sixth_form === 'Has a sixth form' && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Sixth form</dt>
|
||||
<dd className="dark:text-warm-200">Yes</dd>
|
||||
</>
|
||||
)}
|
||||
{school.religious_character &&
|
||||
school.religious_character !== 'Does not apply' &&
|
||||
school.religious_character !== 'None' && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Religion</dt>
|
||||
<dd className="dark:text-warm-200">{school.religious_character}</dd>
|
||||
</>
|
||||
)}
|
||||
{school.admissions_policy && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Admissions</dt>
|
||||
<dd className="dark:text-warm-200">{school.admissions_policy}</dd>
|
||||
</>
|
||||
)}
|
||||
{school.trust && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Trust</dt>
|
||||
<dd className="dark:text-warm-200">{school.trust}</dd>
|
||||
</>
|
||||
)}
|
||||
{(school.address || school.postcode) && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Address</dt>
|
||||
<dd className="dark:text-warm-200">
|
||||
{[school.address, school.postcode].filter(Boolean).join(', ')}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
{school.local_authority && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">LA</dt>
|
||||
<dd className="dark:text-warm-200">{school.local_authority}</dd>
|
||||
</>
|
||||
)}
|
||||
{school.head_name && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Head</dt>
|
||||
<dd className="dark:text-warm-200">{school.head_name}</dd>
|
||||
</>
|
||||
)}
|
||||
{websiteUrl && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Website</dt>
|
||||
<dd className="truncate">
|
||||
<a
|
||||
href={websiteUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="pointer-events-auto text-teal-600 hover:underline dark:text-teal-400"
|
||||
>
|
||||
{websiteUrl.replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
export const PoiPopupCardContent = memo(function PoiPopupCardContent({
|
||||
poi,
|
||||
}: {
|
||||
poi: PoiPopupCardData;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-3 py-2 max-w-[280px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={getPoiIconUrl(poi.category, poi.emoji, poi.icon_category, poi.name)}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-5 w-5 shrink-0 rounded-[4px] bg-white object-contain p-0.5"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold dark:text-warm-100">{poi.name}</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-warm-500 dark:text-warm-400">
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: `rgb(${getPoiGroupColor(poi.group).join(',')})`,
|
||||
}}
|
||||
/>
|
||||
{ts(poi.category)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{poi.school && renderSchoolMetadata(poi.school)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
131
frontend/src/components/map/map-page/useMobileDrawer.ts
Normal file
131
frontend/src/components/map/map-page/useMobileDrawer.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { useCallback, useRef, useState } from 'react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
|
||||
import type { MapFlyToOptions } from '../../../types';
|
||||
import type { MapFlyTo } from './types';
|
||||
|
||||
export interface PendingFlyTo {
|
||||
lat: number;
|
||||
lng: number;
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile drawer / bottom sheet state plus the fly-to plumbing that keeps a
|
||||
* selected target visible above them. Fly-tos requested while the drawer panel
|
||||
* hasn't measured itself yet are parked in refs and consumed once the panel
|
||||
* rect arrives, so the camera lands in the area the drawer leaves uncovered.
|
||||
*/
|
||||
export function useMobileDrawer(isMobile: boolean, flyToRef: MutableRefObject<MapFlyTo | null>) {
|
||||
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
||||
const [mobileBottomSheetHeight, setMobileBottomSheetHeight] = useState(0);
|
||||
const mobileDrawerPanelRectRef = useRef<DOMRectReadOnly | null>(null);
|
||||
const pendingCurrentLocationFlyToRef = useRef<{ lat: number; lng: number } | null>(null);
|
||||
const pendingLocationSearchFlyToRef = useRef<PendingFlyTo | null>(null);
|
||||
|
||||
const consumePendingLocationSearchFlyTo = useCallback(
|
||||
(rect?: DOMRectReadOnly | null) => {
|
||||
const pending = pendingLocationSearchFlyToRef.current;
|
||||
const panelRect = rect ?? mobileDrawerPanelRectRef.current;
|
||||
if (!pending || !panelRect) return;
|
||||
|
||||
const bottomInset = Math.max(0, window.innerHeight - panelRect.top);
|
||||
const flyTo = flyToRef.current;
|
||||
if (!flyTo) return;
|
||||
flyTo(pending.lat, pending.lng, pending.zoom, {
|
||||
visibleViewportArea: { bottom: bottomInset },
|
||||
});
|
||||
pendingLocationSearchFlyToRef.current = null;
|
||||
},
|
||||
[flyToRef]
|
||||
);
|
||||
|
||||
const consumePendingCurrentLocationFlyTo = useCallback(
|
||||
(rect?: DOMRectReadOnly | null) => {
|
||||
const pending = pendingCurrentLocationFlyToRef.current;
|
||||
const panelRect = rect ?? mobileDrawerPanelRectRef.current;
|
||||
if (!pending || !panelRect) return;
|
||||
|
||||
const bottomInset = Math.max(0, window.innerHeight - panelRect.top);
|
||||
const flyTo = flyToRef.current;
|
||||
if (!flyTo) return;
|
||||
flyTo(pending.lat, pending.lng, 17, {
|
||||
visibleViewportArea: { bottom: bottomInset },
|
||||
});
|
||||
pendingCurrentLocationFlyToRef.current = null;
|
||||
},
|
||||
[flyToRef]
|
||||
);
|
||||
|
||||
const openMobileDrawer = useCallback(() => {
|
||||
setMobileDrawerOpen(true);
|
||||
}, []);
|
||||
|
||||
/** Open the drawer and fly to the searched location once the panel rect is known. */
|
||||
const openMobileDrawerForLocationSearch = useCallback(
|
||||
(target: PendingFlyTo) => {
|
||||
pendingLocationSearchFlyToRef.current = target;
|
||||
setMobileDrawerOpen(true);
|
||||
consumePendingLocationSearchFlyTo();
|
||||
},
|
||||
[consumePendingLocationSearchFlyTo]
|
||||
);
|
||||
|
||||
const clearPendingLocationSearchFlyTo = useCallback(() => {
|
||||
pendingLocationSearchFlyToRef.current = null;
|
||||
}, []);
|
||||
|
||||
/** Park a current-location fly-to until the drawer panel has measured itself. */
|
||||
const queueCurrentLocationFlyTo = useCallback(
|
||||
(lat: number, lng: number) => {
|
||||
pendingCurrentLocationFlyToRef.current = { lat, lng };
|
||||
consumePendingCurrentLocationFlyTo();
|
||||
},
|
||||
[consumePendingCurrentLocationFlyTo]
|
||||
);
|
||||
|
||||
const handleMobileDrawerPanelRectChange = useCallback(
|
||||
(rect: DOMRectReadOnly) => {
|
||||
mobileDrawerPanelRectRef.current = rect;
|
||||
consumePendingCurrentLocationFlyTo(rect);
|
||||
consumePendingLocationSearchFlyTo(rect);
|
||||
},
|
||||
[consumePendingCurrentLocationFlyTo, consumePendingLocationSearchFlyTo]
|
||||
);
|
||||
|
||||
const handleMobileDrawerClose = useCallback(() => {
|
||||
pendingCurrentLocationFlyToRef.current = null;
|
||||
pendingLocationSearchFlyToRef.current = null;
|
||||
mobileDrawerPanelRectRef.current = null;
|
||||
setMobileDrawerOpen(false);
|
||||
}, []);
|
||||
|
||||
const getMobileMapFlyToOptions = useCallback((): MapFlyToOptions | undefined => {
|
||||
if (!isMobile) return undefined;
|
||||
|
||||
const panelRect = mobileDrawerPanelRectRef.current;
|
||||
if (mobileDrawerOpen && panelRect) {
|
||||
const bottomInset = Math.max(0, window.innerHeight - panelRect.top);
|
||||
if (bottomInset > 0) {
|
||||
return { visibleViewportArea: { bottom: bottomInset } };
|
||||
}
|
||||
}
|
||||
|
||||
return mobileBottomSheetHeight > 0
|
||||
? { visibleArea: { bottom: mobileBottomSheetHeight } }
|
||||
: undefined;
|
||||
}, [isMobile, mobileBottomSheetHeight, mobileDrawerOpen]);
|
||||
|
||||
return {
|
||||
mobileDrawerOpen,
|
||||
mobileBottomSheetHeight,
|
||||
setMobileBottomSheetHeight,
|
||||
openMobileDrawer,
|
||||
openMobileDrawerForLocationSearch,
|
||||
clearPendingLocationSearchFlyTo,
|
||||
queueCurrentLocationFlyTo,
|
||||
handleMobileDrawerPanelRectChange,
|
||||
handleMobileDrawerClose,
|
||||
getMobileMapFlyToOptions,
|
||||
};
|
||||
}
|
||||
|
|
@ -7,11 +7,11 @@ interface SubNavProps {
|
|||
export function SubNav({ tabs, activeTab, onTabChange }: SubNavProps) {
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto w-full px-6 pt-4">
|
||||
<div className="flex gap-2 border-b border-warm-200 dark:border-warm-700">
|
||||
<div className="flex gap-2 overflow-x-auto border-b border-warm-200 dark:border-warm-700">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`cursor-pointer px-4 py-2 text-sm font-medium border-b-2 ${
|
||||
className={`cursor-pointer shrink-0 whitespace-nowrap px-4 py-2 text-sm font-medium border-b-2 ${
|
||||
activeTab === tab.key
|
||||
? 'border-teal-500 text-teal-700 dark:text-teal-400'
|
||||
: 'border-transparent text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
|
||||
|
|
|
|||
43
frontend/src/hooks/useMapCardLayout.ts
Normal file
43
frontend/src/hooks/useMapCardLayout.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
const DESKTOP_TOP_CARD_WIDTH = 300;
|
||||
const DESKTOP_TOP_CARD_GAP = 8;
|
||||
const DESKTOP_TOP_CARD_HORIZONTAL_INSET = 24;
|
||||
const DESKTOP_TOP_CARDS_STACKED_MIN_MAP_WIDTH =
|
||||
DESKTOP_TOP_CARD_WIDTH + DESKTOP_TOP_CARD_HORIZONTAL_INSET;
|
||||
const DESKTOP_TOP_CARDS_ROW_MIN_MAP_WIDTH =
|
||||
DESKTOP_TOP_CARD_WIDTH * 2 + DESKTOP_TOP_CARD_GAP + DESKTOP_TOP_CARD_HORIZONTAL_INSET;
|
||||
|
||||
interface UseMapCardLayoutOptions {
|
||||
mapWidth: number;
|
||||
hideTopCardsWhenNarrow: boolean;
|
||||
hideLegend: boolean;
|
||||
hideLocationSearch: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop top-card layout for the map overlay area: hides the cards entirely
|
||||
* when the map is too narrow for a single card, and stacks them vertically
|
||||
* when there is room for one card but not for two side by side.
|
||||
*/
|
||||
export function useMapCardLayout({
|
||||
mapWidth,
|
||||
hideTopCardsWhenNarrow,
|
||||
hideLegend,
|
||||
hideLocationSearch,
|
||||
}: UseMapCardLayoutOptions) {
|
||||
return useMemo(() => {
|
||||
const hideTopCardsForWidth =
|
||||
hideTopCardsWhenNarrow && mapWidth > 0 && mapWidth < DESKTOP_TOP_CARDS_STACKED_MIN_MAP_WIDTH;
|
||||
const stackTopCards =
|
||||
hideTopCardsWhenNarrow &&
|
||||
mapWidth >= DESKTOP_TOP_CARDS_STACKED_MIN_MAP_WIDTH &&
|
||||
mapWidth < DESKTOP_TOP_CARDS_ROW_MIN_MAP_WIDTH;
|
||||
|
||||
return {
|
||||
showLocationSearch: !hideLocationSearch && !hideTopCardsForWidth,
|
||||
showLegend: !hideLegend && !hideTopCardsForWidth,
|
||||
topCardsLayoutClass: stackTopCards ? 'flex-col items-start' : 'items-start justify-between',
|
||||
};
|
||||
}, [mapWidth, hideTopCardsWhenNarrow, hideLegend, hideLocationSearch]);
|
||||
}
|
||||
|
|
@ -880,6 +880,7 @@ const de: Translations = {
|
|||
walk: 'Zu Fuß',
|
||||
cycle: 'Fahrrad',
|
||||
nationalAvg: 'England-Schnitt',
|
||||
crimeDataEnds: 'Polizeidaten für dieses Gebiet enden {{year}}',
|
||||
},
|
||||
|
||||
// ── Street View ────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -864,6 +864,7 @@ const en = {
|
|||
walk: 'Walk',
|
||||
cycle: 'Cycle',
|
||||
nationalAvg: 'National avg',
|
||||
crimeDataEnds: 'Police data for this area ends {{year}}',
|
||||
},
|
||||
|
||||
// ── Street View ────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -893,6 +893,7 @@ const fr: Translations = {
|
|||
walk: 'Marche',
|
||||
cycle: 'Vélo',
|
||||
nationalAvg: 'Moyenne nationale',
|
||||
crimeDataEnds: 'Les données de police pour cette zone s\'arrêtent en {{year}}',
|
||||
},
|
||||
|
||||
// ── Street View ────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -852,6 +852,7 @@ const hi: Translations = {
|
|||
walk: 'पैदल',
|
||||
cycle: 'साइकिल',
|
||||
nationalAvg: 'राष्ट्रीय औसत',
|
||||
crimeDataEnds: 'इस क्षेत्र के लिए पुलिस डेटा {{year}} में समाप्त होता है',
|
||||
},
|
||||
|
||||
streetView: {
|
||||
|
|
|
|||
|
|
@ -881,6 +881,7 @@ const hu: Translations = {
|
|||
walk: 'Gyalog',
|
||||
cycle: 'Kerékpár',
|
||||
nationalAvg: 'Országos átlag',
|
||||
crimeDataEnds: 'A körzet rendőrségi adatai {{year}}-ig érhetők el',
|
||||
},
|
||||
|
||||
// ── Street View ────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -823,6 +823,7 @@ const zh: Translations = {
|
|||
walk: '步行',
|
||||
cycle: '骑行',
|
||||
nationalAvg: '全国平均',
|
||||
crimeDataEnds: '该地区的警方数据截至{{year}}年',
|
||||
},
|
||||
|
||||
// ── Street View ────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -303,6 +303,12 @@ export interface HexagonStatsResponse {
|
|||
price_history?: PricePoint[];
|
||||
/** Per-crime-type per-year counts averaged across the selection. */
|
||||
crime_by_year?: CrimeYearStats[];
|
||||
/**
|
||||
* Latest year in the crime dataset as a whole. A selection whose series end
|
||||
* earlier sits in a force-level publication gap (e.g. Greater Manchester
|
||||
* since mid-2019) and its crime figures are captioned as stale.
|
||||
*/
|
||||
crime_latest_year?: number;
|
||||
central_postcode?: string;
|
||||
filter_exclusions?: FilterExclusion[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue