Refactor chart

This commit is contained in:
Thomas Nordquist
2019-07-13 15:57:30 +02:00
parent 7c63e871f0
commit b5d9ff3067
13 changed files with 274 additions and 238 deletions

View File

@@ -0,0 +1,105 @@
import DateFormatter from '../helper/DateFormatter'
import NoData from './NoData'
import NumberFormatter from '../helper/NumberFormatter'
import React, { memo, useCallback } from 'react'
import TooltipComponent from './TooltipComponent'
import { default as ReactResizeDetector } from 'react-resize-detector'
import { emphasize } from '@material-ui/core/styles'
import { mapCurveType } from './mapCurveType'
import { PlotCurveTypes } from '../../reducers/Charts'
import { Point, Tooltip } from './Model'
import { Theme, withTheme } from '@material-ui/core'
import { useCustomXDomain } from './effects/useCustomXDomain'
import { useCustomYDomain } from './effects/useCustomYDomain'
import 'react-vis/dist/style.css'
const { XYPlot, LineMarkSeries, YAxis, HorizontalGridLines, Hint } = require('react-vis')
const abbreviate = require('number-abbreviate')
export interface Props {
data: Array<{ x: number; y: number }>
theme: Theme
interpolation?: PlotCurveTypes
range?: [number?, number?]
timeRangeStart?: number
color?: string
}
export default withTheme(
memo((props: Props) => {
const [width, setWidth] = React.useState(300)
const [tooltip, setTooltip] = React.useState<Tooltip | undefined>()
const detectResize = React.useCallback(newWidth => setWidth(newWidth), [])
const hintFormatter = React.useCallback(
(point: any) => [
{ title: <b>Time</b>, value: <DateFormatter timeFirst={true} date={new Date(point.x)} /> },
{ title: <b>Value</b>, value: <NumberFormatter value={point.y} /> },
{ title: <b>Raw</b>, value: <span>{point.y}</span> },
],
[]
)
const onMouseLeave = React.useCallback(() => {
setTooltip(undefined)
}, [])
const showTooltip = React.useCallback((point: Point, something: { event: MouseEvent }) => {
if (!something) {
return
}
setTooltip({ point, value: hintFormatter(point), element: something.event.target as any })
}, [])
const paletteColor =
props.theme.palette.type === 'light' ? props.theme.palette.secondary.dark : props.theme.palette.primary.light
const color = props.color ? props.color : paletteColor
const highlightSelectedPoint = useCallback(
(point: Point) => {
const highlight = tooltip && tooltip.point.x === point.x && tooltip.point.y === point.y
return highlight ? emphasize(color, 0.8) : color
},
[tooltip, color]
)
const formatYAxis = useCallback((num: number) => abbreviate(num), [])
const xDomain = useCustomXDomain(props)
const yDomain = useCustomYDomain(props)
const data = props.data
const hasData = data.length > 0
const dummyDomain = [-1, 1]
const dummyData = [{ x: -2, y: -2 }]
return (
<div>
<div style={{ height: '150px', width: '100%', position: 'relative' }}>
{data.length === 0 ? <NoData /> : null}
<XYPlot
width={width}
height={180}
yDomain={hasData ? yDomain : dummyDomain}
xDomain={hasData ? xDomain : dummyDomain}
onMouseLeave={onMouseLeave}
>
<HorizontalGridLines />
<YAxis width={45} tickFormat={formatYAxis} />
<LineMarkSeries
color={color}
colorType="literal"
getColor={highlightSelectedPoint}
onValueMouseOver={showTooltip}
size={3}
data={hasData ? data : dummyData}
curve={mapCurveType(props.interpolation)}
/>
<Hint value={{ x: 0, y: 0 }} style={{ pointerEvents: 'none' }}>
<TooltipComponent tooltip={tooltip} theme={props.theme} />
</Hint>
</XYPlot>
<ReactResizeDetector handleWidth={true} onResize={detectResize} />
</div>
</div>
)
})
)

View File

@@ -0,0 +1,15 @@
export interface Point {
x: number
y: number
}
export interface Tooltip {
value: Array<TooltipRows>
point: Point
element: Element | null
}
export interface TooltipRows {
title: React.ReactElement
value: React.ReactElement
}

