2021년 5월 8일 토요일

node.js 기반 Visual Flow Programming 도구 소개

이 글은 텍스트 기반 코딩이 아닌 Visual Flow Programming (비쥬얼 플로우 프로그래밍) 도구를 소개한다. 여기서는 사용하기 편리한 node.js 기반 프로그래밍 도구를 공유한다. 

NoFlo
NoFlo는 Node.js 브라우저 모두에서 실행되는 JavaScript 용 흐름 기반 프로그래밍 구현이다. NoFlo 구성요소는 ES6를 포함하여 JavaScript로 변환되는 모든 언어로 작성할 수 있다. 이 시스템은 J. Paul Morrison의 저서 Flow-Based Programming(1994)에서 크게 영감을 받았다.
J. Paul Morrison

NoFlo는 웹 프레임 워크 나 UI 툴킷이 아니다. JavaScript 애플리케이션에서 데이터 흐름을 조정하고 재구성하는 방법이다. 
NoFlo는 웹 서버 및 도구 구축에서부터 GUI 애플리케이션 내부의 이벤트 조정, 로봇 구동 또는 인터넷에 연결된 예술 설치 구축에 이르기까지 모든 작업에 사용되고 있다.

NoFlo 자체는 JavaScript에서 흐름 기반 프로그램을 구현하기 위한 라이브러리이다. 설치 방법은 다음과 같으며, 소스 코드는 github에서 다운로드가능하다(Getting start).
npm install noflo --save

ComfyUI로 유명해진 노드 그래프 프로그래밍 라이브러리이다. 

다음 기능을 제공한다.
  • Canvas2D에서 렌더링(확대/축소 및 패닝, 복잡한 인터페이스를 쉽게 렌더링할 수 있으며 WebGLTexture 내에서 사용할 수 있음)
  • 사용하기 쉬운 편집기 (검색 상자, 키보드 단축키, 다중 선택, 상황에 맞는 메뉴 등)
  • 그래프당 수백 개의 노드를 지원하도록 최적화됨(편집기뿐만 아니라 실행 시에도 가능)
  • 사용자 지정 가능한 테마(색상, 모양, 배경)
  • 노드의 모든 작업/그리기/이벤트를 개인화하기 위한 콜백
  • 하위 그래프(그래프 자체를 포함하는 노드)
  • 라이브 모드 시스템 (그래프를 숨기지만 노드를 호출하여 원하는 대로 렌더링, UI 생성에 유용)
  • 그래프는 NodeJS에서 실행할 수 있음.
  • 사용자 정의 가능한 노드(색상, 모양, 슬롯 수직 또는 수평, 위젯, 사용자 지정 렌더링)
  • 모든 JS 응용 프로그램에 쉽게 통합 할 수 있음 (하나의 단일 파일, 종속성 없음).
  • Typescript 지원

Rete는 시각적 프로그래밍을 위한 모듈식 프레임 워크이다. Rete를 사용하면 브라우저에서 직접 노드 기반 편집기를 만들 수 있다. 사용자가 한 줄의 코드없이 편집기에서 데이터 처리 방식을 정의할 수 있다.
설치는 다음과 같다. 설명 문서는 여기를 참고하라.
npm install rete rete-vue-render-plugin rete-connection-plugin

이 도구는 리액트 기반 플로우 프로그래밍 지원 프레임웍이다. 사용이 쉽고, 커스터마이징 가능하다. 빠른 렌더링과 다양한 플러그인 컴포넌트를 지원한다. 아울러, 신뢰성 높은 타입스트립트를 지원한다. 

예제 실행은 다음과 같다.
코드샌드박스를 설치한다.
sudo npm install -g codesandbox

codesandbox 에서 https://codesandbox.io/s/6qgyt 예제 download 후 폴더에 압축 해제 후 적절한 node.js 버전을 설치한다.
sudo chown -R $USER /usr/local/lib/node_modules
sudo npm install -g n
sudo n 16.13.2

해당 패키지를 설치 후 실행한다.
sudo npm install --legacy-peer-deps
sudo npm i --force
PORT=3001 npm start

예제 실행 결과 (React flow example)

Drawflow를 사용하면 데이터 흐름을 쉽고 빠르게 만들 수 있다.

설치는 다음과 같다. 
npm i drawflow

Total.js flow 플랫폼을 이용하면, node-red와 같은 기능을 개발할 수 있다(소개). 

Reference
부록: litegraph 사용
이 부록은 간단한 그래프 및 속성창 추가 후, run, stop, load 클릭 시 적절히 동작하는 예제를 만든 것이다. 

라이트 그래프로 다양한 작업을 위해 다음을 고려한다.
  • custom node는 class로 파생 받아, 속성을 addInput 등으로 구현 한 후, onExecute, onDrawBackground 등을 구현
  • 비동기 데이터 처리는 setTimer 와 async, await 를 사용
  • 대용량 비동기 빅데이터는 websocket 사용하고, 서버와 클라이어트를 구현해, 이벤트 방식으로 처리
  • 기타, 노드의 동적 그래픽 처리, 각종 이벤트 처리는 문서가 별도 없으므로, 예제 소스코드 분석해 사용
