/ concept-collection / ephys_compression_tests
Sign in
concept-collection / ephys_compression_tests
ephys_compression_tests / web-ui / src / components / dataset / TimeseriesViewWorker.ts
239 lines · 6.6 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[],
68 width: number,
69 height: number,
70 margins: Margins,
71 xRange: Range,
72 yRange: Range,
73) {
74 if (!ctx || !canvas) return;
76 const context = ctx; // Create a stable reference to satisfy TypeScript
78 // Clear canvas
79 context.clearRect(0, 0, width, height);
81 // Draw axes
82 context.strokeStyle = "#666666";
83 context.lineWidth = 1;
84 context.beginPath();
86 // Y axis
87 context.moveTo(margins.left, margins.top);
88 context.lineTo(margins.left, height - margins.bottom);
90 // X axis
91 context.moveTo(margins.left, height - margins.bottom);
92 context.lineTo(width - margins.right, height - margins.bottom);
94 context.stroke();
96 // Calculate the drawing area dimensions
97 const drawingWidth = width - margins.left - margins.right;
98 const drawingHeight = height - margins.top - margins.bottom;
100 // Set up clipping region for timeseries
101 context.save();
102 context.beginPath();
103 context.rect(margins.left, margins.top, drawingWidth, drawingHeight);
104 context.clip();
106 // Set up drawing style for timeseries
107 context.strokeStyle = "#2196f3";
108 context.lineWidth = 2;
109 context.beginPath();
111 // Calculate scaling factors
112 const xScale = drawingWidth / (xRange.max - xRange.min);
113 const yScale = drawingHeight / (yRange.max - yRange.min);
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);
126 }
127 }
129 context.stroke();
131 // Remove clipping before drawing ticks
132 context.restore();
134 // Draw Y-axis ticks and labels
135 const yTicks = getTickPositions(yRange, drawingHeight);
137 context.textAlign = "right";
138 context.textBaseline = "middle";
139 context.fillStyle = "#666666";
140 context.font = "12px Arial";
142 yTicks.forEach((tick) => {
143 const y = margins.top + drawingHeight - tick.x * drawingHeight;
145 // Draw tick mark
146 context.beginPath();
147 context.moveTo(margins.left - 6, y);
148 context.lineTo(margins.left, y);
149 context.stroke();
151 // Draw label
152 context.fillText(tick.value.toString(), margins.left - 8, y);
153 });
155 // Draw X-axis ticks and labels
156 const ticks = getTickPositions(xRange, drawingWidth, true); // Consider number width for x-axis
158 context.textAlign = "center";
159 context.textBaseline = "top";
160 context.fillStyle = "#666666";
161 context.font = "12px Arial";
163 ticks.forEach((tick) => {
164 const x = margins.left + tick.x * drawingWidth;
166 // Draw tick mark
167 context.beginPath();
168 context.moveTo(x, height - margins.bottom);
169 context.lineTo(x, height - margins.bottom + 6);
170 context.stroke();
172 // Draw label
173 context.fillText(tick.value.toString(), x, height - margins.bottom + 8);
174 });
177self.onmessage = (evt: MessageEvent) => {
178 const message = evt.data as WorkerMessage;
180 if (message.type === "initialize") {
181 canvas = message.canvas;
182 ctx = canvas.getContext("2d");
183 if (!ctx) {
184 self.postMessage({
185 type: "error",
186 error: "Failed to get canvas context",
187 });
188 return;
189 }
190 self.postMessage({ type: "initialized" });
191 return;
192 }
194 if (message.type === "render") {
195 throttleRender(() => {
196 const {
197 timeseriesT,
198 timeseriesY,
199 width,
200 height,
201 margins,
202 xRange,
203 yRange,
204 } = message;
205 renderTimeseries(
206 timeseriesT,
207 timeseriesY,
208 width,
209 height,
210 margins,
211 xRange,
212 yRange,
213 );
214 self.postMessage({ type: "render_complete" });
215 });
216 return;
217 }
218};
220let renderStack: (() => void)[] = [];
221let lastRenderTime = 0;
223const throttleRender = (callback: () => void) => {
224 renderStack.push(callback);
225 const checkRender = () => {
226 if (renderStack.length === 0) return;
227 const elapsed = Date.now() - lastRenderTime;
228 if (elapsed > 100) {
229 lastRenderTime = Date.now();
230 renderStack[renderStack.length - 1]();
231 renderStack = [];
232 } else {
233 setTimeout(checkRender, 150);
234 }
235 };
236 checkRender();
237};
239export {}; // Needed for TypeScript modules
moveopenescclose