Search code examples
javascriptchartslightningchart

How to prevent overlap cursor labels with LightningChartJS?


I'm using LightningChartJS v. 1.3.1 and have a chart with multiple series.

I want to have the cursor on all lines and show a label next to the cursor at the same time.

enter image description here

As far as I know there is a way to put the cursor to the nearest series with:

chart.setAutoCursorMode(AutoCursorModes.snapToClosest)

I didn't find a way to enable the cursor for all lines by default.

So I use a mousemove event listener to capture the event and put the cursor on all lines. Unfortunately the labels overlap if the lines are close together or cross each other and the cursor is not precise on the line, because I have to find the y value by search for the nearest index in the source data.

I would be very grateful if someone could help me to answer the following questions:

1. How to prevent overlap labels and control the label position?

2. Is there a more elegant way to show the cursors on all series?

if it is not possible to control the label on y axis,
Maybe it is enough to set the position alternately left and right.

3. How to do that?

enter image description here

Please see the example below:

const {
    AutoCursorModes, AxisTickStrategies, ChartMarkerXY, ChartXY, ColorHEX, ColorPalettes, ColorRGBA, DataPatterns, emptyFill, emptyLine, FontSettings, lightningChart, MarkerBuilders, PointShape, SolidFill, SolidLine, translatePoint, transparentFill, UIBackgrounds, UIDraggingModes, UIElement, UIElementBuilders, UILayoutBuilders, UIOrigins, UIVisibilityModes, VisibleTicks
} = lcjs

const setData = (count) => {
    const data = [];
    
    for (var i = 0; i < count; i++) {
        data.push({x: i, y: Math.floor(Math.random() * 100) + 50});
    }
    return data;
}

const getIndexTimeStamp = (arr, x) => {

        const goal = x;


        let closestValue = Infinity;
        let closestIndex = -1;

        for (let i = 0; i < arr.length; ++i) {
            const diff = Math.abs(arr[i].x - goal);
            
            if (diff < closestValue) {
                closestValue = diff;
                closestIndex = i;
            }
        }

        return closestIndex;
    }

const setChartMarkerPosition = (marker, colorHex, locationX, yValue, content) => {
        marker.setPosition({ x: locationX, y:  yValue });

        marker
            .setResultTableVisibility(UIVisibilityModes.always)
            .setResultTable((table) => table
                .setContent([[content]])
                .setTextFillStyle(new SolidFill({color: ColorHEX(colorHex)}))
                .setBackground(background => background)
             )
            .setGridStrokeXVisibility(UIVisibilityModes.whenDragged)
            .setGridStrokeYVisibility(UIVisibilityModes.whenDragged)
            .setTickMarkerXVisibility(UIVisibilityModes.whenDragged)
            .setTickMarkerYVisibility(UIVisibilityModes.whenDragged);

};

const chart = lightningChart().ChartXY({
    containerId: "chart",
    defaultAxisXTickStrategy: Object.assign({}, AxisTickStrategies.Numeric)
});

const axisY = chart.getDefaultAxisY();

axisY.setInterval(0, 200, false, true);

const series1 = chart.addLineSeries({ dataPattern: DataPatterns.horizontalProgressive}).setStrokeStyle(new SolidLine({thickness: 1.2, fillStyle: new SolidFill({color: ColorHEX('#FF0000')} )} ))
.setResultTableFormatter((tableBuilder, series, x, y) => tableBuilder
                // is empty to skip marker text
);

const series2 = chart.addLineSeries({ dataPattern: DataPatterns.horizontalProgressive}).setStrokeStyle(new SolidLine({thickness: 1.2, fillStyle: new SolidFill({color: ColorHEX('#FFFF00')})}))
.setResultTableFormatter((tableBuilder, series, x, y) => tableBuilder
                // is empty to skip marker text);
);

const series3 = chart.addLineSeries({ dataPattern: DataPatterns.horizontalProgressive}).setStrokeStyle(new SolidLine({thickness: 1.2, fillStyle: new SolidFill({color: ColorHEX('#FFFFFF')})}))
.setResultTableFormatter((tableBuilder, series, x, y) => tableBuilder
                // is empty to skip marker text
);