다음 코드 입력후, http-server 를 실행한다.
<html>
<head>
    <title>custom lite graph app</title>
<meta http-equiv="X-UA-Compatible" content="chrome=1">
    <link rel="stylesheet" type="text/css" href="style.css">
<link rel="stylesheet" type="text/css" href="litegraph.css">
    <link rel="stylesheet" type="text/css" href="litegraph-editor.css">
<script type="text/javascript" src="litegraph.js"></script>
<script type="text/javascript" src="litegraph-editor.js"></script>
<style>
#propertyPane {
position: absolute;
top: 10px;
left: 10px;
width: 200px;
padding: 10px;
border: 1px solid #ccc;
background-color: #333; /* Dark gray background */
color: #ccc; /* Light gray text */
resize: both;
overflow: auto;
}
#propertyPane input {
background-color: #555; /* Darker gray for input background */
color: #ccc; /* Light gray text for input */
border: 1px solid #777; /* Border color for input */
}
#propertyPane label {
color: #ccc; /* Light gray text for labels */
}
</style>
</head>
<body style='width:100%; height:100%'>
<canvas id='mycanvas' width="1000" height="600"></canvas>
</br>
<button id="runButton">Run</button>
<button id="stopButton">Stop</button>
<button id="loadSoundButton">Load Sound Graph</button>
<button id="saveButton">Save Graph JSON</button>
<button id="loadGraphButton">Load Graph JSON</button>
<div id="propertyPane">
<h4>Properties</h4>
<div id="properties"></div>
</div>
<script>
class custom_multi_node {
constructor(){
this.title = "Multiplication";
this.addInput("A","number");
this.addInput("B","number");
this.addOutput("A*B","number");
this.properties = { precision: 0.1 };

this.weightHandle = this.addWidget("slider","Weight", 100, {min: 0, max: 400, step: 10, precision: 0})
this.addWidget("combo","flag", "on", {values: ["on","off"]})
this.addWidget("text", "Address", "macross flashback")
this.addWidget("button","Click me", "", function() { alert("button clicked") })
this.serialize_widgets = true
}

onExecute(){
let a = this.getInputData(0) || 0;
let b = this.getInputData(1) || 0;
let result = a * b * this.properties.precision * this.weightHandle.value;
this.setOutputData(0, result);
}

onPropertyChanged(name, value) {
console.log(`Property ${name} changed to ${value}`);
// alert(`Property ${name} changed to ${value}`);
}
}

class custom_visual_data {
count = 0;
bigdata_result = null;

constructor(){
this.title = "custom_visual_data";
this.addInput("A","number");
this.addOutput("A","number");
this.properties = { scale: 1.0 };
this.size = [250, 70];
}

onExecute(){
let a = this.getInputData(0) || 0;
a = a * this.properties.scale;
this.setOutputData(0, a);

this.count++;
this.count = this.count % 256;
this.setDirtyCanvas(true);
// this.trigger("onDrawBackground");

this.bigdata_result = null;
calculate_bigdata(this);
}

finish_bigdata(dataset) {
this.bigdata_result = dataset;
console.log("Finished async data calculation");
}

onAdded(){
}

onRemoved(){
}

onDropFile(file){
alert("File dropped: " + file.name);
}

onDrawBackground(ctx){
ctx.save(); // Save the current state
ctx.beginPath();
ctx.rect(0, 0, this.size[0], this.size[1]); // Define the clipping region
ctx.clip();

let blue = Math.max(0, 255 - this.count % 256);
let orange = Math.min(255, this.count % 256);
ctx.fillStyle = `rgb(${orange}, ${Math.floor(orange / 2)}, ${blue})`; // Smooth transition from blue to orange
ctx.strokeStyle = "#808080"; // Gray color
ctx.beginPath();
ctx.arc(50,20,10,0,Math.PI*2);

if(this.bigdata_result){
ctx.font = "12px Arial";
ctx.fillStyle = "orange";
ctx.fillText("BigData: " + this.bigdata_result, 40, 50);
}

ctx.fill();
ctx.stroke();

ctx.restore(); // Restore the previous state */
}
}

async function processLargeData(data) {
    return new Promise((resolve) => {
        console.log("starting async data processing");
        setTimeout(() => {
            const processedData = data.map((item) => item * 2); // data double scale
            console.log("finished async data processing");
            resolve(processedData);
        }, 3000); // processing time estimation
    });
}

async function calculate_bigdata(node) {
const data = [1, 2, 3, 4, 5].map(item => item + node.count); // input data for example
    const processedData = await processLargeData(data); // async data processing
console.log("Processed data:", processedData);
node.finish_bigdata(processedData);
}

LiteGraph.registerNodeType("custom/multiply", custom_multi_node);
LiteGraph.registerNodeType("custom/VisualData", custom_visual_data);

