Allow custom options for charts

This commit is contained in:
Thomas Nordquist
2019-06-17 16:37:13 +02:00
parent 2296143883
commit 0f9c2cd36f
10 changed files with 367 additions and 39 deletions

View File

@@ -66,7 +66,7 @@ export const addChart = (chartParameters: ChartParameters) => async (
dispatch: Dispatch<any>, dispatch: Dispatch<any>,
getState: () => AppState getState: () => AppState
) => { ) => {
let chartExists = Boolean( const chartExists = Boolean(
getState() getState()
.charts.get('charts') .charts.get('charts')
.find(chart => chart.topic === chartParameters.topic && chart.dotPath === chartParameters.dotPath) .find(chart => chart.topic === chartParameters.topic && chart.dotPath === chartParameters.dotPath)
@@ -82,6 +82,19 @@ export const addChart = (chartParameters: ChartParameters) => async (
dispatch(saveCharts()) dispatch(saveCharts())
} }
export const updateChart = (chartParameters: ChartParameters) => async (
dispatch: Dispatch<any>,
getState: () => AppState
) => {
dispatch({
type: ActionTypes.CHARTS_UPDATE,
topic: chartParameters.topic,
dotPath: chartParameters.dotPath,
parameters: chartParameters,
})
dispatch(saveCharts())
}
export const removeChart = (chartParameters: ChartParameters) => async ( export const removeChart = (chartParameters: ChartParameters) => async (
dispatch: Dispatch<any>, dispatch: Dispatch<any>,
getState: () => AppState getState: () => AppState

View File

@@ -8,7 +8,8 @@ import { bindActionCreators } from 'redux'
import { chartActions } from '../../actions' import { chartActions } from '../../actions'
import { ChartParameters } from '../../reducers/Charts' import { ChartParameters } from '../../reducers/Charts'
import { connect } from 'react-redux' import { connect } from 'react-redux'
import { Fade, Paper, Theme, Typography, withStyles } from '@material-ui/core' import { Paper, Theme, Typography, withStyles } from '@material-ui/core'
import { SettingsButton } from './ChartSettings/SettingsButton'
const throttle = require('lodash.throttle') const throttle = require('lodash.throttle')
interface Props { interface Props {
@@ -28,41 +29,17 @@ function Chart(props: Props) {
const { tree, parameters } = props const { tree, parameters } = props
const initialTreeNode = tree.findNode(parameters.topic) const initialTreeNode = tree.findNode(parameters.topic)
const [treeNode, setTreeNode] = React.useState<q.TreeNode<any> | undefined>(initialTreeNode) const [treeNode, setTreeNode] = React.useState<q.TreeNode<any> | undefined>(initialTreeNode)
const [lastUpdate, setLastUpdate] = React.useState(0) usePollingToFetchTreeNode(treeNode, tree, parameters, setTreeNode)
/** If a node is not available when the plot is shown, keep polling until it has been created */ const onRemove = React.useCallback(() => {
function pollForTreeNode() {
const onUpdateCallback = throttle(() => setLastUpdate(treeNode ? treeNode.lastUpdate : 0), 300)
let intervalTimer: any
if (!treeNode) {
intervalTimer = setInterval(() => {
const node = tree.findNode(parameters.topic)
if (node) {
setTreeNode(node)
node.onMessage.subscribe(onUpdateCallback)
clearInterval(intervalTimer)
}
}, 500)
} else {
treeNode.onMessage.subscribe(onUpdateCallback)
}
return function cleanup() {
treeNode && treeNode.onMessage.unsubscribe(onUpdateCallback)
intervalTimer && clearInterval(intervalTimer)
}
}
React.useEffect(pollForTreeNode)
const onClick = React.useCallback(() => {
props.actions.chart.removeChart(props.parameters) props.actions.chart.removeChart(props.parameters)
}, [props.parameters]) }, [props.parameters])
return ( return (
<Paper style={{ padding: '8px' }}> <Paper style={{ padding: '8px' }}>
<div style={{ float: 'right' }}> <div style={{ float: 'right' }}>
<CustomIconButton tooltip="Remove chart" onClick={onClick}> <SettingsButton parameters={parameters} />
<CustomIconButton tooltip="Remove chart" onClick={onRemove}>
<Clear /> <Clear />
</CustomIconButton> </CustomIconButton>
</div> </div>
@@ -74,7 +51,16 @@ function Chart(props: Props) {
{parameters.topic} {parameters.topic}
</Typography> </Typography>
<br /> <br />
{treeNode ? <TopicPlot history={treeNode.messageHistory} dotPath={parameters.dotPath} /> : <span>No data</span>} {treeNode ? (
<TopicPlot
interpolation={props.parameters.interpolation}
range={props.parameters.range ? [props.parameters.range.from, props.parameters.range.to] : undefined}
history={treeNode.messageHistory}
dotPath={parameters.dotPath}
/>
) : (
<span>No data</span>
)}
</Paper> </Paper>
) )
} }
@@ -106,3 +92,37 @@ export default connect(
mapStateToProps, mapStateToProps,
mapDispatchToProps mapDispatchToProps
)(withStyles(styles)(Chart)) )(withStyles(styles)(Chart))
/**
* If a node is not available when the plot is shown, keep polling until it has been created
*/
function usePollingToFetchTreeNode(
treeNode: q.TreeNode<any> | undefined,
tree: q.Tree<any>,
parameters: ChartParameters,
setTreeNode: React.Dispatch<React.SetStateAction<q.TreeNode<any> | undefined>>
) {
const [lastUpdate, setLastUpdate] = React.useState(0)
function pollForTreeNode() {
const onUpdateCallback = throttle(() => setLastUpdate(treeNode ? treeNode.lastUpdate : 0), 300)
let intervalTimer: any
if (!treeNode) {
intervalTimer = setInterval(() => {
const node = tree.findNode(parameters.topic)
if (node) {
setTreeNode(node)
node.onMessage.subscribe(onUpdateCallback)
clearInterval(intervalTimer)
}
}, 500)
} else {
treeNode.onMessage.subscribe(onUpdateCallback)
}
return function cleanup() {
treeNode && treeNode.onMessage.unsubscribe(onUpdateCallback)
intervalTimer && clearInterval(intervalTimer)
}
}
React.useEffect(pollForTreeNode)
}

View File

@@ -0,0 +1,66 @@
import * as React from 'react'
import { AppState } from '../../../reducers'
import { bindActionCreators } from 'redux'
import { chartActions } from '../../../actions'
import { ChartParameters, PlotCurveTypes } from '../../../reducers/Charts'
import { connect } from 'react-redux'
import { Menu, MenuItem } from '@material-ui/core'
function chartParametersForAction(chart: ChartParameters, action: string) {
return {
topic: chart.topic,
dotPath: chart.dotPath,
interpolation: action as any,
}
}
const curves: Array<PlotCurveTypes> = ['curve', 'linear', 'step_after', 'step_before', 'cubic_basis_spline']
function InterpolationSettings(props: {
chart: ChartParameters
actions: {
chart: typeof chartActions
}
anchorEl?: Element
open: boolean
onClose: () => void
}) {
const callbacks = React.useMemo(() => {
const createCurveCallback = (curve: PlotCurveTypes) => () => {
props.actions.chart.updateChart(chartParametersForAction(props.chart, curve))
}
const callbacks: { [key: string]: () => void } = {}
for (const curve of curves) {
callbacks[curve] = createCurveCallback(curve)
}
return callbacks
}, [curves])
const menuItems = React.useMemo(() => {
return curves.map(curve => (
<MenuItem key={curve} onClick={callbacks[curve]} selected={props.chart.interpolation === curve}>
{curve.replace(/_/g, ' ')}
</MenuItem>
))
}, [curves, props.chart])
return (
<Menu id="long-menu" anchorEl={props.anchorEl} open={props.open} onClose={props.onClose}>
{menuItems}
</Menu>
)
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
chart: bindActionCreators(chartActions, dispatch),
},
}
}
export default connect(
undefined,
mapDispatchToProps
)(InterpolationSettings)

View File

@@ -0,0 +1,78 @@
import * as React from 'react'
import { ChartParameters } from '../../../reducers/Charts'
import { Menu, MenuItem, TextField, Typography } from '@material-ui/core'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import { chartActions } from '../../../actions'
function RangeSettings(props: {
actions: { chart: typeof chartActions }
chart: ChartParameters
anchorEl?: Element
open: boolean
onClose: () => void
}) {
const dismissClick = React.useCallback((e: React.MouseEvent) => e.stopPropagation(), [])
const [rangeFrom, setRangeFrom] = React.useState<string | number | undefined>(
props.chart.range && props.chart.range.from
)
const [rangeTo, setRangeTo] = React.useState<string | number | undefined>(props.chart.range && props.chart.range.to)
React.useEffect(() => {
const from = parseFloat(rangeFrom as any)
const to = parseFloat(rangeTo as any)
props.actions.chart.updateChart({
topic: props.chart.topic,
dotPath: props.chart.dotPath,
range: {
from: isNaN(from) ? undefined : from,
to: isNaN(to) ? undefined : to,
},
})
}, [rangeFrom, rangeTo])
const setFromHandler = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => setRangeFrom(e.target.value), [])
const setToHandler = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => setRangeTo(e.target.value), [])
return (
<Menu
style={{ textAlign: 'center' }}
id="long-menu"
anchorEl={props.anchorEl}
open={props.open}
onClose={props.onClose}
>
<Typography>Define custom ranges for the Y-Axis</Typography>
<div style={{ padding: '0 16px' }}>
<TextField
style={{ marginTop: '0' }}
onClick={dismissClick}
label="from"
value={rangeFrom}
onChange={setFromHandler}
margin="normal"
/>
<TextField
style={{ marginLeft: '8px', marginTop: '0' }}
onClick={dismissClick}
label="to"
value={rangeTo}
onChange={setToHandler}
margin="normal"
/>
</div>
</Menu>
)
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
chart: bindActionCreators(chartActions, dispatch),
},
}
}
export default connect(
undefined,
mapDispatchToProps
)(RangeSettings)

