DLT
DLT

Reputation: 441

Select points in line series

I'd like to use a modifier key with the left mouse button that will select the data inside the rectangle, rather than the zoom to that data. Is this possible? I cannot find a suitable API for it. Bonus points if there's a way to select data that falls inside a polygon (like a lasso tool).

Upvotes: 1

Views: 372

Answers (1)

Niilo Keinänen
Niilo Keinänen

Reputation: 2582

Here's one example of completely custom ChartXY interactions. Key points:

  • Default rectangle fit & zoom interactions are disabled.

  • Line series data is cached to a variable which can be used for custom statistics.

  • RectangleSeries is used for visualizing drag area on chart.

  • UI elements are used for displaying statistics of selected area.

  • ChartXY.onSeriesBackgroundMouseDrag event is used for hooking custom actions to user interactions.

Below you'll find a code snippet where dragging with left mouse button creates a rectangular area which shows highlighted X area and solved Y data range within. Releasing the mouse button results in the full selected data points array being solved (length is logged to console).

const {
  Point,
  ColorRGBA,
  SolidFill,
  RadialGradientFill,
  SolidLine,
  translatePoint,
  lightningChart,
  UIOrigins,
  UIElementBuilders,
  UILayoutBuilders,
  emptyFill
} = lcjs;

const { createProgressiveTraceGenerator } = xydata;

const chart = lightningChart()
    .ChartXY()
    // Disable default chart interactions with left mouse button.
    .setMouseInteractionRectangleFit(false)
    .setMouseInteractionRectangleZoom(false)
    .setTitleFillStyle(emptyFill)

const axisX = chart.getDefaultAxisX()
const axisY = chart.getDefaultAxisY()

const lineSeries = chart.addLineSeries({
    dataPattern: {
        pattern: 'ProgressiveX',
    },
})

// Generate test data set.
let dataSet
createProgressiveTraceGenerator()
    .setNumberOfPoints(10 * 1000)
    .generate()
    .toPromise()
    .then((data) => {
        // Cache data set for analytics logic + add static data to series.
        dataSet = data
        lineSeries.add(data)
    })

// Rectangle Series is used to display data selection area.
const rectangleSeries = chart.addRectangleSeries()
const rectangle = rectangleSeries
    .add({ x1: 0, y1: 0, x2: 0, y2: 0 })
    .setFillStyle(
        new RadialGradientFill({
            stops: [
                { offset: 0, color: ColorRGBA(255, 255, 255, 30) },
                { offset: 1, color: ColorRGBA(255, 255, 255, 60) },
            ],
        }),
    )
    .setStrokeStyle(
        new SolidLine({
            thickness: 2,
            fillStyle: new SolidFill({ color: ColorRGBA(255, 255, 255, 255) }),
        }),
    )
    .dispose()

// UI elements are used to display information about the selected data points.
const uiInformationLayout = chart.addUIElement(UILayoutBuilders.Column, { x: axisX, y: axisY }).dispose()
const uiLabel0 = uiInformationLayout.addElement(UIElementBuilders.TextBox)
const uiLabel1 = uiInformationLayout.addElement(UIElementBuilders.TextBox)

// Add events for custom interactions.
chart.onSeriesBackgroundMouseDrag((_, event, button, startLocation) => {
    // If not left mouse button, don't do anything.
    if (button !== 0) return

    // Translate start location and current location to axis coordinates.
    const startLocationAxis = translatePoint(
        chart.engine.clientLocation2Engine(startLocation.x, startLocation.y),
        chart.engine.scale,
        lineSeries.scale,
    )
    const curLocationAxis = translatePoint(
        chart.engine.clientLocation2Engine(event.clientX, event.clientY),
        chart.engine.scale,
        lineSeries.scale,
    )

    // Place Rectangle figure between start location and current location.
    rectangle.restore().setDimensions({
        x1: startLocationAxis.x,
        y1: startLocationAxis.y,
        x2: curLocationAxis.x,
        y2: curLocationAxis.y,
    })

    // * Gather analytics from actively selected data *
    const xStart = Math.min(startLocationAxis.x, curLocationAxis.x)
    const xEnd = Math.max(startLocationAxis.x, curLocationAxis.x)
    // Selected Y range has to be solved from data set.
    // NOTE: For top solve performance, results should be cached and only changes from previous selection area should be checked.
    const { yMin, yMax } = solveDataRangeY(xStart, xEnd)

    // Set UI labels text.
    uiLabel0.setText(`X: [${xStart.toFixed(0)}, ${xEnd.toFixed(0)}]`)
    uiLabel1.setText(`Y: [${yMin.toFixed(1)}, ${yMax.toFixed(1)}]`)

    // Place UI layout above Rectangle.
    uiInformationLayout
        .restore()
        .setOrigin(UIOrigins.LeftBottom)
        .setPosition({ x: xStart, y: Math.max(startLocationAxis.y, curLocationAxis.y) })
})

chart.onSeriesBackgroundMouseDragStop((_, event, button, startLocation) => {
    // If not left mouse button, don't do anything.
    if (button !== 0) return

    // Translate start location and current location to axis coordinates.
    const startLocationAxis = translatePoint(
        chart.engine.clientLocation2Engine(startLocation.x, startLocation.y),
        chart.engine.scale,
        lineSeries.scale,
    )
    const curLocationAxis = translatePoint(
        chart.engine.clientLocation2Engine(event.clientX, event.clientY),
        chart.engine.scale,
        lineSeries.scale,
    )

    // Print selected data points to console.
    const xStart = Math.max(0, Math.floor(Math.min(startLocationAxis.x, curLocationAxis.x)))
    const xEnd = Math.min(dataSet.length - 1, Math.ceil(Math.max(startLocationAxis.x, curLocationAxis.x)))
    const selectedDataPoints = dataSet.slice(xStart, xEnd)
    console.log(`Selected ${selectedDataPoints.length} data points.`)

    // Hide visuals.
    rectangle.dispose()
    uiInformationLayout.dispose()
})

// Logic for solving Y data range between supplied X range from active data set.
const solveDataRangeY = (xStart, xEnd) => {
    // Reduce Y data min and max values within specified X range from data set.
    // Note, this can be very heavy for large data sets - repeative calls should be avoided as much as possible for best performance.
    let yMin = Number.MAX_SAFE_INTEGER
    let yMax = -Number.MAX_SAFE_INTEGER
    xStart = Math.max(0, Math.floor(xStart))
    xEnd = Math.min(dataSet.length - 1, Math.ceil(xEnd))
    for (let iX = xStart; iX < xEnd; iX += 1) {
        const y = dataSet[iX].y
        yMin = y < yMin ? y : yMin
        yMax = y > yMax ? y : yMax
    }
    return { yMin, yMax }
}
<script src="https://unpkg.com/@arction/[email protected]/dist/xydata.iife.js"></script>
<script src="https://unpkg.com/@arction/[email protected]/dist/lcjs.iife.js"></script>

There's many different directions to go with this kind of custom interactions, and while we can't cover every single one with an example, most of the logic should stay the same.

Upvotes: 2

Related Questions