Every now and again I get back to transcribing my old school notes as digital files on this site; which notes I work on really depends on my mood, and if you have read any of this site I’m sure you’re aware I’m more often than not in the mood for mathematics bullshit. To that end, one set of notes that’s been taunting me for months is that from my calculus classes (AP, II, and III). I’ve completed my differential equations before these due to one limitation: graphs. While there aren’t any graphs in my diffeq notes, there are plenty in my calculus notes, and there aren’t really any graphing libraries I like that can be used here. The closest I’ve found was embedding a Desmos graph, but there’s an issue with customization there and I really like consistent aesthetics.
Most JavaScript libraries for graphs are for data not mathematics, which is fine as I can see many more uses for those on the web than functions, but it puts me in this situation. There are a couple here and there that have gotten close to getting me to use them, but in the end I’ve opted for just writing my own.
For starters, I need some axes. Recently (perhaps more aplty “a little while
back”) I rewrote an old script to
work in canvas
, read all about that here. I’m not
going to reiterate the whole process of creating these axes as a lot of it is in
that blog post, but what I will talk about is the fact that those axes are very
specifically designed for that exact usage and nothing more. This calls for what
I like to call generalizing, in this case that means offsetting the previously
anchored axes by some arbitrary origin point. To make this all easier, I went
ahead and started creating constants based on an options
parameter of the form
const VARIABLE = options.variable || DEFAULT;
Because the original axes were explicitly showing the first quadrant, there were rendered as two strokes of a single path to avoid unnecessary complication, but I’m going to need all four quadrants at some point or another so that’ll have to change. This means taking the very simple
ctx.beginPath();
ctx.moveTo(left, top - 0.5);
ctx.lineTo(left, top + height);
ctx.lineTo(left + width + 0.5, top + height);
ctx.stroke();
and replacing it with a more complex
// y-axis
ctx.beginPath();
ctx.moveTo(left + (width*ORIGIN.x), top - 0.5);
ctx.lineTo(left + (width*ORIGIN.x), top + height);
ctx.stroke();
// x-axis
ctx.beginPath();
ctx.moveTo(left - 0.5, top + (height*ORIGIN.y));
ctx.lineTo(left + width, top + (height*ORIGIN.y));
ctx.stroke();
as well as sprinkling those (width*ORIGIN.x)
and (height*ORIGIN.y)
terms
throughout the rest of the code to offset everything. I should also note that,
as implied by its usage here, the ORIGIN
constant is an object of the form
{x:NUMBER,y:NUMBER}
where x
and y
are some fraction of the screen at which
the origin will be placed by width and height respectively.
The most notable place that these offsets need to be added besides the initial
axis renderings is the tick renderings. Just adding the position offsets makes
these ticks render along the axes just fine, but they still count up form zero
at one end up to some set maximum at the other, with the origin in the middle
somewhere. This is just wrong, so I need to offset the beginning of the ticks.
Doing this may make the position of those ticks rendered more correct but it
also produces two further problems: many of the ticks extend beyond the visible
region of the canvas and a large portion of the rendered axes is devoid of
ticks. Both of these can be fixed by slightly redoing how the ticks are counted,
setting a value for the total length of the axes and having it spanned in both
the positive and negative directions up to the edges of the canvas. With some
experimentation it has become evident that I did this wrong, but throwing in
some 1-
s seems to have fixed that.
With that the axes more or less match expected behavior and I can move on to the more exciting part of actually rendering a function. My very first assumption as to how this can be done is to loop across the $x$-axis and scale the pixel value into the actual mathematical value, plug that into the function, scale the result back into a pixel value, then use those values as a point on a path. It’s very rudimentary but there isn’t really any significant reason it wouldn’t work. With that said, the full implementation I came up with is here:
var scaleY = y=>(-1*y*(height/LABEL_RANGE_Y))+top+~~(height*ORIGIN.y),
scaleX = x=>(x*(width/LABEL_RANGE_X))+left+~~(width*ORIGIN.x),
invX = x=>((~~(width*ORIGIN.x-x-left))*(LABEL_RANGE_X/width));
ctx.lineWidth = FUNC_WIDTH;
ctx.strokeStyle = FUNC_COLOR;
ctx.beginPath();
ctx.moveTo(left-0.5,scaleY(f(invX(0))));
for(let i=left;i<=left+width;i++){
console.log(invX(i))
ctx.lineTo(i,scaleY(f(invX(i))));
}
ctx.lineTo(left+width,top+~~(height*ORIGIN.y));
ctx.stroke();
And now to find excuses why this sucks, actually. First of all, minor detail,
sometimes the tick labels get some floating point errors and just become so
excessively long. This is a quick fix, just add some ROUNDING
value so that
ctx.fillText(
(i/TICK_COUNT_X)*LABEL_RANGE_X,
x,top+(height*ORIGIN.y)+EXTEND_LENGTH+1
);
becomes
ctx.fillText(
(~~((i/TICK_COUNT_X)*LABEL_RANGE_X*(10**ROUNDING)))/(10**ROUNDING),
x,top+(height*ORIGIN.y)+EXTEND_LENGTH+1
);
which is a bit rough and sloppy but it gets the job done. Second, I want to test this using trigonometric functions, but those would look best if the $x$-axis is split into $2^n$ ticks and the $y$-axis into $n\cdot10$ ticks, which is only possible right now if those happen to be equivalent… that doesn’t happen very often so I’m going to add in separate settings for the $x$- and $y$-axes:
LABEL_RATE = options.labelRate || 10,
LABEL_RATE_X = options.labelRateX || LABEL_RATE,
LABEL_RATE_Y = options.labelRateY || LABEL_RATE,
Notice I have them defaulting to LABEL_RATE
so unless you set them manually
they act like they aren’t even there.
Ok, with the petty annoyances out of the way, I can get into the real testing of this. It absolutely hates the function $\sin\left(\frac1x\right)$ which is a surprise to absolutely nobody. The issue here is something called “aliasing,” something that should be quite familiar to most people reading this, I would assume. Essentially, the sample rate is lower than the frequency of the function so the full function isn’t rendered but a very butchered version of it based on what the samples could pick up. After searching far and wide for solutions that others have come up for this, I finally stumbled across a blog post called “Plotting High-frequency Functions Using a GPU” that discussed this exact situation. Of course, this is a completely different method of rendering the function, which means rewriting the entire function rendering code… all 14 lines of it… which I’m not exactly in the mood for…
Not only do I just “not feel like it” but the code provided by this post is all in some obscure language, seemingly developed by the author of the post, called Fragmetarium. Canvas isn’t really developed for pixel shaders, it’s developed for painting lines like I did in the first implementation. I’ve been searching various different ideas about this and the best I’m getting is something about painting image data from a source… oh, wait, there’s something about manipulating the pixel data of an image… this could be the right path. Ok, so I found a gist that takes the current state of the canvas and rewrites the pixel data. I believe the main point of this snippet itself is the usage of typed arrays for more efficient operation, which isn’t what I need it for but nonetheless is neat. The real meat of the code is this bit:
var imageData = ctx.getImageData(0, 0, canvasWidth, canvasHeight);
var buf = new ArrayBuffer(imageData.data.length);
var buf8 = new Uint8ClampedArray(buf);
var data = new Uint32Array(buf);
for (var y = 0; y < canvasHeight; ++y) {
for (var x = 0; x < canvasWidth; ++x) {
var value = dataset[y][x];
data[y * canvasWidth + x] =
(255 << 24) | // alpha
(value/2 << 16) | // blue
(value << 8) | // green
255; // red
}
}
imageData.data.set(buf8);
ctx.putImageData(imageData, 0, 0);
There are obvious references to dataset
which is created earlier in the code,
using d3.js, but it’s basically just a 2d array of random
numbers in the range $[0,255]$. Combining this with the calculation method of
the previously mentioned post, I should be able to come up with some actual
rendering code. Also, this will be painting all the pixels from scratch, so I
need to move this to before the axis rendering, which is again just a minor
change but very important. I’ll also need a way to convert from a $y$ position
in the pixel array to a mathematical $y$ position just as I did with $x$:
invY = y=>(((height*ORIGIN.y-y-top))*(LABEL_RANGE_Y/height)),
With that in place, I can use it to calculate whether or not the function exists at a particular point, basically just implementing the aforementioned pixel shader in JavaScript like so:
for(let y=0;y<height;++y)
for(let x=0;x<width;++x){
let count = 0,
samples = 0;
for(let i=0;i<SAMPLES;i++)
for(let j=0;j<SAMPLES;j++){
if (i*i+j*j>SAMPLES**2) continue;
samples++;
let val = f(invX(x+i*SAMPLE_SIZE))-invY(y+j*SAMPLE_SIZE);
count += val>0?1:-1;
}
// ...
}
This count
variable can then be used to calculate the color that a given pixel
should have, I’m just going to use the alpha channel for a standard red color
here because it’s the easiest to do:
var img = ctx.getImageData(0,0,width,height),
buf = new ArrayBuffer(img.data.length),
buf8 = new Uint8ClampedArray(buf),
data = new Uint32Array(buf);
for(let y=0;y<height;++y)
for(let x=0;x<width;++x){
let count = 0,
samples = 0;
// ...
let a = (Math.abs(count)/(SAMPLES**2))*255;
if(!(x%100)&&!(y%100))console.log(a)
data[y*width+x] =
(255-a << 24) | // alpha
(0 << 16) | // blue
(0 << 8) | // green
255; // red
}
img.data.set(buf8);
ctx.putImageData(img,PADDING_LEFT,PADDING_TOP);
Basically this is all just jamming code I found on the internet into the context of my own code, but y’know there’s a bunch of bugfixing work I’m not talking about because it’s really just like “oh no, that number is the inverse of what I had previously assumed” or “oops, that’s the wrong variable name” kind of shit.
Now that I know that this works, I can start tweaking it. First thing’s first,
colors. I’m not quite to the point of knowing how to get it to parse a custom
color code, but I can at least get it to properly render red without alpha
channels. Basically, everything not on the path should be white: FFFFFF
and
everything on the path should be red: FF0000
, so the blue and green values are
the inverse of how much it’s on the path (apparently a
is already this inverse
so that’s nice).
data[y*width+x] =
(255<<24)|// alpha
(a <<16)|// blue
(a << 8)|// green
(255<< 0);// red
A quick note I forgot to mention, this initially didn’t really look any better
than the path based renderer, but turns out that’s because I had a lot of ~~
s
scattered throughout the calculations. These were there so that straight lines
would look like solid lines instead of translucent, but in this case it means
that the aliasing remains. Without those there, certain settings are free to be
non-integer values. I’m reminded of this because I’m attempting to increase the
accuracy of the drawing. Initially I had SAMPLES
set to 3
and SAMPLE_SIZE
set to 0.5
, but by using more small samples (say setting these to 6
and
0.5
respectively) the graph can be cleaned up. I’m ignoring it for now but
adjusting these does change the width of the path, which will need to somehow be
addressed in the future to allow the width to be customized without also
affecting the aliasing.
This brings me to a much more tedious portion of this project: dragging. A big
consideration in the sample rate settings was efficiency, but it still isn’t the
most efficient code in the world. It would be nice to be able to run it in some
other context that wasn’t built so high level as JavaScript code in the browser
but that would contradict the whole point. Ignoring this for now, I’m just going
to write a basic implementation of a mouse drag effect. Obviously all the code
up until now will need to be packaged into its own function (draw()
) so that
it can be called multiple times as its dragged. Having done that, I can write a
function that sets everything up for dragging when the mouse is clicked on the
graph:
window.graph_drag_active = false;
window.graph_drag_origin = origin;
ctx.canvas.addEventListener("mousedown",e=>{
window.graph_drag_position = {x:e.pageX,y:e.pageY};
window.graph_drag_active = true;
});
Then just calculate the change in position whenever the mouse is moved after this initial click:
ctx.canvas.addEventListener("mousemove",e=>{
if(graph_drag_active){
var rect = ctx.canvas.getBoundingClientRect(),
dx = (e.pageX - graph_drag_position.x)/rect.width,
dy = (e.pageY - graph_drag_position.y)/rect.height;
draw({
x:graph_drag_origin.x + dx,
y:graph_drag_origin.y + dy
});
}
});
I initially had problems with writing this part because I kept stupidly writing
the origin
object as origin = {valueForX,valueForY}
. I don’t know how I went
and forgot extremely basic syntax but what can you do. After that everything
just needs to be cleared up when the mouse is lifted back up:
ctx.canvas.addEventListener("mouseup",e=>{
if(graph_drag_active){
var rect = ctx.canvas.getBoundingClientRect(),
dx = (e.pageX - graph_drag_position.x)/rect.width,
dy = (e.pageY - graph_drag_position.y)/rect.height;
window.graph_drag_origin = {
x:graph_drag_origin.x + dx,
y:graph_drag_origin.y + dy
};
draw(graph_drag_origin);
window.graph_drag_active = false;
}
});
This implementation lags horrendously, to the point where I almost thought it wasn’t even running at first. Evidently the rendering is too inefficient to even run on the fly like this with the expectation of slowness. My first thought is to just lower the sample rate while dragging:
ctx.canvas.addEventListener("mousedown",e=>{
// ...
window.graph_drag_temp = [SAMPLES,SAMPLE_SIZE];
SAMPLES = 2;
SAMPLE_SIZE = 1;
});
// ...
ctx.canvas.addEventListener("mouseup",e=>{
if(graph_drag_active){
// ...
SAMPLES = graph_drag_temp[0];
SAMPLE_SIZE = graph_drag_temp[1];
// ...
}
});
This runs, it’s still laggy, it looks really bad, but you can tell that it’s actually following the cursor when dragged. But really, what else can be done? I tried optimizing all the code, removing redundant operations, simplifying calls, whatever you want to call it. But it’s barely getting any better, is there any solution that will actually make this run fast enough to feel natural?
That’s a rhetorical question, obviously if I’m lowering the sample rate when dragging I’m willing to sacrifice accuracy for efficiency when interacting, and what method do we know that makes that exact exchange? That’s right, I’m bringing the original render method back! Basically this just means adding a conditional that’s set to true when dragging and false otherwise, trying to show the code change here would just be a real mess so just go read the commit page on GitHub.
With that, this is basically as functional as I feel it needs to be for the moment, there are still some things I would like to add. Namely zooming in and out with scroll, possibly keyboard controls, offsetting labels that would be cut off by the edge, multiple function rendering, custom graph colors, custom graph widths, … I could go on, but that’s all for now.