diff --git a/modules/electrophysiology_browser/css/electrophysiology_browser.css b/modules/electrophysiology_browser/css/electrophysiology_browser.css index a97cb229417..b5516024df9 100644 --- a/modules/electrophysiology_browser/css/electrophysiology_browser.css +++ b/modules/electrophysiology_browser/css/electrophysiology_browser.css @@ -42,6 +42,10 @@ svg { user-select: none; } +svg:not(:root) { + overflow: clip; +} + .list-group-item { position: relative; display: flex; @@ -97,6 +101,26 @@ svg { border: 1px solid #333; } +.input-interval-bound { + width: 60px; + height: 22px; + font-size: 10px; + float: left; + text-align: center; +} + +.btn-zoom { + margin: 0 auto 3px auto; + width: 50px; + text-align: center; +} + +.col-xs-title { + color: #064785; + font-weight: bold; + text-align: center; +} + #electrode-montage .list-group { border: 1px solid #ddd; } @@ -135,6 +159,14 @@ svg { height: auto !important; } +#eegSessionView .panel-heading > div > i { + cursor: pointer; +} + +#eegSessionView .panel-heading > div > i:hover { + scale: 1.05; +} + #eegSidebar { top: 0; bottom: 0; @@ -187,10 +219,10 @@ svg { /* Large Devices, Wide Screens */ @media only screen and (min-width : 1200px) { .pull-right-lg { - float: right; + text-align: right; } .pagination-nav { padding-top: 0; } -} \ No newline at end of file +} diff --git a/modules/electrophysiology_browser/images/electrodes-2d-hover.png b/modules/electrophysiology_browser/images/electrodes-2d-hover.png deleted file mode 100644 index 0b3bac3488e..00000000000 Binary files a/modules/electrophysiology_browser/images/electrodes-2d-hover.png and /dev/null differ diff --git a/modules/electrophysiology_browser/images/electrodes-2d.png b/modules/electrophysiology_browser/images/electrodes-2d.png deleted file mode 100644 index 40aee820915..00000000000 Binary files a/modules/electrophysiology_browser/images/electrodes-2d.png and /dev/null differ diff --git a/modules/electrophysiology_browser/images/electrodes-3d.png b/modules/electrophysiology_browser/images/electrodes-3d.png deleted file mode 100644 index 547136a3791..00000000000 Binary files a/modules/electrophysiology_browser/images/electrodes-3d.png and /dev/null differ diff --git a/modules/electrophysiology_browser/images/event-panel.png b/modules/electrophysiology_browser/images/event-panel.png deleted file mode 100644 index 0e225567252..00000000000 Binary files a/modules/electrophysiology_browser/images/event-panel.png and /dev/null differ diff --git a/modules/electrophysiology_browser/images/overall-view.png b/modules/electrophysiology_browser/images/overall-view.png deleted file mode 100644 index fc1e471e71f..00000000000 Binary files a/modules/electrophysiology_browser/images/overall-view.png and /dev/null differ diff --git a/modules/electrophysiology_browser/images/signal-values-details.png b/modules/electrophysiology_browser/images/signal-values-details.png deleted file mode 100644 index 64a10d7bce8..00000000000 Binary files a/modules/electrophysiology_browser/images/signal-values-details.png and /dev/null differ diff --git a/modules/electrophysiology_browser/images/signal-values.png b/modules/electrophysiology_browser/images/signal-values.png deleted file mode 100644 index 6fcade5f8e9..00000000000 Binary files a/modules/electrophysiology_browser/images/signal-values.png and /dev/null differ diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/README.md b/modules/electrophysiology_browser/jsx/react-series-data-viewer/README.md index 910e45dde1b..82d314aabbd 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/README.md +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/README.md @@ -9,7 +9,6 @@ To enable the visualization components, set the `useEEGBrowserVisualizationCompo ## Main dependencies - - [Ramda](https://ramdajs.com) A practical functional library for JavaScript programmers. @@ -35,29 +34,58 @@ To install the Protocol Buffers Compiler (protoc), run: The EEG Browser visualization component adds support for some useful visual helpers: The **Signal Viewer** and the **Electrode Map**. -![Overall View](./../../images/overall-view.png) +![Overall View](https://images.loris.ca/eeg-browser/overall-view.png) ### Signal Viewer -![Signal Viewer](./../../images/signal-values-details.png)

+![Signal Viewer](https://images.loris.ca/eeg-browser/signal-values-details.png)

Several tools can be used to navigate through the Signal Viewer: - - The **Timeline Range View** (1) can be used to change the boundaries of the viewed timeline. - - The **Amplitude** and **Filter** tools (2) can be used to increase/reduce the amplitude scale and apply high or low-pass filters. - - The **Channel navigation** (3) can be used to navigate through the viewed channels. - - The **Event Panel** (4) can be used to display information about the events when event data is available. - - If the selected timeline range contains more than 100 events, a message (5) indicates the user to reduce the boundaries of the timeline in order to display the event data. + - The **Zoom Controls** (1) can be used to increase or reduce the time interval while maintaining the same midpoint. + - *Reset*: Set 'zoom' level to default value (5 second interval). + - *+ / -*: Zoom in or out, respectively. + - *Region*: This button becomes available when a region is highlighted on the plot (left-click drag). Pressing it sets the time interval to the selected region. + - The **Timeline Range View** (2) can be used to change the boundaries of the viewed timeline. + - *[<] / [>]*: These arrows translate the interval bounds backwards or forwards, respectively, by 1 second. + - *[<<] / [>>]*: These arrows translate the interval bounds backwards or forwards, respectively, by the value of the interval. + - *Text fields*: The text fields can be edited to manually set the interval. + - *Sliders*: The sliders can be dragged as an alternative way to set the interval range. + - The **Amplitude** and **Filter** tools and the **Show/Hide Overflow** button (3) can be used to increase/reduce the amplitude scale, apply high or low-pass filters, or toggle the visibility of signal spillage, respectively. + - The **Channel Navigation** (4) toolbar can be used to navigate through the viewed channels. + - *Dropdown*: This dropdown allow to change the number of displayed channels. Currently supported values are: 4, 8 16, 32 or 64 visible channels. + - *Text field*: This can be used to manually set the starting index of the displayed channels. + - *[<] / [>]*: These arrows translate the visible channel range backwards or forwards, respectively, by 1 channel. + - *[<<] / [>>]*: These arrows translate the visible channel range backwards or forwards, respectively, by the number of displayed channel. + - The **Channel Adjustment** (5) buttons can be used to vertically adjust the position of the signals. + - *DC/NO Offset*: This button toggles the subtraction of DC offset from the signals, used to center them with respect to their assigned row. + - *Stack/Spread*: This button toggles the channels from being in their assigned row to being all stacked on the same row. [[Stacked View Demo](#stacked-view)] + - The **Event Panel** (6) can be used to display information about the events when event data is available. + - If the selected timeline range contains more than 500 events, a message inside the panel indicates the user to reduce the boundaries of the timeline in order to display the event data.

-

-
- Signal Viewer with signal values and event data displayed. +

+
+ Signal Viewer with hovered signal value and event data displayed.

+ +### Stacked View +Hovering channel names while in 'stacked' or 'spread (default)' view will thicken the respective signal(s). While in stacked view, a feature called "Isolate" becomes available. [[Isolate Mode Demo](#isolate-mode)] +

![Stacked View](https://images.loris.ca/eeg-browser/signal-stacked.png)
+ + +### Isolate Mode +Hovering channel names while in 'isolate' mode will make that signal the only visible signal on the plot. +

![Isolate Mode](https://images.loris.ca/eeg-browser/signal-isolated.png)
+ + ### Electrode Map The current implementation of the Electrode Map supports 2 display modes: 2D and 3D. | 2D View | 3D View | |:-------------------------:|:-------------------------:| -|
The 2D view is a stereographic projection of the electrodes position. Electrodes are indexed and their name is displayed on mouse hover. |
The 3D view displays the exact position of the electrodes on the brain. | +|
The 2D view is a stereographic projection of the electrodes position. Electrodes are indexed and their name is +displayed on mouse hover. |
The 3D view displays the exact position of the electrodes on the brain. | + ### Future developements to come +A signal annotation feature is currently under development. + -A signal annotation feature is currently under development. diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/color/index.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/color/index.tsx index d7aa0d79c63..072788a5946 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/color/index.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/color/index.tsx @@ -1,12 +1,10 @@ +import * as R from 'ramda'; import {scaleOrdinal} from 'd3-scale'; +import {schemeDark2, schemeCategory10} from 'd3-scale-chromatic'; -// import * as R from 'ramda'; -// import {schemeCategory10, schemeSet3} from 'd3-scale-chromatic'; -// export const colorOrder = scaleOrdinal( -// R.concat(schemeCategory10, schemeSet3) -// ); - -export const colorOrder = scaleOrdinal(); +export const colorOrder = scaleOrdinal( + R.concat(schemeDark2, schemeCategory10) +); /** * hex2rgba diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/eeglab/EEGLabSeriesProvider.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/eeglab/EEGLabSeriesProvider.tsx index bc1d62bebe2..ab79a11f815 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/eeglab/EEGLabSeriesProvider.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/eeglab/EEGLabSeriesProvider.tsx @@ -6,7 +6,7 @@ import {createEpicMiddleware} from 'redux-observable'; import thunk from 'redux-thunk'; import {fetchJSON, fetchText} from '../ajax'; import {rootReducer, rootEpic} from '../series/store'; -import {MAX_CHANNELS} from '../vector'; +import {DEFAULT_MAX_CHANNELS, DEFAULT_TIME_INTERVAL} from '../vector'; import { setChannels, emptyChannels, @@ -15,9 +15,9 @@ import { setEpochs, setDatasetMetadata, setPhysioFileID, + setFilteredEpochs, } from '../series/store/state/dataset'; import {setDomain, setInterval} from '../series/store/state/bounds'; -import {updateFilteredEpochs} from '../series/store/logic/filterEpochs'; import {setElectrodes} from '../series/store/state/montage'; import {AnnotationMetadata, EventMetadata} from '../series/store/types'; @@ -107,11 +107,11 @@ class EEGLabSeriesProvider extends Component { }) ); this.store.dispatch(setChannels(emptyChannels( - Math.min(limit, channelMetadata.length), + Math.min(this.props.limit, channelMetadata.length), 1 ))); this.store.dispatch(setDomain(timeInterval)); - this.store.dispatch(setInterval(timeInterval)); + this.store.dispatch(setInterval(DEFAULT_TIME_INTERVAL)); } }).then(() => { return events.instances.map((instance) => { @@ -160,7 +160,7 @@ class EEGLabSeriesProvider extends Component { }) ) ); - this.store.dispatch(updateFilteredEpochs()); + this.store.dispatch(setFilteredEpochs(epochs.map((_, index) => index))); }) ; @@ -199,7 +199,7 @@ class EEGLabSeriesProvider extends Component { } static defaultProps = { - limit: MAX_CHANNELS, + limit: DEFAULT_MAX_CHANNELS, }; } diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/AnnotationForm.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/AnnotationForm.tsx index c93c94a6a98..c07c0b12b25 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/AnnotationForm.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/AnnotationForm.tsx @@ -29,6 +29,7 @@ type CProps = { toggleEpoch: (_: number) => void, updateActiveEpoch: (_: number) => void, interval: [number, number], + domain: [number, number], }; /** @@ -43,7 +44,12 @@ type CProps = { * @param root0.setCurrentAnnotation * @param root0.physioFileID * @param root0.annotationMetadata + * @param root0.toggleEpoch, + * @param root0.updateActiveEpoch, * @param root0.interval + * @param root0.domain + * @param root0.toggleEpoch + * @param root0.updateActiveEpoch */ const AnnotationForm = ({ timeSelection, @@ -55,10 +61,18 @@ const AnnotationForm = ({ setCurrentAnnotation, physioFileID, annotationMetadata, + toggleEpoch, + updateActiveEpoch, interval, + domain, }: CProps) => { const [startEvent = '', endEvent = ''] = timeSelection || []; - const [event, setEvent] = useState([startEvent, endEvent]); + const [event, setEvent] = useState<(number | string)[]>( + [ + startEvent, + endEvent, + ] + ); const [label, setLabel] = useState( currentAnnotation ? currentAnnotation.label : @@ -97,13 +111,14 @@ const AnnotationForm = ({ * @param val */ const handleStartTimeChange = (id, val) => { - const value = parseInt(val); + const value = parseFloat(val); setEvent([value, event[1]]); if (validate([value, event[1]])) { let endTime = event[1]; + if (typeof endTime === 'string') { - endTime = parseInt(endTime); + endTime = parseFloat(endTime); } setTimeSelection( [ @@ -120,13 +135,14 @@ const AnnotationForm = ({ * @param val */ const handleEndTimeChange = (name, val) => { - const value = parseInt(val); + const value = parseFloat(val); setEvent([event[0], value]); if (validate([event[0], value])) { let startTime = event[0]; + if (typeof startTime === 'string') { - startTime = parseInt(startTime); + startTime = parseFloat(startTime); } setTimeSelection( [ @@ -203,10 +219,10 @@ const AnnotationForm = ({ let startTime = event[0]; let endTime = event[1]; if (typeof startTime === 'string') { - startTime = parseInt(startTime); + startTime = parseFloat(startTime); } if (typeof endTime === 'string') { - endTime = parseInt(endTime); + endTime = parseFloat(endTime); } const duration = endTime - startTime; @@ -399,20 +415,34 @@ const AnnotationForm = ({ } - -
- {[...Array(epochs.length).keys()].filter((i) => - epochs[i].onset + epochs[i].duration > interval[0] - && epochs[i].onset < interval[1] - && ( - (epochs[i].type === 'Event' - && rightPanel === 'eventList') - || - (epochs[i].type === 'Annotation' - && rightPanel === 'annotationList') - ) - ).length >= MAX_RENDERED_EPOCHS && -
- Too many events to display for the timeline range. -
- } -
@@ -794,7 +1326,7 @@ SeriesRenderer.defaultProps = { hidden: [], channelMetadata: [], offsetIndex: 1, - limit: MAX_CHANNELS, + limit: DEFAULT_MAX_CHANNELS, }; export default connect( @@ -812,16 +1344,23 @@ export default connect( hidden: state.montage.hidden, channelMetadata: state.dataset.channelMetadata, offsetIndex: state.dataset.offsetIndex, + limit: state.dataset.limit, + domain: state.bounds.domain, physioFileID: state.dataset.physioFileID, + hoveredChannels: state.cursor.hoveredChannels, }), (dispatch: (_: any) => void) => ({ setOffsetIndex: R.compose( dispatch, setOffsetIndex ), + setInterval: R.compose( + dispatch, + setInterval + ), setCursor: R.compose( dispatch, - setCursor + setCursorInteraction ), setRightPanel: R.compose( dispatch, @@ -855,7 +1394,11 @@ export default connect( dispatch, setFilteredEpochs ), - setCurrentAnnotation: R.compose( + setDatasetMetadata: R.compose( + dispatch, + setDatasetMetadata + ), + setCurrentAnnotation: R.compose( dispatch, setCurrentAnnotation ), @@ -871,5 +1414,9 @@ export default connect( dispatch, endDragSelection ), + setHoveredChannels: R.compose( + dispatch, + setHoveredChannels + ), }) )(SeriesRenderer); diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/components.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/components.tsx index 046372d6460..bb6988f0fb6 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/components.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/components.tsx @@ -25,6 +25,7 @@ interface IHandleProps { * @param root0.handle.percent * @param root0.getHandleProps */ + export const Handle: React.FC = ({ domain: [min, max], handle: {id, value, percent}, @@ -38,14 +39,14 @@ export const Handle: React.FC = ({ style={{ left: `${percent}%`, position: 'absolute', - marginLeft: '-9px', - marginTop: '-9px', + marginLeft: '-5px', + marginTop: '-5px', zIndex: 2, - width: 18, - height: 18, + width: 10, + height: 10, cursor: 'pointer', boxShadow: '1px 1px 1px 1px rgba(0, 0, 0, 0.2)', - border: '3px solid #064785', + border: '2px solid #064785', background: '#fff', borderRadius: '50%', }} diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/index.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/index.tsx index 83a0f7f0644..1d424b56b18 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/index.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/index.tsx @@ -8,6 +8,7 @@ import {cursorReducer} from './state/cursor'; import {panelReducer} from './state/rightPanel'; import {timeSelectionReducer} from './state/timeSelection'; import {montageReducer} from './state/montage'; +import {channelsReducer} from './state/channels'; import {createDragBoundsEpic} from './logic/dragBounds'; import {createTimeSelectionEpic} from './logic/timeSelection'; import {createFetchChunksEpic} from './logic/fetchChunks'; @@ -25,7 +26,7 @@ import { createLowPassFilterEpic, createHighPassFilterEpic, } from './logic/highLowPass'; -import {channelsReducer} from './state/channels'; +import {createCursorInteractionEpic} from './logic/cursorInteraction'; export const rootReducer = combineReducers({ bounds: boundsReducer, @@ -74,6 +75,10 @@ export const rootEpic = combineEpics( const {epochs} = dataset; return {epochs}; }), + createCursorInteractionEpic(({cursor}) => { + const {hoveredChannels} = cursor; + return {hoveredChannels}; + }), ); export type RootState = ReturnType; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/cursorInteraction.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/cursorInteraction.tsx new file mode 100644 index 00000000000..cec4ed7cd7f --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/cursorInteraction.tsx @@ -0,0 +1,77 @@ +import * as R from 'ramda'; +import {Observable} from 'rxjs'; +import * as Rx from 'rxjs/operators'; +import {ofType} from 'redux-observable'; +import {createAction} from 'redux-actions'; +import {setCursor, setHoveredChannels} from '../state/cursor'; +import {Cursor} from '../types'; + +export const SET_CURSOR_INTERACTION = 'SET_CURSOR_INTERACTION'; +export const setCursorInteraction = createAction(SET_CURSOR_INTERACTION); + +export type Action = (_: (_: any) => void) => void; + +/** + * createChannelInteractionEpic + * + * @param {Function} fromState - A function to parse the current state + * @returns {Observable} - A stream of actions + */ +export const createCursorInteractionEpic = (fromState: (_: any) => any) => ( + action$: Observable, + state$: Observable +): Observable => { + return action$.pipe( + ofType(SET_CURSOR_INTERACTION), + Rx.map(R.prop('payload')), + Rx.withLatestFrom(state$), + Rx.map<[Cursor, any], any>(([cursor, state]) => { + const channelElements = getChannelsAtCursor( + cursor ? cursor.cursorPosition : null, + cursor ? cursor.viewerRef : null + ); + + const channelIndices = channelElements.map((element) => { + const className = Array.from(element.classList) + .find((name) => name.includes('channel')); + return parseInt(className.split('-')[1]); + }).reverse(); + + const {hoveredChannels} = fromState(state); + + return (dispatch) => { + dispatch(setCursor(cursor ? cursor.cursorPosition : null)); + if ( + JSON.stringify(hoveredChannels) !== JSON.stringify(channelIndices) + ) { + dispatch(setHoveredChannels(channelIndices)); + } + }; + }) + ); +}; + + +/** + * getChannelsAtCursor + * + * @param {[number, number]} cursorPosition - Cursor position from mouseMove callback + * @param {object} viewerRef - Reference to corresponding ResponsiveViewer + * @returns {Array} - The output signal + */ +const getChannelsAtCursor = (cursorPosition, viewerRef) => { + if (cursorPosition === null || viewerRef === null) return []; + + const viewerElement = viewerRef.current.container; + const { + top, + left, + width, + height, + } = viewerElement.getBoundingClientRect(); + + return document.elementsFromPoint( + (cursorPosition[0] * width) + left, + (cursorPosition[1] * height) + top + ).filter((element) => element.tagName === 'path'); +}; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/dragBounds.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/dragBounds.tsx index b05d49cdaad..ef3503c333f 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/dragBounds.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/dragBounds.tsx @@ -27,7 +27,8 @@ export type Action = BoundsAction | { type: 'UPDATE_VIEWED_CHUNKS' }; * @returns {Observable} - A stream of actions */ export const createDragBoundsEpic = () => ( - action$: Observable + action$: Observable, + state$: Observable, ): Observable => { const startDrag$ = action$.pipe( ofType(START_DRAG_INTERVAL), @@ -41,14 +42,28 @@ export const createDragBoundsEpic = () => ( const endDrag$ = action$.pipe(ofType(END_DRAG_INTERVAL)); + /** + * computeNewInterval + * + * @param {[number, number]} interval - New time interval + * @returns {void} + */ + const computeNewInterval = (interval) => { + return (dispatch) => { + dispatch(setInterval(interval)); + }; + }; + const startUpdates$ = startDrag$.pipe( - Rx.map(setInterval) + Rx.withLatestFrom(state$), + Rx.map((payload) => computeNewInterval(payload[0])) ); const dragUpdates$ = startDrag$.pipe( Rx.switchMap(() => continueDrag$.pipe( - Rx.map(setInterval), + Rx.withLatestFrom(state$), + Rx.map((payload) => computeNewInterval(payload[0])), Rx.takeUntil(endDrag$) ) ) diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/fetchChunks.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/fetchChunks.tsx index ba9f611ad51..8740c025f4e 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/fetchChunks.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/fetchChunks.tsx @@ -114,23 +114,30 @@ export const createFetchChunksEpic = (fromState: (any) => State) => ( channels.map((channel) => { return ( channel && - channel.traces.map((_, j) => { - const ncs = shapes.map((shape) => shape[shape.length - 2]); + channel.traces.map((_, traceIndex) => { + const shapeChunks = + shapes.map((shape) => shape[shape.length - 2]); - const citvs = ncs - .map((nc, downsampling) => { - const timeLength = Math.abs( + const chunkIntervals = shapeChunks + .map((numChunks, downsampling) => { + const recordingDuration = Math.abs( timeInterval[1] - timeInterval[0] ); const i0 = - (nc * Math.ceil(bounds.interval[0] - bounds.domain[0])) / - timeLength; + (numChunks * + Math.floor(bounds.interval[0] - bounds.domain[0]) + ) / recordingDuration; const i1 = - (nc * Math.ceil(bounds.interval[1] - bounds.domain[0])) / - timeLength; + (numChunks * + Math.ceil(bounds.interval[1] - bounds.domain[0]) + ) / recordingDuration; return { - interval: [Math.floor(i0), Math.min(Math.ceil(i1), nc)], - numChunks: nc, + interval: + [ + Math.floor(i0), + Math.min(Math.ceil(i1), numChunks), + ], + numChunks: numChunks, downsampling, }; }) @@ -140,39 +147,44 @@ export const createFetchChunksEpic = (fromState: (any) => State) => ( ) .reverse(); - const max = R.reduce( + const finestChunks = R.reduce( R.maxBy(({interval}) => interval[1] - interval[0]), {interval: [0, 0]}, - citvs + chunkIntervals ); - const chunkPromises = R.range(...max.interval).map( + const chunkPromises = R.range(...finestChunks.interval).flatMap( (chunkIndex) => { - const numChunks = max.numChunks; - return fetchChunkAt( - chunksURL, - max.downsampling, - channel.index, - j, - chunkIndex - ).then((chunk) => ({ - interval: [ - timeInterval[0] + - (chunkIndex / numChunks) * - (timeInterval[1] - timeInterval[0]), - timeInterval[0] + - ((chunkIndex + 1) / numChunks) * - (timeInterval[1] - timeInterval[0]), - ], - ...chunk, - })); + const numChunks = finestChunks.numChunks; + const chunkInterval = [ + timeInterval[0] + + (chunkIndex / numChunks) * + (timeInterval[1] - timeInterval[0]), + timeInterval[0] + + ((chunkIndex + 1) / numChunks) * + (timeInterval[1] - timeInterval[0]), + ]; + if (chunkInterval[0] <= bounds.interval[1]) { + return fetchChunkAt( + chunksURL, + finestChunks.downsampling, + channel.index, + traceIndex, + chunkIndex + ).then((chunk) => ({ + interval: chunkInterval, + ...chunk, + })); + } else { + return []; + } } ); return from( Promise.all(chunkPromises).then((chunks) => ({ channelIndex: channel.index, - traceIndex: j, + traceIndex: traceIndex, chunks, })) ); diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/filterEpochs.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/filterEpochs.tsx index 156906fe839..55126816403 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/filterEpochs.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/filterEpochs.tsx @@ -5,6 +5,7 @@ import {ofType} from 'redux-observable'; import {createAction} from 'redux-actions'; import {setFilteredEpochs, setActiveEpoch} from '../state/dataset'; import {MAX_RENDERED_EPOCHS} from '../../../vector'; +import {Epoch} from '../types'; export const UPDATE_FILTERED_EPOCHS = 'UPDATE_FILTERED_EPOCHS'; export const updateFilteredEpochs = createAction(UPDATE_FILTERED_EPOCHS); @@ -114,3 +115,32 @@ export const createActiveEpochEpic = (fromState: (_: any) => any) => ( }) ); }; + +/** + * getEpochsInRange + * + * @param {Epoch[]} epochs - Array of epoch + * @param {[number, number]} interval - Time interval to search + * @param {string} epochType - Epoch type (Annotation|Event) + * @param {boolean} withComments - Include only if has comments + * @returns {Epoch[]} - Epoch[] in interval with epochType + */ +export const getEpochsInRange = ( + epochs, + interval, + epochType, + withComments = false, +) => { + return [...Array(epochs.length).keys()].filter((index) => + ( + (isNaN(epochs[index].onset) && interval[0] === 0) + || + ( + epochs[index].onset + epochs[index].duration > interval[0] && + epochs[index].onset < interval[1] + ) + ) && + epochs[index].type === epochType && + (!withComments || epochs[index].hed || epochs[index].comment) + ); +}; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/pagination.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/pagination.tsx index c92750a5b22..1659e91e4f4 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/pagination.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/pagination.tsx @@ -38,7 +38,7 @@ export const createPaginationEpic = (fromState: (_: any) => State) => ( const offsetIndex = Math.min( Math.max(payload, 1), - channelMetadata.length + Math.max(channelMetadata.length - limit + 1, 1) ); let channelIndex = offsetIndex - 1; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/timeSelection.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/timeSelection.tsx index 5bd62d8652e..075aa8e1a37 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/timeSelection.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/timeSelection.tsx @@ -5,7 +5,7 @@ import {ofType} from 'redux-observable'; import {createAction} from 'redux-actions'; import {setTimeSelection} from '../state/timeSelection'; import {Action as BoundsAction} from '../state/bounds'; -import {MIN_INTERVAL_FACTOR} from '../../../vector'; +import {MIN_INTERVAL} from '../../../vector'; export const START_DRAG_SELECTION = 'START_DRAG_SELECTION'; export const startDragSelection = createAction(START_DRAG_SELECTION); @@ -18,6 +18,17 @@ export const endDragSelection = createAction(END_DRAG_SELECTION); export type Action = BoundsAction | { type: 'UPDATE_VIEWED_CHUNKS' }; +/** + * roundTime + * + * @param {number} value - The initial time value + * @param {number} decimals - The desired decimal precision + * @returns {number} - The value rounded to 'decimal' decimal places + */ +export const roundTime = (value, decimals = 3) => { + return Number(Math.round(Number(value + 'e' + decimals)) + 'e-' + decimals); +}; + /** * createTimeSelectionEpic * @@ -48,7 +59,7 @@ export const createTimeSelectionEpic = (fromState: (_: any) => any) => ( */ const initInterval = ([position, state]) => { const {interval} = R.clone(fromState(state)); - const x = Math.round(interval[0] + position * (interval[1] - interval[0])); + const x = roundTime(interval[0] + position * (interval[1] - interval[0])); return setTimeSelection([x, x]); }; @@ -63,11 +74,7 @@ export const createTimeSelectionEpic = (fromState: (_: any) => any) => ( const updateInterval = ([position, state]) => { const {interval, timeSelection} = R.clone(fromState(state)); const x = interval[0] + position * (interval[1] - interval[0]); - const minSize = Math.abs(interval[1] - interval[0]) * MIN_INTERVAL_FACTOR; - timeSelection[1] = Math.round( - x + Math.max(timeSelection[0] + minSize - timeSelection[1], 0) - ); - + timeSelection[1] = roundTime(x); return setTimeSelection(timeSelection); }; @@ -77,7 +84,9 @@ export const createTimeSelectionEpic = (fromState: (_: any) => any) => ( Rx.map(([, state]) => { if ( state.timeSelection - && (state.timeSelection[1] - state.timeSelection[0] < 2) + && ( + Math.abs(state.timeSelection[1] - state.timeSelection[0] + ) < MIN_INTERVAL) ) { return setTimeSelection(null); } else { diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/bounds.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/bounds.tsx index 88b169fba22..8cfe866adb9 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/bounds.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/bounds.tsx @@ -1,4 +1,5 @@ import {createAction} from 'redux-actions'; +import {DEFAULT_VIEWER_HEIGHT} from '../../../vector'; export const SET_INTERVAL = 'SET_INTERVAL'; export const setInterval = createAction(SET_INTERVAL); @@ -42,7 +43,10 @@ const interval = ( action?: Action ): [number, number] => { if (action && action.type === 'SET_INTERVAL') { - return action.payload; + return [ + Math.min(action.payload[0], action.payload[1]), + Math.max(action.payload[0], action.payload[1]), + ]; } return state; }; @@ -71,7 +75,7 @@ const domain = ( * @param {Action} action - The action * @returns {State} - The updated state */ -const amplitudeScale = (state = 1, action?: Action): number => { +const amplitudeScale = (state = 0.0005, action?: Action): number => { if (action && action.type === 'SET_AMPLITUDE_SCALE') { return action.payload; } @@ -99,7 +103,10 @@ const viewerWidth = (state = 400, action?: Action): number => { * @param {Action} action - The action * @returns {State} - The updated state */ -const viewerHeight = (state = 400, action?: Action): number => { +const viewerHeight = ( + state = DEFAULT_VIEWER_HEIGHT, + action?: Action +): number => { if (action && action.type === 'SET_VIEWER_HEIGHT') { return action.payload; } diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/cursor.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/cursor.tsx index 4b728fafcc7..fac3029d259 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/cursor.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/cursor.tsx @@ -1,16 +1,23 @@ +import * as R from 'ramda'; import {createAction} from 'redux-actions'; export const SET_CURSOR = 'SET_CURSOR'; export const setCursor = createAction(SET_CURSOR); -export type Action = { - type: 'SET_CURSOR', - payload?: number -}; +export const SET_HOVERED_CHANNELS = 'SET_HOVERED_CHANNELS'; +export const setHoveredChannels = createAction(SET_HOVERED_CHANNELS); + +export type Action = + | {type: 'SET_CURSOR', payload?: [number, number]} + | {type: 'SET_HOVERED_CHANNELS', payload?: number[]} -export type State = number; -export type Reducer = (state?: number, action?: Action) => State; +export type State = { + cursorPosition: [number, number] | null, + hoveredChannels: number[], +}; + +export type Reducer = (state?: State, action?: Action) => State; /** * cursorReducer @@ -19,13 +26,19 @@ export type Reducer = (state?: number, action?: Action) => State; * @param {Action} action - The action * @returns {State} - The updated state */ -export const cursorReducer: Reducer = (state = null, action) => { +export const cursorReducer: Reducer = ( + state = {cursorPosition: null, hoveredChannels: []}, + action +) => { if (!action) { return state; } switch (action.type) { case SET_CURSOR: { - return action.payload; + return R.assoc('cursorPosition', action.payload, state); + } + case SET_HOVERED_CHANNELS: { + return R.assoc('hoveredChannels', action.payload, state); } default: { return state; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/dataset.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/dataset.tsx index 9f431d0b975..2027eb49e21 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/dataset.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/dataset.tsx @@ -1,7 +1,7 @@ import * as R from 'ramda'; import {createAction} from 'redux-actions'; import {ChannelMetadata, Epoch} from '../types'; -import {MAX_CHANNELS} from '../../../vector'; +import {DEFAULT_MAX_CHANNELS} from '../../../vector'; export const SET_EPOCHS = 'SET_EPOCHS'; export const setEpochs = createAction(SET_EPOCHS); @@ -31,7 +31,8 @@ export type Action = shapes: number[][], timeInterval: [number, number], seriesRange: [number, number], - limit: number + limit: number, + offsetIndex: number, } }; @@ -65,7 +66,7 @@ export const datasetReducer = ( activeEpoch: null, physioFileID: null, offsetIndex: 1, - limit: MAX_CHANNELS, + limit: DEFAULT_MAX_CHANNELS, shapes: [], timeInterval: [0, 1], seriesRange: [-1, 2], diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/types.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/types.tsx index 2e49c8ad983..cd826e781ee 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/types.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/types.tsx @@ -1,3 +1,5 @@ +import {MutableRefObject} from 'react'; + export type Chunk = { index: number, originalValues: number[], @@ -55,3 +57,8 @@ export type Electrode = { channelIndex?: number, position: [number, number, number], }; + +export type Cursor = { + cursorPosition: [number, number] | null, + viewerRef: MutableRefObject | null, +}; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/vector/index.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/vector/index.tsx index df3ec154f74..7678918fc2f 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/vector/index.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/vector/index.tsx @@ -12,19 +12,26 @@ export type Vector2 = typeof glMatrix.ARRAY_TYPE; export const ap = ( f: [(_: any) => any, (_: any) => any], p: Vector2 -): Vector2 => - vec2.fromValues(f[0](p[0]), f[1](p[1])); +): Vector2 => vec2.fromValues(f[0](p[0]), f[1](p[1])); -export const MIN_INTERVAL_FACTOR = 0.005; +export const MIN_INTERVAL = 0.001; -export const MIN_EPOCH_WIDTH = 1; +export const MIN_EPOCH_WIDTH = 0.025; -export const MAX_VIEWED_CHUNKS = 3; +export const MAX_VIEWED_CHUNKS = 4; -export const MAX_CHANNELS = 6; +export const DEFAULT_MAX_CHANNELS = 16; + +export const CHANNEL_DISPLAY_OPTIONS = [4, 8, 16, 32, 64]; + +export const STATIC_SERIES_RANGE: [number, number] = [-0.05, 0.05]; + +export const DEFAULT_TIME_INTERVAL: [number, number] = [0, 5]; + +export const DEFAULT_VIEWER_HEIGHT = 700; export const SIGNAL_SCALE = Math.pow(10, 6); export const SIGNAL_UNIT = 'µV'; -export const MAX_RENDERED_EPOCHS = 100; +export const MAX_RENDERED_EPOCHS = 500;