diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json new file mode 100644 index 00000000..875f0ab5 --- /dev/null +++ b/cypress/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["cypress", "node"] + }, + "include": ["./**/*.ts", "./**/*.tsx"] +} diff --git a/package.json b/package.json index a3bffbb3..7314510e 100644 --- a/package.json +++ b/package.json @@ -74,8 +74,8 @@ "build": "vite build", "build:standalone": "cross-env BUNDLE_REACT=true vite build", "preview": "vite preview --port 5001", - "test": "react-scripts test --testPathIgnorePatterns=src/h5web/ --env=jsdom --coverage --watchAll=false", - "test:watch": "react-scripts test --env=jsdom --watch", + "test": "vitest run --coverage", + "test:watch": "vitest", "serve:build": "yarn build && vite preview --port 5001", "serve:backend": "node server/server.js", "analyze": "yarn build && source-map-explorer build/main.*", @@ -100,15 +100,6 @@ "not ie <= 11", "not op_mini all" ], - "jest": { - "collectCoverageFrom": [ - "src/**/*.{tsx,js,jsx}", - "!src/index.tsx", - "!src/serviceWorker.ts", - "!src/setupTests.js", - "!src/testbed/**/*" - ] - }, "devDependencies": { "@cypress/react": "^7.0.3", "@cypress/webpack-dev-server": "^3.11.0", @@ -116,6 +107,7 @@ "@typescript-eslint/eslint-plugin": "8.16.0", "@typescript-eslint/parser": "8.16.0", "@vitejs/plugin-react": "^4.3.4", + "@vitest/coverage-v8": "^3.2.4", "cross-env": "^7.0.3", "cypress": "^13.16.0", "cypress-real-events": "^1.13.0", @@ -129,7 +121,8 @@ "lint-staged": "15.2.10", "prettier": "3.4.1", "serve": "14.2.4", - "vite": "^6.4.2" + "vite": "^6.4.2", + "vitest": "^3.2.4" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/src/components/experimentViewer/ExperimentSearch.tsx b/src/components/experimentViewer/ExperimentSearch.tsx index 49534eac..46fa31c7 100644 --- a/src/components/experimentViewer/ExperimentSearch.tsx +++ b/src/components/experimentViewer/ExperimentSearch.tsx @@ -9,6 +9,7 @@ import { ToggleButtonGroup, ToggleButton, } from '@mui/material'; +import { alpha, useTheme } from '@mui/material/styles'; import SearchIcon from '@mui/icons-material/Search'; import ClearIcon from '@mui/icons-material/Clear'; import { instruments } from '../../lib/instrumentData'; @@ -30,6 +31,7 @@ const ExperimentSearch: React.FC = ({ isLoading = false, isSearchActive = false, }): JSX.Element => { + const theme = useTheme(); const [selectedInstrument, setSelectedInstrument] = useState(initialInstrument || null); const [experimentNumber, setExperimentNumber] = useState( initialExperimentNumber ? initialExperimentNumber.toString() : '' @@ -55,7 +57,6 @@ const ExperimentSearch: React.FC = ({ // Get instrument names for autocomplete const instrumentNames = instruments.map((instrument) => instrument.name); - return ( @@ -69,12 +70,12 @@ const ExperimentSearch: React.FC = ({ options={instrumentNames} sx={{ width: 200 }} size="small" - renderInput={(params) => } + renderInput={(params) => } disabled={isLoading} /> setExperimentNumber(e.target.value)} @@ -82,7 +83,6 @@ const ExperimentSearch: React.FC = ({ type="number" size="small" sx={{ width: 175 }} - placeholder="Enter number" disabled={isLoading} /> @@ -102,7 +102,22 @@ const ExperimentSearch: React.FC = ({ startIcon={} onClick={handleClear} disabled={isLoading} - sx={{ height: 40 }} + sx={{ + height: 40, + color: theme.palette.mode === 'dark' ? theme.palette.common.white : theme.palette.text.primary, + borderColor: + theme.palette.mode === 'dark' + ? alpha(theme.palette.common.white, 0.28) + : alpha(theme.palette.text.primary, 0.18), + bgcolor: + theme.palette.mode === 'dark' + ? alpha(theme.palette.common.white, 0.06) + : alpha(theme.palette.background.paper, 0.95), + '&:hover': { + borderColor: theme.palette.primary.main, + bgcolor: alpha(theme.palette.primary.main, theme.palette.mode === 'dark' ? 0.18 : 0.08), + }, + }} > Clear @@ -121,6 +136,18 @@ const ExperimentSearch: React.FC = ({ onChange={(_, newLimit) => newLimit !== null && setResultLimit(newLimit)} size="small" disabled={isLoading} + sx={{ + borderRadius: 0, + '& .MuiToggleButtonGroup-grouped': { + borderRadius: 0, + width: 56, + minWidth: 56, + height: 36, + px: 0, + justifyContent: 'center', + fontVariantNumeric: 'tabular-nums', + }, + }} > 10 25 @@ -128,16 +155,6 @@ const ExperimentSearch: React.FC = ({ 100 - - {isSearchActive && (selectedInstrument || experimentNumber) && ( - - - Searching for: {selectedInstrument && Instrument: {selectedInstrument}} - {selectedInstrument && experimentNumber && ' | '} - {experimentNumber && Experiment: {experimentNumber}} - - - )} ); }; diff --git a/src/components/experimentViewer/FileCard.tsx b/src/components/experimentViewer/FileCard.tsx index 57380d89..256343cb 100644 --- a/src/components/experimentViewer/FileCard.tsx +++ b/src/components/experimentViewer/FileCard.tsx @@ -196,7 +196,7 @@ const FileCard: React.FC = ({ {/* Mode toggle */} - Slice Selection (2D → 1D) + Slice selection (2D → 1D) = ({ > - File Tree + File tree {/* Settings */} diff --git a/src/components/experimentViewer/Graph.test.tsx b/src/components/experimentViewer/Graph.test.tsx new file mode 100644 index 00000000..a97c845a --- /dev/null +++ b/src/components/experimentViewer/Graph.test.tsx @@ -0,0 +1,252 @@ +import React, { act } from 'react'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { ThemeProvider, createTheme } from '@mui/material/styles'; +import { createRoot, type Root } from 'react-dom/client'; + +import type { LinePlotData } from '../../lib/types'; +import PlotViewer from './Graph'; + +type MockLineVisProps = { + abscissaParams?: { + label?: string; + scaleType?: string; + }; + curveType?: string; + domain?: [number, number]; + interpolation?: string; +}; + +const mockLineVis = vi.fn(); + +vi.mock('@h5web/lib', () => { + const mockBuildDomain = ( + array: { data: ArrayLike }, + _scaleType?: string, + errors?: { data: ArrayLike } + ): [number, number] | undefined => { + let min = Number.POSITIVE_INFINITY; + let max = Number.NEGATIVE_INFINITY; + + Array.from(array.data).forEach((value, index) => { + if (!Number.isFinite(value)) { + return; + } + + const error = errors ? Array.from(errors.data)[index] : undefined; + const hasFiniteError = typeof error === 'number' && Number.isFinite(error); + const low = hasFiniteError ? value - error : value; + const high = hasFiniteError ? value + error : value; + + min = Math.min(min, low); + max = Math.max(max, high); + }); + + return Number.isFinite(min) && Number.isFinite(max) ? [min, max] : undefined; + }; + + const ScaleType = { + Linear: 'linear', + Log: 'log', + SymLog: 'symlog', + }; + + const CurveType = { + LineOnly: 'OnlyLine', + GlyphsOnly: 'OnlyGlyphs', + LineAndGlyphs: 'LineAndGlyphs', + }; + + const Interpolation = { + Linear: 'Linear', + Constant: 'Constant', + }; + + return { + CurveType, + DomainWidget: ({ + onCustomDomainChange, + }: { + onCustomDomainChange: (domain: [number | null, number | null]) => void; + }) => ( +
+ + +
+ ), + getDomain: mockBuildDomain, + Interpolation, + LineVis: (props: MockLineVisProps) => { + mockLineVis(props); + return
; + }, + Menu: ({ label, children }: { label: string; children: import('react').ReactNode }) => ( +
+ {label} + {children} +
+ ), + RadioGroup: ({ + label, + options, + optionsLabels, + value, + onChange, + }: { + label?: string; + options: string[]; + optionsLabels?: Record; + value: string; + onChange: (nextValue: string) => void; + }) => ( +
+ {label && {label}} + {options.map((option) => ( + + ))} +
+ ), + ScaleSelector: ({ + label, + value, + options, + onScaleChange, + }: { + label: string; + value: string; + options: string[]; + onScaleChange: (nextValue: string) => void; + }) => ( + + ), + ScaleType, + Separator: () => , + ToggleBtn: ({ label, value, onToggle }: { label: string; value?: boolean; onToggle: () => void }) => ( + + ), + Toolbar: ({ children }: { children: import('react').ReactNode }) =>
{children}
, + useSafeDomain: (domain: [number, number]) => [domain], + }; +}); + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const linePlotData: LinePlotData[] = [ + { + filename: 'primary-file', + data: [1, 2, 4], + errors: [0.1, 0.2, 0.3], + }, + { + filename: 'secondary-file', + data: [1.5, 3], + errors: [0.05, 0.15], + }, +]; + +let mountedContainer: HTMLDivElement | null = null; +let mountedRoot: Root | null = null; + +function renderPlotViewer(showErrors = false, onShowErrorsChange = vi.fn()): HTMLDivElement { + mountedContainer = document.createElement('div'); + document.body.appendChild(mountedContainer); + mountedRoot = createRoot(mountedContainer); + + act(() => { + mountedRoot!.render( + + + + ); + }); + + return mountedContainer; +} + +function getLastLineVisProps(): MockLineVisProps { + return mockLineVis.mock.calls[mockLineVis.mock.calls.length - 1][0] as MockLineVisProps; +} + +function getButton(container: HTMLElement, matcher: string | RegExp): HTMLButtonElement { + const button = Array.from(container.querySelectorAll('button')).find((candidate) => { + const label = candidate.textContent || ''; + return typeof matcher === 'string' ? label === matcher : matcher.test(label); + }); + + if (!button) { + throw new Error(`Button not found: ${matcher.toString()}`); + } + + return button; +} + +function clickButton(container: HTMLElement, matcher: string | RegExp): void { + act(() => { + getButton(container, matcher).dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); +} + +beforeEach(() => { + mockLineVis.mockClear(); +}); + +afterEach(() => { + if (mountedRoot) { + act(() => { + mountedRoot!.unmount(); + }); + } + + mountedRoot = null; + mountedContainer?.remove(); + mountedContainer = null; +}); + +test('renders the extended 1D toolbar and labels the x axis as Index', () => { + const container = renderPlotViewer(); + + expect(container.textContent).toContain('Aspect'); + expect(container.textContent).toContain('Curve type'); + expect(container.textContent).toContain('Interpolation'); + expect(getLastLineVisProps().abscissaParams).toEqual( + expect.objectContaining({ + label: 'Index', + scaleType: 'linear', + }) + ); +}); + +test('updates LineVis props when the y range and style controls change', () => { + const onShowErrorsChange = vi.fn(); + + const container = renderPlotViewer(false, onShowErrorsChange); + + expect(getLastLineVisProps().domain).toEqual([1, 4]); + + clickButton(container, 'Set y min'); + expect(getLastLineVisProps().domain).toEqual([2.5, 4]); + + clickButton(container, 'Line + Points'); + expect(getLastLineVisProps().curveType).toBe('LineAndGlyphs'); + + clickButton(container, 'Constant'); + expect(getLastLineVisProps().interpolation).toBe('Constant'); + + clickButton(container, /X scale:/i); + expect(getLastLineVisProps().abscissaParams?.scaleType).toBe('log'); + + clickButton(container, 'Error bars'); + expect(onShowErrorsChange).toHaveBeenCalledWith(true); + + clickButton(container, 'Reset y range'); + expect(getLastLineVisProps().domain).toEqual([1, 4]); +}); diff --git a/src/components/experimentViewer/Graph.tsx b/src/components/experimentViewer/Graph.tsx index d91ea223..bfa39d18 100644 --- a/src/components/experimentViewer/Graph.tsx +++ b/src/components/experimentViewer/Graph.tsx @@ -1,8 +1,42 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import ndarray from 'ndarray'; -import { getDomain, LineVis, ScaleType, ScaleSelector, Separator, ToggleBtn, Toolbar } from '@h5web/lib'; +import { + CurveType, + DomainWidget, + getDomain, + Interpolation, + LineVis, + Menu, + RadioGroup, + ScaleSelector, + ScaleType, + Separator, + ToggleBtn, + Toolbar, + useSafeDomain, +} from '@h5web/lib'; +import type { AxisScaleType, CustomDomain, Domain } from '@h5web/lib'; import type { LinePlotData } from '../../lib/types'; -import { Box, Paper, Typography, useTheme } from '@mui/material'; +import { Box, Paper, Typography } from '@mui/material'; +import { alpha, useTheme } from '@mui/material/styles'; +import { MdAutoGraph, MdGridOn } from 'react-icons/md'; +import ErrorsIcon from '../../h5web/packages/app/src/vis-packs/core/line/ErrorsIcon'; +import resetZoomButtonStyles from '../../h5web/packages/lib/src/toolbar/floating/ResetZoomButton.module.css'; +import tooltipStyles from '../../h5web/packages/lib/src/vis/shared/Tooltip.module.css'; + +const DEFAULT_DOMAIN: Domain = [0.1, 1]; +const AXIS_SCALE_OPTIONS: AxisScaleType[] = [ScaleType.Linear, ScaleType.Log, ScaleType.SymLog]; + +const CURVE_TYPE_LABELS: Record = { + [CurveType.LineOnly]: 'Line', + [CurveType.GlyphsOnly]: 'Points', + [CurveType.LineAndGlyphs]: 'Line + Points', +}; + +const INTERPOLATION_LABELS: Record = { + [Interpolation.Linear]: 'Linear', + [Interpolation.Constant]: 'Constant', +}; interface PlotViewerProps { linePlotData: LinePlotData[]; @@ -12,84 +46,62 @@ interface PlotViewerProps { const PlotViewer: React.FC = ({ linePlotData, showErrors, onShowErrorsChange }): JSX.Element => { const theme = useTheme(); + const hasData = linePlotData.length > 0; // State for line plot controls const [lineShowGrid, setLineShowGrid] = useState(true); - const [xScaleType, setXScaleType] = useState(ScaleType.Linear); - const [yScaleType, setYScaleType] = useState(ScaleType.Linear); - - // Handle empty state - if (linePlotData.length === 0) { - return ( - - - - No data selected - - - Select files from the left panel to plot - - - - ); - } + const [xScaleType, setXScaleType] = useState(ScaleType.Linear); + const [yScaleType, setYScaleType] = useState(ScaleType.Linear); + const [customYDomain, setCustomYDomain] = useState([null, null]); + const [curveType, setCurveType] = useState(CurveType.LineOnly); + const [interpolation, setInterpolation] = useState(Interpolation.Linear); // Sort data by domain size (largest first) to ensure the file with the biggest domain is primary // This prevents crashes when auxiliary data has a larger domain than primary - const sortedData = [...linePlotData].sort((a, b) => b.data.length - a.data.length); + const sortedData = hasData ? [...linePlotData].sort((a, b) => b.data.length - a.data.length) : []; // Render line plots - largest domain as primary, rest as auxiliaries const primaryData = sortedData[0]; - - const primaryArray = ndarray(primaryData.data, [primaryData.data.length]); - - // Generate abscissas (x-values) for primary data based on its length - const primaryAbscissas = Float32Array.from({ length: primaryData.data.length }, (_, i) => i); + const primaryLength = primaryData?.data.length ?? DEFAULT_DOMAIN.length; + const primaryArray = ndarray(primaryData?.data ?? DEFAULT_DOMAIN, [primaryLength]); // Create error array if available and showErrors is true const primaryErrorsArray = - showErrors && primaryData.errors ? ndarray(primaryData.errors, [primaryData.errors.length]) : undefined; + showErrors && primaryData?.errors ? ndarray(primaryData.errors, [primaryData.errors.length]) : undefined; // Create auxiliaries for additional lines with error bars // Pad auxiliary arrays with NaN to match primary length (NaN values won't render) - const primaryLength = primaryData.data.length; - const auxiliaries = sortedData.slice(1).map((data) => { - // Pad data array with NaN if shorter than primary - const paddedData = new Float32Array(primaryLength); - paddedData.set(data.data); - if (data.data.length < primaryLength) { - paddedData.fill(NaN, data.data.length); - } + const auxiliaries = primaryData + ? sortedData.slice(1).map((data) => { + // Pad data array with NaN if shorter than primary + const paddedData = new Float32Array(primaryLength); + paddedData.set(data.data); + if (data.data.length < primaryLength) { + paddedData.fill(NaN, data.data.length); + } - // Pad errors array with NaN if available and shorter than primary - let paddedErrors: Float32Array | undefined; - if (showErrors && data.errors) { - paddedErrors = new Float32Array(primaryLength); - paddedErrors.set(data.errors); - if (data.errors.length < primaryLength) { - paddedErrors.fill(NaN, data.errors.length); - } - } + // Pad errors array with NaN if available and shorter than primary + let paddedErrors: Float32Array | undefined; + if (showErrors && data.errors) { + paddedErrors = new Float32Array(primaryLength); + paddedErrors.set(data.errors); + if (data.errors.length < primaryLength) { + paddedErrors.fill(NaN, data.errors.length); + } + } - return { - array: ndarray(paddedData, [primaryLength]), - label: data.filename, - color: data.color, - errors: paddedErrors ? ndarray(paddedErrors, [primaryLength]) : undefined, - }; - }); + return { + array: ndarray(paddedData, [primaryLength]), + label: data.filename, + color: data.color, + errors: paddedErrors ? ndarray(paddedErrors, [primaryLength]) : undefined, + }; + }) + : []; // Calculate combined Y domain across all data to ensure proper graph sizing // Start with primary data domain - let combinedDomain = getDomain(primaryArray, yScaleType, primaryErrorsArray); + let combinedDomain = primaryData ? getDomain(primaryArray, yScaleType, primaryErrorsArray) : DEFAULT_DOMAIN; // Extend domain to include all auxiliaries for (const aux of auxiliaries) { @@ -99,26 +111,151 @@ const PlotViewer: React.FC = ({ linePlotData, showErrors, onSho } } - const domain = combinedDomain; + const autoDomain = combinedDomain || DEFAULT_DOMAIN; + const effectiveYDomain = useMemo( + () => [customYDomain[0] ?? autoDomain[0], customYDomain[1] ?? autoDomain[1]], + [autoDomain, customYDomain] + ); + const [safeYDomain] = useSafeDomain(effectiveYDomain, autoDomain, yScaleType); - return ( - - + + + No data selected + + + Select files from the left panel to plot + + + + ); + } + + const tooltipBackground = + theme.palette.mode === 'dark' ? alpha(theme.palette.grey[900], 0.94) : alpha(theme.palette.background.paper, 0.97); + const tooltipBorderColor = + theme.palette.mode === 'dark' ? alpha(theme.palette.common.white, 0.12) : alpha(theme.palette.text.primary, 0.12); + const floatingControlBackground = + theme.palette.mode === 'dark' ? alpha(theme.palette.grey[800], 0.96) : alpha(theme.palette.background.paper, 0.9); + const floatingControlHoverBackground = + theme.palette.mode === 'dark' ? alpha(theme.palette.grey[700], 0.98) : alpha(theme.palette.background.paper, 0.98); + const floatingControlTextColor = + theme.palette.mode === 'dark' ? theme.palette.common.white : theme.palette.text.primary; + const floatingControlBorderColor = + theme.palette.mode === 'dark' ? alpha(theme.palette.common.white, 0.24) : alpha(theme.palette.text.primary, 0.12); + const floatingControlHoverBorderColor = + theme.palette.mode === 'dark' ? alpha(theme.palette.common.white, 0.34) : alpha(theme.palette.text.primary, 0.18); + + const toolbarThemeTokens = { + color: theme.palette.text.primary, + backgroundColor: theme.palette.background.paper, + '--h5w-btn-hover--bgColor': theme.palette.action.hover, + '--h5w-btn-hover--shadowColor': alpha(theme.palette.text.primary, theme.palette.mode === 'dark' ? 0.28 : 0.16), + '--h5w-btnRaised--bgColor': theme.palette.background.default, + '--h5w-btnRaised--shadowColor': alpha(theme.palette.text.primary, theme.palette.mode === 'dark' ? 0.3 : 0.18), + '--h5w-btnRaised-hover--shadowColor': alpha( + theme.palette.text.primary, + theme.palette.mode === 'dark' ? 0.42 : 0.24 + ), + '--h5w-btnPressed--bgColor': alpha(theme.palette.primary.main, theme.palette.mode === 'dark' ? 0.32 : 0.18), + '--h5w-btnPressed--shadowColor': alpha(theme.palette.primary.main, theme.palette.mode === 'dark' ? 0.5 : 0.32), + '--h5w-btnPressed-hover--shadowColor': alpha( + theme.palette.primary.main, + theme.palette.mode === 'dark' ? 0.62 : 0.4 + ), + '--h5w-toolbar--bgColor': theme.palette.background.paper, + '--h5w-toolbar-label--color': theme.palette.text.secondary, + '--h5w-toolbar-separator--color': alpha(theme.palette.text.primary, theme.palette.mode === 'dark' ? 0.2 : 0.14), + '--h5w-toolbar-popup--bgColor': theme.palette.background.paper, + '--h5w-toolbar-input-focus--shadowColor': theme.palette.primary.main, + '--h5w-selector-arrowIcon--color': theme.palette.text.secondary, + '--h5w-selector-label--color': theme.palette.text.secondary, + '--h5w-selector-groupLabel--color': theme.palette.text.secondary, + '--h5w-selector-menu--bgColor': theme.palette.background.paper, + '--h5w-selector-option-hover--bgColor': theme.palette.action.hover, + '--h5w-selector-option-selected--bgColor': alpha( + theme.palette.primary.main, + theme.palette.mode === 'dark' ? 0.3 : 0.16 + ), + '--h5w-selector-option-focus--outlineColor': theme.palette.primary.main, + '--h5w-domainWidget-popup--bgColor': theme.palette.background.paper, + '--h5w-domainControls--colorAlt': theme.palette.text.primary, + '--h5w-domainControls-boundInput--shadowColor': alpha( + theme.palette.text.primary, + theme.palette.mode === 'dark' ? 0.3 : 0.16 + ), + '--h5w-domainControls-boundInput-focus--shadowColor': theme.palette.primary.main, + '--h5w-domainControls-boundInput-editing--bgColor': theme.palette.background.default, + '--h5w-domainControls-boundInput-editing--borderColor': theme.palette.primary.main, + '--h5w-error--color': theme.palette.error.main, + }; + + const lineVisStyles = { + '--h5w-tooltip-guide--color': alpha(theme.palette.primary.main, theme.palette.mode === 'dark' ? 0.82 : 0.6), + '--h5w-tooltip-guide--opacity': theme.palette.mode === 'dark' ? 0.9 : 0.72, + [`& .${tooltipStyles.tooltip}`]: { + backgroundColor: tooltipBackground, + color: theme.palette.text.primary, + border: `1px solid ${tooltipBorderColor}`, + boxShadow: `0 0 0 1px ${tooltipBorderColor}, 0 12px 28px ${alpha( + theme.palette.common.black, + theme.palette.mode === 'dark' ? 0.5 : 0.16 + )}`, + backdropFilter: 'blur(8px)', + }, + [`& .${resetZoomButtonStyles.btnLike}`]: { + color: floatingControlTextColor, + backgroundColor: floatingControlBackground, + border: `1px solid ${floatingControlBorderColor}`, + boxShadow: `0 0 0 1px ${floatingControlBorderColor}, 0 10px 24px ${alpha( + theme.palette.common.black, + theme.palette.mode === 'dark' ? 0.42 : 0.14 + )}`, + backdropFilter: 'blur(8px)', + fontWeight: 500, + }, + [`& .${resetZoomButtonStyles.btn}:hover > .${resetZoomButtonStyles.btnLike}, & .${resetZoomButtonStyles.btn}:focus-visible > .${resetZoomButtonStyles.btnLike}`]: + { + backgroundColor: floatingControlHoverBackground, + borderColor: floatingControlHoverBorderColor, + boxShadow: `0 0 0 1px ${floatingControlHoverBorderColor}, 0 12px 28px ${alpha( + theme.palette.common.black, + theme.palette.mode === 'dark' ? 0.5 : 0.16 + )}`, + }, + [`& .${resetZoomButtonStyles.btn}:focus-visible`]: { + outline: 'none', + }, + }; + + return ( + + + + {/* Y-axis scale selector */} @@ -126,26 +263,60 @@ const PlotViewer: React.FC = ({ linePlotData, showErrors, onSho - setLineShowGrid(!lineShowGrid)} /> + setLineShowGrid(!lineShowGrid)} + /> + + onShowErrorsChange(!showErrors)} + /> - onShowErrorsChange(!showErrors)} /> + + + + - 0 ? auxiliaries : undefined} - showGrid={lineShowGrid} - scaleType={yScaleType} - abscissaParams={{ scaleType: xScaleType, value: primaryAbscissas }} - /> + + 0 ? auxiliaries : undefined} + showGrid={lineShowGrid} + scaleType={yScaleType} + curveType={curveType} + interpolation={interpolation} + abscissaParams={{ label: 'Index', scaleType: xScaleType }} + /> + ); }; diff --git a/src/components/experimentViewer/Viewer2D.tsx b/src/components/experimentViewer/Viewer2D.tsx index e6686e9c..0c70cafd 100644 --- a/src/components/experimentViewer/Viewer2D.tsx +++ b/src/components/experimentViewer/Viewer2D.tsx @@ -39,7 +39,7 @@ const Viewer2D: React.FC = ({ filepath }): JSX.Element => { Select a file to view 2D data - Choose a file from the File Tree to visualize HDF5 datasets in 2D + Choose a file from the File tree to visualize HDF5 datasets in 2D diff --git a/src/components/experimentViewer/ViewerTabs.tsx b/src/components/experimentViewer/ViewerTabs.tsx index 62011481..f14fba52 100644 --- a/src/components/experimentViewer/ViewerTabs.tsx +++ b/src/components/experimentViewer/ViewerTabs.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Tabs, Tab, Box } from '@mui/material'; +import { alpha, useTheme } from '@mui/material/styles'; import ShowChartIcon from '@mui/icons-material/ShowChart'; import GridOnIcon from '@mui/icons-material/GridOn'; @@ -9,11 +10,95 @@ interface ViewerTabsProps { } const ViewerTabs: React.FC = ({ activeTab, onTabChange }): JSX.Element => { + const theme = useTheme(); + return ( - - onTabChange(newTab)} variant="fullWidth"> - } iconPosition="start" /> - } iconPosition="start" /> + + onTabChange(newTab)} + variant="fullWidth" + sx={{ + minHeight: 0, + p: 0.5, + borderRadius: 2, + bgcolor: alpha(theme.palette.text.primary, theme.palette.mode === 'dark' ? 0.12 : 0.05), + '& .MuiTabs-flexContainer': { + gap: 1, + }, + '& .MuiTabs-indicator': { + display: 'none', + }, + }} + > + } + iconPosition="start" + sx={{ + minHeight: 42, + borderRadius: 1.5, + textTransform: 'none', + fontWeight: 600, + color: 'text.secondary', + transition: theme.transitions.create(['background-color', 'color', 'box-shadow'], { + duration: theme.transitions.duration.shorter, + }), + '& .MuiSvgIcon-root': { + color: 'inherit', + }, + '&:hover': { + bgcolor: alpha(theme.palette.text.primary, theme.palette.mode === 'dark' ? 0.1 : 0.06), + }, + '&.Mui-selected': { + color: theme.palette.primary.contrastText, + bgcolor: theme.palette.primary.main, + boxShadow: `0 0 0 1px ${alpha(theme.palette.primary.main, 0.24)}, 0 6px 16px ${alpha( + theme.palette.primary.main, + theme.palette.mode === 'dark' ? 0.4 : 0.18 + )}`, + }, + }} + /> + } + iconPosition="start" + sx={{ + minHeight: 42, + borderRadius: 1.5, + textTransform: 'none', + fontWeight: 600, + color: 'text.secondary', + transition: theme.transitions.create(['background-color', 'color', 'box-shadow'], { + duration: theme.transitions.duration.shorter, + }), + '& .MuiSvgIcon-root': { + color: 'inherit', + }, + '&:hover': { + bgcolor: alpha(theme.palette.text.primary, theme.palette.mode === 'dark' ? 0.1 : 0.06), + }, + '&.Mui-selected': { + color: theme.palette.primary.contrastText, + bgcolor: theme.palette.primary.main, + boxShadow: `0 0 0 1px ${alpha(theme.palette.primary.main, 0.24)}, 0 6px 16px ${alpha( + theme.palette.primary.main, + theme.palette.mode === 'dark' ? 0.4 : 0.18 + )}`, + }, + }} + /> ); diff --git a/src/pages/ExperimentViewer.tsx b/src/pages/ExperimentViewer.tsx index 6b286244..fe56f65b 100644 --- a/src/pages/ExperimentViewer.tsx +++ b/src/pages/ExperimentViewer.tsx @@ -520,7 +520,7 @@ const ExperimentViewer: React.FC = (): JSX.Element => { > - Search for HDF5 Data + Search for HDF5 data Enter an instrument and/or experiment number above to search for jobs with HDF5 output files. @@ -541,7 +541,7 @@ const ExperimentViewer: React.FC = (): JSX.Element => { > - No Jobs Found + No jobs found {searchInstrument && `Instrument: ${searchInstrument}`} @@ -558,7 +558,7 @@ const ExperimentViewer: React.FC = (): JSX.Element => { {/* Results - show FileTree and Graph when we have jobs */} {(jobId || instrumentName || (isSearchActive && jobs.length > 0)) && ( <> - {/* Left panel - File Tree */} + {/* Left panel - File tree */} =22.12.0 + jiti: ">=1.21.0" + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: ">=0.54.8" + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + bin: + vite: bin/vite.js + checksum: f98bf7feb114794ae4b1ccbb2363f0417fd7a8c7e493e8cbc988b39313401db15d2ff64737eb8f10d805458c028a67b81395afcbff5b5fa70a70ffa2ec70aa52 + languageName: node + linkType: hard + "vite@npm:^6.4.2": version: 6.4.2 resolution: "vite@npm:6.4.2" @@ -18043,6 +18839,62 @@ __metadata: languageName: node linkType: hard +"vitest@npm:^3.2.4": + version: 3.2.4 + resolution: "vitest@npm:3.2.4" + dependencies: + "@types/chai": ^5.2.2 + "@vitest/expect": 3.2.4 + "@vitest/mocker": 3.2.4 + "@vitest/pretty-format": ^3.2.4 + "@vitest/runner": 3.2.4 + "@vitest/snapshot": 3.2.4 + "@vitest/spy": 3.2.4 + "@vitest/utils": 3.2.4 + chai: ^5.2.0 + debug: ^4.4.1 + expect-type: ^1.2.1 + magic-string: ^0.30.17 + pathe: ^2.0.3 + picomatch: ^4.0.2 + std-env: ^3.9.0 + tinybench: ^2.9.0 + tinyexec: ^0.3.2 + tinyglobby: ^0.2.14 + tinypool: ^1.1.1 + tinyrainbow: ^2.0.0 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + vite-node: 3.2.4 + why-is-node-running: ^2.3.0 + peerDependencies: + "@edge-runtime/vm": "*" + "@types/debug": ^4.1.12 + "@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0 + "@vitest/browser": 3.2.4 + "@vitest/ui": 3.2.4 + happy-dom: "*" + jsdom: "*" + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@types/debug": + optional: true + "@types/node": + optional: true + "@vitest/browser": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + bin: + vitest: vitest.mjs + checksum: e9aa14a2c4471c2e0364d1d7032303db8754fac9e5e9ada92fca8ebf61ee78d2c5d4386bff25913940a22ea7d78ab435c8dd85785d681b23e2c489d6c17dd382 + languageName: node + linkType: hard + "void-elements@npm:3.1.0": version: 3.1.0 resolution: "void-elements@npm:3.1.0" @@ -18422,6 +19274,18 @@ __metadata: languageName: node linkType: hard +"why-is-node-running@npm:^2.3.0": + version: 2.3.0 + resolution: "why-is-node-running@npm:2.3.0" + dependencies: + siginfo: ^2.0.0 + stackback: 0.0.2 + bin: + why-is-node-running: cli.js + checksum: 58ebbf406e243ace97083027f0df7ff4c2108baf2595bb29317718ef207cc7a8104e41b711ff65d6fa354f25daa8756b67f2f04931a4fd6ba9d13ae8197496fb + languageName: node + linkType: hard + "widest-line@npm:^4.0.1": version: 4.0.1 resolution: "widest-line@npm:4.0.1"