This commit is contained in:
futpib 2018-11-08 20:39:54 +03:00
parent a0e63e4f94
commit 5a07ba5f56
15 changed files with 513 additions and 67 deletions

View File

@ -2,4 +2,5 @@
module.exports = Object.assign(
{},
require('./pulse'),
require('./preferences'),
);

10
actions/preferences.js Normal file
View File

@ -0,0 +1,10 @@
const { createActions: createActionCreators } = require('redux-actions');
module.exports = createActionCreators({
PREFERENCES: {
SET: null,
TOGGLE: null,
RESET_DEFAULTS: null,
},
});

View File

@ -0,0 +1,8 @@
const r = require('r-dom');
const Button = props => r.button({
...props,
}, props.children);
module.exports = Button;

View File

@ -0,0 +1,15 @@
const r = require('r-dom');
const Checkbox = props => r.label({
classSet: { checkbox: true },
}, [
r.input({
...props,
type: 'checkbox',
}),
...[].concat(props.children),
]);
module.exports = Checkbox;

24
components/graph/base.js Normal file
View File

@ -0,0 +1,24 @@
const {
GraphView: GraphViewBase,
} = require('react-digraph');
class GraphView extends GraphViewBase {
constructor(props) {
super(props);
Object.assign(this, {
_super_handleNodeMove: this.handleNodeMove,
handleNodeMove: this.constructor.prototype.handleNodeMove.bind(this),
});
}
handleNodeMove(position, nodeId, shiftKey) {
this._super_handleNodeMove(position, nodeId, shiftKey);
if (this.props.onNodeMove) {
this.props.onNodeMove(position, nodeId, shiftKey);
}
}
}
module.exports = { GraphView };

View File

