Search code examples
d3.jsd3fc

d3fc - Crosshair with snapping using latest version 14


In previous version of d3fc my code was using fc.util.seriesPointSnapXOnly for snapping the crosshair.

This appears to be gone in the latest version of d3fc (or maybe I'm missing it in one of the standalone packages?).

I'm using the canvas implementation (annotationCanvasCrosshair) and it seems to also be missing the "snap" function where it was previously used like so:

fc.tool.crosshair()
 .snap(fc.util.seriesPointSnapXOnly(line, series))

Additionally, "on" is also not available, so I can't attach events like trackingstart, trackingend, etc.

How can I implement a snapping crosshair now? The canvas version of the components are badly lacking examples. Does anyone have an example showing a snapping crosshair in the latest version of d3fc via canvas rendering?

Here's what I have so far https://codepen.io/parliament718/pen/xxbQGgp


Solution

  • I understand you've raised the issue with d3fc github, therefore I'll assume you are aware that util/snap.js is been deprecated.

    Since this functionality unsupported now, it seems that the only feasible way to work around it will be to implement your own.

    I took your pen and original snap.js code as starting point and applied the method outlined in Simple Crosshair example from the documentation.

    I ended up having to add missing functions and their dependencies verbatim (surely you can refactor and package it up into a separate module):

    function defined() {
        var outerArguments = arguments;
        return function(d, i) {
            for (var c = 0, j = outerArguments.length; c < j; c++) {
                if (outerArguments[c](d, i) == null) {
                    return false;
                } 
            }
            return true;
        };
    }
    
    function minimum(data, accessor) {
        return data.map(function(dataPoint, index) {
            return [accessor(dataPoint, index), dataPoint, index];
        }).reduce(function(accumulator, dataPoint) {
            return accumulator[0] > dataPoint[0] ? dataPoint : accumulator;
        }, [Number.MAX_VALUE, null, -1]);
    }
    
    function pointSnap(xScale, yScale, xValue, yValue, data, objectiveFunction) {
        // a default function that computes the distance between two points
        objectiveFunction = objectiveFunction || function(x, y, cx, cy) {
            var dx = x - cx,
                dy = y - cy;
            return dx * dx + dy * dy;
        };
    
        return function(point) {
            var filtered = data.filter(function(d, i) {
                return defined(xValue, yValue)(d, i);
            });
    
            var nearest = minimum(filtered, function(d) {
                return objectiveFunction(point.x, point.y, xScale(xValue(d)), yScale(yValue(d)));
            })[1];
    
            return [{
                datum: nearest,
                x: nearest ? xScale(xValue(nearest)) : point.x,
                y: nearest ? yScale(yValue(nearest)) : point.y
            }];
        };
    }
    
    function seriesPointSnap(series, data, objectiveFunction) {
        return function(point) { 
            var xScale = series.xScale(),
                yScale = series.yScale(),
                xValue = series.crossValue(),
                yValue = (series.openValue).call(series);
            return pointSnap(xScale, yScale, xValue, yValue, data, objectiveFunction)(point);
        };
    };
    
    function seriesPointSnapXOnly(series, data) {
        function objectiveFunction(x, y, cx, cy) {
            var dx = x - cx;
            return Math.abs(dx);
        }
        return seriesPointSnap(series, data, objectiveFunction);
    }
    

    The working end result can be seen here: https://codepen.io/timur_kh/pen/YzXXOOG. I basically defined two series and used a pointer component to update that second series data and trigger a re-render:

        const data = {
          series: stream.take(50), // your candle stick chart
          crosshair: [] // second series to hold the crosshair position
        };
        .............
        const crosshair = fc.annotationCanvasCrosshair() // define your crosshair
    
        const multichart = fc.seriesCanvasMulti()
                .series([candlesticks, crosshair]) // we've got two series now
          .mapping((data, index, series) => {
          switch(series[index]) {
            case candlesticks:
              return data.series;
            case crosshair:
              return data.crosshair;
          }
        });
        .............
    function render() {
      d3.select('#zoom-chart')
        .datum(data)
        .call(chart);  
        // add the pointer component to the plot-area, re-rendering each time the event fires.
      var pointer = fc.pointer()
        .on('point', (event) => {     
          data.crosshair = seriesPointSnapXOnly(candlesticks, data.series)(event[0]);// and when we update the crosshair position - we snap it to the other series using the old library code.
          render();
        });
    
      d3.select('#zoom-chart .plot-area')
        .call(pointer); 
    }
    

    UPD: the functionality can be simplified like so, i also updated the pen:

    function minimum(data, accessor) {
        return data.map(function(dataPoint, index) {
            return [accessor(dataPoint, index), dataPoint, index];
        }).reduce(function(accumulator, dataPoint) {
            return accumulator[0] > dataPoint[0] ? dataPoint : accumulator;
        }, [Number.MAX_VALUE, null, -1]);
    }
    
    function seriesPointSnapXOnly(series, data, point) { 
            if (point == undefined) return []; // short circuit if data point was empty
            var xScale = series.xScale(),
                xValue = series.crossValue();
    
            var filtered = data.filter((d) => (xValue(d) != null));
            var nearest = minimum(filtered, (d) => Math.abs(point.x - xScale(xValue(d))))[1]; 
    
            return [{
                x: xScale(xValue(nearest)),
                y: point.y 
            }];
        };
    

    This is far from polished, but I'm hoping it conveys the general idea.