v6版D3.js力导向图

1,790 阅读1分钟

效果图:

代码:

<template>
    <!--功能 1、d3力导向图-->
    <!--坑   1、d3之v6版d3.event事件已取消,改为在回调中作为参数 2、attr只能分开写,不能合并写,合并写不能渲染出属性 3、图形/text颜色用fill、line颜色用stroke-->
    <!--4、由于层级,先绘制线再节点  5、画布中的offsetX貌似是相对于svg 6、连线用path的一个原因是textpath元素href指向指定的path-->
    <!--5、父元素穿透:pointer-events:none 6、设置svg中的样式:加/deep/或者去掉scope-->
    <div class="force-page">

        <div class="force-tip"
             :style="{'top':`${tipData.point.y}px`,'left':`${tipData.point.x}px`,'display':isShowTip?'block':'none'}">
            <ul>
                <li @click="analysis()">分析1</li>
                <li @click="analysis()">分析2</li>
            </ul>
        </div>

        <!--静态svg-->
        <!--                <svg>-->
        <!--            <defs>-->
        <!--                <g id="test1">-->
        <!--                    <circle cx="20" cy="20" r="20" fill="blue"></circle>-->
        <!--                </g>-->
        <!--                <g id="test2">-->
        <!--                    <circle cx="200" cy="0" r="20" fill="green"></circle>-->
        <!--                </g>-->
        <!--            </defs>-->
        <!--            <g style="cursor: pointer">-->
        <!--                <line x1="100" y1="100" x2="400" y2="300" stroke="#000"></line>-->
        <!--                <line x1="100" y1="100" x2="400" y2="300" stroke="transparent" stroke-width="10"></line>-->
        <!--                <rect x="240" y="190" width="20" height="20" fill="#ccc" stroke="blue" rx="6" ry="6"></rect>-->
        <!--                <text x="250" y="208" font-size="20" text-anchor="middle" fill="#42a9f4">?</text>-->
        <!--                <circle cx="100" cy="100" r="30" fill="#ccc" stroke="blue" stroke-width="2"></circle>-->
        <!--                <text x="100" y="108" font-size="20" text-anchor="middle">河北</text>-->
        <!--                <circle cx="400" cy="300" r="30" fill="#ccc" stroke="blue" stroke-width="2"></circle>-->
        <!--                <text x="400" y="308" font-size="20" text-anchor="middle">北京</text>-->
        <!--            </g>-->
        <!--            <use x="0" y="0" xlink:href="#test1"></use>-->
        <!--            <use x="100" y="100" xlink:href="#test2"></use>-->

        <!--箭头-->
        <!--                    <line x1="50" y1="50" x2="100" y2="100" stroke="#000" marker-end="url(#arrow)"></line>-->
        <!--                    <defs>-->
        <!--                        <marker markerWidth="20" markerHeight="20" refX="10" refY="10" id="arrow" orient="auto">-->
        <!--                            <path d="M0 0 L10 10 L0 20 z" fill="none" stroke="blue"></path>-->
        <!--                        </marker>-->
        <!--                    </defs>-->

        <!--                </svg>-->


    </div>
</template>

