/ concept-collection / ephys_compression_tests
Sign in
concept-collection / ephys_compression_tests
ephys_compression_tests / web-ui / src / components / dataset / TimeseriesViewWorker.ts
276 lines · 7.7 KBCodeBlameHistory
552a4baadd web-uiJeremy Magland 1// Web worker for rendering timeseries data to canvas
3import { Margins, Range, WorkerMessage } from "./WorkerTypes";
5// Helper function to find a nice integer tick interval
6function getNiceTickInterval(range: number, maxTicks: number): number {
7 const minInterval = Math.ceil(range / maxTicks);
8 if (minInterval <= 1) return 1;
10 const magnitude = Math.pow(10, Math.floor(Math.log10(minInterval)));
11 const niceIntervals = [1, 2, 5, 10];
13 for (const interval of niceIntervals) {
14 const tickInterval = interval * magnitude;
15 if (tickInterval >= minInterval) {
16 return Math.ceil(tickInterval);
17 }
18 }
19 return Math.ceil(niceIntervals[niceIntervals.length - 1] * magnitude * 10);
22// Helper function to estimate the width of a number in pixels
23// This is an approximation since we can't measure text width directly in a worker
24function estimateNumberWidth(num: number): number {
25 const numStr = Math.abs(num).toString();
26 const digitWidth = 8; // Approximate width of a digit in pixels
27 const padding = 4; // Padding between numbers
28 return (numStr.length + (num < 0 ? 1 : 0)) * digitWidth + padding;
31// Helper function to get tick positions
32function getTickPositions(
33 range: Range,
34 width: number,
35 considerNumberWidth = false, // Only true for x-axis where we need to handle large integers
36): { value: number; x: number }[] {
37 let pixelsPerTick = 20; // Default minimum pixels between ticks
39 if (considerNumberWidth) {
40 // For x-axis, calculate spacing based on largest number width
41 const maxAbsValue = Math.max(Math.abs(range.min), Math.abs(range.max));
42 const maxNumberWidth = estimateNumberWidth(maxAbsValue);
43 pixelsPerTick = Math.max(maxNumberWidth, 20); // Use the larger of estimated width or minimum spacing
44 }
45 const maxTicks = Math.floor(width / pixelsPerTick);
46 const tickInterval = getNiceTickInterval(range.max - range.min, maxTicks);
48 const firstTick = Math.ceil(range.min / tickInterval) * tickInterval;
49 const lastTick = Math.floor(range.max);
51 const ticks: { value: number; x: number }[] = [];
52 for (let value = firstTick; value <= lastTick; value += tickInterval) {
53 const x = (value - range.min) / (range.max - range.min);
54 if (Number.isInteger(value)) {
55 ticks.push({ value, x });
56 }
57 }
59 return ticks;
62let canvas: OffscreenCanvas | null = null;
63let ctx: OffscreenCanvasRenderingContext2D | null = null;
65function renderTimeseries(
66 timeseriesT: number[],
67 timeseriesY: number[],
c6f25e2multi-channel in uiJeremy Magland 68 timeseriesYAll: number[][] | undefined,
552a4baadd web-uiJeremy Magland 69 width: number,
70 height: number,
71 margins: Margins,
72 xRange: Range,
73 yRange: Range,
74) {
75 if (!ctx || !canvas) return;
77 const context = ctx; // Create a stable reference to satisfy TypeScript
79 // Clear canvas
80 context.clearRect(0, 0, width, height);
82 // Draw axes
83 context.strokeStyle = "#666666";
84 context.lineWidth = 1;
85 context.beginPath();
87 // Y axis
88 context.moveTo(margins.left, margins.top);
89 context.lineTo(margins.left, height - margins.bottom);
91 // X axis
92 context.moveTo(margins.left, height - margins.bottom);
93 context.lineTo(width - margins.right, height - margins.bottom);
95 context.stroke();
97 // Calculate the drawing area dimensions
98 const drawingWidth = width - margins.left - margins.right;
99 const drawingHeight = height - margins.top - margins.bottom;
101 // Set up clipping region for timeseries
102 context.save();
103 context.beginPath();
104 context.rect(margins.left, margins.top, drawingWidth, drawingHeight);
105 context.clip();
107 // Calculate scaling factors
108 const xScale = drawingWidth / (xRange.max - xRange.min);
109 const yScale = drawingHeight / (yRange.max - yRange.min);
c6f25e2multi-channel in uiJeremy Magland 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 ];
125 timeseriesYAll.forEach((channelY, channelIdx) => {
126 context.strokeStyle = colors[channelIdx % colors.length];
127 context.lineWidth = 1.5;
128 context.beginPath();
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();
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 }
552a4baadd web-uiJeremy Magland 161 }
c6f25e2multi-channel in uiJeremy Magland 163 context.stroke();
164 }
552a4baadd web-uiJeremy Magland 165
166 // Remove clipping before drawing ticks
167 context.restore();
169 // Draw Y-axis ticks and labels
170 const yTicks = getTickPositions(yRange, drawingHeight);
172 context.textAlign = "right";
173 context.textBaseline = "middle";
174 context.fillStyle = "#666666";
175 context.font = "12px Arial";
177 yTicks.forEach((tick) => {
178 const y = margins.top + drawingHeight - tick.x * drawingHeight;
180 // Draw tick mark
181 context.beginPath();
182 context.moveTo(margins.left - 6, y);
183 context.lineTo(margins.left, y);
184 context.stroke();
186 // Draw label
187 context.fillText(tick.value.toString(), margins.left - 8, y);
188 });
190 // Draw X-axis ticks and labels
191 const ticks = getTickPositions(xRange, drawingWidth, true); // Consider number width for x-axis
193 context.textAlign = "center";
194 context.textBaseline = "top";
195 context.fillStyle = "#666666";
196 context.font = "12px Arial";
198 ticks.forEach((tick) => {
199 const x = margins.left + tick.x * drawingWidth;
201 // Draw tick mark
202 context.beginPath();
203 context.moveTo(x, height - margins.bottom);
204 context.lineTo(x, height - margins.bottom + 6);
205 context.stroke();
207 // Draw label
208 context.fillText(tick.value.toString(), x, height - margins.bottom + 8);
209 });
212self.onmessage = (evt: MessageEvent) => {
213 const message = evt.data as WorkerMessage;
215 if (message.type === "initialize") {
216 canvas = message.canvas;
217 ctx = canvas.getContext("2d");
218 if (!ctx) {
219 self.postMessage({
220 type: "error",
221 error: "Failed to get canvas context",
222 });
223 return;
224 }
225 self.postMessage({ type: "initialized" });
226 return;
227 }
229 if (message.type === "render") {
230 throttleRender(() => {
231 const {
232 timeseriesT,
233 timeseriesY,
c6f25e2multi-channel in uiJeremy Magland 234 timeseriesYAll,
552a4baadd web-uiJeremy Magland 235 width,
236 height,
237 margins,
238 xRange,
239 yRange,
240 } = message;
241 renderTimeseries(
242 timeseriesT,
243 timeseriesY,
c6f25e2multi-channel in uiJeremy Magland 244 timeseriesYAll,
552a4baadd web-uiJeremy Magland 245 width,
246 height,
247 margins,
248 xRange,
249 yRange,
250 );
251 self.postMessage({ type: "render_complete" });
252 });
253 return;
254 }
255};
257let renderStack: (() => void)[] = [];
258let lastRenderTime = 0;
260const throttleRender = (callback: () => void) => {
261 renderStack.push(callback);
262 const checkRender = () => {
263 if (renderStack.length === 0) return;
264 const elapsed = Date.now() - lastRenderTime;
265 if (elapsed > 100) {
266 lastRenderTime = Date.now();
267 renderStack[renderStack.length - 1]();
268 renderStack = [];
269 } else {
270 setTimeout(checkRender, 150);
271 }
272 };
273 checkRender();
274};
276export {}; // Needed for TypeScript modules
moveopenescclose