Commit 0bd07302 authored by Dorian Goepp's avatar Dorian Goepp

Upgrade the configuration system for gadgets

The configuration of each gadget is now managed with MobX. It means that we get rid of the mechanisms of FlexLayout and that we can configure items that are not displayed in FlexLayout.
Also, The configuration and readme mode are now displayed as a modal overlay over the whole web page. This is still WIP, but much better on some aspects than before.

Limitation : for now, if two tabs are instantiated with the same gadget inside, the configuration would be common to both. It clearly should be fixed.
parent 70e9dd70
......@@ -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;
......
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}
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
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
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 {
......
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: {