has issues

This commit is contained in:
Andras Schmelczer 2026-05-25 13:20:17 +01:00
parent 2e112d7398
commit c645b0f1d4
96 changed files with 2147083 additions and 5787 deletions

View file

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