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 (
+
+
+
+
+ );
+}
+
+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 (
+
+ );
+}
+
+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 = ({
)}
/>
-
-
+
@@ -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 "チャネルタイプ"