Graphing Calculator demo annotated source

Back to index

        

A 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 &#38; 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 FunctionGraphs 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