diff --git a/package-lock.json b/package-lock.json index 4ef85664bae9461ae553d8d592c18a6472171bcf..b525edb49670fdcd7b3d06a4129d93315ee17edf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2455,6 +2455,11 @@ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24=" }, + "bootstrap": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.3.1.tgz", + "integrity": "sha512-rXqOmH1VilAt2DyPzluTi2blhk17bO7ef+zLLPlWvG494pDxcM234pJ8wTc/6R40UWizAIIMgxjvxZg5kmsbag==" + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -8269,6 +8274,11 @@ "lodash._reinterpolate": "~3.0.0" } }, + "lodash.topath": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/lodash.topath/-/lodash.topath-4.5.2.tgz", + "integrity": "sha1-NhY1Hzu6YZlKCTGYlmC9AyVP0Ak=" + }, "lodash.unescape": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/lodash.unescape/-/lodash.unescape-4.0.1.tgz", @@ -10603,6 +10613,26 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.6.tgz", "integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==" }, + "react-jsonschema-form": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/react-jsonschema-form/-/react-jsonschema-form-1.6.1.tgz", + "integrity": "sha512-rDZjAMzI9GrG5EpBbqmhnch3jgFd9YN9U2bk8zc0PBldgGiENjo+ziIh4vseDNijPNU+07wclD5anIN23nmYxw==", + "requires": { + "ajv": "^6.7.0", + "babel-runtime": "^6.26.0", + "core-js": "^2.5.7", + "lodash.topath": "^4.5.2", + "prop-types": "^15.5.8", + "react-is": "^16.8.4" + }, + "dependencies": { + "core-js": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.9.tgz", + "integrity": "sha512-HOpZf6eXmnl7la+cUdMnLvUxKNqLUzJvgIziQ0DiF3JwSImNphIqdGqzj6hIKyX04MmV0poclQ7+wjWvxQyR2A==" + } + } + }, "react-lifecycles-compat": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", diff --git a/package.json b/package.json index 5705e7db426c823a238e8d23b7ea637063ef1cfa..078fd8712c694c8243012fc6dc4451f558d43107 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "@trendmicro/react-sidenav": "^0.4.5", "@types/d3-array": "^1.2.6", "@types/roslib": "^0.18.3", + "bootstrap": "^4.3.1", "d3": "^5.8.0", "d3-scale": "^2.2.2", "flexlayout-react": "^0.3.3", @@ -16,6 +17,7 @@ "prop-types": "^15.7.2", "react": "^16.8.6", "react-dom": "^16.7.0", + "react-jsonschema-form": "^1.5.0", "react-numeric-input": "^2.2.3", "react-scripts": "^3.0.1", "react-sizeme": "^2.5.2", diff --git a/public/images/default.svg b/public/images/default.svg new file mode 100644 index 0000000000000000000000000000000000000000..52a3198bc4dce97d28a38dcca658b80794513534 --- /dev/null +++ b/public/images/default.svg @@ -0,0 +1 @@ +<svg version="1.1" viewBox="0.0 0.0 944.8818897637796 718.1102362204724" fill="none" stroke="none" stroke-linecap="square" stroke-miterlimit="10" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"><clipPath id="p.0"><path d="m0 0l944.8819 0l0 718.1102l-944.8819 0l0 -718.1102z" clip-rule="nonzero"/></clipPath><g clip-path="url(#p.0)"><path fill="#000000" fill-opacity="0.0" d="m0 0l944.8819 0l0 718.1102l-944.8819 0z" fill-rule="evenodd"/><path fill="#efefef" d="m0 0l944.8819 0l0 718.1102l-944.8819 0z" fill-rule="evenodd"/><path fill="#cccccc" d="m290.1798 354.03406l0 0c0 -92.37506 71.41586 -167.25983 159.51181 -167.25983l0 0c42.305176 0 82.877594 17.621964 112.7919 48.989273c29.914246 31.367294 46.71991 73.91051 46.71991 118.27055l0 0c0 92.37506 -71.41589 167.25983 -159.51181 167.25983l0 0c-88.09595 0 -159.51181 -74.884766 -159.51181 -167.25983z" fill-rule="evenodd"/><path fill="#d9d9d9" d="m180.32224 75.26723l269.38583 0l0 278.77167l-269.38583 0z" fill-rule="evenodd"/><path fill="#999999" d="m616.34216 513.66l123.93677 -99.38309l123.93677 99.38309l-47.33966 160.80524l-153.19421 0z" fill-rule="evenodd"/></g></svg> \ No newline at end of file diff --git a/public/light-flat.css b/public/light-flat.css index 7bd3b7e5b83a3abfc90dc0d93681a9c2ef2d26c9..a42b7d940cc63a848c85e849523ea057932df727 100755 --- a/public/light-flat.css +++ b/public/light-flat.css @@ -77,7 +77,22 @@ 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; diff --git a/src/Board.js b/src/Board.js index 6003ca6e0d1207efdf9995efd4276ff4eb15d847..ab9e166a716261fc2a1baa421ad111f798602ace 100755 --- a/src/Board.js +++ b/src/Board.js @@ -1,8 +1,9 @@ import React from 'react'; // Tab-based dynamic layout manager -import FlexLayout, {Actions} from 'flexlayout-react'; +import FlexLayout from 'flexlayout-react'; // MobX, a state manager -import {decorate, observable} from 'mobx' +import {toJS} from 'mobx' +import {observer} from 'mobx-react' // The React Components that are going into tabs import {VideoStream, DemoVideoStream} from './VideoStream' import {RosLineChart, DemoLineChart} from './LineChart' @@ -16,25 +17,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 @@ -73,21 +65,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); } @@ -99,22 +85,18 @@ class Board extends React.Component { }, null); } - updateConfig(nodeId, config) { - this.state.model.doAction(Actions.updateNodeAttributes(nodeId, { - config: config - })); - console.log("updateConfig called with the following configuration:") - console.log(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 @@ -124,70 +106,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. - * - * This mode is handled through a configuration object handled by FlexLayout. - * @param {String} modeName name of the mode to be switched + * Switch in and out of a modal. This function returns a fuction + * that does the real switching for a given modal. + * + * 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)) { - 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 { - 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 - className= "tabset-button" - src="images/icons/settings.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 - className= "tabset-button" - src="images/icons/info-grey-outline.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/icons/settings.svg" + alt="settings" + key="settings" + onClick={modeToggle("settings").bind(this)}/> + ); + } + // does the active (selected) tab have a readme? + if ('readme' in component) { + renderValues.buttons.push( + <img src="images/icons/info-grey-outline.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/Common.css b/src/Common.css index 90353611ab4136d9a67b82f0af6a0f19dde6b317..517d051a856cfb874cdf1096b1c6c404b39eb209 100644 --- a/src/Common.css +++ b/src/Common.css @@ -29,6 +29,12 @@ h2 { margin: 10px 10px 10px 10px; } +/* For the forms generated by react-jsonschema-form */ +i.glyphicon { display: none; } +.btn-add::after { content: 'Add'; } +.array-item-move-up::after { content: 'Move Up'; } +.array-item-move-down::after { content: 'Move Down'; } +.array-item-remove::after { content: 'Remove'; } .tabset-button:hover { filter: invert(67%) sepia(71%) saturate(551%) hue-rotate(1deg) brightness(200%) contrast(86%); } diff --git a/src/HorizontalGauge/HozirontalGauge.js b/src/HorizontalGauge/HozirontalGauge.js index 65fca8a360dffc0c2335feba35fdf1f26109ff64..794ba06f1a4e1d4f8aa12d83a86455a58cbcafb6 100644 --- a/src/HorizontalGauge/HozirontalGauge.js +++ b/src/HorizontalGauge/HozirontalGauge.js @@ -7,44 +7,34 @@ class HorizontalGauge extends Component { render() { const value = ('value' in this.props && this.props.value) ? this.props.value : 0; - // Define the dimensions of the gaug - const margin = { left:10, top: 10, right: 10, bottom: 10} - , width = this.props.size.width - margin.left - margin.right - , height = 20; + // Define the dimensions of the gauge + const margin = { left:10, top: 10, right: 10, bottom: 10}, + width = this.props.size.width - margin.left - margin.right, + height = 20; const viewbox = "0 0 " + (width + margin.left + margin.right) +" "+ (height + margin.top + margin.bottom); + const min = this.props.min || 0, + max = this.props.max || 1; // Scale to map from values in [0, 1] to values in [0, width] (callable) const xScale = scaleLinear() - .domain([0, 1]) + .domain([min, max]) .range([0, width]) .clamp(true); const svg = <svg viewBox={viewbox} width={width + margin.left + margin.right} height={height + margin.top + margin.bottom}> - {/* translate to add some margin around the gauge */} - <g className="gauge" transform={"translate(" + margin.left + "," + margin.top + ")"}> - {/* Background of the gauge */} - <rect className="background" x="0" y="0" width={width} height={height} ry="5px"/> - {/* Representation of the value */} - <rect className="overlay" x="0" y="0" width={xScale(value)} height={height} ry="5px"/> - {/* Text of the gauge */} - <text className="label" x={width/2} y={height/2} textAnchor="middle" dominantBaseline="central"> - {"" + value.toFixed(2)} - </text> - </g> + {/* translate to add some margin around the gauge */} + <g className="gauge" transform={"translate(" + margin.left + "," + margin.top + ")"}> + {/* Background of the gauge */} + <rect className="background" x="0" y="0" width={width} height={height} ry="5px"/> + {/* Representation of the value */} + <rect className="overlay" x="0" y="0" width={xScale(value)} height={height} ry="5px"/> + {/* Text of the gauge */} + <text className="label" x={width/2} y={height/2} textAnchor="middle" dominantBaseline="central"> + {"" + value.toFixed(2)} + </text> + </g> </svg>; - const config = this.props.node.getConfig() - - if (config && 'displayMode' in config) { - if (config.displayMode === "readme") { - return <p className="about">This gauge represent a <b>measure</b> ranging from 0 to 1.</p>; - } else if (config.displayMode === "settings") { - return <div> - <p>config panel</p> - </div>; - } - } - return <div>{svg}</div>; } } diff --git a/src/HorizontalGauge/RosHGauge.js b/src/HorizontalGauge/RosHGauge.js index 874fd0de0638c3e13dd9ee96d25c16ed32c70886..a14070cce05c4f7e7c32f011828c4fffe2410cca 100644 --- a/src/HorizontalGauge/RosHGauge.js +++ b/src/HorizontalGauge/RosHGauge.js @@ -1,29 +1,70 @@ import React, {Component} from 'react' -import RosLib from 'roslib'; +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.topic = "/predictions/interaction_involvement" - this.type = "april_messages/head_orientation_prediction" + this.datum = null; + + this.props.store.components['h-gauge'] = new HGaugeStore(); + defaultConfig(this.props.store.components['h-gauge']); + } + + 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) { - this.listener = new RosLib.Topic({ - ros : this.props.ros, - name: this.topic, - messageType: this.type - }); - - this.listener.subscribe(function(message) { - this.setState({datum: message.prediction_of_head_orientation.list[0].values[1]}); - }.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); } } @@ -35,8 +76,19 @@ class RosHGauge extends Component { } render() { - return <HorizontalGauge value={this.state.datum} {...this.props}/> + return <HorizontalGauge + 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 +export default RosHGauge diff --git a/src/InteractionTrace/InteractionTrace.js b/src/InteractionTrace/InteractionTrace.js index 2d3a1bf9e73ad2ed86b36686a4748f38da7af250..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( @@ -26,11 +67,12 @@ class InteractionTrace extends Component { super(props); this.interactionStore = new InteractionTraceStore(); - this.props.store.components['interactionTrace'] = this.interactionStore; + 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,37 +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) - // Value for the boredom at which we plot a red line (showing max value) - this.maxBoredom = 2; - // How many primitive actions (at least) we want to display - this.nbPrimitiveActions = 3; - - // 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 = false; - - // 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); + } } /** @@ -95,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]); @@ -291,25 +333,6 @@ class InteractionTrace extends Component { const viewbox = "0 0 " + (width + this.margin.left + this.margin.right) +" "+ (height + this.margin.top + this.margin.bottom); - const config = this.props.node.getConfig() - - if (config && 'displayMode' in config) { - if (config.displayMode === "readme") { - return <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>; - } else if (config.displayMode === "settings") { - return <div> - <p>config panel</p> - </div>; - } - } - return ( <svg viewBox={viewbox} width="100%" height="99%" className="interaction-trace"> {/* Compute how many pixels an em takes */} @@ -361,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 9e7140d8e67639fb3e5e01439853b0c0bdcd3aad..fb0caea1cc806f5c47ef886e80271eb28a8a01cd 100644 --- a/src/InteractionTrace/RosInteractionTrace.js +++ b/src/InteractionTrace/RosInteractionTrace.js @@ -1,55 +1,102 @@ import React, {Component} from 'react' -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 = []; - this.lowerIdResets = true; - this.trace = { - topic: "/algorithm/trace", - type: "april_messages/trace" - } - this.boredom = { - topic: "/algorithm/boredom", - type: "april_messages/boredom" - } - this.mood = { - topic: "/algorithm/mood", - type: "april_messages/mood" - } + } + 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) { - this.trace_listener = new RosLib.Topic({ - ros : this.props.ros, - name: this.trace.topic, - messageType: this.trace.type - }); - 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: this.boredom.topic, - messageType: this.boredom.type - }); - 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: this.mood.topic, - messageType: this.mood.type + 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); } @@ -109,7 +156,7 @@ class RosInteractionTrace extends Component { */ mergeDataInState(newDatum) { // Empty the data and get the first interaction of an experiment - if (this.lowerIdResets && newDatum.id === 0 && this.data.length > 1) { + if (this.lowerIdResets && this.data.length > 0 && newDatum.id < this.data[this.data.length-1].id) { this.data = []; } @@ -125,7 +172,10 @@ class RosInteractionTrace extends Component { ) decorate(RosInteractionTrace, { data: observable, - lowerIdResets: observable, + traceTopic: computed, + boredomTopic: computed, + moodTopic: computed, + lowerIdResets: computed, }) -export default sizeMe({monitorHeight: true})(RosInteractionTrace); \ No newline at end of file +export default sizeMe({monitorHeight: true})(RosInteractionTrace); diff --git a/src/LatestInteraction/DemoLatestInteraction.js b/src/LatestInteraction/DemoLatestInteraction.js index 7f805eeaf158cbca59f236d02907060e38a5d1e9..fafd8715cce7751069b1a949b437d2288bc891ba 100644 --- a/src/LatestInteraction/DemoLatestInteraction.js +++ b/src/LatestInteraction/DemoLatestInteraction.js @@ -1,5 +1,5 @@ import React from 'react' -import {LatestInteraction} from './index' +import {LatestInteraction} from './LatestInteraction' export function DemoLatestInteraction(props) { const intended = {action_names: ["A", "A", "B", "C"], primitive_valences:[1, 2, 3, 4]}; diff --git a/src/LatestInteraction/LatestInteraction.js b/src/LatestInteraction/LatestInteraction.js new file mode 100644 index 0000000000000000000000000000000000000000..24aea55f137ae0e0ebe078b4703767fd50e75f03 --- /dev/null +++ b/src/LatestInteraction/LatestInteraction.js @@ -0,0 +1,65 @@ +import React from 'react' +import {observer} from 'mobx-react' + +import { actionClassName } from '../utils/valenceColours'; + +export const LatestInteraction = observer( +function LatestInteraction(props) { + let intended, enacted; + let iteration = null; + if ('int-trace' in props.store.components && props.store.components['int-trace'].showInteraction) { + intended = props.store.components['int-trace'].currentInteraction.intended; + enacted = props.store.components['int-trace'].currentInteraction.enacted; + const iteration_id = props.store.components['int-trace'].currentInteraction.id; + iteration = [<p key="first"><span className="label">Iteration:</span> {iteration_id}</p>, <hr key="last"/>]; + } else { + intended = props.intended; + enacted = props.enacted; + } + + /** + * Return the + * @param {Object} inter object representing the interaction to display (either intended or enacted) + * @param {Number} i index of primitive action which row this is makeing + */ + function inter2row(inter, length, i) { + if (i < length) { + return ( + <td className={actionClassName(inter.primitive_valences[i])}> + {inter.action_names[i]} ({inter.primitive_valences[i]}) + </td>); + } else { + return null; + } + } + + const length_i = intended.action_names.length, + length_e = enacted.action_names.length; + let rows = []; + for (let i=0; i<Math.max(length_i, length_e); i++) { + rows.push( + <tr key={i}> + {inter2row(intended, length_i, i)} + {inter2row(enacted, length_e, i)} + </tr> + ); + } + + return ( + <div className="latest-interaction"> + {iteration} + <table> + <thead> + <tr> + <th className="label">Intended</th> + <th className="label">Enacted</th> + </tr> + </thead> + <tbody> + {rows} + </tbody> + </table> + </div> + ); +} +) diff --git a/src/LatestInteraction/RosLastestInteraction.js b/src/LatestInteraction/RosLastestInteraction.js index 8e5913ca24b7ea1e8207d9ccbb65b708d1a3cdc8..49031be36e3f24f7d3a1603db2f6ec13ef78834c 100644 --- a/src/LatestInteraction/RosLastestInteraction.js +++ b/src/LatestInteraction/RosLastestInteraction.js @@ -1,36 +1,89 @@ import React, {Component} from 'react' import RosLib from 'roslib' +import {autorun, decorate, observable} from 'mobx' +import {observer} from 'mobx-react' -import {LatestInteraction} from './index' +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) - - this.state = {intended: { - action_names: [], - primitive_valences: [] - }, enacted: { - action_names: [], - primitive_valences: [] - }}; - this.trace = { - topic: "/algorithm/trace", - type: "april_messages/trace" - } + super(props); + + 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']); + + 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); } } @@ -42,8 +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}/> } -} +}) +decorate(RosLatestInteraction, { + latestInteraction: observable, +}); -export { RosLatestInteraction }; +export {RosLatestInteraction }; diff --git a/src/LatestInteraction/index.js b/src/LatestInteraction/index.js index 47c459b88fd0823a66d5e741d8b232a4cd4ecfe0..624763d0ce870e4db0a5e9b3ddc76fccf0955706 100644 --- a/src/LatestInteraction/index.js +++ b/src/LatestInteraction/index.js @@ -1,83 +1,4 @@ -import React from 'react' -import {observer} from 'mobx-react' - import './LatestInteraction.css' -import { actionClassName } from '../utils/valenceColours'; - -export const LatestInteraction = observer( -function LatestInteraction(props) { - let intended, enacted; - let iteration = null; - if ('interactionTrace' in props.store.components && props.store.components['interactionTrace'].showInteraction) { - intended = props.store.components['interactionTrace'].currentInteraction.intended; - enacted = props.store.components['interactionTrace'].currentInteraction.enacted; - const iteration_id = props.store.components['interactionTrace'].currentInteraction.id; - iteration = [<p key="first"><span className="label">Iteration:</span> {iteration_id}</p>, <hr key="last"/>]; - } else { - intended = props.intended; - enacted = props.enacted; - } - - /** - * Return the - * @param {Object} inter object representing the interaction to display (either intended or enacted) - * @param {Number} i index of primitive action which row this is makeing - */ - function inter2row(inter, length, i) { - if (i < length) { - return ( - <td className={actionClassName(inter.primitive_valences[i])}> - {inter.action_names[i]} ({inter.primitive_valences[i]}) - </td>); - } else { - return null; - } - } - - const length_i = intended.action_names.length, - length_e = enacted.action_names.length; - let rows = []; - for (let i=0; i<Math.max(length_i, length_e); i++) { - rows.push( - <tr key={i}> - {inter2row(intended, length_i, i)} - {inter2row(enacted, length_e, i)} - </tr> - ); - } - - const config = this.props.node.getConfig() - if (config && 'displayMode' in config) { - if (config.displayMode === "readme") { - return <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>; - } else if (config.displayMode === "settings") { - return <div> - <p>config panel</p> - </div>; - } - } - - return ( - <div className="latest-interaction"> - {iteration} - <table> - <thead> - <tr> - <th className="label">Intended</th> - <th className="label">Enacted</th> - </tr> - </thead> - <tbody> - {rows} - </tbody> - </table> - </div> - ); -} -) export { DemoLatestInteraction } from './DemoLatestInteraction' export { RosLatestInteraction } from './RosLastestInteraction' \ No newline at end of file diff --git a/src/LineChart/LineChart.js b/src/LineChart/LineChart.js index d7bc96e173e7a58dbb4fd2d87fd6045d5018344e..866659999f0fa27a7ef09ffff3602d7499d38952 100644 --- a/src/LineChart/LineChart.js +++ b/src/LineChart/LineChart.js @@ -49,8 +49,8 @@ class LineChart extends Component { /** * Give the extent of values for an Array of Array (of whatever, see `accessor`). * - * Say we have two curves to plot. Each curve is an array of coordinates (objects {x: , y:}). They are added - * to `dataObject` with their legend as a key. + * Say we have two curves to plot. Each curve is an array of coordinates (objects {x: , y:}) (the 'x' key is optional). + * They are added to `dataObject` with their legend as a key. * * This function is a replacement of d3's `extent` function, as the data structure is more complex here. * @@ -67,27 +67,6 @@ class LineChart extends Component { })); return [lower, upper]; } - - /** - * Add default values for the abscissa axis, if there is none - * @param {Object(Array(Object))} data the data to be plotted - */ - pre_process(data) { - let new_data = {} - // Iterate over the plot names (keys in data) - Object.keys(data).forEach((name) => { - // Iterate over the data of the plot (entries in the Array) - new_data[name] = data[name].map((entry, i) => { - // `entry` is an object supposed to have two keys: `x` and `y`. If the latter is missing, we add one based - // on the index of `entry` in the array. - if (!('x' in entry)) { - entry.x = i; - } - return entry; - }) - }); - return new_data - } render() { // Use the margin convention practice @@ -97,7 +76,7 @@ class LineChart extends Component { const viewbox = "0 0 " + (width + margin.left + margin.right) +" "+ (height + margin.top + margin.bottom); - const data = this.pre_process(this.props.data); + const data = this.props.data; // Allocate one colour per line to plot const lineNames = Object.keys(data); @@ -107,7 +86,7 @@ class LineChart extends Component { // X scale will use the index of our data const xScale = d3.scaleLinear() - .domain(this.extent(data, value => value.x)) // input + .domain(this.extent(data, (value, i) => 'x' in value ? value.x : i)) // input .range([0, width]) .nice(); // output @@ -126,7 +105,7 @@ class LineChart extends Component { // d3's line generator const line = d3.line() - .x(d => xScale(d.x)) // set the x values for the line generator + .x((d, i) => xScale('x' in d ? d.x: i)) // set the x values for the line generator .y(d => yScale(d.y)) // set the y values for the line generator .curve(d3.curveMonotoneX) // apply smoothing to the line @@ -194,7 +173,7 @@ function Circles(props) { // One circle for each data point that is provided as a property to this Component const output = props.data.map((point, i) => ( <circle key={i} className="dot" fill={props.colour} - cx={props.xScale(point.x)} cy={props.yScale(point.y)} r="5"/> + cx={props.xScale('x' in point ? point.x : i)} cy={props.yScale(point.y)} r="5"/> )); return output; } diff --git a/src/LineChart/RosLineChart.js b/src/LineChart/RosLineChart.js index 2879cea0a75ee102ff89f884dc74ca083e9177bd..e90c012bbedf1129e7ddaa542850465313d612d6 100644 --- a/src/LineChart/RosLineChart.js +++ b/src/LineChart/RosLineChart.js @@ -1,83 +1,154 @@ 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 {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.state = { - data: {"" : []}, - topic: { - name: "/predictions/interaction_involvement", - messageType: "april_messages/head_orientation_prediction", - fields: [ - "prediction_of_head_orientation.list[0].values[1]", - ] - }, - // xLabel: "Count", - yLabel: "Involvement", - }; + 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.data = observable.map(); + } + + /** + * 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) { - this.listener = new RosLib.Topic(Object.assign({ros : this.props.ros}, this.state.topic)); - - this.listener.subscribe(function(message) { - - this.setState((state) => { - let fields; - if (!('fields' in this.state.topic) && ('field' in this.state.topic)) { - fields = [this.state.topic.field]; - } else { - fields = this.state.topic.fields; - } - - let new_data = this.state.data; - fields.forEach((field) => { - if (!(field in new_data)) { - new_data[field] = [] - } - console.debug("Raw message: ", message); - console.debug("Selected field: ", property(field)(message)); - new_data[field] = this.state.data[field].concat([{"y": property(field)(message)}]) - }) - return { - data: new_data, - } - }); - }.bind(this)); + 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); } } componentWillUnmount() { - if ('listener' in this) { - this.listener.unsubscribe(); + if ('listeners' in this) { + this.listeners.forEach((listener) => {listener.unsubscribe();}); } } render () { - const config = this.props.node.getConfig() - - if (config && 'displayMode' in config) { - if (config.displayMode === "readme") { - return <p className="about">This <b>linechart</b> represents a <b>measure</b> evolving over time.</p>; - } - - if (config.displayMode === "settings") { - return <div> - <p>config panel</p> - </div>; - } - } - - return <SizedLineChart data={this.state.data} xLabel={this.state.xLabel} yLabel={this.state.yLabel} {...this.props}/> + return <SizedLineChart data={Object.fromEntries(this.data)} + xLabel={this.store.config.xLabel} + yLabel={this.store.config.yLabel} + {...this.props}/> } -} +}) +decorate(RosLineChart, { + data: observable, + process_message: action, +}) export {RosLineChart} diff --git a/src/MemoryRanked/MemoryRanked.js b/src/MemoryRanked/MemoryRanked.js index 202784df12651c9422fcba0dab89534559582acd..f39c5222760e49b96d548ce7f593c150110845b2 100644 --- a/src/MemoryRanked/MemoryRanked.js +++ b/src/MemoryRanked/MemoryRanked.js @@ -1,15 +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); + + 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} @@ -25,16 +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 - this.guideLineCount = 3; // If set to 4, one guide line will be displayed every four line of data - - // The highest number of actions displayed in one memory entry. - this.maxActionLength = 15; + // // 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; - this.sorter = function (interaction) { - // return interaction.action_names.length - return interaction.valence - } + // // 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. @@ -63,32 +153,45 @@ class MemoryRanked extends Component { guideLines() { const data = this.props.data; // Pre-computation for the guide lines, inserted below, in 6. - let NbGuideLines = Math.floor(data.length / this.guideLineCount); - if (data.length % this.guideLineCount === 0) { // no line at the bottom + let NbGuideLines = Math.floor(data.length / this.guideLinesStep); + if (data.length % this.guideLinesStep === 0) { // no line at the bottom --NbGuideLines; } // Build a set of ordinates, one for each line let y = [] for (let i = 1; i <= NbGuideLines; i++) { // Here, 50 is the top margin used in all the columns of this component - y.push(this.margin.top + 50 + this.yScale(this.guideLineCount*i) + this.vStep / 2) + y.push(this.margin.top + 50 + this.yScale(this.guideLinesStep*i) + this.vStep / 2) } return y; } - sort(data, accessor = (value => 0), revert = false) { - return data.sort((a, b) => { - if (!revert) { - return accessor(a) - accessor(b) - } else { - return accessor(b) - accessor(a) + sort(data) { + let sorter; + // Make a sorting function based on the configuration or, by default, sort + // by descending valence. + 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(order === "ascending") { + return getter(a) - getter(b); + } else { + return getter(b) - getter(a); + } } - }); + } else { + sorter = function(a, b) { + return b.valence - a.valence; + } + } + + return data.sort(sorter); } render() { - const data = this.sort(this.props.data, this.sorter, true); + const data = this.sort(this.props.data); // Minimal required height for this figure @@ -136,18 +239,17 @@ class MemoryRanked extends Component { ]; - const config = this.props.node.getConfig() - if (config && 'displayMode' in config) { - if (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") { - return <div> - <p>config panel</p> - </div>; - } + 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 (this.store.config.displayMode === "settings") { + return <div> + <p>config panel</p> + </div>; + } } const viewbox = "0 0 " + (width + this.margin.left + this.margin.right) @@ -179,40 +281,44 @@ class MemoryRanked extends Component { x2={this.xLayout.upper(2)} y2={yValue}/> })} </svg>); - } + } - /** - * Create the react Components declared in declaration and place them at the required x and y coordinates. - * - * *Note*: it passes an additional `width` attribute on to the elements so that they know how much horizontal - * space is available for them. - * @param {Object} declaration object defining what elements will be appended to the SVG. - * Expected attributes: - * - component: React component (function or class) to be added - * - x: index of the element, on the horizontal axis - * - all additional attributes are passed to the React Component - * @param {Number} i unique value used by React to track the Components it generates - */ - makeSVGComponent(declaration, i) { - // Coordinates used for hte translation - const x = this.xLayout.lower(declaration.x); - const y = this.margin.top + this.yOffset; + /** + * Create the react Components declared in declaration and place them at the required x and y coordinates. + * + * *Note*: it passes an additional `width` attribute on to the elements so that they know how much horizontal + * space is available for them. + * @param {Object} declaration object defining what elements will be appended to the SVG. + * Expected attributes: + * - component: React component (function or class) to be added + * - x: index of the element, on the horizontal axis + * - all additional attributes are passed to the React Component + * @param {Number} i unique value used by React to track the Components it generates + */ + makeSVGComponent(declaration, i) { + // Coordinates used for hte translation + const x = this.xLayout.lower(declaration.x); + const y = this.margin.top + this.yOffset; - // Take the extra properties and pass them to the Component - let props = Object.assign({}, declaration); - delete props.x; delete props.y; delete props.component; - props.width = this.xLayout.width(declaration.x); - - // Instanciate the component - const component = React.createElement(declaration.component, props); - - return ( - <g key={"SVG"+i} transform={"translate(" + x + "," + y + ")"}> - {/* <rect x="0" y="0" width={valenceLocation.width} height={valenceLocation.height} fill="none" stroke="steelblue"/> */} - {component} - </g> - ); - } - } + // Take the extra properties and pass them to the Component + let props = Object.assign({}, declaration); + delete props.x; delete props.y; delete props.component; + props.width = this.xLayout.width(declaration.x); + + // Instanciate the component + const component = React.createElement(declaration.component, props); + + return ( + <g key={"SVG"+i} transform={"translate(" + x + "," + y + ")"}> + {/* <rect x="0" y="0" width={valenceLocation.width} height={valenceLocation.height} fill="none" stroke="steelblue"/> */} + {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 f95fd883ee6e408775477eda60b749db9eed3e05..1fa39d09e15d8144820a8a42ba89033b2fe4414b 100644 --- a/src/MemoryRanked/RosMemoryRanked.js +++ b/src/MemoryRanked/RosMemoryRanked.js @@ -1,59 +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 {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(); + } + + newSchema = { + title: "Robot's memory", + type: "object", + required: ["topic"], + properties: { topic: { - name: "/algorithm/memory", - messageType: "april_messages/memory" - } - }; + 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) { - this.listener = new RosLib.Topic(Object.assign({ros : this.props.ros}, this.state.topic)); - 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 () { - const config = this.props.node.getConfig() - - if (config && 'displayMode' in config) { - if (config.displayMode === "readme") { - return ( - <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>); - } else if (config.displayMode === "settings") { - return <div> - <p>config panel</p> - </div>; - } - } - - return <MemoryRanked data={this.state.data} xLabel={this.state.xLabel} yLabel={this.state.yLabel} {...this.props}/> + return <MemoryRanked data={toJS(this.data)} {...this.props}/> } -} +})) +decorate(RosMemoryRanked, { + data: observable, + topic: computed, +}) export {RosMemoryRanked} diff --git a/src/Mood/Mood.js b/src/Mood/Mood.js index b41f8fcaefdc8918569fd1ba0d11d17d5ed14828..d68d91f107b29b7169912c992a583588fcfd8579 100644 --- a/src/Mood/Mood.js +++ b/src/Mood/Mood.js @@ -1,33 +1,29 @@ -import React from 'react'; +import React, {Component} from 'react'; import sizeMe from 'react-sizeme' import './Mood.css' /** * Display a picture based on the agent's mood. - * @param {any} props React properties */ -export function Mood(props) { - const image = { - "PAINED": "images/mood/mood-pained.svg", - "PLEASED": "images/mood/mood-happy.svg", - "BORED": "images/mood/mood-bored.svg" - }; - - const config = props.node.getConfig() - if (config && 'displayMode' in config) { - if (config.displayMode === "readme") { - return <p className="about">The <b>current mood</b> of the agent regarding the valences of its actions.</p>; - } else if (config.displayMode === "settings") { - return <div> - <p>config panel</p> - </div>; - } +export class Mood extends Component { + constructor(props) { + super(props); + + this.image = { + "PAINED": "images/mood/mood-pained.svg", + "PLEASED": "images/mood/mood-happy.svg", + "BORED": "images/mood/mood-bored.svg" + }; } - return (<img alt={"the current mood of the agent is "+props.mood} src={image[props.mood]} - width={props.size.height} height={props.size.height} - className="mood" />); + render() { + + return ( + <img alt={"the current mood of the agent is "+this.props.mood} src={this.image[this.props.mood]} + width={this.props.size.height} height={this.props.size.height} + className="mood" />); + } } const SizedMood = sizeMe({monitorHeight: true})(Mood); diff --git a/src/Mood/RosMood.js b/src/Mood/RosMood.js index 226251b15f5ede0809e72ab63bd64285903c49e4..71afde0b1b8bfe6e7d1be6f880e0517a113689cb 100644 --- a/src/Mood/RosMood.js +++ b/src/Mood/RosMood.js @@ -1,30 +1,68 @@ 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 { 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.mood = { - topic: "/algorithm/mood", - type: "april_messages/mood" - } + 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); + + this.mood = observable.box("PLEASED"); } componentDidMount() { if ('ros' in this.props && this.props.ros) { - this.mood_listener = new RosLib.Topic({ - ros : this.props.ros, - name: this.mood.topic, - messageType: this.mood.type - }); - 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 { + } else { console.warn('RosMood expects to be passed a valid Ros object as property, got ', this.props.ros); } } @@ -36,8 +74,11 @@ class RosMood extends Component { } render() { - return <SizedMood mood={this.state.mood} {...this.props}/> + return <Mood mood={this.mood.get()} {...this.props}/> } -} +})) + +const SizedRosMood = sizeMe({monitorHeight: true})(RosMood); +export {SizedRosMood}; -export { RosMood }; +export {RosMood }; diff --git a/src/VideoStream/index.js b/src/VideoStream/index.js index 1bdcea11e145ca67a4e6c0f3280875c0a1aec1d4..9ec20f96bffc94b71ac264ede71d878651787a7a 100644 --- a/src/VideoStream/index.js +++ b/src/VideoStream/index.js @@ -1,66 +1,126 @@ import React, {Component} from 'react' +import {reaction, 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) + super(props); - const hasUrl = ('url' in this.props && this.props.url); - this.topic = '/pepper/image_raw'; - this.defaultImage = ''; + this.store = new VideoStore(); - this.state = { - url: hasUrl ? this.props.url : this.defaultImage - }; + // Automatically detect if the configured topic name is valid + this.hasTopic = observable.box(false); + this.searchForTopic() - if (this.props.ros) { - this.props.ros.getTopics(function(infos){ - if (undefined !== infos.topics.find(elem => elem === this.topic)) { - this.setState({url: 'http://localhost:8081/stream?topic=' + this.topic}); - } - }.bind(this)) - } + // Fill the undefined fields of the configuration object with their default values. + defaultConfig(this.store); + this.props.store.components['video'] = this.store; } - handleSettingsChange(event) { - this.setState({url: event.target.value}) - } + defaultImage = 'images/default.svg'; - render() { - - const config = this.props.node.getConfig() - if (config && 'displayMode' in config) { - if (config.displayMode === "settings") { - return <div> - <form className="pure-form pure-form-stacked" onSubmit={(event)=>event.preventDefault()}> - <fieldset> - <label>Nice modification option for the module</label> + /** + * 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. + */ + get streamUrl() { + if ('url' in this.props && this.props.url) { + return this.props.url; + } else if (this.hasTopic.get()) { + // all requierd configuration fields are defined + return 'http://' + this.store.config.host + '/stream?topic=' + + this.store.config.topic; + } else { + return this.defaultImage; + } + } - <label htmlFor="url">URL of the stream</label> - <input className="pure-input-1" id="url" name="url" type="text" placeholder="http://localhost:8080/..." - value={this.state.url} onChange={this.handleSettingsChange.bind(this)}/> - </fieldset> - </form> - <p>Changes are automatically saved!</p> - </div>; - } - if (config.displayMode === "readme") { - return <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>; - } + /** + * Check that all the requirements are met for subscribing to the required + * topic + * + * Thanks to MobX magic, the result is computed again each time the topic + * configuration is changed. + */ + searchForTopic() { + if (this.props.ros) { + this.topicWatcher = reaction(() => ( + this.store.config.topic + this.store.config.host + ), + (data, reaction) => { + if ('topic' in this.store.config && 'host' in this.store.config) { + this.props.ros.getTopics(infos => { + this.hasTopic.set(undefined !== infos.topics.find( + elem => elem === this.store.config.topic)); + + if (!this.hasTopic.get()) { + console.warn("[VideoStream] The required topic " + + this.store.config.topic + " is not published"); + } + console.debug(this.hasTopic.get()); + }) + } else { + console.warn("[VideoStream] either no topic is configured " + + "or no host is configured. This should never " + + "happen at runtime. Contact the developpers."); + } + }) + } else { + console.warn("[VideoStream] ROS property is provided. This should " + + "never happen at runtime. Contact the developpers."); } + } + render() { return ( - <div className="video-stream container"> - <img src={this.state.url} alt="there should have been a video stream here"/> - </div>); + <div className="video-stream container"> + <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" node={props.node}/>; + return <VideoStream url="images/table-right.jpg" {...props}/>; } export {VideoStream, DemoVideoStream}; diff --git a/src/demo-1-config.js b/src/demo-1-config.js index e5f7169c2f55451620493a52014e4fd27236f12d..1e920f48fa1ec733c22e8de2b9dc7017c0bbe779 100644 --- a/src/demo-1-config.js +++ b/src/demo-1-config.js @@ -17,6 +17,11 @@ export const flexlayout_json = { "weight": 50, "selected": 0, "children": [ + { + "name": "Interaction trace", + "type": "tab", + "component": "int-trace", + }, { "name": "The magic graph", "type": "tab", @@ -34,16 +39,13 @@ export const flexlayout_json = { "children": [ { "type": "tabset", - "name": "Robot's memory", "weight": 60, "selected": 0, "children": [ { + "name": "Robot's memory", "type": "tab", "component": "memory", - "config": { - "hasReadme": true - }, }, ] }, @@ -57,31 +59,25 @@ export const flexlayout_json = { "children": [ { "type": "tabset", - "name": "Latest interaction", "weight": 78, "selected": 0, "children": [ { + "name": "Latest interaction", "type": "tab", "component": "latest-interaction", - "config": { - "hasReadme": true - }, }, ] }, { "type": "tabset", - "name": "Involvement", "weight": 22, "height": 70, "children": [ { + "name": "Involvement", "type": "tab", "component": "h-gauge", - "config": { - "hasReadme": true - }, }, ] }, @@ -89,15 +85,12 @@ export const flexlayout_json = { }, { "type": "tabset", - "name": "Robot video stream", "weight": 75, "children": [ { + "name": "Robot video stream", "type": "tab", "component": "video", - "config": { - "hasReadme": true - } } ] } 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/devel-config.js b/src/devel-config.js index ee0d339e005bd3fb7220b677d4f63aac50d946cc..ff6964fad27321f71a719ab359452e7b224146e5 100644 --- a/src/devel-config.js +++ b/src/devel-config.js @@ -35,9 +35,6 @@ export const flexlayout_json = { { "type": "tab", "component": "int-trace", - "config": { - "hasReadme": true - } }, ] }, @@ -56,7 +53,7 @@ export const flexlayout_json = { "type": "tab", "component": "memory", "config": { - "hasReadme": true + "mockConfigField": true // Just to show that we can store config in here. }, }, { @@ -79,9 +76,6 @@ export const flexlayout_json = { { "type": "tab", "component": "latest-interaction", - "config": { - "hasReadme": true - }, }, ] }, @@ -96,9 +90,6 @@ export const flexlayout_json = { { "type": "tab", "component": "mood", - "config": { - "hasReadme": true - }, }, ] }, @@ -109,9 +100,6 @@ export const flexlayout_json = { { "type": "tab", "component": "h-gauge", - "config": { - "hasReadme": true - }, }, ] }, @@ -124,10 +112,7 @@ export const flexlayout_json = { "children": [ { "type": "tab", - "component": "demo-video", - "config": { - "hasReadme": true - } + "component": "video", } ] } diff --git a/src/index.js b/src/index.js index bc5fddff32cbf27003af8ceff15694538fdb7465..73d63ae0e86e7f6b56f1f6815751746913e11138 100644 --- a/src/index.js +++ b/src/index.js @@ -1,15 +1,49 @@ 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 {Menu} from "./Menu"; +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) { @@ -39,6 +73,14 @@ 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="level-1"> <div id="menu"> @@ -49,12 +91,13 @@ class Main extends React.Component { <Header ros={this.ros}/> </div> <div id="board"> - <Board ros={this.ros}/> + <Board ros={this.ros} store={this.store}/> </div> </div> + {modal} </div> ) } -} +}) ReactDOM.render(<Main ros={true}/>, document.getElementById("container")); diff --git a/src/utils/configurationHelpers.js b/src/utils/configurationHelpers.js new file mode 100644 index 0000000000000000000000000000000000000000..f79e45fa09e13f9d215a422e4ca423d61bdb32d1 --- /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); +} diff --git a/src/utils/modeModal.js b/src/utils/modeModal.js new file mode 100644 index 0000000000000000000000000000000000000000..544a581d919d1710a1b2b4f320bba56298f10f42 --- /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; + } + +}