View File

@@ -0,0 +1,27 @@
import React, { memo } from 'react'
import { Typography } from '@material-ui/core'
function NoData() {
return (
<div
style={{
height: '100%',
position: 'absolute',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
color: '#ccc',
verticalAlign: 'middle',
paddingLeft: '30px',
zIndex: 10,
}}
>
<Typography style={{ fontWeight: 'bold' }} variant="h5">
No Data
</Typography>
</div>
)
}
export default memo(NoData)

View File

@@ -0,0 +1,61 @@
import React, { memo } from 'react'
import { fade } from '@material-ui/core/styles'
import { Fade, Grow, Paper, Popper, Theme, Typography, withTheme } from '@material-ui/core'
import { Tooltip } from './Model'
const { Hint } = require('react-vis')
function TooltipComponent(props: { tooltip?: Tooltip; theme: Theme }) {
const { tooltip } = props
return (
<Popper
style={Boolean(tooltip) ? { transition: 'all 0.1s ease-out' } : undefined}
open={Boolean(tooltip)}
transition={true}
keepMounted={true}
placement="top"
anchorEl={tooltip && tooltip.element}
>
<div
style={{
paddingBottom: '8px',
transition: 'all 0.5s ease',
}}
>
<Fade in={Boolean(tooltip)} timeout={300}>
<Grow in={Boolean(tooltip)} timeout={300}>
<Paper
style={{
padding: '4px',
marginTop: '-12px',
backgroundColor: fade(
props.theme.palette.type === 'light'
? props.theme.palette.background.paper
: props.theme.palette.background.default,
0.7
),
}}
>
<table style={{ lineHeight: '1.25em' }}>
<tbody>
{tooltip &&
tooltip.value.map((v: any, idx: number) => (
<tr key={idx}>
<td>
<Typography style={{ lineHeight: '1.2' }}>{v.title}</Typography>
</td>
<td>
<Typography style={{ lineHeight: '1.2' }}>{v.value}</Typography>
</td>
</tr>
))}
</tbody>
</table>
</Paper>
</Grow>
</Fade>
</div>
</Popper>
)
}
export default withTheme(memo(TooltipComponent))

View File