View File

@@ -0,0 +1,26 @@
import * as React from 'react'
import ChartSettings from '.'
import CustomIconButton from '../../helper/CustomIconButton'
import MoreVertIcon from '@material-ui/icons/Settings'
import { ChartParameters } from '../../../reducers/Charts'
export function SettingsButton(props: { parameters: ChartParameters }) {
const [visible, setVisible] = React.useState(false)
const settingsRef = React.useRef()
const toggleSettings = React.useCallback(() => {
setVisible(!visible)
}, [visible])
const close = React.useCallback(() => {
setVisible(false)
}, [])
return (
<span>
<ChartSettings open={visible} close={close} anchorEl={settingsRef} chart={props.parameters} />
<CustomIconButton tooltip="Chart settings" onClick={toggleSettings}>
<MoreVertIcon ref={settingsRef as any} />
</CustomIconButton>
</span>
)
}

View File

@@ -0,0 +1,51 @@
import * as React from 'react'
import InterpolationSettings from './InterpolationSettings'
import { ChartParameters } from '../../../reducers/Charts'
import { Menu, MenuItem } from '@material-ui/core'
import RangeSettings from './RangeSettings'
function ChartSettings(props: {
open: boolean
close: () => void
chart: ChartParameters
anchorEl: React.MutableRefObject<undefined>
}) {
const [rangeVisible, setRangeVisible] = React.useState(false)
const [interpolationVisible, setInterpolationVisible] = React.useState(false)
const toggleRange = React.useCallback(() => {
if (!rangeVisible && open) {
props.close()
}
setRangeVisible(!rangeVisible)
}, [rangeVisible, open])
const toggleInterpolation = React.useCallback(() => {
if (!interpolationVisible && open) {
props.close()
}
setInterpolationVisible(!interpolationVisible)
}, [interpolationVisible, open])
return (
<span>
<Menu id="long-menu" anchorEl={props.anchorEl.current} open={props.open} onClose={props.close}>
<MenuItem key="range" onClick={toggleRange}>
Set range
</MenuItem>
<MenuItem key="interpolation" onClick={toggleInterpolation}>
Curve interpolation
</MenuItem>
</Menu>
<RangeSettings chart={props.chart} anchorEl={props.anchorEl.current} open={rangeVisible} onClose={toggleRange} />
<InterpolationSettings
chart={props.chart}
anchorEl={props.anchorEl.current}
open={interpolationVisible}
onClose={toggleInterpolation}
/>
</span>
)
}
export default ChartSettings

