diff --git a/app/src/App.tsx b/app/src/App.tsx index c2a3cef..a87c2e9 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -22,7 +22,7 @@ interface Props { settingsVisible: boolean } -class App extends React.Component { +class App extends React.PureComponent { constructor(props: any) { super(props) this.state = { } diff --git a/app/src/TopicViewModel.ts b/app/src/TopicViewModel.ts new file mode 100644 index 0000000..bf46bbd --- /dev/null +++ b/app/src/TopicViewModel.ts @@ -0,0 +1,19 @@ +import { EventDispatcher } from '../../events' + +export class TopicViewModel { + private selected: boolean + public change = new EventDispatcher(this) + + public constructor() { + this.selected = false + } + + public isSelected() { + return this.selected + } + + public setSelected(selected: boolean) { + this.selected = selected + this.change.dispatch() + } +} diff --git a/app/src/actions/Connection.ts b/app/src/actions/Connection.ts index 77bb979..df15229 100644 --- a/app/src/actions/Connection.ts +++ b/app/src/actions/Connection.ts @@ -6,6 +6,7 @@ import { AppState } from '../reducers' import * as q from '../../../backend/src/Model' import { showTree } from './Tree' import * as url from 'url' +import { TopicViewModel } from '../TopicViewModel' export const connect = (options: MqttOptions, connectionId: string) => (dispatch: Dispatch, getState: () => AppState) => { dispatch(connecting(connectionId)) @@ -15,7 +16,7 @@ export const connect = (options: MqttOptions, connectionId: string) => (dispatch rendererEvents.subscribe(event, (dataSourceState) => { if (dataSourceState.connected) { - const tree = new q.Tree() + const tree = new q.Tree() tree.updateWithConnection(rendererEvents, connectionId) dispatch(connected(tree, host!)) dispatch(showTree(tree)) @@ -26,7 +27,7 @@ export const connect = (options: MqttOptions, connectionId: string) => (dispatch }) } -export const connected: (tree: q.Tree, host: string) => Action = (tree: q.Tree, host: string) => ({ +export const connected: (tree: q.Tree, host: string) => Action = (tree: q.Tree, host: string) => ({ tree, host, type: ActionTypes.CONNECTION_SET_CONNECTED, diff --git a/app/src/actions/Settings.ts b/app/src/actions/Settings.ts index f260d39..ae90b8f 100644 --- a/app/src/actions/Settings.ts +++ b/app/src/actions/Settings.ts @@ -6,6 +6,7 @@ import { AppState } from '../reducers' import * as q from '../../../backend/src/Model' import { batchActions } from 'redux-batched-actions' import { autoExpandLimitSet } from '../components/Settings' +import { TopicViewModel } from '../TopicViewModel' export const setAutoExpandLimit = (autoExpandLimit: number = 0): Action => { return { @@ -42,7 +43,7 @@ export const filterTopics = (filterStr: string) => (dispatch: Dispatch, get const topicFilter = filterStr.toLowerCase() - const nodeFilter = (node: q.TreeNode): boolean => { + const nodeFilter = (node: q.TreeNode): boolean => { const topicMatches = node.path().toLowerCase().indexOf(topicFilter) !== -1 if (topicMatches) { return true @@ -54,17 +55,17 @@ export const filterTopics = (filterStr: string) => (dispatch: Dispatch, get const resultTree = tree.childTopics() .filter(nodeFilter) - .map((node) => { + .map((node: q.TreeNode) => { const clone = node.unconnectedClone() q.TreeNodeFactory.insertNodeAtPosition(node.path().split('/'), clone) return clone.firstNode() }) - .reduce((a: q.TreeNode, b: q.TreeNode) => { + .reduce((a: q.TreeNode, b: q.TreeNode) => { a.updateWithNode(b) return a - }, new q.Tree()) + }, new q.Tree()) - const nextTree: q.Tree = resultTree as q.Tree + const nextTree: q.Tree = resultTree as q.Tree if (tree.updateSource && tree.connectionId) { nextTree.updateWithConnection(tree.updateSource, tree.connectionId, nodeFilter) } @@ -72,7 +73,7 @@ export const filterTopics = (filterStr: string) => (dispatch: Dispatch, get dispatch(batchActions([setAutoExpandLimit(autoExpandLimitForTree(nextTree)), (showTree(nextTree) as any)])) } -function autoExpandLimitForTree(tree: q.Tree) { +function autoExpandLimitForTree(tree: q.Tree) { if (!tree) { return 0 } diff --git a/app/src/actions/Tree.ts b/app/src/actions/Tree.ts index 4cb7af4..5404976 100644 --- a/app/src/actions/Tree.ts +++ b/app/src/actions/Tree.ts @@ -3,22 +3,42 @@ import { ActionTypes } from '../reducers/Tree' import * as q from '../../../backend/src/Model' import { Dispatch, AnyAction } from 'redux' import { setTopic } from './Publish' +import { TopicViewModel } from '../TopicViewModel' +import { batchActions } from 'redux-batched-actions' -export const selectTopic = (topic: q.TreeNode) => (dispatch: Dispatch, getState: () => AppState): AnyAction => { +export const selectTopic = (topic: q.TreeNode) => (dispatch: Dispatch, getState: () => AppState) => { const { selectedTopic } = getState().tree - - // Update publish topic - if (selectedTopic && (selectedTopic.path() === getState().publish.topic || !getState().publish.topic)) { - dispatch(setTopic(topic.path())) + if (selectedTopic === topic) { + return } - return dispatch({ + // Update publish topic + let setTopicDispatch: any | undefined + if (selectedTopic && (selectedTopic.path() === getState().publish.topic || !getState().publish.topic)) { + setTopicDispatch = setTopic(topic.path()) + } + + if (selectedTopic && selectedTopic.viewModel) { + selectedTopic.viewModel.setSelected(false) + } + + if (topic.viewModel) { + topic.viewModel.setSelected(true) + } + + const selectTreeTopicDispatch = { selectedTopic: topic, type: ActionTypes.TREE_SELECT_TOPIC, - }) + } + + if (setTopicDispatch) { + dispatch(batchActions([selectTreeTopicDispatch, setTopicDispatch])) + } else { + dispatch(selectTreeTopicDispatch) + } } -export const showTree = (tree?: q.Tree) => (dispatch: Dispatch, getState: () => AppState): AnyAction => { +export const showTree = (tree?: q.Tree) => (dispatch: Dispatch, getState: () => AppState): AnyAction => { const visibleTree = getState().tree.tree const connectionTree = getState().connection.tree diff --git a/app/src/components/BrokerStatistics.tsx b/app/src/components/BrokerStatistics.tsx index 29eacc0..df840c9 100644 --- a/app/src/components/BrokerStatistics.tsx +++ b/app/src/components/BrokerStatistics.tsx @@ -6,6 +6,7 @@ import { Typography } from '@material-ui/core' import { StyleRulesCallback, withStyles } from '@material-ui/core/styles' import { connect } from 'react-redux' +import { TopicViewModel } from '../TopicViewModel' const abbreviate = require('number-abbreviate') interface Stats { @@ -30,7 +31,7 @@ const styles: StyleRulesCallback = theme => ({ interface Props { classes: any - tree?: q.Tree + tree?: q.Tree } class BrokerStatistics extends React.Component { @@ -95,7 +96,7 @@ class BrokerStatistics extends React.Component { ) } - private renderPair(tree: q.Tree, a: Stats, b: Stats) { + private renderPair(tree: q.Tree, a: Stats, b: Stats) { return (
{this.renderStat(tree, a)}
@@ -104,7 +105,7 @@ class BrokerStatistics extends React.Component { ) } - public renderStat(tree: q.Tree, stat: Stats) { + public renderStat(tree: q.Tree, stat: Stats) { const node = tree.findNode(stat.topic) if (!node) { return null diff --git a/app/src/components/Sidebar/MessageHistory.tsx b/app/src/components/Sidebar/MessageHistory.tsx index ffcc033..8fb33d8 100644 --- a/app/src/components/Sidebar/MessageHistory.tsx +++ b/app/src/components/Sidebar/MessageHistory.tsx @@ -4,12 +4,13 @@ import * as q from '../../../../backend/src/Model' import BarChart from '@material-ui/icons/BarChart' import DateFormatter from '../helper/DateFormatter' import History from './History' +import { TopicViewModel } from '../../TopicViewModel' const PlotHistory = React.lazy(() => import('./PlotHistory')) const throttle = require('lodash.throttle') interface Props { - node?: q.TreeNode + node?: q.TreeNode onSelect: (message: q.Message) => void } diff --git a/app/src/components/Sidebar/NodeStats.tsx b/app/src/components/Sidebar/NodeStats.tsx index 6858a81..99a3da5 100644 --- a/app/src/components/Sidebar/NodeStats.tsx +++ b/app/src/components/Sidebar/NodeStats.tsx @@ -2,9 +2,10 @@ import * as React from 'react' import * as q from '../../../../backend/src/Model' import { Typography } from '@material-ui/core' +import { TopicViewModel } from '../../TopicViewModel' interface Props { - node: q.TreeNode + node: q.TreeNode } class NodeStats extends React.Component { diff --git a/app/src/components/Sidebar/Publish/Publish.tsx b/app/src/components/Sidebar/Publish/Publish.tsx index 8c1921f..13f1d41 100644 --- a/app/src/components/Sidebar/Publish/Publish.tsx +++ b/app/src/components/Sidebar/Publish/Publish.tsx @@ -13,7 +13,6 @@ import { Radio, RadioGroup, TextField, - IconButton, FormControl, InputLabel, Input, @@ -32,10 +31,11 @@ import Clear from '@material-ui/icons/Clear' import { bindActionCreators } from 'redux' import { connect } from 'react-redux' import { publishActions } from '../../../actions' -import ClearAdornment from '../../helper/ClearAdornment'; +import ClearAdornment from '../../helper/ClearAdornment' +import { TopicViewModel } from '../../../TopicViewModel' interface Props { - node?: q.TreeNode + node?: q.TreeNode connectionId?: string topic?: string payload?: string diff --git a/app/src/components/Sidebar/Sidebar.tsx b/app/src/components/Sidebar/Sidebar.tsx index 63dbace..e9c4b3f 100644 --- a/app/src/components/Sidebar/Sidebar.tsx +++ b/app/src/components/Sidebar/Sidebar.tsx @@ -28,18 +28,19 @@ import Topic from './Topic' const ValueRenderer = React.lazy(() => import('./ValueRenderer')) import { connect } from 'react-redux' import { bindActionCreators } from 'redux' +import { TopicViewModel } from '../../TopicViewModel' const throttle = require('lodash.throttle') interface Props { - node?: q.TreeNode, + node?: q.TreeNode, actions: typeof sidebarActons, classes: any, connectionId?: string, } interface State { - node: q.TreeNode, + node: q.TreeNode compareMessage?: q.Message } @@ -69,12 +70,12 @@ class Sidebar extends React.Component { this.props.node && this.removeUpdateListener(this.props.node) } - private registerUpdateListener(node: q.TreeNode) { + private registerUpdateListener(node: q.TreeNode) { node.onMerge.subscribe(this.updateNode) node.onMessage.subscribe(this.updateNode) } - private removeUpdateListener(node: q.TreeNode) { + private removeUpdateListener(node: q.TreeNode) { node.onMerge.unsubscribe(this.updateNode) node.onMessage.unsubscribe(this.updateNode) } diff --git a/app/src/components/Sidebar/Topic.tsx b/app/src/components/Sidebar/Topic.tsx index 52cf301..7434b4d 100644 --- a/app/src/components/Sidebar/Topic.tsx +++ b/app/src/components/Sidebar/Topic.tsx @@ -5,14 +5,15 @@ import { withStyles, Theme, StyleRulesCallback } from '@material-ui/core/styles' import { treeActions } from '../../actions' import { bindActionCreators } from 'redux' import { connect } from 'react-redux' +import { TopicViewModel } from '../../TopicViewModel' interface Props { classes: any theme: Theme - node?: q.TreeNode - selected?: q.TreeNode + node?: q.TreeNode + selected?: q.TreeNode actions: typeof treeActions - didSelectNode: (node: q.TreeNode) => void + didSelectNode: (node: q.TreeNode) => void } const styles: StyleRulesCallback = (theme: Theme) => ({ diff --git a/app/src/components/Tree/Tree.tsx b/app/src/components/Tree/Tree.tsx index 83b5373..08e774a 100644 --- a/app/src/components/Tree/Tree.tsx +++ b/app/src/components/Tree/Tree.tsx @@ -4,30 +4,38 @@ import * as q from '../../../../backend/src/Model' import { AppState } from '../../reducers' import TreeNode from './TreeNode' import { connect } from 'react-redux' +import { TopicOrder } from '../../reducers/Settings' +import { TopicViewModel } from '../../TopicViewModel' const MovingAverage = require('moving-average') -const timeInterval = 10 * 1000 -const average = MovingAverage(timeInterval) +const averagingTimeInterval = 10 * 1000 +const average = MovingAverage(averagingTimeInterval) declare var window: any interface Props { - autoExpandLimit: number connectionId?: string - tree?: q.Tree + tree?: q.Tree filter: string host?: string + + topicOrder: TopicOrder + autoExpandLimit: number } -class Tree extends React.Component { +interface State { + lastUpdate: number +} + +class Tree extends React.PureComponent { private updateTimer?: any - private lastUpdate: number = 0 private perf: number = 0 + private renderTime = 0 constructor(props: any) { super(props) - this.state = { } + this.state = { lastUpdate: 0 } } public time(): number { @@ -40,17 +48,17 @@ class Tree extends React.Component { public componentWillReceiveProps(nextProps: Props) { if (this.props.tree !== nextProps.tree) { if (this.props.tree) { - this.props.tree.onMerge.unsubscribe(this.throttledTreeUpdate) + this.props.tree.didReceive.unsubscribe(this.throttledTreeUpdate) } if (nextProps.tree) { - nextProps.tree.onMerge.subscribe(this.throttledTreeUpdate) + nextProps.tree.didReceive.subscribe(this.throttledTreeUpdate) } this.setState(this.state) } } public componentWillUnmount() { - this.props.tree && this.props.tree.onMerge.unsubscribe(this.throttledTreeUpdate) + this.props.tree && this.props.tree.didReceive.unsubscribe(this.throttledTreeUpdate) } public throttledTreeUpdate = () => { @@ -60,14 +68,17 @@ class Tree extends React.Component { const expectedRenderTime = average.forecast() const updateInterval = Math.max(expectedRenderTime * 7, 300) - const timeUntilNextUpdate = updateInterval - (performance.now() - this.lastUpdate) + const timeUntilNextUpdate = updateInterval - (performance.now() - this.renderTime) this.updateTimer = setTimeout(() => { window.requestIdleCallback(() => { - this.lastUpdate = performance.now() this.updateTimer && clearTimeout(this.updateTimer) this.updateTimer = undefined - this.setState(this.state) + this.renderTime = performance.now() + this.props.tree && this.props.tree.applyUnmergedChanges() + window.requestIdleCallback(() => { + this.setState({ lastUpdate: this.renderTime }) + }, { timeout: 100 }) }, { timeout: 500 }) }, Math.max(0, timeUntilNextUpdate)) } @@ -91,9 +102,11 @@ class Tree extends React.Component { isRoot={true} treeNode={tree} name={this.props.host} - lastUpdate={tree.lastUpdate} collapsed={false} performanceCallback={this.performanceCallback} + autoExpandLimit={this.props.autoExpandLimit} + topicOrder={this.props.topicOrder} + lastUpdate={tree.lastUpdate} />
) @@ -106,10 +119,11 @@ class Tree extends React.Component { const mapStateToProps = (state: AppState) => { return { - autoExpandLimit: state.settings.autoExpandLimit, tree: state.tree.tree, filter: state.tree.filter, host: state.connection.host, + autoExpandLimit: state.settings.autoExpandLimit, + topicOrder: state.settings.topicOrder, } } diff --git a/app/src/components/Tree/TreeNode.tsx b/app/src/components/Tree/TreeNode.tsx index 7e5c2f5..9a2e67f 100644 --- a/app/src/components/Tree/TreeNode.tsx +++ b/app/src/components/Tree/TreeNode.tsx @@ -3,7 +3,6 @@ import * as q from '../../../../backend/src/Model' import { Theme, withStyles } from '@material-ui/core/styles' -import LabelImportant from '@material-ui/icons/LabelImportant' import TreeNodeSubnodes from './TreeNodeSubnodes' import TreeNodeTitle from './TreeNodeTitle' import { bindActionCreators } from 'redux' @@ -11,6 +10,9 @@ import { connect } from 'react-redux' import { isElementInViewport } from '../helper/isElementInViewport' import { treeActions } from '../../actions' import { AppState } from '../../reducers' +import { TopicOrder } from '../../reducers/Settings' +import { TopicViewModel } from '../../TopicViewModel' +const debounce = require('lodash.debounce') declare var performance: any @@ -26,36 +28,40 @@ const styles = (theme: Theme) => { display: 'block', marginLeft: '10px', }, - // hover: { - // '&:hover': { - // backgroundColor: 'rgba(80, 80, 80, 0.35)', - // }, - // }, topicSelect: { float: 'right' as 'right', opacity: 0, cursor: 'pointer', marginTop: '-1px', }, + selected: { + backgroundColor: 'rgba(120, 120, 120, 0.55)', + }, + hover: { + backgroundColor: 'rgba(80, 80, 80, 0.55)', + }, } } interface Props { actions: typeof treeActions - lastUpdate: number animateChages: boolean isRoot?: boolean - treeNode: q.TreeNode + treeNode: q.TreeNode name?: string | undefined collapsed?: boolean | undefined performanceCallback?: ((ms: number) => void) | undefined - autoExpandLimit: number classes: any className?: string + topicOrder: TopicOrder + autoExpandLimit: number + lastUpdate: number } interface State { collapsedOverride: boolean | undefined + mouseOver: boolean + selected: boolean } class TreeNode extends React.Component { @@ -63,13 +69,13 @@ class TreeNode extends React.Component { private dirtyEdges: boolean = true private dirtyMessage: boolean = true private animationDirty: boolean = false + private lastRenderTime = 0 private cssAnimationWasSetAt?: number private willUpdateTime: number = performance.now() private titleRef?: React.RefObject = React.createRef() private nodeRef?: React.RefObject = React.createRef() - private topicSelectRef?: React.RefObject = React.createRef() private subnodesDidchange = () => { this.dirtySubnodes = true @@ -88,6 +94,8 @@ class TreeNode extends React.Component { this.state = { collapsedOverride: props.collapsed, + mouseOver: false, + selected: false, } } @@ -96,13 +104,21 @@ class TreeNode extends React.Component { this.addSubscriber(treeNode) } - private addSubscriber(treeNode: q.TreeNode) { + private addSubscriber(treeNode: q.TreeNode) { + treeNode.viewModel = new TopicViewModel() + treeNode.viewModel.change.subscribe(this.viewStateHasChanged) treeNode.onMerge.subscribe(this.subnodesDidchange) treeNode.onEdgesChange.subscribe(this.edgesDidChange) treeNode.onMessage.subscribe(this.messageDidChange) } - private removeSubscriber(treeNode: q.TreeNode) { + private viewStateHasChanged = (msg: void, viewModel: TopicViewModel) => { + this.setState({ selected: viewModel.isSelected() }) + } + + private removeSubscriber(treeNode: q.TreeNode) { + treeNode.viewModel && treeNode.viewModel.change.unsubscribe(this.viewStateHasChanged) + treeNode.viewModel = undefined treeNode.onMerge.unsubscribe(this.subnodesDidchange) treeNode.onEdgesChange.unsubscribe(this.edgesDidChange) treeNode.onMessage.unsubscribe(this.messageDidChange) @@ -118,13 +134,14 @@ class TreeNode extends React.Component { public componentWillUnmount() { const { treeNode } = this.props this.removeSubscriber(treeNode) - this.topicSelectRef = undefined this.titleRef = undefined this.nodeRef = undefined } private stateHasChanged(newState: State) { return this.state.collapsedOverride !== newState.collapsedOverride + || this.state.mouseOver !== newState.mouseOver + || this.state.selected !== newState.selected } private propsHasChanged(newProps: Props) { @@ -135,9 +152,7 @@ class TreeNode extends React.Component { const shouldRenderToRemoveCssAnimation = this.cssAnimationWasSetAt !== undefined return this.stateHasChanged(nextState) || this.propsHasChanged(nextProps) - || this.dirtyEdges - || this.dirtyMessage - || this.dirtySubnodes + || (this.dirtyEdges || this.dirtyMessage || this.dirtySubnodes) || this.animationDirty || shouldRenderToRemoveCssAnimation } @@ -176,10 +191,11 @@ class TreeNode extends React.Component { const animation = shouldStartAnimation ? { willChange: 'auto', translateZ: 0, animation: 'example 0.5s' } : {} this.animationDirty = shouldStartAnimation + const highlightClass = this.state.selected ? this.props.classes.selected : (this.state.mouseOver ? this.props.classes.hover : '') return (
{ collapsed={this.collapsed()} treeNode={this.props.treeNode} name={this.props.name} - lastUpdate={this.props.treeNode.lastUpdate} + didSelectNode={this.didSelectTopic} /> {this.renderNodes()} @@ -198,31 +214,27 @@ class TreeNode extends React.Component { ) } + private didSelectTopic = () => { + this.props.actions.selectTopic(this.props.treeNode) + } + private mouseOver = (event: React.MouseEvent) => { event.stopPropagation() - if (this.nodeRef && this.nodeRef.current) { - this.nodeRef.current.style.backgroundColor = 'rgba(100, 100, 100, 0.55)' - } - if (this.topicSelectRef && this.topicSelectRef.current) { - this.topicSelectRef.current.style.opacity = '1' - } + this.setHover(true) } + private mouseOut = (event: React.MouseEvent) => { event.stopPropagation() - if (this.nodeRef && this.nodeRef.current) { - this.nodeRef.current.style.backgroundColor = 'inherit' - } - if (this.topicSelectRef && this.topicSelectRef.current) { - this.topicSelectRef.current.style.opacity = '0' - } + this.setHover(false) } + private setHover = debounce((hover: boolean) => { + this.setState({ mouseOver: hover }) + }, 5) + private didSelectNode = (event: React.MouseEvent) => { event.stopPropagation() - if (this.topicSelectRef && this.topicSelectRef.current) { - this.topicSelectRef.current.style.opacity = '1' - } - this.props.actions.selectTopic(this.props.treeNode) + this.didSelectTopic() } private didClickNode = (event: React.MouseEvent) => { @@ -236,8 +248,9 @@ class TreeNode extends React.Component { ) @@ -250,10 +263,4 @@ const mapDispatchToProps = (dispatch: any) => { } } -const mapStateToProps = (state: AppState) => { - return { - autoExpandLimit: state.settings.autoExpandLimit, - } -} - -export default withStyles(styles)(connect(mapStateToProps, mapDispatchToProps)(TreeNode)) +export default withStyles(styles)(connect(null, mapDispatchToProps)(TreeNode)) diff --git a/app/src/components/Tree/TreeNodeSubnodes.tsx b/app/src/components/Tree/TreeNodeSubnodes.tsx index 80988c3..70972a1 100644 --- a/app/src/components/Tree/TreeNodeSubnodes.tsx +++ b/app/src/components/Tree/TreeNodeSubnodes.tsx @@ -7,17 +7,20 @@ import TreeNode from './TreeNode' import { connect } from 'react-redux' import { TopicOrder } from '../../reducers/Settings' import { Theme, withStyles } from '@material-ui/core' +import { TopicViewModel } from '../../TopicViewModel' export interface Props { - lastUpdate: number - topicOrder?: TopicOrder animateChanges: boolean - treeNode: q.TreeNode - autoExpandLimit: number + treeNode: q.TreeNode filter?: string collapsed?: boolean | undefined - didSelectNode?: (node: q.TreeNode) => void classes: any + + lastUpdate: number + + topicOrder: TopicOrder + selectedTopic?: q.TreeNode + autoExpandLimit: number } interface State { @@ -31,7 +34,7 @@ class TreeNodeSubnodes extends React.Component { this.state = { alreadyAdded: 10 } } - private sortedNodes(): q.TreeNode[] { + private sortedNodes(): q.TreeNode[] { const { topicOrder, treeNode } = this.props let edges = treeNode.edgeArray @@ -72,15 +75,19 @@ class TreeNodeSubnodes extends React.Component { } const nodes = this.sortedNodes().slice(0, this.state.alreadyAdded) - const listItems = nodes.map(node => ( - - )) + const listItems = nodes.map((node) => { + return ( + + ) + }) return ( @@ -90,13 +97,6 @@ class TreeNodeSubnodes extends React.Component { } } -const mapStateToProps = (state: AppState) => { - return { - topicOrder: state.settings.topicOrder, - filter: state.tree.filter, - } -} - const styles = (theme: Theme) => ({ list: { display: 'block' as 'block', @@ -107,4 +107,4 @@ const styles = (theme: Theme) => ({ }, }) -export default withStyles(styles)(connect(mapStateToProps)(TreeNodeSubnodes)) +export default withStyles(styles)(TreeNodeSubnodes) diff --git a/app/src/components/Tree/TreeNodeTitle.tsx b/app/src/components/Tree/TreeNodeTitle.tsx index 2ca5b54..2245eba 100644 --- a/app/src/components/Tree/TreeNodeTitle.tsx +++ b/app/src/components/Tree/TreeNodeTitle.tsx @@ -1,29 +1,33 @@ import * as React from 'react' import { connect } from 'react-redux' -import { bindActionCreators } from 'redux' -import { treeActions } from '../../actions' import * as q from '../../../../backend/src/Model' import { withStyles, Theme } from '@material-ui/core' +import { TopicViewModel } from '../../TopicViewModel' +const debounce = require('lodash.debounce') export interface TreeNodeProps extends React.HTMLAttributes { - treeNode: q.TreeNode - actions: any + treeNode: q.TreeNode name?: string | undefined collapsed?: boolean | undefined - lastUpdate: number classes: any + didSelectNode: any } class TreeNodeTitle extends React.Component { private mouseOver = (event: React.MouseEvent) => { - if (this.props.treeNode.message) { - this.props.actions.selectTopic(this.props.treeNode) - } + event.preventDefault() + this.selectTopic() } + private selectTopic = debounce(() => { + if (this.props.treeNode.message) { + this.props.didSelectNode(this.props.treeNode) + } + }, 5) + public render() { return ( - + {this.renderExpander()} {this.renderSourceEdge()} {this.renderCollapsedSubnodes()} {this.renderValue()} ) @@ -59,12 +63,6 @@ class TreeNodeTitle extends React.Component { } } -const mapDispatchToProps = (dispatch: any) => { - return { - actions: bindActionCreators(treeActions, dispatch), - } -} - const styles = (theme: Theme) => ({ value: { whiteSpace: 'nowrap' as 'nowrap', @@ -88,4 +86,4 @@ const styles = (theme: Theme) => ({ }, }) -export default withStyles(styles)(connect(null, mapDispatchToProps)(TreeNodeTitle)) +export default withStyles(styles)(TreeNodeTitle) diff --git a/app/src/index.tsx b/app/src/index.tsx index cb62d20..bbee6e6 100644 --- a/app/src/index.tsx +++ b/app/src/index.tsx @@ -3,7 +3,7 @@ import './tracking' import * as React from 'react' import * as ReactDOM from 'react-dom' import reduxThunk from 'redux-thunk' -import { batchDispatchMiddleware } from 'redux-batched-actions'; +import { batchDispatchMiddleware } from 'redux-batched-actions' import { MuiThemeProvider, createMuiTheme } from '@material-ui/core/styles' import reducers from './reducers' diff --git a/app/src/reducers/Connection.ts b/app/src/reducers/Connection.ts index 561abb4..b7bcc91 100644 --- a/app/src/reducers/Connection.ts +++ b/app/src/reducers/Connection.ts @@ -2,10 +2,11 @@ import { Action } from 'redux' import { createReducer } from './lib' import * as q from '../../../backend/src/Model' import { MqttOptions } from '../../../backend/src/DataSource' +import { TopicViewModel } from '../TopicViewModel' export interface ConnectionState { host?: string - tree?: q.Tree + tree?: q.Tree connectionOptions?: MqttOptions connectionId?: string error?: string @@ -30,7 +31,7 @@ export interface SetConnecting { export interface SetConnected { type: ActionTypes.CONNECTION_SET_CONNECTED host: string - tree: q.Tree + tree: q.Tree } export interface SetDisconnected { diff --git a/app/src/reducers/Tree.ts b/app/src/reducers/Tree.ts index 5dc6ff3..4dedadd 100644 --- a/app/src/reducers/Tree.ts +++ b/app/src/reducers/Tree.ts @@ -1,10 +1,11 @@ import * as q from '../../../backend/src/Model' import { Action } from 'redux' import { createReducer } from './lib' +import { TopicViewModel } from '../TopicViewModel' export interface TreeState { - tree?: q.Tree - selectedTopic?: q.TreeNode + tree?: q.Tree + selectedTopic?: q.TreeNode filter?: string } @@ -17,13 +18,13 @@ export enum ActionTypes { export interface ShowTree { type: ActionTypes.TREE_SHOW_TREE - tree?: q.Tree + tree?: q.Tree filter?: string } export interface SelectTopic { type: ActionTypes.TREE_SELECT_TOPIC - selectedTopic?: q.TreeNode + selectedTopic?: q.TreeNode } const initialState: TreeState = { } diff --git a/app/src/reducers/index.ts b/app/src/reducers/index.ts index a9fa813..7a302f0 100644 --- a/app/src/reducers/index.ts +++ b/app/src/reducers/index.ts @@ -1,5 +1,3 @@ -import * as q from '../../../backend/src/Model' - import { Action, Reducer, combineReducers } from 'redux' import { trackEvent } from '../tracking' diff --git a/backend/src/Model/Edge.ts b/backend/src/Model/Edge.ts index 90007c4..1b90874 100644 --- a/backend/src/Model/Edge.ts +++ b/backend/src/Model/Edge.ts @@ -1,11 +1,11 @@ import { Hashable, TreeNode } from './' const sha1 = require('sha1') -export class Edge implements Hashable { +export class Edge implements Hashable { public name: string - public target!: TreeNode - public source?: TreeNode | undefined + public target!: TreeNode + public source?: TreeNode | undefined private cachedHash?: string constructor(name: string) { @@ -32,7 +32,7 @@ export class Edge implements Hashable { return this.cachedHash } - public firstEdge(): Edge { + public firstEdge(): Edge { if (this.source && this.source.sourceEdge) { return this.source.sourceEdge.firstEdge() } diff --git a/backend/src/Model/Tree.ts b/backend/src/Model/Tree.ts index 8788933..67932cb 100644 --- a/backend/src/Model/Tree.ts +++ b/backend/src/Model/Tree.ts @@ -1,20 +1,22 @@ import { TreeNode } from './' -import { EventBusInterface, makeConnectionMessageEvent, MqttMessage } from '../../../events' +import { EventBusInterface, makeConnectionMessageEvent, MqttMessage, EventDispatcher } from '../../../events' import { TreeNodeFactory } from './TreeNodeFactory' -export class Tree extends TreeNode { +export class Tree extends TreeNode { public connectionId?: string public updateSource?: EventBusInterface - public nodeFilter?: (node: TreeNode) => boolean + public nodeFilter?: (node: TreeNode) => boolean private subscriptionEvent?: any public isTree = true private cachedHash = `${Math.random()}` + private unmergedMessages: MqttMessage[] = [] + public didReceive = new EventDispatcher>(this) constructor() { super(undefined, undefined) } - public updateWithConnection(emitter: EventBusInterface, connectionId: string, nodeFilter?:(node: TreeNode) => boolean) { + public updateWithConnection(emitter: EventBusInterface, connectionId: string, nodeFilter?:(node: TreeNode) => boolean) { this.updateSource = emitter this.connectionId = connectionId this.nodeFilter = nodeFilter @@ -27,15 +29,22 @@ export class Tree extends TreeNode { return this.cachedHash } - private handleNewData = (msg: MqttMessage) => { - const edges = msg.topic.split('/') - const node = TreeNodeFactory.fromEdgesAndValue(edges, msg.payload) - node.mqttMessage = msg + public applyUnmergedChanges() { + this.unmergedMessages.forEach((msg) => { + const edges = msg.topic.split('/') + const node = TreeNodeFactory.fromEdgesAndValue(edges, msg.payload) + node.mqttMessage = msg - if (this.nodeFilter && !this.nodeFilter(node)) { - return - } - this.updateWithNode(node.firstNode()) + if (!this.nodeFilter || this.nodeFilter(node)) { + this.updateWithNode(node.firstNode()) + } + }) + this.unmergedMessages = [] + } + + private handleNewData = (msg: MqttMessage) => { + this.unmergedMessages.push(msg) + this.didReceive.dispatch() } public stopUpdating() { diff --git a/backend/src/Model/TreeNode.ts b/backend/src/Model/TreeNode.ts index c53ab23..cdb10b6 100644 --- a/backend/src/Model/TreeNode.ts +++ b/backend/src/Model/TreeNode.ts @@ -1,28 +1,29 @@ import { Edge, Message, RingBuffer } from './' import { EventDispatcher, MqttMessage } from '../../../events' -export class TreeNode { - public sourceEdge?: Edge +export class TreeNode { + public sourceEdge?: Edge public message?: Message public mqttMessage?: MqttMessage public messageHistory: RingBuffer = new RingBuffer(3000, 100) - public edges: {[s: string]: Edge} = {} - public edgeArray: Edge[] = [] + public viewModel?: ViewModel + public edges: {[s: string]: Edge} = {} + public edgeArray: Edge[] = [] public collapsed = false public messages: number = 0 public lastUpdate: number = Date.now() - public onMerge = new EventDispatcher(this) - public onEdgesChange = new EventDispatcher(this) - public onMessage = new EventDispatcher(this) + public onMerge = new EventDispatcher>(this) + public onEdgesChange = new EventDispatcher>(this) + public onMessage = new EventDispatcher>(this) public isTree = false private cachedPath?: string - private cachedChildTopics?: TreeNode[] + private cachedChildTopics?: TreeNode[] private cachedLeafMessageCount?: number private cachedChildTopicCount?: number public unconnectedClone() { - const node = new TreeNode() + const node = new TreeNode() node.message = this.message node.mqttMessage = this.mqttMessage node.messageHistory = this.messageHistory.clone() @@ -32,7 +33,7 @@ export class TreeNode { return node } - constructor(sourceEdge?: Edge, message?: Message) { + constructor(sourceEdge?: Edge, message?: Message) { if (sourceEdge) { this.sourceEdge = sourceEdge sourceEdge.target = this @@ -63,7 +64,7 @@ export class TreeNode { return `N${(this.sourceEdge ? this.sourceEdge.hash() : '')}` } - public firstNode(): TreeNode { + public firstNode(): TreeNode { return this.sourceEdge && this.sourceEdge.source ? this.sourceEdge.source.firstNode() : this } @@ -78,11 +79,11 @@ export class TreeNode { return this.cachedPath } - private previous(): TreeNode | undefined { + private previous(): TreeNode | undefined { return this.sourceEdge ? this.sourceEdge.source || undefined : undefined } - public addEdge(edge: Edge, emitUpdate: boolean = false) { + public addEdge(edge: Edge, emitUpdate: boolean = false) { this.edges[edge.name] = edge this.edgeArray.push(edge) edge.source = this @@ -92,7 +93,7 @@ export class TreeNode { } } - public branch(): TreeNode[] { + public branch(): TreeNode[] { const previous = this.previous() if (!previous) { return [this] @@ -101,7 +102,7 @@ export class TreeNode { return previous.branch().concat([this]) } - public updateWithNode(node: TreeNode) { + public updateWithNode(node: TreeNode) { if (node.message) { this.setMessage(node.message) this.onMessage.dispatch(node.message) @@ -119,7 +120,7 @@ export class TreeNode { .reduce((a, b) => a + b, 0) + this.messages } - return this.cachedLeafMessageCount + return this.cachedLeafMessageCount as number } public childTopicCount(): number { @@ -129,14 +130,14 @@ export class TreeNode { .reduce((a, b) => a + b, this.edgeArray.length === 0 ? 1 : 0) } - return this.cachedChildTopicCount + return this.cachedChildTopicCount as number } public edgeCount(): number { return this.edgeArray.length } - public childTopics(): TreeNode[] { + public childTopics(): TreeNode[] { if (this.cachedChildTopics === undefined) { const initialValue = this.message && this.message.value ? [this] : [] @@ -145,16 +146,16 @@ export class TreeNode { .reduce((a, b) => a.concat(b), initialValue) } - return this.cachedChildTopics + return this.cachedChildTopics as TreeNode[] } - public findNode (path: String): TreeNode | undefined { + public findNode (path: String): TreeNode | undefined { const topics = path.split('/') return this.findChild(topics) } - private findChild(edges: string[]): TreeNode | undefined { + private findChild(edges: string[]): TreeNode | undefined { if (edges.length === 0) { return this } @@ -167,7 +168,7 @@ export class TreeNode { return nextEdge.target.findChild(edges.slice(1)) } - private mergeEdges(node: TreeNode) { + private mergeEdges(node: TreeNode) { const edgeKeys = Object.keys(node.edges) let edgesDidUpdate = false diff --git a/backend/src/Model/TreeNodeFactory.ts b/backend/src/Model/TreeNodeFactory.ts index 3ac3e03..305637c 100644 --- a/backend/src/Model/TreeNodeFactory.ts +++ b/backend/src/Model/TreeNodeFactory.ts @@ -5,11 +5,11 @@ interface HasLength { } export abstract class TreeNodeFactory { - public static insertNodeAtPosition(edgeNames: string[], node: TreeNode) { - let currentNode: TreeNode = new Tree() + public static insertNodeAtPosition(edgeNames: string[], node: TreeNode) { + let currentNode: TreeNode = new Tree() let edge for (const edgeName of edgeNames) { - edge = new Edge(edgeName) + edge = new Edge(edgeName) currentNode.addEdge(edge) currentNode = new TreeNode(edge) edge.target = currentNode @@ -18,15 +18,15 @@ export abstract class TreeNodeFactory { node.sourceEdge!.target = node } - public static fromEdgesAndValue(edgeNames: string[], value?: T): TreeNode { - const node = new TreeNode() + public static fromEdgesAndValue(edgeNames: string[], value?: T): TreeNode { + const node = new TreeNode() node.setMessage({ value, length: value ? value.length : 0, received: new Date(), }) - this.insertNodeAtPosition(edgeNames, node) + this.insertNodeAtPosition(edgeNames, node) return node }