multi-channel in ui
5 changed files+194−49
python/ephys_compression_tests/datasets/retina512/__init__.pymodified+1−1View file
@@ -19,7 +19,7 @@ def _load_long_description():
1919
2020 LONG_DESCRIPTION = _load_long_description()
2121
22-tags = ["real", "ecephys", "timeseries", "1d", "integer", "correlated"]
22+tags = ["real", "ecephys", "timeseries", "single-channel", "integer", "correlated"]
2323
2424
2525 def load_retina512_example_ch0_seg2_6() -> np.ndarray:
web-ui/src/components/dataset/TimeseriesView.tsxmodified+108−22View file
@@ -20,8 +20,11 @@ const TimeseriesView: React.FC<TimeseriesViewProps> = ({
2020 const { client, error: clientError } = useTimeseriesDataClient(dataset);
2121 const [dataT, setDataT] = useState<number[] | null>(null);
2222 const [dataY, setDataY] = useState<SupportedTypedArray | null>(null);
23+ const [dataYAll, setDataYAll] = useState<SupportedTypedArray[] | null>(null);
2324 const [error, setError] = useState<string | null>(clientError);
2425 const [isLoading, setIsLoading] = useState(false);
26+ const [selectedChannel, setSelectedChannel] = useState<number | "all">(0);
27+ const [numChannels, setNumChannels] = useState<number>(1);
2528
2629 const [canvasElement, setCanvasElement] = useState<HTMLCanvasElement | null>(
2730 null,
@@ -66,10 +69,25 @@ const TimeseriesView: React.FC<TimeseriesViewProps> = ({
6669 setIsLoading(true);
6770 const start = Math.floor(xRange.min);
6871 const end = Math.ceil(xRange.max) + 1;
69- const rangeData = await client.fetchRange(start, end);
70- setDataY(rangeData);
72+
73+ if (selectedChannel === "all") {
74+ // Load all channels
75+ const allChannelData = await Promise.all(
76+ Array.from({ length: numChannels }, (_, ch) =>
77+ client.fetchRange(start, end, ch)
78+ )
79+ );
80+ setDataYAll(allChannelData);
81+ setDataY(null);
82+ } else {
83+ // Load single channel
84+ const rangeData = await client.fetchRange(start, end, selectedChannel);
85+ setDataY(rangeData);
86+ setDataYAll(null);
87+ }
88+
7189 const dT = Array.from(
72- { length: rangeData.length },
90+ { length: end - start },
7391 (_, i) => i + start,
7492 );
7593 setDataT(dT);
@@ -84,12 +102,16 @@ const TimeseriesView: React.FC<TimeseriesViewProps> = ({
84102 };
85103
86104 loadRangeData();
87- }, [client, xRange]);
105+ }, [client, xRange, selectedChannel, numChannels]);
88106
89107 // Update xRange when client is initialized
90108 useEffect(() => {
91109 if (client) {
92110 const shape = client.getShape();
111+ const channels = client.getNumChannels();
112+ setNumChannels(channels);
113+ // Default to "all" if 20 or fewer channels, otherwise default to channel 0
114+ setSelectedChannel(channels > 1 && channels <= 20 ? "all" : 0);
93115 dispatch({
94116 type: "SET_X_RANGE",
95117 range: { min: 0, max: Math.min(999, shape - 1) },
@@ -219,23 +241,37 @@ const TimeseriesView: React.FC<TimeseriesViewProps> = ({
219241
220242 // Calculate yRange from data
221243 const yRange = useMemo<Range>(() => {
222- if (!dataY) return { min: 0, max: 1 };
223- return {
224- min: computeMin(dataY),
225- max: computeMax(dataY),
226- };
227- }, [dataY]);
244+ if (dataYAll) {
245+ // Calculate range across all channels
246+ let min = Infinity;
247+ let max = -Infinity;
248+ for (const channelData of dataYAll) {
249+ const channelMin = computeMin(channelData);
250+ const channelMax = computeMax(channelData);
251+ if (channelMin < min) min = channelMin;
252+ if (channelMax > max) max = channelMax;
253+ }
254+ return { min, max };
255+ } else if (dataY) {
256+ return {
257+ min: computeMin(dataY),
258+ max: computeMax(dataY),
259+ };
260+ }
261+ return { min: 0, max: 1 };
262+ }, [dataY, dataYAll]);
228263
229264 // Handle dimension changes
230265 useEffect(() => {
231266 if (!worker) return;
232- if (!dataY) return;
233267 if (!dataT) return;
268+ if (!dataY && !dataYAll) return;
234269
235270 const msg: WorkerMessage = {
236271 type: "render",
237272 timeseriesT: dataT,
238- timeseriesY: Array.from(dataY),
273+ timeseriesY: dataY ? Array.from(dataY) : [],
274+ timeseriesYAll: dataYAll ? dataYAll.map(ch => Array.from(ch)) : undefined,
239275 width,
240276 height,
241277 margins,
@@ -243,11 +279,11 @@ const TimeseriesView: React.FC<TimeseriesViewProps> = ({
243279 yRange,
244280 };
245281 worker.postMessage(msg);
246- }, [width, height, dataT, dataY, worker, margins, xRange, yRange]);
282+ }, [width, height, dataT, dataY, dataYAll, worker, margins, xRange, yRange]);
247283
248284 // Render cursor on overlay canvas
249285 useEffect(() => {
250- if (!overlayCanvasElement || selectedIndex === null || !dataY) return;
286+ if (!overlayCanvasElement || selectedIndex === null || (!dataY && !dataYAll)) return;
251287 const ctx = overlayCanvasElement.getContext("2d");
252288 if (!ctx) return;
253289
@@ -272,18 +308,35 @@ const TimeseriesView: React.FC<TimeseriesViewProps> = ({
272308 margins,
273309 dataT,
274310 dataY,
311+ dataYAll,
275312 xRange,
276313 ]);
277314
278315 const selectedValue = useMemo(() => {
279- if (selectedIndex === -1 || !dataT || !dataY) return null;
280- for (let i = 0; i < dataT.length; i++) {
281- if (dataT[i] === selectedIndex) {
282- return dataY[i];
316+ if (selectedIndex === -1 || !dataT) return null;
317+
318+ if (dataYAll) {
319+ // Return all channel values
320+ const values: number[] = [];
321+ for (let ch = 0; ch < dataYAll.length; ch++) {
322+ for (let i = 0; i < dataT.length; i++) {
323+ if (dataT[i] === selectedIndex) {
324+ values.push(dataYAll[ch][i]);
325+ break;
326+ }
327+ }
328+ }
329+ return values.length > 0 ? values : null;
330+ } else if (dataY) {
331+ // Return single channel value
332+ for (let i = 0; i < dataT.length; i++) {
333+ if (dataT[i] === selectedIndex) {
334+ return dataY[i];
335+ }
283336 }
284337 }
285338 return null;
286- }, [selectedIndex, dataT, dataY]);
339+ }, [selectedIndex, dataT, dataY, dataYAll]);
287340
288341 if (error || clientError) {
289342 return <div>Error loading data: {error || clientError}</div>;
@@ -294,7 +347,7 @@ const TimeseriesView: React.FC<TimeseriesViewProps> = ({
294347 }
295348
296349 const handleCanvasClick = (e: React.MouseEvent<HTMLDivElement>) => {
297- if (!overlayCanvasElement || !dataY || isDragging) return;
350+ if (!overlayCanvasElement || (!dataY && !dataYAll) || isDragging) return;
298351
299352 // Enable wheel zooming on first click
300353 if (!isWheelEnabled) {
@@ -323,6 +376,36 @@ const TimeseriesView: React.FC<TimeseriesViewProps> = ({
323376 }
324377 />
325378 </div>
379+ {numChannels > 1 && (
380+ <div style={{ marginBottom: 10, display: "flex", alignItems: "center", gap: 8 }}>
381+ <label htmlFor="channel-select" style={{ fontSize: "14px", color: "#666" }}>
382+ Channel:
383+ </label>
384+ <select
385+ id="channel-select"
386+ value={selectedChannel}
387+ onChange={(e) => {
388+ const value = e.target.value;
389+ setSelectedChannel(value === "all" ? "all" : Number(value));
390+ }}
391+ style={{
392+ padding: "4px 8px",
393+ fontSize: "14px",
394+ borderRadius: "4px",
395+ border: "1px solid #ccc",
396+ backgroundColor: "white",
397+ cursor: "pointer",
398+ }}
399+ >
400+ <option value="all">All (overlay)</option>
401+ {Array.from({ length: numChannels }, (_, i) => (
402+ <option key={i} value={i}>
403+ {i}
404+ </option>
405+ ))}
406+ </select>
407+ </div>
408+ )}
326409 {showHint && (
327410 <div
328411 style={{
@@ -405,9 +488,12 @@ const TimeseriesView: React.FC<TimeseriesViewProps> = ({
405488 }}
406489 />
407490 </div>
408- {selectedIndex !== -1 && dataY && (
491+ {selectedIndex !== -1 && selectedValue && (
409492 <div style={{ height: 30, padding: "5px 0", color: "#666" }}>
410- Index: {selectedIndex}, Value: {selectedValue?.toFixed(3)}
493+ Index: {selectedIndex},{" "}
494+ {Array.isArray(selectedValue)
495+ ? `Values: [${selectedValue.slice(0, 5).map(v => v.toFixed(3)).join(", ")}${selectedValue.length > 5 ? ", ..." : ""}]`
496+ : `Value: ${selectedValue.toFixed(3)}`}
411497 </div>
412498 )}
413499 </div>
web-ui/src/components/dataset/TimeseriesViewWorker.tsmodified+55−18View file
@@ -65,6 +65,7 @@ let ctx: OffscreenCanvasRenderingContext2D | null = null;
6565 function renderTimeseries(
6666 timeseriesT: number[],
6767 timeseriesY: number[],
68+ timeseriesYAll: number[][] | undefined,
6869 width: number,
6970 height: number,
7071 margins: Margins,
@@ -103,30 +104,64 @@ function renderTimeseries(
103104 context.rect(margins.left, margins.top, drawingWidth, drawingHeight);
104105 context.clip();
105106
106- // Set up drawing style for timeseries
107- context.strokeStyle = "#2196f3";
108- context.lineWidth = 2;
109- context.beginPath();
110-
111107 // Calculate scaling factors
112108 const xScale = drawingWidth / (xRange.max - xRange.min);
113109 const yScale = drawingHeight / (yRange.max - yRange.min);
114110
115- // Draw the path
116- let isFirst = true;
117- for (let i = 0; i < timeseriesT.length; i++) {
118- const x = margins.left + (timeseriesT[i] - xRange.min) * xScale;
119- const y =
120- margins.top + drawingHeight - (timeseriesY[i] - yRange.min) * yScale;
121- if (isFirst) {
122- context.moveTo(x, y);
123- isFirst = false;
124- } else {
125- context.lineTo(x, y);
111+ // Draw timeseries - either all channels or single channel
112+ if (timeseriesYAll && timeseriesYAll.length > 0) {
113+ // Draw all channels with different colors
114+ const colors = [
115+ "#2196f3", // blue
116+ "#f44336", // red
117+ "#4caf50", // green
118+ "#ff9800", // orange
119+ "#9c27b0", // purple
120+ "#00bcd4", // cyan
121+ "#ffeb3b", // yellow
122+ "#795548", // brown
123+ ];
124+
125+ timeseriesYAll.forEach((channelY, channelIdx) => {
126+ context.strokeStyle = colors[channelIdx % colors.length];
127+ context.lineWidth = 1.5;
128+ context.beginPath();
129+
130+ let isFirst = true;
131+ for (let i = 0; i < timeseriesT.length; i++) {
132+ const x = margins.left + (timeseriesT[i] - xRange.min) * xScale;
133+ const y = margins.top + drawingHeight - (channelY[i] - yRange.min) * yScale;
134+ if (isFirst) {
135+ context.moveTo(x, y);
136+ isFirst = false;
137+ } else {
138+ context.lineTo(x, y);
139+ }
140+ }
141+ context.stroke();
142+ });
143+ } else {
144+ // Draw single channel
145+ context.strokeStyle = "#2196f3";
146+ context.lineWidth = 2;
147+ context.beginPath();
148+
149+ // Draw the path
150+ let isFirst = true;
151+ for (let i = 0; i < timeseriesT.length; i++) {
152+ const x = margins.left + (timeseriesT[i] - xRange.min) * xScale;
153+ const y =
154+ margins.top + drawingHeight - (timeseriesY[i] - yRange.min) * yScale;
155+ if (isFirst) {
156+ context.moveTo(x, y);
157+ isFirst = false;
158+ } else {
159+ context.lineTo(x, y);
160+ }
126161 }
127- }
128162
129- context.stroke();
163+ context.stroke();
164+ }
130165
131166 // Remove clipping before drawing ticks
132167 context.restore();
@@ -196,6 +231,7 @@ self.onmessage = (evt: MessageEvent) => {
196231 const {
197232 timeseriesT,
198233 timeseriesY,
234+ timeseriesYAll,
199235 width,
200236 height,
201237 margins,
@@ -205,6 +241,7 @@ self.onmessage = (evt: MessageEvent) => {
205241 renderTimeseries(
206242 timeseriesT,
207243 timeseriesY,
244+ timeseriesYAll,
208245 width,
209246 height,
210247 margins,
web-ui/src/components/dataset/WorkerTypes.tsmodified+1−0View file
@@ -16,6 +16,7 @@ export type WorkerMessage =
1616 type: "render";
1717 timeseriesT: number[];
1818 timeseriesY: number[];
19+ timeseriesYAll?: number[][]; // For multi-channel overlay
1920 width: number;
2021 height: number;
2122 margins: Margins;
web-ui/src/hooks/TimeseriesDataClient.tsmodified+29−8View file
@@ -23,6 +23,7 @@ const TypedArrayConstructors = {
2323
2424 export class TimeseriesDataClient {
2525 private shape: number = 0;
26+ private numChannels: number = 1;
2627 private dtype: DType | null = null;
2728 private chunkSize: number;
2829 private cache: ChunkCache = {};
@@ -62,7 +63,15 @@ export class TimeseriesDataClient {
6263 throw new Error(`Failed to fetch dataset info: ${response.statusText}`);
6364 }
6465 const info = await response.json();
65- this.shape = info.shape[0];
66+
67+ // Handle multi-dimensional shape: [num_timepoints, num_channels]
68+ if (Array.isArray(info.shape)) {
69+ this.shape = info.shape[0];
70+ this.numChannels = info.shape.length > 1 ? info.shape[1] : 1;
71+ } else {
72+ this.shape = info.shape;
73+ this.numChannels = 1;
74+ }
6675
6776 if (!this.isValidDType(info.dtype)) {
6877 throw new Error(`Unsupported data type: ${info.dtype}`);
@@ -106,8 +115,9 @@ export class TimeseriesDataClient {
106115 const end = Math.min(start + this.chunkSize, this.shape);
107116 const url = this.datasetDataUrl;
108117 const itemSize = TypedArrayConstructors[this.dtype].BYTES_PER_ELEMENT;
109- const byteStart = start * itemSize;
110- const byteEnd = end * itemSize;
118+ // Account for multi-channel data: each timepoint has numChannels values
119+ const byteStart = start * this.numChannels * itemSize;
120+ const byteEnd = end * this.numChannels * itemSize;
111121
112122 try {
113123 const response = await fetch(url, {
@@ -135,11 +145,15 @@ export class TimeseriesDataClient {
135145 return fetchPromise;
136146 }
137147
138- async fetchRange(start: number, end: number): Promise<SupportedTypedArray> {
148+ async fetchRange(start: number, end: number, channel: number = 0): Promise<SupportedTypedArray> {
139149 if (!this.dtype) {
140150 throw new Error("Data type not initialized");
141151 }
142152
153+ if (channel < 0 || channel >= this.numChannels) {
154+ throw new Error(`Invalid channel ${channel}. Must be between 0 and ${this.numChannels - 1}`);
155+ }
156+
143157 const chunkIndices = this.getChunkIndices(start, end);
144158 const chunks = await Promise.all(
145159 chunkIndices.map((idx) => this.fetchChunk(idx)),
@@ -156,10 +170,13 @@ export class TimeseriesDataClient {
156170 const chunk = chunks[i];
157171 const chunkStart = chunkIndices[i] * this.chunkSize;
158172 const copyStart = Math.max(0, start - chunkStart);
159- const copyEnd = Math.min(chunk.length, end - chunkStart);
160- const copyLength = copyEnd - copyStart;
161- result.set(chunk.subarray(copyStart, copyEnd), resultOffset);
162- resultOffset += copyLength;
173+ const copyEnd = Math.min(chunk.length / this.numChannels, end - chunkStart);
174+
175+ // Extract the selected channel from interleaved data
176+ for (let t = copyStart; t < copyEnd; t++) {
177+ const sourceIdx = t * this.numChannels + channel;
178+ result[resultOffset++] = chunk[sourceIdx];
179+ }
163180 }
164181
165182 return result;
@@ -172,4 +189,8 @@ export class TimeseriesDataClient {
172189 getDType(): DType | null {
173190 return this.dtype;
174191 }
192+
193+ getNumChannels(): number {
194+ return this.numChannels;
195+ }
175196 }