Skip to content

Commit

Permalink
refactor(legend): Simplify the code and tweak performance (#2663)
Browse files Browse the repository at this point in the history
* refactor(legend): Simplify the code and tweak performance
Closes #2660

* fix children vis-expand

* Finalise legend component

* Finish fix

* rebase

---------

Co-authored-by: jolevesq <[email protected]>
  • Loading branch information
jolevesq and jolevesq authored Dec 20, 2024
1 parent 5b51ec7 commit 7c8c489
Show file tree
Hide file tree
Showing 9 changed files with 483 additions and 298 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,15 @@ export class LegendEventProcessor extends AbstractEventProcessor {
createNewLegendEntries(2, layers);

// Update the legend layers with the updated array, triggering the subscribe
this.getLayerState(mapId).setterActions.setLegendLayers(layers);
// Reorder the array so legend tab is in synch
const sortedLayers = layers.sort((a, b) =>
MapEventProcessor.getMapIndexFromOrderedLayerInfo(mapId, a.layerPath) >
MapEventProcessor.getMapIndexFromOrderedLayerInfo(mapId, b.layerPath)
? 1
: -1
);

this.getLayerState(mapId).setterActions.setLegendLayers(sortedLayers);
}
// #endregion

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { useTheme } from '@mui/material';
import { memo, useMemo } from 'react';
import { Box, Collapse, List } from '@/ui';
import { TypeLegendLayer } from '@/core/components/layers/types';
import { getSxClasses } from './legend-styles';
import { CV_CONST_LAYER_TYPES } from '@/api/config/types/config-constants';
import { ItemsList } from './legend-layer-items';
import { logger } from '@/core/utils/logger';

// Define component types and interfaces
type LegendLayerType = React.FC<{ layer: TypeLegendLayer }>;

interface CollapsibleContentProps {
layer: TypeLegendLayer;
legendExpanded: boolean; // Expanded come from store ordered layer info array
initLightBox: (imgSrc: string, title: string, index: number, total: number) => void;
LegendLayerComponent: LegendLayerType;
}

interface WMSLegendImageProps {
imgSrc: string;
initLightBox: (imgSrc: string, title: string, index: number, total: number) => void;
legendExpanded: boolean;
sxClasses: Record<string, object>;
}

// Constant style outside of render
const styles = {
wmsImage: {
maxWidth: '90%',
cursor: 'pointer',
},
} as const;

// Extracted WMS Legend Component
const WMSLegendImage = memo(
({ imgSrc, initLightBox, legendExpanded, sxClasses }: WMSLegendImageProps): JSX.Element => (
<Collapse in={legendExpanded} sx={sxClasses!.collapsibleContainer} timeout="auto">
<Box
component="img"
tabIndex={0}
src={imgSrc}
sx={styles.wmsImage}
onClick={() => initLightBox(imgSrc, '', 0, 2)}
onKeyDown={(e) => (e.code === 'Space' || e.code === 'Enter' ? initLightBox(imgSrc, '', 0, 2) : null)}
/>
</Collapse>
)
);
WMSLegendImage.displayName = 'WMSLegendImage';

export const CollapsibleContent = memo(function CollapsibleContent({
layer,
legendExpanded,
initLightBox,
LegendLayerComponent,
}: CollapsibleContentProps): JSX.Element | null {
logger.logTraceRender('components/legend/legend-layer-container');

// Hooks
const theme = useTheme();
const sxClasses = useMemo(() => getSxClasses(theme), [theme]);

// Props extraction
const { children, items } = layer;

// Early returns
if (children?.length === 0 && items?.length === 1) return null;

const isWMSWithLegend = layer.type === CV_CONST_LAYER_TYPES.WMS && layer.icons?.[0]?.iconImage && layer.icons[0].iconImage !== 'no data';

// If it is a WMS legend, create a specific component
if (isWMSWithLegend) {
return (
<WMSLegendImage
imgSrc={layer.icons[0].iconImage || ''}
initLightBox={initLightBox}
legendExpanded={legendExpanded}
sxClasses={sxClasses}
/>
);
}

return (
<Collapse in={legendExpanded} sx={sxClasses.collapsibleContainer} timeout="auto">
<List>
{layer.children
.filter((d) => !['error', 'processing'].includes(d.layerStatus ?? ''))
.map((item) => (
<LegendLayerComponent layer={item} key={item.layerPath} />
))}
</List>
<ItemsList items={items} />
</Collapse>
);
});
128 changes: 128 additions & 0 deletions packages/geoview-core/src/core/components/legend/legend-layer-ctrl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { useTheme } from '@mui/material';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Box,
IconButton,
Stack,
VisibilityOutlinedIcon,
HighlightOutlinedIcon,
ZoomInSearchIcon,
Typography,
VisibilityOffOutlinedIcon,
HighlightIcon,
} from '@/ui';
import { useLayerHighlightedLayer, useLayerStoreActions } from '@/core/stores/store-interface-and-intial-values/layer-state';
import { TypeLegendItem, TypeLegendLayer } from '@/core/components/layers/types';
import { useMapStoreActions } from '@/core/stores/';
import { getSxClasses } from './legend-styles';
import { logger } from '@/core/utils/logger';

interface SecondaryControlsProps {
layer: TypeLegendLayer;
visibility: boolean; // Visibility come from store ordered layer info array
}

