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

@ -32,6 +32,7 @@ import EnumBarChart from './EnumBarChart';
import StackedBarChart from './StackedBarChart';
import StackedEnumChart from './StackedEnumChart';
import PriceHistoryChart from './PriceHistoryChart';
import CrimeYearChart from './CrimeYearChart';
import ExternalSearchLinks from './ExternalSearchLinks';
import { InfoIcon, TransitIcon } from '../ui/icons';
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
@ -264,6 +265,18 @@ export default function AreaPane({
return new Map(stats.enum_features.map((feature) => [feature.name, feature]));
}, [stats]);
// Crime-by-year series is keyed in the API by the bare crime type (e.g. "Burglary").
// We also index by the configured feature name (with " (avg/yr)" suffix) so the
// metric-row renderer can look it up using the feature name it already has.
const crimeByYearByFeatureName = useMemo(() => {
const map = new Map<string, NonNullable<HexagonStatsResponse['crime_by_year']>[number]>();
for (const entry of stats?.crime_by_year ?? []) {
map.set(entry.name, entry);
map.set(`${entry.name} (avg/yr)`, entry);
}
return map;
}, [stats]);
const globalFeatureByName = useMemo(
() => new Map(globalFeatures.map((f) => [f.name, f])),
[globalFeatures]
@ -599,6 +612,7 @@ export default function AreaPane({
const globalMean = globalHistogram
? calculateHistogramMean(globalHistogram)
: undefined;
const crimeSeries = crimeByYearByFeatureName.get(feature.name);
return (
<MetricRow
@ -611,7 +625,9 @@ export default function AreaPane({
/>
}
chart={
numericStats.histogram &&
crimeSeries && crimeSeries.points.length > 1 ? (
<CrimeYearChart points={crimeSeries.points} />
) : (numericStats.histogram &&
(globalHistogram ? (
<DualHistogram
localCounts={numericStats.histogram.counts}
@ -648,7 +664,7 @@ export default function AreaPane({
integerAxisLabels={feature.step === 1}
compact
/>
))
)))
}
value={formatValue(numericStats.mean, feature)}
valueTitle={

View file

@ -0,0 +1,102 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { CrimeYearPoint } from '../../types';
interface CrimeYearChartProps {
points: CrimeYearPoint[];
}
const PADDING = { top: 6, right: 4, bottom: 14, left: 4 };
const HEIGHT = 48;
export default function CrimeYearChart({ points }: CrimeYearChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const w = entries[0].contentRect.width;
if (w > 0) setWidth(w);
});
observer.observe(el);
return () => observer.disconnect();
}, []);
const { yearMin, yearMax, max } = useMemo(() => {
let yMin = Infinity;
let yMax = -Infinity;
let m = 0;
for (const p of points) {
if (p.year < yMin) yMin = p.year;
if (p.year > yMax) yMax = p.year;
if (p.count > m) m = p.count;
}
return { yearMin: yMin, yearMax: yMax, max: m };
}, [points]);
if (points.length === 0) {
return null;
}
const plotW = Math.max(0, width - PADDING.left - PADDING.right);
const plotH = HEIGHT - PADDING.top - PADDING.bottom;
const yearRange = Math.max(1, yearMax - yearMin);
const scaleMax = max > 0 ? max : 1;
const scaleX = (year: number) => PADDING.left + ((year - yearMin) / yearRange) * plotW;
const scaleY = (count: number) => PADDING.top + (1 - count / scaleMax) * plotH;
const baseline = PADDING.top + plotH;
const lineFill = points
.map((p, i) => `${i === 0 ? 'M' : 'L'}${scaleX(p.year)},${scaleY(p.count)}`)
.join(' ');
const areaFill = `${lineFill} L${scaleX(yearMax)},${baseline} L${scaleX(yearMin)},${baseline} Z`;
return (
<div ref={containerRef} className="w-full">
{width > 0 && (
<svg width={width} height={HEIGHT} className="block">
<path d={areaFill} className="fill-rose-400/30 dark:fill-rose-500/25" />
<path
d={lineFill}
fill="none"
strokeWidth={1.5}
strokeLinejoin="round"
strokeLinecap="round"
className="stroke-rose-600 dark:stroke-rose-400"
/>
{points.map((p) => (
<circle
key={p.year}
cx={scaleX(p.year)}
cy={scaleY(p.count)}
r={1.6}
className="fill-rose-700 dark:fill-rose-300"
>
<title>{`${p.year}: ${p.count.toFixed(1)}/yr`}</title>
</circle>
))}
<text
x={scaleX(yearMin)}
y={HEIGHT - 2}
textAnchor="start"
fontSize={9}
className="fill-warm-500 dark:fill-warm-400"
>
{yearMin}
</text>
<text
x={scaleX(yearMax)}
y={HEIGHT - 2}
textAnchor="end"
fontSize={9}
className="fill-warm-500 dark:fill-warm-400"
>
{yearMax}
</text>
</svg>
)}
</div>
);
}

View file

@ -91,6 +91,8 @@ interface FiltersProps {
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
onTravelTimeDragEnd: (index: number) => void;
onTravelTimeToggleBest: (index: number) => void;
onTravelTimeToggleNoChange: (index: number) => void;
onTravelTimeToggleNoBuses: (index: number) => void;
aiFilterLoading: boolean;
aiFilterError: string | null;
aiFilterErrorType: AiFilterErrorType | null;
@ -136,6 +138,8 @@ export default memo(function Filters({
onTravelTimeRangeChange,
onTravelTimeDragEnd,
onTravelTimeToggleBest,
onTravelTimeToggleNoChange,
onTravelTimeToggleNoBuses,
aiFilterLoading,
aiFilterError,
aiFilterErrorType,
@ -662,6 +666,8 @@ export default memo(function Filters({
onTravelTimeRangeChange={onTravelTimeRangeChange}
onTravelTimeDragEnd={onTravelTimeDragEnd}
onTravelTimeToggleBest={onTravelTimeToggleBest}
onTravelTimeToggleNoChange={onTravelTimeToggleNoChange}
onTravelTimeToggleNoBuses={onTravelTimeToggleNoBuses}
/>
<AddFilterPanel

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>

View file

@ -15,16 +15,28 @@ import { useAiFilters } from '../../hooks/useAiFilters';
import { useUrlSync } from '../../hooks/useUrlSync';
import { useTutorial } from '../../hooks/useTutorial';
import { getTutorialStyles } from '../../lib/tutorial-styles';
import { travelFieldKey, useTravelTime } from '../../hooks/useTravelTime';
import {
MAX_TRAVEL_MINUTES,
parseServerMode,
resolveTransitVariant,
travelFieldKey,
useTravelTime,
} from '../../hooks/useTravelTime';
import { apiUrl, authHeaders, buildFilterString } from '../../lib/api';
import { useFilterCounts } from '../../hooks/useFilterCounts';
import { trackEvent } from '../../lib/analytics';
import { INITIAL_VIEW_STATE, POSTCODE_SEARCH_ZOOM } from '../../lib/consts';
import {
INITIAL_VIEW_STATE,
POSTCODE_SEARCH_ZOOM,
POSTCODE_ZOOM_THRESHOLD,
} from '../../lib/consts';
import type { OverlayId } from '../../lib/overlays';
import { useLicense } from '../../hooks/useLicense';
import { stateToParams } from '../../lib/url-state';
import {
AreaPane,
Filters,
OverlayPane,
POIPane,
PropertiesPane,
UpgradeModal,
@ -62,6 +74,7 @@ export default function MapPage({
initialFilters,
initialViewState,
initialPOICategories,
initialOverlays,
initialTab,
initialLoading,
theme,
@ -92,11 +105,15 @@ export default function MapPage({
const { t } = useTranslation();
const [selectedPOICategories, setSelectedPOICategories] =
useState<Set<string>>(initialPOICategories);
const [activeOverlays, setActiveOverlays] = useState<Set<OverlayId>>(
() => new Set(initialOverlays ?? [])
);
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);
const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null);
const {
@ -141,6 +158,8 @@ export default function MapPage({
handleSetEntries,
handleTimeRangeChange,
handleToggleBest,
handleToggleNoChange,
handleToggleNoBuses,
} = useTravelTime(initialTravelTime);
const mapFlyToRef = useRef<MapFlyTo | null>(null);
@ -181,8 +200,10 @@ export default function MapPage({
async (query: string) => {
const context = {
filters,
// Send the resolved variant so the AI sees the actual server mode in use,
// not just the UI-side base mode. Lets the model refine no-change/no-buses.
travelTime: activeEntries.map((entry) => ({
mode: entry.mode,
mode: resolveTransitVariant(entry),
label: entry.label,
min: entry.timeRange?.[0],
max: entry.timeRange?.[1],
@ -194,17 +215,29 @@ export default function MapPage({
if (!result) return;
handleSetFilters(result.filters);
// Filter out variants the UI cannot represent (e.g. transit-one-change*)
// FIRST so the same filtered list drives both entry state and fly-to.
// Otherwise we'd fly to a destination for a mode the user can't see.
const representable = result.travelTimeFilters
.map((tt) => ({ tt, parsed: parseServerMode(tt.mode) }))
.filter((x): x is { tt: typeof x.tt; parsed: NonNullable<typeof x.parsed> } => !!x.parsed);
handleSetEntries(
result.travelTimeFilters.map((travelTimeFilter) => ({
mode: travelTimeFilter.mode,
slug: travelTimeFilter.slug,
label: travelTimeFilter.label,
timeRange: [travelTimeFilter.min ?? 0, travelTimeFilter.max ?? 120] as [number, number],
representable.map(({ tt, parsed }) => ({
mode: parsed.mode,
noChange: parsed.noChange,
noBuses: parsed.noBuses,
slug: tt.slug,
label: tt.label,
timeRange: [
tt.min ?? 0,
Math.min(tt.max ?? MAX_TRAVEL_MINUTES, MAX_TRAVEL_MINUTES),
] as [number, number],
useBest: false,
}))
);
const firstTravelTime = result.travelTimeFilters[0];
const firstTravelTime = representable[0]?.tt;
if (!firstTravelTime?.slug) return;
try {
@ -411,6 +444,7 @@ export default function MapPage({
}, []);
const pois = usePOIData(mapData.bounds, selectedPOICategories);
const overlaysZoomedIn = (mapData.currentView?.zoom ?? 0) >= POSTCODE_ZOOM_THRESHOLD;
const actualListingsFilterParam = useMemo(
() => buildFilterString(filters, features),
[filters, features]
@ -430,7 +464,8 @@ export default function MapPage({
selectedPOICategories,
rightPaneTab,
entries,
shareCode
shareCode,
activeOverlays
);
useInitialMapPageView(mapData, initialViewState, initialTab, setRightPaneTab);
@ -485,6 +520,7 @@ export default function MapPage({
filters,
features,
travelTimeEntries: entries,
selectedOverlays: activeOverlays,
shareCode,
t,
onExportStateChange,
@ -502,9 +538,19 @@ export default function MapPage({
selectedPOICategories,
rightPaneTab,
entries,
shareCode
shareCode,
activeOverlays
).toString(),
[entries, features, filters, rightPaneTab, selectedPOICategories, shareCode, shareAndSaveView]
[
activeOverlays,
entries,
features,
filters,
rightPaneTab,
selectedPOICategories,
shareCode,
shareAndSaveView,
]
);
const handleSaveSearch = useCallback(
async (name: string) => {
@ -540,6 +586,7 @@ export default function MapPage({
theme={theme}
ogMode={ogMode}
travelTimeEntries={entries}
activeOverlays={activeOverlays}
/>
);
}
@ -595,6 +642,17 @@ export default function MapPage({
</Suspense>
);
const renderOverlayPane = () => (
<Suspense fallback={<PaneFallback />}>
<OverlayPane
selectedOverlays={activeOverlays}
onOverlaysChange={setActiveOverlays}
zoomedIn={overlaysZoomedIn}
onClose={() => setOverlayPaneOpen(false)}
/>
</Suspense>
);
const renderFilters = (options?: { destinationDropdownPortal?: boolean }) => (
<Suspense fallback={<PaneFallback />}>
<Filters
@ -620,6 +678,8 @@ export default function MapPage({
onTravelTimeRangeChange={handleTimeRangeChange}
onTravelTimeDragEnd={handleTravelTimeDragEnd}
onTravelTimeToggleBest={handleToggleBest}
onTravelTimeToggleNoChange={handleToggleNoChange}
onTravelTimeToggleNoBuses={handleToggleNoBuses}
aiFilterLoading={aiFilterLoading}
aiFilterError={aiFilterError}
aiFilterErrorType={aiFilterErrorType}
@ -645,7 +705,14 @@ export default function MapPage({
</Suspense>
);
const handleTogglePoiPane = () => setPoiPaneOpen((open) => !open);
const handleTogglePoiPane = () => {
setOverlayPaneOpen(false);
setPoiPaneOpen((open) => !open);
};
const handleToggleOverlayPane = () => {
setPoiPaneOpen(false);
setOverlayPaneOpen((open) => !open);
};
const handleMobileDrawerTabChange = (tab: 'area' | 'properties') => {
if (tab === 'properties') {
handlePropertiesTabClick();
@ -713,6 +780,7 @@ export default function MapPage({
initialLoading={initialLoading}
mapData={mapData}
pois={pois}
activeOverlays={activeOverlays}
mapViewFeature={mapViewFeature}
filterRange={filterRange}
viewSource={viewSource}
@ -743,6 +811,9 @@ export default function MapPage({
onTogglePoiPane={handleTogglePoiPane}
poiButtonLabel={t('poiPane.pointsOfInterest')}
poiPane={renderPOIPane()}
overlayPaneOpen={overlayPaneOpen}
onToggleOverlayPane={handleToggleOverlayPane}
overlayPane={renderOverlayPane()}
filtersPane={renderFilters({ destinationDropdownPortal: false })}
mobileLegend={
<MobileMapLegend
@ -777,6 +848,7 @@ export default function MapPage({
filtersPane={renderFilters()}
mapData={mapData}
pois={pois}
activeOverlays={activeOverlays}
mapViewFeature={mapViewFeature}
filterRange={filterRange}
viewSource={viewSource}
@ -801,6 +873,9 @@ export default function MapPage({
poiPaneOpen={poiPaneOpen}
onTogglePoiPane={handleTogglePoiPane}
poiPane={renderPOIPane()}
overlayPaneOpen={overlayPaneOpen}
onToggleOverlayPane={handleToggleOverlayPane}
overlayPane={renderOverlayPane()}
showSelectionPane={!!selectedHexagon}
rightPaneWidth={rightPaneWidth}
rightPaneHandlers={rightPaneHandlers}

View file

@ -0,0 +1,81 @@
import { OVERLAYS, type OverlayId } from '../../lib/overlays';
import { PillGroup } from '../ui/PillGroup';
import { PillToggle } from '../ui/PillToggle';
import { CloseIcon } from '../ui/icons';
interface OverlayPaneProps {
selectedOverlays: Set<OverlayId>;
onOverlaysChange: (overlays: Set<OverlayId>) => void;
zoomedIn: boolean;
onClose?: () => void;
}
export default function OverlayPane({
selectedOverlays,
onOverlaysChange,
zoomedIn,
onClose,
}: OverlayPaneProps) {
const toggleOverlay = (overlay: OverlayId) => {
const next = new Set(selectedOverlays);
if (next.has(overlay)) {
next.delete(overlay);
} else {
next.add(overlay);
}
onOverlaysChange(next);
};
const selectNone = () => onOverlaysChange(new Set());
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-white shadow-lg dark:bg-warm-900">
<div className="flex-shrink-0 px-3 pt-3 pb-2">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide">
Overlays
</span>
<span className="text-xs text-warm-400 dark:text-warm-500">
{selectedOverlays.size}/{OVERLAYS.length}
</span>
<div className="ml-auto flex items-center gap-1">
<button
onClick={selectNone}
className="rounded border border-warm-300 px-2 py-0.5 text-xs text-warm-600 hover:bg-warm-50 dark:border-warm-700 dark:text-warm-400 dark:hover:bg-warm-700"
>
None
</button>
{onClose && (
<button
onClick={onClose}
className="ml-1 p-0.5 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
title="Close"
>
<CloseIcon className="h-4 w-4" />
</button>
)}
</div>
</div>
{!zoomedIn && (
<div className="mt-2 rounded border border-warm-200 bg-warm-50 px-2 py-1.5 text-xs text-warm-500 dark:border-warm-700 dark:bg-navy-950 dark:text-warm-400">
Zoom in to view overlays.
</div>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain border-t border-warm-200 px-3 py-3 dark:border-warm-700">
<PillGroup className="flex-wrap overflow-x-visible">
{OVERLAYS.map((overlay) => (
<PillToggle
key={overlay.id}
label={overlay.label}
active={selectedOverlays.has(overlay.id)}
onClick={() => toggleOverlay(overlay.id)}
size="sm"
/>
))}
</PillGroup>
</div>
</div>
);
}

View file

@ -1,7 +1,13 @@
import { useMemo, useState, useEffect, type MutableRefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { Property } from '../../types';
import { formatDuration, formatAge, formatNumber, formatTransactionDate } from '../../lib/format';
import {
formatDuration,
formatAge,
formatNumber,
formatTransactionDate,
formatYearMonth,
} from '../../lib/format';
import { getNum } from '../../lib/property-fields';
import { useRetainedScrollTop } from '../../hooks/useRetainedScrollTop';
import InfoPopup from '../ui/InfoPopup';
@ -183,6 +189,11 @@ function PropertyCard({ property }: { property: Property }) {
{t('propertyCard.exCouncilBadge')}
</span>
)}
{property.listed_building === 'Yes' && (
<span className="text-xs bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded-full px-1.5 py-0.5 font-medium leading-none">
{ts('Listed building')}
</span>
)}
</div>
</div>
</div>
@ -239,6 +250,14 @@ function PropertyCard({ property }: { property: Property }) {
{formatDuration(property.duration)}
</div>
)}
{property.within_conservation_area && (
<div>
<span className="text-warm-500 dark:text-warm-400">
{t('propertyCard.withinConservationArea')}
</span>{' '}
{ts(property.within_conservation_area)}
</div>
)}
{floorArea !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.floorArea')}</span>{' '}
@ -273,24 +292,137 @@ function PropertyCard({ property }: { property: Property }) {
)}
</div>
{property.renovation_history && property.renovation_history.length > 0 && (
<div className="mt-2">
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">
{t('propertyCard.renovations')}
</div>
<div className="flex flex-wrap gap-1">
{property.renovation_history.map((reno, idx) => (
<span
key={idx}
className="inline-flex items-center gap-1 text-xs bg-warm-100 dark:bg-warm-700 text-warm-700 dark:text-warm-300 rounded px-1.5 py-0.5"
>
{reno.event}
<span className="text-warm-500 dark:text-warm-400">{reno.year}</span>
</span>
))}
</div>
</div>
)}
<PropertyTimeline property={property} />
</div>
);
}
type TimelineEvent =
| { kind: 'sale'; year: number; month: number; price: number; sortKey: number }
| { kind: 'reno'; year: number; event: string; sortKey: number }
| { kind: 'built'; year: number; approximate: boolean; sortKey: number };
function buildTimelineEvents(property: Property): TimelineEvent[] {
const events: TimelineEvent[] = [];
// Skip the most recent sale: it's already shown in the card headline.
// historical_prices is sorted oldest→newest by the pipeline.
const sales = property.historical_prices ?? [];
const olderSales = sales.length > 0 ? sales.slice(0, -1) : [];
for (const sale of olderSales) {
events.push({
kind: 'sale',
year: sale.year,
month: sale.month,
price: sale.price,
sortKey: sale.year + (Math.max(sale.month, 1) - 1) / 12,
});
}
for (const reno of property.renovation_history ?? []) {
events.push({
kind: 'reno',
year: reno.year,
event: reno.event,
// Mid-year so renos sort between Jan and Dec sales of the same year.
sortKey: reno.year + 0.5,
});
}
const builtYear = getNum(property, 'Construction year');
const approximate = property.is_construction_date_approximate ?? true;
if (builtYear !== undefined && Number.isFinite(builtYear) && builtYear > 0) {
const builtYearRounded = Math.round(builtYear);
// New-builds (exact date from price-paid) duplicate the first sale's year.
// Suppress the "Built" marker in that case since the sale carries the info.
const newBuildDuplicate = !approximate && sales.some((sale) => sale.year === builtYearRounded);
if (!newBuildDuplicate) {
events.push({
kind: 'built',
year: builtYearRounded,
approximate,
sortKey: builtYear,
});
}
}
events.sort((a, b) => b.sortKey - a.sortKey);
return events;
}
function PropertyTimeline({ property }: { property: Property }) {
const { t } = useTranslation();
const events = useMemo(() => buildTimelineEvents(property), [property]);
if (events.length === 0) return null;
return (
<div className="mt-3">
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1.5">
{t('propertyCard.historyTitle')}
</div>
<ol className="relative ml-1.5 border-l border-warm-200 dark:border-warm-700">
{events.map((event, idx) => (
<li key={idx} className="relative pl-3 pb-1.5 last:pb-0">
<TimelineMarker kind={event.kind} />
<div className="text-sm leading-tight">
{event.kind === 'sale' && (
<>
<span className="font-semibold text-teal-700 dark:text-teal-400">
£{formatNumber(event.price)}
</span>
<span className="ml-1.5 text-xs text-warm-500 dark:text-warm-400">
{formatYearMonth(event.year, event.month)}
</span>
</>
)}
{event.kind === 'reno' && (
<>
<span className="text-warm-700 dark:text-warm-200">{event.event}</span>
<span className="ml-1.5 text-xs text-warm-500 dark:text-warm-400">
{event.year}
</span>
</>
)}
{event.kind === 'built' && (
<>
<span className="text-warm-700 dark:text-warm-200">
{t('propertyCard.historyBuilt')}
</span>
<span className="ml-1.5 text-xs text-warm-500 dark:text-warm-400">
{event.approximate ? `~${event.year}` : event.year}
</span>
</>
)}
</div>
</li>
))}
</ol>
</div>
);
}
function TimelineMarker({ kind }: { kind: TimelineEvent['kind'] }) {
const base = 'absolute -left-[5px] top-1 w-2 h-2 rounded-full ring-2';
if (kind === 'sale') {
return (
<span
aria-hidden
className={`${base} bg-teal-500 dark:bg-teal-400 ring-warm-50 dark:ring-warm-900`}
/>
);
}
if (kind === 'reno') {
return (
<span
aria-hidden
className={`${base} bg-warm-400 dark:bg-warm-500 ring-warm-50 dark:ring-warm-900`}
/>
);
}
return (
<span
aria-hidden
className={`${base} bg-warm-50 border border-warm-400 dark:bg-warm-900 dark:border-warm-500 ring-warm-50 dark:ring-warm-900`}
/>
);
}

View file

@ -11,7 +11,12 @@ import { EyeIcon } from '../ui/icons/EyeIcon';
import { InfoIcon } from '../ui/icons/InfoIcon';
import { formatFilterValue, formatNumber } from '../../lib/format';
import { useTravelDestinations } from '../../hooks/useTravelDestinations';
import { MODE_ICONS, useTranslatedModes, type TransportMode } from '../../hooks/useTravelTime';
import {
MAX_TRAVEL_MINUTES,
MODE_ICONS,
useTranslatedModes,
type TransportMode,
} from '../../hooks/useTravelTime';
interface TravelTimeCardProps {
mode: TransportMode;
@ -19,6 +24,8 @@ interface TravelTimeCardProps {
label: string;
timeRange: [number, number] | null;
useBest: boolean;
noChange: boolean;
noBuses: boolean;
isPinned: boolean;
isActive: boolean;
dragValue: [number, number] | null;
@ -29,6 +36,8 @@ interface TravelTimeCardProps {
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
onToggleBest: () => void;
onToggleNoChange: () => void;
onToggleNoBuses: () => void;
onRemove: () => void;
filterImpact?: number;
destinationDropdownPortal?: boolean;
@ -40,6 +49,8 @@ export function TravelTimeCard({
label,
timeRange,
useBest,
noChange,
noBuses,
isPinned,
isActive,
dragValue,
@ -50,6 +61,8 @@ export function TravelTimeCard({
onDragChange,
onDragEnd,
onToggleBest,
onToggleNoChange,
onToggleNoBuses,
onRemove,
filterImpact,
destinationDropdownPortal = true,
@ -59,6 +72,8 @@ export function TravelTimeCard({
const { destinations, loading: destinationsLoading } = useTravelDestinations(mode);
const [showInfo, setShowInfo] = useState(false);
const [showBestInfo, setShowBestInfo] = useState(false);
const [showNoChangeInfo, setShowNoChangeInfo] = useState(false);
const [showNoBusesInfo, setShowNoBusesInfo] = useState(false);
const handleDestinationSelect = useCallback(
(selectedSlug: string, selectedLabel: string, lat: number, lon: number) => {
@ -68,7 +83,7 @@ export function TravelTimeCard({
);
const sliderMin = 0;
const sliderMax = 120;
const sliderMax = MAX_TRAVEL_MINUTES;
const displayRange = isActive && dragValue ? dragValue : (timeRange ?? [sliderMin, sliderMax]);
const ModeIcon = MODE_ICONS[mode];
@ -116,18 +131,42 @@ export function TravelTimeCard({
portal={destinationDropdownPortal}
/>
{/* Best-case toggle — transit only, shown when destination is set */}
{/* Transit-only toggles — shown when destination is set */}
{slug && mode === 'transit' && (
<div className="flex items-center gap-1.5">
<PillToggle
label={t('travel.bestCase')}
active={useBest}
onClick={onToggleBest}
size="xs"
/>
<IconButton onClick={() => setShowBestInfo(true)} title={t('travel.bestCaseTitle')}>
<InfoIcon className="w-3 h-3" />
</IconButton>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
<div className="flex items-center gap-1.5">
<PillToggle
label={t('travel.bestCase')}
active={useBest}
onClick={onToggleBest}
size="xs"
/>
<IconButton onClick={() => setShowBestInfo(true)} title={t('travel.bestCaseTitle')}>
<InfoIcon className="w-3 h-3" />
</IconButton>
</div>
<div className="flex items-center gap-1.5">
<PillToggle
label={t('travel.noChange')}
active={noChange}
onClick={onToggleNoChange}
size="xs"
/>
<IconButton onClick={() => setShowNoChangeInfo(true)} title={t('travel.noChangeTitle')}>
<InfoIcon className="w-3 h-3" />
</IconButton>
</div>
<div className="flex items-center gap-1.5">
<PillToggle
label={t('travel.noBuses')}
active={noBuses}
onClick={onToggleNoBuses}
size="xs"
/>
<IconButton onClick={() => setShowNoBusesInfo(true)} title={t('travel.noBusesTitle')}>
<InfoIcon className="w-3 h-3" />
</IconButton>
</div>
</div>
)}
@ -141,6 +180,22 @@ export function TravelTimeCard({
</InfoPopup>
)}
{showNoChangeInfo && (
<InfoPopup title={t('travel.noChangeTitle')} onClose={() => setShowNoChangeInfo(false)}>
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
<Trans i18nKey="travel.noChangeDesc" components={{ strong: <strong /> }} />
</p>
</InfoPopup>
)}
{showNoBusesInfo && (
<InfoPopup title={t('travel.noBusesTitle')} onClose={() => setShowNoBusesInfo(false)}>
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
<Trans i18nKey="travel.noBusesDesc" components={{ strong: <strong /> }} />
</p>
</InfoPopup>
)}
{/* Time range slider — only show when we have data */}
{slug && (
<div>

View file

@ -58,6 +58,8 @@ interface ActiveFilterListProps {
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
onTravelTimeDragEnd: (index: number) => void;
onTravelTimeToggleBest: (index: number) => void;
onTravelTimeToggleNoChange: (index: number) => void;
onTravelTimeToggleNoBuses: (index: number) => void;
}
export function ActiveFilterList({
@ -85,6 +87,8 @@ export function ActiveFilterList({
onTravelTimeRangeChange,
onTravelTimeDragEnd,
onTravelTimeToggleBest,
onTravelTimeToggleNoChange,
onTravelTimeToggleNoBuses,
}: ActiveFilterListProps) {
const travelCards = (
<TravelTimeFilterCards
@ -100,6 +104,8 @@ export function ActiveFilterList({
onTravelTimeRangeChange={onTravelTimeRangeChange}
onTravelTimeDragEnd={onTravelTimeDragEnd}
onTravelTimeToggleBest={onTravelTimeToggleBest}
onTravelTimeToggleNoChange={onTravelTimeToggleNoChange}
onTravelTimeToggleNoBuses={onTravelTimeToggleNoBuses}
onDragStart={onDragStart}
onDragChange={onDragChange}
/>

View file

@ -56,6 +56,8 @@ interface ActiveFiltersPanelProps {
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
onTravelTimeDragEnd: (index: number) => void;
onTravelTimeToggleBest: (index: number) => void;
onTravelTimeToggleNoChange: (index: number) => void;
onTravelTimeToggleNoBuses: (index: number) => void;
}
export function ActiveFiltersPanel({
@ -99,6 +101,8 @@ export function ActiveFiltersPanel({
onTravelTimeRangeChange,
onTravelTimeDragEnd,
onTravelTimeToggleBest,
onTravelTimeToggleNoChange,
onTravelTimeToggleNoBuses,
}: ActiveFiltersPanelProps) {
const { t } = useTranslation();
@ -201,6 +205,8 @@ export function ActiveFiltersPanel({
onTravelTimeRangeChange={onTravelTimeRangeChange}
onTravelTimeDragEnd={onTravelTimeDragEnd}
onTravelTimeToggleBest={onTravelTimeToggleBest}
onTravelTimeToggleNoChange={onTravelTimeToggleNoChange}
onTravelTimeToggleNoBuses={onTravelTimeToggleNoBuses}
/>
</div>
)}

View file

@ -20,6 +20,8 @@ interface TravelTimeFilterCardsProps {
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
onTravelTimeDragEnd: (index: number) => void;
onTravelTimeToggleBest: (index: number) => void;
onTravelTimeToggleNoChange: (index: number) => void;
onTravelTimeToggleNoBuses: (index: number) => void;
onDragStart: (name: string, initialValue?: [number, number]) => void;
onDragChange: (value: [number, number]) => void;
}
@ -37,6 +39,8 @@ export function TravelTimeFilterCards({
onTravelTimeRangeChange,
onTravelTimeDragEnd,
onTravelTimeToggleBest,
onTravelTimeToggleNoChange,
onTravelTimeToggleNoBuses,
onDragStart,
onDragChange,
}: TravelTimeFilterCardsProps) {
@ -52,6 +56,8 @@ export function TravelTimeFilterCards({
label={entry.label}
timeRange={entry.timeRange}
useBest={entry.useBest}
noChange={entry.noChange ?? false}
noBuses={entry.noBuses ?? false}
isPinned={pinnedFeature === fieldKey}
isActive={activeFeature === fieldKey}
dragValue={activeFeature === fieldKey ? dragValue : null}
@ -64,6 +70,8 @@ export function TravelTimeFilterCards({
onDragChange={onDragChange}
onDragEnd={() => onTravelTimeDragEnd(index)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onToggleNoChange={() => onTravelTimeToggleNoChange(index)}
onToggleNoBuses={() => onTravelTimeToggleNoBuses(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
filterImpact={filterImpacts?.[fieldKey]}
destinationDropdownPortal={destinationDropdownPortal}

View file

@ -13,8 +13,10 @@ import type { useMapData } from '../../../hooks/useMapData';
import type { useTutorial } from '../../../hooks/useTutorial';
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
import type { getTutorialStyles } from '../../../lib/tutorial-styles';
import type { OverlayId } from '../../../lib/overlays';
import type { SearchedLocation } from '../LocationSearch';
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
import { EyeIcon } from '../../ui/icons/EyeIcon';
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
import type { MapFlyTo, PaneResizeHandlers } from './types';
import { MapFallback, PaneFallback } from './Fallbacks';
@ -35,6 +37,7 @@ interface DesktopMapPageProps {
filtersPane: ReactNode;
mapData: MapData;
pois: POI[];
activeOverlays: Set<OverlayId>;
mapViewFeature: string | null;
filterRange: [number, number] | null;
viewSource: 'drag' | 'eye' | null;
@ -59,6 +62,9 @@ interface DesktopMapPageProps {
poiPaneOpen: boolean;
onTogglePoiPane: () => void;
poiPane: ReactNode;
overlayPaneOpen: boolean;
onToggleOverlayPane: () => void;
overlayPane: ReactNode;
showSelectionPane: boolean;
rightPaneWidth: number;
rightPaneHandlers: PaneResizeHandlers;
@ -81,6 +87,7 @@ export function DesktopMapPage({
filtersPane,
mapData,
pois,
activeOverlays,
mapViewFeature,
filterRange,
viewSource,
@ -105,6 +112,9 @@ export function DesktopMapPage({
poiPaneOpen,
onTogglePoiPane,
poiPane,
overlayPaneOpen,
onToggleOverlayPane,
overlayPane,
showSelectionPane,
rightPaneWidth,
rightPaneHandlers,
@ -168,6 +178,7 @@ export function DesktopMapPage({
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={pois}
activeOverlays={activeOverlays}
onViewChange={mapData.handleViewChange}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
@ -197,16 +208,30 @@ export function DesktopMapPage({
totalCount={totalCount}
/>
</Suspense>
<button
data-tutorial="poi-button"
onClick={onTogglePoiPane}
className={`absolute bottom-4 right-4 z-10 px-3 py-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 flex items-center gap-2 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
>
<MapPinIcon className="w-5 h-5" />
<span className="text-sm font-medium">{t('poiPane.pointsOfInterest')}</span>
</button>
<div className="absolute bottom-4 right-4 z-10 flex flex-col items-end gap-2">
<button
onClick={onToggleOverlayPane}
className={`flex items-center gap-2 rounded-lg bg-white px-3 py-2 shadow-lg dark:bg-warm-800 ${overlayPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 hover:text-teal-600 dark:text-warm-400 dark:hover:text-teal-400'}`}
>
<EyeIcon className="h-5 w-5" filled={overlayPaneOpen} />
<span className="text-sm font-medium">Overlays</span>
</button>
<button
data-tutorial="poi-button"
onClick={onTogglePoiPane}
className={`flex items-center gap-2 rounded-lg bg-white px-3 py-2 shadow-lg dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 hover:text-teal-600 dark:text-warm-400 dark:hover:text-teal-400'}`}
>
<MapPinIcon className="h-5 w-5" />
<span className="text-sm font-medium">{t('poiPane.pointsOfInterest')}</span>
</button>
</div>
{overlayPaneOpen && (
<div className="absolute bottom-28 right-4 z-10 flex h-[220px] min-h-0 w-80 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
{overlayPane}
</div>
)}
{poiPaneOpen && (
<div className="absolute bottom-14 right-4 z-10 flex h-[60vh] min-h-0 w-80 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
<div className="absolute bottom-28 right-4 z-10 flex h-[60vh] min-h-0 w-80 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
{poiPane}
</div>
)}

View file

@ -10,9 +10,11 @@ import type {
} from '../../../types';
import type { useMapData } from '../../../hooks/useMapData';
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
import type { OverlayId } from '../../../lib/overlays';
import type { SearchedLocation } from '../LocationSearch';
import MobileBottomSheet from '../MobileBottomSheet';
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
import { EyeIcon } from '../../ui/icons/EyeIcon';
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
import type { MapFlyTo } from './types';
import { MapFallback, PaneFallback } from './Fallbacks';
@ -26,6 +28,7 @@ interface MobileMapPageProps {
initialLoading: boolean;
mapData: MapData;
pois: POI[];
activeOverlays: Set<OverlayId>;
mapViewFeature: string | null;
filterRange: [number, number] | null;
viewSource: 'drag' | 'eye' | null;
@ -56,6 +59,9 @@ interface MobileMapPageProps {
onTogglePoiPane: () => void;
poiButtonLabel: string;
poiPane: ReactNode;
overlayPaneOpen: boolean;
onToggleOverlayPane: () => void;
overlayPane: ReactNode;
filtersPane: ReactNode;
mobileLegend: ReactNode;
renderAreaPane: () => ReactNode;
@ -69,6 +75,7 @@ export function MobileMapPage({
initialLoading,
mapData,
pois,
activeOverlays,
mapViewFeature,
filterRange,
viewSource,
@ -99,6 +106,9 @@ export function MobileMapPage({
onTogglePoiPane,
poiButtonLabel,
poiPane,
overlayPaneOpen,
onToggleOverlayPane,
overlayPane,
filtersPane,
mobileLegend,
renderAreaPane,
@ -119,6 +129,7 @@ export function MobileMapPage({
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={pois}
activeOverlays={activeOverlays}
onViewChange={mapData.handleViewChange}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
@ -150,16 +161,31 @@ export function MobileMapPage({
</Suspense>
</div>
<button
onClick={onTogglePoiPane}
className={`absolute top-3 right-3 z-20 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
aria-label={poiButtonLabel}
>
<MapPinIcon className="w-5 h-5" />
</button>
<div className="absolute right-3 top-3 z-20 flex flex-col gap-2">
<button
onClick={onToggleOverlayPane}
className={`rounded-lg bg-white p-2 shadow-lg dark:bg-warm-800 ${overlayPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 hover:text-teal-600 dark:text-warm-400 dark:hover:text-teal-400'}`}
aria-label="Overlays"
>
<EyeIcon className="h-5 w-5" filled={overlayPaneOpen} />
</button>
<button
onClick={onTogglePoiPane}
className={`rounded-lg bg-white p-2 shadow-lg dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 hover:text-teal-600 dark:text-warm-400 dark:hover:text-teal-400'}`}
aria-label={poiButtonLabel}
>
<MapPinIcon className="h-5 w-5" />
</button>
</div>
{overlayPaneOpen && (
<div className="absolute top-24 right-3 left-3 z-20 flex h-[220px] min-h-0 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
{overlayPane}
</div>
)}
{poiPaneOpen && (
<div className="absolute top-14 right-3 left-3 z-20 flex h-[45dvh] min-h-0 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
<div className="absolute top-24 right-3 left-3 z-20 flex h-[45dvh] min-h-0 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
{poiPane}
</div>
)}

View file

@ -3,6 +3,7 @@ import { Suspense } from 'react';
import type { FeatureMeta, ViewState } from '../../../types';
import type { useMapData } from '../../../hooks/useMapData';
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
import type { OverlayId } from '../../../lib/overlays';
import { MapFallback } from './Fallbacks';
import { Map } from './lazyComponents';
@ -18,6 +19,7 @@ interface ScreenshotMapPageProps {
theme: 'light' | 'dark';
ogMode?: boolean;
travelTimeEntries: TravelTimeEntry[];
activeOverlays: Set<OverlayId>;
}
export function ScreenshotMapPage({
@ -30,6 +32,7 @@ export function ScreenshotMapPage({
theme,
ogMode,
travelTimeEntries,
activeOverlays,
}: ScreenshotMapPageProps) {
return (
<div className="h-full w-full">
@ -39,6 +42,7 @@ export function ScreenshotMapPage({
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={[]}
activeOverlays={activeOverlays}
onViewChange={mapData.handleViewChange}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}

View file

@ -4,7 +4,7 @@ import { cellToLatLng } from 'h3-js';
import type { FeatureMeta, HexagonStatsResponse, PostcodeFeature } from '../../../types';
import type { HexagonLocation } from '../../../lib/external-search';
import type { useMapData } from '../../../hooks/useMapData';
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
import { resolveTransitVariant, type TravelTimeEntry } from '../../../hooks/useTravelTime';
import { getSpecificCrimeFeatureName } from '../../../lib/crime-filter';
import { getElectionVoteShareFeatureName } from '../../../lib/election-filter';
import { getEthnicityFeatureName } from '../../../lib/ethnicity-filter';
@ -33,7 +33,9 @@ export function getMapPageBackendFeatureName(featureName: string): string {
export function useJourneyDestination(entries: TravelTimeEntry[]) {
return useMemo(() => {
const entry = entries.find((item) => item.mode === 'transit' && item.slug);
return entry ? { mode: entry.mode, slug: entry.slug } : null;
// The server needs the resolved variant (e.g. transit-no-change-no-bus)
// to look up the correct parquet directory, not the UI-side base mode.
return entry ? { mode: resolveTransitVariant(entry), slug: entry.slug } : null;
}, [entries]);
}

View file

@ -3,6 +3,7 @@ import { lazy } from 'react';
export const Map = lazy(() => import('../Map'));
export const Filters = lazy(() => import('../Filters'));
export const POIPane = lazy(() => import('../POIPane'));
export const OverlayPane = lazy(() => import('../OverlayPane'));
export const AreaPane = lazy(() => import('../AreaPane'));
export const PropertiesPane = lazy(() =>
import('../PropertiesPane').then((module) => ({ default: module.PropertiesPane }))

View file

@ -6,6 +6,7 @@ import type {
ViewState,
} from '../../../types';
import type { TravelTimeInitial } from '../../../hooks/useTravelTime';
import type { OverlayId } from '../../../lib/overlays';
import type { Page } from '../../ui/Header';
import type { PointerEvent } from 'react';
@ -25,6 +26,7 @@ export interface MapPageProps {
initialFilters: FeatureFilters;
initialViewState: ViewState;
initialPOICategories: Set<string>;
initialOverlays?: Set<OverlayId>;
initialTab: 'properties' | 'area';
initialLoading: boolean;
theme: 'light' | 'dark';

View file

@ -5,8 +5,9 @@ import type { Bounds, FeatureFilters, FeatureMeta } from '../../../types';
import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../../lib/api';
import { trackEvent } from '../../../lib/analytics';
import type { ExportNotice, ExportState } from './types';
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
import { resolveTransitVariant, type TravelTimeEntry } from '../../../hooks/useTravelTime';
import { buildTravelParam, dedupeTravelTimeEntries } from '../../../lib/travel-params';
import type { OverlayId } from '../../../lib/overlays';
const EXPORT_FILE_NAME = 'perfect-postcode-export.xlsx';
const EXPORT_TIMEOUT_MS = 150_000;
@ -70,7 +71,8 @@ function triggerExportDownload(blob: Blob, fileName: string): void {
function appendTravelStateParams(params: URLSearchParams, entries: TravelTimeEntry[]): void {
for (const entry of dedupeTravelTimeEntries(entries)) {
if (!entry.slug) continue;
let value = `${entry.mode}:${entry.slug}:${encodeURIComponent(entry.label)}`;
const serverMode = resolveTransitVariant(entry);
let value = `${serverMode}:${entry.slug}:${encodeURIComponent(entry.label)}`;
if (entry.useBest) value += ':b';
if (entry.timeRange) {
value += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
@ -84,6 +86,7 @@ interface UseExportControllerOptions {
filters: FeatureFilters;
features: FeatureMeta[];
travelTimeEntries: TravelTimeEntry[];
selectedOverlays: Set<OverlayId>;
shareCode?: string;
t: TFunction;
onExportStateChange?: (state: ExportState) => void;
@ -94,6 +97,7 @@ export function useExportController({
filters,
features,
travelTimeEntries,
selectedOverlays,
shareCode,
t,
onExportStateChange,
@ -147,6 +151,9 @@ export function useExportController({
const travelParam = buildTravelParam(travelTimeEntries);
if (travelParam) params.set('travel', travelParam);
appendTravelStateParams(params, travelTimeEntries);
for (const overlay of selectedOverlays) {
params.append('overlay', overlay);
}
if (shareCode) params.set('share', shareCode);
const url = apiUrl('export', params);
@ -189,6 +196,7 @@ export function useExportController({
exporting,
features,
filters,
selectedOverlays,
shareCode,
showExportNotice,
t,