diff --git a/app/src/actions/Charts.ts b/app/src/actions/Charts.ts index d12fe73..3e4ede7 100644 --- a/app/src/actions/Charts.ts +++ b/app/src/actions/Charts.ts @@ -66,7 +66,7 @@ export const addChart = (chartParameters: ChartParameters) => async ( dispatch: Dispatch, getState: () => AppState ) => { - let chartExists = Boolean( + const chartExists = Boolean( getState() .charts.get('charts') .find(chart => chart.topic === chartParameters.topic && chart.dotPath === chartParameters.dotPath) @@ -82,6 +82,19 @@ export const addChart = (chartParameters: ChartParameters) => async ( dispatch(saveCharts()) } +export const updateChart = (chartParameters: ChartParameters) => async ( + dispatch: Dispatch, + getState: () => AppState +) => { + dispatch({ + type: ActionTypes.CHARTS_UPDATE, + topic: chartParameters.topic, + dotPath: chartParameters.dotPath, + parameters: chartParameters, + }) + dispatch(saveCharts()) +} + export const removeChart = (chartParameters: ChartParameters) => async ( dispatch: Dispatch, getState: () => AppState diff --git a/app/src/components/ChartPanel/Chart.tsx b/app/src/components/ChartPanel/Chart.tsx index cf9d96e..8b29682 100644 --- a/app/src/components/ChartPanel/Chart.tsx +++ b/app/src/components/ChartPanel/Chart.tsx @@ -8,7 +8,8 @@ import { bindActionCreators } from 'redux' import { chartActions } from '../../actions' import { ChartParameters } from '../../reducers/Charts' 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') interface Props { @@ -28,41 +29,17 @@ function Chart(props: Props) { const { tree, parameters } = props const initialTreeNode = tree.findNode(parameters.topic) const [treeNode, setTreeNode] = React.useState | 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 */ - 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(() => { + const onRemove = React.useCallback(() => { props.actions.chart.removeChart(props.parameters) }, [props.parameters]) return (
- + +
@@ -74,7 +51,16 @@ function Chart(props: Props) { {parameters.topic}
- {treeNode ? : No data} + {treeNode ? ( + + ) : ( + No data + )}
) } @@ -106,3 +92,37 @@ export default connect( mapStateToProps, mapDispatchToProps )(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 | undefined, + tree: q.Tree, + parameters: ChartParameters, + setTreeNode: React.Dispatch | 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) +} diff --git a/app/src/components/ChartPanel/ChartSettings/InterpolationSettings.tsx b/app/src/components/ChartPanel/ChartSettings/InterpolationSettings.tsx new file mode 100644 index 0000000..35c3d29 --- /dev/null +++ b/app/src/components/ChartPanel/ChartSettings/InterpolationSettings.tsx @@ -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 = ['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 => ( + + {curve.replace(/_/g, ' ')} + + )) + }, [curves, props.chart]) + + return ( + + {menuItems} + + ) +} + +const mapDispatchToProps = (dispatch: any) => { + return { + actions: { + chart: bindActionCreators(chartActions, dispatch), + }, + } +} + +export default connect( + undefined, + mapDispatchToProps +)(InterpolationSettings) diff --git a/app/src/components/ChartPanel/ChartSettings/RangeSettings.tsx b/app/src/components/ChartPanel/ChartSettings/RangeSettings.tsx new file mode 100644 index 0000000..5da5f4f --- /dev/null +++ b/app/src/components/ChartPanel/ChartSettings/RangeSettings.tsx @@ -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( + props.chart.range && props.chart.range.from + ) + const [rangeTo, setRangeTo] = React.useState(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) => setRangeFrom(e.target.value), []) + const setToHandler = React.useCallback((e: React.ChangeEvent) => setRangeTo(e.target.value), []) + return ( + + Define custom ranges for the Y-Axis +
+ + +
+
+ ) +} + +const mapDispatchToProps = (dispatch: any) => { + return { + actions: { + chart: bindActionCreators(chartActions, dispatch), + }, + } +} + +export default connect( + undefined, + mapDispatchToProps +)(RangeSettings) diff --git a/app/src/components/ChartPanel/ChartSettings/SettingsButton.tsx b/app/src/components/ChartPanel/ChartSettings/SettingsButton.tsx new file mode 100644 index 0000000..bb8efc6 --- /dev/null +++ b/app/src/components/ChartPanel/ChartSettings/SettingsButton.tsx @@ -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 ( + + + + + + + ) +} diff --git a/app/src/components/ChartPanel/ChartSettings/index.tsx b/app/src/components/ChartPanel/ChartSettings/index.tsx new file mode 100644 index 0000000..571579c --- /dev/null +++ b/app/src/components/ChartPanel/ChartSettings/index.tsx @@ -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 +}) { + 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 ( + + + + Set range + + + Curve interpolation + + + + + + ) +} + +export default ChartSettings diff --git a/app/src/components/ChartPanel/index.tsx b/app/src/components/ChartPanel/index.tsx index 8253fdc..760d564 100644 --- a/app/src/components/ChartPanel/index.tsx +++ b/app/src/components/ChartPanel/index.tsx @@ -60,7 +60,7 @@ function ChartPanel(props: Props) { )) return ( -
+
{charts} diff --git a/app/src/components/Sidebar/PlotHistory.tsx b/app/src/components/Sidebar/PlotHistory.tsx index 1f8f029..a117f1b 100644 --- a/app/src/components/Sidebar/PlotHistory.tsx +++ b/app/src/components/Sidebar/PlotHistory.tsx @@ -1,6 +1,7 @@ import * as React from 'react' import DateFormatter from '../helper/DateFormatter' import { default as ReactResizeDetector } from 'react-resize-detector' +import { PlotCurveTypes } from '../../reducers/Charts' import { Theme, withTheme } from '@material-ui/core' import 'react-vis/dist/style.css' const { XYPlot, LineMarkSeries, Hint, XAxis, YAxis, HorizontalGridLines } = require('react-vis') @@ -9,6 +10,25 @@ const abbreviate = require('number-abbreviate') interface Props { data: Array<{ x: number; y: number }> 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) => { @@ -31,12 +51,16 @@ export default withTheme((props: Props) => { setTooltip({ value }) }, []) - const data = props.data - 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 (
- + abbreviate(num)} /> @@ -50,12 +74,35 @@ export default withTheme((props: Props) => { onValueMouseOut={hideTooltip} size={3} data={data} - curve="curveMonotoneX" + curve={mapCurveType(props.interpolation)} /> {tooltip.value ? : null}
) - }, [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] +} diff --git a/app/src/components/TopicPlot.tsx b/app/src/components/TopicPlot.tsx index 35a4f5d..76f8872 100644 --- a/app/src/components/TopicPlot.tsx +++ b/app/src/components/TopicPlot.tsx @@ -4,10 +4,13 @@ import * as React from 'react' import PlotHistory from './Sidebar/PlotHistory' import { Base64Message } from '../../../backend/src/Model/Base64Message' import { toPlottableValue } from './Sidebar/CodeDiff/util' +import { PlotCurveTypes } from '../reducers/Charts' interface Props { history: q.MessageHistory dotPath?: string + interpolation?: PlotCurveTypes + range?: [number?, number?] } function nodeToHistory(history: q.MessageHistory) { @@ -38,7 +41,7 @@ function nodeDotPathToHistory(history: q.MessageHistory, dotPath: string) { function render(props: Props) { const data = props.dotPath ? nodeDotPathToHistory(props.history, props.dotPath) : nodeToHistory(props.history) - return + return } export default render diff --git a/app/src/reducers/Charts.ts b/app/src/reducers/Charts.ts index 642137e..ef12e89 100644 --- a/app/src/reducers/Charts.ts +++ b/app/src/reducers/Charts.ts @@ -2,9 +2,16 @@ import { Action } from 'redux' import { createReducer } from './lib' import { Record, List } from 'immutable' +export type PlotCurveTypes = 'curve' | 'linear' | 'cubic_basis_spline' | 'step_after' | 'step_before' + export interface ChartParameters { topic: string dotPath?: string + interpolation?: PlotCurveTypes + range?: { + from?: number + to?: number + } } export interface ChartsStateModel { @@ -13,12 +20,13 @@ export interface ChartsStateModel { export type ChartsState = Record -export type Action = AddChart | RemoveChart | SetCharts +export type Action = AddChart | RemoveChart | SetCharts | UpdateChart export enum ActionTypes { CHARTS_ADD = 'CHARTS_ADD', CHARTS_REMOVE = 'CHARTS_REMOVE', CHARTS_SET = 'CHARTS_SET', + CHARTS_UPDATE = 'CHARTS_UPDATE', } export interface AddChart { @@ -26,6 +34,13 @@ export interface AddChart { chart: ChartParameters } +export interface UpdateChart { + type: ActionTypes.CHARTS_ADD + topic: string + dotPath?: string + parameters: Partial +} + export interface RemoveChart { type: ActionTypes.CHARTS_REMOVE chart: ChartParameters @@ -44,12 +59,21 @@ export const chartsReducer = createReducer(initialState(), { CHARTS_ADD: addChart, CHARTS_REMOVE: removeChart, CHARTS_SET: setCharts, + CHARTS_UPDATE: updateChart, }) function addChart(state: ChartsState, action: AddChart) { 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) { const charts = state.get('charts') const newCharts = charts.filter(chart => chart.topic !== action.chart.topic || chart.dotPath !== action.chart.dotPath)