diff --git a/.babelrc b/.babelrc deleted file mode 100644 index 1dbc87b57a7..00000000000 --- a/.babelrc +++ /dev/null @@ -1,13 +0,0 @@ -{ - "presets": [ - "@babel/preset-react", - "@babel/preset-env" - ], - "plugins": ["@babel/plugin-proposal-object-rest-spread", - ["@babel/plugin-transform-runtime", - { - "regenerator": true - } - ] - ] -} diff --git a/.eslintignore b/.eslintignore index 803c5f1d9fe..a08f7d49f4f 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,9 +1,11 @@ node_modules/* vendor/* +babel.config.js # compiled js ignored since it is run on the jsx directory modules/*/js/* modules/*/js/*/* +modules/electrophysiology_browser/jsx/react-series-data-viewer/src/protocol-buffers/chunk_pb.js htdocs/js/components/* # Ignore external libs diff --git a/.flowconfig b/.flowconfig new file mode 100644 index 00000000000..5f164425304 --- /dev/null +++ b/.flowconfig @@ -0,0 +1,15 @@ +[ignore] +/node_modules/npm/test/fixtures/config/package.json +/node_modules/npm/node_modules/config-chain/test/broken.json + +[include] +/modules/electrophysiology_browser/jsx/react-series-data-viewer/src + +[libs] + +[lints] + +[options] +react.runtime=automatic + +[strict] diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 00000000000..3df220b14a5 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,20 @@ +module.exports = function(api) { + api.cache(true); + const presets = [ + "@babel/preset-flow", + "@babel/preset-react", + "@babel/preset-env" + ]; + const plugins = [ + "@babel/plugin-proposal-object-rest-spread", + ["@babel/plugin-transform-runtime", + { + "regenerator": true + } + ] + ]; + return { + presets, + plugins + }; +} diff --git a/modules/electrophysiology_browser/css/electrophysiology_browser.css b/modules/electrophysiology_browser/css/electrophysiology_browser.css new file mode 100644 index 00000000000..2df99d06291 --- /dev/null +++ b/modules/electrophysiology_browser/css/electrophysiology_browser.css @@ -0,0 +1,80 @@ +.react-series-data-viewer-scoped .dropdown-menu { + width: calc(100% - 5px); +} + +.react-series-data-viewer-scoped .dropdown-menu li { + margin-top: 0; + padding: 0 10px; +} + +.react-series-data-viewer-scoped .dropdown-menu li:hover { + background: #eee; + cursor: pointer; + margin-top: 0; + width: 100%; +} + +.btn.btn-xs { + font-size: 12px; +} + +.btn-group .btn { + margin: 0; +} + +.btn-group { + margin-right: 10px; +} + +.btn-primary:focus:not(.active), +.btn-primary:active:not(.active) { + color: #246EB6; + background-color: white; + border-color: #246EB6; + outline: 0; +} + +.no-gutters > div { + padding:0; +} + +svg { + user-select: none; +} + +.annotation.list-group-item { + background: #fffae6; + border-left: 5px solid #ff6600; +} + +.event-list .btn.btn-primary { + color: #555; + border: 1px solid #555; +} + +.event-list .btn.btn-primary.active { + color: #000; + background-color: #ddd; + border: 1px solid #000; +} + +.event-list .btn.btn-primary:hover { + color: #333; + background-color: #eee; + border: 1px solid #333; +} + +#electrode-montage .list-group { + border: 1px solid #ddd; +} + +#electrode-montage .list-group-item:first-child { + border-top: none; +} + +#electrode-montage .list-group-item { + margin-bottom: 0; + border-left: none; + border-right: none; + border-bottom: none; +} \ No newline at end of file diff --git a/modules/electrophysiology_browser/jsx/components/DownloadPanel.js b/modules/electrophysiology_browser/jsx/components/DownloadPanel.js new file mode 100644 index 00000000000..63b68d0796a --- /dev/null +++ b/modules/electrophysiology_browser/jsx/components/DownloadPanel.js @@ -0,0 +1,105 @@ +/** + * This file contains React component for Electrophysiology module. + */ +import React, {Component} from 'react'; +import Panel from 'Panel'; + +/** + * EEG Download Panel + * + * Display EEG files fto download + */ +class DownloadPanel extends Component { + /** + * @constructor + * @param {object} props - React Component properties + */ + constructor(props) { + super(props); + this.state = { + data: this.props.data, + labels: { + physiological_file: 'EEG File', + physiological_electrode_file: 'Electrode Info', + physiological_channel_file: 'Channels Info', + physiological_task_event_file: 'Events', + all_files: 'All Files', + physiological_fdt_file: '', + }, + }; + } + + /** + * Renders the React component. + * + * @return {JSX} - React markup for the component + */ + render() { + return ( + +
+ {this.state.data.downloads + .filter((download) => + download.type != 'physiological_fdt_file' + ) + .map((download, i) => { + const disabled = (download.file === '') ? true : false; + return ( +
+
{this.state.labels[download.type]}
+ {disabled + ? Not Available + : Download + } +
+ ); + }) + } +
+
+ ); + } +} + +export {DownloadPanel}; diff --git a/modules/electrophysiology_browser/jsx/components/SidebarContent.js b/modules/electrophysiology_browser/jsx/components/SidebarContent.js index c7c144f7912..4780bcc5f62 100644 --- a/modules/electrophysiology_browser/jsx/components/SidebarContent.js +++ b/modules/electrophysiology_browser/jsx/components/SidebarContent.js @@ -8,12 +8,13 @@ const styles = { sidebar: { width: 150, height: 'calc(100vh)', - backgroundColor: '#1a487e', + background: '#E4EBF2', + border: '1px solid #C3D5DB', fontWeight: 200, fontFamily: 'Helvetica, Arial, sans-serif', }, sidebarLink: { - color: '#fff', + color: '#064785', fontSize: '16px', display: 'none', padding: '10px 0 0 30px', @@ -36,11 +37,10 @@ const SidebarContent = (props) => {
Navigation @@ -48,7 +48,7 @@ const SidebarContent = (props) => {
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Acquisition Summary -
Sampling Frequency - {this.state.data.task.frequency.sampling} -
{this.state.data.task.channel[0].name} - {this.state.data.task.channel[0].value} -
{this.state.data.task.channel[1].name} - {this.state.data.task.channel[1].value} -
{this.state.data.task.channel[2].name} - {this.state.data.task.channel[2].value} -
{this.state.data.task.channel[3].name} - {this.state.data.task.channel[3].value} -
EEG Reference - {this.state.data.task.reference} -
Powerline Frequency - {this.state.data.task.frequency.powerline} -
-
-
- -
-
EEG File
- -
-
-
Electrode Info
- -
-
-
Channels Info
- -
-
-
Events
- -
-
-
FDT File
- -
-
+ {this.props.children}
-
- - -
-
-
-
- - - - - - - - - - - - - - - -
Task Description - {this.state.data.details.task.description} -
Instructions - {this.state.data.details.instructions} -
EEG Ground - {this.state.data.details.eeg.ground} -
Trigger Count - {this.state.data.details.trigger_count} + +
+
+
+ + + + + + - - - + + - - - + + - - - + + - - - + + - - - + + - - - + + +
Summary
+ Sampling Frequency + + {this.state.data.task.frequency.sampling}
EEG Placement Scheme - {this.state.data.details.eeg.placement_scheme} +
+ {this.state.data.task.channel[0].name} + + {this.state.data.task.channel[0].value}
Record Type - {this.state.data.details.record_type} +
+ {this.state.data.task.channel[1].name} + + {this.state.data.task.channel[1].value}
CogAtlas ID - {this.state.data.details.cog.atlas_id} +
+ {this.state.data.task.channel[2].name} + + {this.state.data.task.channel[2].value}
CogPOID - {this.state.data.details.cog.poid} +
+ {this.state.data.task.channel[3].name} + + {this.state.data.task.channel[3].value}
Institution Name - {this.state.data.details.institution.name} +
+ EEG Reference + + {this.state.data.task.reference}
Institution Address - {this.state.data.details.institution.address} +
+ Powerline Frequency + + {this.state.data.task.frequency.powerline}
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Device Serial Number - {this.state.data.details.device.serial_number} -
Misc Channel Count - {this.state.data.details.misc.channel_count} -
Manufacturer - {this.state.data.details.manufacturer.name} -
Manufacturer Model Name - {this.state.data.details.manufacturer.model_name} -
Cap Manufacturer - {this.state.data.details.cap.manufacturer} -
Cap Model Name - {this.state.data.details.cap.model_name} -
Hardware Filters - {this.state.data.details.hardware_filters} -
Recording Duration - {this.state.data.details.recording_duration} -
Epoch Length - {this.state.data.details.epoch_length} -
Device Version - {this.state.data.details.device.version} -
Subject Artifact Description - {this.state.data.details.subject_artifact_description} -
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Task Description + + {this.state.data.details.task.description} +
+ Instructions + + {this.state.data.details.instructions} +
+ EEG Ground + + {this.state.data.details.eeg.ground} +
+ Trigger Count + + {this.state.data.details.trigger_count} +
+ EEG Placement Scheme + + {this.state.data.details.eeg.placement_scheme} +
+ Record Type + + {this.state.data.details.record_type} +
+ CogAtlas ID + + {this.state.data.details.cog.atlas_id} +
+ CogPOID + + {this.state.data.details.cog.poid} +
+ Institution Name + + {this.state.data.details.institution.name} +
+ Institution Address + + {this.state.data.details.institution.address} +
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Device Serial Number + + {this.state.data.details.device.serial_number} +
+ Misc Channel Count + + {this.state.data.details.misc.channel_count} +
+ Manufacturer + + {this.state.data.details.manufacturer.name} +
+ Manufacturer Model Name + + {this.state.data.details.manufacturer.model_name} +
+ Cap Manufacturer + + {this.state.data.details.cap.manufacturer} +
+ Cap Model Name + + {this.state.data.details.cap.model_name} +
+ Hardware Filters + + {this.state.data.details.hardware_filters} +
+ Recording Duration + + {this.state.data.details.recording_duration} +
+ Epoch Length + + {this.state.data.details.epoch_length} +
+ Device Version + + {this.state.data.details.device.version} +
+ Subject Artifact Description + + { + this.state.data.details + .subject_artifact_description + } +
+
+
-
-
-
+ + ); } @@ -543,6 +345,7 @@ FilePanel.propTypes = { title: PropTypes.string, data: PropTypes.object, }; + FilePanel.defaultProps = { id: 'file_panel', title: 'FILENAME', diff --git a/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js b/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js index 8a5e74971a9..5517d70a59c 100644 --- a/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js +++ b/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js @@ -11,8 +11,14 @@ import PropTypes from 'prop-types'; import StaticDataTable from 'jsx/StaticDataTable'; import {FilePanel} from './components/electrophysiology_session_panels'; +import {DownloadPanel} from './components/DownloadPanel'; import Sidebar from './components/Sidebar'; import SidebarContent from './components/SidebarContent'; +import {EEGLabSeriesProvider} from './react-series-data-viewer/src/eeglab'; +import SeriesRenderer + from './react-series-data-viewer/src/series/components/SeriesRenderer'; +import EEGMontage + from './react-series-data-viewer/src/series/components/EEGMontage'; /** * Electrophysiology Session View page @@ -146,6 +152,9 @@ class ElectrophysiologySessionView extends Component { }, ], }, + chunkDirectoryURL: null, + epochsTableURL: null, + electrodesTableUrls: null, }, ], }; @@ -192,16 +201,39 @@ class ElectrophysiologySessionView extends Component { } return resp.json(); }) - .then((data) => this.getState((appState) => { - appState.setup = {data}; - appState.isLoaded = true; - appState.patient.info = data.patient; - let database = []; - for (let i = 0; i < data.database.length; i++) { - database.push(data.database[i]); - } - appState.database = database; - this.setState(appState); + .then((data) => { + const database = data.database.map((dbEntry) => ({ + ...dbEntry, + // EEG Visualisation urls + chunkDirectoryURL: + dbEntry + && dbEntry.file.chunks_url + && window.location.origin + + '/mri/jiv/get_file.php?file=' + + dbEntry.file.chunks_url, + epochsTableURL: + dbEntry + && dbEntry.file.downloads[3].file + && window.location.origin + + '/mri/jiv/get_file.php?file=' + + dbEntry.file.downloads[3].file, + electrodesTableUrls: + dbEntry + && dbEntry.file.downloads[1].file + && window.location.origin + + '/mri/jiv/get_file.php?file=' + + dbEntry.file.downloads[1].file, + })); + + this.setState({ + setup: {data}, + isLoaded: true, + database: database, + patient: { + info: data.patient, + }, + }); + document.getElementById( 'nav_next' ).href = dataURL + data.nextSession + outputTypeArg; @@ -214,7 +246,7 @@ class ElectrophysiologySessionView extends Component { if (data.nextSession !== '') { document.getElementById('nav_next').style.display = 'block'; } - })) + }) .catch((error) => { this.setState({error: true}); console.error(error); @@ -252,13 +284,36 @@ class ElectrophysiologySessionView extends Component { if (this.state.isLoaded) { let database = []; for (let i = 0; i < this.state.database.length; i++) { + const { + chunkDirectoryURL, + epochsTableURL, + electrodesTableUrls, + } = this.state.database[i]; database.push( -
+
+ > +
+ + +
+
+ +
+
+ +
+
+
+
+
); } @@ -291,9 +346,7 @@ class ElectrophysiologySessionView extends Component { freezeColumn='PSCID' Hide={{rowsPerPage: true, downloadCSV: true, defaultColumn: true}} /> - {database} -
); } diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/package.json b/modules/electrophysiology_browser/jsx/react-series-data-viewer/package.json new file mode 100644 index 00000000000..8cdbbe2953e --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/package.json @@ -0,0 +1,39 @@ +{ + "name": "react-series-data-viewer", + "homepage": "https://github.com/aces/react-series-data-viewer/", + "version": "1.0.0", + "description": "react-series-data-viewer React component", + "dependencies": { + "react": "^16.12.0", + "react-dom": "^16.13.1", + "@visx/axis": "^1.4.0", + "@visx/group": "^1.0.0", + "@visx/responsive": "^1.3.0", + "@visx/shape": "^1.4.0", + "axios": "^0.18.0", + "d3-3d": "0.0.10", + "d3-array": "^1.2.4", + "d3-dsv": "^1.0.10", + "d3-scale": "^2.1.2", + "d3-scale-chromatic": "^1.3.3", + "differenceequationsignal1d": "^0.1.1", + "gl-matrix": "^2.8.1", + "google-protobuf": "^3.6.1", + "ramda": "^0.25.0", + "react-redux": "^7.2.1", + "redux": "^4.0.0", + "redux-actions": "^2.6.1", + "redux-logger": "^3.0.6", + "redux-observable": "^1.0.0", + "redux-thunk": "^2.3.0", + "resize-observer-polyfill": "^1.5.0", + "rxjs": "^6.6.3" + }, + "devDependencies": { + "babel-plugin-flow-react-proptypes": "^24.1.2", + "flow-bin": "^0.123.0", + "flow-typed": "^2.5.1" + }, + "license": "MIT", + "repository": "https://github.com/aces/react-series-data-viewer" +} diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/protocol-buffers/chunk.proto b/modules/electrophysiology_browser/jsx/react-series-data-viewer/protocol-buffers/chunk.proto new file mode 100644 index 00000000000..c3a069e172a --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/protocol-buffers/chunk.proto @@ -0,0 +1,8 @@ +syntax = "proto3"; + +message FloatChunk { + int64 index = 1; + int64 downsampling = 2; + int64 cutoff = 3; + repeated float samples = 4; +} diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/python/protocol_buffers/chunk_pb2.py b/modules/electrophysiology_browser/jsx/react-series-data-viewer/python/protocol_buffers/chunk_pb2.py new file mode 100644 index 00000000000..95d061a4f6d --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/python/protocol_buffers/chunk_pb2.py @@ -0,0 +1,90 @@ +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: protocol-buffers/chunk.proto + +import sys +_b=sys.version_info[0]<3 and (lambda x:x) or (lambda x:x.encode('latin1')) +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor.FileDescriptor( + name='protocol-buffers/chunk.proto', + package='', + syntax='proto3', + serialized_options=None, + serialized_pb=_b('\n\x1cprotocol-buffers/chunk.proto\"R\n\nFloatChunk\x12\r\n\x05index\x18\x01 \x01(\x03\x12\x14\n\x0c\x64ownsampling\x18\x02 \x01(\x03\x12\x0e\n\x06\x63utoff\x18\x03 \x01(\x03\x12\x0f\n\x07samples\x18\x04 \x03(\x02\x62\x06proto3') +) + + + + +_FLOATCHUNK = _descriptor.Descriptor( + name='FloatChunk', + full_name='FloatChunk', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='index', full_name='FloatChunk.index', index=0, + number=1, type=3, cpp_type=2, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='downsampling', full_name='FloatChunk.downsampling', index=1, + number=2, type=3, cpp_type=2, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='cutoff', full_name='FloatChunk.cutoff', index=2, + number=3, type=3, cpp_type=2, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='samples', full_name='FloatChunk.samples', index=3, + number=4, type=2, cpp_type=6, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=32, + serialized_end=114, +) + +DESCRIPTOR.message_types_by_name['FloatChunk'] = _FLOATCHUNK +_sym_db.RegisterFileDescriptor(DESCRIPTOR) + +FloatChunk = _reflection.GeneratedProtocolMessageType('FloatChunk', (_message.Message,), dict( + DESCRIPTOR = _FLOATCHUNK, + __module__ = 'protocol_buffers.chunk_pb2' + # @@protoc_insertion_point(class_scope:FloatChunk) + )) +_sym_db.RegisterMessage(FloatChunk) + + +# @@protoc_insertion_point(module_scope) diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/ajax/index.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/ajax/index.js new file mode 100644 index 00000000000..ad5565d479f --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/ajax/index.js @@ -0,0 +1,10 @@ +import axios from 'axios'; + +export const fetchBlob = (...args) => + axios(...args, {responseType: 'blob'}).then(({data}) => data); + +export const fetchJSON = (...args) => + axios(...args, {responseType: 'json'}).then(({data}) => data); + +export const fetchText = (...args) => + axios(...args, {responseType: 'text'}).then(({data}) => data); diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/chunks/index.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/chunks/index.js new file mode 100644 index 00000000000..9005ebd88e2 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/chunks/index.js @@ -0,0 +1,22 @@ +// @flow +import {FloatChunk} from '../protocol-buffers/chunk_pb'; +import {fetchBlob} from '../ajax'; + +export const fetchChunk = (url: string): Promise => { + return fetchBlob(url).then((blob) => { + const reader = new FileReader(); + reader.readAsArrayBuffer(blob); + return new Promise((resolve) => { + reader.addEventListener('loadend', () => { + const parsed = FloatChunk.deserializeBinary(reader.result); + resolve({ + index: parsed.getIndex(), + cutoff: parsed.getCutoff(), + downsampling: parsed.getDownsampling(), + originalValues: parsed.getSamplesList(), + values: parsed.getSamplesList(), + }); + }); + }); + }); +}; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/color/index.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/color/index.js new file mode 100644 index 00000000000..c0d7ee7f4c3 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/color/index.js @@ -0,0 +1,18 @@ +// @flow + +import {scaleOrdinal} from 'd3-scale'; +// 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 hex2rgba = ({color = '#000000', alpha = 1} : { + color: string, + alpha: number, +}) => { + const r = parseInt(color.slice(1, 3), 16); + const g = parseInt(color.slice(3, 5), 16); + const b = parseInt(color.slice(5, 7), 16); + + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +}; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/eeglab/EEGLabSeriesProvider.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/eeglab/EEGLabSeriesProvider.js new file mode 100644 index 00000000000..aeefda0ffc8 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/eeglab/EEGLabSeriesProvider.js @@ -0,0 +1,141 @@ +import {tsvParse} from 'd3-dsv'; +import {Component} from 'react'; +import {createStore, applyMiddleware} from 'redux'; +import {Provider} from 'react-redux'; +import {createEpicMiddleware} from 'redux-observable'; +import thunk from 'redux-thunk'; +import {fetchJSON, fetchText} from '../ajax'; +import {rootReducer, rootEpic} from '../series/store'; +import { + setChannels, + setEpochs, + setDatasetMetadata, + emptyChannels, +} 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'; + +/** + * EEGLabSeriesProvider component + */ +class EEGLabSeriesProvider extends Component { + /** + * @constructor + * @param {object} props - React Component properties + */ + constructor(props: Props) { + super(props); + const epicMiddleware = createEpicMiddleware(); + + this.store = createStore( + rootReducer, + applyMiddleware(thunk, epicMiddleware) + ); + + epicMiddleware.run(rootEpic); + + window.EEGLabSeriesProviderStore = this.store; + + const { + chunkDirectoryURLs, + epochsTableURLs, + electrodesTableUrls, + limit, + } = props; + + const chunkUrls = + chunkDirectoryURLs instanceof Array + ? chunkDirectoryURLs + : [chunkDirectoryURLs]; + + const epochUrls = + epochsTableURLs instanceof Array ? epochsTableURLs : [epochsTableURLs]; + + const electrodeUrls = + electrodesTableUrls instanceof Array + ? electrodesTableUrls + : [electrodesTableUrls]; + + const racers = (fetcher, urls, route = '') => + urls.map((url) => + fetcher(`${url}${route}`) + .then((json) => ({json, url})) + // if request fails don't resolve + .catch((error) => { + console.error(error); + return new Promise((resolve) => {}); + }) + ); + + Promise.race(racers(fetchJSON, chunkUrls, '/index.json')).then( + ({json, url}) => { + const {channelMetadata, shapes, timeInterval, seriesRange} = json; + this.store.dispatch( + setDatasetMetadata({ + chunkDirectoryURL: url, + channelMetadata, + shapes, + timeInterval, + seriesRange, + limit, + }) + ); + this.store.dispatch(setChannels(emptyChannels(this.props.limit, 1))); + this.store.dispatch(setDomain(timeInterval)); + this.store.dispatch(setInterval(timeInterval)); + } + ).then(() => Promise.race(racers(fetchText, epochUrls)).then((text) => { + if (!(typeof text.json === 'string' + || text.json instanceof String)) return; + this.store.dispatch( + setEpochs(tsvParse( + text.json.replace('trial_type', 'label')) + .map(({onset, duration, label}, i) => ({ + onset: parseFloat(onset), + duration: parseFloat(duration), + type: i%5 == 0 ? 'Annotation' : 'Event', + label: label, + comment: null, + channels: 'all', + })) + ) + ); + this.store.dispatch(updateFilteredEpochs()); + }) + ); + + Promise.race(racers(fetchText, electrodeUrls)) + .then((text) => { + if (!(typeof text.json === 'string' + || text.json instanceof String)) return; + this.store.dispatch( + setElectrodes( + tsvParse(text.json).map(({name, x, y, z}) => ({ + name: name, + channelIndex: null, + position: [parseFloat(x), parseFloat(y), parseFloat(z)], + })) + ) + ); + }) + .catch((error) => { + console.error(error); + }); + } + + /** + * Renders the React component. + * + * @return {JSX} - React markup for the component + */ + render() { + return {this.props.children}; + } +} + +EEGLabSeriesProvider.defaultProps = { + limit: 6, +}; + +export default EEGLabSeriesProvider; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/eeglab/index.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/eeglab/index.js new file mode 100644 index 00000000000..2ab562789f2 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/eeglab/index.js @@ -0,0 +1 @@ +export {default as EEGLabSeriesProvider} from './EEGLabSeriesProvider'; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/protocol-buffers/chunk_pb.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/protocol-buffers/chunk_pb.js new file mode 100644 index 00000000000..a5b7e452a36 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/protocol-buffers/chunk_pb.js @@ -0,0 +1,259 @@ +/** + * @fileoverview + * @enhanceable + * @suppress {messageConventions} JS Compiler reports an error if a variable or + * field starts with 'MSG_' and isn't a translatable message. + * @public + */ +// GENERATED CODE -- DO NOT EDIT! + +var jspb = require('google-protobuf'); +var goog = jspb; +var global = Function('return this')(); + +goog.exportSymbol('proto.FloatChunk', null, global); + +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.FloatChunk = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, proto.FloatChunk.repeatedFields_, null); +}; +goog.inherits(proto.FloatChunk, jspb.Message); +if (goog.DEBUG && !COMPILED) { + proto.FloatChunk.displayName = 'proto.FloatChunk'; +} +/** + * List of repeated fields within this message type. + * @private {!Array} + * @const + */ +proto.FloatChunk.repeatedFields_ = [4]; + + + +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto suitable for use in Soy templates. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * com.google.apps.jspb.JsClassTemplate.JS_RESERVED_WORDS. + * @param {boolean=} opt_includeInstance Whether to include the JSPB instance + * for transitional soy proto support: http://goto/soy-param-migration + * @return {!Object} + */ +proto.FloatChunk.prototype.toObject = function(opt_includeInstance) { + return proto.FloatChunk.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Whether to include the JSPB + * instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.FloatChunk} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.FloatChunk.toObject = function(includeInstance, msg) { + var f, obj = { + index: jspb.Message.getFieldWithDefault(msg, 1, 0), + downsampling: jspb.Message.getFieldWithDefault(msg, 2, 0), + cutoff: jspb.Message.getFieldWithDefault(msg, 3, 0), + samplesList: jspb.Message.getRepeatedFloatingPointField(msg, 4) + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.FloatChunk} + */ +proto.FloatChunk.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.FloatChunk; + return proto.FloatChunk.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.FloatChunk} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.FloatChunk} + */ +proto.FloatChunk.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + case 1: + var value = /** @type {number} */ (reader.readInt64()); + msg.setIndex(value); + break; + case 2: + var value = /** @type {number} */ (reader.readInt64()); + msg.setDownsampling(value); + break; + case 3: + var value = /** @type {number} */ (reader.readInt64()); + msg.setCutoff(value); + break; + case 4: + var value = /** @type {!Array} */ (reader.readPackedFloat()); + msg.setSamplesList(value); + break; + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.FloatChunk.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.FloatChunk.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.FloatChunk} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.FloatChunk.serializeBinaryToWriter = function(message, writer) { + var f = undefined; + f = message.getIndex(); + if (f !== 0) { + writer.writeInt64( + 1, + f + ); + } + f = message.getDownsampling(); + if (f !== 0) { + writer.writeInt64( + 2, + f + ); + } + f = message.getCutoff(); + if (f !== 0) { + writer.writeInt64( + 3, + f + ); + } + f = message.getSamplesList(); + if (f.length > 0) { + writer.writePackedFloat( + 4, + f + ); + } +}; + + +/** + * optional int64 index = 1; + * @return {number} + */ +proto.FloatChunk.prototype.getIndex = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 1, 0)); +}; + + +/** @param {number} value */ +proto.FloatChunk.prototype.setIndex = function(value) { + jspb.Message.setProto3IntField(this, 1, value); +}; + + +/** + * optional int64 downsampling = 2; + * @return {number} + */ +proto.FloatChunk.prototype.getDownsampling = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 2, 0)); +}; + + +/** @param {number} value */ +proto.FloatChunk.prototype.setDownsampling = function(value) { + jspb.Message.setProto3IntField(this, 2, value); +}; + + +/** + * optional int64 cutoff = 3; + * @return {number} + */ +proto.FloatChunk.prototype.getCutoff = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 3, 0)); +}; + + +/** @param {number} value */ +proto.FloatChunk.prototype.setCutoff = function(value) { + jspb.Message.setProto3IntField(this, 3, value); +}; + + +/** + * repeated float samples = 4; + * @return {!Array} + */ +proto.FloatChunk.prototype.getSamplesList = function() { + return /** @type {!Array} */ (jspb.Message.getRepeatedFloatingPointField(this, 4)); +}; + + +/** @param {!Array} value */ +proto.FloatChunk.prototype.setSamplesList = function(value) { + jspb.Message.setField(this, 4, value || []); +}; + + +/** + * @param {!number} value + * @param {number=} opt_index + */ +proto.FloatChunk.prototype.addSamples = function(value, opt_index) { + jspb.Message.addToRepeatedField(this, 4, value, opt_index); +}; + + +proto.FloatChunk.prototype.clearSamplesList = function() { + this.setSamplesList([]); +}; + + +goog.object.extend(exports, proto); diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/AnnotationForm.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/AnnotationForm.js new file mode 100644 index 00000000000..8bb432663ec --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/AnnotationForm.js @@ -0,0 +1,190 @@ +// @flow + +import React, {useEffect, useState} from 'react'; +import type {Epoch as EpochType, RightPanel} from '../store/types'; +import {connect} from 'react-redux'; +import {setTimeSelection} from '../store/state/timeSelection'; +import {setRightPanel} from '../store/state/rightPanel'; +import * as R from 'ramda'; +import {toggleEpoch, updateActiveEpoch} from '../store/logic/filterEpochs'; + +type Props = { + timeSelection: ?[number, number], + epochs: EpochType[], + filteredEpochs: number[], + setTimeSelection: [?number, ?number] => void, + setRightPanel: RightPanel => void, + toggleEpoch: number => void, + updateActiveEpoch: ?number => void, + interval: [number, number], +}; + +const AnnotationForm = ({ + timeSelection, + epochs, + filteredEpochs, + setTimeSelection, + setRightPanel, + toggleEpoch, + updateActiveEpoch, + interval, +}: Props) => { + const [startEvent = '', endEvent = ''] = timeSelection || []; + let [event, setEvent] = useState([startEvent, endEvent]); + + useEffect(() => { + const [startEvent = '', endEvent = ''] = timeSelection || []; + setEvent([startEvent, endEvent]); + }, [timeSelection]); + + const validate = (event) => ( + (event[0] || event[0] === 0) + && (event[1] || event[1] === 0) + && event[0] <= event[1] + && event[0] >= interval[0] && event[0] <= interval[1] + && event[1] >= interval[0] && event[1] <= interval[1] + ); + + return ( +
+
+ New Annotation + { + setRightPanel(null); + }} + > +
+
+
+
+ + { + const value = parseInt(e.target.value); + setEvent([value, event[1]]); + + if (validate([value, event[1]])) { + setTimeSelection( + [ + parseInt(value) || null, + parseInt(event[1]) || null, + ] + ); + } + }} + value={event[0]} + /> +
+
+ + { + const value = parseInt(e.target.value); + setEvent([event[0], value]); + + if (validate([event[0], value])) { + setTimeSelection([parseInt(event[0]) || null, value]); + } + }} + value={event[1]} + /> +
+
+
+ + +
+
+ + +
+ +
+
+ ); +}; + +AnnotationForm.defaultProps = { + timeSelection: null, + epochs: [], + filteredEpochs: [], +}; + +export default connect( + (state)=> ({ + timeSelection: state.timeSelection, + epochs: state.dataset.epochs, + filteredEpochs: state.dataset.filteredEpochs, + interval: state.bounds.interval, + }), + (dispatch: (any) => void) => ({ + setTimeSelection: R.compose( + dispatch, + setTimeSelection + ), + setRightPanel: R.compose( + dispatch, + setRightPanel + ), + toggleEpoch: R.compose( + dispatch, + toggleEpoch + ), + updateActiveEpoch: R.compose( + dispatch, + updateActiveEpoch + ), + }) +)(AnnotationForm); diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/Axis.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/Axis.js new file mode 100644 index 00000000000..b27d37a0249 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/Axis.js @@ -0,0 +1,52 @@ +// @flow + +import {scaleLinear} from 'd3-scale'; +import {Axis as VxAxis} from '@visx/axis'; + +type Props = { + orientation: 'top' | 'right' | 'bottom' | 'left', + domain: [number, number], + range: [number, number], + ticks: number, + padding: number, + format: number => string, + hideLine: bool, +}; + +const Axis = ({ + orientation, + domain, + range, + ticks, + padding, + format, + hideLine, +}: Props) => { + const scale = scaleLinear() + .domain(domain) + .range(range); + + let tickValues = scale.ticks(ticks); + tickValues = tickValues.slice(padding, tickValues.length - padding); + + return ( + + ); +}; + +Axis.defaultProps = { + orientation: 'bottom', + domain: [0, 1], + ticks: 10, + padding: 0, + hideLine: false, + format: (tick) => `${tick}`, +}; + +export default Axis; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/EEGMontage.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/EEGMontage.js new file mode 100644 index 00000000000..2e25a0214cf --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/EEGMontage.js @@ -0,0 +1,290 @@ +// @flow + +import * as R from 'ramda'; +import {connect} from 'react-redux'; +import {_3d} from 'd3-3d'; +import {Group} from '@visx/group'; +import ResponsiveViewer from './ResponsiveViewer'; +import type {Electrode} from '../../series/store/types'; +import {setHidden} from '../../series/store/state/montage'; +import React, {useState} from 'react'; +import Panel from 'jsx/Panel'; + +type Props = { + electrodes: Electrode[], + hidden: number[], + drag: bool, + mx: number, + my: number, + mouseX: number, + mouseY: number, + setHidden: (number[]) => void, +}; + +const EEGMontage = ( + { + electrodes, + hidden, + setHidden, + }: Props) => { + if (electrodes.length === 0) return null; + + const [angleX, setAngleX] = useState(0); + const [angleZ, setAngleZ] = useState(0); + const [drag, setDrag] = useState(false); + const [mx, setMx] = useState(0); + const [my, setMy] = useState(0); + const [mouseX, setMouseX] = useState(0); + const [mouseY, setMouseY] = useState(0); + const [view3D, setView3D] = useState(false); + + const scale = 1200; + let scatter3D = []; + let scatter2D = []; + const startAngle = 0; + const color = '#000000'; + + let point3D = _3d() + .x((d) => d.x) + .y((d) => d.y) + .z((d) => d.z) + .rotateZ( startAngle) + .rotateX(-startAngle) + .scale(scale); + + const dragStart = (v) => { + setDrag(true); + setMx(v[0]); + setMy(v[1]); + }; + + const dragged = (v) => { + if (!drag) return; + const beta = (v[0] - mx + mouseX) * -2 * Math.PI; + const alpha = (v[1] - my + mouseY) * -2 * Math.PI; + + const angleX = Math.min(Math.PI/2, Math.max(0, alpha - startAngle)); + setAngleX(angleX); + + const angleZ = (beta + startAngle); + setAngleZ(angleZ); + }; + + const dragEnd = (v) => { + setDrag( false); + setMouseX( v[0] - mx + mouseX); + setMouseY(v[1] - my + mouseY); + }; + + /** + * Compute the stereographic projection. + * + * Given a unit sphere with radius r = 1 and center at The origin. + * Project the point p = (x, y, z) from the sphere's South pole (0, 0, -1) + * on a plane on the sphere's North pole (0, 0, 1). + * + * P' = P * (2r / (r + z)) + * + * @param {number} x - x coordinate of electrodes on a unit sphere scale + * @param {number} y - x coordinate of electrodes on a unit sphere scale + * @param {number} z - x coordinate of electrodes on a unit sphere scale + * @param {number} scale - Scale to change the projection point.Defaults to 1, which is on the sphere + * + * @return {number[]} : x, y positions of electrodes as projected onto a unit circle. + */ + const stereographicProjection = (x, y, z, scale=1.0) => { + const mu = 1.0 / (scale + z); + return [x * mu, y * mu]; + }; + + electrodes.map((electrode, i) => { + scatter3D.push({ + x: electrode.position[0], + y: electrode.position[1], + z: electrode.position[2], + }); + const [x, y] = stereographicProjection( + electrode.position[0] * 10, + electrode.position[1] * 10, + electrode.position[2] * 10 + ); + scatter2D.push({x: x * 150, y: y * 150 / 0.8}); + }); + + const Montage3D = () => ( + + {point3D.rotateZ(angleZ).rotateX(angleX)(scatter3D).map((point, i) => { + return ( + + ); + })} + + ); + + const Montage2D = () => ( + + + + + + + {scatter2D.map((point, i) => + + + {i + 1} + + )} + + ); + + return ( + +
+
+
+ {electrodes.map((electrode, i) => { + return ( +
+ {i+1}. + {electrode.name} +
+ ); + })} +
+
+
+ {view3D ? +
+ + + +
+ : +
+ + + +
+ } +
+ + +
+
+
+
+ ); +}; + +EEGMontage.defaultProps = { + montage: [], + hidden: [], +}; + +export default connect( + (state) => ({ + hidden: state.montage.hidden, + electrodes: state.montage.electrodes, + }), + (dispatch: any => void) => ({ + setHidden: R.compose( + dispatch, + setHidden, + ), + }) +)(EEGMontage); diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/Epoch.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/Epoch.js new file mode 100644 index 00000000000..6c323b36cbf --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/Epoch.js @@ -0,0 +1,55 @@ +// @flow + +import {vec2} from 'gl-matrix'; +import {MIN_EPOCH_WIDTH} from '../../vector'; + +type Props = { + parentHeight: number, + onset: number, + duration: number, + scales: [any, any], + color: string, + opacity: number, +}; + +const Epoch = ( + { + parentHeight, + onset, + duration, + scales, + color, + opacity, + }: Props) => { + const start = vec2.fromValues( + scales[0](onset), + scales[1](-parentHeight/2), + ); + + const end = vec2.fromValues( + scales[0](onset + duration) + MIN_EPOCH_WIDTH, + scales[1](parentHeight/2) + ); + + const width = Math.abs(end[0] - start[0]); + const height = Math.abs(end[1] - start[1]); + const center = (start[0] + end[0]) / 2; + + return ( + + ); +}; + +Epoch.defaultProps = { + color: '#dae5f2', + opacity: 1, +}; + +export default Epoch; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/EventManager.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/EventManager.js new file mode 100644 index 00000000000..8c85b9e982d --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/EventManager.js @@ -0,0 +1,146 @@ +// @flow + +import React from 'react'; +import type {Epoch as EpochType, RightPanel} from '../store/types'; +import {connect} from 'react-redux'; +import {setTimeSelection} from '../store/state/timeSelection'; +import {setRightPanel} from '../store/state/rightPanel'; +import * as R from 'ramda'; +import {toggleEpoch, updateActiveEpoch} from '../store/logic/filterEpochs'; + +type Props = { + timeSelection: ?[number, number], + epochs: EpochType[], + filteredEpochs: number[], + setTimeSelection: [?number, ?number] => void, + setRightPanel: RightPanel => void, + toggleEpoch: number => void, + updateActiveEpoch: ?number => void, + interval: [number, number], +}; + +const EventManager = ({ + epochs, + filteredEpochs, + setTimeSelection, + setRightPanel, + toggleEpoch, + updateActiveEpoch, + interval, +}: Props) => { + return ( + <> +
+
+ Events/Annotations
+ in timeline view + { + setRightPanel(null); + }} + > +
+
+
+ {[...Array(epochs.length).keys()].filter((index) => + epochs[index].onset + epochs[index].duration > interval[0] + && epochs[index].onset < interval[1] + ).map((index) => { + const epoch = epochs[index]; + const visible = filteredEpochs.includes(index); + return ( +
+ {epoch.label}
+ {epoch.onset}{epoch.duration > 0 + && ' - ' + (epoch.onset + epoch.duration)} + +
+ ); + })} +
+
+
+ + ); +}; + +EventManager.defaultProps = { + timeSelection: null, + epochs: [], + filteredEpochs: [], +}; + +export default connect( + (state)=> ({ + timeSelection: state.timeSelection, + epochs: state.dataset.epochs, + filteredEpochs: state.dataset.filteredEpochs, + interval: state.bounds.interval, + }), + (dispatch: (any) => void) => ({ + setTimeSelection: R.compose( + dispatch, + setTimeSelection + ), + setRightPanel: R.compose( + dispatch, + setRightPanel + ), + toggleEpoch: R.compose( + dispatch, + toggleEpoch + ), + updateActiveEpoch: R.compose( + dispatch, + updateActiveEpoch + ), + }) +)(EventManager); diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/IntervalSelect.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/IntervalSelect.js new file mode 100644 index 00000000000..3d92fad9bdf --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/IntervalSelect.js @@ -0,0 +1,195 @@ +// @flow + +import * as R from 'ramda'; +import {vec2} from 'gl-matrix'; +import {Group} from '@visx/group'; +import {connect} from 'react-redux'; +import {scaleLinear} from 'd3-scale'; +import { + startDragInterval, + continueDragInterval, + endDragInterval, +} from '../store/logic/dragBounds'; +import ResponsiveViewer from './ResponsiveViewer'; +import Axis from './Axis'; +import React, {useCallback, useEffect, useState} from 'react'; +import {setInterval} from '../store/state/bounds'; +import {updateFilteredEpochs} from '../store/logic/filterEpochs'; + +type Props = { + viewerHeight: number, + seriesViewerWidth: number, + domain: [number, number], + interval: [number, number], + setInterval: [number, number] => void, + dragStart: number => void, + dragContinue: number => void, + dragEnd: number => void, + updateFilteredEpochs: void => void, +}; + +const IntervalSelect = ({ + viewerHeight, + seriesViewerWidth, + domain, + interval, + setInterval, + dragStart, + dragContinue, + dragEnd, + updateFilteredEpochs, +}: Props) => { + const [refNode, setRefNode] = useState(null); + const [bounds, setBounds] = useState(null); + + useEffect(() => { + if (refNode) { + setBounds(refNode.getBoundingClientRect()); + } + }, [seriesViewerWidth]); + + const getNode = useCallback((domNode) => { + if (domNode) { + setRefNode(domNode); + } + }, []); + + const topLeft = vec2.fromValues( + -seriesViewerWidth/2, + viewerHeight/2 + ); + const bottomRight = vec2.fromValues( + seriesViewerWidth/2, + -viewerHeight/2 + ); + + const scale = scaleLinear() + .domain(domain) + .range([-seriesViewerWidth/2, seriesViewerWidth/2]); + + const ySlice = (x) => ({ + p0: vec2.fromValues(x, topLeft[1]), + p1: vec2.fromValues(x, bottomRight[1]), + }); + + const start = ySlice(scale(interval[0])).p1[0]; + const end = ySlice(scale(interval[1])).p0[0]; + const width = Math.abs(end - start); + const center = (start + end) / 2; + + const BackShadowLayer = ({interval}) => ( + + ); + + const AxisLayer = ({viewerWidth, viewerHeight, domain}) => ( + + + + ); + + const onMouseMove = (v : MouseEvent) => { + if (bounds === null || bounds === undefined) return; + const x = Math.min(1, Math.max(0, (v.pageX - bounds.left)/bounds.width)); + dragContinue(x); + }; + + const onMouseUp = (v : MouseEvent) => { + if (bounds === null || bounds === undefined) return; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + const x = Math.min(100, Math.max(0, (v.pageX - bounds.left)/bounds.width)); + + dragEnd(x); + updateFilteredEpochs(); + }; + + return ( +
+
+ Timeline range view + { + setInterval([domain[0], domain[1]]); + updateFilteredEpochs(); + }} + value='Reset' + /> +
+
+ { + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + R.compose(dragStart, R.nth(0))(v); + }} + > + + + +
+
+ ); +}; + +IntervalSelect.defaultProps = { + viewerHeight: 50, + seriesViewerWidth: 400, + domain: [0, 1], + interval: [0.25, 0.75], +}; + +export default connect( + (state) => ({ + domain: state.bounds.domain, + interval: state.bounds.interval, + seriesViewerWidth: state.bounds.viewerWidth, + }), + (dispatch: any => void) => ({ + dragStart: R.compose( + dispatch, + startDragInterval + ), + dragContinue: R.compose( + dispatch, + continueDragInterval + ), + dragEnd: R.compose( + dispatch, + endDragInterval + ), + updateFilteredEpochs: R.compose( + dispatch, + updateFilteredEpochs + ), + setInterval: R.compose( + dispatch, + setInterval + ), + }) +)(IntervalSelect); diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/LineChunk.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/LineChunk.js new file mode 100644 index 00000000000..319ae015934 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/LineChunk.js @@ -0,0 +1,123 @@ +// @flow + +import * as R from 'ramda'; +import {scaleLinear} from 'd3-scale'; +import {vec2} from 'gl-matrix'; +import {colorOrder} from '../../color'; +import type {Chunk} from '../store/types'; +import {LinePath} from '@visx/shape'; +import {Group} from '@visx/group'; + +const LineMemo = R.memoizeWith( + ({interval, amplitudeScale, filters, channelIndex, traceIndex, chunkIndex}) => + `${interval.join(',')},${amplitudeScale},${filters.join('-')},` + + `${channelIndex}-${traceIndex}-${chunkIndex}`, + ({ + channelIndex, + traceIndex, + chunkIndex, + interval, + seriesRange, + amplitudeScale, + filters, + values, + color, + ...rest +}) => { + const scales = [ + scaleLinear() + .domain(interval) + .range([-0.5, 0.5]), + scaleLinear() + .domain(seriesRange.map((x) => (x * amplitudeScale))) + .range([-0.5, 0.5]), + ]; + + const points = values.map((value, i) => + vec2.fromValues( + scales[0]( + interval[0] + (i / values.length) * (interval[1] - interval[0]) + ), + -scales[1](value) + ) + ); + + return ( + + ); + } +); + +type Props = { + channelIndex: number, + traceIndex: number, + chunkIndex: number, + chunk: Chunk, + seriesRange: [number, number], + amplitudeScale: number, + scales: [any, any], + color?: string +}; + +const LineChunk = ({ + channelIndex, + traceIndex, + chunkIndex, + chunk, + seriesRange, + amplitudeScale, + scales, + color, + ...rest +}: Props) => { + const {interval, values} = chunk; + + if (values.length === 0) { + return ; + } + + const range = scales[1].range(); + const chunkLength = Math.abs(scales[0](interval[1]) - scales[0](interval[0])); + const chunkHeight = Math.abs(range[1] - range[0]); + + const p0 = vec2.fromValues( + (scales[0](interval[0]) + scales[0](interval[1])) / 2, + (range[0] + range[1]) / 2 + ); + + const lineColor = colorOrder(channelIndex) || '#999'; + + return ( + + + + + + ); +}; + +export default LineChunk; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/ResponsiveViewer.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/ResponsiveViewer.js new file mode 100644 index 00000000000..e86d49935a2 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/ResponsiveViewer.js @@ -0,0 +1,111 @@ +// @flow + +import * as R from 'ramda'; +import React from 'react'; +import type {Node} from 'react'; +import {scaleLinear} from 'd3-scale'; +import {withParentSize} from '@visx/responsive'; +import type {Vector2} from '../../vector'; + +type Props = { + parentWidth: number, + parentHeight: number, + mouseDown: Vector2 => void, + mouseMove: Vector2 => void, + mouseUp: Vector2 => void, + mouseLeave: Vector2 => void, + children: Node +}; + +const ResponsiveViewer = ({ + parentWidth, + parentHeight, + mouseDown, + mouseMove, + mouseUp, + mouseLeave, + children, +}: Props) => { + const provision = (layer) => + React.cloneElement( + layer, + {viewerWidth: parentWidth, viewerHeight: parentHeight} + ); + + const layers = React.Children.toArray(children).map(provision); + + const domain = window.EEGLabSeriesProviderStore.getState().bounds.domain; + const amplitude = [0, 1]; + const eventScale = [ + scaleLinear() + .domain(domain) + .range([-parentWidth/2, parentWidth/2]), + scaleLinear() + .domain(amplitude) + .range([-parentHeight/2, parentHeight/2]), + ]; + + const eventToPosition = (e) => { + const { + top, + left, + width, + height, + } = e.currentTarget.getBoundingClientRect(); + return [ + Math.min( + 1, + Math.max( + 0, + eventScale[0].invert( + eventScale[0]((e.clientX - left) / width) + ) + ) + ), + eventScale[1].invert(eventScale[1]((e.clientY - top) / height)), + ]; + }; + + return ( + + {layers} + + ); +}; + +ResponsiveViewer.defaultProps = { + parentWidth: 400, + parentHeight: 300, + mouseMove: () => {}, + mouseDown: () => {}, + mouseUp: () => {}, + mouseLeave: () => {}, +}; + +export default withParentSize(ResponsiveViewer); diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/SeriesCursor.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/SeriesCursor.js new file mode 100644 index 00000000000..9d838be9d10 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/SeriesCursor.js @@ -0,0 +1,219 @@ +// @flow + +import * as R from 'ramda'; +import type {Node} from 'react'; +import {bisector} from 'd3-array'; +import {colorOrder} from '../../color'; +import type {Channel, Epoch} from '../store/types'; +import {connect} from 'react-redux'; +import {MAX_RENDERED_EPOCHS} from '../../vector'; +import {useEffect} from 'react'; + +type CursorContentProps = { + time: number, + channel: Channel, + contentIndex: number, + showMarker: boolean, +}; + +type Props = { + cursor: number, + channels: Channel[], + epochs: Epoch[], + filteredEpochs: number[], + CursorContent: CursorContentProps => Node, + interval: [number, number], + showMarker: boolean +}; + +const SeriesCursor = ( + { + cursor, + channels, + epochs, + filteredEpochs, + CursorContent, + interval, + showMarker, + }: Props +) => { + let reversedEpochs = [...filteredEpochs].reverse(); + useEffect(() => { + reversedEpochs = [...filteredEpochs].reverse(); + }, [filteredEpochs]); + + const left = Math.min(Math.max(100 * cursor, 0), 100) + '%'; + const time = interval[0] + cursor * (interval[1] - interval[0]); + + const Cursor = () => ( +
+ ); + + const ValueTags = () => ( +
+ {channels.map((channel, i) => ( +
+ +
+ ))} +
+ ); + + const TimeMarker = () => ( +
+ {time} +
+ ); + + const EpochMarker = () => { + if (reversedEpochs.length > MAX_RENDERED_EPOCHS) return null; + + const index = reversedEpochs.find((index) => + epochs[index].onset < time + ); + + return index !== undefined ? ( +
+ {epochs[index].label} +
+ ) : null; + }; + + return ( +
+ + + + +
+ ); +}; + +const createIndices = R.memoizeWith( + R.identity, + (array) => array.map((_, i) => i) +); + +const indexToTime = (chunk) => (index) => + chunk.interval[0] + + (index / chunk.values.length) * (chunk.interval[1] - chunk.interval[0]); + +const CursorContent = ({time, channel, contentIndex, showMarker}) => { + const Marker = ({color}) => ( +
+ ); + + return ( +
+ {channel.traces.map((trace, i) => { + const chunk = trace.chunks.find( + (chunk) => chunk.interval[0] <= time && chunk.interval[1] >= time + ); + const computeValue = (chunk) => { + const indices = createIndices(chunk.values); + const bisectTime = bisector(indexToTime(chunk)).left; + const idx = bisectTime(indices, time); + const value = chunk.values[idx-1]; + + return value; + }; + + return ( +
+ {showMarker && ()} + {chunk && computeValue(chunk)} +
+ ); + })} +
+ ); +}; + +SeriesCursor.defaultProps = { + channels: [], + epochs: [], + filteredEpochs: [], + CursorContent, + showMarker: false, +}; + +export default connect( + (state)=> ({ + epochs: state.dataset.epochs, + filteredEpochs: state.dataset.filteredEpochs, + }) +)(SeriesCursor); diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/SeriesRenderer.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/SeriesRenderer.js new file mode 100644 index 00000000000..56fa70ad775 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/SeriesRenderer.js @@ -0,0 +1,721 @@ +// @flow + +import React, {useCallback, useEffect, useState} from 'react'; +import * as R from 'ramda'; +import {vec2} from 'gl-matrix'; +import {Group} from '@visx/group'; +import {connect} from 'react-redux'; +import {scaleLinear} from 'd3-scale'; +import {MAX_RENDERED_EPOCHS, MAX_CHANNELS} from '../../vector'; +import ResponsiveViewer from './ResponsiveViewer'; +import Axis from './Axis'; +import LineChunk from './LineChunk'; +import Epoch from './Epoch'; +import SeriesCursor from './SeriesCursor'; +import {setCursor} from '../store/state/cursor'; +import {setRightPanel} from '../store/state/rightPanel'; +import {setFilteredEpochs} from '../store/state/dataset'; +import {setOffsetIndex} from '../store/logic/pagination'; +import IntervalSelect from './IntervalSelect'; +import EventManager from './EventManager'; +import AnnotationForm from './AnnotationForm'; +import Panel from 'jsx/Panel'; + +import { + setAmplitudesScale, + resetAmplitudesScale, +} from '../store/logic/scaleAmplitudes'; +import { + LOW_PASS_FILTERS, + setLowPassFilter, + HIGH_PASS_FILTERS, + setHighPassFilter, +} from '../store/logic/highLowPass'; +import { + setViewerWidth, + setViewerHeight, +} from '../store/state/bounds'; +import { + continueDragSelection, + endDragSelection, + startDragSelection, +} from '../store/logic/timeSelection'; + +import type { + ChannelMetadata, + Channel, + Epoch as EpochType, + RightPanel, +} from '../store/types'; + +type Props = { + viewerWidth: number, + viewerHeight: number, + interval: [number, number], + amplitudeScale: number, + rightPanel: RightPanel, + cursor: ?number, + timeSelection: ?[number, number], + setCursor: (?number) => void, + setRightPanel: RightPanel => void, + channels: Channel[], + channelMetadata: ChannelMetadata[], + hidden: number[], + epochs: EpochType[], + filteredEpochs: number[], + activeEpoch: number, + offsetIndex: number, + setOffsetIndex: number => void, + setAmplitudesScale: number => void, + resetAmplitudesScale: void => void, + setLowPassFilter: string => void, + setHighPassFilter: string => void, + setViewerWidth: number => void, + setViewerHeight: number => void, + setFilteredEpochs: number[] => void, + dragStart: number => void, + dragContinue: number => void, + dragEnd: number => void, + limit: number, +}; + +const SeriesRenderer = ({ + viewerHeight, + viewerWidth, + interval, + amplitudeScale, + cursor, + rightPanel, + timeSelection, + setCursor, + setRightPanel, + channels, + channelMetadata, + hidden, + epochs, + filteredEpochs, + activeEpoch, + offsetIndex, + setOffsetIndex, + setAmplitudesScale, + resetAmplitudesScale, + setLowPassFilter, + setHighPassFilter, + setViewerWidth, + setViewerHeight, + setFilteredEpochs, + dragStart, + dragContinue, + dragEnd, + limit, +}: Props) => { + if (channels.length === 0) return null; + + useEffect(() => { + setViewerHeight(viewerHeight); + }, [viewerHeight]); + + useEffect(() => { + if (refNode) { + setBounds(refNode.getBoundingClientRect()); + } + }, [viewerWidth]); + + const [highPass, setHighPass] = useState('none'); + const [lowPass, setLowPass] = useState('none'); + const [refNode, setRefNode] = useState(null); + const [bounds, setBounds] = useState(null); + const getBounds = useCallback((domNode) => { + if (domNode) { + setRefNode(domNode); + } + }, []); + + const topLeft = vec2.fromValues( + -viewerWidth/2, + viewerHeight/2 + ); + + const bottomRight = vec2.fromValues( + viewerWidth/2, + -viewerHeight/2 + ); + + const diagonal = vec2.create(); + vec2.sub(diagonal, bottomRight, topLeft); + + const center = vec2.create(); + vec2.add(center, topLeft, bottomRight); + vec2.scale(center, center, 1 / 2); + + const scales = [ + scaleLinear() + .domain(interval) + .range([topLeft[0], bottomRight[0]]), + scaleLinear() + .domain([-viewerHeight/2, viewerHeight/2]) + .range([topLeft[1], bottomRight[1]]), + ]; + + const filteredChannels = channels.filter((_, i) => !hidden.includes(i)); + + const XAxisLayer = ({viewerWidth, viewerHeight, interval}) => { + return ( + <> + + + + + + + + ); + }; + + const EpochsLayer = () => { + return ( + + {filteredEpochs.length < MAX_RENDERED_EPOCHS && + filteredEpochs.map((index) => { + return ( + + ); + }) + } + {timeSelection && + + } + {activeEpoch !== null && + + } + + ); + }; + + const ChannelAxesLayer = ({viewerWidth, viewerHeight}) => { + const axisHeight = viewerHeight / MAX_CHANNELS; + return ( + + + {filteredChannels.map((channel, i) => { + const seriesRange = channelMetadata[channel.index].seriesRange; + return ( + ''} + orientation='right' + hideLine={true} + /> + ); + })} + + ); + }; + + const ChannelsLayer = ({viewerWidth}) => { + useEffect(() => { + setViewerWidth(viewerWidth); + }, [viewerWidth]); + + return ( + <> + + + + + {filteredChannels.map((channel, i) => { + if (!channelMetadata[channel.index]) { + return null; + } + const subTopLeft = vec2.create(); + vec2.add( + subTopLeft, + topLeft, + vec2.fromValues(0, (i * diagonal[1]) / MAX_CHANNELS) + ); + + const subBottomRight = vec2.create(); + vec2.add( + subBottomRight, + topLeft, + vec2.fromValues( + diagonal[0], + ((i + 1) * diagonal[1]) / MAX_CHANNELS + ) + ); + + const subDiagonal = vec2.create(); + vec2.sub(subDiagonal, subBottomRight, subTopLeft); + + const axisEnd = vec2.create(); + vec2.add(axisEnd, subTopLeft, vec2.fromValues(0.1, subDiagonal[1])); + + const seriesRange = channelMetadata[channel.index].seriesRange; + const scales = [ + scaleLinear() + .domain(interval) + .range([subTopLeft[0], subBottomRight[0]]), + scaleLinear() + .domain(seriesRange) + .range([subTopLeft[1], subBottomRight[1]]), + ]; + + return ( + channel.traces.map((trace, j) => ( + trace.chunks.map((chunk, k) => ( + + )) + )) + ); + })} + + ); + }; + + const hardLimit = Math.min(offsetIndex + limit - 1, channelMetadata.length); + + const onMouseMove = (v : MouseEvent) => { + if (bounds === null || bounds === undefined) return; + const x = Math.min(1, Math.max(0, (v.pageX - bounds.left)/bounds.width)); + return (dragContinue)(x); + }; + + const onMouseUp = (v : MouseEvent) => { + if (bounds === null || bounds === undefined) return; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + const x = Math.min(100, Math.max(0, (v.pageX - bounds.left)/bounds.width)); + return (dragEnd)(x); + }; + + return ( + + {channels.length > 0 ? ( + <> +
+
+ +
+
+
+
+
+
+ setAmplitudesScale(1.1)} + value='-' + /> + resetAmplitudesScale()} + value='Reset' + /> + setAmplitudesScale(0.9)} + value='+' + /> +
+
+ +
    + {Object.keys(HIGH_PASS_FILTERS).map((key) => +
  • { + setHighPassFilter(key); + setHighPass(key); + }} + >{HIGH_PASS_FILTERS[key].label}
  • + )} +
