Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(legend): Simplify the code and tweak performance #2663

Merged
merged 5 commits into from
Dec 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading