一、需求分析设计
笔者开发了一个在线考试系统,导师提出一个需求:添加对考试错题相关知识点的总结。
在question表中关联知识点的编号,题目可能关联多个知识点。这里笔者的设计是,只关联一个知识点,便于维护。
下面是知识点表格的设计:
masterID关联父级知识点,顶级知识点则为空。
二、思维导图组件
1、d3.js 插件准备
【包管理工具引入d3】:
yarn add d3 / npm install d3(推荐使用yarn)
【js方式引入d3】
d3.js 入学者文档连接
2、vue 思维导图组件
这个模块笔者也是从网上获得的样式,原作者写的不太标准,不过也感谢作者大大啦!!
<template><div :id="id"></div>
</template>
<script>import * as d3 from 'd3';export default {props: {data: Object,nodeWidth:{type: Number,default: 160},nodeHeight:{type: Number,default: 40},active:{type: String,default: ''}},watch:{data(curVal, oldVal) {this.$nextTick(() => {this.drawMap()})}},data() {return {id: 'TreeMap' + randomString(4),deep: 0,treeData: null,show: true,demoData: {"label": "中国",link: "demo",url: 'https://baike.baidu.com/item/%E4%B8%AD%E5%9B%BD/1122445?fr=aladdin',"children":[{"label": "浙江",disabled: true,"children":[{"label": "杭州"},{"label": "宁波"},{"label": "温州"},{"label": "绍兴"}]},{"label": "广西","children":[{"label": "桂林","children":[{"label": "秀峰区"},{"label": "叠彩区"},{"label": "象山区"},{"label": "七星区"}]},{"label": "南宁"},{"label": "柳州"},{"label": "防城港"}]},]}}},mounted() {this.$nextTick(() => {this.drawMap()})},methods: {drawMap() {let that = this// 源数据let data = {}// 判断data是否为空对象if (this.data && JSON.stringify(this.data) !== "{}") {data = this.data} else {data = this.demoData}if (!this.treeData) {this.treeData = data} else {// 清空画布d3.select('#' + this.id).selectAll("svg").remove();}let leafList = []getTreeLeaf(data, leafList)let leafNum = leafList.lengthlet TreeDeep = getDepth(data)// 左右内边距let mapPaddingLR = 10// 上下内边距let mapPaddingTB = 0let mapWidth = this.nodeWidth * TreeDeep + mapPaddingLR * 2;let mapHeight = (this.nodeHeight - 4) * leafNum + mapPaddingTB * 2;// 定义画布—— 外边距 10pxlet svgMap = d3.select('#' + this.id).append('svg').attr("width", mapWidth).attr("height", mapHeight).style("margin", "0px")// 定义树状图画布let treeMap = svgMap.append("g").attr("transform", "translate(" + mapPaddingLR + "," + (mapHeight / 2 - mapPaddingTB) + ")");// 将源数据转换为可以生成树状图的数据(有节点 nodes 和连线 links )let treeData = d3.tree()// 设置每个节点的尺寸.nodeSize(// 节点包含后方的连接线 [节点高度,节点宽度][this.nodeHeight, this.nodeWidth])// 设置树状图节点之间的垂直间隔.separation(function (a, b) {// 样式一:节点间等间距// return (a.parent == b.parent ? 1: 2) / a.depth;// 样式二:根据节点子节点的数量,动态调整节点间的间距let rate = (a.parent == b.parent ? (b.children ? b.children.length / 2 : 1) : 2) / a.depth// 间距比例不能小于0.7,避免间距太小而重叠if (rate < 0.7) {rate = 0.7}return rate;})(// 创建层级布局,对源数据进行数据转换d3.hierarchy(data).sum(function (node) {// 函数执行的次数,为树节点的总数,node为每个节点return node.value;}))// 贝塞尔曲线生成器let Bézier_curve_generator = d3.linkHorizontal().x(function (d) {return d.y;}).y(function (d) {return d.x;});//绘制边treeMap.selectAll("path")// 节点的关系 links.data(treeData.links()).enter().append("path").attr("d", function (d) {// 根据name值的长度调整连线的起点var start = {x: d.source.x,// 连线起点的x坐标// 第1个10为与红圆圈的间距,第2个10为link内文字与边框的间距,第3个10为标签文字与连线起点的间距y: d.source.y + 10 + (d.source.data.link ? (getPXwidth(d.source.data.link) + 10) : 0) + getPXwidth(d.source.data.label) + 10};var end = {x: d.target.x, y: d.target.y};return Bézier_curve_generator({source: start, target: end});}).attr("fill", "none").attr("stroke", "#c3c3c3")// 虚线// .attr("stroke-dasharray", "8").attr("stroke-width", 1);// 创建分组——节点+文字let groups = treeMap.selectAll("g")// 节点 nodes.data(treeData.descendants()).enter().append("g").attr("transform", function (d) {var cx = d.x;var cy = d.y;return "translate(" + cy + "," + cx + ")";});//绘制节点(节点前的圆圈)groups.append("circle")// 树的展开折叠.on("click", function (event, node) {let data = node.dataif (data.children) {data.childrenTemp = data.childrendata.children = null} else {data.children = data.childrenTempdata.childrenTemp = null}that.drawMap()}).attr("cursor", 'pointer').attr("r", 4).attr("fill", function (d) {if (d.data.childrenTemp) {return 'red'} else {return 'white'}}).attr("stroke", "red").attr("stroke-width", 1);//绘制标注(节点前的矩形)groups.append("rect").attr("x", 8).attr("y", -10).attr("width",function (d) {return d.data.link ? (getPXwidth(d.data.link) + 10) : 0}).attr("height", 22).attr("fill", "grey")// 添加圆角.attr("rx", 4)//绘制链接方式groups.append("text").attr("x", 12).attr("y", -5).attr("dy", 10).attr("fill", 'white').attr("font-size", 12).text(function (d) {return d.data.link;})//绘制文字groups.append("text").on("click", function (event, node) {let data = node.data// 被禁用的节点,点击无效if (data.disabled) {return}// 有外链的节点,打开新窗口后恢复到思维导图页面if (data.url) {window.open(data.url)that.$emit('activeChange', 'map')return}// 标准节点—— 传出 propif (data.dicType) {that.$emit('dicTypeChange', data.dicType)}// 标准节点—— 传出 propif (data.prop) {that.$emit('activeChange', data.prop)}}).attr("x", function (d) {return 12 + (d.data.link ? (getPXwidth(d.data.link) + 10) : 0)}).attr("fill",function (d) {if (d.data.prop === that.active) {return '#409EFF'}}).attr("font-weight",function (d) {if (d.data.prop === that.active) {return 'bold'}}).attr("font-size", 14).attr("cursor",function (d) {if (d.data.disabled) {return 'not-allowed'} else {return 'pointer'}}).attr("y", -5).attr("dy", 10).attr("slot", function (d) {return d.data.prop;}).text(function (d) {return d.data.label;})},},}// 获取树的深度function getDepth(json) {var arr = [];arr.push(json);var depth = 0;while (arr.length > 0) {var temp = [];for (var i = 0; i < arr.length; i++) {temp.push(arr[i]);}arr = [];for (var i = 0; i < temp.length; i++) {if (temp[i].children && temp[i].children.length > 0) {for (var j = 0; j < temp[i].children.length; j++) {arr.push(temp[i].children[j]);}}}if (arr.length >= 0) {depth++;}}return depth;}// 提取树的子节点,最终所有树的子节点都会存入传入的leafList数组中function getTreeLeaf(treeData, leafList) {// 判断是否为数组if (Array.isArray(treeData)) {treeData.forEach(item => {if (item.children && item.children.length > 0) {getTreeLeaf(item.children, leafList)} else {leafList.push(item)}})} else {if (treeData.children && treeData.children.length > 0) {getTreeLeaf(treeData.children, leafList)} else {leafList.push(treeData)}}}// 获取包含汉字的字符串的长度function getStringSizeLength(string) {//先把中文替换成两个字节的英文,再计算长度return string.replace(/[\u0391-\uFFE5]/g, "aa").length;}// 生成随机的字符串function randomString(strLength) {strLength = strLength || 32;let strLib = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz"let n = "";for (let i = 0; i < strLength; i++) {n += strLib.charAt(Math.floor(Math.random() * strLib.length));}return n}// 获取字符串的像素宽度function getPXwidth(str, fontSize = "12px", fontFamily = "Microsoft YaHei") {var span = document.createElement("span");var result = {};result.width = span.offsetWidth;result.height = span.offsetHeight;span.style.visibility = "hidden";span.style.fontSize = fontSize;span.style.fontFamily = fontFamily;span.style.display = "inline-block";document.body.appendChild(span);if (typeof span.textContent != "undefined") {span.textContent = str;} else {span.innerText = str;}result.width = parseFloat(window.getComputedStyle(span).width) - result.width;// 字符串的显示高度// result.height = parseFloat(window.getComputedStyle(span).height) - result.height;return result.width;}
</script>
这里主要说明一下数据格式:
"label": "知识点", //模块名称
"link": "知识", //标注信息
"url"://连接,
"children":[{"label": "分支1",//以此类推 …………
}]
其他vue模块使用该组件
<superMindmap v-if="showMindMap" :active='active' :data="mapData" @activeChange="activeChange"/>// 点击思维导图节点后,触发变量更新
activeChange(newLabel) {this.active = newLabelthis.reloadMindMap()
},// 重载思维导图
reloadMindMap() {this.showMindMap = falsethis.$nextTick(() => {this.showMindMap = true})
},
三、效果展示
查看对应的考试,即可获取错题知识点的思维导图