SPlit up
Some checks failed
Build and publish Docker image / build-and-push (push) Failing after 15s
CI / Check (push) Failing after 1m58s

This commit is contained in:
Andras Schmelczer 2026-06-12 21:51:37 +01:00
parent cf39ad754e
commit f59d01227b
91 changed files with 10370 additions and 7562 deletions

View file

@ -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 ? (

View file

@ -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>
);
}

View 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;
}

View 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}
/>
);
});

View 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>
);
});

View file

@ -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}
/>

View file

@ -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}

View 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>
);
});

View 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>
)}
</>
);
}

View 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>
);
});

View 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,
};
}

View file

@ -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'

View 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]);
}

View file

@ -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 ────────────────────────────────────

View file

@ -864,6 +864,7 @@ const en = {
walk: 'Walk',
cycle: 'Cycle',
nationalAvg: 'National avg',
crimeDataEnds: 'Police data for this area ends {{year}}',
},
// ── Street View ────────────────────────────────────

View file

@ -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 ────────────────────────────────────

View file

@ -852,6 +852,7 @@ const hi: Translations = {
walk: 'पैदल',
cycle: 'साइकिल',
nationalAvg: 'राष्ट्रीय औसत',
crimeDataEnds: 'इस क्षेत्र के लिए पुलिस डेटा {{year}} में समाप्त होता है',
},
streetView: {

View file

@ -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 ────────────────────────────────────

View file

@ -823,6 +823,7 @@ const zh: Translations = {
walk: '步行',
cycle: '骑行',
nationalAvg: '全国平均',
crimeDataEnds: '该地区的警方数据截至{{year}}年',
},
// ── Street View ────────────────────────────────────

View file

@ -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[];
}