Graphing Calculator demo annotated source
Back to indexA 2D graphing calculator built with Torus
2
Bootstrap the required globals from Torus, since we're not bundling
4for (const exportedName in Torus) {
5 window[exportedName] = Torus[exportedName];
6}
7
A swatch of colors with enough contrast to be used for graphs and graph panels in the overlay sidebar.
10const COLORS = [
11 '#e05252',
12 '#685ebb',
13 '#649c41',
14 '#ab589d',
15 '#d08b36',
16 '#209e9e',
17 '#726f84',
18 '#58384e',
19];
We select colors by just cycling through the list, starting with the 0th one.
21let colorIdx = -1;
22
We evaluate user input as functions by transforming them into executable
JavaScript functions with the new Function()
constructor. Traditionally,
we'd discourage this because running this with untrusted strings is a security risk.
But since all inputs here are coming directly from the user's input field, it's ok.
This allows us to easily support a rich array of mathematical notations,
and here's a simple substitution list so rather than writing Math.tan(x)
in the
input field in the app, which would be weird, we can just write tan(x)
.
30const NOTATION_SUBSTITUTES = {
31 'abs': 'Math.abs',
32 'sqrt': 'Math.sqrt',
33 'log': 'Math.log',
34 'tan': 'Math.tan',
35 'sin': 'Math.sin',
36 'cos': 'Math.cos',
37 '\\^': '**',
38 'PI': 'Math.PI',
39}
40
To get the next color from the swatch, just increment the counter and get the item from the array of colors.
43const randomColor = () => {
44 colorIdx = (colorIdx + 1) % COLORS.length;
45 return COLORS[colorIdx];
46}
47
"clamp" a given value to the min and max, so that numbers are
bounded by the min/max ranges no matter how big or small, but reflect their
true values within the range. This is necessary because, as an optimization,
modern browsers don't render lines that are far outside of <canvas>
viewports.
But large graphs can explode in range, so rather than asking the browser to render,
say, (2, 300000)
and having it ignore the command because it's outside of the
visible area, we clamp the y-values to the window's width and height areas
so they're reasonably close to the visible area and browsers display large values correctly.
56const clamp = (val, min, max) => {
57 if (val > min) {
58 return val < max ? val : max;
59 } else {
60 return min;
61 }
62}
63
View model that syncs display settings between the graph
controls and the graph itself. This record keeps track of all data about
panning/zooming around the graph, and display settings like resolution
and whether
we try to detect and auto-fix vertical asymptotes.
68class GraphPropsRecord extends Record {
69
70 constructor() {
GraphPropsRecord
has centerX, centerY, and zoom properties.
centerX and Y and coordinate values, and zoom
is pixels per unit (i.e. if 20, 20 pixels on canvas
corresponds to one unit in the graph.) We initialize this record
with some default values.
76 super({
77 centerX: 0,
78 centerY: 0,
79 zoom: 100,
The units for this is "pixels per sample". i.e. If it's 5, we'll compute a new value every 5 pixels on the x-axis and connect the dots.
83 resolution: 5,
If this is set to true, we can try to detect large swings across the y = 0 line and not draw those lines.
86 detectAsymptotes: false,
87 });
88 }
89
We override the behavior of Record#update()
so we enforce an upper limit
of zoom level. Otherwise, graphs start to break meaninglessly.
92 update(dict) {
93 if (dict.zoom !== undefined) {
94 dict.zoom = dict.zoom < 10 ? 10 : dict.zoom;
95 }
96 super.update(dict);
97 }
98
In "high performance" mode, we render a y-value for every single pixel along the width of the screen. On slower devices this might be an issue with many functions, though I haven't found any issues yet. This toggles whether we do that, or just render every 5 pixels like normal.
103 toggleHighPerf() {
104 this.update({
105 resolution: this.get('resolution') === 5 ? 1 : 5,
106 });
107 }
108
109 toggleDetectAsymptotes() {
110 this.update({
111 detectAsymptotes: !this.get('detectAsymptotes'),
112 });
113 }
114
115}
116
This represents a single function the user defines, to be drawn in the graph region.
119class FunctionRecord extends Record {
120
121 constructor(...args) {
122 super(...args);
By default, functions have a random color, are not hidden, and are not invalid.
124 this.update({
125 color: randomColor(),
126 hidden: false,
invalid
means the user input was not a valid, computable function.
128 invalid: false,
129 });
130 }
131
We override this so we can inject two extra values: jsFunction
, which is a runnable
JavaScript function object that does f(x) -> y
computation, and invalid
, which is explained above.
134 summarize() {
135 let invalid = false;
136 let fn = () => 0;
137 let substitutedText = this.get('text');
We make the substitutions for things like sin(x) -> Math.sin(x)
so the JavaScript
engine can run it like normal JS functions.
140 for (const [regex, sub] of Object.entries(NOTATION_SUBSTITUTES)) {
141 substitutedText = substitutedText.replace(new RegExp(regex, 'g'), sub);
142 }
143 try {
We try to construct a function that computes the given operation, as a JavaScript function object.
146 fn = new Function('x', 'return ' + substitutedText);
147 } catch (e) {
If it fails, the user input is invalid.
149 invalid = true;
150 }
151 return Object.assign(
152 super.summarize(),
153 {
154 jsFunction: fn,
155 invalid: invalid,
156 }
157 );
158 }
159
160}
161
This represents a collection of functions, i.e. the list in the overlay panel we see in the UI.
164class FunctionStore extends StoreOf(FunctionRecord) {}
165
The AppBar
is the overlay panel that contains all UI about functions and graph controls.
167class AppBar extends StyledComponent {
168
169 init(functionStore, graphProps) {
170 this.functionStore = functionStore;
171 this.graphProps = graphProps;
172
We create a new list view to hold panels of function controls.
174 this.functionList = new FunctionList(this.functionStore, functionStore);
175
There are a bunch of methods here that we need to bind, so we can call them as event listeners in our render step.
178 this.addFunction = this.addFunction.bind(this);
179 this.resetGraphProps = this.resetGraphProps.bind(this);
180 this.moveUp = this.moveUp.bind(this);
181 this.moveDown = this.moveDown.bind(this);
182 this.moveLeft = this.moveLeft.bind(this);
183 this.moveRight = this.moveRight.bind(this);
184 this.zoomIn = this.zoomIn.bind(this);
185 this.zoomOut = this.zoomOut.bind(this);
186 this.toggleHighPerfMode = this.toggleHighPerfMode.bind(this);
187 this.toggleDetectAsymptotes = this.toggleDetectAsymptotes.bind(this);
188
We want to reference graphProps with this.records
, but
when props on it updates, we don't really need to re-render.
So we don't, for performance reasons.
192 this.bind(graphProps, () => {});
193 }
194
195 styles() {
196 const CONTROL_SIZE = 34;
197 const CONTROL_MARGIN = 4;
198 return {
199 'position': 'fixed',
200 'top': '0',
201 'left': '0',
202 'width': '320px',
203 'display': 'flex',
204 'flex-direction': 'column',
205 'justify-content': 'flex-start',
206 'align-items': 'center',
207 'max-height': 'calc(100vh - 18px)',
208 'overflow-y': 'auto',
209 'padding': '18px',
210 'body.graph_dragging &': {
211 'pointer-events': 'none',
212 },
213 '.graphSettings': {
214 'padding': '8px',
215 },
216 'label': {
217 'font-size': '14px',
218 },
219 '.title': {
220 'font-weight': 'bold',
221 'font-size': '20px',
222 'margin-bottom': '12px',
223 },
224 '.inputGroup': {
225 'margin-top': '12px',
226 },
227 '.controlGroup': {
228 'display': 'flex',
229 'flex-direction': 'row',
230 'justify-content': 'space-around',
231 'align-items': 'center',
232 'height': (CONTROL_SIZE * 3) + (CONTROL_MARGIN * 2) + 'px',
233 'button': {
234 'background': '#fff',
235 'border-radius': '6px',
236 'border': '2px solid #aaa',
237 'font-size': '18px',
238 'text-align': 'center',
239 'box-sizing': 'border-box',
240 'height': CONTROL_SIZE + 'px',
241 'width': CONTROL_SIZE + 'px',
242 'padding': '0',
243 'cursor': 'pointer',
244 'transition': 'transform .2s',
245 '&:hover': {
246 'transform': 'scale(1.1)',
247 },
248 },
249 },
In the .panGroup
container, we try to arrange the panning
buttons (for moving around the graph) in a cross shape
using absolute position coordinates.
253 '.panGroup': {
254 'height': (CONTROL_SIZE * 3) + (CONTROL_MARGIN * 2) + 'px',
255 'width': (CONTROL_SIZE * 3) + (CONTROL_MARGIN * 2) + 'px',
256 'position': 'relative',
257 'button': {
258 'position': 'absolute',
259 'display': 'block',
260 },
261 '.moveUpButton': {
262 'top': 0,
263 'left': CONTROL_SIZE + CONTROL_MARGIN + 'px',
264 },
265 '.moveDownButton': {
266 'top': (CONTROL_SIZE * 2) + (CONTROL_MARGIN * 2) + 'px',
267 'left': CONTROL_SIZE + CONTROL_MARGIN + 'px',
268 },
269 '.moveRightButton': {
270 'top': CONTROL_SIZE + CONTROL_MARGIN + 'px',
271 'left': (CONTROL_SIZE * 2) + (CONTROL_MARGIN * 2) + 'px',
272 },
273 '.moveLeftButton': {
274 'top': CONTROL_SIZE + CONTROL_MARGIN + 'px',
275 'left': '0px',
276 },
277 },
278 '.zoomGroup': {
279 'display': 'flex',
280 'flex-direction': 'column',
281 'align-items': 'center',
282 'justify-content': 'space-between',
283 'height': '100%',
284 },
285 '.resetGroup': {
286 'button': {
287 'font-size': '14px',
288 'width': '60px',
289 },
290 },
291 '.toggleInput': {
292 'margin-right': '8px',
293 },
294 '.panel': {
295 'flex-shrink': '0',
296 'width': '100%',
297 'margin-bottom': '18px',
298 'border-radius': '8px',
299 'overflow': 'hidden',
300 'box-shadow': '0 2px 8px -1px rgba(0, 0, 0, .3)',
301 'min-height': '36px',
302 'box-sizing': 'border-box',
303 },
304 '.newFunctionPanel, .graphSettings': {
305 'background': '#fff',
306 },
307 '.newFunctionPanel': {
308 'transition': 'transform .2s',
309 'button': {
310 'height': '100%',
311 'width': '100%',
312 'font-size': '16px',
313 'line-height': '36px',
314 'text-align': 'left',
315 'border': 0,
316 'cursor': 'pointer',
317 'background': 'transparent',
318 },
319 '&:hover': {
320 'background': '#f8f8f8',
321 'transform': 'translateY(2px)',
322 },
323 },
324 }
325 }
326
327 addFunction() {
By default, we create the function f(x) = x.
329 this.functionStore.create({
330 text: 'x',
331 });
332 }
333
334 resetGraphProps() {
335 this.record.update({
336 centerX: 0,
337 centerY: 0,
338 zoom: 100,
339 });
340 }
341
342 moveUp() {
343 this.record.update({
344 centerY: this.record.get('centerY') + (100 / this.record.get('zoom')),
345 });
346 }
347
348 moveDown() {
349 this.record.update({
350 centerY: this.record.get('centerY') - (100 / this.record.get('zoom')),
351 });
352 }
353
354 moveLeft() {
355 this.record.update({
356 centerX: this.record.get('centerX') - (100 / this.record.get('zoom')),
357 });
358 }
359
360 moveRight() {
361 this.record.update({
362 centerX: this.record.get('centerX') + (100 / this.record.get('zoom')),
363 });
364 }
365
366 zoomIn() {
367 this.record.update({
368 zoom: this.record.get('zoom') * 1.2,
369 });
370 }
371
372 zoomOut() {
373 this.record.update({
374 zoom: this.record.get('zoom') / 1.2,
375 });
376 }
377
378 toggleHighPerfMode() {
379 this.record.toggleHighPerf();
380 }
381
382 toggleDetectAsymptotes() {
383 this.record.toggleDetectAsymptotes();
384 }
385
386 compose() {
387 return jdom`<div class="appBar">
388 <div class="panel graphSettings">
389 <div class="title">
390 Graphing Calculator 📈
391 </div>
392 <div class="inputGroup controlGroup">
393 <div class="panGroup">
394 <button class="moveUpButton" onclick="${this.moveUp}">☝️</button>
395 <button class="moveDownButton" onclick="${this.moveDown}">👇</button>
396 <button class="moveLeftButton" onclick="${this.moveLeft}">👈</button>
397 <button class="moveRightButton" onclick="${this.moveRight}">👉</button>
398 </div>
399 <div class="resetGroup">
400 <button class="resetButton" onclick="${this.resetGraphProps}">Reset</button>
401 </div>
402 <div class="zoomGroup">
403 <button class="zoomInButton" onclick="${this.zoomIn}">🔍</button>
404 <button class="zoomOutButton" onclick="${this.zoomOut}">🔭</button>
405 </div>
406 </div>
407 <div class="inputGroup">
408 <input class="toggleInput" id="higherPerfCheck" type="checkbox" onchange="${this.toggleHighPerfMode}" />
409 <label for="higherPerfCheck">More accurate graphs (might be slower)</label>
410 </div>
411 <div class="inputGroup">
412 <input class="toggleInput" id="detectAsymptotes" type="checkbox" onchange="${this.toggleDetectAsymptotes}" />
413 <label for="detectAsymptotes">Try to detect & fix vertical asymptotes</label>
414 </div>
415 </div>
416 ${this.functionList.node}
417 <div class="panel newFunctionPanel">
418 <button class="newFunctionButton" onclick="${this.addFunction}">
419 + Add another function
420 </button>
421 </div>
422 </div>`;
423 }
424
425}
426
This represents a single function list item in the overlay sidebar
428class FunctionPanel extends StyledComponent {
429
Since this is a List
item, it's given two arguments, the first
the record for this component, and the second a callback to remove
the item from the list. We'll store the latter as a property.
433 init(functionRecord, removeCallback, functionStore) {
434 this.removeCallback = removeCallback;
435 this.functionStore = functionStore;
436
437 this.keyUp = this.keyUp.bind(this);
438 this.updateFunctionText = this.updateFunctionText.bind(this);
439 this.toggleHidden = this.toggleHidden.bind(this);
440 this.duplicate = this.duplicate.bind(this);
441 this.updateColor = this.updateColor.bind(this);
442
We want to re-render this component every time something about the function changes.
445 this.bind(functionRecord, props => this.render(props));
446 }
447
448 styles(props) {
449 const HEIGHT = 72;
450 return {
451 'height': HEIGHT + 'px',
452 'background': props.color,
453 '&.hidden': {
454 'opacity': '.45',
455 },
456 '.caps': {
457 'text-transform': 'uppercase',
458 },
459 '.inputArea, .buttonArea': {
460 'height': '50%',
461 'display': 'flex',
462 'flex-direction': 'row',
463 'align-items': 'center',
464 },
465 '.inputArea': {
466 'justify-content': 'space-between',
467 '.yPrefix, input': {
468 'display': 'block',
469 'height': '100%',
470 },
471 '.yPrefix': {
472 'background': 'rgba(255, 255, 255, 0.4)',
473 'width': '40px',
474 'text-align': 'center',
475 'line-height': (HEIGHT / 2) + 'px',
476 'color': '#fff',
477 },
478 'input': {
479 'flex-grow': '1',
480 'margin': 0,
481 'border-radius': 0,
482 'box-sizing': 'border-box',
483 'padding': '4px 6px',
484 'font-size': '16px',
485 'border': 0,
486 '&:focus': {
487 'background': 'rgba(255, 255, 255, .9)',
488 'outline': 'none',
489 },
490 '&::placeholder': {
491 'color': '#aaa',
492 },
493 '&.invalid': {
494 'box-shadow': 'inset 0 0 0 3px rgba(208, 83, 55, 0.6)',
495 'background': '#eccfcf',
496 },
497 },
498 },
499 '.buttonArea': {
500 'justify-content': 'flex-end',
501 },
502 'button': {
503 'margin-right': '6px',
504 'color': '#fff',
505 'height': '24px',
506 'line-height': '22px',
507 'background-color': 'rgba(255, 255, 255, 0.4)',
508 'font-size': '14px',
509 'border-radius': '4px',
510 'border': 0,
511 'cursor': 'pointer',
512 '&:hover': {
513 'background': '#fff',
514 'color': props.color,
515 },
516 },
517 }
518 }
519
520 keyUp(evt) {
521 if (evt && evt.key === 'Enter') {
522 this.updateFunctionText();
523 }
524 }
525
This component is not a controlled component, because it doesn't need to be, and for sake of performance. So when we do need to update the value of the function, we grab the input from the text field manually, and update the record.
529 updateFunctionText() {
530 const text = this.node.querySelector('input').value;
531 this.record.update({
532 text: text,
533 });
534 }
535
536 toggleHidden() {
537 this.record.update({
538 hidden: !this.record.get('hidden'),
539 });
540 }
541
542 duplicate() {
543 this.functionStore.create({
544 text: this.record.get('text'),
545 });
546 }
547
548 updateColor() {
549 this.record.update({
550 color: randomColor(),
551 });
552 }
553
554 compose(props) {
555 return jdom`<div class="panel functionPanel ${props.hidden ? 'hidden' : ''}">
556 <div class="inputArea">
557 <div class="yPrefix">y =</div>
558 <input type="text" value="${props.text}" onblur="${this.updateFunctionText}"
559 onkeyup="${this.keyUp}" placeholder="log(), sqrt(), abs(), trig supported"
560 class="${props.invalid ? 'invalid' : ''}" />
561 </div>
562 <div class="buttonArea">
563 <button onclick="${this.removeCallback}">Delete</button>
564 <button onclick="${this.toggleHidden}">${props.hidden ? '🙈 Show' : '👀 Hide'}</button>
565 <button onclick="${this.duplicate}">Duplicate</button>
566 <button onclick="${this.updateColor}">🎨 Color</button>
567 </div>
568 </div>`;
569 }
570
571}
572
List class that wraps around a collection (Store) of functions.
We override this so rather than using <ul>
s we use <div>
s.
575class FunctionList extends Styled(ListOf(FunctionPanel)) {
576
577 styles() {
578 return {
579 'width': '100%',
580 }
581 }
582
583 compose() {
584 return jdom`<div>${this.nodes}</div>`;
585 }
586
587}
588
This component represents a single graph -- just the curve connecting the y-values.
These are then collected into a GraphCollection
component,
which is a List
that contains multiple canvas elements, one for each
function graph. Because canvases are transparent, we can just stack
FunctionGraph
s on top of each other to render the whole, final graph.
594class FunctionGraph extends Component {
595
In addition to the normal arguments, we also get passed down the graph configs, so we can render the function graphs properly.
598 init(functionRecord, _removeCallback, graphProps) {
599 this.graphProps = graphProps;
600
Create a new canvas for this graph and get the 2D drawing context.
602 this.canvas = document.createElement('canvas');
603 this.context = this.canvas.getContext('2d');
604
We want to re-draw just this function on the canvas when anything about the function changes.
607 this.bind(functionRecord, () => this.redraw());
608 }
609
Method to wipe and re-draw the function's graph on the canvas.
612 redraw() {
Shorthand so I don't have to keep typing this.context
614 const ctx = this.context;
615
We get the window's height and width and make sure our canvas is sized to fit.
618 const width = this.canvas.width = window.innerWidth;
619 const height = this.canvas.height = window.innerHeight;
620
Clear canvas
622 ctx.clearRect(0, 0, width, height);
623
624 const functionSummary = this.record.summarize();
If the function is hidden, we don't need to do anything after clearing the canvas.
627 if (functionSummary.hidden) {
628 return;
629 } else {
630 const graphPropsSummary = this.graphProps.summarize();
631
Destructure properties from graphProps
633 const {centerX, centerY, zoom, resolution, detectAsymptotes} = graphPropsSummary;
634 const centerXScreen = width / 2;
635 const centerYScreen = height / 2;
These values represent min/max values on the graph, and help us determine which x- and y-values we need to worry about computing.
638 const minX = ~~(centerX - (centerXScreen / zoom)) - 1;
639 const maxX = ~~(centerX + (centerXScreen / zoom)) + 1;
640 const minY = ~~(centerY - (centerYScreen / zoom)) - 1;
641 const maxY = ~~(centerY + (centerYScreen / zoom)) + 1;
642
Pair of short functions that map x/y values to their position on the canvas.
645 const xToCoord = xValue => {
646 return centerXScreen + ((xValue - centerX) * zoom);
647 }
648 const yToCoord = yValue => {
649 return centerYScreen - ((yValue - centerY) * zoom);
650 }
651
This is the function we need to run on each x-value.
653 const f = functionSummary.jsFunction;
654
Re-draw this function
656 ctx.lineWidth = 3;
We want to draw the function graph with the function's color
658 ctx.strokeStyle = functionSummary.color;
659 ctx.beginPath();
We keep track of the last y value computed, to do potential asymptote detection
662 let lastY = 0;
663 const increment = resolution / zoom;
664 for (let x = minX; x < maxX; x += increment) {
Try to get a non-asymptotic value of y
666 const y = f(x);
Graph it.
668 if (!isNaN(y)) {
There's some complexity here to avoid drawing an incorrect line
through the middle of the screen when asymptotic limits switch signs.
Essentially, we consider an asymptote any short increment in X that
results in a y value that jumps across the y = 0 line, and from
the bottom of the screen to the top of the screen (> height
).
674 const diff = y - lastY;
675 const diffSign = y * lastY < 0;
676 lastY = y;
677 if (detectAsymptotes && Math.abs(diff * zoom) > height && diffSign) {
If there is an asymptote as we've defined it, don't connect from the previous point; just go to a new point.
680 ctx.moveTo(xToCoord(x), yToCoord(clamp(y, minY, maxY)));
681 } else {
Otherwise, connect the line from the previous point.
683 ctx.lineTo(xToCoord(x), yToCoord(clamp(y, minY, maxY)));
684 }
685 }
686 }
Commit the curve we've just defined to the canvas.
688 ctx.stroke();
689 }
690
691 }
692
When we render this component, we just render the canvas element we keep drawing to.
695 compose() {
696 return this.canvas;
697 }
698
699}
700
<ul>
of all the functions' canvases.
702const GraphCollection = ListOf(FunctionGraph);
703
The Graph
component holds the graph grid, and manages the rest of the
functions' graphs in a GraphCollection
under it.
706class Graph extends StyledComponent {
707
708 init(functionStore, graphProps) {
709 this.functionStore = functionStore;
710 this.graphProps = graphProps;
711
Create a canvas for the grid, and get the 2D drawing context.
713 this.canvas = document.createElement('canvas');
714 this.context = this.canvas.getContext('2d');
715
Given a collection of functions, create a list of views that each render their function to their own canvas.
718 this.functionGraphs = new GraphCollection(functionStore, this.graphProps);
719
We bind redraw
a bit differently than normal here because we want to be
efficient about when we re-render the entire graph from scratch. We really
only ever need to do it once per frame, before the frame is painted. So
we use requestAnimationFrame()
.
724 const boundReDraw = this.redraw.bind(this);
725 this.redraw = () => requestAnimationFrame(boundReDraw);
726 this.handleWheel = this.handleWheel.bind(this);
727 this.handleMousedown = this.handleMousedown.bind(this);
728 this.handleMouseup = this.handleMouseup.bind(this);
729 this.handleMousemove = this.handleMousemove.bind(this);
730 this.handleTouchstart = this.handleTouchstart.bind(this);
731 this.handleTouchend = this.handleTouchend.bind(this);
732 this.handleTouchmove = this.handleTouchmove.bind(this);
733
When the window is resized, we want to re-draw the graph to fit.
735 window.addEventListener('resize', this.redraw);
When anything about the graph settings are updated, we want to re-draw.
737 this.bind(this.graphProps, this.redraw);
738 }
739
Make sure to remove the event listener we bound earlier, if we ever remove this component.
741 remove() {
742 window.removeEventListener('resize', this.redraw);
743 }
744
745 styles() {
746 return {
747 'position': 'fixed',
748 'z-index': '-1',
749 'top': '0',
750 'left': '0',
751 'right': '0',
752 'bottom': '0',
753 'cursor': 'grab',
754 '&:active': {
755 'cursor': 'grabbing',
756 },
757 'canvas': {
758 'position': 'absolute',
759 'top': '0',
760 'left': '0',
761 'right': '0',
762 'bottom': '0',
763 },
764 }
765 }
766
When the mouse scroll wheel scrolls on the page,
we want to zoom in or out depending on the scroll direction.
We also throttle this with requestAnimationFrame
to be
efficient about how much we re-render the graph, and when.
771 handleWheel(evt) {
Prevent overscroll spring behavior on macOS
773 evt.preventDefault();
774 const change = evt.deltaY;
775 const scaledChange = Math.max(change / 1000, -.3);
776 requestAnimationFrame(() => {
777 this.record.update({
778 zoom: this.record.get('zoom') * (1 + scaledChange),
779 });
780 });
781 }
782
The next three functions are a standard drag-and-drop implementation for mouse events. We mark the starting position when we start dragging...
785 handleMousedown(evt) {
786 this._dragging = true;
When we set this flag, the overlay sidebar becomes invisible to pointer events, so the graph receives all the mouse actions and the sidebar can't block it all of a sudden.
790 document.body.classList.add('graph_dragging');
791 this._lastClientX = evt.clientX;
792 this._lastClientY = evt.clientY;
793 }
794
... and set the flag to false when we stop ...
796 handleMouseup() {
797 this._dragging = false;
798 document.body.classList.remove('graph_dragging');
799 }
800
... and in between, anytime there's mouse movement over the graph,
we compute how much distance changed between the initial click down
and now, and pan the graph by that amount, again throttled by
requestAnimationFrame()
to be efficient about when we redraw.
805 handleMousemove(evt) {
806 evt.preventDefault();
807 if (this._dragging) {
808 const clientX = evt.clientX;
809 const clientY = evt.clientY;
810 const deltaX = clientX - this._lastClientX;
811 const deltaY = clientY - this._lastClientY;
812
813 requestAnimationFrame(() => {
814 const props = this.record.summarize();
815 this.record.update({
816 centerX: props.centerX - (deltaX / props.zoom),
817 centerY: props.centerY + (deltaY / props.zoom),
818 });
819 });
820
821 this._lastClientX = clientX;
822 this._lastClientY = clientY;
823 }
824 }
825
The next three functions are the same as the drag-and-drop code above, but for touch events on things like Windows laptops and iPad.
828 handleTouchstart(evt) {
829 this._touchDragging = true;
830 document.body.classList.add('graph_dragging');
831 this._lastClientX = evt.touches[0].clientX;
832 this._lastClientY = evt.touches[0].clientY;
833 }
834
835 handleTouchend() {
836 this._touchDragging = false;
837 document.body.classList.remove('graph_dragging');
838 }
839
840 handleTouchmove(evt) {
841 evt.preventDefault();
842 if (this._touchDragging) {
843 const clientX = evt.touches[0].clientX;
844 const clientY = evt.touches[0].clientY;
845 const deltaX = clientX - this._lastClientX;
846 const deltaY = clientY - this._lastClientY;
847
848 requestAnimationFrame(() => {
849 const props = this.record.summarize();
850 this.record.update({
851 centerX: props.centerX - (deltaX / props.zoom),
852 centerY: props.centerY + (deltaY / props.zoom),
853 });
854 });
855
856 this._lastClientX = clientX;
857 this._lastClientY = clientY;
858 }
859 }
860
Re-draw the entire graph, including all the grid lines and coordinate numbers.
862 redraw() {
863 const graphPropsSummary = this.graphProps.summarize();
864 const width = this.canvas.width = window.innerWidth;
865 const height = this.canvas.height = window.innerHeight;
866
Shorthand so I don't have to keep typing this.context
868 const ctx = this.context;
869
Clear canvas
871 ctx.clearRect(0, 0, width, height);
872
Destructure properties that matter from the graph settings
874 const {centerX, centerY, zoom} = graphPropsSummary;
875 const centerXScreen = width / 2;
876 const centerYScreen = height / 2;
877 const minX = ~~(centerX - (centerXScreen / zoom)) - 1;
878 const maxX = ~~(centerX + (centerXScreen / zoom)) + 1;
879 const minY = ~~(centerY - (centerYScreen / zoom)) - 1;
880 const maxY = ~~(centerY + (centerYScreen / zoom)) + 1;
881
Much of this is same as above, in FunctionGraph
's rendering code.
883 const xToCoord = xValue => {
884 return centerXScreen + ((xValue - centerX) * zoom);
885 }
886 const yToCoord = yValue => {
887 return centerYScreen - ((yValue - centerY) * zoom);
888 }
889
Draw the horizontal grid lines
891 ctx.strokeStyle = '#aaa'; // dark grey
892 ctx.beginPath();
893 for (let y = minY; y < maxY; y ++) {
894 ctx.moveTo(xToCoord(minX), yToCoord(y));
895 ctx.lineTo(xToCoord(maxX), yToCoord(y));
896 }
Draw the vertical grid lines
898 for (let x = minX; x < maxX; x ++) {
899 ctx.moveTo(xToCoord(x), yToCoord(minY));
900 ctx.lineTo(xToCoord(x), yToCoord(maxY));
901 }
Commit the lines to the canvas
903 ctx.stroke();
904
Draw the zero axes, a bit bolder and thicker than the others.
906 ctx.lineWidth = 3;
907 ctx.strokeStyle = '#333';
908 ctx.beginPath();
The y = 0 line
910 ctx.moveTo(xToCoord(minX), yToCoord(0));
911 ctx.lineTo(xToCoord(maxX), yToCoord(0));
912 // The x = 0 line
913 ctx.moveTo(xToCoord(0), yToCoord(minY));
914 ctx.lineTo(xToCoord(0), yToCoord(maxY));
915 ctx.stroke();
916
917 ctx.lineWidth = 2;
918
We mark some key coordinates on the graph to orient the user.
920 ctx.font = '16px sans-serif';
921 ctx.fillStyle = '#555';
922 const markCoord = (x, y) => {
923 ctx.fillText(`(${x}, ${y})`, xToCoord(x) + 5, yToCoord(y) + 18);
924 }
925 markCoord(0, 0);
926 markCoord(1, 0);
927 markCoord(0, 1);
928 markCoord(10, 0);
929 markCoord(0, 10);
930 markCoord(-10, 0);
931 markCoord(0, -10);
932 markCoord(50, 0);
933 markCoord(0, 50);
934 markCoord(-50, 0);
935 markCoord(0, -50);
936
Since this redraw method is called when the window is resized, for example, we want to tell each graph to also re-render.
939 for (const graph of this.functionGraphs.components) {
940 graph.redraw();
941 }
942 }
943
944 compose() {
Bind all the drag-and-drop listeners to the parent container of all the canvas (graph) elements.
947 return jdom`<div id="graphContainer"
948 onwheel="${this.handleWheel}"
949 onmousedown="${this.handleMousedown}"
950 onmouseup="${this.handleMouseup}"
951 onmousemove="${this.handleMousemove}"
952 ontouchstart="${this.handleTouchstart}"
953 ontouchend="${this.handleTouchend}"
954 ontouchmove="${this.handleTouchmove}">
955 ${this.canvas}
956 ${this.functionGraphs.node}
957 </div>`;
958 }
959
960}
961
The app's root component, also holds all global data.
963class App extends StyledComponent {
964
965 init() {
Create our main collection of functions
967 this.functionStore = new FunctionStore([
Default, example functions
969 new FunctionRecord({text: 'x + 1'}),
970 new FunctionRecord({text: 'x * x'}),
971 new FunctionRecord({text: 'sqrt(x)'}),
972 new FunctionRecord({text: '1 / x'}),
973 new FunctionRecord({text: '2.71828 ^ x * sin(5 * x) / 20'}),
974 ]);
Create a record to keep graph settings, and sync updates across the UI.
977 this.graphProps = new GraphPropsRecord();
Create nested components: the sidebar overlay, and the graph
979 this.appBar = new AppBar(this.functionStore, this.graphProps);
980 this.graph = new Graph(this.functionStore, this.graphProps);
981 }
982
983 styles() {
984 return {
985 'font-family': '-apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
986 'footer': {
987 'position': 'fixed',
988 'right': '0',
989 'bottom': '0',
990 'padding': '6px 8px',
991 'color': '#333',
992 'opacity': '.5',
993 'font-size': '14px',
994 'cursor': 'pointer',
995 'transition': 'opacity .2s',
996 'a': {
997 'color': '#333',
998 },
999 '&:hover': {
1000 'opacity': '.8',
1001 },
1002 },
1003 }
1004 }
1005
The app itself is pretty simple: the overlay in an overlay container, and the graph below it.
1008 compose() {
1009 return jdom`<main>
1010 <div class="overlay">
1011 ${this.appBar.node}
1012 </div>
1013 ${this.graph.node}
1014 <footer>
1015 Built with
1016 <a href="https://linus.zone/torus" target="_blank" rel="noopener">Torus</a>
1017 by <a href="https://linus.zone/now" target="_blank" rel="noopener">Linus</a>
1018 </footer>
1019 </main>`;
1020 }
1021
1022}
1023
Create an instance of the app and mount it to the page DOM.
1025const app = new App();
1026document.body.appendChild(app.node);
I like it when the page background isn't completely white. This is an off-white shade of very light grey;
1029document.body.style.backgroundColor = '#fbfbfb';
1030