2
\$\begingroup\$

I'm learning JavaScript and decided to build this mortgage calculator as a starting point. For the most part it was reasonably straightforward, but when I implemented the graphs using D3 I found myself with two similar functions that contained a lot of the same code. I could have used one of the many D3-based charting libraries but the point of this exercise was to learn.

Although this came up in a D3 context, my question is about JavaScript as opposed to D3.

I attempted to refactor it in three different ways:

  1. Initialize an object by passing in custom functions that took a reference to the object as an argument
  2. Object inheritance via Object.create
  3. Object composition using a closure for the inner object

They all work but I'm not thrilled about any of them. The first one should probably have been done with more this and bind and less my and obj but still wouldn't really feel great. The second one is probably the simplest but made me a little nervous because of the need to use .bind(this). The third one didn't fully eliminate all of the code repetition because using a closure for the inner object made it hard for the non-repeated code to do its job.

Please let me know what you think of the various methods and if you have a better suggestion for how to implement this. Again, the point is to understand JS better and not to actually implement (or use) a D3 charting library.

var mtgCharts = (function() {
    function pAndIChart(chartID) {
        var margin = {top: 10, right: 36, bottom: 50, left: 72},
            width, height,
            x = d3.time.scale(),
            y = d3.scale.linear();

        function setSizeAndScales() {
            width = parseInt(d3.select('#'+chartID).style('width'), 10) - margin.left - margin.right;
            height = parseInt(d3.select('#'+chartID).style('height'), 10) - margin.top - margin.bottom;

            x.range([0, width]);
            y.range([height, 0]);
        }

        setSizeAndScales();

        var xAxis = d3.svg.axis()
            .scale(x)
            .orient('bottom')
            .tickFormat(d3.time.format('%b-%y'));

        var yAxis = d3.svg.axis()
            .scale(y)
            .orient('left');

        var chart = d3.select('#' + chartID)
            .append('g')
            .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');

        var gXAxis = chart.append('g')
            .attr('class', 'x axis')
            .attr('transform', 'translate(0,' + height + ')');

        var gYAxis = chart.append('g')
            .attr('class', 'y axis');

        var resize = function() {
            setSizeAndScales();
            gXAxis
                .call(xAxis)
                .selectAll('text')
                .style('text-anchor', 'end')
                .attr('dx', '-1em')
                .attr('dy', '-0.6em')
                .attr('transform', function(d) {return 'rotate(-90)'});

            gYAxis.call(yAxis);
            var paymentMonths = chart.selectAll('.paymentMonth');
            paymentMonths
                .attr('transform', function(d) {return 'translate(' + x(d.paymentDate.toDate()) + ',0)';});

            chart.selectAll('.bar')
                .attr('y', function(d) { return y(d.yHigh); })
                .attr('height', function(d) { return y(d.yLow) - y(d.yHigh); })
                .attr('width', width/paymentMonths.size())
                .style('fill', function(d) {return d.color;});
        };

        var draw = function(amSched) {
            x.domain(d3.extent(amSched.map(function(d) { return d.paymentDate.toDate(); })));
            y.domain([0, d3.max(amSched, function(d) { return d.interest + d.principal; })]);

            var paymentMonths = chart.selectAll('.paymentMonth').data(amSched);

            paymentMonths
                .enter().append('g')
                .attr('class', 'paymentMonth');

            paymentMonths.exit().remove();

            var bars = paymentMonths.selectAll('.bar')
                .data(function(d) {
                    return [{color: 'IndianRed', yLow: 0, yHigh: d.principal},
                            {color: 'SteelBlue', yLow: d.principal, yHigh: d.interest + d.principal}];
                });

            bars.enter().append('rect')
                .attr('class', 'bar')
            bars.exit().remove();

            resize();
        }
        d3.select(window).on('resize.' + chartID, resize);
        return {draw: draw};
    }

    function balanceChart(chartID) {
        var margin = {top: 10, right: 36, bottom: 50, left: 72},
            width, height,
            x = d3.time.scale(),
            y = d3.scale.linear();

        function setSizeAndScales() {
            width = parseInt(d3.select('#'+chartID).style('width'), 10) - margin.left - margin.right;
            height = parseInt(d3.select('#'+chartID).style('height'), 10) - margin.top - margin.bottom;

            x.range([0, width]);
            y.range([height, 0]);
        }

        setSizeAndScales();

        var xAxis = d3.svg.axis()
            .scale(x)
            .orient('bottom')
            .tickFormat(d3.time.format('%b-%y'));

        var yAxis = d3.svg.axis()
            .scale(y)
            .orient('left');

        var yGrid = d3.svg.axis()
            .scale(y)
            .orient('left')
            .tickFormat("");

        var chart = d3.select('#' + chartID)
            .append('g')
            .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');

        var gXAxis = chart.append('g')
            .attr('class', 'x axis')
            .attr('transform', 'translate(0,' + height + ')');

        var gYAxis = chart.append('g')
            .attr('class', 'y axis');

        var gYTicks = chart.append('g')
            .attr('class', 'grid');

        var line = d3.svg.line()
            .x(function(d) {return x(d.paymentDate.toDate());})
            .y(function(d) {return y(d.endingBalance);});

        var resize = function() {
            setSizeAndScales();
            gXAxis
                .call(xAxis)
                .selectAll('text')
                .style('text-anchor', 'end')
                .attr('dx', '-1em')
                .attr('dy', '-0.6em')
                .attr('transform', function(d) {return 'rotate(-90)'});

            gYAxis.call(yAxis);
            yGrid.tickSize(-width, 0, 0);
            gYTicks.call(yGrid);
            chart.select('#balanceLine').attr('d', line);
        };

        var draw = function(amSched) {
            x.domain(d3.extent(amSched.map(function(d) { return d.paymentDate.toDate(); })));
            y.domain([0, d3.max(amSched, function(d) { return d.endingBalance; })]);
            chart.select('#balanceLine').remove();
            chart.append('path')
                .datum(amSched)
                .attr('class', 'line')
                .attr('id', 'balanceLine');

            resize();
        };

        d3.select(window).on('resize.' + chartID, resize);
        return {
            draw: draw,
            resize: resize
        };
    }
    return {
        pAndIChart: pAndIChart,
        balanceChart: balanceChart
    };
})();