View File

@@ -60,7 +60,7 @@ function ChartPanel(props: Props) {
)) ))
return ( return (
<div style={{ width: '100%', height: '100%', padding: '8px', borderTop: '1px solid #999' }}> <div style={{ width: '100%', height: '100%', padding: '8px' }}>
<Grid container spacing={1}> <Grid container spacing={1}>
<TransitionGroup component={null} className="example"> <TransitionGroup component={null} className="example">
{charts} {charts}

View File

@@ -1,6 +1,7 @@
import * as React from 'react' import * as React from 'react'
import DateFormatter from '../helper/DateFormatter' import DateFormatter from '../helper/DateFormatter'
import { default as ReactResizeDetector } from 'react-resize-detector' import { default as ReactResizeDetector } from 'react-resize-detector'
import { PlotCurveTypes } from '../../reducers/Charts'
import { Theme, withTheme } from '@material-ui/core' import { Theme, withTheme } from '@material-ui/core'
import 'react-vis/dist/style.css' import 'react-vis/dist/style.css'
const { XYPlot, LineMarkSeries, Hint, XAxis, YAxis, HorizontalGridLines } = require('react-vis') const { XYPlot, LineMarkSeries, Hint, XAxis, YAxis, HorizontalGridLines } = require('react-vis')
@@ -9,6 +10,25 @@ const abbreviate = require('number-abbreviate')
interface Props { interface Props {
data: Array<{ x: number; y: number }> data: Array<{ x: number; y: number }>
theme: Theme theme: Theme
interpolation?: PlotCurveTypes
range?: [number?, number?]
}
function mapCurveType(type: PlotCurveTypes | undefined) {
switch (type) {
case 'curve':
return 'curveMonotoneX'
case 'linear':
return 'curveLinear'
case 'cubic_basis_spline':
return 'curveBasis'
case 'step_after':
return 'curveStepAfter'
case 'step_before':
return 'curveStepBefore'
default:
return 'curveMonotoneX'
}
} }
export default withTheme((props: Props) => { export default withTheme((props: Props) => {
@@ -31,12 +51,16 @@ export default withTheme((props: Props) => {
setTooltip({ value }) setTooltip({ value })
}, []) }, [])
const data = props.data
return React.useMemo(() => { return React.useMemo(() => {
const data = props.data
const calculatedDomain = domainForData(data)
let yDomain: [number, number] = props.range
? [props.range[0] || calculatedDomain[0], props.range[1] || calculatedDomain[1]]
: calculatedDomain
return ( return (
<div style={{ height: '150px', overflow: 'hidden' }}> <div style={{ height: '150px', overflow: 'hidden' }}>
<XYPlot width={width} height={180}> <XYPlot width={width} height={180} yDomain={yDomain}>
<HorizontalGridLines /> <HorizontalGridLines />
<XAxis /> <XAxis />
<YAxis width={45} tickFormat={(num: number) => abbreviate(num)} /> <YAxis width={45} tickFormat={(num: number) => abbreviate(num)} />
@@ -50,12 +74,35 @@ export default withTheme((props: Props) => {
onValueMouseOut={hideTooltip} onValueMouseOut={hideTooltip}
size={3} size={3}
data={data} data={data}
curve="curveMonotoneX" curve={mapCurveType(props.interpolation)}
/> />
{tooltip.value ? <Hint format={hintFormatter} value={tooltip.value} /> : null} {tooltip.value ? <Hint format={hintFormatter} value={tooltip.value} /> : null}
</XYPlot> </XYPlot>
<ReactResizeDetector handleWidth={true} onResize={detectResize} /> <ReactResizeDetector handleWidth={true} onResize={detectResize} />
</div> </div>
) )
}, [width, data, tooltip]) }, [width, props.data, tooltip, props.interpolation, props.range])
}) })
function domainForData(data: Array<{ x: number; y: number }>): [number, number] {
let max = data[0].y
let min = data[0].y
data.forEach(d => {
if (max < d.y) {
max = d.y
}
if (min > d.y) {
min = d.y
}
})
if ((max === 1 || max === 0) && (min === 1 || min === 0)) {
return [0, 1]
}
if (min === max) {
return [min - 0.5 * min, min + 0.5 * min]
}
return [min, max]
}

View File

@@ -4,10 +4,13 @@ import * as React from 'react'
import PlotHistory from './Sidebar/PlotHistory' import PlotHistory from './Sidebar/PlotHistory'
import { Base64Message } from '../../../backend/src/Model/Base64Message' import { Base64Message } from '../../../backend/src/Model/Base64Message'
import { toPlottableValue } from './Sidebar/CodeDiff/util' import { toPlottableValue } from './Sidebar/CodeDiff/util'
import { PlotCurveTypes } from '../reducers/Charts'
interface Props { interface Props {
history: q.MessageHistory history: q.MessageHistory
dotPath?: string dotPath?: string
interpolation?: PlotCurveTypes
range?: [number?, number?]
} }
function nodeToHistory(history: q.MessageHistory) { function nodeToHistory(history: q.MessageHistory) {
@@ -38,7 +41,7 @@ function nodeDotPathToHistory(history: q.MessageHistory, dotPath: string) {
function render(props: Props) { function render(props: Props) {
const data = props.dotPath ? nodeDotPathToHistory(props.history, props.dotPath) : nodeToHistory(props.history) const data = props.dotPath ? nodeDotPathToHistory(props.history, props.dotPath) : nodeToHistory(props.history)
return <PlotHistory data={data} /> return <PlotHistory range={props.range} interpolation={props.interpolation} data={data} />
} }
export default render export default render

View File

@@ -2,9 +2,16 @@ import { Action } from 'redux'
import { createReducer } from './lib' import { createReducer } from './lib'
import { Record, List } from 'immutable' import { Record, List } from 'immutable'
export type PlotCurveTypes = 'curve' | 'linear' | 'cubic_basis_spline' | 'step_after' | 'step_before'
export interface ChartParameters { export interface ChartParameters {
topic: string topic: string
dotPath?: string dotPath?: string
interpolation?: PlotCurveTypes
range?: {
from?: number
to?: number
}
} }
export interface ChartsStateModel { export interface ChartsStateModel {
@@ -13,12 +20,13 @@ export interface ChartsStateModel {
export type ChartsState = Record<ChartsStateModel> export type ChartsState = Record<ChartsStateModel>
export type Action = AddChart | RemoveChart | SetCharts export type Action = AddChart | RemoveChart | SetCharts | UpdateChart
export enum ActionTypes { export enum ActionTypes {
CHARTS_ADD = 'CHARTS_ADD', CHARTS_ADD = 'CHARTS_ADD',
CHARTS_REMOVE = 'CHARTS_REMOVE', CHARTS_REMOVE = 'CHARTS_REMOVE',
CHARTS_SET = 'CHARTS_SET', CHARTS_SET = 'CHARTS_SET',
CHARTS_UPDATE = 'CHARTS_UPDATE',
} }
export interface AddChart { export interface AddChart {
@@ -26,6 +34,13 @@ export interface AddChart {
chart: ChartParameters chart: ChartParameters
} }
export interface UpdateChart {
type: ActionTypes.CHARTS_ADD
topic: string
dotPath?: string
parameters: Partial<ChartParameters>
}
export interface RemoveChart { export interface RemoveChart {
type: ActionTypes.CHARTS_REMOVE type: ActionTypes.CHARTS_REMOVE
chart: ChartParameters chart: ChartParameters
@@ -44,12 +59,21 @@ export const chartsReducer = createReducer(initialState(), {
CHARTS_ADD: addChart, CHARTS_ADD: addChart,
CHARTS_REMOVE: removeChart, CHARTS_REMOVE: removeChart,
CHARTS_SET: setCharts, CHARTS_SET: setCharts,
CHARTS_UPDATE: updateChart,
}) })
function addChart(state: ChartsState, action: AddChart) { function addChart(state: ChartsState, action: AddChart) {
return state.set('charts', state.get('charts').push(action.chart)) return state.set('charts', state.get('charts').push(action.chart))
} }
function updateChart(state: ChartsState, action: UpdateChart) {
const charts = state.get('charts')
const chartIdx = charts.findIndex(chart => chart.topic === action.topic && chart.dotPath === action.dotPath)
const chart = charts.get(chartIdx)
return state.set('charts', chart ? charts.set(chartIdx, { ...chart, ...action.parameters }) : charts)
}
function removeChart(state: ChartsState, action: RemoveChart) { function removeChart(state: ChartsState, action: RemoveChart) {
const charts = state.get('charts') const charts = state.get('charts')
const newCharts = charts.filter(chart => chart.topic !== action.chart.topic || chart.dotPath !== action.chart.dotPath) const newCharts = charts.filter(chart => chart.topic !== action.chart.topic || chart.dotPath !== action.chart.dotPath)