diff --git a/Src/Witsml/Data/Curves/DateTimeIndex.cs b/Src/Witsml/Data/Curves/DateTimeIndex.cs index 3fb16cc20..e760e0c7b 100644 --- a/Src/Witsml/Data/Curves/DateTimeIndex.cs +++ b/Src/Witsml/Data/Curves/DateTimeIndex.cs @@ -92,7 +92,7 @@ public override int GetHashCode() { return this.Value.GetHashCode(); } - + public static TimeSpan operator -(DateTimeIndex index1, DateTimeIndex index2) { return index1.Value - index2.Value; diff --git a/Src/Witsml/Data/Curves/Index.cs b/Src/Witsml/Data/Curves/Index.cs index 114e70e60..8098877e9 100644 --- a/Src/Witsml/Data/Curves/Index.cs +++ b/Src/Witsml/Data/Curves/Index.cs @@ -26,7 +26,7 @@ public abstract class Index : IComparable { return index1.CompareTo(index2) >= 0; } - + public static Index operator -(Index index1, Index index2) { return index1 switch diff --git a/Src/Witsml/Data/Curves/TimeSpanIndex.cs b/Src/Witsml/Data/Curves/TimeSpanIndex.cs index 75aff3a1d..8c6e9556a 100644 --- a/Src/Witsml/Data/Curves/TimeSpanIndex.cs +++ b/Src/Witsml/Data/Curves/TimeSpanIndex.cs @@ -9,25 +9,25 @@ namespace Witsml.Data.Curves; public class TimeSpanIndex : Index { private const string TimeSpanPattern = @"hh\:mm\:ss"; - + public TimeSpan Value { get; } public TimeSpanIndex(TimeSpan value) { Value = value; } - + public TimeSpanIndex(long milliseconds) { Value = TimeSpan.FromMilliseconds(milliseconds); } - + [Obsolete("AddEpsilon is deprecated due to assuming 3 decimals of precision for depth indexes. Some WITSML servers do not use 3 decimals.")] public override Index AddEpsilon() { throw new System.NotImplementedException(); } - + public override int CompareTo(Index that) { TimeSpanIndex thatWitsmlTimeSpan = (TimeSpanIndex)that; @@ -55,7 +55,7 @@ public override bool IsNullValue() { return Value == TimeSpan.Zero; } - + public override string ToString() { return GetValueAsString(); diff --git a/Src/WitsmlExplorer.Api/Jobs/AnalyzeGapJob.cs b/Src/WitsmlExplorer.Api/Jobs/AnalyzeGapJob.cs index 2596e90d9..85f434a12 100644 --- a/Src/WitsmlExplorer.Api/Jobs/AnalyzeGapJob.cs +++ b/Src/WitsmlExplorer.Api/Jobs/AnalyzeGapJob.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; + using WitsmlExplorer.Api.Models; namespace WitsmlExplorer.Api.Jobs; @@ -12,7 +13,7 @@ public record AnalyzeGapJob : Job /// Log reference object /// public LogObject LogReference { get; init; } - + /// /// Array of mnemonics names /// @@ -22,7 +23,7 @@ public record AnalyzeGapJob : Job /// Size of the GAP for depth /// public double GapSize { get; set; } - + /// /// Size of the GAP for dateTime /// diff --git a/Src/WitsmlExplorer.Api/Jobs/Common/Interfaces/IObjectReference.cs b/Src/WitsmlExplorer.Api/Jobs/Common/Interfaces/IObjectReference.cs index e9de7da08..ef153b325 100644 --- a/Src/WitsmlExplorer.Api/Jobs/Common/Interfaces/IObjectReference.cs +++ b/Src/WitsmlExplorer.Api/Jobs/Common/Interfaces/IObjectReference.cs @@ -11,4 +11,4 @@ public interface IObjectReference public string Name { get; } public string WellName { get; } public string WellboreName { get; } -} \ No newline at end of file +} diff --git a/Src/WitsmlExplorer.Api/Models/ObjectOnWellbore.cs b/Src/WitsmlExplorer.Api/Models/ObjectOnWellbore.cs index 64b0fbb87..b100ed7d1 100644 --- a/Src/WitsmlExplorer.Api/Models/ObjectOnWellbore.cs +++ b/Src/WitsmlExplorer.Api/Models/ObjectOnWellbore.cs @@ -2,7 +2,7 @@ namespace WitsmlExplorer.Api.Models { - public class ObjectOnWellbore : IObjectReference + public class ObjectOnWellbore : IObjectReference { public string Uid { get; init; } public string WellUid { get; init; } diff --git a/Src/WitsmlExplorer.Api/Workers/AnalyzeGapWorker.cs b/Src/WitsmlExplorer.Api/Workers/AnalyzeGapWorker.cs index d27ec2219..42a88a355 100644 --- a/Src/WitsmlExplorer.Api/Workers/AnalyzeGapWorker.cs +++ b/Src/WitsmlExplorer.Api/Workers/AnalyzeGapWorker.cs @@ -19,6 +19,7 @@ using WitsmlExplorer.Api.Models; using WitsmlExplorer.Api.Models.Reports; using WitsmlExplorer.Api.Services; + using Index = Witsml.Data.Curves.Index; namespace WitsmlExplorer.Api.Workers; @@ -30,7 +31,7 @@ public class AnalyzeGapWorker : BaseWorker, IWorker { public JobType JobType => JobType.AnalyzeGaps; public AnalyzeGapWorker(ILogger logger, IWitsmlClientProvider witsmlClientProvider) : base(witsmlClientProvider, logger) { } - + /// /// Find all gaps for selected mnemonics and required size of gap. If no mnemonics are selected, all mnemonics on the log will be analyzed. /// @@ -45,7 +46,7 @@ public AnalyzeGapWorker(ILogger logger, IWitsmlClientProvider wit bool isDepthLog = job.LogReference.IndexType == WitsmlLog.WITSML_INDEX_TYPE_MD; List gapReportItems = new(); List logDataRows = new(); - + var witsmlLog = await WorkerTools.GetLog(GetTargetWitsmlClientOrThrow(), job.LogReference, ReturnElements.HeaderOnly); if (witsmlLog == null) { @@ -56,7 +57,7 @@ public AnalyzeGapWorker(ILogger logger, IWitsmlClientProvider wit var jobMnemonics = job.Mnemonics.Any() ? job.Mnemonics.ToList() : witsmlLog.LogCurveInfo.Select(x => x.Mnemonic).ToList(); await using LogDataReader logDataReader = new(GetTargetWitsmlClientOrThrow(), witsmlLog, new List(jobMnemonics), Logger); - + WitsmlLogData logData = await logDataReader.GetNextBatch(); var logMnemonics = logData?.MnemonicList.Split(CommonConstants.DataSeparator).Select((value, index) => new { index, value }).ToList(); while (logData != null) @@ -148,7 +149,7 @@ private IEnumerable GetAnalyzeGapReportItem(string mnemoni Index lastValueIndex = inputIndexList.FirstOrDefault(); if (lastValueIndex == null) return gapValues; - + foreach (var inputIndex in inputIndexList.Skip(1)) { var gapSize = isLogIncreasing ? (inputIndex - lastValueIndex) : (lastValueIndex - inputIndex); diff --git a/Src/WitsmlExplorer.Frontend/components/Constants.tsx b/Src/WitsmlExplorer.Frontend/components/Constants.tsx index 5b60effbb..033800f81 100644 --- a/Src/WitsmlExplorer.Frontend/components/Constants.tsx +++ b/Src/WitsmlExplorer.Frontend/components/Constants.tsx @@ -8,6 +8,9 @@ export const DateFormat = { DATETIME_MS: "DD.MM.YYYY HH:mm:ss.SSS" }; +export const MILLIS_IN_SECOND = 1000; +export const SECONDS_IN_MINUTE = 60; + export const STORAGE_THEME_KEY = "selectedTheme"; export const STORAGE_TIMEZONE_KEY = "selectedTimeZone"; export const STORAGE_MODE_KEY = "selectedMode"; diff --git a/Src/WitsmlExplorer.Frontend/components/ContentViews/CurveValuesPlot.tsx b/Src/WitsmlExplorer.Frontend/components/ContentViews/CurveValuesPlot.tsx index d8e972fbe..90c91d988 100644 --- a/Src/WitsmlExplorer.Frontend/components/ContentViews/CurveValuesPlot.tsx +++ b/Src/WitsmlExplorer.Frontend/components/ContentViews/CurveValuesPlot.tsx @@ -10,19 +10,25 @@ interface CurveValuesPlotProps { data: any[]; columns: ExportableContentTableColumn[]; name: string; + autoRefresh: boolean; + isDescending?: boolean; } export const CurveValuesPlot = React.memo((props: CurveValuesPlotProps): React.ReactElement => { - const { data, columns, name } = props; + const { data, columns, name, autoRefresh, isDescending = false } = props; const { operationState: { colors } } = useContext(OperationContext); const chart = useRef(null); + const selectedLabels = useRef>(null); + const scrollIndex = useRef(0); + const horizontalZoom = useRef<[number, number]>([0, 100]); - const chartOption = React.useMemo(() => getChartOption(data, columns, name, colors), [data, columns, name, colors]); + const chartOption = getChartOption(data, columns, name, colors, isDescending, autoRefresh, selectedLabels.current, scrollIndex.current, horizontalZoom.current); const onLegendChange = (params: { name: string; selected: Record }) => { - const actionType = Object.values(params.selected).every((s) => s === false) ? "legendSelect" : "legendUnSelect"; + const shouldShowAll = Object.values(params.selected).every((s) => s === false); + const actionType = shouldShowAll ? "legendSelect" : "legendUnSelect"; chart.current.dispatchAction({ type: "legendSelect", name: params.name }); for (const legend in params.selected) { if (legend !== params.name) { @@ -32,10 +38,29 @@ export const CurveValuesPlot = React.memo((props: CurveValuesPlotProps): React.R }); } } + selectedLabels.current = { + ...Object.keys(params.selected).reduce((acc, key) => { + acc[key] = shouldShowAll; + return acc; + }, {} as Record), + [params.name]: true + }; + }; + + const onLegendScroll = (params: { scrollDataIndex: number }) => { + scrollIndex.current = params.scrollDataIndex; + }; + + const onDataZoom = (params: { dataZoomId: string; start: number; end: number }) => { + if (params.dataZoomId == "horizontalZoom") { + horizontalZoom.current = [params.start, params.end]; + } }; const handleEvents = { - legendselectchanged: onLegendChange + legendselectchanged: onLegendChange, + legendscroll: onLegendScroll, + datazoom: onDataZoom }; return ( @@ -53,8 +78,20 @@ export const CurveValuesPlot = React.memo((props: CurveValuesPlotProps): React.R }); CurveValuesPlot.displayName = "CurveValuesPlot"; -const getChartOption = (data: any[], columns: ExportableContentTableColumn[], name: string, colors: Colors) => { - const valueOffsetFromColumn = 0.01; +const getChartOption = ( + data: any[], + columns: ExportableContentTableColumn[], + name: string, + colors: Colors, + isDescending: boolean, + autoRefresh: boolean, + selectedLabels: Record, + scrollIndex: number, + horizontalZoom: [number, number] +) => { + const VALUE_OFFSET_FROM_COLUMN = 0.01; + const AUTO_REFRESH_SIZE = 300; + if (autoRefresh) data = data.slice(-AUTO_REFRESH_SIZE); // Slice to avoid lag while streaming const indexCurve = columns[0].columnOf.mnemonic; const indexUnit = columns[0].columnOf.unit; const isTimeLog = columns[0].type == ContentType.DateTime; @@ -73,7 +110,7 @@ const getChartOption = (data: any[], columns: ExportableContentTableColumn value.min - valueOffsetFromColumn, - max: (value: { min: number; max: number }) => (value.max - value.min < 1 ? value.min + 1 - valueOffsetFromColumn : dataColumns.length), + min: (value: { min: number; max: number }) => value.min - VALUE_OFFSET_FROM_COLUMN, + max: (value: { min: number; max: number }) => (value.max - value.min < 1 ? value.min + 1 - VALUE_OFFSET_FROM_COLUMN : dataColumns.length), minInterval: 1, maxInterval: 1, splitLine: { @@ -120,6 +159,7 @@ const getChartOption = (data: any[], columns: ExportableContentTableColumn { const index = Math.floor(param); if (index >= dataColumns.length) return ""; @@ -131,7 +171,7 @@ const getChartOption = (data: any[], columns: ExportableContentTableColumn value.min - 0.001, // The edge points can disappear, so make sure everything is shown max: (value: { max: number }) => value.max + 0.001, axisLabel: { @@ -142,29 +182,35 @@ const getChartOption = (data: any[], columns: ExportableContentTableColumn "" + }, { - orient: "vertical", - filterMode: "empty", - type: "inside" - }, - { - orient: "vertical", - filterMode: "empty", - type: "slider", - labelFormatter: () => "" - }, - { + id: "horizontalZoom", orient: "horizontal", filterMode: "empty", type: "slider", - startValue: 0, - endValue: 8, + start: horizontalZoom[0], + end: horizontalZoom[1], minValueSpan: 1, maxValueSpan: 12, labelFormatter: () => "" } ], animation: false, + backgroundColor: colors.ui.backgroundDefault, series: dataColumns.map((col, i) => { const minMaxValue = minMaxValues.find((v) => v.curve == col.columnOf.mnemonic); return { @@ -176,7 +222,7 @@ const getChartOption = (data: any[], columns: ExportableContentTableColumn { const { navigationState } = useContext(NavigationContext); - const { dispatchOperation } = useContext(OperationContext); + const { + operationState: { colors }, + dispatchOperation + } = useContext(OperationContext); const { selectedWell, selectedWellbore, selectedObject, selectedLogCurveInfo } = navigationState; const [columns, setColumns] = useState[]>([]); const [tableData, setTableData] = useState([]); const [isLoading, setIsLoading] = useState(true); const [selectedRows, setSelectedRows] = useState([]); const [showPlot, setShowPlot] = useState(false); + const [autoRefresh, setAutoRefresh] = useState(false); + const [refreshDelay, setRefreshDelay] = useState(DEFAULT_REFRESH_DELAY); + const [refreshFlag, setRefreshFlag] = useState(null); + const controller = useRef(new AbortController()); + const timer = useRef>(); const selectedLog = selectedObject as LogObject; const { exportData, exportOptions } = useExport(); - const getDeleteLogCurveValuesJob = (currentSelected: LogCurveInfoRow[], checkedContentItems: ContentTableRow[], selectedLog: LogObject) => { - const indexRanges = getIndexRanges(checkedContentItems, selectedLog); - const mnemonics = currentSelected.map((logCurveInfoRow) => logCurveInfoRow.mnemonic); + const onRowSelectionChange = useCallback((rows: CurveValueRow[]) => setSelectedRows(rows), []); + + useEffect(() => { + if (refreshFlag != null && autoRefresh) { + // Fetch new data (streaming) + const startIndex = getCurrentMaxIndex(); + const endIndex = getOffsetIndex(startIndex, TIME_INDEX_OFFSET, DEPTH_INDEX_OFFSET); + getLogData(startIndex, endIndex).then(() => { + timer.current = setTimeout(() => setRefreshFlag((flag) => !flag), refreshDelay * MILLIS_IN_SECOND); + }); + } + }, [refreshFlag]); + + useEffect(() => { + if (autoRefresh) { + setRefreshFlag((flag) => !flag); + } - const deleteLogCurveValuesJob: DeleteLogCurveValuesJob = { - logReference: toObjectReference(selectedLog), - mnemonics: mnemonics, - indexRanges: indexRanges + return () => { + if (timer.current) clearTimeout(timer.current); }; - return deleteLogCurveValuesJob; - }; + }, [autoRefresh]); + + const getDeleteLogCurveValuesJob = useCallback( + (currentSelected: LogCurveInfoRow[], checkedContentItems: ContentTableRow[], selectedLog: LogObject) => { + const indexRanges = getIndexRanges(checkedContentItems, selectedLog); + const mnemonics = currentSelected.map((logCurveInfoRow) => logCurveInfoRow.mnemonic); + + const deleteLogCurveValuesJob: DeleteLogCurveValuesJob = { + logReference: toObjectReference(selectedLog), + mnemonics: mnemonics, + indexRanges: indexRanges + }; + return deleteLogCurveValuesJob; + }, + [getIndexRanges, toObjectReference] + ); const exportSelectedIndexRange = useCallback(() => { const exportColumns = columns.map((column) => `${column.columnOf.mnemonic}[${column.columnOf.unit}]`).join(exportOptions.separator); @@ -63,12 +105,15 @@ export const CurveValuesView = (): React.ReactElement => { exportData(`${selectedWellbore.name}-${selectedLog.name}`, exportColumns, data); }, [columns, selectedRows]); - const onContextMenu = (event: React.MouseEvent, _: CurveValueRow, checkedContentItems: CurveValueRow[]) => { - const deleteLogCurveValuesJob = getDeleteLogCurveValuesJob(selectedLogCurveInfo, checkedContentItems, selectedLog); - const contextMenuProps = { deleteLogCurveValuesJob, dispatchOperation }; - const position = getContextMenuPosition(event); - dispatchOperation({ type: OperationType.DisplayContextMenu, payload: { component: , position } }); - }; + const onContextMenu = useCallback( + (event: React.MouseEvent, _: CurveValueRow, checkedContentItems: CurveValueRow[]) => { + const deleteLogCurveValuesJob = getDeleteLogCurveValuesJob(selectedLogCurveInfo, checkedContentItems, selectedLog); + const contextMenuProps = { deleteLogCurveValuesJob, dispatchOperation }; + const position = getContextMenuPosition(event); + dispatchOperation({ type: OperationType.DisplayContextMenu, payload: { component: , position } }); + }, + [selectedLogCurveInfo, selectedLog, getDeleteLogCurveValuesJob, dispatchOperation, getContextMenuPosition] + ); const updateColumns = (curveSpecifications: CurveSpecification[]) => { const isNewMnemonic = (mnemonic: string) => { @@ -90,66 +135,129 @@ export const CurveValuesView = (): React.ReactElement => { useEffect(() => { setTableData([]); setIsLoading(true); - const controller = new AbortController(); - - async function getLogData() { - const mnemonics = selectedLogCurveInfo.map((lci) => lci.mnemonic); - const startIndex = String(selectedLogCurveInfo[0].minIndex); - const endIndex = String(selectedLogCurveInfo[0].maxIndex); - - let completeData: CurveValueRow[] = []; - const logData: LogData = await LogObjectService.getLogData( - selectedWell.uid, - selectedWellbore.uid, - selectedLog.uid, - mnemonics, - completeData.length === 0, - startIndex, - endIndex, - controller.signal - ); - if (logData && logData.data) { - updateColumns(logData.curveSpecifications); - - const logDataRows = logData.data.map((data, index) => { - const row: CurveValueRow = { - id: completeData.length + index, - ...data - }; - return row; - }); - completeData = [...completeData, ...logDataRows]; - setTableData(completeData); - } - } + setAutoRefresh(false); if (selectedLogCurveInfo) { - getLogData() + getLogData(String(selectedLogCurveInfo[0].minIndex), String(selectedLogCurveInfo[0].maxIndex)) .catch(truncateAbortHandler) .then(() => setIsLoading(false)); } - return () => { - controller.abort(); - }; + return () => controller.current?.abort(); }, [selectedLogCurveInfo, selectedLog]); - const panelElements = [ - , - - ]; + const onClickAutoRefresh = () => { + if (autoRefresh) { + setAutoRefresh(false); + } else { + // First fetch the latest data, then start streaming + const isTimeLog = selectedLog.indexType === WITSML_INDEX_TYPE_DATE_TIME; + const currentEndIndex = isTimeLog ? selectedLog.endIndex : selectedLog.endIndex.replace(/[^0-9.]/g, ""); + const startIndex = getOffsetIndex(currentEndIndex, -TIME_INDEX_START_OFFSET, -DEPTH_INDEX_START_OFFSET); + const endIndex = getOffsetIndex(currentEndIndex, TIME_INDEX_OFFSET, DEPTH_INDEX_OFFSET); + getLogData(startIndex, endIndex).then(() => { + setAutoRefresh(true); + }); + } + }; + const getCurrentMinIndex = (): string => { + const indexCurve = selectedLog.indexCurve; + const minIndex = tableData.length > 0 && indexCurve in tableData[0] ? tableData[0][indexCurve as keyof LogDataRow] : selectedLogCurveInfo[0].minIndex; + return String(minIndex); + }; + + const getCurrentMaxIndex = (): string => { + const indexCurve = selectedLog.indexCurve; + const maxIndex = tableData.length > 0 && indexCurve in tableData[0] ? tableData.slice(-1)[0][indexCurve as keyof LogDataRow] : selectedLogCurveInfo[0].maxIndex; + return String(maxIndex); + }; + + const getOffsetIndex = (baseIndex: string, timeOffset: number, depthOffset: number) => { + const isTimeLog = selectedLog.indexType === WITSML_INDEX_TYPE_DATE_TIME; + const isDescending = selectedLog.direction == WITSML_LOG_ORDERTYPE_DECREASING; + if (isTimeLog) { + const endTime = new Date(baseIndex); + endTime.setSeconds(endTime.getSeconds() + (isDescending ? -timeOffset : timeOffset)); + return endTime.toISOString(); + } else { + return String(+baseIndex + (isDescending ? -depthOffset : depthOffset)); + } + }; + + const getLogData = async (startIndex: string, endIndex: string) => { + const mnemonics = selectedLogCurveInfo.map((lci) => lci.mnemonic); + const startIndexIsInclusive = !autoRefresh; + controller.current = new AbortController(); + + const logData: LogData = await LogObjectService.getLogData( + selectedWell.uid, + selectedWellbore.uid, + selectedLog.uid, + mnemonics, + startIndexIsInclusive, + startIndex, + endIndex, + controller.current.signal + ); + if (logData && logData.data) { + updateColumns(logData.curveSpecifications); + + const logDataRows = logData.data.map((data, index) => { + const row: CurveValueRow = { + id: index, + ...data + }; + return row; + }); + if (autoRefresh && tableData.length > 0) { + setTableData([...tableData, ...logDataRows]); + } else { + setTableData(logDataRows); + } + } + }; + + const panelElements = useMemo( + () => [ + , + + ], + [isLoading, exportSelectedDataPoints, exportSelectedIndexRange, selectedRows] + ); + + if (!selectedLog || !selectedLogCurveInfo) return null; return ( <> - + setShowPlot(!showPlot)} /> Show Plot + {selectedLog?.objectGrowing && ( + <> + + Stream + {autoRefresh && ( + setRefreshDelay(value)} + /> + )} + + )} {isLoading && } {!isLoading && !tableData.length && ( @@ -160,16 +268,23 @@ export const CurveValuesView = (): React.ReactElement => { {Boolean(columns.length) && Boolean(tableData.length) && (showPlot ? ( - + ) : ( setSelectedRows(rows as CurveValueRow[])} + onRowSelectionChange={onRowSelectionChange} onContextMenu={onContextMenu} data={tableData} checkableRows={true} panelElements={panelElements} stickyLeftColumns={2} + autoRefresh={autoRefresh} /> ))} diff --git a/Src/WitsmlExplorer.Frontend/components/ContentViews/EditInterval.tsx b/Src/WitsmlExplorer.Frontend/components/ContentViews/EditInterval.tsx index b4d20e912..cab94e206 100644 --- a/Src/WitsmlExplorer.Frontend/components/ContentViews/EditInterval.tsx +++ b/Src/WitsmlExplorer.Frontend/components/ContentViews/EditInterval.tsx @@ -1,31 +1,49 @@ import { Button, Icon, Label, TextField, Typography } from "@equinor/eds-core-react"; -import { Dispatch, SetStateAction, useContext, useState } from "react"; +import { isValid, parse } from "date-fns"; +import { format } from "date-fns-tz"; +import { Dispatch, SetStateAction, useContext, useEffect, useState } from "react"; import styled from "styled-components"; import NavigationContext from "../../contexts/navigationContext"; import NavigationType from "../../contexts/navigationType"; -import { Colors, colors, dark } from "../../styles/Colors"; -import { formatIndexValue } from "../Modals/SelectIndexToDisplayModal"; -import { format } from "date-fns-tz"; -import { isValid, parse } from "date-fns"; +import OperationContext from "../../contexts/operationContext"; import LogObject from "../../models/logObject"; +import { Colors, colors, dark } from "../../styles/Colors"; import { WITSML_INDEX_TYPE_DATE_TIME } from "../Constants"; -import OperationContext from "../../contexts/operationContext"; +import { formatIndexValue } from "../Modals/SelectIndexToDisplayModal"; -const EditInterval = (): React.ReactElement => { +interface EditIntervalProps { + disabled?: boolean; + overrideStartIndex?: string; + overrideEndIndex?: string; +} + +const EditInterval = (props: EditIntervalProps): React.ReactElement => { + const { disabled, overrideStartIndex, overrideEndIndex } = props; const { dispatchNavigation, navigationState } = useContext(NavigationContext); const { selectedObject, selectedLogCurveInfo } = navigationState; const selectedLog = selectedObject as LogObject; - const { minIndex, maxIndex } = selectedLogCurveInfo ? selectedLogCurveInfo[0] : { minIndex: null, maxIndex: null }; - const [startIndex, setStartIndex] = useState(String(minIndex)); - const [endIndex, setEndIndex] = useState(String(maxIndex)); - const [isEdited, setIsEdited] = useState(false); + const [startIndex, setStartIndex] = useState(null); + const [endIndex, setEndIndex] = useState(null); + const [isEdited, setIsEdited] = useState(false); const [isValidStart, setIsValidStart] = useState(true); const [isValidEnd, setIsValidEnd] = useState(true); const { operationState: { colors } } = useContext(OperationContext); + useEffect(() => { + const minIndex = selectedLogCurveInfo?.[0]?.minIndex; + const maxIndex = selectedLogCurveInfo?.[0]?.maxIndex; + setStartIndex(getParsedValue(String(minIndex))); + setEndIndex(getParsedValue(String(maxIndex))); + }, []); + + useEffect(() => { + if (overrideStartIndex) setStartIndex(getParsedValue(overrideStartIndex)); + if (overrideEndIndex) setEndIndex(getParsedValue(overrideEndIndex)); + }, [overrideStartIndex, overrideEndIndex]); + const submitEditInterval = () => { setIsEdited(false); const logCurveInfoWithUpdatedIndex = selectedLogCurveInfo.map((logCurveInfo) => { @@ -49,7 +67,7 @@ const EditInterval = (): React.ReactElement => { return selectedLog?.indexType === WITSML_INDEX_TYPE_DATE_TIME; }; - const getDefaultValue = (input: string) => { + const getParsedValue = (input: string) => { return isTimeCurve() ? (parseDate(input) ? format(new Date(input), dateTimeFormat) : "") : input; }; @@ -83,8 +101,10 @@ const EditInterval = (): React.ReactElement => { { props.disabled ? ` diff --git a/Src/WitsmlExplorer.Frontend/components/ContentViews/EditNumber.tsx b/Src/WitsmlExplorer.Frontend/components/ContentViews/EditNumber.tsx new file mode 100644 index 000000000..dd0d851dc --- /dev/null +++ b/Src/WitsmlExplorer.Frontend/components/ContentViews/EditNumber.tsx @@ -0,0 +1,80 @@ +import { Icon, Label, TextField } from "@equinor/eds-core-react"; +import { Tooltip } from "@material-ui/core"; +import { ChangeEvent, ReactElement, useContext, useState } from "react"; +import styled from "styled-components"; +import OperationContext from "../../contexts/operationContext"; +import { TooltipLayout } from "../ContextMenus/OptionsContextMenu"; +import { StyledButton } from "./EditInterval"; + +interface EditNumberProps { + label: string; + infoTooltip?: string; + infoIconColor?: string; + defaultValue?: number; + onSubmit: (value: number) => void; +} + +const EditNumber = (props: EditNumberProps): ReactElement => { + const { label, infoTooltip, infoIconColor, defaultValue = 0, onSubmit } = props; + const { + operationState: { colors } + } = useContext(OperationContext); + const [isEdited, setIsEdited] = useState(false); + const [value, setValue] = useState(String(defaultValue)); + + const submitEditNumber = () => { + setIsEdited(false); + onSubmit(parseFloat(value) || null); + }; + + const onInputChange = (e: ChangeEvent) => { + const newValue = e.target.value; + if (/^\d*\.?\d*$/.test(newValue)) { + setIsEdited(true); + setValue(newValue); + } + }; + + return ( + + + {infoTooltip}}> + + + ) : null + } + /> + + + + + ); +}; + +const EditNumberLayout = styled.div` + display: flex; + flex-direction: row; + gap: 0.25rem; + align-items: center; +`; + +const StyledLabel = styled(Label)` + align-items: center; + font-style: italic; +`; + +const StyledTextField = styled(TextField)` + div { + background-color: transparent; + } + min-width: 100px; + max-width: 100px; +`; + +export default EditNumber; diff --git a/Src/WitsmlExplorer.Frontend/components/ContentViews/table/ContentTable.tsx b/Src/WitsmlExplorer.Frontend/components/ContentViews/table/ContentTable.tsx index eb8a9ad32..a9ed78919 100644 --- a/Src/WitsmlExplorer.Frontend/components/ContentViews/table/ContentTable.tsx +++ b/Src/WitsmlExplorer.Frontend/components/ContentViews/table/ContentTable.tsx @@ -47,7 +47,7 @@ declare module "@tanstack/react-table" { } } -export const ContentTable = (contentTableProps: ContentTableProps): React.ReactElement => { +export const ContentTable = React.memo((contentTableProps: ContentTableProps): React.ReactElement => { const { columns, data, @@ -62,7 +62,8 @@ export const ContentTable = (contentTableProps: ContentTableProps): React.ReactE viewId, downloadToCsvFileName = null, onRowSelectionChange, - initiallySelectedRows = [] + initiallySelectedRows = [], + autoRefresh = false } = contentTableProps; const { operationState: { colors } @@ -147,6 +148,18 @@ export const ContentTable = (contentTableProps: ContentTableProps): React.ReactE columnVirtualizer.measure(); }, [columnSizing]); + useEffect(() => { + if (autoRefresh) { + tableContainerRef.current.scrollTop = tableContainerRef.current.scrollHeight; + } + }); + + const oldDataCountRef = React.useRef(null); + + useEffect(() => { + oldDataCountRef.current = autoRefresh ? data.length : null; + }, [autoRefresh]); + const columnItems = columnVirtualizer.getVirtualItems(); const [spaceLeft, spaceRight] = calculateHorizontalSpace(columnItems, columnVirtualizer.getTotalSize(), stickyLeftColumns); @@ -194,7 +207,7 @@ export const ContentTable = (contentTableProps: ContentTableProps): React.ReactE stickyLeftColumns={stickyLeftColumns} /> ) : null} -
+
oldDataCountRef.current ? "fading-row" : ""} > {columnItems.map((column) => { @@ -282,7 +296,8 @@ export const ContentTable = (contentTableProps: ContentTableProps): React.ReactE
); -}; +}); +ContentTable.displayName = "ContentTable"; const sortingIcons = { asc: , diff --git a/Src/WitsmlExplorer.Frontend/components/ContentViews/table/tableParts.ts b/Src/WitsmlExplorer.Frontend/components/ContentViews/table/tableParts.ts index a9ff68d10..3f8b67cbc 100644 --- a/Src/WitsmlExplorer.Frontend/components/ContentViews/table/tableParts.ts +++ b/Src/WitsmlExplorer.Frontend/components/ContentViews/table/tableParts.ts @@ -29,6 +29,7 @@ export interface ContentTableProps { viewId?: string; //id that will be used to save view settings to local storage, or null if should not save downloadToCsvFileName?: string; initiallySelectedRows?: ContentTableRow[]; + autoRefresh?: boolean; } export enum Order { diff --git a/Src/WitsmlExplorer.Frontend/components/ContextMenus/OptionsContextMenu.tsx b/Src/WitsmlExplorer.Frontend/components/ContextMenus/OptionsContextMenu.tsx index 7dfdacce6..bede48893 100644 --- a/Src/WitsmlExplorer.Frontend/components/ContextMenus/OptionsContextMenu.tsx +++ b/Src/WitsmlExplorer.Frontend/components/ContextMenus/OptionsContextMenu.tsx @@ -48,7 +48,7 @@ const OptionLayout = styled.div` gap: 8px; `; -const TooltipLayout = styled.div` +export const TooltipLayout = styled.div` white-space: pre-line; `; diff --git a/Src/WitsmlExplorer.Frontend/components/GlobalStyles.tsx b/Src/WitsmlExplorer.Frontend/components/GlobalStyles.tsx index 5b323bc0a..5d91cdb6b 100644 --- a/Src/WitsmlExplorer.Frontend/components/GlobalStyles.tsx +++ b/Src/WitsmlExplorer.Frontend/components/GlobalStyles.tsx @@ -149,6 +149,16 @@ const GlobalStyles = createGlobalStyle<{ colors: Colors }>` p[class*="Typography__StyledTypography"] { color:${(props) => props.colors.text.staticIconsDefault}; } + + @keyframes fadeToNormal { + from { + background-color: ${(props) => props.colors.interactive.successResting}; + } + } + + .fading-row { + animation: fadeToNormal 3s; + } `; export default GlobalStyles; diff --git a/Tests/WitsmlExplorer.Api.Tests/Workers/AnalyzeGapWorkerTests.cs b/Tests/WitsmlExplorer.Api.Tests/Workers/AnalyzeGapWorkerTests.cs index c0d92be29..0fc4f0925 100644 --- a/Tests/WitsmlExplorer.Api.Tests/Workers/AnalyzeGapWorkerTests.cs +++ b/Tests/WitsmlExplorer.Api.Tests/Workers/AnalyzeGapWorkerTests.cs @@ -1,19 +1,25 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + using Moq; + using Serilog; + using Witsml; using Witsml.Data; using Witsml.Data.Curves; using Witsml.Extensions; using Witsml.ServiceReference; + using WitsmlExplorer.Api.Jobs; using WitsmlExplorer.Api.Models; using WitsmlExplorer.Api.Models.Reports; using WitsmlExplorer.Api.Services; using WitsmlExplorer.Api.Workers; + using Xunit; namespace WitsmlExplorer.Api.Tests.Workers; @@ -28,20 +34,20 @@ public class AnalyzeGapWorkerTests private const string WellboreUid = "wellboreUid"; private const string CurveIndex = "Depth"; private const string MnemonicList = "Depth,BPOS,SPM1"; - + private const string DepthDataRow1 = "51,88,100"; private const string DepthDataRow2 = "52,88,100"; private const string DepthDataRow3 = "53,88,100"; - + private const string DepthDataWithGapsRow1 = "51,88,100"; private const string DepthDataWithGapsRow2 = "52,,110"; private const string DepthDataWithGapsRow3 = "53,92,"; private const string DepthDataWithGapsRow4 = "54,94,130"; - + private const string TimeDataRow1 = "2023-04-19T00:00:00Z,88,100"; private const string TimeDataRow2 = "2023-04-19T00:00:01Z,88,100"; private const string TimeDataRow3 = "2023-04-19T00:00:02Z,88,100"; - + private const string TimeDataWithGapsRow1 = "2023-04-19T00:00:00Z,88,100"; private const string TimeDataWithGapsRow2 = "2023-04-19T00:00:01Z,,110"; private const string TimeDataWithGapsRow3 = "2023-04-19T00:00:02Z,92,"; @@ -49,7 +55,7 @@ public class AnalyzeGapWorkerTests private readonly Mock _witsmlClient; private readonly AnalyzeGapWorker _worker; - + public AnalyzeGapWorkerTests() { Mock witsmlClientProvider = new(); @@ -60,7 +66,7 @@ public AnalyzeGapWorkerTests() ILogger logger = loggerFactory.CreateLogger(); _worker = new AnalyzeGapWorker(logger, witsmlClientProvider.Object); } - + [Theory] [InlineData(true)] [InlineData(false)] @@ -80,10 +86,10 @@ public async Task AnalyzeGap_Depth_CorrectData_WithoutGaps_IsValid(bool isIncrea Assert.Equal(WellboreUid, report.LogReference.WellboreUid); Assert.Empty(report.ReportItems); } - + [Theory] [InlineData(true)] - [InlineData(false)] + [InlineData(false)] public async Task AnalyzeGap_Depth_CorrectData_WithGaps_IsValid(bool isIncreasing) { AnalyzeGapJob job = GetAnalyzeGapJobTemplate(WitsmlLog.WITSML_INDEX_TYPE_MD); @@ -100,7 +106,7 @@ public async Task AnalyzeGap_Depth_CorrectData_WithGaps_IsValid(bool isIncreasin Assert.Equal(WellboreUid, report.LogReference.WellboreUid); Assert.NotEmpty(report.ReportItems); } - + [Theory] [InlineData(true)] [InlineData(false)] @@ -120,7 +126,7 @@ public async Task AnalyzeGap_Time_CorrectData_WithoutGaps_IsValid(bool isIncreas Assert.Equal(WellboreUid, report.LogReference.WellboreUid); Assert.Empty(report.ReportItems); } - + [Theory] [InlineData(true)] [InlineData(false)] @@ -140,7 +146,7 @@ public async Task AnalyzeGap_Time_CorrectData_WithGaps_IsValid(bool isIncreasing Assert.Equal(WellboreUid, report.LogReference.WellboreUid); Assert.NotEmpty(report.ReportItems); } - + [Fact] public async Task AnalyzeGap_Execute_Error_NoData() { @@ -148,7 +154,7 @@ public async Task AnalyzeGap_Execute_Error_NoData() JobInfo jobInfo = new(); job.JobInfo = jobInfo; bool isSuccess = false; - + _witsmlClient.Setup(client => client.GetFromStoreAsync(It.IsAny(), It.IsAny())) .Returns((WitsmlLogs logs, OptionsIn options) => Task.FromResult(new WitsmlLogs())); @@ -156,7 +162,7 @@ public async Task AnalyzeGap_Execute_Error_NoData() (WorkerResult Result, RefreshAction) analyzeGapTask = await _worker.Execute(job); Assert.Equal(isSuccess, analyzeGapTask.Result.IsSuccess); } - + private static void SetupClient(Mock witsmlClient, string indexType, bool isLogDataWithGaps, bool isIncreasing) { bool isDepthLog = indexType == WitsmlLog.WITSML_INDEX_TYPE_MD; @@ -170,7 +176,7 @@ private static void SetupClient(Mock witsmlClient, string indexTy .Returns((WitsmlLogs logs, OptionsIn options) => isLogDataWithGaps ? Task.FromResult(GetTestWitsmlLogs(logDataWithGaps, isDepthLog, isIncreasing)) : Task.FromResult(GetTestWitsmlLogs(logDataWithoutGaps, isDepthLog, isIncreasing))); - + witsmlClient.InSequence(mockSequence) .Setup(client => client.GetFromStoreAsync(It.IsAny(), It.IsAny())) @@ -183,7 +189,7 @@ private static void SetupClient(Mock witsmlClient, string indexTy client.GetFromStoreAsync(It.IsAny(), It.IsAny())) .Returns(Task.FromResult(new WitsmlLogs() { Logs = new List() })); } - + private static WitsmlLogs GetTestWitsmlLogs(WitsmlLogData logData, bool isDepthLog, bool isIncreasing) { logData.Data = isIncreasing