has issues
This commit is contained in:
parent
2e112d7398
commit
c645b0f1d4
96 changed files with 2147083 additions and 5787 deletions
|
|
@ -2,7 +2,7 @@ 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 { Map as MapGL, useControl, ScaleControl } from 'react-map-gl/maplibre';
|
||||
import { Layer, Map as MapGL, Source, useControl, ScaleControl } from 'react-map-gl/maplibre';
|
||||
import type { MapRef } from 'react-map-gl/maplibre';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
|
|
@ -17,6 +17,7 @@ import type {
|
|||
Bounds,
|
||||
MapFlyToOptions,
|
||||
ActualListing,
|
||||
SchoolMetadata,
|
||||
} from '../../types';
|
||||
|
||||
import {
|
||||
|
|
@ -27,7 +28,12 @@ import {
|
|||
getPoiIconUrl,
|
||||
getMapCenterForTargetScreenPoint,
|
||||
} from '../../lib/map-utils';
|
||||
import { MAP_MIN_ZOOM, MAP_BOUNDS, POI_GROUP_COLORS } from '../../lib/consts';
|
||||
import {
|
||||
MAP_MIN_ZOOM,
|
||||
MAP_BOUNDS,
|
||||
POI_GROUP_COLORS,
|
||||
POSTCODE_ZOOM_THRESHOLD,
|
||||
} from '../../lib/consts';
|
||||
import LocationSearch, { type SearchedLocation } from './LocationSearch';
|
||||
import MapLegend from './MapLegend';
|
||||
import HoverCard from './HoverCard';
|
||||
|
|
@ -37,12 +43,14 @@ 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 } from '../../lib/overlays';
|
||||
|
||||
interface MapProps {
|
||||
data: HexagonData[];
|
||||
postcodeData: PostcodeFeature[];
|
||||
usePostcodeView: boolean;
|
||||
pois: POI[];
|
||||
activeOverlays?: Set<OverlayId>;
|
||||
actualListings?: ActualListing[];
|
||||
onViewChange: (params: ViewChangeParams) => void;
|
||||
viewFeature: string | null;
|
||||
|
|
@ -81,6 +89,7 @@ interface MapProps {
|
|||
|
||||
const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = [];
|
||||
const EMPTY_ACTUAL_LISTINGS: ActualListing[] = [];
|
||||
const EMPTY_OVERLAYS = new Set<OverlayId>();
|
||||
|
||||
function formatListingPrice(price: number): string {
|
||||
return `£${price.toLocaleString()}`;
|
||||
|
|
@ -224,6 +233,131 @@ function getPoiGroupColor(group: string): [number, number, number] {
|
|||
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">FSM</dt>
|
||||
<dd className="dark:text-warm-200">{school.fsm_percent.toFixed(1)}%</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 getRenderedViewState(map: MapRef | null): ViewState | null {
|
||||
if (!map) return null;
|
||||
|
||||
|
|
@ -275,11 +409,132 @@ function DeckOverlay({
|
|||
return null;
|
||||
}
|
||||
|
||||
function overlayTileUrl(path: string): string {
|
||||
return `${window.location.origin}/api/overlays/${path}/{z}/{x}/{y}`;
|
||||
}
|
||||
|
||||
function OverlayTileLayers({
|
||||
activeOverlays,
|
||||
zoom,
|
||||
}: {
|
||||
activeOverlays: Set<OverlayId>;
|
||||
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');
|
||||
|
||||
return (
|
||||
<>
|
||||
{showNoise && (
|
||||
<Source
|
||||
id="overlay-noise-source"
|
||||
type="raster"
|
||||
tiles={[overlayTileUrl('noise')]}
|
||||
tileSize={256}
|
||||
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')]}
|
||||
maxzoom={15}
|
||||
>
|
||||
<Layer
|
||||
id="overlay-crime-heatmap"
|
||||
type="heatmap"
|
||||
source-layer="crime_hotspots"
|
||||
minzoom={POSTCODE_ZOOM_THRESHOLD}
|
||||
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')]}
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(function Map({
|
||||
data,
|
||||
postcodeData,
|
||||
usePostcodeView,
|
||||
pois,
|
||||
activeOverlays = EMPTY_OVERLAYS,
|
||||
actualListings = EMPTY_ACTUAL_LISTINGS,
|
||||
onViewChange,
|
||||
viewFeature,
|
||||
|
|
@ -514,6 +769,7 @@ export default memo(function Map({
|
|||
maxBounds={maxBounds}
|
||||
>
|
||||
<DeckOverlay layers={layers} getTooltip={null} />
|
||||
<OverlayTileLayers activeOverlays={activeOverlays} zoom={viewState.zoom} />
|
||||
{!screenshotMode && <ScaleControl position="bottom-left" maxWidth={100} unit="metric" />}
|
||||
</MapGL>
|
||||
{screenshotMode ? (
|
||||
|
|
@ -666,7 +922,7 @@ export default memo(function Map({
|
|||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 py-2">
|
||||
<div className="px-3 py-2 max-w-[280px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={getPoiIconUrl(
|
||||
|
|
@ -694,6 +950,7 @@ export default memo(function Map({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{popupInfo.school && renderSchoolMetadata(popupInfo.school)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue