Monday, March 14, 2022

React-Vis Graph with Million Points and Zoom


 


In this post we will review how to display a react-vis graph with million points, including zoom support.

React-Vis is a great library that can display many times of charts. In this post we will use a simple line chart with react and redux. We also include a zoom support. 

Another important issue in this post is handling millions of points. If we try to display millions of points, the react-vis library starts slowing down, hence we add special handling in transforming the original huge amount of points to up to 1000 visible points. We use a transformation to join several points into one average point to get to this requirement.


First, let review the reduce slice.


slice.js

import {createSlice} from '@reduxjs/toolkit'


const maxPoints = 1000

const initialState = {
selections: {},
points: {},
series: {},
}


function getSelectedPoints(points,selection) {
if (!selection) {
return points
}
const selectedPoints = []

points.forEach(point => {
if (point.x >= selection.left && point.x <= selection.right) {
selectedPoints.push(point)
}
})
return selectedPoints
}

function reducePoints(points, ratio) {
if (ratio <= 1) {
return points
}

const reduced = []
let slice = []
points.forEach(point => {
slice.push(point)
if (slice.length === ratio) {
let x = 0
let y = 0
slice.forEach(slicePoint => {
x += slicePoint.x
y += slicePoint.y
})


const averagePoint = {
x: x / slice.length,
y: y / slice.length,
}

reduced.push(averagePoint)

slice = []
}
})
return reduced
}

function convertPoints(state,graphId) {
const selection = state.selections[graphId]
const points = state.points[graphId]
const xWidth = points[points.length - 1].x - points[0].x
const selectedPoints = getSelectedPoints(points,selection)

let allowedPoints
if (selection) {
const selectedWidth = Math.trunc(selection.right - selection.left)
allowedPoints = Math.trunc(maxPoints * xWidth / selectedWidth)
} else {
allowedPoints = maxPoints
}

const ratio = Math.trunc(points.length / allowedPoints)
return reducePoints(selectedPoints, ratio)
}

function updateSeries(state, graphId) {
state.series[graphId] = [
{
title: 'Apples',
disabled: false,
data: convertPoints(state,graphId),
},
]
}


export const slice = createSlice({
name: 'graph',
initialState,
reducers: {
setPoints: (state, action) => {
const {graphId, points} = action.payload
state.points[graphId] = points
updateSeries(state,graphId)
},
setSelection: (state, action) => {
const {graphId, selection} = action.payload
state.selections[graphId] = selection
updateSeries(state,graphId)
},
clearSelection: (state, action) => {
const {graphId} = action.payload
delete state.selections[graphId]
updateSeries(state,graphId)
},
},
})

export const {setSelection, clearSelection, setPoints} = slice.actions


export function selectState(state) {
return state.graph
}

export default slice.reducer


 We supply 3 actions: set points, set selection (for zoom), and clear selection (for zoom removal).

The convertPoints function, first selects only the relevant points according to the zoom selection area, and the reduce the points according to the amount of points. For example, if we have 1,000,000 points, and we zoom on scale 400,000-500,000, then we're left with 100,000 points. Then we convert each 100 points to a single average point so we only use 1000 points for the actual display.


The following class controls the way the axis labels are displayed:



custom-axis-label.js

import React, { PureComponent } from 'react';
import './index.css'

class CustomAxisLabel extends PureComponent {

render() {

const yLabelOffset = {
y: this.props.marginTop + this.props.innerHeight / 2 + this.props.title.length*2,
x: 10
};

const xLabelOffset = {
x: this.props.marginLeft + (this.props.innerWidth)/2 - this.props.title.length*2,
y: 1.2 * this.props.innerHeight
};

const transform = this.props.xAxis
? `translate(${xLabelOffset.x}, ${xLabelOffset.y})`
: `translate(${yLabelOffset.x}, ${yLabelOffset.y}) rotate(-90)`;

return (
<g transform={transform}>
<text className= 'unselectable axis-labels'>
{this.props.title}
</text>
</g>
);
}
}

