block by nbremer 9a2fdead4297d2747df3d60aa48f64f1

Canvas tutorial - Extreme level - Solution

Full Screen

The solution to the exercise (extreme level | 1st step) from my canvas tutorial where an SVG based scatterplot had to be turned into a canvas based one.

Built with blockbuilder.org

index.html

<!DOCTYPE html>

<head>
    <meta charset="utf-8">

    <style>
        body {
            font-family: 'Roboto', sans-serif;
        }

        svg, canvas {
            position: absolute;
            top: 0;
            left: 0;
        }

        svg {
             /* so the canvas will capture all hover action */
            pointer-events: none;
        }

        .chart-title {
            font-size: 16px;
            text-anchor: middle;
            letter-spacing: 1.3px;
        }

        .axis .title {
            font-size: 12px;
            font-weight: 500;
            text-anchor: middle;
            text-transform: uppercase;
            letter-spacing: 1.3px;
            fill: #6b6b6b;
        }

        .axis text {
            font-family: 'Roboto', sans-serif;
            font-size: 11px;
            fill: #919191;
        }

        .axis path {
            display: none;
        }

        .axis line {
            stroke: #a5a5a5;
        }
    </style>
    <!-- Google fonts -->
    <link href="https://fonts.googleapis.com/css?family=Roboto:400,500" rel="stylesheet">

    <script src="https://d3js.org/d3.v4.min.js"></script>
</head>

<body>
    <script>
        ////////////// Create the SVG and canvas //////////////

        let margin = {
            top: 40,
            right: 10,
            bottom: 60,
            left: 100
        }
        let width = 1000 - margin.left - margin.right,
            height = 800 - margin.top - margin.bottom,
            width_m = width + margin.left + margin.right,
            height_m = height + margin.top + margin.bottom

        //Create the canvas, while doing the "retina hack"
        let canvas = d3.select("body")
            .append("canvas")
            .attr("width", 2 * width_m)
            .attr("height", 2 * height_m)
            .style("width", width_m + "px")
            .style("height", height_m + "px")

        //Get the 2D context from the canvas element
        let ctx = canvas.node().getContext("2d")
        ctx.scale(2,2)
        ctx.translate(margin.left, margin.top)

        //Create the SVG container
        let svg = d3.select("body")
            .append("svg")
            .attr("width", width_m)
            .attr("height", height_m)
            .append("g")
            .attr("transform", "translate(" + margin.left + "," + margin.top + ")")

        ////////////// Create scales //////////////

        const x_scale = d3.scaleLinear() //rating
            .range([0, width])

        const y_scale = d3.scaleLog() //budget
            .domain([500, 500e6]) //in dollars
            .range([height, 0])

        const r_scale = d3.scaleSqrt() //profit ratio = revenue / budget
            .domain([0, 6])
            .range([0, 10])

        //Make the movies that have the highest profit ratio less visible
        //to balance their increased size
        const opacity_scale = d3.scaleLinear() //profit ratio = revenue / budget
            .domain([0, 100])
            .range([0.2, 0.01])
            .clamp(true)

        //A linear color scale to give an idea about when a movie was made
        const color_scale = d3.scaleSequential(d3.interpolateViridis) //release year

        ////////////// Voronoi //////////////

        //Initialize the voronoi within the rectangle of width x height
        const voronoi = d3.voronoi()
            .x(function (d) { return x_scale(d.rating) })
            .y(function (d) { return y_scale(d.budget) })
            .extent([[0, 0], [width, height]])

        ////////////// Read in the data //////////////

        //Data based on dataset from 
        //https://data.world/popculture/imdb-5000-movie-dataset
        d3.csv("imdb-movies.csv", function (error, data) {
            if (error) throw error

            ////////////// Final data prep //////////////

            //Make the number columns actually numeric
            data.forEach(d => {
                d.release_year = +d.release_year
                d.budget = +d.budget
                d.revenue = +d.revenue
                d.rating = +d.rating
                d.num_voted_users = +d.num_voted_users
                d.profit_ratio = +d.profit_ratio
            })

            ////////////// Adjust scales //////////////

            //Set the range for the scales now that the data is read in
            x_scale.domain(d3.extent(data, d => d.rating)).nice()
            color_scale.domain(d3.extent(data, d => d.release_year))

            ////////////// Create circles //////////////

            draw_all_circles()

            //Turned into a function, because this is also needed for the hover
            function draw_all_circles() {
                //Clear the entire canvas
                ctx.clearRect(0,0,width_m,height_m)

                data.forEach(d => {
                    //Set the color and opacity
                    ctx.fillStyle = color_scale(d.release_year)
                    ctx.globalAlpha = opacity_scale(d.profit_ratio)

                    //Draw the circle
                    ctx.beginPath()
                    ctx.arc(x_scale(d.rating), y_scale(d.budget), r_scale(d.profit_ratio), 0, 2 * Math.PI)
                    ctx.closePath()
                    ctx.fill()
                })
            }//function draw_all_circles

            ////////////// Create axes //////////////

            let x_axis = svg.append("g") //x scale - rating
                .attr("class", "axis x")
                .attr("transform", "translate(0 " + height + ")")
                .call(d3.axisBottom(x_scale))

            let y_axis = svg.append("g") //y scale - budget
                .attr("class", "axis y")
                .call(d3.axisLeft(y_scale)
                    .ticks(5)
                    .tickFormat(d3.format("$,.0s"))
                )

            ////////////// Create titles //////////////

            //Add chart title
            svg.append("text")
                .attr("class", "chart-title")
                .attr("x", width / 2)
                .attr("y", -10)
                .text("comparing budgets versus ratings for nearly 5000 movies across the last 90 years")

            //Add x title
            x_axis.append("text")
                .attr("class", "title")
                .attr("x", width / 2)
                .attr("y", 40)
                .text("Rating")

            //Add y title
            y_axis.append("text")
                .attr("class", "title")
                .attr("x", 0)
                .attr("y", 50)
                .text("Budget")

            ////////////// Create hover interaction //////////////

            //Create the voronoi grid (but this is not drawn)
            diagram = voronoi(data)

            //When a mouse is moved over the canvas, see if it’s near enough
            canvas.on("mousemove", function() {
                //Get the x and y location of the mouse over the canvas
                let m = d3.mouse(this)
                //Find the nearest movie to the mouse within a max distance of 50 pixels
                let found = diagram.find(m[0] - margin.left, m[1] - margin.top, 50)

                //If found is defined, run a function to do stuff
                if (found) highlight_circle(found.data) 
                else draw_all_circles()
            })//on mousemove

            function highlight_circle(d) {
                console.log(d.title)

                //Draw all circles
                draw_all_circles()

                //Draw the mouse-overed circle in black
                ctx.fillStyle = "black"
                ctx.globalAlpha = 1
                //Draw the circle
                ctx.beginPath()
                ctx.arc(x_scale(d.rating), y_scale(d.budget), r_scale(d.profit_ratio), 0, 2*Math.PI)
                ctx.closePath()
                ctx.fill()

                //NOTE | this is actually rather ineffective: redrawing all circles on each mousemove.
                //It would be much better to either have another canvas on which you draw the black circle if needed
                //Or to just draw that 1 black circle on the SVG when needed
            }//function draw_all_circles

        })//d3.csv

    </script>
</body>