Functions taking obj passed in during instantiation: (PS: I am aware of the bug in this one and the next one involving my config object if config.margin.top etc. is 0.)

var mtgCharts = (function() {
    function genericChart(chartID, config) {
        var my = {};

        my.margin = {
            top: (config.margin && config.margin.top) || 10,
            right: (config.margin && config.margin.right) || 36,
            bottom: (config.margin && config.margin.bottom) || 50,
            left: (config.margin && config.margin.left) || 72
        };

        my.gridlines = {
            x: false,
            y: (config.gridlines && (config.gridlines.y === true)) || false
        }

        my.customFns = {
            resize: (config.customFns && config.customFns.resize) || function(){},
            draw: (config.customFns && config.customFns.draw) || function(){},
            yDomain: (config.customFns && config.customFns.yDomain) || function() {return 0;}
        };

        my.x = d3.time.scale();
        my.y = d3.scale.linear();

        my.chart = d3.select('#'+chartID);

        my.setSizeAndScales = function () {
            my.width = parseInt(my.chart.style('width'), 10) - my.margin.left - my.margin.right;
            my.height = parseInt(my.chart.style('height'), 10) - my.margin.top - my.margin.bottom;

            my.x.range([0, my.width]);
            my.y.range([my.height, 0]);
        }
        my.setSizeAndScales();

        my.xAxis = d3.svg.axis()
            .scale(my.x)
            .orient('bottom')
            .tickFormat(d3.time.format('%b-%y'));

        my.yAxis = d3.svg.axis()
            .scale(my.y)
            .orient('left');

        my.yGrid = d3.svg.axis()
            .scale(my.y)
            .orient('left')
            .tickFormat("");

        my.innerChart = my.chart
            .append('g')
            .attr('transform', 'translate(' + my.margin.left + ',' + my.margin.top + ')');

        my.gXAxis = my.innerChart.append('g')
            .attr('class', 'x axis')
            .attr('transform', 'translate(0,' + my.height + ')');

        my.gYAxis = my.innerChart.append('g')
            .attr('class', 'y axis');

        if (my.gridlines.y) {
            my.gYTicks = my.innerChart.append('g')
                .attr('class', 'grid');
        }

        my.resize = function () {
            my.setSizeAndScales();
            my.gXAxis
                .call(my.xAxis)
                .selectAll('text')
                .style('text-anchor', 'end')
                .attr('dx', '-1em')
                .attr('dy', '-0.6em')
                .attr('transform', function(d) {return 'rotate(-90)'});

            my.gYAxis.call(my.yAxis);
            my.yGrid.tickSize(-my.width, 0, 0);
            if (my.gridlines.y) {
                my.gYTicks.call(my.yGrid);
            }
            my.customFns.resize(my);
        }

        my.draw = function(amSched) {
            my.x.domain(d3.extent(amSched.map(function(d) { return d.paymentDate.toDate(); })));
            my.y.domain([0, d3.max(amSched, my.customFns.yDomain)]);

            my.customFns.draw(my, amSched);
            my.resize();
        }
        d3.select(window).on('resize.' + chartID, my.resize);
        return {draw: my.draw};
    }

    function pAndIChart(chartID) {
        var my = genericChart(chartID, {
            customFns: {
                draw: function(obj, data) {
                    var paymentMonths = obj.innerChart.selectAll('.paymentMonth').data(data);

                    paymentMonths
                        .enter().append('g')
                        .attr('class', 'paymentMonth');

                    paymentMonths.exit().remove();

                    var bars = paymentMonths.selectAll('.bar')
                        .data(function(d) {
                            return [{color: 'IndianRed', yLow: 0, yHigh: d.principal},
                                    {color: 'SteelBlue', yLow: d.principal, yHigh: d.interest + d.principal}];
                        });

                    bars.enter().append('rect')
                        .attr('class', 'bar')
                    bars.exit().remove();
                },
                resize: function(obj) {
                    var paymentMonths = obj.innerChart.selectAll('.paymentMonth');

                    paymentMonths
                        .attr('transform', function(d) {return 'translate(' + obj.x(d.paymentDate.toDate()) + ',0)';});

                    obj.innerChart.selectAll('.bar')
                        .attr('y', function(d) { return obj.y(d.yHigh); })
                        .attr('height', function(d) { return obj.y(d.yLow) - obj.y(d.yHigh); })
                        .attr('width', obj.width/paymentMonths.size())
                        .style('fill', function(d) {return d.color;});
                },
                yDomain: function(d) {
                    return d.interest + d.principal;
                }
            }
        });
        return my;
    };

    function balanceChart(chartID) {
        var my = genericChart(chartID, {
            customFns: {
                draw: function(obj, data) {
                    obj.innerChart.select('#balanceLine').remove();
                    obj.innerChart.append('path')
                        .datum(data)
                        .attr('class', 'line')
                        .attr('id', 'balanceLine');
                },
                resize: function(obj) {
                    var line = d3.svg.line()
                        .x(function(d) {return obj.x(d.paymentDate.toDate());})
                        .y(function(d) {return obj.y(d.endingBalance);});
                    obj.innerChart.select('#balanceLine').attr('d', line);
                },
                yDomain: function(d) {
                    return d.endingBalance;
                }
            }
        });
        return my;
    };
    return {
        pAndIChart : pAndIChart,
        balanceChart : balanceChart
    };
})();

