数据预处理 — 将文件转化为 csv 格式
此次实验有 3 个数据文件:company_node.txt
, management_edge.txt
以及 person_node.txt
。这些文件并不是标准的 csv 格式,而是以 \t
为分隔符,所以预处理的主要目的就是把文件转换为以 ,
为分隔符的 csv 文件。另外对于文件中出现的字符串,我们在其两端加上引号。用 R 处理的代码如下:
1 | library(tidyverse) |
节点数据的处理没有太大问题,但在处理关系数据时,我们发现读入的数据在 :END_ID
与 update_time
两列之前存在某些交错,如下图的第 15 行所示。并且,这还导致了数据读入的时候多了一列。
所以我们需要对于这些交错的行进行修正。这只需对 update_time
进行字符串匹配,并对匹配到的行进行交换即可。
1 | management_edge <- read_delim("src/management_edge.txt", "\t", escape_double = FALSE, trim_ws = TRUE) |
最后我们得到的数据文件格式如下:
company_node.csv
person_node.csv
management_edge.csv
将数据导入 neo4j
将数据转换成 csv 格式之后,导入到 neo4j 就比较简单了。这次实验数据文件并不大,我们也只是在本地环境 (Windows) 使用 neo4j。
neo4j 安装
首先从 neo4j 官网下载二进制压缩包 neo4j-community-3.5.4-windows.zip
,然后解压到 D:\ProgramData\neo4j-community-3.5.4
。之后配置环境变量:
NEO4J_HOME
: D:\ProgramData\neo4j-community-3.5.4
Path
(追加): %NEO4J_HOME%\bin
然后就可以从命令行启动 neo4j,启动命令为 neo4j server
。
导入数据
在运行上述命令之后,就可以访问网页端 http://localhost:7474/
。首次登陆需要修改密码,之后便可以正常使用。我们使用 LOAD CSV
方法导入数据,选择这个方法的原因一是由于这次数据量并不算大,不用在导入速率上做过多考虑;二是使用这种方法并不需要像其他很多方法一样需要在 neo4j 关闭服务时才能导入。为了导入数据,需要将预处理之后的 csv 文件放在 NEO4J_HOME\import
文件夹下,然后在网页端的命令交互环境执行下述 cypher 语句:
1 | USING PERIODIC COMMIT 300 |
上面的语句导入了公司节点,为验证成功导入,使用以下查询:
1 | MATCH (c:Company) RETURN c |
结果如图,这表明导入成功。
此外可以为这些数据创建索引:
1 | CREATE INDEX ON :Company(ID) |
同样的方法导入个人节点:
1 | USING PERIODIC COMMIT 300 |
以及创建索引:
1 | CREATE INDEX ON :Person(ID) |
之后是关系数据的导入:
1 | USING PERIODIC COMMIT 300 |
为了检验导入的效果,我们使用以下查询:
1 | MATCH (c:Company)-[:isManagedBy]->(p:Person) RETURN c,p |
得到的结果如下所示,这表明数据读取成功。
导出 json
为方便测试,我们还将 neo4j 中的数据导出成 json 格式,用到的是 apoc 工具。为使用这个工具,需要从 https://github.com/neo4j-contrib/neo4j-apoc-procedures/releases/3.5.0.2
中下载最新版本的的 jar 包,然后将其放在 NEO4J_HOME\plugins
文件夹下,并修改 NEO4J_HOME\conf\neo4j.conf
文件,添加如下两行:
1 | dbms.security.procedures.unrestricted=apoc.trigger.*,apoc.meta.* |
重启 neo4j,运行下述 cypher 语句,就可以在相应的路径下看到导出的文件。
1 | CALL apoc.graph.fromDB('test',{}) yield graph |
这个语句以 json 格式导出了所有的节点和关系,如下所示:
Node.js Express框架链接前端与Neo4j
基本准备
Node.js安装
官网下载并安装,基本操作。
下载后自带npm
,不需要再单独下载。环境变量在安装时已经被自动添加进路径中。
Express安装
命令行直接输入命令安装
1 | npm install express -g |
Express项目创建
在当前工作目录建立项目(比如我是在用户目录下)
1 | express -e neo4j_d3js |
-e表示ejs模板引擎,不写的话默认创建jade模板引擎。
1 | npm start |
一开始按照教程使用
node app
完全没反应,后来添加了app.listen(3000)
又提示被占用。于是查找后,发现使用上述命令不能添加监听端口。于是选择上述命令。
直接启动http://localhost:3000即可看见自己的网页。
为了让express也能识别html
视图文件,需要:
在app.js文件中,找到
app.set(‘view engine’, ‘ejs’);
把它替换成:
app.set(‘view engine’, ‘html’);
再用app.engine()方法注册模板引擎的后缀名。
app.engine(‘html’, require(‘ejs’).__express);
在view
文件夹下本身有一个index.ejs
文件,改后缀名为.html
即可,注意,error.ejs
也必须修改后缀名。
另外,在routes
文件夹下有index.js
文件,就用于路由设置的文件,直接在里面修改即可。
为了连接neo4j,还必须安装依赖包才能使用接口。
1 | npm install node-neo4j —save |
至此,所有外界安装就绪,接下来就是纯写代码工作了。
具体实现
环境创建与数据库链接
1 | var express = require('express'); |
此处首先引入了
express
环境,并且创建了一个路由router
,路由主要用于监听和定位不同的发送请求,相当于创建了类似虚拟文件夹的概念,不同的请求会发送到约定的路径中,由路由监听并作出相应反馈。
根目录设定与渲染
1 | router.get('/', function(req, res) { |
这是该文件创建时自带的语句。经过尝试,发现这个语句用于根目录的创建与渲染。因为当我删除这句话时,浏览器的日志会报错提示某些未定义的元素。之后又查阅了
res.render
用法——渲染一个view,同时向callback传递渲染后的字符串,如果在渲染过程中有错误发生next(err)将会被自动调用。callback将会被传入一个可能发生的错误以及渲染后的页面,这样就不会自动输出了。
因此这个函数是必不可少的。
初始化节点处理
1 | router.get('/getInitialNode', function (req, res){ |
此处路径与前端匹配。首先是利用JS自带的随机数函数生成0-1的随机数,3761代表我们处理过后导入neo4j中所有公司与个体的总和,我们对这个结果取整,用于初始点的选取。
之后是对neo4j查询语句,取出id标识匹配的数据并通过
res.send
函数返回相应的JSON
字符串至前端。
邻居节点扩展
1 | router.get('/spanNodes/*', function (req, res){ |
此处很关键的处理在于路径的匹配,网上教程无一例外均提到利用正则表达式实现参数的传递,相当于在某个路径下匹配所有字符串,而这个字符串就自动识别为
req.params
。不过这里也只实现了一个参数,多参数传递没有细看。在前端,这里的参数是需要扩展邻居的结点_id
值,直接用于查询语句,最终得到该节点所有的关系和邻居,同样方法返回。
Html与D3前端实现
SVG与力导向图
SVG基本参数设定
1 | var marge = {top:60,bottom:60,left:60,right:60} |
marge
表示画布边框距离;width
和height
为当前画图作用范围的长与宽;g
标签为SVG分组标签;设定transform
属性为顶端与左侧留出间距。
力导向图布局
1 | var forceSimulation = d3.forceSimulation() |
此处为新建力导向图,这是所有教程都会有的标准创建用法。但是在这里我在对
link
初始化方法时,将默认的source
与target
为自动分配的index改为了我从数据库读出来带有的_id
属性,当然这也是可以唯一区分节点的。否则直接使用会报错提示某些未定义元素的出现。
1 | forceSimulation.nodes(nodes).on("tick",ticked); |
第一行表示将
nodes
数组中的节点提取出来,赋给相应的索引、坐标和物理变量,用于定位与能量计算,同时每过一个时钟tick
就刷新一次节点的各项数据。第二行表示将
edges
数组中的边(关系)提取出来,此处设定了边的长度为一个固定值。第三行表示设定力导向图的中心位于整个画图范围的中间部位。
绘制
1 | var links = g.append("g") |
由于是初次画图,因此新增
line
分组,把边导入数据,此时明显只有数据没有元素,需要使用enter()
为新进入的数据分配元素(标签),并且赋予颜色与线宽。颜色来源见后续部分。
1 | var linksText = g.append("g") |
此处新建
text
标签,描述线上文字显示,即关系显示。从neo4j读出来的关系中title
表示原数据中的职位。
1 | var gs = g.selectAll(".circleText") |
此处为节点圆心的坐标和拖拽部分,这个在参考的教程中有提到,说圆心自身的坐标应当与圆半径、显示文字分开,具体理由不太能理解。
此处有定义基本的拖拽行为,这也是标准用法,具体的三个函数见后续部分。
1 | gs.append("circle") |
这部分为圆的主体部分,我将公司与个体区分开来,公司半径会更大,利于直观区分。填充颜色与线条颜色填充方法相同。
最后面的三个监听行为是前端的最核心部分,与express的交互和甲方需求都在这里实现。
1 | gs.append("text") |
此部分为圆上文字显示,内容为人名或公司名,属性为设置文字的起始显示位置。
函数模块
1 | function ticked(){ |
ticked
函数按照教程说法,用于不断更新边、边上文字、圆心的具体几何属性,在保证了基本几何参数正确的情况下,更便于做其他的操作,可以完全独立于相对位置来完成代码逻辑。
1 | function started(d){ |
这三个函数为拖拽函数的三个行为函数,这也是标准用法,直接复制教程。大体意思是,拖拽开始时,固定原始圆心坐标;过程中会不断更新固定坐标;最后结束时,不再固定坐标,而是能够根据力导向图自身的能量计算到达平衡状态。
fx = fixed x
。
1 | var colorScale = d3.scaleOrdinal() |
此处为颜色模版使用,以节点数组长度为作用域分配10色模版,每添加一个新的元素便能够触发分配。经过试验,在没有新添加节点或边的情况下,只会分配一种颜色。
利用express交互实现功能
初始化图形
此部分函数主要用于初始化画布,即随机选点与扩展邻居。同时会在双击行为时,满足一定条件时被调用,重新随机选取与扩展节点。因此,在逻辑部分,并不完全按照第一次随机选取实现,会有一些小处理。
1 | $.ajax({ |
此处基本为教程标准用法,大体含义就是针对express路由发送请求。此处发送初始节点请求,最大等待时间3秒,数据类型为
json
,关闭异步功能,若成功返回,则赋值给我的data
变量。其中最开始并没有增加关闭异步功能的语句,直到运行时一直出现如下错误
SCRIPT5007: Unable to get property '0' of undefined or null reference
但能够观察到data是有数据的,不存在找不到数组的情况。经过反复尝试,突然发现有的时候报错提示和控制台输出语句会交换顺序,于是怀疑可能是这二者是几乎同时执行的,经过一定的搜索和询问同学,最终解决了这个问题。
1 | var i; |
这部分逻辑用于判断随机选取的节点是否已经在画布中,如果存在不能反复添加,否则会出现两个相同的节点。此处如果节点已经存在必须递归调用此函数,直到找到未添加节点为止。这样写很不安全,一旦出现暴力攻击,将数据库节点使用完后,会无限循环调用函数,导致栈溢出。但此处是为了实验,也便于用户使用,才这样写的。否则必须增加节点数记录,判断是否已经用完节点。
1 | $.ajax({ |
此处类似初始节点过程,只不过这里传递了参数,
index
在上面定义,为选定节点的索引。
1 | data.data.forEach(function (neibor){ |
此处将返回的所有邻居遍历,由于此函数在前面的判断时,保证了选取的节点为新的节点,因此通过该节点生成的邻居也必然是新的,此处没必要判断边是否已经存在。数据存储好后,重新刷新画布。
扩展邻居
1 | function spanNodes(d, index){ |
变量
update_num
用于记录当前节点是否真的扩展了边,这有两种情况——该节点没有邻居和该节点已经被双击过了。无论是哪种,我们都不应该新添加边,并且需要调用initialGraph()
函数重新随机生成节点和邻居,保证每次双击都有新的元素出现。另外,在添加边时,还要判断边地另一端节点是否已经出现在画布中,如果出现了,不能反复添加节点,只需要添加新的边即可。
信息显示
1 | function showInfo(d, index){ |
第一二三行行为:鼠标移动到节点上,节点会变透明,0.7为透明程度,1表示不透明。
old_color
记录原始颜色,便于鼠标移开后恢复原始颜色。
layout
为实现设定的全局风格,作用域为所有body
。visible
表示将这部分文本显示出来。默认显示在浏览器画布的左下角。
信息隐藏
1 | function hideInfo(d, index){ |
此处将透明度复原,并且使用了延迟行为。
hidden
表示隐藏之前的文字。
刷新画布
1 | function restart(){ |
这部分基本和第一次初始化时相同,只有一些小细节不同。比如已经定义过的变量只需要更新即可,不要反复定义;再比如上方展示的,原变量只需要对新数据操作后与原数据合并即可,此时不能添加新的分组标签;最后是默认的重启函数,此处可以设置
alpha
值,默认是1,我就没作修改了。
结果展示
鼠标在空白处
左下角什么都没有显示。
鼠标移至公司
圆变透明了,并且左下角出现信息。
双击扩展
双击后如果没有邻居或者邻居已经显示出来了,就随机创建新的节点。并且这里,个体也变透明了,左下角有相应信息。