From ac0f416cdcf5daac16e9a99232db12a7fcd7db82 Mon Sep 17 00:00:00 2001 From: jeffersoncasimir Date: Tue, 30 Sep 2025 17:30:28 -0400 Subject: [PATCH 01/23] Update module with EEGNet features --- .../css/electrophysiology_browser.css | 624 +++++- .../css/electrophysiology_browser_index.css | 44 + .../help/sessions.md | 36 + .../jsx/electrophysiologyBrowserIndex.js | 12 + .../jsx/electrophysiologySessionView.js | 38 +- .../src/eeglab/EEGLabSeriesProvider.tsx | 276 ++- .../src/series/components/AnnotationForm.tsx | 996 ++++++--- .../src/series/components/DatasetTagger.tsx | 1699 +++++++++++---- .../src/series/components/EEGMontage.tsx | 763 +++++-- .../src/series/components/Epoch.tsx | 49 +- .../src/series/components/EventManager.tsx | 715 +++++-- .../src/series/components/Form.js | 540 ++++- .../src/series/components/HEDEndorsement.tsx | 1887 +++++++++++++++++ .../src/series/components/LoadingBar.js | 40 + .../src/series/components/Panel.js | 152 ++ .../src/series/components/SeriesCursor.tsx | 49 +- .../src/series/components/SeriesRenderer.tsx | 902 +++++--- .../src/series/components/components.tsx | 30 +- .../src/series/store/logic/fetchChunks.tsx | 98 +- .../src/series/store/logic/filterEpochs.tsx | 91 +- .../src/series/store/logic/highLowPass.tsx | 96 +- .../src/series/store/state/dataset.tsx | 16 + .../src/series/store/types.tsx | 31 +- .../src/vector/index.tsx | 2 + .../react-series-data-viewer/tsconfig.json | 6 + .../php/electrophysiology_browser.class.inc | 16 + ...ophysiologybrowserrowprovisioner.class.inc | 16 + .../php/events.class.inc | 54 + .../php/models/datasettags.class.inc | 281 ++- .../php/models/electrophysioevents.class.inc | 421 ++-- .../php/models/hedendorsement.class.inc | 274 +++ .../php/sessions.class.inc | 1 + .../test/electrophysiologyBrowserTest.php | 579 +++++ 33 files changed, 9077 insertions(+), 1757 deletions(-) create mode 100644 modules/electrophysiology_browser/css/electrophysiology_browser_index.css create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/HEDEndorsement.tsx create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/LoadingBar.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/Panel.js create mode 100644 modules/electrophysiology_browser/jsx/react-series-data-viewer/tsconfig.json create mode 100644 modules/electrophysiology_browser/php/models/hedendorsement.class.inc create mode 100644 modules/electrophysiology_browser/test/electrophysiologyBrowserTest.php diff --git a/modules/electrophysiology_browser/css/electrophysiology_browser.css b/modules/electrophysiology_browser/css/electrophysiology_browser.css index 5481f7ec617..54b7ab00da4 100644 --- a/modules/electrophysiology_browser/css/electrophysiology_browser.css +++ b/modules/electrophysiology_browser/css/electrophysiology_browser.css @@ -14,15 +14,23 @@ width: 100%; } +.react-series-data-viewer-scoped > panel-primary { + padding-bottom: 35px; +} + +#channel-viewer > .panel-body { + margin-bottom: 30px; +} + .checkbox-flex-label > div > input[type="checkbox"] { vertical-align: top; } .checkbox-flex-label { display: flex; - align-items: center; + align-items: flex-start; margin-bottom: 0; - justify-content: flex-end; + justify-content: center; } .btn-dropdown-toggle { @@ -45,6 +53,10 @@ padding: 0; } +.dropdown-menu > .active { + background-color: #e4ebf2; +} + .btn.btn-xs { font-size: 12px; @@ -66,6 +78,12 @@ outline: 0; } +.btn-blue:not(.active) { + color: white; + background-color: #246EB6; + border-color: #246EB6; +} + .no-gutters > div { padding:0; } @@ -78,7 +96,9 @@ svg:not(:root) { overflow: clip; } -.annotation.list-group-item { +.annotation.list-group-item, +.panel-event.list-group-item, +.panel-event-channel-based.list-group-item { position: relative; display: flex; flex-direction: column; @@ -88,24 +108,145 @@ svg:not(:root) { width: 100%; } -.annotation { - background: #fffae6; - border-left: 5px solid #8eecfa; +.annotation, +.panel-event { + background: #fff; + border-left: 5px solid #a6d5f2; } -.epoch-details { +.panel-event-channel-based { + background: #fff; + border-left: 5px solid #7ef1de; +} + +#right-panel-controls { + display: flex; + justify-content: flex-end; + padding-right: 5px; +} + +.list-group-item:hover { + background: #fff9d6; +} + +#event-name { + width: 100%; +} + +/* THIS REMOVED BORDERS */ +.form-control.form-edit:not([name="select-add-hed"]) { + background-color: unset !important; + border: none !important; + box-shadow: none !important; +} + +.event-label { + display: inline-block; + background-color: #eff1f2; + color: #1f2329; + font-weight: normal; +} + +.control-label { + margin: 0; +} + +.additional-columns-outer { display: flex; width: 100%; - padding: 10px 0; + margin-top: 8px; + align-items: center; } -.epoch-action { +.additional-columns-inner { + flex: 1; +} + +.code-mimic { + font-family: Menlo, Monaco, Consolas, "Courier New", monospace; + padding: 2px 4px; + font-size: 90%; + border-radius: 4px; +} + +.word-break-word { + word-break: break-word; +} + +.form-element-row { display: flex; - flex-direction: row; - justify-content: end; align-items: center; } +.select-tag-text, .select-placeholder-text { + font-style: italic; + color: #aaa; +} + +.select-tag-text > *, +.select-placeholder-text > * { + font-style: normal; + color: #555; +} + +.select-element-row > label { + flex-basis: 40%; +} + +.select-element-row > div { + flex: 1; +} + +.numeric-element-row { + margin: 8px 0; +} +.numeric-element-row > label { + flex-basis: 40%; +} + +.numeric-element-row > label::after { + font-weight: normal; + content: ' [s]'; +} + +.numeric-element-row > div { + flex-basis: 60%; +} + +.epoch-details { + display: flex; + width: calc(100% - 15px); + padding: 10px 0; +} + +.epoch-action { + display: grid; + grid-template-columns: 50% 50%; + row-gap: 5px; + align-content: center; + padding-right: 15px; + scale: 1.25; +} + +.epoch-action > .btn-xs { + padding: 0; + font-size: 13px; +} + +.epoch-details > .epoch-label { + flex-grow: 1; + padding-left: 15px; + padding-right: 10px; + margin-right: 5px; + text-overflow: ellipsis; + overflow: hidden; +} + +.epoch-action button { + width: 24px; + height: 22px; +} + .epoch-tag { padding: 10px; background: #e7e4e4; @@ -113,6 +254,11 @@ svg:not(:root) { width: 100%; } +.epoch-tag > div, +.epoch-tag > div > div { + margin: 5px 0; +} + .badge-pill { max-width: 100%; } @@ -140,15 +286,7 @@ svg:not(:root) { display: inline-block; } -.toggle-long-hed label > div { - padding-right: 10px !important; -} - -.toggle-long-hed input[type='checkbox'] { - vertical-align: top; -} - -.tag-hed-score { +.tag-hed-artifact { cursor: pointer; } @@ -161,8 +299,8 @@ svg:not(:root) { border-radius: 6px; position: absolute; z-index: 1; - top: -50px; - right: 105%; + top: 50px; + right: unset; width: 500px; max-height: 350px; overflow-y: scroll; @@ -179,12 +317,54 @@ svg:not(:root) { font-size: 16px; } +#dataset-hed-tooltip { + position: absolute; + width: 22.5%; + height: 52.5vh; + background-color: #555555; + color: whitesmoke; + padding: 10px; + border-radius: 6px; + text-align: left; + z-index: 5; +} + .tooltip-description { font-weight: normal; font-style: italic; + font-size: 12px; + margin-top: 15px; +} + +.tooltip-footer { + font-size: 12px; + color: black; + background: whitesmoke; + border-radius: 5px; + padding: 5px; +} + +.tooltip-footer-viewer { + position: absolute; + bottom: 20%; + width: 93%; +} + +.tooltip-footer-panel { + width: 50%; + margin: auto; +} + +.hed-badge-not-origin:after { + content: '※'; + vertical-align: top; + font-size: 11px; } .badge-hed-add { + /*color: #4b9b29 !important;*/ + /*border: solid 1px #4b9b29; + #246EB6*/ color: #246EB6 !important; border: solid 1px #246EB6; background-color: #fff !important; @@ -209,6 +389,14 @@ svg:not(:root) { border-left: #999 solid 1px; } +#select-schema-dropdown button { + padding: 2px !important; +} + +#hed-tag-input::-webkit-calendar-picker-indicator { + opacity: 100; +} + .selection-filter-tag { display: inline-block; margin: 2px 5px; @@ -220,15 +408,21 @@ svg:not(:root) { max-width: 100vw; } + .selection-filter-dataset-tag { color: #fff; background-color: #A9A9A9; } .dataset-tag-selected { + /*background-color: #4B9B29;*/ background-color: rgb(24, 99, 0); } +#tag-modal-container > button { + z-index: 9; +} + .filter-tag-name { white-space: nowrap; text-overflow: ellipsis; @@ -237,12 +431,193 @@ svg:not(:root) { max-width: 12vw; } -.dataset-tag-dirty { - opacity: 0.6; +#filter-dropdown > .dropdown-menu, +#filter-dropdown > .dropdown-menu > li > .dropdown-menu +{ + min-width: unset; + width: max-content; } -.tag-modal-container-dirty:before { - content: '*'; +#filter-dropdown > .dropdown-menu > li > i { + width: 14px; +} + +#filter-dropdown > button > i { + width: 12px; +} + +.filter-ban-circle::after { + content: "\e090"; + position: relative; + right: 100%; + color: black; + opacity: 0.5; + font-size: 13px; +} + +input[type="search"]#hed-search, input[type="search"]#label-search { + -webkit-appearance: searchfield; +} + +input[type="search"]#hed-search::-webkit-search-cancel-button, +input[type="search"]#label-search::-webkit-search-cancel-button { + -webkit-appearance: searchfield-cancel-button; +} + +label[for=toggle-ignore_na], label[for=toggle-invert-search] { + font-weight: normal; + font-size: 12px; +} + +.label-ellipsis { + text-overflow: ellipsis; + overflow: hidden; + padding-left: 5px; + width: 64px; + font-style: italic; +} + +@media only screen and (min-width : 1441px) { + .label-ellipsis { + width: 80px; + } +} + +@media only screen and (min-width : 1550px) { + .label-ellipsis { + width: 105px; + } +} + +@media only screen and (min-width : 1660px) { + .label-ellipsis { + width: 8vw; + } +} + +li.hed-endorsement { + display: block; + cursor: default; +} + +li.hed-endorsement::before { + content: '\e034'; + font-family: 'Glyphicons Halflings'; + font-size: 11px; + float: left; + margin-top: 3px; + margin-left: -20px; + font-style: normal; +} + +option.hed-endorsed::after { + content: '\e034'; + font-family: 'Glyphicons Halflings'; + font-size: 12px; + float: right; + margin-top: 3px; + margin-right: 10px; + color: green; + font-style: normal; +} + +li.hed-endorsed::before { + color: green; +} + +li.hed-caveat::before { + color: red; +} + +li.hed-comment::before { + content: '\e111'; + color: #256eb6; +} + +.hed-endorsement-list:hover, .hed-endorsement-list-selected { + background-color: #fff9d6; +} + +.hed-endorsement-radio-label { + padding: 0 4px; + font-weight: normal; +} + +.caret-right { + border-bottom: 4px solid transparent; + border-top: 4px solid transparent; + border-left: 4px solid; + float: right; + position: relative; + top: 7px; +} + +.tagged-by-panel, .additional-columns-panel { + max-width: calc(100% - 5px); + margin: 5px 0; +} + +.tagged-by-panel > .panel-heading > .panel-title, +.dataset-tagged-by-panel > .panel-heading > .panel-title, +.additional-columns-panel > .panel-heading > .panel-title { + font-size: 14px; + font-weight: normal; +} + +.tagged-by-panel > .panel-collapse { + background-color: #fff; +} + +.list-group-item:hover .tagged-by-panel > .panel-collapse { + background-color: #fff9d6; +} + +.dataset-tagged-by-panel { + width: 100%; +} + +.dataset-tagged-by-panel > .panel-collapse, +.additional-columns-panel > .panel-collapse, +.tagged-by-panel.tagged-by-active > .panel-collapse { + background-color: white; +} + +.hed-endorsement-tooltip { + padding: 1px 6px; + border-radius: 5px; + font-weight: normal; + font-size: 12px; + color: #034785; + background-color: #E4EBF2; + border: 1px solid #034785; + display: inline-block; + position: absolute; + z-index: 4; + word-break: normal; + margin-left: 5px; + width: max-content; +} + +.hed-endorsement-tooltip-line { + display: inline-block; + position: absolute; + background-color: #034785; + margin: 0; +} + +.glyphicon-endorsement-panel { + color: #256eb6; + padding: 0 2px; + float: right; +} + +.glyphicon-greyed { + color: grey !important; + cursor: not-allowed !important; +} + +.dataset-tag-dirty { + opacity: 0.6; } #select_column { @@ -266,17 +641,36 @@ svg:not(:root) { margin-bottom: 15px; } -#tag-modal-container > button { +#tag-modal-container > div > button { position: unset; width: unset; } - -#tag-modal-container > div > div { +#tag-modal-container > div > div > div { width: 75vw !important; height: 75vh; } +#tag-modal-container > div > div > div > div:last-child { + display: none; +} + +.cursor-pointer { + cursor: pointer; +} + +.cursor-grab { + cursor: grab; +} + +.cursor-grabbing { + cursor: grabbing; +} + +.cursor-default { + cursor: default; +} + .line-height-14 { line-height: 14px; } @@ -285,8 +679,32 @@ svg:not(:root) { margin-top: 10px; } +.flex-basis-30 { + flex-basis: 30%; +} + +.flex-basis-40 { + flex-basis: 40%; +} + .flex-basis-45 { - flex-basis: 45% + flex-basis: 45%; +} + +.flex-basis-50 { + flex-basis: 50%; +} + +.width-100 { + width: 100%; +} + +.scale-1_5 { + scale: 1.5; +} + +.float-right { + float: right; } .event-list .btn.btn-primary { @@ -306,6 +724,15 @@ svg:not(:root) { border: 1px solid #333; } +.event-panel-message { + padding: 10px 5px; + background: rgba(238, 238, 238, 0.8); + text-align: center; + position: sticky; + top: 0; + z-index: 1; +} + .input-interval-bound { width: 60px; height: 22px; @@ -342,15 +769,62 @@ svg:not(:root) { border-bottom: none; } -.electrode:hover circle { - stroke: #064785; - cursor: pointer; - fill: #E4EBF2; +#channel-selector-montage > div { + padding-top: 3% !important; } -.electrode:hover text { - fill: #064785; - cursor: pointer; +#channel-selector-montage svg { + margin-top: 5vh; +} + +#channel-selector-montage > div > div > div:nth-child(2) { + max-height: 80vh !important; +} + +#channel-selector-montage > div > div > div:last-child { + display: none !important; /* Disappear modal footer */ +} + +.epoch-action .glyphicon { + padding-top: 2px !important; +} + +.glyph-option { + /* background: url('/images/noList.svg') 0 center no-repeat;*/ + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; +} + +.panel-heading .glyph-option { + background-color: #064785; + margin-right: 0.5em; +} + +.epoch-action .glyph-option { + background-color: #555; + padding: 0.6em; +} + +.glyphicon-list-show { + -webkit-mask-image: url(/images/list.svg); + mask-image: url(/images/list.svg); +} + +.glyphicon-list-hide { + -webkit-mask-image: url(/images/noList.svg); + mask-image: url(/images/noList.svg); +} + +.glyphicon-option-show { + -webkit-mask-image: url(/images/option.svg); + mask-image: url(/images/option.svg); +} + +.glyphicon-option-hide { + -webkit-mask-image: url(/images/noOption.svg); + mask-image: url(/images/noOption.svg); } #eegSessionView .table-scroll { @@ -387,8 +861,44 @@ svg:not(:root) { width: auto; } -.cursor-default { - cursor: default; +.close-tab { + background-color: rgb(228, 235, 242); + border: 1px solid #C3D5DB !important; + border-bottom-color: rgb(228, 235, 242) !important; + font-weight: normal; + font-size: 20px !important; + border-radius: 4px 4px 0 0; + align-content: center; + text-align: center; + flex-grow: 1; + padding: 5px 10px !important; + margin-right: 0 !important; +} + +.event-tab { + flex: 1; + margin-right: 3px; +} + +.close-tab:hover { + color: #246EB6; + text-decoration: none; +} + +.nav-tabs > li > a { + line-height: 0.8; + font-size: 14px; + align-content: center; + height: 42px; +} + +.nav-tabs > li { + margin-bottom: -1px !important; +} + +.nav-tabs > li:not(:last-child) > a { + font-size: 14px; + margin-right: 3px; } /* Custom, iPhone Retina */ @@ -417,7 +927,7 @@ svg:not(:root) { margin-left: 150px; } - #tag-modal-container > button { + #tag-modal-container > div > button { position: fixed; left: 15px; bottom: 20px; @@ -426,12 +936,33 @@ svg:not(:root) { } } +/* Medium Devices, Desktops */ +@media only screen and (max-width : 991px) { + .row:has(#right-panel-controls) { + width: 95% !important; + } +} + /* Medium Devices, Desktops */ @media only screen and (min-width : 992px) { .event-list { - margin-top: 40px; margin-bottom: 0; } + + .badge-hed-tooltip { + top: -50px !important; + right: 105% !important; + } + + .nav-tabs > li:not(:last-child) > a { + font-size: 9px !important; + } +} + +@media only screen and (min-width : 1080px) { + .nav-tabs > li:not(:last-child) > a { + font-size: 11px !important; + } } /* Large Devices, Wide Screens */ @@ -443,4 +974,15 @@ svg:not(:root) { .pagination-nav { padding-top: 0; } + + .nav-tabs > li:not(:last-child) > a { + font-size: 12px !important; + } +} + +/* Extra Large Devices, Extra Wide Screens */ +@media only screen and (min-width : 1400px) { + .nav-tabs > li:not(:last-child) > a { + font-size: 14px !important; + } } diff --git a/modules/electrophysiology_browser/css/electrophysiology_browser_index.css b/modules/electrophysiology_browser/css/electrophysiology_browser_index.css new file mode 100644 index 00000000000..7df1d5415a4 --- /dev/null +++ b/modules/electrophysiology_browser/css/electrophysiology_browser_index.css @@ -0,0 +1,44 @@ +.browser-index-css-tooltip { + position: relative; +} + +.browser-index-css-tooltip .browser-index-tooltip-text { + width: 220px; + bottom: 140%; + left: 50%; + margin-left: -110px; + margin-top: 8px; + visibility: hidden; + background-color: #E4EBF2; + color: #064785; + text-align: center; + padding: 5px; + position: absolute; + font-size: 12px; + border-radius: 4px; + font-weight: normal; + border: 1px solid #C3D5DB; + + opacity: 0; + /*transition: opacity 0.5s;*/ +} + +.browser-index-css-tooltip:hover .browser-index-tooltip-text { + visibility: visible; + opacity: 1; +} + +.browser-index-css-tooltip .browser-index-tooltip-text::after { + content: " "; + position: absolute; + top: 100%; + left: 50%; + margin-left: -5px; + border-width: 5px; + border-style: solid; + border-color: #C3D5DB transparent transparent transparent; +} + +.dynamicContentWrapper.table-scroll { + overflow-x: unset !important; +} diff --git a/modules/electrophysiology_browser/help/sessions.md b/modules/electrophysiology_browser/help/sessions.md index 7aa591e5f2d..868f82a89bf 100644 --- a/modules/electrophysiology_browser/help/sessions.md +++ b/modules/electrophysiology_browser/help/sessions.md @@ -12,3 +12,39 @@ Files can be downloaded containing only the recording signal, the events, or oth - Channels info (tsv): channel status and filter settings. - Events (tsv): events (both stimuli and responses) recorded during the session. +___ + +#### Keyboard Shortcuts Legend + +##### ***When cursor is inside signal plot:*** + +(↑, ←): Arrow keys + +← / → : Go backwards/forwards by the value of the time interval +↑ / ↓ : Page channels up/down by number of displayed channels +Use the Shift key ⇧ in order to : +(⇧ +) **+** : Zoom in +(⇧ +) **–** : Zoom out +(⇧ +) **Z**: Zoom to selected time region +(⇧ +) **X**: Reset zoom interval to default +(⇧ +) **M**: Increase amplitude +(⇧ +) **N**: Decrease amplitude +(⇧ +) **E**: Open 'Event Panel' +(⇧ +) **A**: Open 'Add Event' panel +(⇧ +) **H**: Open 'HED Endorsement' panel +(⇧ +) **C**: Close open panel +(⇧ +) **V**: Show values at cursor (on/off) +(⇧ +) **B**: Plot all signals on same axis ('Stacked' on/off) +(⇧ +) **S**: 'Isolate' mode - Show one channel at a time (in 'Stacked' view) by hovering on channel name + +##### ***HED Endorsement Panel shortcuts*** + +(⇧ +) ↑ / ↓ : Select previous/next HED tag +(⇧ +) ← / → : Select and jump to previous/next HED tag +(⇧ +) **I**: Scroll to current selection +(⇧ +) **J**: Jump to current selection +(⇧ +) **K**: Edit current selection's event +(^ +) **E** : Select 'Endorse' action +(^ +) **C** : Select 'Caveat' action +(^ +) **M** : Select 'Comment' action +(^ +) **Enter** : Submit selected action diff --git a/modules/electrophysiology_browser/jsx/electrophysiologyBrowserIndex.js b/modules/electrophysiology_browser/jsx/electrophysiologyBrowserIndex.js index 73bdaeae8d8..a7c881931a2 100644 --- a/modules/electrophysiology_browser/jsx/electrophysiologyBrowserIndex.js +++ b/modules/electrophysiology_browser/jsx/electrophysiologyBrowserIndex.js @@ -7,6 +7,7 @@ import {withTranslation} from 'react-i18next'; import Loader from 'Loader'; import FilterableDataTable from 'FilterableDataTable'; +import {HasHEDIcon} from "./react-series-data-viewer/src/series/components/components"; /** * Electrophysiology Browser page. @@ -147,6 +148,17 @@ class ElectrophysiologyBrowserIndex extends Component { name: 'visitLabel', type: 'text', }}, + {label: 'Has HED Tags', show: true, filter: { + name: 'HasHEDTags', + type: 'select', + hide: false, + options: { + 'yes': 'Yes', + 'no': 'No', + }, + }, + custom_label: + }, {label: 'Acquisition Time', show: true}, {label: 'Insertion Time', show: true}, {label: 'Links', show: true}, diff --git a/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js b/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js index 59785e3f58d..dc14a5cac36 100644 --- a/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js +++ b/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js @@ -18,7 +18,7 @@ import {SummaryPanel} from './components/electrophysiology_session_summary'; import {DownloadPanel} from './components/DownloadPanel'; import Sidebar from './components/Sidebar'; import SidebarContent from './components/SidebarContent'; - +import {HasHEDIcon} from "./react-series-data-viewer/src/series/components/components"; let EEGLabSeriesProvider; let SeriesRenderer; let EEGMontage; @@ -241,6 +241,12 @@ class ElectrophysiologySessionView extends Component { datasetTags: dbEntry && dbEntry.file.datasetTags, + datasetTagEndorsements: + dbEntry + && dbEntry.file.datasetTagEndorsements, + eegMontage: + dbEntry + && dbEntry.file.eegMontage, })); this.setState({ @@ -342,8 +348,10 @@ class ElectrophysiologySessionView extends Component { events, hedSchema, datasetTags, + datasetTagEndorsements, electrodesURL, coordSystemURL, + eegMontage, } = this.state.database[i]; const file = this.state.database[i].file; const splitPagination = []; @@ -359,6 +367,27 @@ class ElectrophysiologySessionView extends Component { >{j+1} ); } + const recordingHasHED = events.hed_tags.length > 0 || + Object.keys(datasetTags).some((column) => { + return Object.keys(datasetTags[column]).filter((columnValue) => { + return datasetTags[column][columnValue].length > 0; + }).some((columnValue) => { + if (column === 'trial_type') { + return events.instances.some((event) => { + return event['TrialType'] === columnValue; + }); + } else if (column === 'value') { + return events.instances.some((event) => { + return event['EventValue'] === columnValue; + }); + } + + return events.extra_columns.some((prop) => { + return prop.PropertyName === column && + prop.PropertyValue === columnValue; + }); + }) + }); database.push(
{ applyMiddleware(thunk, epicMiddleware) ); + this.state = { + activeMenuOption: 'TAG_MODE', + datasetTaggerTabsRef: createRef(), + } + epicMiddleware.run(rootEpic); const { @@ -74,41 +85,55 @@ class EEGLabSeriesProvider extends Component { coordSystemURL, hedSchema, datasetTags, + datasetTagEndorsements, events, physioFileID, limit, samplingFrequency, + eegMontageName, + recordingHasHED, } = props; - if (!window.EEGLabSeriesProviderStore) { + + if (!window.EEGLabSeriesProviderStore) window.EEGLabSeriesProviderStore = []; - } window.EEGLabSeriesProviderStore[chunksURL] = this.store; - /** - * - * @returns {void} - Confirmation dialog to prevent accidental page leave - */ + window.onbeforeunload = function() { - const dataset = - window.EEGLabSeriesProviderStore[chunksURL].getState().dataset; + const dataset = window.EEGLabSeriesProviderStore[chunksURL].getState().dataset; if ([...dataset.addedTags, ...dataset.deletedTags].length > 0) { return 'Are you sure you want to leave unsaved changes behind?'; } - }; + } 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), - }; - }); + formattedDatasetTags[column][value] = datasetTags[column][value].map((tag) => { + const hedEndorsements = datasetTagEndorsements + .filter((edorsement) => { + return edorsement.HEDRelID === tag.ID; + }).map((edorsement) => { + const endorserName = + `${edorsement.FirstName.substring(0, 1)}.${edorsement.LastName}`; + return { + EndorsedBy: endorserName, + EndorsedByID: edorsement.EndorsedByID, + EndorsementComment: edorsement.EndorsementComment, + EndorsementStatus: edorsement.EndorsementStatus, + EndorsementTime: edorsement.LastUpdate, + } + }); + return { + ...tag, + AdditionalMembers: parseInt(tag.AdditionalMembers), + TaggerName: tag.TaggerName === 'Origin' ? 'Data Authors' : tag.TaggerName, + TaggedBy: tag.TaggedBy, + Endorsements: hedEndorsements, + } + }); }); - }); - + }) this.store.dispatch(setPhysioFileID(physioFileID)); this.store.dispatch(setHedSchemaDocument(hedSchema)); this.store.dispatch(setDatasetTags(formattedDatasetTags)); @@ -127,23 +152,19 @@ class EEGLabSeriesProvider extends Component { // if request fails don't resolve .catch((error) => { console.error(error); - return new Promise(null); + return new Promise((resolve) => { + }); })]; } else { - return [new Promise(null)]; + 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, @@ -154,6 +175,8 @@ class EEGLabSeriesProvider extends Component { seriesRange, limit, samplingFrequency, + eegMontageName, + recordingHasHED, }) ); this.store.dispatch(setChannels(emptyChannels( @@ -166,29 +189,42 @@ class EEGLabSeriesProvider extends Component { } ).then(() => { const epochs = []; + const channelDelimiter = events.channel_delimiter.length > 0 + ? events.channel_delimiter + : DEFAULT_CHANNEL_DELIMITER; + this.store.dispatch(setDatasetMetadata({ channelDelimiter: channelDelimiter })); + events.instances.map((instance) => { - const epochIndex = - epochs.findIndex( - (e) => e.physiologicalTaskEventID - === instance.PhysiologicalTaskEventID - ); + const epochIndex = epochs.findIndex((e) => e.physiologicalTaskEventID === instance.PhysiologicalTaskEventID); - const extraColumns = Array.from( - events.extraColumns - ).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.hedTags).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); + const hedEndorsements = events.hed_endorsements + .filter((endorsement) => { + return endorsement.HEDRelID === hedTag.ID; + }).map((endorsement) => { + const endorserName = + `${endorsement.FirstName.substring(0, 1)}.${endorsement.LastName}`; + return { + EndorsedBy: endorserName, + EndorsedByID: endorsement.EndorsedByID, + EndorsementComment: endorsement.EndorsementComment, + EndorsementStatus: endorsement.EndorsementStatus, + EndorsementTime: endorsement.LastUpdate, + } + }); + // Currently only supporting schema-defined HED tags return { schemaElement: foundTag ?? null, @@ -201,7 +237,10 @@ class EEGLabSeriesProvider extends Component { HasPairing: hedTag.HasPairing, PairRelID: hedTag.PairRelID, AdditionalMembers: isNaN(additionalMembers) ? 0 : additionalMembers, - }; + TaggerName: hedTag.TaggerName === 'Origin' ? 'Data Authors' : hedTag.TaggerName, + TaggedBy: hedTag.TaggedBy, + Endorsements: hedEndorsements, + } }); if (epochIndex === -1) { @@ -213,11 +252,15 @@ class EEGLabSeriesProvider extends Component { duration: parseFloat(instance.Duration), type: 'Event', label: epochLabel ?? instance.EventValue, - value: instance.EventValue, - trialType: instance.TrialType, + value: instance.EventValue, + trial_type: instance.TrialType, properties: extraColumns, hed: hedTags, - channels: 'all', + channels: instance.Channel === 'n/a' + ? [] + : channelDelimiter.length > 0 + ? instance.Channel.split(channelDelimiter) + : [instance.Channel], physiologicalTaskEventID: instance.PhysiologicalTaskEventID, }); } else { @@ -228,7 +271,7 @@ class EEGLabSeriesProvider extends Component { }).then((epochs) => { const sortedEpochs = epochs .flat() - .sort(function(a, b) { + .sort(function (a, b) { return a.onset - b.onset; }); @@ -237,15 +280,16 @@ class EEGLabSeriesProvider extends Component { this.store.dispatch(setFilteredEpochs({ plotVisibility: sortedEpochs.reduce((indices, epoch, index) => { if (!(epoch.onset < 1 && epoch.duration >= timeInterval[1])) { - // Full-recording events not visible by default - indices.push(index); + indices.push(index); // Full-recording events not visible by default } return indices; }, []), columnVisibility: [], + searchVisibility: [], })); }); + Promise.race(racers(fetchText, electrodesURL)) .then((text) => { if (!(typeof text.json === 'string' @@ -267,16 +311,12 @@ class EEGLabSeriesProvider extends Component { Promise.race(racers(fetchJSON, coordSystemURL)) .then( ({json, _}) => { if (json) { - const { - EEGCoordinateSystem, - EEGCoordinateUnits, - EEGCoordinateSystemDescription, - } = json; + const {EEGCoordinateSystem, EEGCoordinateUnits, EEGCoordinateSystemDescription} = json; this.store.dispatch( setCoordinateSystem({ - name: EEGCoordinateSystem ?? 'Other', + name: EEGCoordinateSystem ?? 'Other', units: EEGCoordinateUnits ?? 'm', - description: EEGCoordinateSystemDescription ?? 'n/a', + description: EEGCoordinateSystemDescription ?? 'n/a' }) ); } @@ -294,42 +334,98 @@ class EEGLabSeriesProvider extends Component { render() { const [signalViewer, ...rest] = React.Children.toArray(this.props.children); + const hedTagLogo = ( + + ); + return (
-
- - - - + <> +
+
+ + {hedTagLogo} + + Dataset Tag Manager - + +
+
+ More about HED + +
-
- More about HED - +
+
-
+ } label="Open Dataset Tag Manager" > - + { + this.setState({ activeMenuOption: menuOption }) + }} + filenamePrefix={this.props.chunksURL[0] + .split('/').at(-1) // filename + .split('_').slice(0, -1) // prefix + .join('_') + } + />
{signalViewer} 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 ca7d93991d0..c7faab47874 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 @@ -1,10 +1,5 @@ import React, {useEffect, useState} from 'react'; -import { - Epoch as EpochType, - RightPanel, - HEDTag, - HEDSchemaElement, -} from '../store/types'; +import {ChannelMetadata, Epoch as EpochType, HEDSchemaElement, HEDTag, RightPanel,} from '../store/types'; import {connect} from 'react-redux'; import {setTimeSelection} from '../store/state/timeSelection'; import {setRightPanel} from '../store/state/rightPanel'; @@ -16,9 +11,12 @@ import { updateActiveEpoch } from '../store/logic/filterEpochs'; import {RootState} from '../store'; -import {setActiveEpoch, setEpochs} from '../store/state/dataset'; +import {setEpochs} from '../store/state/dataset'; import {setCurrentAnnotation} from '../store/state/currentAnnotation'; import {NumericElement, SelectElement, TextboxElement} from './Form'; +import Panel from 'jsx/Panel'; +import Modal from 'jsx/Modal'; +import EEGMontage from "./EEGMontage"; import swal from 'sweetalert2'; import {InfoIcon} from "./components"; import {colorOrder} from "../../color"; @@ -27,6 +25,7 @@ import {colorOrder} from "../../color"; type CProps = { timeSelection?: [number, number], epochs: EpochType[], + domain: [number, number], filteredEpochs: number[], setTimeSelection: (_: [number, number]) => void, setRightPanel: (_: RightPanel) => void, @@ -39,6 +38,12 @@ type CProps = { interval: [number, number], hedSchema: HEDSchemaElement[], datasetTags: any, + channelDelimiter: string, + channelMetadata: ChannelMetadata[], + panelIsDirty: boolean, + setPanelIsDirty: (_: boolean) => void, + eventChannels: string[], + setEventChannels: (_: string[] ) => void, }; /** @@ -46,6 +51,7 @@ type CProps = { * @param root0 * @param root0.timeSelection * @param root0.epochs + * @param root0.domain * @param root0.setTimeSelection * @param root0.setRightPanel * @param root0.setEpochs @@ -57,10 +63,17 @@ type CProps = { * @param root0.interval * @param root0.hedSchema * @param root0.datasetTags + * @param root0.channelDelimiter + * @param root0.channelMetadata + * @param root0.panelIsDirty + * @param root0.setPanelIsDirty + * @param root0.eventChannels + * @param root0.setEventChannels */ const AnnotationForm = ({ timeSelection, epochs, + domain, setTimeSelection, setRightPanel, setEpochs, @@ -72,45 +85,110 @@ const AnnotationForm = ({ interval, hedSchema, datasetTags, + channelDelimiter, + channelMetadata, + panelIsDirty, + setPanelIsDirty, + eventChannels, + setEventChannels, }: CProps) => { - const [startEvent = '', endEvent = ''] = timeSelection || []; - const [event, setEvent] = useState<(number | string)[]>( - [ - startEvent, - endEvent, - ] + const [eventInterval, setEventInterval] = useState<(number | string)[]>( + timeSelection ?? ['', ''] ); const [label, setLabel] = useState( currentAnnotation ? - currentAnnotation.label : - null + currentAnnotation.label : + null ); + const [eventProperties, setEventProperties] = useState({}); const [isSubmitted, setIsSubmitted] = useState(false); const [isDeleted, setIsDeleted] = useState(false); const [annoMessage, setAnnoMessage] = useState(''); const [newTags, setNewTags] = useState([]); const [deletedTagIDs, setDeletedTagIDs] = useState([]); + const [throwChannelEditWarning, setThrowChannelEditWarning] = useState(false); + const [channelSelectorVisible, setChannelSelectorVisible] = useState(false); + + useEffect(() => { + setEventProperties( + Object.keys(datasetTags) + .filter(column => column !== 'trial_type') + .reduce((properties, column) => { + const property = currentAnnotation?.properties.find(prop => prop.PropertyName === column); + properties[column] = property ? property.PropertyValue : ''; + return properties; + }, {}) + ); + }, []); + + // Time Selection useEffect(() => { - const [startEvent = '', endEvent = ''] = timeSelection || []; - setEvent([startEvent, endEvent]); + if (!currentAnnotation) { + // Only if being created. Time edit not currently allowed + const [startEvent, endEvent] = timeSelection ?? [0, 0]; + setEventInterval([startEvent, endEvent]); + } }, [timeSelection]); + useEffect(() => { + console.log('ue trigger'); + setPanelIsDirty( + (deletedTagIDs.length > 0 || + (newTags.length > 0 && newTags.find((tag) => tag.value !== '')) || + JSON.stringify(eventChannels) !== + JSON.stringify(currentAnnotation ? currentAnnotation.channels : []) + ) || + (!currentAnnotation && ( + (label && label.length > 0) || + Object.values(eventProperties).some(((prop: string) => { + return prop.length > 0; + })) + )) + ) + }, [label, eventProperties, deletedTagIDs, newTags, eventChannels, currentAnnotation?.channels]); + + const validateTimeRange = (timeRange) => { + return (timeRange[0] || timeRange[0] === 0) + && (timeRange[1] || timeRange[1] === 0) + && !( + timeRange[0] === 0 && + timeRange[1] === 0 + ); + } + + // Initiate on load + useEffect(() => { + setEventChannels(currentAnnotation ? currentAnnotation.channels : []); + }, []); + + /** * * @param event */ const validate = (event) => { - return (event[0] || event[0] === 0) - && (event[1] || event[1] === 0) - && event[0] <= event[1] - && ( - newTags.some((tag) => tag.value !== '') || - deletedTagIDs.length > 0 + return validateTimeRange(event) && + event[0] <= event[1] && ( + ( + currentAnnotation && ( + newTags.some((tag) => tag.value !== '') || + deletedTagIDs.length > 0 || + JSON.stringify(eventChannels) !== JSON.stringify(currentAnnotation?.channels) + ) + ) || ( + !currentAnnotation && ( + (label && label.length > 0) || + Object.values(eventProperties).some(((prop: string) => { + return prop.length > 0; + })) || + newTags.some((tag) => tag.value !== '') + ) + ) ); - }; + } /** * @@ -118,19 +196,20 @@ const AnnotationForm = ({ * @param val */ const handleStartTimeChange = (id, val) => { - const value = parseFloat(val); - setEvent([value, event[1]]); + const value = Math.min(Math.max(parseFloat(val), domain[0]), domain[1]); + setEventInterval([value, eventInterval[1]]); - if (validate([value, event[1]])) { - let endTime = event[1]; + if (validateTimeRange([value, eventInterval[1]])) { + let endTime = eventInterval[1]; if (typeof endTime === 'string') { endTime = parseFloat(endTime); } + setTimeSelection( [ - value || null, - endTime || null, + value, + endTime, ] ); } @@ -142,24 +221,50 @@ const AnnotationForm = ({ * @param val */ const handleEndTimeChange = (name, val) => { - const value = parseFloat(val); - setEvent([event[0], value]); + const value = Math.min(Math.max(parseFloat(val), domain[0]), domain[1]); + setEventInterval([eventInterval[0], value]); - if (validate([event[0], value])) { - let startTime = event[0]; + if (validateTimeRange([eventInterval[0], value])) { + let startTime = eventInterval[0]; if (typeof startTime === 'string') { startTime = parseFloat(startTime); } setTimeSelection( [ - startTime || null, + startTime, value, ] ); } }; + /** + * + * @param name + * @param val + */ + const handleDurationChange = (name, val) => { + const value = Math.min(Math.max(parseFloat(val), domain[0]), domain[1]); + const endTime = parseFloat(eventInterval[0].toString()) + value; + setEventInterval([eventInterval[0], endTime]); + + if (validateTimeRange([eventInterval[0], endTime])) { + let startTime = eventInterval[0]; + + if (typeof startTime === 'string') { + startTime = parseFloat(startTime); + } + setTimeSelection( + [ + startTime, + endTime, + ] + ); + } + }; + + /** * */ @@ -174,11 +279,11 @@ const AnnotationForm = ({ }, 2000); } else { setNewTags([ - ...newTags, { type: tagType, value: '', - } + }, + ...newTags, ]); } }; @@ -236,15 +341,36 @@ const AnnotationForm = ({ * */ const handleReset = () => { + if (!currentAnnotation) { + // Clear all fields + setLabel(''); + setTimeSelection([0, 0]); + setEventInterval([0, 0]); + setEventProperties( + Object.keys(eventProperties) + .reduce((props, prop) => { + console.log('prop', prop); + return { + ...props, + [prop]: '', + }; + }, {}) + ); + } setNewTags([]); setDeletedTagIDs([]); + + setEventChannels(currentAnnotation + ? currentAnnotation.channels + : [] + ); }; /** * */ const handleDelete = () => { - setIsDeleted(true); + // setIsDeleted(true); }; // Submit @@ -255,7 +381,7 @@ const AnnotationForm = ({ } // Validate inputs - if (!label || !event[0] || !event[1]) { + if (!label || !validateTimeRange(eventInterval)) { swal.fire( 'Warning', 'Please fill out all required fields', @@ -275,7 +401,11 @@ const AnnotationForm = ({ return node.id; }); - const currentTagIDs = (currentAnnotation.hed ?? []).map((tag) => { + const currentTagIDs = ( + currentAnnotation + ? currentAnnotation.hed ?? [] + : [] + ).map((tag) => { return tag.ID; }).filter(currentTagID => { return !deletedTagIDs.includes(currentTagID) @@ -301,15 +431,21 @@ const AnnotationForm = ({ '/electrophysiology_browser/events/'; // get duration of event - let startTime = event[0]; - let endTime = event[1]; + let startTime = eventInterval[0]; + let endTime = eventInterval[1]; if (typeof startTime === 'string') { startTime = parseFloat(startTime); } if (typeof endTime === 'string') { endTime = parseFloat(endTime); } - const duration = endTime - startTime; + const duration = currentAnnotation // Edit not currently allowed + ? currentAnnotation.duration + : Math.abs(endTime - startTime); + + const onset = currentAnnotation // Edit not currently allowed + ? currentAnnotation.onset + : Math.min(startTime, endTime); // set body // instance_id = null for new events @@ -320,13 +456,24 @@ const AnnotationForm = ({ currentAnnotation.physiologicalTaskEventID : null, instance: { - onset: startTime, + onset: onset, duration: duration, label_name: label, label_description: label, - channels: 'all', + channels: eventChannels.length > 0 + ? eventChannels + : ['n/a'], added_hed: newTagIDs, deleted_hed: deletedTagIDs, + event_type: 'trial_type', + properties: Object.keys(eventProperties) + .reduce((properties, propertyName) => { + properties[propertyName] = + eventProperties[propertyName].length > 0 + ? eventProperties[propertyName] + : 'n/a'; + return properties; + }, {}), }, }; @@ -339,25 +486,17 @@ const AnnotationForm = ({ return response.json(); } throw (response); - }).then((response) => { setIsSubmitted(false); - // 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; - // TODO: Properly handle new event - const hedTags = Array.from(data.hedTags).map((hedTag : HEDTag) => { + // TODO: Properly handle new event -- below line strange + 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, @@ -370,43 +509,68 @@ const AnnotationForm = ({ HasPairing: hedTag.HasPairing, PairRelID: hedTag.PairRelID, AdditionalMembers: hedTag.AdditionalMembers, + TaggedBy: hedTag.TaggedBy, + TaggerName: hedTag.TaggerName === 'Origin' + ? 'Data Authors' + : hedTag.TaggerName, + Endorsements: data.hed_endorsements + .filter((endorsement) => { + return endorsement.HEDRelID === hedTag.ID; + }), } }); const epochLabel = [null, 'n/a'].includes(data.instance.TrialType) - ? null - : data.instance.TrialType; + ? null + : data.instance.TrialType; + const newAnnotation : EpochType = { onset: parseFloat(data.instance.Onset), duration: parseFloat(data.instance.Duration), type: 'Event', label: epochLabel ?? data.instance.EventValue, value: data.instance.EventValue, - trialType: data.instance.TrialType, - properties: data.extraColumns, + trial_type: data.instance.TrialType, + properties: data.extra_columns, hed: hedTags, - channels: 'all', + channels: data.instance.Channel === 'n/a' + ? [] + : data.instance.Channel.split(channelDelimiter), physiologicalTaskEventID: data.instance.PhysiologicalTaskEventID, }; - epochs.push(newAnnotation); - setEpochs( - epochs - .sort(function(a, b) { - return a.onset - b.onset; - }) - ); - // Reset Form - handleReset(); - setCurrentAnnotation(newAnnotation); + // Maintain index + if (currentAnnotation !== null) { + const eventIndex = epochs.indexOf(currentAnnotation); + setEpochs([ + ...epochs.slice(0, eventIndex), + newAnnotation, + ...epochs.slice(eventIndex + 1), + ]); + } else { + epochs.push(newAnnotation); + setEpochs( + epochs + .sort(function(a, b) { + return a.onset - b.onset; + }) + ); + } // Display success message setAnnoMessage(currentAnnotation ? 'Event Updated!' : 'Event Added!'); + setCurrentAnnotation(newAnnotation); + + // handleReset(); + setNewTags([]); + setDeletedTagIDs([]); + setTimeout(() => { setAnnoMessage(''); // Empty string will cause success div to hide + updateActiveEpoch(epochs.indexOf(currentAnnotation ? currentAnnotation : newAnnotation)); }, 2000); }).catch((error) => { console.error(error); @@ -431,7 +595,7 @@ const AnnotationForm = ({ useEffect(() => { if (isDeleted) { const url = window.location.origin - + '/electrophysiology_browser/events/'; + + '/electrophysiology_browser/events/'; const body = { physioFileID: physioFileID, instance_id: currentAnnotation ? @@ -505,14 +669,14 @@ const AnnotationForm = ({ } const addHedTagOptions = [ - { - type: 'SCORE', - value: 'SCORE Artifacts', - }, { type: 'DATASET', value: 'Tags in current dataset', }, + { + type: 'ARTIFACTS', + value: 'HED 8.3.0 Artifacts', + }, ] const buildPropertyOptions = (optgroup: string, parentHED: string) => { @@ -524,7 +688,7 @@ const AnnotationForm = ({ label: tag.name, longName: tag.longName, value: tag.id, - optgroup: optgroup, + optgroup: `${optgroup} [${tag.schemaName}]`, Description: tag.description, } }).sort((tagA, tagB) => { @@ -535,11 +699,11 @@ const AnnotationForm = ({ const artifactTagOptions = [ ...buildPropertyOptions( 'Biological-artifact', - 'Artifact/Biological-artifact/' + 'Property/Data-property/Data-artifact/Biological-artifact/' ), ...buildPropertyOptions( - 'Non-biological-artifact', - 'Artifact/Non-biological-artifact/' + 'Nonbiological-artifact', + 'Property/Data-property/Data-artifact/Nonbiological-artifact/' ), ]; @@ -561,7 +725,9 @@ const AnnotationForm = ({ label: schemaElement.name, longName: schemaElement.longName, value: schemaElement.id, - optgroup: optGroup.length > 0 ? optGroup : schemaElement.name, + optgroup: optGroup.length > 0 + ? `${optGroup} [${schemaElement.schemaName}]` + : schemaElement.name, Description: schemaElement.description, } } @@ -579,7 +745,7 @@ const AnnotationForm = ({ const getOptions = (optionType: string) => { switch (optionType) { - case 'SCORE': + case 'ARTIFACTS': return artifactTagOptions; case 'DATASET': return getUniqueDatasetTags(); @@ -605,12 +771,21 @@ const AnnotationForm = ({ return (
- - {hedTag.schemaElement.name} - + + {hedTag.schemaElement.name} + {hedTag.schemaElement.description}
+
+
+
+
+ Tagged By: +
+
+ {hedTag.TaggerName} +
+
+
+
+ Endorsed By: +
+
+
+ { + hedTag.Endorsements.length > 0 + ? hedTag.Endorsements + .filter((endorsement) => { + return ['Endorsed', 'Caveat'] + .includes(endorsement.EndorsementStatus); + }) + .map((endorsement) => { + return <> + {endorsement.EndorsedBy} + + + }) + : 'n/a' + } +
+
+
+
); } + const strikethroughText = (text) => { + const combiner = '\u0336'; + return text + .split('') + .map(char => char + combiner) + .join(''); + } + const buildHEDBadges = (hedTags: HEDTag[], belongsToEvent: boolean = false) => { const rootTags = hedTags.filter((tag) => { return !hedTags.some((t) => { @@ -719,10 +970,20 @@ const AnnotationForm = ({ return tagBadges; } + const closePanel = () => { + setRightPanel('eventList'); + handleReset(); + setCurrentAnnotation(null); + setTimeSelection(null); + updateActiveEpoch(null); + setPanelIsDirty(false); + } + 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); - updateActiveEpoch(null); + borderTopLeftRadius: 0, }} - > + > + {currentAnnotation ? 'Edit Event' : 'Add Event'}
-
+ +
Event Name - - { - currentAnnotation.label === currentAnnotation.trialType - ? 'trial_type' - : 'value' - } - - - } - value={currentAnnotation ? currentAnnotation.label : ""} - required={true} - readonly={true} - /> - - setLabel(value)} + required={false/*currentAnnotation === null*/} + disabled={currentAnnotation !== null} + labelOuterClass={'flex-basis-40'} + labelClass={'control-label'} + elementClass={'additional-columns-outer'} + inputClass={'additional-columns-inner'} />
+
+ + eventInterval[0]) + ? ( + Math.round(( + // Math.abs( + parseFloat(eventInterval[1].toString()) - parseFloat(eventInterval[0].toString()) + // ) + + Number.EPSILON) * 1000) / 1000 + ) + : 0 + } + required={currentAnnotation === null} + onUserInput={handleDurationChange} + /> + +
+
+ +
+
+ + { setChannelSelectorVisible(false); }} + show={channelSelectorVisible} + // onSubmit={() => { new Promise() console.log('submit'); }} + > + { + return channelMetadata.findIndex((channel) => { + return channel.name === channelName; + }) + }).filter(index => index !== -1), + }} + contentHeight='75vh' + cssClass={'scale-1_5'} + editChannels={true} + setCancelWarning={setThrowChannelEditWarning} + setEventChannels={setEventChannels} + eventChannels={eventChannels} + setChannelSelectorVisible={setChannelSelectorVisible} + /> + +
+
+
+ { + [...eventChannels, ...(currentAnnotation?.channels ?? [])].length < 5 && +
+ } +
+ { + [...eventChannels, ...(currentAnnotation?.channels ?? [])].length > 0 + ? Array.from((new Set([...eventChannels, ...(currentAnnotation?.channels ?? [])]))) + .sort((channelA, channelB) => { + return channelMetadata.findIndex(channel => channel.name === channelA) + - channelMetadata.findIndex(channel => channel.name === channelB); + }) + .map((channel, i) => { + return <> + {i > 0 && <>{channelDelimiter} } + + {channel} + {/*!currentAnnotation?.channels.includes(channel) ? '*' : ''*/} + + + }) + : 'n/a' + } +
+ +
+
+
{ - currentAnnotation && currentAnnotation.properties.length > 0 && ( - <> - -
+ Object.keys(datasetTags) + .filter(column => column !== 'trial_type') + .length > 0 && ( + + Additional Columns + + } + initCollapsed={true} + collapsed={true} + style={{ + padding: '0 15px', + margin: '5px 15px 0 15px', + }} + > +
+
{ - currentAnnotation.properties.map((property) => { - return ( - - ); - }) + Object.keys(datasetTags) + .filter(column => column !== 'trial_type') + .map((property) => { + return ( + prop.PropertyName === property) + ? currentAnnotation.properties.find(prop => prop.PropertyName === property).PropertyValue + : 'n/a' + : eventProperties[property] + } + onUserInput={ + (_, value) => setEventProperties({ + ...eventProperties, + [property]: value, + }) + } + required={false} + disabled={currentAnnotation !== null} + labelOuterClass={'flex-basis-40'} + labelClass={'event-label code-mimic word-break-word'} + elementClass={'additional-columns-outer'} + inputClass={'additional-columns-inner'} + /> + ); + }) } +
- +
) } -
-
+
+ { + currentAnnotation && currentAnnotation.hed && ( + currentAnnotation.hed.some(hedTag => hedTag.TaggerName !== 'Data Authors') || + getTagsForEpoch(currentAnnotation, datasetTags, hedSchema) + .some(hedTag => hedTag.TaggerName !== 'Data Authors') + ) && ( + <> + ※ = Not tagged by Data Authors + + ) + } +
+
{ currentAnnotation && currentAnnotation.hed && - getTagsForEpoch(currentAnnotation, datasetTags, hedSchema) - .length > 0 && ( + getTagsForEpoch(currentAnnotation, datasetTags, hedSchema).length > 0 && ( <>
Dataset
{ @@ -882,7 +1327,7 @@ const AnnotationForm = ({ && currentAnnotation.hed.length > 0 ) || newTags.length > 0 ) && ( -
Instance
+
Instance
) } { @@ -894,24 +1339,61 @@ const AnnotationForm = ({ return badge; }) } +
+
+ Add tag from: +
+ { + const addOption = addHedTagOptions.find((option) => { + return option.value === value; + }) + handleAddTag(addOption.type) + }} + /> +
{ newTags.map((tag, tagIndex) => { + const emptyText = `Select ${ + addHedTagOptions.find((option) => { + return option.type === newTags[tagIndex].type; + }) + .value // Plural -- make singular + .split(' ') + .map((s, i) => s.slice(-1) === 's' ? s.slice(0, -1) : s) + .join(' ') + }`; + return ( <> { - handleTagChange(tagIndex, value); + handleTagChange( + tagIndex, + value === emptyText ? '' : value + ); }} useOptionGroups={true} /> @@ -936,54 +1418,82 @@ const AnnotationForm = ({ }) }
-
-
- Select tag from: -
- { - const addOption = addHedTagOptions.find((option) => { - return option.value === value; - }) - handleAddTag(addOption.type) - }} - /> - {/**/} - {/* Add Tag*/} - {/**/} -
- - - +
+ + +
+ + +
+ + {/*{currentAnnotation &&*/} + {/* */} + {/* Delete*/} + {/* */} + {/*}*/} {annoMessage && (
void) => ({ setTimeSelection: R.compose( diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/DatasetTagger.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/DatasetTagger.tsx index f3e3e2ef9d0..bda944e942f 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/DatasetTagger.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/DatasetTagger.tsx @@ -1,15 +1,38 @@ -import React, {useEffect, useRef, useState} from 'react'; +import React, {MutableRefObject, useEffect, useRef, useState} from 'react'; import * as R from 'ramda'; import {connect} from "react-redux"; import {RootState} from "../store"; -import {SelectDropdown} from 'jsx/MultiSelectDropdown'; -import {CheckboxElement} from 'jsx/Form'; -import {HEDTag, HEDSchemaElement} from "../store/types"; -import {setAddedTags, setDatasetTags, setDeletedTags, setRelOverrides} from "../store/state/dataset"; +import {CheckboxElement, SelectDropdown} from './Form'; +import Panel from './Panel'; // Different from jsx/Panel +import {HEDSchemaElement, HEDTag} from "../store/types"; +import {setAddedTags, setDatasetTags, setDeletedTags, setRelOverrides, setDatasetMetadata} from "../store/state/dataset"; import swal from "sweetalert2"; -import {buildHEDString, getNthMemberTrailingBadgeIndex} from "../store/logic/filterEpochs"; +import {buildHEDString, getNthMemberTrailingBadgeIndex, getRootTags} from "../store/logic/filterEpochs"; import {colorOrder} from "../../color"; +const TagAction = { + 'Select': { + text: 'Select Action', + icon: undefined, + color: 'white', + }, + 'Endorsed': { + text: 'Endorse', + icon: 'flag', + color: 'green', + }, + 'Caveat': { + text: 'Caveat', + icon: 'flag', + color: 'red', + }, + 'Comment': { + text: 'Comment', + icon: 'comment', + color: '#256eb6', + }, +} + type CProps = { physioFileID: number, hedSchema: HEDSchemaElement[], @@ -19,8 +42,15 @@ type CProps = { deletedTags: HEDTag[], setAddedTags: (_: HEDTag[]) => void, setDeletedTags: (_: HEDTag[]) => void, + channelDelimiter: string, setRelOverrides: (_: HEDTag[]) => void, setDatasetTags: (_: any) => void, + activeMenuTab: string, + setActiveMenuTab: (_: string) => void, + tabsRef: MutableRefObject, + tagsHaveChanges: boolean, + setDatasetMetadata: (_: any) => void, + filenamePrefix: string, }; /** @@ -32,10 +62,17 @@ type CProps = { * @param root0.hedSchema * @param root0.addedTags * @param root0.deletedTags + * @param root0.channelDelimiter * @param root0.setAddedTags * @param root0.setDeletedTags * @param root0.setDatasetTags * @param root0.setRelOverrides + * @param root0.activeMenuTab + * @param root0.setActiveMenuTab + * @param root0.tabsRef + * @param root0.tagsHaveChanges + * @param root0.setDatasetMetadata + * @param root0.filenamePrefix */ const DatasetTagger = ({ physioFileID, @@ -44,10 +81,17 @@ const DatasetTagger = ({ hedSchema, addedTags, deletedTags, + channelDelimiter, setAddedTags, setDeletedTags, setDatasetTags, setRelOverrides, + activeMenuTab, + setActiveMenuTab, + tabsRef, + tagsHaveChanges, + setDatasetMetadata, + filenamePrefix, }: CProps) => { const tagListID = 'searchable-hed-tags'; const [searchText, setSearchText] = useState(''); @@ -61,8 +105,53 @@ const DatasetTagger = ({ const [datasetTooltip, setDatasetTooltip] = useState({ title: '', description: '', + taggedBy: 0, + taggerName: '', + endorsements: [], }); const [activeHEDSchemas, setActiveHEDSchemas] = useState({}); + const [numJSONSpaces, setNumJSONSpaces] = useState(2); + const [showUnpublishedTags, setShowUnpublishedTags] = useState(false); + + const [activeEndorsementMenuItem, setActiveEndorsementMenuItem] = useState({ + action: 'Select', + commentText: '', + }); + const endorsementMenuRef = useRef(null); + + + const handleOutsideClick = (event) => { + // Currently the best way to distinguish outer box + if (event.target.style.zIndex === '9999') { + event.stopPropagation(); + } + } + + const resetAllChanges = () => { + setAddedTags([]); + setDeletedTags([]); + setRelOverrides([]); + setGroupedTags([]); + } + + const onModalClose = (event) => { + if ([...addedTags, ...deletedTags].length > 0) { + event.stopPropagation(); + swal.fire({ + title: 'Are you sure?', + text: 'Leaving this window will result in the loss of any information entered.', + type: 'warning', + showCancelButton: true, + confirmButtonText: 'Proceed', + cancelButtonText: 'Cancel', + }).then((result) => { + if (result.value) { + resetAllChanges(); + event.target.dispatchEvent((new Event('click', { bubbles: true, cancelable: true }))); + } + }); + } + } useEffect(() => { // Initialize active HED schemas @@ -73,8 +162,32 @@ const DatasetTagger = ({ } }); setActiveHEDSchemas(activeSchemas); + + // ff only one column, preselect it + if (Object.keys(datasetTags).length === 1) { + setColumnTo(Object.keys(datasetTags)[0]); + } + + // Prevent default behaviour + document.querySelector('#tag-modal-container > div > div') + .addEventListener('click', handleOutsideClick, true); + + return () => { + document.querySelector('#tag-modal-container > div > div') + ?.removeEventListener('click', handleOutsideClick, true); + + }; + + + // Tabs initially invisible + // tabsRef.current.style.display = 'none'; + // setActiveMenuTab('TAG_MODE'); }, []); + useEffect(() => { + setDatasetMetadata({ tagsHaveChanges: false, }); + }, [tagsHaveChanges]); + const generateTagID = (index: number) => { return `add_${Date.now()}${index}`; } @@ -105,11 +218,8 @@ const DatasetTagger = ({ confirmButtonText: 'Yes, reset all changes!' }).then((result) => { if (result.value) { - setAddedTags([]); - setDeletedTags([]); - setRelOverrides([]); - setGroupedTags([]); -; } + resetAllChanges(); + } }); } @@ -226,7 +336,9 @@ const DatasetTagger = ({ return mapping.AddID === addedTagID; }); if (tagMapping) { - addedTag.ID = tagMapping.RelID + addedTag.ID = tagMapping.RelID; + addedTag.TaggerName = tagMapping.TaggerName; + addedTag.TaggedBy = tagMapping.TaggedBy; if (pairRelTagMapping) { addedTag.PairRelID = pairRelTagMapping.RelID; } @@ -292,6 +404,7 @@ const DatasetTagger = ({ }); setDatasetTags(updatedDatasetTags); + setDatasetMetadata({ tagsHaveChanges: true }); setAddedTags([]); setDeletedTags([]); setRelOverrides([]); @@ -347,14 +460,16 @@ const DatasetTagger = ({ } useEffect(() => { - const openTagViewerClasses = document - .querySelector('#tag-modal-container > button') - .classList; - - if ([...addedTags, ...deletedTags].length > 0) { - openTagViewerClasses.add('tag-modal-container-dirty'); - } else { - openTagViewerClasses.remove('tag-modal-container-dirty'); + // Override onClose() + document + .querySelector( + '#tag-modal-container > div > div > div > div > span' + ).addEventListener('click', onModalClose, true); + + return () => { + document.querySelector( + '#tag-modal-container > div > div > div > div > span') + ?.removeEventListener('click', onModalClose, true); } }, [addedTags, deletedTags]) @@ -393,27 +508,21 @@ const DatasetTagger = ({ HasPairing: '0', PairRelID: null, AdditionalMembers: 0, + TaggedBy: 0, + TaggerName: 'You', + Endorsements: [], } ]); setSearchText(''); setSearchTextValid(false); } else { - console.error('Failed to add tag. TODO: report') + console.log('Failed to add tag. TODO: report') } } - const getRootTags = (tags: HEDTag[]) => { - return tags.filter((tag) => { - return tag.ID && - !tags.some((t) => { - return tag.ID === t.PairRelID; - }) - }); - } - const handleRemoveTag = (tagRelID: any) => { if (groupMode) { - console.warn('If you want to delete a tag, you cannot be in "Group Mode". Press the "Tag Mode" button'); + console.log('If you want to delete a tag, you cannot be in "Group Mode". Press the "Tag Mode" button'); return; } @@ -482,6 +591,13 @@ const DatasetTagger = ({ const handleFieldValueChange = (e) => { setActiveFieldValue(e.target.value); setGroupedTags([]); + tabsRef.current.style.display = 'block'; // overkill - revise + } + + const setColumnTo = (columnName: string) => { + setActiveColumnName(columnName); + setActiveFieldValue(null); + setSearchText(''); } const handleColumnValueChange = (e) => { @@ -497,6 +613,24 @@ const DatasetTagger = ({ }).length; } + // TODO: Revise this -- Breaks when a comment is present (different from HEDEndorsement) + const tagInGroupIsEndorsed = (tag: HEDTag, tagList: HEDTag[]) => { + return tag.Endorsements.length === 0 || + tag.Endorsements.every((endorsement) => { + return endorsement.EndorsementStatus !== 'Endorsed' + }) || ( + tag.PairRelID !== null && + tagInGroupIsEndorsed(tagList.find(t => t.ID === tag.PairRelID), tagList) + ); + } + + const allTagsAreEndorsed = (hedTags: HEDTag[]) => { + return hedTags.length > 0 && + !getRootTags(hedTags).some((rootTag) => { + return tagInGroupIsEndorsed(rootTag, hedTags); + }); + } + const buildDataList = (onlyParentNodes: boolean) => { return ( @@ -556,11 +690,15 @@ const DatasetTagger = ({ if (!column) return null; - return column.field_values.map((fieldValue) => { + return column.field_values + .filter((value) => value !== '') + .map((fieldValue) => { const valueIsDirty = isDirty(columnName, fieldValue); + const tagsAreEndorsed = allTagsAreEndorsed(datasetTags[columnName][fieldValue]); return (