diff --git a/src/hooks/useConstrainedFloatingButton.ts b/src/hooks/useConstrainedFloatingButton.ts deleted file mode 100644 index d090c71ff..000000000 --- a/src/hooks/useConstrainedFloatingButton.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { RefObject, useEffect } from 'react' - -export function useConstrainedFloatingButton( - mapContainerRef: RefObject, - buttonRef: RefObject, - isExpanded: boolean, -) { - useEffect(() => { - const updateButtonPosition = () => { - if (!mapContainerRef.current || !buttonRef.current) return - - const mapRect = mapContainerRef.current.getBoundingClientRect() - const buttonRect = buttonRef.current.getBoundingClientRect() - const buttonHeight = buttonRect.height || 48 - const offset = 5 - - const buttonElement = buttonRef.current - - const isRtl = document.documentElement.dir === 'rtl' - - if (isExpanded) { - if (buttonElement) { - if (isRtl) { - buttonElement.style.left = `${offset}px` - buttonElement.style.right = '' - } else { - buttonElement.style.right = `${offset}px` - buttonElement.style.left = '' - } - buttonElement.style.bottom = `${offset}px` - buttonElement.style.top = '' - buttonElement.style.display = '' - } - return - } - - const mapTop = mapRect.top - const mapBottom = mapRect.bottom - const mapVisible = mapBottom > 0 && mapTop < window.innerHeight - - if (!mapVisible) { - if (buttonElement) { - buttonElement.style.display = 'none' - } - return - } - - if (buttonElement) { - buttonElement.style.display = '' - } - - const desiredBottomFromViewportBottom = offset - const desiredTopFromViewportTop = window.innerHeight - buttonHeight - offset - const desiredBottomFromViewportTop = window.innerHeight - desiredBottomFromViewportBottom - - let finalTop: number | undefined - let finalBottom: number | undefined - - const buttonTopFromViewportTop = desiredTopFromViewportTop - const buttonBottomFromViewportTop = desiredBottomFromViewportTop - - if (buttonTopFromViewportTop < mapTop) { - finalTop = mapTop + offset - finalBottom = undefined - } else if (buttonBottomFromViewportTop > mapBottom) { - const constrainedBottomFromTop = mapBottom - offset - if (constrainedBottomFromTop - buttonHeight < mapTop) { - finalTop = mapTop + offset - finalBottom = undefined - } else { - finalBottom = window.innerHeight - constrainedBottomFromTop - finalTop = undefined - } - } else { - finalBottom = desiredBottomFromViewportBottom - finalTop = undefined - } - - if (buttonElement) { - if (isRtl) { - buttonElement.style.left = `${mapRect.left + offset}px` - buttonElement.style.right = '' - } else { - buttonElement.style.right = `${window.innerWidth - mapRect.right + offset}px` - buttonElement.style.left = '' - } - if (finalTop !== undefined) { - buttonElement.style.top = `${finalTop}px` - buttonElement.style.bottom = '' - } else if (finalBottom !== undefined) { - buttonElement.style.bottom = `${finalBottom}px` - buttonElement.style.top = '' - } - } - } - - updateButtonPosition() - - let intersectionObserver: IntersectionObserver | null = null - if (mapContainerRef.current) { - intersectionObserver = new IntersectionObserver( - () => { - updateButtonPosition() - }, - { - threshold: 0, - rootMargin: '0px', - }, - ) - intersectionObserver.observe(mapContainerRef.current) - } - - window.document - .getElementsByClassName('ant-layout-content') - .item(0) - ?.addEventListener('scroll', updateButtonPosition) - window.addEventListener('resize', updateButtonPosition) - - const dirObserver = new MutationObserver(updateButtonPosition) - dirObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['dir'] }) - - return () => { - if (intersectionObserver) { - intersectionObserver.disconnect() - } - dirObserver.disconnect() - window.document - .getElementsByClassName('ant-layout-content') - .item(0) - ?.removeEventListener('scroll', updateButtonPosition) - window.removeEventListener('resize', updateButtonPosition) - } - }, [mapContainerRef, buttonRef, isExpanded]) -} diff --git a/src/pages/components/map-related/MapShell.tsx b/src/pages/components/map-related/MapShell.tsx index 4aec902bf..1cb762b9a 100644 --- a/src/pages/components/map-related/MapShell.tsx +++ b/src/pages/components/map-related/MapShell.tsx @@ -1,20 +1,20 @@ import { CloseFullscreenTwoTone, OpenInFullTwoTone } from '@mui/icons-material' import { IconButton } from '@mui/material' -import { PropsWithChildren, ReactNode, useCallback, useRef, useState } from 'react' +import { PropsWithChildren, ReactNode, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import { AttributionControl, MapContainer, MapContainerProps, ZoomControl } from 'react-leaflet' -import { useConstrainedFloatingButton } from 'src/hooks/useConstrainedFloatingButton' +import { AttributionControl, MapContainer, MapContainerProps } from 'react-leaflet' import { useTheme } from 'src/layout/ThemeContext' +import { MapZoomBar } from './MapZoomBar' /** * Shared shell for every map page: the `.map-info` wrapper (with the - * expand/collapse + dark-theme classes), the floating expand button, and a - * MapContainer whose zoom/attribution controls are placed direction-aware - * (zoom at the inline-end, attribution at the inline-start). Pages pass the - * MapContainer props (center/zoom/…) and the layers as children. An optional - * `legend` renders in the shared `.map-legend` box (top inline-start corner) - * so every map page places and themes its legend the same way — pages provide - * only the legend content. + * expand/collapse + dark-theme classes) and a MapContainer whose controls are + * placed direction-aware — the zoom bar at the inline-end with the + * expand/collapse button as its bottom segment, attribution at the + * inline-start. Pages pass the MapContainer props (center/zoom/…) and the + * layers as children. An optional `legend` renders in the shared `.map-legend` + * box (top inline-start corner) so every map page places and themes its legend + * the same way — pages provide only the legend content. */ type MapShellProps = PropsWithChildren @@ -22,32 +22,24 @@ export function MapShell({ children, legend, ...mapProps }: MapShellProps) { const [isExpanded, setIsExpanded] = useState(false) const toggleExpanded = useCallback(() => setIsExpanded((expanded) => !expanded), []) - const mapContainerRef = useRef(null) - const buttonRef = useRef(null) - useConstrainedFloatingButton(mapContainerRef, buttonRef, isExpanded) - const { isDarkTheme } = useTheme() const { i18n } = useTranslation() const isRtl = i18n.dir() === 'rtl' return (
- - {isExpanded ? ( - - ) : ( - - )} - {legend &&
{legend}
} - + + + {isExpanded ? ( + + ) : ( + + )} + + {children} diff --git a/src/pages/components/map-related/MapZoomBar.tsx b/src/pages/components/map-related/MapZoomBar.tsx new file mode 100644 index 000000000..96fa869b0 --- /dev/null +++ b/src/pages/components/map-related/MapZoomBar.tsx @@ -0,0 +1,39 @@ +import { Control, ControlPosition, DomEvent, DomUtil } from 'leaflet' +import { PropsWithChildren, useEffect, useState } from 'react' +import { createPortal } from 'react-dom' +import { useMap } from 'react-leaflet' + +type MapZoomBarProps = PropsWithChildren<{ position: ControlPosition }> + +/** + * Leaflet's zoom control with extra buttons appended into the same + * `.leaflet-bar`, so they read as one connected control rather than separate + * boxes stacked in a corner. Zoom in/out stays Leaflet's own: it keeps the + * disabled states at the zoom limits, the button titles and the keyboard + * handling that a hand-rolled pair of buttons would have to reimplement. + * + * The slot is a stable node appended after `addTo`, so the container Leaflet + * builds is never queried or patched from the outside — changing `position` + * just rebuilds the control and re-appends the same slot. + */ +export function MapZoomBar({ position, children }: MapZoomBarProps) { + const map = useMap() + const [slot] = useState(() => DomUtil.create('div', 'map-zoom-bar-slot')) + + useEffect(() => { + // Without this a click on the button also reaches the map and pans/zooms it. + DomEvent.disableClickPropagation(slot) + DomEvent.disableScrollPropagation(slot) + }, [slot]) + + useEffect(() => { + const control = new Control.Zoom({ position }) + control.addTo(map) + control.getContainer()?.appendChild(slot) + return () => { + control.remove() + } + }, [map, position, slot]) + + return createPortal(children, slot) +} diff --git a/src/resources/map.scss b/src/resources/map.scss index 37d2bdad7..0ee05c70b 100644 --- a/src/resources/map.scss +++ b/src/resources/map.scss @@ -19,6 +19,12 @@ $button-shadow: $button-border: 1px solid rgb(0 0 0 / 25%); $button-border-dark: 1px solid rgb(255 255 255 / 25%); +// Divider between the segments of the zoom bar (+ / − / expand). Deliberately +// stronger than the frame: Leaflet's own #ccc all but disappears against the +// button background, which made the three segments read as one blank slab. +$bar-divider: rgb(0 0 0 / 40%); +$bar-divider-dark: rgb(255 255 255 / 40%); + main, main > div, .map-container, @@ -38,28 +44,13 @@ main > div, position: relative; margin-bottom: 1em; - .expand-button { - inset-inline-end: 5px; - bottom: 5px; - color: #1976d2 !important; - position: fixed; - z-index: 2000; - width: 2.6rem; - height: 2.6rem; - background: $button-bg; - border: $button-border; - box-shadow: $button-shadow; - - &:hover { - background: rgb(255 255 255 / 95%); - } - - &:active { - background: rgb(255 255 255 / 100%); - } + // The slot MapZoomBar appends into the zoom bar must not generate a box of + // its own, or the button would be nested one level deeper than the zoom + // links and stop lining up with them. + .map-zoom-bar-slot { + display: contents; } - // Match the expand button's frame so both buttons read with equal weight. .leaflet-control-zoom.leaflet-bar { border: $button-border; box-shadow: $button-shadow; @@ -67,12 +58,50 @@ main > div, .leaflet-control-zoom a { background: $button-bg; + border-bottom-color: $bar-divider; &:hover { background: rgb(255 255 255 / 95%); } } + // The expand button is the bar's bottom segment, so it drops every MUI + // button default (round, padded, its own frame) and takes the zoom links' + // box instead: same size, same background, square but for the bar's bottom + // corners. The separator above it is the zoom-out link's own bottom border, + // which Leaflet only removes while that link is the bar's :last-child. + .leaflet-control-zoom .expand-button { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + padding: 0; + border: none; + border-radius: 0 0 4px 4px; + background: $button-bg; + color: #1976d2 !important; + + &:hover { + background: rgb(255 255 255 / 95%); + } + + &:active { + background: rgb(255 255 255 / 100%); + } + + svg { + font-size: 16px; + } + } + + // Leaflet grows its bar buttons on touch-capable displays; track it so the + // expand button never ends up a different size from the zoom links. + .leaflet-touch .leaflet-control-zoom .expand-button { + width: 30px; + height: 30px; + } + .leaflet-control-attribution { background: $control-bg; } @@ -185,7 +214,7 @@ main > div, .leaflet-control-zoom a { background: $button-bg-dark; color: #fff; - border-color: #444; + border-bottom-color: $bar-divider-dark; &:hover { background: rgb(40 40 40 / 100%); @@ -201,9 +230,10 @@ main > div, } } - .expand-button { + // Background only — the accent color, frame and divider are all inherited + // from the light rules, which already hold in both themes. + .leaflet-control-zoom .expand-button { background: $button-bg-dark; - border: $button-border-dark; &:hover { background: rgb(40 40 40 / 95%);