From 0bce49d160e1dfbef0e4fc8cf8be98357cb7f87d Mon Sep 17 00:00:00 2001 From: Laetitia Fesselier Date: Thu, 11 Mar 2021 13:13:37 -0500 Subject: [PATCH 1/8] [EEG Browser] Visualization --- .babelrc | 13 - .eslintignore | 3 +- .github/workflows/loristest.yml | 5 +- babel.config.js | 20 + htdocs/bootstrap/css/custom-css.css | 4 +- modules/electrophysiology_browser/.gitignore | 1 + .../css/electrophysiology_browser.css | 93 +++ .../jsx/components/DownloadPanel.js | 100 +++ .../jsx/components/SidebarContent.js | 10 +- .../electrophysiology_session_panels.js | 715 ++++++------------ .../electrophysiology_session_summary.js | 141 ++++ .../jsx/electrophysiologySessionView.js | 92 ++- .../jsx/react-series-data-viewer/.flowconfig | 15 + .../jsx/react-series-data-viewer/README.md | 21 + .../jsx/react-series-data-viewer/package.json | 41 + .../protocol-buffers/chunk.proto | 8 + .../src/ajax/index.js | 17 + .../src/chunks/index.js | 22 + .../src/color/index.js | 18 + .../src/eeglab/EEGLabSeriesProvider.js | 145 ++++ .../src/series/components/AnnotationForm.js | 190 +++++ .../src/series/components/Axis.js | 52 ++ .../src/series/components/EEGMontage.js | 308 ++++++++ .../src/series/components/Epoch.js | 55 ++ .../src/series/components/EventManager.js | 143 ++++ .../src/series/components/IntervalSelect.js | 195 +++++ .../src/series/components/LineChunk.js | 123 +++ .../src/series/components/ResponsiveViewer.js | 111 +++ .../src/series/components/SeriesCursor.js | 219 ++++++ .../src/series/components/SeriesRenderer.js | 703 +++++++++++++++++ .../src/series/store/index.js | 75 ++ .../src/series/store/logic/dragBounds.js | 97 +++ .../src/series/store/logic/fetchChunks.js | 162 ++++ .../src/series/store/logic/filterEpochs.js | 100 +++ .../src/series/store/logic/highLowPass.js | 134 ++++ .../src/series/store/logic/pagination.js | 71 ++ .../src/series/store/logic/scaleAmplitudes.js | 49 ++ .../src/series/store/logic/timeSelection.js | 89 +++ .../src/series/store/state/bounds.js | 85 +++ .../src/series/store/state/channel.js | 36 + .../src/series/store/state/cursor.js | 29 + .../src/series/store/state/dataset.js | 125 +++ .../src/series/store/state/filters.js | 43 ++ .../src/series/store/state/montage.js | 42 + .../src/series/store/state/rightPanel.js | 28 + .../src/series/store/state/timeSelection.js | 30 + .../src/series/store/types.js | 43 ++ .../src/vector/index.js | 18 + .../php/file_reader.class.inc | 53 ++ .../php/sessions.class.inc | 30 +- package.json | 6 +- 51 files changed, 4389 insertions(+), 539 deletions(-) delete mode 100644 .babelrc create mode 100644 babel.config.js create mode 100644 modules/electrophysiology_browser/css/electrophysiology_browser.css create mode 100644 modules/electrophysiology_browser/jsx/components/DownloadPanel.js create mode 100644 modules/electrophysiology_browser/jsx/components/electrophysiology_session_summary.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/.flowconfig create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/README.md create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/package.json create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/protocol-buffers/chunk.proto create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/ajax/index.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/chunks/index.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/color/index.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/eeglab/EEGLabSeriesProvider.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/AnnotationForm.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/Axis.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/EEGMontage.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/Epoch.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/EventManager.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/IntervalSelect.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/LineChunk.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/ResponsiveViewer.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/SeriesCursor.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/SeriesRenderer.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/index.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/dragBounds.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/fetchChunks.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/filterEpochs.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/highLowPass.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/pagination.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/scaleAmplitudes.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/timeSelection.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/bounds.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/channel.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/cursor.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/dataset.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/filters.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/montage.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/rightPanel.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/timeSelection.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/types.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/vector/index.js create mode 100644 modules/electrophysiology_browser/php/file_reader.class.inc 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 107fe6c6d50..bb4df14c49a 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,10 +1,12 @@ node_modules/* vendor/* project/* +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 @@ -21,4 +23,3 @@ htdocs/js/FileSaver.min.js htdocs/vendor/ htdocs/fontawesome/ htdocs/bootstrap/ - diff --git a/.github/workflows/loristest.yml b/.github/workflows/loristest.yml index cd1258d88ba..af166f7dc00 100644 --- a/.github/workflows/loristest.yml +++ b/.github/workflows/loristest.yml @@ -40,7 +40,10 @@ jobs: - name: Change PHP Version in Dockerfile run: sed -i "s/7.4/${{ matrix.php }}/g" Dockerfile.test.php7 - + + - name: Install OS package dependencies + run: sudo apt-get install -y protobuf-compiler + - name: Install composer dependencies if: steps.composer-cache.outputs.cache-hit != 'true' run: composer install --prefer-dist --no-progress --no-suggest 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/htdocs/bootstrap/css/custom-css.css b/htdocs/bootstrap/css/custom-css.css index cdde7d74796..1e6a934688e 100644 --- a/htdocs/bootstrap/css/custom-css.css +++ b/htdocs/bootstrap/css/custom-css.css @@ -533,7 +533,7 @@ div.navbar div.container div.navbar-brand { border-color: #246EB6; } -a.btn.btn-primary { +a.btn.btn-primary:not(.download) { color: #064785; border-color: transparent; background-color: white; @@ -541,7 +541,7 @@ a.btn.btn-primary { transition: background-color 0.2s ease-in; } -a.btn.btn-primary:hover { +a.btn.btn-primary:hover:not(.download) { color: #E89A0C; border-color: transparent; background-color: white; diff --git a/modules/electrophysiology_browser/.gitignore b/modules/electrophysiology_browser/.gitignore index d5a65bf3098..9ae4d1334ef 100644 --- a/modules/electrophysiology_browser/.gitignore +++ b/modules/electrophysiology_browser/.gitignore @@ -1 +1,2 @@ js/* +jsx/react-series-data-viewer/src/protocol-buffers/chunk_pb.js diff --git a/modules/electrophysiology_browser/css/electrophysiology_browser.css b/modules/electrophysiology_browser/css/electrophysiology_browser.css new file mode 100644 index 00000000000..0a87b6f7951 --- /dev/null +++ b/modules/electrophysiology_browser/css/electrophysiology_browser.css @@ -0,0 +1,93 @@ +.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; +} + +.electrode:hover circle, +.electrode.hover circle { + stroke: #064785; + cursor: pointer; + fill: #E4EBF2 +} + +.electrode:hover text, +.electrode.hover text { + fill: #064785; + cursor: pointer; +} \ 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..002b0a59fc4 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/components/DownloadPanel.js @@ -0,0 +1,100 @@ +/** + * 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', + physiological_annotation_files: 'Annotations', + 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 === ''); + 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
- -
-
-
Annotations
- -
-
-
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} -
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} -
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 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 + } +
+
+
-
-
- + +
); } @@ -561,6 +280,7 @@ FilePanel.propTypes = { title: PropTypes.string, data: PropTypes.object, }; + FilePanel.defaultProps = { id: 'file_panel', title: 'FILENAME', @@ -570,3 +290,4 @@ FilePanel.defaultProps = { export { FilePanel, }; + diff --git a/modules/electrophysiology_browser/jsx/components/electrophysiology_session_summary.js b/modules/electrophysiology_browser/jsx/components/electrophysiology_session_summary.js new file mode 100644 index 00000000000..3460960faa1 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/components/electrophysiology_session_summary.js @@ -0,0 +1,141 @@ +import React, {Component} from 'react'; +import PropTypes from 'prop-types'; +import Panel from 'jsx/Panel'; + +/** + * Summary Panel + * + * This file contains React component for Electrophysiology module. + * + * @author Alizée Wickenheiser. + * @version 0.0.1 + */ +class SummaryPanel extends Component { + /** + * @constructor + * @param {object} props - React Component properties + */ + constructor(props) { + super(props); + this.state = { + data: this.props.data, + }; + } + + /** + * Renders the React component. + * + * @return {JSX} - React markup for the component + */ + render() { + const styles = { + table: { + header: { + color: '#074785', + padding: '5px 10px', + wordWrap: 'break-word', + width: '200px', + }, + style: { + background: '#fff', + width: '100%', + }, + data: { + padding: '5px 10px', + wordWrap: 'break-word', + }, + }, + }; + + return ( +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 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} +
+
+
+
+ ); + } +} + +SummaryPanel.propTypes = { + data: PropTypes.object, +}; + +SummaryPanel.defaultProps = { + data: {}, +}; + +export { + SummaryPanel, +}; diff --git a/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js b/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js index c21c77e7475..aa1c3f3fbd5 100644 --- a/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js +++ b/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js @@ -11,8 +11,16 @@ import PropTypes from 'prop-types'; import StaticDataTable from 'jsx/StaticDataTable'; import {FilePanel} from './components/electrophysiology_session_panels'; +import {SummaryPanel} from './components/electrophysiology_session_summary'; +import {DownloadPanel} from './components/DownloadPanel'; import Sidebar from './components/Sidebar'; import SidebarContent from './components/SidebarContent'; +import EEGLabSeriesProvider + from './react-series-data-viewer/src/eeglab/EEGLabSeriesProvider'; +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 @@ -150,6 +158,9 @@ class ElectrophysiologySessionView extends Component { }, ], }, + chunkDirectoryURL: null, + epochsTableURL: null, + electrodesTableUrls: null, }, ], }; @@ -196,16 +207,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 + && loris.BaseURL + + '/electrophysiology_browser/file_reader/?file=' + + dbEntry.file.chunks_url, + epochsTableURL: + dbEntry + && dbEntry.file.downloads[3].file + && loris.BaseURL + + '/electrophysiology_browser/file_reader/?file=' + + dbEntry.file.downloads[3].file, + electrodesTableUrls: + dbEntry + && dbEntry.file.downloads[1].file + && loris.BaseURL + + '/electrophysiology_browser/file_reader/?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; @@ -218,7 +252,7 @@ class ElectrophysiologySessionView extends Component { if (data.nextSession !== '') { document.getElementById('nav_next').style.display = 'block'; } - })) + }) .catch((error) => { this.setState({error: true}); console.error(error); @@ -256,13 +290,43 @@ 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( -
+
+ > +
+ + +
+
+ +
+ +
+ +
+
+
+
+
); } @@ -295,9 +359,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/.flowconfig b/modules/electrophysiology_browser/jsx/react-series-data-viewer/.flowconfig new file mode 100644 index 00000000000..ca078320be1 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/.flowconfig @@ -0,0 +1,15 @@ +[ignore] +/node_modules/npm/ + +[include] +../../../../jsx + +[libs] + +[lints] + +[options] +react.runtime=automatic +module.name_mapper='^jsx' -> '/../../../../jsx/' + +[strict] diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/README.md b/modules/electrophysiology_browser/jsx/react-series-data-viewer/README.md new file mode 100644 index 00000000000..3736f710e73 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/README.md @@ -0,0 +1,21 @@ +## Main dependencies + +### Ramda (https://ramdajs.com) +A practical functional library for JavaScript programmers. + +### Redux (https://redux.js.org) +A Predictable State Container for JS Apps + +### Visx (https://airbnb.io/visx) +A collection of expressive, low-level visualization primitives for React. + +### RxJS (https://rxjs-dev.firebaseapp.com/guide/overview) +RxJS is a library for composing asynchronous and event-based programs by using observable sequences. +It provides one core type, the Observable, satellite types (Observer, Schedulers, Subjects) and operators to allow handling asynchronous events as collections. + +### flow (https://flow.org) +A static type checker for javascript. + +### Protocol Buffers (https://developers.google.com/protocol-buffers) +To install the Protocol Buffers Compiler (protoc), run: +`apt install -y protobuf-compiler` 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..021609e45c4 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/package.json @@ -0,0 +1,41 @@ +{ + "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", + "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" + }, + "scripts": { + "flow": "./node_modules/flow-bin/cli.js", + "postinstall": "protoc protocol-buffers/chunk.proto --js_out=import_style=commonjs,binary:./src/" + }, + "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/src/ajax/index.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/ajax/index.js new file mode 100644 index 00000000000..40928b48d8b --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/ajax/index.js @@ -0,0 +1,17 @@ +export const fetchBlob = (...args) => + fetch(...args).then((response) => + response.blob().then((data) => data) + ); + +export const fetchJSON = (...args) => + fetch(...args).then((response) => { + if (!response.ok) { + return new Promise(() => {}); + } + return response.json().then((data) => data); + }); + +export const fetchText = (...args) => + fetch(...args).then((response) => + response.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..4ff68864697 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/eeglab/EEGLabSeriesProvider.js @@ -0,0 +1,145 @@ +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 {MAX_CHANNELS} from '../vector'; +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( + Math.min(this.props.limit, channelMetadata.length), + 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: '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: MAX_CHANNELS, +}; + +export default EEGLabSeriesProvider; 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..a43e6ca4e8e --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/EEGMontage.js @@ -0,0 +1,308 @@ +// @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 [selectedElectrode, setSelectedElectrode] = useState(null); + + 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) => + + + {electrodes[i].name} + + + {i + 1} + {electrodes[i].name} + + + )} + + ); + + return ( +
+ +
+
+
+ {electrodes.map((electrode, i) => { + return ( +
setSelectedElectrode( + e.currentTarget.getAttribute('data-key') + )} + onMouseLeave={(e) => setSelectedElectrode(null)} + > + {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..e94c57435b3 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/EventManager.js @@ -0,0 +1,143 @@ +// @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..aa1db7d951d --- /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' + style={{marginLeft: '15px'}} + /> +
+
+ { + 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..83d9424b957 --- /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..69c4ab32d8a --- /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..07796347f2a --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/SeriesRenderer.js @@ -0,0 +1,703 @@ +// @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; + if (!seriesRange) return null; + 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 Amplitude' + /> + 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}
  • + )} +
+
+
+ +
+ + 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='>>' + /> +
+
+
+
+
+
+
+ {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 && + + } + +
+ {[...Array(epochs.length).keys()].filter((i) => + epochs[i].onset + epochs[i].duration > interval[0] + && epochs[i].onset < interval[1] + ).length >= MAX_RENDERED_EPOCHS && +
+ Too many events to display for the timeline range. + Limit the time range. +
+ } +
+
+
+
+ {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: MAX_CHANNELS, +}; + +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..c50fec9a6e4 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/filterEpochs.js @@ -0,0 +1,100 @@ +// @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'; +import {MAX_RENDERED_EPOCHS} from '../../../vector'; + +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); + let newFilteredEpochs = [...Array(epochs.length).keys()] + .filter((index) => + epochs[index].onset + epochs[index].duration > interval[0] + && epochs[index].onset < interval[1] + ); + + if (newFilteredEpochs.length >= MAX_RENDERED_EPOCHS) { + newFilteredEpochs = []; + } + + 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..57b28e50b54 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/dataset.js @@ -0,0 +1,125 @@ +// @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'; +import {MAX_CHANNELS} from '../../../vector'; + +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: MAX_CHANNELS, + 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/file_reader.class.inc b/modules/electrophysiology_browser/php/file_reader.class.inc new file mode 100644 index 00000000000..9d2343c03bf --- /dev/null +++ b/modules/electrophysiology_browser/php/file_reader.class.inc @@ -0,0 +1,53 @@ + + * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3 + * @link https://www.github.com/aces/Loris/ + */ +class File_Reader extends \NDB_Page +{ + public $skipTemplate = true; + + /** + * Handle how to operate all the files. + * GET method gets a file. + * + * @param ServerRequestInterface $request The incoming PSR7 request + * + * @return ResponseInterface The outgoing PSR7 response + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + $config = \NDB_Factory::singleton()->config(); + $downloadpath = \Utility::appendForwardSlash( + $config->getSetting("dataDirBasepath") + ); + switch ($request->getMethod()) { + case "GET": + $file = $request->getQueryParams()['file'] ?? null; + $filename = urldecode(basename($file)); + $path = dirname($file); + + $downloader = new \LORIS\FilesDownloadHandler( + new \SPLFileInfo($downloadpath . $path) + ); + return $downloader->handle( + $request->withAttribute('filename', $filename) + ); + default: + return (new \LORIS\Http\Response\JSON\MethodNotAllowed( + ["GET"] + )); + } + } +} diff --git a/modules/electrophysiology_browser/php/sessions.class.inc b/modules/electrophysiology_browser/php/sessions.class.inc index 06f254755d3..5dd9793cb05 100644 --- a/modules/electrophysiology_browser/php/sessions.class.inc +++ b/modules/electrophysiology_browser/php/sessions.class.inc @@ -126,14 +126,14 @@ class Sessions extends \NDB_Page if (!isset($parameters['outputType'])) { return (new \LORIS\Http\Response\JSON\BadRequest( - 'outputType required' + 'OutputType required' )); } $outputType = $parameters['outputType']; if (!in_array($outputType, ['raw', 'derivative', 'all_types'])) { return (new \LORIS\Http\Response\JSON\BadRequest( - 'invalid output type' + 'Invalid output type' )); } @@ -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; @@ -363,7 +366,8 @@ class Sessions extends \NDB_Page $physiologicalFile ); - $fileSummary['downloads'] = $links; + $fileSummary['downloads'] = $links; + $fileSummary['chunks_url'] = $chunksUrl; $fileCollection[]['file'] = $fileSummary; } @@ -468,6 +472,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 b065eae15fd..c3539e813bd 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,9 @@ "@babel/cli": "^7.6.4", "@babel/core": "^7.11.0", "@babel/plugin-proposal-object-rest-spread": "^7.6.2", - "@babel/preset-env": "^7.6.3", + "@babel/preset-env": "^7.9.6", "@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", @@ -54,7 +55,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", From da0a118989e0bedc6a7dc2b8e238ec94536dc921 Mon Sep 17 00:00:00 2001 From: Laetitia Fesselier Date: Wed, 28 Apr 2021 10:42:56 -0400 Subject: [PATCH 2/8] Improvements following comments --- .../css/electrophysiology_browser.css | 78 +++++++- .../jsx/components/DownloadPanel.js | 15 +- .../electrophysiology_session_panels.js | 5 +- .../electrophysiology_session_summary.js | 2 +- .../jsx/electrophysiologySessionView.js | 18 +- .../jsx/react-series-data-viewer/package.json | 5 +- .../src/series/components/Axis.js | 1 + .../src/series/components/EEGMontage.js | 82 +++------ .../src/series/components/EventManager.js | 4 +- .../src/series/components/IntervalSelect.js | 170 ++++++++---------- .../src/series/components/ResponsiveViewer.js | 2 +- .../src/series/components/SeriesCursor.js | 13 +- .../src/series/components/SeriesRenderer.js | 89 +++++---- .../src/series/components/components.js | 79 ++++++++ .../src/series/store/logic/dragBounds.js | 35 +--- .../src/vector/index.js | 4 + 16 files changed, 347 insertions(+), 255 deletions(-) create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/components.js diff --git a/modules/electrophysiology_browser/css/electrophysiology_browser.css b/modules/electrophysiology_browser/css/electrophysiology_browser.css index 0a87b6f7951..8d28406cd9b 100644 --- a/modules/electrophysiology_browser/css/electrophysiology_browser.css +++ b/modules/electrophysiology_browser/css/electrophysiology_browser.css @@ -79,15 +79,85 @@ svg { border-bottom: none; } -.electrode:hover circle, -.electrode.hover circle { +.electrode:hover circle { stroke: #064785; cursor: pointer; fill: #E4EBF2 } -.electrode:hover text, -.electrode.hover text { +.electrode:hover text { fill: #064785; cursor: pointer; +} + +#eegSessionView .table-scroll { + padding-bottom: 0; +} + +#eegSessionView #lorisworkspace > .panel { + border: none; +} + +#eegSessionView .panel-heading { + height: auto !important; +} + +#eegSidebar { + top: 0; + bottom: 0; + left: 0; + height: calc(100%); + position: fixed; +} + +#page.eegBrowser { + vertical-align: top; + position: relative; + width: auto; +} + +/* Custom, iPhone Retina */ +@media only screen and (min-width : 320px) { + .pagination-nav { + padding-top: 8px; + } + + #eegSidebar { + display: none; + } +} + +/* Extra Small Devices, Phones */ +@media only screen and (min-width : 480px) { + +} + +/* Small Devices, Tablets */ +@media only screen and (min-width : 768px) { + #eegSidebar { + display: block; + } + + #page.eegBrowser { + margin-left: 150px; + } +} + +/* Medium Devices, Desktops */ +@media only screen and (min-width : 992px) { + .event-list { + margin-top: 40px; + margin-bottom: 0; + } +} + +/* Large Devices, Wide Screens */ +@media only screen and (min-width : 1200px) { + .pull-right-lg { + float: right; + } + + .pagination-nav { + padding-top: 0; + } } \ No newline at end of file diff --git a/modules/electrophysiology_browser/jsx/components/DownloadPanel.js b/modules/electrophysiology_browser/jsx/components/DownloadPanel.js index 002b0a59fc4..1a8b5a50d0c 100644 --- a/modules/electrophysiology_browser/jsx/components/DownloadPanel.js +++ b/modules/electrophysiology_browser/jsx/components/DownloadPanel.js @@ -20,8 +20,8 @@ class DownloadPanel extends Component { data: this.props.data, labels: { physiological_file: 'EEG File', - physiological_electrode_file: 'Electrode Info', - physiological_channel_file: 'Channels Info', + physiological_electrode_file: 'Electrodes', + physiological_channel_file: 'Channels', physiological_task_event_file: 'Events', physiological_annotation_files: 'Annotations', all_files: 'All Files', @@ -46,6 +46,8 @@ class DownloadPanel extends Component { display: 'flex', flexDirection: 'column', justifyContent: 'center', + maxWidth: '250px', + margin: '0 auto', }}> {this.state.data.downloads .filter((download) => @@ -56,20 +58,21 @@ class DownloadPanel extends Component { return (
{this.state.labels[download.type]}
{disabled ? Not Available :
-
+
-
+
-
+
-
+
); }; 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 index a43e6ca4e8e..11f051e00d5 100644 --- 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 @@ -37,7 +37,6 @@ const EEGMontage = ( const [mouseX, setMouseX] = useState(0); const [mouseY, setMouseY] = useState(0); const [view3D, setView3D] = useState(false); - const [selectedElectrode, setSelectedElectrode] = useState(null); const scale = 1200; let scatter3D = []; @@ -134,34 +133,34 @@ const EEGMontage = ( {scatter2D.map((point, i) => +
-
-
- {electrodes.map((electrode, i) => { - return ( -
setSelectedElectrode( - e.currentTarget.getAttribute('data-key') - )} - onMouseLeave={(e) => setSelectedElectrode(null)} - > - {i+1}. - {electrode.name} -
- ); - })} -
-
-
+
{view3D ? -
- - - -
+ + + : -
- - - -
+ + + }
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 index e94c57435b3..e3f30b13c6a 100644 --- 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 @@ -38,7 +38,7 @@ const EventManager = ({ justifyContent: 'space-between', }} > - Events/Annotations
+ Events / Annotations in timeline view void, @@ -30,7 +26,6 @@ type Props = { const IntervalSelect = ({ viewerHeight, - seriesViewerWidth, domain, interval, setInterval, @@ -39,75 +34,19 @@ const IntervalSelect = ({ dragEnd, updateFilteredEpochs, }: Props) => { - const [refNode, setRefNode] = useState(null); - const [bounds, setBounds] = useState(null); + const [isDragging, setIsDragging] = useState(false); - 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 sliderStyle = { + position: 'relative', }; - 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(); + const railStyle = { + position: 'absolute', + width: '100%', + height: 10, + marginTop: -9, + borderBottom: '1px solid #000', + cursor: 'pointer', }; return ( @@ -118,7 +57,7 @@ const IntervalSelect = ({ color: '#064785', fontWeight: 'bold', paddingLeft: '15px', - marginBottom: '10px', + marginBottom: '15px', }} > Timeline Range View @@ -136,30 +75,80 @@ const IntervalSelect = ({
- { - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - R.compose(dragStart, R.nth(0))(v); + { + if (!isDragging) { + dragStart(values); + setIsDragging(true); + } else { + dragContinue(values); + } + }} + onChange={(values) => { + dragStart(values); + dragEnd(values); + setIsDragging(false); }} > - - - + + {({getRailProps}) => ( +
+ )} + + + {({handles, getHandleProps}) => ( +
+ {handles.map((handle) => ( + + ))} +
+ )} +
+ + {({ticks}) => ( +
+ {ticks.map((tick) => ( + + ))} +
+ )} +
+ + +
+ Time (s) +
); }; IntervalSelect.defaultProps = { - viewerHeight: 50, - seriesViewerWidth: 400, + viewerHeight: 20, domain: [0, 1], interval: [0.25, 0.75], }; @@ -168,7 +157,6 @@ export default connect( (state) => ({ domain: state.bounds.domain, interval: state.bounds.interval, - seriesViewerWidth: state.bounds.viewerWidth, }), (dispatch: any => void) => ({ dragStart: R.compose( 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 index 69c4ab32d8a..e86d49935a2 100644 --- 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 @@ -74,7 +74,7 @@ const ResponsiveViewer = ({ parentWidth, parentHeight, ].join(' ')} - style={{overflow: 'visible'}} + style={{overflow: 'hidden'}} width={parentWidth} height={parentHeight} onMouseDown={R.compose( 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 index 9d838be9d10..4ac08d8fbd8 100644 --- 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 @@ -6,7 +6,7 @@ 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 {MAX_RENDERED_EPOCHS, SIGNAL_SCALE} from '../../vector'; import {useEffect} from 'react'; type CursorContentProps = { @@ -37,6 +37,8 @@ const SeriesCursor = ( showMarker, }: Props ) => { + if (!cursor) return null; + let reversedEpochs = [...filteredEpochs].reverse(); useEffect(() => { reversedEpochs = [...filteredEpochs].reverse(); @@ -94,12 +96,13 @@ const SeriesCursor = ( position: 'absolute', display: 'flex', flexDirection: 'row', - backgroundColor: '#eee', + backgroundColor: '#fff', + color: '#064785', padding: '2px 2px', borderRadius: '3px', }} > - {time} + {Math.round(time)}
); @@ -180,7 +183,7 @@ const CursorContent = ({time, channel, contentIndex, showMarker}) => { const idx = bisectTime(indices, time); const value = chunk.values[idx-1]; - return value; + return value * SIGNAL_SCALE; }; return ( @@ -195,7 +198,7 @@ const CursorContent = ({time, channel, contentIndex, showMarker}) => { }} > {showMarker && ()} - {chunk && computeValue(chunk)} + {chunk && Math.round(computeValue(chunk))}
); })} 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 index 07796347f2a..79b9029fb05 100644 --- 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 @@ -6,7 +6,7 @@ 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 {MAX_RENDERED_EPOCHS, MAX_CHANNELS, SIGNAL_UNIT} from '../../vector'; import ResponsiveViewer from './ResponsiveViewer'; import Axis from './Axis'; import LineChunk from './LineChunk'; @@ -330,19 +330,25 @@ const SeriesRenderer = ({ return ( {channels.length > 0 ? (
-
+
-
+
-
- - {HIGH_PASS_FILTERS[highPass].label} - -
-
+ {HIGH_PASS_FILTERS[highPass].label} +
@@ -407,12 +412,11 @@ const SeriesRenderer = ({ className="btn btn-xs btn-primary dropdown-toggle" data-toggle='dropdown' > -
- - {LOW_PASS_FILTERS[lowPass].label} - -
-
+ {LOW_PASS_FILTERS[lowPass].label} +
@@ -433,8 +437,13 @@ const SeriesRenderer = ({
Showing{' '} @@ -509,13 +518,30 @@ const SeriesRenderer = ({ onMouseLeave={() => setCursor(null)} >
- {cursor && ( - - )} +
+ ({SIGNAL_UNIT}) +
+
+ Time (s) +
+
@@ -572,10 +598,11 @@ const SeriesRenderer = ({ }
{[...Array(epochs.length).keys()].filter((i) => @@ -590,7 +617,6 @@ const SeriesRenderer = ({ }} > Too many events to display for the timeline range. - Limit the time range.
}
@@ -598,10 +624,7 @@ const SeriesRenderer = ({
{rightPanel && -
+
{rightPanel === 'annotationForm' && } {rightPanel === 'epochList' && }
diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/components.js b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/components.js new file mode 100644 index 00000000000..ac2158ecf7f --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/components.js @@ -0,0 +1,79 @@ +import * as React from 'react'; +import { + SliderItem, + GetHandleProps, +} from 'react-compound-slider'; + +// ******************************************************* +// HANDLE COMPONENT +// ******************************************************* +interface IHandleProps { + domain: number[]; + handle: SliderItem; + getHandleProps: GetHandleProps; +} + +export const Handle: React.SFC = ({ + domain: [min, max], + handle: {id, value, percent}, + getHandleProps, +}) => ( +
+); + +// ******************************************************* +// TICK COMPONENT +// ******************************************************* +interface ITickProps { + key: string; + tick: SliderItem; + count: number; +} + +export const Tick: React.FC = ({tick, count}) => ( +
+
+
+ {tick.value} +
+
+); 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 index fac1ca8fa8c..9a0c78cafa3 100644 --- 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 @@ -12,7 +12,6 @@ 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); @@ -29,8 +28,6 @@ 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')) @@ -41,37 +38,9 @@ export const createDragBoundsEpic = (fromState: any => BoundsState) => ( 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); + const endDrag$ = action$.pipe(ofType(END_DRAG_INTERVAL)); - return setInterval(interval); - }; + const computeNewInterval = ([selection]) => setInterval(selection); const startUpdates$ = startDrag$.pipe( Rx.withLatestFrom(state$), 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 index 4e83fb92437..501a9ccddf2 100644 --- 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 @@ -15,4 +15,8 @@ export const MAX_VIEWED_CHUNKS = 3; export const MAX_CHANNELS = 6; +export const SIGNAL_SCALE = Math.pow(10, 6); + +export const SIGNAL_UNIT = 'µV'; + export const MAX_RENDERED_EPOCHS = 100; From 2b6eb7bc4e13b328c60634fd3ecd4e65aefe104e Mon Sep 17 00:00:00 2001 From: Laetitia Fesselier Date: Wed, 12 May 2021 17:32:52 -0400 Subject: [PATCH 3/8] iEEG/EEG metadata --- .../jsx/components/DownloadPanel.js | 27 +- .../electrophysiology_session_panels.js | 272 +++------------- .../electrophysiology_session_summary.js | 108 ++----- .../jsx/electrophysiologySessionView.js | 27 +- .../php/models/electrophysiofile.class.inc | 6 +- .../php/sessions.class.inc | 299 ++++++++++++++---- 6 files changed, 328 insertions(+), 411 deletions(-) diff --git a/modules/electrophysiology_browser/jsx/components/DownloadPanel.js b/modules/electrophysiology_browser/jsx/components/DownloadPanel.js index 1a8b5a50d0c..468439279ac 100644 --- a/modules/electrophysiology_browser/jsx/components/DownloadPanel.js +++ b/modules/electrophysiology_browser/jsx/components/DownloadPanel.js @@ -2,6 +2,7 @@ * This file contains React component for Electrophysiology module. */ import React, {Component} from 'react'; +import PropTypes from 'prop-types'; import Panel from 'Panel'; /** @@ -17,16 +18,7 @@ class DownloadPanel extends Component { constructor(props) { super(props); this.state = { - data: this.props.data, - labels: { - physiological_file: 'EEG File', - physiological_electrode_file: 'Electrodes', - physiological_channel_file: 'Channels', - physiological_task_event_file: 'Events', - physiological_annotation_files: 'Annotations', - all_files: 'All Files', - physiological_fdt_file: '', - }, + downloads: this.props.downloads, }; } @@ -49,7 +41,7 @@ class DownloadPanel extends Component { maxWidth: '250px', margin: '0 auto', }}> - {this.state.data.downloads + {this.state.downloads .filter((download) => download.type != 'physiological_fdt_file' ) @@ -69,7 +61,7 @@ class DownloadPanel extends Component { verticalAlign: 'middle', paddingLeft: 0, }} - >{this.state.labels[download.type]}
+ >{download.label}
{disabled ? @@ -65,206 +44,47 @@ class FilePanel extends Component { title={'Acquisition Details for ' + this.props.title} >
-
-
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- 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 - } -
+
+
+ {splitData.map((column, i) => ( +
+
+ + + {column.map((row, j) => { + const {name, value} = row; + return ( + + + + + ); + })} + +
{name}{value}
+
-
+ ))}
@@ -274,6 +94,7 @@ class FilePanel extends Component { ); } } + FilePanel.propTypes = { id: PropTypes.string, title: PropTypes.string, @@ -289,4 +110,3 @@ FilePanel.defaultProps = { export { FilePanel, }; - diff --git a/modules/electrophysiology_browser/jsx/components/electrophysiology_session_summary.js b/modules/electrophysiology_browser/jsx/components/electrophysiology_session_summary.js index aecbd246f34..55dfba2d561 100644 --- a/modules/electrophysiology_browser/jsx/components/electrophysiology_session_summary.js +++ b/modules/electrophysiology_browser/jsx/components/electrophysiology_session_summary.js @@ -7,7 +7,6 @@ import Panel from 'jsx/Panel'; * * This file contains React component for Electrophysiology module. * - * @author Alizée Wickenheiser. * @version 0.0.1 */ class SummaryPanel extends Component { @@ -18,7 +17,12 @@ class SummaryPanel extends Component { constructor(props) { super(props); this.state = { - data: this.props.data, + data: [ + this.props.data.frequency.sampling, + ...this.props.data.channel_count, + this.props.data.reference, + this.props.data.frequency.powerline, + ], }; } @@ -28,25 +32,6 @@ class SummaryPanel extends Component { * @return {JSX} - React markup for the component */ render() { - const styles = { - table: { - header: { - color: '#074785', - padding: '5px 10px', - wordWrap: 'break-word', - width: '200px', - }, - style: { - background: '#fff', - width: '100%', - }, - data: { - padding: '5px 10px', - wordWrap: 'break-word', - }, - }, - }; - return (
- - - - - - - - - - - - - - - - - - - - - - - - - - - - + {this.state.data.map((row, i) => { + const {name, value} = row; + return ( + + + + + ); + })}
- 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} -
{name}{value}
diff --git a/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js b/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js index 25637d172c1..53fd61d4ad9 100644 --- a/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js +++ b/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js @@ -63,29 +63,12 @@ class ElectrophysiologySessionView extends Component { { file: { name: '', - task: { + summary: { frequency: { sampling: '', powerline: '', }, - channel: [ - { - name: '', - value: '', - }, - { - name: '', - value: '', - }, - { - name: '', - value: '', - }, - { - name: '', - value: '', - }, - ], + channel_count: [], reference: '', }, details: { @@ -300,7 +283,7 @@ class ElectrophysiologySessionView extends Component {
diff --git a/modules/electrophysiology_browser/php/models/electrophysiofile.class.inc b/modules/electrophysiology_browser/php/models/electrophysiofile.class.inc index bbd1bb451ca..79b68b93b78 100644 --- a/modules/electrophysiology_browser/php/models/electrophysiofile.class.inc +++ b/modules/electrophysiology_browser/php/models/electrophysiofile.class.inc @@ -39,7 +39,7 @@ class ElectrophysioFile implements \LORIS\Data\DataInstance $query = "SELECT PhysiologicalFileID, - PhysiologicalModalityID, + PhysiologicalModality as Modality, PhysiologicalOutputTypeID, SessionID, InsertTime, @@ -47,7 +47,9 @@ class ElectrophysioFile implements \LORIS\Data\DataInstance AcquisitionTime, InsertedByUser, FilePath - FROM physiological_file + FROM physiological_file + LEFT JOIN physiological_modality as pm + USING (PhysiologicalModalityID) WHERE PhysiologicalFileID=:PFID"; $params = ['PFID' => $physiologicalFileID]; $fileData = $db->pselectRow($query, $params); diff --git a/modules/electrophysiology_browser/php/sessions.class.inc b/modules/electrophysiology_browser/php/sessions.class.inc index 5dd9793cb05..4c4b0b82a64 100644 --- a/modules/electrophysiology_browser/php/sessions.class.inc +++ b/modules/electrophysiology_browser/php/sessions.class.inc @@ -263,101 +263,247 @@ class Sessions extends \NDB_Page $fileSummary['name'] = $fileName; - // get the task frequency information + // get the summary frequency information $sampling = $physioFileObj->getParameter('SamplingFrequency'); $powerline = $physioFileObj->getParameter('PowerLineFrequency'); - $fileSummary['task']['frequency']['sampling'] = $sampling; - $fileSummary['task']['frequency']['powerline'] = $powerline; + $fileSummary['summary']['frequency']['sampling'] = [ + 'name' => 'Sampling Frequency', + 'value' => $sampling, + ]; + + $fileSummary['summary']['frequency']['powerline'] = [ + 'name' => 'Powerline Frequency', + 'value' => $powerline, + ]; - // get the task channel information + // get the summary channel information + + if ($physioFileObj->getParameter('Modality') == 'ieeg') { + $ecogChannelCount = $physioFileObj->getParameter('ECOGChannelCount'); + $seegChannelCount = $physioFileObj->getParameter('SEEGChannelCount'); + + $fileSummary['summary']['channel_count'][] = [ + 'name' => 'ECOG Channel Count', + 'value' => $ecogChannelCount, + ]; + $fileSummary['summary']['channel_count'][] = [ + 'name' => 'SEEG Channel Count', + 'value' => $seegChannelCount, + ]; + } $eegChannelCount = $physioFileObj->getParameter('EEGChannelCount'); $eogChannelCount = $physioFileObj->getParameter('EOGChannelCount'); $ecgChannelCount = $physioFileObj->getParameter('ECGChannelCount'); $emgChannelCount = $physioFileObj->getParameter('EMGChannelCount'); - $fileSummary['task']['channel'][] = [ + $fileSummary['summary']['channel_count'][] = [ 'name' => 'EEG Channel Count', 'value' => $eegChannelCount, ]; - $fileSummary['task']['channel'][] = [ + $fileSummary['summary']['channel_count'][] = [ 'name' => 'EOG Channel Count', 'value' => $eogChannelCount, ]; - $fileSummary['task']['channel'][] = [ + $fileSummary['summary']['channel_count'][] = [ 'name' => 'ECG Channel Count', 'value' => $ecgChannelCount, ]; - $fileSummary['task']['channel'][] = [ + $fileSummary['summary']['channel_count'][] = [ 'name' => 'EMG Channel Count', 'value' => $emgChannelCount, ]; - // get the task reference + // get the summary reference - $reference = $physioFileObj->getParameter('EEGReference'); + if ($physioFileObj->getParameter('Modality') == 'ieeg') { + $reference = $physioFileObj->getParameter('iEEGReference'); + } else { + $reference = $physioFileObj->getParameter('EEGReference'); + } - $fileSummary['task']['reference'] = $reference; + $fileSummary['summary']['reference'] = [ + 'name' => 'Reference', + 'value' => $reference, + ]; // get the file's details - $taskDesc = $physioFileObj->getParameter('TaskDescription'); - $instructions = $physioFileObj->getParameter('Instructions'); - $placement = $physioFileObj->getParameter('EEGPlacementScheme'); - $triggerCount = $physioFileObj->getParameter('TriggerChannelCount'); - $recordingType = $physioFileObj->getParameter('Recording_type'); - $cogAtlasID = $physioFileObj->getParameter('CogAtlasID'); - $cogPoid = $physioFileObj->getParameter('CogPOID'); - $instituteName = $physioFileObj->getParameter('InstitutionName'); - $intituteAddress = $physioFileObj->getParameter('InstitutionAddress'); - $miscChannelCount = $physioFileObj->getParameter('MiscChannelCount'); - $manufacturer = $physioFileObj->getParameter('Manufacturer'); - $modelName = $physioFileObj->getParameter( - 'ManufacturerModelName' - ); - $capManufacturer = $physioFileObj->getParameter( - 'ManufacturerCapModelName' - ); - $capModelName = $physioFileObj->getParameter( - 'ManufacturerCapModelName' - ); - $hardwareFilters = $physioFileObj->getParameter('HardwareFilters'); - $duration = $physioFileObj->getParameter('RecordingDuration'); - $epochLength = $physioFileObj->getParameter('EpochLength'); - $softwareVersion = $physioFileObj->getParameter( - 'DeviceSoftwareVersion' + if ($physioFileObj->getParameter('Modality') == 'ieeg') { + $placement = $physioFileObj->getParameter('iEEGPlacementScheme'); + $ground = $physioFileObj->getParameter('iEEGGround'); + $electrodeManufacturer = [ + 'name' => 'Electrode Manufacturer', + 'value' => $physioFileObj->getParameter('ElectrodeManufacturer'), + ]; + $electrodeManufacturersModelName = [ + 'name' => 'Electrode Manufacturer\'s Model Name', + 'value' => $physioFileObj->getParameter( + 'ElectrodeManufacturersModelName' + ), + ]; + } else { + $placement = $physioFileObj->getParameter('EEGPlacementScheme'); + $ground = $physioFileObj->getParameter('EEGGround'); + $electrodeManufacturer = [ + 'name' => 'Cap Manufacturer', + 'value' => $physioFileObj->getParameter('CapManufacturer'), + ]; + $electrodeManufacturersModelName = [ + 'name' => 'Cap Manufacturer\'s Model Name', + 'value' => $physioFileObj->getParameter( + 'CapManufacturersModelName' + ), + ]; + + } + + $taskName = $physioFileObj->getParameter('TaskName'); + $taskDesc = $physioFileObj->getParameter('TaskDescription'); + $instructions = $physioFileObj->getParameter('Instructions'); + $headCircumference = $physioFileObj->getParameter('HeadCircumference'); + $triggerCount = $physioFileObj->getParameter('TriggerChannelCount'); + $recordingType = $physioFileObj->getParameter('Recording_type'); + $cogAtlasID = $physioFileObj->getParameter('CogAtlasID'); + $cogPoid = $physioFileObj->getParameter('CogPOID'); + $instituteName = $physioFileObj->getParameter('InstitutionName'); + $intituteAddress = $physioFileObj->getParameter('InstitutionAddress'); + $miscChannelCount = $physioFileObj->getParameter('MiscChannelCount'); + $manufacturer = $physioFileObj->getParameter('Manufacturer'); + $modelName = $physioFileObj->getParameter( + 'ManufacturersModelName' ); - $serialNumber = $physioFileObj->getParameter('DeviceSerialNumber'); - $artefactDesc = $physioFileObj->getParameter( + $hardwareFilters = $physioFileObj->getParameter('HardwareFilters'); + $duration = $physioFileObj->getParameter('RecordingDuration'); + $epochLength = $physioFileObj->getParameter('EpochLength'); + $softwareVersions = $physioFileObj->getParameter('SoftwareVersions'); + $softwareFilters = $physioFileObj->getParameter('SoftwareFilters'); + $serialNumber = $physioFileObj->getParameter('DeviceSerialNumber'); + $artefactDesc = $physioFileObj->getParameter( 'SubjectArtefactDescription' ); - $chunksUrl = $physioFileObj->getParameter( + $chunksUrl = $physioFileObj->getParameter( 'electrophyiology_chunked_dataset_path' ); - $fileSummary['details']['task']['description'] = $taskDesc; - $fileSummary['details']['instructions'] = $instructions; - $fileSummary['details']['eeg']['ground'] = ''; - $fileSummary['details']['eeg']['placement_scheme'] = $placement; - $fileSummary['details']['trigger_count'] = $triggerCount; - $fileSummary['details']['record_type'] = $recordingType; - $fileSummary['details']['cog']['atlas_id'] = $cogAtlasID; - $fileSummary['details']['cog']['poid'] = $cogPoid; - $fileSummary['details']['institution']['name'] = $instituteName; - $fileSummary['details']['institution']['address'] = $intituteAddress; - $fileSummary['details']['misc']['channel_count'] = $miscChannelCount; - $fileSummary['details']['manufacturer']['name'] = $manufacturer; - $fileSummary['details']['manufacturer']['model_name'] = $modelName; - $fileSummary['details']['cap']['manufacturer'] = $capManufacturer; - $fileSummary['details']['cap']['model_name'] = $capModelName; - $fileSummary['details']['hardware_filters'] = $hardwareFilters; - $fileSummary['details']['recording_duration'] = $duration; - $fileSummary['details']['epoch_length'] = $epochLength; - $fileSummary['details']['device']['version'] = $softwareVersion; - $fileSummary['details']['device']['serial_number'] = $serialNumber; - $fileSummary['details']['subject_artefact_description'] = $artefactDesc; + $fileSummary['details'] = [ + [ + 'name' => 'Task Name', + 'value' => $taskName, + ], + [ + 'name' => 'Task Description', + 'value' => $taskDesc, + ], + [ + 'name' => 'Instructions', + 'value' => $instructions, + ], + [ + 'name' => 'Recording Type', + 'value' => $recordingType, + ], + [ + 'name' => 'Recording Duration', + 'value' => $duration, + ], + [ + 'name' => 'Epoch Length', + 'value' => $epochLength, + ], + [ + 'name' => 'Subject Artefact Description', + 'value' => $artefactDesc, + ], + [ + 'name' => 'Head Circumference', + 'value' => $headCircumference, + ], + [ + 'name' => 'Placement Scheme', + 'value' => $placement, + ], + [ + 'name' => 'Ground', + 'value' => $ground, + ], + [ + 'name' => 'Trigger Channel Count', + 'value' => $triggerCount, + ], + [ + 'name' => 'Misc Channel Count', + 'value' => $miscChannelCount, + ], + [ + 'name' => 'CogAtlas ID', + 'value' => $cogAtlasID, + ], + [ + 'name' => 'CogPO ID', + 'value' => $cogPoid, + ], + [ + 'name' => 'Institution Name', + 'value' => $instituteName, + ], + [ + 'name' => 'Institution Address', + 'value' => $intituteAddress, + ], + [ + 'name' => 'Manufacturer', + 'value' => $manufacturer, + ], + [ + 'name' => 'Manufacturers Model Name', + 'value' => $modelName, + ], + $electrodeManufacturer, + $electrodeManufacturersModelName, + [ + 'name' => 'Device Serial Number', + 'value' => $serialNumber, + ], + [ + 'name' => 'Hardware Filters', + 'value' => $hardwareFilters, + ], + [ + 'name' => 'Software Versions', + 'value' => $softwareVersions, + ], + [ + 'name' => 'Software Filters', + 'value' => $softwareFilters, + ], + ]; + + if ($physioFileObj->getParameter('Modality') == 'ieeg') { + $fileSummary['details'][] = [ + 'name' => 'DC Offset Correction', + 'value' => $physioFileObj->getParameter('DCOffsetCorrection'), + ]; + + $fileSummary['details'][] = [ + 'name' => 'Electrode Groups', + 'value' => $physioFileObj->getParameter('ElectrodeGroups'), + ]; + + $fileSummary['details'][] = [ + 'name' => 'Electrical Stimulation', + 'value' => $physioFileObj->getParameter('ElectricalStimulation'), + ]; + + $fileSummary['details'][] = [ + 'name' => 'Electrical Stimulation Parameters', + 'value' => $physioFileObj->getParameter( + 'ElectricalStimulationParameters' + ), + ]; + } // get the links to the files for downloads @@ -394,8 +540,9 @@ class Sessions extends \NDB_Page $params['PFID'] = $physioFileID; $downloadLinks = []; $downloadLinks[] = [ - 'type' => 'physiological_file', - 'file' => $physioFile, + 'type' => 'physiological_file', + 'file' => $physioFile, + 'label' => 'EEG File', ]; $queries = [ @@ -406,6 +553,14 @@ class Sessions extends \NDB_Page 'physiological_archive' => 'all_files', ]; + $labels = [ + 'physiological_electrode_file' => 'Electrodes', + 'physiological_channel_file' => 'Channels', + 'physiological_task_event_file' => 'Events', + 'physiological_annotation_files' => 'Annotations', + 'all_files' => 'All Files', + ]; + foreach ($queries as $query_key => $query_value) { $query_statement = "SELECT DISTINCT(FilePath), '$query_value' AS FileType @@ -416,13 +571,15 @@ class Sessions extends \NDB_Page $query_statement = $db->pselectRow($query_statement, $params); if (isset($query_statement['FileType'])) { $downloadLinks[] = [ - 'type' => $query_statement['FileType'], - 'file' => $query_statement['FilePath'], + 'type' => $query_statement['FileType'], + 'file' => $query_statement['FilePath'], + 'label' => $labels[$query_statement['FileType']] ]; } else { $downloadLinks[] = [ - 'type' => $query_value, - 'file' => '', + 'type' => $query_value, + 'file' => '', + 'label' => $labels[$query_value], ]; } } @@ -439,13 +596,15 @@ class Sessions extends \NDB_Page $queryFDT = $db->pselectRow($queryFDT, $params); if (isset($queryFDT['FileType'])) { $downloadLinks[] = [ - 'type' => $queryFDT['FileType'], - 'file' => $queryFDT['FilePath'], + 'type' => $queryFDT['FileType'], + 'file' => $queryFDT['FilePath'], + 'label' => '', ]; } else { $downloadLinks[] = [ - 'type' => 'physiological_fdt_file', - 'file' => '', + 'type' => 'physiological_fdt_file', + 'file' => '', + 'label' => '', ]; } From a0806a81bf2921586ac7a2d01a9b719920686188 Mon Sep 17 00:00:00 2001 From: Laetitia Fesselier Date: Thu, 27 May 2021 19:58:46 -0400 Subject: [PATCH 4/8] Split files --- ...21-05-20-Electrophysiology-split-files.sql | 25 + htdocs/bootstrap/css/custom-css.css | 6 +- jsx/Panel.js | 15 +- .../jsx/components/DownloadPanel.js | 128 ++-- .../electrophysiology_session_panels.js | 12 +- .../electrophysiology_session_summary.js | 11 +- .../jsx/electrophysiologySessionView.js | 148 ++++- .../src/ajax/index.js | 18 +- .../src/eeglab/EEGLabSeriesProvider.js | 80 ++- .../src/series/components/IntervalSelect.js | 1 + .../src/series/components/SeriesRenderer.js | 19 +- .../src/series/store/logic/fetchChunks.js | 10 +- .../src/series/store/state/dataset.js | 6 +- ...ophysiologybrowserrowprovisioner.class.inc | 5 +- .../php/models/electrophysiofile.class.inc | 88 ++- .../php/sessions.class.inc | 554 +++++++++--------- .../php/split_data.class.inc | 58 ++ 17 files changed, 741 insertions(+), 443 deletions(-) create mode 100644 SQL/New_patches/2021-05-20-Electrophysiology-split-files.sql create mode 100644 modules/electrophysiology_browser/php/split_data.class.inc diff --git a/SQL/New_patches/2021-05-20-Electrophysiology-split-files.sql b/SQL/New_patches/2021-05-20-Electrophysiology-split-files.sql new file mode 100644 index 00000000000..a9129de08f2 --- /dev/null +++ b/SQL/New_patches/2021-05-20-Electrophysiology-split-files.sql @@ -0,0 +1,25 @@ +INSERT INTO `ImagingFileTypes` VALUE ('archive', 'Archive file'); + + `Index` INT(5) NOT NULL, + `ArchiveID` INT(10) UNSIGNED NOT NULL, + +ALTER TABLE `physiological_file` + ADD COLUMN `Index` INT(5) DEFAULT NULL, + ADD COLUMN `ParentID` INT(10) unsigned DEFAULT NULL, + ADD CONSTRAINT `FK_ParentID` FOREIGN KEY (`ParentID`) REFERENCES `physiological_file` (`PhysiologicalFileID`); + +CREATE TABLE `physiological_split_file` ( + `ID` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `Index` INT(5) NOT NULL, + `ArchiveID` INT(10) UNSIGNED NOT NULL, + `FileType` VARCHAR(12) DEFAULT NULL, + `FilePath` VARCHAR(255) NOT NULL, + `Duration` DECIMAL(10,3) NOT NULL, + CONSTRAINT `FK_ArchiveID` + FOREIGN KEY (`ArchiveID`) + REFERENCES `physiological_file` (`PhysiologicalFileID`), + CONSTRAINT `FK_ImagingFileTypes` + FOREIGN KEY (`FileType`) + REFERENCES `ImagingFileTypes` (`type`), + PRIMARY KEY (`ID`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; \ No newline at end of file diff --git a/htdocs/bootstrap/css/custom-css.css b/htdocs/bootstrap/css/custom-css.css index 1e6a934688e..37a1103ae46 100644 --- a/htdocs/bootstrap/css/custom-css.css +++ b/htdocs/bootstrap/css/custom-css.css @@ -496,7 +496,7 @@ div.navbar div.container div.navbar-brand { margin-right: 5px; } -.btn-default { +.btn-default:not(.disabled) { border-color: #246EB6; color: #246EB6; background-color: white; @@ -533,7 +533,7 @@ div.navbar div.container div.navbar-brand { border-color: #246EB6; } -a.btn.btn-primary:not(.download) { +a.btn.btn-primary:not(.download, .split-nav) { color: #064785; border-color: transparent; background-color: white; @@ -541,7 +541,7 @@ a.btn.btn-primary:not(.download) { transition: background-color 0.2s ease-in; } -a.btn.btn-primary:hover:not(.download) { +a.btn.btn-primary:hover:not(.download, .split-nav) { color: #E89A0C; border-color: transparent; background-color: white; diff --git a/jsx/Panel.js b/jsx/Panel.js index a9aa17226cc..d8a976409b5 100644 --- a/jsx/Panel.js +++ b/jsx/Panel.js @@ -68,10 +68,15 @@ class Panel extends Component { onClick={this.toggleCollapsed} data-toggle="collapse" data-target={'#' + this.props.id} - style={this.props.collapsing ? - {cursor: 'pointer', height: '3em', fontWeight: 'bold'} : - {cursor: 'default', height: '3em', fontWeight: 'bold'} + data-parent={this.props.parentId ? + '#'+this.props.parentId : + false } + style={{ + cursor: this.props.collapsing ? 'pointer' : 'default', + height: '3em', + fontWeight: 'bold', + }} > {title} {this.props.collapsing ? : ''} @@ -86,7 +91,7 @@ class Panel extends Component {
@@ -100,6 +105,7 @@ class Panel extends Component { Panel.propTypes = { initCollapsed: PropTypes.bool, + parentId: PropTypes.string, id: PropTypes.string, height: PropTypes.string, title: PropTypes.string, @@ -109,6 +115,7 @@ Panel.propTypes = { }; Panel.defaultProps = { initCollapsed: false, + parentId: null, id: 'default-panel', height: '100%', class: 'panel-primary', diff --git a/modules/electrophysiology_browser/jsx/components/DownloadPanel.js b/modules/electrophysiology_browser/jsx/components/DownloadPanel.js index 468439279ac..e9b457cddf1 100644 --- a/modules/electrophysiology_browser/jsx/components/DownloadPanel.js +++ b/modules/electrophysiology_browser/jsx/components/DownloadPanel.js @@ -33,59 +33,89 @@ class DownloadPanel extends Component { id={this.props.id} title={'File Download'} > -
- {this.state.downloads - .filter((download) => - download.type != 'physiological_fdt_file' - ) - .map((download, i) => { - const disabled = (download.file === ''); + ); diff --git a/modules/electrophysiology_browser/jsx/components/electrophysiology_session_panels.js b/modules/electrophysiology_browser/jsx/components/electrophysiology_session_panels.js index 3cd5bb71997..03cb9234a40 100644 --- a/modules/electrophysiology_browser/jsx/components/electrophysiology_session_panels.js +++ b/modules/electrophysiology_browser/jsx/components/electrophysiology_session_panels.js @@ -28,7 +28,7 @@ class FilePanel extends Component { */ render() { const halfSize = this.state.data.length/2; - const splitData = [ + const columns = [ this.state.data.slice(0, halfSize), this.state.data.slice(halfSize), ]; @@ -41,12 +41,14 @@ class FilePanel extends Component {
- {splitData.map((column, i) => ( + {columns.map((column, i) => (
({ ...dbEntry, // EEG Visualisation urls - chunkDirectoryURL: + chunksURLs: dbEntry - && dbEntry.file.chunks_url - && loris.BaseURL - + '/electrophysiology_browser/file_reader/?file=' - + dbEntry.file.chunks_url, - epochsTableURL: + && dbEntry.file.chunks_urls.map( + (url) => + loris.BaseURL + + '/electrophysiology_browser/file_reader/?file=' + + url + ), + epochsURL: dbEntry - && dbEntry.file.downloads[3].file + && dbEntry.file.downloads[3]?.file && loris.BaseURL + '/electrophysiology_browser/file_reader/?file=' + dbEntry.file.downloads[3].file, - electrodesTableUrls: + electrodesURL: dbEntry - && dbEntry.file.downloads[1].file + && dbEntry.file.downloads[1]?.file && loris.BaseURL + '/electrophysiology_browser/file_reader/?file=' + dbEntry.file.downloads[1].file, @@ -253,6 +257,40 @@ class ElectrophysiologySessionView extends Component { }); } + /** + * Get split data for split index + * + * @param {int} physioFileID + * @param {int} fileIndex + * @param {int} splitIndex + */ + getSplitData(physioFileID, fileIndex, splitIndex) { + const dataURL = loris.BaseURL + + '/electrophysiology_browser/split_data'; + const formData = new FormData(); + formData.append('physioFileID', physioFileID); + formData.append('splitIndex', splitIndex); + + fetch( + dataURL, { + method: 'POST', + body: formData, + }).then((resp) => { + if (!resp.ok) { + throw Error(resp.statusText); + } + + resp.json().then((splitData) => { + const database = JSON.parse(JSON.stringify(this.state.database)); + database[fileIndex].file.splitData = splitData; + this.setState({database}); + }); + }).catch((error) => { + this.setState({error: true}); + console.error(error); + }); + } + /** * Renders the React component. * @@ -274,10 +312,24 @@ class ElectrophysiologySessionView extends Component { let database = []; for (let i = 0; i < this.state.database.length; i++) { const { - chunkDirectoryURL, - epochsTableURL, - electrodesTableUrls, + chunksURLs, + epochsURL, + electrodesURL, } = this.state.database[i]; + const file = this.state.database[i].file; + const splitPagination = []; + for (const j of Array(file.splitData?.splitCount).keys()) { + splitPagination.push( + this.getSplitData(file.id, i, j)} + >{j+1} + ); + } database.push(
- + + {file.splitData && + <> + + Viewing signal split file: + + this.getSplitData( + file.id, + i, + file.splitData.splitIndex-1 + )} + > + {'<'} + + {splitPagination} + this.getSplitData( + file.id, + i, + file.splitData.splitIndex+1 + ) + } + > + {'>'} + + + } + +
- fetch(...args).then((response) => - response.blob().then((data) => data) - ); + fetch(...args).then((response) => { + if (!response.ok) { + return new Promise(() => {}); + } + return response.blob().then((data) => data); + }); export const fetchJSON = (...args) => fetch(...args).then((response) => { @@ -12,6 +15,9 @@ export const fetchJSON = (...args) => }); export const fetchText = (...args) => - fetch(...args).then((response) => - response.text().then((data) => data) - ); + fetch(...args).then((response) => { + if (!response.ok) { + return new Promise(() => {}); + } + return response.text().then((data) => data); + }); 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 index 4ff68864697..bff25a0c5f6 100644 --- 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 @@ -17,6 +17,13 @@ import {setDomain, setInterval} from '../series/store/state/bounds'; import {updateFilteredEpochs} from '../series/store/logic/filterEpochs'; import {setElectrodes} from '../series/store/state/montage'; +type Props = { + chunksURL: string, + epochsURL: string, + electrodesURL: string, + limit: number, +}; + /** * EEGLabSeriesProvider component */ @@ -34,47 +41,43 @@ class EEGLabSeriesProvider extends Component { applyMiddleware(thunk, epicMiddleware) ); + this.state = { + channels: [], + }; + + this.store.subscribe(this.listener.bind(this)); + epicMiddleware.run(rootEpic); window.EEGLabSeriesProviderStore = this.store; const { - chunkDirectoryURLs, - epochsTableURLs, - electrodesTableUrls, + chunksURL, + epochsURL, + electrodesURL, 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) => {}); - }) - ); + const racers = (fetcher, url, route = '') => { + if (url) { + return [fetcher(`${url}${route}`) + .then((json) => ({json, url})) + // if request fails don't resolve + .catch((error) => { + console.error(error); + return new Promise((resolve) => {}); + })]; + } else { + return [new Promise((resolve) => {})]; + } + }; - Promise.race(racers(fetchJSON, chunkUrls, '/index.json')).then( + Promise.race(racers(fetchJSON, chunksURL, '/index.json')).then( ({json, url}) => { const {channelMetadata, shapes, timeInterval, seriesRange} = json; this.store.dispatch( setDatasetMetadata({ - chunkDirectoryURL: url, + chunksURL: url, channelMetadata, shapes, timeInterval, @@ -89,7 +92,7 @@ class EEGLabSeriesProvider extends Component { this.store.dispatch(setDomain(timeInterval)); this.store.dispatch(setInterval(timeInterval)); } - ).then(() => Promise.race(racers(fetchText, epochUrls)).then((text) => { + ).then(() => Promise.race(racers(fetchText, epochsURL)).then((text) => { if (!(typeof text.json === 'string' || text.json instanceof String)) return; this.store.dispatch( @@ -109,7 +112,7 @@ class EEGLabSeriesProvider extends Component { }) ); - Promise.race(racers(fetchText, electrodeUrls)) + Promise.race(racers(fetchText, electrodesURL)) .then((text) => { if (!(typeof text.json === 'string' || text.json instanceof String)) return; @@ -128,13 +131,28 @@ class EEGLabSeriesProvider extends Component { }); } + /** + * Store update listener + */ + listener() { + this.setState({ + channels: this.store.getState().dataset.channels, + }); + } + /** * Renders the React component. * * @return {JSX} - React markup for the component */ render() { - return {this.props.children}; + const [signalViewer, ...rest] = this.props.children; + return ( + + {(this.state.channels.length > 0) && signalViewer} + {rest} + + ); } } 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 index d53bb5116e4..d9b5b7d1e35 100644 --- 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 @@ -58,6 +58,7 @@ const IntervalSelect = ({ fontWeight: 'bold', paddingLeft: '15px', marginBottom: '15px', + textAlign: 'center', }} > Timeline Range View 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 index 79b9029fb05..5a6ea9e1965 100644 --- 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 @@ -19,7 +19,6 @@ 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, @@ -177,10 +176,15 @@ const SeriesRenderer = ({ }; const EpochsLayer = () => { + const fEpochs = [...Array(epochs.length).keys()].filter((i) => + epochs[i].onset + epochs[i].duration > interval[0] + && epochs[i].onset < interval[1] + ); + return ( - {filteredEpochs.length < MAX_RENDERED_EPOCHS && - filteredEpochs.map((index) => { + {fEpochs.length < MAX_RENDERED_EPOCHS && + fEpochs.map((index) => { return ( + <> {channels.length > 0 ? (
@@ -446,7 +447,7 @@ const SeriesRenderer = ({ }} > - Showing{' '} + Showing channels{' '} Loading...
)} - + ); }; 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 index 9d2244be11c..454b305a82f 100644 --- 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 @@ -46,7 +46,7 @@ export const loadChunks = ({channelIndex, ...rest}: FetchedChunks) => { export const fetchChunkAt = R.memoizeWith( (baseURL, downsampling, channelIndex, traceIndex, chunkIndex) => - `${channelIndex}-${traceIndex}-${chunkIndex}-${downsampling}`, + `${baseURL}-${channelIndex}-${traceIndex}-${chunkIndex}-${downsampling}`, ( baseURL: string, downsampling: number, @@ -73,9 +73,8 @@ export const createFetchChunksEpic = (fromState: any => State) => ( Rx.map(([_, state]) => fromState(state)), Rx.debounceTime(UPDATE_DEBOUNCE_TIME), Rx.concatMap(({bounds, dataset}) => { - const {chunkDirectoryURL, shapes, timeInterval, channels} = dataset; - - if (!chunkDirectoryURL) { + const {chunksURL, shapes, timeInterval, channels} = dataset; + if (!chunksURL) { return of(); } @@ -122,9 +121,8 @@ export const createFetchChunksEpic = (fromState: any => State) => ( const chunkPromises = R.range(...max.interval).map( (chunkIndex) => { const numChunks = max.numChunks; - return fetchChunkAt( - chunkDirectoryURL, + chunksURL, max.downsampling, channel.index, j, 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 index 57b28e50b54..a3490c9a5a5 100644 --- 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 @@ -34,7 +34,7 @@ export type Action = | { type: 'SET_DATASET_METADATA', payload: { - chunkDirectoryURL: string, + chunksURL: string, channelNames: string[], shapes: number[][], timeInterval: [number, number], @@ -45,7 +45,7 @@ export type Action = | ChannelAction; export type State = { - chunkDirectoryURL: string, + chunksURL: string, channelMetadata: ChannelMetadata[], channels: Channel[], activeChannel: number | null, @@ -60,7 +60,7 @@ export type State = { export const datasetReducer = ( state: State = { - chunkDirectoryURL: '', + chunksURL: '', channelMetadata: [], channels: [], filteredChannels: [], diff --git a/modules/electrophysiology_browser/php/electrophysiologybrowserrowprovisioner.class.inc b/modules/electrophysiology_browser/php/electrophysiologybrowserrowprovisioner.class.inc index e0a91797ff6..e99b52e4ed0 100644 --- a/modules/electrophysiology_browser/php/electrophysiologybrowserrowprovisioner.class.inc +++ b/modules/electrophysiology_browser/php/electrophysiologybrowserrowprovisioner.class.inc @@ -62,7 +62,10 @@ class ElectrophysiologyBrowserRowProvisioner WHERE c.Active='Y' AND s.Active='Y' AND pf.FileType IN ( - SELECT type FROM ImagingFileTypes WHERE Description LIKE '%(EEG)' + SELECT type FROM ImagingFileTypes + WHERE + description LIKE '%(EEG)' + OR type = 'archive' ) GROUP BY SessionID", [] diff --git a/modules/electrophysiology_browser/php/models/electrophysiofile.class.inc b/modules/electrophysiology_browser/php/models/electrophysiofile.class.inc index 79b68b93b78..562d6369bcc 100644 --- a/modules/electrophysiology_browser/php/models/electrophysiofile.class.inc +++ b/modules/electrophysiology_browser/php/models/electrophysiofile.class.inc @@ -24,8 +24,10 @@ namespace LORIS\electrophysiology_browser\Models; */ class ElectrophysioFile implements \LORIS\Data\DataInstance { - private $_fileData = []; - private $_parameters = []; + private $_fileData = []; + private $_parameters = []; + private $_chunksURLs = []; + private $_splitFileIDs = []; /** * Construct a BIDSFile @@ -46,13 +48,12 @@ class ElectrophysioFile implements \LORIS\Data\DataInstance FileType, AcquisitionTime, InsertedByUser, - FilePath + FilePath FROM physiological_file LEFT JOIN physiological_modality as pm USING (PhysiologicalModalityID) WHERE PhysiologicalFileID=:PFID"; - $params = ['PFID' => $physiologicalFileID]; - $fileData = $db->pselectRow($query, $params); + $fileData = $db->pselectRow($query, ['PFID' => $physiologicalFileID]); foreach ($fileData AS $key=>$value) { $this->_fileData[$key] = $value; @@ -64,12 +65,55 @@ class ElectrophysioFile implements \LORIS\Data\DataInstance FROM physiological_parameter_file as ppf LEFT JOIN parameter_type as pt USING (ParameterTypeID) WHERE - PhysiologicalFileID=:PFID"; - $parameterRaw = $db->pselect($query, $params); + PhysiologicalFileID=:PFID + AND pt.Name <> 'electrophyiology_chunked_dataset_path'"; + $parameterRaw = $db->pselect($query, ['PFID' => $physiologicalFileID]); foreach ($parameterRaw AS $row) { $this->_parameters[$row['Name']] = $row['Value']; } + + /* + * Split files + * + * If the physiological file is the parent of a collection + * set or override a few parameters + */ + + $query = "SELECT PhysiologicalFileID + FROM `physiological_file` + WHERE ParentID=:PFID + ORDER BY `Index`"; + $this->_splitFileIDs = $db->pselectCol($query, ['PFID' => $physiologicalFileID]); + + if (count($this->_splitFileIDs) > 0) { + $this->_fileData['SplitCount'] = strval(count($this->_splitFileIDs)); + + // Overrides Recording Duration + $query = "SELECT SUM(ppf.Value) + FROM physiological_parameter_file as ppf + LEFT JOIN parameter_type as pt USING (ParameterTypeID) + WHERE PhysiologicalFileID IN (" . join($this->_splitFileIDs, ',') . ") + AND pt.Name = 'RecordingDuration'"; + $this->_parameters['RecordingDuration'] = $db->pselectOne($query, []); + + // Set chunk paths + $query = "SELECT ppf.Value + FROM physiological_parameter_file as ppf + LEFT JOIN parameter_type as pt USING (ParameterTypeID) + WHERE PhysiologicalFileID IN (" . join($this->_splitFileIDs, ',') . ") + AND pt.Name = 'electrophyiology_chunked_dataset_path'"; + $this->_chunksURLs = $db->pselectCol($query, []); + } else { + $this->_fileData['SplitCount'] = 'n/a'; + + $query = "SELECT ppf.Value + FROM physiological_parameter_file as ppf + LEFT JOIN parameter_type as pt USING (ParameterTypeID) + WHERE PhysiologicalFileID=:PFID + AND pt.Name = 'electrophyiology_chunked_dataset_path'"; + $this->_chunksURLs = $db->pselectCol($query, ['PFID' => $physiologicalFileID]); + } } /** @@ -79,12 +123,40 @@ class ElectrophysioFile implements \LORIS\Data\DataInstance * * @return string The value of the parameter */ - function getParameter(string $parameterName): string + function getParameter(string $parameterName) : string { return $this->_fileData[$parameterName] ?? ($this->_parameters[$parameterName] ?? ''); } + /** + * Gets the chunks URLs for this file + * + * @return array The list of URLs + */ + function getChunksURLs() : array + { + return $this->_chunksURLs; + } + + /** + * Gets the split data for this file + * + * @param int $index The split index + * + * @return array The split data + */ + function getSplitData(int $index) : ?array + { + if (count($this->_splitFileIDs) === 0) return null; + + return [ + 'splitIndex' => $index, + 'splitCount' => intval($this->_fileData['SplitCount']), + 'splitPhysioFile' => new ElectrophysioFile(intval($this->_splitFileIDs[$index])), + ]; + } + /** * Implements \LORIS\Data\DataInstance interface * diff --git a/modules/electrophysiology_browser/php/sessions.class.inc b/modules/electrophysiology_browser/php/sessions.class.inc index 4c4b0b82a64..812ae268851 100644 --- a/modules/electrophysiology_browser/php/sessions.class.inc +++ b/modules/electrophysiology_browser/php/sessions.class.inc @@ -162,8 +162,9 @@ class Sessions extends \NDB_Page USING (PhysiologicalOutputTypeID) WHERE s.Active = "Y" - AND pf.FileType IN ("bdf", "cnt", "edf", "set", "vhdr", "vsm") - ORDER BY pf.SessionID'; + AND pf.FileType IN ('. + '"bdf", "cnt", "edf", "set", "vhdr", "vsm", "archive"'. + ') ORDER BY pf.SessionID'; $response = []; @@ -209,8 +210,6 @@ class Sessions extends \NDB_Page return $subjectData; } - - /** * Get the list of electrophysiology recordings with their recording information. * @@ -225,320 +224,294 @@ class Sessions extends \NDB_Page $fileCollection = []; $params = []; $params['SID'] = $this->sessionID; - $query = 'SELECT - pf.PhysiologicalFileID, - pf.FilePath - FROM - physiological_file pf '; + + $query = 'SELECT + pf.PhysiologicalFileID + FROM + physiological_file pf '; if ($outputType != 'all_types') { - $query .= 'LEFT JOIN physiological_output_type pot ON '; - $query .= 'pf.PhysiologicalOutputTypeID=' - .'pot.PhysiologicalOutputTypeID '; - $query .= 'WHERE SessionID=:SID '; - $query .= 'AND pot.OutputTypeName = :OTN '; + $query .= 'LEFT JOIN physiological_output_type pot ON '; + $query .= 'pf.PhysiologicalOutputTypeID=' + .'pot.PhysiologicalOutputTypeID '; + $query .= 'WHERE SessionID=:SID '; + $query .= 'AND pot.OutputTypeName = :OTN AND pf.ParentID IS NULL '; + $params['OTN'] = $outputType; } else { - $query .= "WHERE SessionID=:SID"; + $query .= "WHERE SessionID=:SID AND pf.ParentID IS NULL"; } $physiologicalFiles = $db->pselect($query, $params); foreach ($physiologicalFiles as $file) { - $fileSummary = []; - $physiologicalFileID = $file['PhysiologicalFileID']; - $physiologicalFile = $file['FilePath']; - $physioFileObj = new ElectrophysioFile( - intval($physiologicalFileID) - ); - $fileName = basename( - $physioFileObj->getParameter('FilePath') - ); - - // ----------------------------------------------------- - // Create a file summary object with file's information - // ----------------------------------------------------- - - // get the file name + $fileCollection[]['file'] = $this->getSummary($file); + } - $fileSummary['name'] = $fileName; + return $fileCollection; + } - // get the summary frequency information - $sampling = $physioFileObj->getParameter('SamplingFrequency'); - $powerline = $physioFileObj->getParameter('PowerLineFrequency'); + /** + * Get the electrophysiology recording summary metadata. + * + * @param array $file electrophysiology file data + * + * @return array with the file metadata + */ + function getSummary(array $file) : array + { + $fileSummary = []; + $physioFileID = intval($file['PhysiologicalFileID']); + $physioFileObj = new ElectrophysioFile($physioFileID); + $physioFile = $physioFileObj->getParameter('FilePath'); + $modality = $physioFileObj->getParameter('Modality'); + $modalityPrefix = $modality === 'ieeg' ? 'iEEG' : 'EEG'; + $modalityCapName = $modality === 'ieeg' ? 'Electrode' : 'Cap'; + + $fileSummary['id'] = $physioFileID; + $fileSummary['name'] = basename($physioFile); + + // Summary + + $channels = ['EEG', 'EOG', 'ECG', 'EMG']; + if ($modality === 'ieeg') { + $channels = array_merge(['ECOG', 'SEEG'], $channels); + } - $fileSummary['summary']['frequency']['sampling'] = [ + $fileSummary['summary'] = [ + [ 'name' => 'Sampling Frequency', - 'value' => $sampling, - ]; - - $fileSummary['summary']['frequency']['powerline'] = [ - 'name' => 'Powerline Frequency', - 'value' => $powerline, - ]; - - // get the summary channel information - - if ($physioFileObj->getParameter('Modality') == 'ieeg') { - $ecogChannelCount = $physioFileObj->getParameter('ECOGChannelCount'); - $seegChannelCount = $physioFileObj->getParameter('SEEGChannelCount'); + 'value' => $physioFileObj->getParameter('SamplingFrequency'), + ], + ]; + $fileSummary['summary'] = array_merge( + $fileSummary['summary'], + array_map( + fn($channel) => + [ + 'name' => $channel.' Channel Count', + 'value' => $physioFileObj->getParameter( + $channel.'ChannelCount' + ), + ], + $channels + ) + ); + $fileSummary['summary'] = array_merge( + $fileSummary['summary'], + [ + [ + 'name' => 'Reference', + 'value' => $physioFileObj->getParameter( + $modalityPrefix.'Reference' + ), + ], + [ + 'name' => 'Powerline Frequency', + 'value' => $physioFileObj->getParameter('PowerLineFrequency'), + ], + ] + ); - $fileSummary['summary']['channel_count'][] = [ - 'name' => 'ECOG Channel Count', - 'value' => $ecogChannelCount, - ]; - $fileSummary['summary']['channel_count'][] = [ - 'name' => 'SEEG Channel Count', - 'value' => $seegChannelCount, - ]; - } + // Details - $eegChannelCount = $physioFileObj->getParameter('EEGChannelCount'); - $eogChannelCount = $physioFileObj->getParameter('EOGChannelCount'); - $ecgChannelCount = $physioFileObj->getParameter('ECGChannelCount'); - $emgChannelCount = $physioFileObj->getParameter('EMGChannelCount'); + $fileSummary['details'] = [ + [ + 'name' => 'Task Name', + 'value' => $physioFileObj->getParameter('TaskName'), + ], + [ + 'name' => 'Task Description', + 'value' => $physioFileObj->getParameter('TaskDescription'), + ], + [ + 'name' => 'Instructions', + 'value' => $physioFileObj->getParameter('Instructions'), + ], + [ + 'name' => 'Recording Type', + 'value' => $physioFileObj->getParameter('RecordingType'), + ], + [ + 'name' => 'Recording Split Count', + 'value' => $physioFileObj->getParameter('SplitCount'), + ], + [ + 'name' => 'Recording Duration', + 'value' => $physioFileObj->getParameter('RecordingDuration'), + ], + [ + 'name' => 'Epoch Length', + 'value' => $physioFileObj->getParameter('EpochLength'), + ], + [ + 'name' => 'Subject Artefact Description', + 'value' => $physioFileObj->getParameter( + 'SubjectArtefactDescription' + ), + ], + [ + 'name' => 'Head Circumference', + 'value' => $physioFileObj->getParameter('HeadCircumference'), + ], + [ + 'name' => 'Placement Scheme', + 'value' => $physioFileObj->getParameter( + $modalityPrefix.'PlacementScheme' + ), + ], + [ + 'name' => 'Ground', + 'value' => $physioFileObj->getParameter($modalityPrefix.'Ground'), + ], + [ + 'name' => 'Trigger Channel Count', + 'value' => $physioFileObj->getParameter('TriggerChannelCount'), + ], + [ + 'name' => 'Misc Channel Count', + 'value' => $physioFileObj->getParameter('MiscChannelCount'), + ], + [ + 'name' => 'CogAtlas ID', + 'value' => $physioFileObj->getParameter('CogAtlasID'), + ], + [ + 'name' => 'CogPO ID', + 'value' => $physioFileObj->getParameter('CogPOID'), + ], + [ + 'name' => 'Institution Name', + 'value' => $physioFileObj->getParameter('InstitutionName'), + ], + [ + 'name' => 'Institution Address', + 'value' => $physioFileObj->getParameter('InstitutionAddress'), + ], + [ + 'name' => 'Manufacturer', + 'value' => $physioFileObj->getParameter('Manufacturer'), + ], + [ + 'name' => 'Manufacturers Model Name', + 'value' => $physioFileObj->getParameter( + 'ManufacturersModelName' + ), + ], + [ + 'name' => $modalityCapName.' Manufacturer', + 'value' => $physioFileObj->getParameter( + $modalityCapName.'Manufacturer' + ), + ], + [ + 'name' => $modalityCapName.' Manufacturer\'s Model Name', + 'value' => $physioFileObj->getParameter( + $modalityCapName.'ManufacturersModelName' + ), + ], + [ + 'name' => 'Device Serial Number', + 'value' => $physioFileObj->getParameter('DeviceSerialNumber'), + ], + [ + 'name' => 'Hardware Filters', + 'value' => $physioFileObj->getParameter('HardwareFilters'), + ], + [ + 'name' => 'Software Versions', + 'value' => $physioFileObj->getParameter('SoftwareVersions'), + ], + [ + 'name' => 'Software Filters', + 'value' => $physioFileObj->getParameter('SoftwareFilters'), + ], + ]; - $fileSummary['summary']['channel_count'][] = [ - 'name' => 'EEG Channel Count', - 'value' => $eegChannelCount, - ]; - $fileSummary['summary']['channel_count'][] = [ - 'name' => 'EOG Channel Count', - 'value' => $eogChannelCount, - ]; - $fileSummary['summary']['channel_count'][] = [ - 'name' => 'ECG Channel Count', - 'value' => $ecgChannelCount, - ]; - $fileSummary['summary']['channel_count'][] = [ - 'name' => 'EMG Channel Count', - 'value' => $emgChannelCount, + if ($modality == 'ieeg') { + $fileSummary['details'][] = [ + 'name' => 'DC Offset Correction', + 'value' => $physioFileObj->getParameter('DCOffsetCorrection'), ]; - // get the summary reference + $fileSummary['details'][] = [ + 'name' => 'Electrode Groups', + 'value' => $physioFileObj->getParameter('ElectrodeGroups'), + ]; - if ($physioFileObj->getParameter('Modality') == 'ieeg') { - $reference = $physioFileObj->getParameter('iEEGReference'); - } else { - $reference = $physioFileObj->getParameter('EEGReference'); - } + $fileSummary['details'][] = [ + 'name' => 'Electrical Stimulation', + 'value' => $physioFileObj->getParameter('ElectricalStimulation'), + ]; - $fileSummary['summary']['reference'] = [ - 'name' => 'Reference', - 'value' => $reference, + $fileSummary['details'][] = [ + 'name' => 'Electrical Stimulation Parameters', + 'value' => $physioFileObj->getParameter( + 'ElectricalStimulationParameters' + ), ]; + } - // get the file's details + // get the links to the files for downloads - if ($physioFileObj->getParameter('Modality') == 'ieeg') { - $placement = $physioFileObj->getParameter('iEEGPlacementScheme'); - $ground = $physioFileObj->getParameter('iEEGGround'); - $electrodeManufacturer = [ - 'name' => 'Electrode Manufacturer', - 'value' => $physioFileObj->getParameter('ElectrodeManufacturer'), - ]; - $electrodeManufacturersModelName = [ - 'name' => 'Electrode Manufacturer\'s Model Name', - 'value' => $physioFileObj->getParameter( - 'ElectrodeManufacturersModelName' - ), - ]; - } else { - $placement = $physioFileObj->getParameter('EEGPlacementScheme'); - $ground = $physioFileObj->getParameter('EEGGround'); - $electrodeManufacturer = [ - 'name' => 'Cap Manufacturer', - 'value' => $physioFileObj->getParameter('CapManufacturer'), - ]; - $electrodeManufacturersModelName = [ - 'name' => 'Cap Manufacturer\'s Model Name', - 'value' => $physioFileObj->getParameter( - 'CapManufacturersModelName' - ), - ]; + $fileSummary['downloads'] = $this->getDownloadLinks($physioFileObj); + $fileSummary['chunks_urls'] = $physioFileObj->getChunksURLs(); - } + $fileSummary['splitData'] = $physioFileObj->getSplitData(0); - $taskName = $physioFileObj->getParameter('TaskName'); - $taskDesc = $physioFileObj->getParameter('TaskDescription'); - $instructions = $physioFileObj->getParameter('Instructions'); - $headCircumference = $physioFileObj->getParameter('HeadCircumference'); - $triggerCount = $physioFileObj->getParameter('TriggerChannelCount'); - $recordingType = $physioFileObj->getParameter('Recording_type'); - $cogAtlasID = $physioFileObj->getParameter('CogAtlasID'); - $cogPoid = $physioFileObj->getParameter('CogPOID'); - $instituteName = $physioFileObj->getParameter('InstitutionName'); - $intituteAddress = $physioFileObj->getParameter('InstitutionAddress'); - $miscChannelCount = $physioFileObj->getParameter('MiscChannelCount'); - $manufacturer = $physioFileObj->getParameter('Manufacturer'); - $modelName = $physioFileObj->getParameter( - 'ManufacturersModelName' - ); - $hardwareFilters = $physioFileObj->getParameter('HardwareFilters'); - $duration = $physioFileObj->getParameter('RecordingDuration'); - $epochLength = $physioFileObj->getParameter('EpochLength'); - $softwareVersions = $physioFileObj->getParameter('SoftwareVersions'); - $softwareFilters = $physioFileObj->getParameter('SoftwareFilters'); - $serialNumber = $physioFileObj->getParameter('DeviceSerialNumber'); - $artefactDesc = $physioFileObj->getParameter( - 'SubjectArtefactDescription' - ); - $chunksUrl = $physioFileObj->getParameter( - 'electrophyiology_chunked_dataset_path' - ); + return $fileSummary; + } - $fileSummary['details'] = [ - [ - 'name' => 'Task Name', - 'value' => $taskName, - ], - [ - 'name' => 'Task Description', - 'value' => $taskDesc, - ], - [ - 'name' => 'Instructions', - 'value' => $instructions, - ], - [ - 'name' => 'Recording Type', - 'value' => $recordingType, - ], - [ - 'name' => 'Recording Duration', - 'value' => $duration, - ], - [ - 'name' => 'Epoch Length', - 'value' => $epochLength, - ], - [ - 'name' => 'Subject Artefact Description', - 'value' => $artefactDesc, - ], - [ - 'name' => 'Head Circumference', - 'value' => $headCircumference, - ], - [ - 'name' => 'Placement Scheme', - 'value' => $placement, - ], - [ - 'name' => 'Ground', - 'value' => $ground, - ], - [ - 'name' => 'Trigger Channel Count', - 'value' => $triggerCount, - ], - [ - 'name' => 'Misc Channel Count', - 'value' => $miscChannelCount, - ], - [ - 'name' => 'CogAtlas ID', - 'value' => $cogAtlasID, - ], - [ - 'name' => 'CogPO ID', - 'value' => $cogPoid, - ], - [ - 'name' => 'Institution Name', - 'value' => $instituteName, - ], - [ - 'name' => 'Institution Address', - 'value' => $intituteAddress, - ], - [ - 'name' => 'Manufacturer', - 'value' => $manufacturer, - ], - [ - 'name' => 'Manufacturers Model Name', - 'value' => $modelName, - ], - $electrodeManufacturer, - $electrodeManufacturersModelName, - [ - 'name' => 'Device Serial Number', - 'value' => $serialNumber, - ], - [ - 'name' => 'Hardware Filters', - 'value' => $hardwareFilters, - ], - [ - 'name' => 'Software Versions', - 'value' => $softwareVersions, - ], - [ - 'name' => 'Software Filters', - 'value' => $softwareFilters, - ], + /** + * Gets the download link for all the electrophysiology files + * + * @param ElectrophysioFile $physioFile ElectrophysiologyFile instance + * + * @return array array with the path to the different files associated to the + * electrophysiology file + */ + function getDownloadlinks(ElectrophysioFile $physioFile): array + { + $nSplit = intval($physioFile->getParameter('SplitCount')); + $physioFileName = basename($physioFile->getParameter('FilePath')); + $downloadLinks = []; + + if ($nSplit === 0) { + $downloadLinks[] = [ + 'groupName' => '', + 'links' => $this->getPhysioFileDownloadlinks($physioFile), ]; - - if ($physioFileObj->getParameter('Modality') == 'ieeg') { - $fileSummary['details'][] = [ - 'name' => 'DC Offset Correction', - 'value' => $physioFileObj->getParameter('DCOffsetCorrection'), - ]; - - $fileSummary['details'][] = [ - 'name' => 'Electrode Groups', - 'value' => $physioFileObj->getParameter('ElectrodeGroups'), - ]; - - $fileSummary['details'][] = [ - 'name' => 'Electrical Stimulation', - 'value' => $physioFileObj->getParameter('ElectricalStimulation'), - ]; - - $fileSummary['details'][] = [ - 'name' => 'Electrical Stimulation Parameters', - 'value' => $physioFileObj->getParameter( - 'ElectricalStimulationParameters' - ), + } else { + foreach (range(0, $nSplit-1) as $i) { + $splitData = $physioFile->getSplitData($i); + $splitPhysioFile = $splitData['splitPhysioFile']; + + $downloadLinks[] = [ + 'groupName' => 'Split '.($i+1), + 'links' => $this->getPhysioFileDownloadlinks($splitPhysioFile), ]; } - - // get the links to the files for downloads - - $links = $this->getDownloadLinks( - intval($physiologicalFileID), - $physiologicalFile - ); - - $fileSummary['downloads'] = $links; - $fileSummary['chunks_url'] = $chunksUrl; - - $fileCollection[]['file'] = $fileSummary; } - - return $fileCollection; + return $downloadLinks; } - /** - * Gets the download link for the files associated to the electrophysiology + * Gets the download link for the files associated to a particular electrophysiology * file (channels.tsv, electrodes.tsv, task events.tsv...) * - * @param int $physioFileID FileID of the electrophysiology file - * @param string $physioFile electrophysiology file's relative path + * @param ElectrophysioFile $physioFileObj ElectrophysiologyFile instance * * @return array array with the path to the different files associated to the * electrophysiology file */ - function getDownloadlinks(int $physioFileID, string $physioFile): array + function getPhysioFileDownloadlinks(ElectrophysioFile $physioFileObj): array { - $db = \NDB_Factory::singleton()->database(); - - $params = []; - $params['PFID'] = $physioFileID; - $downloadLinks = []; + $physioFileID = $physioFileObj->getParameter('PhysiologicalFileID'); + $physioFile = $physioFileObj->getParameter('FilePath'); + $db = \NDB_Factory::singleton()->database(); + $downloadLinks = []; + $downloadLinks[] = [ 'type' => 'physiological_file', 'file' => $physioFile, @@ -563,12 +536,12 @@ class Sessions extends \NDB_Page foreach ($queries as $query_key => $query_value) { $query_statement = "SELECT - DISTINCT(FilePath), '$query_value' AS FileType + DISTINCT(FilePath), '$query_value' AS FileType FROM - $query_key + $query_key WHERE - PhysiologicalFileID=:PFID"; - $query_statement = $db->pselectRow($query_statement, $params); + PhysiologicalFileID=:PFID"; + $query_statement = $db->pselectRow($query_statement, ['PFID' => $physioFileID]); if (isset($query_statement['FileType'])) { $downloadLinks[] = [ 'type' => $query_statement['FileType'], @@ -585,15 +558,15 @@ class Sessions extends \NDB_Page } $queryFDT = "SELECT - Value AS FilePath, - 'physiological_fdt_file' AS FileType - FROM - physiological_parameter_file - JOIN parameter_type AS pt USING (ParameterTypeID) - WHERE - pt.Name='fdt_file' - AND PhysiologicalFileID=:PFID"; - $queryFDT = $db->pselectRow($queryFDT, $params); + Value AS FilePath, + 'physiological_fdt_file' AS FileType + FROM + physiological_parameter_file + JOIN parameter_type AS pt USING (ParameterTypeID) + WHERE + pt.Name='fdt_file' + AND PhysiologicalFileID=:PFID"; + $queryFDT = $db->pselectRow($queryFDT, ['PFID' => $physioFileID]); if (isset($queryFDT['FileType'])) { $downloadLinks[] = [ 'type' => $queryFDT['FileType'], @@ -607,7 +580,6 @@ class Sessions extends \NDB_Page 'label' => '', ]; } - return $downloadLinks; } diff --git a/modules/electrophysiology_browser/php/split_data.class.inc b/modules/electrophysiology_browser/php/split_data.class.inc new file mode 100644 index 00000000000..675e333cb61 --- /dev/null +++ b/modules/electrophysiology_browser/php/split_data.class.inc @@ -0,0 +1,58 @@ + + * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3 + * @link https://www.github.com/aces/Loris/ + */ +class Split_Data extends \NDB_Page +{ + /** + * Handle how to operate all the files. + * GET method gets a file. + * + * @param ServerRequestInterface $request The incoming PSR7 request + * + * @return ResponseInterface The outgoing PSR7 response + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + $user = \NDB_Factory::singleton()->user(); + + switch ($request->getMethod()) { + case 'POST': + if (!($user->hasPermission('electrophysiology_browser_view_allsites') + || ($user->hasPermission('electrophysiology_browser_view_site') + && $user->hasStudySite()))) { + return (new \LORIS\Http\Response\JSON\Unauthorized()); + } + + // Parse POST request body. + $values = (array)$request->getParsedBody(); + + if (!isset($values['physioFileID']) + || !isset($values['splitIndex'])) { + return (new \LORIS\Http\Response\JSON\BadRequest()); + } + + return new \LORIS\Http\Response\JSON\OK( + (new ElectrophysioFile(intval($values['physioFileID']))) + ->getSplitData(intval($values['splitIndex'])) + ); + default: + return (new \LORIS\Http\Response\JSON\MethodNotAllowed( + ["POST"] + )); + } + } +} \ No newline at end of file From 75c10c12f52afeafccc521595f87d0d71ac170b1 Mon Sep 17 00:00:00 2001 From: Laetitia Fesselier Date: Tue, 1 Jun 2021 14:21:23 -0400 Subject: [PATCH 5/8] Flow -> TypeScript conversion --- babel.config.js | 1 - .../jsx/react-series-data-viewer/.flowconfig | 15 ----- .../jsx/react-series-data-viewer/README.md | 2 +- .../jsx/react-series-data-viewer/package.json | 9 +-- .../src/ajax/index.js | 23 ------- .../src/ajax/index.tsx | 23 +++++++ .../src/chunks/{index.js => index.tsx} | 1 - .../src/color/{index.js => index.tsx} | 2 - ...esProvider.js => EEGLabSeriesProvider.tsx} | 30 ++++++--- .../{AnnotationForm.js => AnnotationForm.tsx} | 44 ++++++++----- .../series/components/{Axis.js => Axis.tsx} | 10 ++- .../{EEGMontage.js => EEGMontage.tsx} | 26 ++++---- .../series/components/{Epoch.js => Epoch.tsx} | 9 ++- .../{EventManager.js => EventManager.tsx} | 23 ++++--- .../{IntervalSelect.js => IntervalSelect.tsx} | 35 +++++----- .../{LineChunk.js => LineChunk.tsx} | 14 ++-- ...sponsiveViewer.js => ResponsiveViewer.tsx} | 28 ++++---- .../{SeriesCursor.js => SeriesCursor.tsx} | 14 ++-- .../{SeriesRenderer.js => SeriesRenderer.tsx} | 66 +++++++++---------- .../{components.js => components.tsx} | 0 .../src/series/store/{index.js => index.tsx} | 4 +- .../logic/{dragBounds.js => dragBounds.tsx} | 8 +-- .../logic/{fetchChunks.js => fetchChunks.tsx} | 25 ++++--- .../{filterEpochs.js => filterEpochs.tsx} | 10 ++- .../logic/{highLowPass.js => highLowPass.tsx} | 12 ++-- .../logic/{pagination.js => pagination.tsx} | 10 ++- ...scaleAmplitudes.js => scaleAmplitudes.tsx} | 9 ++- .../{timeSelection.js => timeSelection.tsx} | 10 +-- .../store/state/{bounds.js => bounds.tsx} | 12 ++-- .../store/state/{channel.js => channel.tsx} | 6 +- .../store/state/{cursor.js => cursor.tsx} | 8 +-- .../store/state/{dataset.js => dataset.tsx} | 14 ++-- .../store/state/{filters.js => filters.tsx} | 16 ++--- .../store/state/{montage.js => montage.tsx} | 6 +- .../state/{rightPanel.js => rightPanel.tsx} | 6 +- .../{timeSelection.js => timeSelection.tsx} | 8 +-- .../src/series/store/{types.js => types.tsx} | 8 +-- .../src/vector/{index.js => index.tsx} | 4 +- .../php/models/electrophysiofile.class.inc | 60 +++++++++++------ .../php/sessions.class.inc | 32 +++++---- .../php/split_data.class.inc | 12 ++-- package.json | 3 +- tsconfig.json | 20 ++++++ webpack.config.js | 9 ++- 44 files changed, 349 insertions(+), 338 deletions(-) delete mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/.flowconfig delete mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/ajax/index.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/ajax/index.tsx rename modules/electrophysiology_browser/jsx/react-series-data-viewer/src/chunks/{index.js => index.tsx} (98%) rename modules/electrophysiology_browser/jsx/react-series-data-viewer/src/color/{index.js => index.tsx} (98%) rename modules/electrophysiology_browser/jsx/react-series-data-viewer/src/eeglab/{EEGLabSeriesProvider.js => EEGLabSeriesProvider.tsx} (89%) rename modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/{AnnotationForm.js => AnnotationForm.tsx} (83%) rename modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/{Axis.js => Axis.tsx} (90%) rename modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/{EEGMontage.js => EEGMontage.tsx} (94%) rename modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/{Epoch.js => Epoch.tsx} (84%) rename modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/{EventManager.js => EventManager.tsx} (89%) rename modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/{IntervalSelect.js => IntervalSelect.tsx} (85%) rename modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/{LineChunk.js => LineChunk.tsx} (90%) rename modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/{ResponsiveViewer.js => ResponsiveViewer.tsx} (82%) rename modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/{SeriesCursor.js => SeriesCursor.tsx} (96%) rename modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/{SeriesRenderer.js => SeriesRenderer.tsx} (93%) rename modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/{components.js => components.tsx} (100%) rename modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/{index.js => index.tsx} (97%) rename modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/{dragBounds.js => dragBounds.tsx} (91%) rename modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/{fetchChunks.js => fetchChunks.tsx} (90%) rename modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/{filterEpochs.js => filterEpochs.tsx} (90%) rename modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/{highLowPass.js => highLowPass.tsx} (92%) rename modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/{pagination.js => pagination.tsx} (87%) rename modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/{scaleAmplitudes.js => scaleAmplitudes.tsx} (85%) rename modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/{timeSelection.js => timeSelection.tsx} (94%) rename modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/{bounds.js => bounds.tsx} (84%) rename modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/{channel.js => channel.tsx} (89%) rename modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/{cursor.js => cursor.tsx} (77%) rename modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/{dataset.js => dataset.tsx} (92%) rename modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/{filters.js => filters.tsx} (78%) rename modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/{montage.js => montage.tsx} (88%) rename modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/{rightPanel.js => rightPanel.tsx} (80%) rename modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/{timeSelection.js => timeSelection.tsx} (77%) rename modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/{types.js => types.tsx} (84%) rename modules/electrophysiology_browser/jsx/react-series-data-viewer/src/vector/{index.js => index.tsx} (82%) create mode 100644 tsconfig.json diff --git a/babel.config.js b/babel.config.js index 3df220b14a5..6e76911457a 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,7 +1,6 @@ module.exports = function(api) { api.cache(true); const presets = [ - "@babel/preset-flow", "@babel/preset-react", "@babel/preset-env" ]; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/.flowconfig b/modules/electrophysiology_browser/jsx/react-series-data-viewer/.flowconfig deleted file mode 100644 index ca078320be1..00000000000 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/.flowconfig +++ /dev/null @@ -1,15 +0,0 @@ -[ignore] -/node_modules/npm/ - -[include] -../../../../jsx - -[libs] - -[lints] - -[options] -react.runtime=automatic -module.name_mapper='^jsx' -> '/../../../../jsx/' - -[strict] diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/README.md b/modules/electrophysiology_browser/jsx/react-series-data-viewer/README.md index 3736f710e73..3fdb76869e6 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/README.md +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/README.md @@ -13,7 +13,7 @@ A collection of expressive, low-level visualization primitives for React. RxJS is a library for composing asynchronous and event-based programs by using observable sequences. It provides one core type, the Observable, satellite types (Observer, Schedulers, Subjects) and operators to allow handling asynchronous events as collections. -### flow (https://flow.org) +### TypeScript (https://www.typescriptlang.org) A static type checker for javascript. ### Protocol Buffers (https://developers.google.com/protocol-buffers) diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/package.json b/modules/electrophysiology_browser/jsx/react-series-data-viewer/package.json index c5428d87369..ce869505d17 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/package.json +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/package.json @@ -27,14 +27,15 @@ "redux-observable": "^1.0.0", "redux-thunk": "^2.3.0", "resize-observer-polyfill": "^1.5.0", - "rxjs": "^6.6.3" + "rxjs": "^6.6.3", + "tslib": "^1.9.3" }, "devDependencies": { - "babel-plugin-flow-react-proptypes": "^24.1.2", - "flow-bin": "^0.123.0" + "@types/react": "^16.12.0", + "@types/react-dom": "^16.9.9", + "@types/react-redux": "7.1.16" }, "scripts": { - "flow": "./node_modules/flow-bin/cli.js", "postinstall": "protoc protocol-buffers/chunk.proto --js_out=import_style=commonjs,binary:./src/" }, "license": "MIT", 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 deleted file mode 100644 index 35ba2acde27..00000000000 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/ajax/index.js +++ /dev/null @@ -1,23 +0,0 @@ -export const fetchBlob = (...args) => - fetch(...args).then((response) => { - if (!response.ok) { - return new Promise(() => {}); - } - return response.blob().then((data) => data); - }); - -export const fetchJSON = (...args) => - fetch(...args).then((response) => { - if (!response.ok) { - return new Promise(() => {}); - } - return response.json().then((data) => data); - }); - -export const fetchText = (...args) => - fetch(...args).then((response) => { - if (!response.ok) { - return new Promise(() => {}); - } - return response.text().then((data) => data); - }); diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/ajax/index.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/ajax/index.tsx new file mode 100644 index 00000000000..021f6a76f5d --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/ajax/index.tsx @@ -0,0 +1,23 @@ +export const fetchBlob = (url: string, params?: {}) => + fetch(url, params).then((response) => { + if (!response.ok) { + return Promise.resolve(null) as Promise; + } + return response.blob().then((data) => data); + }); + +export const fetchJSON = (url: string, params?: {}) => + fetch(url, params).then((response) => { + if (!response.ok) { + return Promise.resolve(null) as Promise; + } + return response.json().then((data) => data); + }); + +export const fetchText = (url: string, params?: {}) => + fetch(url, params).then((response) => { + if (!response.ok) { + return Promise.resolve(null) as Promise; + } + return response.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.tsx similarity index 98% rename from modules/electrophysiology_browser/jsx/react-series-data-viewer/src/chunks/index.js rename to modules/electrophysiology_browser/jsx/react-series-data-viewer/src/chunks/index.tsx index 9005ebd88e2..465b6dd3976 100644 --- 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.tsx @@ -1,4 +1,3 @@ -// @flow import {FloatChunk} from '../protocol-buffers/chunk_pb'; import {fetchBlob} from '../ajax'; 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.tsx similarity index 98% rename from modules/electrophysiology_browser/jsx/react-series-data-viewer/src/color/index.js rename to modules/electrophysiology_browser/jsx/react-series-data-viewer/src/color/index.tsx index c0d7ee7f4c3..1f2027139d8 100644 --- 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.tsx @@ -1,5 +1,3 @@ -// @flow - import {scaleOrdinal} from 'd3-scale'; // import * as R from 'ramda'; // import {schemeCategory10, schemeSet3} from 'd3-scale-chromatic'; 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.tsx similarity index 89% rename from modules/electrophysiology_browser/jsx/react-series-data-viewer/src/eeglab/EEGLabSeriesProvider.js rename to modules/electrophysiology_browser/jsx/react-series-data-viewer/src/eeglab/EEGLabSeriesProvider.tsx index bff25a0c5f6..a8316b47419 100644 --- 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.tsx @@ -1,6 +1,6 @@ +import React, {Component} from 'react'; import {tsvParse} from 'd3-dsv'; -import {Component} from 'react'; -import {createStore, applyMiddleware} from 'redux'; +import {createStore, applyMiddleware, Store} from 'redux'; import {Provider} from 'react-redux'; import {createEpicMiddleware} from 'redux-observable'; import thunk from 'redux-thunk'; @@ -16,8 +16,15 @@ import { import {setDomain, setInterval} from '../series/store/state/bounds'; import {updateFilteredEpochs} from '../series/store/logic/filterEpochs'; import {setElectrodes} from '../series/store/state/montage'; +import {Channel} from '../series/store/types'; -type Props = { +declare global { + interface Window { + EEGLabSeriesProviderStore: Store; + } +} + +type CProps = { chunksURL: string, epochsURL: string, electrodesURL: string, @@ -27,12 +34,17 @@ type Props = { /** * EEGLabSeriesProvider component */ -class EEGLabSeriesProvider extends Component { +class EEGLabSeriesProvider extends Component { + private store: Store; + public state: { + channels: Channel[] + }; + /** * @constructor * @param {object} props - React Component properties */ - constructor(props: Props) { + constructor(props: CProps) { super(props); const epicMiddleware = createEpicMiddleware(); @@ -154,10 +166,10 @@ class EEGLabSeriesProvider extends Component { ); } -} -EEGLabSeriesProvider.defaultProps = { - limit: MAX_CHANNELS, -}; + static defaultProps = { + limit: MAX_CHANNELS, + }; +} export default EEGLabSeriesProvider; 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.tsx similarity index 83% rename from modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/AnnotationForm.js rename to modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/AnnotationForm.tsx index 8bb432663ec..cce1762d5b1 100644 --- 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.tsx @@ -1,21 +1,20 @@ -// @flow - import React, {useEffect, useState} from 'react'; -import type {Epoch as EpochType, RightPanel} from '../store/types'; -import {connect} from 'react-redux'; +import {Epoch as EpochType, RightPanel} from '../store/types'; +import {connect, DefaultRootState} 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'; +import {RootState} from '../store'; -type Props = { - timeSelection: ?[number, number], +type CProps = { + timeSelection?: [number, number], epochs: EpochType[], filteredEpochs: number[], - setTimeSelection: [?number, ?number] => void, - setRightPanel: RightPanel => void, - toggleEpoch: number => void, - updateActiveEpoch: ?number => void, + setTimeSelection: (_: [number, number]) => void, + setRightPanel: (_: RightPanel) => void, + toggleEpoch: (_: number) => void, + updateActiveEpoch: (_: number) => void, interval: [number, number], }; @@ -28,7 +27,7 @@ const AnnotationForm = ({ toggleEpoch, updateActiveEpoch, interval, -}: Props) => { +}: CProps) => { const [startEvent = '', endEvent = ''] = timeSelection || []; let [event, setEvent] = useState([startEvent, endEvent]); @@ -81,10 +80,14 @@ const AnnotationForm = ({ setEvent([value, event[1]]); if (validate([value, event[1]])) { + let endTime = event[1]; + if (typeof endTime === 'string'){ + endTime = parseInt(endTime); + } setTimeSelection( [ - parseInt(value) || null, - parseInt(event[1]) || null, + value || null, + endTime || null, ] ); } @@ -104,7 +107,16 @@ const AnnotationForm = ({ setEvent([event[0], value]); if (validate([event[0], value])) { - setTimeSelection([parseInt(event[0]) || null, value]); + let startTime = event[0]; + if (typeof startTime === 'string'){ + startTime = parseInt(startTime); + } + setTimeSelection( + [ + startTime || null, + value + ] + ); } }} value={event[1]} @@ -145,7 +157,7 @@ const AnnotationForm = ({