@@ -1,5 +1,5 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { Props } from './PlotHistory' import { Props } from '../Chart'
export function useCustomXDomain(props: Props): [number, number] | undefined { export function useCustomXDomain(props: Props): [number, number] | undefined {
return useMemo(() => { return useMemo(() => {

View File

@@ -0,0 +1,39 @@
import { Props } from '../Chart'
import { useMemo } from 'react'
import { Point } from '../Model'
export function useCustomYDomain(props: Props) {
return useMemo(() => {
const data = props.data
const calculatedDomain = domainForData(data)
const yDomain: [number, number] = props.range
? [props.range[0] || calculatedDomain[0], props.range[1] || calculatedDomain[1]]
: calculatedDomain
return yDomain
}, [props.data])
}
function domainForData(data: Array<Point>): [number, number] {
if (!data[0]) {
const defaultDomain: [number, number] = [-1, 1]
return defaultDomain
}
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

@@ -0,0 +1,17 @@
import { PlotCurveTypes } from '../../reducers/Charts'
export 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'
}
}

View File

@@ -1,6 +1,6 @@
import * as q from '../../../../backend/src/Model' import * as q from '../../../../backend/src/Model'
import React from 'react' import React from 'react'
import Chart from './Chart' import TopicChart from './TopicChart'
import { ChartParameters } from '../../reducers/Charts' import { ChartParameters } from '../../reducers/Charts'
import { usePollingToFetchTreeNode } from '../helper/usePollingToFetchTreeNode' import { usePollingToFetchTreeNode } from '../helper/usePollingToFetchTreeNode'
@@ -16,5 +16,5 @@ export function ChartWithTreeNode(props: Props) {
} }
const treeNode = usePollingToFetchTreeNode(tree, parameters.topic) const treeNode = usePollingToFetchTreeNode(tree, parameters.topic)
return <Chart treeNode={treeNode} parameters={parameters} /> return <TopicChart treeNode={treeNode} parameters={parameters} />
} }

View File

@@ -51,7 +51,7 @@ function useMessageSubscriptionToUpdate(treeNode?: q.TreeNode<any>) {
return messageHistory return messageHistory
} }
function Chart(props: Props) { function TopicChart(props: Props) {
const { parameters, treeNode } = props const { parameters, treeNode } = props
const [frozenHistory, setFrozenHistory] = React.useState<q.MessageHistory | undefined>() const [frozenHistory, setFrozenHistory] = React.useState<q.MessageHistory | undefined>()
const messageHistory = useMessageSubscriptionToUpdate(treeNode) const messageHistory = useMessageSubscriptionToUpdate(treeNode)
@@ -108,4 +108,4 @@ const mapDispatchToProps = (dispatch: any) => {
export default connect( export default connect(
undefined, undefined,
mapDispatchToProps mapDispatchToProps
)(Chart) )(TopicChart)

View File

@@ -23,6 +23,8 @@ interface State {
} }
class HistoryDrawer extends React.Component<Props, State> { class HistoryDrawer extends React.Component<Props, State> {
private handleCtrlA = selectTextWithCtrlA({ targetSelector: 'pre' })
constructor(props: any) { constructor(props: any) {
super(props) super(props)
this.state = { this.state = {
@@ -40,8 +42,6 @@ class HistoryDrawer extends React.Component<Props, State> {
event.stopPropagation() event.stopPropagation()
} }
private handleCtrlA = selectTextWithCtrlA({ targetSelector: 'pre' })
public renderHistory() { public renderHistory() {
const style = (element: HistoryItem) => ({ const style = (element: HistoryItem) => ({
backgroundColor: element.selected backgroundColor: element.selected

View File

@@ -1,228 +0,0 @@
import DateFormatter from '../helper/DateFormatter'
import NumberFormatter from '../helper/NumberFormatter'
import React, { useCallback, useState } from 'react'
import { default as ReactResizeDetector } from 'react-resize-detector'
import { emphasize, fade } from '@material-ui/core/styles'
import { Paper, Popper, Theme, Typography, withTheme } from '@material-ui/core'
import { PlotCurveTypes } from '../../reducers/Charts'
import { useCustomXDomain } from './useCustomXDomain'
import 'react-vis/dist/style.css'
const { XYPlot, LineMarkSeries, Hint, YAxis, HorizontalGridLines } = require('react-vis')
const abbreviate = require('number-abbreviate')
export interface Props {
data: Array<{ x: number; y: number }>
theme: Theme
interpolation?: PlotCurveTypes
range?: [number?, number?]
timeRangeStart?: number
color?: string
}
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'
}
}
function useToggle(initialState: boolean): [boolean, () => void, (value: boolean) => void] {
const [value, setValue] = useState(initialState)
const toggle = useCallback(() => {
setValue(!value)
}, [value])
return [value, toggle, setValue]
}
interface Point {
x: any
y: any
}
export default withTheme((props: Props) => {
const [hintStaysOpen, toggleHintStaysOpen, setStaysOpen] = useToggle(false)
const [width, setWidth] = React.useState(300)
const [tooltip, setTooltip] = React.useState<{ value: any; point: Point; element: any } | undefined>()
const detectResize = React.useCallback(newWidth => setWidth(newWidth), [])
const hintFormatter = React.useCallback((point: any) => {
return [
{ title: <b>Time</b>, value: <DateFormatter timeFirst={true} date={new Date(point.x)} /> },
{ title: <b>Value</b>, value: <NumberFormatter value={point.y} /> },
{ title: <b>Raw</b>, value: point.y },
]
}, [])
const onMouseLeave = React.useCallback(() => {
setStaysOpen(false)
setTooltip(undefined)
}, [])
const showTooltip = React.useCallback((point: Point, something: { event: MouseEvent }) => {
if (!something) {
return
}
setTooltip({ point, value: hintFormatter(point), element: something.event.target })
}, [])
const paletteColor =
props.theme.palette.type === 'light' ? props.theme.palette.secondary.dark : props.theme.palette.primary.light
const color = props.color ? props.color : paletteColor
const highlightSelectedPoint = useCallback(
(point: Point) => {
const highlight = tooltip && tooltip.point.x === point.x && tooltip.point.y === point.y
return highlight ? emphasize(color, 0.8) : color
},
[tooltip, color]
)
const getAnchorElement = useCallback(() => tooltip && tooltip.element, [tooltip])
const formatYAxis = useCallback((num: number) => abbreviate(num), [])
const xDomain = useCustomXDomain(props)
return React.useMemo(() => {
const data = props.data
const calculatedDomain = domainForData(data)
const yDomain: [number, number] = props.range
? [props.range[0] || calculatedDomain[0], props.range[1] || calculatedDomain[1]]
: calculatedDomain
const hasData = data.length > 0
const dummyDomain = [-1, 1]
const dummyData = [{ x: -2, y: -2 }]
return (
<div>
<div style={{ height: '150px', width: '100%', position: 'relative' }}>
{data.length === 0 ? <NoData /> : null}
<XYPlot
width={width}
height={180}
yDomain={hasData ? yDomain : dummyDomain}
xDomain={hasData ? xDomain : dummyDomain}
onMouseLeave={onMouseLeave}
>
<HorizontalGridLines />
<YAxis width={45} tickFormat={formatYAxis} />
<LineMarkSeries
color={color}
colorType="literal"
getColor={highlightSelectedPoint}
onValueMouseOver={showTooltip}
size={3}
data={hasData ? data : dummyData}
curve={mapCurveType(props.interpolation)}
/>
<Hint
value={{}}
style={{
pointerEvents: 'none',
}}
>
<Popper open={Boolean(tooltip)} placement="top" anchorEl={getAnchorElement()}>
<div
style={{
paddingBottom: '8px',
}}
>
<Paper
style={{
padding: '4px',
marginTop: '-12px',
backgroundColor: fade(
props.theme.palette.type === 'light'
? props.theme.palette.background.paper
: props.theme.palette.background.default,
0.7
),
}}
>
<table style={{ lineHeight: '1.25em' }}>
<tbody>
{tooltip &&
tooltip.value.map((v: any, idx: number) => (
<tr key={idx}>
<td>
<Typography style={{ lineHeight: '1.2' }}>{v.title}</Typography>
</td>
<td>
<Typography style={{ lineHeight: '1.2' }}>{v.value}</Typography>
</td>
</tr>
))}
</tbody>
</table>
</Paper>
</div>
</Popper>
</Hint>
</XYPlot>
<ReactResizeDetector handleWidth={true} onResize={detectResize} />
</div>
</div>
)
}, [width, props.data, tooltip, props.interpolation, props.range, props.color, props.theme, xDomain])
})
function NoData() {
return (
<div
style={{
height: '100%',
position: 'absolute',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
color: '#ccc',
verticalAlign: 'middle',
paddingLeft: '30px',
zIndex: 10,
}}
>
<Typography style={{ fontWeight: 'bold' }} variant="h5">
No Data
</Typography>
</div>
)
}
function domainForData(data: Array<{ x: number; y: number }>): [number, number] {
if (!data[0]) {
const defaultDomain: [number, number] = [-1, 1]
return defaultDomain
}
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

@@ -1,7 +1,7 @@
import * as dotProp from 'dot-prop' import * as dotProp from 'dot-prop'
import * as q from '../../../backend/src/Model' import * as q from '../../../backend/src/Model'
import * as React from 'react' import * as React from 'react'
import PlotHistory from './Sidebar/PlotHistory' import PlotHistory from './Chart/Chart'
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' import { PlotCurveTypes } from '../reducers/Charts'

View File

@@ -46,8 +46,8 @@ export const styles = (theme: Theme) => {
display: 'inline-block' as 'inline-block', display: 'inline-block' as 'inline-block',
whiteSpace: 'nowrap' as 'nowrap', whiteSpace: 'nowrap' as 'nowrap',
height: '14px', height: '14px',
padding: '0 4px', padding: '1px 4px 0 4px',
margin: '1px 0px 2px 0px', margin: '1px 0px',
'&:hover': { '&:hover': {
backgroundColor: theme.palette.type === 'light' ? blueGrey[100] : theme.palette.primary.light, backgroundColor: theme.palette.type === 'light' ? blueGrey[100] : theme.palette.primary.light,
}, },