Merge pull request #12 from uwap/refactor

Refactor everything
This commit is contained in:
uwap 2018-01-19 17:36:00 +01:00 committed by GitHub
commit a23bef70db
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 2053 additions and 1096 deletions

View file

@ -2,4 +2,7 @@
"presets": [ "presets": [
"env", "react" "env", "react"
], ],
"plugins": [
"transform-class-properties"
],
} }

View file

@ -43,7 +43,6 @@ module.exports = {
"no-floating-decimal": "error", "no-floating-decimal": "error",
"no-implicit-coercion": "error", "no-implicit-coercion": "error",
"no-implied-eval": "error", "no-implied-eval": "error",
"no-invalid-this": "error",
"no-iterator": "error", "no-iterator": "error",
"no-loop-func": "error", "no-loop-func": "error",
"no-multi-spaces": "warn", "no-multi-spaces": "warn",
@ -126,6 +125,11 @@ module.exports = {
"space-before-blocks": "error", "space-before-blocks": "error",
"quotes": ["error", "double"], "quotes": ["error", "double"],
// ES6
"arrow-spacing": "error",
"arrow-parens": "warn",
"no-confusing-arrow": ["error", {"allowParens": true}],
// react // react
"react/prop-types": "off", "react/prop-types": "off",
"react/display-name": "off", "react/display-name": "off",

View file

@ -8,7 +8,5 @@ types/types.js
[options] [options]
esproposal.export_star_as=enable esproposal.export_star_as=enable
unsafe.enable_getters_and_setters=true module.system.node.resolve_dirname=node_modules
module.system.node.resolve_dirname=src
[lints]
all=warn

2
.gitignore vendored
View file

@ -1,2 +1,2 @@
node_modules/ node_modules/
public/ dist/

View file

@ -1,7 +1,8 @@
language: node_js language: node_js
node_js: node_js:
- 7 - 6
- 8 - 8
- 9
script: script:
- yarn travis - yarn travis
cache: cache:

View file

@ -97,7 +97,8 @@ const config : Config = {
command: "/service/onkyo/command", command: "/service/onkyo/command",
defaultValue: "", defaultValue: "",
values: { mpd: "NPR01", kohina: "NPR02", somafm_dronezone: "NPR03", somafm_thetrip: "NPR04", values: { mpd: "NPR01", kohina: "NPR02", somafm_dronezone: "NPR03", somafm_thetrip: "NPR04",
querfunk: "NPR05", somafm_defconradio: "NPR06", somafm_secretagent: "NPR07", somafm_lush: "NPR08"} querfunk: "NPR05", somafm_defconradio: "NPR06", somafm_secretagent: "NPR07", somafm_lush: "NPR08",
somafm_beatblender: "NPR09"}
}, },
rundumleuchte: { rundumleuchte: {
state: "/service/openhab/out/pca301_rundumleuchte/state", state: "/service/openhab/out/pca301_rundumleuchte/state",
@ -121,9 +122,9 @@ const config : Config = {
controls: { controls: {
led_stahltrager: { led_stahltrager: {
name: "LED Stahlträger", name: "LED Stahlträger",
position: [380, 300], position: [380, 590],
icon: "white-balance-iridescent mdi-rotate-90", icon: "white-balance-iridescent",
iconColor: state => state.led_stahltraeger == "on" ? utils.rainbow : "#000000", iconColor: ({led_stahltraeger}) => led_stahltraeger == "on" ? utils.rainbow : "#000000",
ui: [ ui: [
{ {
type: "toggle", type: "toggle",
@ -137,7 +138,7 @@ const config : Config = {
name: "Snackbar", name: "Snackbar",
position: [510, 500], position: [510, 500],
icon: "fridge", icon: "fridge",
iconColor: state => state.snackbar == "on" ? "#E20074" : "#000000", iconColor: ({snackbar}) => snackbar == "on" ? "#E20074" : "#000000",
ui: [ ui: [
{ {
type: "toggle", type: "toggle",
@ -150,8 +151,8 @@ const config : Config = {
twinkle: { twinkle: {
name: "Twinkle", name: "Twinkle",
position: [530, 560], position: [530, 560],
icon: "led-off mdi-flip-v", icon: ({twinkle}) => twinkle == "on" ? "led-on flip-v" : "led-off flip-v",
iconColor: state => state.twinkle == "on" ? utils.rainbow : "#000000", iconColor: ({twinkle}) => twinkle == "on" ? utils.rainbow : "#000000",
ui: [ ui: [
{ {
type: "toggle", type: "toggle",
@ -165,7 +166,7 @@ const config : Config = {
name: "Ventilator", name: "Ventilator",
position: [520, 450], position: [520, 450],
icon: "fan", icon: "fan",
iconColor: state => state.fan == "on" ? "#00FF00" : "#000000", iconColor: ({fan}) => fan == "on" ? "#00FF00" : "#000000",
ui: [ ui: [
{ {
type: "toggle", type: "toggle",
@ -191,7 +192,7 @@ const config : Config = {
name: "Videospiele", name: "Videospiele",
position: [100, 100], position: [100, 100],
icon: "gamepad-variant", icon: "gamepad-variant",
iconColor: state => state.videogames == "on" ? "#00FF00" : "#000000", iconColor: ({videogames}) => videogames == "on" ? "#00FF00" : "#000000",
ui: [ ui: [
{ {
type: "toggle", type: "toggle",
@ -205,7 +206,7 @@ const config : Config = {
name: "Rechner und Drucker", name: "Rechner und Drucker",
position: [297, 90], position: [297, 90],
icon: "desktop-classic", icon: "desktop-classic",
iconColor: state => state.olymp_pc == "on" ? "#00FF00" : "#000000", iconColor: ({olymp_pc}) => olymp_pc == "on" ? "#00FF00" : "#000000",
ui: [ ui: [
{ {
type: "toggle", type: "toggle",
@ -219,7 +220,7 @@ const config : Config = {
name: "Fliegenbratgerät", name: "Fliegenbratgerät",
position: [450, 590], position: [450, 590],
icon: "fire", icon: "fire",
iconColor: state => state.flyfry == "on" ? "#6666FF" : "#000000", iconColor: ({flyfry}) => flyfry == "on" ? "#6666FF" : "#000000",
ui: [ ui: [
{ {
type: "toggle", type: "toggle",
@ -233,15 +234,15 @@ const config : Config = {
name: "Artnet", name: "Artnet",
position: [535,480], position: [535,480],
icon: "spotlight", icon: "spotlight",
iconColor: state => iconColor: ({artnet}) =>
({ ({
off: "#000000", off: "#000000",
yellow: "#CCCC00", yellow: "#F0DF10",
red: "#FF0000", red: "#FF0000",
purple: "#FF00FF", purple: "#FF00FF",
green: "#00FF00", green: "#00FF00",
cycle: utils.rainbow cycle: utils.rainbow
})[state.artnet], })[artnet],
ui: [ ui: [
{ {
type: "toggle", type: "toggle",
@ -270,8 +271,8 @@ const config : Config = {
onkyo: { onkyo: {
name: "Onkyo", name: "Onkyo",
position: [350, 650], position: [350, 650],
iconColor: state => iconColor: ({onkyo_connection, onkyo_power}) =>
state.onkyo_connection != "connected" ? "#888888" : (state.onkyo_power == "on" ? "#00FF00" : "#000000"), onkyo_connection != "connected" ? "#888888" : (onkyo_power == "on" ? "#00FF00" : "#000000"),
icon: "volume-high", icon: "volume-high",
ui: [ ui: [
{ {
@ -279,7 +280,7 @@ const config : Config = {
text: "Power", text: "Power",
icon: "power", icon: "power",
topic: "onkyo_power", topic: "onkyo_power",
enableCondition: (a, b, state) => state.onkyo_connection == "connected" enableCondition: (a, b, state) => state.onkyo_connection.internal == "connected"
}, },
{ {
type: "section", type: "section",
@ -292,14 +293,14 @@ const config : Config = {
min: 0, min: 0,
max: 50, max: 50,
icon: "volume-high", icon: "volume-high",
enableCondition: (a, b, state) => state.onkyo_connection == "connected" enableCondition: (a, b, state) => state.onkyo_connection.internal == "connected"
}, },
{ {
type: "toggle", type: "toggle",
text: "Mute", text: "Mute",
topic: "onkyo_mute", topic: "onkyo_mute",
icon: "volume-off", icon: "volume-off",
enableCondition: (a, b, state) => state.onkyo_connection == "connected" enableCondition: (a, b, state) => state.onkyo_connection.internal == "connected"
}, },
{ {
type: "section", type: "section",
@ -316,7 +317,7 @@ const config : Config = {
pult: "Pult" pult: "Pult"
}, },
icon: "usb", icon: "usb",
enableCondition: (a, b, state) => state.onkyo_connection == "connected" enableCondition: (a, b, state) => state.onkyo_connection.internal == "connected"
}, },
{ {
type: "dropDown", type: "dropDown",
@ -330,10 +331,11 @@ const config : Config = {
querfunk: "Querfunk", querfunk: "Querfunk",
somafm_defconradio: "Defcon Radio (SomaFM)", somafm_defconradio: "Defcon Radio (SomaFM)",
somafm_secretagent: "Secret Agent (SomaFM)", somafm_secretagent: "Secret Agent (SomaFM)",
somafm_lush: "Lush (SomaFM)" somafm_lush: "Lush (SomaFM)",
somafm_beatblender: "Beat Blender (Soma FM)"
}, },
icon: "radio", icon: "radio",
enableCondition: (a, b, state) => state.onkyo_connection == "connected" && state.onkyo_inputs == "netzwerk" enableCondition: (a, b, state) => state.onkyo_connection.internal == "connected" && state.onkyo_inputs.internal == "netzwerk"
}, },
{ {
type: "section", type: "section",
@ -350,7 +352,7 @@ const config : Config = {
name: "Rundumleuchte", name: "Rundumleuchte",
position: [310,275], position: [310,275],
icon: "alarm-light", icon: "alarm-light",
iconColor: state => state.rundumleuchte == "on" ? "#CCCC00" : "#000000", iconColor: ({rundumleuchte}) => rundumleuchte == "on" ? "#F0DF10" : "#000000",
ui: [ ui: [
{ {
type: "toggle", type: "toggle",
@ -364,7 +366,7 @@ const config : Config = {
name: "Tür", name: "Tür",
position: [455,350], position: [455,350],
icon: "swap-vertical", icon: "swap-vertical",
iconColor: state => state.door_status == "on" ? "#00FF00" : "#FF0000", iconColor: ({door_status}) => door_status == "on" ? "#00FF00" : "#FF0000",
ui: [ ui: [
{ {
type: "link", type: "link",
@ -377,7 +379,7 @@ const config : Config = {
name: "Infoscreen", name: "Infoscreen",
position: [255, 495], position: [255, 495],
icon: "developer-board", icon: "developer-board",
iconColor: state => state.infoscreen == "on" ? "#4444FF" : "#000000", iconColor: ({infoscreen}) => infoscreen == "on" ? "#4444FF" : "#000000",
ui: [ ui: [
{ {
type: "toggle", type: "toggle",
@ -399,18 +401,30 @@ const config : Config = {
baseLayer: true, baseLayer: true,
name: "RaumZeitLabor", name: "RaumZeitLabor",
defaultVisibility: "visible", defaultVisibility: "visible",
opacity: 0.7 opacity: 0.7,
bounds: {
topLeft: [0, 0],
bottomRight: [1000, 700]
}
}, },
{ {
image: require("../img/layers/rzl/details.svg"), image: require("../img/layers/rzl/details.svg"),
name: "Details", name: "Details",
defaultVisibility: "visible", defaultVisibility: "visible",
opacity: 0.4 opacity: 0.4,
bounds: {
topLeft: [0, 0],
bottomRight: [1000, 700]
}
}, },
{ {
image: require("../img/layers/rzl/labels.svg"), image: require("../img/layers/rzl/labels.svg"),
name: "Labels", name: "Labels",
defaultVisibility: "visible" defaultVisibility: "visible",
bounds: {
topLeft: [0, 0],
bottomRight: [1000, 700]
}
} }
] ]
}; };

View file

@ -1,5 +1,29 @@
// @flow // @flow
export const rainbow = "rgba(200,120,120,0.5);" export const rainbow = "rgba(200,120,120,0.5);"
+ "background: linear-gradient(40deg, #FF0000 0%, #00FF00 50%, #0000FF 70%, #FFFF00 100%);" + "--before-background: linear-gradient(40deg, #FF0000 0%, #00FF00 50%, #0000FF 70%, #FFFF00 100%);";
+ "background-clip: text; -webkit-background-clip: text;";
export const esper_topics = (chip_id: string) => ({
[ `esper_${chip_id}_version` ]: {
state: `/service/esper/${chip_id}/info`,
command: "",
defaultValue: "UNKNOWN",
values: {},
parseState: msg => JSON.parse(msg.toString()).version.esper
}
});
export const esper_statistics = (chip_id: string,
prev_ui: Array<ControlUI> = []) => (
prev_ui.concat([
{
type: "section",
text: "Funkdose"
},
{
type: "text",
text: "Version",
topic: `esper_${chip_id}_version`
}
])
);

View file

@ -16,10 +16,12 @@ body .leaflet-div-icon {
border: 0; border: 0;
background: transparent; background: transparent;
} }
.leaflet-marker-icon .mdi {
line-height: 1;
}
#drawer_uiComponents .mdi { #drawer_uiComponents .mdi {
width: auto; width: auto;
height: auto; height: auto;
} }
.mdi:before {
background-clip: text;
-webkit-background-clip: text;
background-image: var(--before-background, transparent);
}

View file

@ -1,7 +1,7 @@
{ {
"name": "spacemap", "name": "mqtt-control-map",
"version": "1.0.0", "version": "1.0.0",
"author": "uwap <me+spacemap.package.json@uwap.name>", "author": "uwap <me+mqttmap.package.json@uwap.name>",
"description": "control devices via mqtt on a beautiful map of your space", "description": "control devices via mqtt on a beautiful map of your space",
"scripts": { "scripts": {
"build": "webpack --bail --config webpack.dev.js", "build": "webpack --bail --config webpack.dev.js",
@ -13,16 +13,16 @@
}, },
"dependencies": { "dependencies": {
"babel-preset-env": "^1.6.0", "babel-preset-env": "^1.6.0",
"leaflet": "^1.2.0", "leaflet": "^1.3.1",
"material-ui": "next", "lodash": "^4.17.4",
"material-ui": "npm:material-ui@next",
"material-ui-old": "npm:material-ui@latest", "material-ui-old": "npm:material-ui@latest",
"mdi": "^2.0.46", "mdi": "^2.0.46",
"mqtt": "^2.11.0", "mqtt": "^2.14.0",
"ramda": "^0.24.1", "react": "^16.0.0",
"react": "^15.6.1", "react-dom": "^16.0.0",
"react-dom": "^15.6.1", "react-leaflet": "^1.8.0",
"react-leaflet": "^1.5.0", "react-tap-event-plugin": "^3.0.0",
"react-tap-event-plugin": "^2.0.1",
"redux": "^3.7.2" "redux": "^3.7.2"
}, },
"devDependencies": { "devDependencies": {
@ -30,22 +30,23 @@
"babel-core": "^6.25.0", "babel-core": "^6.25.0",
"babel-eslint": "^8.0.1", "babel-eslint": "^8.0.1",
"babel-loader": "^7.1.1", "babel-loader": "^7.1.1",
"babel-plugin-transform-class-properties": "^6.24.1",
"babel-preset-react": "^6.24.1", "babel-preset-react": "^6.24.1",
"clean-webpack-plugin": "^0.1.17", "clean-webpack-plugin": "^0.1.17",
"css-loader": "^0.28.7", "css-loader": "^0.28.9",
"eslint": "^4.10.0", "eslint": "^4.10.0",
"eslint-plugin-flowtype": "^2.39.1", "eslint-plugin-flowtype": "^2.39.1",
"eslint-plugin-react": "^7.4.0", "eslint-plugin-react": "^7.4.0",
"extract-text-webpack-plugin": "^3.0.2", "extract-text-webpack-plugin": "^3.0.2",
"file-loader": "^1.1.5", "file-loader": "^1.1.5",
"flow": "^0.2.3", "flow": "^0.2.3",
"flow-bin": "^0.50.0", "flow-bin": "^0.63.1",
"flow-typed": "^2.2.1", "flow-typed": "^2.2.1",
"html-webpack-plugin": "^2.30.1", "html-webpack-plugin": "^2.30.1",
"husky": "^0.14.3", "husky": "^0.14.3",
"style-loader": "^0.19.0", "style-loader": "^0.19.0",
"webpack": "^3.1.0", "webpack": "^3.1.0",
"webpack-dev-server": "^2.9.4", "webpack-dev-server": "^2.11.0",
"webpack-merge": "^4.1.1", "webpack-merge": "^4.1.1",
"webpack-shell-plugin": "^0.5.0" "webpack-shell-plugin": "^0.5.0"
}, },

View file

@ -1,149 +0,0 @@
// @flow
import React from "react";
import Switch from "material-ui/Switch";
import Select from "material-ui/Select";
import { MenuItem } from "material-ui/Menu";
import Slider from "material-ui-old/Slider";
import MuiThemeProvider from "material-ui-old/styles/MuiThemeProvider";
import Config from "./config";
import Input, { InputLabel } from "material-ui/Input";
import { FormControl } from "material-ui/Form";
import R from "ramda";
import {
ListItem,
ListItemIcon,
ListItemSecondaryAction,
ListItemText,
ListSubheader
} from "material-ui/List";
import Button from "material-ui/Button";
const enabled = (props: ControlUI, state: State) => {
if (props.enableCondition == null) {
return true;
} else {
const val = state.values[props.topic];
return props.enableCondition(
val.internal == null ? val.actual : val.internal, val.actual,
R.map(x => x.internal == null ? x.actual
: x.internal, state.values == null ? {} : state.values));
}
};
const getValue = (topic: string, val: string) =>
Config.topics[topic].values[val];
const renderIcon = (icon: string) => {
if (icon != null) {
return (
<ListItemIcon>
<i className={`mdi mdi-${icon} mdi-24px`}></i>
</ListItemIcon>
);
}
return null;
};
export const onSwitch = (topic: string, props: ControlUI, state: State) =>
(x, toggled: boolean) => {
if (state.mqtt != null) {
state.mqtt.publish(Config.topics[topic].command,
toggled ? getValue(topic, R.propOr("on", "on", props))
: getValue(topic, R.propOr("off", "off", props)));
}
};
export const isToggled = (state: State, props: ControlUI) => {
const val = state.values[props.topic];
if (props.toggled != null) {
return props.toggled(val.internal == null ? val.actual : val.internal,
val.actual);
} else {
return val.internal === R.propOr("on", "on", props);
}
};
export const toggle = (state: State, props: ControlUI) => {
return (
<ListItem>
{renderIcon(props.icon)}
<ListItemText primary={props.text} />
<ListItemSecondaryAction>
<Switch label={props.text}
checked={isToggled(state, props)}
onChange={onSwitch(props.topic, props, state)}
disabled={!(enabled(props, state))} />
</ListItemSecondaryAction>
</ListItem>
);
};
const onDropDownChange = (topic: string, props: ControlUI, state: State) =>
(event) => {
if (state.mqtt != null) {
state.mqtt.publish(Config.topics[topic].command, event.target.value);
}
};
const dropDownItem = (topic: string) => (text: string, key: string) => (
<MenuItem value={Config.topics[topic].values[key]} key={key}>{text}</MenuItem>
);
export const dropDown = (state: State, props: ControlUI) => {
const id = `${props.topic}.${Object.keys(props.options)
.reduce((v, r) => v + "." + r)}`;
return (
<ListItem>
{renderIcon(props.icon)}
<FormControl>
<InputLabel htmlFor={id}>{props.text}</InputLabel>
<Select value={state.values[props.topic].actual}
onChange={onDropDownChange(props.topic, props, state)}
disabled={!(enabled(props, state))}
input={<Input id={id} />}
>
{R.values(R.mapObjIndexed(dropDownItem(props.topic), props.options))}
</Select>
</FormControl>
</ListItem>
);
};
const onSliderChange = (state: State, props: ControlUI) =>
(event, value) => {
if (state.mqtt != null) {
state.mqtt.publish(Config.topics[props.topic].command, value.toString());
}
};
export const slider = (state: State, props: ControlUI) => (
<ListItem>
{renderIcon(props.icon)}
<ListItemText primary={props.text} />
<ListItemSecondaryAction>
<MuiThemeProvider>
<Slider value={state.values[props.topic].actual}
min={props.min == null ? 0 : props.min}
max={props.max == null ? 1 : props.max}
step={props.step == null ? 1 : props.step}
onChange={onSliderChange(state, props)}
style={{width: 100}}
/></MuiThemeProvider>
</ListItemSecondaryAction>
</ListItem>
);
export const section = (state: State, props: ControlUI) => (
<ListSubheader>{props.text}</ListSubheader>
);
export const link = (state: State, props: ControlUI) => (
<ListItem>
<Button raised
onClick={() => window.open(props.link, "_blank")}
color="primary"
>
{props.text}
</Button>
</ListItem>
);

View file

@ -1,43 +0,0 @@
// @flow
import React from "react";
import AppBar from "material-ui/AppBar";
import Toolbar from "material-ui/Toolbar";
import { CircularProgress } from "material-ui/Progress";
import IconButton from "material-ui/IconButton";
import Typography from "material-ui/Typography";
const TopBarLayerSelector = (_props: Object) => (
<IconButton>
<i className="mdi mdi-layers"></i>
</IconButton>
);
const TopBarIndicatorMenu = (props: Object) => (
<IconButton>
{props.mqtt.connected ?
(<i style={{fontSize: 48}} className="mdi mdi-map"></i>) :
(<i style={{fontSize: 48}} className="mdi mdi-lan-disconnect"></i>)}
</IconButton>
);
const TopBarIndicator = (props: Object) => {
if (props.mqtt == null || props.mqtt.reconnecting) {
return (<CircularProgress size={48}
style={{color: "rgba(0, 0, 0, 0.54)"}} />);
} else {
return (<TopBarIndicatorMenu {...props} />);
}
};
const TopBar = (props: Object) => (
<AppBar position="static">
<Toolbar>
<TopBarIndicator {...props} />
<Typography type="title">{props.title}</Typography>
{false && <TopBarLayerSelector {...props} />}
</Toolbar>
</AppBar>
);
export default TopBar;

138
src/components/App.js Normal file
View file

@ -0,0 +1,138 @@
// @flow
import React from "react";
import _ from "lodash";
import MuiThemeProvider from "material-ui/styles/MuiThemeProvider";
import createMuiTheme from "material-ui/styles/createMuiTheme";
import withStyles from "material-ui/styles/withStyles";
import * as Colors from "material-ui/colors";
import SideBar from "components/SideBar";
import ControlMap from "components/ControlMap";
import TopBar from "components/TopBar";
import UiItemList from "components/UiItemList";
import keyOf from "utils/keyOf";
import { controlGetIcon } from "utils/parseIconName";
import connectMqtt from "../connectMqtt";
export type AppProps = {
config: Config
};
export type AppState = {
selectedControl: ?Control,
drawerOpened: boolean,
mqttState: State,
mqttSend: (topic: string, value: any) => void,
mqttConnected: boolean,
};
class App extends React.Component<AppProps & Classes, AppState> {
constructor(props: AppProps & Classes) {
super(props);
this.state = {
selectedControl: null,
drawerOpened: false,
mqttState: _.mapValues(props.config.topics, (topic) => ({
actual: topic.defaultValue,
internal: keyOf(topic.values, topic.defaultValue)
})),
mqttSend: connectMqtt(props.config.space.mqtt, {
onMessage: this.receiveMessage.bind(this),
onConnect: () => this.setState({ mqttConnected: true }),
onReconnect: () => this.setState({ mqttConnected: false }),
onDisconnect: () => this.setState({ mqttConnected: false }),
subscribe: _.map(props.config.topics, (x) => x.state)
}),
mqttConnected: false
};
}
static styles(_theme: Object) {
return {
drawerPaper: {
width: 320
}
};
}
get theme() {
return createMuiTheme({
palette: {
primary: Colors[this.props.config.space.color]
}
});
}
receiveMessage(rawTopic: string, message: Object) {
const topic = _.findKey(
this.props.config.topics,
(v) => v.state === rawTopic
);
if (topic == null) {
return;
}
const parseValue = this.props.config.topics[topic].parseState;
const value = parseValue == null ? message.toString() : parseValue(message);
this.setState({mqttState: _.merge(this.state.mqttState,
{ [topic]: {
actual: value,
internal: keyOf(this.props.config.topics[topic].values, value) || value
}})});
}
changeControl(control: ?Control = null) {
this.setState({selectedControl: control, drawerOpened: control != null});
}
closeDrawer() {
this.setState({drawerOpened: false});
}
changeState(topic: string, value: any) {
const rawTopic = this.props.config.topics[topic].command;
if (rawTopic == null) {
return;
}
this.state.mqttSend(
rawTopic,
String(this.props.config.topics[topic].values[value] || value)
);
}
render() {
return (
<div>
<MuiThemeProvider theme={this.theme}>
<div>
<TopBar title={`${this.props.config.space.name} Map`}
connected={this.state.mqttConnected} />
<SideBar open={this.state.drawerOpened}
control={this.state.selectedControl}
onCloseRequest={this.closeDrawer.bind(this)}
icon={this.state.selectedControl == null ? null :
controlGetIcon(this.state.selectedControl,
this.state.mqttState)}
>
{this.state.selectedControl == null
|| <UiItemList state={this.state.mqttState}
controls={this.state.selectedControl.ui}
onChangeState={this.changeState.bind(this)}
/>}
</SideBar>
</div>
</MuiThemeProvider>
<ControlMap width={1000} height={700} zoom={0}
layers={this.props.config.layers}
controls={this.props.config.controls}
onChangeControl={this.changeControl.bind(this)}
state={this.state.mqttState}
/>
</div>
);
}
}
export default withStyles(App.styles)(App);

View file

@ -0,0 +1,109 @@
// @flow
import React from "react";
import { Map, ImageOverlay, Marker, LayersControl } from "react-leaflet";
import Leaflet from "leaflet";
import _ from "lodash";
import parseIconName, { controlGetIcon } from "utils/parseIconName";
export type Point = [number, number];
const convertPoint = ([y, x]: Point): Point => [-x, y];
export type ControlMapProps = {
width: number,
height: number,
zoom: number,
layers: Array<Layer>,
controls: Controls,
onChangeControl: (control: Control) => void,
state: State
};
export default class ControlMap extends React.Component<ControlMapProps> {
constructor(props: ControlMapProps) {
super(props);
}
get center(): Point {
return convertPoint([
this.props.width / 2,
this.props.height / 2
]);
}
render() {
return (
<Map center={this.center}
zoom={this.props.zoom}
crs={Leaflet.CRS.Simple}>
{this.renderMarkers()}
{this.renderLayers()}
</Map>
);
}
renderMarkers() {
return _.map(this.props.controls, this.renderMarker.bind(this));
}
createLeafletIcon(control: Control) {
const icon = controlGetIcon(control, this.props.state);
const iconClass = parseIconName(`${icon} 36px`);
return Leaflet.divIcon({
iconSize: Leaflet.point(36, 36),
iconAnchor: Leaflet.point(18, 18),
html: `<i class="${iconClass}"
style="line-height: 1; color: ${this.iconColor(control)}"></i>`
});
}
iconColor(control: Control): string {
const ints = _.mapValues(this.props.state, (x) => x.internal || x.actual);
const acts = _.mapValues(this.props.state, (x) => x.actual);
if (control.iconColor != null) {
return control.iconColor(ints, acts, this.props.state);
}
return "#000";
}
renderMarker(control: Control, key: string) {
return (
<Marker position={convertPoint(control.position)}
key={key}
icon={this.createLeafletIcon(control)}
onClick={() => this.props.onChangeControl(control)}
>
</Marker>
);
}
renderLayers() {
return (
<LayersControl position="topright">
{this.props.layers.map(this.renderLayer)}
</LayersControl>
);
}
renderLayer(layer: Layer) {
const LayersControlType =
layer.baseLayer ? LayersControl.BaseLayer : LayersControl.Overlay;
return (
<LayersControlType
key={layer.name}
name={layer.name}
checked={layer.defaultVisibility === "visible"}
removeLayer={(_layer) => {}}
removeLayerControl={(_layer) => {}}
addOverlay={(_layer, _name, _checked) => {}}
addBaseLayer={(_layer, _name, _checked) => {}}>
<ImageOverlay url={layer.image}
bounds={[
convertPoint(layer.bounds.topLeft),
convertPoint(layer.bounds.bottomRight)
]}
opacity={layer.opacity || 1} />
</LayersControlType>
);
}
}

72
src/components/SideBar.js Normal file
View file

@ -0,0 +1,72 @@
// @flow
import * as React from "react";
import withStyles from "material-ui/styles/withStyles";
import Drawer from "material-ui/Drawer";
import Typography from "material-ui/Typography";
import IconButton from "material-ui/IconButton";
import AppBar from "material-ui/AppBar";
import Toolbar from "material-ui/Toolbar";
import List from "material-ui/List";
import { renderIcon } from "utils/parseIconName";
export type SideBarProps = {
control: ?Control,
open: boolean,
onCloseRequest: () => void,
icon?: ?string,
children?: React.Node
};
export type SideBarState = {
};
class SideBar extends React.Component<SideBarProps & Classes, SideBarState> {
constructor(props: SideBarProps & Classes) {
super(props);
}
static styles(_theme: Object): Object {
return {
drawerPaper: {
width: 320
},
flex: {
flex: 1
}
};
}
close() {
this.props.onCloseRequest();
}
render() {
return (
<Drawer open={this.props.open}
anchor="right"
onRequestClose={this.close}
classes={{paper: this.props.classes.drawerPaper}}
type="persistent"
>
<AppBar position="static">
<Toolbar>
{this.props.icon == null
|| renderIcon(this.props.icon, "mdi-36px")}
<Typography type="title" className={this.props.classes.flex}>
{this.props.control == null || this.props.control.name}
</Typography>
<IconButton onClick={this.close.bind(this)}>
<i className="mdi mdi-close mdi-36px"></i>
</IconButton>
</Toolbar>
</AppBar>
<List id="drawer_uiComponents">
{this.props.children}
</List>
</Drawer>
);
}
}
export default withStyles(SideBar.styles)(SideBar);

42
src/components/TopBar.js Normal file
View file

@ -0,0 +1,42 @@
// @flow
import React from "react";
import AppBar from "material-ui/AppBar";
import Toolbar from "material-ui/Toolbar";
import Typography from "material-ui/Typography";
import { CircularProgress } from "material-ui/Progress";
export type TopBarProps = {
title: string,
connected: boolean
};
export type TopBarState = {
};
export default class TopBar extends React.Component<TopBarProps, TopBarState> {
constructor(props: TopBarProps) {
super(props);
}
render() {
return (
<AppBar position="static">
<Toolbar>
{this.renderConnectionIndicator()}
<Typography type="title">{this.props.title}</Typography>
</Toolbar>
</AppBar>
);
}
renderConnectionIndicator() {
if (this.props.connected) {
return (<i style={{fontSize: 48}} className="mdi mdi-map"></i>);
}
return (
<CircularProgress size={48} style={{color: "rgba(0, 0, 0, 0.54)"}} />
);
}
}

View file

@ -0,0 +1,214 @@
// @flow
import React from "react";
import _ from "lodash";
import {
ListItemSecondaryAction,
ListItemText,
ListSubheader
} from "material-ui/List";
import Switch from "material-ui/Switch";
import Input, { InputLabel } from "material-ui/Input";
import { FormControl } from "material-ui/Form";
import Select from "material-ui/Select";
import { MenuItem } from "material-ui/Menu";
import Button from "material-ui/Button";
import keyOf from "utils/keyOf";
type UiItemProps<I> = {
item: I,
state: State,
onChangeState: (topic: string, nextState: any) => void
};
export default class UiItem<I:Object> extends React.Component<UiItemProps<I>> {
constructor(props: UiItemProps<I>) {
super(props);
}
runPrimaryAction() {
}
render() {
return null;
}
/*
* TODO: The type system can't really check if the enableCondition is of
* any function type or if it is a TopicDependentOption or a
* StateDependentOption. This should be fixed.
*/
isEnabled() {
if (Object.keys(this.props.item).includes("enableCondition") &&
typeof this.props.item.enableCondition == "function") {
const enableCondition = this.props.item.enableCondition;
const state = this.props.state;
const internals = _.mapValues(state, (x) => x.internal);
const actuals = _.mapValues(state, (x) => x.actual);
return enableCondition(internals, actuals, state);
} else {
return true;
}
}
}
export class UiControl<I: UIControl> extends UiItem<I> {
constructor(props: UiItemProps<I>) {
super(props);
}
changeState(next: any) {
if (this.props.item.topic == null) {
throw new Error(
`Missing topic in ${this.props.item.type} "${this.props.item.text}"`
);
}
this.props.onChangeState(this.props.item.topic, next);
}
getValue() {
const control = this.props.item;
const topic: string = control.topic || "";
const value = this.props.state[topic];
if (value == null) {
if (topic === "") {
throw new Error(
`Missing topic in ${control.type} "${control.text}"`
);
}
throw new Error(
`Unknown topic "${topic}" in ${control.type} "${control.text}"`
);
}
return value;
}
isEnabled() {
if (Object.keys(this.props.item).includes("enableCondition") &&
typeof this.props.item.enableCondition == "function") {
const enableCondition = this.props.item.enableCondition;
const value = this.getValue();
return enableCondition(
value.internal || value.actual, value.actual, this.props.state);
} else {
return true;
}
}
}
export class Toggle extends UiControl<UIToggle> {
isToggled = () => {
const value = this.getValue();
const control = this.props.item;
const isChecked = control.toggled ||
((i, _a, _s) => i === (control.on || "on"));
const checked = isChecked(
value.internal || value.actual, value.actual, this.props.state);
return checked;
}
runPrimaryAction = () => {
if (this.isEnabled()) {
const control = this.props.item;
const toggled = this.isToggled();
const next = toggled ? (control.off || "off") : (control.on || "on");
this.changeState(next);
}
}
render() {
return [
<ListItemText key="label" primary={this.props.item.text} />,
<ListItemSecondaryAction key="action">
<Switch label={this.props.item.text}
checked={this.isToggled()}
onChange={this.runPrimaryAction}
disabled={!this.isEnabled()} />
</ListItemSecondaryAction>
];
}
}
export class DropDown extends UiControl<UIDropDown> {
runPrimaryAction = (next?: any) => {
if (this.isEnabled()) {
const control = this.props.item;
const keys = _.keys(control.options);
const value = this.getValue();
const valueIndex = keyOf(keys, value);
if (next == null) {
this.changeState(keys[(valueIndex + 1) % keys.length]);
} else {
this.changeState(next);
}
}
}
render() {
const control = this.props.item;
const value = this.getValue();
const id = `${control.topic}-${control.text}`;
const options = control.options;
if (options == null) {
throw new Error(
`Parameter "options" missing for ${control.type} "${control.text}"`
);
}
return (
<FormControl>
<InputLabel htmlFor={id}>{control.text}</InputLabel>
<Select value={value.internal || value.actual}
onChange={(event) => this.runPrimaryAction(event.target.value)}
disabled={!this.isEnabled()}
input={<Input id={id} />}
>
{_.map(options, (v, k) => <MenuItem value={k} key={k}>{v}</MenuItem>)}
</Select>
</FormControl>
);
}
}
export class Link extends UiItem<UILink> {
runPrimaryAction = () => {
const control = this.props.item;
if (control.link == null) {
throw new Error(
`Parameter "link" missing for ${control.type} "${control.text}"`
);
}
if (this.isEnabled()) {
window.open(control.link, "_blank");
}
}
render() {
return (
<Button raised
onClick={this.runPrimaryAction}
color="primary"
disabled={!this.isEnabled()}
>
{this.props.item.text}
</Button>
);
}
}
export class Section extends UiItem<UISection> {
render() {
return (
<ListSubheader>{this.props.item.text}</ListSubheader>
);
}
}
export class Text extends UiControl<UIText> {
render() {
return [
<ListItemText key="label" primary={this.props.item.text} />,
<ListItemText key="val" secondary={this.getValue().internal} />
];
}
}

View file

@ -0,0 +1,116 @@
// @flow
import React from "react";
import _ from "lodash";
import {
ListItem,
ListItemIcon,
ListItemSecondaryAction,
ListItemText
} from "material-ui/List";
import { renderIcon } from "utils/parseIconName";
// TODO: Use something else
import Slider from "material-ui-old/Slider";
import MuiThemeProvider from "material-ui-old/styles/MuiThemeProvider";
import { Toggle, DropDown, Link, Section, Text } from "./UiItem";
export type UiItemListProps = {
controls: Array<ControlUI>,
state: State,
onChangeState: (topic: string, nextState: any) => void
};
export default class UiItemList extends React.Component<UiItemListProps> {
constructor(props: UiItemListProps) {
super(props);
}
render() {
return this.props.controls.map((control, key) => {
if (control.type == null) {
throw new Error(
"A control is missing the \"type\" parameter"
);
}
if (control.type === "section") {
return this.renderControl(control);
}
return (
<ListItem key={key}>
{control.icon == null ||
<ListItemIcon>{renderIcon(control.icon, "mdi-24px")}</ListItemIcon>}
{this.renderControl(control)}
</ListItem>
);
});
}
renderControl(control: ControlUI) {
switch (control.type) {
case "toggle": {
return <Toggle item={control}
state={this.props.state}
onChangeState={this.props.onChangeState} />;
}
case "dropDown": {
return <DropDown item={control}
state={this.props.state}
onChangeState={this.props.onChangeState} />;
}
case "section": {
return <Section item={control}
state={this.props.state}
onChangeState={this.props.onChangeState} />;
}
case "link": {
return <Link item={control}
state={this.props.state}
onChangeState={this.props.onChangeState} />;
}
case "slider": {
return this.renderSlider(control);
}
case "text": {
return <Text item={control}
state={this.props.state}
onChangeState={this.props.onChangeState} />;
}
default: {
throw new Error(
`Unknown UI type "${control.type}" for "${control.text}" component`
);
}
}
}
getValue(control: UIControl) {
const value = this.props.state[control.topic];
if (value == null) {
throw new Error(
`Unknown topic "${control.topic}" in ${control.type} "${control.text}"`
);
}
return value;
}
renderSlider(control: UISlider) {
const value = this.getValue(control);
return [
<ListItemText primary={control.text} key="text" />,
<ListItemSecondaryAction key="action">
<MuiThemeProvider>
<Slider value={value.internal || value.actual}
min={control.min || 0}
max={control.max || 100}
step={control.step || 1}
onChange={
(_event, newvalue) =>
this.props.onChangeState(control.topic, newvalue)
}
style={{width: 100}}
/></MuiThemeProvider>
</ListItemSecondaryAction>
];
}
}

57
src/connectMqtt.js Normal file
View file

@ -0,0 +1,57 @@
// @flow
import mqtt from "mqtt";
// TODO: type mqtt.js
export type MqttSettings = {
onReconnect?: (mqtt: Object) => void,
onDisconnect?: (mqtt: Object) => void,
onMessage?: (topic: string, message: Object) => void,
onMessageSent?: (topic: string, message: any) => void,
onConnect?: (mqtt: Object) => void,
subscribe?: Array<string>
}
export type MessageCallback = (topic: string, message: any) => void;
export default function connectMqtt(
url: string,
settings: MqttSettings = {}
): MessageCallback {
const client = mqtt.connect(url);
client.on("connect", () => {
if (settings.subscribe != null) {
client.subscribe(settings.subscribe);
}
if (settings.onConnect != null) {
settings.onConnect(client);
}
});
client.on("message", (topic, message) => {
if (settings.onMessage != null) {
settings.onMessage(topic, message);
}
});
client.on("offline", () => {
if (settings.onDisconnect != null) {
settings.onDisconnect(client);
}
});
client.on("close", () => {
if (settings.onDisconnect != null) {
settings.onDisconnect(client);
}
});
client.on("reconnect", () => {
if (settings.onReconnect != null) {
settings.onReconnect(client);
}
});
return (topic: string, message: any) => {
client.publish(topic, message, null, (error) => {
if (error == null && settings.onMessageSent != null) {
settings.onMessageSent(topic, message);
}
});
};
}

View file

@ -1,24 +1,11 @@
// @flow // @flow
import React from "react"; import React from "react";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import MuiThemeProvider from "material-ui/styles/MuiThemeProvider";
import createMuiTheme from "material-ui/styles/createMuiTheme";
import withStyles from "material-ui/styles/withStyles";
import Drawer from "material-ui/Drawer";
import injectTapEventPlugin from "react-tap-event-plugin"; import injectTapEventPlugin from "react-tap-event-plugin";
import { store, Actions } from "./state";
import connectMqtt from "./mqtt"; import App from "components/App";
import SpaceMapBar from "./appbar";
import * as UiItems from "./UiItems.js";
import SpaceMap from "./map.js";
import R from "ramda";
import Config from "./config"; import Config from "./config";
import Toolbar from "material-ui/Toolbar";
import * as colors from "material-ui/colors";
import Typography from "material-ui/Typography";
import List from "material-ui/List";
import IconButton from "material-ui/IconButton";
import AppBar from "material-ui/AppBar";
import "../node_modules/mdi/css/materialdesignicons.min.css"; import "../node_modules/mdi/css/materialdesignicons.min.css";
import "../css/styles.css"; import "../css/styles.css";
@ -27,67 +14,6 @@ injectTapEventPlugin();
document.title = `${Config.space.name} Map`; document.title = `${Config.space.name} Map`;
const UiItem = (state) => (props : ControlUI) => // $FlowFixMe
UiItems[props.type](state, props); const contentElement: Element = document.getElementById("content");
ReactDOM.render(<App config={Config} />, contentElement);
const renderUi = (state: State, key: ?string) =>
key != null && Config.controls[key] != null ?
R.map(UiItem(state), Config.controls[key].ui) : null;
const theme = createMuiTheme({
palette: {
primary: colors[Config.space.color]
}
});
const appStyles = withStyles((_theme) => ({
drawerPaper: {
width: 320
}
}));
class app extends React.Component<{state: State, classes: Object}> {
render() {
const state = this.props.state;
const classes = this.props.classes;
if (state == null) return (<div></div>);
return (
<div>
<MuiThemeProvider theme={theme}>
<div>
<SpaceMapBar title={`${Config.space.name} Map`} {...state} />
<Drawer open={state.uiOpened != null}
anchor="right"
onRequestClose={() => store.dispatch({type: Actions.CHANGE_UI})}
classes={{paper: classes.drawerPaper}}
type="persistent"
>
<AppBar position="static">
<Toolbar>
<IconButton onClick={() => store.dispatch({type: Actions.CHANGE_UI})}>
<i className="mdi mdi-format-horizontal-align-right mdi-36px"></i>
</IconButton>
<Typography type="title">
{state.uiOpened == null ? "" : Config.controls[state.uiOpened].name}
</Typography>
</Toolbar>
</AppBar>
<List id="drawer_uiComponents">
{renderUi(state, state.uiOpened)}
</List>
</Drawer>
</div>
</MuiThemeProvider>
<SpaceMap width={1000} height={700} image="rzl.png" zoom={0} state={state} />
</div>
);
}
}
const App = appStyles(app);
store.subscribe(() => ReactDOM.render(<App state={store.getState()} />, document.getElementById("content")));
store.dispatch({type:null});
connectMqtt(Config.space.mqtt, store);

View file

@ -1,83 +0,0 @@
// @flow
import React from "react";
import { Map, ImageOverlay, Marker, LayersControl } from "react-leaflet";
import Leaflet from "leaflet";
import R from "ramda";
import Config from "./config";
import { Actions } from "./state";
import { store } from "./state";
// convert width/height coordinates to -height/width coordinates
const c = (p) => [-p[1], p[0]];
const color = (iconColor, state: State) => {
// TODO: give iconColor not only internal but also actual values
return iconColor == null ? "#000000" :
iconColor(
R.map(x => x.internal == null ?
x.actual : x.internal, state.values == null ? {} : state.values)
);
};
const iconHtml = (el, state: State) =>
"<i class=\"mdi mdi-" + el.icon + " mdi-36px\" style=\""
+ "color:" + color(el.iconColor, state) + ";\">"
+ "</i>";
const Markers = (props) => R.values(R.mapObjIndexed((el, key) => (
<Marker position={c(el.position)} key={el.name}
icon={Leaflet.divIcon(
{
html: iconHtml(el, props.state),
iconSize: Leaflet.point(36, 36),
iconAnchor: Leaflet.point(18, 18)
})}
onClick={(e) => store.dispatch({
type: Actions.CHANGE_UI,
payload: key,
toggle: e.originalEvent.ctrlKey})}>
</Marker>
), R.propOr({}, "controls", Config)));
type SpaceMapProps = {
state: State,
width: number,
height: number,
zoom: number,
image: string
};
class SpaceMap extends React.Component<SpaceMapProps> {
constructor(props: SpaceMapProps) {
super(props);
}
render() {
const props = this.props;
return (
<Map center={c([props.width / 2, props.height / 2])} zoom={props.zoom}
crs={Leaflet.CRS.Simple}>
{Markers(props)}
<LayersControl position="topright">
{Config.layers.map(x =>
this.renderLayer(x, [c([0, 0]), c([props.width, props.height])]))}
</LayersControl>
</Map>
);
}
renderLayer(layer, bounds) {
const LayersControlType =
layer.baseLayer ? LayersControl.BaseLayer : LayersControl.Overlay;
return (
<LayersControlType name={layer.name}
checked={layer.defaultVisibility === "visible"}>
<ImageOverlay url={layer.image}
bounds={bounds}
opacity={layer.opacity} />
</LayersControlType>
);
}
}
export default SpaceMap;

View file

@ -1,35 +0,0 @@
// @flow
import mqtt from "mqtt";
import { Actions } from "./state";
import { Store } from "redux";
import Config from "./config";
import R from "ramda";
export default function connectMqtt(url: string, store: Store<*, *>) {
const client = mqtt.connect(url);
client.on("connect", () => {
store.dispatch({
type: Actions.MQTT_CONNECT, payload: client
});
R.forEachObjIndexed(v =>
client.subscribe(v.state), Config.topics);
});
client.on("message", (topic, message) => {
store.dispatch({
type: Actions.MQTT_MESSAGE,
payload: {
message: message,
topic: topic
}
});
});
client.on("offline", () => {
store.dispatch({ type: null });
});
client.on("close", () => {
store.dispatch({ type: null });
});
client.on("reconnect", () => {
store.dispatch({ type: null });
});
}

View file

@ -1,81 +0,0 @@
// @flow
import R from "ramda";
import { createStore } from "redux";
import Config from "./config";
import { keyOf } from "./util";
import { onSwitch, isToggled } from "./UiItems";
export const Actions = Object.freeze({
MQTT_CONNECT: "CONNECT",
MQTT_MESSAGE: "MESSAGE",
CHANGE_UI: "UI_POPUP"
});
const initState : State = {
mqtt: null,
uiOpened: null,
values: R.map(
topic => {
return {
internal: keyOf(topic.values, topic.defaultValue),
actual: topic.defaultValue
};
}, Config.topics),
visibleLayers: []
};
const onMessage = (state: State, action: StateAction) => {
if (action.payload == null) {
return state;
}
/*
* action.payload.topic is the mqtt topic
* topics is the list of all internal topic references
* that have their state topic set to action.payload.topic
*/
const payload = action.payload == null ? { topic: "", message: {} }
: action.payload; // thx flow </3
const topics = R.keys(R.pickBy(
val => val.state === payload.topic, Config.topics));
const message = payload.message;
const parsedMessage = (topic: string) => {
let parseFunction = Config.topics[topic].parseState;
if (parseFunction == null) {
return message.toString();
} else {
return parseFunction(message);
}
};
const newValue = (topic: string) => {
return {
actual: parsedMessage(topic),
internal: keyOf(Config.topics[topic].values, parsedMessage(topic))
};
};
return R.mergeDeepRight(state, R.objOf("values", R.mergeAll(
R.map(topic => R.objOf(topic, newValue(topic)), topics)
)));
};
const match = (value: any, array: Map<any, any>) => array[value];
const handleEvent = (state: State = initState, action: StateAction) => {
return match(action.type, {
[Actions.MQTT_CONNECT]: R.merge(state, { mqtt: action.payload }),
[Actions.MQTT_MESSAGE]: onMessage(state, action),
[Actions.CHANGE_UI]: (() => {
const control = Config.controls[action.payload];
if (action.toggle && control.ui.length > 0
&& control.ui[0].type === "toggle") {
const props = control.ui[0];
onSwitch(props.topic, props, state)(null, !isToggled(state, props));
return state;
} else {
return R.merge(state, { uiOpened: action.payload });
}
})(),
[null]: state
});
};
export const store = createStore(handleEvent, initState);

View file

@ -1,5 +0,0 @@
// @flow
import R from "ramda";
export const keyOf = <a, b> (map: Map<b, a>, value: a): ?b =>
((keys) => keys[R.findIndex(k => map[k] === value, keys)])(R.keys(map));

8
src/utils/keyOf.js Normal file
View file

@ -0,0 +1,8 @@
// @flow
import _ from "lodash";
const keyOf = <a, b> (map: Map<a, b>, value: b): ?a => (
_.findKey(map, (x) => x === value)
);
export default keyOf;

View file

@ -0,0 +1,23 @@
// @flow
import React from "react";
import _ from "lodash";
export default function parseIconName(name: string): string {
return `mdi ${name.split(" ").map((icon) => "mdi-".concat(icon)).join(" ")}`;
}
export const renderIcon = (name: string, extraClass?: string) => {
return <i className={`${extraClass || ""} ${parseIconName(name)}`}></i>;
};
export const controlGetIcon = (control: Control, state: State): string => {
const internals = _.mapValues(state, (x) => x.internal || x.actual);
const actuals = _.mapValues(state, (x) => x.actual);
return typeof control.icon !== "function" ? control.icon
: control.icon(internals, actuals, state);
};
export const renderControlIcon = (control: Control,
state: State, extraClass?: string) => {
return renderIcon(controlGetIcon(control, state), extraClass);
};

View file

@ -1,5 +1,9 @@
declare type Map<K,V> = { [K]: V }; declare type Map<K,V> = { [K]: V };
declare type Classes = {
classes: Map<string, string>
};
declare type Topic = { declare type Topic = {
state: string, state: string,
command: string, command: string,
@ -9,37 +13,98 @@ declare type Topic = {
}; };
declare type Topics = Map<string,Topic>; declare type Topics = Map<string,Topic>;
declare type ControlUI = { declare type TopicDependentOption<T> = (
type: "toggle" | "dropDown" | "slider" | "section", internal: string, actual: any, state: State
) => T;
declare type StateDependentOption<T> = (
internals: Map<string, string>, actuals: Map<string, any>, state: State
) => T;
interface UIControl {
+type: string,
+text: string,
+topic: string
};
interface Enableable {
enableCondition?: TopicDependentOption<boolean>
};
declare type UIToggle = $ReadOnly<{|
type: "toggle",
text: string, text: string,
topic?: string, topic: string,
icon?: string, icon?: string,
enableCondition?: TopicDependentOption<boolean>,
on?: string,
off?: string,
toggled?: TopicDependentOption<boolean>
|}>;
enableCondition?: (internal: string, actual: any) => boolean, declare type UIDropDown = $ReadOnly<{|
type: "dropDown",
text: string,
topic: string,
icon?: string,
enableCondition?: TopicDependentOption<boolean>,
options: Map<string, any>,
renderValue?: (value: string) => string
|}>;
// LINK optiona properties declare type UISlider = $ReadOnly<{|
link?: string, type: "slider",
text: string,
// TOGGLE optional properties topic: string,
on?: string, // on override for toggle icon?: string,
off?: string, // off override for toggle enableCondition?: TopicDependentOption<boolean>,
toggled?: (internal: string, actual: any) => boolean,
// DROPDOWN optional properties
options?: Map<string,any>, //options for dropDown
renderValue?: (value: string) => string,
// SLIDER optional properties
min?: number, min?: number,
max?: number, max?: number,
step?: number step?: number
}; |}>;
declare type UISection = $ReadOnly<{|
type: "section",
text: string
|}>;
declare type UILink = $ReadOnly<{|
type: "link",
text: string,
link: string,
enableCondition?: StateDependentOption<boolean>,
// TODO: check if both the following options are implemented
icon?: string
|}>;
declare type UIText = $ReadOnly<{|
type: "text",
text: string,
topic: string,
icon?: string
|}>;
declare type ControlUI =
UIToggle
| UIDropDown
| UISlider
| UISection
| UILink
| UIText
declare type Control = { declare type Control = {
name: string, name: string,
position: Array<number>, position: [number, number],
icon: string, icon: string | (
iconColor?: (state: Map<string,any>) => string, internals: Map<string, string>,
actuals: Map<string, any>,
state: State
) => string,
iconColor?: (
internals: Map<string, string>,
actuals: Map<string, any>,
state: State
) => string,
ui: Array<ControlUI> ui: Array<ControlUI>
}; };
declare type Controls = Map<string,Control>; declare type Controls = Map<string,Control>;
@ -54,31 +119,39 @@ declare type Config = {
declare type Space = { declare type Space = {
name: string, name: string,
color: "red"|"pink"|"purple"|"deepPurple"|"indigo"|"blue"|"lightBlue"|"cyan"|"teal"| color: "red"|"pink"|"purple"|"deepPurple"|"indigo"|"blue"|"lightBlue"|"cyan"|"teal"|
"green"|"lightgreen"|"lime"|"yellow"|"amber"|"orange"|"deepOrange"|"brown"|"grey"|"blueGrey" "green"|"lightGreen"|"lime"|"yellow"|"amber"|"orange"|"deepOrange"|"brown"|"grey"|"blueGrey",
mqtt: string
}; };
declare type State = { declare type StateValue = {
mqtt: ?any, internal: string,
uiOpened: ?string, actual: any
};
declare type State = Map<string,StateValue>;
//declare type State = {
// mqtt: ?any,
// uiOpened: ?string,
// A map of the actual state values for each topic. // A map of the actual state values for each topic.
// internal is the internal term for the value, // internal is the internal term for the value,
// that is equal to the key in the values section of that // that is equal to the key in the values section of that
// topic, for example given by: // topic, for example given by:
// values: { off: "OFF", on: "ON" } // values: { off: "OFF", on: "ON" }
// and actual is the value of that or whatever is given by mqtt. // and actual is the value of that or whatever is given by mqtt.
values: Map<string, { internal: ?string, actual: any }>, // values: Map<string, { internal: ?string, actual: any }>,
visibleLayers: Array<string> // visibleLayers: Array<string>
}; //};
declare type Point = [number, number];
declare type Layer = { declare type Layer = {
image: string, image: string,
name: string, name: string,
baseLayer: boolean, baseLayer?: boolean,
defaultVisibility: "visible" | "hidden", defaultVisibility: "visible" | "hidden",
opacity: number opacity?: number,
}; bounds: {
topLeft: Point,
declare type StateAction = { bottomRight: Point
type: "DISCONNECT" | "CONNECT" | "MESSAGE" | "UI_POPUP", }
payload?: any
}; };

View file

@ -11,6 +11,7 @@ const preBuildScripts = process.env.NO_FLOW == undefined ?
module.exports = { module.exports = {
resolve: { resolve: {
modules: [path.resolve(__dirname, "src"), "node_modules"],
extensions: ['.js', '.jsx'] extensions: ['.js', '.jsx']
}, },
output: { output: {
@ -24,7 +25,7 @@ module.exports = {
}, },
plugins: [ plugins: [
new CleanWebpackPlugin(["dist"]), new CleanWebpackPlugin(["dist"]),
// new WebpackShellPlugin({onBuildStart:preBuildScripts}), new WebpackShellPlugin({onBuildStart:preBuildScripts}),
new HtmlWebpackPlugin({ new HtmlWebpackPlugin({
title: 'Space Map', title: 'Space Map',
template: 'index.ejs' template: 'index.ejs'

View file

@ -18,7 +18,7 @@ const extractCSS = ExtractTextPlugin.extract({
module.exports = merge(common, { module.exports = merge(common, {
entry: { entry: {
main: path.resolve(__dirname, 'src/index.jsx'), main: path.resolve(__dirname, 'src/index.jsx'),
vendor: ['react', 'material-ui', 'mqtt', 'ramda'] vendor: ['react', 'material-ui', 'mqtt', 'lodash']
}, },
module: { module: {
loaders: [ loaders: [

1587
yarn.lock

File diff suppressed because it is too large Load diff