<script>
    import forceFun from '../../api/forceFun'
    import * as d3 from 'd3'

    export default {
        name: "force",
        data() {
            return {
                svgSrc: require('../../assets/images/gl.svg'),
                tipData: {
                    point: {x: 100, y: 100},
                    data: null
                },
                isShowTip: false,
            }
        },
        mounted() {
            // this.drawForce()
            this.drawForce1()
        },
        methods: {
            drawForce() {
                //数据
                let nodesData = [{name: "桂林"}, {name: "广州"},
                    {name: "厦门"}, {name: "杭州"},
                    {name: "上海"}, {name: "青岛"},
                    {name: "天津"}];

                let linksData = [{source: 0, target: 1}, {source: 0, target: 2},
                    {source: 0, target: 3}, {source: 1, target: 4},
                    {source: 1, target: 5}, {source: 1, target: 6}];

                //开始布局画图
                const width = 800;
                const height = 600;


                //初始化力学仿真器
                let simulation = d3.forceSimulation(nodesData)    //使用指定的nodes创建一个新的没有任何力模型的仿真
                    .force('link', d3.forceLink(linksData))       //弹簧力,为仿真添加指定name的力模型并返回仿真
                    .force('charge', d3.forceManyBody().strength(-1000))  //电荷力/万有引力/多体力
                    .force('center', d3.forceCenter(width / 2, height / 2))  //向心力
                    .on('tick', ticked)

                //定义一个序数颜色比例尺
                var color = d3.scaleOrdinal(d3.schemeCategory10)


                //添加svg标签
                // 1、attr只能分开写,不能合并写,合并写不能渲染出属性
                let svg = d3.select('.force-page')
                    .append('svg')
                    .attr('id', 'chart')
                    .attr('width', width)
                    .attr('height', height)

                //添加group
                let gWapper = svg.append('g')
                    .attr('class', 'gWapper')
                    .attr('cursor', 'pointer')
                //绘制连线
                let links = gWapper.append('g') //root
                    .selectAll('line')  //dom
                    .data(linksData)  //model
                    .enter()
                    .append('line')
                    .attr("stroke", "yellow")
                    .attr("stroke-width", 1);

                //绘制节点
                let nodes = gWapper.append('g')
                    .selectAll('circle')
                    .data(nodesData)
                    .enter()
                    .append('circle')
                    .attr('r', 20)
                    .attr('fill', (d, i) => color(i))
                    .call(d3.drag()
                        .on('start', dragstart)
                        .on('drag', dragged)
                        .on('end', dragend)
                    )

                //绘制文字
                let texts = gWapper.append('g')
                    .selectAll('text')
                    .data(nodesData)
                    .enter()
                    .append('text')
                    .attr('text-anchor', 'middle')
                    .attr('dy', '0.3em')
                    .text(d => d.name)
                    .call(d3.drag()
                        .on('start', dragstart)
                        .on('drag', dragged)
                        .on('end', dragend)
                    )


                function dragstart(event, d) {
                    if (!event.active) {
                        simulation.alphaTarget(.2).restart()
                    }
                    d.fx = d.x
                    d.fy = d.y
                }

                function dragged(event, d) {
                    d.fx = event.x
                    d.fy = event.y
                }

                function dragend(event, d) {
                    if (!event.active) {
                        simulation.alphaTarget(0)
                    }
                    d.fx = null
                    d.fy = null
                }


                //虽然仿真系统会更新节点的位置(只是设置了nodes对象的x y属性),但是它不会转为svg内部元素的坐标表示,这需要我们自己来操作
                function ticked() {
                    links.attr('x1', d => d.source.x)
                        .attr('y1', d => d.source.y)
                        .attr('x2', d => d.target.x)
                        .attr('y2', d => d.target.y)

                    nodes.attr('cx', d => d.x)
                        .attr('cy', d => d.y)

                    texts.attr('x', d => d.x)
                        .attr('y', d => d.y)
                    // .attr('dominant-baseline',  'middle')

                }

            },
            drawForce1() {
                let width = 800, height = 600;
                const opacityRange = [0.7, 1.0];
                const nodeSizeRange = [10, 50];
                const linkParallelGap = 6;
                let opacityScale, nodeSizeScale;
                let data = {
                    "nodes": [
                        {
                            "ntId": 1,
                            "label": "小明",
                            "number": 20
                        },
                        {
                            "ntId": 6,
                            "label": "小红",
                            "number": 17
                        },
                        {
                            "ntId": 7,
                            "label": "小刚",
                            "number": 140
                        },
                        {
                            "ntId": 8,
                            "label": "小乐",
                            "number": 43
                        }
                    ],
                    "links": [
                        {
                            "sourceId": 7,
                            "targetId": 6,
                            "number": 2352
                        },
                        {
                            "sourceId": 7,
                            "targetId": 8,
                            "number": 1866
                        },
                        {
                            "sourceId": 6,
                            "targetId": 7,
                            "number": 1863
                        },
                        {
                            "sourceId": 7,
                            "targetId": 1,
                            "number": 788
                        },
                        {
                            "sourceId": 1,
                            "targetId": 7,
                            "number": 787
                        },
                        {
                            "sourceId": 8,
                            "targetId": 6,
                            "number": 676
                        },
                        {
                            "sourceId": 6,
                            "targetId": 8,
                            "number": 390
                        }
                    ]
                };
                let {nodes, links} = data;
                let linksData = []
                let nodesData = nodes
                linksData = links.map(d => {
                    return {
                        source: d.sourceId,
                        target: d.targetId,
                        attackCount: d.number,
                    };
                })
                generateScale()
                /**
                 * 创建力导向图
                 *
                 */
                let simulation = d3.forceSimulation(nodesData)
                    //仿真中运行linksData后,linksData改变为与nodes绑定的数据格式
                    .force('link', d3.forceLink(linksData).id(d => d.ntId))
                    .force('charge', d3.forceManyBody().strength(-8000))
                    .force('center', d3.forceCenter(width / 2, height / 2))
                    .on('tick', ticked)


                //创建svg
                let svg = d3.select('.force-page')
                    .append('svg')
                    .attr('width', width)
                    .attr('height', height)
                    .style('background', '#ccc')
                    .on('click', () => {
                        this.isShowTip = false;
                    })

                let gWrapper = svg.append('g')

                //连线
                let linkEles = gWrapper.append('g')
                    .classed('links', true)
                    .selectAll('path')
                    .data(linksData)
                    .enter()
                    //单个线
                    // .append('line')
                    //双向线
                    .append('path')
                    .attr('id', (d, i) => `linkPath${i}`)
                    .classed('link', true)
                    .attr('stroke', 'yellow')
                    .attr('stroke-width', 2)
                    .attr('marker-end', 'url(#arrow)')
                    .attr('opacity', d => opacityScale(+d.number))
                    .style('cursor', 'pointer')
                    .on('mouseenter', (event, d) => {
                        linkLabelEles.attr('opacity', item => {
                            return item === d ? 1 : 0;
                        })
                        linkEles.attr('opacity', item => {
                            return item === d ? 1 : 0.3;
                        })
                    })
                    .on('mouseleave', () => {
                        linkLabelEles.attr('opacity', 0)
                        linkEles.attr('opacity', 1)
                    })


                //节点
                let nodeEles = gWrapper.append('g')
                    .attr('class', 'nodes')
                    .style('cursor', 'pointer')
                    .selectAll('.node')
                    .data(nodesData)
                    .enter()
                    .append('g')
                    .classed('node', true)
                    .attr('opacity', d => {
                        // 修复特定条件下,透明度为负值bug
                        let tempOpacity = opacityScale(+d.number);
                        return tempOpacity ? tempOpacity : 1;
                    })
                    .call(d3.drag()
                        .on('start', dragstart)
                        .on('drag', dragged)
                        .on('end', dragend)
                    )
                    .on('click', (event, d) => {
                        event.stopPropagation();
                        this.isShowTip = true;
                        this.tipData = {
                            point: {x: event.offsetX, y: event.offsetY},
                            data: d
                        }
                    })
                    .on('mouseenter', (event, d) => {
                        linkEles.attr('opacity', item => {
                            return (item.target.ntId === d.ntId || item.source.ntId === d.ntId) ? 1 : 0
                        })
                        nodeLabelElms.text(item => d.ntId === item.ntId ? item.label : ellipse(item.label, 8))
                    })
                    .on('mouseleave', (event, d) => {
                        linkEles.attr('opacity', 1)
                        nodeLabelElms.text(item => ellipse(item.label, 8))
                    })

                //图片
                nodeEles.append('image')
                    //.attr('xlink:href', this.svgSrc)
                    .attr('href', this.svgSrc)
                    .attr('class', 'node-icon')
                    .attr('width', d => nodeSizeScale(+d.number))
                    .attr('height', d => nodeSizeScale(+d.number))
                    .attr('x', d => -nodeSizeScale(+d.number) / 2)
                    .attr('y', d => -nodeSizeScale(+d.number) / 2)

                let nodeLabelContainer = nodeEles.append('g')
                    .classed('node-label', true)
                    .attr('transform', d => {
                        const y = nodeSizeScale(+d.number) * 0.35;
                        return `translate(0,${y})`
                    })


                //文字
                let nodeLabelElms = nodeLabelContainer.append('text')
                    .attr('dy', '1em')
                    .attr('fill', '#fff')
                    .text(d => ellipse(d.label, 8))

                //重点:文字背景矩形
                let textPadding = 6;
                nodeLabelContainer.each(function (d) {
                    //d:当前元素绑定的绑定的数据,this:函数内部 this 指向当前 DOM 元素(node[i])
                    let textBox = this.getBBox()
                    //或者
                    // let textBox = d3.select(this)
                    // .select('text')
                    // .node()
                    // .getBBox()
                    d3.select(this)
                        .insert('rect', 'text')
                        .attr('x', textBox.x - textPadding)
                        .attr('y', textBox.y - textPadding)
                        .attr('width', textBox.width + textPadding * 2)
                        .attr('height', textBox.height + textPadding * 2)
                        .attr('fill', '#ea3d66')
                })


                //重点:箭头
                let arrow = gWrapper.append('defs')
                    .append('marker')
                    .attr('id', 'arrow')
                    .attr('markerWidth', 20)
                    .attr('markerHeight', 20)
                    .attr('refX', 4)
                    .attr('refY', 4)
                    .attr('orient', 'auto')
                    .append('path')
                    .attr('d', 'M0 0 L4 4 L0 8Z')
                    .attr('fill', 'none')
                    .attr('stroke', 'blue')


                function ticked() {
                    linkEles.classed('animate', false)
                    // 当只有单向线时
                    // linkEles.attr('x1', d => d.source.x)
                    //     .attr('y1', d => d.source.y)
                    //     .attr('x2', d => d.target.x)
                    //     .attr('y2', d => d.target.y)

                    nodeEles.attr('transform', d => {
                        return `translate(${d.x},${d.y})`
                    })

                    //双向线
                    linkEles.attr('d', d => {
                        if (d.target && d.source) {
                            const dx = d.target.x - d.source.x;
                            const dy = d.target.y - d.source.y;
                            const dr = 0;
                            const slopeVec = {x: dx, y: dy};
                            let transformedSource = addVector(d.source, slopeVec, 15);
                            let transformedTarget = addVector(
                                d.target,
                                {x: dx, y: dy},
                                -1 * 15,
                            );

                            transformedSource = parallelTransform(
                                transformedSource,
                                slopeVec,
                                linkParallelGap,
                            );
                            transformedTarget = parallelTransform(
                                transformedTarget,
                                slopeVec,
                                linkParallelGap,
                            );


                            //弧线
                            //A圆弧:M 起始点x 起始点y A 椭圆x 椭圆y 椭圆旋转角度 大弧(1)还是小弧(0) 顺时针(1)还是逆时针(0) 终点x 终点y
                            // return (
                            //     'M' +
                            //     transformedSource.x +
                            //     ',' +
                            //     transformedSource.y +
                            //     'A' +
                            //     dr +
                            //     ',' +
                            //     dr +
                            //     ' 0 0,1 ' +
                            //     transformedTarget.x +
                            //     ',' +
                            //     transformedTarget.y
                            // );

                            //直线
                            return (
                                'M' +
                                transformedSource.x +
                                ',' +
                                transformedSource.y +
                                'L ' +
                                transformedTarget.x +
                                ',' +
                                transformedTarget.y
                            );
                        }
                    })

                    //攻击文字旋转
                    linkLabelEles.attr('transform', function (d) {
                        if (d.source && d.target) {
                            if (d.source.x > d.target.x) {
                                const bBox = this.getBBox();
                                let rx = bBox.x + bBox.width / 2
                                let ry = bBox.y + bBox.height / 2
                                return `rotate(180,${rx},${ry})`
                            } else {
                                return `rotate(0)`
                            }
                        }

                    })
                    if (simulation.alpha() < 0.02) {
                        linkEles.classed('animate', true)
                        simulation.stop();
                    }


                }

                //创建攻击文字
                let linkLabelEles = gWrapper.append('g')
                    .classed('link-labels', true)
                    .selectAll('text')
                    .data(linksData)
                    .enter()
                    .append('text')
                    .attr('dy', -10)
                    .attr('text-anchor', 'middle')
                    .attr('opacity', 0)
                    .style('pointer-events', 'none')   //让父元素可以穿透
                // 文字路径
                linkLabelEles.append('textPath')
                    .attr('href', (d, i) => `#linkPath${i}`)
                    .text(d => `攻击数:${d.attackCount}`)
                    .attr('startOffset', '50%')
                    .style('pointer-events', 'none')

                function dragstart(event, d) {
                    if (!event.active) {
                        simulation.alphaTarget(0.3).restart()
                    }
                    d.fx = d.x
                    d.fy = d.y
                }

                function dragged(event, d) {
                    d.fx = event.x
                    d.fy = event.y
                }

                function dragend(event, d) {
                    if (!event.active) {
                        simulation.alphaTarget(0)
                    }
                    d.fx = null
                    d.fy = null

                }

                function ellipse(str, maxLength) {
                    let s = String(str);
                    return s.length > maxLength ? `${s.substring(0, maxLength)}...` : s;
                }

                /**
                 * 双向的添加1,2标识
                 * @param link
                 * @param i
                 */
                function checkBiLink(link, i) {
                    let sameIdx;
                    try {
                        sameIdx = linksData.findIndex(item => {
                            return (
                                item.source.ntId === link.target.ntId &&
                                item.target.ntId === link.source.ntId &&
                                !item.bidir
                            );
                        });
                    } catch {
                        sameIdx = -1;
                    }
                    if (sameIdx > i) {
                        link.bidir = 1;
                        linksData[sameIdx].bidir = 2;
                    }
                }

                linksData.forEach(checkBiLink);

                /**
                 * 生成scale
                 */
                function generateScale() {
                    opacityScale = d3.scaleLinear()
                        .domain(d3.extent(linksData, d => +d.attackCount))
                        .range(opacityRange)
                    nodeSizeScale = d3.scaleLinear()
                        .domain(d3.extent(nodesData, d => +d.number))
                        .range(nodeSizeRange)
                }

                //k线弧度
                function calcAngleDegrees(x, y) {
                    return Math.atan2(y, x);
                }

                //缩短矢量连接线,过程详解见笔记中svg常见绘制效果
                function addVector(point, slopeVec, unit) {
                    const AngleDegrees = calcAngleDegrees(slopeVec.x, slopeVec.y);
                    let x1 = point.x + Math.cos(AngleDegrees) * unit;
                    let y1 = point.y + Math.sin(AngleDegrees) * unit;
                    return {x: x1, y: y1};
                }

                //绘制平行线
                function parallelTransform(point, slopeVec, unit) {
                    const AngleDegrees = calcAngleDegrees(slopeVec.x, slopeVec.y) + Math.PI/2;
                    let x1 = point.x + Math.cos(AngleDegrees) * unit;
                    let y1 = point.y + Math.sin(AngleDegrees) * unit;
                    return {x: x1, y: y1};
                }


            },
            analysis() {
                console.log(this.tipData.data);
            },

        }
    }
</script>

<style lang="less" scoped>
    /*stroke-dashoffset的from和to相加要为一个虚线的单位宽度加空隙,才不会有明显的过度效果*/
    @keyframes linkMove {
        from {
            stroke-dashoffset: 0;
            stroke-dasharray: 10;
        }
        to {
            stroke-dashoffset: 20;
            stroke-dasharray: 10;
        }
    }

    .force-page {
        width: 100%;
        height: 100%;
        position: relative;

        .force-tip {
            position: absolute;
        }

        /deep/ svg {
            .link {
                &.animate {
                    animation: linkMove 1s infinite linear;
                }
            }
        }
    }


</style>