seems fine

This commit is contained in:
Andras Schmelczer 2026-05-05 22:29:28 +01:00
parent 48983e3b4b
commit 7a1696541f
37 changed files with 4999 additions and 1242 deletions

View file

@ -33,7 +33,6 @@ interface FeatureBrowserProps {
onClearOpenInfoFeature?: () => void;
travelTimeEntries: TravelTimeEntry[];
onAddTravelTimeEntry: (mode: TransportMode) => void;
isLicensed: boolean;
}
export default function FeatureBrowser({
@ -47,7 +46,6 @@ export default function FeatureBrowser({
onClearOpenInfoFeature,
travelTimeEntries: _travelTimeEntries,
onAddTravelTimeEntry,
isLicensed,
}: FeatureBrowserProps) {
const { t } = useTranslation();
const modes = useTranslatedModes();
@ -107,6 +105,11 @@ export default function FeatureBrowser({
onChange={setSearch}
placeholder={t('filters.searchFeatures')}
/>
{!search && (
<p className="mt-2 px-1 text-xs leading-relaxed text-warm-500 dark:text-warm-400">
{t('filters.chooseFilters')}
</p>
)}
</div>
<div>
{mergedGrouped.map((group) => {
@ -200,10 +203,6 @@ export default function FeatureBrowser({
description={search ? t('filters.tryDifferentSearch') : t('filters.removeFilterHint')}
className="px-3 py-4"
/>
) : isLicensed ? (
<p className="flex-1 flex items-center justify-center px-4 py-6 text-xs text-center text-warm-400 dark:text-warm-500">
{t('filters.chooseFilters')}
</p>
) : null}
</div>
{infoFeature && (

View file

@ -1082,7 +1082,6 @@ export default memo(function Filters({
onClearOpenInfoFeature={onClearOpenInfoFeature}
travelTimeEntries={travelTimeEntries}
onAddTravelTimeEntry={handleAddTravelTimeAndScroll}
isLicensed={isLicensed}
/>
{!isLicensed && (
<div className="shrink-0 flex flex-col items-center px-5 pt-4 pb-0 border-t border-warm-200 dark:border-warm-700">

View file

@ -11,6 +11,10 @@ interface JourneyInstructionsProps {
entries: TravelTimeEntry[];
/** When set, shown as a subtitle (e.g. the central postcode for a hexagon) */
label?: string;
/** Preloaded journey rows, useful for static demos that should not call the API. */
presetJourneys?: JourneyInstructionPreset[];
className?: string;
showGoogleMapsLink?: boolean;
}
interface JourneyData {
@ -24,6 +28,16 @@ interface JourneyData {
loading: boolean;
}
export interface JourneyInstructionPreset {
slug: string;
label: string;
legs: JourneyLeg[] | null;
/** Median (50th percentile) total travel time, including waiting. */
minutes: number | null;
/** Best-case (5th percentile) total travel time. */
bestMinutes?: number | null;
}
// Official TfL line colors + other known London transit
const ROUTE_COLORS: Record<string, { color: string; darkText?: boolean }> = {
Bakerloo: { color: '#B36305' },
@ -164,14 +178,23 @@ export default function JourneyInstructions({
postcode,
entries,
label,
presetJourneys,
className,
showGoogleMapsLink = true,
}: JourneyInstructionsProps) {
const { t } = useTranslation();
const [journeys, setJourneys] = useState<JourneyData[]>([]);
// Only transit entries with a destination set
const transitEntries = entries.filter((e) => e.mode === 'transit' && e.slug !== '');
const hasPresetJourneys = Boolean(presetJourneys?.length);
useEffect(() => {
if (hasPresetJourneys) {
setJourneys([]);
return;
}
if (transitEntries.length === 0) {
setJourneys([]);
return;
@ -227,18 +250,29 @@ export default function JourneyInstructions({
});
return () => controller.abort();
}, [postcode, transitEntries.map((e) => e.slug).join(',')]); // eslint-disable-line react-hooks/exhaustive-deps
}, [postcode, hasPresetJourneys, transitEntries.map((e) => e.slug).join(',')]); // eslint-disable-line react-hooks/exhaustive-deps
if (transitEntries.length === 0) return null;
if (transitEntries.length === 0 && !hasPresetJourneys) return null;
const displayedJourneys: JourneyData[] = hasPresetJourneys
? (presetJourneys ?? []).map((journey) => ({
slug: journey.slug,
label: journey.label,
legs: journey.legs,
minutes: journey.minutes,
bestMinutes: journey.bestMinutes ?? null,
loading: false,
}))
: journeys;
return (
<div className="mx-3 mt-2 space-y-2">
<div className={className ?? 'mx-3 mt-2 space-y-2'}>
{label && (
<div className="text-xs text-warm-500 dark:text-warm-400">
{t('areaPane.journeysFrom', { label })}
</div>
)}
{journeys.map((j) => {
{displayedJourneys.map((j) => {
const displayLegs = j.legs ? invertLegs(j.legs) : null;
const legSum = j.legs ? j.legs.reduce((sum, l) => sum + l.minutes, 0) : 0;
const totalMin = j.minutes ?? legSum;
@ -267,27 +301,29 @@ export default function JourneyInstructions({
{displayLegs.map((leg, i) => (
<TimelineLeg key={i} leg={leg} isLast={i === displayLegs.length - 1} />
))}
<a
href={googleMapsUrl(postcode, j.label || j.slug)}
target="_blank"
rel="noopener noreferrer"
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
>
{t('areaPane.viewOnGoogleMaps')}
<svg
className="w-3 h-3"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
{showGoogleMapsLink && (
<a
href={googleMapsUrl(postcode, j.label || j.slug)}
target="_blank"
rel="noopener noreferrer"
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
>
<path
d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5M7.5 1.5H10.5V4.5M10.5 1.5L5.5 6.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</a>
{t('areaPane.viewOnGoogleMaps')}
<svg
className="w-3 h-3"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path
d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5M7.5 1.5H10.5V4.5M10.5 1.5L5.5 6.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</a>
)}
</div>
) : j.minutes != null ? (
<div>
@ -297,27 +333,29 @@ export default function JourneyInstructions({
{t('areaPane.walk')} · {j.minutes} {t('common.min')}
</span>
</div>
<a
href={googleMapsUrl(postcode, j.label || j.slug)}
target="_blank"
rel="noopener noreferrer"
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
>
{t('areaPane.viewOnGoogleMaps')}
<svg
className="w-3 h-3"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
{showGoogleMapsLink && (
<a
href={googleMapsUrl(postcode, j.label || j.slug)}
target="_blank"
rel="noopener noreferrer"
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
>
<path
d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5M7.5 1.5H10.5V4.5M10.5 1.5L5.5 6.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</a>
{t('areaPane.viewOnGoogleMaps')}
<svg
className="w-3 h-3"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path
d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5M7.5 1.5H10.5V4.5M10.5 1.5L5.5 6.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</a>
)}
</div>
) : (
<span className="text-xs text-warm-500 dark:text-warm-400">

View file

@ -13,6 +13,10 @@ export interface SearchedLocation {
geometry: PostcodeGeometry;
latitude: number;
longitude: number;
markerLatitude?: number;
markerLongitude?: number;
openProperties?: boolean;
focusAddress?: string;
}
const ZOOM_FOR_TYPE: Record<string, number> = {
@ -81,6 +85,46 @@ export default function LocationSearch({
return;
}
if (result.type === 'address') {
setError(null);
setLoading(true);
search.close();
try {
const res = await fetch(
`/api/postcode/${encodeURIComponent(result.postcode)}`,
authHeaders()
);
if (!res.ok) {
setError(t('locationSearch.postcodeNotFound'));
return;
}
const json: {
postcode: string;
latitude: number;
longitude: number;
geometry: PostcodeGeometry;
} = await res.json();
onFlyTo(result.lat, result.lon, 17);
onLocationSearched?.({
postcode: json.postcode,
geometry: json.geometry,
latitude: result.lat,
longitude: result.lon,
markerLatitude: result.lat,
markerLongitude: result.lon,
openProperties: true,
focusAddress: result.address,
});
search.clear();
if (isMobile) setExpanded(false);
} catch {
setError(t('locationSearch.lookupFailed'));
} finally {
setLoading(false);
}
return;
}
// Postcode — fetch geometry
setError(null);
setLoading(true);

View file

@ -48,6 +48,8 @@ interface MapProps {
filterRange: [number, number] | null;
viewSource: 'drag' | 'eye' | null;
onCancelPin: () => void;
onResetPreviewScale?: () => void;
canResetPreviewScale?: boolean;
features: FeatureMeta[];
selectedHexagonId: string | null;
hoveredHexagonId: string | null;
@ -77,6 +79,49 @@ interface Dimensions {
height: number;
}
interface DeckWithPrivateDraw {
_drawLayers?: (
redrawReason: string,
renderOptions?: { viewports?: unknown[]; [key: string]: unknown }
) => unknown;
__propertyMapNullViewportPatch?: boolean;
}
function patchNullViewportDraw(overlay: MapboxOverlay) {
const deck = (overlay as unknown as { _deck?: DeckWithPrivateDraw })._deck;
if (!deck || deck.__propertyMapNullViewportPatch || typeof deck._drawLayers !== 'function') {
return;
}
const drawLayers = deck._drawLayers.bind(deck);
deck._drawLayers = (redrawReason, renderOptions) => {
const viewports = renderOptions?.viewports;
if (viewports) {
// Split-route startup can hand deck.gl a transient null viewport before MapLibre has sized the map.
const nonNullViewports = viewports.filter(Boolean);
if (nonNullViewports.length === 0) return;
if (nonNullViewports.length !== viewports.length) {
return drawLayers(redrawReason, { ...renderOptions, viewports: nonNullViewports });
}
}
return drawLayers(redrawReason, renderOptions);
};
deck.__propertyMapNullViewportPatch = true;
}
class SafeMapboxOverlay extends MapboxOverlay {
onAdd(map: unknown) {
const element = super.onAdd(map);
patchNullViewportDraw(this);
return element;
}
setProps(props: Parameters<MapboxOverlay['setProps']>[0]) {
super.setProps(props);
patchNullViewportDraw(this);
}
}
function DeckOverlay({
layers,
getTooltip,
@ -86,10 +131,13 @@ function DeckOverlay({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getTooltip: any;
}) {
const overlay = useControl(() => new MapboxOverlay({ interleaved: true }));
const overlay = useControl(() => new SafeMapboxOverlay({ interleaved: true }));
useEffect(() => {
overlay.setProps({ layers: layers.filter(Boolean), getTooltip });
overlay.setProps({
layers: layers.filter(Boolean),
getTooltip,
});
}, [overlay, layers, getTooltip]);
return null;
@ -106,6 +154,8 @@ export default memo(function Map({
filterRange,
viewSource,
onCancelPin,
onResetPreviewScale,
canResetPreviewScale = false,
features,
selectedHexagonId,
hoveredHexagonId,
@ -311,7 +361,7 @@ export default memo(function Map({
) : null
) : (
<>
<div className="absolute top-3 left-3 right-3 z-10 flex flex-wrap items-start justify-between gap-2 pointer-events-none">
<div className="absolute top-3 left-3 right-3 z-[60] flex flex-wrap items-start justify-between gap-2 pointer-events-none">
<LocationSearch
onFlyTo={handleFlyTo}
onLocationSearched={onLocationSearched}
@ -330,6 +380,8 @@ export default memo(function Map({
range={colorRange}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
onResetScale={viewSource === 'eye' ? onResetPreviewScale : undefined}
resetScaleDisabled={!canResetPreviewScale}
mode="feature"
theme={theme}
suffix=" min"
@ -344,6 +396,8 @@ export default memo(function Map({
range={colorRange}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
onResetScale={viewSource === 'eye' ? onResetPreviewScale : undefined}
resetScaleDisabled={!canResetPreviewScale}
mode="feature"
enumValues={
colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { lazy, Suspense, useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { cellToLatLng } from 'h3-js';
import type {
@ -11,15 +11,8 @@ import type {
} from '../../types';
import type { SearchedLocation } from './LocationSearch';
import type { Page } from '../ui/Header';
import Map from './Map';
import Filters from './Filters';
import POIPane from './POIPane';
import { PropertiesPane } from './PropertiesPane';
import AreaPane from './AreaPane';
import MobileDrawer from './MobileDrawer';
import MobileBottomSheet from './MobileBottomSheet';
import MapLegend from './MapLegend';
import { MapPageSelectionPane } from './MapPageSelectionPane';
import { useMapData } from '../../hooks/useMapData';
import { usePOIData } from '../../hooks/usePOIData';
import { useFilters } from '../../hooks/useFilters';
@ -30,7 +23,6 @@ import { useAiFilters } from '../../hooks/useAiFilters';
import { useUrlSync } from '../../hooks/useUrlSync';
import { useTutorial } from '../../hooks/useTutorial';
import { getTutorialStyles } from '../../lib/tutorial-styles';
import Joyride from 'react-joyride';
import {
useTravelTime,
useTranslatedModes,
@ -44,11 +36,40 @@ import { trackEvent } from '../../lib/analytics';
import { INITIAL_VIEW_STATE } from '../../lib/consts';
import { getSchoolBackendFeatureName } from '../../lib/school-filter';
import { useLicense } from '../../hooks/useLicense';
import UpgradeModal from '../ui/UpgradeModal';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { MapPinIcon } from '../ui/icons/MapPinIcon';
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
const Map = lazy(() => import('./Map'));
const Filters = lazy(() => import('./Filters'));
const POIPane = lazy(() => import('./POIPane'));
const AreaPane = lazy(() => import('./AreaPane'));
const PropertiesPane = lazy(() =>
import('./PropertiesPane').then((module) => ({ default: module.PropertiesPane }))
);
const MobileDrawer = lazy(() => import('./MobileDrawer'));
const MapPageSelectionPane = lazy(() =>
import('./MapPageSelectionPane').then((module) => ({ default: module.MapPageSelectionPane }))
);
const UpgradeModal = lazy(() => import('../ui/UpgradeModal'));
const Joyride = lazy(() => import('react-joyride'));
function MapFallback() {
return (
<div className="flex h-full w-full items-center justify-center bg-warm-100 dark:bg-navy-950">
<SpinnerIcon className="h-8 w-8 animate-spin text-teal-600 dark:text-teal-400" />
</div>
);
}
function PaneFallback() {
return (
<div className="flex h-full w-full items-center justify-center bg-white dark:bg-navy-950">
<SpinnerIcon className="h-6 w-6 animate-spin text-teal-600 dark:text-teal-400" />
</div>
);
}
export interface ExportState {
onExport: () => void;
exporting: boolean;
@ -193,6 +214,7 @@ export default function MapPage({
features,
viewFeature,
activeFeature,
pinnedFeature,
travelTimeEntries: entries,
shareCode,
});
@ -335,8 +357,19 @@ export default function MapPage({
const handleLocationSearchResult = useCallback(
(result: SearchedLocation | null) => {
if (result) {
setCurrentLocation(null);
handleLocationSearch(result.postcode, result.geometry, result.latitude, result.longitude);
if (result.markerLatitude != null && result.markerLongitude != null) {
setCurrentLocation({ lat: result.markerLatitude, lng: result.markerLongitude });
} else {
setCurrentLocation(null);
}
handleLocationSearch(
result.postcode,
result.geometry,
result.latitude,
result.longitude,
result.openProperties,
result.focusAddress
);
if (isMobile) setMobileDrawerOpen(true);
} else {
setCurrentLocation(null);
@ -604,121 +637,134 @@ export default function MapPage({
if (screenshotMode) {
return (
<div className="h-full w-full">
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={[]}
onViewChange={mapData.handleViewChange}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={() => {}}
features={features}
selectedHexagonId={null}
hoveredHexagonId={null}
onHexagonClick={() => {}}
onHexagonHover={() => {}}
initialViewState={initialViewState}
theme={theme}
screenshotMode
ogMode={ogMode}
bounds={mapData.bounds}
travelTimeEntries={entries}
/>
<Suspense fallback={<MapFallback />}>
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={[]}
onViewChange={mapData.handleViewChange}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={() => {}}
onResetPreviewScale={mapData.handleResetPreviewScale}
canResetPreviewScale={mapData.canResetPreviewScale}
features={features}
selectedHexagonId={null}
hoveredHexagonId={null}
onHexagonClick={() => {}}
onHexagonHover={() => {}}
initialViewState={initialViewState}
theme={theme}
screenshotMode
ogMode={ogMode}
bounds={mapData.bounds}
travelTimeEntries={entries}
/>
</Suspense>
</div>
);
}
const renderAreaPane = () => (
<AreaPane
stats={areaStats}
globalFeatures={features}
loading={loadingAreaStats}
hexagonId={selectedHexagon?.id || null}
isPostcode={selectedHexagon?.type === 'postcode'}
postcodeData={
selectedHexagon?.type === 'postcode'
? mapData.postcodeData.find((f) => f.properties.postcode === selectedHexagon?.id) || null
: null
}
onViewProperties={handleViewPropertiesFromArea}
onClearFilters={hasActiveFilters ? handleClearAll : undefined}
hexagonLocation={hexagonLocation}
filters={filters}
unfilteredCount={unfilteredAreaCount}
travelTimeEntries={activeEntries}
isGroupExpanded={isAreaGroupExpanded}
onToggleGroup={toggleAreaGroup}
/>
<Suspense fallback={<PaneFallback />}>
<AreaPane
stats={areaStats}
globalFeatures={features}
loading={loadingAreaStats}
hexagonId={selectedHexagon?.id || null}
isPostcode={selectedHexagon?.type === 'postcode'}
postcodeData={
selectedHexagon?.type === 'postcode'
? mapData.postcodeData.find((f) => f.properties.postcode === selectedHexagon?.id) ||
null
: null
}
onViewProperties={handleViewPropertiesFromArea}
onClearFilters={hasActiveFilters ? handleClearAll : undefined}
hexagonLocation={hexagonLocation}
filters={filters}
unfilteredCount={unfilteredAreaCount}
travelTimeEntries={activeEntries}
isGroupExpanded={isAreaGroupExpanded}
onToggleGroup={toggleAreaGroup}
/>
</Suspense>
);
const renderPropertiesPane = () => (
<PropertiesPane
properties={properties}
total={propertiesTotal}
loading={loadingProperties}
hexagonId={selectedHexagon?.id || null}
onLoadMore={handleLoadMoreProperties}
onSaveProperty={onSaveProperty ? handleSavePropertyWithToast : undefined}
onUnsaveProperty={onUnsaveProperty}
isPropertySaved={isPropertySaved}
getSavedPropertyId={getSavedPropertyId}
/>
<Suspense fallback={<PaneFallback />}>
<PropertiesPane
properties={properties}
total={propertiesTotal}
loading={loadingProperties}
hexagonId={selectedHexagon?.id || null}
onLoadMore={handleLoadMoreProperties}
onSaveProperty={onSaveProperty ? handleSavePropertyWithToast : undefined}
onUnsaveProperty={onUnsaveProperty}
isPropertySaved={isPropertySaved}
getSavedPropertyId={getSavedPropertyId}
/>
</Suspense>
);
const renderPOIPane = () => (
<POIPane
groups={poiCategoryGroups}
selectedCategories={selectedPOICategories}
onCategoriesChange={setSelectedPOICategories}
poiCount={pois.length}
onClose={() => setPoiPaneOpen(false)}
/>
<Suspense fallback={<PaneFallback />}>
<POIPane
groups={poiCategoryGroups}
selectedCategories={selectedPOICategories}
onCategoriesChange={setSelectedPOICategories}
poiCount={pois.length}
onClose={() => setPoiPaneOpen(false)}
/>
</Suspense>
);
const renderFilters = (options?: { destinationDropdownPortal?: boolean }) => (
<Filters
features={features}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
enabledFeatures={enabledFeatures}
onAddFilter={handleAddFilter}
onRemoveFilter={handleRemoveFilter}
onFilterChange={handleFilterChange}
onDragStart={handleDragStart}
onDragChange={handleDragChange}
onDragEnd={handleDragEnd}
pinnedFeature={pinnedFeature}
onTogglePin={handleTogglePin}
openInfoFeature={pendingInfoFeature}
onClearOpenInfoFeature={onClearPendingInfoFeature}
travelTimeEntries={entries}
onTravelTimeAddEntry={handleAddEntry}
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
onTravelTimeSetDestination={handleTravelTimeSetDestination}
onTravelTimeRangeChange={handleTimeRangeChange}
onTravelTimeDragEnd={handleTravelTimeDragEnd}
onTravelTimeToggleBest={handleToggleBest}
aiFilterLoading={aiFilterLoading}
aiFilterError={aiFilterError}
aiFilterErrorType={aiFilterErrorType}
aiFilterNotes={aiFilterNotes}
aiFilterSummary={aiFilterSummary}
onAiFilterSubmit={handleAiFilterSubmit}
isLoggedIn={!!user}
onLoginRequired={onRegisterClick ?? (() => {})}
isLicensed={user?.subscription === 'licensed'}
onUpgradeClick={() => onNavigateTo('pricing')}
onResetTutorial={tutorial.resetTutorial}
filterImpacts={filterCounts.impacts}
onClearAll={handleClearAll}
onSaveSearch={onSaveSearch}
savingSearch={savingSearch}
destinationDropdownPortal={options?.destinationDropdownPortal}
/>
<Suspense fallback={<PaneFallback />}>
<Filters
features={features}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
enabledFeatures={enabledFeatures}
onAddFilter={handleAddFilter}
onRemoveFilter={handleRemoveFilter}
onFilterChange={handleFilterChange}
onDragStart={handleDragStart}
onDragChange={handleDragChange}
onDragEnd={handleDragEnd}
pinnedFeature={pinnedFeature}
onTogglePin={handleTogglePin}
openInfoFeature={pendingInfoFeature}
onClearOpenInfoFeature={onClearPendingInfoFeature}
travelTimeEntries={entries}
onTravelTimeAddEntry={handleAddEntry}
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
onTravelTimeSetDestination={handleTravelTimeSetDestination}
onTravelTimeRangeChange={handleTimeRangeChange}
onTravelTimeDragEnd={handleTravelTimeDragEnd}
onTravelTimeToggleBest={handleToggleBest}
aiFilterLoading={aiFilterLoading}
aiFilterError={aiFilterError}
aiFilterErrorType={aiFilterErrorType}
aiFilterNotes={aiFilterNotes}
aiFilterSummary={aiFilterSummary}
onAiFilterSubmit={handleAiFilterSubmit}
isLoggedIn={!!user}
onLoginRequired={onRegisterClick ?? (() => {})}
isLicensed={user?.subscription === 'licensed'}
onUpgradeClick={() => onNavigateTo('pricing')}
onResetTutorial={tutorial.resetTutorial}
filterImpacts={filterCounts.impacts}
onClearAll={handleClearAll}
onSaveSearch={onSaveSearch}
savingSearch={savingSearch}
destinationDropdownPortal={options?.destinationDropdownPortal}
/>
</Suspense>
);
const renderMobileLegend = () => {
@ -734,6 +780,8 @@ export default function MapPage({
range={mapData.colorRange}
showCancel={viewSource === 'eye'}
onCancel={handleCancelPin}
onResetScale={viewSource === 'eye' ? mapData.handleResetPreviewScale : undefined}
resetScaleDisabled={!mapData.canResetPreviewScale}
mode="feature"
theme={theme}
inline
@ -753,6 +801,8 @@ export default function MapPage({
range={mapData.colorRange}
showCancel={viewSource === 'eye'}
onCancel={handleCancelPin}
onResetScale={viewSource === 'eye' ? mapData.handleResetPreviewScale : undefined}
resetScaleDisabled={!mapData.canResetPreviewScale}
mode="feature"
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
featureName={mobileLegendMeta.name}
@ -794,34 +844,38 @@ export default function MapPage({
)}
<div className="absolute inset-0">
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={pois}
onViewChange={mapData.handleViewChange}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={handleCancelPin}
features={features}
selectedHexagonId={selectedHexagon?.id || null}
hoveredHexagonId={hoveredHexagon}
onHexagonClick={handleMobileHexagonClick}
onHexagonHover={handleHexagonHover}
initialViewState={initialViewState}
flyToRef={mapFlyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={handleLocationSearchResult}
onCurrentLocationFound={handleCurrentLocationFound}
currentLocation={currentLocation}
bounds={mapData.bounds}
hideLegend
travelTimeEntries={entries}
/>
<Suspense fallback={<MapFallback />}>
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={pois}
onViewChange={mapData.handleViewChange}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={handleCancelPin}
onResetPreviewScale={mapData.handleResetPreviewScale}
canResetPreviewScale={mapData.canResetPreviewScale}
features={features}
selectedHexagonId={selectedHexagon?.id || null}
hoveredHexagonId={hoveredHexagon}
onHexagonClick={handleMobileHexagonClick}
onHexagonHover={handleHexagonHover}
initialViewState={initialViewState}
flyToRef={mapFlyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={handleLocationSearchResult}
onCurrentLocationFound={handleCurrentLocationFound}
currentLocation={currentLocation}
bounds={mapData.bounds}
hideLegend
travelTimeEntries={entries}
/>
</Suspense>
</div>
{mapData.loading && (
@ -849,40 +903,41 @@ export default function MapPage({
</div>
)}
<MobileBottomSheet
activeCount={Object.keys(filters).length + entries.length}
legend={renderMobileLegend()}
>
<MobileBottomSheet legend={renderMobileLegend()}>
{renderFilters({ destinationDropdownPortal: false })}
</MobileBottomSheet>
{mobileDrawerOpen && selectedHexagon && (
<MobileDrawer
onClose={() => setMobileDrawerOpen(false)}
renderArea={renderAreaPane}
renderProperties={renderPropertiesPane}
tab={rightPaneTab}
onTabChange={(t) => {
if (t === 'properties') {
handlePropertiesTabClick();
} else {
setRightPaneTab(t);
}
}}
/>
<Suspense fallback={<PaneFallback />}>
<MobileDrawer
onClose={() => setMobileDrawerOpen(false)}
renderArea={renderAreaPane}
renderProperties={renderPropertiesPane}
tab={rightPaneTab}
onTabChange={(t) => {
if (t === 'properties') {
handlePropertiesTabClick();
} else {
setRightPaneTab(t);
}
}}
/>
</Suspense>
)}
{bookmarkToast}
{mapData.licenseRequired && (
<UpgradeModal
isLoggedIn={!!user}
onLoginClick={onLoginClick ?? (() => {})}
onRegisterClick={onRegisterClick ?? (() => {})}
onStartCheckout={() => license.startCheckout()}
onZoomToFreeZone={handleZoomToFreeZone}
isShareReturn={!!shareReturnViewRef.current}
/>
<Suspense fallback={null}>
<UpgradeModal
isLoggedIn={!!user}
onLoginClick={onLoginClick ?? (() => {})}
onRegisterClick={onRegisterClick ?? (() => {})}
onStartCheckout={() => license.startCheckout()}
onZoomToFreeZone={handleZoomToFreeZone}
isShareReturn={!!shareReturnViewRef.current}
/>
</Suspense>
)}
</div>
);
@ -901,17 +956,21 @@ export default function MapPage({
</div>
)}
<Joyride
steps={tutorial.steps}
run={tutorial.run}
continuous
showProgress
showSkipButton
callback={tutorial.handleCallback}
styles={getTutorialStyles(theme)}
disableScrolling
locale={{ last: 'Finish' }}
/>
{tutorial.run && (
<Suspense fallback={null}>
<Joyride
steps={tutorial.steps}
run={tutorial.run}
continuous
showProgress
showSkipButton
callback={tutorial.handleCallback}
styles={getTutorialStyles(theme)}
disableScrolling
locale={{ last: 'Finish' }}
/>
</Suspense>
)}
<div
data-tutorial="filters"
@ -932,35 +991,39 @@ export default function MapPage({
</div>
<div data-tutorial="map" className="flex-1 relative">
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={pois}
onViewChange={mapData.handleViewChange}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={handleCancelPin}
features={features}
selectedHexagonId={selectedHexagon?.id || null}
hoveredHexagonId={hoveredHexagon}
onHexagonClick={handleHexagonClick}
onHexagonHover={handleHexagonHover}
initialViewState={initialViewState}
flyToRef={mapFlyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={handleLocationSearchResult}
onCurrentLocationFound={handleCurrentLocationFound}
currentLocation={currentLocation}
bounds={mapData.bounds}
travelTimeEntries={entries}
densityLabel={densityLabel}
totalCount={hasActiveFilters ? filterCounts.total : undefined}
/>
<Suspense fallback={<MapFallback />}>
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={pois}
onViewChange={mapData.handleViewChange}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={handleCancelPin}
onResetPreviewScale={mapData.handleResetPreviewScale}
canResetPreviewScale={mapData.canResetPreviewScale}
features={features}
selectedHexagonId={selectedHexagon?.id || null}
hoveredHexagonId={hoveredHexagon}
onHexagonClick={handleHexagonClick}
onHexagonHover={handleHexagonHover}
initialViewState={initialViewState}
flyToRef={mapFlyToRef}
theme={theme}
filters={filters}
selectedPostcodeGeometry={selectedPostcodeGeometry}
onLocationSearched={handleLocationSearchResult}
onCurrentLocationFound={handleCurrentLocationFound}
currentLocation={currentLocation}
bounds={mapData.bounds}
travelTimeEntries={entries}
densityLabel={densityLabel}
totalCount={hasActiveFilters ? filterCounts.total : undefined}
/>
</Suspense>
{mapData.loading && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="flex items-center gap-2 bg-white/90 dark:bg-warm-800/90 px-4 py-2.5 rounded-lg shadow-lg pointer-events-auto">
@ -989,29 +1052,33 @@ export default function MapPage({
</div>
{selectedHexagon && (
<MapPageSelectionPane
width={rightPaneWidth}
resizeHandlers={rightPaneHandlers}
tab={rightPaneTab}
onAreaTabClick={() => setRightPaneTab('area')}
onPropertiesTabClick={handlePropertiesTabClick}
onClose={handleCloseSelection}
renderAreaPane={renderAreaPane}
renderPropertiesPane={renderPropertiesPane}
/>
<Suspense fallback={<PaneFallback />}>
<MapPageSelectionPane
width={rightPaneWidth}
resizeHandlers={rightPaneHandlers}
tab={rightPaneTab}
onAreaTabClick={() => setRightPaneTab('area')}
onPropertiesTabClick={handlePropertiesTabClick}
onClose={handleCloseSelection}
renderAreaPane={renderAreaPane}
renderPropertiesPane={renderPropertiesPane}
/>
</Suspense>
)}
{bookmarkToast}
{mapData.licenseRequired && (
<UpgradeModal
isLoggedIn={!!user}
onLoginClick={onLoginClick ?? (() => {})}
onRegisterClick={onRegisterClick ?? (() => {})}
onStartCheckout={() => license.startCheckout()}
onZoomToFreeZone={handleZoomToFreeZone}
isShareReturn={!!shareReturnViewRef.current}
/>
<Suspense fallback={null}>
<UpgradeModal
isLoggedIn={!!user}
onLoginClick={onLoginClick ?? (() => {})}
onRegisterClick={onRegisterClick ?? (() => {})}
onStartCheckout={() => license.startCheckout()}
onZoomToFreeZone={handleZoomToFreeZone}
isShareReturn={!!shareReturnViewRef.current}
/>
</Suspense>
)}
</div>
);

View file

@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
interface VisualViewportState {
height: number;
@ -8,7 +7,6 @@ interface VisualViewportState {
}
interface MobileBottomSheetProps {
activeCount: number;
children: ReactNode;
legend?: ReactNode;
}
@ -57,11 +55,9 @@ function clamp(value: number, min: number, max: number): number {
}
export default function MobileBottomSheet({
activeCount,
children,
legend,
}: MobileBottomSheetProps) {
const { t } = useTranslation();
const viewport = useVisualViewportState();
const sheetRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
@ -133,8 +129,6 @@ export default function MobileBottomSheet({
return () => sheet.removeEventListener('focusin', handleFocusIn);
}, [heightBounds.initial, heightBounds.max, viewport.height]);
const sheetTitle = activeCount === 0 ? t('filters.chooseFilters') : t('filters.activeFilters');
return (
<section
ref={sheetRef}
@ -148,29 +142,16 @@ export default function MobileBottomSheet({
? undefined
: 'height 140ms ease, bottom 180ms ease',
}}
aria-label={sheetTitle}
>
<div
className="shrink-0 touch-none px-4 pt-2 pb-1"
className="shrink-0 touch-none px-4 py-2"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
<div className="w-full flex flex-col items-center gap-2" role="presentation">
<div className="w-full flex items-center justify-center" role="presentation">
<span className="h-1.5 w-12 rounded-full bg-warm-300 dark:bg-navy-600" />
<span className="w-full flex items-center justify-between">
<span className="flex items-center gap-2 min-w-0">
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100 truncate">
{sheetTitle}
</span>
{activeCount > 0 && (
<span className="text-xs font-medium px-1.5 py-0.5 rounded-full bg-teal-50 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
{activeCount}
</span>
)}
</span>
</span>
</div>
</div>

View file

@ -6,7 +6,7 @@ interface PriceHistoryChartProps {
points: PricePoint[];
}
const PADDING = { top: 8, right: 8, bottom: 20, left: 42 };
const PADDING = { top: 8, right: 24, bottom: 20, left: 42 };
const HEIGHT = 120;
const priceFmt = { prefix: '£' };