diff --git a/packages/geoview-core/public/configs/navigator/28-geocore.json b/packages/geoview-core/public/configs/navigator/28-geocore.json index 4d265953b33..139ba497d62 100644 --- a/packages/geoview-core/public/configs/navigator/28-geocore.json +++ b/packages/geoview-core/public/configs/navigator/28-geocore.json @@ -25,6 +25,10 @@ { "geoviewLayerType": "geoCore", "geoviewLayerId": "03ccfb5c-a06e-43e3-80fd-09d4f8f69703" + }, + { + "geoviewLayerType": "geoCore", + "geoviewLayerId": "6433173f-bca8-44e6-be8e-3e8a19d3c299" } ] }, diff --git a/packages/geoview-core/src/api/event-processors/event-processor-children/feature-info-event-processor.ts b/packages/geoview-core/src/api/event-processors/event-processor-children/feature-info-event-processor.ts index c77e32514f8..1104da4b362 100644 --- a/packages/geoview-core/src/api/event-processors/event-processor-children/feature-info-event-processor.ts +++ b/packages/geoview-core/src/api/event-processors/event-processor-children/feature-info-event-processor.ts @@ -164,23 +164,18 @@ export class FeatureInfoEventProcessor extends AbstractEventProcessor { // Depending on the event type if (eventType === 'click') { - const atLeastOneFeature = layerDataArray.find((layerEntry) => !!layerEntry.features?.length) || false; + // Show details panel as soon as there is a click on the map + // If the current tab is not 'details' nor 'geochart', switch to details + if (!['details', 'geochart'].includes(UIEventProcessor.getActiveFooterBarTab(mapId))) { + UIEventProcessor.setActiveFooterBarTab(mapId, 'details'); + } + // Open details appbar tab when user clicked on map layer. + if (UIEventProcessor.getAppBarComponents(mapId).includes('details')) { + UIEventProcessor.setActiveAppBarTab(mapId, `${mapId}AppbarPanelButtonDetails`, 'details', true, true); + } // Update the layer data array in the store, all the time, for all statuses featureInfoState.setterActions.setLayerDataArray(layerDataArray); - - // If there was some features on this propagation - if (atLeastOneFeature) { - // If the current tab is not 'details' nor 'geochart', switch to details - if (!['details', 'geochart'].includes(UIEventProcessor.getActiveFooterBarTab(mapId))) { - UIEventProcessor.setActiveFooterBarTab(mapId, 'details'); - } - - // Open details appbar tab when user clicked on map layer. - if (UIEventProcessor.getAppBarComponents(mapId).includes('details')) { - UIEventProcessor.setActiveAppBarTab(mapId, `${mapId}AppbarPanelButtonDetails`, 'details', true, true); - } - } } else if (eventType === 'name') { // Update the layer data array in the store, all the time, for all statuses featureInfoState.setterActions.setLayerDataArray(layerDataArray); 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 ac657ef24c4..4f50671147d 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 @@ -48,7 +48,7 @@ import { UIEventProcessor } from './ui-event-processor'; import { TypeMapFeaturesConfig } from '@/core/types/global-types'; import { TypeClickMarker } from '@/core/components'; import { IMapState, TypeOrderedLayerInfo, TypeScaleInfo } from '@/core/stores/store-interface-and-intial-values/map-state'; -import { TypeFeatureInfoResultSet, TypeHoverFeatureInfo } from '@/core/stores/store-interface-and-intial-values/feature-info-state'; +import { TypeHoverFeatureInfo } from '@/core/stores/store-interface-and-intial-values/feature-info-state'; import { TypeBasemapProps } from '@/geo/layer/basemap/basemap-types'; import { LegendEventProcessor } from './legend-event-processor'; import { TypeLegendLayer } from '@/core/components/layers/types'; @@ -109,6 +109,20 @@ export class MapEventProcessor extends AbstractEventProcessor { (removedFeatures[i].geometry as TypeGeometry).ol_uid ); } + }, + { + equalityFn: (prev, curr) => { + // Quick length checks first (prevents re-render) and calls to removeHighlight + if (prev === curr) return true; + if (prev.length !== curr.length) return false; + if (prev.length === 0) return true; + + // Use Set for O(1) lookup instead of array operations + const prevUids = new Set(prev.map((feature) => (feature.geometry as TypeGeometry).ol_uid)); + + // Single pass through current features + return curr.every((feature) => prevUids.has((feature.geometry as TypeGeometry).ol_uid)); + }, } ); @@ -412,15 +426,10 @@ export class MapEventProcessor extends AbstractEventProcessor { this.getMapStateProtected(mapId).setterActions.setPointerPosition(pointerPosition); } - static setClickCoordinates(mapId: string, clickCoordinates: TypeMapMouseInfo): Promise { - // Perform query via the feature info layer set process - const promise = this.getMapViewerLayerAPI(mapId).featureInfoLayerSet.queryLayers(clickCoordinates.lnglat); - + static setClickCoordinates(mapId: string, clickCoordinates: TypeMapMouseInfo): void { + // GV: We do not need to perform query, there is a handler on the map click in layer set. // Save in store this.getMapStateProtected(mapId).setterActions.setClickCoordinates(clickCoordinates); - - // Return the promise - return promise; } static setZoom(mapId: string, zoom: number): void { diff --git a/packages/geoview-core/src/core/components/crosshair/crosshair.tsx b/packages/geoview-core/src/core/components/crosshair/crosshair.tsx index 3e1dadc872b..45c250b3457 100644 --- a/packages/geoview-core/src/core/components/crosshair/crosshair.tsx +++ b/packages/geoview-core/src/core/components/crosshair/crosshair.tsx @@ -47,10 +47,7 @@ export const Crosshair = memo(function Crosshair({ mapTargetElement }: Crosshair logger.logTraceUseCallback('CROSSHAIR - simulateClick', pointerPosition); if (event.key === 'Enter' && pointerPosition) { // Update the store - setClickCoordinates(pointerPosition).catch((error) => { - // Log - logger.logPromiseFailed('Failed to setClickCoordinates in crosshair.simulateClick', error); - }); + setClickCoordinates(pointerPosition); } }, [pointerPosition, setClickCoordinates] diff --git a/packages/geoview-core/src/core/components/details/details-panel.tsx b/packages/geoview-core/src/core/components/details/details-panel.tsx index 0303c0c29d2..262abaf5cdf 100644 --- a/packages/geoview-core/src/core/components/details/details-panel.tsx +++ b/packages/geoview-core/src/core/components/details/details-panel.tsx @@ -15,9 +15,9 @@ import { TypeFeatureInfoEntry, TypeGeometry, TypeLayerData } from '@/geo/map/map import { LayerListEntry, Layout } from '@/core/components/common'; import { getSxClasses } from './details-style'; -import { FeatureInfo } from './feature-info-new'; -import { LAYER_STATUS, TABS } from '@/core/utils/constant'; -import DetailsSkeleton from './details-skeleton'; +import { FeatureInfo } from './feature-info'; +import { FEATURE_INFO_STATUS, TABS } from '@/core/utils/constant'; +import { DetailsSkeleton } from './details-skeleton'; interface DetailsPanelType { fullWidth?: boolean; @@ -29,38 +29,31 @@ interface DetailsPanelType { * @returns {JSX.Element} the layers list */ export function DetailsPanel({ fullWidth = false }: DetailsPanelType): JSX.Element { - // Log logger.logTraceRender('components/details/details-panel'); + // Hooks const { t } = useTranslation(); - const theme = useTheme(); const sxClasses = getSxClasses(theme); - // Get states and actions from store + // Store const mapId = useGeoViewMapId(); const selectedLayerPath = useDetailsSelectedLayerPath(); const arrayOfLayerDataBatch = useDetailsLayerDataArrayBatch(); const checkedFeatures = useDetailsCheckedFeatures(); const visibleLayers = useMapVisibleLayers(); const mapClickCoordinates = useMapClickCoordinates(); - const { setSelectedLayerPath, removeCheckedFeature, setLayerDataArrayBatchLayerPathBypass } = useDetailsStoreActions(); const { addHighlightedFeature, removeHighlightedFeature } = useMapStoreActions(); - // #region USE STATE SECTION **************************************************************************************** - - // internal state + // States const [currentFeatureIndex, setCurrentFeatureIndex] = useState(0); const [selectedLayerPathLocal, setselectedLayerPathLocal] = useState(selectedLayerPath); const [arrayOfLayerListLocal, setArrayOfLayerListLocal] = useState([]); - const prevLayerSelected = useRef(); const prevLayerFeatures = useRef(); const prevFeatureIndex = useRef(0); // 0 because that's the default index for the features - // #endregion - // #region MAIN HOOKS SECTION *************************************************************************************** /** @@ -69,33 +62,36 @@ export function DetailsPanel({ fullWidth = false }: DetailsPanelType): JSX.Eleme * @param {TypeFeatureInfoEntry} feature The feature to check * @returns {boolean} true if feature is in checkedFeatures */ + // Create a memoized Set of checked feature IDs + const checkedFeaturesSet = useMemo(() => { + return new Set(checkedFeatures.map((feature) => (feature.geometry as TypeGeometry)?.ol_uid)); + }, [checkedFeatures]); + + // Modified isFeatureInCheckedFeatures using the Set for O(1) lookup const isFeatureInCheckedFeatures = useCallback( (feature: TypeFeatureInfoEntry): boolean => { - // Log - logger.logTraceUseCallback('DETAILS-PANEL - isFeatureInCheckedFeatures'); - - return checkedFeatures.some((checkedFeature) => { - return (checkedFeature.geometry as TypeGeometry)?.ol_uid === (feature.geometry as TypeGeometry)?.ol_uid; - }); + return checkedFeaturesSet.has((feature.geometry as TypeGeometry)?.ol_uid); }, - [checkedFeatures] + [checkedFeaturesSet] ); /** * Clears the highlighed features when they are not checked. * @param {TypeFeatureInfoEntry[] | undefined | null} arrayToClear The array to clear of the unchecked features */ + // Modified clearHighlightsUnchecked const clearHighlightsUnchecked = useCallback( (arrayToClear: TypeFeatureInfoEntry[] | undefined | null) => { - // Log logger.logTraceUseCallback('DETAILS-PANEL - clearHighlightsUnchecked'); - // Clear any feature that's not currently checked arrayToClear?.forEach((feature) => { - if (!isFeatureInCheckedFeatures(feature)) removeHighlightedFeature(feature); + const featureId = (feature.geometry as TypeGeometry)?.ol_uid; + if (!checkedFeaturesSet.has(featureId)) { + removeHighlightedFeature(feature); + } }); }, - [isFeatureInCheckedFeatures, removeHighlightedFeature] + [checkedFeaturesSet, removeHighlightedFeature] ); /** @@ -301,7 +297,6 @@ export function DetailsPanel({ fullWidth = false }: DetailsPanelType): JSX.Eleme // #endregion // #region EVENT HANDLERS SECTION *********************************************************************************** - /** * Handles click to remove all features in right panel. */ @@ -348,7 +343,6 @@ export function DetailsPanel({ fullWidth = false }: DetailsPanelType): JSX.Eleme }, [setSelectedLayerPath] ); - // #endregion // #region PROCESSING *********************************************************************************************** @@ -414,31 +408,39 @@ export function DetailsPanel({ fullWidth = false }: DetailsPanelType): JSX.Eleme }, [mapClickCoordinates, memoLayersList]); /** - * Check all layers status is processing while querying + * Check all layers status is processed while querying */ - const memoIsAllLayersQueryStatusProcessing = useMemo(() => { + const memoIsAllLayersQueryStatusProcessed = useMemo(() => { // Log - logger.logTraceUseMemo('DETAILS-PANEL - order layer status processing.'); + logger.logTraceUseMemo('DETAILS-PANEL - AllLayersQueryStatusProcessed.'); if (!arrayOfLayerDataBatch || arrayOfLayerDataBatch?.length === 0) return () => false; - return () => !!arrayOfLayerDataBatch?.every((layer) => layer.queryStatus === LAYER_STATUS.PROCESSING); + return () => arrayOfLayerDataBatch?.every((layer) => layer.queryStatus === FEATURE_INFO_STATUS.PROCESSED); }, [arrayOfLayerDataBatch]); // #endregion - // #region RENDER SECTION ******************************************************************************************* - /** * Render the right panel content based on detail's layer and loading status. * NOTE: Here we return null, so that in responsive grid layout, it can be used as flag to render the guide for details. * @returns {JSX.Element | null} JSX.Element | null */ const renderContent = (): JSX.Element | null => { - if (memoIsAllLayersQueryStatusProcessing()) { + // If there is no layer, return null for the guide to show + if ((memoLayersList && memoLayersList.length === 0) || selectedLayerPath === '') { + return null; + } + + // Until process or something found for selected layerPath, return skeleton + if (!memoIsAllLayersQueryStatusProcessed() && !(memoSelectedLayerDataFeatures && memoSelectedLayerDataFeatures.length > 0)) { return ; } + if (memoSelectedLayerDataFeatures && memoSelectedLayerDataFeatures.length > 0) { + // Get only the current feature + const currentFeature = memoSelectedLayerDataFeatures[currentFeatureIndex]; + return ( @@ -486,10 +488,12 @@ export function DetailsPanel({ fullWidth = false }: DetailsPanelType): JSX.Eleme - + ); } + + // if no condition met, return null for Guide tab return null; }; @@ -505,6 +509,4 @@ export function DetailsPanel({ fullWidth = false }: DetailsPanelType): JSX.Eleme {renderContent()} ); - - // # endregion } diff --git a/packages/geoview-core/src/core/components/details/details-skeleton.tsx b/packages/geoview-core/src/core/components/details/details-skeleton.tsx index 8be1b61a9bc..db1a982ed0d 100644 --- a/packages/geoview-core/src/core/components/details/details-skeleton.tsx +++ b/packages/geoview-core/src/core/components/details/details-skeleton.tsx @@ -1,16 +1,27 @@ +import { memo } from 'react'; import { Box, Skeleton } from '@/ui'; +// Constants outside component to prevent recreating every render +const sizes = ['15%', '10%', '15%', '25%', '10%', '20%', '10%']; + +const SKELETON_STYLES = { + box: { padding: '10px' }, + title: { mb: 1 }, + text: { pt: 4, pb: 4 }, +} as const; + /** * Custom details skeleton build with mui skeleton component. * @returns {JSX.Element} */ -export default function DetailsSkeleton(): JSX.Element { - const sizes = ['15%', '10%', '15%', '25%', '10%', '20%', '10%']; +// Memoizes entire component, preventing re-renders if props haven't changed +export const DetailsSkeleton = memo(function DetailsSkeleton(): JSX.Element { return ( - - + + + {sizes.map((size, index) => ( - + @@ -18,4 +29,4 @@ export default function DetailsSkeleton(): JSX.Element { ); -} +}); diff --git a/packages/geoview-core/src/core/components/details/feature-info-new.tsx b/packages/geoview-core/src/core/components/details/feature-info-new.tsx deleted file mode 100644 index 6e8ddcbbe41..00000000000 --- a/packages/geoview-core/src/core/components/details/feature-info-new.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { getCenter } from 'ol/extent'; - -import { useTheme, Theme } from '@mui/material/styles'; -import { List, ZoomInSearchIcon, Tooltip, IconButton, Checkbox, Paper, Box, Typography } from '@/ui'; -import { useDetailsCheckedFeatures, useDetailsStoreActions } from '@/core/stores/store-interface-and-intial-values/feature-info-state'; -import { useMapStoreActions } from '@/core/stores/store-interface-and-intial-values/map-state'; -import { logger } from '@/core/utils/logger'; -import { delay } from '@/core/utils/utilities'; -import { TypeFeatureInfoEntry, TypeFieldEntry, TypeGeometry } from '@/geo/map/map-schema-types'; - -import { FeatureInfoTable } from './feature-info-table'; -import { getSxClasses } from './details-style'; - -export interface TypeFeatureInfoProps { - features: TypeFeatureInfoEntry[] | undefined | null; - currentFeatureIndex: number; -} - -/** - * feature info for a layer list - * - * @param {TypeFeatureInfoProps} Feature info properties - * @returns {JSX.Element} the feature info - */ -export function FeatureInfo({ features, currentFeatureIndex }: TypeFeatureInfoProps): JSX.Element { - // Log - logger.logTraceRender('components/details/feature-info-new'); - - const { t } = useTranslation(); - - const theme: Theme & { - iconImage: React.CSSProperties; - } = useTheme(); - const sxClasses = getSxClasses(theme); - - // internal state - const [checked, setChecked] = useState(false); - const feature = features![currentFeatureIndex]; - const featureUid = feature?.geometry ? (feature.geometry as TypeGeometry).ol_uid : null; - const featureIconSrc = feature?.featureIcon.toDataURL(); - const nameFieldValue = feature?.nameField ? (feature?.fieldInfo?.[feature.nameField]?.value as string) || '' : 'No name'; - - // states from store - const checkedFeatures = useDetailsCheckedFeatures(); - const { addCheckedFeature, removeCheckedFeature } = useDetailsStoreActions(); - const { zoomToExtent, highlightBBox, transformPoints, showClickMarker } = useMapStoreActions(); - - /** - * Build feature list to be displayed inside table. - */ - const featureInfoList: TypeFieldEntry[] = useMemo(() => { - // Log - logger.logTraceUseMemo('DETAILS PANEL - Feature Info new - featureInfoList'); - - const featureInfo = Object.keys(feature?.fieldInfo ?? {}).map((fieldName) => { - // We have few service WMS from BC where fields name are extremely long and separated by . - // for WMS and WFS we should only keep the last item. If we see this with other type of services, - // we may need to remove the check and apply all the time. - // TODO: should we do this at the root when sourceinfo is define? - const alias = - feature.geoviewLayerType !== 'ogcWms' && feature.geoviewLayerType !== 'ogcWfs' - ? feature.fieldInfo[fieldName]?.alias || fieldName - : (feature.fieldInfo[fieldName]?.alias || fieldName).split('.').pop() || ''; - - return { - fieldKey: feature.fieldInfo[fieldName]!.fieldKey, - value: feature.fieldInfo[fieldName]!.value, - dataType: feature.fieldInfo[fieldName]!.dataType, - alias, - domain: null, - }; - }); - - // Remove last item who is the geoviewID - featureInfo.pop(); - - return featureInfo; - }, [feature]); - - /** - * Toggle feature selected. - */ - const handleFeatureSelectedChange = useCallback( - (e: React.ChangeEvent): void => { - e.stopPropagation(); - - if (!checked) { - addCheckedFeature(feature); - } else { - removeCheckedFeature(feature); - } - }, - [addCheckedFeature, checked, feature, removeCheckedFeature] - ); - - const handleZoomIn = (e: React.MouseEvent): void => { - e.stopPropagation(); - - // If the feature has an extent - if (feature.extent) { - // Project - const center = getCenter(feature.extent); - const newCenter = transformPoints([center], 4326)[0]; - - // Zoom to extent and wait for it to finish - // TODO: We have the same patch in data-table, see if we should create a reusable custom patch / or cahnge desing - zoomToExtent(feature.extent) - .then(async () => { - // Typically, the click marker is removed after a zoom, so wait a bit here and re-add it... - // TODO: Refactor - Zoom ClickMarker - Improve the logic in general of when/if a click marker should be removed after a zoom - await delay(150); - - // Add (back?) a click marker, and bbox extent who will disapear - showClickMarker({ lnglat: newCenter }); - highlightBBox(feature.extent!, false); - }) - .catch((error: unknown) => { - // Log - logger.logPromiseFailed('zoomToExtent in handleZoomIn in FeatureInfoNew', error); - }); - } - }; - - useEffect(() => { - // Log - logger.logTraceUseEffect('FEATURE-INFO-NEW - checkedFeatures', checkedFeatures); - - setChecked( - checkedFeatures.some((checkedFeature) => { - return (checkedFeature.geometry as TypeGeometry)?.ol_uid === featureUid; - }) - ); - }, [checkedFeatures, featureUid]); - - return ( - - - {/* Left box - feature icon and feature name */} - - - - {nameFieldValue} - - - {/* Right box - checkbox and zoom icon */} - - - handleFeatureSelectedChange(e)} - checked={checked} - sx={sxClasses.selectFeatureCheckbox} - /> - - handleZoomIn(e)} className="buttonOutline"> - - - - - - - - - - - - ); -} diff --git a/packages/geoview-core/src/core/components/details/feature-info-table.tsx b/packages/geoview-core/src/core/components/details/feature-info-table.tsx index ff33a8aa5f0..d4ffbdadeb7 100644 --- a/packages/geoview-core/src/core/components/details/feature-info-table.tsx +++ b/packages/geoview-core/src/core/components/details/feature-info-table.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useTheme } from '@mui/material/styles'; import linkifyHtml from 'linkify-html'; @@ -8,7 +8,6 @@ import { isImage, stringify, generateId, sanitizeHtmlContent } from '@/core/util import { HtmlToReact } from '@/core/containers/html-to-react'; import { logger } from '@/core/utils/logger'; import { TypeFieldEntry } from '@/geo/map/map-schema-types'; - import { getSxClasses } from './details-style'; import { useLightBox } from '@/core/components/common'; @@ -16,117 +15,168 @@ interface FeatureInfoTableProps { featureInfoList: TypeFieldEntry[]; } -/** - * Feature info table that creates a table keys/values of the given feature info - * - * @param {FeatureInfoTableProps} Feature info table properties - * @returns {JSX.Element} the layers list - */ -export function FeatureInfoTable({ featureInfoList }: FeatureInfoTableProps): JSX.Element { - // Log - logger.logTraceRender('components/details/feature-info-table'); +interface FeatureItemProps { + item: string; + alias: string; + index: number; + featureInfoItem: TypeFieldEntry; + onInitLightBox: (value: string, alias: string, index: number) => void; +} - const { t } = useTranslation(); +interface FeatureRowProps { + featureInfoItem: TypeFieldEntry; + index: number; + onInitLightBox: (value: string, alias: string, index: number) => void; +} +// Extracted FeatureItem component +export const FeatureItem = memo(function FeatureItem({ + item, + alias, + index, + featureInfoItem, + onInitLightBox, +}: FeatureItemProps): JSX.Element { + // Hooks + const { t } = useTranslation(); const theme = useTheme(); const sxClasses = getSxClasses(theme); - const { initLightBox, LightBoxComponent } = useLightBox(); - - // linkify options - const linkifyOptions = useMemo(() => { - // Log - logger.logTraceUseMemo('DETAILS PANEL - Feature Info table - linkifyOptions'); - - return { + const linkifyOptions = useMemo( + () => ({ attributes: { title: t('details.externalLink'), }, defaultProtocol: 'https', format: { - url: (value: string) => (value.length > 50 ? `${value.slice(0, 40)}…${value.slice(value.length - 10, value.length)}` : value), + url: (value: string) => (value.length > 50 ? `${value.slice(0, 40)}…${value.slice(value.length - 10)}` : value), }, ignoreTags: ['script', 'style', 'img'], target: '_blank', - }; - }, [t]); - - /** - * Parse the content of the field to see if we need to create an image, a string element or a link - * @param {TypeFieldEntry} featureInfoItem the field item - * @returns {JSX.Element | JSX.Element[]} the React element(s) - */ - function setFeatureItem(featureInfoItem: TypeFieldEntry): JSX.Element | JSX.Element[] { - function process(item: string, alias: string, index: number): JSX.Element { - let element: JSX.Element; - if (alias === 'html') { - element = ( - - - - ); - } else if (typeof item === 'string' && isImage(item)) { - element = ( - initLightBox(featureInfoItem.value as string, featureInfoItem.alias, index)} - keyDown={(e: KeyboardEvent) => { - if (e.key === 'Enter') { - initLightBox(featureInfoItem.value as string, `${index}_${featureInfoItem.alias}`, index); - } - }} - /> - ); - } else { - element = ( - - - - ); - } - - return element; - } - - const { alias, value } = featureInfoItem; - let values: string | string[] = Array.isArray(value) ? String(value.map(stringify)) : String(stringify(value)); - // Split text but leave html intact - if (alias !== 'html') values = values.toString().split(';'); + }), + [t] + ); - const results = Array.isArray(values) - ? values.map((item: string, index: number) => process(item, alias, index)) - : process(values, alias, 0); + if (alias === 'html') { + return ( + + + + ); + } - return results; + if (typeof item === 'string' && isImage(item)) { + return ( + onInitLightBox(featureInfoItem.value as string, featureInfoItem.alias, index)} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + onInitLightBox(featureInfoItem.value as string, `${index}_${featureInfoItem.alias}`, index); + } + }} + /> + ); } return ( - - {featureInfoList.map((featureInfoItem, index) => ( + + + + ); +}); + +// Extracted FeatureRow component +export const FeatureRow = memo(function FeatureRow({ featureInfoItem, index, onInitLightBox }: FeatureRowProps): JSX.Element { + const theme = useTheme(); + const { alias, value } = featureInfoItem; + + // Convert value to string, handling arrays and other types + const stringValue = useMemo((): string[] => { + if (Array.isArray(value)) { + return [value.map((item) => stringify(item)).join(';')] as string[]; + } + return [stringify(value)] as string[]; + }, [value]); + + // Generate stable IDs for each item when component mounts + const itemIds = useMemo(() => stringValue.map(() => generateId()), [stringValue]); + + return ( + 0 ? theme.palette.geoViewColor.bgColor.darken(0.1) : '', + color: index % 2 > 0 ? theme.palette.geoViewColor.bgColor.darken(0.9) : '', + marginBottom: '1.25rem', + }} + > + {featureInfoItem.alias !== 'html' && ( 0 ? theme.palette.geoViewColor.bgColor.darken(0.1) : '', - color: index % 2 > 0 ? theme.palette.geoViewColor.bgColor.darken(0.9) : '', - marginBottom: '1.25rem', + fontWeight: 'bold', + width: '80%', + flexGrow: 0, + maxWidth: 'none', + flexBasis: 'auto', }} - key={`${featureInfoItem.alias} ${index.toString()}`} > - {featureInfoItem.alias !== 'html' && ( - - {featureInfoItem.alias} - - )} - {setFeatureItem(featureInfoItem)} + {alias} + )} + + {stringValue.map((item: string, idx: number) => ( + + ))} + + + ); +}); + +export const FeatureInfoTable = memo(function FeatureInfoTable({ featureInfoList }: FeatureInfoTableProps): JSX.Element { + logger.logTraceRender('components/details/feature-info-table'); + + // Hooks + const theme = useTheme(); + const sxClasses = getSxClasses(theme); + + // Store + const { initLightBox, LightBoxComponent } = useLightBox(); + + // Remove last item who is the internall geoviewID field + if (featureInfoList[featureInfoList.length - 1].alias === 'geoviewID') featureInfoList.pop(); + + return ( + + {featureInfoList.map((featureInfoItem, index) => ( + ))} ); -} +}); diff --git a/packages/geoview-core/src/core/components/details/feature-info.tsx b/packages/geoview-core/src/core/components/details/feature-info.tsx new file mode 100644 index 00000000000..5a76c3147b2 --- /dev/null +++ b/packages/geoview-core/src/core/components/details/feature-info.tsx @@ -0,0 +1,200 @@ +import { useCallback, useEffect, useState, useMemo, memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useTheme } from '@mui/material/styles'; +import { getCenter } from 'ol/extent'; + +import { List, ZoomInSearchIcon, Tooltip, IconButton, Checkbox, Paper, Box, Typography } from '@/ui'; +import { useDetailsCheckedFeatures, useDetailsStoreActions } from '@/core/stores/store-interface-and-intial-values/feature-info-state'; +import { useMapStoreActions } from '@/core/stores/store-interface-and-intial-values/map-state'; +import { logger } from '@/core/utils/logger'; +import { delay } from '@/core/utils/utilities'; +import { TypeFeatureInfoEntry, TypeFieldEntry, TypeGeometry } from '@/geo/map/map-schema-types'; +import { FeatureInfoTable } from './feature-info-table'; +import { getSxClasses } from './details-style'; + +interface FeatureInfoProps { + feature: TypeFeatureInfoEntry; +} + +interface FeatureHeaderProps { + iconSrc: string; + name: string; + hasGeometry: boolean; + checked: boolean; + onCheckChange: (e: React.ChangeEvent) => void; + onZoomIn: (e: React.MouseEvent) => void; +} + +// Constants outside component to prevent recreating every render +const HEADER_STYLES = { + container: { + p: '0 20px 10px 20px', + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + }, +} as const; + +const PAPER_STYLES = { + boxShadow: 'none', + border: 'none', + paddingTop: '0.5rem', +} as const; + +const TYPOGRAPHY_STYLES = { + display: 'inline-block', +} as const; + +// Extracted Header Component +const FeatureHeader = memo(function FeatureHeader({ iconSrc, name, hasGeometry, checked, onCheckChange, onZoomIn }: FeatureHeaderProps) { + // Hooks + const { t } = useTranslation(); + const theme = useTheme(); + const sxClasses = getSxClasses(theme); + + return ( + + + + + {name} + + + + + + + + + + + + + + ); +}); + +export function FeatureInfo({ feature }: FeatureInfoProps): JSX.Element | null { + logger.logTraceRender('components/details/feature-info'); + + // Hooks + const theme = useTheme(); + const sxClasses = getSxClasses(theme); + + // State + const [checked, setChecked] = useState(false); + + // Store + const checkedFeatures = useDetailsCheckedFeatures(); + const { addCheckedFeature, removeCheckedFeature } = useDetailsStoreActions(); + const { zoomToExtent, highlightBBox, transformPoints, showClickMarker } = useMapStoreActions(); + + // Feature data processing + const featureData = useMemo(() => { + if (!feature) return null; + + return { + uid: feature.geometry ? (feature.geometry as TypeGeometry).ol_uid : null, + iconSrc: feature.featureIcon.toDataURL(), + name: feature.nameField ? (feature.fieldInfo?.[feature.nameField]?.value as string) || '' : 'No name', + extent: feature.extent, + geometry: feature.geometry, + geoviewLayerType: feature.geoviewLayerType, + }; + }, [feature]); + + // Process feature info list + const featureInfoList: TypeFieldEntry[] = useMemo(() => { + if (!feature?.fieldInfo) return []; + + return Object.entries(feature.fieldInfo) + .filter(([key]) => key !== feature.nameField) + .map(([fieldName, field]) => ({ + fieldKey: field!.fieldKey, + value: field!.value, + dataType: field!.dataType, + alias: + feature.geoviewLayerType !== 'ogcWms' && feature.geoviewLayerType !== 'ogcWfs' + ? field!.alias || fieldName + : (field!.alias || fieldName).split('.').pop() || '', + domain: null, + })); + }, [feature]); + + // Event Handlers + const handleFeatureSelectedChange = useCallback( + (e: React.ChangeEvent): void => { + e.stopPropagation(); + if (!feature) return; + + if (!checked) { + addCheckedFeature(feature); + } else { + removeCheckedFeature(feature); + } + }, + [addCheckedFeature, checked, feature, removeCheckedFeature] + ); + + const handleZoomIn = useCallback( + (e: React.MouseEvent): void => { + e.stopPropagation(); + if (!featureData?.extent) return; + + const center = getCenter(featureData.extent); + const newCenter = transformPoints([center], 4326)[0]; + + // Zoom to extent and wait for it to finish + // TODO: We have the same patch in data-table, see if we should create a reusable custom patch / or cahnge desing + zoomToExtent(featureData.extent) + .then(async () => { + // Typically, the click marker is removed after a zoom, so wait a bit here and re-add it... + // TODO: Refactor - Zoom ClickMarker - Improve the logic in general of when/if a click marker should be removed after a zoom + await delay(150); + + // Add (back?) a click marker, and bbox extent who will disapear + showClickMarker({ lnglat: newCenter }); + highlightBBox(featureData.extent!, false); + }) + .catch((error: unknown) => { + // Log + logger.logPromiseFailed('zoomToExtent in handleZoomIn in FeatureInfoNew', error); + }); + }, + [featureData, transformPoints, zoomToExtent, showClickMarker, highlightBBox] + ); + + // Effects + useEffect(() => { + logger.logTraceUseEffect('FEATURE-INFO - checkedFeatures', checkedFeatures); + + if (!featureData?.uid) return; + + setChecked(checkedFeatures.some((checkedFeature) => (checkedFeature.geometry as TypeGeometry)?.ol_uid === featureData.uid)); + }, [checkedFeatures, featureData]); + + // Early return if no feature + if (!featureData) return null; + + return ( + + + + + + + + ); +} diff --git a/packages/geoview-core/src/core/stores/store-interface-and-intial-values/map-state.ts b/packages/geoview-core/src/core/stores/store-interface-and-intial-values/map-state.ts index 24b4e7246b4..ff9095f68bf 100644 --- a/packages/geoview-core/src/core/stores/store-interface-and-intial-values/map-state.ts +++ b/packages/geoview-core/src/core/stores/store-interface-and-intial-values/map-state.ts @@ -15,7 +15,7 @@ import { MapEventProcessor } from '@/api/event-processors/event-processor-childr import { TypeClickMarker } from '@/core/components/click-marker/click-marker'; import { TypeFeatureInfoEntry } from '@/geo/map/map-schema-types'; import { TypePointMarker } from '@/api/config/types/map-schema-types'; -import { TypeFeatureInfoResultSet, TypeHoverFeatureInfo } from './feature-info-state'; +import { TypeHoverFeatureInfo } from './feature-info-state'; import { CV_MAP_CENTER } from '@/api/config/types/config-constants'; // GV Important: See notes in header of MapEventProcessor file for information on the paradigm to apply when working with MapEventProcessor vs MapState @@ -84,7 +84,7 @@ export interface IMapState { zoomToGeoLocatorLocation: (coords: [number, number], bbox?: [number, number, number, number]) => Promise; zoomToMyLocation: (position: GeolocationPosition) => Promise; transformPoints: (coords: Coordinate[], outputProjection: number) => Coordinate[]; - setClickCoordinates: (pointerPosition: TypeMapMouseInfo) => Promise; + setClickCoordinates: (pointerPosition: TypeMapMouseInfo) => void; setCurrentBasemapOptions: (basemapOptions: TypeBasemapOptions) => void; setFixNorth: (ifFix: boolean) => void; setOverlayClickMarkerRef: (htmlRef: HTMLElement) => void; @@ -459,7 +459,7 @@ export function initializeMapState(set: TypeSetStore, get: TypeGetStore): IMapSt * @param {TypeMapMouseInfo} pointerPosition - The pointer position. * @returns {Promise} */ - setClickCoordinates: (pointerPosition: TypeMapMouseInfo): Promise => { + setClickCoordinates: (pointerPosition: TypeMapMouseInfo): void => { // Redirect to processor return MapEventProcessor.setClickCoordinates(get().mapId, pointerPosition); }, diff --git a/packages/geoview-core/src/core/utils/constant.ts b/packages/geoview-core/src/core/utils/constant.ts index fb4e5bf693e..e2125e0a60c 100644 --- a/packages/geoview-core/src/core/utils/constant.ts +++ b/packages/geoview-core/src/core/utils/constant.ts @@ -31,6 +31,12 @@ export const LAYER_STATUS = { ERROR: 'error', } as const; +export const FEATURE_INFO_STATUS = { + PROCESSING: 'processing', + PROCESSED: 'processed', + ERROR: 'error', +} as const; + export const VECTOR_LAYER = { csv: '', esriFeature: '', GeoJSON: '', GeoPackage: '', ogcFeature: '', ogcWfs: '' }; export const ARROW_KEY_CODES: string[] = ['ArrowUp', 'ArrowRight', 'ArrowDown', 'ArrowLefts']; diff --git a/packages/geoview-core/src/geo/layer/gv-layers/raster/gv-wms.ts b/packages/geoview-core/src/geo/layer/gv-layers/raster/gv-wms.ts index 16daf08ab06..12fd85c0bb8 100644 --- a/packages/geoview-core/src/geo/layer/gv-layers/raster/gv-wms.ts +++ b/packages/geoview-core/src/geo/layer/gv-layers/raster/gv-wms.ts @@ -119,16 +119,17 @@ export class GVWMS extends AbstractGVRaster { const layerConfig = this.getLayerConfig(); // Check if bounds are properly set - if (!layerConfig.initialSettings!.bounds) { - const newBounds = this.getBounds(this.getLayerPath()); - if (newBounds) - layerConfig.initialSettings!.bounds = Projection.transformExtentFromProj( - newBounds, - this.getMapViewer().getView().getProjection(), - Projection.PROJECTION_NAMES.LNGLAT - ); - else return []; - } + // TODO: We always do the check if not bounds are not set properly from layer creation process + // if (!layerConfig.initialSettings!.bounds) { + const newBounds = this.getBounds(this.getLayerPath()); + if (newBounds) + layerConfig.initialSettings!.bounds = Projection.transformExtentFromProj( + newBounds, + this.getMapViewer().getView().getProjection(), + Projection.PROJECTION_NAMES.LNGLAT + ); + // else return []; + // } const clickCoordinate = this.getMapViewer().convertCoordinateLngLatToMapProj(lnglat); if ( diff --git a/packages/geoview-core/src/geo/layer/layer-sets/feature-info-layer-set.ts b/packages/geoview-core/src/geo/layer/layer-sets/feature-info-layer-set.ts index 7fccf9eb363..a1a787edb55 100644 --- a/packages/geoview-core/src/geo/layer/layer-sets/feature-info-layer-set.ts +++ b/packages/geoview-core/src/geo/layer/layer-sets/feature-info-layer-set.ts @@ -81,8 +81,8 @@ export class FeatureInfoLayerSet extends AbstractLayerSet { */ // eslint-disable-next-line @typescript-eslint/no-unused-vars protected override onPropagateToStore(resultSetEntry: TypeFeatureInfoResultSetEntry, type: PropagationType): void { - // Redirect - this.#propagateToStore(resultSetEntry, type === 'layerName' ? 'name' : 'click'); + // Redirect - Add layer to the list after registration + this.#propagateToStore(resultSetEntry, type === 'layer-registration' ? 'name' : 'click'); } /** diff --git a/packages/geoview-core/src/geo/map/map-viewer.ts b/packages/geoview-core/src/geo/map/map-viewer.ts index ab7b8c4c8ab..12365f032ac 100644 --- a/packages/geoview-core/src/geo/map/map-viewer.ts +++ b/packages/geoview-core/src/geo/map/map-viewer.ts @@ -431,10 +431,7 @@ export class MapViewer { }; // Save in the store - MapEventProcessor.setClickCoordinates(this.mapId, clickCoordinates).catch((error) => { - // Log - logger.logPromiseFailed('setClickCoordinates in #handleMapSingleClick in MapViewer', error); - }); + MapEventProcessor.setClickCoordinates(this.mapId, clickCoordinates); // Emit to the outside this.#emitMapSingleClick(clickCoordinates);