commit
a23bef70db
29 changed files with 2053 additions and 1096 deletions
3
.babelrc
3
.babelrc
|
|
@ -2,4 +2,7 @@
|
||||||
"presets": [
|
"presets": [
|
||||||
"env", "react"
|
"env", "react"
|
||||||
],
|
],
|
||||||
|
"plugins": [
|
||||||
|
"transform-class-properties"
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
2
.gitignore
vendored
|
|
@ -1,2 +1,2 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
public/
|
dist/
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
}
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
27
package.json
27
package.json
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
149
src/UiItems.js
149
src/UiItems.js
|
|
@ -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>
|
|
||||||
);
|
|
||||||
|
|
@ -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
138
src/components/App.js
Normal 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);
|
||||||
109
src/components/ControlMap.js
Normal file
109
src/components/ControlMap.js
Normal 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
72
src/components/SideBar.js
Normal 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
42
src/components/TopBar.js
Normal 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)"}} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
214
src/components/UiItemList/UiItem.js
Normal file
214
src/components/UiItemList/UiItem.js
Normal 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} />
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
116
src/components/UiItemList/index.js
Normal file
116
src/components/UiItemList/index.js
Normal 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
57
src/connectMqtt.js
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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);
|
|
||||||
|
|
|
||||||
83
src/map.js
83
src/map.js
|
|
@ -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;
|
|
||||||
35
src/mqtt.js
35
src/mqtt.js
|
|
@ -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 });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
81
src/state.js
81
src/state.js
|
|
@ -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);
|
|
||||||
|
|
@ -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
8
src/utils/keyOf.js
Normal 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;
|
||||||
23
src/utils/parseIconName.js
Normal file
23
src/utils/parseIconName.js
Normal 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);
|
||||||
|
};
|
||||||
143
types/types.js
143
types/types.js
|
|
@ -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
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue