diff --git a/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js b/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js index 812471f7f7..d7bd65d28a 100644 --- a/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js +++ b/modules/electrophysiology_browser/jsx/electrophysiologySessionView.js @@ -358,6 +358,10 @@ class ElectrophysiologySessionView extends Component { eegMontage, } = this.state.database[i]; const file = this.state.database[i].file; + const channelsURL = `${loris.BaseURL}/api/v0.0.4-dev/candidates` + + `/${this.state.patient.info.pscid}` + + `/${this.state.patient.info.visit_label}/recordings/${file.name}` + + `/channels`; const splitPagination = []; for (const j of Array(file.splitData?.splitCount).keys()) { splitPagination.push( @@ -403,6 +407,7 @@ class ElectrophysiologySessionView extends Component { {EEG_VIS_ENABLED &&
= (a: T, b: T) => boolean; + +/** + * A dependency-aware key-value mutable cache. + * + * Only a single value is cached per key, when querying the cache, that value + * is recomputed it the dependencies of that entry have changed. + */ +export default class MutableKeyDepCache { + private cache = new Map(); + private comparator: Comparator; + + /** + * Create a new mutable key dependency cache with the given dependency + * comparator. + */ + constructor(comparator: Comparator = Object.is) { + this.comparator = comparator; + } + + /** + * Query the cache for a value with the given key and dependencies. + */ + get(key: K, deps: D, compute: () => V): V { + const entry = this.cache.get(key); + + // If the key is present and its dependencies have not changed, return the + // existing value. + if (entry && this.comparator(entry.deps, deps)) { + return entry.value; + } + + // Otherwise, recompute, cache, and return the new value. + const value = compute(); + this.cache.set(key, {deps, value}); + return value; + } +} diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/eeglab/EEGLabSeriesProvider.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/eeglab/EEGLabSeriesProvider.tsx index f4c6d3091f..938cfc96c7 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/eeglab/EEGLabSeriesProvider.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/eeglab/EEGLabSeriesProvider.tsx @@ -1,4 +1,6 @@ -import React, {Component, createRef} from 'react'; +import React, { + Component, createContext, createRef, useState, useEffect, +} from 'react'; import {tsvParse} from 'd3-dsv'; import {applyMiddleware, createStore, Store} from 'redux'; import {Provider} from 'react-redux'; @@ -22,7 +24,9 @@ import {setDomain, setInterval} from '../series/store/state/bounds'; import { setCoordinateSystem, setElectrodes, } from '../series/store/state/montage'; -import {EventMetadata, HEDSchemaElement} from '../series/store/types'; +import { + ChannelInfo, ChannelInfos, ChannelMetadata, EventMetadata, HEDSchemaElement, +} from '../series/store/types'; import TriggerableModal from 'jsx/TriggerableModal'; import DatasetTagger from '../series/components/DatasetTagger'; import {InfoIcon} from '../series/components/components'; @@ -35,6 +39,7 @@ declare global { type CProps = { + channelsURL: string, chunksURL: string, epochsURL: string, electrodesURL: string, @@ -59,16 +64,66 @@ const MenuOption = { }; /** - * EEGLabSeriesProvider component + * The channel informaton context, which provides the BIDS information about\ + * the channels present in the acquisition, if available. + */ +export const ChannelInfosContext = createContext([]); + +/** + * The channel metadata context, which provides the metadata about the channels + * present in the acquisition. + */ +export const ChannelMetasContext = createContext([]); + +/** + * Function wrapper around the older `EEGLabSeriesProviderClass` class + * component. + */ +function EEGLabSeriesProvider(props: CProps) { + const [channelInfos, setChannelInfos] = useState([]); + const [channelMetas, setChannelMetas] = useState([]); + + // Fetch the channel BIDS information from the API. + useEffect(() => { + fetchJSON(props.channelsURL).then((json: ChannelInfos) => { + setChannelInfos(json.Channels); + }); + }, [props.channelsURL]); + + return ( + + + + + + ); +} + +/** + * Props for the `EEGLabSeriesProviderClass` component, which extend the props + * of the functional component. + */ +type CClassProps = CProps & { + /** + * Setter for the channel metadata context lifted to the functional component. + */ + setChannelMetas: (_: ChannelMetadata[]) => void, +}; + +/** + * EEGLabSeriesProviderClass component */ -class EEGLabSeriesProvider extends Component { +class EEGLabSeriesProviderClass extends Component { private store: Store; /** * @class * @param {object} props - React Component properties */ - constructor(props: CProps) { + constructor(props: CClassProps) { super(props); const epicMiddleware = createEpicMiddleware(); @@ -99,6 +154,7 @@ class EEGLabSeriesProvider extends Component { eegMontageName, recordingHasHED, t, + setChannelMetas, } = props; if (!window.EEGLabSeriesProviderStore) { @@ -184,6 +240,7 @@ class EEGLabSeriesProvider extends Component { const { channelMetadata, shapes, timeInterval, seriesRange, validSamples, } = json; + setChannelMetas(channelMetadata); this.store.dispatch( setDatasetMetadata({ chunksURL: url, 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 3671fc45e9..79f9adbbc5 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,4 +1,4 @@ -import React, {useEffect, useState} from 'react'; +import React, {useContext, useEffect, useState} from 'react'; import {ChannelMetadata, Epoch as EpochType, HEDSchemaElement, HEDTag, RightPanel,} from '../store/types'; import {connect} from 'react-redux'; import {setTimeSelection} from '../store/state/timeSelection'; @@ -21,6 +21,7 @@ import swal from 'sweetalert2'; import {InfoIcon} from "./components"; import {colorOrder} from "../../color"; import {useTranslation} from "react-i18next"; +import {ChannelMetasContext} from '../../eeglab/EEGLabSeriesProvider'; type CProps = { @@ -40,7 +41,6 @@ type CProps = { hedSchema: HEDSchemaElement[], datasetTags: any, channelDelimiter: string, - channelMetadata: ChannelMetadata[], panelIsDirty: boolean, setPanelIsDirty: (_: boolean) => void, eventChannels: string[], @@ -65,7 +65,6 @@ type CProps = { * @param root0.hedSchema * @param root0.datasetTags * @param root0.channelDelimiter - * @param root0.channelMetadata * @param root0.panelIsDirty * @param root0.setPanelIsDirty * @param root0.eventChannels @@ -87,13 +86,13 @@ const AnnotationForm = ({ hedSchema, datasetTags, channelDelimiter, - channelMetadata, panelIsDirty, setPanelIsDirty, eventChannels, setEventChannels, }: CProps) => { const {t} = useTranslation(); + const channelMetadata = useContext(ChannelMetasContext); const [eventInterval, setEventInterval] = useState<(number | string)[]>( timeSelection ?? ['', ''] ); @@ -1636,7 +1635,6 @@ export default connect( hedSchema: state.dataset.hedSchema, datasetTags: state.dataset.datasetTags, channelDelimiter: state.dataset.channelDelimiter, - channelMetadata: state.dataset.channelMetadata, channels: state.channels, }), (dispatch: (any) => void) => ({ diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/ChannelTypesSelector.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/ChannelTypesSelector.tsx new file mode 100644 index 0000000000..9dc611c8e7 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/ChannelTypesSelector.tsx @@ -0,0 +1,65 @@ +import {useCallback} from "react"; +import {useTranslation} from "react-i18next"; +import {ChannelTypeState} from "./SeriesRenderer"; + +/** + * Component that displays the list of channel types present in the acquisition and + * allows to configure which ones should be displayed or not. + */ +const ChannelTypesSelector = ({channelTypes, setChannelTypes}: { + channelTypes: Record, + setChannelTypes: React.Dispatch>>, +}) => { + const {t} = useTranslation(); + + // Toggle the visibility of a channel type. + const toggleChannelType = useCallback((channelTypeName: string) => { + setChannelTypes((channelTypes) => { + const channelType = channelTypes[channelTypeName]; + return ({ + ...channelTypes, + [channelTypeName]: { + ...channelType, + visible: !channelType.visible, + }, + }); + }); + }, [setChannelTypes]); + + return ( +
+ +
    + {Object.entries(channelTypes).map(([name, {visible, channelsCount}]) => ( +
  • { + toggleChannelType(name); + e.stopPropagation(); + }} + > + {name} ({channelsCount}) + e.stopPropagation() + } + onChange={() => toggleChannelType(name)} + /> +
  • + ))} +
+
+ ); +} + +export default ChannelTypesSelector; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/Epoch.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/Epoch.tsx index 87f5b3cd02..df53c45910 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/Epoch.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/Epoch.tsx @@ -1,10 +1,11 @@ -import React from 'react'; +import React, {useContext} from 'react'; import {vec2} from 'gl-matrix'; import {MIN_EPOCH_WIDTH} from '../../vector'; import {ScaleLinear} from 'd3-scale'; import {connect} from "react-redux"; import {RootState} from "../store"; -import {Channel, ChannelMetadata} from "../store/types"; +import {Channel} from "../store/types"; +import {ChannelMetasContext} from '../../eeglab/EEGLabSeriesProvider'; type CProps = { key: string, @@ -20,7 +21,6 @@ type CProps = { minWidth: number, epochChannels?: string[], channels: Channel[], - channelMetadata: ChannelMetadata[], }; /** @@ -35,7 +35,6 @@ type CProps = { * @param root0.minWidth * @param root0.epochChannels * @param root0.channels - * @param root0.channelMetadata */ const Epoch = ( { @@ -49,8 +48,9 @@ const Epoch = ( minWidth, epochChannels, channels, - channelMetadata, }: CProps) => { + const channelMetadata = useContext(ChannelMetasContext); + onset = isNaN(onset) ? 0 : onset; duration = isNaN(duration) ? 0 : duration; @@ -121,5 +121,4 @@ Epoch.defaultProps = { export default connect( (state: RootState)=> ({ channels: state.channels, - channelMetadata: state.dataset.channelMetadata, }))(Epoch); diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/EventManager.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/EventManager.tsx index c4022f88a3..f788630f2d 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/EventManager.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/EventManager.tsx @@ -1,4 +1,4 @@ -import React, {useState, useEffect} from 'react'; +import React, {useState, useEffect, useContext} from 'react'; import {setCurrentAnnotation} from '../store/state/currentAnnotation'; import {MAX_RENDERED_EPOCHS} from '../../vector'; import { @@ -15,7 +15,6 @@ import { HEDTag, HEDSchemaElement, RightPanel, - ChannelMetadata, Channel } from '../store/types'; import {connect} from 'react-redux'; @@ -26,6 +25,7 @@ import {RootState} from '../store'; import {setFilteredEpochs} from '../store/state/dataset'; import {CheckboxElement} from './Form'; import {useTranslation, Trans} from "react-i18next"; +import {ChannelMetasContext} from '../../eeglab/EEGLabSeriesProvider'; type CProps = { timeSelection?: [number, number], @@ -46,7 +46,6 @@ type CProps = { datasetTags: any, channelDelimiter: string, channels: Channel[], - channelMetadata: ChannelMetadata[], canEdit: boolean, tagsHaveChanges: boolean, }; @@ -70,7 +69,6 @@ type CProps = { * @param root0.hedSchema * @param root0.channelDelimiter * @param root0.channels - * @param root0.channelMetadata * @param root0.datasetTags * @param root0.tagsHaveChanges */ @@ -92,11 +90,11 @@ const EventManager = ({ datasetTags, channelDelimiter, channels, - channelMetadata, canEdit, tagsHaveChanges, }: CProps) => { const {t} = useTranslation(); + const channelMetadata = useContext(ChannelMetasContext); const [epochsInRange, setEpochsInRange] = useState(getEpochsInRange(epochs, interval)); const [allEpochsVisible, setAllEpochsVisibility] = useState(() => { if (epochsInRange.length < MAX_RENDERED_EPOCHS) { @@ -769,7 +767,6 @@ export default connect( datasetTags: state.dataset.datasetTags, channelDelimiter: state.dataset.channelDelimiter, channels: state.channels, // TODO: merge with below and pass? - channelMetadata: state.dataset.channelMetadata, tagsHaveChanges: state.dataset.tagsHaveChanges, }), (dispatch: (_: any) => void) => ({ diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/Pagination.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/Pagination.tsx new file mode 100644 index 0000000000..a2b9977ebd --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/Pagination.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import {useTranslation} from "react-i18next"; +import {CHANNEL_DISPLAY_OPTIONS} from "../../vector"; +import {RightPanel} from "../store/types"; + +/** + * Pagination component that provides controls for selecting how many channels + * should be displayed at once, and navigating through the paginated channels. + */ +function Pagination({ + limit, + selectedChannelsCount, + offsetIndex, + updateOffsetIndex, + displayedChannelsLimit, + setDisplayedChannelsLimit, + rightPanel, +}: { + limit: number, + selectedChannelsCount: number, + offsetIndex: number, + updateOffsetIndex: (_: number) => void, + displayedChannelsLimit: number, + setDisplayedChannelsLimit: (_: number) => void, + rightPanel: RightPanel, +}) { + const {t} = useTranslation(); + + const hardLimit = Math.min(offsetIndex + limit - 1, selectedChannelsCount); + + return ( +
+ + {t('Displaying: ', {ns: 'electrophysiology_browser'})} + +   + {t('Showing:', {ns: 'electrophysiology_browser'})} +   + {updateOffsetIndex(parseInt(e.target.value))}} + /> +   + {t('to {{channelsInView}} of {{totalChannels}}', { + ns: 'electrophysiology_browser', + channelsInView: hardLimit, + totalChannels: selectedChannelsCount + })} + +
+ updateOffsetIndex(offsetIndex - limit)} + value='<<' + /> + updateOffsetIndex(offsetIndex - 1)} + value='<' + /> + updateOffsetIndex(offsetIndex + 1)} + value='>' + /> + updateOffsetIndex(offsetIndex + limit)} + value='>>' + /> +
+
+ ); +} + +export default Pagination; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/SeriesCursor.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/SeriesCursor.tsx index bae745a831..549413891d 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/SeriesCursor.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/SeriesCursor.tsx @@ -3,11 +3,13 @@ import {bisector} from 'd3-array'; import {colorOrder} from '../../color'; import {Channel, ChannelMetadata, Epoch} from '../store/types'; import {connect} from 'react-redux'; -import {MAX_RENDERED_EPOCHS, SIGNAL_SCALE, SIGNAL_UNIT} from '../../vector'; +import {MAX_RENDERED_EPOCHS} from '../../vector'; import {MutableRefObject, useEffect} from 'react'; import {RootState} from '../store'; import {getEpochsInRange} from '../store/logic/filterEpochs'; import {useTranslation} from "react-i18next"; +import {getChannelUnit, useChannelInfo} from '../store/logic/channels'; +import {normalizeUnit, normalizeValueUnit} from '../../utils'; type CursorContentProps = { time: number, @@ -158,17 +160,17 @@ const SeriesCursor = ( chunk.interval[1] >= time ); if (!hoveredChunk) return; + const rawUnit = getChannelUnit(useChannelInfo(hoveredChannel)); const chunkValue = computeValue(hoveredChunk, time); const channelColor = colorOrder(channelIndex.toString()).toString(); + const unit = normalizeUnit(rawUnit); + const value = normalizeValueUnit(chunkValue, unit); return (
- {channelName}: {Math.round(chunkValue)} {SIGNAL_UNIT} + {channelName}: {value} {unit}
); })} @@ -267,7 +269,7 @@ const computeValue = (chunk, time) => { const idx = bisectTime(indices, time); const value = chunk.values[idx-1]; - return value * SIGNAL_SCALE; + return value; }; /** @@ -290,6 +292,8 @@ const CursorContent = ( channelMetadata, }: CursorContentProps ) => { + const rawUnit = getChannelUnit(useChannelInfo(channel)); + const unit = normalizeUnit(rawUnit); return (
{channel.traces.map((trace, i) => { @@ -313,7 +317,7 @@ const CursorContent = ( }} > {channelMetadata[channel.index].name}:  - {chunk && Math.round(computeValue(chunk, time))} {SIGNAL_UNIT} + {chunk && normalizeValueUnit(computeValue(chunk, time), unit)} {unit}
); })} diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/SeriesRenderer.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/SeriesRenderer.tsx index 39b6ba5e91..8d7a28065a 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/SeriesRenderer.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/components/SeriesRenderer.tsx @@ -5,6 +5,8 @@ import React, { useRef, FunctionComponent, MutableRefObject, + useMemo, + useContext, } from 'react'; import * as R from 'ramda'; import {vec2} from 'gl-matrix'; @@ -16,11 +18,9 @@ import {colorOrder} from '../../color'; import { MAX_RENDERED_EPOCHS, DEFAULT_MAX_CHANNELS, - CHANNEL_DISPLAY_OPTIONS, - SIGNAL_UNIT, + DEFAULT_SIGNAL_UNIT, Vector2, DEFAULT_TIME_INTERVAL, - STATIC_SERIES_RANGE, DEFAULT_VIEWER_HEIGHT, MIN_EPOCH_WIDTH, } from '../../vector'; @@ -32,11 +32,12 @@ import SeriesCursor from './SeriesCursor'; import LoadingBar from './LoadingBar'; import {setRightPanel} from '../store/state/rightPanel'; import {setDatasetMetadata} from '../store/state/dataset'; -import {setOffsetIndex} from '../store/logic/pagination'; +import {createChannelTypesDict, filterDisplayedChannels, filterSelectedChannels, findBidsChannel} from '../store/logic/channels'; import IntervalSelect from './IntervalSelect'; import EventManager from './EventManager'; import AnnotationForm from './AnnotationForm'; import {RootState} from '../store'; +import {createAction} from 'redux-actions'; import { setAmplitudesScale, @@ -60,10 +61,10 @@ import { } from '../store/logic/timeSelection'; import { - ChannelMetadata, Channel, Epoch as EpochType, RightPanel, EpochFilter, + Trace, } from '../store/types'; import {setCurrentAnnotation} from '../store/state/currentAnnotation'; import {setCursorInteraction} from '../store/logic/cursorInteraction'; @@ -72,6 +73,62 @@ import {getEpochsInRange, updateActiveEpoch} from '../store/logic/filterEpochs'; import HEDEndorsement from "./HEDEndorsement"; import {setTimeSelection} from "../store/state/timeSelection"; import {useTranslation} from "react-i18next"; +import ChannelTypesSelector from './ChannelTypesSelector'; +import Pagination from './Pagination'; +import {SET_CHANNELS} from '../store/state/channels'; +import {UPDATE_VIEWED_CHUNKS} from '../store/logic/fetchChunks'; +import {ChannelInfosContext, ChannelMetasContext} from '../../eeglab/EEGLabSeriesProvider'; +import {computePercentileRange, computeMean} from '../../utils'; +import MutableKeyDepCache from '../../MutableDepCache'; + +/** + * The state of a channel type. + */ +export type ChannelTypeState = { + visible: boolean, + channelsCount: number, +}; + +/** + * The specific cache type used to cache channel ranges. + */ +type ChannelRangeCache = MutableKeyDepCache< + // The channel index. + number, + // The dependencies upon which the range depends. + ChannelRangeCacheDeps, + // The computed range. + [number, number] +> + +/** + * The dependencies upon which a channel range depends. + */ +type ChannelRangeCacheDeps = { + interval: [number, number], + chunkIds: number[], +} + +/** + * Check whethere two channel range cache dependencies are equal. + */ +function compareChannelRangeDeps(a: ChannelRangeCacheDeps, b: ChannelRangeCacheDeps): boolean { + if (a.interval[0] !== b.interval[0] || a.interval[1] !== b.interval[1]) { + return false; + } + + if (a.chunkIds.length !== b.chunkIds.length) { + return false; + } + + for (let i = 0; i < a.chunkIds.length; i += 1) { + if (a.chunkIds[i] !== b.chunkIds[i]) { + return false; + } + } + + return true; +} type CProps = { ref: MutableRefObject, @@ -86,13 +143,10 @@ type CProps = { setRightPanel: (_: RightPanel | void) => void, chunksURL: string, channels: Channel[], - channelMetadata: ChannelMetadata[], hidden: number[], epochs: EpochType[], filteredEpochs: EpochFilter, activeEpoch: number, - offsetIndex: number, - setOffsetIndex: (_: number) => void, setAmplitudesScale: (_: number) => void, resetAmplitudesScale: (_: void) => void, setLowPassFilter: (_: string) => void, @@ -130,13 +184,10 @@ const SeriesRenderer: FunctionComponent = ({ setRightPanel, chunksURL, channels, - channelMetadata, hidden, epochs, filteredEpochs, activeEpoch, - offsetIndex, - setOffsetIndex, setAmplitudesScale, resetAmplitudesScale, setLowPassFilter, @@ -160,6 +211,11 @@ const SeriesRenderer: FunctionComponent = ({ numDisplayedChannels, setNumDisplayedChannels, ] = useState(DEFAULT_MAX_CHANNELS); + + // The channel types are indexed by channel type name. + const [channelTypes, setChannelTypes] = useState>({}); + const channelMetadata = useContext(ChannelMetasContext); + const [cursorEnabled, setCursorEnabled] = useState(false); const toggleCursor = () => setCursorEnabled((value) => !value); const [DCOffsetView, setDCOffsetView] = useState(true); @@ -174,6 +230,7 @@ const SeriesRenderer: FunctionComponent = ({ const [lowPass, setLowPass] = useState('none'); const [refNode, setRefNode] = useState(null); const [bounds, setBounds] = useState(null); + const [offsetIndex, setOffsetIndex] = useState(1); const getBounds = useCallback((domNode) => { if (domNode) { setRefNode(domNode); @@ -185,6 +242,13 @@ const SeriesRenderer: FunctionComponent = ({ const [eventChannels, setEventChannels] = useState([]); const {t} = useTranslation(); + const bidsChannels = useContext(ChannelInfosContext); + + // Initialize the channel types mapping once channels information is loaded. + useEffect(() => { + setChannelTypes(createChannelTypesDict(channelMetadata, bidsChannels)); + }, [channelMetadata, bidsChannels]); + window.onbeforeunload = function() { if (panelIsDirty) { return t( @@ -297,6 +361,49 @@ const SeriesRenderer: FunctionComponent = ({ const viewerRef = useRef(null); const cursorRef = useRef(null); + // Selected channels are all the channels that should be currently available in the viewer, + // including those not currently displayed because of pagination. + const selectedChannels = useMemo(() => ( + filterSelectedChannels(channelMetadata, bidsChannels, channelTypes) + ), [bidsChannels, channelMetadata, channelTypes]); + + // Indexes of the selected channel indexes for comparison with previous renders. + const selectedChannelIndexes = JSON.stringify(selectedChannels.map((channel) => channel.index)); + + // Displayed channels are all the selected channels that are currently displayed on screen. + channels = useMemo(() => ( + filterDisplayedChannels(selectedChannels, offsetIndex, limit, channels) + ), [bidsChannels, selectedChannelIndexes, offsetIndex, limit, channels]); + + // Indexes of the displayed channel indexes for comparison with previous renders. + const displayedChannelIndexes = JSON.stringify(channels.map((channel) => channel.index)); + + // Hack to update the global store whenever displayed channels are updated. + useEffect(() => { + const store = window.EEGLabSeriesProviderStore[chunksURL]; + if (store === undefined) { + return; + } + + store.dispatch(createAction(SET_CHANNELS)(channels)); + store.dispatch(createAction(UPDATE_VIEWED_CHUNKS)()); + }, [displayedChannelIndexes]); + + const filteredChannels = channels.filter((_, i) => !hidden.includes(i)); + + // Function used to update the pagination offset index, with checks to prevent invalid indexes. + const updateOffsetIndex = useCallback((newOffsetIndex: number) => { + if ( + isNaN(newOffsetIndex) + || newOffsetIndex < 1 + || newOffsetIndex > selectedChannels.length - limit + 1 + ) { + return; + } + + setOffsetIndex(newOffsetIndex); + }, [selectedChannels.length, limit]); + useEffect(() => { // Keypress handler /** * @@ -317,10 +424,10 @@ const SeriesRenderer: FunctionComponent = ({ const intervalSize = interval[1] - interval[0]; switch (e.code) { case 'ArrowUp': - setOffsetIndex(offsetIndex - limit); + updateOffsetIndex(offsetIndex - limit); break; case 'ArrowDown': - setOffsetIndex(offsetIndex + limit); + updateOffsetIndex(offsetIndex + limit); break; case 'ArrowRight': setInterval([ @@ -514,6 +621,11 @@ const SeriesRenderer: FunctionComponent = ({ vec2.add(center, topLeft, bottomRight); vec2.scale(center, center, 1 / 2); + // A mutable cache for the visible range of the values each channel. + const channelRangeCache: MutableRefObject = useRef( + new MutableKeyDepCache(compareChannelRangeDeps) + ); + const scales: [ ScaleLinear, ScaleLinear @@ -526,7 +638,6 @@ const SeriesRenderer: FunctionComponent = ({ .range([topLeft[1], bottomRight[1]]), ]; - const filteredChannels = channels.filter((_, i) => !hidden.includes(i)); const showAxisScaleLines = false; // Visibility state of y-axis scale lines /** @@ -664,6 +775,31 @@ const SeriesRenderer: FunctionComponent = ({ ) : filteredChannels; + // Get the maximum range for each channel type based on the ranges of + // the visible values of each visible channel of that type. + const channelTypeRanges: Record = {}; + channelList.forEach((channel) => { + const bidsChannel = findBidsChannel(channelMetadata[channel.index], bidsChannels); + const type = bidsChannel?.ChannelType ?? 'Unknown'; + + // Get the visible values range of that channel, from the cache if possible. + const [min, max] = channelRangeCache.current.get( + channel.index, + { + interval, + chunkIds: channel.traces.flatMap((trace) => trace.chunks.map((chunk) => chunk.index)).sort(), + }, + () => {console.log("RECOMPUTE"); return getChannelVisibleRange(channel, interval) } + ); + + const range = max - min; + if (!channelTypeRanges[type]) { + channelTypeRanges[type] = range; + } else { + channelTypeRanges[type] = Math.max(channelTypeRanges[type], range); + } + }); + return ( <> = ({ (chunk) => chunk.values.length > 0 ).length; - const valuesInView = trace.chunks.map((chunk) => { - let includedIndices = [0, chunk.values.length]; - if (chunk.interval[0] < interval[0]) { - const startIndex = chunk.values.length * - (interval[0] - chunk.interval[0]) / - (chunk.interval[1] - chunk.interval[0]); - includedIndices = [startIndex, includedIndices[1]]; - } - if (chunk.interval[1] > interval[1]) { - const endIndex = chunk.values.length * - (interval[1] - chunk.interval[0]) / - (chunk.interval[1] - chunk.interval[0]); - includedIndices = [includedIndices[0], endIndex]; - } - return chunk.values.slice( - includedIndices[0], includedIndices[1] - ); - }).flat(); + const valuesInView = getTraceVisibleValues(trace, interval); if (valuesInView.length === 0) { return; } - const seriesRange: [number, number] = STATIC_SERIES_RANGE; + // Get the range centered on the average for channels of the same type + const average = computeMean(valuesInView); + const bidsChannel = findBidsChannel(channelMetadata[channel.index], bidsChannels); + const type = bidsChannel?.ChannelType ?? 'Unknown'; + const overallRange = channelTypeRanges[type] || 0; + const seriesRange: [number, number] = [average - overallRange / 2, average + overallRange / 2]; const scales: [ ScaleLinear, @@ -819,8 +943,6 @@ const SeriesRenderer: FunctionComponent = ({ ); }; - const hardLimit = Math.min(offsetIndex + limit - 1, channelMetadata.length); - /** * */ @@ -845,13 +967,12 @@ const SeriesRenderer: FunctionComponent = ({ }; /** - * + * Handle a change to the limit of the number of displayed channels. */ - const handleChannelChange = (e) => { - const numChannels = parseInt(e.target.value, 10); + const handleChannelChange = (numChannels: number) => { setNumDisplayedChannels(numChannels); // This one is the frontend controller setDatasetMetadata({limit: numChannels}); // Will trigger re-render to the store - setOffsetIndex(offsetIndex); // Will include new channels on limit increase + updateOffsetIndex(offsetIndex); // Will include new channels on limit increase setViewerHeight( numChannels > 4 ? DEFAULT_VIEWER_HEIGHT @@ -983,6 +1104,10 @@ const SeriesRenderer: FunctionComponent = ({ ) } +
@@ -1182,83 +1307,15 @@ const SeriesRenderer: FunctionComponent = ({ )} />
- -
- - {t('Displaying: ', {ns: 'electrophysiology_browser'})} - -   - {t('Showing:', {ns: 'electrophysiology_browser'})} -   - { - const value = parseInt(e.target.value); - !isNaN(value) && setOffsetIndex(value); - }} - /> -   - {t('to {{channelsInView}} of {{totalChannels}}', { - ns: 'electrophysiology_browser', - channelsInView: hardLimit, - totalChannels: channelMetadata.length - })} - -
- setOffsetIndex(offsetIndex - limit)} - value='<<' - /> - setOffsetIndex(offsetIndex - 1)} - value='<' - /> - setOffsetIndex(offsetIndex + 1)} - value='>' - /> - setOffsetIndex(offsetIndex + limit)} - value='>>' - /> -
-
+ @@ -1354,7 +1411,7 @@ const SeriesRenderer: FunctionComponent = ({ position: 'absolute', }} > - ({SIGNAL_UNIT}) + ({DEFAULT_SIGNAL_UNIT}) : null } @@ -1621,11 +1678,71 @@ SeriesRenderer.defaultProps = { channels: [], epochs: [], hidden: [], - channelMetadata: [], - offsetIndex: 1, limit: DEFAULT_MAX_CHANNELS, }; +/** + * Get the range of the visible values of a channel across all its traces. + */ +function getChannelVisibleRange(channel: Channel, interval: [number, number]): [number, number] { + let channelMin = Infinity; + let channelMax = -Infinity; + + channel.traces.forEach((trace) => { + const values = getTraceVisibleValues(trace, interval); + if (values.length === 0) { + return; + } + + const [traceMin, traceMax] = computePercentileRange(values); + console.log(traceMin, traceMax); + channelMin = Math.min(channelMin, traceMin); + channelMax = Math.max(channelMax, traceMax); + }); + + return [channelMin, channelMax]; +} + +/** + * Get the values of a trace that fall within the visible interval. + */ +function getTraceVisibleValues(trace: Trace, interval: [number, number]): number[] { + const [start, end] = interval; + const values = []; + + for (const chunk of trace.chunks) { + const [chunkStart, chunkEnd] = chunk.interval; + + // No overlap, skip this chunk. + if (chunkEnd <= start || chunkStart >= end) { + continue; + } + + // Calculate overlap with view window. + const overlapStart = Math.max(start, chunkStart); + const overlapEnd = Math.min(end, chunkEnd); + + const chunkDuration = chunkEnd - chunkStart; + const numSamples = chunk.values.length; + + // Convert time to sample indices. + const startIndex = Math.max(0, Math.floor( + (overlapStart - chunkStart) / chunkDuration * numSamples + )); + + const endIdx = Math.min(numSamples, Math.ceil( + (overlapEnd - chunkStart) / chunkDuration * numSamples + )); + + // Push individual values. + for (let i = startIndex; i < endIdx; i++) { + values.push(chunk.values[i]); + } + } + + return values; +} + export default connect( (state: RootState)=> ({ viewerWidth: state.bounds.viewerWidth, @@ -1640,8 +1757,6 @@ export default connect( filteredEpochs: state.dataset.filteredEpochs, activeEpoch: state.dataset.activeEpoch, hidden: state.montage.hidden, - channelMetadata: state.dataset.channelMetadata, - offsetIndex: state.dataset.offsetIndex, limit: state.dataset.limit, loadedChannels: state.dataset.loadedChannels, domain: state.bounds.domain, @@ -1649,10 +1764,6 @@ export default connect( hoveredChannels: state.cursor.hoveredChannels, }), (dispatch: (_: any) => void) => ({ - setOffsetIndex: R.compose( - dispatch, - setOffsetIndex - ), setInterval: R.compose( dispatch, setInterval diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/index.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/index.tsx index 1d424b56b1..c247636e59 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/index.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/index.tsx @@ -12,7 +12,6 @@ import {channelsReducer} from './state/channels'; import {createDragBoundsEpic} from './logic/dragBounds'; import {createTimeSelectionEpic} from './logic/timeSelection'; import {createFetchChunksEpic} from './logic/fetchChunks'; -import {createPaginationEpic} from './logic/pagination'; import { createActiveEpochEpic, createFilterEpochsEpic, @@ -51,10 +50,6 @@ export const rootEpic = combineEpics( dataset, channels, })), - createPaginationEpic(({dataset, channels}) => { - const {limit, channelMetadata} = dataset; - return {limit, channelMetadata, channels}; - }), createScaleAmplitudesEpic(({bounds}) => { const {amplitudeScale} = bounds; return amplitudeScale; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/channels.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/channels.tsx new file mode 100644 index 0000000000..42f0ce3dbd --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/channels.tsx @@ -0,0 +1,147 @@ +import {useContext} from 'react'; +import {ChannelTypeState} from '../../components/SeriesRenderer'; +import {Channel, ChannelInfo, ChannelMetadata} from '../types'; +import {DEFAULT_SIGNAL_UNIT} from '../../../vector'; +import { + ChannelInfosContext, + ChannelMetasContext, +} from '../../../eeglab/EEGLabSeriesProvider'; + +/** + * Get the information about a channel from the context. + */ +export function useChannelMetadata(channel: Channel): ChannelMetadata | null { + const channelMetadatas = useContext(ChannelMetasContext); + return channelMetadatas[channel.index] ?? null; +} + +/** + * Get the BIDS metadata of a channel from the context. + */ +export function useChannelInfo(channel: Channel): ChannelInfo | null { + const channelMetadata = useChannelMetadata(channel); + const channelInfos = useContext(ChannelInfosContext); + + if (channelMetadata === null) { + return null; + } + + return findBidsChannel(channelMetadata, channelInfos) ?? null; +} + +/** + * Get the unit of a channel from its BIDS metadata, or fall back to the default + * unit if that metadata or unit is not available. + */ +export function getChannelUnit(channelInfo: ChannelInfo | null): string { + return channelInfo?.Unit ?? DEFAULT_SIGNAL_UNIT; +} + +/** + * Create the channel types dictionary that maps each channel type found in the dataset + * to its state. + */ +export function createChannelTypesDict( + rawChannels: ChannelMetadata[], + bidsChannels: ChannelInfo[], +): Record { + // A mapping of channel types indexed by their names. + const channelTypes: Record = {}; + + for (const rawChannel of rawChannels) { + const bidsChannel = findBidsChannel(rawChannel, bidsChannels); + const channelTypeName = bidsChannel?.ChannelType ?? 'Unknown'; + if (channelTypes[channelTypeName] === undefined) { + channelTypes[channelTypeName] = { + visible: true, + channelsCount: 0, + }; + } + + channelTypes[channelTypeName].channelsCount++; + } + + return channelTypes; +} + +/** + * Filter the list of all channels to keep only those whose channel types are visible. + */ +export function filterSelectedChannels( + rawChannels: ChannelMetadata[], + bidsChannels: ChannelInfo[], + channelTypes: Record, +): ChannelMetadata[] { + // If no channel types are loaded, do not filter any channel out. + if (Object.keys(channelTypes).length === 0) { + return rawChannels; + } + + return rawChannels.filter((rawChannel) => { + const bidsChannel = findBidsChannel(rawChannel, bidsChannels); + + // If there is a mismatch between the BIDS channels and the raw channels, + // display the channel by default. + if (bidsChannel === undefined) { + return channelTypes['Unknown'].visible; + } + + const channelType = channelTypes[bidsChannel.ChannelType]; + if (channelType === undefined) { + return true; + } + + return channelTypes[bidsChannel.ChannelType].visible; + }); +} + +/** + * Find the BIDS channel corresponding to a raw channel among a list of BIDS channels. + */ +export function findBidsChannel( + rawChannel: ChannelMetadata, + bidsChannels: ChannelInfo[] +): ChannelInfo | undefined { + return bidsChannels.find((bidsChannel) => + bidsChannel.ChannelName === rawChannel.name + ); +} + +/** + * Filter the list of selected channels to keep only those that should be displayed on the current + * page. + */ +export function filterDisplayedChannels( + selectedChannels: ChannelMetadata[], + offsetIndex: number, + pageLimit: number, + previousChannels: Channel[], +): Channel[] { + // Index of of the first displayed channel among the selected channels. + let selectedChannelIndex = offsetIndex - 1; + + const maxSelectedChannelIndex = Math.min( + offsetIndex + pageLimit - 1, + selectedChannels.length + ); + + const newChannels = []; + while (selectedChannelIndex < maxSelectedChannelIndex) { + // Get the channel index of the selected channel. + const channelIndex = selectedChannels[selectedChannelIndex].index; + + // Re-use previous channels if possible. + // TODO: need to handle multiple traces using shapes + const channel = previousChannels.find((pastChannel) => + pastChannel.index === channelIndex + ) ?? { + index: channelIndex, + traces: [{chunks: [], type: 'line'}], + }; + + newChannels.push(channel); + selectedChannelIndex++; + } + + return newChannels; +} diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/fetchChunks.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/fetchChunks.tsx index 116127ccf5..c38bd0514c 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/fetchChunks.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/fetchChunks.tsx @@ -31,9 +31,11 @@ export const loadChunks = (chunksData: FetchedChunks[]) => { return (dispatch: (_: any) => void) => { const channels : Channel[] = []; - const filters: Filter[] - = window.EEGLabSeriesProviderStore[chunksData[0].chunksURL] - .getState().filters; + const filters: Filter[] = chunksData[0] !== undefined + ? window.EEGLabSeriesProviderStore[chunksData[0].chunksURL] + .getState().filters + : []; + for (let index = 0; index < chunksData.length; index++) { const {channelIndex, chunks} : { channelIndex: number, diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/pagination.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/pagination.tsx deleted file mode 100644 index 1659e91e4f..0000000000 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/logic/pagination.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import * as R from 'ramda'; -import {Observable} from 'rxjs'; -import * as Rx from 'rxjs/operators'; -import {ofType} from 'redux-observable'; -import {createAction} from 'redux-actions'; -import {Channel, ChannelMetadata} from '../types'; -import {setChannels} from '../state/channels'; -import {setDatasetMetadata} from '../state/dataset'; -import {updateViewedChunks} from './fetchChunks'; - -export const SET_OFFSET_INDEX = 'SET_OFFSET_INDEX'; -export const setOffsetIndex = createAction(SET_OFFSET_INDEX); - -export type Action = (_: (_: any) => void) => void; - -export type State = { - limit: number, - channelMetadata: ChannelMetadata[], - channels: Channel[] -}; - -/** - * createPaginationEpic - * - * @param {Function} fromState - A function to parse the current state - * @returns {Observable} - A stream of actions - */ -export const createPaginationEpic = (fromState: (_: any) => State) => ( - action$: Observable, - state$: Observable -): Observable => { - return action$.pipe( - ofType(SET_OFFSET_INDEX), - Rx.map(R.prop('payload')), - Rx.withLatestFrom(state$), - Rx.map<[number, State], any>(([payload, state]) => { - const {limit, channelMetadata, channels} = fromState(state); - - const offsetIndex = Math.min( - Math.max(payload, 1), - Math.max(channelMetadata.length - limit + 1, 1) - ); - - let channelIndex = offsetIndex - 1; - - const newChannels = []; - const hardLimit = Math.min( - offsetIndex + limit - 1, - channelMetadata.length - ); - while (channelIndex < hardLimit) { - // TODO: need to handle multiple traces using shapes - const channel = - channels.find( - R.pipe( - R.prop('index'), - R.equals(channelIndex) - ) - ) || { - index: channelIndex, - traces: [{chunks: [], type: 'line'}], - }; - - newChannels.push(channel); - channelIndex++; - } - - return (dispatch) => { - dispatch(setDatasetMetadata({offsetIndex})); - dispatch(setChannels(newChannels)); - dispatch(updateViewedChunks()); - }; - }) - ); -}; diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/bounds.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/bounds.tsx index 8cfe866adb..accdd9edb5 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/bounds.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/bounds.tsx @@ -75,7 +75,7 @@ const domain = ( * @param {Action} action - The action * @returns {State} - The updated state */ -const amplitudeScale = (state = 0.0005, action?: Action): number => { +const amplitudeScale = (state = 1, action?: Action): number => { if (action && action.type === 'SET_AMPLITUDE_SCALE') { return action.payload; } diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/dataset.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/dataset.tsx index 4b66e3d5dd..94040320ce 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/dataset.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/state/dataset.tsx @@ -1,7 +1,6 @@ import * as R from 'ramda'; import {createAction} from 'redux-actions'; import { - ChannelMetadata, Epoch, EpochFilter, HEDSchemaElement, @@ -62,7 +61,6 @@ export type Action = loadedChannels: number, samplingFrequency: string, eegMontageName: string, - offsetIndex: number, channelDelimiter: string, tagsHaveChanges: boolean, recordingHasHED: boolean, @@ -71,8 +69,6 @@ export type Action = export type State = { chunksURL: string, - channelMetadata: ChannelMetadata[], - offsetIndex: number, channelDelimiter: string, limit: number, loadedChannels: number, @@ -105,7 +101,6 @@ export type State = { export const datasetReducer = ( state: State = { chunksURL: '', - channelMetadata: [], epochs: [], filteredEpochs: { plotVisibility: [], @@ -114,7 +109,6 @@ export const datasetReducer = ( }, activeEpoch: null, physioFileID: null, - offsetIndex: 1, channelDelimiter: '', limit: DEFAULT_MAX_CHANNELS, loadedChannels: 0, diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/types.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/types.tsx index dd757ee876..c453cccd48 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/types.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/series/store/types.tsx @@ -16,6 +16,7 @@ export type Trace = { }; export type ChannelMetadata = { + index: number, name: string, seriesRange: [number, number] }; @@ -114,3 +115,41 @@ export type HEDEndorsement = { EndorsementStatus: EndorsementStatus, EndorsementTime: string, } + +/** + * LORIS EEG API acquisition metadata. + */ +export type ChannelInfosMetadata = { + CandID: string; + Visit: string; + File: string; +} + +/** + * Channel information extracted from the BIDS `channels.tsv` file and obtained + * through the LORIS EEG acquisition channel API. + */ +export type ChannelInfo = { + ChannelName: string; + ChannelDescription: string; + ChannelType: string; + ChannelTypeDescription: string; + ChannelStatus: string; + StatusDescription: string; + SamplingFrequency: number; + LowCutoff: string; + HighCutoff: string; + ManualFlag: string; + Notch: string; + Reference: string; + Unit: string; + ChannelFilePath: string; +} + +/** + * LORIS EEG acquisition channels API data. + */ +export type ChannelInfos = { + Meta: ChannelInfosMetadata; + Channels: ChannelInfo[]; +} diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/utils.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/utils.tsx new file mode 100644 index 0000000000..66a765d445 --- /dev/null +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/utils.tsx @@ -0,0 +1,97 @@ +/** + * Get the minimum and maximum values from a non-empty list of numbers. + */ +export function getMinMaxRange(values: number[]): [number, number] { + let min = values[0]; + let max = values[0]; + for (let i = 1; i < values.length; i++) { + if (values[i] < min) { + min = values[i]; + } + if (values[i] > max) { + max = values[i]; + } + } + + return [min, max]; +} + +/** + * Get the value at the n-th percentile index in a list. + */ +export function getIndexPercentile( + values: number[], + percentile: number, +): number { + const index = Math.floor((percentile / 100) * values.length); + const clampedIndex = Math.min(index, values.length - 1); + return values[clampedIndex]; +} + +/** + * Compute the percentile range in a list of numbers. + */ +export function computePercentileRange( + values: number[], + lowerPercentile = 5, + upperPercentile = 95, +): [number, number] { + if (values.length === 0) { + return [0, 0]; + } + + const sorted = [...values].sort((a, b) => a - b); + + return [ + getIndexPercentile(sorted, lowerPercentile), + getIndexPercentile(sorted, upperPercentile), + ]; +} + +/** + * Compute the mean in a list of numbers. + */ +export function computeMean(values: number[]): number { + if (values.length === 0) { + return 0; + } + + return values.reduce((a, b) => a + b, 0) / values.length; +} + +/** + * Normalize a channel unit for visualization. + */ +export function normalizeUnit(unit: string): string { + // Display MEG signals in femtoteslas instead of teslas. + if (unit === 'T') { + return 'fT'; + } + + // Display EEG signals in microvolts instead of volts. + if (unit === 'V') { + return 'µV'; + } + + // Do not modify unknown units. + return unit; +} + +/** + * Normalize a value for visualization based on the target unit, assuming the value is in the + * non-prefixed. + */ +export function normalizeValueUnit(value: number, unit: string): number { + // Convert MEG signal values from teslas to femtoteslas. + if (unit === 'fT') { + return Math.round(value * 1e15); + } + + // Convert EEG signal values from volts to microvolts. + if (unit === 'µV') { + return Math.round(value * 1e6); + } + + // Simply remove decimals for unknown units. + return Math.round(value); +} diff --git a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/vector/index.tsx b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/vector/index.tsx index de36681100..789b7f3367 100644 --- a/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/vector/index.tsx +++ b/modules/electrophysiology_browser/jsx/react-series-data-viewer/src/vector/index.tsx @@ -24,15 +24,12 @@ export const DEFAULT_MAX_CHANNELS = 16; export const CHANNEL_DISPLAY_OPTIONS = [4, 8, 16, 32, 64]; -export const STATIC_SERIES_RANGE: [number, number] = [-0.05, 0.05]; - export const DEFAULT_TIME_INTERVAL: [number, number] = [0, 5]; export const DEFAULT_VIEWER_HEIGHT = 700; -export const SIGNAL_SCALE = Math.pow(10, 6); - -export const SIGNAL_UNIT = 'µV'; +/** The default unit assumed for signal values if channel metadata is not available. */ +export const DEFAULT_SIGNAL_UNIT = 'V'; export const MAX_RENDERED_EPOCHS = 500; diff --git a/modules/electrophysiology_browser/locale/fr/LC_MESSAGES/electrophysiology_browser.po b/modules/electrophysiology_browser/locale/fr/LC_MESSAGES/electrophysiology_browser.po index 146bcf6fee..c247dac37a 100644 --- a/modules/electrophysiology_browser/locale/fr/LC_MESSAGES/electrophysiology_browser.po +++ b/modules/electrophysiology_browser/locale/fr/LC_MESSAGES/electrophysiology_browser.po @@ -765,3 +765,6 @@ msgstr "Tous les fichiers" msgid "View Session" msgstr "Voir la session" + +msgid "Channel Types" +msgstr "Types de chaînes" diff --git a/modules/electrophysiology_browser/locale/ja/LC_MESSAGES/electrophysiology_browser.po b/modules/electrophysiology_browser/locale/ja/LC_MESSAGES/electrophysiology_browser.po index 802ae27562..fe255896f0 100644 --- a/modules/electrophysiology_browser/locale/ja/LC_MESSAGES/electrophysiology_browser.po +++ b/modules/electrophysiology_browser/locale/ja/LC_MESSAGES/electrophysiology_browser.po @@ -618,3 +618,6 @@ msgstr "すべてのファイル" msgid "View Session" msgstr "セッションを見る" + +msgid "Channel Types" +msgstr "チャネルタイプ"