@ -5,6 +5,7 @@ const {
flatten,
memoizeWith,
pick,
filter,
} = require('ramda');
const React = require('react');
@ -14,17 +15,20 @@ const r = require('r-dom');
const { connect } = require('react-redux');
const { bindActionCreators } = require('redux');
const {
GraphView,
Edge,
} = require('react-digraph');
const math = require('mathjs');
const { Edge } = require('react-digraph');
const d = require('../../utils/d');
const { pulse: pulseActions } = require('../../actions');
const { getPaiByTypeAndIndex } = require('../../selectors');
const {
GraphView,
} = require('./satellites-graph');
Edge.calculateOffset = function (nodeSize, source, target) {
const arrowVector = math.matrix([ target.x - source.x, target.y - source.y ]);
const offsetLength = Math.max(0, Math.min((0.85 * size), (math.norm(arrowVector) / 2) - 40));
@ -71,6 +75,7 @@ const paoToNode = memoize(pao => ({
}));
const paiToEdge = memoize(pai => ({
id: key(pai),
source: sourceKey(pai),
target: targetKey(pai),
index: pai.index,
@ -112,34 +117,6 @@ const graphConfig = {
},
};
class D {
constructor(s = '') {
this._s = s;
}
_next(...args) {
return new this.constructor([ this._s, ...args ].join(' '));
}
moveTo(x, y) {
return this._next('M', x, y);
}
lineTo(x, y) {
return this._next('L', x, y);
}
close() {
return this._next('z');
}
toString() {
return this._s;
}
}
const d = () => new D();
const size = 120;
const s2 = size / 2;
@ -213,48 +190,48 @@ const renderNode = (nodeRef, data, key, selected, hovered) => r({
source: Source,
client: Client,
module: Module,
}[data.type], {
}[data.type] || Module, {
selected,
hovered,
});
const DebugText = ({ dgo, pai, open = false }) => r.div({
const DebugText = ({ dgo, pai, props }) => r.div({
style: {
fontSize: '50%',
},
}, [
open && JSON.stringify(dgo, null, 2),
open && JSON.stringify(pai, null, 2),
]);
}, props.preferences.showDebugInfo ? [
JSON.stringify(dgo, null, 2),
JSON.stringify(pai, null, 2),
] : []);
const SinkText = ({ dgo, pai }) => r.div([
const SinkText = ({ dgo, pai, props }) => r.div([
r.div({
title: pai.name,
}, pai.description),
r(DebugText, { dgo, pai }),
r(DebugText, { dgo, pai, props }),
]);
const SourceText = ({ dgo, pai }) => r.div([
const SourceText = ({ dgo, pai, props }) => r.div([
r.div({
title: pai.name,
}, pai.description),
r(DebugText, { dgo, pai }),
r(DebugText, { dgo, pai, props }),
]);
const ClientText = ({ dgo, pai }) => r.div([
const ClientText = ({ dgo, pai, props }) => r.div([
r.div({
}, pai.name),
r(DebugText, { dgo, pai }),
r(DebugText, { dgo, pai, props }),
]);
const ModuleText = ({ dgo, pai }) => r.div([
const ModuleText = ({ dgo, pai, props }) => r.div([
r.div({
title: pai.properties.module.description,
}, pai.name),
r(DebugText, { dgo, pai }),
r(DebugText, { dgo, pai, props }),
]);
const renderNodeText = dgo => r('foreignObject', {
const renderNodeText = props => dgo => r('foreignObject', {
x: -s2,
y: -s2,
}, r.div({
@ -274,6 +251,7 @@ const renderNodeText = dgo => r('foreignObject', {
}[dgo.type] || ModuleText, {
dgo,
pai: dgoToPai.get(dgo),
props,
})));
const afterRenderEdge = (id, element, edge, edgeContainer) => {
@ -289,6 +267,26 @@ class Graph extends React.Component {
this.state = {
selected: null,
};
Object.assign(this, {
onSelectNode: this.onSelectNode.bind(this),
onCreateNode: this.onCreateNode.bind(this),
onUpdateNode: this.onUpdateNode.bind(this),
onDeleteNode: this.onDeleteNode.bind(this),
onSelectEdge: this.onSelectEdge.bind(this),
onCreateEdge: this.onCreateEdge.bind(this),
onSwapEdge: this.onSwapEdge.bind(this),
onDeleteEdge: this.onDeleteEdge.bind(this),
});
}
shouldComponentUpdate(nextProps, nextState) {
return !(
(nextProps.objects === this.props.objects) &&
(nextProps.infos === this.props.infos) &&
(nextProps.preferences === this.props.preferences) &&
(nextState.selected === this.state.selected)
);
}
onSelectNode(selected) {
@ -296,7 +294,6 @@ class Graph extends React.Component {
}
onCreateNode() {
}
onUpdateNode() {
@ -322,17 +319,33 @@ class Graph extends React.Component {
onDeleteEdge() {}
render() {
const nodes = map(paoToNode, flatten(map(values, [
this.props.objects.sinks,
this.props.objects.sources,
this.props.objects.clients,
this.props.objects.modules,
])));
const edges = map(paiToEdge, flatten(map(values, [
this.props.infos.sinkInputs,
this.props.infos.sourceOutputs,
])));
const connectedNodeKeys = {};
edges.forEach(edge => {
connectedNodeKeys[edge.source] = true;
connectedNodeKeys[edge.target] = true;
});
const nodes = filter(node => {
if ((this.props.preferences.hideDisconnectedClients && node.type === 'client') ||
(this.props.preferences.hideDisconnectedModules && node.type === 'module') ||
(this.props.preferences.hideDisconnectedSources && node.type === 'source') ||
(this.props.preferences.hideDisconnectedSinks && node.type === 'sink')
) {
return connectedNodeKeys[node.id];
}
return true;
}, map(paoToNode, flatten(map(values, [
this.props.objects.sinks,
this.props.objects.sources,
this.props.objects.clients,
this.props.objects.modules,
]))));
nodes.forEach(node => {
if (node.x !== undefined) {
return;
@ -359,6 +372,7 @@ class Graph extends React.Component {
style: {},
}, r(GraphView, {
nodeKey: 'id',
edgeKey: 'id',
nodes,
edges,
@ -367,14 +381,14 @@ class Graph extends React.Component {
...graphConfig,
onSelectNode: this.onSelectNode.bind(this),
onCreateNode: this.onCreateNode.bind(this),
onUpdateNode: this.onUpdateNode.bind(this),
onDeleteNode: this.onDeleteNode.bind(this),
onSelectEdge: this.onSelectEdge.bind(this),
onCreateEdge: this.onCreateEdge.bind(this),
onSwapEdge: this.onSwapEdge.bind(this),
onDeleteEdge: this.onDeleteEdge.bind(this),
onSelectNode: this.onSelectNode,
onCreateNode: this.onCreateNode,
onUpdateNode: this.onUpdateNode,
onDeleteNode: this.onDeleteNode,
onSelectEdge: this.onSelectEdge,
onCreateEdge: this.onCreateEdge,
onSwapEdge: this.onSwapEdge,
onDeleteEdge: this.onDeleteEdge,
showGraphControls: false,
@ -384,14 +398,19 @@ class Graph extends React.Component {
renderDefs,
renderNode,
renderNodeText,
renderNodeText: renderNodeText(this.props),
afterRenderEdge,
}));
}
}
module.exports = connect(
state => state.pulse,
state => ({
objects: state.pulse.objects,
infos: state.pulse.infos,
preferences: state.preferences,
}),
dispatch => bindActionCreators(pick([
'moveSinkInput',
'moveSourceOutput',

View File

@ -0,0 +1,147 @@
/* global document */
const {
map,
prop,
groupBy,
flatten,
addIndex,
mapObjIndexed,
values,
} = require('ramda');
const React = require('react');
const r = require('r-dom');
const plusMinus = require('../../utils/plus-minus');
const {
GraphView: GraphViewBase,
} = require('./base');
const mapIndexed = addIndex(map);
const Satellite = () => r(React.Fragment);
const satelliteSpread = 36;
class GraphView extends React.Component {
constructor(props) {
super(props);
this.state = {
edgesByTargetNodeKey: {},
satelliteNodesByTargetNodeKey: {},
};
this.graph = React.createRef();
Object.assign(this, {
onSwapEdge: this.onSwapEdge.bind(this),
onNodeMove: this.onNodeMove.bind(this),
renderNode: this.renderNode.bind(this),
renderNodeText: this.renderNodeText.bind(this),
});
}
static getDerivedStateFromProps(props) {
const { nodeKey, edgeKey } = props;
const edgesByTargetNodeKey = groupBy(prop('target'), props.edges);
const satelliteNodesByTargetNodeKey = map(map(edge => ({
[nodeKey]: `${edge.target}__satellite__${edge[edgeKey]}`,
edge: edge[edgeKey],
source: edge.source,
target: edge.target,
type: 'satellite',
})), edgesByTargetNodeKey);
return { edgesByTargetNodeKey, satelliteNodesByTargetNodeKey };
}
static repositionSatellites(position, satelliteNodes) {
satelliteNodes.forEach((satelliteNode, i) => {
satelliteNode.x = position.x;
satelliteNode.y = position.y +
(satelliteSpread * plusMinus(i)) +
((satelliteSpread / 2) * ((satelliteNodes.length + 1) % 2));
});
}
onSwapEdge(sourceNode, targetNode, edge) {
this.props.onSwapEdge(sourceNode, targetNode, edge);
const { nodeKey } = this.props;
const createdEdgeId = `edge-${sourceNode[nodeKey]}-${targetNode[nodeKey]}-container`;
const createdEdge = document.getElementById(createdEdgeId);
createdEdge.remove();
this.graph.current.forceUpdate();
}
onNodeMove(position, nodeId, shiftKey) {
const { nodeKey } = this.props;
const satelliteNodes = this.state.satelliteNodesByTargetNodeKey[nodeId];
if (satelliteNodes) {
this.constructor.repositionSatellites(position, satelliteNodes);
satelliteNodes.forEach(satelliteNode => {
this.graph.current.handleNodeMove(satelliteNode, satelliteNode[nodeKey], shiftKey);
});
}
}
renderNode(nodeRef, dgo, key, selected, hovered) {
if (dgo.type !== 'satellite') {
return this.props.renderNode(nodeRef, dgo, key, selected, hovered);
}
return r(Satellite);
}
renderNodeText(dgo) {
if (dgo.type !== 'satellite') {
return this.props.renderNodeText(dgo);
}
return r(React.Fragment);
}
render() {
const { nodeKey } = this.props;
const { edgesByTargetNodeKey, satelliteNodesByTargetNodeKey } = this.state;
const nodes = flatten(map(node => {
const satelliteNodes = satelliteNodesByTargetNodeKey[node[nodeKey]] || [];
this.constructor.repositionSatellites(node, satelliteNodes);
return satelliteNodes.concat(node);
}, this.props.nodes));
const edges = flatten(values(mapObjIndexed((edges, target) => mapIndexed((edge, i) => ({
id: edge.id,
source: edge.source,
target: satelliteNodesByTargetNodeKey[target][i][nodeKey],
originalTarget: edge.target,
index: edge.index,
type: edge.type,
}), edges), edgesByTargetNodeKey)));
return r(GraphViewBase, {
...this.props,
ref: this.graph,
nodes,
edges,
onSwapEdge: this.onSwapEdge,
onNodeMove: this.onNodeMove,
renderNode: this.renderNode,
renderNodeText: this.renderNodeText,
});
}
}
module.exports = { GraphView };

View File

@ -0,0 +1,90 @@
const {
pick,
} = require('ramda');
const r = require('r-dom');
const { connect } = require('react-redux');
const { bindActionCreators } = require('redux');
const { withStateHandlers } = require('recompose');
const { preferences: preferencesActions } = require('../../actions');
const Button = require('../button');
const Checkbox = require('../checkbox');
const Preferences = withStateHandlers(
{
open: false,
},
{
toggle: ({ open }) => () => ({ open: !open }),
},
)(({ open, toggle, ...props }) => r.div({
classSet: {
preferences: true,
open,
},
}, open ? [
r.div([
r(Button, {
style: { width: '100%' },
onClick: toggle,
}, 'Close'),
]),
r.div([
r(Checkbox, {
checked: props.preferences.hideDisconnectedClients,
onChange: () => props.actions.toggle('hideDisconnectedClients'),
}, 'Hide disconnected clients'),
]),
r.div([
r(Checkbox, {
checked: props.preferences.hideDisconnectedModules,
onChange: () => props.actions.toggle('hideDisconnectedModules'),
}, 'Hide disconnected modules'),
]),
r.div([
r(Checkbox, {
checked: props.preferences.hideDisconnectedSource,
onChange: () => props.actions.toggle('hideDisconnectedSource'),
}, 'Hide disconnected source'),
]),
r.div([
r(Checkbox, {
checked: props.preferences.hideDisconnectedSinks,
onChange: () => props.actions.toggle('hideDisconnectedSinks'),
}, 'Hide disconnected sinks'),
]),
r.div([
r(Checkbox, {
checked: props.preferences.showDebugInfo,
onChange: () => props.actions.toggle('showDebugInfo'),
}, 'Show debug info'),
]),
r.div([
r(Button, {
style: { width: '100%' },
onClick: props.actions.resetDefaults,
}, 'Reset to defaults'),
]),
] : [
r(Button, {
onClick: toggle,
}, 'Props'),
]));
module.exports = connect(
state => pick([ 'preferences' ], state),
dispatch => ({
actions: bindActionCreators(preferencesActions, dispatch),
}),
)(Preferences);

View File

@ -5,6 +5,28 @@ body {
font: -webkit-control;
}
button {
background: var(--themeBgColor);
color: var(--themeTextColor);
border: 1px solid var(--borders);
user-select: none;
}
button:hover {
border-color: var(--themeSelectedBgColor);
}
button:focus {
outline: none;
border-color: var(--themeSelectedBgColor);
}
button:active {
background: var(--themeSelectedBgColor);
position: relative;
top: 1px;
}
.view-wrapper .graph {
background: var(--themeBaseColor);
}
@ -39,3 +61,32 @@ body {
.view-wrapper .graph .arrow {
fill: var(--successColor);
}
.preferences {
position: absolute;
right: 0;
top: 0;
bottom: 0;
padding: 1rem;
overflow: auto;
}
.preferences:not(.open) {
pointer-events: none;
}
.preferences:not(.open) > * {
pointer-events: initial;
}
.preferences.open {
background: var(--themeBgColor);
}
.preferences > div {
margin-bottom: 1rem;
}
.checkbox {
user-select: none;
}

View File

@ -2,13 +2,16 @@
const { combineReducers } = require('redux');
const { reducer: pulse, initialState: pulseInitialState } = require('./pulse');
const { reducer: preferences, initialState: preferencesInitialState } = require('./preferences');
const initialState = {
pulse: pulseInitialState,
preferences: preferencesInitialState,
};
const reducer = combineReducers({
pulse,
preferences,
});
module.exports = {

27
reducers/preferences.js Normal file
View File

@ -0,0 +1,27 @@
const {
merge,
} = require('ramda');
const { handleActions } = require('redux-actions');
const { preferences } = require('../actions');
const initialState = {
hideDisconnectedClients: true,
hideDisconnectedModules: true,
hideDisconnectedSources: false,
hideDisconnectedSinks: false,
showDebugInfo: false,
};
const reducer = handleActions({
[preferences.set]: (state, { payload }) => merge(state, payload),
[preferences.toggle]: (state, { payload }) => merge(state, { [payload]: !state[payload] }),
[preferences.resetDefaults]: () => initialState,
}, initialState);
module.exports = {
initialState,
reducer,
};

View File

@ -1,5 +1,7 @@
/* global document */
const React = require('react');
const r = require('r-dom');
const { render } = require('react-dom');
@ -9,14 +11,16 @@ const { Provider } = require('react-redux');
const createStore = require('./store');
const Graph = require('./components/graph');
const Preferences = require('./components/preferences');
const theme = require('./utils/theme');
const Root = () => r(Provider, {
store: createStore(),
}, [
}, r(React.Fragment, [
r(Graph),
]);
r(Preferences),
]));
Object.entries(theme.colors).forEach(([ key, value ]) => {
document.body.style.setProperty('--' + key, value);

30
utils/d/index.js Normal file
View File

@ -0,0 +1,30 @@
class D {
constructor(s = '') {
this._s = s;
}
_next(...args) {
return new this.constructor([ this._s, ...args ].join(' '));
}
moveTo(x, y) {
return this._next('M', x, y);
}
lineTo(x, y) {
return this._next('L', x, y);
}
close() {
return this._next('z');
}
toString() {
return this._s;
}
}
const d = () => new D();
module.exports = d;

4
utils/plus-minus.js Normal file
View File

@ -0,0 +1,4 @@
const plusMinus = i => Math.ceil(i / 2) * ((2 * ((i + 1) % 2)) - 1);
module.exports = plusMinus;

13
utils/plus-minus.test.js Normal file
View File

@ -0,0 +1,13 @@
import test from 'ava';
import { map, range } from 'ramda';
import plusMinus from './plus-minus';
test(t => {
t.deepEqual(
map(plusMinus, range(0, 7)),
[ 0, -1, 1, -2, 2, -3, 3 ],
);
});