Hide POIs, show overlay state, remove zoom button, rotate vpn
This commit is contained in:
parent
35276d34fa
commit
bce34b73de
32 changed files with 1137 additions and 186 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Map as MapGL, NavigationControl, ScaleControl } from 'react-map-gl/maplibre';
|
||||
import { Map as MapGL, ScaleControl } from 'react-map-gl/maplibre';
|
||||
import type { MapRef } from 'react-map-gl/maplibre';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import type {
|
||||
|
|
@ -350,6 +350,16 @@ export default memo(function Map({
|
|||
}, [screenshotMode]);
|
||||
|
||||
const handleLoad = useCallback(() => {
|
||||
// touchZoomRotate is a single handler for pinch-zoom AND pinch-rotate, so
|
||||
// leaving it enabled for the zoom would also let touch users rotate the map
|
||||
// off north with no compass to reset it. Split them: keep the zoom, drop the
|
||||
// rotate, matching dragRotate/pitchWithRotate being off.
|
||||
const map = mapRef.current?.getMap();
|
||||
map?.touchZoomRotate.disableRotation();
|
||||
// Same split for the keyboard. Its disableRotation docstring claims it kills
|
||||
// panning too, but the handler only zeroes the bearing/pitch deltas, so plain
|
||||
// arrow-key panning survives and Shift+arrow rotate/tilt does not.
|
||||
map?.keyboard.disableRotation();
|
||||
setMapReady(true);
|
||||
}, []);
|
||||
|
||||
|
|
@ -550,9 +560,6 @@ export default memo(function Map({
|
|||
zoom={viewState.zoom}
|
||||
/>
|
||||
<DeckOverlay key={deckOverlayKey} layers={layers} getTooltip={null} />
|
||||
{!screenshotMode && (
|
||||
<NavigationControl position="bottom-left" showZoom showCompass visualizePitch={false} />
|
||||
)}
|
||||
{!screenshotMode && <ScaleControl position="bottom-left" maxWidth={100} unit="metric" />}
|
||||
</MapGL>
|
||||
{basemap === 'satellite' && (
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
REGISTERED_MAX_FILTERS,
|
||||
INITIAL_VIEW_STATE,
|
||||
POSTCODE_ZOOM_THRESHOLD,
|
||||
POI_ZOOM_THRESHOLD,
|
||||
} from '../../lib/consts';
|
||||
import { boundsToCenterZoom } from '../../lib/fit-bounds';
|
||||
import type { OverlayId } from '../../lib/overlays';
|
||||
|
|
@ -76,6 +77,7 @@ import {
|
|||
useScreenshotReadySignal,
|
||||
} from './map-page/effects';
|
||||
import { useMobileDrawer } from './map-page/useMobileDrawer';
|
||||
import type { MapControlState } from './map-page/MapControlButton';
|
||||
import type { MapFlyTo, MapPageProps } from './map-page/types';
|
||||
|
||||
export type { ExportState } from './map-page/types';
|
||||
|
|
@ -586,12 +588,26 @@ export default function MapPage({
|
|||
[handleCurrentLocationSearch, isMobile, openMobileDrawer, queueCurrentLocationFlyTo]
|
||||
);
|
||||
|
||||
const fetchedPois = usePOIData(mapData.bounds, selectedPOICategories);
|
||||
const overlaysZoomedIn = (mapData.currentView?.zoom ?? 0) >= POSTCODE_ZOOM_THRESHOLD;
|
||||
const poisZoomedIn = (mapData.currentView?.zoom ?? 0) >= POI_ZOOM_THRESHOLD;
|
||||
// Zoomed out POIs aren't drawn, so skip the fetch rather than pull a
|
||||
// country-wide result set nothing will use.
|
||||
const fetchedPois = usePOIData(mapData.bounds, selectedPOICategories, poisZoomedIn);
|
||||
// Disabling POIs (clearing every category) must remove all POI cards/markers
|
||||
// immediately, not on the next fetch tick. Gate on the selection itself so a
|
||||
// stale fetch result can never keep cards on screen.
|
||||
const pois: POI[] = selectedPOICategories.size > 0 ? fetchedPois : EMPTY_POIS;
|
||||
const overlaysZoomedIn = (mapData.currentView?.zoom ?? 0) >= POSTCODE_ZOOM_THRESHOLD;
|
||||
// Tell the toolbar buttons whether their selection is actually on the map, so
|
||||
// a selection hidden by the zoom limit is visible as an amber dot rather than
|
||||
// looking like the map simply has nothing on it.
|
||||
const overlayControl: MapControlState = {
|
||||
status: activeOverlays.size === 0 ? 'idle' : overlaysZoomedIn ? 'active' : 'hidden',
|
||||
hiddenHint: t('overlays.zoomWarning', { count: activeOverlays.size }),
|
||||
};
|
||||
const poiControl: MapControlState = {
|
||||
status: selectedPOICategories.size === 0 ? 'idle' : poisZoomedIn ? 'active' : 'hidden',
|
||||
hiddenHint: t('poiPane.zoomWarning', { count: selectedPOICategories.size }),
|
||||
};
|
||||
const actualListingsFilterParam = useMemo(
|
||||
() => buildFilterString(filters, features),
|
||||
[filters, features]
|
||||
|
|
@ -931,11 +947,12 @@ export default function MapPage({
|
|||
selectedCategories={selectedPOICategories}
|
||||
onCategoriesChange={setSelectedPOICategories}
|
||||
poiCount={pois.length}
|
||||
zoomedIn={poisZoomedIn}
|
||||
onClose={handleClosePoiPane}
|
||||
/>
|
||||
</Suspense>
|
||||
),
|
||||
[handleClosePoiPane, poiCategoryGroups, pois.length, selectedPOICategories]
|
||||
[handleClosePoiPane, poisZoomedIn, poiCategoryGroups, pois.length, selectedPOICategories]
|
||||
);
|
||||
|
||||
const overlayPane = useMemo(
|
||||
|
|
@ -1239,9 +1256,11 @@ export default function MapPage({
|
|||
onTogglePoiPane={handleTogglePoiPane}
|
||||
poiButtonLabel={t('poiPane.pointsOfInterest')}
|
||||
poiPane={poiPane}
|
||||
poiControl={poiControl}
|
||||
overlayPaneOpen={overlayPaneOpen}
|
||||
onToggleOverlayPane={handleToggleOverlayPane}
|
||||
overlayPane={overlayPane}
|
||||
overlayControl={overlayControl}
|
||||
filtersPane={filtersPane}
|
||||
mobileLegend={mobileLegend}
|
||||
renderAreaPane={renderAreaPane}
|
||||
|
|
@ -1296,9 +1315,11 @@ export default function MapPage({
|
|||
poiPaneOpen={poiPaneOpen}
|
||||
onTogglePoiPane={handleTogglePoiPane}
|
||||
poiPane={poiPane}
|
||||
poiControl={poiControl}
|
||||
overlayPaneOpen={overlayPaneOpen}
|
||||
onToggleOverlayPane={handleToggleOverlayPane}
|
||||
overlayPane={overlayPane}
|
||||
overlayControl={overlayControl}
|
||||
showSelectionPane={!!selectedHexagon}
|
||||
rightPaneWidth={rightPaneWidth}
|
||||
rightPaneHandlers={rightPaneHandlers}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ interface POIPaneProps {
|
|||
selectedCategories: Set<string>;
|
||||
onCategoriesChange: (categories: Set<string>) => void;
|
||||
poiCount: number;
|
||||
zoomedIn: boolean;
|
||||
onNavigateToSource?: (slug: string) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
|
@ -26,6 +27,7 @@ export default function POIPane({
|
|||
selectedCategories,
|
||||
onCategoriesChange,
|
||||
poiCount: _poiCount,
|
||||
zoomedIn,
|
||||
onNavigateToSource,
|
||||
onClose,
|
||||
}: POIPaneProps) {
|
||||
|
|
@ -148,6 +150,15 @@ export default function POIPane({
|
|||
</p>
|
||||
</InfoPopup>
|
||||
)}
|
||||
|
||||
{!zoomedIn && selectedCount > 0 && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-2 rounded border border-amber-300 bg-amber-50 px-2 py-1.5 text-xs text-amber-800 dark:border-amber-700/60 dark:bg-amber-900/30 dark:text-amber-200"
|
||||
>
|
||||
{t('poiPane.zoomWarning', { count: selectedCount })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain border-t border-warm-200 dark:border-warm-700">
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { HouseIcon } from '../../ui/icons/HouseIcon';
|
|||
import { SpinnerIcon } from '../../ui/icons/SpinnerIcon';
|
||||
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
|
||||
import type { MapFlyTo, PaneResizeHandlers } from './types';
|
||||
import { MapControlButton, type MapControlState } from './MapControlButton';
|
||||
import { MapFallback, PaneFallback } from './Fallbacks';
|
||||
import { MapErrorBoundary } from '../MapErrorBoundary';
|
||||
import { LoadingOverlay } from './LoadingOverlay';
|
||||
|
|
@ -74,9 +75,11 @@ interface DesktopMapPageProps {
|
|||
poiPaneOpen: boolean;
|
||||
onTogglePoiPane: () => void;
|
||||
poiPane: ReactNode;
|
||||
poiControl: MapControlState;
|
||||
overlayPaneOpen: boolean;
|
||||
onToggleOverlayPane: () => void;
|
||||
overlayPane: ReactNode;
|
||||
overlayControl: MapControlState;
|
||||
showSelectionPane: boolean;
|
||||
rightPaneWidth: number;
|
||||
rightPaneHandlers: PaneResizeHandlers;
|
||||
|
|
@ -132,9 +135,11 @@ export function DesktopMapPage({
|
|||
poiPaneOpen,
|
||||
onTogglePoiPane,
|
||||
poiPane,
|
||||
poiControl,
|
||||
overlayPaneOpen,
|
||||
onToggleOverlayPane,
|
||||
overlayPane,
|
||||
overlayControl,
|
||||
showSelectionPane,
|
||||
rightPaneWidth,
|
||||
rightPaneHandlers,
|
||||
|
|
@ -255,22 +260,24 @@ export function DesktopMapPage({
|
|||
</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
data-tutorial="overlays-button"
|
||||
<MapControlButton
|
||||
{...overlayControl}
|
||||
dataTutorial="overlays-button"
|
||||
label={t('overlays.heading')}
|
||||
paneOpen={overlayPaneOpen}
|
||||
showLabel
|
||||
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">{t('overlays.heading')}</span>
|
||||
</button>
|
||||
<button
|
||||
data-tutorial="poi-button"
|
||||
icon={(highlighted) => <EyeIcon className="h-5 w-5" filled={highlighted} />}
|
||||
/>
|
||||
<MapControlButton
|
||||
{...poiControl}
|
||||
dataTutorial="poi-button"
|
||||
label={t('poiPane.pointsOfInterest')}
|
||||
paneOpen={poiPaneOpen}
|
||||
showLabel
|
||||
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>
|
||||
icon={() => <MapPinIcon className="h-5 w-5" />}
|
||||
/>
|
||||
</div>
|
||||
{listingsPaneOpen && (
|
||||
<div className="absolute bottom-16 right-4 z-10 flex max-h-[60vh] min-h-0 w-72 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
import { cleanup, fireEvent, render } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MapControlButton, type MapControlStatus } from './MapControlButton';
|
||||
|
||||
const HINT = 'Zoom in further to see the selected overlays.';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
/** Queries are scoped to this render's own container, so a test can render
|
||||
* several buttons without them colliding in the shared document. */
|
||||
function renderButton(status: MapControlStatus, paneOpen = false) {
|
||||
const highlightedCalls: boolean[] = [];
|
||||
const onClick = vi.fn();
|
||||
const { container } = render(
|
||||
<MapControlButton
|
||||
status={status}
|
||||
hiddenHint={HINT}
|
||||
label="Overlays"
|
||||
paneOpen={paneOpen}
|
||||
showLabel
|
||||
onClick={onClick}
|
||||
icon={(highlighted) => {
|
||||
highlightedCalls.push(highlighted);
|
||||
return <svg />;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const button = container.querySelector('button');
|
||||
if (!button) throw new Error('button not rendered');
|
||||
return {
|
||||
button,
|
||||
dot: container.querySelector('.bg-amber-500'),
|
||||
highlighted: highlightedCalls[0],
|
||||
onClick,
|
||||
};
|
||||
}
|
||||
|
||||
describe('MapControlButton', () => {
|
||||
it('only shows the amber dot when the selection is hidden', () => {
|
||||
expect(renderButton('idle').dot).toBeNull();
|
||||
expect(renderButton('active').dot).toBeNull();
|
||||
expect(renderButton('hidden').dot).not.toBeNull();
|
||||
});
|
||||
|
||||
it('explains the amber dot in the name so it is not conveyed by colour alone', () => {
|
||||
// Hovering gives the tooltip and screen readers get the same text, both
|
||||
// keeping the visible label as a prefix.
|
||||
const hidden = renderButton('hidden');
|
||||
expect(hidden.button.getAttribute('title')).toBe(`Overlays. ${HINT}`);
|
||||
expect(hidden.button.getAttribute('aria-label')).toBe(`Overlays. ${HINT}`);
|
||||
|
||||
const active = renderButton('active');
|
||||
expect(active.button.getAttribute('title')).toBe('Overlays');
|
||||
expect(active.button.getAttribute('aria-label')).toBe('Overlays');
|
||||
});
|
||||
|
||||
it('highlights whenever a selection exists or the pane is open', () => {
|
||||
expect(renderButton('idle').highlighted).toBe(false);
|
||||
expect(renderButton('active').highlighted).toBe(true);
|
||||
// Hidden still counts as selected, so the button stays highlighted and the
|
||||
// dot carries the "not on the map right now" part on its own.
|
||||
expect(renderButton('hidden').highlighted).toBe(true);
|
||||
expect(renderButton('idle', true).highlighted).toBe(true);
|
||||
});
|
||||
|
||||
it('reports the pane open state to assistive tech', () => {
|
||||
expect(renderButton('idle').button.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(renderButton('idle', true).button.getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('fires onClick', () => {
|
||||
const { button, onClick } = renderButton('idle');
|
||||
fireEvent.click(button);
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
73
frontend/src/components/map/map-page/MapControlButton.tsx
Normal file
73
frontend/src/components/map/map-page/MapControlButton.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* State of a map control's selection:
|
||||
* - `idle`: nothing selected, so nothing is expected on the map.
|
||||
* - `active`: selected and currently drawn.
|
||||
* - `hidden`: selected but not drawn, because the map is zoomed out past the
|
||||
* layer's threshold (POI_ZOOM_THRESHOLD / POSTCODE_ZOOM_THRESHOLD).
|
||||
*/
|
||||
export type MapControlStatus = 'idle' | 'active' | 'hidden';
|
||||
|
||||
export interface MapControlState {
|
||||
status: MapControlStatus;
|
||||
/** Explains the amber dot: why the selection isn't on the map right now. */
|
||||
hiddenHint: string;
|
||||
}
|
||||
|
||||
interface MapControlButtonProps extends MapControlState {
|
||||
label: string;
|
||||
paneOpen: boolean;
|
||||
/** Desktop shows the label beside the icon; mobile is icon-only. */
|
||||
showLabel: boolean;
|
||||
onClick: () => void;
|
||||
icon: (highlighted: boolean) => ReactNode;
|
||||
dataTutorial?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Floating map control (Overlays, POIs) that reports whether its selection is
|
||||
* actually on the map: teal once something is selected, plus an amber dot while
|
||||
* that selection is hidden by the zoom limit.
|
||||
*/
|
||||
export function MapControlButton({
|
||||
status,
|
||||
hiddenHint,
|
||||
label,
|
||||
paneOpen,
|
||||
showLabel,
|
||||
onClick,
|
||||
icon,
|
||||
dataTutorial,
|
||||
}: MapControlButtonProps) {
|
||||
const highlighted = paneOpen || status !== 'idle';
|
||||
// The dot conveys "hidden" with colour alone, so the button's name carries
|
||||
// that meaning for the tooltip and for screen readers. Keeping `label` as the
|
||||
// prefix leaves the accessible name matching the visible one.
|
||||
const name = status === 'hidden' ? `${label}. ${hiddenHint}` : label;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-tutorial={dataTutorial}
|
||||
onClick={onClick}
|
||||
aria-expanded={paneOpen}
|
||||
aria-label={name}
|
||||
title={name}
|
||||
className={`flex items-center gap-2 rounded-lg bg-white shadow-lg dark:bg-warm-800 ${showLabel ? 'px-3 py-2' : 'p-2'} ${highlighted ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 hover:text-teal-600 dark:text-warm-400 dark:hover:text-teal-400'}`}
|
||||
>
|
||||
{/* Anchored to the icon rather than the button so the dot sits in the same
|
||||
spot on the labelled desktop button and the icon-only mobile one. */}
|
||||
<span className="relative flex">
|
||||
{icon(highlighted)}
|
||||
{status === 'hidden' && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-amber-500 ring-2 ring-white dark:bg-amber-400 dark:ring-warm-800"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{showLabel && <span className="text-sm font-medium">{label}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ import { HouseIcon } from '../../ui/icons/HouseIcon';
|
|||
import { SpinnerIcon } from '../../ui/icons/SpinnerIcon';
|
||||
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
|
||||
import type { MapFlyTo } from './types';
|
||||
import { MapControlButton, type MapControlState } from './MapControlButton';
|
||||
import { MapFallback, PaneFallback } from './Fallbacks';
|
||||
import { MapErrorBoundary } from '../MapErrorBoundary';
|
||||
import { LoadingOverlay } from './LoadingOverlay';
|
||||
|
|
@ -72,9 +73,11 @@ interface MobileMapPageProps {
|
|||
onTogglePoiPane: () => void;
|
||||
poiButtonLabel: string;
|
||||
poiPane: ReactNode;
|
||||
poiControl: MapControlState;
|
||||
overlayPaneOpen: boolean;
|
||||
onToggleOverlayPane: () => void;
|
||||
overlayPane: ReactNode;
|
||||
overlayControl: MapControlState;
|
||||
filtersPane: ReactNode;
|
||||
mobileLegend: ReactNode;
|
||||
renderAreaPane: () => ReactNode;
|
||||
|
|
@ -127,9 +130,11 @@ export function MobileMapPage({
|
|||
onTogglePoiPane,
|
||||
poiButtonLabel,
|
||||
poiPane,
|
||||
poiControl,
|
||||
overlayPaneOpen,
|
||||
onToggleOverlayPane,
|
||||
overlayPane,
|
||||
overlayControl,
|
||||
filtersPane,
|
||||
mobileLegend,
|
||||
renderAreaPane,
|
||||
|
|
@ -220,20 +225,22 @@ export function MobileMapPage({
|
|||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
<MapControlButton
|
||||
{...overlayControl}
|
||||
label={t('overlays.heading')}
|
||||
paneOpen={overlayPaneOpen}
|
||||
showLabel={false}
|
||||
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={t('overlays.heading')}
|
||||
>
|
||||
<EyeIcon className="h-5 w-5" filled={overlayPaneOpen} />
|
||||
</button>
|
||||
<button
|
||||
icon={(highlighted) => <EyeIcon className="h-5 w-5" filled={highlighted} />}
|
||||
/>
|
||||
<MapControlButton
|
||||
{...poiControl}
|
||||
label={poiButtonLabel}
|
||||
paneOpen={poiPaneOpen}
|
||||
showLabel={false}
|
||||
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>
|
||||
icon={() => <MapPinIcon className="h-5 w-5" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{listingsPaneOpen && (
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { apiUrl, logNonAbortError, authHeaders } from '../lib/api';
|
|||
|
||||
const DEBOUNCE_MS = 150;
|
||||
|
||||
export function usePOIData(bounds: Bounds | null, selectedCategories: Set<string>) {
|
||||
export function usePOIData(bounds: Bounds | null, selectedCategories: Set<string>, enabled = true) {
|
||||
const [pois, setPois] = useState<POI[]>([]);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
|
@ -14,7 +14,7 @@ export function usePOIData(bounds: Bounds | null, selectedCategories: Set<string
|
|||
requestIdRef.current += 1;
|
||||
const requestId = requestIdRef.current;
|
||||
|
||||
if (!bounds || selectedCategories.size === 0) {
|
||||
if (!bounds || selectedCategories.size === 0 || !enabled) {
|
||||
abortControllerRef.current?.abort();
|
||||
setPois([]);
|
||||
return;
|
||||
|
|
@ -58,7 +58,7 @@ export function usePOIData(bounds: Bounds | null, selectedCategories: Set<string
|
|||
}
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
}, [bounds, selectedCategories]);
|
||||
}, [bounds, selectedCategories, enabled]);
|
||||
|
||||
return pois;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { act, renderHook } from '@testing-library/react';
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { POI } from '../types';
|
||||
import { POI_ZOOM_THRESHOLD } from '../lib/consts';
|
||||
import { usePoiLayers } from './usePoiLayers';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
|
|
@ -150,9 +151,11 @@ describe('usePoiLayers', () => {
|
|||
});
|
||||
|
||||
it('hides minor POI categories until the configured zoom threshold', () => {
|
||||
// Starts at POI_ZOOM_THRESHOLD: past the gate that hides every POI, so this
|
||||
// isolates the minor-category filter rather than the global one.
|
||||
const { result, rerender } = renderHook(
|
||||
({ zoom }) => usePoiLayers({ pois: [busStop], zoom, isDark: false }),
|
||||
{ initialProps: { zoom: 13 } }
|
||||
{ initialProps: { zoom: POI_ZOOM_THRESHOLD } }
|
||||
);
|
||||
|
||||
expect(layerById(result.current.poiLayers, 'poi-background').props.data).toEqual([]);
|
||||
|
|
@ -164,6 +167,26 @@ describe('usePoiLayers', () => {
|
|||
expect(result.current.visiblePois).toEqual([busStop]);
|
||||
});
|
||||
|
||||
it('hides every POI and cluster below POI_ZOOM_THRESHOLD', () => {
|
||||
const neighbour: POI = { ...supermarket, id: 'neighbour', name: 'Neighbour', lat: 51.5001 };
|
||||
const { result, rerender } = renderHook(
|
||||
({ zoom }) => usePoiLayers({ pois: [supermarket, neighbour], zoom, isDark: false }),
|
||||
{ initialProps: { zoom: POI_ZOOM_THRESHOLD - 0.1 } }
|
||||
);
|
||||
|
||||
// Zoomed out the POIs go away entirely, rather than collapsing into the
|
||||
// cluster bubbles they used to leave behind.
|
||||
expect(layerById(result.current.poiLayers, 'poi-background').props.data).toEqual([]);
|
||||
expect(layerById(result.current.poiLayers, 'poi-clusters').props.data).toEqual([]);
|
||||
expect(result.current.visiblePois).toEqual([]);
|
||||
|
||||
rerender({ zoom: POI_ZOOM_THRESHOLD });
|
||||
|
||||
const shown = layerById(result.current.poiLayers, 'poi-background').props.data as POI[];
|
||||
const clusters = layerById(result.current.poiLayers, 'poi-clusters').props.data as unknown[];
|
||||
expect(shown.length + clusters.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('keeps POI hover popup state in sync with layer hover events', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePoiLayers({ pois: [supermarket], zoom: 15, isDark: false })
|
||||
|
|
@ -248,8 +271,10 @@ describe('usePoiLayers', () => {
|
|||
lng: -0.12 - index * 0.0001,
|
||||
})
|
||||
);
|
||||
// Zoom 14 sits in the band where POIs are visible but still cluster
|
||||
// (POI_ZOOM_THRESHOLD <= zoom <= POI_CLUSTER_MAX_ZOOM).
|
||||
const { result } = renderHook(() =>
|
||||
usePoiLayers({ pois: clusteredPois, zoom: 5, isDark: true })
|
||||
usePoiLayers({ pois: clusteredPois, zoom: 14, isDark: true })
|
||||
);
|
||||
const clusterLayer = layerById(result.current.poiLayers, 'poi-clusters');
|
||||
const clusters = clusterLayer.props.data as Array<{ count: number; lng: number; lat: number }>;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
MINOR_POI_ZOOM_THRESHOLD,
|
||||
POI_CLUSTER_RADIUS,
|
||||
POI_CLUSTER_MAX_ZOOM,
|
||||
POI_ZOOM_THRESHOLD,
|
||||
} from '../lib/consts';
|
||||
import { getPoiIconUrl } from '../lib/map-utils';
|
||||
|
||||
|
|
@ -149,9 +150,14 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
|
|||
|
||||
const clusterZoom = Math.floor(zoom);
|
||||
const showMinorPois = zoom >= MINOR_POI_ZOOM_THRESHOLD;
|
||||
// Zoomed out the POIs go away entirely rather than leaving cluster bubbles
|
||||
// behind. This reads the same live view zoom OverlayTileLayers gates on, so
|
||||
// while POI_ZOOM_THRESHOLD matches the overlay limit both vanish on the same
|
||||
// frame.
|
||||
const poisZoomedIn = zoom >= POI_ZOOM_THRESHOLD;
|
||||
|
||||
const { visiblePois, clusters } = useMemo(() => {
|
||||
if (!clusterIndex || pois.length === 0) {
|
||||
if (!clusterIndex || pois.length === 0 || !poisZoomedIn) {
|
||||
return { visiblePois: [] as POI[], clusters: [] as ClusterPoint[] };
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
|
@ -173,7 +179,7 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
|
|||
}
|
||||
}
|
||||
return { visiblePois: individual, clusters: clusterPoints };
|
||||
}, [clusterIndex, clusterZoom, showMinorPois, pois]);
|
||||
}, [clusterIndex, clusterZoom, showMinorPois, poisZoomedIn, pois]);
|
||||
|
||||
const poiShadowLayer = useMemo(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -1012,6 +1012,8 @@ const de: Translations = {
|
|||
'Daten von OpenStreetMap, NaPTAN und GEOLYTIX Grocery Retail Points. Umfasst Haltestellen, Geschäfte, Supermarktketten, Restaurants, Gesundheitsangebote, Freizeit und mehr.',
|
||||
searchCategories: 'Kategorien durchsuchen...',
|
||||
dataSourceInfo: 'Datenquelleninfo',
|
||||
zoomWarning: 'Zoome weiter hinein, um die ausgewählte Kategorie zu sehen.',
|
||||
zoomWarning_other: 'Zoome weiter hinein, um die ausgewählten Kategorien zu sehen.',
|
||||
},
|
||||
|
||||
// ── External Search Links ──────────────────────────
|
||||
|
|
|
|||
|
|
@ -999,6 +999,8 @@ const en = {
|
|||
'Sourced from OpenStreetMap, NaPTAN, and GEOLYTIX Grocery Retail Points. Covers transport stops, shops, chain supermarkets, restaurants, healthcare, leisure, and more.',
|
||||
searchCategories: 'Search categories…',
|
||||
dataSourceInfo: 'Data source info',
|
||||
zoomWarning: 'Zoom in further to see the selected category.',
|
||||
zoomWarning_other: 'Zoom in further to see the selected categories.',
|
||||
},
|
||||
|
||||
// ── External Search Links ──────────────────────────
|
||||
|
|
|
|||
|
|
@ -1022,6 +1022,8 @@ const fr: Translations = {
|
|||
'Données issues d’OpenStreetMap, de NaPTAN et de GEOLYTIX Grocery Retail Points. Couvre les arrêts de transport, commerces, chaînes de supermarchés, restaurants, services de santé, loisirs et plus encore.',
|
||||
searchCategories: 'Rechercher des catégories...',
|
||||
dataSourceInfo: 'Informations sur la source de données',
|
||||
zoomWarning: 'Zoomez davantage pour voir la catégorie sélectionnée.',
|
||||
zoomWarning_other: 'Zoomez davantage pour voir les catégories sélectionnées.',
|
||||
},
|
||||
|
||||
// ── External Search Links ──────────────────────────
|
||||
|
|
|
|||
|
|
@ -976,6 +976,8 @@ const hi: Translations = {
|
|||
'OpenStreetMap, NaPTAN और GEOLYTIX Grocery Retail Points से लिया गया. इसमें परिवहन स्टॉप, दुकानें, चेन सुपरमार्केट, रेस्तरां, स्वास्थ्य सेवा, अवकाश और बहुत कुछ शामिल है.',
|
||||
searchCategories: 'श्रेणियां खोजें...',
|
||||
dataSourceInfo: 'डेटा स्रोत जानकारी',
|
||||
zoomWarning: 'चुनी गई श्रेणी को देखने के लिए और ज़ूम इन करें.',
|
||||
zoomWarning_other: 'चुनी गई श्रेणियां देखने के लिए और ज़ूम इन करें.',
|
||||
},
|
||||
|
||||
externalSearch: {
|
||||
|
|
|
|||
|
|
@ -1009,6 +1009,8 @@ const hu: Translations = {
|
|||
'Forrás: OpenStreetMap, NaPTAN és GEOLYTIX Grocery Retail Points. Tartalmazza a közlekedési megállókat, üzleteket, áruházláncokat, éttermeket, egészségügyi szolgáltatásokat, szabadidős helyeket és még sok mást.',
|
||||
searchCategories: 'Kategóriák keresése...',
|
||||
dataSourceInfo: 'Adatforrás-információ',
|
||||
zoomWarning: 'Nagyíts tovább a kiválasztott kategória megtekintéséhez.',
|
||||
zoomWarning_other: 'Nagyíts tovább a kiválasztott kategóriák megtekintéséhez.',
|
||||
},
|
||||
|
||||
// ── External Search Links ──────────────────────────
|
||||
|
|
|
|||
|
|
@ -945,6 +945,8 @@ const zh: Translations = {
|
|||
'数据来自 OpenStreetMap、NaPTAN 和 GEOLYTIX Grocery Retail Points。涵盖交通站点、商店、连锁超市、餐厅、医疗、休闲等。',
|
||||
searchCategories: '搜索类别...',
|
||||
dataSourceInfo: '数据来源',
|
||||
zoomWarning: '请进一步放大以查看所选类别。',
|
||||
zoomWarning_other: '请进一步放大以查看所选类别。',
|
||||
},
|
||||
|
||||
// ── External Search Links ──────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('index.html viewport', () => {
|
||||
// vitest runs with cwd at the frontend package root.
|
||||
const html = readFileSync(resolve(process.cwd(), 'src/index.html'), 'utf-8');
|
||||
it('allows pinch-zoom (WCAG 1.4.4)', () => {
|
||||
const m = html.match(/<meta name="viewport" content="([^"]*)"/);
|
||||
expect(m).not.toBeNull();
|
||||
const content = m![1];
|
||||
expect(content).not.toMatch(/maximum-scale/);
|
||||
expect(content).not.toMatch(/user-scalable\s*=\s*no/);
|
||||
expect(content).toContain('width=device-width');
|
||||
expect(content).toContain('initial-scale=1');
|
||||
});
|
||||
});
|
||||
|
|
@ -61,6 +61,23 @@ h3 {
|
|||
color 0.2s ease;
|
||||
}
|
||||
|
||||
/* iOS Safari zooms the page in whenever a focused control's text is under 16px,
|
||||
and does not zoom back out afterwards. Every control on a touch device gets 16px,
|
||||
whatever text-* utility it carries; desktop keeps its own sizing.
|
||||
This rule must stay UNLAYERED to work: unlayered author styles outrank every
|
||||
cascade layer, which is what lets it beat Tailwind's text-sm in @layer utilities.
|
||||
Moving it into @layer base would lose to those utilities and silently do nothing.
|
||||
Excludes the types iOS never zooms on, so their layout is left alone. */
|
||||
@media (pointer: coarse) {
|
||||
input:not([type='checkbox']):not([type='radio']):not([type='range']):not([type='button']):not(
|
||||
[type='submit']
|
||||
),
|
||||
select,
|
||||
textarea {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hexagon background animations */
|
||||
@keyframes hex-drift {
|
||||
from {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="theme-color" content="#fafaf9" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#0a0e1a" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue