From cf541c3f14837d00c307151206ded8d207eb20b8 Mon Sep 17 00:00:00 2001 From: Jefferson Casimir Date: Tue, 30 Jan 2024 18:13:21 -0500 Subject: [PATCH] HED Tag Support + HED Tag Manager --- SQL/0000-00-05-ElectrophysiologyTables.sql | 132 +- .../2024-01-30-HED-Tag-Support.sql | 109 ++ .../css/electrophysiology_browser.css | 164 ++ .../jsx/electrophysiologySessionView.js | 10 + .../src/eeglab/EEGLabSeriesProvider.tsx | 184 ++- .../src/series/components/AnnotationForm.tsx | 549 ++++++- .../src/series/components/DatasetTagger.tsx | 1402 +++++++++++++++++ .../src/series/components/EventManager.tsx | 60 +- .../src/series/components/Form.js | 518 +++++- .../src/series/components/components.tsx | 34 + .../src/series/store/logic/filterEpochs.tsx | 209 ++- .../src/series/store/state/dataset.tsx | 52 +- .../src/series/store/types.tsx | 27 +- .../php/events.class.inc | 19 + .../php/models/datasettags.class.inc | 535 +++++++ .../php/models/electrophysioevents.class.inc | 175 +- .../php/sessions.class.inc | 23 + tools/importers/insert_hed_schema.php | 73 + 18 files changed, 4127 insertions(+), 148 deletions(-) create mode 100644 SQL/New_patches/2024-01-30-HED-Tag-Support.sql create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/DatasetTagger.tsx create mode 100644 modules/electrophysiology_browser/php/models/datasettags.class.inc create mode 100644 tools/importers/insert_hed_schema.php diff --git a/SQL/0000-00-05-ElectrophysiologyTables.sql b/SQL/0000-00-05-ElectrophysiologyTables.sql index fca3cf01fc5..d083310b15c 100644 --- a/SQL/0000-00-05-ElectrophysiologyTables.sql +++ b/SQL/0000-00-05-ElectrophysiologyTables.sql @@ -75,6 +75,7 @@ CREATE TABLE `physiological_split_file` ( CREATE TABLE `physiological_parameter_file` ( `PhysiologicalParameterFileID` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, `PhysiologicalFileID` INT(10) UNSIGNED NOT NULL, + `ProjectID` INT(10) UNSIGNED NOT NULL, `ParameterTypeID` INT(10) UNSIGNED NOT NULL, `InsertTime` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, `Value` TEXT, @@ -85,7 +86,10 @@ CREATE TABLE `physiological_parameter_file` ( ON DELETE CASCADE, CONSTRAINT `FK_param_type_ParamTypeID` FOREIGN KEY (`ParameterTypeID`) - REFERENCES `parameter_type` (`ParameterTypeID`) + REFERENCES `parameter_type` (`ParameterTypeID`), + CONSTRAINT `FK_ppf_project_ID` + FOREIGN KEY (`ProjectID`) + REFERENCES `Project` (`ProjectID`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; @@ -271,7 +275,8 @@ CREATE TABLE IF NOT EXISTS `physiological_coord_system_electrode_rel` ( -- Create `physiological_event_file` table CREATE TABLE `physiological_event_file` ( `EventFileID` int(10) unsigned NOT NULL AUTO_INCREMENT, - `PhysiologicalFileID` int(10) unsigned NOT NULL, + `PhysiologicalFileID` int(10) unsigned DEFAULT NULL, + `ProjectID` int(10) unsigned NOT NULL, `FileType` varchar(20) NOT NULL, `FilePath` varchar(255) DEFAULT NULL, `LastUpdate` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -280,7 +285,8 @@ CREATE TABLE `physiological_event_file` ( KEY `FK_physio_file_ID` (`PhysiologicalFileID`), KEY `FK_event_file_type` (`FileType`), CONSTRAINT `FK_event_file_type` FOREIGN KEY (`FileType`) REFERENCES `ImagingFileTypes` (`type`), - CONSTRAINT `FK_physio_file_ID` FOREIGN KEY (`PhysiologicalFileID`) REFERENCES `physiological_file` (`PhysiologicalFileID`) + CONSTRAINT `FK_physio_file_ID` FOREIGN KEY (`PhysiologicalFileID`) REFERENCES `physiological_file` (`PhysiologicalFileID`), + CONSTRAINT `FK_pef_project_ID` FOREIGN KEY (`ProjectID`) REFERENCES `Project` (`ProjectID`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 ; @@ -299,9 +305,9 @@ CREATE TABLE `physiological_task_event` ( `EventType` VARCHAR(50) DEFAULT NULL, `TrialType` VARCHAR(255) DEFAULT NULL, `ResponseTime` TIME DEFAULT NULL, - `AssembledHED` TEXT DEFAULT NULL, PRIMARY KEY (`PhysiologicalTaskEventID`), KEY `FK_event_file` (`EventFileID`), + INDEX idx_pte_EventValue (`EventValue`), CONSTRAINT `FK_phys_file_FileID_4` FOREIGN KEY (`PhysiologicalFileID`) REFERENCES `physiological_file` (`PhysiologicalFileID`) @@ -536,38 +542,88 @@ INSERT INTO ImagingFileTypes ('cnt', 'Neuroscan CNT data format (EEG)'), ('archive', 'Archive file'); --- Insert into annotation_file_type -INSERT INTO physiological_annotation_file_type - (FileType, Description) - VALUES - ('tsv', 'TSV File Type, contains information about each annotation'), - ('json', 'JSON File Type, metadata for annotations'); - --- Insert into annotation_label_type -INSERT INTO physiological_annotation_label - (AnnotationLabelID, LabelName, LabelDescription) - VALUES - (1, 'artifact', 'artifactual data'), - (2, 'motion', 'motion related artifact'), - (3, 'flux_jump', 'artifactual data due to flux jump'), - (4, 'line_noise', 'artifactual data due to line noise (e.g., 50Hz)'), - (5, 'muscle', 'artifactual data due to muscle activity'), - (6, 'epilepsy_interictal', 'period deemed interictal'), - (7, 'epilepsy_preictal', 'onset of preictal state prior to onset of epilepsy'), - (8, 'epilepsy_seizure', 'onset of epilepsy'), - (9, 'epilepsy_postictal', 'postictal seizure period'), - (10, 'epileptiform', 'unspecified epileptiform activity'), - (11, 'epileptiform_single', 'a single epileptiform graphoelement (including possible slow wave)'), - (12, 'epileptiform_run', 'a run of one or more epileptiform graphoelements'), - (13, 'eye_blink', 'Eye blink'), - (14, 'eye_movement', 'Smooth Pursuit / Saccadic eye movement'), - (15, 'eye_fixation', 'Fixation onset'), - (16, 'sleep_N1', 'sleep stage N1'), - (17, 'sleep_N2', 'sleep stage N2'), - (18, 'sleep_N3', 'sleep stage N3'), - (19, 'sleep_REM', 'REM sleep'), - (20, 'sleep_wake', 'sleep stage awake'), - (21, 'sleep_spindle', 'sleep spindle'), - (22, 'sleep_k-complex', 'sleep K-complex'), - (23, 'scorelabeled', 'a global label indicating that the EEG has been annotated with SCORE.'); +-- Create `hed_schema` table +CREATE TABLE `hed_schema` ( + `ID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `Name` varchar(255) NOT NULL, + `Version` varchar(255) NOT NULL, + `Description` text NULL, + `URL` varchar(255) NOT NULL UNIQUE, + PRIMARY KEY (`ID`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +-- Create `hed_schema_nodes` table +CREATE TABLE `hed_schema_nodes` ( + `ID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `ParentID` int(10) unsigned NULL, + `SchemaID` int(10) unsigned NOT NULL, + `Name` varchar(255) NOT NULL, + `LongName` varchar(255) NOT NULL, + `Description` text NOT NULL, + PRIMARY KEY (`ID`), + CONSTRAINT `FK_hed_parent_node` + FOREIGN KEY (`ParentID`) + REFERENCES `hed_schema_nodes` (`ID`), + CONSTRAINT `FK_hed_schema` FOREIGN KEY (`SchemaID`) REFERENCES `hed_schema` (`ID`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +-- Create `physiological_task_event_hed_rel` table +CREATE TABLE `physiological_task_event_hed_rel` ( + `ID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `PhysiologicalTaskEventID` int(10) unsigned NOT NULL, + `HEDTagID` int(10) unsigned NULL, -- Reference to hed_schema_nodes.ID. Can be null to only add parentheses + `TagValue` text NULL, -- For value tags + `HasPairing` boolean DEFAULT FALSE, -- Is grouped with #AdditionalMembers# members + `PairRelID` int(10) unsigned NULL, -- The `ID` of right side of the pair + `AdditionalMembers` int(10) unsigned DEFAULT 0, -- Number of additional members to encapsulate + PRIMARY KEY (`ID`), + CONSTRAINT `FK_physiological_task_event_hed_rel_pair` FOREIGN KEY (`PairRelID`) + REFERENCES `physiological_task_event_hed_rel` (`ID`) ON DELETE CASCADE ON UPDATE CASCADE, + KEY `FK_physiological_task_event_hed_rel_2` (`HEDTagID`), + CONSTRAINT `FK_physiological_task_event_hed_rel_2` FOREIGN KEY (`HEDTagID`) + REFERENCES `hed_schema_nodes` (`ID`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `FK_physiological_task_event_hed_rel_1` FOREIGN KEY (`PhysiologicalTaskEventID`) + REFERENCES `physiological_task_event` (`PhysiologicalTaskEventID`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +-- Create `bids_event_dataset_mapping` table +CREATE TABLE `bids_event_dataset_mapping` ( + `ID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `ProjectID` int(10) unsigned NOT NULL, + `PropertyName` varchar(50) NOT NULL, + `PropertyValue` varchar(255) NOT NULL, + `HEDTagID` int(10) unsigned NULL, -- Reference to hed_schema_nodes.ID. Can be null to only add parentheses + `TagValue` text NULL, -- For value tags + `Description` TEXT NULL, -- Level Description + `HasPairing` BOOLEAN DEFAULT FALSE, -- Is grouped with #AdditionalMembers# members + `PairRelID` int(10) unsigned NULL, -- The `ID` of right side of the pair + `AdditionalMembers` int(10) unsigned DEFAULT 0, -- Number of additional members to encapsulate + PRIMARY KEY (`ID`), + INDEX idx_event_dataset_PropertyName_PropertyValue (`PropertyName`, `PropertyValue`), + CONSTRAINT `FK_project_id` FOREIGN KEY (`ProjectID`) REFERENCES `Project` (`ProjectID`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `FK_dataset_hed_tag_id` FOREIGN KEY (`HEDTagID`) REFERENCES `hed_schema_nodes` (`ID`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + + +-- Create `bids_event_file_mapping` table +CREATE TABLE `bids_event_file_mapping` ( + `ID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `EventFileID` int(10) unsigned NOT NULL, + `PropertyName` varchar(50) NOT NULL, + `PropertyValue` varchar(255) NOT NULL, + `HEDTagID` int(10) unsigned NULL, -- Reference to hed_schema_nodes.ID. Can be null to only add parentheses + `TagValue` text NULL, -- For value tags + `Description` TEXT NULL, -- Level Description + `HasPairing` BOOLEAN DEFAULT FALSE, -- Is grouped with #AdditionalMembers# members + `PairRelID` int(10) unsigned NULL, -- The `ID` of right side of the pair + `AdditionalMembers` int(10) unsigned DEFAULT 0, -- Number of additional members to encapsulate + PRIMARY KEY (`ID`), + INDEX idx_event_file_PropertyName_PropertyValue (`PropertyName`, `PropertyValue`), + CONSTRAINT `FK_event_mapping_file_id` FOREIGN KEY (`EventFileID`) REFERENCES `physiological_event_file` (`EventFileID`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `FK_file_hed_tag_id` FOREIGN KEY (`HEDTagID`) REFERENCES `hed_schema_nodes` (`ID`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + + + + diff --git a/SQL/New_patches/2024-01-30-HED-Tag-Support.sql b/SQL/New_patches/2024-01-30-HED-Tag-Support.sql new file mode 100644 index 00000000000..46320f2c186 --- /dev/null +++ b/SQL/New_patches/2024-01-30-HED-Tag-Support.sql @@ -0,0 +1,109 @@ +-- Remove unused column +ALTER TABLE `physiological_task_event` DROP COLUMN `AssembledHED`; + +-- Add index for performance improvement +ALTER TABLE `physiological_task_event` ADD INDEX idx_pte_EventValue (`EventValue`); + +-- Event files are always associated to Projects, sometimes exclusively (dataset-scope events.json files) +-- Add ProjectID and make PhysiologicalFileID DEFAULT NULL (ProjectID should ideally not be NULLable) +ALTER TABLE `physiological_event_file` + CHANGE `PhysiologicalFileID` `PhysiologicalFileID` int(10) unsigned DEFAULT NULL, + ADD COLUMN `ProjectID` int(10) unsigned DEFAULT NULL AFTER `PhysiologicalFileID`, + ADD KEY `FK_physiological_event_file_project_id` (`ProjectID`), + ADD CONSTRAINT `FK_physiological_event_file_project_id` + FOREIGN KEY (`ProjectID`) REFERENCES `Project` (`ProjectID`); + +-- Add ProjectID and make PhysiologicalFileID DEFAULT NULL +ALTER TABLE `physiological_parameter_file` + CHANGE `PhysiologicalFileID` `PhysiologicalFileID` int(10) unsigned DEFAULT NULL, + ADD COLUMN `ProjectID` int(10) unsigned DEFAULT NULL AFTER `PhysiologicalFileID`, + ADD KEY `FK_physiological_parameter_file_project_id` (`ProjectID`), + ADD CONSTRAINT `FK_physiological_parameter_file_project_id` + FOREIGN KEY (`ProjectID`) REFERENCES `Project` (`ProjectID`); + +-- Create `hed_schema` table +CREATE TABLE `hed_schema` ( + `ID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `Name` varchar(255) NOT NULL, + `Version` varchar(255) NOT NULL, + `Description` text NULL, + `URL` varchar(255) NOT NULL UNIQUE, + PRIMARY KEY (`ID`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +-- Create `hed_schema_nodes` table +CREATE TABLE `hed_schema_nodes` ( + `ID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `ParentID` int(10) unsigned NULL, + `SchemaID` int(10) unsigned NOT NULL, + `Name` varchar(255) NOT NULL, + `LongName` varchar(255) NOT NULL, + `Description` text NOT NULL, + PRIMARY KEY (`ID`), + CONSTRAINT `FK_hed_parent_node` + FOREIGN KEY (`ParentID`) + REFERENCES `hed_schema_nodes` (`ID`), + CONSTRAINT `FK_hed_schema` FOREIGN KEY (`SchemaID`) REFERENCES `hed_schema` (`ID`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +-- Create `physiological_task_event_hed_rel` table +CREATE TABLE `physiological_task_event_hed_rel` ( + `ID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `PhysiologicalTaskEventID` int(10) unsigned NOT NULL, + `HEDTagID` int(10) unsigned NULL, -- Reference to hed_schema_nodes.ID. Can be null to only add parentheses + `TagValue` text NULL, -- For value tags + `HasPairing` boolean DEFAULT FALSE, -- Is grouped with #AdditionalMembers# members + `PairRelID` int(10) unsigned NULL, -- The `ID` of right side of the pair + `AdditionalMembers` int(10) unsigned DEFAULT 0, -- Number of additional members to encapsulate + PRIMARY KEY (`ID`), + CONSTRAINT `FK_physiological_task_event_hed_rel_pair` FOREIGN KEY (`PairRelID`) + REFERENCES `physiological_task_event_hed_rel` (`ID`) ON DELETE CASCADE ON UPDATE CASCADE, + KEY `FK_physiological_task_event_hed_rel_2` (`HEDTagID`), + CONSTRAINT `FK_physiological_task_event_hed_rel_2` FOREIGN KEY (`HEDTagID`) + REFERENCES `hed_schema_nodes` (`ID`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `FK_physiological_task_event_hed_rel_1` FOREIGN KEY (`PhysiologicalTaskEventID`) + REFERENCES `physiological_task_event` (`PhysiologicalTaskEventID`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +-- Create `bids_event_dataset_mapping` table +CREATE TABLE `bids_event_dataset_mapping` ( + `ID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `ProjectID` int(10) unsigned NOT NULL, + `PropertyName` varchar(50) NOT NULL, + `PropertyValue` varchar(255) NOT NULL, + `HEDTagID` int(10) unsigned NULL, -- Reference to hed_schema_nodes.ID. Can be null to only add parentheses + `TagValue` text NULL, -- For value tags + `Description` TEXT NULL, -- Level Description + `HasPairing` BOOLEAN DEFAULT FALSE, -- Is grouped with #AdditionalMembers# members + `PairRelID` int(10) unsigned NULL, -- The `ID` of right side of the pair + `AdditionalMembers` int(10) unsigned DEFAULT 0, -- Number of additional members to encapsulate + PRIMARY KEY (`ID`), + INDEX idx_event_dataset_PropertyName_PropertyValue (`PropertyName`, `PropertyValue`), + CONSTRAINT `FK_project_id` FOREIGN KEY (`ProjectID`) REFERENCES `Project` (`ProjectID`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `FK_dataset_hed_tag_id` FOREIGN KEY (`HEDTagID`) REFERENCES `hed_schema_nodes` (`ID`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + + +-- Create `bids_event_file_mapping` table +CREATE TABLE `bids_event_file_mapping` ( + `ID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `EventFileID` int(10) unsigned NOT NULL, + `PropertyName` varchar(50) NOT NULL, + `PropertyValue` varchar(255) NOT NULL, + `HEDTagID` int(10) unsigned NULL, -- Reference to hed_schema_nodes.ID. Can be null to only add parentheses + `TagValue` text NULL, -- For value tags + `Description` TEXT NULL, -- Level Description + `HasPairing` BOOLEAN DEFAULT FALSE, -- Is grouped with #AdditionalMembers# members + `PairRelID` int(10) unsigned NULL, -- The `ID` of right side of the pair + `AdditionalMembers` int(10) unsigned DEFAULT 0, -- Number of additional members to encapsulate + PRIMARY KEY (`ID`), + INDEX idx_event_file_PropertyName_PropertyValue (`PropertyName`, `PropertyValue`), + CONSTRAINT `FK_event_mapping_file_id` FOREIGN KEY (`EventFileID`) REFERENCES `physiological_event_file` (`EventFileID`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `FK_file_hed_tag_id` FOREIGN KEY (`HEDTagID`) REFERENCES `hed_schema_nodes` (`ID`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + + + + + + diff --git a/modules/electrophysiology_browser/css/electrophysiology_browser.css b/modules/electrophysiology_browser/css/electrophysiology_browser.css index 8a4300a663e..41b325ddca0 100644 --- a/modules/electrophysiology_browser/css/electrophysiology_browser.css +++ b/modules/electrophysiology_browser/css/electrophysiology_browser.css @@ -113,6 +113,162 @@ svg:not(:root) { width: 100%; } +.badge-pill { + max-width: 100%; +} + +.badge-pill p { + overflow: hidden; + text-overflow: ellipsis; + margin: 0; +} + +.badge-property { + background-color: #690096; +} + +.badge-hed { + background-color: #d54a08; + cursor: help; + position: relative; + display: inline-block; +} + +.tag-hed, .dataset-tag-hed { + cursor: pointer; + position: relative; + display: inline-block; +} + +.tag-hed-score { + cursor: pointer; +} + +.badge-hed .badge-hed-tooltip, +.tag-hed .badge-hed-tooltip { + visibility: hidden; + background-color: #555555; + color: #fff; + padding: 10px; + border-radius: 6px; + position: absolute; + z-index: 1; + top: -50px; + right: 105%; + width: 500px; + max-height: 350px; + overflow-y: scroll; + white-space: normal; + text-align: left; +} + +.badge-hed:hover .badge-hed-tooltip, +.tag-hed:hover .badge-hed-tooltip { + visibility: visible; +} + +.badge-hed-tooltip .tooltip-title { + font-size: 16px; +} + +.tooltip-description { + font-weight: normal; + font-style: italic; +} + +.badge-hed-add { + color: #246EB6 !important; + border: solid 1px #246EB6; + background-color: #fff !important; + cursor: pointer; +} + +.selection-filter-tags { + margin: 0; + font-weight: bold; +} + +.tag-remove-button { + cursor: pointer; + margin-left: 5px; + padding-left: 7.5px; + border-left: #b0885d solid 1px; + float: right; + width: 15px; +} + +.dataset-tag-remove-button { + border-left: #999 solid 1px; +} + +.selection-filter-tag { + display: inline-block; + margin: 2px 5px; + padding: 5px 10px; + color: black; + background-color: #E89A0C; + border-radius: 5px; + height: 30px; + max-width: 100vw; +} + +.selection-filter-dataset-tag { + color: #fff; + background-color: #A9A9A9; +} + +.dataset-tag-selected { + background-color: rgb(24, 99, 0); +} + +.filter-tag-name { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + float: left; + max-width: 12vw; +} + +.dataset-tag-dirty { + opacity: 0.6; +} + +.tag-modal-container-dirty:before { + content: '*'; +} + +#select_column { + height: 27px; + text-align: center; +} + +.dataset-filter-tags { + margin: 0 2px; +} + +.dataset-filter-tag { + max-width: 52vw; +} + +.dataset-tag-name { + max-width: calc(100% - 20px); +} + +#tag-modal-container { + margin-bottom: 15px; +} + +#tag-modal-container > div > button { + position: unset; + width: unset; +} + + +#tag-modal-container > div > div > div { + width: 75vw !important; + height: 75vh; +} + .line-height-14 { line-height: 14px; } @@ -252,6 +408,14 @@ svg:not(:root) { #page.eegBrowser { margin-left: 150px; } + + #tag-modal-container > div > button { + position: fixed; + left: 15px; + bottom: 20px; + width: 120px; + white-space: normal; + } } /* Medium Devices, Desktops */ diff --git a/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js b/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js index 7078bb97543..23820067c3f 100644 --- a/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js +++ b/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js @@ -235,6 +235,12 @@ class ElectrophysiologySessionView extends Component { events: dbEntry && dbEntry.file.events, + hedSchema: + dbEntry + && dbEntry.file.hedSchema, + datasetTags: + dbEntry + && dbEntry.file.datasetTags, })); this.setState({ @@ -334,6 +340,8 @@ class ElectrophysiologySessionView extends Component { chunksURLs, epochsURL, events, + hedSchema, + datasetTags, electrodesURL, coordSystemURL, } = this.state.database[i]; @@ -368,6 +376,8 @@ class ElectrophysiologySessionView extends Component { events={events} electrodesURL={electrodesURL} coordSystemURL={coordSystemURL} + hedSchema={hedSchema} + datasetTags={datasetTags} physioFileID={this.state.database[i].file.id} > { +class EEGLabSeriesProvider extends Component { private store: Store; /** @@ -60,16 +65,35 @@ class EEGLabSeriesProvider extends Component { window.EEGLabSeriesProviderStore = this.store; + console.log('thisstore', this.store); + const { chunksURL, + epochsURL, electrodesURL, coordSystemURL, + hedSchema, + datasetTags, events, physioFileID, limit, } = props; + const formattedDatasetTags = {}; + Object.keys(datasetTags).forEach((column) => { + formattedDatasetTags[column] = {}; + Object.keys(datasetTags[column]).forEach((value) => { + formattedDatasetTags[column][value] = datasetTags[column][value].map((tag) => { + return { + ...tag, + AdditionalMembers: parseInt(tag.AdditionalMembers), + } + }); + }); + }) this.store.dispatch(setPhysioFileID(physioFileID)); + this.store.dispatch(setHedSchemaDocument(hedSchema)); + this.store.dispatch(setDatasetTags(formattedDatasetTags)); /** * @@ -81,27 +105,21 @@ class EEGLabSeriesProvider extends Component { 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 Promise.resolve(); - })]; + .then((json) => ({json, url})) + // if request fails don't resolve + .catch((error) => { + console.error(error); + return new Promise((resolve) => {}); + })]; } else { - return [Promise.resolve()]; + return [new Promise((resolve) => {})]; } }; Promise.race(racers(fetchJSON, chunksURL, '/index.json')).then( ({json, url}) => { if (json) { - const { - channelMetadata, - shapes, - timeInterval, - seriesRange, - validSamples, - } = json; + const {channelMetadata, shapes, timeInterval, seriesRange, validSamples} = json; this.store.dispatch( setDatasetMetadata({ chunksURL: url, @@ -120,37 +138,62 @@ class EEGLabSeriesProvider extends Component { this.store.dispatch(setDomain(timeInterval)); this.store.dispatch(setInterval(DEFAULT_TIME_INTERVAL)); } - }).then(() => { - const epochs = []; - events.instances.map((instance) => { - const epochIndex = epochs.findIndex((e) => e.physiologicalTaskEventID === instance.PhysiologicalTaskEventID); + } + ).then(() => { + const epochs = []; + events.instances.map((instance) => { + const epochIndex = epochs.findIndex((e) => e.physiologicalTaskEventID === instance.PhysiologicalTaskEventID); + + const extraColumns = Array.from(events.extra_columns).filter((column) => { + return column.PhysiologicalTaskEventID === instance.PhysiologicalTaskEventID + }); - const extraColumns = Array.from(events.extra_columns).filter((column) => { - return column.PhysiologicalTaskEventID === instance.PhysiologicalTaskEventID + const hedTags = Array.from(events.hed_tags).filter((column) => { + return column.PhysiologicalTaskEventID === instance.PhysiologicalTaskEventID + }).map((hedTag) => { + const foundTag = hedSchema.find((tag) => { + return tag.id === hedTag.HEDTagID; + }); + const additionalMembers = parseInt(hedTag.AdditionalMembers); + + // Currently only supporting schema-defined HED tags + return { + schemaElement: foundTag ?? null, + HEDTagID: foundTag ? foundTag.id : null, + ID: hedTag.ID, + PropertyName: hedTag.PropertyName, + PropertyValue: hedTag.PropertyValue, + TagValue: hedTag.TagValue, + Description: hedTag.Description, + HasPairing: hedTag.HasPairing, + PairRelID: hedTag.PairRelID, + AdditionalMembers: isNaN(additionalMembers) ? 0 : additionalMembers, + } + }); + + if (epochIndex === -1) { + const epochLabel = [null, 'n/a'].includes(instance.TrialType) + ? null + : instance.TrialType; + epochs.push({ + onset: parseFloat(instance.Onset), + duration: parseFloat(instance.Duration), + type: 'Event', + label: epochLabel ?? instance.EventValue, + value: instance.EventValue, + trial_type: instance.TrialType, + properties: extraColumns, + hed: hedTags, + channels: 'all', + physiologicalTaskEventID: instance.PhysiologicalTaskEventID, }); - if (epochIndex === -1) { - const epochLabel = [null, 'n/a'].includes(instance.TrialType) - ? null - : instance.TrialType; - epochs.push({ - onset: parseFloat(instance.Onset), - duration: parseFloat(instance.Duration), - type: 'Event', - label: epochLabel ?? instance.EventValue, - value: instance.EventValue, - trial_type: instance.TrialType, - properties: extraColumns, - hed: null, - channels: 'all', - physiologicalTaskEventID: instance.PhysiologicalTaskEventID, - }); } else { console.error('ERROR: EPOCH EXISTS'); } }); - return epochs; + return epochs; }).then((epochs) => { - const sortedEpochs = epochs + const sortedEpochs = epochs .flat() .sort(function (a, b) { return a.onset - b.onset; @@ -169,6 +212,7 @@ class EEGLabSeriesProvider extends Component { })); }); + Promise.race(racers(fetchText, electrodesURL)) .then((text) => { if (!(typeof text.json === 'string' @@ -212,8 +256,52 @@ class EEGLabSeriesProvider extends Component { */ render() { const [signalViewer, ...rest] = React.Children.toArray(this.props.children); + + + // TODO: Replace with image ref + const hedTagLogo = ( + + + ); + return ( +
+ +
+ + {hedTagLogo} + + + Dataset Tag Manager + +
+
+ More about HED + +
+
+ } + label="Open Dataset Tag Manager" + > + + + {signalViewer} {rest}
diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/AnnotationForm.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/AnnotationForm.tsx index ba2651673ab..ff4c27c97ba 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/AnnotationForm.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/AnnotationForm.tsx @@ -2,17 +2,27 @@ import React, {useEffect, useState} from 'react'; import { Epoch as EpochType, RightPanel, + HEDTag, + HEDSchemaElement, } 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'; +import { + getNthMemberTrailingBadgeIndex, + getTagsForEpoch, + toggleEpoch, + updateActiveEpoch +} from '../store/logic/filterEpochs'; import {RootState} from '../store'; -import {setEpochs} from '../store/state/dataset'; +import {setActiveEpoch, setEpochs} from '../store/state/dataset'; import {setCurrentAnnotation} from '../store/state/currentAnnotation'; import {NumericElement, SelectElement, TextboxElement} from './Form'; import swal from 'sweetalert2'; +import {InfoIcon} from "./components"; +import {colorOrder} from "../../color"; + type CProps = { timeSelection?: [number, number], @@ -27,6 +37,8 @@ type CProps = { toggleEpoch: (_: number) => void, updateActiveEpoch: (_: number) => void, interval: [number, number], + hedSchema: HEDSchemaElement[], + datasetTags: any, }; /** @@ -40,12 +52,11 @@ type CProps = { * @param root0.currentAnnotation * @param root0.setCurrentAnnotation * @param root0.physioFileID - * @param root0.annotationMetadata * @param root0.toggleEpoch, * @param root0.updateActiveEpoch, * @param root0.interval - * @param root0.toggleEpoch - * @param root0.updateActiveEpoch + * @param root0.hedSchema + * @param root0.datasetTags */ const AnnotationForm = ({ timeSelection, @@ -59,6 +70,8 @@ const AnnotationForm = ({ toggleEpoch, updateActiveEpoch, interval, + hedSchema, + datasetTags, }: CProps) => { const [startEvent = '', endEvent = ''] = timeSelection || []; const [event, setEvent] = useState<(number | string)[]>( @@ -69,13 +82,15 @@ const AnnotationForm = ({ ); const [label, setLabel] = useState( currentAnnotation ? - currentAnnotation.label : - null + currentAnnotation.label : + null ); const [isSubmitted, setIsSubmitted] = useState(false); const [isDeleted, setIsDeleted] = useState(false); const [annoMessage, setAnnoMessage] = useState(''); + const [newTags, setNewTags] = useState([]); + const [deletedTagIDs, setDeletedTagIDs] = useState([]); // Time Selection useEffect(() => { @@ -87,11 +102,18 @@ const AnnotationForm = ({ * * @param event */ - const validate = (event) => ( - (event[0] || event[0] === 0) + const validate = (event) => { + return (event[0] || event[0] === 0) && (event[1] || event[1] === 0) && event[0] <= event[1] - ); + // TODO: Confine to domain + // && event[0] >= interval[0] && event[0] <= interval[1] + // && event[1] >= interval[0] && event[1] <= interval[1] + && ( + newTags.some((tag) => tag.value !== '') || + deletedTagIDs.length > 0 + ); + } /** * @@ -143,11 +165,67 @@ const AnnotationForm = ({ /** * - * @param name + */ + const handleAddTag = (tagType: string) => { + // Add tag if all are filled + if (newTags.find((tag) => { + return tag.value === ''; + })) { + setAnnoMessage('Fill other tags first'); + setTimeout(() => { + setAnnoMessage(''); + }, 2000); + } else { + setNewTags([ + ...newTags, + { + type: tagType, + value: '', + } + ]); + } + }; + + + /** + * + */ + const handleDeleteTag = (event) => { + const elementID = event.target.getAttribute('id'); + const tagRelID = elementID.split('-').pop(); + if (currentAnnotation.hed && + currentAnnotation.hed.map((tag) => { + return tag.ID + }).includes(tagRelID) + ) { + setDeletedTagIDs([...deletedTagIDs, tagRelID]); + } + }; + + /** + * + * @param tagIndex + */ + const handleRemoveAddedTag = (tagIndex: number) => { + setNewTags(newTags.filter((tag, index) => { + return index !== tagIndex; + })); + }; + + /** + * + * @param tagIndex * @param value */ - const handleLabelChange = (name, value) => { - setLabel(value); + const handleTagChange = (tagIndex, value) => { + setNewTags([ + ...newTags.slice(0, tagIndex), + { + ...newTags[tagIndex], + value: value + }, + ...newTags.slice(tagIndex + 1), + ]) }; /** @@ -165,6 +243,9 @@ const AnnotationForm = ({ // setEvent(['', '']); // setTimeSelection([null, null]); // setLabel(''); + + setNewTags([]); + setDeletedTagIDs([]); }; /** @@ -192,10 +273,42 @@ const AnnotationForm = ({ return; } + const newTagIDs = newTags + .filter((tag) => { + return tag.value !== ''; + }) + .map((tag) => { + const node = getNodeByName(tag.value); + if (node) + return node.id; + }); + + const currentTagIDs = (currentAnnotation.hed ?? []).map((tag) => { + return tag.ID; + }).filter(currentTagID => { + return !deletedTagIDs.includes(currentTagID) + }); + + // Prevent duplicates + // const addingNewTagMultipleTimes = newTagIDs.length !== (new Set(newTagIDs)).size; + // const addingExistingTag = newTagIDs.filter((tagID) => { + // return currentTagIDs.includes(tagID) + // }).length > 0; + // + // if (addingNewTagMultipleTimes || addingExistingTag) { + // swal.fire( + // 'Warning', + // 'Duplicates are not allowed', + // 'warning' + // ); + // setIsSubmitted(false); + // return; + // } + const url = window.location.origin + '/electrophysiology_browser/events/'; - // get duration of annotation + // get duration of event let startTime = event[0]; let endTime = event[1]; if (typeof startTime === 'string') { @@ -207,7 +320,7 @@ const AnnotationForm = ({ const duration = endTime - startTime; // set body - // instance_id = null for new annotations + // instance_id = null for new events const body = { request_type: 'event_update', physioFileID: physioFileID, @@ -220,6 +333,8 @@ const AnnotationForm = ({ label_name: label, label_description: label, channels: 'all', + added_hed: newTagIDs, + deleted_hed: deletedTagIDs, }, }; @@ -231,15 +346,40 @@ const AnnotationForm = ({ if (response.ok) { return response.json(); } + throw (response); }).then((response) => { setIsSubmitted(false); - // if in edit mode, remove old annotation instance + // if in edit mode, remove old event instance if (currentAnnotation !== null) { epochs.splice(epochs.indexOf(currentAnnotation), 1); } + // } else { + // newAnnotation.physiologicalTaskEventID = parseInt(data.instance_id); + // } const data = response.instance; + console.log(data); + + // TODO: Properly handle new event + const hedTags = Array.from(data.hed_tags).map((hedTag : HEDTag) => { + const foundTag = hedSchema.find((tag) => { + return tag.id === hedTag.HEDTagID; + }); + // Currently only supporting schema-defined HED tags + return { + schemaElement: foundTag ?? null, + HEDTagID: hedTag.HEDTagID, + ID: hedTag.ID, + PropertyName: hedTag.PropertyName, + PropertyValue: hedTag.PropertyValue, + TagValue: hedTag.TagValue, + Description: hedTag.Description, + HasPairing: hedTag.HasPairing, + PairRelID: hedTag.PairRelID, + AdditionalMembers: hedTag.AdditionalMembers, + } + }); const newAnnotation : EpochType = { onset: parseFloat(data.instance.Onset), @@ -249,6 +389,7 @@ const AnnotationForm = ({ value: data.instance.EventValue, trial_type: data.instance.TrialType, properties: data.extra_columns, + hed: hedTags, channels: 'all', physiologicalTaskEventID: data.instance.PhysiologicalTaskEventID, }; @@ -256,13 +397,14 @@ const AnnotationForm = ({ epochs.push(newAnnotation); setEpochs( epochs - .sort(function(a, b) { - return a.onset - b.onset; - }) + .sort(function(a, b) { + return a.onset - b.onset; + }) ); // Reset Form handleReset(); + setCurrentAnnotation(newAnnotation); // Display success message setAnnoMessage(currentAnnotation ? @@ -294,7 +436,7 @@ const AnnotationForm = ({ useEffect(() => { if (isDeleted) { const url = window.location.origin - + '/electrophysiology_browser/events/'; + + '/electrophysiology_browser/events/'; const body = { physioFileID: physioFileID, instance_id: currentAnnotation ? @@ -339,7 +481,7 @@ const AnnotationForm = ({ 'success' ); - // If in edit mode, switch back to annotation panel + // If in edit mode, switch back to EventManager panel if (currentAnnotation !== null) { setCurrentAnnotation(null); setRightPanel('eventList'); @@ -361,6 +503,228 @@ const AnnotationForm = ({ } }, [isDeleted]); + const getNodeByName = (name: string) => { + return hedSchema.find((node) => { + return node.name === name + }); + } + + const addHedTagOptions = [ + { + type: 'SCORE', + value: 'SCORE Artifacts', + }, + { + type: 'DATASET', + value: 'Tags in current dataset', + }, + ] + + const buildPropertyOptions = (optgroup: string, parentHED: string) => { + return hedSchema.filter((node) => { + return node.longName.includes(parentHED) + }).map((tag) => { + return { + HEDTagID: tag.id, + label: tag.name, + longName: tag.longName, + value: tag.id, + optgroup: optgroup, + Description: tag.description, + } + }).sort((tagA, tagB) => { + return tagA.label.localeCompare(tagB.label); + }); + } + + const artifactTagOptions = [ + ...buildPropertyOptions( + 'Biological-artifact', + 'Artifact/Biological-artifact/' + ), + ...buildPropertyOptions( + 'Non-biological-artifact', + 'Artifact/Non-biological-artifact/' + ), + ]; + + const getUniqueDatasetTags = () => { + const idSet = new Set(); + const tagList = []; + Object.keys(datasetTags).forEach((columnName) => { + Object.keys(datasetTags[columnName]).forEach((fieldValue) => { + const hedTags = datasetTags[columnName][fieldValue].map((hedTag) => { + if (hedTag && hedTag.HEDTagID !== null) { + const schemaElement = hedSchema.find((schemaTag) => { + return schemaTag.id === hedTag.HEDTagID; + }) + if (schemaElement && !idSet.has(schemaElement.id)) { + idSet.add(schemaElement.id); + const optGroup = schemaElement.longName.substring(0, schemaElement.longName.lastIndexOf('/')); + return { + HEDTagID: schemaElement.id, + label: schemaElement.name, + longName: schemaElement.longName, + value: schemaElement.id, + optgroup: optGroup.length > 0 ? optGroup : schemaElement.name, + Description: schemaElement.description, + } + } + } + }); + tagList.push(...hedTags.filter((tag) => { + return tag !== undefined; + })); + }); + }); + return tagList.sort((tagA, tagB) => { + return tagA.label.localeCompare(tagB.label); + }); + } + + const getOptions = (optionType: string) => { + switch (optionType) { + case 'SCORE': + return artifactTagOptions; + case 'DATASET': + return getUniqueDatasetTags(); + default: + return []; + } + } + + const buildGroupSpan = (char: string, colorIndex: number) => { + return ( + + {char} + + ); + } + + const buildHEDBadge = (hedTag: HEDTag, belongsToEvent: boolean) => { + return ( +
+
+ + {hedTag.schemaElement.name} + + +
+
+
+ {hedTag.schemaElement.longName} +
+
+
+ {hedTag.schemaElement.description} +
+
+
+ ); + } + + const buildHEDBadges = (hedTags: HEDTag[], belongsToEvent: boolean = false) => { + const rootTags = hedTags.filter((tag) => { + return !hedTags.some((t) => { + return tag.ID === t.PairRelID + }) + }); + + const tagBadges = []; + + rootTags.forEach((tag) => { + if (deletedTagIDs.includes(tag.ID)) { + return; + } + let groupColorIndex = 0; + if (tag.PairRelID === null) { + tagBadges.push(buildHEDBadge(tag, belongsToEvent)); + groupColorIndex++; + } else { + const tagGroup = []; + let groupMember = tag; + while (groupMember) { + tagGroup.push(groupMember); + groupMember = hedTags.find((hedTag) => { + return hedTag.ID === groupMember.PairRelID; + }); + } + + const tagBadgeGroup = []; + const tagBadgeSubgroup = []; + tagGroup.reverse().map((groupTag) => { + if (groupTag.PairRelID === null) { + tagBadgeGroup.push(buildHEDBadge(groupTag, belongsToEvent)); + } else { + if (groupTag.HasPairing === '1') { + if (groupTag.AdditionalMembers > 0 || tagBadgeSubgroup.length === 0) { + let commaIndex = getNthMemberTrailingBadgeIndex( + tagBadgeGroup, + groupTag.AdditionalMembers + ( + tagBadgeSubgroup.length > 0 ? 0 : 1 + ) + ); + + tagBadgeGroup.splice(commaIndex, 0, buildGroupSpan(')', groupColorIndex)); + if (tagBadgeSubgroup.length > 0) { + tagBadgeGroup.splice(0, 0, ...tagBadgeSubgroup); + } + if (groupTag.HEDTagID !== null) { + tagBadgeGroup.splice(0, 0, buildHEDBadge(groupTag, belongsToEvent)); + } + tagBadgeGroup.splice(0, 0, buildGroupSpan('(', groupColorIndex)); + tagBadgeSubgroup.length = 0; + } else { + if (groupTag.HEDTagID === null) { + if (tagBadgeSubgroup.length > 0) { + tagBadgeSubgroup.splice(0, 0, buildGroupSpan('(', groupColorIndex)); + tagBadgeSubgroup.push(buildGroupSpan(')', groupColorIndex)); + } else { + console.error('UNEXPECTED STATE'); + } + } else { + if (tagBadgeSubgroup.length > 0) { + tagBadgeSubgroup.splice(0, 0, buildHEDBadge(groupTag, belongsToEvent)); + tagBadgeSubgroup.splice(0, 0, buildGroupSpan('(', groupColorIndex)); + tagBadgeSubgroup.push(buildGroupSpan(')', groupColorIndex)); + } else { + tagBadgeGroup.splice(0, 0, buildHEDBadge(groupTag, belongsToEvent)); + tagBadgeGroup.splice(0, 0, buildGroupSpan('(', groupColorIndex)); + tagBadgeGroup.push(buildGroupSpan(')', groupColorIndex)); + } + } + } + groupColorIndex++; + } else { + if (tagBadgeSubgroup.length > 0) { + tagBadgeGroup.splice(0, 0, ...tagBadgeSubgroup); + } + tagBadgeSubgroup.splice(0, tagBadgeSubgroup.length, buildHEDBadge(groupTag, belongsToEvent)); + } + } + }); + tagBadges.push(...tagBadgeGroup); + } + }); + return tagBadges; + } + + return (
{currentAnnotation ? 'Edit' : 'Add'} Event { + if (deletedTagIDs.length > 0 || + (newTags.length > 0 && newTags.find((tag) => tag.value !== '') + )) { + if (!confirm('Are you sure you want to discard your changes? ' + + ' Otherwise, press cancel and "Submit" your changes')) { + return; + } + } setRightPanel('eventList'); setCurrentAnnotation(null); setTimeSelection(null); @@ -473,6 +845,129 @@ const AnnotationForm = ({ ) }
+
+ +
+ { + currentAnnotation && currentAnnotation.hed && ( + <> +
Dataset
+ { + buildHEDBadges( + getTagsForEpoch(currentAnnotation, datasetTags, hedSchema), + false, + ).map((badge) => { + return badge; + }) + } + + ) + } + { + ( + ( + currentAnnotation + && currentAnnotation.hed + && currentAnnotation.hed.length > 0 + ) || newTags.length > 0 + ) && ( +
Instance
+ ) + } + { + currentAnnotation && currentAnnotation.hed && + buildHEDBadges( + currentAnnotation.hed, + true, + ).map((badge) => { + return badge; + }) + } +
+ { + newTags.map((tag, tagIndex) => { + return ( + <> + { + handleTagChange(tagIndex, value); + }} + useOptionGroups={true} + /> +
handleRemoveAddedTag(tagIndex)} + style={{ + position: 'relative', + left: '100%', + bottom: '30px', + height: '0', + width: '10%', + textAlign: 'center', + marginLeft: '2px', + cursor: 'pointer', + fontWeight: 'bold', + }} + > + x +
+ + ); + }) + } +
+
+
+ Select tag from: +
+ { + const addOption = addHedTagOptions.find((option) => { + return option.value === value; + }) + handleAddTag(addOption.type) + }} + /> + {/**/} + {/* Add Tag*/} + {/**/} +
+
+
+ {buildDataList(searchText.length === 0)} + + + + +
+
+ Description: +
+
+
+
+