const data1 = setData(100);
const data2 = setData(100);
const data3 = setData(100);

series1.add( data1 );
series2.add( data2 );
series3.add( data3 );

const elem = document.getElementById('chart');
const elemLeftSpace = elem.getBoundingClientRect().left;
const elemTopSpace = elem.getBoundingClientRect().top;

let marker1;
let marker2;
let marker3;

elem.addEventListener( 'mousemove', ( event ) => {

     const cursorPoint = chart.solveNearest({x: event.clientX - elemLeftSpace, y: event.clientY - elemTopSpace});
    
    if (cursorPoint) {
        const locationOnAxes = translatePoint(
            chart.engine.clientLocation2Engine(event.clientX, event.clientY),
            chart.engine.scale,
            {
               x: chart.getDefaultAxisX().scale,
               y: chart.getDefaultAxisY().scale
            });
            
           
            const foundSeries_1 = getIndexTimeStamp(data1, Math.ceil(cursorPoint.location.x));
            const foundSeries_2 = getIndexTimeStamp(data2, Math.ceil(cursorPoint.location.x));
            const foundSeries_3 = getIndexTimeStamp(data3, Math.ceil(cursorPoint.location.x));


            if (foundSeries_1 > -1) {
                
                if (!marker1) { marker1 = chart.addChartMarkerXY(); }
           
                setChartMarkerPosition( 
                    marker1, 
                    '#FF0000', 
                    cursorPoint.location.x, 
                    data1[foundSeries_1].y, 
                    'Marker 1: ' + (cursorPoint.location.y).toFixed(1)
                );
            }
            
            if (foundSeries_2 > -1) {
                
                if (!marker2) { marker2 = chart.addChartMarkerXY(); }
           
                setChartMarkerPosition( 
                    marker2, 
                    '#FFFF00', 
                    cursorPoint.location.x, 
                    data2[foundSeries_2].y, 
                    'Marker 2 ' + (cursorPoint.location.y).toFixed(3)
                );
            }
            
            if (foundSeries_3 > -1) {
                
                if (!marker3) { marker3 = chart.addChartMarkerXY(); }
           
                setChartMarkerPosition( 
                    marker3, 
                    '#FFFFFF', 
                    cursorPoint.location.x, 
                    data3[foundSeries_3].y, 
                    'Marker 3: ' + (cursorPoint.location.y).toFixed(1)
                );
            }
            
    }
});
<div class="wrapper">
   <div id="chart" style="height: 200px;"></div>
</div>

<script src="https://unpkg.com/@arction/[email protected]/dist/lcjs.iife.js"></script>


