赞
踩
太久没有更新了,主要最近行情不太好失业了一段时间,一度到怀疑人生,然后就是做的东西大多没有什么含金量,没什么好分享的就很尴尬。
刚好最近遇到一个奇葩的需求,一个基地管理的需求,由于项目中的基地很偏,地图上都定位不到,只能通过一个图片作为底图,然后在上面绘制一些图层,需要做一个自定义编排的需求,先上图:
上面是实现的demo,首先上html结构代码(技术栈:V3+TS+elementplus)
<div class="massif-box"> <!-- 左侧栏 --> <div class="asidebox"> <div class="topbar"> <div class="tabitem" @click="changetype(item)" v-for="item in typelist" :key="item.code" :class="{cur:item.code == curtype}"> {{ item.name }}</div> </div> <div class="barcontent"> <div class="searchbar"> <el-input v-model="searchvalue" style="width:calc(100% - 20px)" placeholder="请输入关键字" :suffix-icon="Search" /> </div> <div class="block-content"> <div class="block-item" v-for="(item) in curlist" :key="item.id" :class="{cur:item.id == cur!.id}" @click.capture="selectitem(item)"> <div class="imgbox"></div> <div class="text">{{ item.name }}</div> <el-tag type="success" class="tag">陆基</el-tag> <template v-if="item.id == cur.id"> <el-icon class="icon" @click.stop="editattr"><EditPen /></el-icon> <el-icon class="icon" @click.stop="removevnode"><Delete /></el-icon> </template> </div> </div> <button @click="tojson">画布转json</button> <button @click="tocanvas">json回显画布</button> </div> </div> <!-- 右边内容区域 --> <div class="basecontent" ref="basecontent" @drop="drop" @dragover="dragOver"> <!-- 画布容器 --> <canvas id="canvas"></canvas> <!-- 可拖拽元素 --> <div class="toolone" @dragstart.capture="onStart"> <el-tooltip class="box-item" effect="dark" content="地块" placement="right" > <div class="item-one"> <img :src="massifimg" alt="" :draggable="true"/> </div> </el-tooltip> <el-tooltip class="box-item" effect="dark" content="塘口" placement="right" > <div class="item-two"> <img :src="pondimg" alt="" :draggable="true"/> </div> </el-tooltip> <el-tooltip placement="right-start" class="custom-tooltip" effect="light" > <template #content> <div class="tip-box" @dragstart.stop="onStart"> <div class="device-one tip-item"> <img :src="video" alt="" :draggable="true"/> <div>xxx</div> </div> <div class="device-two tip-item"> <img :src="onedevice" alt="" :draggable="true"/> <div>yyyy</div> </div> <div class="device-three tip-item"> <img :src="video" alt="" :draggable="true"/> <div>mmmm</div> </div> </div> </template> <div class="item-three"> <img :src="deviceimg" alt=""/> </div> </el-tooltip> </div> <!-- 右下角工具元素 --> <div class="tooltwo"> <div class="top"> <img :src="layerimg" alt="" @click="openlyer"/> </div> <div class="center"> <img :src="daohangimg" alt="" /> <img :src="screenimg" alt="" /> <img :src="reductionimg" alt=""/> </div> <div class="bottom"> <img :src="addimg" alt="" @click="zoomIn" /> <img :src="minusimg" alt="" @click="zoomOut"/> </div> </div> <ponddialog :pondparams="circleparams" ref="pondDialog" @get-value="updatecanvas"/> <massifdialog :massifparams="rectparams" ref="massifDialog" @get-value="updatecanvas"/> <devicedialog :deviceparams="deviceparams" ref="deviceDialog" @get-value="updatecanvas"/> <layerdialog :layerlist="layerlist" ref="layerDialog" @get-visible="updatevisible"/> </div> </div>
结构分为左侧菜单栏和右侧画布区域,通过左上角的图标拖拽到画布上生成图形,选中图形弹出属性设置框,可以调制样式或更新数据。整个画布也可以转成json存储,通过json也可以回显画布。
import { EditPen, Plus, Delete, Search } from '@element-plus/icons-vue'; EditPen Plus Delete Search //弹框组件 import ponddialog from './ponddialog.vue'; import massifdialog from './massifdialog.vue'; import devicedialog from './devicedialog.vue'; import layerdialog from './layerdialog.vue'; //图片 import massifimg from'@/assets/imgs/massif/massif.png'; import pondimg from'@/assets/imgs/massif/pond.png'; import deviceimg from'@/assets/imgs/massif/device.png'; import addimg from'@/assets/imgs/massif/add.png'; import minusimg from'@/assets/imgs/massif/minus.png'; import screenimg from'@/assets/imgs/massif/screen.png'; import reductionimg from'@/assets/imgs/massif/reduction.png'; import daohangimg from'@/assets/imgs/massif/daohang.png'; import layerimg from'@/assets/imgs/massif/layer.png'; import video from'@/assets/imgs/massif/video.png'; import onedevice from'@/assets/imgs/massif/onedevice.png'; import basemap from'@/assets/imgs/massif/basemap2.png'; //画布插件 import * as fabric from 'fabric'; //生成唯一id方法 import { generateUUID } from '@/utils' //参数类型声明 import {Rectparams,Circleparams,DeviceParams,p, Curparams} from './types' // 画布区域的父级元素 const basecontent = ref<HTMLElement>(); //canvas实例 let canvas: fabric.Canvas; //搜索值 let searchvalue = ref<string>(''); //以下是左侧列表的相关数据 //图层的类型 const typelist = ref<{name:string,code:string}[]>([{name:'地块',code:'Rect'},{name:'塘口',code:'Circle'},{name:'设备',code:'Device'}]); //当前图层类型 const curtype = ref<string>('Rect'); //所有图层数组 let vnodelist = ref<Curparams[]>([]) //选择类型 const changetype = (item:{name:string,code:string})=>{ curtype.value = item.code; } //根据类型过滤出当前列表 let curlist = computed(()=>{ let list = vnodelist.value.filter(item => (item.types == curtype.value && item.name.startsWith(searchvalue.value))); return list }) //生命周期初始化画布 onMounted(() => { initFabricCanvas(drawbasemap);//drawbasemap是绘制地图的 }); //图层弹框数据 let layerlist = ref<Array<fabric.Object & p >>([] as Array<fabric.Object & p >) watch(()=>vnodelist.value,()=>{ layerlist.value = canvas!.getObjects() as Array<fabric.Object & p> },{ deep:true }) //画布初始化操作 function initFabricCanvas(callback) { if (!basecontent.value) return; canvas = new fabric.Canvas('canvas', { width: basecontent.value.offsetWidth, height: basecontent.value.offsetHeight, preserveObjectStacking:true }); callback && callback() } //绘制底图 地图就是最底层的假地图图片,所以需要默认先绘制 const drawbasemap = ()=>{ const img = new Image(); img.src = basemap; let id = generateUUID(); img.onload = () => { const imgLayer = new fabric.Image(img, { selectable:false, hasControls:false, left: 0, top: 0, scaleX: canvas!.width / img.width, scaleY: canvas!.height / img.height, z: 1, id, types:'Base' }); canvas!.add(imgLayer); } } //开始拖拽事件,根据classname判断拖拽的元素,不同的classname传递不同的type function onStart(e){ let classname = ref<string>('') classname.value = e.target.parentElement.className.split(' ')[0]; switch (classname.value) { case 'item-one': e.dataTransfer.setData('type', 'Rect'); break; case 'item-two': e.dataTransfer.setData('type', 'Circle'); break; case 'device-one': e.dataTransfer.setData('type', 'device-one'); break; case 'device-two': e.dataTransfer.setData('type', 'device-two'); break; case 'device-three': e.dataTransfer.setData('type', 'device-three'); break; default: break; } } //拖拽过程中阻止默认事件 function dragOver(e){ e.preventDefault(); } //拖拽完成绘制图形 function drop(e) { let types = ref<string>(''); types.value = e.dataTransfer.getData('type');//这里拿到拖拽开始事件传递过来的type let vnode:fabric.Object; let id = generateUUID(); switch (types.value) {//根据type绘制不同的图形 case 'Rect': let objone = { selectable: true, // 是否可选 hasControls:false, top:(e.pageY - (e.pageY - e.offsetY))/scale.value, left:(e.pageX - (e.pageX - e.offsetX))/scale.value,//创建对象的x坐标 width: 150, //宽和高 height: 300, fill:'rgba(73, 120, 236,0.6)', //填充颜色 stroke:'rgba(38, 162, 234,1)', //线条颜色 strokeWidth: 4, //线条宽度 strokeOpacity:0.5, types:types.value, id, name:'地块', z:2, classify:'a', area:2, zoomX:scale.value, zoomY:scale.value, angle:0, visible:true } vnode = new fabric.Rect(objone) // 开始绘制 canvas!.add(vnode); //添加到画布中去 vnodelist.value.push(objone); break; case 'Circle': let objtwo = { selectable: true, // 是否可选 hasControls:false, top:(e.pageY - (e.pageY - e.offsetY))/scale.value, left:(e.pageX - (e.pageX - e.offsetX))/scale.value,//创建对象的x坐标 rx: 25, // 圆的水平半径 ry: 25, // 圆的垂直半径 fill: 'rgba(73, 120, 236,0.6)', // 填充颜色 stroke: 'rgba(255,255,255,1)', // 描边颜色 strokeWidth: 1, // 描边宽度 types:types.value, id, name:'塘口', z:3, zoomX:scale.value, zoomY:scale.value, visible:true } vnode = new fabric.Ellipse(objtwo); canvas!.add(vnode); vnodelist.value.push(objtwo); break; case 'device-one': const imgone = new Image(); imgone.src = video; let oneparams = drawdevice(e,id,'视频监控'); imgone.onload = () => { const imgerone = new fabric.Image(imgone, oneparams); canvas!.add(imgerone); vnodelist.value.push(oneparams); } break; case 'device-two': const imgtwo = new Image(); imgtwo.src = onedevice; let twoparams = drawdevice(e,id,'一体设备'); imgtwo.onload = () => { const imgertwo = new fabric.Image(imgtwo,twoparams); canvas!.add(imgertwo); vnodelist.value.push(twoparams); } break; case 'device-three': const imgthree = new Image(); imgthree.src = video; let threeparams = drawdevice(e,id,'安防视频'); imgthree.onload = () => { const imgerthree = new fabric.Image(imgthree, threeparams); canvas!.add(imgerthree); vnodelist.value.push(threeparams); } break; default: break; } reorderObjectsByZ() } //绘制设备类图层参数处理 function drawdevice(e:DragEvent,id:string,name:string){ return { selectable: true, // 是否可选 hasControls:false, top:(e.pageY - (e.pageY - e.offsetY))/scale.value, left:(e.pageX - (e.pageX - e.offsetX))/scale.value, z: 4, id, name, refnumber:'0', versionid:'', types:'Device', zoomX:scale.value, zoomY:scale.value, visible:true } } //循环画布中的元素始终保持层级z有效 function reorderObjectsByZ() {//因为后绘制的图形层级会高一些,为了跟据z属性保持层级逻辑 if (canvas) { const objects = canvas!.getObjects().sort((a:fabric.Object & p, b:fabric.Object & p) => a.z - b.z); //根据z属性排序 canvas.clear(); // 移除所有现有对象 objects.forEach(obj => { canvas.add(obj); // 重新添加对象 }); } } //选中激活对应的图形 let cur = ref<Curparams>({} as Curparams);//记录当前选中的数据 let rectparams = ref<Rectparams>({} as Rectparams) let circleparams = ref<Circleparams>({} as Circleparams) let deviceparams = ref<DeviceParams>({} as DeviceParams) //选择图层获取参数 function selectitem(item){ cur.value = item; closeall(); canvas!.getObjects().forEach((obj: fabric.Object & p) => { if(cur.value.id == obj.id){//根据唯一id判断选中的哪个元素,获取数据回填表单 let defaultparam = { id:obj.id, name:obj.name, types:obj.types } switch(obj.types){ case 'Rect': let rectfill = splitRgbaSimple(obj.fill as string); let rectstroke = splitRgbaSimple(obj.stroke as string); rectparams.value = { ...defaultparam, fill:rectfill.color, fillopacity:rectfill.opacity, width: obj.width, height: obj.height, strokeWidth: obj.strokeWidth, stroke: rectstroke.color, strokeOpacity:rectstroke.opacity, area:obj.area ? + obj.area : 0, classify:obj.classify + '', angle:obj.angle }; break; case 'Circle': let circlefill = splitRgbaSimple(obj.fill as string); let circlestroke = splitRgbaSimple(obj.stroke as string); circleparams.value = { ...defaultparam, fill:circlefill.color, fillopacity:circlefill.opacity, left: obj.left, top: obj.top, rx: obj.rx*2, ry: obj.ry*2, strokeWidth: obj.strokeWidth, stroke: circlestroke.color, strokeOpacity:circlestroke.opacity, }; break; case 'Device': deviceparams.value = { ...defaultparam, refnumber:obj.refnumber + '', versionid:obj.versionid + '', }; break; } canvas!.setActiveObject(obj); // 激活选中元素 canvas!.renderAll(); //重新渲染画布(虽然选中元素通常会自动触发重绘) } }); } //将rgba提取为rgb的格式和透明度 function splitRgbaSimple(rgbaString:string) { const alphaIndex = rgbaString.lastIndexOf(','); const opacity = parseFloat(rgbaString.slice(alphaIndex + 1, -1)) * 100; const rgbString = rgbaString.slice(5, alphaIndex); const color = `rgb(${rgbString.replace(/\s+/g,'')})`; return { color, opacity }; } //弹框实例 const pondDialog = ref(); const massifDialog = ref(); const deviceDialog = ref(); const layerDialog = ref(); //打开修改属性弹框 function editattr(){ let mapflag = { 'Rect': massifDialog, 'Circle': pondDialog, 'Device': deviceDialog, } mapflag[cur.value.types].value.disbled = true; } //回填数据点击确定更新图层 function updatecanvas(params:any){ console.log(params); canvas!.getObjects().forEach((obj: fabric.Object & p) => { if(cur.value.id == obj.id){ switch(obj.types){ case 'Rect': obj.set({ ...params, width:params.width ? +params.width : 50, height:params.height ? +params.height : 100, stroke:rgbToRgba(params.stroke,params.strokeOpacity), fill:rgbToRgba(params.fill,params.fillopacity), angle:+params.angle }); break; case 'Circle': obj.set({ ...params, rx:params.rx ? (+params.rx)/2 : 25, ry:params.ry ? (+params.ry)/2 : 25, left:+params.left, top:+params.top, stroke:rgbToRgba(params.stroke,params.strokeOpacity), fill:rgbToRgba(params.fill,params.fillopacity) }); break; case 'Device': obj.set(params); break; } updatelist({name:obj.name,id:obj.id,types:obj.types})//更新列表中的数据 canvas!.requestRenderAll(); // 重新渲染画布(虽然选中元素通常会自动触发重绘) } }); } //处理颜色格式 最终显示是rgba的格式 function rgbToRgba(rgbString, alpha) { const rgbArray = rgbString.replace(/^rgb\(([^)]+)\)$/, '$1').split(','); const r = parseInt(rgbArray[0].trim(), 10); const g = parseInt(rgbArray[1].trim(), 10); const b = parseInt(rgbArray[2].trim(), 10); alpha = (parseInt(alpha) / 100).toFixed(1); return `rgba(${r}, ${g}, ${b}, ${alpha})`; } //控制图层弹框中选的图层控制当前元素的显示与隐藏 function updatevisible(item){ canvas!.getObjects().forEach((obj: fabric.Object & p) => { if(item.id == obj.id){//查找当前选中的元素 obj.visible = item.visible; canvas!.requestRenderAll() } }); } //删除图层 function removevnode(){ canvas!.getObjects().forEach((obj: fabric.Object & p) => { if(cur!.value!.id == obj?.id){//查找当前选中的元素 canvas!.remove(obj);//移除元素 removelist();//对应的左侧栏数据也移除 } }); } //更新左侧列表数据 function updatelist(params){ let i = vnodelist.value.findIndex(item => item.id == cur.value.id); vnodelist.value[i] = params; } //画布转json const tojson = ()=>{ let jsonbefore = ref<{version:string,objects:Array<fabric.Object & p>}>({ objects:[], version:'6.1.0' }) canvas!.getObjects().forEach((obj: fabric.Object & p) => { let objadd = obj.toObject(); ['id','z','name','types','selectable','hasControls','classify','area','refnumber','versionid'].forEach(item=>{ obj[item] && (objadd[item] = obj[item]) }) objadd.hasControls = false; jsonbefore.value.objects.push(objadd) }); //调试专用 localStorage.setItem('canvas',JSON.stringify(jsonbefore)); canvas!.clear() //调用接口 //return JSON.stringify(jsonbefore) } //用json回显画布 const tocanvas = ()=>{ let json = JSON.parse(localStorage.getItem('canvas') as string); canvas!.loadFromJSON(json._value, () => { canvas!.requestRenderAll(); setTimeout(()=>{ scale.value = canvas!.getObjects()[1].zoomX as number; vnodelist.value = canvas!.getObjects().map((item:fabric.Object & p) =>{ return { id: item.id, types:item.types, name:item.name, visible:item.visible, } }) },100) }); } //移除左侧列表数据 function removelist(){ closeall() let i = vnodelist.value.findIndex(item => item.id == cur.value.id); vnodelist.value.splice(i, 1); } //关闭所有弹框 function closeall(){ [massifDialog,pondDialog,deviceDialog,layerDialog].forEach(item =>{ item.value.disbled = false; }) } //放大缩小事件 最大放大两倍 最小还原1:1 let scale = ref<number>(1); function zoomIn() { if (scale.value < 2) { scale.value += 0.1; // 可以调整步长来平滑缩放 canvas!.setZoom(scale.value) } } //缩小 function zoomOut() { if (scale.value > 1) { scale.value -= 0.1; canvas!.setZoom(scale.value); } } //打开图层弹框 function openlyer(){ closeall(); layerDialog.value.disbled = true; }
以上是全部代码,上述代码中解决了以下问题
1、拖拽是基于html5的新特性draggable结合其自带的拖拽方法拿到xy坐标,计算位于目标元素xy坐标
2、fabric画布元素的层级问题,无论元素创建的先后始终保证自定义层级有效(reorderObjectsByZ方法)
3、fabric画布转json自定义参数丢失的问题(tojson 方法)
4、fabric画布放大或缩小后xy坐标偏移的问题 (记录scale缩放比,始终计算left与top值)
5、ts中fabric画布元素类型如何兼容自定义属性(自定义P类型,与fabric.object交叉声明)
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。