效果如下图所示:

一、文件目录结构:

二、绘制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 textNumberRow = 11
      let tmptext = ''
      if (text) {
        for (let i = 0; i < text.length; i++) {
          if (i > 0 && i % textNumberRow == 0) {
            tmptext += '\n'
          }
          tmptext += text[i]
        }
      }
      return tmptext
    },
    getNewTextNode: function (PntA, text, PntZ, depth) {
      let tmptext
      if (depth == 1) {
        tmptext = text
      } else {
        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 = '12px 微软雅黑'
      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 - 15
      } 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 = 6 // 线宽
        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
      if (node.children) {
        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
      if (node.children) {
        for (let i = 0; i < node.children.length; i++) {
          this.resetY(node.children[i], x, y)
        }
      }
    },
    //水平翻转
    HorizontalFlip: function (node) {
      node.nodes.nodeA.x = -node.nodes.nodeA.x
      node.nodes.nodeZ.x = -node.nodes.nodeZ.x
      node.nodes.text.x = node.nodes.nodeA.x
      if (node.children) {
        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)
      if (node.children) {
        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
        //固定间隔
        let fixedInterval = 40
        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) {
              if (subnode.children.length === 0) {
                //没有子节点(固定间隔)
                width_left.push(fixedInterval), width_right.push(fixedInterval)
              } 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) {
              if (subnode.children.length === 0) {
                //没有子节点(固定间隔)
                height_up.push(fixedInterval), height_down.push(fixedInterval)
              } 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
        }
        if (d.children) {
          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>'
		+'&nbsp;<input type="radio" name="modeRadio" value="select" id="r2"/><label for="r2"> 框选</label>'
		+'&nbsp;<input type="radio" name="modeRadio" value="edit" id="r4"/><label for="r4"> 加线</label>'
		+'&nbsp;&nbsp;<input type="button" id="centerButton" value="居中显示"/>'
		+'<input type="button" id="fullScreenButton" value="全屏显示"/>'
		+'<input type="button" id="zoomOutButton" value=" 放 大 " />'
		+'<input type="button" id="zoomInButton" value=" 缩 小 " />'
		+'&nbsp;&nbsp;<input type="checkbox" id="zoomCheckbox"/><label for="zoomCheckbox">鼠标缩放</label>'
		+'&nbsp;&nbsp;<input type="text" id="findText" style="width: 100px;" value="" οnkeydοwn="enterPressHandler(event)">'
		+ '<input type="button" id="findButton" value=" 查 询 ">'
		+'&nbsp;&nbsp;<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>

 

GitHub 加速计划 / vu / vue
207.54 K
33.66 K
下载
vuejs/vue: 是一个用于构建用户界面的 JavaScript 框架,具有简洁的语法和丰富的组件库,可以用于开发单页面应用程序和多页面应用程序。
最近提交(Master分支:2 个月前 )
73486cb5 * chore: fix link broken Signed-off-by: snoppy <michaleli@foxmail.com> * Update packages/template-compiler/README.md [skip ci] --------- Signed-off-by: snoppy <michaleli@foxmail.com> Co-authored-by: Eduardo San Martin Morote <posva@users.noreply.github.com> 4 个月前
e428d891 Updated Browser Compatibility reference. The previous currently returns HTTP 404. 5 个月前
Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