拼图 GUI 界面 AVATAR 实现

2017 年 10 月~2018 年 3 月在南洋理工大学 DMAL 实验室做 RA,参与 Graph Mining 相关项目。这个项目做的是从图/网络里提取一定数量的 pattern,让用户能够方便地构造数据集相关的 query。这个项目的目标有别于别的相关工作:

  1. pattern 对数据集有尽可能高的覆盖率
  2. pattern 间冗余度尽可能低

详细描述见导师发的文章 Data-driven Visual Graph Query Interface Construction and Maintenance: Challenges and Opportunities

到目前为止我已经实现了 pattern 提取的整个过程,接下来要做一些列实验和写论文。无论是实验或作品展示,都需要用户能够操作的 GUI 界面。但是实验室没有啊,怎么办?(其实实验室是有个用 Java Swing 做的半成品,Java 6 年代做的,研究了半天决定放弃)于是做数据挖掘的我又做起了熟悉的前端,前前后后花一个月做了出来。这篇文章介绍用 pattern 拼图的 GUI 界面实现(老板指定名字为 AVATAR,忘了问缘由)。至于 pattern 提取算法,等文章发出来再做介绍吧。

技术和框架

  • 作图库采用 Linkurious.js,它基于 Sigma.js,所以变量名保持为 sigma,尽管已经 deprecated,但它能正常使用而且提供 Sigma.js 没有的实用功能
  • 前端使用了 jQuery,jQuery UI,Font Awesome,Bootstrap v4 等扩展(才发现 Bootstrap v4 正式发布了,从官网学习了新特征,比 v3 用得方便)
  • 软件主要是前端的工作,后端比较简单,所以想试试用 Node.js 写后端,使用 Express 框架

功能和实现

上传、加载数据集

用户可以在 Upload datastore 窗口上传固定格式的数据集,让后台生成 pattern。数据集上传后,后台会异步地调用算法程序(C++ 编码)处理数据集,生成 pattern 等信息,通过文件输出等形式让前端跟踪进度。

用户可以在 Load datastore 窗口看到现有数据集和正在处理的数据集和处理的进度,可以加载现有的一个数据集。

我使用 child_process.spwan 接口进行异步调用。上传文件我使用了 connect-busboy 包,实现了以流的方式上传文件。网上的其他上传文件方式(比如 express-fileupload 包)我都尝试过,那些方法不但速度比不上 busboy,而且无法上传 1 GB 左右的大文件(后端会出错,具体消息忘了,大概意思是 memory 不足)。

1
2
3
4
5
6
7
8
9
10
11
12
13
req.busboy.on('file', (fieldname, file, filename) => {
let fstream = fs.createWriteStream(`${__dirname}/data/${name}/graph`)
file.pipe(fstream)
fstream.on('close', function () {
let davinci = spawn('./Davinci', [name, '-step1'])
davinci.on('close', (code) => {
utilities.appendLog(`${__dirname}/data/${name}/graph_log`, `child process exited with code ${code}`)
if (0 != code)
fs.writeFile(`${__dirname}/data/${name}/error`, 'Runtime error')
});
res.json({ success: true })
})
})

程序运行完会生成 GraphInfo.jsonlabels.jsonlimits.jsonpatterns.json 等文件,描述了前端所需要的信息。加载数据集后页面会显示数据集概况和标签。

生成和展示 pattern

加载数据集时候 pattern 已经有了,这一步用户在 Generate patterns 窗口指定数量和边数。

调用选择 pattern 的程序,这一步需要对 pattern 进行冗余选取,多次调用 boost 的图形库函数,尽管使用了多线程,速度也不是特别快,一分钟左右。

1
2
3
4
5
6
7
8
9
10
11
app.get('/datastores/:name/patterns', (req, res) => {
let name = req.params['name']
let num = req.query['num']
let minSize = req.query['minSize']
let maxSize = req.query['maxSize']
let davinci = spawn('./Davinci', [name, '-step2', num, minSize, maxSize])
davinci.on('close', (code) => {
let content = fs.readFileSync(`${__dirname}/data/${name}/patterns${num}-${minSize}-${maxSize}.json`, 'utf8')
res.json(JSON.parse(content))
})
})

图的分布是 NP 问题,界面的目标是尽可能清晰地展示 pattern 供用户使用,由于时间关系我不能花大量时间在图显示布局方面,对比现成的常用布局算法,我选用了 ForceAtlas2,Linkurious.js 提供这个布局插件。布局算法过后,我会适当旋转和拉伸点使得 pattern 尽可能地占满显示窗口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* Draw generated pattern in drawing board
*
* @param {Sigma} s
* @param {object} pattern
*/
function drawGeneratedPattern(s, pattern) {
let gap = PATTERN_NODE_SIZE * 5;
for (let i = 0; i < pattern.n; ++i)
addNode(s, Math.random() * gap * 2 - gap, i * gap, true);
pattern.e.forEach(pair => addEdge(s, pair[0], pair[1]));
// sigma.layouts.startForceLink(s, {}); // ForceLink has to be performed one by one
s.startForceAtlas2({}); // ForceAtlas2 is able to be performed simultaneously
setTimeout(() => {
// sigma.layouts.stopForceLink();
// sigma.layouts.killForceLink(); // must kill it!!!!
s.stopForceAtlas2(FORCE_ATLAS2_SETTINGS);
s.configNoverlap({});
s.startNoverlap();
setTimeout(() => {
rotateTofitContainer(s);
scaleTofitContainer(s);
}, LAYOUT_RUN_TIME);
}, LAYOUT_RUN_TIME);
}