Object inheritance via Object.create (PS: I am aware of the bug in this one and the previous one involving my config object if config.margin.top etc. is 0.)

var mtgCharts = (function() {
    var baseChart = {
        init: function(chartID, config) {
            this.margin = {
                top: (config.margin && config.margin.top)       || 10,
                right: (config.margin && config.margin.right)   || 36,
                bottom: (config.margin && config.margin.bottom) || 50,
                left: (config.margin && config.margin.left)     || 72
            };
            this.gridlines = (config.gridlines === true)        || false;
            this.outerChart = d3.select('#' + chartID);
            this.x = d3.time.scale();
            this.y = d3.scale.linear();
            this.setSizeAndScales();
            this.xAxis = d3.svg.axis()
                .scale(this.x)
                .orient('bottom')
                .tickFormat(d3.time.format('%b-%y'));

            this.yAxis = d3.svg.axis()
                .scale(this.y)
                .orient('left');

            this.yGrid = d3.svg.axis()
                .scale(this.y)
                .orient('left')
                .tickFormat("");

            this.chart = this.outerChart
                .append('g')
                .attr('transform', 'translate(' + this.margin.left + ',' + this.margin.top + ')');

            this.gXAxis = this.chart.append('g')
                .attr('class', 'x axis')
                .attr('transform', 'translate(0,' + this.height + ')');

            this.gYAxis = this.chart.append('g')
                .attr('class', 'y axis');

            if (this.gridlines) {
                this.gYTicks = this.chart.append('g')
                    .attr('class', 'grid');
            }
            d3.select(window).on('resize.' + chartID, this.resize.bind(this));
        },
        setSizeAndScales: function() {
            this.width = parseInt(this.outerChart.style('width'), 10) - this.margin.left - this.margin.right;
            this.height = parseInt(this.outerChart.style('height'), 10) - this.margin.top - this.margin.bottom;

            this.x.range([0, this.width]);
            this.y.range([this.height, 0]);
        },
        resize: function() {
            this.setSizeAndScales();
            this.gXAxis
                .call(this.xAxis)
                .selectAll('text')
                .style('text-anchor', 'end')
                .attr('dx', '-1em')
                .attr('dy', '-0.6em')
                .attr('transform', function(d) {return 'rotate(-90)'});

            this.gYAxis.call(this.yAxis);
            if (this.gridlines) {
                this.yGrid.tickSize(-this.width, 0, 0);
                this.gYTicks.call(this.yGrid);
            }
            this.customResize();
        },
        draw: function(data) {
            this.x.domain(d3.extent(data.map(function(d) { return d.paymentDate.toDate(); })));
            this.y.domain([0, d3.max(data, this.customYDomain)]);
            this.customDraw(data);
            this.resize();
        }
    };
    function pAndIChart(chartID) {
        var chart = Object.create(baseChart);
        chart.customResize = function() {
            var paymentMonths = this.chart.selectAll('.paymentMonth');
            paymentMonths
                .attr('transform', function(d) {return 'translate(' + chart.x(d.paymentDate.toDate()) + ',0)';});

            this.chart.selectAll('.bar')
                .attr('y', function(d) { return chart.y(d.yHigh); })
                .attr('height', function(d) { return chart.y(d.yLow) - chart.y(d.yHigh); })
                .attr('width', this.width/paymentMonths.size())
                .style('fill', function(d) {return d.color;});
        }
        chart.customDraw = function(amSched) {
            var paymentMonths = this.chart.selectAll('.paymentMonth').data(amSched);

            paymentMonths
                .enter().append('g')
                .attr('class', 'paymentMonth');

            paymentMonths.exit().remove();

            var bars = paymentMonths.selectAll('.bar')
                .data(function(d) {
                    return [{color: 'IndianRed', yLow: 0, yHigh: d.principal},
                            {color: 'SteelBlue', yLow: d.principal, yHigh: d.interest + d.principal}];
                });

            bars.enter().append('rect')
                .attr('class', 'bar')
            bars.exit().remove();
        }
        chart.customYDomain = function(d) {return d.interest + d.principal;};
        chart.init(chartID, {});
        return chart;
    }

    function balanceChart(chartID) {
        var chart = Object.create(baseChart);
        chart.line = d3.svg.line()
            .x(function(d) {return chart.x(d.paymentDate.toDate());})
            .y(function(d) {return chart.y(d.endingBalance);});
        chart.customResize = function() {
            this.chart.select('#balanceLine').attr('d', chart.line);
        }
        chart.customDraw = function(amSched) {
            this.chart.select('#balanceLine').remove();
            this.chart.append('path')
                .datum(amSched)
                .attr('class', 'line')
                .attr('id', 'balanceLine');
        }
        chart.customYDomain = function(d) {return d.endingBalance;};
        chart.init(chartID, {gridlines: true});
        return chart;
    }
    return {
        pAndIChart: pAndIChart,
        balanceChart: balanceChart
    };
})();