Solution

  • There is no built in multi-series cursor yet.

    The easiest way to achieve what you are trying to do is to create a chart marker and a label for the marker separately. This way you get complete control over where the label is positioned.

    Overlap can be prevented by checking if the label will collide with other labels and if it would collide then the label should be moved enough to not collide.

    const positionLabels = (labels, markers) => {
        const info = []
        labels.forEach((label, i) => {
            const mPos = markers[i].getPosition()
            info[i] = {
                mPlacement: mPos,
                size: label.getSize(),
                screenPos: translatePoint(mPos, { x: chart.getDefaultAxisX().scale, y: chart.getDefaultAxisY().scale }, chart.pixelScale),
                label
            }
        })
        info.sort((a, b) => a.mPlacement.y - b.mPlacement.y)
        const midIndex = Math.floor((info.length - 1) / 2)
        // Ensure labels don't overlap
        // The middle most label is kept in place other labels are moved up or down, if needed, based on available space
        for (let i = midIndex + 1; i < info.length; i += 1) {
            const currLabel = info[i]
            const compareTarget = info[i - 1]
            if (currLabel.screenPos.y - currLabel.size.y / 2 < compareTarget.screenPos.y + compareTarget.size.y / 2) {
                currLabel.screenPos.y = compareTarget.screenPos.y + compareTarget.size.y / 2 + currLabel.size.y / 2
            }
        }
        for (let i = midIndex - 1; i >= 0; i -= 1) {
            const currLabel = info[i]
            const compareTarget = info[i + 1]
            if (currLabel.screenPos.y + compareTarget.size.y / 2 > compareTarget.screenPos.y - compareTarget.size.y / 2) {
                currLabel.screenPos.y = compareTarget.screenPos.y - (compareTarget.size.y / 2 + currLabel.size.y / 2)
            }
        }
        // apply new positions
        info.forEach(inf => inf.label.setPosition(inf.screenPos))
    }
    

    In that snippet I go trough each marker/label and ensure that the labels aren't colliding. The labels are moved so that the middle most label will always be next to the marker for it but the labels above or below it are moved so that there will never be any overlap.

    See the full example below for how to implement that.

    const {
      UIVisibilityModes,
      SolidFill,
      ColorHEX,
      lightningChart,
      AxisTickStrategies,
      DataPatterns,
      SolidLine,
      translatePoint,
      UIElementBuilders,
      UIOrigins,
      UIBackgrounds,
      Themes
    } = lcjs
    const setData = (count) => {
      const data = [];
    
      for (var i = 0; i < count; i++) {
        data.push({
          x: i,
          y: Math.floor(Math.random() * 100) + 50
        });
      }
      return data;
    }
    
    const setChartMarkerPosition = (cm, locationX, yValue, content) => {
      cm.marker.restore()
      cm.label.restore()
      const pos = {
        x: locationX,
        y: yValue
      }
      cm.marker.setPosition(pos)
      cm.label.setText(content)
    };
    
    const positionLabels = (labels, markers) => {
      const info = []
      labels.forEach((label, i) => {
        const mPos = markers[i].getPosition()
        info[i] = {
          mPlacement: mPos,
          size: label.getSize(),
          screenPos: translatePoint(mPos, {
            x: chart.getDefaultAxisX().scale,
            y: chart.getDefaultAxisY().scale
          }, chart.pixelScale),
          label
        }
      })
      info.sort((a, b) => a.mPlacement.y - b.mPlacement.y)
      const midIndex = Math.floor((info.length - 1) / 2)
      // Ensure labels don't overlap
      // The middle most label is kept in place other labels are moved up or down, if needed, based on available space
      for (let i = midIndex + 1; i < info.length; i += 1) {
        const currLabel = info[i]
        const compareTarget = info[i - 1]
        if (currLabel.screenPos.y - currLabel.size.y / 2 < compareTarget.screenPos.y + compareTarget.size.y / 2) {
          currLabel.screenPos.y = compareTarget.screenPos.y + compareTarget.size.y / 2 + currLabel.size.y / 2
        }
      }
      for (let i = midIndex - 1; i >= 0; i -= 1) {
        const currLabel = info[i]
        const compareTarget = info[i + 1]
        if (currLabel.screenPos.y + compareTarget.size.y / 2 > compareTarget.screenPos.y - compareTarget.size.y / 2) {
          currLabel.screenPos.y = compareTarget.screenPos.y - (compareTarget.size.y / 2 + currLabel.size.y / 2)
        }
      }
      // apply new positions
      info.forEach(inf => inf.label.setPosition(inf.screenPos))
    }
    
    const chart = lightningChart().ChartXY({
      containerId: "chart",
      defaultAxisXTickStrategy: Object.assign({}, AxisTickStrategies.Numeric)
    });
    
    const axisY = chart.getDefaultAxisY();
    
    axisY.setInterval(0, 200, false, true);
    const emptyTableBuilder = (tableBuilder, series, x, y) => tableBuilder
    const series1 = chart.addLineSeries({
        dataPattern: DataPatterns.horizontalProgressive
      })
      .setStrokeStyle(new SolidLine({
        thickness: 1.2,
        fillStyle: new SolidFill({
          color: ColorHEX('#FF0000')
        })
      }))
      .setResultTableFormatter(emptyTableBuilder
        // is empty to skip marker text
      );
    
    const series2 = chart.addLineSeries({
        dataPattern: DataPatterns.horizontalProgressive
      })
      .setStrokeStyle(new SolidLine({
        thickness: 1.2,
        fillStyle: new SolidFill({
          color: ColorHEX('#FFFF00')
        })
      }))
      .setResultTableFormatter(emptyTableBuilder
        // is empty to skip marker text);
      );
    
    const series3 = chart.addLineSeries({
        dataPattern: DataPatterns.horizontalProgressive
      })
      .setStrokeStyle(new SolidLine({
        thickness: 1.2,
        fillStyle: new SolidFill({
          color: ColorHEX('#FFFFFF')
        })
      }))
      .setResultTableFormatter(emptyTableBuilder
        // is empty to skip marker text
      );
    
    const data1 = setData(100);
    const data2 = setData(100);
    const data3 = setData(100);
    
    series1.add(data1);
    series2.add(data2);
    series3.add(data3);
    
    const createCustomMarker = (colorHex) => {
      const marker = chart.addChartMarkerXY()
        .setResultTableVisibility(UIVisibilityModes.never)
        .setGridStrokeXVisibility(UIVisibilityModes.never)
        .setGridStrokeYVisibility(UIVisibilityModes.never)
        .setTickMarkerXVisibility(UIVisibilityModes.never)
        .setTickMarkerYVisibility(UIVisibilityModes.never)
      const fill = new SolidFill({
        color: ColorHEX(colorHex)
      })
      return {
        marker: marker
          .setPointMarker(m => m.setFillStyle(fill)),
        label: chart.addUIElement(UIElementBuilders.TextBox
            .setBackground(UIBackgrounds.Rectangle)
            .addStyler(styler => styler
              .setBackground(bg => bg
                .setStrokeStyle(Themes.dark.uiBackgroundStrokeStyle)
                .setFillStyle(Themes.dark.uiBackgroundFillStyle)
              )
            ), chart.pixelScale)
          .setOrigin(UIOrigins.LeftCenter)
          .setTextFillStyle(fill)
      }
    }
    
    const elem = document.getElementById('chart');
    
    let marker1 = createCustomMarker('#FF0000')
    let marker2 = createCustomMarker('#FFFF00')
    let marker3 = createCustomMarker('#FFFFFF')
    marker1.marker.dispose()
    marker1.label.dispose()
    marker2.marker.dispose()
    marker2.label.dispose()
    marker3.marker.dispose()
    marker3.label.dispose()
    
    elem.addEventListener('mousemove', (event) => {
      const mousePos = chart.engine.clientLocation2Engine(event.clientX, event.clientY)
    
      const p1 = series1.solveNearestFromScreen(mousePos, true)
      if (p1) {
        setChartMarkerPosition(
          marker1,
          p1.location.x,
          p1.location.y,
          'Marker 1: ' + (p1.location.y).toFixed(1)
        );
      } else {
        // hide marker if no point is resolved
        marker1.marker.dispose()
        marker1.label.dispose()
      }
    
      const p2 = series2.solveNearestFromScreen(mousePos, true)
      if (p2) {
        setChartMarkerPosition(
          marker2,
          p2.location.x,
          p2.location.y,
          'Marker 2 ' + (p2.location.y).toFixed(3)
        );
      } else {
        // hide marker if no point is resolved
        marker2.marker.dispose()
        marker2.label.dispose()
      }
    
      const p3 = series3.solveNearestFromScreen(mousePos, true)
      if (p3) {
        setChartMarkerPosition(
          marker3,
          p3.location.x,
          p3.location.y,
          'Marker 3: ' + (p3.location.y).toFixed(1)
        );
      } else {
        // hide marker if no point is resolved
        marker3.marker.dispose()
        marker3.label.dispose()
      }
    
      positionLabels([marker1.label, marker2.label, marker3.label], [marker1.marker, marker2.marker, marker3.marker])
    });
    // hide the markers when mouse is not over the area
    elem.addEventListener('mouseleave', () => {
      marker1.marker.dispose()
      marker1.label.dispose()
      marker2.marker.dispose()
      marker2.label.dispose()
      marker3.marker.dispose()
      marker3.label.dispose()
    })
    <div class="wrapper">
      <div id="chart" style="height: 200px;"></div>
    </div>
    
    <script src="https://unpkg.com/@arction/[email protected]/dist/lcjs.iife.js"></script>