CustomAxisLabel.displayName = 'CustomAxisLabel';
CustomAxisLabel.requiresSVG = true;
export default CustomAxisLabel;


We set the labels orientation, and the margins for the labels.

The highlight class handles the zoom in the graph by sending and event with the selection zoom rectangle.


highlight.js

import React from "react";
import { ScaleUtils, AbstractSeries } from "react-vis";

export default class Highlight extends AbstractSeries {
static displayName = "HighlightOverlay";
static defaultProps = {
allow: "x",
color: "rgb(77, 182, 172)",
opacity: 0.3
};

state = {
drawing: false,
drawArea: { top: 0, right: 0, bottom: 0, left: 0 },
x_start: 0,
y_start: 0,
x_mode: false,
y_mode: false,
xy_mode: false
};

constructor(props){
super(props);
document.addEventListener("mouseup", function (e) {
this.stopDrawing()
}.bind(this));
}

_cropDimension(loc, startLoc, minLoc, maxLoc) {
if (loc < startLoc) {
return {
start: Math.max(loc, minLoc),
stop: startLoc
};
}

return {
stop: Math.min(loc, maxLoc),
start: startLoc
};
}

_getDrawArea(loc) {
const { innerWidth, innerHeight } = this.props;
const { x_mode, y_mode, xy_mode } = this.state;
const { drawArea, x_start, y_start } = this.state;
const { x, y } = loc;
let out = drawArea;

if (x_mode | xy_mode) {
// X mode or XY mode
const { start, stop } = this._cropDimension(x, x_start, 0, innerWidth);
out = {
...out,
left: start,
right: stop
}
}
if (y_mode | xy_mode) {
// Y mode or XY mode
const { start, stop } = this._cropDimension(y, y_start, 0, innerHeight);
out = {
...out,
top: innerHeight - start,
bottom: innerHeight - stop
}
}
return out
}

onParentMouseDown(e) {
const { innerHeight, innerWidth, onBrushStart } = this.props;
const { x, y } = this._getMousePosition(e);
const y_rect = innerHeight - y;

// Define zoom mode
if (x < 0 & y >= 0) {
// Y mode
this.setState({
y_mode: true,
drawing: true,
drawArea: {
top: y_rect,
right: innerWidth,
bottom: y_rect,
left: 0
},
y_start: y
});

} else if (x >= 0 & y < 0) {
// X mode
this.setState({
x_mode: true,
drawing: true,
drawArea: {
top: innerHeight,
right: x,
bottom: 0,
left: x
},
x_start: x
});

} else if (x >= 0 & y >= 0) {
// XY mode
this.setState({
xy_mode: true,
drawing: true,
drawArea: {
top: y_rect,
right: x,
bottom: y_rect,
left: x
},
x_start: x,
y_start: y
});
}

// onBrushStart callback
if (onBrushStart) {
onBrushStart(e);
}

}

stopDrawing() {
// Reset zoom state
this.setState({
x_mode: false,
y_mode: false,
xy_mode: false
});

// Quickly short-circuit if the user isn't drawing in our component
if (!this.state.drawing) {
return;
}

const { onBrushEnd } = this.props;
const { drawArea } = this.state;
const xScale = ScaleUtils.getAttributeScale(this.props, "x");
const yScale = ScaleUtils.getAttributeScale(this.props, "y");

// Clear the draw area
this.setState({
drawing: false,
drawArea: { top: 0, right: 0, bottom: 0, left: 0 },
x_start: 0,
y_start: 0
});

// Invoke the callback with null if the selected area was < 5px
if (Math.abs(drawArea.right - drawArea.left) < 5) {
onBrushEnd(null);
return;
}

// Compute the corresponding domain drawn
const domainArea = {
bottom: yScale.invert(drawArea.top),
right: xScale.invert(drawArea.right),
top: yScale.invert(drawArea.bottom),
left: xScale.invert(drawArea.left)
};

if (onBrushEnd) {
onBrushEnd(domainArea);
}
}

_getMousePosition(e) {
// Get graph size
const { marginLeft, marginTop, innerHeight } = this.props;

// Compute position in pixels relative to axis
const loc_x = e.nativeEvent.offsetX - marginLeft;
const loc_y = innerHeight + marginTop - e.nativeEvent.offsetY;

// Return (x, y) coordinates
return {
x: loc_x,
y: loc_y
}

}

onParentMouseMove(e) {
const { drawing } = this.state;

if (drawing) {
const pos = this._getMousePosition(e);
const newDrawArea = this._getDrawArea(pos);
this.setState({ drawArea: newDrawArea });
}

}

render() {
const {
marginLeft,
marginTop,
innerWidth,
innerHeight,
color,
opacity
} = this.props;
const { drawArea: { left, right, top, bottom } } = this.state;
return (
<g
transform={`translate(${marginLeft}, ${marginTop})`}
className="highlight-container">
<rect
className="mouse-target"
fill="black"
opacity="0"
x={0}
y={0}
width={innerWidth}
height={innerHeight}
/>
<rect
className="highlight"
pointerEvents="none"
opacity={opacity}
fill={color}
x={left}
y={bottom}
width={right - left}
height={top - bottom}
/>
</g>
);
}
}



