More
Some checks failed
CI / Check (push) Failing after 2m14s
Build and publish Docker image / build-and-push (push) Failing after 2m38s

This commit is contained in:
Andras Schmelczer 2026-05-04 17:21:26 +01:00
parent cd34ee693f
commit 05a1f316e1
58 changed files with 3113 additions and 1277 deletions

View file

@ -17,6 +17,7 @@ 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';
@ -41,6 +42,7 @@ import { useFilterCounts } from '../../hooks/useFilterCounts';
import { ts } from '../../i18n/server';
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';
@ -116,12 +118,6 @@ export default function MapPage({
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 0.45, 'left');
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right');
const [, mobileResizeHandlers, mobileMapRef] = usePaneResize(
Math.round(window.innerHeight * 0.4),
120,
0.8,
'top'
);
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
@ -510,9 +506,15 @@ export default function MapPage({
const densityLabel = t('mapLegend.historicalMatches');
const hasActiveFilters = Object.keys(filters).length > 0 || activeEntries.length > 0;
const mobileLegendMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
[viewFeature, features]
const mobileLegendMeta = useMemo(() => {
const featureName = viewFeature
? (getSchoolBackendFeatureName(viewFeature) ?? viewFeature)
: null;
return featureName ? features.find((f) => f.name === featureName) || null : null;
}, [viewFeature, features]);
const mapViewFeature = useMemo(
() => (viewFeature ? (getSchoolBackendFeatureName(viewFeature) ?? viewFeature) : null),
[viewFeature]
);
const mobileDensityRange = useMemo((): [number, number] => {
const items = mapData.usePostcodeView ? mapData.postcodeData : mapData.data;
@ -595,7 +597,7 @@ export default function MapPage({
usePostcodeView={mapData.usePostcodeView}
pois={[]}
onViewChange={mapData.handleViewChange}
viewFeature={viewFeature}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
@ -663,7 +665,7 @@ export default function MapPage({
/>
);
const renderFilters = () => (
const renderFilters = (options?: { destinationDropdownPortal?: boolean }) => (
<Filters
features={features}
filters={filters}
@ -702,12 +704,71 @@ export default function MapPage({
onClearAll={handleClearAll}
onSaveSearch={onSaveSearch}
savingSearch={savingSearch}
destinationDropdownPortal={options?.destinationDropdownPortal}
/>
);
const renderMobileLegend = () => {
if (mapViewFeature && mapData.colorRange) {
if (mapViewFeature.startsWith('tt_')) {
return (
<MapLegend
featureLabel={t('travel.travelTime', {
mode: modes.label(
mapViewFeature.split('_')[1] as 'car' | 'bicycle' | 'walking' | 'transit'
),
})}
range={mapData.colorRange}
showCancel={viewSource === 'eye'}
onCancel={handleCancelPin}
mode="feature"
theme={theme}
inline
suffix=" min"
/>
);
}
if (mobileLegendMeta) {
return (
<MapLegend
featureLabel={
viewSource === 'eye'
? t('mapLegend.previewing', { name: ts(mobileLegendMeta.name) })
: ts(mobileLegendMeta.name)
}
range={mapData.colorRange}
showCancel={viewSource === 'eye'}
onCancel={handleCancelPin}
mode="feature"
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
featureName={mobileLegendMeta.name}
theme={theme}
inline
raw={mobileLegendMeta.raw}
/>
);
}
return null;
}
return (
<MapLegend
featureLabel={densityLabel}
range={mobileDensityRange}
showCancel={false}
onCancel={handleCancelPin}
mode="density"
theme={theme}
inline
/>
);
};
if (isMobile) {
return (
<div className="flex-1 flex flex-col overflow-hidden relative touch-pan-y">
<div className="flex-1 overflow-hidden relative">
{initialLoading && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
<div className="flex flex-col items-center gap-4">
@ -719,14 +780,14 @@ export default function MapPage({
</div>
)}
<div ref={mobileMapRef} className="relative overflow-hidden">
<div className="absolute inset-0">
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={pois}
onViewChange={mapData.handleViewChange}
viewFeature={viewFeature}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
@ -748,90 +809,39 @@ export default function MapPage({
hideLegend
travelTimeEntries={entries}
/>
{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">
<SpinnerIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 animate-spin" />
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">
Loading...
</span>
</div>
</div>
)}
<button
onClick={() => setPoiPaneOpen((p) => !p)}
className={`absolute bottom-2 right-2 z-10 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'}`}
>
<MapPinIcon className="w-5 h-5" />
</button>
{poiPaneOpen && (
<div className="absolute bottom-12 right-2 z-10 w-[calc(100%-1rem)] max-h-[60%] overflow-hidden flex flex-col rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900">
{renderPOIPane()}
</div>
)}
</div>
<div
className="relative z-10 py-2 -my-2 cursor-row-resize touch-none group"
{...mobileResizeHandlers}
>
<div className="h-3 flex items-center justify-center bg-warm-100 dark:bg-navy-800 group-hover:bg-warm-200 dark:group-hover:bg-navy-700 border-y border-warm-200 dark:border-navy-700">
<div className="flex flex-row gap-1.5">
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
{mapData.loading && (
<div className="absolute inset-0 z-10 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">
<SpinnerIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 animate-spin" />
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">
Loading...
</span>
</div>
</div>
</div>
)}
<div className="flex-1 min-h-0 bg-white dark:bg-warm-900 overflow-hidden flex flex-col">
{viewFeature && mapData.colorRange ? (
viewFeature.startsWith('tt_') ? (
<MapLegend
featureLabel={t('travel.travelTime', {
mode: modes.label(
viewFeature.split('_')[1] as 'car' | 'bicycle' | 'walking' | 'transit'
),
})}
range={mapData.colorRange}
showCancel={viewSource === 'eye'}
onCancel={handleCancelPin}
mode="feature"
theme={theme}
inline
suffix=" min"
/>
) : mobileLegendMeta ? (
<MapLegend
featureLabel={
viewSource === 'eye'
? t('mapLegend.previewing', { name: ts(mobileLegendMeta.name) })
: ts(mobileLegendMeta.name)
}
range={mapData.colorRange}
showCancel={viewSource === 'eye'}
onCancel={handleCancelPin}
mode="feature"
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
featureName={mobileLegendMeta.name}
theme={theme}
inline
raw={mobileLegendMeta.raw}
/>
) : null
) : (
<MapLegend
featureLabel={densityLabel}
range={mobileDensityRange}
showCancel={false}
onCancel={handleCancelPin}
mode="density"
theme={theme}
inline
/>
)}
<div className="flex-1 min-h-0">{renderFilters()}</div>
</div>
<button
onClick={() => setPoiPaneOpen((p) => !p)}
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={t('poiPane.pointsOfInterest')}
>
<MapPinIcon className="w-5 h-5" />
</button>
{poiPaneOpen && (
<div className="absolute top-14 right-3 left-3 z-20 max-h-[45dvh] overflow-hidden flex flex-col rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900">
{renderPOIPane()}
</div>
)}
<MobileBottomSheet
activeCount={Object.keys(filters).length + entries.length}
legend={renderMobileLegend()}
>
{renderFilters({ destinationDropdownPortal: false })}
</MobileBottomSheet>
{mobileDrawerOpen && selectedHexagon && (
<MobileDrawer
@ -914,7 +924,7 @@ export default function MapPage({
usePostcodeView={mapData.usePostcodeView}
pois={pois}
onViewChange={mapData.handleViewChange}
viewFeature={viewFeature}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}