var graph = new LGraph();
var canvas = new LGraphCanvas("#mycanvas", graph);
graph.allow_scripts = true;
canvas.allow_searchbox = true;   

/* 
update_canvas_HiPPI();
window.addEventListener("resize", function() { 
canvas.resize();
update_canvas_HiPPI();
});

function update_canvas_HiPPI() {
  const ratio = window.devicePixelRatio;
  if(ratio == 1) 
  return;
  const rect = editor.canvas.parentNode.getBoundingClientRect();
  const { width, height } = rect;
  canvas.width = width * ratio;
  canvas.height = height * ratio;
  canvas.style.width = width + "px";
  canvas.style.height = height + "px";
  canvas.getContext("2d").scale(ratio, ratio);
  return canvas;
}
*/

var nodeConstA = LiteGraph.createNode("basic/const");
nodeConstA.pos = [100,200];
nodeConstA.setValue(4.5);
graph.add(nodeConstA);

var nodeConstB = LiteGraph.createNode("basic/const");
nodeConstB.pos = [100,300];
nodeConstB.setValue(1.5);
graph.add(nodeConstB);

var nodeMult = LiteGraph.createNode("custom/multiply");
nodeMult.pos = [350,200];
graph.add(nodeMult);

var nodeWatch = LiteGraph.createNode("basic/watch");
nodeWatch.pos = [600,200];
graph.add(nodeWatch);

var nodeAsync = LiteGraph.createNode("custom/VisualData");
nodeAsync.pos = [600,300];
graph.add(nodeAsync);

// All nodes must be in the graph before connections can be made.
nodeConstA.connect(0, nodeMult, 0);
nodeConstB.connect(0, nodeMult, 1);
nodeMult.connect(0, nodeWatch, 0);
nodeMult.connect(0, nodeAsync, 0);

// Add event listener to the run button
document.getElementById("runButton").addEventListener("click", function() {
graph.start();
});

// Add event listener to the stop button
document.getElementById("stopButton").addEventListener("click", function() {
graph.stop();
});

// Add event listener to show properties when a node is clicked
canvas.onNodeSelected = function(node) {
var propertiesDiv = document.getElementById("properties");
propertiesDiv.innerHTML = "";
for (var property in node.properties) {
if (node.properties.hasOwnProperty(property)) {
var input = document.createElement("input");
input.type = "text";
input.value = node.properties[property];
input.onchange = (function(property, node) {
return function(event) {
node.setProperty(property, event.target.value);
};
})(property, node);
var label = document.createElement("label");
label.innerText = property;
propertiesDiv.appendChild(label);
propertiesDiv.appendChild(input);
propertiesDiv.appendChild(document.createElement("br"));
}
}
};

document.getElementById("loadSoundButton").addEventListener("click", function() {
fetch('audio_delay.json') 
.then(response => response.json())
.then(data => {
graph.configure(data);
alert('Sound graph loaded');
})
.catch(error => console.error('Error loading JSON:', error));
});

document.getElementById("loadGraphButton").addEventListener("click", function() {
fetch('saved_graph.json') 
.then(response => response.json())
.then(data => {
graph.configure(data);
alert('saved_graph loaded');
})
.catch(error => {
console.error('Error loading JSON:', error);
alert('Error loading JSON: ' + error.message);
});
});

document.getElementById("saveButton").addEventListener("click", function() {
var data = JSON.stringify(graph.serialize());
var blob = new Blob([data], { type: 'application/json' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'saved_graph.json';
a.click();
URL.revokeObjectURL(url);
});
</script>
</body>
</html>

전체 화면에 그래프 캔버스 크기를 맞출려면, 위 소스에서 update_canvas_HiPPI() 부분 함수 리마크 처리를 해제한다.

결과는 다음과 같다. 

참고로, JSON 은 위젯 및 속성 리스트를 담고 있다. 
{
    "last_node_id": 7,
    "last_link_id": 7,
    "nodes": [
        {
            "id": 6,
            "type": "widget/knob",
            "pos": [
                199,
                296
            ],
            "size": [
                64,
                84
            ],
            "flags": {},
            "order": 3,
            "mode": 0,
            "outputs": [
                {
                    "name": "",
                    "type": "number",
                    "links": [
                        6
                    ]
                }
            ],
            "properties": {
                "min": 0,
                "max": 2,
                "value": 0.8799999999999999,
                "color": "#7AF",
                "precision": 2,
                "wcolor": "#7AF",
                "size": 50
            },
            "boxcolor": "rgba(112,112,112,1.0)"
        }
    ]
}

라이트그래프 레퍼런스

댓글 4개:

  1. Thank you for sharing the valuable information aboutbnode.js-based programming tool. For on demand Node JS Development Services, visit us.

    답글삭제
  2. 교수님, 정보 정말 감사합니다. 최근에 nodered로 간단하게 데쉬보드 블럭을 추가하면서 이렇게 활용할 수 있는 다른 노드툴들이 뭐있나 싶었는데...정말 정리 잘해주셨네요. ^^ 정말 감사합니다.

    답글삭제