前言
vue3 框架中使用vue2代码结合d3完成股权穿透图和股权结构图(h5)
(没错听上去很违规,但我懒得把代码从vue2改成vue3了,所以是在vue3框架里用vue2写法完成的)
最终效果:
版本信息:
"d3": "4.13.0",
"vant": "^3.1.5",
"vue": "^3.0.0",
股权穿透图基础功能:
1、默认上下游信息展示,如果没有上下游信息只展示自己
2、点击请求子节点信息展示,收起子节点
3、全屏功能
4、放大器放大缩小(react项目中不知道为啥使用d3.zoom方法不好使,可能跟网页中滚动事件冲突有关,最后选择单独防止放大器进行放大缩小功能)
5、移动功能
股权结构图基础功能:
1、tab切换展示上游或下游信息
2、默认展示一层
3、点击请求子节点信息展示,收起子节点
代码链接:
https://github.com/QiuDaShua/vue2-d3.git
股权穿透图代码:
<template>
<div class="father-box">
<div
id="rightPenetrationpage"
:style="{ 'transition': 'transform .5s ease', '-ms-transition': 'transform .5s ease', '-moz-transition': 'transform .5s ease','-webkit-transition': 'transform .5s ease','-o-transition': 'transform .5s ease'}">
<custom-nav-bar
:title="title"
left-arrow
@on-clickleft="onClickLeft">
</custom-nav-bar>
<!-- <div
class="full"
@click.stop="showFullScreen">
<div class="full-icon"></div>
<span>{{isFull ? '退出全屏' :'全屏'}}</span>
</div> -->
<div
id="penetrateChart"
:style="{width:'100%',display:'block',margin:'auto'}"
>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent} from 'vue'
import { useStore } from 'vuex'
import CustomNavBar from '@/components/common/CustomNavbar.vue'
import { fetchCompanySearchDetail, fetchEquityUpperInfo, fetchEquityBelowInfo } from '@/api/companySearch'
import { Notify, Toast } from 'vant'
import { formatMoney, getBLen } from '@/utils/tool'
import { sm2Decrypted } from '@/enrich/crypto-gm'
import { GlobalMutation } from '@/store/types/mutation-types'
import * as $d3 from 'd3'
// 过渡时间
const DURATION = 0
// 加减符号半径
const SYMBOLA_S_R = 9
// 公司
const COMPANY = '0'
// 人
const PERSON = '1'
//x,y距离
// let x0 = 0, y0 = 0, dx = 0, dy = 0
export default defineComponent({
props: {},
components: {
CustomNavBar
},
data () {
return {
layoutTree: {} as any,
diamonds: {} as any,
d3: $d3,
// hasChildNodeArr: [],
originDiamonds: {} as any,
diagonalUp: '',
diagonalDown: '',
tree: {
'name': '中信期货',
'id': '1',
'children': [{
'children': [],
'money': 3000,
'scale': 30,
'name': '中信期货黔西南分公司',
'id': '1-1',
'type': '0'
}, {
'children': [],
'money': 3000,
'scale': 30,
'name': '中信期货六盘水分公司',
'id': '1-2',
'type': '0'
}, {
'children': [],
'money': 3000,
'scale': 30,
'name': '中信期货贵阳分公司',
'id': '1-3',
'type': '0'
}, {
'children': [],
'money': 3000,
'scale': 30,
'name': '中信期货安顺分公司',
'id': '1-4',
'type': '0'
}, {
'children': [],
'money': 3000,
'scale': 30,
'name': '中信期货毕节分公司',
'id': '1-5',
'type': '0'
}, {
'children': [],
'money': 3000,
'scale': 30,
'name': '中信期货遵义分公司',
'id': '1-6',
'type': '0'
}, {
'children': [],
'money': 3000,
'scale': 30,
'name': '中信期货黔东南分公司',
'id': '1-7',
'type': '0'
}, {
'children': [
{'controlPerson': false, 'children': [], 'old': false, 'name': '中信期货黔南分公司下属公司1', 'id': '1-8-1', 'money': 200, 'scale': 20, 'type': '0'},
{'controlPerson': false, 'children': [], 'old': false, 'name': '中信期货黔南分公司下属公司2', 'id': '1-8-2', 'money': 200, 'scale': 20, 'type': '0'},
],
'money': 3000,
'scale': 30,
'name': '中信期货铜仁分公司',
'id': '1-8',
'type': '0'
}, {
'children': [
{'controlPerson': false, 'children': [], 'old': false, 'name': '中信期货黔南分公司下属公司1', 'id': '1-9-1', 'money': 200, 'scale': 20, 'type': '0'},
{'controlPerson': false, 'children': [], 'old': false, 'name': '中信期货黔南分公司下属公司2', 'id': '1-9-2', 'money': 200, 'scale': 20, 'type': '0'},
],
'name': '中信期货黔南分公司',
'id': '1-9',
'money': 3000,
'scale': 30,
'type': '0'
}
],
'parents': [
{
'controlPerson': true,
'money': '3000',
'children': [
{'controlPerson': true, 'money': '3000', 'children': [], 'parentMoney': 3000, 'old': true, 'id': '1-01-1', 'name': '发展公司父级公司1', 'scale': 30, 'type': '0', 'oldUrlName': ''},
{'controlPerson': true, 'money': '3000', 'children': [], 'parentMoney': 3000, 'old': true, 'id': '2-01-1', 'name': '发展公司父级公司2', 'scale': 70, 'type': '0', 'oldUrlName': ''},
],
'name': '发展公司',
'id': '01-1',
'scale': 90,
'type': '0',
'oldUrlName': ''
}
]
},
rootUp: {} as any,
rootDown: {} as any,
svg: {} as any,
svgW: document.documentElement.clientWidth,
svgH: document.documentElement.clientHeight - 44,
title: '股权穿透图',
isFull: false,
name: '',
id: '',
token: '',
regCapi: '',
userid: '',
parents: [] as any[], // 下游信息
children: [] as any[], // 上游信息
}
},
// beforeCreate () {
// document.body.style.overflow = 'hidden'
// },
// beforeDestroy () {
// document.body.style.overflow = 'auto'
// },
// created () {
// // window.addEventListener('orientationchange', this.changeOrient)
// },
mounted () {
const store = useStore()
const data = this.$route.query.data ? JSON.parse(sm2Decrypted(this.$route.query.data)) : {}
const id = data.id
const token = data.token ? data.token : store.state.global.token
const userid = data.userid
this.id = id
this.token = token
this.userid = userid
store.commit(`global/${GlobalMutation.SET_TOKEN}`, token)
Toast.loading({
message: '加载中',
forbidClick: true,
duration: 0,
});
this.getInit()
},
beforeUnmount() {
this.d3.select('#treesvg').remove()
console.log('页面关闭')
},
methods: {
// changeOrient () {
// const box = document.getElementById('penetrateChart').children[0]
// const g = document.getElementById('penetrateChart').children[0].children[0]
// let navbar = document.querySelector('.navbar')
// let flag = false
// flag = isOrient()
// setTimeout(()=>{
// if(flag){
// navbar?.classList.add('smallBar')
// }else{
// navbar?.classList.remove('smallBar')
// }
// console.log(document.documentElement.clientWidth, document.documentElement.clientHeight)
// box.setAttribute('width', document.documentElement.clientWidth)
// box.setAttribute('height', document.documentElement.clientHeight)
// g.setAttribute('transform', 'translate(' + (document.documentElement.clientWidth / 2) + ',' + (document.documentElement.clientHeight / 2) + ')')
// }, 100)
// },
async getDetailInfo(){
await fetchCompanySearchDetail({
token: this.token,
instId: this.id,
userId: this.userid
}).then((response)=>{
const {code =0, records = [] } = response
if (code > 0 && records != null) {
this.regCapi = records[0].reg_capi
this.name = records[0].chn_full_nm
}
})
},
async getUpper(){
await fetchEquityUpperInfo({
token: this.token,
instId: this.id,
regCapi: this.regCapi,
currentPage: 0,
pageSize: 200,
}).then((response) => {
const {code =0, records = [] } = response
if (code > 0 && records != null) {
const dataSource = [] as any[];
records.forEach(element =>{
// let children = []
// // 设置children节点
// if(element.list){
// element.list.forEach(child =>{
// children.push({
// money: child.amount ? formatMoney((child.amount / 10000).toFixed(2)) :'--',
// scale: child.hold_rati || '--%',
// name: child.chn_full_nm || '--',
// id: child.inst_cust_id || '--',
// type: '0'
// })
// })
// }
dataSource.push({
// children: children,
isHaveChildren: element.dataType === '1' ? true : false,
money: element.amount ? formatMoney((element.amount / 10000).toFixed(2)) : '--',
scale: element.hold_rati || '--%',
name: element.chn_full_nm || '--',
id: element.inst_cust_id || '--',
type: '0',
regCapi: element.reg_capi
})
})
this.parents = dataSource
}
})
},
async getBelow(){
await fetchEquityBelowInfo({
token: this.token,
instId: this.id,
currentPage: 0,
pageSize: 200,
}).then((response) => {
const {code =0, records = []} = response
if (code > 0 && records != null) {
const dataSource = [] as any[];
records.forEach(element =>{
// let children = []
// // 设置children节点
// if(element.list){
// element.list.forEach(child =>{
// children.push({
// money: child.amount ? formatMoney((child.amount / 10000).toFixed(2)) :'--',
// scale: child.hold_rati || '--%',
// name: child.chn_full_nm || '--',
// id: child.inst_cust_id || '--',
// type: '0'
// })
// })
// }
dataSource.push({
// children: children,
isHaveChildren: element.dataType === '1' ? true : false,
money: element.amount ? formatMoney((element.amount / 10000).toFixed(2)) :'--',
scale: element.hold_rati || '--%',
name: element.chn_full_nm || '--',
id: element.inst_cust_id || '--',
type: '0',
})
})
this.children = dataSource
}
})
},
// 获取树状数据
getTreeData(){
console.log( this.children, this.parents, '111111111')
let obj = {
id: this.id,
name: this.name,
tap: '节点',
children: this.children,
parents: this.parents,
}
this.tree = {...obj}
Toast.clear()
},
async getInit(){
await this.getDetailInfo()
// await this.getUpper()
// await this.getBelow()
Promise.all([this.getUpper(), this.getBelow()]).finally(()=>{
this.getTreeData()
this.init()
})
},
init () {
let d3 = this.d3
let svgW = this.svgW
let svgH = this.svgH
// x0 = svgW / 2,
// y0= svgH / 2
// 方块形状
this.diamonds = {
w: 162,
h: 66,
intervalW: 182,
intervalH: 150
}
// 源头对象
this.originDiamonds = {
w: 208,
h: 41
}
this.layoutTree = d3.tree().nodeSize([this.diamonds.intervalW, this.diamonds.intervalH]).separation(() => 1);
// 主图
this.svg = d3.select('#penetrateChart').append('svg').attr('width', svgW).attr('height', svgH).attr('id', 'treesvg')
.call(d3.zoom().scaleExtent([0.3, 2]).on('zoom', () => {
// 设置缩放位置以及平移初始位
// if(isiOS && this.isFull){
// // 修改ios手机上才有的移动bug,安卓手机,pc端没有
// let x = d3.event.transform.x
// d3.event.transform.x = d3.event.transform.y
// d3.event.transform.y = -x
// console.log('222', '出现移动bug', d3.event.transform)
// console.log(isiOS, d3.event.transform.x, d3.event.transform.y)
// // dx = d3.event.transform.x - x0
// // dy = d3.event.transform.y - y0
// // x0 = d3.event.transform.x
// // y0 = d3.event.transform.y
// // d3.event.transform.x = d3.event.transform.x + dy
// // d3.event.transform.y = d3.event.transform.y + dx
// // this.svg.attr('transform', 'translate(' + (svgW / 2) + ',' + (svgH / 2) + ') rotate(90)')
// }
this.svg.attr('transform', d3.event.transform.translate(svgW / 2, svgH / 2));
}))
.on('dblclick.zoom', null)
.attr('style', 'position: relative;z-index: 2') //background-image:url(${setWatermark({name: this.$store.state.global.user.userName, loginName: this.$store.state.global.user.userId}).toDataURL()})
.append('g').attr('id', 'g').attr('transform', `translate(${svgW / 2},${svgH / 2})`)
let upTree = {} as any
let downTree = {} as any
// 拷贝树的数据
Object.keys(this.tree).map(item => {
if (item === 'parents') {
upTree = JSON.parse(JSON.stringify(this.tree))
upTree.children = this.tree[item]
upTree.parents = null
} else if (item === 'children') {
downTree = JSON.parse(JSON.stringify(this.tree))
downTree.children = this.tree[item]
downTree.parents = null
}
})
// hierarchy 返回新的结构 x0,y0初始化起点坐标
this.rootUp = d3.hierarchy(upTree, d => d.children);
this.rootUp.x0 = 0
this.rootUp.y0 = 0
this.rootDown = d3.hierarchy(downTree, d => d.children);
this.rootDown.x0 = 0
this.rootDown.y0 = 0;
// 上 和 下 结构
let treeArr = [
{
data: this.rootUp,
type: 'up'
},
{
data: this.rootDown,
type: 'down'
}
]
if(!this.tree['children'].length && !this.tree['parents'].length){
this.updataSelf()
}else{
treeArr.map(item => {
if (item.data.children) {
// item.data.children.forEach(this.collapse);
this.update(item.data, item.type, item.data)
}
})
}
},
updataSelf(){
let nodes = this.rootUp.descendants()
let node = this.svg.selectAll('g.node')
.data(nodes, d => d.data.id || '');
let nodeEnter = node.enter().append('g')
.attr('class', d => 'node node_' + d.depth) //d => showtype === 'up' && !d.depth ? 'hide-node' :
// .attr('transform', 'translate(' + (svgW / 2) + ',' + (svgH / 2) + ')')
.attr('opacity', 1); // 拥有下部分则隐藏初始块 d => showtype === 'up' && !d.depth ? (this.rootDown.data.children. length ? 0 : 1) : 1
// 创建矩形
nodeEnter.append('rect')
.attr('type', d => d.data.id + '_' + d.depth)
.attr('width', d => d.depth ? this.diamonds.w : (getBLen(d.data.name)/2 * 20 + 20))
.attr('height', d => d.depth ? (d.data.type === COMPANY ? this.diamonds.h : this.diamonds.h - 10) : this.originDiamonds.h)
.attr('x', d => d.depth ? -this.diamonds.w / 2 : -(getBLen(d.data.name)/2 * 20 + 20) / 2)
.attr('y', d => d.depth ? 0 : -15)
.attr('stroke', '#DE4A3C')
.attr('stroke-width', 1)
.attr('rx', 10)
.attr('ry', 10)
.style('fill', d => {
if (d.data.type === COMPANY || !d.depth) {
return d.depth ? '#fff' : '#DE4A3C'
} else if (d.data.type === PERSON) {
return '#fff'
}
});
// 文字
nodeEnter.append('text')
.attr('x', 0)
.attr('y', 0)
.attr('dy', `${this.originDiamonds.h/2 - 10}px`)
.attr('text-anchor', 'middle')
.attr('fill', d => d.depth ? '#DE4A3C' : '#fff')
.text(d => d.data.name)
.style('font-size', d => d.depth ? '16px' : '20px')
.style('font-family', 'PingFangSC-Medium')
.style('font-weight', '500')
},
/*
*[update 函数描述], [click 函数描述]
* @param {[Object]} source 第一次是初始源对象,后面是点击的对象
* @param {[String]} showtype up表示向上 down表示向下
* @param {[Object]} sourceTree 初始源对象
*/
update (source, showtype, sourceTree) {
// eslint-disable-next-line
let _this = this
if (source.parents === null) {
source.isOpen = !source.isOpen
}
let nodes
if (showtype === 'up') {
nodes = this.layoutTree(this.rootUp).descendants()
} else {
nodes = this.layoutTree(this.rootDown).descendants()
}
let links = nodes.slice(1);
nodes.forEach(d => {
d.y = d.depth *(d.depth == 1 ? 120 : this.diamonds.intervalH);
});
let node = this.svg.selectAll('g.node' + showtype)
.data(nodes, d => d.data.id || '');
let nodeEnter = node.enter().append('g')
.attr('class', d => showtype === 'up' && !d.depth ? 'hide-node' : 'node' + showtype)
.attr('transform', d => showtype === 'up' ? 'translate(' + d.x + ',' + -(d.y) + ')' : 'translate(' + d.x + ',' + d.y + ')')
.attr('opacity', d => showtype === 'up' && !d.depth ? (this.rootDown.data.children.length ? 0 : 1) : 1); // 拥有下部分则隐藏初始块
// 创建矩形
nodeEnter.append('rect')
.attr('type', d => d.data.id)
.attr('width', d => d.depth ? this.diamonds.w : (getBLen(d.data.name)/2 * 20 + 20))
.attr('height', d => d.depth ? (d.data.type === COMPANY ? this.diamonds.h : this.diamonds.h - 10) : this.originDiamonds.h)
.attr('x', d => d.depth ? -this.diamonds.w / 2 : -(getBLen(d.data.name)/2 * 20 + 20) / 2)
.attr('y', d => d.depth ? showtype === 'up' ? -this.diamonds.h / 2 : 0 : -15)
.attr('stroke', d => d.data.type === COMPANY || !d.depth ? '#DE4A3C' : '#7A9EFF')
.attr('stroke-width', 1)
.attr('rx', 10)
.attr('ry', 10)
.style('fill', d => {
if (d.data.type === COMPANY || !d.depth) {
return d.depth ? '#fff' : '#DE4A3C'
} else if (d.data.type === PERSON) {
return '#fff'
}
});
// 创建圆 加减
let circle = nodeEnter.append('g')
.attr('class', 'circle')
.on('click', function (d) {
_this.click(d, showtype, sourceTree)
});
circle.append('circle')
.attr('type', d => d.data.id || '')
.attr('r', (d) => d.depth ? (d.data.isHaveChildren ? SYMBOLA_S_R : 0) : 0)
.attr('cy', d => d.depth ? showtype === 'up' ? -(SYMBOLA_S_R + this.diamonds.h / 2) : (this.diamonds.h + SYMBOLA_S_R) : 0)
.attr('cx', 0)
.attr('fill', '#F9DDD9')
.attr('stroke', '#FCEDEB')
.style('stroke-width', 1)
circle.append('text')
.attr('x', 0)
.attr('dy', d => d.depth ? (showtype === 'up' ? -(SYMBOLA_S_R / 2 + this.diamonds.h / 2) : this.diamonds.h + SYMBOLA_S_R + 4) : 0)
.attr('text-anchor', 'middle')
.attr('class', 'fa')
.style('fill', '#DE4A3C')
.text(function(d) {
if(d.depth){
if (d.children) {
return '-';
} else if (d._children || d.data.isHaveChildren) {
return '+';
} else {
return '';
}
}else {
return '';
}
})
.style('font-size', '16px');
node.select('.fa')
.text(function (d) {
if (d.children) {
return '-';
} else if (d._children || d.data.isHaveChildren) {
return '+';
} else {
return '';
}
})
// 持股比例
nodeEnter.append('g')
.attr('transform', () => 'translate(0,0)')
.append('text')
.attr('x', 35)
.attr('y', showtype === 'up' ? this.diamonds.h -10 : -10)
.attr('text-anchor', 'middle')
.attr('fill', d => d.data.type === COMPANY ? '#DE4A3C' : '#7A9EFF')
.attr('opacity', d => !d.depth ? 0 : 1)
.text(d => d.data.scale)
.style('font-size', '14px')
.style('font-family', 'PingFangSC-Regular')
.style('font-weight', '400');
// 公司名称
// y轴 否表源头的字体距离
nodeEnter.append('text')
.attr('x', 0)
.attr('y', d => {
// 如果是上半部分
if (showtype === 'up') {
// 如果是1层以上
if (d.depth) {
return -this.diamonds.h / 2
} else {
return 0
}
} else {
if (d.depth) {
return 0
} else {
// if (d.data.name.length > 10) {
// return -5
// }
return 0
}
}
})
.attr('dy', d => d.depth ? (d.data.name.length > 10 ? '1.3em' : '1.8em') : `${this.originDiamonds.h/2 - 10}px`)
.attr('text-anchor', 'middle')
.attr('fill', d => d.depth ? '#DE4A3C' : '#fff')
.text(d =>d.depth ? (d.data.name.length > 10) ? d.data.name.substr(0, 10) : d.data.name : d.data.name)
.style('font-size', d => d.depth ? '14px' : '18px')
.style('font-family', 'PingFangSC-Medium')
.style('font-weight', '500')
.on('click', (d) => {
if(d.data.id && d.depth){
// 跳转操作之类的
}
});
// 名称过长 第二段
nodeEnter.append('text')
.attr('x', 0)
.attr('y', d => {
// ? (d.depth ? -this.diamonds.h / 2 : 0) : 0
if (showtype === 'up') {
if (d.depth) {
return -this.diamonds.h / 2
}
return 8
} else {
if (!d.depth) {
return 8
}
return 0
}
})
.attr('dy', d => d.depth ? '2.5em' : '.3em')
.attr('text-anchor', 'middle')
.attr('fill', d => d.depth ? '#DE4A3C' : '#fff')
.text(d => {
// 索引从第19个开始截取有表示超出
if(d.depth){
if (d.data.name.substr(19, 1)) {
return d.data.name.substr(10, 9) + '...'
}
return d.data.name.substr(10, 9)
}else{
return null
}
})
.style('font-size', '14px')
.style('font-family', 'PingFangSC-Medium')
.style('font-weight', '500');
// 认缴金额
nodeEnter.append('text')
.attr('x', 0)
.attr('y', showtype === 'up' ? -this.diamonds.h / 2 : 0)
.attr('dy', d => d.data.name.substr(10, d.data.name.length).length ? '4.5em' : '4.1em')
.attr('text-anchor', 'middle')
.attr('fill', d => d.depth ? '#445166' : '#fff')
.text(d => d.data.money ? d.data.money.length > 12 ? `认缴金额:${d.data.money.substr(0, 12)}…` : `认缴金额:${d.data.money}万元` : '')
.style('font-size', '12px')
.style('font-family', 'PingFangSC-Regular')
.style('font-weight', '400')
.style('color', '#666666');
/*
* 绘制箭头
* @param {string} markerUnits [设置为strokeWidth箭头会随着线的粗细发生变化]
* @param {string} viewBox 坐标系的区域
* @param {number} markerWidth,markerHeight 标识的大小
* @param {string} orient 绘制方向,可设定为:auto(自动确认方向)和 角度值
* @param {number} stroke-width 箭头宽度
* @param {string} d 箭头的路径
* @param {string} fill 箭头颜色
* @param {string} id resolved0表示公司 resolved1表示个人
* 直接用一个marker达不到两种颜色都展示的效果
*/
nodeEnter.append('marker')
.attr('id', showtype + 'resolved0')
.attr('markerUnits', 'strokeWidth')
.attr('markerUnits', 'userSpaceOnUse')
.attr('viewBox', '0 -5 10 10')
.attr('markerWidth', 12)
.attr('markerHeight', 12)
.attr('orient', '90')
.attr('refX', () => showtype === 'up' ? '-50' : '10')
.attr('stroke-width', 2)
.attr('fill', '#DE4A3C')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', '#DE4A3C');
nodeEnter.append('marker')
.attr('id', showtype + 'resolved1')
.attr('markerUnits', 'strokeWidth')
.attr('markerUnits', 'userSpaceOnUse')
.attr('viewBox', '0 -5 10 10')
.attr('markerWidth', 12)
.attr('markerHeight', 12)
.attr('orient', '90')
.attr('refX', () => showtype === 'up' ? '-50' : '10')
.attr('stroke-width', 2)
.attr('fill', '#DE4A3C')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', '#7A9EFF');
// 将节点转换到它们的新位置。
let nodeUpdate = node
// .transition()
// .duration(DURATION)
.attr('transform', d => showtype === 'up' ? 'translate(' + d.x + ',' + -(d.y) + ')' : 'translate(' + d.x + ',' + (d.y) + ')');
// 将退出节点转换到父节点的新位置.
let nodeExit = node.exit()
// .transition()
// .duration(DURATION)
.attr('transform', () => showtype === 'up' ? 'translate(' + source.x + ',' + -(source.y) + ')' : 'translate(' + source.x + ',' + (parseInt(source.y)) + ')')
.remove();
nodeExit.select('rect')
.attr('width', this.diamonds.w)
.attr('height', this.diamonds.h)
.attr('stroke', 'black')
.attr('stroke-width', 1);
// 修改线条
let link = this.svg.selectAll('path.link' + showtype)
.data(links, d => d.data.id);
// 在父级前的位置画线。
let linkEnter = link.enter().insert('path', 'g')
.attr('class', 'link' + showtype)
.attr('marker-start', d => `url(#${showtype}resolved${d.data.type})`)// 根据箭头标记的id号标记箭头
.attr('stroke', d => d.data.type === COMPANY ? '#DE4A3C' : '#7A9EFF')
.style('fill-opacity', 1)
.attr('fill', 'none')
.attr('stroke-width', '1px')
.attr('d', () => {
let o = {x: source.x0, y: source.y0};
return _this.diagonal(o, o, showtype)
});
let linkUpdate = linkEnter.merge(link);
// 过渡更新位置.
linkUpdate
// .transition()
// .duration(DURATION)
.attr('d', d => _this.diagonal(d, d.parent, showtype));
// 将退出节点转换到父节点的新位置
link.exit()
// .transition()
// .duration(DURATION)
.attr('d', () => {
let o = {
x: source.x,
y: source.y
};
return _this.diagonal(o, o, showtype)
}).remove();
// 隐藏旧位置方面过渡.
nodes.forEach(d => {
d.x0 = d.x;
d.y0 = d.y
});
},
// 拷贝到_children 隐藏1排以后的树
// collapse (source) {
// if (source.children) {
// source._children = source.children;
// source._children.forEach(this.collapse);
// source.children = null;
// this.hasChildNodeArr.push(source);
// }
// },
// 获取点击上游的上游
async fetchUpper (id, regCapi){
Toast.loading({
message: '加载中',
forbidClick: true,
duration: 0,
});
const dataSource = [];
try{
const response = await fetchEquityUpperInfo({
token: this.token,
instId: id,
currentPage: 0,
pageSize: 200,
regCapi: regCapi,
})
const {code =0, records = []} = response
if (code > 0 && records != null) {
const dataSource = [] as any[];
records.forEach(element =>{
dataSource.push({
isHaveChildren: null,
money: element.amount ? formatMoney((element.amount / 10000).toFixed(2)) :'--',
scale: element.hold_rati || '--%',
name: element.chn_full_nm || '--',
id: element.inst_cust_id || '--',
type: '0'
})
})
Toast.clear()
return dataSource
}else{
Toast.clear()
return dataSource
}
}catch(error){
Toast.clear()
return dataSource
}
},
// 获取点击下游的下游
async fetchBelow (id){
Toast.loading({
message: '加载中',
forbidClick: true,
duration: 0,
});
const dataSource = [];
try{
const response = await fetchEquityBelowInfo({
token: this.token,
instId: id,
currentPage: 0,
pageSize: 200,
})
const {code =0, records = []} = response
if (code > 0 && records != null) {
const dataSource = [] as any[];
records.forEach(element =>{
dataSource.push({
isHaveChildren: null,
money: element.amount ? formatMoney((element.amount / 10000).toFixed(2)) :'--',
scale: element.hold_rati || '--%',
name: element.chn_full_nm || '--',
id: element.inst_cust_id || '--',
type: '0'
})
})
Toast.clear()
return dataSource
}else{
Toast.clear()
return dataSource
}
}catch(error){
Toast.clear()
return dataSource
}
},
async click (source, showType, sourceTree) {
// 不是起点才能点
// if (source.depth) {
// if (source.children) {
// source._children = source.children;
// source.children = null;
// } else {
// source.children = source._children;
// source._children = null;
// }
// }
if(source.children){
// 点击减号
source._children = source.children;
source.children = null;
}else {
// 点击加号
if(!source._children){
let res = [] as any[]
if(showType === 'up'){
res = await this.fetchUpper(source.data.id, source.data.regCapi)
}else {
res = await this.fetchBelow(source.data.id)
}
if(!res.length){
Notify({
message: '上游或下游企业信息为空!',
type: 'warning',
duration: 1500
})
return
}
res.forEach(item =>{
let newNode = this.d3.hierarchy(item)
newNode.depth = source.depth + 1;
newNode.height = source.height - 1;
newNode.parent = source;
if(!source.children){
source.children = [];
source.data.children = [];
}
source.children.push(newNode);
source.data.children.push(newNode.data);
})
}else{
source.children = source._children;
source._children = null;
}
}
this.update(source, showType, sourceTree)
},
diagonal (s, d, showtype) {
// 折线
let endMoveNum = 0;
let moveDistance = 0;
if (d) {
if (showtype == 'down') {
let downMoveNum = d.depth ? this.diamonds.h/2 : this.originDiamonds.h/2 -10 ;
// var downMoveNum = 30;
let tmpNum = s.y + (d.y - s.y) / 2;
endMoveNum = downMoveNum;
moveDistance = tmpNum + endMoveNum;
} else {
let upMoveNum = d.depth ? 0 : -this.originDiamonds.h/2 + 5 ;
let tmpNum = d.y + (s.y - d.y) / 2;
endMoveNum = upMoveNum;
moveDistance = tmpNum + endMoveNum;
}
}
if (showtype === 'up') {
return (
'M' +
s.x +
',' +
-s.y +
'L' +
s.x +
',' +
-moveDistance +
'L' +
d.x +
',' +
-moveDistance +
'L' +
d.x +
',' +
-d.y
);
}else {
return (
'M' +
s.x +
',' +
s.y +
'L' +
s.x +
',' +
moveDistance +
'L' +
d.x +
',' +
moveDistance +
'L' +
d.x +
',' +
d.y
);
}
},
resetSvg () {
this.d3.select('#treesvg').remove()
this.init()
},
// 点击全屏
showFullScreen(){
let width = document.documentElement.clientWidth,
height = document.documentElement.clientHeight,
wrapper = document.getElementById('rightPenetrationpage') as HTMLElement,
style = '';
const navbar = document.querySelector('.navbar') as HTMLElement
const fullScreen = document.querySelector('.full') as HTMLElement
// const box = document.getElementById('penetrateChart').children[0]
// const g = document.getElementById('penetrateChart').children[0].children[0]
// setTimeout(()=>{
// // box.setAttribute('width', width)
// // box.setAttribute('height', height - 44)
// g.setAttribute('transform', 'translate(' + (width / 2) + ',' + (height / 2) + ')')
// }, 200)
if (this.isFull) { // 竖屏
console.log('竖过来')
this.isFull = false
this.svgH = height - 44
this.svgW = width
// 设置按钮和顶部样式
fullScreen.classList.remove('fullRight')
navbar.classList.remove('smallBar')
style += 'width:100%';
style += 'height:100%;';
style += '-webkit-transform: translateX(0) translateZ(0px) rotate(0); -ms-transform: translateX(0) translateZ(0px) rotate(0); -moz-transform:translateX(0) translateZ(0px) rotate(0); -o-transform: translateX(0) translateZ(0px) rotateY(0); transform: translateX(0) translateZ(0px) rotate(0);';
style += '-webkit-transform-origin: 0 0;';
style += '-ms-transform-origin: 0 0;';
style += '-moz-transform-origin: 0 0;';
style += '-o-transform-origin: 0 0;';
style += 'transform-origin: 0 0;';
} else { // 横屏
console.log('横过来')
this.isFull = true
this.svgH = width - 44
this.svgW = height
// 设置按钮和顶部样式
fullScreen.classList.add('fullRight')
navbar.classList.add('smallBar')
style += 'width:' + height + 'px;';// 注意旋转后的宽高切换
style += 'height:' + width + 'px;';
style += '-webkit-transform: translateX(0) translateZ(0px) rotate(90deg); -ms-transform: translateX(0) translateZ(0px) rotate(90deg); -moz-transform: translateX(0) translateZ(0px) rotate(90deg); -o-transform: translateX(0) translateZ(0px) rotate(90deg); transform:translateX(0) translateZ(0px) rotate(90deg);';
// 注意旋转中点的处理
style += 'transform-origin: ' + width / 2 + 'px ' + width / 2 + 'px;';
style += '-webkit-transform-origin: ' + width / 2 + 'px ' + width / 2 + 'px;';
style += '-ms-transform-origin: ' + width / 2 + 'px ' + width / 2 + 'px;';
style += '-moz-transform-origin: ' + width / 2 + 'px ' + width / 2 + 'px;';
style += '-o-transform-origin: ' + width / 2 + 'px ' + width / 2 + 'px;';
}
wrapper.style.cssText = style;
// 重新渲染图片
this.resetSvg()
},
// 点击返回
onClickLeft(){
// jsBridge.callHandler('navigationToSkip', {type: '0'}, ()=> {
// console.log('11111111111')
// })
history.back()
}
}
})
</script>
<style lang="scss" scoped>
.father-box{
transform: perspective(1000px);
-ms-transform: perspective(1000px);
-moz-transform: perspective(1000px);
-webkit-transform: perspective(1000px);
-o-transform: perspective(1000px);
}
.info-icon {
width: 16px;
height: 16px;
background-image: url('../../assets/icon/icon_info.png');
background-repeat: no-repeat;
background-size: cover;
}
.full {
position: absolute;
top: 12px;
right:20px;
font-size: 14px;
color: #DE4A3C;
display: flex;
z-index: 9999;
line-height: 24px;
.full-icon {
width: 24px;
height: 24px;
background-image: url('../../assets/icon/icon_fullscreen.png');
background-repeat: no-repeat;
background-size: cover;
margin-right: 7px;
}
}
.fullRight{
top: 12px !important;
right:35px;
}
.smallBar {
:deep(.van-nav-bar__left){
display: none;
}
:deep(.van-nav-bar__content){
background-color: transparent;
}
:deep(.van-nav-bar__title){
// font-size: 4.8vh;
margin-left:30px;
}
}
</style>
股权结构图代码:
<template>
<div
id="structureChartIn"
:style="{width:'100%',display:'block',margin:'auto'}"
>
</div>
</template>
<script lang="ts">
// import { setWatermark } from '@/utils/tool.js'
import { defineComponent} from 'vue'
import { formatMoney, getBLen } from '@/utils/tool'
import { fetchEquityUpperInfo } from '@/api/companySearch'
import { Notify, Toast } from 'vant'
import * as $d3 from 'd3'
// 过渡时间
const DURATION = 400
// 加减符号半径
const SYMBOLA_S_R = 9
// // 公司
// const COMPANY = '0'
// // 人
// const PERSON = '1'
export default defineComponent({
props: {
tree: {
type: Object,
default: () => {
return {
'name': '马云',
'tap': '节点',
'id': '1',
'children': [
{
'name': '中国平安人寿保险股份有限公司自有资金马云的公司厉害得很',
'scale': '2.27',
'id': '1-1',
'money': '3000',
'children': [
{
'name': '中国证券金融股份有限公司',
'scale': '2.27',
'id': '1-1-1',
'money': '3000',
'children': [
{
'name': '中国证券金融股份有限公司',
'scale': '2.27',
'id': '1-1-1-1',
'money': '3000',
}
]
},
{
'name': '中央汇金资产管理有限责任公司',
'scale': '2.27',
'id': '1-1-2',
'money': '3000',
}
]
}
]
}
}
},
token: {
type: String,
default: ''
}
},
components: {
},
data () {
return {
diamonds: {} as any,
originDiamonds: {} as any,
d3: $d3,
// hasChildNodeArr: [],
root: {} as any,
svg: {} as any,
svgW: document.documentElement.clientWidth,
svgH: document.documentElement.clientHeight - 88,
title: '股权结构图',
lastClickD: null,
}
},
watch: {
tree(newVal){
if(newVal.name){
this.init()
}
}
},
mounted () {
// window.addEventListener('orientationchange', this.changeOrient)
},
// beforeUnmount(){
// window.removeEventListener('orientationchange', this.changeOrient)
// },
methods: {
// changeOrient () {
// const box = document.getElementById('structureChartIn').children[0]
// const g = document.getElementById('structureChartIn').children[0].children[0]
// let navbar = document.querySelector('.navbar')
// let flag = false
// flag = isOrient()
// setTimeout(()=>{
// if(flag){
// navbar?.classList.add('smallBar')
// }else{
// navbar?.classList.remove('smallBar')
// }
// console.log(document.documentElement.clientWidth, document.documentElement.clientHeight)
// box.setAttribute('width', document.documentElement.clientWidth)
// box.setAttribute('height', document.documentElement.clientHeight)
// g.setAttribute('transform', 'translate(' + (document.documentElement.clientWidth / 2) + ',' + (document.documentElement.clientHeight / 2) + ')')
// }, 100)
// },
init () {
let d3 = this.d3
let svgW = this.svgW
let svgH = this.svgH
let margin = {top: 20, right: 20, bottom: 30, left: 10}
// 方块形状
this.diamonds = {
w: 320,
h: 60,
}
// 源头对象
this.originDiamonds = {
w: 208,
h: 36
}
// 主图
this.svg = d3.select('#structureChartIn').append('svg').attr('width', svgW).attr('height', svgH).attr('id', 'treesvgIn')
.call(d3.zoom().scaleExtent([0.3, 2]).on('zoom', () => {
const transform = d3.event.transform
this.svg.attr('transform', transform.translate(margin.left, margin.top));
}))
.on('dblclick.zoom', null)
.attr('style', 'position: relative;z-index: 2') // background-image:url(${setWatermark({name: this.$store.state.global.user.userName, loginName: this.$store.state.global.user.userId}).toDataURL()})
.append('g').attr('id', 'gIn')
.attr('transform', `translate(${margin.left},${margin.top})`)
// 拷贝树的数据
let downTree = {} as any
Object.keys(this.tree).map(item => {
if (item === 'children') {
downTree = JSON.parse(JSON.stringify(this.tree))
downTree.children = this.tree[item]
}
})
// hierarchy 返回新的结构 x0,y0初始化起点坐标
this.root = d3.hierarchy(downTree);
this.root.x0 = 0
this.root.y0 = 0
if(!this.root.children){
this.update(this.root)
}else {
// this.root.children.forEach(this.collapse)
this.update(this.root)
}
},
/*
*[update 函数描述], [click 函数描述]
* @param {[Object]} source 第一次是初始源对象,后面是点击的对象
*/
update (source) {
// eslint-disable-next-line
let _this = this
let nodes= this.root.descendants()
let index = -1, count = 0;
this.root.eachBefore(function(n) {
count+=20;
n.style = 'node_' + n.depth;
n.x = ++index * _this.diamonds.h + count;
n.y = n.depth * 25; // 设置下一层水平位置向后移25px
});
let node = this.svg.selectAll('g.node')
.data(nodes, d => {
return d.data.id || ''
} );
let nodeEnter = node.enter().append('g')
.attr('class', d => 'node node_' + d.depth)
.attr('transform', 'translate(' + source.y0 + ',' + source.x0 + ')')
.attr('opacity', 0);
// 创建矩形
nodeEnter.append('rect')
.attr('type', d => d.data.id)
.attr('width', d => d.depth ? this.diamonds.w : (d.data.children.length ? (getBLen(d.data.name)/2 * 18 + 62) : (getBLen(d.data.name)/2 * 18 + 20)))
.attr('height', d => d.depth ? this.diamonds.h : this.originDiamonds.h)
.attr('y', -this.diamonds.h / 2)
.style('stroke', '#DE4A3C')
.attr('stroke-width', 1)
.attr('rx', 6)
.attr('ry', 6)
.style('fill', d => {
return d.data.tap ? '#DE4A3C' : '#fff'
});
nodeEnter.append('rect')
.attr('y', -this.diamonds.h / 2)
.attr('height', d => d.depth ? this.diamonds.h : this.originDiamonds.h)
.attr('width', 6)
.attr('rx', 6)
.attr('ry', 6)
.style('fill', '#DE4A3C')
// 文字
nodeEnter.append('text')
.attr('dy', d=> d.depth ? -7 : -5)
.attr('dx', d=> d.depth ? 36 : (d.data.children.length ? 36 : 10))
.style('font-size', d=> d.depth ? '14px' : '18px')
.style('font-weight', '500')
.attr('fill', d => d.depth ? '#333333' : '#fff')
.text(function(d) {
// 名字长度超过进行截取
if(d.depth){
if(d.data.name.length>20){
return d.data.name.substring(0, 19) + '...';
}
}
return d.data.name;
})
.on('click', (d) => {
if(d.data.id && d.depth){
// 跳转操作之类的
}
});
// 持股比例
nodeEnter.append('text')
.attr('dy', 17)
.attr('dx', 36)
.style('font-size', '12px')
.style('fill', '#666666')
.text(function(d) {
if(!d.data.tap){
return ('持股比例 ' +':')
}
});
nodeEnter.append('text')
.attr('dy', 17)
.attr('dx', 95)
.style('font-size', '12px')
.style('fill', '#DE4A3C')
.text(function(d) {
if(!d.data.tap){
return (d.data.scale)
}
});
// 认缴金额
nodeEnter.append('text')
.attr('dy', 17)
.attr('dx', 150)
.style('font-size', '12px')
.style('fill', '#666666')
.text(function(d) {
if(!d.data.tap){
return ('认缴金额 ' +':')
}
});
nodeEnter.append('text')
.attr('dy', 17)
.attr('dx', 210)
.style('font-size', '12px')
.style('fill', '#DE4A3C')
.text(function(d) {
if(!d.data.tap){
if(d.data.money.length > 14){
return d.data.money.substr(0, 14) + '...'
}else{
return (d.data.money + '万元')
}
}
});
// 箭头
// nodeEnter.append('text')
// .attr('dy', 5.5)
// .attr('dx', 200 )
// .style('font-size', '20px')
// .style('fill', '#000')
// .text(function(d) {
// if(!d.data.tap){
// return '>'
// }
// });
// 创造圆 加减
let circle = nodeEnter.append('g')
.attr('class', 'circle')
.on('click', _this.click);
circle.append('circle')
.style('fill', '#F9DDD9')
.style('stroke', '#FCEDEB')
.style('stroke-width', 1)
.attr('r', function (d) {
if (d.children || d.data.isHaveChildren) {
return 9;
} else {
return 0;
}
})
.attr('cy', d => d.depth ? 0 : (- SYMBOLA_S_R -3))
.attr('cx', 20)
circle.append('text')
.attr('dy', d => d.depth ? 4.5 : -7)
.attr('dx', 20)
.attr('text-anchor', 'middle')
.attr('class', 'fa')
.style('fill', '#DE4A3C')
.text(function(d) {
if (d.children) {
return '-';
} else if (d._children || d.data.isHaveChildren) {
return '+';
} else {
return '';
}
})
.style('font-size', '16px');
node.select('.fa')
.text(function (d) {
if (d.children) {
return '-';
} else if (d._children || d.data.isHaveChildren) {
return '+';
} else {
return '';
}
})
/*
* 绘制箭头
* @param {string} markerUnits [设置为strokeWidth箭头会随着线的粗细发生变化]
* @param {string} viewBox 坐标系的区域
* @param {number} markerWidth,markerHeight 标识的大小
* @param {string} orient 绘制方向,可设定为:auto(自动确认方向)和 角度值
* @param {number} stroke-width 箭头宽度
* @param {string} d 箭头的路径
* @param {string} fill 箭头颜色
*/
// nodeEnter.append('marker')
// .attr('id', 'resolvedIn')
// .attr('markerUnits', 'strokeWidth')
// .attr('markerUnits', 'userSpaceOnUse')
// .attr('viewBox', '0 -5 10 10')
// .attr('markerWidth', 8)
// .attr('markerHeight', 8)
// .attr('orient', '0')
// .attr('refX', '10')
// // .attr('refY', '10')
// .attr('stroke-width', 2)
// .attr('fill', '#DE4A3C')
// .append('path')
// .attr('d', 'M0,-5L10,0L0,5')
// .attr('fill', '#DE4A3C');
// 将节点转换到它们的新位置。
nodeEnter
// .transition()
// .duration(DURATION)
.attr('transform', function(d) { return 'translate(' + d.y + ',' + d.x + ')'; })
.style('opacity', 1);
node
// .transition()
// .duration(DURATION)
.attr('transform', function(d) { return 'translate(' + d.y + ',' + d.x + ')'; })
.style('opacity', 1)
.select('rect');
// 将退出节点转换到父节点的新位置.
let nodeExit = node.exit()
// .transition()
// .duration(DURATION)
.attr('transform', () => 'translate(' + source.y + ',' + (parseInt(source.x)) + ')')
.style('opacity', 0)
.remove();
// nodeExit.select('rect')
// .attr('width', this.diamonds.w)
// .attr('height', this.diamonds.h)
// .attr('stroke', 'black')
// .attr('stroke-width', 1);
// 修改线条
let link = this.svg.selectAll('path.link')
.data(this.root.links(), d => d.target.id);
// 在父级前的位置画线。
let linkEnter = link.enter().insert('path', 'g')
.attr('class', d =>{
return 'link link_' + d.target.depth
} )
// .attr('marker-end', 'url(#resolvedIn)')// 根据箭头标记的id号标记箭头
.attr('stroke', '#DE4A3C')
.style('fill-opacity', 1)
.attr('fill', 'none')
.attr('stroke-width', '1px')
.attr('d', () => {
let o = {x: source.x0, y: source.y0};
return _this.diagonal({source: o, target: o})
})
// .transition()
// .duration(DURATION)
.attr('d', _this.diagonal);
// 过渡更新位置.
link
// .transition()
// .duration(DURATION)
.attr('d', _this.diagonal);
// 将退出节点转换到父节点的新位置
link.exit()
// .transition()
// .duration(DURATION)
.attr('d', () => {
let o = {
x: source.x,
y: source.y
};
return _this.diagonal({source: o, target: o})
}).remove();
// 隐藏旧位置方面过渡.
this.root.each(d => {
d.x0 = d.x;
d.y0 = d.y
});
},
// 获取点击上游的上游
async fetchUpper (id, regCapi){
Toast.loading({
message: '加载中',
forbidClick: true,
duration: 0,
});
const dataSource = [];
try{
const response = await fetchEquityUpperInfo({
token: this.token,
instId: id,
currentPage: 0,
pageSize: 200,
regCapi: regCapi,
})
const {code =0, records = []} = response
if (code > 0 && records != null) {
console.log(records)
const dataSource = [] as any[];
records.forEach(element =>{
dataSource.push({
isHaveChildren: null,
money: element.amount ? formatMoney((element.amount / 10000).toFixed(2)) :'--',
scale: element.hold_rati || '--%',
name: element.chn_full_nm || '--',
id: element.inst_cust_id || '--',
type: '0'
})
})
Toast.clear()
return dataSource
}else{
Toast.clear()
return dataSource
}
}catch(error){
Toast.clear()
return dataSource
}
},
async click (source) {
// if (d.children) {
// d._children = d.children;
// d.children = null;
// } else {
// d.children = d._children;
// d._children = null;
// }
// if (this.lastClickD){
// this.lastClickD._isSelected = false;
// }
// d._isSelected = true;
// this.lastClickD = d;
if(source.children){
// 点击减号
source._children = source.children;
source.children = null;
}else {
// 点击加号
if(!source._children){
let res = [] as any[]
res = await this.fetchUpper(source.data.id, source.data.regCapi)
if(!res.length){
Notify({
message: '上游或下游企业信息为空!',
type: 'warning',
duration: 1500
})
return
}
res.forEach(item =>{
let newNode = this.d3.hierarchy(item)
newNode.depth = source.depth + 1;
newNode.height = source.height - 1;
newNode.parent = source;
if(!source.children){
source.children = [];
source.data.children = [];
}
source.children.push(newNode);
source.data.children.push(newNode.data);
})
}else{
source.children = source._children;
source._children = null;
}
}
this.update(source);
},
// 拷贝到_children 隐藏1排以后的树
// collapse (source) {
// if (source.children) {
// source._children = source.children;
// source._children.forEach(this.collapse);
// source.children = null;
// this.hasChildNodeArr.push(source);
// }
// },
diagonal (d) {
return `M ${d.source.y} ${d.source.x}
H ${(d.source.y + (d.target.y-d.source.y)/2)}
V ${d.target.x}
H ${d.target.y}`;
},
}
})
</script>
总结:
前端小白一枚,在之前只使用过echarts进行可视化,在开发这个功能时候发现d3版本中文网站内容较少,基本出现问题讨论也是在外文网站,踩过一堆版本的坑,最终选择稳定且例子比较多的v4版本。还有基本都是默认信息展示,很少有点击请求的功能,进行一个最终功能的整合,如果有问题欢迎大家积极提出讨论,共同进步~