good
This commit is contained in:
parent
c995f12f8b
commit
8dc939d761
44 changed files with 3540 additions and 2159478 deletions
|
|
@ -74,11 +74,11 @@ const DEMO_FEATURES: FeatureMeta[] = [
|
|||
prefix: '£',
|
||||
},
|
||||
{
|
||||
name: 'Serious crime per 1k residents (avg/yr)',
|
||||
name: 'Serious crime (avg/yr)',
|
||||
type: 'numeric',
|
||||
group: 'Crime',
|
||||
min: 0,
|
||||
max: 120,
|
||||
max: 40,
|
||||
step: 1,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -9,103 +9,85 @@ type LearnTab = 'data-sources' | 'faq' | 'articles' | 'support';
|
|||
|
||||
interface DataSourceDef {
|
||||
id: string;
|
||||
url: string;
|
||||
license: string;
|
||||
optOutUrl?: string;
|
||||
}
|
||||
|
||||
const DATA_SOURCE_DEFS: DataSourceDef[] = [
|
||||
{
|
||||
id: 'price-paid',
|
||||
url: 'https://www.gov.uk/government/statistical-data-sets/price-paid-data-downloads',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'epc',
|
||||
url: 'https://epc.opendatacommunities.org/downloads/domestic',
|
||||
license: 'Open Government Licence v3.0',
|
||||
optOutUrl:
|
||||
'https://www.gov.uk/guidance/energy-performance-certificates-opt-out-of-public-disclosure',
|
||||
},
|
||||
{
|
||||
id: 'nspl',
|
||||
url: 'https://www.arcgis.com/sharing/rest/content/items/077631e063eb4e1ab43575d01381ec33/data',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'iod',
|
||||
url: 'https://www.gov.uk/government/statistics/english-indices-of-deprivation-2025',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'ethnicity',
|
||||
url: 'https://www.ethnicity-facts-figures.service.gov.uk/uk-population-by-ethnicity/national-and-regional-populations/regional-ethnic-diversity/latest/#download-the-data',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{ id: 'crime', url: 'https://data.police.uk/data/', license: 'Open Government Licence v3.0' },
|
||||
{ id: 'crime', license: 'Open Government Licence v3.0' },
|
||||
{
|
||||
id: 'osm-pois',
|
||||
url: 'https://download.geofabrik.de/europe/great-britain-latest.osm.pbf',
|
||||
license: 'Open Data Commons Open Database License (ODbL)',
|
||||
},
|
||||
{
|
||||
id: 'geolytix-retail-points',
|
||||
url: 'https://geolytix.com/blog/supermarket-retail-points/',
|
||||
license: 'GEOLYTIX Open Data License',
|
||||
},
|
||||
{
|
||||
id: 'os-open-greenspace',
|
||||
url: 'https://osdatahub.os.uk/downloads/open/OpenGreenspace',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'forest-research-tow',
|
||||
url: 'https://www.forestresearch.gov.uk/tools-and-resources/national-trees-outside-woodland-map/',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'nfi-woodland',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'conservation-areas',
|
||||
url: 'https://www.planning.data.gov.uk/dataset/conservation-area',
|
||||
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',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'noise',
|
||||
url: 'https://environment.data.gov.uk/spatialdata/road-noise-all-metrics-england-round-4/wcs',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'ofsted',
|
||||
url: 'https://www.gov.uk/government/statistical-data-sets/monthly-management-information-ofsteds-school-inspections-outcomes',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'broadband',
|
||||
url: 'https://www.ofcom.org.uk/phones-and-broadband/coverage-and-speeds/connected-nations-20252/data-downloads-2025',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'council-tax',
|
||||
url: 'https://www.gov.uk/government/statistics/council-tax-levels-set-by-local-authorities-in-england-2025-to-2026',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'ons-rental',
|
||||
url: 'https://www.ons.gov.uk/peoplepopulationandcommunity/housing/datasets/privaterentalmarketsummarystatisticsinengland',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'election-results',
|
||||
url: 'https://electionresults.parliament.uk/general-elections/6',
|
||||
license: 'Open Parliament Licence v3.0',
|
||||
},
|
||||
];
|
||||
|
|
@ -138,6 +120,7 @@ const DS_KEYS: Record<string, [string, string, string]> = {
|
|||
'learnPage.dsGreenspaceUse',
|
||||
],
|
||||
'forest-research-tow': ['learnPage.dsTowName', 'learnPage.dsTowOrigin', 'learnPage.dsTowUse'],
|
||||
'nfi-woodland': ['learnPage.dsNfiName', 'learnPage.dsNfiOrigin', 'learnPage.dsNfiUse'],
|
||||
'conservation-areas': [
|
||||
'learnPage.dsConservationAreasName',
|
||||
'learnPage.dsConservationAreasOrigin',
|
||||
|
|
@ -358,26 +341,6 @@ export default function LearnPage() {
|
|||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-3">
|
||||
{tDynamic(useKey)}
|
||||
</p>
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline break-all"
|
||||
>
|
||||
{source.url}
|
||||
</a>
|
||||
{source.optOutUrl && (
|
||||
<div className="mt-2">
|
||||
<a
|
||||
href={source.optOutUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
|
||||
>
|
||||
{t('learnPage.optOut')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
@ -393,39 +356,13 @@ export default function LearnPage() {
|
|||
<ul className="space-y-1.5 text-sm">
|
||||
<li>{t('learnPage.attrLandRegistry')}</li>
|
||||
<li>
|
||||
{t('learnPage.attrOgl')}{' '}
|
||||
<a
|
||||
href="https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-teal-400 hover:text-teal-300 hover:underline"
|
||||
>
|
||||
{t('learnPage.attrOglLink')}
|
||||
</a>
|
||||
.
|
||||
{t('learnPage.attrOgl')} {t('learnPage.attrOglLink')}.
|
||||
</li>
|
||||
<li>{t('learnPage.attrOs')}</li>
|
||||
<li>{t('learnPage.attrTfl')}</li>
|
||||
<li>
|
||||
{t('learnPage.attrOsm')}{' '}
|
||||
<a
|
||||
href="https://www.openstreetmap.org/copyright"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-teal-400 hover:text-teal-300 hover:underline"
|
||||
>
|
||||
{t('learnPage.attrOsmContrib')}
|
||||
</a>
|
||||
, {t('learnPage.attrOsmLicense')}{' '}
|
||||
<a
|
||||
href="https://opendatacommons.org/licenses/odbl/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-teal-400 hover:text-teal-300 hover:underline"
|
||||
>
|
||||
{t('learnPage.attrOsmLicenseLink')}
|
||||
</a>
|
||||
.
|
||||
{t('learnPage.attrOsm')} {t('learnPage.attrOsmContrib')},{' '}
|
||||
{t('learnPage.attrOsmLicense')} {t('learnPage.attrOsmLicenseLink')}.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -166,6 +166,66 @@ describe('LocationSearch', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('waits for nearest postcode selection before flying to a place result', async () => {
|
||||
window.localStorage.setItem(
|
||||
RECENT_SEARCHES_STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
type: 'place',
|
||||
name: 'E14',
|
||||
slug: 'e14',
|
||||
place_type: 'outcode',
|
||||
lat: 51.505,
|
||||
lon: -0.01,
|
||||
},
|
||||
])
|
||||
);
|
||||
const nearestLookup = deferred<Response>();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((input: string | URL | Request) => {
|
||||
const url = new URL(String(input), 'http://localhost');
|
||||
if (url.pathname === '/api/nearest-postcode') return nearestLookup.promise;
|
||||
return Promise.resolve(new Response(null, { status: 404 }));
|
||||
})
|
||||
);
|
||||
|
||||
const onFlyTo = vi.fn();
|
||||
const onLocationSearched = vi.fn();
|
||||
render(<LocationSearch onFlyTo={onFlyTo} onLocationSearched={onLocationSearched} />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
fireEvent.focus(input);
|
||||
fireEvent.mouseDown(await screen.findByRole('button', { name: 'E14' }));
|
||||
|
||||
expect(onFlyTo).not.toHaveBeenCalled();
|
||||
nearestLookup.resolve(
|
||||
jsonResponse({
|
||||
postcode: 'E14 2DG',
|
||||
latitude: 51.506,
|
||||
longitude: -0.012,
|
||||
geometry: postcodeGeometry,
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onLocationSearched).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(onLocationSearched).toHaveBeenCalledWith({
|
||||
postcode: 'E14 2DG',
|
||||
geometry: postcodeGeometry,
|
||||
latitude: 51.506,
|
||||
longitude: -0.012,
|
||||
zoom: 16,
|
||||
markerLatitude: 51.505,
|
||||
markerLongitude: -0.01,
|
||||
});
|
||||
expect(onFlyTo).toHaveBeenCalledWith(51.505, -0.01, 16);
|
||||
expect(onLocationSearched.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
onFlyTo.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps only the three most recent local searches', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
|
|
|
|||
|
|
@ -132,10 +132,7 @@ export default function LocationSearch({
|
|||
|
||||
if (result.type === 'place') {
|
||||
const zoom = ZOOM_FOR_TYPE[result.place_type] ?? 14;
|
||||
// On mobile the drawer opens after onLocationSearched; MapPage handles
|
||||
// the fly-to there with the correct viewport inset so the target isn't
|
||||
// hidden behind the drawer. On desktop fly immediately for snappy feedback.
|
||||
if (!isMobile) onFlyTo(result.lat, result.lon, zoom);
|
||||
const flyZoom = result.place_type === 'outcode' ? POSTCODE_SEARCH_ZOOM : zoom;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
lat: String(result.lat),
|
||||
|
|
@ -158,10 +155,11 @@ export default function LocationSearch({
|
|||
geometry: json.geometry,
|
||||
latitude: json.latitude,
|
||||
longitude: json.longitude,
|
||||
zoom,
|
||||
zoom: flyZoom,
|
||||
markerLatitude: result.lat,
|
||||
markerLongitude: result.lon,
|
||||
});
|
||||
if (!isMobile) onFlyTo(result.lat, result.lon, flyZoom);
|
||||
search.saveRecentSearch(result);
|
||||
search.clear();
|
||||
if (isMobile) setExpanded(false);
|
||||
|
|
|
|||
|
|
@ -45,7 +45,8 @@ 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';
|
||||
import { type OverlayId, OVERLAY_MIN_ZOOM } from '../../lib/overlays';
|
||||
import { CRIME_TYPE_VALUES } from '../../lib/crime-types';
|
||||
import type { BasemapId } from '../../lib/basemaps';
|
||||
|
||||
interface MapProps {
|
||||
|
|
@ -54,7 +55,9 @@ interface MapProps {
|
|||
usePostcodeView: boolean;
|
||||
pois: POI[];
|
||||
activeOverlays?: Set<OverlayId>;
|
||||
activeCrimeTypes?: Set<string>;
|
||||
basemap?: BasemapId;
|
||||
colorOpacity?: number;
|
||||
actualListings?: ActualListing[];
|
||||
onViewChange: (params: ViewChangeParams) => void;
|
||||
viewFeature: string | null;
|
||||
|
|
@ -94,6 +97,7 @@ interface MapProps {
|
|||
const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = [];
|
||||
const EMPTY_ACTUAL_LISTINGS: ActualListing[] = [];
|
||||
const EMPTY_OVERLAYS = new Set<OverlayId>();
|
||||
const ALL_CRIME_TYPES = new Set<string>(CRIME_TYPE_VALUES);
|
||||
|
||||
function formatListingPrice(price: number): string {
|
||||
return `£${price.toLocaleString()}`;
|
||||
|
|
@ -588,9 +592,11 @@ function overlayTileUrl(path: string): string {
|
|||
|
||||
function OverlayTileLayers({
|
||||
activeOverlays,
|
||||
activeCrimeTypes,
|
||||
zoom,
|
||||
}: {
|
||||
activeOverlays: Set<OverlayId>;
|
||||
activeCrimeTypes: Set<string>;
|
||||
zoom: number;
|
||||
}) {
|
||||
if (zoom < POSTCODE_ZOOM_THRESHOLD || activeOverlays.size === 0) return null;
|
||||
|
|
@ -598,6 +604,14 @@ function OverlayTileLayers({
|
|||
const showNoise = activeOverlays.has('noise');
|
||||
const showCrime = activeOverlays.has('crime-hotspots');
|
||||
const showTrees = activeOverlays.has('trees-outside-woodlands');
|
||||
const showPropertyBorders = activeOverlays.has('property-borders');
|
||||
|
||||
// Restrict the heatmap to the selected crime types. When every type is
|
||||
// selected we omit the filter entirely so all features contribute.
|
||||
const crimeFilter =
|
||||
activeCrimeTypes.size >= CRIME_TYPE_VALUES.length
|
||||
? undefined
|
||||
: ['in', ['get', 'crime_type'], ['literal', Array.from(activeCrimeTypes)]];
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -607,6 +621,7 @@ function OverlayTileLayers({
|
|||
type="raster"
|
||||
tiles={[overlayTileUrl('noise')]}
|
||||
tileSize={256}
|
||||
minzoom={OVERLAY_MIN_ZOOM.noise}
|
||||
maxzoom={14}
|
||||
>
|
||||
<Layer
|
||||
|
|
@ -626,6 +641,7 @@ function OverlayTileLayers({
|
|||
id="overlay-crime-source"
|
||||
type="vector"
|
||||
tiles={[overlayTileUrl('crime-hotspots')]}
|
||||
minzoom={OVERLAY_MIN_ZOOM['crime-hotspots']}
|
||||
maxzoom={15}
|
||||
>
|
||||
<Layer
|
||||
|
|
@ -633,6 +649,7 @@ function OverlayTileLayers({
|
|||
type="heatmap"
|
||||
source-layer="crime_hotspots"
|
||||
minzoom={POSTCODE_ZOOM_THRESHOLD}
|
||||
filter={crimeFilter as never}
|
||||
paint={
|
||||
{
|
||||
'heatmap-weight': [
|
||||
|
|
@ -673,6 +690,7 @@ function OverlayTileLayers({
|
|||
id="overlay-trees-source"
|
||||
type="vector"
|
||||
tiles={[overlayTileUrl('trees-outside-woodlands')]}
|
||||
minzoom={OVERLAY_MIN_ZOOM['trees-outside-woodlands']}
|
||||
maxzoom={16}
|
||||
>
|
||||
<Layer
|
||||
|
|
@ -698,6 +716,30 @@ function OverlayTileLayers({
|
|||
/>
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{showPropertyBorders && (
|
||||
<Source
|
||||
id="overlay-property-borders-source"
|
||||
type="vector"
|
||||
tiles={[overlayTileUrl('property-borders')]}
|
||||
minzoom={OVERLAY_MIN_ZOOM['property-borders']}
|
||||
maxzoom={16}
|
||||
>
|
||||
<Layer
|
||||
id="overlay-property-borders"
|
||||
type="line"
|
||||
source-layer="property_borders"
|
||||
minzoom={POSTCODE_ZOOM_THRESHOLD}
|
||||
paint={
|
||||
{
|
||||
'line-color': '#b45309',
|
||||
'line-opacity': ['interpolate', ['linear'], ['zoom'], 15, 0.35, 18, 0.85],
|
||||
'line-width': ['interpolate', ['linear'], ['zoom'], 15, 0.4, 18, 1.4],
|
||||
} as never
|
||||
}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -708,7 +750,9 @@ export default memo(function Map({
|
|||
usePostcodeView,
|
||||
pois,
|
||||
activeOverlays = EMPTY_OVERLAYS,
|
||||
activeCrimeTypes = ALL_CRIME_TYPES,
|
||||
basemap = 'standard',
|
||||
colorOpacity = 1,
|
||||
actualListings = EMPTY_ACTUAL_LISTINGS,
|
||||
onViewChange,
|
||||
viewFeature,
|
||||
|
|
@ -929,6 +973,7 @@ export default memo(function Map({
|
|||
bounds: viewportBounds,
|
||||
travelTimeEntries,
|
||||
mapDataBeforeId,
|
||||
colorOpacity,
|
||||
});
|
||||
|
||||
const showAutoPoiCards = !screenshotMode && viewState.zoom >= POI_AUTO_CARD_ZOOM_THRESHOLD;
|
||||
|
|
@ -980,8 +1025,12 @@ export default memo(function Map({
|
|||
minZoom={MAP_MIN_ZOOM}
|
||||
maxBounds={maxBounds}
|
||||
>
|
||||
<OverlayTileLayers
|
||||
activeOverlays={activeOverlays}
|
||||
activeCrimeTypes={activeCrimeTypes}
|
||||
zoom={viewState.zoom}
|
||||
/>
|
||||
<DeckOverlay layers={layers} getTooltip={null} />
|
||||
<OverlayTileLayers activeOverlays={activeOverlays} zoom={viewState.zoom} />
|
||||
{!screenshotMode && <ScaleControl position="bottom-left" maxWidth={100} unit="metric" />}
|
||||
</MapGL>
|
||||
{basemap === 'satellite' && (
|
||||
|
|
|
|||
|
|
@ -27,9 +27,11 @@ import { useFilterCounts } from '../../hooks/useFilterCounts';
|
|||
import { trackEvent } from '../../lib/analytics';
|
||||
import { INITIAL_VIEW_STATE, POSTCODE_ZOOM_THRESHOLD } from '../../lib/consts';
|
||||
import type { OverlayId } from '../../lib/overlays';
|
||||
import { CRIME_TYPE_VALUES } from '../../lib/crime-types';
|
||||
import type { BasemapId } from '../../lib/basemaps';
|
||||
import { useLicense } from '../../hooks/useLicense';
|
||||
import { stateToParams } from '../../lib/url-state';
|
||||
import { DEFAULT_COLOR_OPACITY, normalizeColorOpacity } from '../../lib/color-opacity';
|
||||
import { groupFeaturesByCategory } from '../../lib/features';
|
||||
import {
|
||||
getActiveAmenityFilterFeatureNames,
|
||||
|
|
@ -71,8 +73,6 @@ export type { ExportState } from './map-page/types';
|
|||
type PendingFlyTo = { lat: number; lng: number; zoom: number };
|
||||
const EMPTY_ACTUAL_LISTINGS: ActualListing[] = [];
|
||||
|
||||
declare const __DEV__: boolean;
|
||||
|
||||
export default function MapPage({
|
||||
features,
|
||||
poiCategoryGroups,
|
||||
|
|
@ -80,7 +80,9 @@ export default function MapPage({
|
|||
initialViewState,
|
||||
initialPOICategories,
|
||||
initialOverlays,
|
||||
initialCrimeTypes,
|
||||
initialBasemap = 'standard',
|
||||
initialColorOpacity = DEFAULT_COLOR_OPACITY,
|
||||
initialTab,
|
||||
initialLoading,
|
||||
theme,
|
||||
|
|
@ -114,7 +116,13 @@ export default function MapPage({
|
|||
const [activeOverlays, setActiveOverlays] = useState<Set<OverlayId>>(
|
||||
() => new Set(initialOverlays ?? [])
|
||||
);
|
||||
const [crimeTypes, setCrimeTypes] = useState<Set<string>>(
|
||||
() => new Set(initialCrimeTypes ?? CRIME_TYPE_VALUES)
|
||||
);
|
||||
const [basemap, setBasemap] = useState<BasemapId>(initialBasemap);
|
||||
const [colorOpacity, setColorOpacity] = useState(() =>
|
||||
normalizeColorOpacity(initialColorOpacity)
|
||||
);
|
||||
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 0.45, 'left');
|
||||
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right');
|
||||
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
||||
|
|
@ -122,7 +130,10 @@ export default function MapPage({
|
|||
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
|
||||
const [overlayPaneOpen, setOverlayPaneOpen] = useState(false);
|
||||
const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null);
|
||||
const [devActualListingsEnabled, setDevActualListingsEnabled] = useState(true);
|
||||
const [listingsToggleEnabled, setListingsToggleEnabled] = useState(true);
|
||||
const [pendingInitialPostcode, setPendingInitialPostcode] = useState<string | null>(
|
||||
initialPostcode ?? null
|
||||
);
|
||||
|
||||
const {
|
||||
filters,
|
||||
|
|
@ -482,8 +493,12 @@ export default function MapPage({
|
|||
[filters, features]
|
||||
);
|
||||
const actualListingsTravelParam = useMemo(() => buildTravelParam(entries), [entries]);
|
||||
const actualListingsEnabled = !__DEV__ || devActualListingsEnabled;
|
||||
const { listings: actualListings } = useActualListings(
|
||||
// Listings are gated behind the per-user `can_see_listings` flag (off by
|
||||
// default). Only users with the flag get the toggle button and the fetch;
|
||||
// the backing API independently rejects anyone else with 403.
|
||||
const canSeeListings = user?.canSeeListings ?? false;
|
||||
const actualListingsEnabled = canSeeListings && listingsToggleEnabled;
|
||||
const { listings: actualListings, loading: actualListingsLoading } = useActualListings(
|
||||
actualListingsEnabled ? mapData.visibleBounds : null,
|
||||
{
|
||||
filterParam: actualListingsFilterParam,
|
||||
|
|
@ -493,9 +508,13 @@ export default function MapPage({
|
|||
);
|
||||
const visibleActualListings = actualListingsEnabled ? actualListings : EMPTY_ACTUAL_LISTINGS;
|
||||
const handleToggleActualListings = useCallback(() => {
|
||||
if (!__DEV__) return;
|
||||
setDevActualListingsEnabled((enabled) => !enabled);
|
||||
}, []);
|
||||
if (!canSeeListings) return;
|
||||
setListingsToggleEnabled((enabled) => !enabled);
|
||||
}, [canSeeListings]);
|
||||
const selectedPostcodeParam =
|
||||
selectedHexagon?.type === 'postcode'
|
||||
? selectedHexagon.id
|
||||
: (pendingInitialPostcode ?? undefined);
|
||||
|
||||
useUrlSync(
|
||||
mapData.currentView,
|
||||
|
|
@ -506,7 +525,10 @@ export default function MapPage({
|
|||
entries,
|
||||
shareCode,
|
||||
activeOverlays,
|
||||
basemap
|
||||
basemap,
|
||||
crimeTypes,
|
||||
selectedPostcodeParam,
|
||||
colorOpacity
|
||||
);
|
||||
|
||||
useInitialMapPageView(mapData, initialViewState, initialTab, setRightPaneTab);
|
||||
|
|
@ -520,6 +542,7 @@ export default function MapPage({
|
|||
setMobileDrawerOpen(true);
|
||||
consumePendingLocationSearchFlyTo();
|
||||
},
|
||||
onSettled: () => setPendingInitialPostcode(null),
|
||||
});
|
||||
useHorizontalSwipeNavigationGuard();
|
||||
useMobileBackNavigationGuard(isMobile);
|
||||
|
|
@ -581,16 +604,22 @@ export default function MapPage({
|
|||
entries,
|
||||
shareCode,
|
||||
activeOverlays,
|
||||
basemap
|
||||
basemap,
|
||||
crimeTypes,
|
||||
selectedPostcodeParam,
|
||||
colorOpacity
|
||||
).toString(),
|
||||
[
|
||||
activeOverlays,
|
||||
basemap,
|
||||
crimeTypes,
|
||||
colorOpacity,
|
||||
entries,
|
||||
features,
|
||||
filters,
|
||||
rightPaneTab,
|
||||
selectedPOICategories,
|
||||
selectedPostcodeParam,
|
||||
shareCode,
|
||||
shareAndSaveView,
|
||||
]
|
||||
|
|
@ -630,7 +659,9 @@ export default function MapPage({
|
|||
ogMode={ogMode}
|
||||
travelTimeEntries={entries}
|
||||
activeOverlays={activeOverlays}
|
||||
activeCrimeTypes={crimeTypes}
|
||||
basemap={basemap}
|
||||
colorOpacity={colorOpacity}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -691,8 +722,12 @@ export default function MapPage({
|
|||
<OverlayPane
|
||||
selectedOverlays={activeOverlays}
|
||||
onOverlaysChange={setActiveOverlays}
|
||||
selectedCrimeTypes={crimeTypes}
|
||||
onCrimeTypesChange={setCrimeTypes}
|
||||
basemap={basemap}
|
||||
onBasemapChange={setBasemap}
|
||||
colorOpacity={colorOpacity}
|
||||
onColorOpacityChange={setColorOpacity}
|
||||
zoomedIn={overlaysZoomedIn}
|
||||
onClose={() => setOverlayPaneOpen(false)}
|
||||
/>
|
||||
|
|
@ -827,7 +862,9 @@ export default function MapPage({
|
|||
mapData={mapData}
|
||||
pois={pois}
|
||||
activeOverlays={activeOverlays}
|
||||
activeCrimeTypes={crimeTypes}
|
||||
basemap={basemap}
|
||||
colorOpacity={colorOpacity}
|
||||
mapViewFeature={mapViewFeature}
|
||||
filterRange={filterRange}
|
||||
viewSource={viewSource}
|
||||
|
|
@ -847,7 +884,8 @@ export default function MapPage({
|
|||
currentLocation={currentLocation}
|
||||
actualListings={visibleActualListings}
|
||||
actualListingsEnabled={actualListingsEnabled}
|
||||
onToggleActualListings={__DEV__ ? handleToggleActualListings : undefined}
|
||||
actualListingsLoading={actualListingsLoading}
|
||||
onToggleActualListings={canSeeListings ? handleToggleActualListings : undefined}
|
||||
travelTimeEntries={entries}
|
||||
bottomScreenInset={mobileBottomSheetHeight}
|
||||
onBottomSheetCoveredHeightChange={setMobileBottomSheetHeight}
|
||||
|
|
@ -898,7 +936,9 @@ export default function MapPage({
|
|||
mapData={mapData}
|
||||
pois={pois}
|
||||
activeOverlays={activeOverlays}
|
||||
activeCrimeTypes={crimeTypes}
|
||||
basemap={basemap}
|
||||
colorOpacity={colorOpacity}
|
||||
mapViewFeature={mapViewFeature}
|
||||
filterRange={filterRange}
|
||||
viewSource={viewSource}
|
||||
|
|
@ -918,7 +958,8 @@ export default function MapPage({
|
|||
currentLocation={currentLocation}
|
||||
actualListings={visibleActualListings}
|
||||
actualListingsEnabled={actualListingsEnabled}
|
||||
onToggleActualListings={__DEV__ ? handleToggleActualListings : undefined}
|
||||
actualListingsLoading={actualListingsLoading}
|
||||
onToggleActualListings={canSeeListings ? handleToggleActualListings : undefined}
|
||||
travelTimeEntries={entries}
|
||||
densityLabel={densityLabel}
|
||||
totalCount={hasActiveFilters ? filterCounts.total : undefined}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import type { PointerEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import { TabButton } from '../ui/TabButton';
|
||||
|
||||
const MIN_VISIBLE_PANEL_HEIGHT_PX = 104;
|
||||
|
||||
function clampDragOffset(panel: HTMLElement | null, offset: number): number {
|
||||
const panelHeight = panel?.offsetHeight || window.innerHeight * 0.9;
|
||||
const maxOffset = Math.max(0, panelHeight - MIN_VISIBLE_PANEL_HEIGHT_PX);
|
||||
return Math.min(maxOffset, Math.max(0, offset));
|
||||
}
|
||||
|
||||
interface MobileDrawerProps {
|
||||
onClose: () => void;
|
||||
renderArea: () => React.ReactNode;
|
||||
|
|
@ -22,6 +31,12 @@ export default function MobileDrawer({
|
|||
}: MobileDrawerProps) {
|
||||
const { t } = useTranslation();
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const dragStartYRef = useRef(0);
|
||||
const dragStartOffsetRef = useRef(0);
|
||||
const dragOffsetRef = useRef(0);
|
||||
const isDraggingRef = useRef(false);
|
||||
const [dragOffset, setDragOffset] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const panel = panelRef.current;
|
||||
|
|
@ -42,6 +57,13 @@ export default function MobileDrawer({
|
|||
};
|
||||
}, [onPanelRectChange]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const panel = panelRef.current;
|
||||
if (!panel || !onPanelRectChange) return;
|
||||
|
||||
onPanelRectChange(panel.getBoundingClientRect());
|
||||
}, [dragOffset, onPanelRectChange]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
|
|
@ -51,16 +73,66 @@ export default function MobileDrawer({
|
|||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [onClose]);
|
||||
|
||||
const handleDragStart = useCallback((event: PointerEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
dragStartYRef.current = event.clientY;
|
||||
dragStartOffsetRef.current = dragOffsetRef.current;
|
||||
isDraggingRef.current = true;
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragMove = useCallback((event: PointerEvent<HTMLElement>) => {
|
||||
if (!isDraggingRef.current) return;
|
||||
|
||||
const nextOffset = clampDragOffset(
|
||||
panelRef.current,
|
||||
dragStartOffsetRef.current + event.clientY - dragStartYRef.current
|
||||
);
|
||||
dragOffsetRef.current = nextOffset;
|
||||
setDragOffset(nextOffset);
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
if (!isDraggingRef.current) return;
|
||||
|
||||
dragStartYRef.current = 0;
|
||||
dragStartOffsetRef.current = dragOffsetRef.current;
|
||||
isDraggingRef.current = false;
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div data-tutorial="right-pane" className="fixed inset-0 z-50 flex flex-col">
|
||||
{/* Backdrop — top 10% */}
|
||||
<div className="h-[10%] bg-black/50" onClick={onClose} />
|
||||
<div data-tutorial="right-pane" className="pointer-events-none fixed inset-0 z-50 flex flex-col">
|
||||
<div className="h-[10%] shrink-0" aria-hidden="true" />
|
||||
|
||||
{/* Panel — bottom 90% */}
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="h-[90%] bg-white dark:bg-navy-950 rounded-t-2xl flex flex-col shadow-xl overflow-hidden"
|
||||
className="pointer-events-auto h-[90%] bg-white dark:bg-navy-950 rounded-t-2xl flex flex-col border-t border-x border-warm-300 ring-1 ring-navy-950/10 shadow-2xl shadow-navy-950/45 dark:border-navy-600 dark:ring-white/10 dark:shadow-black/60 overflow-hidden"
|
||||
style={{
|
||||
transform: dragOffset > 0 ? `translateY(${dragOffset}px)` : undefined,
|
||||
transition: isDragging ? undefined : 'transform 180ms ease',
|
||||
willChange: isDragging ? 'transform' : undefined,
|
||||
}}
|
||||
>
|
||||
<div className="relative shrink-0 px-4 py-2">
|
||||
<div
|
||||
className="absolute inset-x-0 top-1/2 z-10 h-11 -translate-y-1/2 touch-none"
|
||||
data-mobile-drawer-drag-handle
|
||||
onPointerDown={handleDragStart}
|
||||
onPointerMove={handleDragMove}
|
||||
onPointerUp={handleDragEnd}
|
||||
onPointerCancel={handleDragEnd}
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none flex w-full items-center justify-center"
|
||||
role="presentation"
|
||||
>
|
||||
<span className="h-1.5 w-12 rounded-full bg-warm-300 dark:bg-navy-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab bar + close */}
|
||||
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm shrink-0">
|
||||
<TabButton
|
||||
|
|
|
|||
|
|
@ -1,16 +1,26 @@
|
|||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BASEMAPS, type BasemapId } from '../../lib/basemaps';
|
||||
import { OVERLAYS, type OverlayDefinition, type OverlayId } from '../../lib/overlays';
|
||||
import { CRIME_TYPES, CRIME_TYPE_VALUES } from '../../lib/crime-types';
|
||||
import { PillToggle } from '../ui/PillToggle';
|
||||
import { IconButton } from '../ui/IconButton';
|
||||
import { Slider } from '../ui/Slider';
|
||||
import InfoPopup from '../ui/InfoPopup';
|
||||
import { CloseIcon, InfoIcon } from '../ui/icons';
|
||||
import { colorOpacityToPercent, normalizeColorOpacity } from '../../lib/color-opacity';
|
||||
|
||||
const CRIME_OVERLAY_ID: OverlayId = 'crime-hotspots';
|
||||
|
||||
interface OverlayPaneProps {
|
||||
selectedOverlays: Set<OverlayId>;
|
||||
onOverlaysChange: (overlays: Set<OverlayId>) => void;
|
||||
selectedCrimeTypes: Set<string>;
|
||||
onCrimeTypesChange: (crimeTypes: Set<string>) => void;
|
||||
basemap: BasemapId;
|
||||
onBasemapChange: (basemap: BasemapId) => void;
|
||||
colorOpacity: number;
|
||||
onColorOpacityChange: (opacity: number) => void;
|
||||
zoomedIn: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
|
@ -18,11 +28,16 @@ interface OverlayPaneProps {
|
|||
export default function OverlayPane({
|
||||
selectedOverlays,
|
||||
onOverlaysChange,
|
||||
selectedCrimeTypes,
|
||||
onCrimeTypesChange,
|
||||
basemap,
|
||||
onBasemapChange,
|
||||
colorOpacity,
|
||||
onColorOpacityChange,
|
||||
zoomedIn,
|
||||
onClose,
|
||||
}: OverlayPaneProps) {
|
||||
const { t } = useTranslation();
|
||||
const [infoOverlay, setInfoOverlay] = useState<OverlayDefinition | null>(null);
|
||||
|
||||
const toggleOverlay = (overlay: OverlayId) => {
|
||||
|
|
@ -35,12 +50,31 @@ export default function OverlayPane({
|
|||
onOverlaysChange(next);
|
||||
};
|
||||
|
||||
const crimeOverlayActive = selectedOverlays.has(CRIME_OVERLAY_ID);
|
||||
|
||||
const toggleCrimeType = (value: string) => {
|
||||
const next = new Set(selectedCrimeTypes);
|
||||
if (next.has(value)) {
|
||||
next.delete(value);
|
||||
} else {
|
||||
next.add(value);
|
||||
}
|
||||
onCrimeTypesChange(next);
|
||||
};
|
||||
|
||||
const selectAllCrimeTypes = () => onCrimeTypesChange(new Set(CRIME_TYPE_VALUES));
|
||||
const selectNoCrimeTypes = () => onCrimeTypesChange(new Set());
|
||||
|
||||
const selectNone = () => onOverlaysChange(new Set());
|
||||
|
||||
const showZoomWarning = !zoomedIn && selectedOverlays.size > 0;
|
||||
const colorOpacityPercent = colorOpacityToPercent(colorOpacity);
|
||||
const handleColorOpacityChange = ([value]: number[]) => {
|
||||
onColorOpacityChange(normalizeColorOpacity((value ?? colorOpacityPercent) / 100));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-white shadow-lg dark:bg-warm-900">
|
||||
<div className="flex 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">
|
||||
|
|
@ -78,7 +112,7 @@ export default function OverlayPane({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-contain border-t border-warm-200 px-3 py-3 dark:border-warm-700">
|
||||
<div className="min-h-0 space-y-4 overflow-y-auto overscroll-contain border-t border-warm-200 px-3 py-3 dark:border-warm-700">
|
||||
<div>
|
||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wide text-warm-400 dark:text-warm-500">
|
||||
Base map
|
||||
|
|
@ -96,6 +130,25 @@ export default function OverlayPane({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-warm-400 dark:text-warm-500">
|
||||
Colour opacity
|
||||
</span>
|
||||
<span className="text-[10px] font-medium tabular-nums text-warm-500 dark:text-warm-400">
|
||||
{colorOpacityPercent}%
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={10}
|
||||
max={100}
|
||||
step={5}
|
||||
value={[colorOpacityPercent]}
|
||||
onValueChange={handleColorOpacityChange}
|
||||
aria-label="Colour opacity"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wide text-warm-400 dark:text-warm-500">
|
||||
Data overlays
|
||||
|
|
@ -120,6 +173,49 @@ export default function OverlayPane({
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{crimeOverlayActive && (
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-warm-400 dark:text-warm-500">
|
||||
{t('filters.crimeType')}
|
||||
</span>
|
||||
<span className="text-[10px] text-warm-400 dark:text-warm-500">
|
||||
{selectedCrimeTypes.size}/{CRIME_TYPES.length}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
onClick={selectAllCrimeTypes}
|
||||
className="rounded border border-warm-300 px-1.5 py-0.5 text-[10px] text-warm-600 hover:bg-warm-50 dark:border-warm-700 dark:text-warm-400 dark:hover:bg-warm-700"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={selectNoCrimeTypes}
|
||||
className="rounded border border-warm-300 px-1.5 py-0.5 text-[10px] text-warm-600 hover:bg-warm-50 dark:border-warm-700 dark:text-warm-400 dark:hover:bg-warm-700"
|
||||
>
|
||||
None
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{CRIME_TYPES.map((crime) => (
|
||||
<label
|
||||
key={crime.value}
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 text-xs text-warm-700 hover:bg-warm-50 dark:text-warm-300 dark:hover:bg-warm-800"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCrimeTypes.has(crime.value)}
|
||||
onChange={() => toggleCrimeType(crime.value)}
|
||||
className="h-3.5 w-3.5 shrink-0 rounded border-warm-300 text-amber-600 focus:ring-1 focus:ring-amber-500 dark:border-warm-600 dark:bg-warm-800"
|
||||
/>
|
||||
<span>{crime.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{infoOverlay && (
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import type { SearchedLocation } from '../LocationSearch';
|
|||
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
|
||||
import { EyeIcon } from '../../ui/icons/EyeIcon';
|
||||
import { HouseIcon } from '../../ui/icons/HouseIcon';
|
||||
import { SpinnerIcon } from '../../ui/icons/SpinnerIcon';
|
||||
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
|
||||
import type { MapFlyTo, PaneResizeHandlers } from './types';
|
||||
import { MapFallback, PaneFallback } from './Fallbacks';
|
||||
|
|
@ -40,7 +41,9 @@ interface DesktopMapPageProps {
|
|||
mapData: MapData;
|
||||
pois: POI[];
|
||||
activeOverlays: Set<OverlayId>;
|
||||
activeCrimeTypes: Set<string>;
|
||||
basemap: BasemapId;
|
||||
colorOpacity: number;
|
||||
mapViewFeature: string | null;
|
||||
filterRange: [number, number] | null;
|
||||
viewSource: 'drag' | 'eye' | null;
|
||||
|
|
@ -60,6 +63,7 @@ interface DesktopMapPageProps {
|
|||
currentLocation: { lat: number; lng: number } | null;
|
||||
actualListings: ActualListing[];
|
||||
actualListingsEnabled: boolean;
|
||||
actualListingsLoading: boolean;
|
||||
onToggleActualListings?: () => void;
|
||||
travelTimeEntries: TravelTimeEntry[];
|
||||
densityLabel: string;
|
||||
|
|
@ -93,7 +97,9 @@ export function DesktopMapPage({
|
|||
mapData,
|
||||
pois,
|
||||
activeOverlays,
|
||||
activeCrimeTypes,
|
||||
basemap,
|
||||
colorOpacity,
|
||||
mapViewFeature,
|
||||
filterRange,
|
||||
viewSource,
|
||||
|
|
@ -113,6 +119,7 @@ export function DesktopMapPage({
|
|||
currentLocation,
|
||||
actualListings,
|
||||
actualListingsEnabled,
|
||||
actualListingsLoading,
|
||||
onToggleActualListings,
|
||||
travelTimeEntries,
|
||||
densityLabel,
|
||||
|
|
@ -187,7 +194,9 @@ export function DesktopMapPage({
|
|||
usePostcodeView={mapData.usePostcodeView}
|
||||
pois={pois}
|
||||
activeOverlays={activeOverlays}
|
||||
activeCrimeTypes={activeCrimeTypes}
|
||||
basemap={basemap}
|
||||
colorOpacity={colorOpacity}
|
||||
onViewChange={mapData.handleViewChange}
|
||||
viewFeature={mapViewFeature}
|
||||
colorRange={mapData.colorRange}
|
||||
|
|
@ -223,11 +232,16 @@ export function DesktopMapPage({
|
|||
type="button"
|
||||
onClick={onToggleActualListings}
|
||||
aria-pressed={actualListingsEnabled}
|
||||
aria-busy={actualListingsLoading}
|
||||
aria-label={actualListingsEnabled ? 'Hide actual listings' : 'Show actual listings'}
|
||||
title={actualListingsEnabled ? 'Hide actual listings' : 'Show actual listings'}
|
||||
className={`flex items-center gap-2 rounded-lg bg-white px-3 py-2 shadow-lg dark:bg-warm-800 ${actualListingsEnabled ? 'text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300' : 'text-warm-500 hover:text-red-600 dark:text-warm-400 dark:hover:text-red-400'}`}
|
||||
>
|
||||
<HouseIcon className="h-5 w-5" />
|
||||
{actualListingsLoading ? (
|
||||
<SpinnerIcon className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<HouseIcon className="h-5 w-5" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
Listings{actualListingsEnabled ? ` (${actualListings.length})` : ''}
|
||||
</span>
|
||||
|
|
@ -250,7 +264,7 @@ export function DesktopMapPage({
|
|||
</button>
|
||||
</div>
|
||||
{overlayPaneOpen && (
|
||||
<div className="absolute bottom-28 right-4 z-10 flex h-[260px] 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-16 right-4 z-10 flex max-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">
|
||||
{overlayPane}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import MobileBottomSheet from '../MobileBottomSheet';
|
|||
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
|
||||
import { EyeIcon } from '../../ui/icons/EyeIcon';
|
||||
import { HouseIcon } from '../../ui/icons/HouseIcon';
|
||||
import { SpinnerIcon } from '../../ui/icons/SpinnerIcon';
|
||||
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
|
||||
import type { MapFlyTo } from './types';
|
||||
import { MapFallback, PaneFallback } from './Fallbacks';
|
||||
|
|
@ -31,7 +32,9 @@ interface MobileMapPageProps {
|
|||
mapData: MapData;
|
||||
pois: POI[];
|
||||
activeOverlays: Set<OverlayId>;
|
||||
activeCrimeTypes: Set<string>;
|
||||
basemap: BasemapId;
|
||||
colorOpacity: number;
|
||||
mapViewFeature: string | null;
|
||||
filterRange: [number, number] | null;
|
||||
viewSource: 'drag' | 'eye' | null;
|
||||
|
|
@ -51,6 +54,7 @@ interface MobileMapPageProps {
|
|||
currentLocation: { lat: number; lng: number } | null;
|
||||
actualListings: ActualListing[];
|
||||
actualListingsEnabled: boolean;
|
||||
actualListingsLoading: boolean;
|
||||
onToggleActualListings?: () => void;
|
||||
travelTimeEntries: TravelTimeEntry[];
|
||||
bottomScreenInset: number;
|
||||
|
|
@ -81,7 +85,9 @@ export function MobileMapPage({
|
|||
mapData,
|
||||
pois,
|
||||
activeOverlays,
|
||||
activeCrimeTypes,
|
||||
basemap,
|
||||
colorOpacity,
|
||||
mapViewFeature,
|
||||
filterRange,
|
||||
viewSource,
|
||||
|
|
@ -101,6 +107,7 @@ export function MobileMapPage({
|
|||
currentLocation,
|
||||
actualListings,
|
||||
actualListingsEnabled,
|
||||
actualListingsLoading,
|
||||
onToggleActualListings,
|
||||
travelTimeEntries,
|
||||
bottomScreenInset,
|
||||
|
|
@ -138,7 +145,9 @@ export function MobileMapPage({
|
|||
usePostcodeView={mapData.usePostcodeView}
|
||||
pois={pois}
|
||||
activeOverlays={activeOverlays}
|
||||
activeCrimeTypes={activeCrimeTypes}
|
||||
basemap={basemap}
|
||||
colorOpacity={colorOpacity}
|
||||
onViewChange={mapData.handleViewChange}
|
||||
viewFeature={mapViewFeature}
|
||||
colorRange={mapData.colorRange}
|
||||
|
|
@ -163,7 +172,6 @@ export function MobileMapPage({
|
|||
actualListings={actualListings}
|
||||
bounds={mapData.bounds}
|
||||
hideLegend
|
||||
hideLocationSearch={mobileDrawerOpen && !!selectedHexagonId}
|
||||
travelTimeEntries={travelTimeEntries}
|
||||
bottomScreenInset={bottomScreenInset}
|
||||
/>
|
||||
|
|
@ -177,10 +185,15 @@ export function MobileMapPage({
|
|||
onClick={onToggleActualListings}
|
||||
className={`rounded-lg bg-white p-2 shadow-lg dark:bg-warm-800 ${actualListingsEnabled ? 'text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300' : 'text-warm-500 hover:text-red-600 dark:text-warm-400 dark:hover:text-red-400'}`}
|
||||
aria-pressed={actualListingsEnabled}
|
||||
aria-busy={actualListingsLoading}
|
||||
aria-label={actualListingsEnabled ? 'Hide actual listings' : 'Show actual listings'}
|
||||
title={actualListingsEnabled ? 'Hide actual listings' : 'Show actual listings'}
|
||||
>
|
||||
<HouseIcon className="h-5 w-5" />
|
||||
{actualListingsLoading ? (
|
||||
<SpinnerIcon className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<HouseIcon className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
|
|
@ -200,7 +213,7 @@ export function MobileMapPage({
|
|||
</div>
|
||||
|
||||
{overlayPaneOpen && (
|
||||
<div className="absolute top-24 right-3 left-3 z-20 flex h-[260px] 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 max-h-[60dvh] 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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,9 @@ interface ScreenshotMapPageProps {
|
|||
ogMode?: boolean;
|
||||
travelTimeEntries: TravelTimeEntry[];
|
||||
activeOverlays: Set<OverlayId>;
|
||||
activeCrimeTypes: Set<string>;
|
||||
basemap: BasemapId;
|
||||
colorOpacity: number;
|
||||
}
|
||||
|
||||
export function ScreenshotMapPage({
|
||||
|
|
@ -35,7 +37,9 @@ export function ScreenshotMapPage({
|
|||
ogMode,
|
||||
travelTimeEntries,
|
||||
activeOverlays,
|
||||
activeCrimeTypes,
|
||||
basemap,
|
||||
colorOpacity,
|
||||
}: ScreenshotMapPageProps) {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
|
|
@ -46,7 +50,9 @@ export function ScreenshotMapPage({
|
|||
usePostcodeView={mapData.usePostcodeView}
|
||||
pois={[]}
|
||||
activeOverlays={activeOverlays}
|
||||
activeCrimeTypes={activeCrimeTypes}
|
||||
basemap={basemap}
|
||||
colorOpacity={colorOpacity}
|
||||
onViewChange={mapData.handleViewChange}
|
||||
viewFeature={mapViewFeature}
|
||||
colorRange={mapData.colorRange}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ interface UseInitialPostcodeSelectionOptions {
|
|||
lng?: number
|
||||
) => void;
|
||||
onOpenMobileDrawer: (target: { lat: number; lng: number; zoom: number }) => void;
|
||||
onSettled?: () => void;
|
||||
}
|
||||
|
||||
export function useInitialPostcodeSelection({
|
||||
|
|
@ -43,15 +44,11 @@ export function useInitialPostcodeSelection({
|
|||
flyTo,
|
||||
onLocationSearch,
|
||||
onOpenMobileDrawer,
|
||||
onSettled,
|
||||
}: UseInitialPostcodeSelectionOptions) {
|
||||
useEffect(() => {
|
||||
if (!initialPostcode) return;
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete('pc');
|
||||
const newUrl = params.toString() ? `/dashboard?${params}` : '/dashboard';
|
||||
window.history.replaceState(window.history.state, '', newUrl);
|
||||
|
||||
fetch(`/api/postcode/${encodeURIComponent(initialPostcode)}`, authHeaders())
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Postcode not found');
|
||||
|
|
@ -77,6 +74,9 @@ export function useInitialPostcodeSelection({
|
|||
)
|
||||
.catch(() => {
|
||||
// Silently fail because the postcode might no longer exist.
|
||||
})
|
||||
.finally(() => {
|
||||
onSettled?.();
|
||||
});
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,9 @@ export interface MapPageProps {
|
|||
initialViewState: ViewState;
|
||||
initialPOICategories: Set<string>;
|
||||
initialOverlays?: Set<OverlayId>;
|
||||
initialCrimeTypes?: Set<string>;
|
||||
initialBasemap?: BasemapId;
|
||||
initialColorOpacity?: number;
|
||||
initialTab: 'properties' | 'area';
|
||||
initialLoading: boolean;
|
||||
theme: 'light' | 'dark';
|
||||
|
|
@ -43,7 +45,7 @@ export interface MapPageProps {
|
|||
initialTravelTime?: TravelTimeInitial;
|
||||
initialPostcode?: string;
|
||||
shareCode?: string;
|
||||
user?: { id: string; subscription: string; isAdmin?: boolean } | null;
|
||||
user?: { id: string; subscription: string; isAdmin?: boolean; canSeeListings?: boolean } | null;
|
||||
onLoginClick: () => void;
|
||||
onRegisterClick: () => void;
|
||||
onCheckoutLoginClick?: (returnPath?: string) => void;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue