diff --git a/app/src/App.tsx b/app/src/App.tsx index 1c42ac1..41ebba5 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,7 +1,7 @@ import * as React from 'react' import * as q from '../../backend/src/Model' -import { Tree } from './components/Tree' +import { Tree } from './components/Tree/Tree' import TitleBar from './components/TitleBar' import Sidebar from './components/Sidebar/Sidebar' diff --git a/app/src/components/Sidebar/Sidebar.tsx b/app/src/components/Sidebar/Sidebar.tsx index b055e24..5a014ae 100644 --- a/app/src/components/Sidebar/Sidebar.tsx +++ b/app/src/components/Sidebar/Sidebar.tsx @@ -22,17 +22,17 @@ interface State { } class Sidebar extends React.Component { - private updateNode: (node?: q.TreeNode | undefined) => void + private updateNode = (node: q.TreeNode) => { + if (!node) { + this.setState(this.state) + } else { + this.setState({ node }) + } + } + constructor(props: any) { super(props) - this.state = {} - this.updateNode = (node) => { - if (!node) { - this.setState(this.state) - } else { - this.setState({ node }) - } - } + this.state = { node: new q.Tree() } } public static styles: StyleRulesCallback = (theme: Theme) => { @@ -52,9 +52,19 @@ class Sidebar extends React.Component { } public componentWillReceiveProps(nextProps: Props) { - this.props.node && this.props.node.removeListener('update', this.updateNode) - nextProps.node && nextProps.node.on('update', this.updateNode) - nextProps.node && this.updateNode(nextProps.node) + this.props.node && this.removeUpdateListener(this.props.node) + nextProps.node && this.registerUpdateListener(nextProps.node) + this.props.node && this.setState({ node: this.props.node }) + } + + private registerUpdateListener(node: q.TreeNode) { + node.on(q.TreeNodeUpdateEvents.merge, this.updateNode) + node.on(q.TreeNodeUpdateEvents.message, this.updateNode) + } + + private removeUpdateListener(node: q.TreeNode) { + node.removeListener(q.TreeNodeUpdateEvents.merge, this.updateNode) + node.removeListener(q.TreeNodeUpdateEvents.message, this.updateNode) } private open(): boolean { @@ -79,7 +89,7 @@ class Sidebar extends React.Component { Topic - + @@ -90,7 +100,7 @@ class Sidebar extends React.Component { - + }> Stats diff --git a/app/src/components/Sidebar/Topic.tsx b/app/src/components/Sidebar/Topic.tsx index d636b24..0a80644 100644 --- a/app/src/components/Sidebar/Topic.tsx +++ b/app/src/components/Sidebar/Topic.tsx @@ -6,8 +6,9 @@ import Button from '@material-ui/core/Button' interface Props { classes: any theme: Theme - node: q.TreeNode + node?: q.TreeNode selected?: q.TreeNode + didSelectNode: (node: q.TreeNode) => void } class Topic extends React.Component { @@ -21,7 +22,11 @@ class Topic extends React.Component { public render() { const { node } = this.props - let i = 0 + if (!node) { + return null + } + + let key = 0 const breadCrumps = node.branch() .map(node => node.sourceEdge) .filter(edge => Boolean(edge)) @@ -42,7 +47,7 @@ class Topic extends React.Component { } const joinedBreadCrumps = breadCrumps.reduce((prev, current) => - prev.concat([/]).concat(current), + prev.concat([/]).concat(current), ) return {joinedBreadCrumps} diff --git a/app/src/components/Tree.tsx b/app/src/components/Tree/Tree.tsx similarity index 90% rename from app/src/components/Tree.tsx rename to app/src/components/Tree/Tree.tsx index e25e020..f1c5dda 100644 --- a/app/src/components/Tree.tsx +++ b/app/src/components/Tree/Tree.tsx @@ -1,11 +1,9 @@ import * as React from 'react' import * as io from 'socket.io-client' -import * as q from '../../../backend/src/Model' +import * as q from '../../../../backend/src/Model' import TreeNode from './TreeNode' import List from '@material-ui/core/List' -const throttle = require('lodash.throttle') - class TreeState { public tree: q.Tree public msg: any @@ -22,7 +20,7 @@ declare const performance: any export class Tree extends React.Component { private socket: SocketIOClient.Socket - private renderDuration: number = 200 + private renderDuration: number = 300 private updateTimer?: any private lastUpdate: number = 0 private perf:number = 0 @@ -46,7 +44,7 @@ export class Tree extends React.Component { return } - const updateInterval = Math.max(this.renderDuration * 5, 200) + const updateInterval = Math.max(this.renderDuration * 5, 300) const timeUntilNextUpdate = updateInterval - (performance.now() - this.lastUpdate) this.updateTimer = setTimeout(() => { @@ -75,6 +73,8 @@ export class Tree extends React.Component { return
void) | undefined + didSelectNode?: (node: q.TreeNode) => void + theme: Theme + autoExpandLimit: number +} + +interface TreeNodeState { + title: string | undefined + collapsed: boolean + collapsedOverride: boolean | undefined + edgeCount: number +} + +class TreeNode extends React.Component { + private dirtySubnodes: boolean = true + private dirtyState: boolean = true + private dirtyEdges: boolean = true + private dirtyMessage: boolean = true + + private cssAnimationWasSetAt?: number + + private willUpdateTime: number = performance.now() + private titleRef = React.createRef() + + private subnodesDidchange = () => { + this.dirtySubnodes = true + } + + private messageDidChange = () => { + this.dirtyMessage = true + } + + private edgesDidChange = () => { + this.dirtyMessage = true + } + + constructor(props: TreeNodeProps) { + super(props) + const edgeCount = Object.keys(props.treeNode.edges).length + const collapsed = edgeCount > this.props.autoExpandLimit + this.state = { collapsed, edgeCount, collapsedOverride: props.collapsed, title: props.name } + } + + public componentDidMount() { + this.props.treeNode.on(q.TreeNodeUpdateEvents.merge, this.subnodesDidchange) + this.props.treeNode.on(q.TreeNodeUpdateEvents.edges, this.edgesDidChange) + this.props.treeNode.on(q.TreeNodeUpdateEvents.message, this.messageDidChange) + } + + public componentWillUnmount() { + this.props.treeNode.removeListener(q.TreeNodeUpdateEvents.merge, this.subnodesDidchange) + this.props.treeNode.removeListener(q.TreeNodeUpdateEvents.edges, this.edgesDidChange) + this.props.treeNode.removeListener(q.TreeNodeUpdateEvents.message, this.messageDidChange) + } + + private getStyles() { + return { + collapsedSubnodes: { + color: this.props.theme.palette.text.secondary, + }, + displayBlock: { + display: 'block', + }, + } + } + + public setState(newState: any) { + this.dirtyState = this.stateHasChanged(newState) + + super.setState(newState) + } + + private stateHasChanged(newState: any) { + return this.state.collapsed !== newState.collapsed + || this.state.collapsedOverride !== newState.collapsedOverride + || this.state.edgeCount !== newState.edgeCount + } + + public shouldComponentUpdate() { + const shouldRenderToRemoveCssAnimation = this.cssAnimationWasSetAt !== undefined + return this.dirtyState + || this.dirtyEdges + || this.dirtyMessage + || this.dirtySubnodes + || shouldRenderToRemoveCssAnimation + } + + public componentDidUpdate() { + if (this.props.performanceCallback) { + const renderTime = performance.now() - this.willUpdateTime + this.props.performanceCallback(renderTime) + } + } + + public componentWillUpdate() { + if (this.props.performanceCallback) { + this.willUpdateTime = performance.now() + } + } + + private toggle() { + this.setState({ collapsedOverride: !this.collapsed() }) + } + + private collapsed() { + if (this.state.collapsedOverride !== undefined) { + return this.state.collapsedOverride + } + + return this.state.collapsed + } + + public componentWillReceiveProps() { + const edgeCount = Object.keys(this.props.treeNode.edges).length + this.setState({ edgeCount, collapsed: edgeCount > this.props.autoExpandLimit }) + } + + public render() { + const { displayBlock } = this.getStyles() + const animationStyle = this.indicatingChangeAnimationStyle() + + this.dirtyState = this.dirtyEdges = this.dirtyMessage = this.dirtySubnodes = false + + return
+
+ this.toggle()} + /> +
+ + { this.clear() } +
+ {this.renderNodes()} +
+
+ } + + private clear() { + return
+ } + + private renderNodes() { + return this.toggle()} + treeNode={this.props.treeNode} + /> + } + + private indicatingChangeAnimationStyle() { + if (this.props.isRoot) { + return {} + } + if (this.cssAnimationWasSetAt && (performance.now() - this.cssAnimationWasSetAt) > 500) { + this.cssAnimationWasSetAt = undefined + return {} + } + const isInViewPort = this.titleRef.current && isElementInViewport(this.titleRef.current) + const isDirty = this.dirtyMessage || this.dirtyEdges || this.collapsed() + if (this.props.animateChages && isDirty && isInViewPort) { + if (!this.cssAnimationWasSetAt) { + this.cssAnimationWasSetAt = performance.now() + } + return { animation: 'example 0.5s' } + } + } +} + +export default withTheme()(TreeNode) diff --git a/app/src/components/Tree/TreeNodeSubnodes.tsx b/app/src/components/Tree/TreeNodeSubnodes.tsx new file mode 100644 index 0000000..24cff56 --- /dev/null +++ b/app/src/components/Tree/TreeNodeSubnodes.tsx @@ -0,0 +1,51 @@ +import * as React from 'react' +import * as q from '../../../../backend/src/Model' +import { withTheme, Theme } from '@material-ui/core/styles' +import { List, ListItem, Collapse } from '@material-ui/core' +import TreeNode from './TreeNode' + +export interface Props { + animateChanges: boolean + treeNode: q.TreeNode + autoExpandLimit: number + collapsed?: boolean | undefined + didSelectNode?: (node: q.TreeNode) => void + toggleCollapsed: () => void + theme: Theme +} + +class TreeNodeSubnodes extends React.Component { + public render() { + const edges = Object.values(this.props.treeNode.edges) + const listItemStyle = { + padding: '3px 8px 0px 8px', + } + + const listStyle = { + padding: '3px 8px 0px 8px', + } + + if (edges.length > 0 && !this.props.collapsed) { + const listItems = edges + .map(edge => edge.target) + .map(node => ( + + + + )) + + return + {listItems} + + } + + return null + } +} + +export default withTheme()(TreeNodeSubnodes) diff --git a/app/src/components/Tree/TreeNodeTitle.tsx b/app/src/components/Tree/TreeNodeTitle.tsx new file mode 100644 index 0000000..60e8eee --- /dev/null +++ b/app/src/components/Tree/TreeNodeTitle.tsx @@ -0,0 +1,95 @@ +import * as React from 'react' +import * as q from '../../../../backend/src/Model' +import { Typography } from '@material-ui/core' +import { withTheme, Theme } from '@material-ui/core/styles' + +export interface TreeNodeProps { + treeNode: q.TreeNode + name?: string | undefined + collapsed?: boolean | undefined + toggleCollapsed: () => void + didSelectNode?: (node: q.TreeNode) => void + edgeCount: number + theme: Theme +} + +class TreeNodeTitle extends React.Component { + private getStyles() { + const { theme } = this.props + return { + collapsedSubnodes: { + color: theme.palette.text.secondary, + }, + container: { + display: 'block', + }, + } + } + + public render() { + const style: React.CSSProperties = { + lineHeight: '1em', + whiteSpace: 'nowrap', + } + return + {this.renderExpander()} {this.renderSourceEdge()} {this.renderCollapsedSubnodes()} {this.renderValue()} + + } + + private renderSourceEdge() { + const style: React.CSSProperties = { + fontWeight: 'bold', + overflow: 'hidden', + display: 'inline-block', + } + const name = this.props.name || (this.props.treeNode.sourceEdge && this.props.treeNode.sourceEdge.name) + + return { + this.toggle() + this.props.didSelectNode && this.props.didSelectNode(this.props.treeNode) + }}>{name} + } + + private renderValue() { + const style: React.CSSProperties = { + width: '15em', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + padding: '0', + paddingLeft: '5px', + display: 'inline-block', + } + return this.props.treeNode.message + ? this.props.didSelectNode && this.props.didSelectNode(this.props.treeNode)} + > = {this.props.treeNode.message.value.toString()} + : null + } + + private toggle() { + this.props.toggleCollapsed() + } + + private renderExpander() { + if (this.props.edgeCount === 0) { + return null + } + + return this.props.collapsed + ? this.toggle()}>▶ + : this.toggle()}>▼ + } + + private renderCollapsedSubnodes() { + if (this.props.edgeCount === 0 || !this.props.collapsed) { + return null + } + + const messages = this.props.treeNode.leafes().map(leaf => leaf.messages).reduce((a, b) => a + b) + return ({this.props.treeNode.leafes().length} nodes, {messages} messages) + } +} + +export default withTheme()(TreeNodeTitle) diff --git a/app/src/components/TreeNode.tsx b/app/src/components/TreeNode.tsx deleted file mode 100644 index a8f59f1..0000000 --- a/app/src/components/TreeNode.tsx +++ /dev/null @@ -1,242 +0,0 @@ -import * as React from 'react' -import * as q from '../../../backend/src/Model' -import { List, ListItem, Collapse, Typography } from '@material-ui/core' -import { withTheme, Theme } from '@material-ui/core/styles' -const throttle = require('lodash.throttle') -import Slide from '@material-ui/core/Slide' -import { isElementInViewport } from './helper/isElementInViewport' -const collapseLimit = 3 -declare var performance: any - -export interface TreeNodeProps { - isRoot?: boolean - treeNode: q.TreeNode - name?: string | undefined - collapsed?: boolean | undefined - performanceCallback?: ((ms: number) => void) | undefined - didSelectNode?: (node: q.TreeNode) => void - theme: Theme -} - -interface TreeNodeState { - title: string | undefined - collapsed: boolean - collapsedOverride: boolean | undefined - edgeCount: number -} - -class TreeNode extends React.Component { - private dirty: boolean = true - private willUpdateTime: number = performance.now() - private titleRef = React.createRef() - private markAsDirty = () => { - this.dirty = true - if (!this.props.isRoot) { - this.indicateUpdate() - } - } - - private indicateUpdate = throttle(() => { - const title: any = this.titleRef.current - if (title && isElementInViewport(title)) { - title.style.animation = 'example 0.5s' - setTimeout(() => { - title.style.animation = '' - }, 500) - } - }, 500) - - constructor(props: TreeNodeProps) { - super(props) - const edgeCount = Object.keys(props.treeNode.edges).length - const collapsed = edgeCount > collapseLimit - this.state = { collapsed, edgeCount, collapsedOverride: props.collapsed, title: props.name } - } - - public componentDidMount() { - this.props.treeNode.on('update', this.markAsDirty) - } - - public componentWillUnmount() { - this.props.treeNode.removeListener('update', this.markAsDirty) - } - - private getStyles() { - const { theme } = this.props - return { - collapsedSubnodes: { - color: theme.palette.text.secondary, - }, - container: { - display: 'block', - }, - } - } - - public setState(state: any) { - this.dirty = this.state.collapsed !== state.collapsed - || this.state.collapsedOverride !== state.collapsedOverride - || this.state.edgeCount !== state.edgeCount - - super.setState(state) - } - - public shouldComponentUpdate() { - return this.dirty - } - - public componentDidUpdate() { - if (this.props.performanceCallback) { - const renderTime = performance.now() - this.willUpdateTime - this.props.performanceCallback(renderTime) - } - } - - public componentWillUpdate() { - if (this.props.performanceCallback) { - this.willUpdateTime = performance.now() - } - } - - private collapsed() { - if (this.state.collapsedOverride !== undefined) { - return this.state.collapsedOverride - } - - return this.state.collapsed - } - - private renderNodes() { - const edges = Object.values(this.props.treeNode.edges) - const listItemStyle = { - padding: '3px 8px 0px 8px', - } - - const listStyle = { - padding: '3px 8px 0px 16px', - } - - if (edges.length > 0) { - const listItems = edges - .map(edge => edge.target) - .map(node => ( - - - - )) - - return - {listItems} - - } - } - - private renderSourceEdge() { - const style: React.CSSProperties = { - fontWeight: 'bold', - overflow: 'hidden', - display: 'inline-block', - } - const name = this.state.title || (this.props.treeNode.sourceEdge && this.props.treeNode.sourceEdge.name) - - return { - this.toggle() - this.props.didSelectNode && this.props.didSelectNode(this.props.treeNode) - }>{name} - } - - public componentWillReceiveProps() { - const edgeCount = Object.keys(this.props.treeNode.edges).length - this.setState({ edgeCount, collapsed: edgeCount > collapseLimit }) - } - - private renderValue() { - const style: React.CSSProperties = { - width: '15em', - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', - padding: '0', - paddingLeft: '5px', - display: 'inline-block', - } - return this.props.treeNode.message - ? this.props.didSelectNode && this.props.didSelectNode(this.props.treeNode)} - > = {this.props.treeNode.message.value.toString()} - : null - } - - private clear() { - return
- } - - private renderTitleLine() { - const style: React.CSSProperties = { - lineHeight: '1em', - whiteSpace: 'nowrap', - width: '15em', - } - return
- - {this.renderExpander()} {this.renderSourceEdge()} {this.renderCollapsedSubnodes()} {this.renderValue()} - -
- } - - public render() { - this.dirty = false - - const nodeStyle: React.CSSProperties = { - display: 'block', - } - - return
- { this.renderTitleLine() } -
- { this.clear() } -
- { this.collapsed() ? null : this.renderNodes() } -
-
-
- } - - private toggle() { - this.setState({ collapsedOverride: !this.collapsed() }) - } - - private renderExpander() { - if (this.state.edgeCount === 0) { - return null - } - - return this.collapsed() - ? this.toggle()}>▶ - : this.toggle()}>▼ - } - - private renderCollapsedSubnodes() { - if (this.state.edgeCount === 0 || !this.collapsed()) { - return null - } - - const messages = this.props.treeNode.leafes().map(leaf => leaf.messages).reduce((a, b) => a + b) - return ({this.props.treeNode.leafes().length} nodes, {messages} messages) - } - - private subnodesStyle(): React.CSSProperties { - return { - display: 'block', - } - } -} - -export default withTheme()(TreeNode) diff --git a/backend/src/Model/TreeNode.ts b/backend/src/Model/TreeNode.ts index 48a5d6c..29d7872 100644 --- a/backend/src/Model/TreeNode.ts +++ b/backend/src/Model/TreeNode.ts @@ -1,6 +1,12 @@ import { Edge, Message } from './' import { EventEmitter } from 'events' +export enum TreeNodeUpdateEvents { + edges = 'edges', + message = 'message', + merge = 'merge', +} + export class TreeNode extends EventEmitter { public sourceEdge?: Edge public message?: Message @@ -19,6 +25,10 @@ export class TreeNode extends EventEmitter { this.setMessage(message) } + private propagateUpdate(event: TreeNodeUpdateEvents) { + this.emit(event) + } + public setMessage(value: any) { this.message = value this.messages += 1 @@ -43,10 +53,13 @@ export class TreeNode extends EventEmitter { return this.sourceEdge ? this.sourceEdge.source || undefined : undefined } - public addEdge(edge: Edge) { + public addEdge(edge: Edge, emitUpdate: boolean = false) { this.edges[edge.name] = edge edge.source = this - this.emit('update') + + if (emitUpdate) { + this.propagateUpdate(TreeNodeUpdateEvents.edges) + } } public branch(): TreeNode[] { @@ -61,10 +74,11 @@ export class TreeNode extends EventEmitter { public updateWithNode(node: TreeNode) { if (node.message) { this.setMessage(node.message) + this.propagateUpdate(TreeNodeUpdateEvents.message) } this.mergeEdges(node) - this.emit('update') + this.propagateUpdate(TreeNodeUpdateEvents.merge) } public leafes(): TreeNode[] { @@ -79,13 +93,20 @@ export class TreeNode extends EventEmitter { private mergeEdges(node: TreeNode) { const edgeKeys = Object.keys(node.edges) + let edgesDidUpdate = false + for (const edgeKey of edgeKeys) { const matchingEdge = this.edges[edgeKey] if (matchingEdge) { matchingEdge.target.updateWithNode(node.edges[edgeKey].target) } else { - this.addEdge(node.edges[edgeKey]) + this.addEdge(node.edges[edgeKey], false) + edgesDidUpdate = true } } + + if (edgesDidUpdate) { + this.propagateUpdate(TreeNodeUpdateEvents.edges) + } } } diff --git a/backend/src/Model/index.ts b/backend/src/Model/index.ts index 106de21..0b6a0d3 100644 --- a/backend/src/Model/index.ts +++ b/backend/src/Model/index.ts @@ -1,5 +1,5 @@ export { Edge } from './Edge' -export { TreeNode } from './TreeNode' +export { TreeNode, TreeNodeUpdateEvents } from './TreeNode' export { Message } from './Message' export { TreeNodeFactory } from './TreeNodeFactory' export { Tree } from './Tree' diff --git a/backend/src/index.ts b/backend/src/index.ts index 6561da6..c3fded2 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -4,7 +4,7 @@ const http = require('http') import { TopicProperties, Tree, TreeNodeFactory } from './Model' import { MqttSource, DataSource } from './DataSource' -const options = { url: 'mqtt://test.mosquitto.org' } +const options = { url: 'mqtt://nodered' } const dataSource = new MqttSource() const a: any[] = []