has issues
This commit is contained in:
parent
2e112d7398
commit
c645b0f1d4
96 changed files with 2147083 additions and 5787 deletions
|
|
@ -63,6 +63,16 @@ const DATA_SOURCE_DEFS: DataSourceDef[] = [
|
|||
url: 'https://www.forestresearch.gov.uk/tools-and-resources/national-trees-outside-woodland-map/',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'conservation-areas',
|
||||
url: 'https://opendata-historicengland.hub.arcgis.com/datasets/historicengland::conservation-areas/explore',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'listed-buildings',
|
||||
url: 'https://opendata-historicengland.hub.arcgis.com/datasets/historicengland::national-heritage-list-for-england-nhle/explore?layer=0',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'naptan',
|
||||
url: 'https://naptan.dft.gov.uk/naptan/schema/2.4/doc/NaPTANSchemaGuide-2.4-v0.57.pdf',
|
||||
|
|
@ -128,6 +138,16 @@ const DS_KEYS: Record<string, [string, string, string]> = {
|
|||
'learnPage.dsGreenspaceUse',
|
||||
],
|
||||
'forest-research-tow': ['learnPage.dsTowName', 'learnPage.dsTowOrigin', 'learnPage.dsTowUse'],
|
||||
'conservation-areas': [
|
||||
'learnPage.dsConservationAreasName',
|
||||
'learnPage.dsConservationAreasOrigin',
|
||||
'learnPage.dsConservationAreasUse',
|
||||
],
|
||||
'listed-buildings': [
|
||||
'learnPage.dsListedBuildingsName',
|
||||
'learnPage.dsListedBuildingsOrigin',
|
||||
'learnPage.dsListedBuildingsUse',
|
||||
],
|
||||
naptan: ['learnPage.dsNaptanName', 'learnPage.dsNaptanOrigin', 'learnPage.dsNaptanUse'],
|
||||
noise: ['learnPage.dsNoiseName', 'learnPage.dsNoiseOrigin', 'learnPage.dsNoiseUse'],
|
||||
ofsted: ['learnPage.dsOfstedName', 'learnPage.dsOfstedOrigin', 'learnPage.dsOfstedUse'],
|
||||
|
|
@ -235,6 +255,17 @@ export default function LearnPage() {
|
|||
{ question: t('learnPage.faqDueDiligence4Q'), answer: t('learnPage.faqDueDiligence4A') },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('learnPage.faqBehindDataTitle'),
|
||||
items: [
|
||||
{ question: t('learnPage.faqBehindData1Q'), answer: t('learnPage.faqBehindData1A') },
|
||||
{ question: t('learnPage.faqBehindData2Q'), answer: t('learnPage.faqBehindData2A') },
|
||||
{ question: t('learnPage.faqBehindData3Q'), answer: t('learnPage.faqBehindData3A') },
|
||||
{ question: t('learnPage.faqBehindData4Q'), answer: t('learnPage.faqBehindData4A') },
|
||||
{ question: t('learnPage.faqBehindData5Q'), answer: t('learnPage.faqBehindData5A') },
|
||||
{ question: t('learnPage.faqBehindData6Q'), answer: t('learnPage.faqBehindData6A') },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('learnPage.faqPrivacyTitle'),
|
||||
items: [{ question: t('learnPage.faqPrivacy1Q'), answer: t('learnPage.faqPrivacy1A') }],
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
102
frontend/src/components/map/CrimeYearChart.tsx
Normal file
102
frontend/src/components/map/CrimeYearChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
81
frontend/src/components/map/OverlayPane.tsx
Normal file
81
frontend/src/components/map/OverlayPane.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export function PillToggle({
|
|||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={indeterminate ? 'mixed' : active}
|
||||
className={`${sizeClasses} ${colorClasses} inline-flex max-w-full shrink-0 items-center gap-1.5 rounded-full font-medium whitespace-nowrap cursor-pointer`}
|
||||
>
|
||||
{icon}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue