Search code examples
javascriptd3.jssvglinegraph

Is there a function to find the approximate x and y co-ordinate values on a line chart using D3.js?


I have generated a line chart graph using D3. I am aware that I could use biject functionality to get exact values for x and y co-ordinates that I have specified in my data but I am interested in finding approximate x and y values for any given x or y value that falls with in the min-max range of my data set. Is that possible to do so with D3.js?


Solution

  • The method path.getPointAtLength can be used to get an x and y coordinate on path, and this can be recursively called to get a y coordinate that's equal, or very close to equal, to a specified x coordinate.

    In the example below, I've pre-calculated all the y coordinates for each x in the chart's width. This improves the mouse hover performance, by just looking up the y value each time the mouse moves over the chart (the hover over is captured using a invisible rect element).

            const height = 500
            const width = 500
            const margin = { "top": 20, "bottom": 20, "left": 20, "right": 20 }
    
            const data = [2, 5, 6, 7, 3, 8, 3, 4]
    
            let xScale = d3.scaleLinear()
                .domain([0, data.length - 1])
                .range([0, width])
    
            let yScale = d3.scaleLinear()
                .domain([0, 10])
                .range([height, 0])
    
            let xAxis = d3.axisBottom(xScale)
            let yAxis = d3.axisLeft(yScale)
    
            let curve = d3.curveCatmullRom.alpha(0.5)
    
            let line = d3.line()
                .x(function (d, i) { return xScale(i) })
                .y(function (d) { return yScale(d) })
                .curve(curve);
    
            let svg = d3.select("body").append("svg")
                .attr("width", width + margin.left + margin.right)
                .attr("height", height + margin.top + margin.bottom)
    
            let g = svg.append("g")
                .attr("transform", "translate(" + margin.left + "," + margin.top + ")")
    
            g.append("g")
                .attr("transform", "translate(0," + height + ")")
                .call(xAxis)
    
            g.append("g").call(yAxis)
    
            let path = g.append("path")
                .datum(data)
                .attr("d", line)
    
            let pathNode = path.node()
            let pathNodeLength = pathNode.getTotalLength()
    
            //for every x coordinate, get the y coordinates for the line
            //and store for use later on
            let allCoordinates = []
            let x = 0;
    
            for (x; x < width; x++) {
                let obj = {}
                obj.y = findY(pathNode, pathNodeLength, x, width)
                allCoordinates.push(obj)
            }
    
            let dots = g.selectAll(".dot")
                .data([1]) //create one circle for later use
                .enter()
                .append("g")
                .style("opacity", 0)
    
            dots.append("circle")
                .attr("r", 8)
    
            dotsBgdText = dots.append("text")
                .attr("class", "text-bgd")
                .attr("x", 0)
    
            dotsText = dots.append("text")
                 .attr("class", "text-fgd")
                .attr("x", 0)
    				
            //Add a rect to handle mouse events
            let rect = g.append("rect")
                .attr("width", width - 1) // minus 1 so that it doesn't return an x = width, as the coordinates is 0 based.
                .attr("height", height)
                .style("opacity", 0)
                .on("mousemove", function (d) {
    
                    let mouseX = d3.mouse(this)[0]
    
                    let dotsData = [
                        { "cx": mouseX, "cy": allCoordinates[mouseX].y}
                    ]
    
                    dots.data(dotsData)
                        .attr("transform", function (d) { return "translate(" + d.cx + "," + d.cy + ")" })
                        .style("opacity", 1)
    
                    dotsText.data(dotsData)
                        .text(function (d) {
                            return roundNumber(yScale.invert(d.cy));
                        })
                        .attr("y", function (d) {
                            let maxY = d3.max(dotsData, function (e) { return e.cx == d.cx ? e.cy : 0 })
                            return d.cy == maxY ? 27 : -15;
                        })
    
                    dotsBgdText.data(dotsData)
                        .text(function (d) {
                            return roundNumber(yScale.invert(d.cy));
                        })
                        .attr("y", function (d) {
                            let maxY = d3.max(dotsData, function (e) { return e.cx == d.cx ? e.cy : 0 })
                            return d.cy == maxY ? 27 : -15;
                        })               
    
                   
    
                })
         
    				
          	//iteratively search a path to get a point close to a desired x coordinate
            function findY(path, pathLength, x, width) {
                const accuracy = 1 //px
                const iterations = Math.ceil(Math.log10(accuracy/width) / Math.log10(0.5));  //for width (w), get the # iterations to get to the desired accuracy, generally 1px
                let i = 0;
                let nextLengthChange = pathLength / 2;
                let nextLength = pathLength / 2;
                let y = 0;
                for (i; i < iterations; i++) {
                    let pos = path.getPointAtLength(nextLength)
                    y = pos.y
                    nextLength = x < pos.x ? nextLength - nextLengthChange : nextLength + nextLengthChange
                    nextLengthChange = nextLengthChange / 2
                }
                return y
            }
    
            function roundNumber(n) {
                return Math.round(n * 100) / 100
            }
    path {
      stroke: black;
      fill: none;
    }
    
    .text-bgd {
      stroke: white;
      fill: white;
      stroke-width: 3;
      text-anchor: middle;
    }
    
    .text-fgd {
      stroke: none;
      text-anchor: middle;
    }
    <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>