8d96984Update benchmark results from 2026-06-16 14:21:30 [skip ci]GitHub Actions Bot 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);
20}
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;
29}
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;
60}
62let canvas: OffscreenCanvas | null = null;
63let ctx: OffscreenCanvasRenderingContext2D | null = null;
65function renderTimeseries(
66 timeseriesT: number[],
67 timeseriesY: number[],
68 timeseriesYAll: number[][] | undefined,
69 timeseriesYReconstructed: number[] | undefined,
70 timeseriesYResiduals: number[] | undefined,
71 comparisonMode: string | undefined,
72 width: number,
73 height: number,
74 margins: Margins,
75 xRange: Range,
76 yRange: Range,
77) {
78 if (!ctx || !canvas) return;
80 const context = ctx; // Create a stable reference to satisfy TypeScript
82 // Clear canvas
83 context.clearRect(0, 0, width, height);
85 // Draw axes
86 context.strokeStyle = "#666666";
87 context.lineWidth = 1;
88 context.beginPath();
90 // Y axis
91 context.moveTo(margins.left, margins.top);
92 context.lineTo(margins.left, height - margins.bottom);
94 // X axis
95 context.moveTo(margins.left, height - margins.bottom);
96 context.lineTo(width - margins.right, height - margins.bottom);
98 context.stroke();
100 // Calculate the drawing area dimensions
101 const drawingWidth = width - margins.left - margins.right;
102 const drawingHeight = height - margins.top - margins.bottom;
104 // Set up clipping region for timeseries
105 context.save();
106 context.beginPath();
107 context.rect(margins.left, margins.top, drawingWidth, drawingHeight);
108 context.clip();
110 // Calculate scaling factors
111 const xScale = drawingWidth / (xRange.max - xRange.min);
112 const yScale = drawingHeight / (yRange.max - yRange.min);
114 // Draw timeseries based on comparison mode
115 const mode = comparisonMode || "original";
117 if (mode === "side-by-side" && timeseriesYReconstructed) {
118 // Split canvas vertically
119 const halfWidth = drawingWidth / 2;
121 // Draw original on left
122 context.strokeStyle = "#2196f3";
123 context.lineWidth = 2;
124 context.beginPath();
125 for (let i = 0; i < timeseriesT.length; i++) {
126 const x = margins.left + ((timeseriesT[i] - xRange.min) * halfWidth) / (xRange.max - xRange.min);
127 const y = margins.top + drawingHeight - (timeseriesY[i] - yRange.min) * yScale;
128 if (i === 0) context.moveTo(x, y);
129 else context.lineTo(x, y);
130 }
131 context.stroke();
133 // Draw reconstructed on right
134 context.strokeStyle = "#ff9800"; // orange
135 context.lineWidth = 2;
136 context.beginPath();
137 for (let i = 0; i < timeseriesT.length; i++) {
138 const x = margins.left + halfWidth + ((timeseriesT[i] - xRange.min) * halfWidth) / (xRange.max - xRange.min);
139 const y = margins.top + drawingHeight - (timeseriesYReconstructed[i] - yRange.min) * yScale;
140 if (i === 0) context.moveTo(x, y);
141 else context.lineTo(x, y);
142 }
143 context.stroke();
145 // Draw divider line
146 context.strokeStyle = "#999";
147 context.lineWidth = 1;
148 context.beginPath();
149 context.moveTo(margins.left + halfWidth, margins.top);
150 context.lineTo(margins.left + halfWidth, height - margins.bottom);
151 context.stroke();
152 } else if (mode === "overlay" && timeseriesYReconstructed) {
153 // Draw original in blue
154 context.strokeStyle = "#2196f3";
155 context.lineWidth = 2;
156 context.beginPath();
157 for (let i = 0; i < timeseriesT.length; i++) {
158 const x = margins.left + (timeseriesT[i] - xRange.min) * xScale;
159 const y = margins.top + drawingHeight - (timeseriesY[i] - yRange.min) * yScale;
160 if (i === 0) context.moveTo(x, y);
161 else context.lineTo(x, y);
162 }
163 context.stroke();
165 // Draw reconstructed in orange
166 context.strokeStyle = "#ff9800";
167 context.lineWidth = 2;
168 context.beginPath();
169 for (let i = 0; i < timeseriesT.length; i++) {
170 const x = margins.left + (timeseriesT[i] - xRange.min) * xScale;
171 const y = margins.top + drawingHeight - (timeseriesYReconstructed[i] - yRange.min) * yScale;
172 if (i === 0) context.moveTo(x, y);
173 else context.lineTo(x, y);
174 }
175 context.stroke();
176 } else if (mode === "residuals" && timeseriesYResiduals) {
177 // Draw residuals with diverging colors
178 context.lineWidth = 2;
179 context.beginPath();
181 // Draw zero line
182 const zeroY = margins.top + drawingHeight - (0 - yRange.min) * yScale;
183 context.strokeStyle = "#999";
184 context.lineWidth = 1;
185 context.setLineDash([4, 4]);
186 context.moveTo(margins.left, zeroY);
187 context.lineTo(width - margins.right, zeroY);
188 context.stroke();
189 context.setLineDash([]);
191 // Draw residuals
192 context.strokeStyle = "#9c27b0"; // purple for residuals
193 context.lineWidth = 2;
194 context.beginPath();
195 for (let i = 0; i < timeseriesT.length; i++) {
196 const x = margins.left + (timeseriesT[i] - xRange.min) * xScale;
197 const y = margins.top + drawingHeight - (timeseriesYResiduals[i] - yRange.min) * yScale;
198 if (i === 0) context.moveTo(x, y);
199 else context.lineTo(x, y);
200 }
201 context.stroke();
202 } else if (timeseriesYAll && timeseriesYAll.length > 0) {
203 // Draw all channels with different colors
204 const colors = [
205 "#2196f3", // blue
206 "#f44336", // red
207 "#4caf50", // green
208 "#ff9800", // orange
209 "#9c27b0", // purple
210 "#00bcd4", // cyan
211 "#ffeb3b", // yellow
212 "#795548", // brown
213 ];
215 timeseriesYAll.forEach((channelY, channelIdx) => {
216 context.strokeStyle = colors[channelIdx % colors.length];
217 context.lineWidth = 1.5;
218 context.beginPath();
220 for (let i = 0; i < timeseriesT.length; i++) {
221 const x = margins.left + (timeseriesT[i] - xRange.min) * xScale;
222 const y = margins.top + drawingHeight - (channelY[i] - yRange.min) * yScale;
223 if (i === 0) context.moveTo(x, y);
224 else context.lineTo(x, y);
225 }
226 context.stroke();
227 });
228 } else {
229 // Draw single channel - original only
230 context.strokeStyle = "#2196f3";
231 context.lineWidth = 2;
232 context.beginPath();
234 for (let i = 0; i < timeseriesT.length; i++) {
235 const x = margins.left + (timeseriesT[i] - xRange.min) * xScale;
236 const y = margins.top + drawingHeight - (timeseriesY[i] - yRange.min) * yScale;
237 if (i === 0) context.moveTo(x, y);
238 else context.lineTo(x, y);
239 }
240 context.stroke();
241 }
243 // Remove clipping before drawing ticks
244 context.restore();
246 // Draw Y-axis ticks and labels
247 const yTicks = getTickPositions(yRange, drawingHeight);
249 context.textAlign = "right";
250 context.textBaseline = "middle";
251 context.fillStyle = "#666666";
252 context.font = "12px Arial";
254 yTicks.forEach((tick) => {
255 const y = margins.top + drawingHeight - tick.x * drawingHeight;
257 // Draw tick mark
258 context.beginPath();
259 context.moveTo(margins.left - 6, y);
260 context.lineTo(margins.left, y);
261 context.stroke();
263 // Draw label
264 context.fillText(tick.value.toString(), margins.left - 8, y);
265 });
267 // Draw X-axis ticks and labels
268 const ticks = getTickPositions(xRange, drawingWidth, true); // Consider number width for x-axis
270 context.textAlign = "center";
271 context.textBaseline = "top";
272 context.fillStyle = "#666666";
273 context.font = "12px Arial";
275 ticks.forEach((tick) => {
276 const x = margins.left + tick.x * drawingWidth;
278 // Draw tick mark
279 context.beginPath();
280 context.moveTo(x, height - margins.bottom);
281 context.lineTo(x, height - margins.bottom + 6);
282 context.stroke();
284 // Draw label
285 context.fillText(tick.value.toString(), x, height - margins.bottom + 8);
286 });
287}
289self.onmessage = (evt: MessageEvent) => {
290 const message = evt.data as WorkerMessage;
292 if (message.type === "initialize") {
293 canvas = message.canvas;
294 ctx = canvas.getContext("2d");
295 if (!ctx) {
296 self.postMessage({
297 type: "error",
298 error: "Failed to get canvas context",
299 });
300 return;
301 }
302 self.postMessage({ type: "initialized" });
303 return;
304 }
306 if (message.type === "render") {
307 throttleRender(() => {
308 const {
309 timeseriesT,
310 timeseriesY,
311 timeseriesYAll,
312 timeseriesYReconstructed,
313 timeseriesYResiduals,
314 comparisonMode,
315 width,
316 height,
317 margins,
318 xRange,
319 yRange,
320 } = message;
321 renderTimeseries(
322 timeseriesT,
323 timeseriesY,
324 timeseriesYAll,
325 timeseriesYReconstructed,
326 timeseriesYResiduals,
327 comparisonMode,
328 width,
329 height,
330 margins,
331 xRange,
332 yRange,
333 );
334 self.postMessage({ type: "render_complete" });
335 });
336 return;
337 }
338};
340let renderStack: (() => void)[] = [];
341let lastRenderTime = 0;
343const throttleRender = (callback: () => void) => {
344 renderStack.push(callback);
345 const checkRender = () => {
346 if (renderStack.length === 0) return;
347 const elapsed = Date.now() - lastRenderTime;
348 if (elapsed > 100) {
349 lastRenderTime = Date.now();
350 renderStack[renderStack.length - 1]();
351 renderStack = [];
352 } else {
353 setTimeout(checkRender, 150);
354 }
355 };
356 checkRender();
357};
359export {}; // Needed for TypeScript modules