249 lines
8.2 KiB
TypeScript
249 lines
8.2 KiB
TypeScript
export type DrawSample = { t: number; value: [number, number] };
|
|
|
|
function fmod(a: number, b: number) {
|
|
return ((a % b) + b) % b;
|
|
}
|
|
|
|
export class Oscillioscope {
|
|
private readonly ctx: CanvasRenderingContext2D;
|
|
private readonly timeCursor: HTMLElement;
|
|
settings = { scale: 5 };
|
|
private drawBuffer: DrawSample[] = [];
|
|
private drawImageData: ImageData | null = null;
|
|
|
|
constructor(canvas: HTMLCanvasElement, timeCursor: HTMLElement) {
|
|
this.ctx = canvas.getContext("2d", { alpha: false })!;
|
|
this.timeCursor = timeCursor;
|
|
}
|
|
|
|
private getXpos(t: number): number {
|
|
return t / (1 << this.settings.scale);
|
|
}
|
|
private getTimeFromXpos(x: number): number {
|
|
return x * (1 << this.settings.scale);
|
|
}
|
|
addToBuffer(data: DrawSample[], sample: number) {
|
|
this.drawBuffer = this.drawBuffer.concat(data);
|
|
// prevent buffer accumulation when tab inactive
|
|
const maxDrawBufferSize = this.getTimeFromXpos(this.ctx.canvas.width) - 1;
|
|
if (sample - this.drawBuffer[this.drawBuffer.length >> 1].t > maxDrawBufferSize) {
|
|
// reasonable lazy cap
|
|
this.drawBuffer = this.drawBuffer.slice(this.drawBuffer.length >> 1);
|
|
} else if (this.drawBuffer.length > maxDrawBufferSize) {
|
|
// emergency cap
|
|
this.drawBuffer = this.drawBuffer.slice(-maxDrawBufferSize);
|
|
}
|
|
}
|
|
|
|
clear(clear = true) {
|
|
this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
|
|
if (clear) {
|
|
this.clearBuffer();
|
|
}
|
|
}
|
|
private clearBuffer() {
|
|
this.drawBuffer = [];
|
|
this.drawImageData = null;
|
|
}
|
|
|
|
draw(endTime: number, playingForward: boolean) {
|
|
const { width, height } = this.ctx.canvas;
|
|
|
|
const bufferLen = this.drawBuffer.length;
|
|
if (!bufferLen) {
|
|
return;
|
|
}
|
|
|
|
let startTime = this.drawBuffer[0].t + (this.drawBuffer.carry ? 1 : 0);
|
|
let lenTime = endTime - startTime;
|
|
let startXPos = fmod(this.getXpos(startTime), width);
|
|
let endXPos = startXPos + this.getXpos(lenTime);
|
|
|
|
{
|
|
let drawStartX = Math.floor(startXPos);
|
|
let drawEndX = Math.floor(endXPos);
|
|
let drawLenX = Math.abs(drawEndX - drawStartX) + 1;
|
|
let drawOverflow = false;
|
|
// clip draw area if too large
|
|
if (drawLenX > width) {
|
|
// TODO: put this into a better section so the variables don't all have to be set again
|
|
startTime = this.getTimeFromXpos(this.getXpos(endTime) - width);
|
|
let sliceIndex = 0;
|
|
for (let i in this.drawBuffer) {
|
|
// TODO: replace this with binary search
|
|
if ((this.drawBuffer[i + 1]?.t ?? endTime) <= startTime - 1) {
|
|
sliceIndex += 1;
|
|
} else {
|
|
this.drawBuffer[i].t = startTime - 1;
|
|
this.drawBuffer[i].carry = true;
|
|
this.drawBuffer = this.drawBuffer.slice(sliceIndex);
|
|
break;
|
|
}
|
|
}
|
|
lenTime = endTime - startTime;
|
|
startXPos = fmod(this.getXpos(startTime), width);
|
|
endXPos = startXPos + this.getXpos(lenTime);
|
|
drawStartX = Math.ceil(startXPos); // this is a bit of a hack, since this doesn't relate to the other variables properly
|
|
// i can only get away with this because the other vars like startTime and such aren't used
|
|
// the proper solution would be to somehow round up startTime by a pixel
|
|
drawEndX = Math.floor(endXPos);
|
|
drawLenX = Math.abs(drawEndX - drawStartX) + 1;
|
|
drawOverflow = true;
|
|
}
|
|
|
|
const imagePos = Math.min(drawStartX, drawEndX);
|
|
// create imageData
|
|
let imageData = this.ctx.createImageData(drawLenX, height);
|
|
// create / add drawimageData
|
|
if (this.settings.scale) {
|
|
// full zoom can't have multiple samples on one pixel
|
|
if (this.drawImageData) {
|
|
if (!drawOverflow) {
|
|
// fill in starting area of image data with previously drawn samples
|
|
let x = playingForward ? 0 : drawLenX - 1;
|
|
for (let y = 0; y < height; y++) {
|
|
imageData.data[((drawLenX * y + x) << 2) + 0] =
|
|
this.drawImageData.data[(y << 2) + 0];
|
|
imageData.data[((drawLenX * y + x) << 2) + 1] =
|
|
this.drawImageData.data[(y << 2) + 1];
|
|
imageData.data[((drawLenX * y + x) << 2) + 2] =
|
|
this.drawImageData.data[(y << 2) + 2];
|
|
}
|
|
}
|
|
} else {
|
|
this.drawImageData = this.ctx.createImageData(1, height);
|
|
}
|
|
} else {
|
|
this.drawImageData = null;
|
|
}
|
|
// set alpha
|
|
// TODO flatten
|
|
for (let x = 0; x < drawLenX; x++) {
|
|
for (let y = 0; y < height; y++) {
|
|
imageData.data[((drawLenX * y + x) << 2) + 3] = 255;
|
|
}
|
|
}
|
|
// draw
|
|
const iterateOverHorizontalLine = (
|
|
bufferElem: DrawSample,
|
|
nextBufferElemTime: number,
|
|
callback: (xPos: number) => void,
|
|
initCallback?: (xPos: number) => void,
|
|
) => {
|
|
const startX = fmod(
|
|
Math.floor(this.getXpos(playingForward ? bufferElem.t : nextBufferElemTime + 1)) -
|
|
imagePos,
|
|
width,
|
|
);
|
|
const endX = fmod(
|
|
Math.ceil(this.getXpos(playingForward ? nextBufferElemTime : bufferElem.t + 1)) -
|
|
imagePos,
|
|
width,
|
|
);
|
|
if (initCallback) {
|
|
initCallback(startX);
|
|
}
|
|
for (let xPos = startX; xPos !== endX; xPos = fmod(xPos + 1, width)) {
|
|
callback(xPos);
|
|
}
|
|
};
|
|
|
|
for (let i = this.drawBuffer[0].t < startTime ? 1 : 0; i < bufferLen; i++) {
|
|
let lastBufferElem = this.drawBuffer[i - 1] ?? null;
|
|
let bufferElem = this.drawBuffer[i];
|
|
let nextBufferElemTime = this.drawBuffer[i + 1]?.t ?? endTime;
|
|
if (isNaN(bufferElem.value[0]) || isNaN(bufferElem.value[1])) {
|
|
iterateOverHorizontalLine(bufferElem, nextBufferElemTime, xPos => {
|
|
for (let h = 0; h < 256; h++) {
|
|
const pos = (drawLenX * h + xPos) << 2;
|
|
imageData.data[pos] = 96;
|
|
}
|
|
});
|
|
}
|
|
for (let c = 0; c < 2; c++) {
|
|
if (bufferElem.value[c] >= 0 && bufferElem.value[c] < 256) {
|
|
// NaN check is implicit here
|
|
iterateOverHorizontalLine(
|
|
bufferElem,
|
|
nextBufferElemTime,
|
|
xPos => {
|
|
const pos = (drawLenX * (255 - bufferElem.value[c]) + xPos) << 2;
|
|
if (c) {
|
|
imageData.data[pos] = imageData.data[pos + 2] = 255;
|
|
} else {
|
|
imageData.data[pos] = 0; // clear out NaN red
|
|
imageData.data[pos + 1] = 255;
|
|
}
|
|
},
|
|
// Waveform draw mode connectors
|
|
lastBufferElem && !isNaN(lastBufferElem.value[c])
|
|
? xPos => {
|
|
const dir = lastBufferElem.value[c] < bufferElem.value[c] ? -1 : 1;
|
|
for (
|
|
let h = 255 - lastBufferElem.value[c];
|
|
h !== 255 - bufferElem.value[c];
|
|
h += dir
|
|
) {
|
|
const pos = (drawLenX * h + xPos) << 2;
|
|
if (imageData.data[pos] === 0) {
|
|
// don't overwrite filled cells
|
|
if (c) {
|
|
imageData.data[pos] = imageData.data[pos + 2] = 140;
|
|
} else {
|
|
imageData.data[pos] = 0; // clear out NaN red
|
|
imageData.data[pos + 1] = 140;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
: () => {},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
// put imageData
|
|
this.ctx.putImageData(imageData, imagePos, 0);
|
|
if (endXPos >= width) {
|
|
this.ctx.putImageData(imageData, imagePos - width, 0);
|
|
} else if (endXPos < 0) {
|
|
this.ctx.putImageData(imageData, imagePos + width, 0);
|
|
}
|
|
// write to drawImageData
|
|
if (this.settings.scale) {
|
|
// full zoom can't have multiple samples on one pixel
|
|
const x = playingForward ? drawLenX - 1 : 0;
|
|
for (let y = 0; y < height; y++) {
|
|
this.drawImageData.data[y << 2] = imageData.data[(drawLenX * y + x) << 2];
|
|
this.drawImageData.data[(y << 2) + 1] =
|
|
imageData.data[((drawLenX * y + x) << 2) + 1];
|
|
this.drawImageData.data[(y << 2) + 2] =
|
|
imageData.data[((drawLenX * y + x) << 2) + 2];
|
|
}
|
|
}
|
|
}
|
|
|
|
// clear buffer except last sample
|
|
this.drawBuffer = [{ t: endTime, value: this.drawBuffer[bufferLen - 1].value, carry: true }];
|
|
}
|
|
|
|
// TODO don't make this depend on play direction
|
|
moveTimeCursor(time: number, playingForward: boolean) {
|
|
if (!this.timeCursor.classList.contains("disabled")) {
|
|
const width = this.ctx.canvas.width;
|
|
if (playingForward) {
|
|
this.timeCursor.style.removeProperty("right");
|
|
this.timeCursor.style.left = `${
|
|
(fmod(Math.ceil(this.getXpos(time)), width) / width) * 100
|
|
}%`;
|
|
} else {
|
|
this.timeCursor.style.removeProperty("left");
|
|
this.timeCursor.style.right = `${
|
|
(1 - (fmod(Math.ceil(this.getXpos(time)), width) + 1) / width) * 100
|
|
}%`;
|
|
}
|
|
}
|
|
}
|
|
showTimeCursor(show: boolean) {
|
|
this.timeCursor.classList.toggle("disabled", !show);
|
|
}
|
|
}
|