添删点、边

点击左键添加点和边,点击右键删除点或边。通过监听鼠标事件实现。

此外,右键是删除功能,需要屏蔽浏览器默认的右键菜单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ***************************** remove node *****************************
$('#board').parent().on('contextmenu', (e) => { // disable right click menu on board
console.log('board contextmenu', e);
e.preventDefault();
}, false);
s.renderers[0].bind('rightClickNode', function (e) {
console.log('rightClickNode', e);
console.log('remove node ' + e.data.node.id);
if (inActiveState(s, e.data.node.id)) {
s.activeState.nodes().forEach(n => s.graph.dropNode(n.id));
} else
s.graph.dropNode(e.data.node.id);
s.refresh();
// fix bug: node remains after right click if mouse stays
$('#board .sigma-mouse').hide();
setTimeout(() => { $('#board .sigma-mouse').show(); }, 50);
});

选择、拖动图形

组合使用了 Linkurious.js 的 selectactiveStatedragNodes 插件实现基础功能。其中,同时拖动多个点的时候点的位置经常不准确(估计是插件的 bug),我只好重新计算点的坐标。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
s.dragListener.bind('startdrag', function (e) {
console.log('dragNodes startdrag', e);
draggingNodes = {};
draggingNodes[e.data.node.id] = [e.data.node.x, e.data.node.y];
if (1 == s.activeState.nbNodes()) {
beforeDragNodeId = s.activeState.nodes()[0].id;
} else {
beforeDragNodeId = undefined;
if (inActiveState(s, e.data.node.id) && s.activeState.nbNodes() > 1) { // fix bug of draging multiple nodes
draggingNodes = {};
dragDx = [];
dragDy = [];
s.activeState.nodes().forEach(n => {
draggingNodes[n.id] = [n.x, n.y];
dragDx.push(n.x - e.data.node.x);
dragDy.push(n.y - e.data.node.y);
});
}
}
});

lasso 插件提供了强大的多选工具:

添删标签(label)

标签分组显示,用户选定点后,双击标签给点添加或更改标签;如果想取消标签,选中点后,点击 Detach label 按钮。

添加 pattern

用户可以通过拖动把 pattern 添加到主画板,我使用 jQuery UI Draggable 实现拖动功能。自带的 clone 功能不能拷贝 <canvas>,所以我自己实现了拷贝 <canvas> 的功能,定义为插件的 helper 函数参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
/**
* Allow users to drag a pattern into drawing board
* Use jQuery Draggable plugin
*
* @param {string} sourceClass Pattern type ('sigma-default-pattern-container' or 'sigma-generated-pattern-container')
* @param {Sigma} s Target drawing board
*/
function enableDrag(sourceClass, s) {
$.each($('.' + sourceClass), (_, c) => {
$(c).draggable({
// deep clone canvas
// it costs much time! QAQ
helper: e => {
let clone = $(e.currentTarget).clone();
clone.children('canvas.sigma-scene')[0].getContext('2d').drawImage($(e.currentTarget).children('canvas.sigma-scene')[0], 0, 0);
return clone;
},
appendTo: 'div.container-fluid',
scroll: false,
cursor: 'move',
opacity: 0.7,
cursorAt: { // cursor at center
left: c.offsetWidth / 2,
top: c.offsetHeight / 2
},
start: e => {
console.log('dragstart', e);
},
stop: e => {
console.log('dragstop', e);
let rx = e.pageX - $(s.renderers[0].container).offset().left;
let ry = e.pageY - $(s.renderers[0].container).offset().top;
if (rx - c.offsetWidth / 2 > 0 && rx + c.offsetWidth / 2 < s.renderers[0].container.offsetWidth
&& ry - c.offsetHeight / 2 > 0 && ry + c.offsetHeight / 2 < s.renderers[0].container.offsetHeight) {
console.log('add pattern');
let x = rx - s.sigmaMouse.offsetWidth / 2;
let y = ry - s.sigmaMouse.offsetHeight / 2;
let p = s.camera.cameraPosition(x, y);
switch (e.target.id) {
case 'customized-hub-default-pattern':
addPatternCenter = { x: p.x, y: p.y };
$('#customized-hub-modal').modal('show');
break;
case 'customized-ring-default-pattern':
addPatternCenter = { x: p.x, y: p.y };
$('#customized-ring-modal').modal('show');
break;
default:
addPattern(s, $(e.target).data('sigmaObject'), p.x, p.y);
scaleTillClear(s, s.activeState.nodes()); // avoid node clique
}
}
}
});
});
}

安装

  1. Install Node.js
  2. Install Node.js packages via command npm install
  3. Start server via command node app.js
  4. Access web via http://localhost:3000 or http://<your_server_ip>:3000
0%