+
+ +
+ +
    + {Object.keys(LOW_PASS_FILTERS).map((key) => +
  • { + setLowPassFilter(key); + setLowPass(key); + }} + >{LOW_PASS_FILTERS[key].label}
  • + )} +
+
+
+
+ {filteredEpochs.length >= MAX_RENDERED_EPOCHS && +
+ Too many events to display for the timeline range. + Limit the time range. +
+ } +
+
+
+
+
+
+ {filteredChannels.map((channel) => ( +
+ {channelMetadata[channel.index] && + channelMetadata[channel.index].name} +
+ ))} +
+
setCursor(null)} + > +
+ {cursor && ( + + )} +
+ { + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + R.compose(dragStart, R.nth(0))(v); + }} + > + + + + + +
+
+
+
+
+
+
+ {/* + + */} + {epochs.length > 0 && + + } + +
+ + Showing{' '} + setOffsetIndex(e.target.value)} + /> + {' '} + to {hardLimit} of {channelMetadata.length} + +
+ setOffsetIndex(offsetIndex - limit)} + value='<<' + /> + setOffsetIndex(offsetIndex - 1)} + value='<' + /> + setOffsetIndex(offsetIndex + 1)} + value='>' + /> + setOffsetIndex(offsetIndex + limit)} + value='>>' + /> +
+
+
+
+
+ {rightPanel && +
+ {rightPanel === 'annotationForm' && } + {rightPanel === 'epochList' && } +
+ } +
+ + ) : ( +
+

Loading...

+
+ )} + + ); +}; + +SeriesRenderer.defaultProps = { + interval: [0.25, 0.75], + amplitudeScale: 1, + viewerHeight: 400, + viewerSize: [400, 400], + channels: [], + epochs: [], + hidden: [], + channelMetadata: [], + offsetIndex: 1, + limit: 6, +}; + +export default connect( + (state)=> ({ + viewerWidth: state.bounds.viewerWidth, + viewerHeight: state.bounds.viewerHeight, + interval: state.bounds.interval, + amplitudeScale: state.bounds.amplitudeScale, + cursor: state.cursor, + rightPanel: state.rightPanel, + timeSelection: state.timeSelection, + channels: state.dataset.channels, + epochs: state.dataset.epochs, + filteredEpochs: state.dataset.filteredEpochs, + activeEpoch: state.dataset.activeEpoch, + hidden: state.montage.hidden, + channelMetadata: state.dataset.channelMetadata, + offsetIndex: state.dataset.offsetIndex, + }), + (dispatch: (any) => void) => ({ + setOffsetIndex: R.compose( + dispatch, + setOffsetIndex + ), + setCursor: R.compose( + dispatch, + setCursor + ), + setRightPanel: R.compose( + dispatch, + setRightPanel + ), + setAmplitudesScale: R.compose( + dispatch, + setAmplitudesScale + ), + resetAmplitudesScale: R.compose( + dispatch, + resetAmplitudesScale + ), + setLowPassFilter: R.compose( + dispatch, + setLowPassFilter + ), + setHighPassFilter: R.compose( + dispatch, + setHighPassFilter + ), + setViewerWidth: R.compose( + dispatch, + setViewerWidth + ), + setViewerHeight: R.compose( + dispatch, + setViewerHeight + ), + setFilteredEpochs: R.compose( + dispatch, + setFilteredEpochs + ), + dragStart: R.compose( + dispatch, + startDragSelection + ), + dragContinue: R.compose( + dispatch, + continueDragSelection + ), + dragEnd: R.compose( + dispatch, + endDragSelection + ), + }) +)(SeriesRenderer); diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/index.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/index.js new file mode 100644 index 00000000000..49e1ad78312 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/index.js @@ -0,0 +1,75 @@ +// @flow + +import * as R from 'ramda'; +import {combineReducers} from 'redux'; +import {combineEpics} from 'redux-observable'; +import {boundsReducer} from './state/bounds'; +import {filtersReducer} from './state/filters'; +import {datasetReducer} from './state/dataset'; +import {cursorReducer} from './state/cursor'; +import {panelReducer} from './state/rightPanel'; +import {timeSelectionReducer} from './state/timeSelection'; +import {montageReducer} from './state/montage'; +import {createDragBoundsEpic} from './logic/dragBounds'; +import {createTimeSelectionEpic} from './logic/timeSelection'; +import {createFetchChunksEpic} from './logic/fetchChunks'; +import {createPaginationEpic} from './logic/pagination'; +import { + createActiveEpochEpic, + createFilterEpochsEpic, + createToggleEpochEpic, +} from './logic/filterEpochs'; +import { + createScaleAmplitudesEpic, + createResetAmplitudesEpic, +} from './logic/scaleAmplitudes'; +import { + createLowPassFilterEpic, + createHighPassFilterEpic, +} from './logic/highLowPass'; + +export const rootReducer = combineReducers({ + bounds: boundsReducer, + filters: filtersReducer, + dataset: datasetReducer, + cursor: cursorReducer, + rightPanel: panelReducer, + timeSelection: timeSelectionReducer, + montage: montageReducer, +}); + +export const rootEpic = combineEpics( + createDragBoundsEpic(R.prop('bounds')), + createTimeSelectionEpic(({bounds, timeSelection}) => { + const {interval} = bounds; + return {interval, timeSelection}; + }), + createFetchChunksEpic(({bounds, dataset}) => ({ + bounds, + dataset, + })), + createPaginationEpic(({dataset}) => { + const {limit, channelMetadata, channels} = dataset; + return {limit, channelMetadata, channels}; + }), + createScaleAmplitudesEpic(({bounds}) => { + const {amplitudeScale} = bounds; + return amplitudeScale; + }), + createResetAmplitudesEpic(), + createLowPassFilterEpic(), + createHighPassFilterEpic(), + createFilterEpochsEpic(({bounds, dataset}) => { + const {interval} = bounds; + const {epochs} = dataset; + return {interval, epochs}; + }), + createToggleEpochEpic(({dataset}) => { + const {epochs, filteredEpochs} = dataset; + return {filteredEpochs, epochs}; + }), + createActiveEpochEpic(({dataset}) => { + const {epochs} = dataset; + return {epochs}; + }), +); diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/dragBounds.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/dragBounds.js new file mode 100644 index 00000000000..fac1ca8fa8c --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/dragBounds.js @@ -0,0 +1,97 @@ +// @flow + +import * as R from 'ramda'; +import {Observable, merge} from 'rxjs'; +import * as Rx from 'rxjs/operators'; +import {ofType} from 'redux-observable'; +import {createAction} from 'redux-actions'; +import {SET_INTERVAL, setInterval} from '../state/bounds'; +import {updateViewedChunks} from './fetchChunks'; + +import type { + State as BoundsState, + Action as BoundsAction, +} from '../state/bounds'; +import {MIN_INTERVAL_FACTOR} from '../../../vector'; + +export const START_DRAG_INTERVAL = 'START_DRAG_INTERVAL'; +export const startDragInterval = createAction(START_DRAG_INTERVAL); + +export const CONTINUE_DRAG_INTERVAL = 'CONTINUE_DRAG_INTERVAL'; +export const continueDragInterval = createAction(CONTINUE_DRAG_INTERVAL); + +export const END_DRAG_INTERVAL = 'END_DRAG_INTERVAL'; +export const endDragInterval = createAction(END_DRAG_INTERVAL); + +export type Action = BoundsAction | { type: 'UPDATE_VIEWED_CHUNKS' }; + +export const createDragBoundsEpic = (fromState: any => BoundsState) => ( + action$: Observable, + state$: Observable +): Observable => { + let draggedEnd = null; + + const startDrag$ = action$.pipe( + ofType(START_DRAG_INTERVAL), + Rx.map(R.prop('payload')) + ); + + const continueDrag$ = action$.pipe( + ofType(CONTINUE_DRAG_INTERVAL), + Rx.map(R.prop('payload')) + ); + + const endDrag$ = action$.pipe( + ofType(END_DRAG_INTERVAL), + Rx.map(() => { + draggedEnd = null; + }) + ); + + const computeNewInterval = ([position, state]) => { + const {interval, domain} = R.clone(fromState(state)); + const x = position * domain[1]; + const minSize = Math.abs(domain[1] - domain[0]) * MIN_INTERVAL_FACTOR; + + if (draggedEnd === null) { + draggedEnd = Math.abs(x - interval[0]) < Math.abs(x - interval[1]) + ? 0 + : 1; + } + + const [i0, i1] = draggedEnd === 0 + ? [0, 1] + : [1, 0]; + + const sign = Math.sign(interval[i1] - interval[i0]); + interval[i0] = x; + interval[i0] += + sign > 0 + ? Math.min(interval[i1] - minSize - interval[i0], 0) + : Math.max(interval[i1] + minSize - interval[i0], 0); + + return setInterval(interval); + }; + + const startUpdates$ = startDrag$.pipe( + Rx.withLatestFrom(state$), + Rx.map(computeNewInterval) + ); + + const dragUpdates$ = startDrag$.pipe( + Rx.switchMap(() => + continueDrag$.pipe( + Rx.withLatestFrom(state$), + Rx.map(computeNewInterval), + Rx.takeUntil(endDrag$) + ) + ) + ); + + const updateViewedChunks$ = action$.pipe( + ofType(SET_INTERVAL), + Rx.mapTo(updateViewedChunks()) + ); + + return merge(startUpdates$, dragUpdates$, updateViewedChunks$); +}; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/fetchChunks.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/fetchChunks.js new file mode 100644 index 00000000000..9d2244be11c --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/fetchChunks.js @@ -0,0 +1,162 @@ +// @flow + +import * as R from 'ramda'; +import {ofType} from 'redux-observable'; +import {Observable, from, of} from 'rxjs'; +import * as Rx from 'rxjs/operators'; +import {createAction} from 'redux-actions'; +import type { + State as DatasetState, + Action as DatasetAction, +} from '../state/dataset'; +import type {Chunk} from '../types'; +import type {State as BoundsState} from '../state/bounds'; +import {fetchChunk} from '../../../chunks'; +import {MAX_VIEWED_CHUNKS} from '../../../vector'; +import {setActiveChannel} from '../state/dataset'; +import {setChunks} from '../state/channel'; + +export const UPDATE_VIEWED_CHUNKS = 'UPDATE_VIEWED_CHUNKS'; +export const updateViewedChunks = createAction(UPDATE_VIEWED_CHUNKS); + +type FetchedChunks = { + channelIndex: number, + chunks: Chunk[] +}; + +export const loadChunks = ({channelIndex, ...rest}: FetchedChunks) => { + return (dispatch: any => void) => { + let filters = window.EEGLabSeriesProviderStore.getState().filters; + rest.chunks.forEach((chunk, index, chunks) => { + chunk.filters = []; + chunks[index].values = (Object.values(filters) : any).reduce( + (signal, filter) => { + chunks[index].filters.push(filter.name); + return filter.fn(signal); + }, + chunk.originalValues + ); + }); + + dispatch(setActiveChannel(channelIndex)); + dispatch(setChunks({...rest, channelIndex})); + dispatch(setActiveChannel(null)); + }; +}; + +export const fetchChunkAt = R.memoizeWith( + (baseURL, downsampling, channelIndex, traceIndex, chunkIndex) => + `${channelIndex}-${traceIndex}-${chunkIndex}-${downsampling}`, + ( + baseURL: string, + downsampling: number, + channelIndex: number, + traceIndex: number, + chunkIndex: number + ) => fetchChunk( + `${baseURL}/raw/${downsampling}/${channelIndex}/` + + `${traceIndex}/${chunkIndex}.buf` + ) +); + +type State = {bounds: BoundsState, dataset: DatasetState}; + +const UPDATE_DEBOUNCE_TIME = 100; + +export const createFetchChunksEpic = (fromState: any => State) => ( + action$: Observable, + state$: Observable +): Observable => { + return action$.pipe( + ofType(UPDATE_VIEWED_CHUNKS), + Rx.withLatestFrom(state$), + Rx.map(([_, state]) => fromState(state)), + Rx.debounceTime(UPDATE_DEBOUNCE_TIME), + Rx.concatMap(({bounds, dataset}) => { + const {chunkDirectoryURL, shapes, timeInterval, channels} = dataset; + + if (!chunkDirectoryURL) { + return of(); + } + + const fetches = R.flatten( + channels.map((channel, i) => { + return ( + channel && + channel.traces.map((trace, j) => { + const ncs = shapes.map((shape) => shape[shape.length - 2]); + + const citvs = ncs + .map((nc, downsampling) => { + const timeLength = Math.abs( + timeInterval[1] - timeInterval[0] + ); + const i0 = + (nc * Math.ceil(bounds.interval[0] - bounds.domain[0])) / + timeLength; + const i1 = + (nc * Math.ceil(bounds.interval[1] - bounds.domain[0])) / + timeLength; + return { + interval: [Math.floor(i0), Math.min(Math.ceil(i1), nc)], + numChunks: nc, + downsampling, + }; + }) + .filter( + ({interval, downsampling}) => + // TODO: check this condition... + // Why interval[1] - interval[0] < MAX_VIEWED_CHUNKS ? + // downsampling === 0 prevents a change of downsampling + // otherwise the interval becomes wrong + interval[1] - interval[0] < MAX_VIEWED_CHUNKS + && downsampling === 0 + ); + + const max = R.reduce( + R.maxBy(({interval}) => interval[1] - interval[0]), + {interval: [0, 0]}, + citvs + ); + + const chunkPromises = R.range(...max.interval).map( + (chunkIndex) => { + const numChunks = max.numChunks; + + return fetchChunkAt( + chunkDirectoryURL, + 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, + })); + } + ); + + return from( + Promise.all(chunkPromises).then((chunks) => ({ + channelIndex: channel.index, + traceIndex: j, + chunks, + })) + ); + }) + ); + }) + ); + + return from(fetches).pipe(Rx.mergeMap(R.identity)); + }), + Rx.map((payload) => loadChunks(payload)) + ); +}; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/filterEpochs.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/filterEpochs.js new file mode 100644 index 00000000000..02d6009a158 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/filterEpochs.js @@ -0,0 +1,95 @@ +// @flow + +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 {setFilteredEpochs, setActiveEpoch} from '../state/dataset'; + +export const UPDATE_FILTERED_EPOCHS = 'UPDATE_FILTERED_EPOCHS'; +export const updateFilteredEpochs = createAction(UPDATE_FILTERED_EPOCHS); + +export const TOGGLE_EPOCH = 'TOGGLE_EPOCH'; +export const toggleEpoch = createAction(TOGGLE_EPOCH); + +export const UPDATE_ACTIVE_EPOCH = 'UPDATE_ACTIVE_EPOCH'; +export const updateActiveEpoch = createAction(UPDATE_ACTIVE_EPOCH); + +export type Action = ((any) => void) => void; + +export const createFilterEpochsEpic = (fromState: any => any) => ( + action$: Observable, + state$: Observable +): Observable => { + return action$.pipe( + ofType(UPDATE_FILTERED_EPOCHS), + Rx.map(R.prop('payload')), + Rx.withLatestFrom(state$), + Rx.map(([payload, state]) => { + const {interval, epochs} = fromState(state); + const newFilteredEpochs = [...Array(epochs.length).keys()] + .filter((index) => + epochs[index].onset + epochs[index].duration > interval[0] + && epochs[index].onset < interval[1] + ); + + return (dispatch) => { + dispatch(setFilteredEpochs(newFilteredEpochs)); + }; + }) + ); +}; + +export const createToggleEpochEpic = (fromState: any => any) => ( + action$: Observable, + state$: Observable +): Observable => { + return action$.pipe( + ofType(TOGGLE_EPOCH), + Rx.map(R.prop('payload')), + Rx.withLatestFrom(state$), + Rx.map(([payload, state]) => { + const {filteredEpochs, epochs} = fromState(state); + const index = payload; + let newFilteredEpochs; + + if (filteredEpochs.includes(index)) { + newFilteredEpochs = filteredEpochs.filter((i) => i !== index); + } else if (index >= 0 && index < epochs.length) { + newFilteredEpochs = filteredEpochs.slice(); + newFilteredEpochs.push(index); + newFilteredEpochs.sort(); + } else { + return; + } + + return (dispatch) => { + dispatch(setFilteredEpochs(newFilteredEpochs)); + }; + }) + ); +}; + +export const createActiveEpochEpic = (fromState: any => any) => ( + action$: Observable, + state$: Observable +): Observable => { + return action$.pipe( + ofType(UPDATE_ACTIVE_EPOCH), + Rx.map(R.prop('payload')), + Rx.withLatestFrom(state$), + Rx.map(([payload, state]) => { + const {epochs} = fromState(state); + const index = payload; + + if (index < 0 || index >= epochs.length) { + return; + } + + return (dispatch) => { + dispatch(setActiveEpoch(index)); + }; + }) + ); +}; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/highLowPass.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/highLowPass.js new file mode 100644 index 00000000000..af370b291b7 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/highLowPass.js @@ -0,0 +1,134 @@ +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 {updateViewedChunks} from './fetchChunks'; +import {setFilter} from '../state/filters'; +import {DifferenceEquationSignal1D} from 'differenceequationsignal1d'; + +export const SET_LOW_PASS_FILTER = 'SET_LOW_PASS_FILTER'; +export const setLowPassFilter = createAction(SET_LOW_PASS_FILTER); + +export const SET_HIGH_PASS_FILTER = 'SET_HIGH_PASS_FILTER'; +export const setHighPassFilter = createAction(SET_HIGH_PASS_FILTER); + +export type Action = ((any) => void) => void; + +const applyFilter = (coefficients, input) => { + const diffFilter = new DifferenceEquationSignal1D(); + diffFilter.enableBackwardSecondPass(); + + if (coefficients) { + diffFilter.setInput(input); + diffFilter.setACoefficients(coefficients.a); + diffFilter.setBCoefficients(coefficients.b); + diffFilter.run(); // eventually should be pixpipe's update() + return Array.from(diffFilter.getOutput()); + } + return input; +}; + +export const LOW_PASS_FILTERS = { + 'none': { + label: 'No Low Pass Filter', + coefficients: null, + }, + 'lopass15': { + label: 'Low Pass 15Hz', + coefficients: { + b: [0.080716994603448, 0.072647596309189, 0.080716994603448], + a: [1.000000000000000, -1.279860238209870, 0.527812029663189], + }, + }, + 'lopass20': { + label: 'Low Pass 20Hz', + coefficients: { + b: [0.113997925584386, 0.149768961515167, 0.113997925584386], + a: [1.000000000000000, -1.036801335341888, 0.436950120418250], + }, + }, + 'lopass30': { + label: 'Low Pass 30Hz', + coefficients: { + b: [0.192813914343002, 0.325725940431161, 0.192813914343002], + a: [1.000000000000000, -0.570379950222695, 0.323884080078956], + }, + }, + 'lopass40': { + label: 'Low Pass 40Hz', + coefficients: { + b: [0.281307434361307, 0.517866041871659, 0.281307434361307], + a: [1.000000000000000, -0.135289362582513, 0.279792792112445], + }, + }, +}; + +export const createLowPassFilterEpic = (fromState: any => State) => ( + action$: Observable, + state$: Observable +): Observable => action$.pipe( + ofType(SET_LOW_PASS_FILTER), + Rx.map(R.prop('payload')), + Rx.withLatestFrom(state$), + Rx.map(([payload]) => (dispatch) => { + dispatch(setFilter({ + key: 'lowPass', + name: payload, + fn: R.curry(applyFilter)(LOW_PASS_FILTERS[payload].coefficients), + })); + dispatch(updateViewedChunks()); + }) +); + +export const HIGH_PASS_FILTERS = { + 'none': { + label: 'No High Pass Filter', + coefficients: null, + }, + 'hipass0_5': { + label: 'High Pass 0.5Hz', + coefficients: { + b: [0.937293010134975, -1.874580964130496, 0.937293010134975], + a: [1.000000000000000, -1.985579602684723, 0.985739491853153], + }, + }, + 'hipass1': { + label: 'High Pass 1Hz', + coefficients: { + b: [0.930549324176904, -1.861078566912498, 0.930549324176904], + a: [1.000000000000000, -1.971047525054235, 0.971682555986628], + }, + }, + 'hipass5': { + label: 'High Pass 5Hz', + coefficients: { + b: [0.877493430773021, -1.754511635757187, 0.877493430773021], + a: [1.000000000000000, -1.851210698908115, 0.866238657864428], + }, + }, + 'hipass10': { + label: 'High Pass 10Hz', + coefficients: { + b: [0.813452161011750, -1.625120853023986, 0.813452161011750], + a: [1.000000000000000, -1.694160769645868, 0.750559011393507], + }, + }, +}; + +export const createHighPassFilterEpic = (fromState: any => State) => ( + action$: Observable, + state$: Observable +): Observable => action$.pipe( + ofType(SET_HIGH_PASS_FILTER), + Rx.map(R.prop('payload')), + Rx.withLatestFrom(state$), + Rx.map(([payload]) => (dispatch) => { + dispatch(setFilter({ + key: 'highPass', + name: payload, + fn: R.curry(applyFilter)(HIGH_PASS_FILTERS[payload].coefficients), + })); + dispatch(updateViewedChunks()); + }) +); diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/pagination.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/pagination.js new file mode 100644 index 00000000000..3b2b0c6ac93 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/pagination.js @@ -0,0 +1,71 @@ +// @flow + +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 type {Channel, ChannelMetadata} from '../types'; +import { + emptyChannels, + setDatasetMetadata, + setChannels, +} from '../state/dataset'; +import {updateViewedChunks} from './fetchChunks'; + +export const SET_OFFSET_INDEX = 'SET_OFFSET_INDEX'; +export const setOffsetIndex = createAction(SET_OFFSET_INDEX); + +export type Action = ((any) => void) => void; + +export type State = { + limit: number, + channelMetadata: ChannelMetadata[], + channels: Channel[] +}; + +export const createPaginationEpic = (fromState: any => State) => ( + action$: Observable, + state$: Observable +): Observable => { + return action$.pipe( + ofType(SET_OFFSET_INDEX), + Rx.map(R.prop('payload')), + Rx.withLatestFrom(state$), + Rx.map(([payload, state]) => { + const {limit, channelMetadata, channels} = fromState(state); + + const offsetIndex = Math.min( + Math.max(payload, 1), + channelMetadata.length + ); + + let channelIndex = offsetIndex - 1; + + const newChannels = []; + const hardLimit = Math.min( + offsetIndex + limit - 1, + channelMetadata.length + ); + while (channelIndex < hardLimit) { + // TODO: need to handle multiple traces using shapes + const channel = + channels.find( + R.pipe( + R.prop('index'), + R.equals(channelIndex) + ) + ) || emptyChannels(1, 1)[0]; + channel.index = channelIndex; + newChannels.push(channel); + channelIndex++; + } + + return (dispatch) => { + dispatch(setDatasetMetadata({offsetIndex})); + dispatch(setChannels(newChannels)); + dispatch(updateViewedChunks()); + }; + }) + ); +}; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/scaleAmplitudes.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/scaleAmplitudes.js new file mode 100644 index 00000000000..2e60538eb4f --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/scaleAmplitudes.js @@ -0,0 +1,49 @@ +// @flow + +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 {setAmplitudeScale} from '../state/bounds'; +import {updateViewedChunks} from './fetchChunks'; + +export const SET_AMPLITUDES_SCALE = 'SET_AMPLITUDES_SCALE'; +export const setAmplitudesScale = createAction(SET_AMPLITUDES_SCALE); +export const RESET_AMPLITUDES_SCALE = 'RESET_AMPLITUDES_SCALE'; +export const resetAmplitudesScale = createAction(RESET_AMPLITUDES_SCALE); +export type Action = ((any) => void) => void; + +export const createScaleAmplitudesEpic = (fromState: any => number) => ( + action$: Observable, + state$: Observable +): Observable => { + return action$.pipe( + ofType(SET_AMPLITUDES_SCALE), + Rx.map(R.prop('payload')), + Rx.withLatestFrom(state$), + Rx.map(([payload, state]) => { + const scale = payload; + const amplitudeScale = fromState(state); + + return (dispatch) => { + dispatch(setAmplitudeScale(scale * amplitudeScale)); + dispatch(updateViewedChunks()); + }; + }) + ); +}; + +export const createResetAmplitudesEpic = () => ( + action$: Observable, +): Observable => { + return action$.pipe( + ofType(RESET_AMPLITUDES_SCALE), + Rx.map(() => { + return (dispatch) => { + dispatch(setAmplitudeScale()); + dispatch(updateViewedChunks()); + }; + }) + ); +}; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/timeSelection.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/timeSelection.js new file mode 100644 index 00000000000..1d1ca590146 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/timeSelection.js @@ -0,0 +1,89 @@ +// @flow + +import * as R from 'ramda'; +import {Observable, merge} from 'rxjs'; +import * as Rx from 'rxjs/operators'; +import {ofType} from 'redux-observable'; +import {createAction} from 'redux-actions'; +import {setTimeSelection} from '../state/timeSelection'; + +import type { + Action as BoundsAction, +} from '../state/bounds'; + +import {MIN_INTERVAL_FACTOR} from '../../../vector'; + +export const START_DRAG_SELECTION = 'START_DRAG_SELECTION'; +export const startDragSelection = createAction(START_DRAG_SELECTION); + +export const CONTINUE_DRAG_SELECTION = 'CONTINUE_DRAG_SELECTION'; +export const continueDragSelection = createAction(CONTINUE_DRAG_SELECTION); + +export const END_DRAG_SELECTION = 'END_DRAG_SELECTION'; +export const endDragSelection = createAction(END_DRAG_SELECTION); + +export type Action = BoundsAction | { type: 'UPDATE_VIEWED_CHUNKS' }; + +export const createTimeSelectionEpic = (fromState: any => any) => ( + action$: Observable, + state$: Observable +): Observable => { + const startDrag$ = action$.pipe( + ofType(START_DRAG_SELECTION), + Rx.map(R.prop('payload')), + ); + + const continueDrag$ = action$.pipe( + ofType(CONTINUE_DRAG_SELECTION), + Rx.map(R.prop('payload')) + ); + + const initInterval = ([position, state]) => { + const {interval} = R.clone(fromState(state)); + const x = Math.round(interval[0] + position * (interval[1] - interval[0])); + return setTimeSelection([x, x]); + }; + + 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) + ); + + return setTimeSelection(timeSelection); + }; + + const endDrag$ = action$.pipe( + ofType(END_DRAG_SELECTION), + Rx.withLatestFrom(state$), + Rx.map(([payload, state]) => { + if ( + state.timeSelection + && (state.timeSelection[1] - state.timeSelection[0] < 2) + ) { + return setTimeSelection(null); + } else { + return setTimeSelection(state.timeSelection); + } + }) + ); + + const startUpdates$ = startDrag$.pipe( + Rx.withLatestFrom(state$), + Rx.map(initInterval) + ); + + const dragUpdates$ = startDrag$.pipe( + Rx.switchMap(() => + continueDrag$.pipe( + Rx.withLatestFrom(state$), + Rx.map(updateInterval), + Rx.takeUntil(endDrag$) + ) + ) + ); + + return merge(startUpdates$, dragUpdates$, endDrag$); +}; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/bounds.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/bounds.js new file mode 100644 index 00000000000..c981946e0a2 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/bounds.js @@ -0,0 +1,85 @@ +// @flow + +import {createAction} from 'redux-actions'; + +export const SET_INTERVAL = 'SET_INTERVAL'; +export const setInterval = createAction(SET_INTERVAL); + +export const SET_DOMAIN = 'SET_DOMAIN'; +export const setDomain = createAction(SET_DOMAIN); + +export const SET_AMPLITUDE_SCALE = 'SET_AMPLITUDE_SCALE'; +export const setAmplitudeScale = createAction(SET_AMPLITUDE_SCALE); + +export const SET_VIEWER_WIDTH = 'SET_VIEWER_WIDTH'; +export const setViewerWidth = createAction(SET_VIEWER_WIDTH); + +export const SET_VIEWER_HEIGHT = 'SET_VIEWER_HEIGHT'; +export const setViewerHeight = createAction(SET_VIEWER_HEIGHT); + +export type Action = + | {type: 'SET_INTERVAL', payload: [number, number]} + | {type: 'SET_DOMAIN', payload: [number, number]} + | {type: 'SET_AMPLITUDE_SCALE', payload: number} + | {type: 'SET_VIEWER_WIDTH', payload: number} + | {type: 'SET_VIEWER_HEIGHT', payload: number} + +export type State = { + interval: [number, number], + domain: [number, number], + amplitudeScale: number, + viewerWidth: number, + viewerHeight: number, +}; + +const interval = (state = [0.25, 0.75], action: ?Action): [number, number] => { + if (action && action.type === 'SET_INTERVAL') { + return action.payload; + } + return state; +}; + +const domain = (state = [0, 1], action: ?Action): [number, number] => { + if (action && action.type === 'SET_DOMAIN') { + return action.payload; + } + return state; +}; + +const amplitudeScale = (state = 1, action: ?Action): number => { + if (action && action.type === 'SET_AMPLITUDE_SCALE') { + return action.payload; + } + return state; +}; + +const viewerWidth = (state = 400, action: ?Action): number => { + if (action && action.type === 'SET_VIEWER_WIDTH') { + return action.payload; + } + return state; +}; + +const viewerHeight = (state = 400, action: ?Action): number => { + if (action && action.type === 'SET_VIEWER_HEIGHT') { + return action.payload; + } + return state; +}; + +export const boundsReducer: (State, Action) => State = ( + state = { + interval: interval(), + domain: domain(), + amplitudeScale: amplitudeScale(), + viewerWidth: viewerWidth(), + viewerHeight: viewerHeight(), + }, + action +) => ({ + interval: interval(state.interval, action), + domain: domain(state.domain, action), + amplitudeScale: amplitudeScale(state.amplitudeScale, action), + viewerWidth: viewerWidth(state.viewerWidth, action), + viewerHeight: viewerHeight(state.viewerHeight, action), +}); diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/channel.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/channel.js new file mode 100644 index 00000000000..0fdf9112ade --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/channel.js @@ -0,0 +1,36 @@ +// @flow + +import * as R from 'ramda'; +import {createAction} from 'redux-actions'; +import type {Channel, Chunk} from '../types'; + +export const SET_CHUNKS = 'SET_CHUNKS'; +export const setChunks = createAction(SET_CHUNKS); + +export type Action = { + type: 'SET_CHUNKS', + payload: {traceIndex: number, chunks: Chunk[]} +}; + +export type State = Channel; + +export const channelReducer = ( + state: Channel = {index: 0, traces: []}, + action: ?Action +): State => { + if (!action) { + return state; + } + switch (action.type) { + case SET_CHUNKS: { + return R.assocPath( + ['traces', action.payload.traceIndex, 'chunks'], + action.payload.chunks, + state + ); + } + default: { + return state; + } + } +}; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/cursor.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/cursor.js new file mode 100644 index 00000000000..a453d04dd30 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/cursor.js @@ -0,0 +1,29 @@ +// @flow + +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 type State = ?number; + +export type Reducer = (state: ?number, action: ?Action) => State; + +export const cursorReducer: Reducer = (state = null, action) => { + if (!action) { + return state; + } + switch (action.type) { + case SET_CURSOR: { + return action.payload; + } + default: { + return state; + } + } +}; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/dataset.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/dataset.js new file mode 100644 index 00000000000..85a465f7d0f --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/dataset.js @@ -0,0 +1,124 @@ +// @flow + +import * as R from 'ramda'; +import {createAction} from 'redux-actions'; +import type {Channel, ChannelMetadata, Epoch} from '../types'; +import type {Action as ChannelAction} from './channel'; +import {channelReducer} from './channel'; + +export const SET_CHANNELS = 'SET_CHANNELS'; +export const setChannels = createAction(SET_CHANNELS); + +export const SET_ACTIVE_CHANNEL = 'SET_ACTIVE_CHANNEL'; +export const setActiveChannel = createAction(SET_ACTIVE_CHANNEL); + +export const SET_EPOCHS = 'SET_EPOCHS'; +export const setEpochs = createAction(SET_EPOCHS); + +export const SET_FILTERED_EPOCHS = 'SET_FILTERED_EPOCHS'; +export const setFilteredEpochs = createAction(SET_FILTERED_EPOCHS); + +export const SET_ACTIVE_EPOCH = 'SET_ACTIVE_EPOCH'; +export const setActiveEpoch = createAction(SET_ACTIVE_EPOCH); + +export const SET_DATASET_METADATA = 'SET_DATASET_METADATA'; +export const setDatasetMetadata = createAction(SET_DATASET_METADATA); + +export type Action = + | {type: 'SET_CHANNELS', payload: Channel[]} + | {type: 'SET_ACTIVE_CHANNEL', payload: number} + | {type: 'SET_EPOCHS', payload: Epoch[]} + | {type: 'SET_FILTERED_EPOCHS', payload: number[]} + | {type: 'SET_ACTIVE_EPOCH', payload: number} + | { + type: 'SET_DATASET_METADATA', + payload: { + chunkDirectoryURL: string, + channelNames: string[], + shapes: number[][], + timeInterval: [number, number], + seriesRange: [number, number], + limit: number + } + } + | ChannelAction; + +export type State = { + chunkDirectoryURL: string, + channelMetadata: ChannelMetadata[], + channels: Channel[], + activeChannel: number | null, + offsetIndex: number, + limit: number, + epochs: Epoch[], + filteredEpochs: number[], + activeEpoch: number | null, + shapes: number[][], + timeInterval: [number, number] +}; + +export const datasetReducer = ( + state: State = { + chunkDirectoryURL: '', + channelMetadata: [], + channels: [], + filteredChannels: [], + activeChannel: null, + epochs: [], + filteredEpochs: [], + activeEpoch: null, + offsetIndex: 1, + limit: 6, + shapes: [], + timeInterval: [0, 1], + seriesRange: [-1, 2], + }, + action: ?Action +): State => { + if (!action) { + return state; + } + switch (action.type) { + case SET_CHANNELS: { + return R.assoc('channels', action.payload, state); + } + case SET_ACTIVE_CHANNEL: { + return R.assoc('activeChannel', action.payload, state); + } + case SET_EPOCHS: { + return R.assoc('epochs', action.payload, state); + } + case SET_FILTERED_EPOCHS: { + return R.assoc('filteredEpochs', action.payload, state); + } + case SET_ACTIVE_EPOCH: { + return R.assoc('activeEpoch', action.payload, state); + } + case SET_DATASET_METADATA: { + return R.merge(state, action.payload); + } + default: { + const activeIndex = state.channels.findIndex( + (c) => c.index === state.activeChannel + ); + if (activeIndex < 0) { + return state; + } + return R.assocPath( + ['channels', activeIndex], + channelReducer(state.channels[activeIndex], (action: any)), + state + ); + } + } +}; + +export const emptyChannels = (channelsCount: number, tracesCount: number) => { + const makeTrace = () => ({chunks: [], type: 'line'}); + const makeChannel = (index) => ({ + index, + traces: R.range(0, tracesCount).map(makeTrace), + }); + + return R.range(0, channelsCount).map(makeChannel); +}; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/filters.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/filters.js new file mode 100644 index 00000000000..c000ea1ea7a --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/filters.js @@ -0,0 +1,43 @@ +// @flow + +import * as R from 'ramda'; +import {createAction} from 'redux-actions'; + +export const SET_FILTER = 'SET_FILTER'; +export const setFilter = createAction(SET_FILTER); + +export type Action = { + type: 'SET_FILTER', + payload: { + key: string, + name: string, + fn: (number[]) => number[], + } +}; + +export const filtersReducer = ( + state: {[key: string]: { + name: string, + fn: (number[]) => number[] + }} = {}, + action: ?Action +): any => { + if (!action) { + return state; + } + switch (action.type) { + case SET_FILTER: { + return R.assoc( + action.payload.key, + { + name: action.payload.name, + fn: action.payload.fn, + }, + state + ); + } + default: { + return state; + } + } +}; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/montage.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/montage.js new file mode 100644 index 00000000000..951313dcda5 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/montage.js @@ -0,0 +1,42 @@ +// @flow + +import * as R from 'ramda'; +import {createAction} from 'redux-actions'; +import type {Electrode} from '../types'; + +export const SET_ELECTRODES = 'SET_ELECTRODES'; +export const setElectrodes = createAction(SET_ELECTRODES); + +export const SET_HIDDEN = 'SET_HIDDEN'; +export const setHidden = createAction(SET_HIDDEN); + +export type Action = + | {type: 'SET_ELECTRODES', payload: Electrode[]} + | {type: 'SET_HIDDEN', payload: number[]}; + +export type State = { + electrodes: Electrode[], + hidden: number[] +}; + +export type Reducer = (state: State, action: ?Action) => State; + +export const montageReducer: Reducer = ( + state = {electrodes: [], hidden: []}, + action +) => { + if (!action) { + return state; + } + switch (action.type) { + case SET_ELECTRODES: { + return R.assoc('electrodes', action.payload, state); + } + case SET_HIDDEN: { + return R.assoc('hidden', action.payload, state); + } + default: { + return state; + } + } +}; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/rightPanel.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/rightPanel.js new file mode 100644 index 00000000000..9d2ae6acee8 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/rightPanel.js @@ -0,0 +1,28 @@ +// @flow + +import {createAction} from 'redux-actions'; +import type {RightPanel} from '../types'; + +export const SET_RIGHT_PANEL = 'SET_RIGHT_PANEL'; +export const setRightPanel = createAction(SET_RIGHT_PANEL); + +export type Action = { + type: "SET_RIGHT_PANEL", + payload: RightPanel +}; + +export type Reducer = (state: RightPanel, action: ?Action) => RightPanel; + +export const panelReducer: Reducer = (state = null, action) => { + if (!action) { + return state; + } + switch (action.type) { + case SET_RIGHT_PANEL: { + return action.payload; + } + default: { + return state; + } + } +}; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/timeSelection.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/timeSelection.js new file mode 100644 index 00000000000..20176404680 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/timeSelection.js @@ -0,0 +1,30 @@ +// @flow + +import {createAction} from 'redux-actions'; + +export const SET_TIME_SELECTION = 'SET_TIME_SELECTION'; +export const setTimeSelection = createAction(SET_TIME_SELECTION); + +export type Action = { + type: "SET_TIME_SELECTION", + payload: ?[number, number] +}; + +export type State = ?[number, number]; + +export type Reducer = (state: ?[number, number], action: ?Action) => State; + +export const timeSelectionReducer: Reducer = (state = null, action) => { + if (!action) { + return state; + } + + switch (action.type) { + case SET_TIME_SELECTION: { + return action.payload; + } + default: { + return state; + } + } +}; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/types.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/types.js new file mode 100644 index 00000000000..8097beb66d2 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/types.js @@ -0,0 +1,43 @@ +// @flow + +export type Chunk = { + index: number, + originalValues: number[], + values: number[], + filters: string[], + downsampling: number, + interval: [number, number], + cutoff: number +}; + +export type Trace = { + chunks: Chunk[], + type: "line" +}; + +export type ChannelMetadata = { + name: string, + seriesRange: [number, number] +}; + +export type Channel = { + index: number, + traces: Trace[] +}; + +export type Epoch = { + onset: number, + duration: number, + type: 'Event' | 'Annotation', + label: string, + comment: ?string, + channels: number[] | "all", +}; + +export type RightPanel = ?('annotationForm' | 'epochList'); + +export type Electrode = { + name: string, + channelIndex: ?number, + position: [number, number, number], +}; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/vector/index.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/vector/index.js new file mode 100644 index 00000000000..4e83fb92437 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/vector/index.js @@ -0,0 +1,18 @@ +// @flow + +import {vec2, glMatrix} from 'gl-matrix'; + +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])); + +export const MIN_INTERVAL_FACTOR = 0.005; + +export const MIN_EPOCH_WIDTH = 1; + +export const MAX_VIEWED_CHUNKS = 3; + +export const MAX_CHANNELS = 6; + +export const MAX_RENDERED_EPOCHS = 100; diff --git a/modules/electrophysiology_browser/php/sessions.class.inc b/modules/electrophysiology_browser/php/sessions.class.inc index 0fda74038d1..3dbebd4572b 100644 --- a/modules/electrophysiology_browser/php/sessions.class.inc +++ b/modules/electrophysiology_browser/php/sessions.class.inc @@ -333,6 +333,9 @@ class Sessions extends \NDB_Page $artefactDesc = $physioFileObj->getParameter( 'SubjectArtefactDescription' ); + $chunksUrl = $physioFileObj->getParameter( + 'electrophyiology_chunked_dataset_path' + ); $fileSummary['details']['task']['description'] = $taskDesc; $fileSummary['details']['instructions'] = $instructions; @@ -364,6 +367,7 @@ class Sessions extends \NDB_Page ); $fileSummary['downloads'] = $links; + $fileSummary['chunks_url'] = $chunksUrl; $fileCollection[]['file'] = $fileSummary; } @@ -467,6 +471,26 @@ class Sessions extends \NDB_Page return $depends; } + /** + * Get CSS Dependencies + * + * @return array + */ + function getCSSDependencies() + { + $depends = parent::getCSSDependencies(); + $factory = \NDB_Factory::singleton(); + $baseurl = $factory->settings()->getBaseURL(); + $depends = array_merge( + $depends, + [ + $baseurl + . '/electrophysiology_browser/css/electrophysiology_browser.css', + ] + ); + return $depends; + } + /** * Generate a breadcrumb trail for this page. * diff --git a/package.json b/package.json index e0f4c949f68..dc4c3d9392c 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@babel/plugin-proposal-object-rest-spread": "^7.6.2", "@babel/preset-env": "^7.6.3", "@babel/preset-react": "^7.6.3", + "@babel/preset-flow": "^7.12.1", "alex": ">=8.0.1", "babel-eslint": "^10.0.1", "babel-loader": "^8.0.5", @@ -53,7 +54,8 @@ "tests:integration": "./test/dockerized-integration-tests.sh", "tests:integration:debug": "DEBUG=true ./test/dockerized-integration-tests.sh", "compile": "webpack", - "watch": "webpack --watch" + "watch": "webpack --watch", + "postinstall": "cd modules/electrophysiology_browser/jsx/react-series-data-viewer && npm install" }, "repository": { "type": "git",