type ControlActions = {
handleToggleVisibility: (e: React.MouseEvent) => void;
handleHighlightLayer: (e: React.MouseEvent) => void;
handleZoomTo: (e: React.MouseEvent) => void;
};

// Constant style outside of render
const styles = {
btnMargin: { marginTop: '-0.3125rem' },
} as const;

// Custom hook for control actions
const useControlActions = (layerPath: string): ControlActions => {
const { setOrToggleLayerVisibility } = useMapStoreActions();
const { setHighlightLayer, zoomToLayerExtent } = useLayerStoreActions();

return useMemo(
() => ({
handleToggleVisibility: (e: React.MouseEvent): void => {
e.stopPropagation();
setOrToggleLayerVisibility(layerPath);
},
handleHighlightLayer: (e: React.MouseEvent): void => {
e.stopPropagation();
setHighlightLayer(layerPath);
},
handleZoomTo: (e: React.MouseEvent): void => {
e.stopPropagation();
zoomToLayerExtent(layerPath).catch((error) => {
logger.logPromiseFailed('in zoomToLayerExtent in legend-layer.handleZoomTo', error);
});
},
}),
[layerPath, setHighlightLayer, setOrToggleLayerVisibility, zoomToLayerExtent]
);
};

// Create subtitle
const useSubtitle = (children: TypeLegendLayer[], items: TypeLegendItem[]): string => {
// Hooks
const { t } = useTranslation();

return useMemo(() => {
if (children.length) {
return t('legend.subLayersCount').replace('{count}', children.length.toString());
}
if (items.length > 1) {
return t('legend.itemsCount')
.replace('{count}', items.filter((item) => item.isVisible).length.toString())
.replace('{totalCount}', items.length.toString());
}
return '';
}, [children.length, items, t]);
};

// SecondaryControls component (no memo to force re render from layers panel modifications)
export function SecondaryControls({ layer, visibility }: SecondaryControlsProps): JSX.Element {
logger.logTraceRender('components/legend/legend-layer-ctrl');

// Hooks
const theme = useTheme();
const sxClasses = useMemo(() => getSxClasses(theme), [theme]);

// Stores
const highlightedLayer = useLayerHighlightedLayer();

// Is button disabled?
const isLayerVisible = layer.controls?.visibility ?? false;

// Extract constant from layer prop
const { layerStatus, items, children } = layer;

// Component helper
const controls = useControlActions(layer.layerPath);
const subTitle = useSubtitle(children, items);

if (!['processed', 'loaded'].includes(layerStatus || 'error')) {
return <Box />;
}

return (
<Stack direction="row" alignItems="center" sx={sxClasses.layerStackIcons}>
{!!subTitle.length && <Typography fontSize={14}>{subTitle}</Typography>}
<Box sx={sxClasses.subtitle}>
<IconButton
edge="end"
tooltip="layers.toggleVisibility"
className="buttonOutline"
onClick={controls.handleToggleVisibility}
disabled={!isLayerVisible}
>
{visibility ? <VisibilityOutlinedIcon /> : <VisibilityOffOutlinedIcon />}
</IconButton>
<IconButton tooltip="legend.highlightLayer" sx={styles.btnMargin} className="buttonOutline" onClick={controls.handleHighlightLayer}>
{highlightedLayer === layer.layerPath ? <HighlightIcon /> : <HighlightOutlinedIcon />}
</IconButton>
<IconButton tooltip="legend.zoomTo" className="buttonOutline" onClick={controls.handleZoomTo}>
<ZoomInSearchIcon />
</IconButton>
</Box>
</Stack>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useTheme } from '@mui/material';
import { memo, useMemo } from 'react';
import { Box, ListItem, Tooltip, ListItemText, ListItemIcon, List, BrowserNotSupportedIcon } from '@/ui';
import { TypeLegendItem } from '@/core/components/layers/types';
import { getSxClasses } from './legend-styles';
import { logger } from '@/core/utils/logger';

interface ItemsListProps {
items: TypeLegendItem[];
}

// Extracted ListItem Component
const LegendListItem = memo(
({ item }: { item: TypeLegendItem }): JSX.Element => (
<ListItem key={`${item.icon}-${item.name}`} className={!item.isVisible ? 'unchecked' : 'checked'}>
<ListItemIcon>{item.icon ? <Box component="img" alt={item.name} src={item.icon} /> : <BrowserNotSupportedIcon />}</ListItemIcon>
<Tooltip title={item.name} placement="top" enterDelay={1000}>
<ListItemText primary={item.name} />
</Tooltip>
</ListItem>
)
);
LegendListItem.displayName = 'LegendListItem';

// Item list component (no memo to force re render from layers panel modifications)
export const ItemsList = memo(function ItemsList({ items }: ItemsListProps): JSX.Element | null {
logger.logTraceRender('components/legend/legend-layer-items');

// Hooks
const theme = useTheme();
const sxClasses = useMemo(() => getSxClasses(theme), [theme]);

if (!items?.length) return null;

// Direct mapping since we only reach this code if items has content
return (
<List sx={sxClasses.subList}>
{items.map((item) => (
<LegendListItem item={item} key={`${item.icon}-${item.name}`} />
))}
</List>
);
});
Loading

0 comments on commit 7c8c489

Please sign in to comment.