Last one is the graph class with uses all of the above.


component.js

import React from 'react'
import '../../node_modules/react-vis/dist/style.css'

import {
Borders,
DiscreteColorLegend,
HorizontalGridLines,
LineSeries,
VerticalGridLines,
XAxis,
XYPlot,
YAxis,
} from 'react-vis'
import Highlight from './highlight'
import {useDispatch, useSelector} from 'react-redux'
import {selectState, setSelection} from './slice'

function Graph(props) {
const {graphId} = props
const dispatch = useDispatch()
const state = useSelector(selectState)
const selection = state.selections[graphId]
const series = state.series[graphId]

if (!series) {
return null
}

const width = 1000

function highlightArea(area) {
dispatch(setSelection({
graphId,
selection: area,
}))
}

return (
<div>
<div className="legend">
<DiscreteColorLegend
width={180}
items={series}/>
</div>

<div className="chart no-select" onDragStart={function (e) {
e.preventDefault()
}}>
<XYPlot
xDomain={selection && [selection.left, selection.right]}
yDomain={selection && [selection.bottom, selection.top]}
height={500}
width={width}
margin={{left: 45, right: 20, top: 10, bottom: 200}}>

<HorizontalGridLines/>
<VerticalGridLines/>

{series.map(entry => (
<LineSeries
key={entry.title}
data={entry.data}
/>
))}

<Highlight
onBrushEnd={highlightArea}
/>
<Borders style={{all: {fill: '#fff'}}}/>
<XAxis tickFormat={(v) => new Date(v * 3600 * 1000).toISOString()} tickLabelAngle={-60}/>
<YAxis tickFormat={(v) => (<tspan className="unselectable"> {v} </tspan>)}/>
</XYPlot>
</div>
</div>
)
}

export default Graph



Notice that the graph supports multiple instance by using the graphId property. In this case we treat the x-axis as hours so we multiply it we 3600 seconds. The graph display a list of points, each including the x and y properties.


Final Note

While the react-vis response time is good, redux slows the GUI down. To prevent this, configure redux to skip the data of the reducer containing the huge amount of data. For example, to ignore the graph reducer data, use:

import {configureStore} from '@reduxjs/toolkit'

import notification from './notification/slice'
import dashboard from './dashboard/slice'
import graph from './graph/slice'
import histogram from './histogram/slice'

export const store = configureStore({
middleware: (getDefaultMiddleware) => getDefaultMiddleware({
immutableCheck: {ignoredPaths: ['graph']},
serializableCheck: {ignoredPaths: ['graph']},
}),
reducer: {
dashboard,
histogram,
graph,
notification,
},
})


No comments:

Post a Comment