效果如下图所示:
一、文件目录结构:
二、绘制png的鱼头、鱼尾图片
fish_head.png: fish_tail.png:
三、下载js文件
jquery、jtopo请到官网下载。
四、编写鱼骨图核心文件:MakFishBone.js
let MakFishBone = (function (window) {
let MakFishBone = function (canvas, options) {
return new MakFishBone.fn.init(canvas, options)
}
MakFishBone.fn = MakFishBone.prototype = {
constructor: MakFishBone,
init: function (canvas, options) {
this.canvas = canvas
let dpr = window.devicePixelRatio || 1
canvas.width = parseInt(canvas.style.width) * dpr
canvas.height = parseInt(canvas.style.height) * dpr
this.ctx = canvas.getContext('2d')
let defaultConfig = {
/*json数据*/
data: null,
/*是否可以拖动,默认是true */
dragable: true,
/*是否显示工具条 */
showToolbar: true,
/* debug模式 */
debug: true,
//交错显示
stagger: true,
//背景
sceneBackgroundImage: null,
//单击节段回调
clickNodeCallback: null
}
this.cfg = $.extend(defaultConfig, options)
let stage = new JTopo.Stage(canvas)
this.stage = stage
//显示工具栏
showJTopoToobar(stage)
this.scene = new JTopo.Scene(stage)
},
getFishBoneNode: function (position, text) {
let jNode = new JTopo.Node(text || '')
jNode.shadow = false
// jNode.showSelected = false;
jNode.dragable = false
if (position) {
jNode.setLocation(position.x, position.y)
}
jNode.setSize(0, 0)
if (this.cfg.debug) {
jNode.setSize(1, 1)
}
return jNode
},
getNodeTextRect: function (node, text) {
this.ctx.font = node.font
let textArray = text.split('\n')
let maxLength = 0
maxText = textArray[0]
for (let i = 0; i < textArray.length; i++) {
let rowwidth = this.ctx.measureText(textArray[i]).width
if (rowwidth > maxLength) {
maxLength = rowwidth
maxText = textArray[i]
}
}
let lineHeight = this.ctx.measureText('田').width
return {
width: maxLength,
height: lineHeight * textArray.length,
lineHeight: lineHeight
}
},
//格式化文本节点值
getFormatText: function (text) {
let tmptext = ''
for (let i = 0; i < text.length; i++) {
if (i > 0 && i % 4 == 0) {
tmptext += '\n'
}
tmptext += text[i]
}
return tmptext
},
getNewTextNode: function (PntA, text, PntZ, depth) {
let tmptext = this.getFormatText(text)
let nodeText = new JTopo.TextNode(tmptext || '')
nodeText.shadow = false
//nodeText.showSelected = false;
nodeText.dragable = false
nodeText.fontColor = '40,40,40'
nodeText.font = '14px 微软雅黑'
nodeText.paint = function (a) {
a.beginPath()
a.font = this.font
a.strokeStyle = 'rgba(' + this.fontColor + ', ' + this.alpha + ')'
a.fillStyle = 'rgba(' + this.fontColor + ', ' + this.alpha + ')'
let textArray = this.text.split('\n')
let maxLength = 0
maxText = textArray[0]
for (let i = 0; i < textArray.length; i++) {
let rowwidth = a.measureText(textArray[i]).width
if (rowwidth > maxLength) {
maxLength = rowwidth
maxText = textArray[i]
}
}
this.width = maxLength
let lineHeight = a.measureText('田').width
this.height = lineHeight * textArray.length
let x = -this.width / 2
let y = -this.height / 2 + lineHeight
for (let j = 0; j < textArray.length; j++) {
a.fillText(textArray[j], x, y)
y += lineHeight
}
a.closePath()
}
let size = this.getNodeTextRect(nodeText, tmptext)
nodeText.textSize = size
let tx = 0, ty = 0
//设置中骨文本节点坐标
if (depth == 1) {
tx = PntZ.x + 15, ty = PntZ.y - 10
} else {
tx = PntA.x, ty = PntA.y
}
if (PntA.y == PntZ.y) {
//横线
tx -= size.width
ty -= size.lineHeight / 2
} else {
//斜线
tx -= size.width / 2
ty -= size.height
}
nodeText.setLocation(tx, ty)
this.scene.add(nodeText)
let nodeA = this.getFishBoneNode(PntA)
let nodeZ = this.getFishBoneNode(PntZ)
if (depth == 0) {
//获取鱼骨图,设置根节点x,y坐标
let img = new Image()
img.src = '/static/image/fish_head.png'
//图片加载完成之后执行
img.onload = function () {
nodeA.y = nodeA.y - img.height / 2
nodeZ.y = nodeZ.y - img.height / 2
nodeA.setImage('/static/image/fish_tail.png', true)
nodeZ.setImage('/static/image/fish_head.png', true)
}
}
this.scene.add(nodeA)
this.scene.add(nodeZ)
nodeZ.assPnt = nodeA
nodeA.assPnt = nodeZ
let link = new JTopo.Link(nodeA, nodeZ, '')
link.bundleOffset = 60 // 折线拐角处的长度
link.bundleGap = 20 // 线条之间的间隔
link.textOffsetY = 3 // 文本偏移量(向下3个像素)
if (depth == 0) {
link.lineWidth = 4 // 线宽
link.strokeColor = '8,147,117'
} else {
link.lineWidth = 2 // 线宽
link.strokeColor = '100,149,237'
}
this.scene.add(link)
return {nodeA: nodeA, nodeZ: nodeZ, link: link, text: nodeText}
},
resetX: function (node, x) {
node.nodes.nodeA.x += x
node.nodes.nodeZ.x += x
node.nodes.text.x += x
for (let i = 0; i < node.children.length; i++) {
this.resetX(node.children[i], x)
}
},
resetY: function (node, x, y) {
node.nodes.nodeA.x += x
node.nodes.nodeA.y += y
node.nodes.nodeZ.x += x
node.nodes.nodeZ.y += y
node.nodes.text.x += x
node.nodes.text.y += y
for (let i = 0; i < node.children.length; i++) {
this.resetY(node.children[i], x, y)
}
},
//水平翻转
HorizontalFlip: function (node) {
let size
if (node.name) {
size = this.getNodeTextRect(node, node.name)
}
node.nodes.nodeA.x = -node.nodes.nodeA.x
node.nodes.nodeZ.x = -node.nodes.nodeZ.x
node.nodes.text.x = -node.nodes.text.x + (size ? -size.width : 0)
for (let i = 0; i < node.children.length; i++) {
this.HorizontalFlip(node.children[i])
}
},
//垂直翻转
VerticalFlip: function (node) {
let size
if (node.name) {
let tmptext = this.getFormatText(node.name)
size = this.getNodeTextRect(node, tmptext)
}
node.nodes.nodeA.y = -node.nodes.nodeA.y
node.nodes.nodeZ.y = -node.nodes.nodeZ.y
node.nodes.text.y = -node.nodes.text.y + (size ? -size.height : 0)
for (let i = 0; i < node.children.length; i++) {
this.VerticalFlip(node.children[i])
}
},
//根据节点level值画节点
drawLevel: function (depth) {
if (depth < 0) {
return
}
let clevels = this.flatData.filter(x => x.level == depth)
//depth最小为0,偶数为横线,奇数为斜线
let isHorizontal = (depth % 2) === 0
for (let i = 0; i < clevels.length; i++) {
let arow = clevels[i]
let lineLength = 100
//筛选子节点
let chilnodes = []
let tnodes = []
for (let k = 0; k < this.AllTmpNode.length; k++) {
if (this.AllTmpNode[k].path.indexOf(arow.path + '_') === 0) {
chilnodes.push(this.AllTmpNode[k])
} else {
tnodes.push(this.AllTmpNode[k])
}
}
this.AllTmpNode = tnodes
if (isHorizontal) {
//横线
//先计算子节点宽度(分斜线左边部分,和斜线右边部分
let width_left = []
let width_right = []
let widthtotal = 0
for (let j = 0; j < chilnodes.length; j++) {
let subnode = chilnodes[j]
if (subnode.children.length === 0) {
//没有子节点(固定间隔30)
width_left.push(15), width_right.push(15)
} else if (subnode.children.length === 1) {
//1个子节点(半幅
width_left.push(Math.abs(subnode.children[0].nodes.nodeA.x))
width_right.push(0)
} else {
//多个子节点
let xleft = subnode.children[0].nodes.nodeA.x
let xright = subnode.children[0].nodes.nodeA.x
for (let k = 1; k < subnode.children.length; k++) {
let growNode = subnode.children[k].nodes.nodeA
if (growNode.x < xleft) {
xleft = growNode.x
}
if (growNode.x > xright) {
xright = growNode.x
}
}
width_left.push(Math.abs(xleft)), width_right.push(Math.abs(xright))
}
widthtotal += width_left[j] + width_right[j]
}
lineLength += widthtotal
//计算斜线的基础位置(0,0)作为目标点
let PntA = {x: -lineLength, y: 0}
let PntZ = {x: 0, y: 0}
arow.lineLength = lineLength
//返回4个节点
arow.nodes = this.getNewTextNode(PntA, arow.name, PntZ, depth)
this.AllTmpNode.push(arow)
//把它的子节点全部放到当前节点上
let newX = PntA.x
for (let j = 0; j < chilnodes.length; j++) {
let subnode = chilnodes[j]
newX += width_left[j]
this.resetX(subnode, newX)
newX += width_right[j]
}
if (i % 2 != 0) {
//右边(水平翻转整颗树)
this.HorizontalFlip(arow)
}
} else {
//斜线
//先计算子节点的高度(子节点的高度,上半部分和下半部分分开计算
let height_up = []
let height_down = []
let heighttotal = 0
for (let j = 0; j < chilnodes.length; j++) {
let subnode = chilnodes[j]
if (subnode.children.length === 0) {
//没有子节点(固定间隔30)
height_up.push(15), height_down.push(15)
} else if (subnode.children.length === 1) {
//1个子节点(半幅
height_up.push(subnode.children[0].lineLength)
height_down.push(0)
} else {
//多个子节点
let yTop = subnode.children[0].nodes.nodeA.y
let yBottom = subnode.children[0].nodes.nodeA.y
for (let k = 1; k < subnode.children.length; k++) {
let growNode = subnode.children[k].nodes.nodeA
if (growNode.y < yTop) {
yTop = growNode.y
}
if (growNode.y > yBottom) {
yBottom = growNode.y
}
}
height_up.push(Math.abs(yTop)), height_down.push(Math.abs(yBottom))
}
heighttotal += height_up[j] + height_down[j]
}
lineLength += heighttotal
//计算斜线的基础位置(0,0)作为目标点
let PntA = {x: -lineLength / 2, y: -lineLength}
let PntZ = {x: 0, y: 0}
arow.lineLength = lineLength
//返回4个节点
arow.nodes = this.getNewTextNode(PntA, arow.name, PntZ, depth)
this.AllTmpNode.push(arow)
//把它的子节点全部放到当前节点上
let newX = PntA.x
let newY = PntA.y
for (let j = 0; j < chilnodes.length; j++) {
newY += height_up[j]
newX += height_up[j] / 2
this.resetY(chilnodes[j], newX, newY)
newY += height_down[j]
newX += height_down[j] / 2
}
if (i % 2 != 0) {
//右上斜(垂直翻转整颗树)
this.VerticalFlip(arow)
}
}
}
//子元素花完了,画根元素
this.drawLevel(depth - 1)
},
start: function () {
let flatData = []
let maxdepth = 0
function dofloatdata (d, path, depth) {
d.level = depth
d.path = path
flatData.push(d)
if (depth > maxdepth) {
maxdepth = depth
}
for (let i = 0; i < d.children.length; i++) {
dofloatdata(d.children[i], path + '_' + i, depth + 1)
}
}
dofloatdata(this.cfg.data, '0', 0)
this.flatData = flatData
if (this.cfg.debug) {
console.log('maxdepth:' + maxdepth)
console.log(flatData)
}
this.AllTmpNode = []
this.drawLevel(maxdepth)
this.movePntS((this.cfg.data.lineLength + this.canvas.width) / 2, this.canvas.height / 2)
//居中显示
this.stage.centerAndZoom()
},
movePntS: function (x, y) {
for (let i = 0; i < this.scene.childs.length; i++) {
let a = this.scene.childs[i]
a.x += x
a.y += y
}
},
}
MakFishBone.fn.init.prototype = MakFishBone.fn
return MakFishBone
})(window)
五、jtopo工具栏toolbar.js
// 页面工具栏
function showJTopoToobar(stage){
var toobarDiv = $('<div class="jtopo_toolbar">').html(''
+'<input type="radio" name="modeRadio" value="normal" checked id="r1"/>'
+'<label for="r1"> 默认</label>'
+' <input type="radio" name="modeRadio" value="select" id="r2"/><label for="r2"> 框选</label>'
+' <input type="radio" name="modeRadio" value="edit" id="r4"/><label for="r4"> 加线</label>'
+' <input type="button" id="centerButton" value="居中显示"/>'
+'<input type="button" id="fullScreenButton" value="全屏显示"/>'
+'<input type="button" id="zoomOutButton" value=" 放 大 " />'
+'<input type="button" id="zoomInButton" value=" 缩 小 " />'
+' <input type="checkbox" id="zoomCheckbox"/><label for="zoomCheckbox">鼠标缩放</label>'
+' <input type="text" id="findText" style="width: 100px;" value="" οnkeydοwn="enterPressHandler(event)">'
+ '<input type="button" id="findButton" value=" 查 询 ">'
+' <input type="button" id="exportButton" value="导出PNG">');
$('#content').prepend(toobarDiv);
// 工具栏按钮处理
$("input[name='modeRadio']").click(function(){
stage.mode = $("input[name='modeRadio']:checked").val();
});
$('#centerButton').click(function(){
stage.centerAndZoom(); //缩放并居中显示
});
$('#zoomOutButton').click(function(){
stage.zoomOut();
});
$('#zoomInButton').click(function(){
stage.zoomIn();
});
$('#cloneButton').click(function(){
stage.saveImageInfo();
});
$('#exportButton').click(function() {
stage.saveImageInfo();
});
$('#printButton').click(function() {
stage.saveImageInfo();
});
$('#zoomCheckbox').click(function(){
if($('#zoomCheckbox').is(':checked')){
stage.wheelZoom = 1.2; // 设置鼠标缩放比例
}else{
stage.wheelZoom = null; // 取消鼠标缩放比例
}
});
$('#fullScreenButton').click(function(){
runPrefixMethod(stage.canvas, "RequestFullScreen")
});
window.enterPressHandler = function (event){
if(event.keyCode == 13 || event.which == 13){
$('#findButton').click();
}
};
// 查询
$('#findButton').click(function(){
var text = $('#findText').val().trim();
//var nodes = stage.find('node[text="'+text+'"]');
var scene = stage.childs[0];
var nodes = scene.childs.filter(function(e){
return e instanceof JTopo.Node;
});
nodes = nodes.filter(function(e){
if(e.text == null) return false;
return e.text.indexOf(text) != -1;
});
if(nodes.length > 0){
var node = nodes[0];
node.selected = true;
var location = node.getCenterLocation();
// 查询到的节点居中显示
stage.setCenter(location.x, location.y);
function nodeFlash(node, n){
if(n == 0) {
node.selected = false;
return;
};
node.selected = !node.selected;
setTimeout(function(){
nodeFlash(node, n-1);
}, 300);
}
// 闪烁几下
nodeFlash(node, 6);
}
});
}
var runPrefixMethod = function(element, method) {
var usablePrefixMethod;
["webkit", "moz", "ms", "o", ""].forEach(function(prefix) {
if (usablePrefixMethod) return;
if (prefix === "") {
// 无前缀,方法首字母小写
method = method.slice(0,1).toLowerCase() + method.slice(1);
}
var typePrefixMethod = typeof element[prefix + method];
if (typePrefixMethod + "" !== "undefined") {
if (typePrefixMethod === "function") {
usablePrefixMethod = element[prefix + method]();
} else {
usablePrefixMethod = element[prefix + method];
}
}
}
);
return usablePrefixMethod;
};
六、index.html引入js
七、组件封装:src\components\Jtopo.vue
<template>
<div id="content" style="width:100%">
<br/>
<canvas id="canvas" ref="canvas" style="background-color: rgb(238, 238, 238);width:1000px;height:600px"></canvas>
</div>
</template>
<script>
export default {
name: 'Jtopo',
props: {
fishboneData: {
type: Object
}
},
mounted () {
this.initTopo()
},
methods: {
initTopo () {
let canvas = this.$refs.canvas
if(this.fishboneData){
let mfb = new MakFishBone(canvas, {data: this.fishboneData})
mfb.start()
}
}
}
}
</script>
八、测试页面
<template>
<div>
<Jtopo :fishboneData="fishboneData"/>
</div>
</template>
<script>
import Jtopo from '../../components/Jtopo'
export default {
data () {
return {
fishboneData: null
}
},
name: 'Fishbone',
components: {Jtopo},
created () {
this.fishboneData = {
'children': [
{
'children': [
{
'children': [
{'children': [], 'name': '睡眠中迷糊', fontColor: '', lineColor: '', link: ''},
{'children': [], 'name': '意识不清', fontColor: '', lineColor: '', link: ''},
{'children': [], 'name': '精神异常', fontColor: '', lineColor: '', link: ''},
], 'name': '精神因素', fontColor: '', lineColor: '', link: 'http://www.baidu.com'
},
{
'children': [
{'children': [], 'name': '舒适度改变', fontColor: '', lineColor: '', link: ''},
{'children': [], 'name': '其它', fontColor: '', lineColor: '', link: ''}
], 'name': '依从性差', fontColor: '', lineColor: '', link: ''
},
{
'children': [
{'children': [], 'name': '自身理解', fontColor: '', lineColor: '', link: ''},
{'children': [], 'name': '护士指导', fontColor: '', lineColor: '', link: ''}
], 'name': '知识缺乏', fontColor: '', lineColor: '', link: ''
},
], 'name': '病人', fontColor: '', lineColor: '', link: ''
},
{
'children': [
{
'children': [
{'children': [], 'name': '缺乏安全意识', fontColor: '', lineColor: '', link: ''},
{'children': [], 'name': '自身知识不足', fontColor: '', lineColor: '', link: ''}
], 'name': '安全告知不到位', fontColor: '', lineColor: '', link: ''
},
{
'children': [
{'children': [], 'name': '工作责任心不强', fontColor: '', lineColor: '', link: ''},
{'children': [], 'name': '分级护理落实差', fontColor: '', lineColor: '', link: ''}
], 'name': '未及时发现安全隐患', fontColor: '', lineColor: '', link: ''
},
{
'children': [
{'children': [], 'name': '医生固定', fontColor: '', lineColor: '', link: ''}
], 'name': '违反管道护理常规', fontColor: '', lineColor: '', link: ''
},
{
'children': [
{'children': [], 'name': '分级护理交接班制度执行差', fontColor: '', lineColor: '', link: ''},
{'children': [], 'name': '医护沟通不足', fontColor: '', lineColor: '', link: ''},
{'children': [], 'name': '特殊病人、重点环节风险评估不足', fontColor: '', lineColor: '', link: ''}
], 'name': '约束措施、无力、不当', fontColor: '', lineColor: '', link: ''
}
], 'name': '医生护士', fontColor: '', lineColor: '', link: ''
},
{
'children': [
{'children': [], 'name': '粗心大意', fontColor: '', lineColor: '', link: ''},
{
'children': [
{'children': [], 'name': '对保护性约束', fontColor: '', lineColor: '', link: ''},
{'children': [], 'name': '对自行拔管可能带来的危害不清', fontColor: '', lineColor: '', link: ''}
], 'name': '家属随意终止约束', fontColor: '', lineColor: '', link: ''
}
], 'name': '家属', fontColor: '', lineColor: '', link: ''
},
{
'children': [
{
'children': [
{'children': [], 'name': '未沟通', fontColor: '', lineColor: '', link: ''}
], 'name': '质量问题', fontColor: '', lineColor: '', link: ''
},
{'children': [], 'name': '培训不足', fontColor: '', lineColor: '', link: ''},
{
'children': [
{'children': [], 'name': '护士长', fontColor: '', lineColor: '', link: ''}
], 'name': '监管不足', fontColor: '', lineColor: '', link: ''
},
{'children': [], 'name': '护士人力不足', fontColor: '', lineColor: '', link: ''}
], 'id': '1004', 'fid': '1', 'name': '管理', fontColor: '', lineColor: '', link: ''
}
], 'name': '管道脱落', fontColor: '', lineColor: '', link: ''
}
},
}
</script>
来源:CSDN
作者:FinelyYang
链接:https://blog.csdn.net/xiaoxiangzi520/article/details/103926013