diff --git a/webapp/eslint.config.js b/webapp/eslint.config.js index 1d327970c5..64ab173c74 100644 --- a/webapp/eslint.config.js +++ b/webapp/eslint.config.js @@ -130,7 +130,7 @@ export default [ { // Includes hooks from 'react-use' additionalHooks: - "(useSafeMemo|useUpdateEffectOnce|useDeepCompareEffect|useShallowCompareEffect|useCustomCompareEffect)", + "(useSafeMemo|useUpdateEffect|useUpdateEffectOnce|useDeepCompareEffect|useShallowCompareEffect|useCustomCompareEffect)", }, ], "require-await": "warn", // TODO: switch to "error" when the quantity of warning will be low diff --git a/webapp/src/components/common/DataGrid.tsx b/webapp/src/components/common/DataGrid.tsx index 2f95b302d6..39659f4e24 100644 --- a/webapp/src/components/common/DataGrid.tsx +++ b/webapp/src/components/common/DataGrid.tsx @@ -19,83 +19,97 @@ import { type EditListItem, type GridSelection, type DataEditorProps, + type GridCell, } from "@glideapps/glide-data-grid"; import "@glideapps/glide-data-grid/dist/index.css"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { voidFn } from "@/utils/fnUtils"; import { darkTheme } from "./Matrix/styles"; +import { useUpdateEffect } from "react-use"; interface StringRowMarkerOptions { kind: "string" | "clickable-string"; getTitle?: (rowIndex: number) => string; + width?: number; } -type RowMarkers = +export type RowMarkers = | NonNullable | StringRowMarkerOptions["kind"] | StringRowMarkerOptions; type RowMarkersOptions = Exclude; -export interface DataGridProps - extends Omit { +export interface DataGridProps extends Omit { rowMarkers?: RowMarkers; enableColumnResize?: boolean; } +const ROW_HEIGHT = 30; +const OVERSCROLL = 2; + function isStringRowMarkerOptions( rowMarkerOptions: RowMarkersOptions, ): rowMarkerOptions is StringRowMarkerOptions { return rowMarkerOptions.kind === "string" || rowMarkerOptions.kind === "clickable-string"; } -function DataGrid(props: DataGridProps) { - const { - rowMarkers = { kind: "none" }, - getCellContent, - columns: columnsFromProps, - onCellEdited, - onCellsEdited, - onColumnResize, - onColumnResizeStart, - onColumnResizeEnd, - enableColumnResize = true, - freezeColumns, - ...rest - } = props; - +function DataGrid({ + rowMarkers = { kind: "none" }, + getCellContent, + columns: columnsFromProps, + onCellEdited, + onCellsEdited, + onColumnResize, + onColumnResizeStart, + onColumnResizeEnd, + onGridSelectionChange, + enableColumnResize = true, + freezeColumns, + rows, + ...rest +}: DataGridProps) { const rowMarkersOptions: RowMarkersOptions = typeof rowMarkers === "string" ? { kind: rowMarkers } : rowMarkers; + const isStringRowMarkers = isStringRowMarkerOptions(rowMarkersOptions); const adjustedFreezeColumns = isStringRowMarkers ? (freezeColumns || 0) + 1 : freezeColumns; - const [columns, setColumns] = useState(columnsFromProps); - const [selection, setSelection] = useState({ - columns: CompactSelection.empty(), + // Manually calculate the height allows to remove the blank space at the bottom of the grid + // when there is no scrollbar. Header is included in the height calculation. + //! Group header is not included, fix it when needed. + const height = ROW_HEIGHT * (rows + 1) + OVERSCROLL; + + const [columns, setColumns] = useState(initColumns); + const [gridSelection, setGridSelection] = useState({ rows: CompactSelection.empty(), + columns: CompactSelection.empty(), }); - // Add a column for the "string" row markers if needed - useEffect(() => { - setColumns( - isStringRowMarkers ? [{ id: "", title: "" }, ...columnsFromProps] : columnsFromProps, - ); + useUpdateEffect(() => { + setColumns(initColumns()); }, [columnsFromProps, isStringRowMarkers]); //////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////// + function initColumns() { + return isStringRowMarkers + ? [{ id: "", title: "", width: rowMarkersOptions.width }, ...columnsFromProps] + : columnsFromProps; + } + const ifElseStringRowMarkers = ( colIndex: number, - onTrue: () => R1, + onTrue: (options: StringRowMarkerOptions) => R1, onFalse: (colIndex: number) => R2, ) => { let adjustedColIndex = colIndex; if (isStringRowMarkers) { if (colIndex === 0) { - return onTrue(); + return onTrue(rowMarkersOptions); } adjustedColIndex = colIndex - 1; @@ -118,11 +132,8 @@ function DataGrid(props: DataGridProps) { return ifElseStringRowMarkers( colIndex, - () => { - const title = - isStringRowMarkers && rowMarkersOptions.getTitle - ? rowMarkersOptions.getTitle(rowIndex) - : `Row ${rowIndex + 1}`; + ({ getTitle }) => { + const title = getTitle ? getTitle(rowIndex) : `Row ${rowIndex + 1}`; return { kind: GridCellKind.Text, @@ -133,7 +144,7 @@ function DataGrid(props: DataGridProps) { themeOverride: { bgCell: darkTheme.bgHeader, }, - }; + } satisfies GridCell; }, (adjustedColIndex) => { return getCellContent([adjustedColIndex, rowIndex]); @@ -223,7 +234,7 @@ function DataGrid(props: DataGridProps) { if (newSelection.current) { // Select the whole row when clicking on a row marker cell if (rowMarkersOptions.kind === "clickable-string" && newSelection.current.cell[0] === 0) { - setSelection({ + setGridSelection({ ...newSelection, current: undefined, rows: CompactSelection.fromSingleSelection(newSelection.current.cell[1]), @@ -238,28 +249,24 @@ function DataGrid(props: DataGridProps) { } } - // Prevent selecting the row marker column + // Select/Deselect all the rows like others row markers when selecting the column if (newSelection.columns.hasIndex(0)) { - // TODO find a way to have the rows length to select all the rows like other row markers - // setSelection({ - // ...newSelection, - // columns: CompactSelection.empty(), - // rows: CompactSelection.fromSingleSelection([ - // 0, - // // rowsLength - // ]), - // }); - - setSelection({ + const isSelectedAll = gridSelection.rows.length === rows; + + setGridSelection({ ...newSelection, - columns: newSelection.columns.remove(0), + columns: CompactSelection.empty(), + rows: isSelectedAll + ? CompactSelection.empty() + : CompactSelection.fromSingleSelection([0, rows]), }); return; } } - setSelection(newSelection); + setGridSelection(newSelection); + onGridSelectionChange?.(newSelection); } }; @@ -269,16 +276,18 @@ function DataGrid(props: DataGridProps) { return ( diff --git a/webapp/src/components/common/DataGridForm.tsx b/webapp/src/components/common/DataGridForm.tsx index 0a00e813e9..6c4b9b9919 100644 --- a/webapp/src/components/common/DataGridForm.tsx +++ b/webapp/src/components/common/DataGridForm.tsx @@ -21,7 +21,7 @@ import { } from "@glideapps/glide-data-grid"; import type { DeepPartial } from "react-hook-form"; import { useCallback, useEffect, useMemo, useState } from "react"; -import DataGrid, { type DataGridProps } from "./DataGrid"; +import DataGrid, { type DataGridProps, type RowMarkers } from "./DataGrid"; import { Box, Divider, IconButton, setRef, Tooltip, type SxProps, type Theme } from "@mui/material"; import useUndo, { type Actions } from "use-undo"; import UndoIcon from "@mui/icons-material/Undo"; @@ -36,6 +36,8 @@ import useEnqueueErrorSnackbar from "@/hooks/useEnqueueErrorSnackbar"; import useFormCloseProtection from "@/hooks/useCloseFormSecurity"; import { useUpdateEffect } from "react-use"; import { toError } from "@/utils/fnUtils"; +import useSafeMemo from "@/hooks/useSafeMemo"; +import { getColumnWidth } from "@/utils/dataGridUtils"; type Data = Record>; @@ -72,7 +74,7 @@ function DataGridForm({ columns, allowedFillDirections = "vertical", enableColumnResize, - rowMarkers, + rowMarkers: rowMarkersFromProps, onSubmit, onSubmitSuccessful, onStateChange, @@ -91,8 +93,6 @@ function DataGridForm({ // Deep comparison fix the issue but with big data it can be slow. const isDirty = savedData !== data; - const rowNames = Object.keys(data); - const formState = useMemo( () => ({ isDirty, @@ -107,16 +107,43 @@ function DataGridForm({ useEffect(() => setRef(apiRef, { data, setData, formState }), [apiRef, data, setData, formState]); + // Rows cannot be added or removed, so no dependencies are needed + const rowNames = useSafeMemo(() => Object.keys(defaultData), []); + + const columnsWithAdjustedSize = useMemo( + () => + columns.map((column) => ({ + ...column, + width: getColumnWidth(column, () => + rowNames.map((rowName) => defaultData[rowName][column.id].toString()), + ), + })), + [columns, defaultData, rowNames], + ); + + const columnIds = useMemo(() => columns.map((column) => column.id), [columns]); + + const rowMarkers = useMemo( + () => + rowMarkersFromProps || { + kind: "clickable-string", + getTitle: (index) => rowNames[index], + width: getColumnWidth({ title: "", id: "" }, () => rowNames), + }, + [rowMarkersFromProps, rowNames], + ); + //////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////// - const getRowAndColumnNames = (location: Item) => { - const [colIndex, rowIndex] = location; - const columnIds = columns.map((column) => column.id); - - return [rowNames[rowIndex], columnIds[colIndex]]; - }; + const getRowAndColumnNames = useCallback( + (location: Item) => { + const [colIndex, rowIndex] = location; + return [rowNames[rowIndex], columnIds[colIndex]]; + }, + [rowNames, columnIds], + ); const getDirtyValues = () => { return rowNames.reduce((acc, rowName) => { @@ -190,7 +217,7 @@ function DataGridForm({ readonly: true, }; }, - [data, columns], + [data, getRowAndColumnNames], ); //////////////////////////////////////////////////////////////// @@ -261,15 +288,10 @@ function DataGridForm({ > rowNames[index], - } - } + rowMarkers={rowMarkers} fillHandle allowedFillDirections={allowedFillDirections} enableColumnResize={enableColumnResize} @@ -283,7 +305,6 @@ function DataGridForm({ mt: 1.5, }} > - void; readOnly?: boolean; showPercent?: boolean; + showStats?: boolean; } function MatrixGrid({ @@ -57,9 +58,21 @@ function MatrixGrid({ onMultipleCellsEdit, readOnly, showPercent, + showStats = true, }: MatrixGridProps) { + const [gridSelection, setGridSelection] = useState({ + rows: CompactSelection.empty(), + columns: CompactSelection.empty(), + }); + const { gridToData } = useColumnMapping(columns); + const selectionStats = useSelectionStats({ + data, + selection: gridSelection, + gridToData, + }); + const theme = useMemo(() => { if (readOnly) { return { @@ -140,23 +153,27 @@ function MatrixGrid({ //////////////////////////////////////////////////////////////// return ( - + <> + + {showStats && } + ); } diff --git a/webapp/src/components/common/Matrix/components/MatrixStats/index.tsx b/webapp/src/components/common/Matrix/components/MatrixStats/index.tsx new file mode 100644 index 0000000000..6770242a7b --- /dev/null +++ b/webapp/src/components/common/Matrix/components/MatrixStats/index.tsx @@ -0,0 +1,97 @@ +/** + * Copyright (c) 2025, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * 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/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +import { Box, Paper, Typography, Divider } from "@mui/material"; +import { formatGridNumber } from "../../shared/utils"; + +interface MatrixStatsProps { + stats: { + count: number; + sum: number; + average: number; + min: number; + max: number; + } | null; +} + +function MatrixStats({ stats }: MatrixStatsProps) { + if (!stats) { + return null; + } + + const statItems = [ + { label: "Nb", value: stats.count }, + { label: "Total", value: formatGridNumber({ value: stats.sum }) }, + { + label: "Avg", + value: formatGridNumber({ value: stats.average, maxDecimals: 2 }), + }, + { label: "Min", value: formatGridNumber({ value: stats.min }) }, + { label: "Max", value: formatGridNumber({ value: stats.max }) }, + ]; + + return ( + + {statItems.map((item, index) => ( + + + {item.label} + + + {item.value} + + {index < statItems.length - 1 && ( + + )} + + ))} + + ); +} + +export default MatrixStats; diff --git a/webapp/src/components/common/Matrix/hooks/useSelectionStats/index.ts b/webapp/src/components/common/Matrix/hooks/useSelectionStats/index.ts new file mode 100644 index 0000000000..d6c38ca79f --- /dev/null +++ b/webapp/src/components/common/Matrix/hooks/useSelectionStats/index.ts @@ -0,0 +1,99 @@ +/** + * Copyright (c) 2025, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * 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/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +import type { GridSelection, Item } from "@glideapps/glide-data-grid"; +import { useState } from "react"; +import { useUpdateEffect } from "react-use"; + +interface SelectionStats { + sum: number; + average: number; + min: number; + max: number; + count: number; +} + +interface UseSelectionStatsProps { + data: number[][]; + selection: GridSelection; + gridToData: (coordinates: Item) => Item | null; +} + +export function useSelectionStats({ data, selection, gridToData }: UseSelectionStatsProps) { + const [stats, setStats] = useState(null); + + useUpdateEffect(() => { + let sum = 0; + let min = Infinity; + let max = -Infinity; + let count = 0; + const numRows = data.length; + const numCols = data[0]?.length ?? 0; + + const processValue = (value: number) => { + sum += value; + min = Math.min(min, value); + max = Math.max(max, value); + count++; + }; + + if (selection.current?.range) { + const { x, y, width, height } = selection.current.range; + + for (let col = x; col < x + width; ++col) { + for (let row = y; row < y + height; ++row) { + const coordinates = gridToData([col, row]); + + if (coordinates) { + const [dataCol, dataRow] = coordinates; + const value = data[dataRow]?.[dataCol]; + + if (value !== undefined) { + processValue(value); + } + } + } + } + } + + for (const row of selection.rows) { + for (let col = 0; col < numCols; ++col) { + const value = data[row]?.[col]; + + if (value !== undefined) { + processValue(value); + } + } + } + + for (const col of selection.columns) { + for (let row = 0; row < numRows; ++row) { + const coordinates = gridToData([col, row]); + + if (coordinates) { + const [dataCol, dataRow] = coordinates; + const value = data[dataRow]?.[dataCol]; + + if (value !== undefined) { + processValue(value); + } + } + } + } + + setStats(count ? { sum, min, max, count, average: sum / count } : null); + }, [data, selection]); + + return stats; +} diff --git a/webapp/src/components/common/TableMode.tsx b/webapp/src/components/common/TableMode.tsx index a30bfe79fe..138b194919 100644 --- a/webapp/src/components/common/TableMode.tsx +++ b/webapp/src/components/common/TableMode.tsx @@ -73,7 +73,6 @@ function TableMode({ return { title, id: col, - width: title.length * 10, } satisfies GridColumn; }), ); diff --git a/webapp/src/utils/dataGridUtils.ts b/webapp/src/utils/dataGridUtils.ts new file mode 100644 index 0000000000..1918ed14b9 --- /dev/null +++ b/webapp/src/utils/dataGridUtils.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2025, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * 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/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +import type { GridColumn } from "@glideapps/glide-data-grid"; +import { measureTextWidth } from "./domUtils"; + +/** + * Gets the width of the given column. + * + * @param column - The column to get the width of. + * @param values - The values of the column. + * @returns The width of the column. + */ +export function getColumnWidth(column: GridColumn, values: () => string[]) { + if ("width" in column) { + return column.width; + } + + const width = Math.max( + measureTextWidth(column.title), + // `values` is a function to prevent unnecessary computation if the column already has a width + ...values().map((value) => measureTextWidth(value)), + ); + + // Under 110px, add a margin to the width to prevent text from being cut off + return width < 110 ? width + 15 : width; +} diff --git a/webapp/src/utils/domUtils.ts b/webapp/src/utils/domUtils.ts new file mode 100644 index 0000000000..cc1c85edb3 --- /dev/null +++ b/webapp/src/utils/domUtils.ts @@ -0,0 +1,65 @@ +/** + * Copyright (c) 2025, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * 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/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +const CANVAS_CONTEXT = document.createElement("canvas").getContext("2d"); +const BODY_FONT = getCssFont(); + +/** + * Gets the computed style of the given element. + * + * @see https://stackoverflow.com/a/21015393 + * + * @param element - The element to get the style from. + * @param prop - The property to get the value of. + * @returns The computed style of the given element. + */ +export function getCssStyle(element: HTMLElement, prop: string) { + return window.getComputedStyle(element, null).getPropertyValue(prop); +} + +/** + * Gets the font of the given element, or the `body` element if none is provided. + * The returned value follows the CSS `font` shorthand property format. + * + * @see https://stackoverflow.com/a/21015393 + * + * @param element - The element to get the font from. + * @returns The font of the given element. + */ +export function getCssFont(element = document.body) { + const fontWeight = getCssStyle(element, "font-weight") || "normal"; + const fontSize = getCssStyle(element, "font-size") || "16px"; + const fontFamily = getCssStyle(element, "font-family") || "Arial"; + + return `${fontWeight} ${fontSize} ${fontFamily}`; +} + +/** + * Uses `canvas.measureText` to compute and return the width of the specified text, + * using the specified canvas font if defined (use font from the "body" otherwise). + * + * @see https://stackoverflow.com/a/21015393 + * + * @param text - The text to be rendered. + * @param [font] - The CSS font that text is to be rendered with (e.g. "bold 14px Arial"). + * If not provided, the font of the `body` element is used. + * @returns The width of the text in pixels. + */ +export function measureTextWidth(text: string, font?: string) { + if (CANVAS_CONTEXT) { + CANVAS_CONTEXT.font = font || BODY_FONT; + return CANVAS_CONTEXT.measureText(text).width; + } + return 0; +}