From 2d0cfb7784f9baba1549af56bb9e310eb6d16a71 Mon Sep 17 00:00:00 2001 From: jolevesq Date: Mon, 2 Dec 2024 17:15:01 -0500 Subject: [PATCH 1/4] refactor(component): Review component to improve performance Closes #2626 --- .../map-event-processor.ts | 5 + .../components/attribution/attribution.tsx | 78 ++++---- .../components/click-marker/click-marker.tsx | 23 +-- .../components/crosshair/crosshair-icon.tsx | 7 +- .../core/components/crosshair/crosshair.tsx | 29 ++- .../hover-tooltip/hover-tooltip.tsx | 115 +++++++----- .../map-info/map-info-expand-button.tsx | 75 +++++--- .../map-info/map-info-fixnorth-switch.tsx | 75 +++++--- .../map-info/map-info-rotation-button.tsx | 43 ++++- .../src/core/components/map-info/map-info.tsx | 61 ++++--- .../mouse-position/mouse-position.tsx | 169 ++++++++++++------ .../north-arrow/hooks/useManageArrow.tsx | 102 ++++++----- .../north-arrow/north-arrow-icon.tsx | 12 +- .../components/north-arrow/north-arrow.tsx | 49 ++--- .../src/core/components/scale/scale.tsx | 57 +++--- .../map-state.ts | 17 ++ .../src/geo/map/feature-highlight.ts | 2 +- .../geoview-core/src/geo/map/map-viewer.ts | 2 + .../map}/point-markers.ts | 0 19 files changed, 575 insertions(+), 346 deletions(-) rename packages/geoview-core/src/{core/components/point-markers => geo/map}/point-markers.ts (100%) diff --git a/packages/geoview-core/src/api/event-processors/event-processor-children/map-event-processor.ts b/packages/geoview-core/src/api/event-processors/event-processor-children/map-event-processor.ts index c437f8c86ed..d3ad007f75b 100644 --- a/packages/geoview-core/src/api/event-processors/event-processor-children/map-event-processor.ts +++ b/packages/geoview-core/src/api/event-processors/event-processor-children/map-event-processor.ts @@ -427,6 +427,11 @@ export class MapEventProcessor extends AbstractEventProcessor { this.getMapStateProtected(mapId).setterActions.setZoom(zoom); } + static setIsMouseInsideMap(mapId: string, inside: boolean): void { + // Save in store + this.getMapStateProtected(mapId).setterActions.setIsMouseInsideMap(inside); + } + static setRotation(mapId: string, rotation: number): void { // Save in store this.getMapStateProtected(mapId).setterActions.setRotation(rotation); diff --git a/packages/geoview-core/src/core/components/attribution/attribution.tsx b/packages/geoview-core/src/core/components/attribution/attribution.tsx index 9f3b17ac242..c0a5bc58a55 100644 --- a/packages/geoview-core/src/core/components/attribution/attribution.tsx +++ b/packages/geoview-core/src/core/components/attribution/attribution.tsx @@ -1,41 +1,73 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState, memo } from 'react'; import { useTheme } from '@mui/material/styles'; import { Box, MoreHorizIcon, Popover, IconButton, Typography } from '@/ui'; -import { useUIMapInfoExpanded } from '@/core/stores/store-interface-and-intial-values/ui-state'; import { useMapAttribution } from '@/core/stores/store-interface-and-intial-values/map-state'; -import { generateId } from '@/core/utils/utilities'; import { useGeoViewMapId } from '@/core/stores/geoview-store'; import { logger } from '@/core/utils/logger'; +// Constants outside component to prevent recreating every render +const POPOVER_POSITIONS = { + anchorOrigin: { + vertical: 'top' as const, + horizontal: 'right' as const, + }, + transformOrigin: { + vertical: 'bottom' as const, + horizontal: 'right' as const, + }, +} as const; + +const BOX_STYLES = { padding: '1rem', width: '28.125rem' } as const; + +const ICON_BUTTON_BASE_STYLES = { + width: '30px', + height: '30px', + my: '1rem', + margin: 'auto', +} as const; + /** * Create an Attribution component that will display an attribution box * with the attribution text * * @returns {JSX.Element} created attribution element */ -export function Attribution(): JSX.Element { +// Memoizes entire component, preventing re-renders if props haven't changed +export const Attribution = memo(function Attribution(): JSX.Element { // Log logger.logTraceRender('components/attribution/attribution'); + // Hooks const theme = useTheme(); - const mapId = useGeoViewMapId(); - const mapElem = document.getElementById(`shell-${mapId}`); - - // internal state + // State const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); - // getStore value + // Store const mapAttribution = useMapAttribution(); - const expanded = useUIMapInfoExpanded(); + const mapId = useGeoViewMapId(); + const mapElem = document.getElementById(`shell-${mapId}`); + + const buttonStyles = { + ...ICON_BUTTON_BASE_STYLES, + color: theme.palette.geoViewColor.bgColor.light[800], + }; + + // Memoize values + const attributionContent = useMemo( + () => mapAttribution.map((attribution) => {attribution}), + [mapAttribution] + ); + + // Callbacks + // Popover state expand/collapse const handleOpenPopover = useCallback((event: React.MouseEvent): void => { setAnchorEl(event.currentTarget); }, []); - const handleClosePopover = useCallback(() => { setAnchorEl(null); }, []); @@ -49,16 +81,7 @@ export function Attribution(): JSX.Element { tooltipPlacement="top" tooltip="mapctrl.attribution.tooltip" aria-label="mapctrl.attribution.tooltip" - sx={{ - color: theme.palette.geoViewColor.bgColor.light[800], - marginTop: expanded ? '0.75rem' : '0.25rem', - [theme.breakpoints.up('md')]: { - marginTop: expanded ? '1.4375rem' : 'none', - }, - width: '30px', - height: '30px', - my: '1rem', - }} + sx={buttonStyles} > @@ -66,22 +89,15 @@ export function Attribution(): JSX.Element { open={open} anchorEl={anchorEl} container={mapElem} - anchorOrigin={{ - vertical: 'top', - horizontal: 'right', - }} + anchorOrigin={POPOVER_POSITIONS.anchorOrigin} transformOrigin={{ vertical: 'bottom', horizontal: 'right', }} onClose={handleClosePopover} > - - {mapAttribution.map((attribution) => { - return {attribution}; - })} - + {attributionContent} ); -} +}); diff --git a/packages/geoview-core/src/core/components/click-marker/click-marker.tsx b/packages/geoview-core/src/core/components/click-marker/click-marker.tsx index ffdab942c49..777bdf64dee 100644 --- a/packages/geoview-core/src/core/components/click-marker/click-marker.tsx +++ b/packages/geoview-core/src/core/components/click-marker/click-marker.tsx @@ -1,8 +1,8 @@ -import React, { useEffect, useRef } from 'react'; +import { useEffect, useRef, memo } from 'react'; import { Coordinate } from 'ol/coordinate'; // For typing only - import { Box, ClickMapMarker } from '@/ui'; + import { useMapClickMarker, useMapClickCoordinates, useMapStoreActions } from '@/core/stores/store-interface-and-intial-values/map-state'; import { logger } from '@/core/utils/logger'; import { TypeJsonObject } from '@/core/types/global-types'; @@ -19,21 +19,22 @@ export type TypeClickMarker = { * * @returns {JSX.Element} the react element with a marker on click */ -export function ClickMarker(): JSX.Element { - // Log +// Memoizes entire component, preventing re-renders if props haven't changed +export const ClickMarker = memo(function ClickMarker(): JSX.Element { logger.logTraceRender('components/click-marker/click-marker'); - const mapId = useGeoViewMapId(); - - // internal state + // State const clickMarkerRef = useRef(null); - const clickMarkerId = `${mapId}-clickmarker`; + const clickMarkerId = `${useGeoViewMapId()}-clickmarker`; - // get values from the store + // Store const clickMarker = useMapClickMarker(); const clickCoordinates = useMapClickCoordinates(); const { setOverlayClickMarkerRef, showClickMarker } = useMapStoreActions(); - setTimeout(() => setOverlayClickMarkerRef(clickMarkerRef.current as HTMLElement), 0); + + useEffect(() => { + setOverlayClickMarkerRef(clickMarkerRef.current as HTMLElement); + }, [setOverlayClickMarkerRef]); useEffect(() => { // Log @@ -67,4 +68,4 @@ export function ClickMarker(): JSX.Element { /> ); -} +}); diff --git a/packages/geoview-core/src/core/components/crosshair/crosshair-icon.tsx b/packages/geoview-core/src/core/components/crosshair/crosshair-icon.tsx index 936c490bc29..db439b479a5 100644 --- a/packages/geoview-core/src/core/components/crosshair/crosshair-icon.tsx +++ b/packages/geoview-core/src/core/components/crosshair/crosshair-icon.tsx @@ -1,7 +1,10 @@ +import { memo } from 'react'; + /** * Create a cross hair icon */ -export function CrosshairIcon(): JSX.Element { +// Memoizes entire component, preventing re-renders if props haven't changed +export const CrosshairIcon = memo(function CrosshairIcon(): JSX.Element { return ( @@ -9,4 +12,4 @@ export function CrosshairIcon(): JSX.Element { ); -} +}); diff --git a/packages/geoview-core/src/core/components/crosshair/crosshair.tsx b/packages/geoview-core/src/core/components/crosshair/crosshair.tsx index 8841248d3cf..3e1dadc872b 100644 --- a/packages/geoview-core/src/core/components/crosshair/crosshair.tsx +++ b/packages/geoview-core/src/core/components/crosshair/crosshair.tsx @@ -1,15 +1,12 @@ -import { useCallback, useEffect, useRef } from 'react'; - +import { memo, useCallback, useEffect, useRef } from 'react'; import { useTheme } from '@mui/material/styles'; - import { useTranslation } from 'react-i18next'; - import { Box, Fade, Typography } from '@/ui'; + import { getSxClasses } from './crosshair-style'; import { CrosshairIcon } from './crosshair-icon'; import { useAppCrosshairsActive } from '@/core/stores/store-interface-and-intial-values/app-state'; import { useMapPointerPosition, useMapStoreActions } from '@/core/stores/store-interface-and-intial-values/map-state'; - import { logger } from '@/core/utils/logger'; type CrosshairProps = { @@ -21,24 +18,25 @@ type CrosshairProps = { * @param {CrosshairProps} - Crossahir props who caintain the mapTargetELement * @returns {JSX.Element} the crosshair component */ -export function Crosshair({ mapTargetElement }: CrosshairProps): JSX.Element { - // Log +// Memoizes entire component, preventing re-renders if props haven't changed +export const Crosshair = memo(function Crosshair({ mapTargetElement }: CrosshairProps): JSX.Element { logger.logTraceRender('components/crosshair/crosshair'); + // Hooks const { t } = useTranslation(); - const theme = useTheme(); const sxClasses = getSxClasses(theme); - // get store values + // State (no useState for item used inside function only without rendering... use useRef) + const panelButtonId = useRef(''); + const panDelta = useRef(128); + + // Store const isCrosshairsActive = useAppCrosshairsActive(); const pointerPosition = useMapPointerPosition(); const { setClickCoordinates, setMapKeyboardPanInteractions } = useMapStoreActions(); - // do not use useState for item used inside function only without rendering... use useRef - const panelButtonId = useRef(''); - const panDelta = useRef(128); - + // Callbacks /** * Simulate map mouse click to trigger details panel * @function simulateClick @@ -73,7 +71,8 @@ export function Crosshair({ mapTargetElement }: CrosshairProps): JSX.Element { setMapKeyboardPanInteractions(panDelta.current); } }, - [setMapKeyboardPanInteractions] + // eslint-disable-next-line react-hooks/exhaustive-deps + [] // State setters are stable, no need for dependencies ); useEffect(() => { @@ -110,4 +109,4 @@ export function Crosshair({ mapTargetElement }: CrosshairProps): JSX.Element { ); -} +}); diff --git a/packages/geoview-core/src/core/components/hover-tooltip/hover-tooltip.tsx b/packages/geoview-core/src/core/components/hover-tooltip/hover-tooltip.tsx index 255514b6128..255852d51c6 100644 --- a/packages/geoview-core/src/core/components/hover-tooltip/hover-tooltip.tsx +++ b/packages/geoview-core/src/core/components/hover-tooltip/hover-tooltip.tsx @@ -1,10 +1,14 @@ -import React, { useEffect, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useTheme, Theme } from '@mui/material/styles'; - import { Box } from '@/ui'; + import { logger } from '@/core/utils/logger'; -import { useMapHoverFeatureInfo, useMapPointerPosition } from '@/core/stores/store-interface-and-intial-values/map-state'; +import { + useMapHoverFeatureInfo, + useMapIsMouseInsideMap, + useMapPointerPosition, +} from '@/core/stores/store-interface-and-intial-values/map-state'; import { getSxClasses } from './hover-tooltip-styles'; import { useGeoViewMapId } from '@/core/stores/geoview-store'; import { useAppGeoviewHTMLElement } from '@/core/stores/store-interface-and-intial-values/app-state'; @@ -14,65 +18,51 @@ import { useAppGeoviewHTMLElement } from '@/core/stores/store-interface-and-inti * * @returns {JSX.Element} the hover tooltip component */ -export function HoverTooltip(): JSX.Element | null { +// Memoizes entire component, preventing re-renders if props haven't changed +export const HoverTooltip = memo(function HoverTooltip(): JSX.Element | null { // Log, commented too annoying // logger.logTraceRender('components/hover-tooltip/hover-tooltip'); + // Hooks const { t } = useTranslation(); - const mapId = useGeoViewMapId(); - const theme: Theme & { iconImage: React.CSSProperties; } = useTheme(); - - // internal component state - const [tooltipValue, setTooltipValue] = useState(''); - const [tooltipIcon, setTooltipIcon] = useState(''); - const [showTooltip, setShowTooltip] = useState(false); - const sxClasses = getSxClasses(theme); - // store state - const hoverFeatureInfo = useMapHoverFeatureInfo(); - const pointerPosition = useMapPointerPosition(); - const mapElem = useAppGeoviewHTMLElement().querySelector(`[id^="mapTargetElement-${mapId}"]`) as HTMLElement; - + // State + const [tooltipValue, setTooltipValue] = useState(''); const tooltipRef = useRef(null); + const tooltipState = useRef({ + value: '', + icon: '', + show: false, + }); // state management using refs to avoid re-renders - // Update tooltip when store value change from propagation by hover-layer-set to map-event-processor - useEffect(() => { - // Log - logger.logTraceUseEffect('HOVER-TOOLTIP - hoverFeatureInfo', hoverFeatureInfo); - - if (hoverFeatureInfo) { - setTooltipValue(hoverFeatureInfo!.fieldInfo?.value as string | ''); - setTooltipIcon(hoverFeatureInfo!.featureIcon.toDataURL()); - setShowTooltip(true); - } - }, [hoverFeatureInfo]); - - // clear the tooltip and mouse move and set pixel location - useEffect(() => { - // Log, commented too annoying - // logger.logTraceUseEffect('HOVER-TOOLTIP - pointerPosition', pointerPosition); - - setTooltipValue(''); - setTooltipIcon(''); - setShowTooltip(false); - }, [pointerPosition]); + // Store + const hoverFeatureInfo = useMapHoverFeatureInfo(); + const pointerPosition = useMapPointerPosition(); + const isMouseouseInMap = useMapIsMouseInsideMap(); + const mapElem = useAppGeoviewHTMLElement().querySelector(`[id^="mapTargetElement-${useGeoViewMapId()}"]`) as HTMLElement; + + // Callbacks + const hideTooltip = useCallback(() => { + tooltipState.current = { + value: '', + icon: '', + show: false, + }; + }, []); // Update tooltip position when we have the dimensions of the tooltip - useEffect(() => { - logger.logTraceUseEffect('HOVER-TOOLTIP - tooltipValue changed', tooltipValue); - - if (!mapElem || !tooltipRef.current || !pointerPosition || !pointerPosition.pixel || !tooltipValue) { + const updateTooltipPosition = useCallback(() => { + if (!mapElem || !tooltipRef.current || !pointerPosition?.pixel || !tooltipState.current.value) { return; } const mapRect = mapElem.getBoundingClientRect(); const tooltipRect = tooltipRef.current.getBoundingClientRect(); - // Check if the tooltip is outside the map let tooltipX = pointerPosition.pixel[0] + 10; let tooltipY = pointerPosition.pixel[1] - 35; @@ -86,9 +76,38 @@ export function HoverTooltip(): JSX.Element | null { tooltipRef.current.style.left = `${tooltipX}px`; tooltipRef.current.style.top = `${tooltipY}px`; - }, [tooltipValue, mapElem, pointerPosition]); + }, [mapElem, pointerPosition]); + + // Update tooltip when store value change from propagation by hover-layer-set to map-event-processor + useEffect(() => { + // Log + logger.logTraceUseEffect('HOVER-TOOLTIP - hoverFeatureInfo', hoverFeatureInfo); + + if (!hoverFeatureInfo || !isMouseouseInMap) { + // clear the tooltip, no info at pixel location + hideTooltip(); + } else { + tooltipState.current = { + value: (hoverFeatureInfo.fieldInfo?.value as string) || '', + icon: hoverFeatureInfo.featureIcon.toDataURL(), + show: true, + }; + + // Use value to force the ref state to change (multiple hoverInfo response) + // TODO: refactor: From the hover layer set, only return the good value + setTooltipValue((hoverFeatureInfo.fieldInfo?.value as string) || ''); + + updateTooltipPosition(); + } + }, [hideTooltip, hoverFeatureInfo, isMouseouseInMap, updateTooltipPosition]); + + // Clear tooltip on mouse move + useEffect(() => { + hideTooltip(); + }, [pointerPosition, hideTooltip]); - if (showTooltip && !tooltipValue) { + // Don't render if we should show tooltip but have no value + if (tooltipState.current.show && !tooltipState.current.value) { return null; } @@ -97,11 +116,11 @@ export function HoverTooltip(): JSX.Element | null { ref={tooltipRef} sx={sxClasses.tooltipItem} style={{ - visibility: showTooltip ? 'visible' : 'hidden', + visibility: tooltipState.current.show ? 'visible' : 'hidden', }} > - + {tooltipValue} ); -} +}); diff --git a/packages/geoview-core/src/core/components/map-info/map-info-expand-button.tsx b/packages/geoview-core/src/core/components/map-info/map-info-expand-button.tsx index fb37ecdcd86..8223a89f8a9 100644 --- a/packages/geoview-core/src/core/components/map-info/map-info-expand-button.tsx +++ b/packages/geoview-core/src/core/components/map-info/map-info-expand-button.tsx @@ -1,55 +1,82 @@ -import { useEffect } from 'react'; +import { memo, useCallback, useEffect } from 'react'; import { useTheme } from '@mui/material'; import { ExpandMoreIcon, ExpandLessIcon, IconButton, Box } from '@/ui'; import { useUIStoreActions, useUIMapInfoExpanded } from '@/core/stores/store-interface-and-intial-values/ui-state'; import { logger } from '@/core/utils/logger'; +// Constants outside component to prevent recreating every render +const TOOLTIP_KEY = 'layers.toggleCollapse'; + +const BOX_STYLES = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +} as const; + +const BUTTON_BASE_STYLES = { + my: '1rem', +} as const; + +/** + * Expand icon component + */ +const ExpandIcon = memo(function ExpandIcon({ expanded }: { expanded: boolean }) { + return expanded ? : ; +}); + /** * Map Information Expand Button component * * @returns {JSX.Element} the expand buttons */ -export function MapInfoExpandButton(): JSX.Element { +// Memoizes entire component, preventing re-renders if props haven't changed +export const MapInfoExpandButton = memo(function MapInfoExpandButton(): JSX.Element { + logger.logTraceRender('components/map-info/mmap-info-expand-button'); + + // Hooks const theme = useTheme(); - // get the expand or collapse from expand button click + // Store const expanded = useUIMapInfoExpanded(); const { setMapInfoExpanded } = useUIStoreActions(); - const tooltipAndAria = 'layers.toggleCollapse'; - - /** - * Expand the map information bar - */ - const expandMapInfo = (): void => { - setMapInfoExpanded(true); + const buttonStyles = { + ...BUTTON_BASE_STYLES, + color: theme.palette.geoViewColor.bgColor.light[800], }; - /** - * Collapse map information - */ - const collapseMapInfo = (): void => { - setMapInfoExpanded(false); - }; + // Callback expand/collapse + const expandMapInfo = useCallback( + (): void => { + setMapInfoExpanded(true); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] // State setters are stable, no need for dependencies + ); + const collapseMapInfo = useCallback( + (): void => { + setMapInfoExpanded(false); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] // State setters are stable, no need for dependencies + ); useEffect(() => { // Log logger.logTraceUseEffect('MAP-INFO-EXPAND-BUTTON - mount'); - - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( - + (expanded ? collapseMapInfo() : expandMapInfo())} - sx={{ color: theme.palette.geoViewColor.bgColor.light[800], my: '1rem' }} + sx={buttonStyles} > - {expanded ? : } + ); -} +}); diff --git a/packages/geoview-core/src/core/components/map-info/map-info-fixnorth-switch.tsx b/packages/geoview-core/src/core/components/map-info/map-info-fixnorth-switch.tsx index 0d1091e1120..d4fc26fc0f0 100644 --- a/packages/geoview-core/src/core/components/map-info/map-info-fixnorth-switch.tsx +++ b/packages/geoview-core/src/core/components/map-info/map-info-fixnorth-switch.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { memo, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useTheme } from '@mui/material/styles'; import { useMediaQuery } from '@mui/material'; @@ -14,36 +14,67 @@ import { } from '@/core/stores/store-interface-and-intial-values/map-state'; import { logger } from '@/core/utils/logger'; +// Constants outside component to prevent recreating every render +const BOX_STYLES = { + minWidth: '30px', + display: 'flex', + alignItems: 'center', +} as const; + +/** + * Switch component for controlling map north orientation + */ +const NorthSwitch = memo(function NorthSwitch({ + isFixNorth, + onToggle, + tooltipText, + visible, +}: { + isFixNorth: boolean; + onToggle: (event: React.ChangeEvent) => void; + tooltipText: string; + visible: boolean; +}) { + return visible ? : null; +}); + /** * Map Information Fix North Switch component * * @returns {JSX.Element} the fix north switch */ -export function MapInfoFixNorthSwitch(): JSX.Element { - const { t } = useTranslation(); +// Memoizes entire component, preventing re-renders if props haven't changed +export const MapInfoFixNorthSwitch = memo(function MapInfoFixNorthSwitch(): JSX.Element { + logger.logTraceRender('components/map-info/map-info-fixnorth-switch'); + // Hooks + const { t } = useTranslation(); const theme = useTheme(); const deviceSizeMedUp = useMediaQuery(theme.breakpoints.down('md')); - // get store values + // Store const expanded = useUIMapInfoExpanded(); const isNorthEnable = useMapNorthArrow(); const isFixNorth = useMapFixNorth(); const mapProjection = useMapProjection(); const { setFixNorth, setRotation } = useMapStoreActions(); - /** - * Emit an event to specify the map to rotate to keep north straight - */ - const fixNorth = (event: React.ChangeEvent): void => { - // this event will be listen by the north-arrow.tsx component - setFixNorth(event.target.checked); + const isLCCProjection = `EPSG:${mapProjection}` === Projection.PROJECTION_NAMES.LCC; + const showSwitch = expanded && isLCCProjection && isNorthEnable; - // if unchecked, reset rotation - if (!event.target.checked) { - setRotation(0); - } - }; + // Callbacks + const handleFixNorth = useCallback( + (event: React.ChangeEvent): void => { + const isChecked = event.target.checked; + setFixNorth(isChecked); + + if (!isChecked) { + setRotation(0); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] // State setters are stable, no need for dependencies + ); useEffect(() => { // Log @@ -55,16 +86,8 @@ export function MapInfoFixNorthSwitch(): JSX.Element { }, [deviceSizeMedUp, setFixNorth]); return ( - - {expanded && `EPSG:${mapProjection}` === Projection.PROJECTION_NAMES.LCC && isNorthEnable ? ( - - ) : null} + + ); -} +}); diff --git a/packages/geoview-core/src/core/components/map-info/map-info-rotation-button.tsx b/packages/geoview-core/src/core/components/map-info/map-info-rotation-button.tsx index 27fd78d2474..98d947f49ab 100644 --- a/packages/geoview-core/src/core/components/map-info/map-info-rotation-button.tsx +++ b/packages/geoview-core/src/core/components/map-info/map-info-rotation-button.tsx @@ -1,31 +1,58 @@ +import { memo, useCallback, useRef } from 'react'; import { useTheme } from '@mui/material/styles'; -import { useRef } from 'react'; import { ArrowUpIcon, IconButton } from '@/ui'; + import { useMapRotation, useMapStoreActions } from '@/core/stores/store-interface-and-intial-values/map-state'; +import { logger } from '@/core/utils/logger'; /** * Map Information Rotation Button component * * @returns {JSX.Element} the rotation buttons */ -export function MapInfoRotationButton(): JSX.Element { +// Memoizes entire component, preventing re-renders if props haven't changed +export const MapInfoRotationButton = memo(function MapInfoRotationButton(): JSX.Element { + logger.logTraceRender('components/map-info/map-info-rotation-button'); + + // Hooks const theme = useTheme(); - // internal state + + // State const iconRef = useRef(null); - // get the values from store + // Store const mapRotation = useMapRotation(); const { setRotation } = useMapStoreActions(); + const buttonStyles = { + width: '30px', + height: '30px', + my: '1rem', + color: theme.palette.geoViewColor.bgColor.light[800], + }; + const iconStyles = { + transform: `rotate(${mapRotation}rad)`, + transition: 'transform 0.3s ease-in-out', + }; + + // Callbacks + const handleRotationReset = useCallback( + (): void => { + setRotation(0); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] // State setters are stable, no need for dependencies + ); + return ( setRotation(0)} - sx={{ color: theme.palette.geoViewColor.bgColor.light[800] }} + onClick={handleRotationReset} + sx={buttonStyles} > - + ); -} +}); diff --git a/packages/geoview-core/src/core/components/map-info/map-info.tsx b/packages/geoview-core/src/core/components/map-info/map-info.tsx index 17413163d13..8c5a18ef3e1 100644 --- a/packages/geoview-core/src/core/components/map-info/map-info.tsx +++ b/packages/geoview-core/src/core/components/map-info/map-info.tsx @@ -1,57 +1,60 @@ +import { memo, useMemo } from 'react'; import { useTheme } from '@mui/material/styles'; - import { Box } from '@/ui'; + import { Attribution } from '@/core/components/attribution/attribution'; import { MousePosition } from '@/core/components/mouse-position/mouse-position'; import { Scale } from '@/core/components/scale/scale'; - import { MapInfoExpandButton } from './map-info-expand-button'; import { MapInfoRotationButton } from './map-info-rotation-button'; import { MapInfoFixNorthSwitch } from './map-info-fixnorth-switch'; -// import { getSxClasses } from './map-info-style'; import { useMapInteraction } from '@/core/stores/store-interface-and-intial-values/map-state'; import { useUIMapInfoExpanded } from '@/core/stores/store-interface-and-intial-values/ui-state'; import { logger } from '@/core/utils/logger'; import { useGeoViewMapId } from '@/core/stores/geoview-store'; +// Constants outside component to prevent recreating every render +const MAP_INFO_BASE_STYLES = { + display: 'flex', + alignItems: 'center', + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + px: '1rem', + zIndex: 200, +} as const; + /** * Create a map information element that contains attribtuion, mouse position and scale * * @returns {JSX.Element} the map information element */ -export function MapInfo(): JSX.Element { - // Log +// Memoizes entire component, preventing re-renders if props haven't changed +export const MapInfo = memo(function MapInfo(): JSX.Element { logger.logTraceRender('components/map-info/map-info'); - const mapId = useGeoViewMapId(); - + // Hooks const theme = useTheme(); - // get store values + // Store const expanded = useUIMapInfoExpanded(); + const interaction = useMapInteraction(); // Static map, do not display mouse position or rotation controls + const mapId = useGeoViewMapId(); // Element id for panel height (expanded) - // get value from the store - // if map is static do not display mouse position or rotation controls - const interaction = useMapInteraction(); + // Memoize values + const containerStyles = useMemo( + () => ({ + ...MAP_INFO_BASE_STYLES, + height: expanded ? '6rem' : '3rem', + background: theme.palette.geoViewColor.bgColor.dark[800], + color: theme.palette.geoViewColor.bgColor.light[800], + }), + [expanded, theme.palette.geoViewColor.bgColor.dark, theme.palette.geoViewColor.bgColor.light] + ); return ( - + {interaction === 'dynamic' && ( @@ -70,4 +73,4 @@ export function MapInfo(): JSX.Element { )} ); -} +}); diff --git a/packages/geoview-core/src/core/components/mouse-position/mouse-position.tsx b/packages/geoview-core/src/core/components/mouse-position/mouse-position.tsx index a23d168f80c..dbf814fdce2 100644 --- a/packages/geoview-core/src/core/components/mouse-position/mouse-position.tsx +++ b/packages/geoview-core/src/core/components/mouse-position/mouse-position.tsx @@ -1,102 +1,161 @@ -import { useState, useEffect } from 'react'; +import { memo, useState, useEffect, useMemo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Coordinate } from 'ol/coordinate'; import { useTheme } from '@mui/material/styles'; import { Box, Button, CheckIcon } from '@/ui'; + import { useUIMapInfoExpanded } from '@/core/stores/store-interface-and-intial-values/ui-state'; import { useMapPointerPosition } from '@/core/stores/store-interface-and-intial-values/map-state'; import { coordFormatDMS } from '@/geo/utils/utilities'; - import { getSxClasses } from './mouse-position-style'; +interface FormattedCoordinates { + lng: string; + lat: string; +} + +// Constants outside component to prevent recreating every render +const POSITION_MODES = { + DMS: 0, + DD: 1, + PROJECTED: 2, +} as const; + +/** + * Format coordinates utility component + */ +const CoordinateDisplay = memo(function CoordinateDisplay({ + position, + isActive, + sxClasses, + fontSize, +}: { + position: string; + isActive: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sxClasses: any; + fontSize: string; +}) { + return ( + + + {position} + + ); +}); + /** - * Create the mouse position + * Format the coordinates output in lat long + */ +const formatCoordinates = (lnglat: Coordinate, DMS: boolean, t: (key: string) => string): FormattedCoordinates => { + const labelX = lnglat[0] < 0 ? t('mapctrl.mouseposition.west') : t('mapctrl.mouseposition.east'); + const labelY = lnglat[1] < 0 ? t('mapctrl.mouseposition.south') : t('mapctrl.mouseposition.north'); + + const lng = `${DMS ? coordFormatDMS(lnglat[0]) : Math.abs(lnglat[0]).toFixed(4)} ${labelX}`; + const lat = `${DMS ? coordFormatDMS(lnglat[1]) : Math.abs(lnglat[1]).toFixed(4)} ${labelY}`; + + return { lng, lat }; +}; + +/** + * Create mouse position component * @returns {JSX.Element} the mouse position component */ -export function MousePosition(): JSX.Element { +// Memoizes entire component, preventing re-renders if props haven't changed +export const MousePosition = memo(function MousePosition(): JSX.Element { // Log too annoying // logger.logTraceRender('components/mouse-position/mouse-position'); + // Hooks const { t } = useTranslation(); - const theme = useTheme(); - const sxClasses = getSxClasses(theme); + const sxClasses = useMemo(() => getSxClasses(theme), [theme]); - // internal component state + // State const [positions, setPositions] = useState(['', '', '']); - const [positionMode, setPositionMode] = useState(0); + const [positionMode, setPositionMode] = useState(POSITION_MODES.DMS); - // get store values + // Store const expanded = useUIMapInfoExpanded(); const pointerPosition = useMapPointerPosition(); - /** - * Switch position mode - */ - const switchPositionMode = (): void => { - setPositionMode((positionMode + 1) % 3); - }; + /// Callbacks + const switchPositionMode = useCallback((): void => { + setPositionMode((p) => (p + 1) % 3); + }, []); useEffect(() => { // Log too annoying // logger.logTraceUseEffect('MOUSE-POSITION - pointerPosition', pointerPosition); - /** - * Format the coordinates output in lat long - * @param {Coordinate} lnglat the Lng and Lat value to format - * @param {boolean} DMS true if need to be formatted as Degree Minute Second, false otherwise - * @returns {Object} an object containing formatted Longitude and Latitude values - */ - function formatCoordinates(lnglat: Coordinate, DMS: boolean): { lng: string; lat: string } { - const labelX = lnglat[0] < 0 ? t('mapctrl.mouseposition.west') : t('mapctrl.mouseposition.east'); - const labelY = lnglat[1] < 0 ? t('mapctrl.mouseposition.south') : t('mapctrl.mouseposition.north'); - - const lng = `${DMS ? coordFormatDMS(lnglat[0]) : Math.abs(lnglat[0]).toFixed(4)} ${labelX}`; - const lat = `${DMS ? coordFormatDMS(lnglat[1]) : Math.abs(lnglat[1]).toFixed(4)} ${labelY}`; - - return { lng, lat }; - } - if (pointerPosition !== undefined) { const { lnglat, projected } = pointerPosition; - const DMS = formatCoordinates(lnglat, true); - const DD = formatCoordinates(lnglat, false); + const DMS = formatCoordinates(lnglat, true, t); + const DD = formatCoordinates(lnglat, false, t); setPositions([`${DMS.lng} | ${DMS.lat}`, `${DD.lng} | ${DD.lat}`, `${projected[0].toFixed(4)}m E | ${projected[1].toFixed(4)}m N`]); } }, [pointerPosition, t]); + // Memoized content + const expandedContent = useMemo( + () => ( + + {positions.map((position, index) => ( + + ))} + + ), + [positions, positionMode, expanded, sxClasses, theme.palette.geoViewFontSize.lg] + ); + + const collapsedContent = useMemo( + () => ( + + {positions[positionMode]} + + ), + [expanded, positions, positionMode, sxClasses.mousePositionText] + ); + return ( ); -} +}); diff --git a/packages/geoview-core/src/core/components/north-arrow/hooks/useManageArrow.tsx b/packages/geoview-core/src/core/components/north-arrow/hooks/useManageArrow.tsx index d2beae176fa..cdb657bd64e 100644 --- a/packages/geoview-core/src/core/components/north-arrow/hooks/useManageArrow.tsx +++ b/packages/geoview-core/src/core/components/north-arrow/hooks/useManageArrow.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Projection } from '@/geo/utils/projection'; import { NORTH_POLE_POSITION } from '@/core/utils/constant'; import { @@ -13,18 +13,22 @@ import { } from '@/core/stores/store-interface-and-intial-values/map-state'; import { logger } from '@/core/utils/logger'; +interface ArrowReturn { + rotationAngle: { angle: number }; + northOffset: number; +} + /** * Custom hook to Manage North arrow. * @returns rotationAngle and northoffset */ -// TODO: Refactor - Explicit this return as a type for rotationAngle and northoffset -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const useManageArrow = (): any => { +const useManageArrow = (): ArrowReturn => { + // State const [rotationAngle, setRotationAngle] = useState({ angle: 0 }); const [northOffset, setNorthOffset] = useState(0); const angle = useRef(0); // keep track of rotation angle for fix north - // get the values from store + // Store const mapProjection = useMapProjection(); const northArrowElement = useMapNorthArrowElement(); const fixNorth = useMapFixNorth(); @@ -34,58 +38,65 @@ const useManageArrow = (): any => { const mapSize = useMapSize(); const { getPixelFromCoordinate, setRotation } = useMapStoreActions(); + // Memoize projection check as it's used multiple times + const isLCCProjection = useMemo(() => `EPSG:${mapProjection}` === Projection.PROJECTION_NAMES.LCC, [mapProjection]); + const isWebMercator = useMemo(() => `EPSG:${mapProjection}` === Projection.PROJECTION_NAMES.WM, [mapProjection]); + /** * Calculate the north arrow offset * Calculation taken from RAMP: https://github.com/fgpv-vpgf/fgpv-vpgf/blob/master/packages/ramp-core/src/app/geo/map-tools.service.js * @param {number} angleDegrees north arrow rotation */ - function setOffset(angleDegrees: number): void { - const mapWidth = mapSize[0] / 2; - const arrowWidth = 24; - const offsetX = mapWidth - arrowWidth / 2; - - if (!fixNorth && getPixelFromCoordinate(NORTH_POLE_POSITION) !== null && `EPSG:${mapProjection}` === Projection.PROJECTION_NAMES.LCC) { - // hard code north pole so that arrow does not continue pointing past it - const screenNorthPoint = getPixelFromCoordinate(NORTH_POLE_POSITION); - const screenY = screenNorthPoint[1]; - - // if the extent is near the north pole be more precise otherwise use the original math - // note: using the precise math would be ideal but when zooming in, the calculations make very - // large adjustments so reverting to the old less precise math provides a better experience. - const triangle = { - x: offsetX, - y: getPixelFromCoordinate(mapCenterCoord)[1], - m: 1, - }; // original numbers - if (screenNorthPoint[0] < 2400 && screenNorthPoint[1] > -1300 && -screenNorthPoint[1] < 3000) { - // more precise - [triangle.x, triangle.y] = screenNorthPoint; - triangle.m = -1; - } + const setOffset = useCallback( + (angleDegrees: number): void => { + const mapWidth = mapSize[0] / 2; + const arrowWidth = 24; + const offsetX = mapWidth - arrowWidth / 2; + + if (!fixNorth && getPixelFromCoordinate(NORTH_POLE_POSITION) !== null && isLCCProjection) { + // hard code north pole so that arrow does not continue pointing past it + const screenNorthPoint = getPixelFromCoordinate(NORTH_POLE_POSITION); + const screenY = screenNorthPoint[1]; + + // if the extent is near the north pole be more precise otherwise use the original math + // note: using the precise math would be ideal but when zooming in, the calculations make very + // large adjustments so reverting to the old less precise math provides a better experience. + const triangle = { + x: offsetX, + y: getPixelFromCoordinate(mapCenterCoord)[1], + m: 1, + }; // original numbers + if (screenNorthPoint[0] < 2400 && screenNorthPoint[1] > -1300 && -screenNorthPoint[1] < 3000) { + // more precise + [triangle.x, triangle.y] = screenNorthPoint; + triangle.m = -1; + } - // z is the hypotenuse line from center point to the top of the viewer. The triangle is always a right triangle - const z = triangle.y / Math.sin(angleDegrees * 0.01745329252); // 0.01745329252 is the radian conversion + // z is the hypotenuse line from center point to the top of the viewer. The triangle is always a right triangle + const z = triangle.y / Math.sin(angleDegrees * 0.01745329252); // 0.01745329252 is the radian conversion - // this would be the bottom of our triangle, the length from center to where the arrow should be placed - let screenX = - screenY < 0 - ? triangle.x + triangle.m * (Math.sin((90 - angleDegrees) * 0.01745329252) * z) - arrowWidth / 2 - : screenNorthPoint[0] - arrowWidth; + // this would be the bottom of our triangle, the length from center to where the arrow should be placed + let screenX = + screenY < 0 + ? triangle.x + triangle.m * (Math.sin((90 - angleDegrees) * 0.01745329252) * z) - arrowWidth / 2 + : screenNorthPoint[0] - arrowWidth; - // Limit the arrow to the bounds of the inner shell (+/- 25% from center) - screenX = Math.max(offsetX - mapWidth * 0.25, Math.min(screenX, offsetX + mapWidth * 0.25)); + // Limit the arrow to the bounds of the inner shell (+/- 25% from center) + screenX = Math.max(offsetX - mapWidth * 0.25, Math.min(screenX, offsetX + mapWidth * 0.25)); - setNorthOffset(screenX); - } else { - setNorthOffset(offsetX); - } - } + setNorthOffset(screenX); + } else { + setNorthOffset(offsetX); + } + }, + [fixNorth, getPixelFromCoordinate, isLCCProjection, mapCenterCoord, mapSize] + ); useEffect(() => { // Log logger.logTraceUseEffect('USEMANAGEARROW - northArrowElement', northArrowElement, fixNorth); - if (`EPSG:${mapProjection}` === Projection.PROJECTION_NAMES.LCC) { + if (isLCCProjection) { // Because of the projection, corners are wrapped and central value of the polygon may be higher then corners values. // There is no easy way to see if the user sees the north pole just by using bounding box. One of the solution may // be to use a debounce function to call on moveEnd where we @@ -122,14 +133,13 @@ const useManageArrow = (): any => { // set arrow offset setOffset(angleDegrees); } - } else if (`EPSG:${mapProjection}` === Projection.PROJECTION_NAMES.WM) { + } else if (isWebMercator) { setOffset(0); // set arrow rotation to map rotation as Web /mercator always has north straight up setRotationAngle({ angle: mapRotation * (180 / Math.PI) }); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [northArrowElement, fixNorth, mapSize, mapRotation]); + }, [northArrowElement, fixNorth, mapSize, mapRotation, isLCCProjection, isWebMercator, mapZoom, setOffset, setRotation]); return { rotationAngle, northOffset }; }; diff --git a/packages/geoview-core/src/core/components/north-arrow/north-arrow-icon.tsx b/packages/geoview-core/src/core/components/north-arrow/north-arrow-icon.tsx index 2c9a9b0e6f6..57d5e5930ad 100644 --- a/packages/geoview-core/src/core/components/north-arrow/north-arrow-icon.tsx +++ b/packages/geoview-core/src/core/components/north-arrow/north-arrow-icon.tsx @@ -1,3 +1,5 @@ +import { memo } from 'react'; + /** * interface for north arrow icon properties */ @@ -11,7 +13,8 @@ interface NorthArrowIconProps { * * @param {NorthArrowIconProps} props north arrow icon properties */ -export function NorthArrowIcon(props: NorthArrowIconProps): JSX.Element { +// Memoizes entire component, preventing re-renders if props haven't changed +export const NorthArrowIcon = memo(function NorthArrowIcon(props: NorthArrowIconProps): JSX.Element { const { width, height } = props; return ( @@ -51,17 +54,18 @@ export function NorthArrowIcon(props: NorthArrowIconProps): JSX.Element { ); -} +}); /** * Create a north pole icon * */ -export function NorthPoleIcon(): JSX.Element { +// Memoizes entire component, preventing re-renders if props haven't changed +export const NorthPoleIcon = memo(function NorthPoleIcon(): JSX.Element { return ( ); -} +}); diff --git a/packages/geoview-core/src/core/components/north-arrow/north-arrow.tsx b/packages/geoview-core/src/core/components/north-arrow/north-arrow.tsx index d381560ce44..13149089555 100644 --- a/packages/geoview-core/src/core/components/north-arrow/north-arrow.tsx +++ b/packages/geoview-core/src/core/components/north-arrow/north-arrow.tsx @@ -1,4 +1,4 @@ -import { useRef } from 'react'; +import { memo, useMemo, useRef } from 'react'; import { useTheme } from '@mui/material/styles'; @@ -13,27 +13,35 @@ import { useGeoViewMapId } from '@/core/stores/geoview-store'; import { logger } from '@/core/utils/logger'; /** - * Create a north arrow + * Create a north arrow component * * @returns {JSX.Element} the north arrow component */ -export function NorthArrow(): JSX.Element { - // Log +// Memoizes entire component, preventing re-renders if props haven't changed +export const NorthArrow = memo(function NorthArrow(): JSX.Element { logger.logTraceRender('components/north-arrow/north-arrow'); + // Hooks const theme = useTheme(); const sxClasses = getSxClasses(theme); - // internal component state (do not use useState for item used inside function only without rendering... use useRef) + // State (no useState for item used inside function only without rendering... use useRef) const northArrowRef = useRef(null); - // get the values from store + // Store const mapProjection = useMapProjection(); const northArrowElement = useMapNorthArrowElement(); - const { rotationAngle, northOffset } = useManageArrow(); - return `EPSG:${mapProjection}` === Projection.PROJECTION_NAMES.LCC || `EPSG:${mapProjection}` === Projection.PROJECTION_NAMES.WM ? ( + // Memoize this check as it's used in conditional rendering + const isValidProjection = useMemo( + () => `EPSG:${mapProjection}` === Projection.PROJECTION_NAMES.LCC || `EPSG:${mapProjection}` === Projection.PROJECTION_NAMES.WM, + [mapProjection] + ); + + if (!isValidProjection) return ; + + return ( - ) : ( - ); -} +}); /** * Create a north pole flag icon * @returns {JSX.Element} the north pole marker icon */ -export function NorthPoleFlag(): JSX.Element { - const mapId = useGeoViewMapId(); - - // internal state - const northPoleId = `${mapId}-northpole`; +// Memoizes entire component, preventing re-renders if props haven't changed +export const NorthPoleFlag = memo(function NorthPoleFlag(): JSX.Element { + // State + const northPoleId = `${useGeoViewMapId()}-northpole`; const northPoleRef = useRef(null); - // get the values from store + // Store const mapProjection = useMapProjection(); const { setOverlayNorthMarkerRef } = useMapStoreActions(); setTimeout(() => setOverlayNorthMarkerRef(northPoleRef.current as HTMLElement), 0); // set marker reference + const isVisible = `EPSG:${mapProjection}` === Projection.PROJECTION_NAMES.LCC; + return ( - + ); -} +}); diff --git a/packages/geoview-core/src/core/components/scale/scale.tsx b/packages/geoview-core/src/core/components/scale/scale.tsx index c08ffdb2e7b..dbd3dcefecd 100644 --- a/packages/geoview-core/src/core/components/scale/scale.tsx +++ b/packages/geoview-core/src/core/components/scale/scale.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useMemo } from 'react'; +import { memo, useState, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -17,31 +17,39 @@ interface TypeScale { borderBottom: boolean; } +// Constants outside component to prevent recreating every render +const SCALE_MODES = { + METRIC: 0, + IMPERIAL: 1, + NUMERIC: 2, +} as const; + +const BOX_STYLES = { minWidth: 120 } as const; + /** - * Create an element that displays the scale + * Create a scale component * * @returns {JSX.Element} created scale element */ -export function Scale(): JSX.Element { - // Log +// Memoizes entire component, preventing re-renders if props haven't changed +export const Scale = memo(function Scale(): JSX.Element { logger.logTraceRender('components/scale/scale'); - const mapId = useGeoViewMapId(); - + // Hooks const { t } = useTranslation(); - const theme = useTheme(); - const sxClasses = getSxClasses(theme); + const sxClasses = useMemo(() => getSxClasses(theme), [theme]); - // internal component state - const [scaleMode, setScaleMode] = useState(0); + // State + const [scaleMode, setScaleMode] = useState(SCALE_MODES.METRIC); - // get the values from store + // Store + const mapId = useGeoViewMapId(); const expanded = useUIMapInfoExpanded(); const scale = useMapScale(); const interaction = useMapInteraction(); - // Memoize scale values array since it doesn't change + // Memoize values const scaleValues: TypeScale[] = useMemo( () => [ { @@ -63,22 +71,25 @@ export function Scale(): JSX.Element { [scale.labelGraphicMetric, scale.labelGraphicImperial, scale.labelNumeric] ); - // Memoize getScaleWidth function + // Callback const getScaleWidth = useCallback( (mode: number): string => { - if (mode === 0) return scale.lineWidthMetric; - if (mode === 1) return scale.lineWidthImperial; - return 'none'; + switch (mode) { + case SCALE_MODES.METRIC: + return scale.lineWidthMetric; + case SCALE_MODES.IMPERIAL: + return scale.lineWidthImperial; + default: + return 'none'; + } }, [scale.lineWidthMetric, scale.lineWidthImperial] ); - - // Memoize switchScale callback const switchScale = useCallback((): void => { setScaleMode((prev) => (prev + 1) % 3); }, []); - // Memoize the expanded content + // Memoize UI - expanded content const expandedContent = useMemo( () => ( @@ -93,7 +104,7 @@ export function Scale(): JSX.Element { /> ( - +