diff --git a/public/light-flat.css b/public/light-flat.css index c96628efa69d7a8561fd5ecbe72034d0c8483898..a94a76e701acac20729f91122d0152589285d0b5 100755 --- a/public/light-flat.css +++ b/public/light-flat.css @@ -67,6 +67,23 @@ html,body border:1px solid #ddd; } +#overlay { + background: rgb(156, 156, 151); + height: 100%; + width: 100%; + opacity: .9; + top: 0; + left: 0; + position: absolute; + padding: 0; + transition: opacity .5s; +} + +#overlay:hover { + opacity: .9; + transition: opacity .5s; +} + .flexlayout__layout { left: 0; top: 0; diff --git a/src/Board.js b/src/Board.js index a2f34f1508b6ab78998a7884d8e8be029e06a2f0..6ce96b201a8341c2c616a9bbffc1c3c8b698116f 100755 --- a/src/Board.js +++ b/src/Board.js @@ -1,8 +1,8 @@ import React from "react"; +import {toJS} from 'mobx' +import {observer} from 'mobx-react' // Tab-based dynamic layout manager -import FlexLayout, {Actions} from "flexlayout-react"; -// MobX, a state manager -import {decorate, observable} from "mobx" +import FlexLayout from "flexlayout-react"; // The React Components that are going into tabs import {VideoStream, DemoVideoStream} from "./VideoStream" import { RosLineChart, DemoLineChart } from "./LineChart" @@ -15,25 +15,16 @@ import DemoString from './String' // get the default configuration for the layout import {flexlayout_json as json} from './demo-1-config' +// import {flexlayout_json as json} from './demo-tabbed-config' // import {flexlayout_json as json} from './devel-config' -// CSS common to the whole app -import './Common.css' - -class ObservableStore { - components = {} -} -decorate(ObservableStore, { - components: observable -}) - +const Board = observer( class Board extends React.Component { constructor(props) { super(props) this.state = {model: FlexLayout.Model.fromJson(json)} this.refLayout = React.createRef(); - this.componentStore = new ObservableStore(); } /** Instanciates the right React Component to be added in a tab @@ -70,21 +61,15 @@ class Board extends React.Component { if (component in tab_types){ return tab_types[component]; } else { - return <div>The component type {component} is not declared. - The mistake is either in the configuration (type of a tab) or - in the source file <pre>index.js</pre> (see <pre>tab_types</pre>). - </div>; + return invalidComponent(component); } - - } var component = node.getComponent(); return React.createElement(getComponentType(component), { node: node, - updateConfig: this.updateConfig.bind(this, node.getId()), ros: this.props.ros, - store: this.componentStore + store: this.props.store }, null); } @@ -96,20 +81,18 @@ class Board extends React.Component { }, null); } - updateConfig(nodeId, config) { - this.state.model.doAction(Actions.updateNodeAttributes(nodeId, { - config: config - })); - } - /** - * Add a settings button to the tabsets whose currently activated tab is configurable. + * Add settings and readme buttons to the tabsets whose currently activated + * tab is configurable and has a readme. * - * One tab is declared as configurable through its 'configurable' config setting in the model. - * The button will toggle the value of the field 'settingsMode' in the configuration of the currently - * activated tab in the tabsed. + * One tab is configurable when it has a 'configSchema' (JSON Schema + * description of its configuration fields) in its MobX store (accessed at + * this.props.store.components['ComponentNameHere']). The readme capability + * is detected with the presence of a 'readme' field, which is a JSX object + * to be displayed as README. * - * have a look at `App.tsx` in the source code of FlexLayout to understand when this function is called. + * have a look at `App.tsx` in the source code of FlexLayout to understand + * when this function is called. * * @param {TabSetNode|BorderNode} node * @param {any} renderValues @@ -119,70 +102,89 @@ class Board extends React.Component { // The node could be either a tab set or a border if (node.getType() === "tabset") { /** - * Switch in and out of a display mode (for a widget in the dashboard). This function returns a fuction - * that does the real switching for a given mode. + * Switch in and out of a modal. This function returns a fuction + * that does the real switching for a given modal. * - * This mode is handled through a configuration object handled by FlexLayout. - * @param {String} modeName name of the mode to be switched + * There currently, 2019/07/02, are two modals : readme and settings. + * The former is showing information about a gadget and the latter + * displays a form to set the configuration of the related gadget. + * @param {String} modeName name of the mode to be switched ("readme" + * or "settings") * @return function */ function modeToggle(modeName) { + const selectedTab = node.getSelectedNode() + return function() { - // `this` is bound to the button's tabset - const tab = this.getSelectedNode(); // retrieve the selected tab - const config = tab.getConfig(); // get the configuration of this tab - if (!("displayMode" in config) || (config.displayMode !== modeName)) { - if (!this.isMaximized()) { // Maximize the tabset - this._model.doAction(Actions.maximizeToggle(this.getId())); - } - config.displayMode = modeName; - this._model.doAction( - FlexLayout.Actions.updateNodeAttributes(tab.getId(), {config: config})); + // If no modal is active, display the relevant one; + // otherwise, get out of it. + if (!this.props.store.modal.enabled) { + this.props.store.displayModal(modeName, selectedTab.getComponent()); } else { - if (this.isMaximized()) { // Return the tabset to its original size - this._model.doAction(Actions.maximizeToggle(this.getId())); - } - delete config.displayMode; - this._model.doAction( - FlexLayout.Actions.updateNodeAttributes(tab.getId(), {config: config})); + this.props.store.exitModal(); } } - } + }; + + // Add the readme and settings buttons to the tabset bar, if applicable - const selected = node.getSelectedNode() + const selectedTab = node.getSelectedNode() // did we actually get a node? - if (typeof selected !== 'undefined') { - // is the active (selected) tab declared as configurable? - const config = selected.getConfig() - if (config && ('configurable' in config) && config['configurable'] === true) { - renderValues.buttons.push( - <img src="images/baseline-settings-20px.svg" - alt="settings" - key="settings" - onClick={modeToggle("settings").bind(node)}/> - ) - } - // is the active (selected) tab declared as having a readme? - if (config && ('hasReadme' in config) && config['hasReadme'] === true) { - renderValues.buttons.push( - <img src="images/readme-grey.svg" - alt="readme" - key="readme" - onClick={modeToggle("readme").bind(node)}/> - ) + if (typeof selectedTab !== 'undefined') { + const components = this.props.store.components; + // Is there a store for this gadget? + if (selectedTab.getComponent() in components) { + const component = components[selectedTab.getComponent()]; + // is the active (selected) tab configurable? + if ('configSchema' in component) { + renderValues.buttons.push( + <img src="images/baseline-settings-20px.svg" + alt="settings-test" + key="settings-test" + onClick={modeToggle("settings").bind(this)}/> + ); + } + // does the active (selected) tab have a readme? + if ('readme' in component) { + renderValues.buttons.push( + <img src="images/readme-grey.svg" + alt="readme" + key="readme" + onClick={modeToggle("readme").bind(this)}/> + ); + } } } } } render() { + // This line is there only to consume a mobX variable and trigger re-render of FlexLayout on changes in components. + // We need it so that we can have the "readme" and "settings" buttons displayed in the tabSet bars. + // It produces no other side-effect and the return value of this function call is ignored... + toJS(this.props.store.components); + return ( <FlexLayout.Layout ref={this.refLayout} model={this.state.model} factory={this.factory.bind(this)} - onRenderTabSet={this.RenderTabsetHandle}/> + onRenderTabSet={this.RenderTabsetHandle.bind(this)}/> ); } +}) + +/** + * This function creates a placeholder React component in the case where the user + * asks for a gadget that does not exist. + * @param {String} component name of the required component + */ +function invalidComponent(component) { + return function(props) { + return <div>The component type "{component}"" is not declared. + The mistake is either in the configuration (type of a tab) or + in the source file <tt>src/Board.js</tt> (search for <tt>tab_types</tt>). + </div>; + } } export {Board} diff --git a/src/HorizontalGauge/RosHGauge.js b/src/HorizontalGauge/RosHGauge.js index 3cdb3a899264aa998492aa6ccfa440ceef468616..7341eb30f552ca3e390ec6776541ba16bbb7e63f 100644 --- a/src/HorizontalGauge/RosHGauge.js +++ b/src/HorizontalGauge/RosHGauge.js @@ -1,47 +1,70 @@ import React, {Component} from 'react' +import {decorate, observable, computed, autorun} from 'mobx' +import {observer} from 'mobx-react' import RosLib from 'roslib' import {get} from 'lodash/object' +import {defaultConfig} from '../utils/configurationHelpers' import HorizontalGauge from './index' +class HGaugeStore { + readme = <p className="about">This gauge represents a <b>measure</b> ranging from 0 to 1.</p>; + configSchema = { + title: "Horizontal Gauge", + type: "object", + required: ["topic", "field", "min", "max"], + properties: { + topic: {type: "string", title: "ROS topic (absolute name)", + default: "/predictions/interaction_involvement"}, + field: {type: "string", title: "the field to be read from the message", + default: "prediction_of_head_orientation.list[0].values[1]"}, + min: {type: "number", title: "Lower end of the data range", default: 0}, + max: {type: "number", title: "Upper end of the data range", default: 1}, + } + }; +} + +const RosHGauge = observer( class RosHGauge extends Component { constructor(props) { super(props) - - this.state = {datum: null} + this.datum = null; + + this.props.store.components['h-gauge'] = new HGaugeStore(); + defaultConfig(this.props.store.components['h-gauge']); } - static get modes() { - return { - readme: <p className="about">This gauge represents a <b>measure</b> ranging from 0 to 1.</p>, - settingsSchema: { - title: "Horizontal Gauge", - type: "object", - required: ["topic", "field", "min", "max"], - properties: { - topic: {type: "string", title: "ROS topic (absolute name)", - default: "/predictions/interaction_involvement"}, - field: {type: "string", title: "the field to be read from the message", - default: "prediction_of_head_orientation.list[0].values[1]"}, - min: {type: "number", title: "Lower end of the data range", default: 0}, - max: {type: "number", title: "Upper end of the data range", default: 1}, - } - }, - }; + get topic() { + return this.props.store.components['h-gauge'].config.topic; + } + get field() { + return this.props.store.components['h-gauge'].config.field; + } + get min() { + return this.props.store.components['h-gauge'].config.min; + } + get max() { + return this.props.store.components['h-gauge'].config.max; } componentDidMount() { if ('ros' in this.props && this.props.ros) { - const config = this.props.node.getConfig(); - this.listener = new RosLib.Topic({ - ros : this.props.ros, - name: config.topic, - }); - - this.listener.subscribe(function(message) { - this.setState({datum: get(message, config.field)}); - }.bind(this)); - }else { + this.topicAutorun = autorun( reaction => { + if ('listener' in this) { + this.listener.unsubscribe(); + } + this.listener = new RosLib.Topic({ + ros : this.props.ros, + name: this.topic, + }); + + // callback on the topic, that takes stores the value of a field + // (defined in configuration) in 'this.datum'. + this.listener.subscribe(function(message) { + this.datum = get(message, this.field); + }.bind(this)); + }) + } else { console.warn('RosHGauge expects to be passed a valid Ros object as property, got ', this.props.ros); } } @@ -53,14 +76,19 @@ class RosHGauge extends Component { } render() { - const config = this.props.node.getConfig(); - return <HorizontalGauge - value={this.state.datum} - min={config.min} - max={config.max} + value={this.datum} + min={this.min} + max={this.max} {...this.props}/>; } -} +}) +decorate(RosHGauge, { + datum: observable, + topic: computed, + field: computed, + min: computed, + max: computed, +}) export default RosHGauge \ No newline at end of file diff --git a/src/HorizontalGauge/index.js b/src/HorizontalGauge/index.js index aca98b163416f442df91e95771b29bc68f9750f3..7959d5c24bbd2b652bffe0f3949fc952148b64b6 100644 --- a/src/HorizontalGauge/index.js +++ b/src/HorizontalGauge/index.js @@ -1,11 +1,6 @@ -import React from 'react' import HorizontalGauge from './HozirontalGauge' import RosHGauge from './RosHGauge' -import {Modal} from '../utils/modeHandler' export default HorizontalGauge; -function modal(props){ - return <Modal component={RosHGauge} {...props}/>; -} -export {modal as RosHGauge}; \ No newline at end of file +export {RosHGauge}; \ No newline at end of file diff --git a/src/InteractionTrace/InteractionTrace.js b/src/InteractionTrace/InteractionTrace.js index 689c0fdc9ca48de25262d520591c214f11d93d23..021e3df75175ec887fca6efec0b0831eabac283b 100644 --- a/src/InteractionTrace/InteractionTrace.js +++ b/src/InteractionTrace/InteractionTrace.js @@ -1,8 +1,9 @@ import React, {Component} from 'react' -import {decorate, observable} from 'mobx' +import {decorate, observable, computed} from 'mobx' import {observer} from 'mobx-react' import * as d3 from 'd3' +import {defaultConfig} from '../utils/configurationHelpers' import LinearLayout from '../utils/LinearLayout' import { extent } from '../utils/extent'; import { getBoredom, getIValence, getEValence } from './accessors'; @@ -13,10 +14,50 @@ import { Backgrounds, Iteration, Actions, Mood, class InteractionTraceStore { currentInteraction = null; showInteraction = false; + config = {}; + configSchema = { + title: "Interaction trace", + type: "object", + properties: { + maxBoredom: { + title: "Maximal expected value for the boredom (used for scaling)", + type: "integer", + default: 2, + }, + nbPrimitiveActions: { + title: "How many primitive actions are displayed", + type: "integer", + default: 3, + }, + widthInEm: { + title: "The width of each column in em units", + type: "number", + default: 2.5, + }, + debug: { + title: "Debug mode", + type: "boolean", + default: false + } + } + }; + readme = ( + <div className="about"> + <h1>Trace of the reasoning process performed by the agent</h1> + <p>The <b>mood</b> represents the effect of the <b>valence</b> on the mood of the agent.</p> + <h2>Boredom</h2> + <p> + Observe the evolution of the <b>interactions</b> performed by the agent. <b>Intended</b> interactions are the + interactions the agent plans to perform whereas <b>enacted</b> interactions are the interactions actually + performed in the physical world of the agent. An interaction is composed of an <b>action</b> and a + <b>valence</b>, the valence being dependent of the response from the environment. + </p> + </div>); } decorate(InteractionTraceStore, { currentInteraction: observable, showInteraction: observable, + config: observable, }) const InteractionTrace = observer( @@ -27,10 +68,11 @@ class InteractionTrace extends Component { this.interactionStore = new InteractionTraceStore(); this.props.store.components['int-trace'] = this.interactionStore; + // Fill the undefined fields of the configuration object with their default values. + defaultConfig(this.interactionStore); - // Use the margin convention practice + // Use the margin convention practice of D3 this.margin = {top: 20, right: 30, bottom: 20, left: 20}; - // this.margin = {top: 0, right: 0, bottom: 0, left: 0}; // Blanck space between each element of the figure this.spacing = {v: 20, h: 15}; @@ -54,38 +96,37 @@ class InteractionTrace extends Component { .ratioCell(0.5) // Row for the valences (takes a ratio of the avalable space) .fixedCell(75); // Row for the actions (temporary value, updated later in function of this.em) - const config = this.props.node.getConfig(); - // Value for the boredom at which we plot a red line (showing max value) - this.maxBoredom = config.maxBoredom; - // How many primitive actions (at least) we want to display - this.nbPrimitiveActions = config.nbPrimitiveActions; - - // Will store the conversion from 1em in px for the LabelColumn. - // This is a tricky trick to set the size of the LabelColumn to match with the longest chain of actions that - // it will have to show. + // Will store the conversion from 1em in px. We have to assume that this computation is approximative. this.em = undefined; - - // In debug mode, we add bounding boxes for the different areas of this figure - this.debug = config.debug; - - // Other parameter: how many em the minimal column width has to be (function minXWidth). } - // The smallest allowed space around each point on the x axis. - minXWidth() { - return 2.5*this.em; + /** In debug mode, we add bounding boxes for the different areas of this figure */ + get debug() { + return this.interactionStore.config.debug; + } + /** A red line, showing the maximum accepted boredom level, will be displayed using this value */ + get maxBoredom() { + return this.interactionStore.config.maxBoredom; + } + /** How many primitive actions (at least) we want to display */ + get nbPrimitiveActions() { + return this.interactionStore.config.nbPrimitiveActions; + } + /** The smallest allowed space around each point on the x axis. */ + get minXWidth() { + return this.interactionStore.config.widthInEm * this.em; } /** Get the font size of a node in the DOM. It tells us how big one em is in px. * - * This value is used to adjust the width of the LabelColumn based on the width of its content. + * This value is used to adjust the width of the columns in the trace, based on the size of their content (which has text). * * @param {DOMnode} node the DOM node from which we retrieve information */ getEmSize(node) { - if (node) { - this.em = parseFloat(getComputedStyle(node).fontSize); - } + if (node) { + this.em = parseFloat(getComputedStyle(node).fontSize); + } } /** @@ -96,7 +137,7 @@ class InteractionTrace extends Component { * the second is a function that will return true iff the datum we give it belongs to the domain. */ filter(data) { - const maxNumberOfInteractions = Math.floor(this.xLayout.width(2) / this.minXWidth()) || 0; + const maxNumberOfInteractions = Math.floor(this.xLayout.width(2) / this.minXWidth) || 0; const highestId = d3.max(data, (datum => datum.id)) || 0; const xDomain = [highestId-maxNumberOfInteractions, highestId]; const domainFilter = (datum) => (datum.id >= xDomain[0] && datum.id <= xDomain[1]); @@ -343,6 +384,12 @@ class InteractionTrace extends Component { } } ) +decorate(InteractionTrace, { + debug: computed, + maxBoredom: computed, + nbPrimitiveActions: computed, + em: observable, +}); export default InteractionTrace; class MakeSVGComponents extends Component { diff --git a/src/InteractionTrace/RosInteractionTrace.js b/src/InteractionTrace/RosInteractionTrace.js index 511477e4899d89b5b1d7259ca4ad591d5321aa78..d957505659fa9c12259de40cce9f9663a1c24e4b 100644 --- a/src/InteractionTrace/RosInteractionTrace.js +++ b/src/InteractionTrace/RosInteractionTrace.js @@ -1,102 +1,102 @@ import React, {Component} from 'react' -import {Modal} from '../utils/modeHandler' -import {observable, decorate} from 'mobx' +import {observable, decorate, computed, autorun} from 'mobx' import {observer} from 'mobx-react' import sizeMe from 'react-sizeme' import RosLib from 'roslib' +import {appendConfigSchema} from '../utils/configurationHelpers' import InteractionTrace from './InteractionTrace' const RosInteractionTrace = observer( class RosInteractionTrace extends Component { + newSchema = { + required: ["trace", "boredom", "mood"], + properties: { + trace: { + title: "Topic for the trace", + type: "string", + default: "/algorithm/trace" + }, + boredom: { + title: "Topic for the boredom", + type: "string", + default: "/algorithm/boredom" + }, + mood: { + title: "Topic for the mood", + type: "string", + default: "/algorithm/mood" + }, + lowerIdResets: { + title: "Reset the display when a new interaction arrives with an ID lower than the latest one?", + type: "boolean", + default: true, + } + } + }; + constructor(props) { super(props) this.data = []; } - static get modes() { - return { - readme: ( - <div className="about"> - <h1>Trace of the reasoning process performed by the agent</h1> - <p>The <b>mood</b> represents the effect of the <b>valence</b> on the mood of the agent.</p> - <h2>Boredom</h2> - <p> - Observe the evolution of the <b>interactions</b> performed by the agent. <b>Intended</b> interactions are the - interactions the agent plans to perform whereas <b>enacted</b> interactions are the interactions actually - performed in the physical world of the agent. An interaction is composed of an <b>action</b> and a - <b>valence</b>, the valence being dependent of the response from the environment. - </p> - </div>), - settingsSchema: { - title: "Interaction trace", - type: "object", - required: ["trace", "boredom", "mood"], - properties: { - trace: { - title: "Topic for the trace", - type: "string", - default: "/algorithm/trace" - }, - boredom: { - title: "Topic for the boredom", - type: "string", - default: "/algorithm/boredom" - }, - mood: { - title: "Topic for the mood", - type: "string", - default: "/algorithm/mood" - }, - lowerIdResets: { - title: "Reset the display when a new interaction arrives with an ID lower than the latest one?", - type: "boolean", - default: true, - }, - maxBoredom: { - title: "Maximal expected value for the boredom (used for scaling)", - type: "integer", - default: 2, - }, - nbPrimitiveActions: { - title: "How many primitive actions are displayed", - type: "integer", - default: 3, - }, - debug: { - title: "Debug mode", - type: "boolean", - default: false - } - } - } - }; + get traceTopic() { + return this.props.store.components['int-trace'].config.trace; + } + + get boredomTopic() { + return this.props.store.components['int-trace'].config.boredom; + } + + get moodTopic() { + return this.props.store.components['int-trace'].config.mood; + } + + get lowerIdResets() { + return this.props.store.components['int-trace'].config.lowerIdResets; } componentDidMount() { + // Append the configuration schema of this component to the one of InteractionTrace + // Doing so, the configuration form will have all fields for both components. + appendConfigSchema(this.props.store.components['int-trace'], this.newSchema); + if ('ros' in this.props && this.props.ros) { - const config = this.props.node.getConfig(); - this.trace_listener = new RosLib.Topic({ - ros : this.props.ros, - name: config.trace, - messageType: "april_messages/trace" - }); - this.trace_listener.subscribe(this.interaction_to_data.bind(this)); + // With this MobX autorun, if the topics are changed in the configuration, + // we will automatically unsubscribe from the old ones and register to the + // new topic names. + this.topicAutorun = autorun( reaction => { + if ('trace_listener' in this) { + this.trace_listener.unsubscribe(); + } + this.trace_listener = new RosLib.Topic({ + ros : this.props.ros, + name: this.traceTopic, + messageType: "april_messages/trace" + }); + this.trace_listener.subscribe(this.interaction_to_data.bind(this)); - this.boredom_listener = new RosLib.Topic({ - ros : this.props.ros, - name: config.boredom, - messageType: "april_messages/boredom" - }); - this.boredom_listener.subscribe(this.boredom_to_data.bind(this)); + if ('boredom_listener' in this) { + this.boredom_listener.unsubscribe(); + } + this.boredom_listener = new RosLib.Topic({ + ros : this.props.ros, + name: this.boredomTopic, + messageType: "april_messages/boredom" + }); + this.boredom_listener.subscribe(this.boredom_to_data.bind(this)); - this.mood_listener = new RosLib.Topic({ - ros : this.props.ros, - name: config.mood, - messageType: "april_messages/mood" + if ('mood_listener' in this) { + this.mood_listener.unsubscribe(); + } + this.mood_listener = new RosLib.Topic({ + ros : this.props.ros, + name: this.moodTopic, + messageType: "april_messages/mood" + }); + this.mood_listener.subscribe(this.mood_to_data.bind(this)); }); - this.mood_listener.subscribe(this.mood_to_data.bind(this)); } else { console.warn('RosInteractionTrace expects to be passed a valid Ros object as property, got ', this.props.ros); } @@ -156,8 +156,7 @@ class RosInteractionTrace extends Component { */ mergeDataInState(newDatum) { // Empty the data and get the first interaction of an experiment - const config = this.props.node.getConfig(); - if (config.lowerIdResets && this.data.length > 0 && newDatum.id < this.data[this.data.length-1].id) { + if (this.lowerIdResets && this.data.length > 0 && newDatum.id < this.data[this.data.length-1].id) { this.data = []; } @@ -173,11 +172,10 @@ class RosInteractionTrace extends Component { ) decorate(RosInteractionTrace, { data: observable, - // lowerIdResets: observable, + traceTopic: computed, + boredomTopic: computed, + moodTopic: computed, + lowerIdResets: computed, }) -const ModalRosInteractionTrace = observer(function(props) { - return <Modal component={RosInteractionTrace} {...props}/> -}); - -export default sizeMe({monitorHeight: true})(ModalRosInteractionTrace); \ No newline at end of file +export default sizeMe({monitorHeight: true})(RosInteractionTrace); \ No newline at end of file diff --git a/src/LatestInteraction/RosLastestInteraction.js b/src/LatestInteraction/RosLastestInteraction.js index 2899cc9ece45e1c9fdd73c168dff61b7364f731d..49031be36e3f24f7d3a1603db2f6ec13ef78834c 100644 --- a/src/LatestInteraction/RosLastestInteraction.js +++ b/src/LatestInteraction/RosLastestInteraction.js @@ -1,64 +1,89 @@ import React, {Component} from 'react' import RosLib from 'roslib' +import {autorun, decorate, observable} from 'mobx' +import {observer} from 'mobx-react' -import {Modal} from '../utils/modeHandler' +import {defaultConfig} from '../utils/configurationHelpers' import {LatestInteraction} from './LatestInteraction' +class LatestInteractionStore { + readme = ( + <div className="about"> + <p> + <b>Last action</b> done by the agent and its <b>valence</b>. + </p> + <p> + If you place your mouse pointer over an <b>action</b> in the interaction trace, this widget will display + the <b>full interaction</b>. + </p> + </div>); + config = {}; + configSchema = { + title: "Interaction trace", + type: "object", + required: ["trace"], + properties: { + trace: { + title: "Topic for the trace", + type: "string", + default: "/algorithm/trace" + }, + } + }; +} +decorate(LatestInteractionStore, { + config: observable, +}) + +const RosLatestInteraction = observer( class RosLatestInteraction extends Component { constructor(props) { - super(props) + super(props); - this.state = {intended: { - action_names: [], - primitive_valences: [] - }, enacted: { - action_names: [], - primitive_valences: [] - }}; - this.trace = { - topic: "/algorithm/trace", - type: "april_messages/trace" - } - } + this.props.store.components['latest-interaction'] = new LatestInteractionStore(); + // Fill the undefined fields of the configuration object with their default values. + defaultConfig(this.props.store.components['latest-interaction']); - static get modes() { - return { - readme: ( - <div className="about"> - <p> - <b>Last action</b> done by the agent and its <b>valence</b>. - </p> - <p> - If you place your mouse pointer over an <b>action</b> in the interaction trace, this widget will display - the <b>full interaction</b>. - </p> - </div>), - settingsSchema: { - title: "Interaction trace", - type: "object", - required: ["trace"], - properties: { - trace: { - title: "Topic for the trace", - type: "string", - default: "/algorithm/trace" - }, - } + this.latestInteraction = { + intended: { + action_names: [], + primitive_valences: [] + }, + enacted: { + action_names: [], + primitive_valences: [] } }; + this.topicType = "april_messages/trace"; + } + + get topicName() { + return this.props.store.components['latest-interaction'].config.trace; } componentDidMount() { if ('ros' in this.props && this.props.ros) { - this.trace_listener = new RosLib.Topic({ - ros : this.props.ros, - name: this.trace.topic, - messageType: this.trace.type - }); - this.trace_listener.subscribe(message => { - this.setState(message); + // This will be executed now and anytime later, if the topic name or type + // is modified + this.topicAutorun = autorun(reaction => { + // Unsubscribe if there was a subscription + if ('trace_listener' in this) { + this.trace_listener.unsubscribe(); + } + + // Create a listener + this.trace_listener = new RosLib.Topic({ + ros : this.props.ros, + name: this.topicName, + messageType: this.topicType + }); + + // Subscribe (and declare a callback) + this.trace_listener.subscribe(message => { + this.latestInteraction = message; + }); }); - }else { + } else { console.warn('RosMood expects to be passed a valid Ros object as property, got ', this.props.ros); } } @@ -70,12 +95,13 @@ class RosLatestInteraction extends Component { } render() { - return <LatestInteraction intended={this.state.intended} enacted={this.state.enacted} {...this.props}/> + return <LatestInteraction intended={this.latestInteraction.intended} + enacted={this.latestInteraction.enacted} + {...this.props}/> } -} - -const ModalLatestInteraction = function(props) { - return <Modal component={RosLatestInteraction} {...props}/> -}; +}) +decorate(RosLatestInteraction, { + latestInteraction: observable, +}); -export { ModalLatestInteraction as RosLatestInteraction }; +export {RosLatestInteraction }; diff --git a/src/LineChart/RosLineChart.js b/src/LineChart/RosLineChart.js index e45e8e23ea7c5ec863306eafa5f45d87ad302bcf..e90c012bbedf1129e7ddaa542850465313d612d6 100644 --- a/src/LineChart/RosLineChart.js +++ b/src/LineChart/RosLineChart.js @@ -1,123 +1,133 @@ import React, {Component} from 'react'; import property from 'lodash/property'; +import {autorun, decorate, observable, action} from 'mobx' +import {observer} from 'mobx-react' import RosLib from 'roslib'; -import {Modal} from '../utils/modeHandler' +import {defaultConfig} from '../utils/configurationHelpers' import {SizedLineChart} from './LineChart' +class LineChartStore { + configSchema = { + title: "Generic plot", + type: "object", + required: ["curves", "maxPoints"], + properties: { + curves: { + title: "Plotted lines", + type: "array", + minItems: 1, + maxItems: 10, + default: [{ + topic: "/predictions/interaction_involvement", + field: "prediction_of_head_orientation.list[0].values[1]" + }], + items: { + type: "object", + properties: { + topic: { + title: "topic", + type: "string", + default: "/std_msgs/int64" + }, + field: { + title: "field", + type: "string", + default: "data[0]" + } + } + }, + }, + maxPoints: { + title: "How many data points are plotted", + type: "integer", + minimum: 0, + maximum: 200, + default: 50, + }, + xLabel: { + title: "Label of abscissa", + type: "string", + }, + yLabel: { + title: "Label of ordinate", + type: "string", + default: "Involvement", + }, + } + }; + config = {}; + readme = ( + <div className="about"> + <p> + This linechart represents a measure evolving over time. It is possible to choose + any viable ROS topic to be plotted. + </p> + <p> + For readibility's sake, we chose to limit the number of curves to ten on a single plot. Hopefully + you would never reach such an unreasonable number. + </p> + </div> + ); +} +decorate(LineChartStore, { + config: observable, +}) + +const RosLineChart = observer( class RosLineChart extends Component { constructor(props) { super(props); + this.store = new LineChartStore(); + this.props.store.components['line-chart'] = this.store; + // Fill the undefined fields of the configuration object with their default values. + defaultConfig(this.store); + this.listeners = []; - this.state = { - data: {"" : []} - }; + this.data = observable.map(); } - static get modes() { - return { - readme: ( - <div className="about"> - <p> - This linechart represents a measure evolving over time. It is possible to choose - any viable ROS topic to be plotted. - </p> - <p> - For readibility's sake, we chose to limit the number of curves to ten on a single plot. Hopefully - you would never reach such an unreasonable number. - </p> - </div> - ), - settingsSchema: { - title: "Generic plot", - type: "object", - required: ["curves", "maxPoints"], - properties: { - curves: { - title: "Plotted lines", - type: "array", - minItems: 1, - maxItems: 10, - default: [{ - topic: "/predictions/interaction_involvement", - field: "prediction_of_head_orientation.list[0].values[1]" - }], - items: { - type: "object", - properties: { - topic: { - title: "topic", - type: "string", - default: "/std_msgs/int64" - }, - field: { - title: "field", - type: "string", - default: "data[0]" - } - } - }, - }, - maxPoints: { - title: "How many data points are plotted", - type: "integer", - minimum: 0, - maximum: 200, - default: 50, - }, - xLabel: { - title: "Label of abscissa", - type: "string", - }, - yLabel: { - title: "Label of ordinate", - type: "string", - default: "Involvement", - }, - } - } - }; + /** + * Extract a field from a ros message and add it to the state, for plotting. + * + * If there are already maxPoints in storage, we remove the oldest data point. + * @param {Object} curve has two entries, `field`, path to the field to be extracted from incoming messages + * and `topic` the ROS topic from which we extract the field. + * @param {integer} maxPoints mupper limit on the number of data entries for the plot + * @param {Object} message messages from a ROS topic + */ + process_message(curve, maxPoints, message) { + const field = curve.field; + const data_field = curve.topic+field; + if (!this.data.has(data_field)) { + this.data.set(data_field, []); + } + if (this.data.get(data_field).length >= maxPoints) { + this.data.get(data_field).shift(); + } + this.data.set(data_field, this.data.get(data_field).concat([{"y": property(field)(message)}])); } componentDidMount() { if ('ros' in this.props && this.props.ros) { - /** - * Extract a field from a ros message and add it to the state, for plotting. - * - * If there are already maxPoints in storage, we remove the oldest data point. - * @param {Object} curve has two entries, `field`, path to the field to be extracted from incoming messages - * and `topic` the ROS topic from which we extract the field. - * @param {integer} maxPoints mupper limit on the number of data entries for the plot - * @param {Object} message messages from a ROS topic - */ - function process_message(curve, maxPoints, message) { - this.setState((state) => { - let new_data = this.state.data; - const field = curve.field; - const data_field = curve.topic+field; - if (!(data_field in new_data)) { - new_data[data_field] = [] - } - if (new_data[data_field].length >= maxPoints) { - new_data[data_field].shift(); - } - new_data[data_field] = this.state.data[data_field].concat([{"y": property(field)(message)}]) - return { - data: new_data, - } - }); - } - - // Create a ROS topic listener for each curve (topic + field) in the configuration - const config = this.props.node.getConfig(); - if ('curves' in config) { - config.curves.forEach((curve) => { - const listener = new RosLib.Topic({ros : this.props.ros, name: curve.topic}); - listener.subscribe(process_message.bind(this, curve, config.maxPoints)); - this.listeners.push(listener); - }); - } + this.topicAutorun = autorun(reaction => { + if ('listeners' in this) { + this.listeners.forEach((listener) => {listener.unsubscribe();}); + } + // Create a ROS topic listener for each curve (topic + field) in the configuration + if ('curves' in this.store.config) { + this.store.config.curves.forEach((curve) => { + const listener = new RosLib.Topic({ + ros : this.props.ros, + name: curve.topic}); + listener.subscribe( + this.process_message.bind(this, curve, + this.store.config.maxPoints)); + this.listeners.push(listener); + }); + } + }); } else { console.warn('RosLineChart expects to be passed a valid Ros object as property, got ', this.props.ros); } @@ -130,13 +140,15 @@ class RosLineChart extends Component { } render () { - const config = this.props.node.getConfig() - return <SizedLineChart data={this.state.data} xLabel={config.xLabel} yLabel={config.yLabel} {...this.props}/> + return <SizedLineChart data={Object.fromEntries(this.data)} + xLabel={this.store.config.xLabel} + yLabel={this.store.config.yLabel} + {...this.props}/> } -} - -const ModalLineChart = function(props) { - return <Modal component={RosLineChart} {...props}/> -}; +}) +decorate(RosLineChart, { + data: observable, + process_message: action, +}) -export {ModalLineChart as RosLineChart} +export {RosLineChart} diff --git a/src/MemoryRanked/MemoryRanked.js b/src/MemoryRanked/MemoryRanked.js index f724953589796f4b1f01b5f15430a29658704d0c..f39c5222760e49b96d548ce7f593c150110845b2 100644 --- a/src/MemoryRanked/MemoryRanked.js +++ b/src/MemoryRanked/MemoryRanked.js @@ -1,18 +1,101 @@ import React, {Component} from 'react'; -import sizeMe from 'react-sizeme'; +import {decorate, observable, computed} from 'mobx' +import {observer} from 'mobx-react' import {property} from 'lodash' +import {defaultConfig} from '../utils/configurationHelpers' import './MemoryRanked.css'; import LinearLayout from '../utils/LinearLayout' import { Scatter } from './Scatter'; import { LabelColumn } from './LabelColumn'; import { NumberColumn } from './NumberColumn'; +class MemoryStore { + readme = ( + <div> + <h1>Display the sequences of the interactions learnt by the agent</h1> + <p> + Each time the agent performs a <b>sequence of interactions</b> in the environment, it records the + corresponding <b>valence</b> and stores it in the memory. Each record is called an <b>occurence</b> + of the sequence. + </p> + </div>); + configSchema = { + title: "Robot's memory", + type: "object", + required: ["guideLinesStep", "maxActionLength", "sorting"], + properties: { + guideLinesStep: { + title: "How many entries between two horizontal rules", + type: "integer", + minimum: 2, + default: 3, + }, + maxActionLength: { + title: "The highest number of actions displayed in one memory entry", + type: "integer", + minimum: 2, + default: 10, + }, + sorting: { + title: "How to sort the entries of the memory", + type: "object", + required: ["value", "order"], + default: { + value: "valence", + order: "descending" + }, + properties: { + value: { + title: "Value used for sorting", + type: "string", + default: "valence", + anyOf: [ + { + title: "valence", + type: "string", + enum: ["valence"] + }, + { + title: "action length", + type: "string", + enum: ["action_names.length"] + }, + { + title: "number of occurences", + type: "string", + enum: ["occurence"] + }, + ], + }, + order: { + title: "Order of sorting", + type: "string", + default: "descending", + enum: [ + "ascending", + "descending" + ] + } + } + } + } + }; + config = {}; +} +decorate(MemoryStore, { + config: observable +}) + +const MemoryRanked = observer( class MemoryRanked extends Component { constructor(props) { super(props); - const config = this.props.node.getConfig(); + this.store = new MemoryStore(); + this.props.store.components['memory'] = this.store; + // Fill the undefined fields of the configuration object with their default values. + defaultConfig(this.store); // Use the margin convention practice this.margin = {top: 10, right: 20, bottom: 20, left: 20} @@ -28,12 +111,20 @@ class MemoryRanked extends Component { // The increment in Y coordinate between two entries in the memory this.vStep = 30; - // How frequent the horizontal guide lines should be put - // If set to 4, one guide line will be displayed every four line of data - this.guideLinesStep = config.guideLinesStep; + // // How frequent the horizontal guide lines should be put + // // If set to 4, one guide line will be displayed every four line of data + // this.guideLinesStep = config.guideLinesStep; - // The highest number of actions displayed in one memory entry. - this.maxActionLength = config.maxActionLength; + // // The highest number of actions displayed in one memory entry. + // this.maxActionLength = config.maxActionLength; + } + + get guideLinesStep() { + return this.store.config.guideLinesStep; + } + + get maxActionLength() { + return this.store.maxActionLength; } /** Get the font size of a node in the DOM. It tells us how big one em is in px. @@ -80,11 +171,11 @@ class MemoryRanked extends Component { let sorter; // Make a sorting function based on the configuration or, by default, sort // by descending valence. - const config = this.props.node.getConfig(); - if ('sorting' in config) { - const getter = property(config.sorting.value); + if ('sorting' in this.store.config) { + const getter = property(this.store.config.sorting.value); + const order = this.store.config.sorting.order; sorter = function(a, b) { - if(config.sorting.order === "ascending") { + if(order === "ascending") { return getter(a) - getter(b); } else { return getter(b) - getter(a); @@ -148,14 +239,13 @@ class MemoryRanked extends Component { ]; - const config = this.props.node.getConfig() - if (config && 'displayMode' in config) { - if (config.displayMode === "readme") { + if (this.store.config && 'displayMode' in this.store.config) { + if (this.store.config.displayMode === "readme") { return <div className="about"> <h1>Display the sequences of the interactions learnt by the agent</h1> <p>Each time the agent performs a <b>sequence of interactions</b> in the environment, it records the corresponding <b>valence</b> and stores it in the memory. Each record is called an <b>occurence</b> of the sequence.</p> </div>; - } else if (config.displayMode === "settings") { + } else if (this.store.config.displayMode === "settings") { return <div> <p>config panel</p> </div>; @@ -225,6 +315,10 @@ class MemoryRanked extends Component { </g> ); } -} +}) +decorate(MemoryRanked, { + guideLinesStep: computed, + maxActionLength: computed, +}) -export default sizeMe({monitorHeight: true})(MemoryRanked); \ No newline at end of file +export default MemoryRanked; \ No newline at end of file diff --git a/src/MemoryRanked/RosMemoryRanked.js b/src/MemoryRanked/RosMemoryRanked.js index ee418cea51cb81d1a57e8e34881648771354a4b8..1fa39d09e15d8144820a8a42ba89033b2fe4414b 100644 --- a/src/MemoryRanked/RosMemoryRanked.js +++ b/src/MemoryRanked/RosMemoryRanked.js @@ -1,126 +1,78 @@ import React, {Component} from 'react'; +import {observable, decorate, computed, toJS, autorun} from 'mobx' +import {observer} from 'mobx-react' +import sizeMe from 'react-sizeme'; import RosLib from 'roslib'; -import {Modal} from '../utils/modeHandler' +import {appendConfigSchema} from '../utils/configurationHelpers' import MemoryRanked from './MemoryRanked' +const RosMemoryRanked = sizeMe({monitorHeight: true})(observer( class RosMemoryRanked extends Component { constructor(props) { super(props); - this.state = {data: []}; + this.data = observable.array(); } - static get modes() { - return { - readme: ( - <div> - <h1>Display the sequences of the interactions learnt by the agent</h1> - <p> - Each time the agent performs a <b>sequence of interactions</b> in the environment, it records the - corresponding <b>valence</b> and stores it in the memory. Each record is called an <b>occurence</b> - of the sequence. - </p> - </div>), - settingsSchema: { - title: "Robot's memory", - type: "object", - required: ["guideLinesStep", "maxActionLength", "topic", "sorting"], - properties: { - topic: { - title: "Topic for the memory", - type: "string", - default: "/algorithm/memory" - }, - guideLinesStep: { - title: "How many entries between two horizontal rules", - type: "integer", - minimum: 2, - default: 3, - }, - maxActionLength: { - title: "The highest number of actions displayed in one memory entry", - type: "integer", - minimum: 2, - default: 10, - }, - sorting: { - title: "How to sort the entries of the memory", - type: "object", - required: ["value", "order"], - default: { - value: "valence", - order: "descending" - }, - properties: { - value: { - title: "Value used for sorting", - type: "string", - default: "valence", - anyOf: [ - { - title: "valence", - type: "string", - enum: ["valence"] - }, - { - title: "action length", - type: "string", - enum: ["action_names.length"] - }, - { - title: "number of occurences", - type: "string", - enum: ["occurence"] - }, - ], - }, - order: { - title: "Order of sorting", - type: "string", - default: "descending", - enum: [ - "ascending", - "descending" - ] - } - } - }, - } - } + newSchema = { + title: "Robot's memory", + type: "object", + required: ["topic"], + properties: { + topic: { + title: "Topic for the memory", + type: "string", + default: "/algorithm/memory" + }, } + }; + + get topic() { + return this.props.store.components['memory'].config.topic; } componentDidMount() { + // Append the configuration schema of this component to the one of MemoryRanked + // Doing so, the configuration form will have all fields for both components. + appendConfigSchema(this.props.store.components['memory'], this.newSchema); + if ('ros' in this.props && this.props.ros) { - const config = this.props.node.getConfig(); - const topicConfig = { - ros : this.props.ros, - name: config.topic, - messageType: "april_messages/memory", - }; - this.listener = new RosLib.Topic(topicConfig); - this.listener.subscribe(this.memory_to_data.bind(this)); + // With this MobX autorun, if the topics are changed in the configuration, + // we will automatically unsubscribe from the old ones and register to the + // new topic names. + this.topicAutorun = autorun( reaction => { + if ('listener' in this) { + this.listener.unsubscribe(); + } + const topicConfig = { + ros : this.props.ros, + name: this.topic, + messageType: "april_messages/memory", + }; + this.listener = new RosLib.Topic(topicConfig); + this.listener.subscribe(message => { + this.data.replace(message.interactions); + }); + }); } else { console.warn('RosInteractionTrace expects to be passed a valid Ros object as property, got ', this.props.ros); } } componentWillUnmount() { - this.listener.unsubscribe(); - } - - memory_to_data(message) { - this.setState({data: message.interactions}); + if ('listener' in this) { + this.listener.unsubscribe(); + } } render () { - return <MemoryRanked data={this.state.data} {...this.props}/> + return <MemoryRanked data={toJS(this.data)} {...this.props}/> } -} - -function ModalRosMemoryRanked(props) { - return <Modal component={RosMemoryRanked} {...props}/> -} +})) +decorate(RosMemoryRanked, { + data: observable, + topic: computed, +}) -export {ModalRosMemoryRanked as RosMemoryRanked} +export {RosMemoryRanked} diff --git a/src/Mood/RosMood.js b/src/Mood/RosMood.js index ffaab93f72aab9038e1283580878d9e7731605de..8df2fa4a1e4d66e04cdda47b6be82078d8268de7 100644 --- a/src/Mood/RosMood.js +++ b/src/Mood/RosMood.js @@ -1,47 +1,65 @@ import React, {Component} from 'react' +import {decorate, observable, autorun} from 'mobx' +import {observer} from 'mobx-react' import RosLib from 'roslib' +import sizeMe from 'react-sizeme' -import { Modal } from '../utils/modeHandler' -import { SizedMood } from './Mood' +import {defaultConfig} from '../utils/configurationHelpers' +import { Mood } from './Mood' +class MoodStore { + readme = ( + <p className="about"> + The <b> agent's current mood</b> regarding the valences of its actions. + </p>); + configSchema = { + title: "Robot's mood", + type: "object", + required: ["topic"], + properties: { + topic: { + title: "Topic used", + type: "string", + default: "/algorithm/mood" + } + } + }; + config = {}; +} +decorate(MoodStore, { + config: observable, +}) + +const RosMood = sizeMe({monitorHeight: true})(observer( class RosMood extends Component { constructor(props) { super(props) - this.state = {mood: "PLEASED"}; - } + this.store = new MoodStore(); + this.props.store.components['mood'] = this.store; + // Fill the undefined fields of the configuration object with their default values. + defaultConfig(this.store); - static get modes() { - return { - readme: ( - <p className="about"> - The <b> agent's current mood</b> regarding the valences of its actions. - </p>), - settingsSchema: { - title: "Robot's mood", - type: "object", - required: ["topic"], - properties: { - topic: { - title: "Topic used", - type: "string", - default: "/algorithm/mood" - } - } - } - } + this.mood = observable.box("PLEASED"); } componentDidMount() { if ('ros' in this.props && this.props.ros) { - const config = this.props.node.getConfig(); - this.mood_listener = new RosLib.Topic({ - ros : this.props.ros, - name: config.topic, - messageType: "april_messages/mood" - }); - this.mood_listener.subscribe(message => { - this.setState(message); + // With this MobX autorun, if the topics are changed in the configuration, + // we will automatically unsubscribe from the old ones and register to the + // new topic names. + this.topicAutorun = autorun( reaction => { + if ('mood_listener' in this) { + this.mood_listener.unsubscribe(); + } + this.mood_listener = new RosLib.Topic({ + ros : this.props.ros, + name: this.store.config.topic, + messageType: "april_messages/mood" + }); + this.mood_listener.subscribe(message => { + this.mood.set(message.mood); + }); }); } else { console.warn('RosMood expects to be passed a valid Ros object as property, got ', this.props.ros); @@ -55,12 +73,11 @@ class RosMood extends Component { } render() { - return <SizedMood mood={this.state.mood} {...this.props}/> + return <Mood mood={this.mood.get()} {...this.props}/> } -} +})) -function ModalRosMood(props) { - return <Modal component={RosMood} {...props}/>; -} +const SizedRosMood = sizeMe({monitorHeight: true})(RosMood); +export {SizedRosMood}; -export { ModalRosMood as RosMood }; +export {RosMood }; diff --git a/src/VideoStream/index.js b/src/VideoStream/index.js index 4f5609af7c09555128de610c9dda0deec3430324..92900a7ae55875a45c5705835ff004191f3110a8 100644 --- a/src/VideoStream/index.js +++ b/src/VideoStream/index.js @@ -1,53 +1,66 @@ import React, {Component} from 'react' -import {Modal} from '../utils/modeHandler' -import {has} from 'lodash/object' +import {decorate, observable, computed} from 'mobx' +import {observer} from 'mobx-react' + +import {defaultConfig} from '../utils/configurationHelpers' import './VideoStream.css' +class VideoStore { + readme = ( + <div className="about"> + <h1>See what the agent is seeing</h1> + <p><b>Video stream</b> coming from the robot or the device.</p> + </div> + ); + configSchema = { + title: "Video streamer", + type: "object", + required: ["host", "topic"], + properties: { + topic: { + type: "string", + title: "ROS topic (absolute name)", + default: "/pepper/image_raw" + }, + host: { + type: "string", + title: "Host (IP or name)", + default:"localhost:8081" + }, + } + }; + config = {}; +} +decorate(VideoStore, { + config: observable, +}) + +const VideoStream = observer( class VideoStream extends Component { constructor(props) { super(props); - this.defaultImage = 'images/default.svg'; + this.store = new VideoStore(); + // Fill the undefined fields of the configuration object with their default values. + defaultConfig(this.store); + this.props.store.components['video'] = this.store; } - /** - * Method that returns the display modes and desired settings properties (in JSON Schema for the settings). - * These are used by Modal, from modeHandler, to switch from this component to a configuration view or a readme - * view. - */ - static get modes() { - return { - readme: ( - <div className="about"> - <h1>See what the agent is seeing</h1> - <p><b>Video stream</b> coming from the robot or the device.</p> - </div> - ), - settingsSchema: { - title: "Video streamer", - type: "object", - required: ["host", "topic"], - properties: { - topic: {type: "string", title: "ROS topic (absolute name)", default: "/pepper/image_raw"}, - host: {type: "string", title: "Host (IP or name)", default:"localhost:8081"}, - } - } - }; - } + defaultImage = 'images/default.svg'; /** - * Return the url of the video stream to display, based on the current configuration. - * If there is no stream configured, gives the URL passed as a property to this object, or the one defined in - * this.defaultImage. + * Return the url of the video stream to display, based on the current + * configuration. + * If there is no stream configured, gives the URL passed as a property to + * this object, or the one defined in this.defaultImage. */ - getStreamUrl() { - const config = this.props.node.getConfig(); - const config_fields = ['topic', 'host']; + get streamUrl() { if ('url' in this.props && this.props.url) { return this.props.url; - } else if (config_fields.reduce((accumulator, field) => accumulator && has(config, field), true)) { + } else if ('topic' in this.store.config && 'host' in this.store.config) { // all requierd configuration fields are defined - return 'http://' + config.host + '/stream?topic=' + config.topic; + return 'http://' + this.store.config.host + '/stream?topic=' + + this.store.config.topic; } else { return this.defaultImage; } @@ -56,17 +69,17 @@ class VideoStream extends Component { render() { return ( <div className="video-stream container"> - <img src={this.getStreamUrl()} alt="there should have been a video stream here"/> + <img src={this.streamUrl} alt="there should have been a video stream + here"/> </div>); } -} +}) +decorate(VideoStream, { + streamUrl: computed, +}) function DemoVideoStream(props) { return <VideoStream url="images/table-right.jpg" {...props}/>; } -function ModalVideoStream(props){ - return <Modal component={VideoStream} {...props}/>; -} - -export {ModalVideoStream as VideoStream, DemoVideoStream}; +export {VideoStream, DemoVideoStream}; diff --git a/src/demo-tabbed-config.js b/src/demo-tabbed-config.js new file mode 100644 index 0000000000000000000000000000000000000000..12cbf926332fe27b4504613cb5052ea5c3d4f07c --- /dev/null +++ b/src/demo-tabbed-config.js @@ -0,0 +1,94 @@ +export const flexlayout_json = { + global: { + splitterSize: 5, + tabSetTabStripHeight: 30, + tabSetHeaderHeight: 25, + enableEdgeDock: false, + tabEnableRename: false, + tabSetEnableHeader: false, + // tabSetEnableTabStrip: false, + }, + layout: { + "type": "row", + "weight": 100, + "children": [ + { + "type": "tabset", + "weight": 50, + "selected": 0, + "children": [ + { + "type": "tab", + "name": "Interaction trace", + "component": "int-trace", + }, + ] + }, + { + "type": "row", + "weight": 50, + "children": [ + { + "type": "tabset", + "weight": 60, + "selected": 0, + "children": [ + { + "type": "tab", + "name": "Robot's memory", + "component": "memory", + }, + ] + }, + { + "type": "row", + "weight": 40, + "children": [ + { + "type": "row", + "weight": 25, + "children": [ + { + "type": "tabset", + "weight": 78, + "selected": 0, + "children": [ + { + "type": "tab", + "name": "Latest interaction", + "component": "latest-interaction", + }, + ] + }, + { + "type": "tabset", + "weight": 22, + "height": 70, + "children": [ + { + "type": "tab", + "name": "Involvement", + "component": "h-gauge", + }, + ] + }, + ] + }, + { + "type": "tabset", + "weight": 75, + "children": [ + { + "type": "tab", + "name": "Robot video stream", + "component": "video", + } + ] + } + ] + } + ] + } + ] + } +}; diff --git a/src/index.js b/src/index.js index 161069eb862e0ccea3097481e6da90cdce38f232..966bb929f9f431d5d3c34146d0abdac715f16aea 100644 --- a/src/index.js +++ b/src/index.js @@ -1,16 +1,48 @@ import React from "react"; import ReactDOM from "react-dom"; +// MobX, a state manager +import {decorate, observable} from "mobx" +import {observer} from 'mobx-react' // ROS library import RosLib from 'roslib'; // React Components to be displayed import {Board} from './Board'; -import { Header } from "./Header"; +import {Header} from './Header'; +import {Modal} from './utils/modeModal'; import 'bootstrap/dist/css/bootstrap.min.css' +// CSS common to the whole app +import './Common.css' +class ObservableStore { + components = {} + modal = { + enabled: false, + mode: null, + component: null, + } + displayModal(mode, component) { + this.modal.enabled = true; + this.modal.mode = mode; + this.modal.component = component; + } + exitModal() { + this.modal.enabled = false; + this.modal.mode = null; + this.modal.component = null; + } +} +decorate(ObservableStore, { + components: observable, + modal: observable, +}) + +const Main = observer( class Main extends React.Component { constructor(props) { super(props); + + this.store = new ObservableStore(); this.ros = undefined; if ('props' in this && 'ros' in this.props && this.props.ros) { @@ -40,17 +72,26 @@ class Main extends React.Component { } render() { + let modal = null; + if (this.store.modal.enabled) { + modal = ( + <div id="overlay" tabIndex="-1" role="dialog"> + <Modal store={this.store}/> + </div>); + } + return ( <div id="outer"> <div id="header"> <Header ros={this.ros}/> </div> <div id="board"> - <Board ros={this.ros}/> + <Board ros={this.ros} store={this.store}/> </div> + {modal} </div> ) } -} +}) ReactDOM.render(<Main ros={true}/>, document.getElementById("container")); \ No newline at end of file diff --git a/src/utils/configurationHelpers.js b/src/utils/configurationHelpers.js new file mode 100644 index 0000000000000000000000000000000000000000..0ae533d4a50957b4b0c365ad74ee8064a170d8e3 --- /dev/null +++ b/src/utils/configurationHelpers.js @@ -0,0 +1,67 @@ +import { merge } from 'lodash/object'; +import { extendObservable } from 'mobx'; + +/** + * Fill the configuration with default values taken from a JSON Schema. + * + * Take a MobX store (i.e. an observable Object), get the 'configSchema' attribute and merge the default values from + * this schema into the 'config' attribute of our store. The merge is done so that values already in 'config' are not + * verwritten, but missing ones are filled. + * 'configSchema' is following the JSON Schema standard, as used for https://react-jsonschema-form.readthedocs.io/en/latest/ + * @param {Object} store An observable (per MobX) object containing a 'configSchema' property + */ +export function defaultConfig(store) { + // get the current config; if it does not exist yet, create it + if (!('config' in store)) { + extendObservable(store, {config: {}}); + } + let configuration = store.config; + // for each configuration field that is not yet defined, use the default value (from configSchema) + if ('configSchema' in store) { + merge(configuration, defaultConfigFromSchema(configuration, store.configSchema)); + store.config = configuration; + } +} + +/** + * Merge an object with the default values from a JSON Schema. + * + * Search for properties in a JSON Schema. If they are here, use the default value of each JsonSchema property for + * the configuration. This means that if a property is already defined in the configuration, it will not be + * altered. + * @param {Object} config current configuration + * @param {Object} configSchema JSON Schema for the configuration + * @return the new configuration object (possibly unmodified) + */ +export function defaultConfigFromSchema(config, configSchema) { + if (!('properties' in configSchema)) { + console.warn("no properties in the schema ", configSchema); + return config; + } + else { + for (let [key, value] of Object.entries(configSchema.properties)) { + if (!(key in config)) { + // let message = "The key '" + key + "' is missing in the configuration."; + if ('default' in value) { + config[key] = value.default; + // message += " Using default value '" + value.default + "'."; + } + // console.debug(message); + } + } + return config; + } +} + +/** Append a JSON-Schema description of configuration fields to an already existing one. + * This practically allows to add new configuration fields for a component. + * + * @param {Object} store MobX observable in which the configuration and configuration JSON-Schema are stored + * @param {Object} configSchema JSON-Schema formated object describing fields of the configuration for a React component + */ +export function appendConfigSchema(store, configSchema) { + // Append the configuration schema of this component to the one currently in the store + merge(store.configSchema, configSchema); + // Fill the undefined fields of the configuration object with their default values. + defaultConfig(store); +} \ No newline at end of file diff --git a/src/utils/modeHandler.js b/src/utils/modeHandler.js deleted file mode 100644 index 3451f9b4b563e9ede46caa0dd31c2f22a805e6e7..0000000000000000000000000000000000000000 --- a/src/utils/modeHandler.js +++ /dev/null @@ -1,129 +0,0 @@ -import React, {Component} from 'react'; -import Form from 'react-jsonschema-form'; -import { merge } from 'lodash/object'; - -/** - * This class will look for the `modes` attribute if the class passed as the `component` property. This object is used - * to add buttons to show a readme about the component or a settings form. - * - * All changes to the node's settings are saved through the updateConfig function (passed as props). - * - * Expected React properties : - * - component: class for the component to be instanciated - * - updateConfig & node: as provided by the `Board` component to all its children - */ -export class Modal extends Component { - constructor(props) { - super(props); - - this.modes = ('modes' in this.props.component) ? this.props.component.modes : {}; - this.configGetter = this.props.node.getConfig.bind(this.props.node); - this.configSetter = this.props.updateConfig; - - // get the current config; if it does not exist yet, create it - let config = this.configGetter() || {}; - - merge(config, this.announceCapabilities(config)); - if (config.configurable) { - merge(config, this.defaultConfigFromSchema(config)); - } - this.configSetter(config); - } - - /** - * Search for the 'settingsSchema' and 'readme' properties of the managed component. A corresponding field in the - * configuration is set accordingly. This is used to display or not the UI buttons to show the Readme or the - * configuration of a component. - * @param {Object} config current configuration of the object - * @return the new configuration object (possibly unmodified) - */ - announceCapabilities(config) { - if (this.modes.settingsSchema) { - config.configurable = true; - } - else { - config.configurable = false; - } - if (this.modes.readme) { - config.hasReadme = true; - } - else { - config.hasReadme = false; - } - return config; - } - - /** - * Search for properties in a JSON Schema. If they are here, use the default value of each JsonSchema property for - * the configuration. This means that if a property is already defined in the configuration, it will not be - * altered. - * @param {Object} config current configuration of the object - * @return the new configuration object (possibly unmodified) - */ - defaultConfigFromSchema(config) { - const schema = this.modes.settingsSchema; - if (!schema) { - return config; - } - else if (!('properties' in schema)) { - console.warn("no properties in the schema"); - return config; - } - else { - for (let [key, value] of Object.entries(schema.properties)) { - if (!(key in config)) { - // let message = "The key '" + key + "' is missing in the configuration."; - if ('default' in value) { - config[key] = value.default; - // message += " Using default value '" + value.default + "'."; - } - // console.debug(message); - } - } - return config; - } - } - - /** - * Render either the value of the normal parameter, in normal mode, or the content for the `readme` and `settings` - * modes. The display mode is defined by the 'displayMode' attribute of the component's configuration. - */ - render() { - const config = this.configGetter(); - if (config && 'displayMode' in config) { - if (config.configurable && config.displayMode === "settings") { - return [ - <Form - key="form" - className = "container" - schema={this.modes.settingsSchema} - onSubmit={this.handleSettingsChange.bind(this)} - formData={config} - liveValidate={true} />, - <br key="line-break"/>, - <p className="container" key="required-field"> - <span clasName="required">*</span> : required field - </p>]; - } - if (config.hasReadme && config.displayMode === "readme") { - return this.modes.readme; - } - } - return React.createElement(this.props.component, this.props, null); - } - - /** - * Given a new configuration object (from the form), update the configuration of the component (in FlexLayout). - * - * The update is done through Lodash's merge function. - * @param {Object} value update for the configuration of the component (we take the field formData) - * @return Also return the updated configuration. - */ - handleSettingsChange({ formData }) { - let config = this.configGetter(); - merge(config, formData); - this.configSetter(config); - return config; - } - -} \ No newline at end of file diff --git a/src/utils/modeModal.js b/src/utils/modeModal.js new file mode 100644 index 0000000000000000000000000000000000000000..645dc52c40473048c737d2fa9a6606db53fa787b --- /dev/null +++ b/src/utils/modeModal.js @@ -0,0 +1,68 @@ +import React, {Component} from 'react'; +import Form from 'react-jsonschema-form'; +import { merge } from 'lodash/object'; + +/** + * This component displays either the readme information about a component or a form to change the settings of a + * component. + * + * All changes to the node's settings are saved through a mobX store (passed as props). + * + * Expected React properties : + * - node: as provided by the `Board` component to all its children + * - store: object containing information about all relevant React components, as well as information on the current + * mode. + */ +export class Modal extends Component { + constructor(props) { + super(props); + + this.component = this.props.store.components[this.props.store.modal.component]; + this.hasReadme = 'readme' in this.component; + this.configurable = 'configSchema' in this.component; + } + + /** + * Render either the content for the `readme` or `settings` modes. The display mode is defined by the 'mode' + * attribute of the modal's configuration. + */ + render() { + const mode = this.props.store.modal.mode; + if (this.configurable && mode === "settings") { + return [ + <Form + key="form" + className = "container" + schema={this.component['configSchema']} + onSubmit={this.handleSettingsChange.bind(this)} + formData={this.component.config} + liveValidate={true} />, + <br key="line-break"/>, + <p className="container" key="required-field"> + <span className="required">*</span> : required field + </p>]; + } + if (this.hasReadme && mode === "readme") { + return ( + <div onClick={() => {this.props.store.exitModal()}}> + {this.component['readme']} + </div>); + } + } + + /** + * Given a new configuration object (from the form), update the configuration of the component and exit the modal. + * + * The update is done through Lodash's merge function. + * @param {Object} value update for the configuration of the component (we take the field formData) + * @return Also return the updated configuration. + */ + handleSettingsChange({ formData }) { + let config = this.component.config; + merge(config, formData); + this.component.config = config; + this.props.store.exitModal(); + return config; + } + +} \ No newline at end of file