diff --git a/src/components/app-wrapper.jsx b/src/components/app-wrapper.jsx
index 16fb774ba3..7fe47ef274 100644
--- a/src/components/app-wrapper.jsx
+++ b/src/components/app-wrapper.jsx
@@ -208,8 +208,8 @@ const lightTheme = createTheme({
reactflow: {
backgroundColor: 'white',
labeledGroup: {
- backgroundColor: 'white',
- borderColor: '#11161A',
+ backgroundColor: '#FAFAFA',
+ borderColor: '#BDBDBD',
},
edge: {
stroke: '#6F767B',
diff --git a/src/components/graph/layout.ts b/src/components/graph/layout.ts
index 3d1d22e4ca..f00ddd3baa 100644
--- a/src/components/graph/layout.ts
+++ b/src/components/graph/layout.ts
@@ -10,8 +10,8 @@ import { NODE_HEIGHT, NODE_WIDTH } from './nodes/constants';
import { groupIdSuffix, LABELED_GROUP_TYPE } from './nodes/labeled-group-node.type';
import { CurrentTreeNode, isSecurityModificationNode, NetworkModificationNodeType } from './tree-node.type';
-const widthSpacing = 70;
-const heightSpacing = 90;
+const widthSpacing = 110;
+const heightSpacing = 140;
export const nodeWidth = NODE_WIDTH + widthSpacing;
export const nodeHeight = NODE_HEIGHT + heightSpacing;
export const snapGrid = [10, nodeHeight]; // Used for drag and drop
diff --git a/src/components/graph/nodes/build-button.tsx b/src/components/graph/nodes/build-button.tsx
index af404981c2..51c50b7b4c 100644
--- a/src/components/graph/nodes/build-button.tsx
+++ b/src/components/graph/nodes/build-button.tsx
@@ -94,7 +94,9 @@ export const BuildButton = ({
return !buildStatus || buildStatus === BUILD_STATUS.NOT_BUILT ? (
) : (
-
+ (theme.palette.mode === 'light' ? theme.palette.primary.light : undefined) }}
+ />
);
};
diff --git a/src/components/graph/nodes/build-status-chip.styles.ts b/src/components/graph/nodes/build-status-chip.styles.ts
new file mode 100644
index 0000000000..4710029a6c
--- /dev/null
+++ b/src/components/graph/nodes/build-status-chip.styles.ts
@@ -0,0 +1,24 @@
+/**
+ * Copyright (c) 2025, RTE (http://www.rte-france.com)
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+import { type SxStyle } from '@gridsuite/commons-ui';
+import { Theme } from '@mui/material';
+
+export const buildStatusChipStyles = {
+ base: (theme: Theme) =>
+ ({
+ padding: theme.spacing(1, 0.5),
+ fontSize: '12px',
+ fontWeight: 400,
+ lineHeight: '100%',
+ }) as const,
+
+ notBuilt: {
+ background: (theme: Theme) => theme.node.buildStatus.notBuilt,
+ color: (theme: Theme) => theme.palette.getContrastText(theme.node.buildStatus.notBuilt),
+ } as SxStyle,
+} as const;
diff --git a/src/components/graph/nodes/build-status-chip.tsx b/src/components/graph/nodes/build-status-chip.tsx
index ed2beca07b..f34384fd73 100644
--- a/src/components/graph/nodes/build-status-chip.tsx
+++ b/src/components/graph/nodes/build-status-chip.tsx
@@ -6,53 +6,26 @@
*/
import React, { ReactElement } from 'react';
-import { Chip } from '@mui/material';
+import { Chip, useTheme } from '@mui/material';
import { useIntl } from 'react-intl';
import { BUILD_STATUS } from 'components/network/constants';
import { mergeSx, type SxStyle } from '@gridsuite/commons-ui';
-
-function getBuildStatusSx(buildStatus: BUILD_STATUS | undefined): SxStyle {
- return (theme) => {
- const bs = theme.node.buildStatus;
- // pick background based on status
- let bg: string;
-
- switch (buildStatus) {
- case BUILD_STATUS.BUILT:
- bg = bs.success;
- break;
- case BUILD_STATUS.BUILT_WITH_WARNING:
- bg = bs.warning;
- break;
- case BUILD_STATUS.BUILT_WITH_ERROR:
- bg = bs.error;
- break;
- default:
- bg = bs.notBuilt;
- break;
- }
-
- // only set explicit contrast color when it's the "notBuilt" background
- const shouldSetContrast = bg === bs.notBuilt;
-
- return {
- background: bg,
- ...(shouldSetContrast ? { color: theme.palette.getContrastText(bg) } : {}),
- '&:hover': {
- backgroundColor: bg,
- },
- };
- };
+import { zoomStyles } from '../zoom.styles';
+import { buildStatusChipStyles } from './build-status-chip.styles';
+
+function getBuildStatusColor(buildStatus: BUILD_STATUS | undefined) {
+ switch (buildStatus) {
+ case BUILD_STATUS.BUILT:
+ return 'success';
+ case BUILD_STATUS.BUILT_WITH_WARNING:
+ return 'warning';
+ case BUILD_STATUS.BUILT_WITH_ERROR:
+ return 'error';
+ default:
+ return undefined;
+ }
}
-const baseStyle: SxStyle = (theme) =>
- ({
- padding: theme.spacing(1, 0.5),
- fontSize: '12px',
- fontWeight: 400,
- lineHeight: '100%',
- }) as const;
-
type BuildStatusChipProps = {
buildStatus?: BUILD_STATUS;
sx?: SxStyle;
@@ -62,18 +35,24 @@ type BuildStatusChipProps = {
const BuildStatusChip = ({ buildStatus = BUILD_STATUS.NOT_BUILT, sx, icon, onClick }: BuildStatusChipProps) => {
const intl = useIntl();
+ const theme = useTheme();
+
+ const showLabel = zoomStyles.visibility.showBuildStatusLabel(theme);
+ const label = showLabel ? intl.formatMessage({ id: buildStatus }) : undefined;
+ const color = getBuildStatusColor(buildStatus);
- const label = intl.formatMessage({ id: buildStatus });
+ // Custom styling for NOT_BUILT status (no standard MUI color)
+ const notBuiltSx: SxStyle | undefined = color === undefined ? buildStatusChipStyles.notBuilt : undefined;
- return (
-
+ // Combine styles: base + compact circular (if no label) + notBuilt color + parent overrides
+ const finalSx = mergeSx(
+ buildStatusChipStyles.base,
+ showLabel ? undefined : (theme) => zoomStyles.layout.getCompactChipSize(theme),
+ notBuiltSx,
+ sx
);
+
+ return ;
};
export default BuildStatusChip;
diff --git a/src/components/graph/nodes/labeled-group-node.styles.ts b/src/components/graph/nodes/labeled-group-node.styles.ts
new file mode 100644
index 0000000000..de3b481391
--- /dev/null
+++ b/src/components/graph/nodes/labeled-group-node.styles.ts
@@ -0,0 +1,55 @@
+/**
+ * Copyright (c) 2025, RTE (http://www.rte-france.com)
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+import { type MuiStyles } from '@gridsuite/commons-ui';
+import { colors, Theme } from '@mui/material';
+import { zoomStyles } from '../zoom.styles';
+import { DETAIL_LEVELS } from '../zoom.constants';
+
+export const LABEL_GROUP_OFFSET = 30;
+
+export const getContainerStyle = (theme: Theme, isLight: boolean) => {
+ let backgroundColor = 'transparent';
+ if (theme.tree?.detailLevel === DETAIL_LEVELS.MINIMAL) {
+ backgroundColor = isLight ? theme.palette.grey[200] : colors.grey[700];
+ }
+
+ return {
+ ...zoomStyles.labeledGroupBorder(theme),
+ background: backgroundColor,
+ borderColor: isLight ? colors.grey[400] : colors.grey[500],
+ };
+};
+
+export const labeledGroupNodeStyles = {
+ label: (theme) => ({
+ position: 'absolute',
+ top: -15,
+ right: 20,
+ backgroundColor: theme.reactflow.labeledGroup.backgroundColor,
+ padding: '3px 6px',
+ border: '1px solid',
+ borderColor: theme.reactflow.labeledGroup.borderColor,
+ boxShadow: theme.shadows[1],
+ fontSize: '13px',
+ display: theme.tree?.detailLevel === DETAIL_LEVELS.MINIMAL ? 'none' : 'flex',
+ gap: '4px',
+ alignItems: 'center',
+ justifyContent: 'center',
+ lineHeight: 1,
+ }),
+
+ icon: (theme) => ({
+ fontSize: zoomStyles.iconSize(theme),
+ verticalAlign: 'middle',
+ }),
+
+ text: (theme) => ({
+ display: theme.tree?.detailLevel === DETAIL_LEVELS.STANDARD ? 'inline' : 'none',
+ verticalAlign: 'middle',
+ }),
+} as const satisfies MuiStyles;
diff --git a/src/components/graph/nodes/labeled-group-node.tsx b/src/components/graph/nodes/labeled-group-node.tsx
index b18d07ca2a..03f02f12a3 100644
--- a/src/components/graph/nodes/labeled-group-node.tsx
+++ b/src/components/graph/nodes/labeled-group-node.tsx
@@ -5,50 +5,34 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
-import { Box } from '@mui/material';
-import { NodeProps, ReactFlowState, useStore } from '@xyflow/react';
-import { nodeHeight as nodeLayoutHeight, nodeWidth as nodeLayoutWidth } from '../layout';
+import { Box, useTheme } from '@mui/material';
+import { NodeProps } from '@xyflow/react';
import SecurityIcon from '@mui/icons-material/Security';
+import { nodeHeight as nodeLayoutHeight, nodeWidth as nodeLayoutWidth } from '../layout';
import { FormattedMessage } from 'react-intl';
-import { type MuiStyles } from '@gridsuite/commons-ui';
import { LabeledGroupNodeType } from './labeled-group-node.type';
import { NODE_HEIGHT, NODE_WIDTH } from './constants';
-
-const styles = {
- border: {
- border: 'dashed 3px #8B8F8F',
- borderRadius: '8px',
- },
- label: (theme) => ({
- position: 'absolute',
- top: -13,
- right: 8,
- backgroundColor: theme.reactflow.labeledGroup.backgroundColor,
- padding: '0px 6px',
- border: '1px solid',
- borderColor: theme.reactflow.labeledGroup.borderColor,
- fontSize: 12,
- display: 'flex',
- gap: '10px',
- alignItems: 'center',
- }),
-} as const satisfies MuiStyles;
+import { labeledGroupNodeStyles, getContainerStyle, LABEL_GROUP_OFFSET } from './labeled-group-node.styles';
export function LabeledGroupNode({ data }: NodeProps) {
+ const theme = useTheme();
// Vertically, the border is halfway between the node and the edge above,
// and since that edge is centered between two nodes, we divide the space by 4.
const verticalPadding = (nodeLayoutHeight - NODE_HEIGHT) / 4;
// horizontally, the border is placed exactly halfway between two nodes — that's why we divide the space between them by 2.
const horizontalPadding = (nodeLayoutWidth - NODE_WIDTH) / 2;
- const labeledGroupTopPosition = data.position.topLeft.row * nodeLayoutHeight - verticalPadding;
+ // Adjust position and size to account for border width and label block
+ const labeledGroupTopPosition = data.position.topLeft.row * nodeLayoutHeight - verticalPadding - LABEL_GROUP_OFFSET;
const labeledGroupLeftPosition = data.position.topLeft.column * nodeLayoutWidth - horizontalPadding;
const labeledGroupHeight =
- (data.position.bottomRight.row - data.position.topLeft.row + 1) * nodeLayoutHeight - 2 * verticalPadding;
+ (data.position.bottomRight.row - data.position.topLeft.row + 1) * nodeLayoutHeight -
+ 2 * verticalPadding +
+ 2 * LABEL_GROUP_OFFSET;
const labeledGroupWidth = (data.position.bottomRight.column - data.position.topLeft.column + 1) * nodeLayoutWidth;
- const zoom = useStore((s: ReactFlowState) => s.transform?.[2] ?? 1);
+ const isLight = theme.palette.mode === 'light';
return (
) {
left={labeledGroupLeftPosition}
height={labeledGroupHeight}
width={labeledGroupWidth}
- sx={styles.border}
+ sx={getContainerStyle(theme, isLight)}
>
- {zoom >= 0.5 && (
-
-
+
+
+
- )}
+
);
}
diff --git a/src/components/graph/nodes/network-modification-node.styles.ts b/src/components/graph/nodes/network-modification-node.styles.ts
new file mode 100644
index 0000000000..1c5c391c3d
--- /dev/null
+++ b/src/components/graph/nodes/network-modification-node.styles.ts
@@ -0,0 +1,92 @@
+/**
+ * Copyright (c) 2025, RTE (http://www.rte-france.com)
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+import { type MuiStyles } from '@gridsuite/commons-ui';
+import { Theme } from '@mui/material';
+import { baseNodeStyles, interactiveNodeStyles } from './styles';
+import { zoomStyles } from '../zoom.styles';
+
+export const getBorderWidthStyle = (theme: Theme, isSelected: boolean) => ({
+ borderWidth: zoomStyles.borderWidth(theme, isSelected),
+});
+
+export const getNodeBaseStyle = (theme: Theme, isSelected: boolean) =>
+ isSelected ? modificationNodeStyles.selected(theme) : modificationNodeStyles.default(theme);
+
+export const modificationNodeStyles = {
+ selected: (theme) => ({
+ ...baseNodeStyles(theme, 'column'),
+ background: theme.node.modification.selectedBackground,
+ border: theme.node.modification.selectedBorder,
+ boxShadow: theme.shadows[6],
+ ...interactiveNodeStyles(theme, 'modification'),
+ }),
+
+ default: (theme) => ({
+ ...baseNodeStyles(theme, 'column'),
+ border: theme.node.modification.border,
+ ...interactiveNodeStyles(theme, 'modification'),
+ }),
+
+ contentBox: (theme) => ({
+ flexGrow: 1,
+ display: zoomStyles.visibility.showNodeContent(theme) ? 'flex' : 'none',
+ alignItems: 'flex-end',
+ marginLeft: theme.spacing(1),
+ marginRight: theme.spacing(1),
+ marginBottom: theme.spacing(1),
+ }),
+
+ typographyText: (theme) => ({
+ color: theme.palette.text.primary,
+ fontSize: '20px',
+ fontWeight: 400,
+ lineHeight: 'normal',
+ textAlign: 'left',
+ display: '-webkit-box',
+ WebkitBoxOrient: 'vertical',
+ WebkitLineClamp: 2,
+ overflow: 'hidden',
+ width: 'auto',
+ textOverflow: 'ellipsis',
+ wordBreak: 'break-word',
+ }),
+
+ footer: (theme) => ({
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginLeft: theme.spacing(1),
+ marginRight: theme.spacing(1),
+ height: zoomStyles.layout.useFullHeightFooter(theme) ? '100%' : '35%',
+ }),
+
+ chipFloating: (theme) => ({
+ position: 'absolute',
+ top: theme.spacing(-4.3),
+ left: theme.spacing(1),
+ zIndex: 2,
+ }),
+
+ chipLarge: (theme) => zoomStyles.layout.getLargeChipSize(theme) ?? {},
+
+ buildButtonContainer: (theme) => ({
+ display: zoomStyles.visibility.showBuildButton(theme) ? 'block' : 'none',
+ }),
+
+ globalBuildStatusIcon: (theme) => ({
+ fontSize: zoomStyles.iconSize(theme),
+ '& path': {
+ stroke: `currentColor`,
+ strokeWidth: zoomStyles.iconStrokeWidth(theme),
+ },
+ }),
+
+ tooltip: {
+ maxWidth: '720px',
+ },
+} as const satisfies MuiStyles;
diff --git a/src/components/graph/nodes/network-modification-node.tsx b/src/components/graph/nodes/network-modification-node.tsx
index 7bec54d513..d047e75b02 100644
--- a/src/components/graph/nodes/network-modification-node.tsx
+++ b/src/components/graph/nodes/network-modification-node.tsx
@@ -9,82 +9,24 @@ import { NodeProps, Position } from '@xyflow/react';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import { useSelector } from 'react-redux';
import Box from '@mui/material/Box';
-import { LIGHT_THEME, type MuiStyles } from '@gridsuite/commons-ui';
+import { LIGHT_THEME } from '@gridsuite/commons-ui';
import { getLocalStorageTheme } from '../../../redux/session-storage/local-storage';
import { BUILD_STATUS } from '../../network/constants';
import { AppState } from 'redux/reducer';
import { CopyType } from 'components/network-modification.type';
import { ModificationNode } from '../tree-node.type';
import NodeHandle from './node-handle';
-import { baseNodeStyles, interactiveNodeStyles } from './styles';
import NodeOverlaySpinner from './node-overlay-spinner';
import BuildStatusChip from './build-status-chip';
-
+import React, { useCallback, useMemo } from 'react';
import { BuildButton } from './build-button';
-import { Tooltip, Typography } from '@mui/material';
+import { Tooltip, Typography, useTheme } from '@mui/material';
import { useIntl } from 'react-intl';
-import { useMemo } from 'react';
import { TOOLTIP_DELAY } from 'utils/UIconstants';
import ForwardRefBox from 'components/utils/forwardRefBox';
-
-const styles = {
- networkModificationSelected: (theme) => ({
- ...baseNodeStyles(theme, 'column'),
- background: theme.node.modification.selectedBackground,
- border: theme.node.modification.selectedBorder,
- boxShadow: theme.shadows[6],
- ...interactiveNodeStyles(theme, 'modification'),
- }),
- networkModification: (theme) => ({
- ...baseNodeStyles(theme, 'column'),
- border: theme.node.modification.border,
- ...interactiveNodeStyles(theme, 'modification'),
- }),
- contentBox: (theme) => ({
- flexGrow: 1,
- display: 'flex',
- alignItems: 'flex-end',
- marginLeft: theme.spacing(1),
- marginRight: theme.spacing(1),
- marginBottom: theme.spacing(1),
- }),
- typographyText: (theme) => ({
- color: theme.palette.text.primary,
- fontSize: '20px',
- fontWeight: 400,
- lineHeight: 'normal',
- textAlign: 'left',
- display: '-webkit-box',
- WebkitBoxOrient: 'vertical',
- WebkitLineClamp: 2,
- overflow: 'hidden',
- width: 'auto',
- textOverflow: 'ellipsis',
- wordBreak: 'break-word',
- }),
- footerBox: (theme) => ({
- display: 'flex',
- justifyContent: 'flex-start',
- marginLeft: theme.spacing(1),
- height: '35%',
- }),
- buildBox: (theme) => ({
- display: 'flex',
- justifyContent: 'flex-end',
- marginTop: theme.spacing(-5),
- marginRight: theme.spacing(0),
- height: '35%',
- }),
- chipFloating: (theme) => ({
- position: 'absolute',
- top: theme.spacing(-4),
- left: theme.spacing(1),
- zIndex: 2,
- }),
- tooltip: {
- maxWidth: '720px',
- },
-} as const satisfies MuiStyles;
+import { zoomStyles } from '../zoom.styles';
+import { modificationNodeStyles, getBorderWidthStyle, getNodeBaseStyle } from './network-modification-node.styles';
+import { DETAIL_LEVELS } from '../zoom.constants';
const NetworkModificationNode = (props: NodeProps) => {
const currentNode = useSelector((state: AppState) => state.currentTreeNode);
@@ -93,19 +35,29 @@ const NetworkModificationNode = (props: NodeProps) => {
const currentRootNetworkUuid = useSelector((state: AppState) => state.currentRootNetworkUuid);
const intl = useIntl();
-
- const isSelectedNode = () => {
+ const theme = useTheme();
+ const isSelectedNode = useCallback(() => {
return props.id === currentNode?.id;
- };
+ }, [currentNode, props.id]);
- const isSelectedForCut = () => {
+ const isSelectedForCut = useCallback(() => {
return (
(props.id === selectionForCopy?.nodeId && selectionForCopy?.copyType === CopyType.NODE_CUT) ||
((props.id === selectionForCopy?.nodeId ||
selectionForCopy.allChildren?.map((child) => child.id)?.includes(props.id)) &&
selectionForCopy?.copyType === CopyType.SUBTREE_CUT)
);
- };
+ }, [props.id, selectionForCopy]);
+
+ const nodeOpacity = useMemo(() => {
+ if (!isSelectedForCut()) {
+ return 1;
+ }
+ return getLocalStorageTheme() === LIGHT_THEME ? 0.3 : 0.6;
+ }, [isSelectedForCut]);
+
+ const tooltipDelay = theme.tree?.detailLevel === DETAIL_LEVELS.MINIMAL ? 0 : TOOLTIP_DELAY;
+
const tooltipContent = useMemo(() => {
return (
@@ -123,63 +75,65 @@ const NetworkModificationNode = (props: NodeProps) => {
);
}, [props.data, intl]);
- const getNodeOpacity = () => {
- return isSelectedForCut() ? (getLocalStorageTheme() === LIGHT_THEME ? 0.3 : 0.6) : 'unset';
- };
-
return (
<>
- {props.data.globalBuildStatus !== props.data.localBuildStatus && (
- }
- onClick={(e) => e.stopPropagation()}
- />
- )}
+ {props.data.globalBuildStatus !== props.data.localBuildStatus &&
+ zoomStyles.visibility.showGlobalBuildStatus(theme) && (
+ }
+ onClick={(e) => e.stopPropagation()}
+ />
+ )}
getNodeBaseStyle(theme, isSelectedNode()),
+ { opacity: nodeOpacity },
+ (theme) => getBorderWidthStyle(theme, isSelectedNode()),
]}
>
-
-
+
+
{props.data.label}
-
- {props.data.globalBuildStatus !== BUILD_STATUS.BUILDING && (
-
- )}
-
-
-
- {props.data.localBuildStatus !== BUILD_STATUS.BUILDING && (
-
- )}
+
+
+ {props.data.globalBuildStatus !== BUILD_STATUS.BUILDING && (
+
+ )}
+
+
+ {props.data.localBuildStatus !== BUILD_STATUS.BUILDING && (
+
+ )}
+
{props.data.localBuildStatus === BUILD_STATUS.BUILDING && }
diff --git a/src/components/graph/nodes/node-handle.tsx b/src/components/graph/nodes/node-handle.tsx
index abe11d9f96..3290f464fd 100644
--- a/src/components/graph/nodes/node-handle.tsx
+++ b/src/components/graph/nodes/node-handle.tsx
@@ -7,24 +7,29 @@
import { useTheme } from '@mui/material';
import { Handle, HandleType, Position } from '@xyflow/react';
+import { zoomStyles } from '../zoom.styles';
type NodeHandleProps = {
type: HandleType;
position: Position;
};
+
const NodeHandle = ({ type, position }: NodeHandleProps) => {
const theme = useTheme();
+ const hidden = !zoomStyles.visibility.showHandles(theme);
+
return (
diff --git a/src/components/graph/nodes/root-node.tsx b/src/components/graph/nodes/root-node.tsx
index 257102ec18..5d4c1612f1 100644
--- a/src/components/graph/nodes/root-node.tsx
+++ b/src/components/graph/nodes/root-node.tsx
@@ -15,6 +15,7 @@ import { type MuiStyles, OverflowableText } from '@gridsuite/commons-ui';
import { DeviceHub } from '@mui/icons-material';
import NodeHandle from './node-handle';
import { baseNodeStyles, interactiveNodeStyles } from './styles';
+import { zoomStyles } from '../zoom.styles';
const styles = {
// full node container styles
@@ -87,13 +88,25 @@ const RootNode = (props: NodeProps) => {
return (
<>
-
+ ({ borderWidth: zoomStyles.borderWidth(theme, false) }),
+ ]}
+ >
-
+ ({
+ display: zoomStyles.visibility.showNodeContent(theme) ? 'flex' : 'none',
+ }),
+ ]}
+ >
diff --git a/src/components/graph/nodes/styles.ts b/src/components/graph/nodes/styles.ts
index cca6e64b1d..a5b8d7e19c 100644
--- a/src/components/graph/nodes/styles.ts
+++ b/src/components/graph/nodes/styles.ts
@@ -9,6 +9,7 @@ import { LIGHT_THEME, type SxStyle } from '@gridsuite/commons-ui';
import { Theme } from '@mui/material';
import { getLocalStorageTheme } from 'redux/session-storage/local-storage';
import { NODE_HEIGHT, NODE_WIDTH } from './constants';
+import { zoomStyles } from '../zoom.styles';
export const baseNodeStyles = (theme: Theme, direction: 'row' | 'column') =>
({
@@ -20,7 +21,7 @@ export const baseNodeStyles = (theme: Theme, direction: 'row' | 'column') =>
p: 1,
alignItems: 'stretch',
background: theme.node.common.background,
- borderRadius: '8px',
+ borderRadius: zoomStyles.borderRadius(theme),
overflow: 'hidden',
}) as const satisfies SxStyle;
diff --git a/src/components/graph/zoom-theme-provider.tsx b/src/components/graph/zoom-theme-provider.tsx
new file mode 100644
index 0000000000..d8e33f54be
--- /dev/null
+++ b/src/components/graph/zoom-theme-provider.tsx
@@ -0,0 +1,61 @@
+/**
+ * Copyright (c) 2025, RTE (http://www.rte-france.com)
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+import { ReactNode, useEffect, useMemo, useRef } from 'react';
+import { ReactFlowState, useStore } from '@xyflow/react';
+import { ThemeProvider, Theme } from '@mui/material';
+import { DETAIL_LEVELS, ZOOM_THRESHOLDS, type DetailLevel } from './zoom.constants';
+
+interface TreeTheme {
+ detailLevel: DetailLevel;
+}
+
+declare module '@mui/material/styles' {
+ interface Theme {
+ tree: TreeTheme;
+ }
+}
+
+function getDetailLevel(zoom: number): DetailLevel {
+ if (zoom <= ZOOM_THRESHOLDS.MINIMAL_MAX) {
+ return DETAIL_LEVELS.MINIMAL;
+ } else if (zoom < ZOOM_THRESHOLDS.STANDARD_MIN) {
+ return DETAIL_LEVELS.REDUCED;
+ } else {
+ return DETAIL_LEVELS.STANDARD;
+ }
+}
+
+export const ZoomThemeProvider = ({ children }: { children: ReactNode }) => {
+ const zoom = useStore((s: ReactFlowState) => s.transform?.[2] ?? 1);
+ const containerRef = useRef(null);
+
+ // Update CSS variable for dynamic scaling calculations (borders, icons)
+ useEffect(() => {
+ if (containerRef.current) {
+ containerRef.current.style.setProperty('--tree-zoom', zoom.toString());
+ }
+ }, [zoom]);
+
+ // Calculate detail level
+ const treeMeta = useMemo(() => {
+ return { detailLevel: getDetailLevel(zoom) };
+ }, [zoom]);
+
+ const themeWithTree = useMemo(() => {
+ return (outerTheme: Theme) => ({
+ ...outerTheme,
+ tree: treeMeta,
+ });
+ }, [treeMeta]);
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/components/graph/zoom.constants.ts b/src/components/graph/zoom.constants.ts
new file mode 100644
index 0000000000..6ee0c0c5ec
--- /dev/null
+++ b/src/components/graph/zoom.constants.ts
@@ -0,0 +1,19 @@
+/**
+ * Copyright (c) 2025, RTE (http://www.rte-france.com)
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+export const DETAIL_LEVELS = {
+ MINIMAL: 'minimal',
+ REDUCED: 'reduced',
+ STANDARD: 'standard',
+} as const;
+
+export type DetailLevel = (typeof DETAIL_LEVELS)[keyof typeof DETAIL_LEVELS];
+
+export const ZOOM_THRESHOLDS = {
+ MINIMAL_MAX: 0.3,
+ STANDARD_MIN: 0.5,
+} as const;
diff --git a/src/components/graph/zoom.styles.ts b/src/components/graph/zoom.styles.ts
new file mode 100644
index 0000000000..894c5e23ba
--- /dev/null
+++ b/src/components/graph/zoom.styles.ts
@@ -0,0 +1,95 @@
+/**
+ * Copyright (c) 2025, RTE (http://www.rte-france.com)
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+import { Theme } from '@mui/material';
+import { DETAIL_LEVELS } from './zoom.constants';
+
+/**
+ * Zoom-based styling configuration
+ * Uses CSS custom properties (--tree-zoom) for efficient scaling without re-renders
+ * Formula: baseValue + scaleFactor * (1/zoom - 1)
+ */
+
+const SCALING = {
+ borderWidth: { base: { light: 1, dark: 3 }, scale: 1.5 },
+ iconSize: { base: 15, scale: 10 },
+ iconStrokeWidth: { base: 0.3, scale: 0.15 },
+ labeledGroupBorder: { base: 3, scale: 2 },
+ edgeWidth: { base: 1, scale: 1 },
+ borderRadius: { base: 8, scale: 2 },
+} as const;
+
+/**
+ * Generate CSS calc() expression for zoom-based scaling
+ * calc(base + scale * (1/var(--tree-zoom) - 1))
+ */
+function cssScale(base: number, scale: number): string {
+ return `calc(${base}px + ${scale}px * (1 / var(--tree-zoom, 1) - 1))`;
+}
+
+export const zoomStyles = {
+ // Dynamic styling using CSS custom properties
+ borderWidth: (theme: Theme, selected = false): string => {
+ const base =
+ theme.palette.mode === 'dark' && selected ? SCALING.borderWidth.base.dark : SCALING.borderWidth.base.light;
+ return cssScale(base, SCALING.borderWidth.scale);
+ },
+
+ borderRadius: (theme: Theme): string => {
+ return cssScale(SCALING.borderRadius.base, SCALING.borderRadius.scale);
+ },
+
+ edgeWidth: (theme: Theme): string => {
+ return cssScale(SCALING.edgeWidth.base, SCALING.edgeWidth.scale);
+ },
+
+ iconSize: (theme: Theme): string => {
+ return cssScale(SCALING.iconSize.base, SCALING.iconSize.scale);
+ },
+
+ iconStrokeWidth: (theme: Theme): string => {
+ return cssScale(SCALING.iconStrokeWidth.base, SCALING.iconStrokeWidth.scale);
+ },
+
+ labeledGroupBorder: (theme: Theme) => {
+ const width = cssScale(SCALING.labeledGroupBorder.base, SCALING.labeledGroupBorder.scale);
+ const radius = cssScale(SCALING.borderRadius.base, SCALING.borderRadius.scale);
+ const style = theme.tree?.detailLevel === DETAIL_LEVELS.MINIMAL ? 'solid' : 'dashed';
+ return {
+ border: `${style} ${width} #8B8F8F`,
+ borderRadius: radius,
+ };
+ },
+
+ // Visibility
+ visibility: {
+ showHandles: (theme: Theme) => theme.tree?.detailLevel === DETAIL_LEVELS.STANDARD,
+ showNodeContent: (theme: Theme) => theme.tree?.detailLevel !== DETAIL_LEVELS.MINIMAL,
+ showLabeledGroupLabel: (theme: Theme) => theme.tree?.detailLevel !== DETAIL_LEVELS.MINIMAL,
+ showBuildStatusLabel: (theme: Theme) => theme.tree?.detailLevel === DETAIL_LEVELS.STANDARD,
+ showBuildButton: (theme: Theme) => theme.tree?.detailLevel === DETAIL_LEVELS.STANDARD,
+ showGlobalBuildStatus: (theme: Theme) => theme.tree?.detailLevel !== DETAIL_LEVELS.MINIMAL,
+ },
+
+ // Layout
+ layout: {
+ useFullHeightFooter: (theme: Theme) => theme.tree?.detailLevel === DETAIL_LEVELS.MINIMAL,
+ getCompactChipSize: (theme: Theme, size: number = 3) => ({
+ borderRadius: '50%',
+ width: theme.spacing(size),
+ height: theme.spacing(size),
+ minWidth: 'auto',
+ padding: 0,
+ '& .MuiChip-label': { padding: 0, overflow: 'hidden', display: 'none' },
+ '& .MuiChip-icon': { margin: 0 },
+ }),
+ getLargeChipSize: (theme: Theme): React.CSSProperties | undefined =>
+ theme.tree?.detailLevel === DETAIL_LEVELS.MINIMAL
+ ? zoomStyles.layout.getCompactChipSize(theme, 6)
+ : undefined,
+ },
+};
diff --git a/src/components/network-modification-tree.jsx b/src/components/network-modification-tree.jsx
index 2ada96c754..571e1b26e5 100644
--- a/src/components/network-modification-tree.jsx
+++ b/src/components/network-modification-tree.jsx
@@ -32,6 +32,7 @@ import { StudyDisplayMode } from './network-modification.type';
import { useSyncNavigationActions } from 'hooks/use-sync-navigation-actions';
import { NodeType } from './graph/tree-node.type';
import { useTreeNodeFocus } from 'hooks/use-tree-node-focus';
+import { zoomStyles } from './graph/zoom.styles';
const styles = {
modificationTree: (theme) => ({
@@ -39,6 +40,7 @@ const styles = {
height: '100%',
backgroundColor: theme.reactflow.backgroundColor,
'.react-flow': {
+ '--xy-edge-stroke-width-default': zoomStyles.edgeWidth(theme),
'--xy-edge-stroke': theme.reactflow.edge.stroke,
},
'.react-flow__attribution a': {
@@ -331,19 +333,10 @@ const NetworkModificationTree = ({ onNodeContextMenu, studyUuid }) => {
//maxZoom={2} // Higher value allows for more zoom in
onNodeDragStop={handlePostNodeDragging}
nodeClickDistance={5} // to avoid triggering onNodeDragStop instead of onNodeClick sometimes
- disableKeyboardA11y
- deleteKeyCode={null}
- defaultEdgeOptions={{
- type: 'smoothstep',
- pathOptions: {
- // TODO This negative offset and borderRadius values are needed to have round corners on the edge,
- // but because the nodes are not totally opaque, we can see the edges behind the nodes.
- // When the nodes are redesigned and hopefully the colors are set without transparency, we can use
- // the round edges by un-commenting the two lines below.
- //offset: -24,
- //borderRadius: 48,
- },
+ proOptions={{
+ hideAttribution: true,
}}
+ defaultEdgeOptions={{ type: 'smoothstep' }}
>
-
+
+
+
diff --git a/src/index.css b/src/index.css
index 158c1788e5..8a6bddede7 100644
--- a/src/index.css
+++ b/src/index.css
@@ -346,4 +346,5 @@ code {
}
.react-flow {
--xy-edge-stroke-width-default: 1;
+ --xy-edge-stroke-selected-default: var(--xy-edge-stroke, currentColor);
}