Composition using a closure for the inner object:

var mtgCharts = (function() {
    // Inspired by Rob Moore's examples here at
    // http://www.toptal.com/d3-js/towards-reusable-d3-js-charts
    // and http://bl.ocks.org/rcmoore38/9f2908455355c0589619

    function genericChart() {
        var margin = {top: 10, right: 36, bottom: 50, left: 72};
        var dimensions = {width: 0, height: 0};
        var scales = {x: d3.time.scale(), y: d3.scale.linear()};
        var axes = {
            x:      d3.svg.axis().scale(scales.x).orient('bottom').tickFormat(d3.time.format('%b-%y')),
            y:      d3.svg.axis().scale(scales.y).orient('left'),
            grid:   d3.svg.axis().scale(scales.y).orient('left').tickFormat('')
        };
        var gridlines = false;
        var domElements = {};
        function setSizeAndScales() {
            dimensions.width = parseInt(domElements.outerChart.style('width'), 10) - margin.left - margin.right;
            dimensions.height = parseInt(domElements.outerChart.style('height'), 10) - margin.top - margin.bottom;
            scales.x.range([0, dimensions.width]);
            scales.y.range([dimensions.height, 0]);
        }
        function drawAxes() {
            domElements.gXAxis
                .call(axes.x)
                .selectAll('text')
                .style('text-anchor', 'end')
                .attr('dx', '-1em')
                .attr('dy', '-0.6em')
                .attr('transform', function(d) {return 'rotate(-90)'});

            domElements.gYAxis.call(axes.y);
            if (gridlines) {
                axes.grid.tickSize(-dimensions.width, 0, 0);
                gYTicks.call(axes.grid);
            }
        }
        function chart(selection) {
            domElements.outerChart = selection;
            setSizeAndScales();
            domElements.chart = domElements.outerChart.append('g')
                                    .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
            domElements.gXAxis = domElements.chart.append('g').attr('class', 'x axis')
                                    .attr('transform', 'translate(0,' + dimensions.height + ')');
            domElements.gYAxis = domElements.chart.append('g').attr('class', 'y axis');
            if (gridlines) {
                domElements.gYTicks = domElements.chart.append('g').attr('class', 'grid');
            }
        }
        chart.setSizeAndScales = setSizeAndScales;
        chart.drawAxes = drawAxes;
        chart.domElements = domElements;
        chart.scales = scales;
        chart.dimensions = dimensions;
        return chart;
    }

    function pAndIChart(chartID) {
        var chart = genericChart();
        d3.select('#' + chartID).call(chart);

        var resize = function() {
            chart.setSizeAndScales();
            chart.drawAxes();
            var paymentMonths = chart.domElements.chart.selectAll('.paymentMonth');
            paymentMonths
                .attr('transform', function(d) {return 'translate(' + chart.scales.x(d.paymentDate.toDate()) + ',0)';})
                .selectAll('.bar')
                .attr('y', function(d) { return chart.scales.y(d.yHigh); })
                .attr('height', function(d) { return chart.scales.y(d.yLow) - chart.scales.y(d.yHigh); })
                .attr('width', chart.dimensions.width/paymentMonths.size())
                .style('fill', function(d) {return d.color;});
        };

        var draw = function(amSched) {
            chart.scales.x.domain(d3.extent(amSched.map(function(d) { return d.paymentDate.toDate(); })));
            chart.scales.y.domain([0, d3.max(amSched, function(d) { return d.interest + d.principal; })]);

            var paymentMonths = chart.domElements.chart.selectAll('.paymentMonth').data(amSched);

            paymentMonths
                .enter().append('g')
                .attr('class', 'paymentMonth');

            paymentMonths.exit().remove();

            var bars = paymentMonths.selectAll('.bar')
                .data(function(d) {
                    return [{color: 'IndianRed', yLow: 0, yHigh: d.principal},
                            {color: 'SteelBlue', yLow: d.principal, yHigh: d.interest + d.principal}];
                });

            bars.enter().append('rect')
                .attr('class', 'bar')
            bars.exit().remove();

            resize();
        }
        d3.select(window).on('resize.' + chartID, resize);
        return {draw: draw};
    }

    function balanceChart(chartID) {
        var chart = genericChart();
        d3.select('#' + chartID).call(chart);

        var line = d3.svg.line()
            .x(function(d) {return chart.scales.x(d.paymentDate.toDate());})
            .y(function(d) {return chart.scales.y(d.endingBalance);});

        var resize = function() {
            chart.setSizeAndScales();
            chart.drawAxes();
            chart.domElements.chart.select('#balanceLine').attr('d', line);
        };

        var draw = function(amSched) {
            chart.scales.x.domain(d3.extent(amSched.map(function(d) { return d.paymentDate.toDate(); })));
            chart.scales.y.domain([0, d3.max(amSched, function(d) { return d.endingBalance; })]);
            chart.domElements.chart.select('#balanceLine').remove();
            chart.domElements.chart.append('path')
                .datum(amSched)
                .attr('class', 'line')
                .attr('id', 'balanceLine');

            resize();
        };

        d3.select(window).on('resize.' + chartID, resize);
        return {draw: draw};
    }
    return {
        pAndIChart: pAndIChart,
        balanceChart: balanceChart
    };
})();
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.